Skip to content

Add fail-closed compiler-owned C++ TU promotion - #2001

Draft
andrewboudreau wants to merge 20 commits into
mainfrom
tools/tu-control-policy-0830
Draft

Add fail-closed compiler-owned C++ TU promotion#2001
andrewboudreau wants to merge 20 commits into
mainfrom
tools/tu-control-policy-0830

Conversation

@andrewboudreau

@andrewboudreau andrewboudreau commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Adds the tools/metadata prerequisite for promoting a proven C++ translation unit with compiler-emitted lifecycle metadata and owned non-text sections.

The intact-TU admission is fail-closed:

  • the independent control demotes the complete current intact-TU inventory to extracted retail ROM gaps
  • the control must pass all 106 executable modules and match its own worker's stock ROM
  • every manifest must admit the exact complete-module-set SHA-256 and pre-promotion symbol-error inventory
  • production must reproduce that same module set and be bit-identical to the control ROM on the same worker
  • a worker without a stock comparison can only use the previously admitted bootstrap ROM SHA; a known stock mismatch always refuses

Control evidence remains content-bound to the demoted scratch config, extracted ROM input tree, linked ELF, DSD, linker, and judging Python tools. Reports retain the current control ROM, historical bootstrap ROM, and deterministic SHA-256 over all 106 executable-module images.

The review follow-up is included:

  • promoted-move rescue accepts either a converted destination path or every independently passing destination member identity
  • mixed member results name the exact regressed member and criterion instead of reporting GONE or UNREADABLE
  • the initial ActorBase path-to-two-member identity upgrade is banked losslessly in this prerequisite (2,553 to 2,554 identities on current main)
  • the workflow now describes enrolled-member scoring accurately

Also restores the known legacy credits for Kurumajiku Init/Cleanup (lunavyqo) and RickshawBs_Spawn (tangosdev), so the dependent source promotion transfers them unchanged.

Verified after rebasing onto current main: 244 focused TU/ROM tests passed with 1 environment skip; 89/89 manifest TUs compile; 405/405 port references resolve; the CONVERTED ratchet passes; no new dead references. The dependent Kurumajiku integration proof is 11,087/11,087 reproducing, 106/106 exact modules, zero new symbol errors, and a production ROM bit-identical to its admitted independent control.

@tangos-validator

tangos-validator Bot commented Aug 30, 2026

Copy link
Copy Markdown

✅ PR validation — Passed

Committed merge introduces no reconstruction or attribution regression.

Full merge validation

Check Result
Committed test merge yes
Byte-verified functions 10,998 / 11,347 (96.92%, +0)
Byte-verified code bytes 2,045,248 / 2,211,124 (92.50%, +0)
Claimed, not byte-verified 218 functions, 60,056 bytes (+0)
Perfect source moves 0 R100
Enrolled ranges (delinks complete) 11,058 functions, 2,052,772 bytes (92.84%, +0) -- differs from byte-verified by +60
Contributor credit 0 added, 0 changed, 0 lost
Relocation check 0 checked; no affected slots
Module fidelity 106/106 exact; 100.000000% compared bytes
Code linked from verified source 11,087 functions, 2,066,772 bytes (93.47%)
Module bytes from source 2,066,772 / 3,049,600 (67.8%); 811,492 (26.6%) are data no delink entry reaches
ROM data reproduced from source 456 symbol(s) exact, 241 partial, 9 differ

Byte-verified means the range carries complete in a delinks.txt, so the ROM build compiled it and compared it to the cartridge. The 218 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,216 this project calls matched.

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

Copy link
Copy Markdown
Collaborator Author

Review: KEEP AND FIX

Reviewed at pr/2001 = 56a2611ac (the force-rebased head; my first pass was against the orphaned 732b0176c and I discarded it). origin/main = f822d4908#1991 and #1978 landed while this sat parked, so this branch's merge-base ce8a7e1b3 is two commits behind. Everything below was re-measured against f822d4908.

Scope, first, because the raw diff is misleading

gh pr diff 2001 --name-only -> 16 files: 15 under tools/, plus .github/workflows/converted-ratchet.yml (comment text only, no step changes). git diff --stat origin/main pr/2001 shows 24 files including include/daDsnBase_c.h and two _ZN6Thwomp* renames. Those eight extra files are main's#1991's daDsnBase_c work landed after this branch's base. This PR touches no src/, no include/, no config/. Not a defect; flagging it so nobody re-reviews it as one.

The parking note's premise is half right

"immune today, because no multi-member TU is banked"

The factual half checks out:

git show origin/main:config/converted-baseline.json | python -c "import json,sys;c=json.load(sys.stdin)['converted'];print(len(c), sum(1 for x in c if '#' in x))"
-> 2565 0

The reasoning half is wrong. Main has three multi-function enrolled sources today, and one of them — src/actors/ActorBase_SceneNode.cppis in the banked set. Zero # keys on main is trivially true (main's scorer never emits a #) and is not evidence that nothing is exposed.

Running this PR's tiers_ratchet.py against main's tree and main's baseline:

banked 2565  current 2570  left baseline 1  rescued 1  HARD FAIL 0
  left:   src/actors/ActorBase_SceneNode.cpp
  gained: src/actors/ActorBase_SceneNode.cpp#_ZN7fBase_c9SceneNode5ResetEv
          src/actors/ActorBase_SceneNode.cpp#_ZN7fBase_c9SceneNodeC1Ev
          src/actors/daObjAbuku_c.cpp#_ZN12daObjAbuku_cD0Ev
          src/actors/daObjAbuku_c.cpp#_ZN12daObjAbuku_cD1Ev
          src/actors/daObjPathLift_c.cpp#_ZN15daObjPathLift_cD0Ev
          src/actors/daObjPathLift_c.cpp#_ZN15daObjPathLift_cD1Ev

