Skip to content

tu: let a promoted TU own the vtable its key function emits - #1880

Closed
andrewboudreau wants to merge 1 commit into
tu/pathlift-promotefrom
tu/promote-batch-1
Closed

tu: let a promoted TU own the vtable its key function emits#1880
andrewboudreau wants to merge 1 commit into
tu/pathlift-promotefrom
tu/promote-batch-1

Conversation

@andrewboudreau

Copy link
Copy Markdown
Collaborator

Stacked on #1878 (which is stacked on #1876).

The wall

Multi-function isolation refused any non-text content, so a reconstructed TU could be promoted only while it emitted no data. Under the Itanium ABI that is exactly backwards: a class's vtable and RTTI go to the TU that defines its key function — which, for almost every actor here, is the destructor the TU exists to hold. The moment a TU is real enough to place its own structors, mwcc hands it _ZTV/_ZTI/_ZTS, and isolation threw the object out:

src/actors/ArrowLift.cpp: isolate: unlicensed content in text-only multi-symbol
object: section[5] .data size 0x8 defines ['_ZTI7fBase_c']; … 11 more

Measured over the 25 entries that were otherwise ready: 23 of them, 9–13 data symbols each, 258 in total and every one _ZTV/_ZTI/_ZTS. The manifest notes call this "Phase F" and defer it in five separate places.

What changed

plan_many now does what plan has always done for singletons, generalised from one kept section to N:

  • drop the non-text content
  • externalise its symbols, so a weak gap-object import can never bind to our address 0 instead of the cartridge's carved-out address — the two steps are one operation, exactly as in the one-function path
  • take the vtable address-point preamble back off the surviving relocations

Every RTTI relocation check plan makes, this makes. _apply was already written in terms of a set of kept sections; it just had no caller that passed more than one.

Two things stay refusals, deliberately:

refused why
an unlicensed .text body the spanning delinks entry means dsd no longer fills that range from the ROM, so a stray function would land inside it
non-RTTI data an ordinary owned definition — a table, a string, a global the source declares — is not carved out of the cartridge by name, so externalising it would invent an import nothing can satisfy. Better a sentence here than an undefined symbol at the link

A genuinely text-only object still takes no surgery and comes back byte-identical.

Promotions

arm9/Scene (21 functions) and ov029/ArrowLift (9). ArrowLift is a key-function TU with 11 data symbols and is the proof this works: it promotes with the image unchanged.

ov002/Tree was ready and is deliberately not here — its TU is still the un-migrated C form (extern void* _ZTV4Tree[] with hand-written vptr stores), so it imports the vtable rather than defining it, and consolidating would have cost _ZTV4Tree's data verification. Caught by diffing the per-record dump; it is a real loss, not a counting artifact.

tools/tu_promote.py

The mechanical half of a promotion: delinks entries in and out, the src_tusrc rename, the legacy git rm, the manifest status, and one attribution.json override per absorbed symbol. It compiles nothing — the proof of a promotion is rombuild reporting 106/106 afterwards, and running that once over a batch beats once per entry. --dry-run reports what each would touch.

Verification

  • rombuild -j16 --no-rom: 106/106 exact, mismatching: 0, PASS
  • ROM data 376 verified / 56 differ — byte-for-byte the base's numbers
  • prepush_attribution: 35 consolidated with credit intact, 0 changed, 0 lost
  • layout_check clean · port_refcheck 407/407 · check_dead_references no new (two prose references followed the absorbed dScene_c files to src/actors/Scene.cpp) · check_src_tu_compiles all
  • 67 unit tests, including a new key-function-TU case asserting every externalised symbol is SHN_UNDEF, every dropped section is zero-sized, and the object never changes length

What is left

The other 22 ready entries are blocked on one thing, and it is not tooling: their TUs define the destructor out of line, so mwcc emits D2, D0, D1 while the ROM has D1 then D0. The fix is the source form the class-form notes already describe — an inline in-class destructor plus a real instantiation — which also removes the homeless D2 each of them carries. That is source work, one class at a time, and it is the same change that makes these files period-accurate C++.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VregK5ZWRa2NbUcneaprG6

