Skip to content

tools: let a policy license a vague-linkage duplicate against the cartridge - #1977

Merged
andrewboudreau merged 1 commit into
mainfrom
tools/deadstrip-duplicate
Aug 30, 2026
Merged

tools: let a policy license a vague-linkage duplicate against the cartridge#1977
andrewboudreau merged 1 commit into
mainfrom
tools/deadstrip-duplicate

Conversation

@andrewboudreau

@andrewboudreau andrewboudreau commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Splits the tool half out of #1878 so it can actually be validated. #1878 bundles this
rombuild/objisolate change with the daObjPathLift_c manifest rows that use it, and
the PR validator restores all of tools/ from the base commit — so it runs main's
rombuild.py, which only knows deadstrip, against the new manifest and fails:

src/actors/daObjPathLift_c.cpp: _ZN7Vector3D1Ev disposition must be deadstrip

That PR can never go green on its own. This one carries no manifest change, so it can.

The problem this unblocks

include/types.h declares ~Vector3() {} inline in-class — deliberately; the ROM's
__destroy_arr over arrays of Vector3 proves the element type has a destructor.
Consequence: every TU that so much as holds a Vector3 local emits a vague-linkage
_ZN7Vector3D1Ev
(4 bytes, bx lr, STB_LOPROC).

It has a real ROM home at arm9:0x020072c0, owned by src/_ZN7Vector3D1Ev.cpp. The old
policy therefore refused it — compiler_only_output rejects any symbol with a configured
home (correctly: it is not compiler-only), and externalized_output takes only
_ZTI/_ZTS data. Four reconstructed TUs stopped at TEXT-VERIFIED on those four bytes
(ArrowSignRight, daObjPathLift_c, arm9/Actor, ov002/LevelObjects all say so in
their manifest notes). In production it surfaces as:

isolate: unlicensed content in text-only multi-symbol object:
section[21] .text size 0x4 defines ['_ZN7Vector3D1Ev']

What changes

A second disposition whose precondition is the opposite of deadstrip's:

{"symbol": "_ZN7Vector3D1Ev", "disposition": "deadstrip-duplicate",
 "reason": "vague-linkage duplicate; arm9:0x020072c0 owns the retail copy"}
  • deadstrip still means homeless-only — refused the moment a symbol acquires a ROM
    home. Unchanged.
  • deadstrip-duplicate requires a ROM home and requires it to be unambiguous (exactly
    one configured home, or the row is refused rather than guessed at). rombuild reads the
    module image at that address, sizes it from the same symbols.txt the delink reads, and
    hands the cartridge's own bytes to objisolate, which refuses unless this object's copy
    is STB_LOPROC and byte-identical.

Dropping a duplicate cannot lose the address: the owning source still supplies it, and if
it did not, the link fails on an undefined symbol rather than quietly leaving a hole. That
loudness is the rest of the safety argument. deadstrip_plan already refused when a
surviving section referenced the symbol; that check is untouched.

Also fixed here, because it is the same code path: compiler_only_policies keyed the
policy on source, but a promoted entry is enrolled under promoted_source. This is
forward-looking rather than a live bug -- of the 21 manifest entries carrying a
compiler_only_output today, exactly one (ActorBase_SceneNode) is enrolled, and its
source and promoted_source are the same path, so the old keying happened to work. The
other 20 are unenrolled src_tu/ shadows that the active filter drops either way. The
fix matters the moment a TU is promoted while its manifest still names the src_tu path
-- the shape of every entry in the rest of this chain -- where the policy would otherwise
be filtered out and the build would refuse the very object it was written for.

Test plan

  • python -m pytest tools/test_rombuild.py tools/test_objisolate.py -q → 52 passed,
    including five new cases: the cartridge-body demand, the required ROM home, the
    ambiguous-home refusal, the unknown-disposition refusal, and the promoted-path keying.
  • python tools/rombuild.py -j 16106/106 exact, 100.000000% of compared bytes; 11,082 source-built functions, 11,082 reproducing, 0 mismatching -- byte-identical to main, as expected for a change that only widens what a manifest row may declare.
  • python tools/port_refcheck.py → 405 checked, all references resolve
  • python tools/prepush_attribution.py --base origin/main --head HEAD → 11,388 tracked, 0 changed, 0 lost

