Add check_decl_return_types.py: one symbol, one declared return type (tool only, not wired into CI) - #2105
Merged
Merged
Conversation
Every recovered C++ method is declared in two files that never see each other:
include/decl_common.h as an extern with an explicit return type, and its class
header as a virtual. Neither includes the other, so the compiler never compares
them, and the linker binds on the mangled name alone. A disagreement survives
every gate we have -- each translation unit is internally consistent so the byte
gates pass, and check_header_offsets only reads field layout.
Measured across the refs where this was first noticed by hand, the tool
reproduces the hand census exactly:
origin/main 2 (joined 7)
origin/cpp/minigame-slot28 2 (joined 7)
origin/cpp/minigame-slot29 3 (joined 8)
origin/cpp/minigame-slot30 4 (joined 9)
It joins wider than the hand census did: eight declarations here glue the sigil
to the name (`virtual Vector3 &GetPos();`), which a whitespace-separated pattern
reads as nothing at all.
NOT wired into CI. Two of the four disagreements are on main today, so a
workflow calling this would fail every push. Wiring it is a follow-up that has
to land after the rows are fixed, not by loosening the check.
The blind-spot counter is part of the output on purpose: a reader deciding
whether to trust a green run needs to know what was not read. It is currently
zero, and getting it there caught two matcher bugs that were also polluting the
map -- a comment's parenthesis inventing a virtual named `16`, and a
destructor borrowing its `(` from the constructor declared lines below. Both are
pinned by tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdCK1xgrJdiJzPCh3bAJfQ
✅ PR validation — Passednoverify: no source/build-data changes in this PR Each changed |
This was referenced Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
tools/check_decl_return_types.pyand its test suite. It is deliberately not wired into CI — see "Why this is not a workflow yet" below.The gap
Every recovered C++ method is declared twice, in two files that never see each other:
Neither header includes the other, so the compiler never compares them. A caller reaching the symbol through either one compiles cleanly regardless of what the other says. The linker binds on the mangled name and does not carry a return type.
So the disagreement survives everything we run: the byte gates pass because each translation unit is internally consistent, and
check_header_offsetsonly reads field layout. The failure it allows is a caller that reads r0 from a callee which never wrote it, or the reverse — silent at compile time and at link time.Nothing in CI catches this today.
git grep decl_common tools/finds onlypr_linkcheck.pyandresolve_placeholders.py, neither of which reads return types.It reproduces a hand census exactly
I measured this by hand first, across the refs where it turned up. The tool run over the same four:
origin/mainorigin/cpp/minigame-slot28origin/cpp/minigame-slot29origin/cpp/minigame-slot30Same numbers, same symbols. And it joins wider than my hand census managed — eight declarations here glue the sigil to the name (
virtual Vector3 &GetPos();,virtual void *Unk_020c76d0();), which a whitespace-separated pattern reads as nothing at all. The hand census missed all eight; they agree, so the answer did not change, but the reach did.The four rows on
slot30:decl_common.hdScMgBase_c::BeforeInitResourcesboolintdScMgBase_c::AfterInitResourcesvoidintdScMgBase_c::OnAimedAtWithEggintvoiddScMgBase_c::OnAimedAtWithEggReturnVecintvoidTwo directions, so this is not "one header is stale".
decl_common.his the minority witness in all four —dScMgD3DBase_c.h,dScMgSlot3_c.handdScMgBase_c.hagree with each other every time.boolvsintis not cosmetic: aboolreturn goes through a widening cast under mwccarm 2004/b56 that anintreturn does not, sonorm_typekeeps them distinct on purpose.Why this is not a workflow yet
Two of the four disagreements —
BeforeInitResourcesandAfterInitResources— are onmainright now. A workflow calling this would fail every push from the moment it landed.The two moves available are: fix the rows, or loosen the check. Loosening it is not on the table — a gate written to pass is not a gate. So this lands as a tool, and wiring it is a follow-up that becomes possible once the four rows are fixed at a single site (
include/decl_common.h; a half-flip does not compile, it is anillegal function overloadingerror, which is the one mercy here).Same two-step as #2082 → #2083 and #2091 → #2101.
What it reports about itself
The accounting prints on failure as well as under
--summary, because someone reading a green run needs the reach as much as someone reading a red one:The unjoined rows are not silently counted as agreement. Most of them structurally cannot disagree: vtable and typeinfo data carry no return type, structors have none either, and the "not a class" rows are SDK namespaces (GX, Sound, cstd) whose free functions have no vtable slot. The three non-virtual rows I checked by hand —
Player::SetPlayableSeqCount,dBgW::Disable,dBgW::IsEnabled— are genuinely non-virtual and agree anyway.There is a blind-spot counter for
virtuallines the matcher failed to read. It is currently zero, and getting it there is where the real work went — it went negative twice, which is how two matcher bugs surfaced that were also corrupting the map, not merely miscounting:virtual ~fBase_c(); /* slots 16 (D1), 17 (D0) */matched as return type~fBase_c(); /* slotsand method name16, inventing a virtual named16in 116 class bodies;(from the constructor declared lines below:virtual ~dActor_c();matched as method namedActor_c, injecting a virtual named after the class.Either one could have joined a
decl_common.hrow and compared garbage. Both are pinned by tests.Design notes
--changedmode, on purpose. The invariant spans one file plus every class header, so editing a class header can break agreement with adecl_common.hrow the diff never touches. A whole-tree run is cheap and is the only honest one.ambiguous, not resolved. Picking one would be a guess, and a wrong guess is a false failure on a correct tree. Currently zero.{.*?}stops at the first inner close, and these bodies nest. A test plants that as a false-green regression.Testing
python -m unittest tools.test_check_decl_return_types— 23 tests, all passing. They cover the real disagreements, the shapes that must not fire, the two map-pollution regressions above, and the tool's own honesty about its reach (one test asserts a multi-line declaration IS parsed — I had documented the opposite, and the test is what caught the doc being wrong).Not added to any workflow, so no CI file changes here.