Multi-function isolation refused any non-text content, so a reconstructed TU could
be promoted only while it emitted no data. Under the Itanium ABI that is exactly
backwards: a class's vtable and RTTI go to the TU that defines its KEY FUNCTION,
which for almost every actor here is the destructor the TU exists to hold. The
moment a TU is real enough to place its own structors, mwcc hands it _ZTV/_ZTI/_ZTS
-- and isolation threw it out. Measured over the 25 entries that were otherwise
ready: 23 of them, 9 to 13 data symbols each, 258 in total and every one _ZTV/_ZTI/_ZTS.

plan_many now does what plan has always done for singletons, generalised from one
kept section to N: drop the non-text content, externalise its symbols so a weak
gap-object import can never bind to our address 0 instead of the cartridge's
carved-out address, and take the vtable address-point preamble back off the
surviving relocations. Every RTTI reloc check plan makes, this makes.

Two things stay refusals, deliberately:

  an unlicensed .text body, because the spanning delinks entry means dsd no longer
  fills that range from the ROM and a stray function would be placed inside it; and

  non-RTTI data. An ordinary owned definition -- a table, a string, a global the
  source declares -- is not carved out of the cartridge by name, so externalising
  it would invent an import nothing can satisfy. Refusing it here says so in a
  sentence instead of at the link in an undefined symbol.

A genuinely text-only object still takes no surgery and comes back byte-identical.

Promotes arm9/Scene (21 functions) and ov029/ArrowLift (9), the two remaining ready
entries the compiler already emits in ROM order. ArrowLift is a key-function TU with
11 data symbols and is the proof this works: it promotes with the image unchanged.

tu_promote.py performs the mechanical half of a promotion -- delinks entries in and
out, the src_tu -> src rename, the legacy git rm, the manifest status, and one
attribution override per absorbed symbol so a many-to-one consolidation reads as
consolidated rather than N authors lost. It compiles nothing: the proof of a
promotion is rombuild reporting 106/106 afterwards, and running that once over a
batch beats running it once per entry.

Two prose references followed the absorbed dScene_c files to src/actors/Scene.cpp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VregK5ZWRa2NbUcneaprG6
@tangos-validator

tangos-validator Bot commented Aug 27, 2026

Copy link
Copy Markdown

❌ PR validation — Failed

Validation failed: full-ROM result regressed from the base commit; full-ROM validation failed

Full merge validation

Check Result
Committed test merge yes
Byte-verified functions 10,999 / 11,347 (96.93%, -28)
Byte-verified code bytes 2,041,720 / 2,211,124 (92.34%, -3,732)
Claimed, not byte-verified 200 functions, 59,660 bytes (+28)
Perfect source moves 0 R100
Enrolled ranges (delinks complete) 11,036 functions, 2,047,728 bytes (92.61%, +0) -- differs from byte-verified by +37
Contributor credit 0 added, 0 changed, 0 lost
Relocation check 0 checked; no affected slots
Port reference check 407 checked; 0 stale
Full ROM build mwccarm failed

Byte-verified means the range carries complete in a delinks.txt, so the ROM build compiled it and compared it to the cartridge. The 200 claimed functions have a src/ file named after the symbol with no NONMATCHING banner, and nothing compiles them -- dsd fills their addresses with the ROM's own bytes. Both together are the 11,199 this project calls matched.

Full ROM build output (mwccarm failed, exit 1)
src/actors/ArrowLift.cpp: isolate: unlicensed content in text-only multi-symbol object: section[5] .data size 0x8 defines ['_ZTI7fBase_c']; section[7] .data size 0x9 defines ['_ZTS7dBase_c']; section[8] .data size 0x9 defines ['_ZTS7fBase_c']; section[9] .data size 0xa defines ['_ZTS8dActor_c']; section[10] .data size 0xb defines ['_ZTS9ArrowLift']; section[11] .data size 0xc defines ['_ZTI10dBgActor_c']; section[13] .data size 0xc defines ['_ZTI7dBase_c']; section[15] .data size 0xc defines ['_ZTI8dActor_c']; section[17] .data size 0xc defines ['_ZTI9ArrowLift']; section[19] .data size 0xd defines ['_ZTS10dBgActor_c']; section[20] .data size 0x88 defines ['_ZTV9ArrowLift']
src/actors/Scene.cpp: isolate: unlicensed content in text-only multi-symbol object: section[5] .data size 0x8 defines ['_ZTI7fBase_c']; section[7] .data size 0x9 defines ['_ZTS7fBase_c']; section[8] .data size 0x9 defines ['_ZTS7dBase_c']; section[9] .data size 0xa defines ['_ZTS8dScene_c']; section[10] .data size 0xc defines ['_ZTI7dBase_c']; section[12] .data size 0xc defines ['_ZTI8dScene_c']; section[14] .data size 0x50 defines ['_ZTV8dScene_c']

