Real C++ member definitions across the minigame family (79 bodies); slots 18 and 32 return void, measured - #2114
Conversation
…nd two vtable-slot return types MEASURED
The slots-18-35 campaign declared the family's virtuals; every declaration was
measured against the ROM's vtables and call sites, but the BODIES stayed free
functions with hand-written mangled names:
extern "C" void _ZN12dScMgPanel_c13OnYoshiTryEatEi(char *self, int flag)
A free function can hold a signature the class never agreed to, and 56 of the
90 family bodies did exactly that -- they disagreed with their own class
declaration on return type, arity, or both, and nothing ever noticed, because
the return type is not part of the Itanium mangled name and an unread trailing
parameter costs no instruction. This converts 79 of them to real members:
void dScMgPanel_c::OnYoshiTryEat(int flag)
which forces body and declaration into ONE spelling. The ROM then adjudicates.
TWO SLOTS CHANGED THEIR RETURN TYPE, and the bytes settled both.
Slot 18, OnYoshiTryEat(int): int -> void. The `int` rested entirely on
dScMgCoin_c ending `return 0;`. That does not survive its own disassembly --
Coin's tail puts the address in r1 because r0 still held a CALL RESULT, not
because r0 was reserved for a return value, and the trailing `mov r0,#0` is
the constant being stored to unk_51c8. Coin is byte-identical under both
spellings and pins neither. Six overrides do pin it, and they pin void:
converted as `int`, mwcc reserves r0 and shifts every register at their exits
by one -- dScMgSlot1_c 3 words of 19, dScMgMemory_c and dScMgMemory2_c 5 of 24
each, dScMgRoulette_c 3 of 63, dScMgPanel_c 4 of 68, dScMgSlot3_c 84 of 205.
ROM add r0, r4, #0x4000 / mov r1, #0 / str r1,[r0,#0x6b4]
int add r1, r4, #0x4000 / mov r2, #0 / str r2,[r1,#0x6b4]
Slot 32, Virtual80(): int -> void, on the same evidence from dScMgSlot3_c's
single override, 6 words of 42. The header had called that `int` "A HINT ...
no body pins"; a body pins it now. The base's own body was byte-exact either
way, which is exactly why the hint survived unrefuted for so long. Unrefuted
is not confirmed.
mwcc rejects the halfway position outright -- a void override of an int virtual
is "differs from virtual base function ... in return type only", an error, not
a warning -- so the base and all 31 declarations move together. The return type
is not mangled, so no symbol, vtable, or config entry moves.
THE RECIPE, and steps 5-7 are the ones no compile error surfaces:
1. git mv the .c to .cpp
2. //cpp as the FIRST BYTES -- the marker is the whole language test;
rombuild.compile_one never looks at the extension
3. #include "<Class>.h"
4. spell the definition as <declRet> Class::Method(<declParams>)
5. point config/arm9/overlays/ovNNN/delinks.txt at the new .cpp path.
Skipping this does NOT fail the compile: the function silently falls back
to ROM bytes and only layout-check notices. 61 keys repointed here.
6. re-point any PROSE that named the old path (check_dead_references)
7. wrap EVERY file-scope declaration in extern "C" { }. A .c file needs no
linkage marker; the same declarations in .cpp are C++ declarations, so
mwcc mangles them -- and one whose identifier is ITSELF a mangled name
gets mangled a second time. Nothing catches it until the whole-ROM link:
Undefined : "_ZN3G2S13GetBG1CharPtrEv()"
Referenced from "dScMgBase_c::Virtual7C()"
Declarations come in two shapes and both bite: `extern void Foo(int);`
and bare `void MultiStore16(u16, void*, int);` with no extern keyword.
8. fix the C-only constructs C++ rejects: void* arithmetic, implicit
function declarations, and a local extern that conflicts with the one
the newly included class header brings in.
Slot 30's return type was measured the same way and is recorded in its own
evidence block: dScMgBase_c::OnAimedAtWithEggReturnVec is void, 14 of 93 words.
VERIFIED: python tools/rombuild.py -j 16 --no-rom
11,088 / 11,088 functions reproducing, 0 mismatching
106/106 modules exact, 100.000000% of compared bytes
Static gates, all green: dead-references, duplicate-sources, header-offsets,
langmode-ratchet, layout-check, src-tu-refs, source-coverage, converted-ratchet,
port_refcheck.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhhAeJwXBnfPp7B5DNjCwh
✅ PR validation — PassedCommitted merge introduces no reconstruction or attribution regression. Full merge validation
Byte-verified means the range carries Warnings: 1 linkcheck result(s) have unresolved relocations; 1 affected source file(s) could not be fully link-checked. Per-file link-check detail1 of 249 changed file(s) do not match the ROM (NO-SYM).
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. |
⏳ PR validation — ValidatingBuilding This comment is replaced with the verdict when it finishes. |
|
Self-review. No PR here can be APPROVED (one shared account), so the sign-off is in this comment, with what I actually checked and what I could not. What I verified
What this PR does NOT proveThe 56/90 signature disagreements are resolved for the 79 bodies converted, not for the family. The inventory found 56 of 90 family bodies whose free-function signature contradicted their own class declaration. This converts 79 bodies; the disagreements inside those are now impossible by construction. Eleven bodies remain unconverted — they are the ones whose first parameter is not obviously Only two return types were re-measured, because only two had a body that could refute them. Slot 30 ( Two traps that cost real time hereDouble mangling. My first blast-radius estimate for the The wrapper itself had two defects, both found by the link and not by any gate:
Line endings. This worktree runs Two things made this hard to diagnose and are worth recording:
None of this changes a byte of output — but a 2368-line diff over 658 lines of real change is not reviewable, and the churn hid inside two earlier commits until I checked. Merge orderStacked on #2112. |
andrewboudreau
left a comment
There was a problem hiding this comment.
Verified independently. The measurement holds; the paperwork undersells it.
This is the return-type claim family I have publicly retracted twice in this repo, so I did not take the headline on trust — I re-measured it from the cartridge by a different method. It survives. Details below, then three asks, one of which is the only thing I'd call a real defect.
Why I believe the byte pin
The trap in "this slot returns void" claims is that return 0; fakes a refutation: you add an instruction, the diff grows, and you conclude int costs something when all you did was write a store nobody asked for. This PR does not fall into it, and says so explicitly — Coin's mov r0,#0 is identified as a stored constant, and the int variants are shown producing register shifts, not extra instructions:
ROM ldr r0,[pc,#0x38] / mov r3,#0x4000000 / ldr r1,[r3]
int ldr r1,[pc,#0x38] / mov r4,#0x4000000 / ldr r2,[r4]
That is a reservation effect — int reserves r0 across the body and everything downstream shuffles up one. Fourteen of ninety-three words at slot 30, six of forty-two at slot 32. That is the signature of a real refutation and it is not something return 0; can counterfeit.
Independent corroboration, from the consumer side
I scanned for ldr rN,[rM,#<offset>] + blx rN pairs and classified what happens to r0 at each site — is it overwritten before it is read (DISCARDED), read first (CONSUMED), live at a bl (ARG), or tail-forwarded. Scoped to ov004 and ov006:
| slot | offset | sites | verdict |
|---|---|---|---|
| 18 | +0x48 | 22 | 20 DISCARDED, 2 FORWARD, 0 CONSUMED |
| 30 | +0x78 | 1 | DISCARDED — add r0, r4, #0x4000 |
| 32 | +0x80 | 1 | DISCARDED — mov r0, #2 |
Slot 30's single site is _ZN11dScMgBase_c8OnKickedEv, which confirms your "OnKicked is the only caller" line exactly. Slot 32's is _ZN11dScMgBase_c18AfterInitResourcesEj. On slot 18 I chased both forwarders rather than counting them as unknowns: func_ov004_020b29a0 has 11 direct callers and all 11 discard (mvn r0,#-1 ×10, mov r0,#0x1d ×1); func_ov006_020df1c0 has zero direct callers and is reached only by dispatch.
So from the caller side as well as the codegen side, nothing in this family reads a result at any of the three slots.
One caution that validates your own filtering. A byte offset does not identify a class. Run that same +0x48 scan across arm9 and all 103 overlays and it reports 19 CONSUMED sites — cmp r0,#6 in ov062, cmp r0,#6 and cmp r0,#4 in ov084. Every one belongs to a different hierarchy that happens to have a slot at the same offset. Restricted to ov004/ov006 the count is zero. Your slot-29 block already does this correctly ("35 sites, of which exactly ONE lies in ov004 or ov006") — I'm noting it because anyone re-running this scan without the module filter will get a number that looks like it contradicts the PR and doesn't.
Ask 1 — the title and body undercount the change
The title says slots 18 and 32. Three slots flip: 18, 30, and 32. Slot 30 (OnAimedAtWithEggReturnVec) is the one carrying the strongest evidence in the whole PR — the fourteen-of-ninety-three word diff quoted above is its measurement — and it appears nowhere in the PR body. Please add it to both. A reviewer skimming the title will not know a third slot moved.
Ask 2 — a stale cross-reference this PR invalidates itself
include/dScMgBase_c.h:560-562, inside the slot-30 block:
Contrast slots 31, 32, 35 and the OnXxx slots, where
intcosts nothing because r0 is not contended at the exit -- there the hint stands unrefuted, which is NOT the same as confirmed.
Line 769 of the same file, in the same PR, declares slot 32 void on a measured refutation. Drop 32 from that list.
Ask 3 — the one I'd actually hold on: a header/decl disagreement this stack introduces
I ran tools/check_decl_return_types.py (from #2105) against every tree in the stack. It compares a symbol's declared return type in include/**/*.h against the same symbol in include/decl_common.h:
origin/main 2 rows AfterInitResources, BeforeInitResources
#2099 2
#2100 3 + OnAimedAtWithEgg header int vs decl_common void
#2102 4 + OnAimedAtWithEggReturnVec header int vs decl_common void
#2106-#2112 4 (carried)
#2114 3 fixes ReturnVec's, leaves OnAimedAtWithEgg's
So the net effect of the whole twelve-PR stack landing is 2 → 3: one new symbol declared two different ways in two headers. It is slot 29, OnAimedAtWithEgg. include/dScMgBase_c.h:539 says:
virtual int OnAimedAtWithEgg(); /* slot 29 */and include/decl_common.h:2322 says:
extern void _ZN11dScMgBase_c16OnAimedAtWithEggEv(void*);This will never fail a check. check_decl_return_types.py is not wired into any CI job, and it exits 1 on origin/main today at those 2 rows — which is exactly why I'm raising it in prose instead of pointing at a red X.
To be precise about what I am and am not claiming: the ROM is neutral at slot 29. The header's own prose is honest about that ("A HINT, the second consecutive slot that no body pins"), and I retracted a "slot 29 is void, proven" claim of my own earlier in this campaign — I am not making it again. This is an agreement fix, not a measurement one. One symbol should not carry two declared return types across two headers in the same tree, and since this PR is already the return-type-reconciliation PR, flipping line 539 to void is the natural place to close it. If you'd rather keep int there, the equivalent fix is to change decl_common.h:2322 instead — either direction resolves it and I have no preference.
Where that leaves this PR
Content is verified and I have no objection to the measurement. Ask 3 is what I'd like resolved before merge, because it is a correctness-of-source issue and the project's ordering puts that ahead of readability. Asks 1 and 2 are documentation and I'd take them in the same push.
Note this PR also fixes the row-18 sentence I had committed to correcting in a follow-up of my own across five earlier reviews — that follow-up is now down to one item (restating the tally as six-for-seven), so thank you for absorbing it.
This is still gated behind #2099, which has not moved.
The slots-18-35 keystone campaign declared the minigame family's virtuals. Every declaration was measured against the ROM's vtables and call sites — but the bodies stayed free functions with hand-written mangled names:
A free function can carry a signature the class never agreed to. 56 of the family's 90 bodies did exactly that — they disagreed with their own class declaration on return type, arity, or both, and nothing ever noticed, because the return type is not part of the Itanium mangled name and an unread trailing parameter costs no instruction.
This PR converts 79 of them into real members:
which forces body and declaration into one spelling, so the ROM has to adjudicate.
Two slots changed their return type, and the bytes settled both
Slot 18 —
OnYoshiTryEat(int):int→void(31 declarations)The
intrested entirely ondScMgCoin_cendingreturn 0;. That does not survive its own disassembly:r1 holds the address because r0 still held a call result, not because r0 was reserved for a return value. Coin is byte-identical under both spellings and pins neither.
Six overrides do pin it, and they pin
void. Compiled asint, mwcc reserves r0 as the result register, and every register at those exits shifts by one:dScMgSlot1_cdScMgMemory_cdScMgMemory2_cdScMgRoulette_cdScMgPanel_cdScMgSlot3_cSlot 32 —
Virtual80():int→void(2 declarations)Same signature, from
dScMgSlot3_c's single override — 6 words of 42:The header had called that
int"A HINT ... no body pins". A body pins it now. The base's own body was byte-exact either way — which is exactly why the hint survived unrefuted for so long. Unrefuted is not confirmed.mwcc rejects the halfway position outright: a
voidoverride of anintvirtual isdiffers from virtual base function ... in return type only— an error, not a warning. So the base and all 31 declarations move together. The return type is not mangled, so no symbol, vtable, or config entry moves.The recipe — steps 5-7 surface no compile error
git mvthe.cto.cpp//cppas the first bytes — the marker is the whole language test;rombuild.compile_onenever looks at the extension#include "<Class>.h"<declRet> Class::Method(<declParams>)config/arm9/overlays/ovNNN/delinks.txtat the new.cpp. Skipping this does not fail the compile — the function silently falls back to ROM bytes and onlylayout-checknotices. 61 keys repointed here.check_dead_references)extern "C" { }. A.cfile needs no linkage marker; the same declarations in.cppare C++ declarations, so mwcc mangles them — and one whose identifier is itself a mangled name gets mangled a second time. Nothing catches it until the whole-ROM link:extern void Foo(int);and barevoid MultiStore16(u16, void*, int);with no keyword — and one may span several lines, so the unit is the run up to the terminating;, not the line.void*arithmetic, implicit declarations, and a localexternthat conflicts with the one the newly included class header brings in.Verification
Static gates, all green:
dead-references,duplicate-sources,header-offsets,langmode-ratchet,layout-check,src-tu-refs,source-coverage,converted-ratchet,port_refcheck.Base
Stacked on
cpp/minigame-slot35(#2112). Merge that first.attribution-overrideper the standing instruction — attribution is a non-goal here.