No src/, config/ or include/ file changes, so no byte can move: this PR only widens
what a future manifest row is allowed to declare.

Follow-up: the daObjPathLift_c promotion half of #1878 rebases onto this and becomes a
config+src-only PR the validator can judge on its merits. #1880#1882#1884#1914
then restack behind it.

…tridge

`deadstrip` licenses a homeless function and is refused the moment the symbol
acquires a ROM home. That is right for a discarded D2 variant, and wrong for
`_ZN7Vector3D1Ev` -- a 4-byte `bx lr` that every TU holding a `Vector3` re-emits
with vague linkage, while the cartridge keeps one copy at arm9:0x020072c0 that
`src/_ZN7Vector3D1Ev.cpp` already owns. Four reconstructed TUs stopped at
text-verified on those four bytes.

Add `deadstrip-duplicate`, whose precondition is the opposite: the symbol must
have a configured ROM home, and exactly one, or the row is refused rather than
guessed at. rombuild sizes the home from the same symbols.txt the delink reads,
reads the module image there, and hands those bytes to objisolate, which refuses
unless this object's copy is STB_LOPROC and byte-identical. Discarding a
duplicate cannot lose the address -- the owning source still supplies it, and if
it did not, the link fails on an undefined symbol instead of leaving a hole.

Also key `compiler_only_policies` on `promoted_source`. A promoted entry is
enrolled under that path, so keying on `source` let the `active` filter drop the
policy for all 19 entries whose src_tu and src paths differ, leaving the build
to refuse the very object the policy was written for.

Split out of #1878, which bundles these tools with the manifest rows that use
them; the validator restores tools/ from base, so that PR can never test its own
tool change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhhAeJwXBnfPp7B5DNjCwh
@tangos-validator

tangos-validator Bot commented Aug 30, 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 0d4d3f8 into main Aug 30, 2026
5 checks passed
@andrewboudreau
andrewboudreau deleted the tools/deadstrip-duplicate branch August 30, 2026 00:57
andrewboudreau added a commit that referenced this pull request Aug 30, 2026
Eight one-function sources and eight delinks entries become one .cpp and one
.text span (0x02146d7c-0x02147328), the shape the original TU had. mwcc emits
one .text section per function in reverse source order, so the reconstructed
file is written highest-address-first; the destructor pair's D1/D0 order is the
compiler's own.

Merging the objects makes mwcc emit an extra vague-linkage _ZN7Vector3D1Ev into
the combined object -- types.h gives Vector3 a deliberately empty inline
destructor, so every TU that destroys one re-emits it. The cartridge keeps a
single copy at arm9:0x020072c0 that src/_ZN7Vector3D1Ev.cpp owns, so the
manifest licenses this object's copy with the deadstrip-duplicate disposition
added in #1977: rombuild reads the cartridge's bytes at that address and
objisolate refuses unless this copy is byte-identical STB_LOPROC output.

Known coverage cost, stated plainly: the file is still a SHADOW TU. Its D0/D1
are free functions carrying @symbol markers that stamp the base vptr directly,
not a real `class daObjPathLift_c`, so this object emits no _ZTV/_ZTI/_ZTS. The
two per-function destructor files it replaces did emit them, and romdata_check
compared 128 of 128 _ZTV15daObjPathLift_c bytes against the cartridge. After
this commit those three data symbols leave the checked set entirely -- verified
by diffing `rombuild --data-json` per symbol, not the summary, which counts
records per object and falls on consolidation for unrelated reasons. No symbol
changed verdict; three stopped being checked. 106/106 is blind to vtables, so
no gate reports this. Restoring it means giving the TU a real class whose
out-of-line destructor is the key function; that is the next step, not this one.


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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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>
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