Warnings: 30 address range(s) left the byte-verified set while enrolled totals held steady: arm9:0x0202e140-0x0202e170, arm9:0x0202e170-0x0202e1b4, arm9:0x0202e1b4-0x0202e1e0, arm9:0x0202e1e0-0x0202e26c, arm9:0x0202e26c-0x0202e300, +25 more; 28 more function(s) now claim a match that nothing compiles; enroll them in a delinks.txt to have the ROM build check them.

The private worker commits a test merge, builds the stock ROM profile, compares every executable module, measures matched and source-built code, checks contributor lineage, and verifies affected relocations. The mod profile is opt-in and is not part of this merge gate.

andrewboudreau added a commit that referenced this pull request Aug 30, 2026
Both come from the #1880 -> #1914 promotion chain, which bundled them with the
manifest rows and sources they produce. The PR validator restores all of
`tools/` from base, so no PR in that chain could ever test its own tool change
-- every one of them is UNSTABLE for that reason alone. These are lifted out and
land alone, off main, so the content PRs that follow are judged against tools
that are already there.

`tu_order_check.py` compiles a manifest TU once and reports, per licensed
function, bytes and emission order. Production isolation places an object's
`.text` into the spanning delink in emission order, so when a TU defines its
destructor out of line and mwcc emits `D2, D0, D1` against the cartridge's
`D1, D0`, the range does not link. The tool makes that visible before a
promotion is attempted rather than after.

`tu_promote.py` performs the mechanical half of a promotion and guesses at
nothing: it collapses the per-function `delinks.txt` entries into one `complete`
entry spanning the manifest's sections, `git mv`s the `src_tu/` source to its
production path (R100, so credit follows), `git rm`s every `legacy_source`,
rewrites the entry to `status: promoted`, and adds one `attribution.json`
override per absorbed symbol. It compiles nothing -- the proof of a promotion is
`rombuild.py` reporting 106/106 with `mismatching: 0` over the whole batch.

The chain's `objisolate.py`/`rombuild.py`/`romdata_check.py` deltas are
deliberately NOT carried: they are the predecessor of the deadstrip-data work
that has since landed as #1977, #1987 and #1989, and main's versions supersede
them. The remaining three files are comment-only, correcting paths that the
promotion moves.

Verified against main at c05617c, not against the chain's 350-commit-old base:
`tu_promote --dry-run ov002/BlueCoinSwitch ov002/Exit ov045/PoleLift` resolves
all three to their production paths, and refuses `ov100/daObjPathLift_c` as
already promoted; `tu_order_check ov002/BlueCoinSwitch ov045/PoleLift` compiles
both and reports the `D0, D1` + extra `D2` against the wanted `D1, D0`.


Claude-Session: https://claude.ai/code/session_01QhhAeJwXBnfPp7B5DNjCwh

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@andrewboudreau

Copy link
Copy Markdown
Collaborator Author

Triage: leaving this open. The working assumption was that #1993 (tu/restack-inline-dtors) supersedes this whole chain. It does not supersede this PR.

#1993 promotes 26 TUs. This PR promotes 2, and neither is in #1993's set:

  • src_tu/actors/ArrowLift.cpp -> src/actors/ArrowLift.cpp (config/tu_manifest.d/ov029/ArrowLift.json)
  • src_tu/scene/Scene.cpp -> src/actors/Scene.cpp (config/tu_manifest.d/arm9/Scene.json)

Both files are still under src_tu/ on origin/main @ 7869b817d, neither has a counterpart under src/actors/ there, and no other open PR touches either — the only open PRs with src_tu/ changes are #1995 (ActorDerived), #2002 (daKrpa_c, daKpFr_c) and #2005 (dPathLiftActor_c, daObjRcCarpet_c, both deliberately left shadow-only).

