Skip to content

romdata_check: stop charging a vtable's preamble to the symbol before it (465 -> 527 verified, 0 regressions) - #2113

Merged
andrewboudreau merged 1 commit into
mainfrom
fix/romdata-preamble-extent
Sep 1, 2026
Merged

romdata_check: stop charging a vtable's preamble to the symbol before it (465 -> 527 verified, 0 regressions)#2113
andrewboudreau merged 1 commit into
mainfrom
fix/romdata-preamble-extent

Conversation

@andrewboudreau

Copy link
Copy Markdown
Collaborator

rom_data_index() sizes every data symbol as distance-to-the-next-symbol. When the next symbol is a _ZTV, that hands the previous symbol eight bytes it does not own — and there are 522 unowned vtables in this ROM for it to do that with.

The defect

symbols.txt uses the convention that _ZTV<C> is the slot array, already past mwcc's two-word object header. So the offset-to-top and typeinfo words live at addr - 8 under no symbol at all. Distance-to-the-next-symbol sizing charges them to whatever came before, and that symbol then scores PARTIAL by exactly eight bytes with nothing it could ever emit to close the gap.

check_symbol already knows about this — it applies preamble = OI.VTABLE_PREAMBLE if name.startswith("_ZTV") else 0 on the emitted side, and corrects reloc addends by the same 8. rom_data_index() was the one place that didn't. The file's own docstring describes the convention under "THE VTABLE PREAMBLE, AND WHY THE COMPARE IS OFFSET"; this makes the index agree with it.

Measured, on this tree

Full rombuild.py -j16 --no-rom --data-json, stock tool then patched, same worktree, same commit:

before:  465 verified, 253 partial, 6 differ, 522 unnamed   (from 7,560 records)
after:   527 verified, 191 partial, 6 differ, 522 unnamed

Per-record, deduped on (module, symbol, addr) across all 1,246 named records:

PARTIAL -> VERIFIED:   62
VERIFIED -> anything:   0     <- the safety claim
romExtent changed:     82

differ does not move. 106/106 exact, 100.000000% and 11,088/11,088 reproducing on both runs — this touches no bytes, only how the index describes them.

validate_merge's romData ratchet is one-directional (fails only when verified falls), so this direction is safe by construction, and the 62 flips ratchet the floor up.

Why a boundary set and not a subtraction

The obvious formulation — "if the next symbol is a _ZTV, subtract 8" — is wrong, and I only found that out by simulating it. Config already names some preamble words: data_ov012_02112404 sits four bytes below _ZTV13BasementWater, data_0209a73c sits eight below _ZTVSt9type_info. A blanket subtraction drove 23 extents to ≤ 0, one of them to −4.

So instead V−8 joins the sorted boundary list alongside the real symbol addresses, and each symbol's extent runs to the next boundary of either kind. Where a named symbol sits nearer than V−8 it simply wins and gets the shorter extent. Zero non-positive extents result, asserted in the tests.

Why it is gated on the cartridge

Not every _ZTV here has a preamble below it. Sweeping all 414 vtable addresses config names, 9 do not:

_ZTV8dActor_c   V-8 = 0x79616c50 0x6f6f5220    "Play" " Roo"  -- the tail of a string
_ZTV8dCcPos_c   V-8 = 0x0204357c 0x0204349c    two code pointers

Inserting a boundary at those would strip eight owned bytes off the neighbour and turn a legitimate PARTIAL into a false VERIFIED — this same defect with the sign flipped, and the more dangerous sign, since it is the one the ratchet cannot catch.

So the boundary goes in only where the cartridge shows a preamble: offset-to-top zero, and the typeinfo word either null (a class with no RTTI still gets the two words) or an address symbols.txt gives to some _ZTI, in this module or in arm9. Where the evidence is absent, today's behaviour stands — an extent too long, which can only cost a VERIFIED that was available, never invent one that wasn't.

Tests

Twelve new cases, all cartridge-free. The boundary arithmetic is split out of rom_data_index() into a pure _module_extents(entries, has_preamble) so it can be driven by a stub predicate, and _vtable_preamble_at is exercised against a fake RV for each gate arm — including the "Play"/" Roo" bytes as a literal regression case. tools/test_romdata_check.py is already enumerated in .github/workflows/tool-tests.yml, so these run in CI without a workflow change.

python -m pytest tools/test_romdata_check.py -q  ->  20 passed

What this does not fix

The other sign of the same family: _ZTV14dScMgD3DBase_c at 0x0213c62c has extent 80 against a real 144, because data_ov006_0213c67c sits inside the table. It scores VERIFIED anyway, because the verdict test is len(linked) >= extent — sixteen of its slots are never compared. That fix lowers verified and therefore trips the ratchet, so it needs its own PR and its own baseline conversation. Deliberately not bundled here.

Context: raised on #2111 and #2112. _ZTV12dScMgSlot3_c's extent goes 152 → 144 here; it stays PARTIAL on main because main's source doesn't yet emit all 36 slots, and flips to VERIFIED on top of the slot stack, which gives this change a free second positive control once #2112 lands.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ

`symbols.txt` points `_ZTV<C>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ
@tangos-validator

tangos-validator Bot commented Aug 31, 2026

Copy link
Copy Markdown

✅ PR validation — Passed

noverify: no source/build-data changes in this PR

Each changed src/*.c|*.cpp is compiled and its relocated bytes compared to the binary data on a private build box. Passing requires every changed file to reproduce the ROM byte-for-byte with correct relocation targets — this catches WRONG-DEST relocations and non-reproducing near-misses that ledger-scoped linkcheck skips.

@andrewboudreau
andrewboudreau merged commit 1e7b428 into main Sep 1, 2026
7 checks passed
@andrewboudreau
andrewboudreau deleted the fix/romdata-preamble-extent branch September 1, 2026 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant