port: make the host build link against current main, and gate it - #1147
port: make the host build link against current main, and gate it#1147andrewboudreau wants to merge 6 commits into
Conversation
✅ PR validation — Passednoverify: no source/build-data changes in this PR Each changed |
|
Rebased onto All five commits replayed with no conflicts. Checked the one place a clean rebase can still be wrong:
What I could not run: |
a1384f3 to
6e210ee
Compare
|
Correction to my note above: the rebase alone was not enough — this PR could not be pushed at all, and its own gate is why.
This is a good illustration of the PR's own thesis. Nothing renamed, no reference went stale,
|
|
Rebased onto Re-checked the one file where a clean rebase can still be wrong,
|
6e210ee to
22c5924
Compare
|
I found MSVC on this machine and ran your link gate for the first time. It works, and it says the port does not build against current main. Your PR's premise no longer holds — through no fault of the PR. I have been reporting "cannot check, no C++ compiler" for three sweeps. That was wrong: VS 18 Community with the VC tools is installed, just not on What the gate foundThe unresolved symbols fall into four groups:
And a newer failure mode worth naming separately —
One thing I did fix, and could not pushThe gate could not even reach the link phase: That is a clean illustration of your thesis: -static_assert(offsetof(Timer, unk_004) == 4, "Timer +0x4");
+// Timer's first member is ONE s64 at 0x0, not two words at 0x0 and 0x4 --
+// include/Timer.h says why (seven history accesses read it as s64; the paired
+// 4-byte ROM accesses are what a 64-bit value looks like with no 64-bit
+// register). Asserting its start and the field that follows still pins it to
+// 0x0..0x8, which is what this check was really for.
+static_assert(offsetof(Timer, unk_000) == 0, "Timer +0x0");
+static_assert(sizeof(((Timer *)0)->unk_000) == 8, "Timer tick count is 64-bit");
static_assert(offsetof(Timer, mIsRunning) == 8, "Timer +0x8");
@@
- CHECK(t.mIsRunning == 0 && t.unk_000 == 0 && t.unk_004 == 0);
+ CHECK(t.mIsRunning == 0 && t.unk_000 == 0); /* one s64 covers 0x0..0x8 */I did not push it. With The gate is right, so I am not going to Worth knowing before you start: that refusal is a bootstrap trap. Once anyone configures StatusRebase from earlier is intact — six commits on |
|
Follow-up triage on the link failure — it is smaller and more mechanical than "21 unresolved symbols" makes it sound. All 18 named symbols already have real implementations in
And the heap family is already in The first thing to fix is the collision, and it is our doing, not yours. Every one of those is now a real C++ method in
On the numbers: Still not pushing anything: your hook correctly refuses while the link is broken, and which of these to fix by extending a slice versus shrinking the shim is a design call in your workstream. Happy to take it on if you want it — say so and I will open it against |
Nothing in the decomp toolchain compiles or links port/, so a src/ or include/ change that shifts a symbol's SPELLING breaks the host build silently. #1049 (decl_*.h extern "C") did exactly that on 2026-08-03 and took 13 of the 14 gate binaries with it; it went unnoticed until someone tried to build. Two more landed on top of it since. Four breaks fixed, all the same family -- a HAL definition kept a spelling the src/ side stopped using: - func_02059650, NestedHeapIterator::Init, func_0206e2f8, data_0209b44c: defined at C++ linkage while decl_*.h now emits C-linkage references. SharedFilePtr::Release needed a real __thiscall method; _ZTV8Platform needed storage. - _Z14ApproachLinearRiii: shims.cpp dropped this bridge because the symbol is "exactly what a host C++ build emits for ApproachLinear(int&, int, int)". That is the ITANIUM mangling. MSVC emits ?ApproachLinear@@YAHAAHHH@Z, so the bridge was load-bearing on Windows. Restored, with the GCC/Clang case noted. - GX::LoadTexPltt: migrated to namespace-style C++ as "language-mode migration only ... nothing outside this file can shift". True for the ROM link, where the filename is the symbol either way; false here, where the name went from hand-spelled to MSVC-mangled. The identical bridge for LoadTex already existed at model_host.cpp:183. - data_020a60b0: reached at C++ linkage while the storage sits in an extern "C" block. ALIASED, not redefined -- a second definition links cleanly and then leaves LoadTexPltt writing to a different VRAM base than the budget code reads. Both of the last two shipped with a premise that is correct for the ROM link and wrong for the host. That is the systematic gap, so this adds the check that closes it: - port/tools/port_linkcheck.py -- builds the port and fails if it does not link. ~2s incremental, skips loudly without MSVC rather than reading as a pass. Passes ninja -k 0: the default -k 1 stops at the first failure, which reported 3 breaks when there were 4 and turned diagnosis into fix-one-discover-more. - port/tools/port_evidence.py -- how much of what the port compiles is proven to be the game's code, by enrollment in the byte-exact ROM build. 19 files are not; --ratchet fails only when that set grows, matching the merge gate's must-not-regress shape rather than being red on day one. build-port.cmd and host_frontier.py now locate MSVC via vswhere: the hardcoded 2022 BuildTools path no longer exists on this machine. Verified on main @ ee0c07f: 14/14 binaries link, 14/14 gates pass (455 models rendered, 473 animation pairs, 0 faults), ratchet clean at 19. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017tYjz5v5R228Kh7YCH4gkR
…join Two defects in the tools the previous commit added, both found by running them against a real break rather than reading them. port_linkcheck.py capped its diagnosis at keep[:40] with no marker that anything was dropped. On the actual failure that is 40 lines of 114, and the truncated report reads as "6 of 14 binaries broke" when 13 did -- the same fix-one-discover-more the -k 0 change was made to end, reintroduced four lines below the comment explaining why it must not be. Group instead: distinct unresolved symbols and failing binaries are both small once deduplicated (11 and 13 here) and are never capped; only the open-ended tail is, and it prints how much it dropped and how to get the rest. A totals footer keeps a capped run honest. Raw line-dedupe would not have worked -- each target compiles its own objects under CMakeFiles/<target>.dir/, so identical failures carry different path prefixes. The symbol capture anchors on " referenced in function" rather than whitespace. MSVC prints C++ linkage as "char data_020a4d38" (?data_020a4d38@@3da), so a \S+ capture keys every such symbol on the token "char and merges distinct ones -- undercounting in exactly the way this report exists to prevent, in a tool written for a C-vs-C++ linkage outage where both spellings are guaranteed to appear. port_evidence.py joined enrolled objects on Path(rel).stem, but the enrolled keys keep their subdirectory (engine/fader/_ZN15FaderBrightness...), and a stem never contains a slash. No file under a src/ SUBDIRECTORY could ever be proven. 138 of the 9,149 enrolled objects live in subdirectories; it recorded 7 byte-exact FaderBrightness files as unproven debt and shipped that in the committed baseline. Key on the src-relative path instead. No bare-stem fallback for non-src/ paths: that fallback is dead code today and would let a bare name collide with an unrelated top-level object and report a file proven on another file's evidence. No such collision exists now -- the point is that it cannot appear later. Baseline regenerated, 19 -> 12. Also refuse --gate together with --ratchet or --update-baseline. --gate narrows the file set before the unproven set is computed, so `--update-baseline --gate 9` would write one gate's files over the whole ledger and fail much later, as a REGRESSION blamed on whoever next runs a full ratchet. Verified: port/hal/ reverted to main -> 13 binaries, 11 symbols, exit 1; restored -> 14/14 link, exit 0. Ratchet green at 12, unknown bucket empty across all 14 gates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgWTKakPywZzfdVbC6YLAu
The two tools this branch added were never run by anything. Nothing in the repo referenced either one -- no workflow, no validation script, no hook, no doc; only each other. On top of that, port_linkcheck returned 0 when it SKIPPED, so on any machine or CI image without 32-bit MSVC it would have read green forever while checking nothing. That is the same failure the branch exists to fix, one level up: #1049 went unnoticed for two days because nothing built port/. A gate nobody runs, that cannot fail when it does not run, is not a gate. Exit contract, now shared by both tools: 0 checked, passed 1 checked, FAILED 2 could not check -- "this did not run" --require collapses 2 into 1, for CI or any caller that must not go green without the check actually running. port_evidence.py raises CannotCheck instead of sys.exit for missing ROM artifacts, a non-byte-exact build on record, and a missing baseline. All three used to exit 1, which is indistinguishable from "someone compiled an unproven file into the port" -- and the two demand opposite responses: re-run the ROM build, versus stop the merge. port_linkcheck.py also stops cold-configuring by default. Building 14 binaries from scratch is minutes, which is fine when asked for and wrong to spring on someone mid-`git push`; unconfigured is now "could not check", and --configure opts in. This mirrors what the hook already does for check_references.py: run only when the inputs exist, and say nothing was checked rather than implying a pass. tools/hooks/pre-push runs both in the every-push section, next to port_refcheck. They read the exit CODE rather than grepping stderr for phrases the way the check_references block above them has to -- an exit code cannot drift out of sync with a reworded message. Verified, in a worktree, all six paths: linkcheck no toolchain / unconfigured -> 2; with --require -> 1 clean tree -> 0 (14/14); hal reverted to main -> 1 evidence no artifacts -> 2; with --require -> 1; ratchet -> 0 at 12 hook inputs absent -> skips loudly, exit 0 broken linkage -> refuses, exit 1 (caught by the cheap default pair alone: 2 binaries, 5 symbols) Note for anyone who already installed the hook: it is a template, so re-copy it (cp tools/hooks/pre-push .git/hooks/pre-push). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgWTKakPywZzfdVbC6YLAu
The hook only documented `cp tools/hooks/pre-push .git/hooks/pre-push`, so the commit before this one told people to re-copy it. That is right for the cp method and unnecessary for the other one: this repo is configured with core.hooksPath=tools/hooks, where git runs the tracked file directly and the new checks went live on the very push that added them. Worth writing down because the failure mode of the cp method is silent -- a stale copy still runs, just without whatever check was added since, which is the same "looks like it is being checked, is not" shape the port gates in this branch exist to close. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgWTKakPywZzfdVbC6YLAu
Every path inside the `if args.ratchet:` block returned, and the --strict
check sits below it, so passing both ran only the ratchet. --strict was not
rejected and not warned about -- it silently did nothing, which is the worst
of the three ways to handle it: the caller reads the exit code believing both
gates ran.
--strict 1
--ratchet 0
--ratchet --strict 0 <- strict ignored; now 1
The two are not in conflict. --strict is the absolute floor, --ratchet the
derivative; "did not regress AND never above zero" is coherent, and becomes
the natural gate once the ledger empties. So the ratchet's OK path falls
through to the strict check rather than returning.
A regression still short-circuits with its REGRESSION list, so the more
actionable diagnosis is still the one printed. --update-baseline continues to
return without consulting --strict, which is deliberate -- it writes the
ledger rather than gating on it -- and now says so.
Also print an explicit "FAILED --strict" verdict. With both flags the last
line printed was "ratchet OK", and exiting 1 immediately after that reads as
a bug in the tool rather than a verdict from the other gate.
Today --ratchet --strict therefore always fails, because 12 unproven files
exist. That is honest rather than useful, and it is why --ratchet was added
in the first place (--strict alone is red from day one). The combination
earns its keep when the debt reaches zero; until then a flag that fails
loudly still beats one that does nothing.
Verified: --strict 1, --ratchet 0, --ratchet --strict 1, no-flags 0,
missing artifacts 2 (could-not-check still outranks both), and a simulated
baseline regression exits 1 still naming the files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgWTKakPywZzfdVbC6YLAu
…vintages
The evidence ratchet crashed on every run, which meant this PR could not be
pushed at all -- its own pre-push hook installs the ratchet, and the ratchet
aborted with `AttributeError: 'str' object has no attribute 'get'`.
`build/rombuild-eligibility.json` gained a `{commit, dirty, files}` wrapper after
this branch was written; it used to be a bare list. Iterating the stamped shape as
a list yields its three KEYS, and the first thing done to a row is `.get`, so it
dies on a string instead of saying the format moved.
`tools/eligible.py:load_report` already reads both shapes and exists precisely so
consumers do not have to care. Using it rather than adding a third opinion about
the file.
`python port/tools/port_evidence.py --ratchet` now runs: OK, 2 known unproven
files against a baseline of 12. `port_refcheck` 403/403.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AXm5k53WFPjCYcDHRDX3x
22c5924 to
0fee0a6
Compare
Nothing in the decomp toolchain compiles or links
port/, so asrc/orinclude/change that shifts a symbol's spelling breaks the host build silently. #1049 did exactly that on 2026-08-03 and took 13 of the 14 gate binaries with it. It went unnoticed until someone tried to build. Two more landed on top of it since.The four breaks
All the same family: a HAL definition kept a spelling the
src/side stopped using.func_02059650,NestedHeapIterator::Init,func_0206e2f8,data_0209b44cdecl_*.hnow emits C-linkage references (#1049).SharedFilePtr::Releasealso needed a real__thiscallmethod;_ZTV8Platformneeded storage._Z14ApproachLinearRiiishims.cppdropped this bridge because the symbol is "exactly what a host C++ build emits forApproachLinear(int&, int, int)". That's the Itanium mangling — MSVC emits?ApproachLinear@@YAHAAHHH@Z. Restored, with the GCC/Clang case noted so it isn't re-deleted.GX::LoadTexPlttLoadTexalready existed atmodel_host.cpp:183.data_020a60b0extern "C"block. Aliased, not redefined — a second definition links cleanly and then leavesLoadTexPlttwriting to a different VRAM base than the budget code reads.The last two shipped with a premise that is correct for the ROM link and wrong for the host. That's a systematic gap, not carelessness — so this adds the thing that closes it.
The gate
port/tools/port_linkcheck.py— builds the port, fails if it doesn't link. ~2s incremental. Skips loudly without MSVC ("This did NOT pass; it did not run") rather than reading as green. Passesninja -k 0: the default-k 1stops at the first failure, which reported 3 breaks when there were 4 and turned diagnosis into fix-one-discover-more. (The report itself then had to be fixed for the same reason — see Review follow-up.)tools/port_refcheck.pydoesn't cover this — it catches asrc/rename stranding a reference, and none of these renamed anything. Only linking catches a linkage break.port/tools/port_evidence.py— how much of what the port compiles is proven to be the game's code, via enrollment in the byte-exact ROM build. 12 files aren't, includingModel::UpdateFileOffsets(recovered code 36 bytes larger than the real function) andModel::LoadCompressedTextureToVram(NONMATCHING).--ratchetfails only when that set grows, matching the must-not-regress shape innotes/pr-validation.mdrather than being red from day one.Also:
build-port.cmdandhost_frontier.pynow locate MSVC viavswhere— the hardcoded VS 2022 BuildTools path no longer exists on this machine.Verification
On
main@ee0c07fa:The ratchet holding at 12 across the 616 changed
src/files since the last port work means nothing unproven was pulled in during that churn.Review follow-up (commit 2)
Code review of commit 1 ran both new tools against a real break instead of reading them, and found two defects in them. Neither touches the linkage fix.
port_linkcheck.pytruncated its own damage report.keep[:40], no marker. On the actual failure that is 40 lines of 114, and the truncated report reads as "6 of 14 binaries broke" when 13 did — the same fix-one-discover-more-k 0was added to end, reintroduced four lines below the comment explaining why it must not be. Now grouped: distinct unresolved symbols (11) and failing binaries (13) are never capped, only the open-ended tail is, and it says how much it dropped. A totals footer keeps a capped run honest.The symbol capture also had to stop splitting on whitespace — MSVC prints C++ linkage as
"char data_020a4d38" (?data_020a4d38@@3DA), so a\S+capture keys every such symbol on the token"charand merges distinct ones. Undercounting, in the report written to prevent undercounting, in a tool built for a C-vs-C++ linkage outage where both spellings are guaranteed to appear.port_evidence.pycould never mark a file under asrc/subdirectory as proven. Enrolled keys keep their subdirectory (engine/fader/_ZN15FaderBrightness…); the join usedPath(rel).stem, which never contains a slash. 138 of the 9,149 enrolled objects live in subdirectories. It put 7 byte-exactFaderBrightnessfiles into the unproven ledger and shipped that in the committed baseline — hence 19 → 12 above. Fixed by keying on the src-relative path, with no bare-stem fallback: that fallback would let a name collide with an unrelated top-level object and report a file proven on another file's evidence.Also:
--gatenow refuses to combine with--ratchet/--update-baseline. It narrows the file set before the unproven set is computed, so--update-baseline --gate 9would have written one gate's files over the whole ledger and failed much later, as a REGRESSION blamed on whoever next ran a full ratchet.Re-verified:
port/hal/reverted to main → 13 binaries, 11 symbols, exit 1; restored → 14/14 link, exit 0. Ratchet green at 12,unknownbucket empty across all 14 gates.Gate wiring (commits 3–4)
The follow-up this PR originally deferred is now in it, because reviewing it turned up the reason it mattered: nothing referenced either tool — no workflow, no validation script, no hook, no doc, only each other — and
port_linkcheck.pyreturned 0 when it SKIPPED. On any machine or CI image without 32-bit MSVC it would have read green forever while checking nothing. That is this branch's own bug one level up: #1049 went unnoticed for two days because nothing builtport/. A gate nobody runs, which cannot fail when it does not run, is not a gate.Both tools now share an exit contract:
--requirecollapses 2 into 1, for CI or any caller that must not go green without the check actually running.port_evidence.pyraisesCannotCheckinstead ofsys.exitfor missing ROM artifacts, a non-byte-exact build on record, and a missing baseline. All three used to exit 1 — indistinguishable from "someone compiled an unproven file into the port", and the two demand opposite responses: re-run the ROM build, versus stop the merge.port_linkcheck.pyalso stops cold-configuring by default: building 14 binaries from scratch is minutes, fine when asked for and wrong to spring on someone mid-git push. Unconfigured is now "could not check";--configureopts in. Same shape the hook already uses forcheck_references.py.tools/hooks/pre-pushruns both in the every-push section besideport_refcheck, reading the exit code rather than grepping stderr for phrases the way thecheck_referencesblock above them has to. An exit code cannot drift out of sync with a reworded message.Verified in a worktree, all six paths:
Install note: this repo can run the hook either via
core.hooksPath=tools/hooks(tracked file runs directly; updates arrive with a pull) or by copying it into.git/hooks/. The copy needs re-copying whenever the hook changes, and a stale copy fails silently — it still runs, just without the newer checks. Commit 4 documents both.🤖 Generated with Claude Code
https://claude.ai/code/session_017tYjz5v5R228Kh7YCH4gkR