Unlike the four TUs #1993 documents as deliberately excluded, and unlike PoleLift (dropped in d3cb31f4c because it is tools/test_tubuild.py's pilot fixture), there is no recorded rationale for ArrowLift and Scene being absent from #1993. They appear to have been lost in the regenerate, not rejected.

This branch is nonetheless stale: its base is tu/pathlift-promote, and the whole chain predates the tools/ split that landed as #1990. Do not rebase it. Once #1993 lands, re-cut both promotions from main with tools/tu_promote.py and gate on a per-symbol rombuild --data-json diff against main — the headline object-record count drops on every consolidation and will not catch a lost vtable. If the re-cut lands, close this. Until then it is the only carrier.

@andrewboudreau

Copy link
Copy Markdown
Collaborator Author

Gatekeeper review — keep open. This is NOT superseded by #1993.

I checked the coverage rather than assuming it. The #1880#1882#1884 chain promotes 66 TUs against #1993's 26, and five promotions are uncovered by anything else in the queue: ov029/ArrowLift and arm9/Scene (#1880), ov045/PoleLift (#1882), ov002/Platform and ov002/Warp (#1884). Closing the chain would silently drop those five, so it stays open.

What I need before this can merge, in order:

  1. Merge current origin/main (bfd908b32). Two things moved under you: Spell the TU-promotion metavariable as <Class>, unbreaking main's references gate #2010 fixed a dead-reference red caused by a src/actors/X.cpp metavariable left in prose, and Re-bank the two daObjAbuku_c paths #1996 absorbed, and check the ratchet on main #2011 re-banked the two daObjAbuku_c paths that TU: promote daObjAbuku_c as genuine C++ #1996 absorbed. If you resolved against an older main, redo it — do not carry a stale count forward.

  2. Resolve config/converted-baseline.json by REGENERATION, not key union. Union is correct for attribution.json and wrong here: score() returns None for an unreadable path, so a single banked path that a promotion absorbed and deleted exits 1 and turns the ratchet red on main.

  3. A correction to guidance I issued earlier, which may be steering you wrong right now. I told agents "the floor is 2565, never go below it." That was wrong. tiers_ratchet.py is a set ratchet: --check computes missing = banked - current and fails only when a path leaves the set. The printed count is informational — there is no floor to defend, and a promotion batch legitimately produces a much smaller count. The real gate is exactly two conditions:

    • every removed path is classified by the tool itself as MOVED -- absorbed into <file> by TU promotion (<tu_id>), i.e. zero genuine backslides; and
    • every named absorbing file exists on the branch and is tracked by git.
      If both hold, regenerate with --update --reason and push. If either fails, stop and report rather than working around it.
  4. Confirm no delinks range is orphaned. Every promoted entry from python tools/tu_manifest.py list must have an existing, tracked promoted_source and exactly one complete .text delinks entry naming it. This is the one failure mode the pipeline cannot self-detect: a .text range that stops being named complete, or names a path with no file there, is refilled by dsd with retail cartridge bytes — the ROM still byte-matches, module fidelity stays 106/106, romdata stays green, every gate passes. Only a drop in bytes-built-from-source reveals it. tools/layout_check.py is the dedicated gate.

