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("