The gate does pass — but it passes because of the new identity-upgrade branch at the top of classify_missing, not because nothing was exposed. Remove that branch and the same file fails with UNREADABLE -- the file could not be read, which is a lie about the cause. The branch is load-bearing on day one.

One live regression: this defeats #2007's promoted-move rescue

classify_missing's move branch requires dest in current. Under the # scheme a promoted multi-member TU's plain path is never in current — only its path#symbol identities are — so every promoted-TU move that #2007 taught the ratchet to absorb goes back to being a hard removal.

Measured on #1993's tree (ddb624baf) against main's banked set, same tree, only the tools swapped:

MAIN tools:  banked 2565  current 2483  left 90  rescued 8  HARD FAIL 82
THIS PR:     banked 2565  current 2566  left 91  rescued 1  HARD FAIL 90

Eight paths that main's ratchet correctly reads as moves become eight extra --update --reason invocations and eight extra permanent rows in config/converted-backslide-exceptions.jsonl for whoever lands #1993. That cost is real and it is paid by a different PR.

One latent cliff

The identity upgrade fires only when every member of the path is CONVERTED (len(members) > 1 and member_ids.issubset(current)). All three of main's multi-member sources happen to have all members passing right now. The first multi-member banked path with a mixed result trips a hard failure whose message does not say "one member regressed" — it says the path is gone. Exposure is about to grow: #1993 creates 29 multi-member tracked sources (23 with at least one passing member, ~94 # identities) and #2005 creates 4.

The strongest argument for this PR is not in its description

tools/rombuild_check.py's complete_entries_text on main keeps only the last section of a complete entry — sec is overwritten on each subsequent section line. This PR fixes it by splitting out complete_entry_sections_text and filtering to .text/.init. Feeding #2004's config/arm9/overlays/ov047/delinks.txt to both versions:

MAIN   -> ('src/actors/daObjKm3_Kurumajiku_c.cpp', 0x2112258, 0x2112320)  200 bytes  <- the .data range
THIS   -> ('src/actors/daObjKm3_Kurumajiku_c.cpp', 0x21111a0, 0x21112bc)  284 bytes  <- the .text range

Under main's tool, a promoted entry that owns data reports its data as source-linked code and silently drops 284 bytes of real .text out of the coverage metric. Zero entries on main trip this today — I scanned every delinks.txt on f822d4908 and no complete entry has a non-text section — so it is latent. But #2004 is the first thing to trip it, which makes this PR a hard prerequisite for #2004 rather than merely its stacking parent.

Relatedly, intact_tu_policies is genuinely dormant: git grep -l intact-object -- config/tu_manifest.d returns 0 entries on main and 0 on this branch; the first consumer is #2004's ov047/daObjKm3_Kurumajiku_c.json. The admission checks are fail-closed (must be promoted, must request production_mode: intact-object, must carry a scratch-data-verified linkcheck with all six phases green, no storage aliases, no input-section retargeting) and I could not find a route to the relaxed fidelity profile without a manifest opting in.

Also in favour of the scheme: #symbol identities are keyed on the enrolment table, so if a delinks range stops being complete the identity vanishes and the gate says GONE -- <sym> is no longer an enrolled member of <path>. That is a second, independent detector for the cartridge-refill failure mode that only layout_check.py currently watches.

One process assumption that is no longer true

The "the validator restores tools/ from base, so a PR cannot both break and fix a tool in one change" rule is dead. #1994 ("Validation now runs the pull request's own tools/") merged 2026-08-30T06:20:17Z and changed .github/workflows/pr-validate.yml. This PR rewrites rombuild.py (+232), rombuild_check.py, tubuild.py, objisolate.py and tu_production.py, and those rewrites will be what produces this PR's own 106/106. Not a reason to reject — a reason to want the headline numbers reproduced under main's tools as well before merge.

