From 6262b7e5667cd3da30ed2cd01ee7c19e9c3695c8 Mon Sep 17 00:00:00 2001 From: andrewboudreau Date: Mon, 31 Aug 2026 17:47:59 -0500 Subject: [PATCH] romdata_check: stop charging a vtable's preamble to the symbol before it `symbols.txt` points `_ZTV` at the slot array, so the vtable object's offset-to-top and typeinfo words sit at `addr - 8` under no symbol at all. `rom_data_index()` sized every data symbol as distance-to-the-next-symbol and therefore handed those eight bytes to whatever came before, which then scored PARTIAL by exactly eight bytes with nothing it could ever emit to close the gap. `check_symbol` already knows about the preamble -- it applies `OI.VTABLE_PREAMBLE` on the emitted side and corrects reloc addends by the same 8 -- so this was the one place that didn't. 522 of this ROM's 540 `_ZTV` symbols are unowned by any source, so the defect is not rare: measured on this tree, `verified` goes 465 -> 527 and `partial` 253 -> 191, with **zero** records moving the other way and `differ` unchanged at 6. Formulated as a boundary set rather than a subtraction, because config already names some preamble words: a blanket "subtract 8 from whatever precedes a vtable" drove 23 extents to <= 0, one of them to -4. Where a real symbol sits nearer than V-8 it simply wins and gets the shorter extent. Gated on the cartridge rather than assumed. 9 of the 414 vtable addresses config names have something other than a preamble below them -- `_ZTV8dActor_c` reads the tail of a string, `_ZTV8dCcPos_c` two code pointers -- and inserting a boundary there would strip eight owned bytes off a neighbour and manufacture a false VERIFIED, which is this same defect with the sign flipped. So the boundary goes in only where offset-to-top is zero and the typeinfo word is null or an address `symbols.txt` gives to some `_ZTI`. Where the evidence is absent the old sizing stands, which can only leave an extent too long (a PARTIAL that could have been VERIFIED) and never too short. The boundary arithmetic is split into a pure `_module_extents(entries, has_preamble)` so CI can test it with a stub predicate and no cartridge. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ --- tools/romdata_check.py | 68 ++++++++++++++++++++--- tools/test_romdata_check.py | 104 ++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 6 deletions(-) diff --git a/tools/romdata_check.py b/tools/romdata_check.py index 6ed50d4e69..96e1ccbafd 100644 --- a/tools/romdata_check.py +++ b/tools/romdata_check.py @@ -50,6 +50,7 @@ python tools/romdata_check.py --json build/romdata.json """ import argparse +import bisect import collections import concurrent.futures import io @@ -57,6 +58,7 @@ import os import pathlib import re +import struct import subprocess import sys import tempfile @@ -103,14 +105,65 @@ def name_index(): return _names +def _vtable_preamble_at(label, addr, typeinfo): + """True when the two words BELOW a `_ZTV` address really are mwcc's preamble. + + `symbols.txt` points `_ZTV` at the slot array, so the object's offset-to-top and + typeinfo words sit at `addr - VTABLE_PREAMBLE` under no symbol at all. They are not + the previous symbol's bytes, but distance-to-the-next-symbol sizing hands them to it + anyway, and the compare then demands eight bytes the previous object never owned. + + Not every `_ZTV` in this image has a preamble -- 9 of the 414 vtable addresses + config names do not -- so this is + gated on the cartridge rather than assumed: offset-to-top must be zero and the + typeinfo word must be null or an address `symbols.txt` gives to some `_ZTI`, in this + module or in arm9. Where the evidence is absent the boundary is not inserted and the + old sizing stands, which can only leave an extent too long (a PARTIAL that could + have been VERIFIED) and never too short (a VERIFIED that should not be). + """ + m = RV.mod_for(label) + if m is None or addr - OI.VTABLE_PREAMBLE < m["base"]: + return False + head = RV.rom_bytes(label, addr - OI.VTABLE_PREAMBLE, OI.VTABLE_PREAMBLE) + if head is None or len(head) < OI.VTABLE_PREAMBLE: + return False + top, info = struct.unpack(" addr), None) - if nxt is not None: - index[(label, name)] = (addr, nxt - addr) + typeinfo = {a for a, n in entries if n.startswith("_ZTI")} | arm9_typeinfo + extents = _module_extents( + entries, lambda a: _vtable_preamble_at(label, a, typeinfo)) + for addr, name in entries: + if name in extents: + index[(label, name)] = (addr, extents[name]) _index = index return _index diff --git a/tools/test_romdata_check.py b/tools/test_romdata_check.py index 18bde87154..eb19a061f8 100644 --- a/tools/test_romdata_check.py +++ b/tools/test_romdata_check.py @@ -70,6 +70,110 @@ def test_empty_input_is_all_zero(self): self.assertEqual((s["symbols"], s["verified"], s["differs"], s["totalRecords"]), (0, 0, 0, 0)) +import unittest.mock # noqa: E402 (used by the preamble-gate tests below) + + +class ExtentStopsAtTheVtablePreamble(unittest.TestCase): + """`symbols.txt` points `_ZTV` at the slot array, so the two words below it are + the vtable object's own offset-to-top and typeinfo and belong to no symbol at all. + Distance-to-the-next-symbol sizing charged them to whatever came before, which then + scored PARTIAL by exactly eight bytes with nothing it could ever emit to close the + gap. 522 of this ROM's 540 vtables are unowned by any source, so this was not rare. + """ + ALWAYS = staticmethod(lambda addr: True) + NEVER = staticmethod(lambda addr: False) + + def test_previous_symbol_is_not_charged_for_the_preamble(self): + e = RDC._module_extents([(0x100, "data_x"), (0x120, "_ZTV3Foo")], self.ALWAYS) + self.assertEqual(e["data_x"], 0x18) + + def test_without_the_fix_that_symbol_is_eight_bytes_short_forever(self): + e = RDC._module_extents([(0x100, "data_x"), (0x120, "_ZTV3Foo")], self.NEVER) + self.assertEqual(e["data_x"], 0x20) + + def test_a_vtable_does_not_pay_for_the_next_vtables_preamble(self): + e = RDC._module_extents([(0x100, "_ZTV3Foo"), (0x190, "_ZTV3Bar")], self.ALWAYS) + self.assertEqual(e["_ZTV3Foo"], 0x88) + + def test_a_named_preamble_word_keeps_only_the_bytes_it_owns(self): + """Where config already names one of the two words, that symbol is a boundary + too and simply gets the shorter extent. Seven symbols in this ROM sit exactly + four bytes below a vtable and eight sit exactly at V-8; the blanket + 'subtract 8 from whatever precedes a vtable' formulation sized 23 of them <= 0, + one of them -4, which is why this is a boundary set and not a subtraction.""" + e = RDC._module_extents( + [(0x100, "data_x"), (0x11c, "data_typeinfo_word"), (0x120, "_ZTV3Foo")], + self.ALWAYS) + self.assertEqual(e["data_x"], 0x18) + self.assertEqual(e["data_typeinfo_word"], 4) + self.assertTrue(all(v > 0 for v in e.values())) + + def test_a_symbol_exactly_at_the_preamble_is_unchanged(self): + e = RDC._module_extents( + [(0x118, "data_preamble"), (0x120, "_ZTV3Foo")], self.ALWAYS) + self.assertEqual(e["data_preamble"], 8) + + def test_the_last_symbol_still_has_no_extent(self): + e = RDC._module_extents([(0x100, "data_x"), (0x120, "_ZTV3Foo")], self.ALWAYS) + self.assertNotIn("_ZTV3Foo", e) + + +class PreambleIsGatedOnTheCartridge(unittest.TestCase): + """9 of this ROM's 414 vtable addresses show something other than a preamble below + them -- strings, code, a non-zero first word. The boundary is inserted only where + the cartridge shows one, so an absent preamble leaves an extent too LONG (a PARTIAL + that might have been VERIFIED) and never too short (a VERIFIED that should not be). + """ + def _rv(self, words): + import struct as _s + + class FakeRV: + @staticmethod + def mod_for(label): + return {"base": 0x100} + + @staticmethod + def rom_bytes(label, addr, size): + return _s.pack("