Declare dScMgBase_c slot 28 (OnHitFromUnderneath) across the minigame family - #2099
Conversation
… family
Twenty-eighth of the eighteen keystone slots, the second and LAST occupied one,
and the first whose return type no body pins at all.
THE SIGNATURE
virtual int OnHitFromUnderneath();
Arity: no explicit parameters, and this is MEASURED ONCE rather than twice --
weaker evidence than slot 27's, said plainly rather than dressed up.
ov004:0x020af04c opens `mov r4, r0` and then WRITES r1 with `add r1, r4, #0x4000`
before ever reading it, and reads no other argument register anywhere.
dScMgSlot1_c's override at ov006:0x0210c4b8 cannot corroborate it the way its
slot-27 override did: it calls the base as its very first act, so a second
argument would ride through r1 untouched and leave no trace either way.
include/dActor_c.h:145 spells `dActor_c &other` and include/dScMgSlot1_c.h had
copied it -- the seventh time that header would have led us wrong on a
parameter list.
Return type `int` is A HINT, and slot 28 is the first slot in this campaign
where that has to be admitted. The base body's early exit --
`cmp r0,#0; popeq {r4,lr}; bxeq lr` -- returns the zero it has just compared,
and the fall-through returns whatever Enable3dEngines left; neither is a
deliberate result. dScMgSlot1_c's override sets no r0 either. And nothing
anywhere in ov004 or ov006 loads vtable+0x70, so there is no caller to consume
one. `int` rests on dActor_c.h's seven-for-eight count on RETURN types and on
nothing else; `void` compiles to the same bytes. It gets no row in
dScMgBase_c.h's tally table, which is the eight slots whose own bodies DO pin
one -- the honest place for it is as the stated exception, not a ninth row.
THE TRAP, FOR THE SECOND AND LAST TIME IN THIS CLASS
dScMgSlot1_c already carried this member -- declared as a NEW virtual with
dActor_c.h's parameter list, landing on index 28 by arithmetic because the base
stopped at 27. `OnHitFromUnderneath()` and `OnHitFromUnderneath(dActor_c &)`
are DIFFERENT FUNCTIONS: declaring the base's alone would have given 28 to the
base, made dScMgSlot1_c's a new slot at 29, and put _ZTV12dScMgSlot1_c straight
back into DIFFERS -- with rombuild green the whole way. Only romdata_check
sees it.
So the reconciliation is part of this same commit, and lands in all five places
a rename has to reach: ...19OnHitFromUnderneathER8dActor_c ->
...19OnHitFromUnderneathEv in ov006/symbols.txt and delinks.txt, the file
renamed with it, config/converted-baseline.json repointed (tiers_ratchet.py has
no rename detection and would score the old path GONE), the prose path in
include/dScMgBase_c.h updated where the dead-references gate reads it, and the
body's forwarding call rewritten as a qualified `dScMgBase_c::
OnHitFromUnderneath()`, which suppresses virtual dispatch and emits the same
direct `bl` the ROM has.
dScMgSlot1_c now declares nothing mwcc has to number for itself. The trap is a
property of the FAMILY rather than of that one class, so the sentinel comment
for slots 29-35 now says so: check every descendant header before declaring
slot N. dScMgAmida_c's `Unk36` is the last early declaration left, and slot 35
lands it.
SIX TABLES, TWO DECLARATIONS
dScMgD3DBase_c ov006:0x020e6d8c -- backs its own table and all four
children's (Jump, Jump2, Trampoline,
Trampoline2), which declare nothing
dScMgSlot1_c ov006:0x0210c4b8 -- reconciled above
NAME CORRECTION, THE SIXTH
0x020e6d8c carried `recovered name: dScMgTrampoline2_c_OnHitFromUnderneath`.
Five vtables point at it -- dScMgD3DBase_c's and all four of its children's --
and a body that appears in a class's table AND in every child's is supplied by
that class. Fifth on this class after slots 24, 25, 26 and 27; sixth in the
campaign. Kept visible above the correction, as before. Its twelve bytes are
a long-branch veneer, `ldr ip,[pc]; bx ip; .word 0x020af04c`, not a linker
artifact: the twenty-six tables that do NOT override this slot hold 0x020af04c
directly, and vtable words are data, not branches.
THE SHADOW SCAFFOLD COMES OFF THE BASE BODY
src/func_ov004_020af04c.cpp carried a local `struct Base` of twenty-six
placeholder virtuals plus `struct Obj : Base` with a `char pad[0x4627]`,
existing so that one call would compile to a load of vtable+0x68. Slot 26 is
OnHitByCannonBlastedChar and dScMgBase_c declares it now, so the class does that
job itself. The file becomes
src/_ZN11dScMgBase_c19OnHitFromUnderneathEv.cpp and reads:
self->mMenuOpen = 0;
func_ov004_020b91fc((char *)&self->mTouchOptions);
int r = self->OnHitByCannonBlastedChar();
if (r == 0) return;
Enable3dEngines();
-- a hit from underneath closes the three-item overlay menu OnHitByMegaChar
opens, resets the polymorphic touch-icon set, and re-enables the 3D engines only
if the class's own OnHitByCannonBlastedChar says so. Both raw offsets go with
the scaffold: 0x4628 is mMenuOpen, 0xf4 is mTouchOptions. Byte-identical.
VERIFICATION
rombuild -j 16 --no-rom : 11,088 / 11,088 reproducing, 106/106 exact, PASS
romdata_check : partialBytes 11,256 -> 11,376 (+120)
verifiedBytes 35,400 -> 35,404 (+4)
verified 465, partial 253, differs 6,
differsRecords 14 -- all four unchanged
romdata_check --files src/_ZN12dScMgSlot1_cD1Ev.cpp : DIFFERS 0
check_dead_references, port_refcheck, check_header_offsets --changed,
langmode ratchet : all PASS
Both byte deltas decompose exactly, over the 33 tables in the hierarchy -- the
32 descendants the census counts plus dScMgBase_c's own:
30 x 4 = +120 in the PARTIAL pool. Thirty, not thirty-three, because
_ZTV12dScMgSlot1_c does not change length (it already emitted through index
28), _ZTV12dScMgAmida_c is scored DIFFERS so its bytes are in neither pool,
and _ZTV14dScMgD3DBase_c is not PARTIAL at all --
1 x 4 = +4 in the VERIFIED pool, and it is D3DBase's. dsd cuts that symbol
at 80 bytes, eighteen slots, well short of the table it names; it is the only
vtable in the family whose configured extent is shorter than what we emit,
which is why it alone scores VERIFIED while still being an incomplete table.
A VERIFIED vtable is not a complete vtable. It gains a word per slot.
Amida's single differing word simply moves from index 28 to index 29 -- Unk36
is one index closer to 36, as its header says, and slot 35 lands it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhhAeJwXBnfPp7B5DNjCwh
✅ PR validation — PassedCommitted merge passes; 2 contributor credit reassignment(s) noted, not a blocker. Full merge validation
Contributor credit moved (2)
Byte-verified means the range carries Warnings: contributor attribution changed, not a blocker (2 changed, 0 lost -- src/_ZN11dScMgBase_c19OnHitFromUnderneathEv.cpp: tangosdev -> andrewboudreau; src/_ZN14dScMgD3DBase_c19OnHitFromUnderneathEv.c: ruspecial -> andrewboudreau). Per-file link-check detailAll 190 changed file(s) compile to the ROM byte-for-byte with correct relocation targets.
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. |
|
Self-review, since this repo runs on one shared account and no PR here can be Verified locally, in
What I would push back on if someone else had written it:
Landing stays with you. |
|
Correction to the commit message, which I cannot amend without a force-push: It says Nothing downstream of it moves: the extent is still far short of the table |
andrewboudreau
left a comment
There was a problem hiding this comment.
CHANGES REQUESTED
The bytes, the census, the name correction and the rename reconciliation are all verified correct — I re-derived every one of them independently and they reproduce exactly. Three asks, and all three are cheap. Two of them make the PR stronger than it currently claims to be.
Before any of it: thank you for writing "A HINT" rather than adding a ninth row. That is the right call and it is the first time in this campaign anyone has declined to claim a measurement they did not have. I tested the claim behind it and it holds — see below.
1. The vtable+0x70 claim is false, and the true fact is better for you
The header says it twice and src/_ZN12dScMgSlot1_c19OnHitFromUnderneathEv.cpp says it a third time:
"Nothing anywhere in ov004 or ov006 loads vtable+0x70, so there is no caller to read a result either."
I scanned both overlays plus arm9 for ldr rX,[rY,#0x70] immediately followed by blx rX. There are three sites in ov004, and one of them is inside this very class:
ov004 0x020aedec in func_ov004_020aeb24
ov004 0x020aee90 in func_ov004_020aeb24
ov004 0x020b06cc in _ZN11dScMgBase_c14BeforeBehaviorEv <- a named member of dScMgBase_c
BeforeBehavior is unambiguous — it dispatches slot 28 on its own this:
0x020b06c4 e1a00004 mov r0,r4
0x020b06c8 e5901000 ldr r1,[r0,#0]
0x020b06cc e5911070 ldr r1,[r1,#0x70]
0x020b06d0 e12fff31 blx r1
Your conclusion survives intact — none of the three consumes the result:
0x020b06ccbranches to0x020b06f8, which isadd r0, r4, #0x4000. r0 is overwritten before anything reads it.0x020aedecand0x020aee90both go straight toadd sp,sp,#0x10; ldm sp!,{r4,lr}, leaving r0 in tail position — which is exactly what a void callee also produces. No discrimination.
So int is still unmeasured. But please replace the absence claim with the presence one. "No caller loads the slot" is false and someone will check it; "three callers load it and none tests the result, one overwrites r0 immediately" is true, is strictly stronger, and is the form that survives being reused. This is the same failure mode as row 18's and row 27's supporting sentences — a conclusion that is right resting on a statement about the ROM that is not.
2. Arity is measured TWICE, from both ends — and the control is in the same scan
You wrote "MEASURED ONCE rather than twice" and said dScMgSlot1_c's override cannot corroborate it. It can't — but the callers can, and this is the strongest evidence shape this campaign has.
All three ov004 sites load the function pointer into r1. mwcc can only pick r1 as the branch-target scratch if r1 carries no argument. And the control is sitting right next to it in the same scan — the two 0x70 dispatch sites in ov002, a different hierarchy:
ov002 0x020cf1d0 ldr r2,[r0,#0] ov004 0x020aede8 ldr r1,[r0,#0]
ov002 0x020cf1d4 mov r1,r5 <- arg ov004 0x020aedec ldr r1,[r1,#0x70]
ov002 0x020cf1d8 ldr r2,[r2,#0x70] ov004 0x020aedf0 blx r1
ov002 0x020cf1dc blx r2
When a second argument exists, the target goes in r2 and r1 holds the argument. When it does not, the target goes in r1. The register mwcc picks for the call target reads the argument count out directly. Three ov004 sites, all one-argument, against a two-argument control in the same instruction shape.
That is caller-side arity — the form I said on #2093 is the one that survives ("a caller that consumes r0 is the measurement"), applied to arguments instead of results. It refutes dActor_c &other from outside the family's own bodies, which the callee-side argument could not do. Worth having in the header; it retires the "measured once" caveat entirely.
3. int is a spelling this tree cannot actually write — and I measured that void costs nothing
This is the ask I am actually blocking on.
The PR declares virtual int OnHitFromUnderneath(); and then cannot honor it in either body it touches:
src/_ZN11dScMgBase_c19OnHitFromUnderneathEv.cppdefines it asextern "C" void. The Door route means the compiler never compares the two, so avoiddefinition of a symbol declaredintsits in the tree unflagged.src/_ZN12dScMgSlot1_c19OnHitFromUnderneathEv.cppisint dScMgSlot1_c::OnHitFromUnderneath()with no return statement — it falls off the end of a non-void function. It compiles and it byte-matches, and it is undefined behaviour.
So I built the alternative under your own headers. Both headers flipped to virtual void, Slot1's definition flipped to void, everything else untouched, pinned compiler 2004/b56:
headers declare virtual void
_ZN11dScMgBase_c19OnHitFromUnderneathEv ov004 0x020af04c 0x48 -> (True, '2004/b56')
_ZN12dScMgSlot1_c19OnHitFromUnderneathEv ov006 0x0210c4b8 0x24 -> (True, '2004/b56')
headers declare virtual int (this PR, unchanged)
_ZN11dScMgBase_c19OnHitFromUnderneathEv ov004 0x020af04c 0x48 -> (True, '2004/b56')
_ZN12dScMgSlot1_c19OnHitFromUnderneathEv ov006 0x0210c4b8 0x24 -> (True, '2004/b56')
Your "void compiles to the same bytes" is now measured rather than asserted, on both bodies. I also ran it on the pre-rename base body four ways before this PR was in front of me, including int with return r; on the early path and return Enable3dEngines(); on the fall-through — that spelling matches too. The bytes are genuinely neutral in every direction I could push them.
Which means the tiebreaker is not the ROM, it is which spelling the tree can state consistently. void can be written in both bodies and in both headers with no UB and no hidden disagreement. int cannot be written in either body without one. A recovered signature that cannot be spelled consistently is evidence against itself, and dActor_c.h is a hint you have already said is wrong on every parameter list it has been checked against.
One further reason not to lean on that count: I owe a correction to include/dScMgBase_c.h that removes row 18 from it. Row 18's supporting sentence — "declaring void would have changed its bytes" — is false by build; _ZN11dScMgCoin_c13OnYoshiTryEatEi byte-matches as void with the return 0; deleted, because the mov r0,#0 there is emitted for the store on the line above. The count slot 28's int rests on is going to be six-for-seven, not seven-for-eight. (Row 27 stays a pin — I was wrong about that one and retracted it on #2098.)
If you would rather keep int, that is a defensible call and I will merge it — but then it has to be honored: the base definition written as a real int return and Slot1's body given one. What I cannot merge is a declaration neither definition obeys.
Everything else reproduces
Census, derived independently from the overlay images, _ZTV addresses out of each module's own symbols.txt:
slot 28 base 0x020af04c tables 33 inherit 26 OVERRIDE 6 distinct addrs 2
0x020e6d8c <- dScMgJump_c, dScMgJump2_c, dScMgD3DBase_c,
dScMgTrampoline_c, dScMgTrampoline2_c
0x0210c4b8 <- dScMgSlot1_c
Six tables, two addresses, two declarations, 26 tables holding 0x020af04c directly. Identical shape to slot 27, exactly as you say.
Name correction. 0x020e6d8c is in five tables — D3DBase's and all four children's — so by the sibling rule it is D3DBase's and dScMgTrampoline2_c_OnHitFromUnderneath is refuted by being one of five rather than the owner. Its twelve bytes are ldr ip,[pc,#0]; bx ip; .word 0x020af04c, which I confirmed off the image. Worth restating what you already handle correctly: those bytes are identical for any class forwarding to this base, so they cannot evidence the name — the five-table membership does, and the veneer question is only whether a real symbol lives there.
The reconciliation reaches the ratchet. I checked all three renamed paths against config/converted-baseline.json rather than trusting the one-line diff: only src/_ZN12dScMgSlot1_c19OnHitFromUnderneathER8dActor_c.cpp was ever banked, and it is updated. The two func_* names were never in the CONVERTED tier, so the single-line change is complete, not partial. That is the check tiers_ratchet.py cannot do for you.
The scaffold removal. Dropping the 27-method struct Base / struct Obj : Base with char pad[0x4627] is the best thing in this diff. self->OnHitByCannonBlastedChar() compiling to ldr r1,[r1,#0x68] off the real class — I confirmed 0x68 is slot 26 in the base body's own dispatch — is exactly what declaring slot 26 was for, and the two raw offsets going with it (0x4628 -> mMenuOpen, 0xf4 -> mTouchOptions) is the readability the campaign is actually for.
The trap paragraph. Generalising it from "dScMgSlot1_c is now clean" to "check every descendant header for a virtual that already lands on N by arithmetic" is the right move. That is the one failure this stack has that no byte gate can see, and it now reads as a property of the family rather than a note about one class.
The slot-28 evidence block asserted that "nothing anywhere in ov004 or
ov006 loads vtable+0x70, so no caller consumes one either". The first
half is false. Scanning both overlays for the actual dispatch pattern
-- `ldr rN,[rM,#0x70]` immediately followed by `blx rN`, rather than any
load at +0x70, which also matches ordinary field reads -- finds three
sites: 0x020aedec and 0x020aee90 inside func_ov004_020aeb24, and
0x020b06cc inside dScMgBase_c::BeforeBehavior.
The conclusion is unchanged, and now rests on a measurement instead of
an absence. All three callers discard the result. The first two are
tail calls, `blx r1; add sp,sp,#0x10; pop {r4,lr}; bx lr`, which hand r0
straight back without reading it -- what a void call and an int call
both compile to. The third branches to a shared epilogue whose first
instruction is `add r0,r4,#0x4000`, so the result is dead before
anything can use it. Slot 28's return type is still unpinned and `int`
is still a hint resting on the seven-of-eight count.
Comment-only. rombuild 11,088/11,088 reproducing, 106/106 exact, PASS;
ROM data 465 verified / 253 partial / 6 differ, all unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QhhAeJwXBnfPp7B5DNjCwh
|
Pushed The block claimed "nothing anywhere in ov004 or ov006 loads vtable+0x70, so no caller consumes one either." The first half is false. I had reasoned from the absence of a caller without actually scanning for one. Scanning for the real dispatch pattern —
The conclusion does not move, and it is now a measurement rather than an absence. All three discard the result. The first two are tail calls — Re-verified on the new tip: rombuild 11,088/11,088 reproducing, 106/106 exact, PASS; ROM data 465 verified / 253 partial / 6 differ — all identical to the figures in the PR body. No byte moves; it is a comment inside a Worth flagging for the reviewer: the mistake was mine in method, not arithmetic. |
andrewboudreau
left a comment
There was a problem hiding this comment.
CHANGES REQUESTED — narrower than last round. One ask taken and taken well; one copy of it missed; the blocker untouched.
The correction is right, and I verified it rather than accepting it
ed243e83c01f replaces the absence claim in include/dScMgBase_c.h with the presence one, and every load-bearing word of the new text checks out against the overlay image:
site A 0x020aedec e5911070 ldr r1,[r1,#0x70]
0x020aedf0 e12fff31 blx r1
0x020aedf4 e28dd010 add sp,sp,#0x10
0x020aedf8 e8bd4010 ldmia sp!,{r4,lr}
0x020aedfc e12fff1e bx lr
site B 0x020aee90 .. 0x020aeea0 byte-identical sequence
site C 0x020b06cc e5911070 ldr r1,[r1,#0x70]
0x020b06d0 e12fff31 blx r1
0x020b06d4 ea000007 b 0x020b06f8
0x020b06f8 e2840901 add r0,r4,#0x4000 <- r0 dead here
0x020b06fc e5901628 ldr r1,[r0,#0x628]
"Two tail-call it out without reading it, the third overwrites r0" is exactly what the bytes say. add sp,sp,#0x10; pop {r4,lr}; bx lr is quoted correctly instruction for instruction, and the branch to 0x020b06f8 resolves where you say it does. Good — this is now the strongest form of the argument, and it survives being re-derived, which the previous form did not.
One nit, non-blocking: the short paragraph up at the slot-21 block says the third caller "overwrites r0 on the next instruction." The next instruction is the b; the overwrite is one hop later at the branch target. The long paragraph downstairs gets it right. Same sentence, two precisions — take the downstairs one.
1. The third copy is still there, and now the PR contradicts itself
I asked for the claim in three places. Two are fixed. src/_ZN12dScMgSlot1_c19OnHitFromUnderneathEv.cpp is unchanged and still says:
"No caller loads vtable+0x70 anywhere in ov004 or ov006, so nothing consumes a result either."
That file and include/dScMgBase_c.h are both touched by this PR and now state opposite facts about the same three instructions. Before this commit the tree was uniformly wrong; now it is inconsistent, which is the harder version to notice later. Same edit as the header's, one file over.
2. The blocker is untouched
Nothing in ed243e83 addresses it, so restating it in one paragraph rather than re-arguing it.
include/dScMgBase_c.h declares virtual int OnHitFromUnderneath();. Neither definition in this PR honors that:
src/_ZN12dScMgSlot1_c19OnHitFromUnderneathEv.cppisint dScMgSlot1_c::OnHitFromUnderneath()and falls off the end —dScMgBase_c::OnHitFromUnderneath(); SetSubBg1Offset(0x100, 0);and then the closing brace. That is undefined behaviour. It byte-matches, but it byte-matches by accident of whatSetSubBg1Offsethappens to leave in r0, which is not a thing the language promises and not a thing a reader can check.src/_ZN11dScMgBase_c19OnHitFromUnderneathEv.cppdefines the mangled symbolextern "C" **void**. The Door route means the compiler never compares that definition against theintdeclaration, so a declaration/definition disagreement sits in the tree with nothing able to flag it.
And the alternative costs nothing — measured under your own headers at this PR's tree, pinned compiler 2004/b56:
headers declare virtual void
_ZN11dScMgBase_c19OnHitFromUnderneathEv ov004 0x020af04c 0x48 -> (True, '2004/b56')
_ZN12dScMgSlot1_c19OnHitFromUnderneathEv ov006 0x0210c4b8 0x24 -> (True, '2004/b56')
headers declare virtual int (this PR, unchanged)
_ZN11dScMgBase_c19OnHitFromUnderneathEv ov004 0x020af04c 0x48 -> (True, '2004/b56')
_ZN12dScMgSlot1_c19OnHitFromUnderneathEv ov006 0x0210c4b8 0x24 -> (True, '2004/b56')
Both bodies, both spellings, all four match. Your "void compiles to the same bytes" is measured, not asserted — which means the ROM has no opinion here and the tiebreaker is which spelling the tree can state without lying. void can be written in both headers and both bodies with no UB and no hidden disagreement. int cannot be written in either body without one.
Either resolution merges. Flip to void, or keep int and honor it — give the base a real int return and give Slot1's body one. What I cannot merge is a declaration that neither definition obeys, in the header that is this campaign's permanent ledger.
3. Still on the table, still optional
The arity upgrade from last round. MEASURED ONCE rather than twice is still in both files, and the callers retire it: all three ov004 sites put the call target in r1, which mwcc can only do when r1 carries no argument, and the two 0x70 dispatch sites in ov002 are a two-argument control in the same scan that put it in r2 with mov r1,r5 alongside. That is caller-side arity with its own control, from outside the family's bodies — strictly stronger than the callee-side argument you have, and it refutes dActor_c &other independently. Take it or leave it; it does not gate the merge.
Everything else from last round stands verified: census 6 tables / 2 addresses / 26 inheriting, the sixth name correction, the converted-baseline.json reconciliation complete across all three renamed paths, and the 27-method scaffold removal.
andrewboudreau
left a comment
There was a problem hiding this comment.
Follow-up, not a new ask — this delivers the measurement I promised on #2102 and corrects my own figure downward in confidence and upward in scope.
I gave you a number and hedged it. Here is the real one.
On #2102 I wrote:
Of the 8 that matched, 3 disagree ... That 3/8 is a floor, not a census — my matcher only walks zero-arg virtuals in the first struct of each header, so the true count is higher.
I rebuilt the matcher to parse backward out of the mangled name — recovering (class, method) from _ZN<len>Class<len>MethodE... — across every struct in every header, any arity. The corrected figure at the top of this stack (#2102) is 4 disagreements out of 9 joinable rows, and it is not a floor. It is complete. Nothing else in decl_common.h is structurally capable of exhibiting this defect. Accounting for all 510 mangled tokens there:
| population | count | can it disagree on a return type? |
|---|---|---|
_ZTV / _ZTI / _ZTS data |
441 | no — data, no return type |
ctors / dtors (C1 C2 D0 D1 D2) |
13 | no — no return type |
SDK namespace free functions (GX, GXS, G3X, CP15, IRQ, Memory, Sound, cstd, G2, G2S) |
42 | no — not class virtuals |
non-nested _Z free functions |
2 | no |
class methods not declared virtual |
3 | not via a vtable — and I checked all three; they agree anyway |
| class virtuals joined and compared | 9 | yes — 4 disagree |
So this is the whole exposure, not a sample.
The four
| class | method | class header says | decl_common.h says |
introduced by |
|---|---|---|---|---|
dScMgBase_c |
BeforeInitResources (slot 1) |
bool |
int |
pre-exists on main |
dScMgBase_c |
AfterInitResources (slot 2) |
void |
int |
pre-exists on main |
dScMgBase_c |
OnAimedAtWithEgg (slot 29) |
int |
void |
#2100 |
dScMgBase_c |
OnAimedAtWithEggReturnVec (slot 30) |
int |
void |
#2102 |
Line references on pr/2102: include/decl_common.h:1457, :1458, :2321, :2322; include/dScMgBase_c.h:33, :34, :503, :577.
Three things worth pulling out of that table:
- Two of the four are not this stack's debt.
BeforeInitResourcesandAfterInitResourcesdisagree onmaintoday, before any of #2099/#2100/#2102. I am not blocking on them and I am not asking you to own them. I list them because the fix site is the same two files and the same edit, so sweeping all four costs you almost nothing more than sweeping two. - The defect is bidirectional. Slots 1/2 have
decl_commonclaimingintwhere the header saysbool/void; slots 29/30 havedecl_commonclaimingvoidwhere the header saysint. So this is not one author's habit running one direction — it is the structural consequence of the two declaration sites never being checked against each other. decl_common.his the minority witness in all four. For slots 1/2,dScMgBase_c.handdScMgD3DBase_c.hagree with each other (bool/void) anddecl_commondiffers. For slots 29/30,dScMgBase_c.h,dScMgD3DBase_c.handdScMgSlot3_c.hall agree (int) anddecl_commondiffers. That is suggestive ofdecl_commonbeing the cheaper side to correct — but suggestive is all it is, see below.
What I have and have not measured
I want to be exact about this, because I got it wrong once already in this stack and retracted it.
- Slot 29, byte-verified, end to end. Flipping
decl_common.h:2322tointand the definition tointreturning the value already inr0reproduces the shipped bytes for the base body and both forwarders. Flipping only one of the two sites is anillegal function overloadingcompile error, not a measurement. The ROM is neutral here —voidalso matches — so this is a free choice, and consistency is the only argument. That neutrality is the retraction of my earlier "slot 29 isvoid, proven by build" claim; a constantreturn 0;manufactured a fake one-word refutation. - Slot 30: not measured. I verified the caller discards
r0on both arms (add r0, r4, #0x4000reached from both sides of the branch at0x020ae170/0x020ae180), so the ROM does not pin a return type here either — but I have not built either spelling. Do not read my slot-29 result as covering slot 30. - Slots 1 and 2: not measured at all. I have built nothing for these. Note that
boolvsintis not cosmetic under this compiler — aboolreturn goes through a widening cast that anintreturn does not — so please treat slot 1 as a real A/B and not a typo sweep, and if it costs bytes, say so and leave it.
The ask is unchanged
Still one fix, still here at the bottom, still not a separate PR: make the two declaration sites agree for the slots this stack touches, and #2100 and #2102 inherit it. Whichever direction you pick, please flip both sites in the same commit — the half-flip does not compile, which is the one mercy in this defect.
The reason it is worth doing rather than waiving: one mangled symbol with two declared return types in headers that never include each other is invisible to the compiler and resolved by the linker on name alone. Nothing in CI catches it today. Four is a small enough number to close by hand right now; it will not stay small as the slot work continues.
Separately, and explicitly mine to fix rather than yours: I still owe a correction to the row-18 sentence in include/dScMgBase_c.h and the "seven-for-eight" tally in that header, which should read six-for-seven. I will land that after the stack stops moving.
andrewboudreau
left a comment
There was a problem hiding this comment.
Still blocked on the same one line — and seven PRs are now stacked behind it
I've reviewed this three times and the block is unchanged, so let me make it as cheap as possible to clear rather than restate the argument again.
The blocker, in one place
include/dScMgBase_c.h declares:
virtual int OnHitFromUnderneath();Neither definition in this PR honors that declaration:
src/_ZN12dScMgSlot1_c19OnHitFromUnderneathEv.cppisint dScMgSlot1_c::OnHitFromUnderneath()and falls off the end of the function. Returning nothing from a non-voidfunction is undefined behaviour. It byte-matches, but it byte-matches by accident of whatSetSubBg1Offsethappens to leave inr0.src/_ZN11dScMgBase_c19OnHitFromUnderneathEv.cppdefines the mangled symbol asextern "C" void— which does not contradict the ROM either, but does contradict the header.
The project's stated goal ordering is accurate C++ and byte-match first, readability last. A header that declares a return type no definition obeys fails the first of those, not the last, which is why I can't downgrade this to fix-forward even though nothing about the bytes is at risk.
The ROM is neutral here — measured
I compiled all four combinations under the pin. Every one reproduces:
build_pin.verify(...) -> (True, '2004/b56') int / void, both definitions
So this is purely a question of what the source should say, not what the cartridge will accept. That also means either resolution merges — I am not asking you to prefer one.
The exact edit
Option A, which is what the rest of the stack converged on. #2108 settled its own slot as void, and #2110 and #2112 both follow that:
- virtual int OnHitFromUnderneath();
+ virtual void OnHitFromUnderneath();in include/dScMgBase_c.h, then drop the int from src/_ZN12dScMgSlot1_c19OnHitFromUnderneathEv.cpp's definition so it reads void dScMgSlot1_c::OnHitFromUnderneath(). The extern "C" void base definition already agrees and needs no change.
Option B: keep int and give both bodies a real return. That is more work and the evidence for int here is weaker than it is at slot 35, where the ROM is actually seen doing cmp r0, #0 after every dispatch. I'd take A.
Either one, and I re-read and merge.
One thing still outstanding from my earlier reviews
src/_ZN12dScMgSlot1_c19OnHitFromUnderneathEv.cpp still carries the third copy of the "nothing loads vtable+0x70" claim. Two copies of it were corrected in earlier rounds; this one wasn't. It's prose, non-blocking on its own, but please take it in the same push.
Why the urgency
The slot stack is #2099 → #2100 → #2102 → #2106 → #2107 → #2108 → #2110 → #2112. Seven PRs sit above this one and the merge order is forced by the chain, so nothing in the range 18–35 can land until this bottom PR moves. Three of those seven are approve-on-content from me already; #2112 completes the whole 18–35 range and is otherwise ready.
I'd rather not close and re-cut a stack this deep — one push here unblocks all of it.
andrewboudreau
left a comment
There was a problem hiding this comment.
I went and measured slot 28 in the cartridge. It answers the question — and it corrects a claim in the file.
Your new comment is honest about the gap and says so plainly: "THE RETURN TYPE IS THE ONE THING HERE THAT IS NOT MEASURED." That's the right thing to write when it's true. But it's the fourth round on one line with seven PRs stacked behind it, so rather than restate the block I went and made the measurement.
Result: every slot-28 dispatch in the ROM discards its result. int has no cartridge support at all.
Method, and the positive control first
Word-by-word ARM decode of ov004 and ov006 — the only two modules that name any dScMg symbol. A dispatch is counted only as a load+call pair: ldr rX,[rY,#off] followed within an 8-instruction window by blx rX, breaking the moment rX is redefined or the basic block ends. A bare ldr at the offset is not a dispatch.
I ran it at +0x68 (slot 26) first, because your own comment says that slot is dispatched (self->Init() → vtable+0x68):
ov004 words=17728 decodable=16819 +0x68 -> 24 dispatch pairs
and most are followed by cmp r0,#2 or cmp r0,#0 — including the one at 0x020af074, inside func_ov004_020af04c, which is exactly the "falls out of a virtual call it has just compared against zero" your comment describes. So the method finds dispatches, the offset convention is right, and the ROM demonstrably does consume some slots' results. A zero at +0x70 would mean something.
At +0x70 (slot 28)
ov004 -> 3 dispatch pairs
ov006 -> 0 dispatch pairs
All three dispatch on this (mov r0,r4; ldr r1,[r0]; ldr r1,[r1,#0x70]; blx r1, with r4 the incoming this). What each does with r0:
Site 3 — 0x020b06cc, inside the _ZN11dScMgBase_c14BeforeBehaviorEv region:
0x020b06cc ldr r1, [r1, #0x70]
0x020b06d0 blx r1
0x020b06d4 b #0x20b06f8
...
0x020b06f8 add r0, r4, #0x4000 <- r0 overwritten on arrival
Discarded.
Sites 1 and 2 — 0x020aedec and 0x020aee90, both in func_ov004_020aeb24, are in tail position:
0x020aee90 ldr r1, [r1, #0x70]
0x020aee94 blx r1
0x020aee98 add sp, sp, #0x10
0x020aee9c pop {r4, lr}
0x020aeea0 bx lr
r0 untouched between the call and the return — so this function forwards slot 28's r0. That only matters if its callers consume it, so I chased one level up. func_ov004_020aeb24 has exactly one direct caller across ov004 and ov006:
_ZN11dScMgBase_c14BeforeBehaviorEv:
0x020b070c bl #0x20aeb24
0x020b0710 add sp, sp, #4
0x020b0714 mov r0, #0 <- forwarded value clobbered here
0x020b0718 pop {r4, r5, r6, r7, lr}
0x020b071c bx lr
The forwarding chain terminates in mov r0, #0. Discarded.
Three dispatch sites, three discards, no consumer anywhere.
The correction
The file comment currently says:
No caller loads vtable+0x70 anywhere in ov004 or ov006, so nothing consumes a result either.
The conclusion is right but the stated reason is not true, and it should not land in the tree as written. Three callers in ov004 do load vtable+0x70. Separately, ov006 has 19 loads at +0x70 — but every one of them is off an object register, not a vptr:
0x020eb3ac ldrsh r0, [r4, #0x90]
0x020eb3bc ldr r0, [r4, #0x70]
0x020eb3c0 cmp r0, #0
Those are field reads at object offset 0x70 with no call attached — nothing to do with slot 28. So the sentence is wrong in both directions at once: it misses three real dispatches and it would have counted nineteen non-dispatches if the scan had been by load rather than by pair. Suggested replacement:
Three call sites dispatch slot 28, all in ov004 —
0x020aedecand0x020aee90infunc_ov004_020aeb24, and0x020b06ccindScMgBase_c::BeforeBehavior. All three discard the result: the first two forwardr0in tail position toBeforeBehavior, which overwrites it withmov r0, #0; the third branches toadd r0, r4, #0x4000. Nothing in the ROM consumes a slot-28 return value.
So the edit is the same one, now with evidence instead of preference
- virtual int OnHitFromUnderneath(); /* slot 28 */
+ virtual void OnHitFromUnderneath(); /* slot 28 */and drop the int from src/_ZN12dScMgSlot1_c19OnHitFromUnderneathEv.cpp so it reads void dScMgSlot1_c::OnHitFromUnderneath(). The extern "C" void base definition already agrees.
I want to be precise about what this proves, because I've had to retract an overreach in this exact family before (#2086, slot 18): this does not prove the original signature was void. The ROM is neutral on what a function declares. What it proves is that the one argument offered for int — dActor_c.h — is now the only thing supporting it, and there is no consumer to corroborate it. And dActor_c.h is the weakest possible source here: it's a sibling hierarchy that diverges at slot 31, which is why slots 18–30 borrowed its labels by index rather than by inheritance. Its return types aren't a "hint" for slot 28; they're the same index transplant that has been wrong on every parameter list this campaign has checked — including, by your own comment, this file's dActor_c &other.
Meanwhile the current form is int with a body that falls off the end, which is undefined behaviour regardless of what the cartridge wants. That has to change either way. Given a choice between void (no consumer found, matches the base definition, matches where #2108/#2110/#2112 landed) and int plus two invented return statements (no evidence, more code), void is the one the measurement points at.
Change it and I'll re-read and merge, and the stack behind it can start moving. If you'd rather not respin this branch for two words, say so and I'll open the one-line PR against cpp/minigame-slot28 myself — your call, I'm not going to push to your branch uninvited.
andrewboudreau
left a comment
There was a problem hiding this comment.
Addendum to the scan above — widening it does not change the conclusion, but it changes one of my numbers, so here it is.
I scoped that scan to ov004 and ov006, the two modules where the family lives. Re-run across arm9 and all 103 overlays, ldr rN,[rM,#0x70] + blx rN finds 5 sites, not 3. The two extra are in ov002:
ov002 0x020cf1d8 DISCARDED add r0, sp, #0x24
ov002 0x020eecfc DISCARDED mov r0, #1
Both overwrite r0 immediately, so all five discard and the conclusion stands unchanged.
Worth flagging why I still quote the ov004/ov006 number as the meaningful one: a byte offset does not identify a class. Slot 28 of some other hierarchy also sits at +0x70, and ov002 is not part of this family. The same scan at +0x48 makes the point sharply — image-wide it finds 19 sites that genuinely consume r0 (cmp r0,#6 in ov062, cmp r0,#6 and cmp r0,#4 in ov084), and every one of them belongs to a different class. Restricted to ov004/ov006 that offset has zero consumers.
So: scan wide, then filter to the modules the class lives in — which is exactly what the slot-29 block in dScMgBase_c.h already does when it says "35 sites, of which exactly ONE lies in ov004 or ov006". I'm holding my own scan to that same standard, and on that standard slot 28 has three sites and no consumer.
andrewboudreau
left a comment
There was a problem hiding this comment.
The block is cleared — #2124 is the two-word fix, against this branch
Thanks for ed243e83c. Your three dispatch sites match my scan exactly, and your note on method — that ldr rD,[rN,#imm] is not a dispatch on its own, 126 naive hits at +0x70 and only 3 real ones — is the right correction to make. I widened the same pair-scan across arm9 and all 103 overlays afterward and got 5 sites rather than 3; the two extra are in ov002 and both discard r0, so your conclusion holds and gets stronger. Detail in my addendum above.
That leaves exactly one thing outstanding, and rather than ask for a fifth round I've done it: #2124, opened against cpp/minigame-slot28, not pushed to it.
It flips OnHitFromUnderneath from int to void in the three headers of this hierarchy and in dScMgSlot1_c's override. Both other definitions were already void; the declaration was the outlier, and the override was falling off the end of a non-void function. Measured on the merge result: 11,088 reproducing, 0 mismatching, 106/106 exact, PASS — your figures, unchanged.
One thing to check if you'd rather write it yourself: three declarations move, not one. dScMgBase_c.h:420, dScMgD3DBase_c.h:97 and dScMgSlot1_c.h:158 all say int, and they have to move together or the overrides stop overriding and mwcc numbers a new slot at 29 — the exact trap your own slot-28 comment documents. dActor_c.h:145 must not move: parallel hierarchy, takes a dActor_c &, different function sharing an index.
And to be unambiguous about what it claims: nothing. The ROM is neutral at slot 28, the slot stays UNPINNED, and int and void compile to the same bytes exactly as you wrote. void is chosen because it is the only spelling both definitions can honour. I retracted a "void, proven by the build" claim of my own at slot 29 earlier in this campaign and I am not making that mistake again.
How I would like to proceed: merge #2124 into this branch and I will re-read and merge #2099 immediately. If you would rather cherry-pick it, rewrite it, or reject the wording, all fine — say so and it is yours. If I hear nothing I will merge #2124 into this branch myself to get the stack moving, since the block was mine to begin with and it is not fair to leave eleven PRs frozen behind my own objection. That merge touches only your branch; #2099 itself still goes through the normal gate.
For the record, the stack behind this: #2100 to #2102 to #2106 to #2107 to #2108 to #2110 to #2112, and #2114 to #2115 to #2116 to #2118. Reviews are posted on all of the ones I have reached.
andrewboudreau
left a comment
There was a problem hiding this comment.
Block cleared. Merging.
My earlier block on this PR was the header/definition split at slot 28 — the three headers say virtual int OnHitFromUnderneath() while the two bodies carrying the mangled names say void. I opened #2124 against this branch to fix it. I was wrong about where that fix belongs, and I have closed #2124. The reasoning matters for the rest of the stack, so it is worth stating here.
Why this branch is safe as it stands
The split at this branch is invisible to the compiler, by construction:
include/dScMgBase_c.h:456 virtual int OnHitFromUnderneath();
src/_ZN11dScMgBase_c19OnHitFromUnderneathEv.cpp:32 extern "C" void _ZN11dScMgBase_c19OnHitFromUnderneathEv(void *c)
src/_ZN14dScMgD3DBase_c19OnHitFromUnderneathEv.c:26 void _ZN14dScMgD3DBase_c19OnHitFromUnderneathEv(void)
src/_ZN12dScMgSlot1_c19OnHitFromUnderneathEv.cpp:43 int dScMgSlot1_c::OnHitFromUnderneath()
Two of the three bodies are free functions whose identifier is a hand-written mangled name. They are not member definitions, so nothing cross-checks them against the class declaration — which is exactly the hazard #2114 exists to retire ("a free function can hold a signature the class never agreed to"). The one body that is a real member, dScMgSlot1_c's, agrees with its header. There is no ill-formed declaration here and no gate that this branch turns red.
Why the fix does not belong on this branch
8fb19d997, twelve PRs up in #2114/#2115/#2116/#2118, converts all three slot-28 bodies into real members and spells them int to match these headers. Flipping the headers to void down here and leaving those definitions up there produces a textually clean merge that does not compile. Measured, by applying #2124's header flip to the stack top (#2118 head 66f3c9360) and building:
_ZN14dScMgD3DBase_c19OnHitFromUnderneathEv.cpp:31: identifier 'dScMgD3DBase_c::OnHitFromUnderneath()' redeclared
was declared as: 'void ()' now declared as: 'int'
_ZN12dScMgSlot1_c19OnHitFromUnderneathEv.cpp:44: same
_ZN11dScMgBase_c19OnHitFromUnderneathEv.cpp: same
So the slot-28 return type is a change against the top of the stack, where headers and definitions can move in one commit. I will re-cut it there once the stack has landed. I still think void is right — no slot-28 body in the tree returns a value, and #2114's own prose calls the int "a hint, not a measurement" and notes void is byte-identical — but that is a separate PR's argument, not this one's.
Gate evidence
tools/premerge_check.py 2099, run on the real merge tree, base origin/main @ fd15c7a32656:
merge tree 81b91c0313ec
gate base merge verdict
converted-ratchet pass pass ok
dead-references pass pass ok
duplicate-sources pass pass ok
header-offsets pass pass ok
langmode-ratchet pass pass ok
layout-check pass pass ok
src-tu-refs pass pass ok
source-coverage pass pass ok
RESULT: nothing goes green -> red.
Bytes: a full rombuild.py -j 16 --no-rom of this branch's content (as carried by #2124, which is this tree plus a return-type flip that emits no symbol, vtable or config change) reports 11,088 / 11,088 functions reproducing, 0 mismatching, 106/106 modules exact, ROM-build analysis: PASS. The repo's own validator is green on this head independently.
Merging with a merge commit rather than a squash so #2100 retargets to main showing only its own commits.
Twenty-eighth of the eighteen keystone slots, the second and LAST occupied one,
and the first whose return type no body pins at all.
THE SIGNATURE
virtual int OnHitFromUnderneath();
Arity: no explicit parameters, and this is MEASURED ONCE rather than twice --
weaker evidence than slot 27's, said plainly rather than dressed up.
ov004:0x020af04c opens
mov r4, r0and then WRITES r1 withadd r1, r4, #0x4000before ever reading it, and reads no other argument register anywhere.
dScMgSlot1_c's override at ov006:0x0210c4b8 cannot corroborate it the way its
slot-27 override did: it calls the base as its very first act, so a second
argument would ride through r1 untouched and leave no trace either way.
include/dActor_c.h:145 spells
dActor_c &otherand include/dScMgSlot1_c.h hadcopied it -- the seventh time that header would have led us wrong on a
parameter list.
Return type
intis A HINT, and slot 28 is the first slot in this campaignwhere that has to be admitted. The base body's early exit --
cmp r0,#0; popeq {r4,lr}; bxeq lr-- returns the zero it has just compared,and the fall-through returns whatever Enable3dEngines left; neither is a
deliberate result. dScMgSlot1_c's override sets no r0 either. And nothing
anywhere in ov004 or ov006 loads vtable+0x70, so there is no caller to consume
one.
intrests on dActor_c.h's seven-for-eight count on RETURN types and onnothing else;
voidcompiles to the same bytes. It gets no row indScMgBase_c.h's tally table, which is the eight slots whose own bodies DO pin
one -- the honest place for it is as the stated exception, not a ninth row.
THE TRAP, FOR THE SECOND AND LAST TIME IN THIS CLASS
dScMgSlot1_c already carried this member -- declared as a NEW virtual with
dActor_c.h's parameter list, landing on index 28 by arithmetic because the base
stopped at 27.
OnHitFromUnderneath()andOnHitFromUnderneath(dActor_c &)are DIFFERENT FUNCTIONS: declaring the base's alone would have given 28 to the
base, made dScMgSlot1_c's a new slot at 29, and put _ZTV12dScMgSlot1_c straight
back into DIFFERS -- with rombuild green the whole way. Only romdata_check
sees it.
So the reconciliation is part of this same commit, and lands in all five places
a rename has to reach: ...19OnHitFromUnderneathER8dActor_c ->
...19OnHitFromUnderneathEv in ov006/symbols.txt and delinks.txt, the file
renamed with it, config/converted-baseline.json repointed (tiers_ratchet.py has
no rename detection and would score the old path GONE), the prose path in
include/dScMgBase_c.h updated where the dead-references gate reads it, and the
body's forwarding call rewritten as a qualified
dScMgBase_c:: OnHitFromUnderneath(), which suppresses virtual dispatch and emits the samedirect
blthe ROM has.dScMgSlot1_c now declares nothing mwcc has to number for itself. The trap is a
property of the FAMILY rather than of that one class, so the sentinel comment
for slots 29-35 now says so: check every descendant header before declaring
slot N. dScMgAmida_c's
Unk36is the last early declaration left, and slot 35lands it.
SIX TABLES, TWO DECLARATIONS
dScMgD3DBase_c ov006:0x020e6d8c -- backs its own table and all four
children's (Jump, Jump2, Trampoline,
Trampoline2), which declare nothing
dScMgSlot1_c ov006:0x0210c4b8 -- reconciled above
NAME CORRECTION, THE SIXTH
0x020e6d8c carried
recovered name: dScMgTrampoline2_c_OnHitFromUnderneath.Five vtables point at it -- dScMgD3DBase_c's and all four of its children's --
and a body that appears in a class's table AND in every child's is supplied by
that class. Fifth on this class after slots 24, 25, 26 and 27; sixth in the
campaign. Kept visible above the correction, as before. Its twelve bytes are
a long-branch veneer,
ldr ip,[pc]; bx ip; .word 0x020af04c, not a linkerartifact: the twenty-six tables that do NOT override this slot hold 0x020af04c
directly, and vtable words are data, not branches.
THE SHADOW SCAFFOLD COMES OFF THE BASE BODY
src/func_ov004_020af04c.cpp carried a local
struct Baseof twenty-sixplaceholder virtuals plus
struct Obj : Basewith achar pad[0x4627],existing so that one call would compile to a load of vtable+0x68. Slot 26 is
OnHitByCannonBlastedChar and dScMgBase_c declares it now, so the class does that
job itself. The file becomes
src/_ZN11dScMgBase_c19OnHitFromUnderneathEv.cpp and reads:
-- a hit from underneath closes the three-item overlay menu OnHitByMegaChar
opens, resets the polymorphic touch-icon set, and re-enables the 3D engines only
if the class's own OnHitByCannonBlastedChar says so. Both raw offsets go with
the scaffold: 0x4628 is mMenuOpen, 0xf4 is mTouchOptions. Byte-identical.
VERIFICATION
rombuild -j 16 --no-rom : 11,088 / 11,088 reproducing, 106/106 exact, PASS
romdata_check : partialBytes 11,256 -> 11,376 (+120)
verifiedBytes 35,400 -> 35,404 (+4)
verified 465, partial 253, differs 6,
differsRecords 14 -- all four unchanged
romdata_check --files src/_ZN12dScMgSlot1_cD1Ev.cpp : DIFFERS 0
check_dead_references, port_refcheck, check_header_offsets --changed,
langmode ratchet : all PASS
Both byte deltas decompose exactly, over the 33 tables in the hierarchy -- the
32 descendants the census counts plus dScMgBase_c's own:
30 x 4 = +120 in the PARTIAL pool. Thirty, not thirty-three, because
_ZTV12dScMgSlot1_c does not change length (it already emitted through index
28), _ZTV12dScMgAmida_c is scored DIFFERS so its bytes are in neither pool,
and _ZTV14dScMgD3DBase_c is not PARTIAL at all --
1 x 4 = +4 in the VERIFIED pool, and it is D3DBase's. dsd cuts that symbol
at 80 bytes -- TWENTY slots, since
_ZTV*in symbols.txt points at slot 0 andthe two-word offset-to-top/typeinfo header is outside the symbol's range --
well short of the table it names; it is the only
vtable in the family whose configured extent is shorter than what we emit,
which is why it alone scores VERIFIED while still being an incomplete table.
A VERIFIED vtable is not a complete vtable. It gains a word per slot.
Amida's single differing word simply moves from index 28 to index 29 -- Unk36
is one index closer to 36, as its header says, and slot 35 lands it.
Targets
maindirectly: #2098 (slot 27) landed while this was being verified,so GitHub retargeted it.
premerge_check --base origin/main 2099re-run againstthe new base @ 3c7d8a3 -- all eight static gates pass on both sides,
nothing goes green -> red.
Slots 18-28 are now landed or open. Seven left: 29-35, and slot 35 closes
_ZTV12dScMgAmida_c, the family's last DIFFERS table.