To make this safe to land, in order

  1. Restore the promoted-move rescue under the # scheme. In classify_missing, when moves.get(rel) yields a dest, accept it if dest in current or if every f"{dest}#{sym}" for sym in ownership.get(dest, []) is in current. Assert against [SUPERSEDED — reference only] TU: promote 27 reconstructed translation units (restack of #1880 -> #1882 -> #1884 -> #1914) #1993's tree: rescued must go back to 8, hard failures back to 82.
  2. Make the identity upgrade explicit rather than silent. Print that the path was rewritten to N member identities, and give the mixed-result case its own message (<path>: member <sym> left the baseline) instead of letting it fall through to GONE/UNREADABLE.
  3. Fix why()'s fallback. A banked plain path that this PR's scan no longer emits currently reports UNREADABLE -- the file could not be read for a file that reads perfectly well. That message will mislead the next person who hits it.
  4. Do the re-bank here, not in a consumer. This PR ships no baseline change (still 2565 entries / 0 #). The first # keys are banked by Promote Kurumajiku as a compiler-owned class TU #2004 — three of them — with zero rows added to config/converted-backslide-exceptions.jsonl. Either bank the identity rewrite in this PR with a --reason, or add a one-shot migration, so the rewrite is auditable in one commit instead of arriving as a side effect of an unrelated actor PR.
  5. Nit: the workflow comment now says "scores every function under src/", but single-member sources still go through score_file. "every enrolled member" would be accurate.

And rebase — the branch base is two commits stale.

Read-only review: no worktree, no build. rombuild.py and eligible.py were deliberately not run (shared build/), so every claim above is from static analysis of the tools and configs, not from a build.

@andrewboudreau

Copy link
Copy Markdown
Collaborator Author

Gatekeeper follow-up. I independently verified the two claims that decide this PR's fate, and both hold.

1. The complete_entries_text bug is real, and it is in the validator's own measurement

tools/rombuild_check.py:82-98 assigns sec rather than appending it, so an entry with more than one section range keeps only the last one:

sec = (int(m.group(2), 16), int(m.group(3), 16))   # last-wins

I sized it on main (765e9d3b1) across all 106 modules:

complete entries: 11077   with >1 section: 0

So it is latent today, not live — nothing on main mis-measures. But the consumer list is what makes it worth fixing rather than shrugging at:

consumer what it does
tools/validate_merge.py:219 the PR validator itself
tools/chaos_db_ci.py:62 progress DB
tools/rombuild_check.py:184, rombuild_diag.py:131 106/106 and its diagnostics

A promotion that gives one complete entry both a .text and a .data range arms it — and #2004 is exactly that shape (284 .text bytes dropped, 200 .data bytes counted as code). The gate that would catch the mistake is the gate that has the bug. That belongs in this PR, and it is the strongest argument in favour of merging it — stronger than anything currently in the description.

For reference, #2013's tools/source_coverage.py gets this right (cur.sections.append(...), every section kept) and documents why: .init is kind:code, 305 entries, 125,712 B that a .text-only tool silently drops.

2. The path#symbol regression is the blocker, and it has a deadline

The measurement from the review above matches my own reading of classify_missing: the move branch requires dest in current, and a promoted multi-member TU's plain path is never in current under the new scheme. Main rescues 8 moves on #1993's tree; this PR rescues 1.

Eight extra --update --reason runs means eight permanent fake backslide rows in config/converted-backslide-exceptions.jsonl — rows that record a regression that never happened, on a file whose union merge driver already duplicates entries. That is not a cost I want #1993 to pay.

Two ways out, and I do not mind which:

Merge-order call

#1993 is the last thing that touches config/converted-baseline.json. It regenerates that file wholesale and is the queue chokepoint. #2004's 1957-entry baseline must never merge ahead of it — a whole-file resolution there silently drops 608 banked entries, and because a shrunken baseline is permissive, no gate would ever say so.

So: #2002#2005#1995#1993 → (#2001 + #2004 as one unit). #2004 cannot land alone regardless, since main's rombuild_check.py would mis-measure its own delinks entry — which is bug 1 above, closing the loop.

Keeping this open. It is doing real work; it just needs the move-branch fix and a restack.

Two corrections to context you may have been given, since both were mine and both were wrong:

  • The converted ratchet is a SET ratchet, not a count ratchet. --check 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" to several agents and it was wrong in both the number and the concept. Correct two stale claims in tiers_ratchet.py's docstring #2014 corrects the docstring that said so.
  • The validator no longer restores tools/ from baseValidation now runs the pull request's own tools/ #1994 reversed that on 2026-08-30. A PR now exercises its own tool change. That matters most here, since this PR rewrites rombuild.py and rombuild_check.py, the tools that produce its own 106/106. Reproduce the headline build numbers under main's tools before it merges.

@andrewboudreau
andrewboudreau force-pushed the tools/tu-control-policy-0830 branch from 56a2611 to 6a884b2 Compare August 30, 2026 15:52
@andrewboudreau
andrewboudreau force-pushed the tools/tu-control-policy-0830 branch from 6a884b2 to ef16a30 Compare August 30, 2026 16:26
@andrewboudreau

Copy link
Copy Markdown
Collaborator Author

Gatekeeper review. Keep and fix — the core control is sound and I want it landed, but two
things block and three want changing. Reviewed against merge-base 675a0ecb; both merge trees
are clean (main × #20012b096c30, main × #2004a6c1b1fd, exit 0).

The design is right. Requiring the production ROM to be bit-identical to a control ROM
built on the same worker with the intact source demoted — so dsd refills its range from
retail extracted/ bytes — is an airtight proof the compiled object reproduced retail,
independent of every other manifest claim. intact_tu_policies (rombuild.py:749+) is a
genuinely strict admission gate, and I checked the obvious escape shapes: ProductionTuError
propagates through _isolate's except Exception into a build refusal, _run_dsd fails
closed, demote_complete_sources writes only under scratch/config/arm9, and the one
file-existence guard (tubuild.py:4421) yields None → refused by the isinstance(..., list)
predicate. That is the right instinct throughout.


Blocking

1. Two supported invocations use intact objects with no ROM comparison at all.
In main() the comparison sits inside the ROM block:

if intact_link_verification:
    comparison = intact_rom_comparison(rom_sha256, intact_link_verification)
    if not comparison["identical"]: raise BuildError(...)

--no-rom never reaches it, and --profile mods takes the elif intact_tus: branch that
writes {"status": "not-applicable"} and continues. And tools/tu_promote.py:240 prints an
instruction telling the next contributor to run python tools/rombuild.py -j16 --no-rom

the tool recommends the one flag that skips the only real proof.

This is the failure this whole queue exists to catch: an intact range silently refilled from
the cartridge while every gate stays green. Please either make the intact ROM comparison
unconditional, or refuse --no-rom and non-stock profiles when intact_tus is non-empty.
Fix the tu_promote.py recommendation either way.

2. linkcheck_symbol_verdict is an unconditional return True (tools/tubuild.py:~3712):

if baseline:
    return True

Main used if baseline: symbols_verdict = symbols_ok — the real command result. It is not
load-bearing for the intact chain (_strict_baseline never reads verified/result, and a
crashed dsd's empty error inventory would mismatch the admitted 9-error inventory and refuse),
so this is not a correctness hole today. But it is precisely the "gate that skips itself and
prints something that reads like a pass" shape this tree has been bitten by repeatedly, and it
will mislead the next reader. Restore the command result. Also note verify_link dropped -f
from dsd check symbols while folding ok_symbols into the ok conjunction.

Recommended

3. _strict_baseline relaxes matchesStockRom exactly when intact TUs exist. Main
refused unless matchesStockRom is True; the branch accepts None whenever
expected_demoted is non-empty — i.e. in the configuration this PR introduces, on the normal
CI worker where build/sm64ds.nds is absent. prepare_intact_link_verification does
compensate (it requires admitted_roms == {baseline["romSha256"]}), so the chain still holds,
but the anchor has moved from a locally re-derived stock ROM to a checked-in JSON value.
That is a defensible trade; it just needs saying. Right now the PR body claims both
bit-identical-to-fresh-control and stock-anchored, and with intact TUs present those are not
both true. Restore the predicate or amend the body — not neither.

4. Add the ^_ZT[ISV] plain-deadstrip refusal, in both copies. Context in #2015: an
RTTI/vtable record banked as plain deadstrip is never compared against the cartridge, and the
existing guard is conditioned on homes, which a coined class name never has. Passes on the
main × #2004 merge tree today (zero such rows), so it costs nothing:

elif disposition == "deadstrip" and symbol[:4] in ("_ZTI", "_ZTS", "_ZTV"):
    errors.append(f"{source}: {symbol} is a vtable/typeinfo record banked as a plain "
                  f"deadstrip, which is never compared against the cartridge. Drop the "
                  f"row, or rename the class to the cartridge's RTTI spelling so it "
                  f"resolves as deadstrip-data")

rombuild.py:736 and tubuild.apply_compiler_only_policy (~:2101) — the intact bypass at
rombuild.py:343–353 returns before the rombuild copy runs, so a one-sided patch leaves the
production path uncovered. While there: apply_compiler_only_policy's docstring says it
"leaves the proof to the production build." On the intact path it is the production build.

5. Split the tiers.py scoring change out. score_member / per-member converted() /
tiers_ratchet re-keying to path#symbol is a change to a published metric riding inside
a TU-infrastructure PR. I checked it and it will not trip the ratchet — per-member scores
are monotonically ≥ the file-wide score, and zero banked paths on the merged baseline are
multi-@symbol, so the lossless-upgrade route never fires. CONVERTED will rise, which is
arguably the right fix for TU-promotion dilution. But the standing rule is that a tiers.py
change lands last, and bundling it here means a revert of the metric change is a revert of
the infrastructure. Please pull it into a follow-up.

6. _current_or_bootstrapped_intact_baseline shells out to tubuild.py linkcheck --baseline --clean because BASELINE_LINK is gitignored, so a fresh worker always bootstraps
— a second full link + ROM build launched from inside a build, into the shared build/ tree.
Fail-closed, but that is this project's known concurrent-build hazard. Wants a loud log line
and ideally a lock.

Confirmed good

  • complete_entries_text is fixed here, correctly. complete_entry_sections_text
    accumulates entry_sections and flushes all of them instead of keeping only the last. That
    closes the latent 0/11077 defect the validator consumes — nothing further needs to land
    separately.
  • attribution.json satisfies KEY UNION: 3 added, 0 dropped, 0 changed against the
    merge-base; 901 overrides = main's 898 + 3. Promote Kurumajiku as a compiler-owned class TU #2004 adds 5 more and drops none.
  • PathLift resolves on merge. Both branches still carry _ZTI8PathLift as plain
    deadstrip; main rewrote that file and the merge takes main's corrected
    _ZTI16dPathLiftActor_c as deadstrip-data. Nothing to do, but note the branch alone is
    internally inconsistent — it renamed the src/ files without updating the manifest.

Landing order

#2004 is purely dependent (production_mode: intact-object is inert without
intact_tu_policies) and its own content is clean. Because --delete-branch on a parent
closes the child rather than retargeting it:

  1. gh pr edit 2004 --base main
  2. gh pr merge 2001 --merge (plain --merge, no --delete-branch)
  3. push main into Promote Kurumajiku as a compiler-owned class TU #2004's branch, re-validate, merge Promote Kurumajiku as a compiler-owned class TU #2004
  4. delete tools/tu-control-policy-0830 by hand afterwards

@andrewboudreau

Copy link
Copy Markdown
Collaborator Author

Gatekeeper re-review — still blocked, but for a new reason, and it is not visible on this PR

Head has not moved since my 16:33 review (ef16a307, force-pushed 16:26). The 15:42 items are genuinely closed — I re-checked all five and will not re-litigate them. What changed is main, and it changed underneath this branch in a way this PR's own green cannot see.

The blocker: merging this into current main turns the converted-ratchet gate red

Built read-only with git merge-tree --write-tree origin/main ef16a307 (clean, tree 15d94188), exported, and ran the gate on both trees:

origin/main         EXIT 0   CONVERTED ratchet PASS  baseline 2553 current 2550 (4 moved into a promoted TU)
origin/main x #2001 EXIT 1   CONVERTED backslide: 4 banked file(s) no longer pass all 5 criteria

The four are src/_ZN7dBase_c{D0Ev,D1Ev,5SpawnEjP7fBase_cii,18AfterInitResourcesEj}.cpp, each reported as:

MOVED -- absorbed into src/actors/ActorDerived.cpp by TU promotion (arm9/ActorDerived); member regression: src/actors/ActorDerived.cpp#func_02013edc fails: Real function name

Root cause. src/actors/ActorDerived.cpp landed on main in #1995 — one of the 16 commits this branch is behind. Under the new per-member scorer, 'src/actors/ActorDerived.cpp' in scores is now False (multi-member sources no longer get a file-wide score), so the move-rescue falls through to dest_member_ids.issubset(current) at tools/tiers_ratchet.py:405-412. Four of five members are in current; func_02013edc is not (real_name). One non-banked member vetoes the rescue of four banked ones.

This also refutes the "per-member scores are monotonically >= the file-wide score" claim in the 15:43 follow-up: real_name inverts, because file scoring judges the path (ActorDerived.cpp — a real name) while member scoring judges the symbol (func_02013edc — not one).

Preferred fix. The mapping already exists in the manifest: config/tu_manifest.d/arm9/ActorDerived.json gives functions[].legacy_source <-> functions[].symbol. Rescue a moved path against its own member identity rather than the whole destination:

# instead of: dest_member_ids.issubset(current)
sym = legacy_symbol_for(rel, dest)          # from the manifest's legacy_source
if dest in current or (sym and f"{dest}#{sym}" in current):

All four banked paths then rescue, and a member that genuinely regresses still fails.

Please do not take the fallback (tiers_ratchet.py --update after restacking). That writes four permanent rows into config/converted-backslide-exceptions.jsonl for a regression that never happened — the exact cost we refused to make #1993 pay.

Why the green on this PR is not evidence

ratchet pass here ran against a base that predates #1995. Separately, #2013 landed tools/source_coverage.py and .github/workflows/source-coverage.yml in those same 16 commits; that workflow is path-filtered to config/**/delinks.txt and src/**, so it has never run on this PR or on #2004 and will not until they target current main. I verified by hand that it would pass — but untested green is not green.

What I verified is clean — no erosion anywhere

I recomputed the enrolled-range union directly from every delinks.txt at three refs:

ref complete entries .text .init text+init union
origin/main 11025 1,941,060 125,712 2,066,772
#2001 ef16a307 11029 1,941,060 125,712 2,066,772
#2004 43243bfb 11025 1,941,060 125,712 2,066,772

Identical, and matching the validator's absolute row on both PRs. Also confirmed: this PR touches no delinks.txt, no symbols.txt, no src/, no include/; its only config change is converted-baseline.json (one path -> two member identities, 2553 -> 2554), and main's baseline is byte-identical to the merge-base's, so nothing drops on merge. Zero compiler_only_output rows with a plain deadstrip on _ZTV/_ZTI/_ZTS exist anywhere in the merged manifest tree — the PathLift concern resolves on merge.

Note also that #2019 landed the ^_ZT[ISV] plain-deadstrip refusal in both rombuild.py and tubuild.apply_compiler_only_policy while this branch sat. Recommendation 4 from my 16:33 review is now on main — please take it in the restack rather than re-implementing it.

Still open from 16:33

  • --no-rom / --profile mods skip the intact ROM comparison — half-addressed. The tu_promote.py hint is fixed (:396 no longer prints --no-rom); the code path is not (rombuild.py:1283 returns before the comparison at :1306; :1204 writes {"status": "not-applicable"} for non-stock profiles). Severity is lower than I first stated — rombuild_check.analyze filters allowed_mod_ranges on rel.startswith("mods/"), so under stock the 106/106 comparison is not relaxed by intact TUs even with --no-rom. Still worth the three-line refusal, and there is no test for it.
  • linkcheck_symbol_verdict unconditional return True — not addressed, and now pinned by a test (tubuild.py:3711-3720; test_tubuild.py asserts linkcheck_symbol_verdict(True, False, None)). _strict_baseline never reads it, so it is not a correctness hole — but it is a gate that prints a pass without running. Restore the real result or rename it so it cannot read as one.
  • _strict_baseline relaxes matchesStockRom (tu_production.py:176) — main's rule was is not True alone. The compensating check exists; the PR body's dual claim is still overstated.
  • Splitting tools/tiers.py out — given that the metric change is now the thing breaking a live gate, the case for landing it separately is stronger, not weaker.
  • Bootstrap launches a nested build into shared build/ — no log line, no lock.

Test wiring, for the record

test_tu_production 17 OK, test_tu_promote 9 OK, test_rombuild 35 OK/3 skipped, test_tiers_ratchet 12 OK, test_rombuild_check 7 OK — all run unconditionally, so the new refusal tests are not hiding behind the _toolchain() false-green. But test_objisolate is 34/34 SKIPPED without the compiler, and no CI workflow runs any of these modules: only test_check_dead_references, test_check_python_names, test_source_coverage and the src_tu compile gate are wired. The 104-line objisolate.py change is covered only by tests that skip in CI. Worth a follow-up PR wiring these into a workflow; not a blocker here.

complete_entries_text is fixed, correct, and covered by an ungated test (test_intact_entry_counts_code_members_and_nontext_separately). Nothing further needs to land separately for it.

Verdict

Restack, do not re-cut. The infrastructure is sound and the earlier objections are closed. Order:

  1. Rebase onto origin/main (16 behind).
  2. Apply the legacy_source-keyed move-rescue; python tools/tiers_ratchet.py --check must exit 0 on the rebased tree.
  3. Take Refuse an RTTI/vtable record banked as a plain deadstrip #2019's ^_ZT[ISV] refusal from main rather than re-adding it.
  4. Refuse --no-rom / non-stock when intact_tus is non-empty (or make intact_rom_comparison unconditional), with a test.
  5. Fix or rename linkcheck_symbol_verdict and unpin the test.
  6. Reproduce 106/106 under main's rombuild.py/rombuild_check.py — this PR's green was produced by its own rewrite of those tools, and since Validation now runs the pull request's own tools/ #1994 the validator no longer restores tools/ from base.

Stays a draft until 1-5 land. #2004's content is clean and needs no changes; its landing sequence is unchanged and I have posted it there.

@andrewboudreau

Copy link
Copy Markdown
Collaborator Author

The move-rescue fix, written and proven — apply this verbatim if you want

The branch has been static for an hour and this is the only thing standing between it and a merge, so rather than leave you to derive it from my last comment I built and tested it. Nothing was pushed to your branch — this is a patch for you to take or reject.

Reproduction, against current main

The branch is now 43 behind. Merge result built read-only (git merge-tree --write-tree origin/main ef16a307 -> tree a7e16f0d), exported, gate run in the export:

EXIT=1
CONVERTED backslide: 4 banked file(s) no longer pass all 5 criteria
  src/_ZN7dBase_c18AfterInitResourcesEj.cpp
  src/_ZN7dBase_c5SpawnEjP7fBase_cii.cpp
  src/_ZN7dBase_cD0Ev.cpp
  src/_ZN7dBase_cD1Ev.cpp
      MOVED -- absorbed into src/actors/ActorDerived.cpp by TU promotion (arm9/ActorDerived);
      member regression: src/actors/ActorDerived.cpp#func_02013edc fails: Real function name
baseline 2554   current 2571   (+21 gained, -4 lost)

The manifest makes the defect plain — arm9/ActorDerived maps each legacy path to exactly one symbol:

legacy=src/_ZN7dBase_cD1Ev.cpp                   symbol=_ZN7dBase_cD1Ev
legacy=src/_ZN7dBase_cD0Ev.cpp                   symbol=_ZN7dBase_cD0Ev
legacy=src/func_02013edc.c                       symbol=func_02013edc      <-- never banked
legacy=src/_ZN7dBase_c5SpawnEjP7fBase_cii.cpp    symbol=_ZN7dBase_c5SpawnEjP7fBase_cii
legacy=src/_ZN7dBase_c18AfterInitResourcesEj.cpp symbol=_ZN7dBase_c18AfterInitResourcesEj

func_02013edc fails Real function name — and it failed it before the promotion too, as src/func_02013edc.c. It was never in the baseline, so it cannot have regressed. But dest_member_ids.issubset(current) asks whether every member of the destination is CONVERTED, so that one never-banked member vetoes the rescue of four that are.

The fix

Rescue a moved path against its own member identity. Three hunks, additive — it can only ever rescue more, never fewer, and a member that genuinely regresses still fails because its own identity drops out of current:

--- a/tools/tiers_ratchet.py
+++ b/tools/tiers_ratchet.py
@@ -203,6 +203,33 @@
     return moves
 
 
+def promoted_move_symbols(root=None):
+    """`legacy_source` -> the symbol that path defined, for every PROMOTED TU entry.
+
+    `promoted_moves` answers WHERE a banked path went; this answers WHICH function it
+    took with it. The rescue in `partition_missing` needs the second one: a promotion
+    is lossless for a given banked path when THAT path's own code is still CONVERTED
+    at its new address, not when every unrelated member of the destination is. The two
+    are different questions and only the manifest can tell them apart.
+    """
+    out = {}
+    try:
+        data = tu_manifest.load(root)
+    except (OSError, ValueError):
+        return out
+    for entry in data.get("entries") or []:
+        if entry.get("status") != "promoted":
+            continue
+        dest = entry.get("promoted_source")
+        if not dest:
+            continue
+        for fn in entry.get("functions") or []:
+            legacy, symbol = fn.get("legacy_source"), fn.get("symbol")
+            if legacy and symbol and legacy != dest:
+                out[legacy] = symbol
+    return out
+
+
 def source_text(rel):
@@ -389,6 +416,7 @@
     if ownership is None:
         ownership = tiers.srcpath.source_definition_index()
+    move_symbols = promoted_move_symbols()
     absorbed_clean, backslid = [], []
@@ -407,7 +435,9 @@
             } if len(dest_members) > 1 else set()
+            own_symbol = move_symbols.get(rel)
             if (dest in current
+                    or (own_symbol and f"{dest}#{own_symbol}" in current)
                     or (dest_member_ids and dest_member_ids.issubset(current))):
                 absorbed_clean.append(rel)
                 continue

Proof it works, on the merge result

EXIT=0
CONVERTED ratchet PASS   baseline 2554   current 2571   (+21 gained, not yet banked)   (4 clean ownership transition(s))

python -m unittest tools.test_tiers_ratchet   -> Ran 12 tests   OK
python -m unittest tools.test_tiers           -> Ran 29 tests   OK

No exception rows. No --update. The four paths reclassify from backslid to absorbed_clean, which is what they always were.

Two things to add on top

  1. A test. partition_missing now has a branch nothing covers: a promoted move whose own symbol is CONVERTED while a sibling member of the same destination is not. That is precisely the case that broke, so it should be the case that is pinned. test_tiers_ratchet currently passes with and without the fix.
  2. The diagnostic line is now stale on a PASS. With the fix applied the run still prints member regression: ...#func_02013edc fails: Real function name under each rescued path, immediately below a PASS header. Harmless but confusing — the reason string is computed before the rescue decision. Worth recomputing it, or suppressing it for absorbed_clean entries.

Where that leaves the PR

Items 1-5 from my 17:25 review still stand; this closes item 2 and hands you the code. #2019's ^_ZT[ISV] refusal is on main — take it in the restack rather than re-implementing. #2004 is content-clean and needs nothing but gh pr edit 2004 --base main before this merges, and do not use --delete-branch on this PR while #2004 is stacked on it: GitHub closes the child rather than retargeting it.

Verified read-only throughout — nothing was pushed, and the exported trees were built with git merge-tree/checkout-index in scratch. Say the word and I will open this as its own PR against main instead, which would also let it land ahead of the restack.

@andrewboudreau

Copy link
Copy Markdown
Collaborator Author

Correction and an addition to the fix I posted

Two things, one of them my error.

1. The function is classify_missing, not partition_missing. I named it wrong in
the patch write-up above. Same file, same lines; the hunks apply as given. Here is the
diff regenerated against your current head ef16a307 so there is no ambiguity:

--- a/tools/tiers_ratchet.py
+++ b/tools/tiers_ratchet.py
@@ -372,6 +372,26 @@
     return "; ".join(tiers.CRITERION_LABEL[k] for k in failed)
 
 
+
+def promoted_move_symbols(root=None):
+    """`legacy_source` -> the symbol that path defined, for every PROMOTED TU entry."""
+    out = {}
+    try:
+        data = tu_manifest.load(root)
+    except (OSError, ValueError):
+        return out
+    for entry in data.get("entries") or []:
+        if entry.get("status") != "promoted":
+            continue
+        dest = entry.get("promoted_source")
+        if not dest:
+            continue
+        for fn in entry.get("functions") or []:
+            legacy, symbol = fn.get("legacy_source"), fn.get("symbol")
+            if legacy and symbol and legacy != dest:
+                out[legacy] = symbol
+    return out
+
 def classify_missing(missing, current, tracked, moves, ownership=None):
     """Split banked identities into clean ownership transitions and backslides.
 
@@ -389,6 +409,7 @@
     """
     if ownership is None:
         ownership = tiers.srcpath.source_definition_index()
+    move_symbols = promoted_move_symbols()
     absorbed_clean, backslid = [], []
     for rel in missing:
         members = ownership.get(rel) or []
@@ -407,7 +428,9 @@
             dest_member_ids = {
                 f"{dest}#{symbol}" for symbol in dest_members
             } if len(dest_members) > 1 else set()
+            own_symbol = move_symbols.get(rel)
             if (dest in current
+                    or (own_symbol and f"{dest}#{own_symbol}" in current)
                     or (dest_member_ids and dest_member_ids.issubset(current))):
                 absorbed_clean.append(rel)
                 continue

2. The fix's validity depends on #2023 dropping an unrelated baseline swap. #2023
(TU promotion slice 1) currently carries a converted-baseline.json change it did not
cause: tiers_ratchet --update is a whole-tree regeneration, and it swept in
src/actors/ActorDerived.cpp as a banked whole-file row while removing the four
src/_ZN7dBase_c*.cpp paths. That is precisely the arm9/ActorDerived entry your new
scorer trips over.

Composed three ways, measured, not predicted:

tree verdict
main + #2001 (unpatched) RED — 4 MOVED paths lose the rescue
main + #2023 as-is + #2001 (unpatched) RED — src/actors/ActorDerived.cpp IDENTITY UPGRADE INCOMPLETE — a different failure the patch above does not fix
main + #2023 swap reverted + #2001 (unpatched) RED — 4 MOVED paths
main + #2023 swap reverted + #2001 + the patch above EXIT 0

So: the patch is right, and it is sufficient provided #2023 lands without that swap. I
have asked for the revert on #2023 and shown there that #2023 stays green without it
(PASS baseline 2549 current 2546). If #2023 lands with the swap instead, you will need
a second change here — banking src/actors/ActorDerived.cpp as a whole-file row is an
over-credit under per-member scoring, because that file's members include
func_02013edc, which is not a real function name. Main's file-wide scorer judges the
path and passes it; yours judges the symbol and correctly does not. Better that
the row never gets banked than that you unwind it.

Nothing else in my 17:25 review changes. Items 1 and 3-6 are still open.

@andrewboudreau

Copy link
Copy Markdown
Collaborator Author

Blocking: this PR is green everywhere and red on the tree that doesn't exist yet

Head ef16a307356c, base main at deb1e1e7a. GitHub says MERGEABLE, this PR's own checks are green, and converted-ratchet is green on origin/main. The merge result fails converted-ratchet.

CONVERTED backslide: 4 banked file(s) no longer pass all 5 criteria
  src/_ZN7dBase_c18AfterInitResourcesEj.cpp
  src/_ZN7dBase_c5SpawnEjP7fBase_cii.cpp
  src/_ZN7dBase_cD0Ev.cpp
  src/_ZN7dBase_cD1Ev.cpp
    MOVED -- absorbed into `src/actors/ActorDerived.cpp` by TU promotion;
    member regression: `src/actors/ActorDerived.cpp#func_02013edc` fails: Real function name

baseline 2554   current 2571   (+21 gained, -4 lost)

Nothing in CI would have caught this. pr-validate.yml is pull_request_target:-only and no ROM build runs on a main push, so no gate ever evaluates a merge result. I found it by exporting the merge tree and running the gates on it and on an identical origin/main tree, then diffing the verdicts.

Why it fires

ActorDerived.cpp landed on main in 18cf39a9d, which this branch is behind. That commit absorbed those four dBase_c sources into one promoted TU. Main's config/converted-baseline.json still carries all four rows — for files that no longer exist — and main stays green because today's classify_missing() accepts a clean TU absorption without looking inside the absorbing file.

This PR changes classify_missing() to check the absorbing file per member. That is the right change and it is working correctly: src/actors/ActorDerived.cpp really does contain func_02013edc, which really does fail the "Real function name" criterion. The stricter check is finding a genuine un-named function that the looser check was papering over.

So this is not a bug in your patch. It is your patch doing its job on a file that landed after you branched.

The disposition is yours, and I'd take the first

Option A — name func_02013edc. It is one function in one promoted TU. Naming it clears the criterion at the source, keeps the new per-member check at full strength, and leaves no exception row behind. This is the outcome the PR is arguing for.

Option B — add a converted-backslide-exceptions.jsonl row with real_function_name in the reason. Cheaper now, but it banks a permanent credit cost for something Option A fixes outright, and TU promotion is already the largest consumer of that file (23 of 37 rows once the current queue lands — see #2029).

Option C — soften the per-member check. I'd argue against it. The check is correct; weakening it to get this PR green trades away the thing the PR is for.

Whichever you pick, merge main in first — the failure is only reachable against a base that has ActorDerived.cpp, so it cannot be verified from where this branch currently sits.

Two other things

complete_entries_text last-section fix is in this PR and I want it landed — it is latent at 0/11077 today and arms on the first multi-section promotion, and the validator consumes that field. That raises the stakes on getting this branch unstuck rather than letting it sit.

#2004 does not actually merge. gh reports it MERGEABLE; that is stale against a base that moved. There are real content conflicts in attribution.json and notes/cpp-tu-current-state.md. Since #2004 is stacked on this branch, it needs resolving after this one moves — and note that when #2001 merges I will use a plain --merge with no --delete-branch, because deleting the branch would close #2004 rather than retarget it.

I am holding both as drafts until #2001's merge result is green. Ping me when main is merged in and a disposition is picked and I will re-gate.

@andrewboudreau

Copy link
Copy Markdown
Collaborator Author

func_02013edc identified — this clears the blocking finding

I ran the identification rather than loosening the gate. The per-member CONVERTED
criterion is finding a real defect here, and relaxing it would trade away the
point of this PR.

Recommended name: dBase_c::SpawnRoot(u32 actorID, u32 param, int groupType)
_ZN7dBase_c9SpawnRootEjji @ 0x02013edc, size 0xc.

// include/dBase_c.h, next to Spawn
static fBase_c *SpawnRoot(u32 actorID, u32 param, int groupType);

// src/actors/ActorDerived.cpp -- MUST STAY LAST IN THE FILE
fBase_c *dBase_c::SpawnRoot(u32 actorID, u32 param, int groupType)
{
    return func_02042fe4(actorID, param, groupType);
}

Compiled and byte-verified in isolation (2004/b56, strict reloc destinations): all
five members MATCH — D1 0x24, D0 0x38, SpawnRoot 0xc, Spawn 0xc,
AfterInitResources 0x34 — and the emitted section order is exactly ROM order, so
functions_occur_in_expected_order still holds. The rewritten caller
dScene_c::SpawnIfNecessary also matches at 0x94 bytes.

Why this name

func_02013edc is a 0xc interworking veneer onto func_02042fe4. Its twin at
0x02013ee8 — already named dBase_c::Spawn — is the identical shape onto
0x02042ffc. Those two callees are the createRoot/createChild pair:

  • func_02042ffc(id, parent, …) does add r1,r1,#0x14parent->manager.sceneNode
  • func_02042fe4(a, b, c) does mov r1,#0the same creator with the parent node
    hard-coded NULL
    , i.e. "create at the root of the process tree"

Both land in func_02043098, which stashes the request through the four globals that
the already-recovered src/_ZN7fBase_cC2Ev.cpp reads back.

ROM-proven: address/size/veneer target, owner class dBase_c (contiguous TU run
0x02013e80..0x02013f28, bracketed by dBase_c::D0 and dBase_c::Spawn, externally
referenced so not file-static), arity 3 (r3 is dead on entry and the sole caller sets
only r0–r2), returns the created object (the caller never rewrites r0), and static/
non-virtual.

Judgement, and labelled as such: the spelling itself. The ROM stores no method
names — a scan of arm9_dec.bin finds only the RTTI strings 7dBase_c/7fBase_c.
Like Spawn, this is a coined Tier-D label. Parameter types are unprovable: a pure
forwarder is type-blind, and (u32,u32,int), (u32,u32,u8) and (u16,u32,int) all
byte-match identically. Pick a spelling; don't claim it.

Runner-up worth knowing: the later EAD framework calls this exact function
createRoot. Rejected because it is lowerCamelCase in a PascalCase tree, and because
importing a name from a later game is inference, not evidence — the failure mode
notes/symbol-name-provenance.md was written after (#1226). SpawnScene over-fits the
single caller; the body contains nothing scene-specific.

That this is sufficient

tools/tiers.py::_real_name_for_symbol rejects a symbol only when a ::-component
matches PLACEHOLDER_PART; func_02013edc hits the ^func_0[0-9a-f]{7}$ alternative.
Any non-placeholder name clears it. Running the real predicate over the file:

member real_name
dBase_c::~dBase_c (D1, D0) PASS
func_02013edc FAIL
dBase_c::Spawn PASS
dBase_c::AfterInitResources PASS

One failure, one criterion, no hidden second offender — the other four criteria are
file-scoped and already pass. Re-scored after the change: 5/5 on every member.

Files that must move in the SAME commit

  • include/dBase_c.h (declaration; the comment block also spells the raw address)
  • src/actors/ActorDerived.cpp:28
  • src/_ZN8dScene_c16SpawnIfNecessaryEv.cpp:14,23
  • src_tu/scene/Scene.cpp:761,770 — the pre-push hook compiles src_tu for every
    branch, so if this lags, every push on every branch is refused
  • config/arm9/symbols.txt:463
  • config/tu_manifest.d/arm9/ActorDerived.json:42 plus the prose at :12/:104 that
    currently asserts the veneer is unnamed
  • attribution.json:755 — re-key …ActorDerived.cpp#func_02013edc
    …#_ZN7dBase_c9SpawnRootEjji, or that member's credit is silently dropped

config/arm9/delinks.txt needs no change — the file is delinked by address range
(0x02013e80–0x02013f28), not by symbol.

Merge-order note

This PR no longer merges into current main (2eb1d87fe) — real conflicts in
attribution.json and config/converted-baseline.json from the TU-promotion lane that
landed ahead of it. Resolve those as a three-way merge, not a key union: a
promotion branch deletes keys, and union resurrects rows for files that no longer
exist. Nothing goes red when you get that wrong, so check it by hand — every key naming
a src/** path must resolve to a file that exists in the merge result.

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