Do not run rombuild.py or eligible.pybuild/ is shared across all worktrees and concurrent runs produce fake failures. Read the TU manifest only through python tools/tu_manifest.py, never config/tu_manifest.d/** directly.

If any premise above is wrong, say so on the PR with evidence rather than routing around it — I have been corrected three times today and each correction was right.

@andrewboudreau

Copy link
Copy Markdown
Collaborator Author

Status note, so nobody spends a validator slot chasing this. The red ratchet on this PR is a stale run, not a live regression:

FAILURE   converted ratchet   ratchet   2026-08-27T20:00

That is three days old. It predates #2007's re-bank, #2011, and today's merges (#1991, #1978, #2000, #1985). gh pr checks shows two checks both named ratchetlangmode ratchet (passing) and converted ratchet (this one) — so the bare name is ambiguous; read the workflow column.

This is almost certainly the base-desync shape rather than a real backslide: a PR cut before a re-bank merges against a baseline that predates it and inherits the red. The fix for that is to merge main in, never to lower the pin. I am not doing that yet, deliberately — see below.

Two corrections to what I told people earlier, both mine and both wrong:

  • The converted ratchet is a SET ratchet, not a count ratchet. --check computes missing = banked - current and fails only when a banked path leaves the set. The printed count is informational and there is no floor number to defend — a TU-promotion batch is expected to lower it. I circulated "the floor is 2565, never go below it" to several agents; it was wrong in both the number and the concept. Correct two stale claims in tiers_ratchet.py's docstring #2014 fixes the docstring that said otherwise.
  • The validator no longer restores tools/ from baseValidation now runs the pull request's own tools/ #1994 reversed that this morning. A PR now exercises its own tool change.

Why this is parked rather than refreshed

#1993 is the restack of this chain, and it is the queue chokepoint: it regenerates config/converted-baseline.json wholesale, so it must be the last thing to touch that file. Refreshing this PR now would (a) consume a slot on the serial validator that #1993 and the ov070 stack need, and (b) produce a green that #1993 immediately invalidates.

Order I am walking: #2002#2005#1995#1993, then re-assess what of this chain is still unlanded. Current signal is that the remainder is smaller than the diffstat suggests — a parallel review of #1914 found all 37 of its uniquely-promoted sources already present on origin/main under src_tu/, with the real harvest being a few dozen header inline-destructor flips. I want #1993 landed and measured before deciding whether this is a re-cut or a merge.

Nothing here is closed and nothing is being discarded. Coverage on main is holding at 2,066,772 B built from source, 0 bytes handed back to the cartridge, and I will re-check it after every merge.

@andrewboudreau

Copy link
Copy Markdown
Collaborator Author

Closing as superseded. The body work here was real, but every mechanism that made it legal
has since landed on main by a different — and deliberately opposite — design.

The blocker is plan_many. At 17e1e7e36:tools/objisolate.py:342-349,366:

RTTI = ("_ZTV", "_ZTI", "_ZTS")
for index, sec in extra:
    if sec.name.startswith(".text"): continue
    for name in occupants_of(index):
        if not name.startswith(RTTI):
            owned.append(...)          # only NON-RTTI is refused
...
drop = [index for index, _sec in extra]   # every RTTI section dropped

So the title — "let a promoted TU own the vtable its key function emits" — is the inverse of
what the code does. The TU is permitted to promote despite emitting a vtable, by discarding
it uncompared. Nothing records which symbols were dropped, and nothing gates it
(tools/rombuild.py:315-317 on this branch: "Measurement only: check_object swallows its own
exceptions, and nothing here can fail the build"
).

Main considered this and refused it. The plan_many docstring that this PR deletes is in
your own merge-base:

"silently dropping an unexpected helper or data section would create a hole inside a range dsd
no longer fills from the ROM… Success therefore needs no ELF surgery: the exact input object
is preserved."

Main solved the same goal strictly in #1987, "Verify class data emitted by promoted C++ TUs":
keep plan_many refusing, and license each emitted record with a deadstrip-data row that
romdata_check compares to the cartridge word-for-word with relocations applied.

main this branch
deadstrip-data disposition yes no (DISPOSITIONS has 2 entries)
verified _ZTV/_ZTI/_ZTS records 50 across 5 TUs 0
ov100/daObjPathLift_c.json rows 14 (13 deadstrip-data) 1

Every tools/ change here is already on main, mostly byte-identical: tu_order_check.py
(#1990, identical), tu_promote.py (#1990, main a superset), rombuild.py (#1876/#1977/#1987),
romdata_check.py (#1876), objisolate.py (#1977/#1987/#1989). Both commits unique to the
stack root landed too: 711db61b43b771dcca, e2691b2a90d4d3f898. Main has since
promoted five TUs the strict way.

Also, for the record: the base of this PR (#1878, tu/pathlift-promote) is itself closed,
and the branch is 411 commits behind main with 11 conflicting files — including
ov100/daObjPathLift_c.json, where all 13 verified rows are on main's side and the branch
side is empty
. A --theirs resolution there would destroy them.

Nothing to salvage from this one — plan_many is the only piece with no main equivalent, and
that is precisely because main rejected it. Re-cut promotions in small batches from current
main using the landed deadstrip-data policy, one compiler_only_output row per emitted
record with canonical_module/canonical_address, exactly as ov100/daObjPathLift_c.json
does today.

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