Skip to content

GC2D/GCConsole2: inline boundaries, bounds accessors, and the water gauges (27 -> 33) - #154

Open
KakarottoCake wants to merge 15 commits into
doldecomp:mainfrom
KakarottoCake:gcconsole2-inline-boundaries
Open

GC2D/GCConsole2: inline boundaries, bounds accessors, and the water gauges (27 -> 33)#154
KakarottoCake wants to merge 15 commits into
doldecomp:mainfrom
KakarottoCake:gcconsole2-inline-boundaries

Conversation

@KakarottoCake

@KakarottoCake KakarottoCake commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #152, continuing GCConsole2 as asked.

mario/GC2D/GCConsole2 27/60 -> 33/60. Project 8098 -> 8104. Six functions now
match byte for byte: startDisappearTimer, startAppearCoin, startAppearRedCoin,
startInsertJetBalloon, startDisappearStar, startDisappearCoin.

After that I went back over the ones you called out as poorly matched. Those did not
land byte matches, but they moved a long way and the reasons are concrete:

function before after
drawWater 83.82% 99.12%
drawWaterBack 74.67% 87.20%
processAppearStar 92.09% 95.99%
startAppearBalloon 92.46% 94.17%
processDownCoin 95.82% 98.88%
processAppearCoin 95.80% 98.86%
processDisappearBalloon 99.72% 99.89%

Full disclosure below, including the things I got wrong and the things I am still
stuck on, so you do not have to re-derive any of it.


Four mechanisms did most of the work

1. Retail doing arithmetic in more steps than us means an inline boundary

Where we wrote 525 - y1, retail emits two instructions:

subfic r3, r0, 0x1d1   ; 465 - y1
addi   r0, r3, 0x3c    ; + 60

not the folded subfic r0, r0, 0x20d. Writing 465 - y1 + 60 by hand does not
reproduce it -- that folds straight back. The constant can only arrive still waiting
for its + 60 if it crossed an inline boundary, because folding happens before
inlining and there is no re-fold afterwards.

That is getOffsetForBelowScreen, which @TheAzack9 asked me to hold off on during the
#152 review as unproven. The codegen now argues for it, so I have restored it under his
name with his // Possibly inline marker. getOffsetForAboveScreen is the same
argument: retail emits neg then add where we folded to a single subf.

Both are used only where an operation sits outside the boundary -- + 60,
+ unk26A, - getHeight(). That is the only place the boundary is observable.
Routing the plain 465 - y1 sites through the same helper costs matches; I measured
it and did not do it. So retail genuinely has both spellings, which is a bit
unsatisfying but is what the object says.

He also thought this belonged on TExPane rather than as a file-local. I have not done
that -- it is his call, and this PR should not grow a new API on his behalf.

2. Each inlined scalar accessor call costs exactly 8 bytes of stack

Measured across six functions before changing anything -- frame deficit against number
of direct ->mInitialBounds reads:

function short by uses
startAppearCoin 8 1
startAppearTank 8 1
startAppearRedCoin 16 2
startInsertJetBalloon 16 2
startAppearStar 16 2
startDisappearStar 16 2

Six for six, and those functions were otherwise byte-identical -- pad the frame by the
delta and the instruction diff goes completely empty.

The catch was the return type. The existing const JUTRect& getInitialBounds() gets
every frame exactly right but emits an extra address computation:

target:  lwz  r3, 8(r28)
ours:    addi r3, r28, 4 ; lwz r3, 4(r3)

A per-field int getInitialY1() const gives both the reserved slot and the direct
load. JUTRect already exposes getWidth()/getHeight() that way, so this follows
what is already there rather than inventing a shape. Returning JUTRect by value is
decisively wrong here -- 8099 -> 8091 project, 28 -> 21 unit -- which fits, since
JUTRect is 16 bytes and every delta is 8.

This landed four matches at once and also corrected frames I was not aiming at:
startDisappearCoin +24 -> +8, endCameraDemo +32 -> +24, startAppearTelop
+48 -> +40, and startAppearTank/startAppearStar to exact.

3. The register save mask says which values were floats

This is what cracked drawWater, and it is the one I would reach for first next time.
The target saves f28-f31 and only r20-r31; we were saving two float registers
and three more integer ones. Retail was holding values in float registers that we
held in integer registers, which for a function whose only floating-point work is
GXPosition2f32 points straight at the quad corners.

Three separate things fell out of that, and all three were needed:

  • top and bottom are f32 locals. The target converts each exactly once,
    before GXBegin, and keeps them in f29/f28 across all four vertices.
  • left and right are not locals at all. The target re-loads
    unk2BC[layer].x1/.x2 from memory and re-converts them at every vertex -- four
    loads and four xoris conversions for two values. Hoisting them, as either int or
    f32, is exactly what was costing the two extra float registers. Inline
    (f32)unk2BC[layer].x1 at each call site is what the target does.
  • The height is the picture's, not the cached rect's. The target reads +0x18 and
    +0x20 off unk2A0[layer], which is J2DPane::mBounds.y1/.y2 -- i.e.
    J2DPane::getHeight() -- where we were subtracting inside unk2BC[layer]. The two
    rects hold the same numbers here, so this is not a behaviour change, but it is the
    one retail actually reads.

83.82% -> 99.12%. This does not transfer to drawWaterBack, and I checked before
assuming it would: there the target converts all eight position components separately
at each vertex, which is what drawGaugeQuadF32's int top, int bottom parameters
already produce. The two functions genuinely differ.

4. An assigned JUTRect inlines; a constructed one calls JUTRect::copy

Copy-construction goes through the out-of-line JUTRect::copy. Assigning to a rect
that already exists inlines member-wise as four lwz/stw pairs. That asymmetry is
visible in the object and it identifies which of the two the source used.

processAppearStar ended with JUTRect bounds(...) and JUTRect bounds2(...) for its
two emitter-centring blocks. The target has only one rect: it calls JUTRect::copy for
the first and inlines the second copy into the same stack slot. Reusing the one
variable took it 92.09% -> 95.99%. drawWater already shows both forms side by side
for the same reason, so this is the shape the file was written in.

Read the other way round, the same rule settles the getContentsBounds TODO that was
already sitting in startAppearBalloon. The target copies the contents rect twice
there -- once out of mContentsBounds at +0xec into a stack temporary, then again from
that temporary into the named local. A const JUTRect& return cannot produce that; a
by-value return does. So J2DWindow::getContentsBounds() returns JUTRect by value.
92.46% -> 94.17%.

processDisappearBalloon looked like it contradicted that, because it copies only
once. It does not: the target copies straight into a slot it reads getHeight() out of
two instructions later and never touches again -- an unnamed temporary, not a local.
The rect was only ever there to be measured, so the local goes and the call reads as
one expression. 99.72% -> 99.89%.


The rest of the commits

loadAfter was calling the wrong virtual. The target dispatches through the pane's
vtable at +0x14 -- J2DPane::resize, the fourth virtual after the destructor, move
and add, and the one J2DTextBox overrides. We called setFontSize, which is not
virtual at all, and read gpSystemFont's +0x24 (getHeight) where the target reads
+0x28, annotated in JUTFont.hpp as getWidth(), shifted left by 10. The bounds also
come from a stack copy via JUTRect::copy, and one height serves both boxes.
92.33% -> 95.24%.

I left the << 10 as the shift the code performs. I do not know what unit that width
is in and would rather leave it plain than name it wrongly -- flagging it in case you do.

load was filling only half the life-pane array. unk17C is J2DPane*[18]
holding nine pairs and unk1D0 is JUTRect[9], one per pair, but the loop indexed
unk17C[i] and unk17C[i + 1] for i in 0..8. Each iteration overwrote the previous
pair's second pane, indices 9 through 17 were never written, and unk1D0[i] took its
bounds from whichever pane landed at [i]. Everywhere else already indexes [n * 2]
and [n * 2 + 1]. This is match-neutral -- load is dominated by a 344-byte frame
difference -- and is in here because the code is wrong as written, not because it moves
a number.

drawWaterBack was drawing the full gauge twice. The else if (unk48) and else
arms both ended in the same drawGaugeQuadF32(bounds, bounds.y1, bounds.y2, 0.0f, 1.0f)
call. The target's unk48 == 0 and unk30C == 0 tests both branch to the same
GXBegin, which is a single guarded block followed by an unconditional draw.

The pressure flash resets its own counter. if (unk30C >= 25) unk30C = 0; sat in
the caller ahead of the colour computation. It is the final else of the colour chain
itself: the target's cmpwi r4, 0x19 / bge lands on li r0, 0 / stb r0, 0x30c(r29),
which then falls into the shared color + 0xc8. The counter is cleared instead of
picking a fade colour, not before picking one. The two are equivalent -- resetting
first meant the frame-0 branch ran with frame == 0 and both of its terms are
(f32)0 * k, so the colour came out unmodified either way -- which is why it was easy
to miss. The helper now takes the counter by reference so the whole cycle reads in one
place. Its comparisons are also signed in the target (cmpwi, not cmplwi) and the
fade's int-to-float conversion is xoris rather than a clrlwi zero-extend, so the
frame index is an int there, not a u8.

drawWaterBack picks its texture through getTexture. The lookup was written out
longhand as an if/else over mTextureNum into a local. That is exactly
J2DPicture::getTexture(0), which already exists. Using it also explains a mnemonic
that had been bothering me in two functions: the target emits ble after
cmplwi r0, 0 where we emitted beq. mTextureNum > 0 canonicalises to != 0 and
gives beq; the accessor's 0 < mTextureNum, with the constant on the left, does not
canonicalise and gives ble. Same test, just which side the zero is on.

waterGun is read before the rect copy, not after -- the target's
lwz r31, 0x3e4(r5) sits between the copy's argument setup and the bl, and a load
cannot be scheduled across a call.

processAppearStar tests the shine count first. I originally rejected
(shines > 100 && unk50) as a fakematch and was wrong; the target's branch layout
tests the count before the flag. Short-circuit order is semantically meaningful and
directly visible in the object, so it is not the same kind of change as swapping
commutative operands.

The coin emitters go through the existing helper. processDownCoin and
processAppearCoin had the centring written longhand next to a
setEmitterToPaneCenter that does exactly that. processAppearStar keeps its longhand
because all four variants measured worse there (90.27 / 90.27 / 90.75 / 90.53 against
91.20), which I cannot explain and am flagging rather than papering over.

setTimer had two logic holes: the non-sentinel path never assigned timerValue,
so the argument was silently dropped, and the field was then written back from the raw
argument rather than the clamped value. checkChangeTelopArray selected the wrong
two Dolpic news tables -- the 5:0001 && 5:0002 branch takes 5_4 and the neither-flag
branch takes 5_1. That fix is at the call sites deliberately: permuting the table
definitions corrects the code offsets but breaks .data symbol ordering, so it fixes
one thing and breaks another.

startDisappearCoin hid its two panes with two different spellings of the same
arithmetic; retail added the height before the + 1 and we added the + 1 first.


Measured and rejected

Stating these so nobody repeats them:

  • JUTRect getInitialBounds() by value -- 8099 -> 8091, 28 -> 21.
  • Applying getOffsetForAboveScreen to all ten -(y2 + 1) sites -- startAppearCoin
    falls 100% -> 81%.
  • Routing plain 465 - y1 through getOffsetForBelowScreen -- costs a match.
  • A getTextureNum() accessor returning int, to try to get drawWater's ble the
    same way getTexture does -- 99.12% -> 98.85%.
  • Clamping drawWater's y as a ternary at the use site rather than an in-place if.
    The target keeps y and the passed value in separate registers, which is what a
    ternary produces, but it measured 99.12% -> 95.83%. The if is right and the extra
    mr comes from somewhere else.
  • Hoisting drawWater's left/right to locals of any type -- that is what the two
    spurious float register saves were.
  • Moving drawWater's height/topDiff statics below the GXSetChanAmbColor call.
    This also zeroes the .sdata2 offset and looks like a tidy declaration-order fix.
    It is not -- it just displaces the section head by 4 bytes, the same 4 that the
    SMS_NO_MEMORY_MESSAGE const below accounts for. I committed it, found the real
    cause, and dropped it. Neither is in this PR, and this TU's .sdata2 is therefore
    still off by 4.
  • Nesting endCameraDemo's body inside the unk50 test, which is what the TODO in that
    function predicts. The compiler collapses it to the same single beq, so it buys
    nothing and costs a 40-line body indented two levels. The TODO stands.
  • Const-qualifying SMS_NO_MEMORY_MESSAGE in System/DummyStrings.hpp. I had this in
    the PR and have removed it -- flagging it because it looked right and was not.
    That
    symbol appears in mario.MAP 293 times and is in .sdata2, the small const section,
    in every one; we declare static const char*, a mutable pointer, which lands in
    .sdata. Adding the second const does put it where the map says, and it cleans up this
    TU's data sections. But DummyStrings.hpp is included very widely, and across the tree
    it costs 4,416 bytes of matched data for zero functions -- 315,187 -> 310,771 --
    breaking .rodata in MapStaticObject, MapObjFloat and MapObjSirena and .sdata in
    NpcInitPrg and MapMirror, all of which were at 100%. So the map is right about where the
    symbol ends up and the one-word change is still the wrong way to get there; something
    else in that header is carrying the difference. I only checked matched functions
    before pushing, which is how it got in.
  • Removing the duplicate <System/DummyStrings.hpp> include (no match change, .rodata
    goes from matching to a uniform +32) and removing <M3DUtil/InfectiousStrings.hpp>
    (four sections differ instead of two, 185 bytes of compiler-generated constants
    vanish). Both includes are correct.

What I am still stuck on

I classified all remaining failures by padding out each frame delta so the diff was
readable, then splitting what was left into three kinds: a real difference (different
opcode, different immediate, an instruction on one side only), register-allocation
permutation, and stack-slot displacement. The pad was removed before committing;
nothing like it survives in this branch.

Frame size only, no real instruction differences -- checkChangeTelopArray,
processAppearLife, startAppearLife, startDownLeftBot, startInsertLife,
processAppearBalloon, processDisappearBalloon, pauseOut, processDrawTelop,
entryHelpActor, processMoveNozzle, startAppearTelop, startAppearTank,
startAppearStar.

checkChangeTelopArray is the frustrating one: 99.94%, every instruction identical,
and 48 bytes of stack unaccounted for with no mInitialBounds use to explain it.
processAppearLife's frame is already the right size -- its three JUTPoint
temporaries just sit 4 bytes higher than retail's, so retail has one more 4-byte local
than we do and I cannot work out what it is. startDownLeftBot and entryHelpActor
are the other direction, 16 and 8 bytes too big.

I deliberately stopped rather than guess at these. processDrawTelop needs +24 and
already carries an unused textBounds local; getting there means inventing two more
locals, which is the line I am not crossing.

Real code differences left, in the ones I worked this round:

  • drawWater 99.12% -- frame 72 short. The two texture-count tests still emit beq
    where the target emits ble after the same cmplwi r0, 0. Both calls there are
    guarded rather than ternaries, so getTexture does not fit and I have not found the
    spelling that does.
  • drawWaterBack 87.20% -- frame 32 short with one extra saved GPR; the target
    re-reads bounds.y1 from the stack at each use where we cache it. It also compares
    < 15 and < 25 signed but < 10 unsigned, on the same register in the
    same chain. Mixing the spellings in source to reproduce that would make the chain
    read arbitrarily, so I left the one instruction wrong rather than write it that way.
  • processAppearStar 95.99% -- frame 56 short, and blueCoinValue gets an extra
    mr r25, r0 because the target allocates the subtraction straight into the register
    already holding blueCoins. Collapsing the two into one variable produces that, but
    the name would then be wrong for what it holds.
  • startAppearBalloon 94.17% -- the unk3E0 == unk3E0 term (already commented as a
    probable copy-paste slip in the original) is folded away by our compiler but survives
    in the target as cmplw r4, r4, so retail's two operands must have been textually
    different expressions that CSE'd to one load. I could not find a spelling that keeps
    the compare without inventing something.
  • loadAfter 95.24% -- frame 352 short. The target re-evaluates
    (int)(value * 0.01f) where we common-subexpression it, and re-loads the member from
    memory in between, which says its operand is a memory lvalue there rather than
    something held in a register. I do not have the shape yet.
  • perform 27.94% -- 3634 real instruction differences. Nowhere near aligned, so its
    percentage is not measuring anything useful. It went down 0.15% in this PR from the
    getContentsBounds change, which affects a helper inlined into it; I would rather not
    make that line read worse to chase noise in a function that is this far off.

Three things I could not resolve at all:

  1. The four UNUSED functions in this TU -- changeNum (312 bytes),
    startDisappearLife (240), resetMoveTank (224), startUpLeftBot (148) -- are
    still empty stubs. You said UNUSED functions have usually been inlined into other
    functions rather than being genuinely dead, so I went looking. For these four it does
    not hold: they are absent from the extracted retail object entirely, their MAP
    addresses are ........ so they were never linked, and no surviving function in the
    unit calls anything we do not already call. There is no disassembly to read and no
    call site to read them from, so anything I write is invention. If you know a build
    where these are linked, that would unblock them.

  2. This TU's .sdata is untouched by this PR and still wrong: two symbols retail
    does not have, dummyMactorStringValue1 and SMS_NO_MEMORY_MESSAGE, and every shared
    symbol at a uniform -8. dummyMactorStringValue1 appears in mario.MAP zero times
    -- it is ours, not retail's, added to force a 12-byte null literal into .rodata.
    Removing it fixes this TU and breaks .rodata in about twenty others. As above, the
    const fix for the other symbol is also a net loss tree-wide. Both need solving in
    DummyStrings.hpp itself, by someone who knows what that header originally was -- the
    comment in it still says nobody does.

  3. Two raw offset casts remain in this file: + 0x68 off unkC4 and + 0xCC off
    the current nozzle. unkC4 searches for the Peach actor, and unkBC/unkC0 next to
    it are properly typed TBathtub*/TBossEel* -- but Peach's class is not decompiled
    anywhere in the tree, so I cannot type it without guessing. Leaving them ugly and
    visible rather than dressing them up.


Verification

mario.dol: OK. Whole-tree clang-format clean. Symbol order clean on the changed file.
CRLF preserved.

Checked project-wide, not just this unit: 8098 -> 8104 functions, and matched data
unchanged at 315,187 bytes.
The +6 is exactly this unit's +6. I am calling out the
data figure specifically because I did not check it the first time round, which is how
the SMS_NO_MEMORY_MESSAGE regression got in.

J2DWindow.hpp is the one header outside GC2D that this touches. It has three callers,
all in GCConsole2.cpp.

main was merged in rather than rebased, so the changed-file set is just this file plus
the two headers.

Correction to the first version of this PR: it also contained the
SMS_NO_MEMORY_MESSAGE const change described under "measured and rejected" above. The
progress report caught five broken data matches in other units; I have measured it,
confirmed the cause and dropped the commit.

Three corrections in the HUD update path, found by reading the instruction
diffs rather than by sweeping respellings.

setTimer had two logic holes. The non-sentinel path never assigned
timerValue, so the argument was silently dropped, and the field was then
written back from the raw argument instead of the value actually computed
and clamped. The low-time colour test is also a nested check on the
materialised pane colour, not a short-circuit conjunction.

checkChangeTelopArray selected the wrong two Dolpic news tables: the
5:0001 && 5:0002 branch takes scDolpicNewsDolpic5_4 and the neither-flag
branch takes 5_1, not the other way round. This is a selection fix at the
call sites; the definition order of the tables is unchanged, since
permuting those corrects the code offsets but breaks .data symbol order.

updateCoinBlendPaneState tests the pane flag with retail's branch
polarity.

No new byte-identical functions. setTimer 96.12% -> 99.81%,
processDownCoin 93.91% -> 95.82%, checkChangeTelopArray 99.93% -> 99.94%
(now frame-only).
startDisappearTimer now matches byte for byte, and startDownLeftBot goes
98.68% -> 99.78%.

Both were blocked by the same construct. Where we compute `525 - y1`
retail emits two instructions:

    subfic r3, r0, 0x1d1   (465 - y1)
    addi   r0, r3, 0x3c    (+ 60)

not the folded `subfic r0, r0, 0x20d` (525 - y1) we were producing. MWCC
folds constants before it inlines and does not re-fold afterwards, so the
465 can only reach the caller un-added if it arrives across an inline
boundary. Writing the arithmetic out as `465 - y1 + 60` does not
reproduce it -- that folds -- which is what makes this codegen evidence
for a real function rather than a spelling choice.

This is the helper TheAzack9 asked to hold off on during the doldecomp#152 review,
restored under his own name and marked with his `// Possibly inline`
convention, because the disassembly now argues for it. He also suggested
it belongs on TExPane rather than here; that call is his, and this keeps
it file-local until he makes it.

Two things measured and deliberately not done. Routing the sites that
want a plain `465 - y1` through the same helper costs a match, so retail
really does have both forms and the helper is only used where the `+ 60`
appears. And startDownLeftBot's frame is now 16 bytes too large, which is
a frame-size question, not a code one -- left alone rather than fitted.
@decomp-dev

decomp-dev Bot commented Aug 27, 2026

Copy link
Copy Markdown

Report for GMSJ01 (39ad624 - 172258c)

📈 Matched code: 37.53% (+0.08%, +2980 bytes)

✅ 6 new matches
Unit Item Bytes Before After
mario/GC2D/GCConsole2 TGCConsole2::startDisappearCoin() +14 98.19% 100.00%
mario/GC2D/GCConsole2 TGCConsole2::startDisappearTimer() +5 98.61% 100.00%
mario/GC2D/GCConsole2 TGCConsole2::startDisappearStar() +3 99.70% 100.00%
mario/GC2D/GCConsole2 TGCConsole2::startAppearCoin() +1 99.86% 100.00%
mario/GC2D/GCConsole2 TGCConsole2::startInsertJetBalloon() +1 99.86% 100.00%
mario/GC2D/GCConsole2 TGCConsole2::startAppearRedCoin() +1 99.84% 100.00%
📈 15 improvements in unmatched items
Unit Item Bytes Before After
mario/GC2D/GCConsole2 TGCConsole2::drawWaterBack() +268 74.67% 87.20%
mario/GC2D/GCConsole2 TGCConsole2::drawWater(J2DOrthoGraph&) +250 83.82% 99.12%
mario/GC2D/GCConsole2 TGCConsole2::loadAfter() +147 92.33% 95.24%
mario/GC2D/GCConsole2 TGCConsole2::processAppearStar(int) +87 91.20% 95.99%
mario/GC2D/GCConsole2 TGCConsole2::processDownCoin(int) +44 93.91% 98.88%
mario/GC2D/GCConsole2 TGCConsole2::setTimer(long) +41 96.12% 99.80%
mario/GC2D/GCConsole2 TGCConsole2::processAppearCoin(int) +27 95.80% 98.86%
mario/GC2D/GCConsole2 TGCConsole2::startCameraDemo() +12 97.78% 98.16%
mario/GC2D/GCConsole2 TGCConsole2::startAppearBalloon(unsigned long, bool) +12 92.46% 94.17%
mario/GC2D/GCConsole2 TGCConsole2::startDownLeftBot() +10 98.68% 99.78%
mario/GC2D/GCConsole2 TGCConsole2::pauseOut() +2 99.49% 99.72%
mario/GC2D/GCConsole2 TGCConsole2::startAppearTank() +1 99.08% 99.32%
mario/GC2D/GCConsole2 TGCConsole2::load(JSUMemoryInputStream&) 0 99.66% 99.66%
mario/GC2D/GCConsole2 TGCConsole2::checkChangeTelopArray() 0 99.93% 99.94%
mario/GC2D/GCConsole2 TGCConsole2::processDisappearBalloon() 0 99.72% 99.89%
📉 3 regressions in unmatched items
Unit Item Bytes Before After
mario/GC2D/GCConsole2 TGCConsole2::perform(unsigned long, JDrama::TGraphics*) -54 28.24% 27.94%
mario/GC2D/GCConsole2 TGCConsole2::endCameraDemo() -8 99.14% 97.84%
mario/GC2D/GCConsole2 TGCConsole2::startAppearStar() -7 99.76% 99.01%

Four functions match byte for byte: startAppearCoin, startAppearRedCoin,
startInsertJetBalloon and startDisappearStar.

Every `->mInitialBounds` use costs exactly 8 bytes of stack in retail and
none in ours. Measured across six functions before touching anything:

    startAppearCoin        short by  8    1 use
    startAppearTank        short by  8    1 use
    startAppearRedCoin     short by 16    2 uses
    startInsertJetBalloon  short by 16    2 uses
    startAppearStar        short by 16    2 uses
    startDisappearStar     short by 16    2 uses

Those functions were otherwise byte-identical -- cancel the frame delta
and the instruction diff goes completely empty -- so the 8 bytes are the
footprint of an inlined accessor, which MWCC reserves and never reclaims.

The existing `getInitialBounds()` returns `const JUTRect&`, which gets the
frame right but emits an extra `addi` to form the address:

    target:  lwz  r3, 8(r28)
    ours:    addi r3, r28, 4 ; lwz r3, 4(r3)

A per-field accessor returning `int` gives both the reserved slot and the
direct load. JUTRect already exposes getWidth() and getHeight() the same
way, so this follows the existing shape rather than inventing one.
Returning JUTRect by value was tried first and is decisively wrong --
8099 -> 8091 project, 28 -> 21 unit -- which fits, since JUTRect is 16
bytes and the deltas are 8.

This also corrects frames we were not aiming at: startAppearTank and
startAppearStar now match exactly (both are left unmatched only by
register allocation), startDisappearCoin went +24 -> +8, endCameraDemo
+32 -> +24, startAppearTelop +48 -> +40.

getOffsetForAboveScreen is the same constant-folding argument as
getOffsetForBelowScreen in 0bf0081. Retail emits `neg` then `add` where
we fold to a single `subf`, which means the negation happened behind an
inline boundary. It is used at exactly the two sites where an operation
sits outside that boundary -- `+ unk26A` and `- getHeight()` -- because
those are the only places the boundary is observable; applying it to all
ten sites costs four matches and was measured and rejected.
Matches byte for byte.

The two calls in this function did the same thing in two different
spellings: one subtracted the pane height inside the negation as
`-(y2 + height + 1)`, the other took the above-screen offset and
subtracted the height from it. They are equal arithmetic, but the
compiler kept the difference -- retail adds the height before the +1,
we added the +1 first:

    target:  subf r0, r3, r0 ; add  r3, r4, r0 ; addi r0, r3, 1
    ours:    subf r3, r3, r0 ; addi r0, r3, 1  ; add  r0, r4, r0

Writing both the same way is what a person would have done, and it is
what retail compiled.
unk17C is J2DPane*[18] holding nine pairs, and unk1D0 is JUTRect[9], one
per pair. The loop that populates them indexed unk17C[i] and unk17C[i+1]
for i in 0..8, so each iteration overwrote the previous one's second pane,
indices 9 through 17 were never written at all, and unk1D0[i] took its
bounds from whichever pane happened to be at [i].

Everywhere else in the file already indexes this array correctly, e.g.

    console->unk17C[console->unk1CC[0] * 2]->show();
    console->unk17C[console->unk1CC[0] * 2 + 1]->setBounds(...);

so the pairing was already established; only the loop that fills it was
wrong.

No match change -- load() is 99.66% and dominated by a 344-byte frame
difference, and this region is a few instructions of it. Committing it
because the code is wrong as written, not because it moves a number.
…FontSize

92.33% -> 95.24%. Still unmatched: loadAfter's frame is 344 bytes short,
which is a separate question and is left alone.

We were calling the wrong method. The target dispatches through the pane's
vtable at +0x14, which is J2DPane::resize -- the fourth virtual, after the
destructor, move and add, and the one J2DTextBox overrides. We called
setFontSize, which is not virtual at all, and separately called
gpSystemFont's vtable +0x24 for its height. The target instead calls
gpSystemFont's +0x28, annotated in JUTFont.hpp as getWidth(), and shifts
it left by 10.

The bounds also come from a copy, not a direct read: the target builds a
JUTRect on the stack via JUTRect::copy and takes y2 - y1 from it, and uses
that same one height for both boxes rather than reading each box's own
bounds.

    lwz  r3, 0x528(r31)  ; unk528
    stb  r29, 0xc(r3)    ; hide
    lwz  r4, gpSystemFont
    bl   J2DTextBox::setFont
    addi r3, r1, 0x550
    addi r4, r4, 0x14
    bl   JUTRect::copy   ; JUTRect bounds(unk528->mBounds)
    lwz  r12, 0x28(r12)  ; gpSystemFont->getWidth()
    subf r23, r4, r0     ; bounds.getHeight()
    slwi r4, r3, 10
    lwz  r12, 0x14(r12)  ; unk528->resize(...)

The `<< 10` is left as the shift the code performs. I do not know what
unit that width is in and would rather leave it plain than name it wrongly.

Also note the ordering: each box is hidden, given its font and resized in
turn, rather than the two being hidden together and then configured
together.
@KakarottoCake
KakarottoCake force-pushed the gcconsole2-inline-boundaries branch from 166d0c1 to efb8a85 Compare August 27, 2026 21:03
Comment thread include/GC2D/ExPane.hpp
int getInitialX1() const { return mInitialBounds.x1; }
int getInitialY1() const { return mInitialBounds.y1; }
int getInitialX2() const { return mInitialBounds.x2; }
int getInitialY2() const { return mInitialBounds.y2; }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel like these could ever be real so lets leave them out of the PR for now

KakarottoCake and others added 8 commits August 27, 2026 17:33
processDownCoin 95.82% -> 98.88%, processAppearCoin 95.80% -> 98.86%.

setEmitterToPaneCenter already existed and was already used three times in
perform, but the identical body was also written out longhand in three
other places. Two of them wanted the helper:

    JUTRect bounds(unkCC->getPane()->mGlobalBounds);
    unk124->mGlobalTranslation.set(bounds.x1 + bounds.getWidth() * 0.5f,
                                   bounds.y1 + bounds.getHeight() * 0.5f,
                                   0.0f);

is character for character the helper's body, and routing it through the
helper gains three points in each function.

processAppearStar has two of these back to back and does NOT want it -- it
drops 91.20% -> 90.27%. I tried the obvious variants: helper for both
(90.27%), swapping their order (90.27%), helper for only the first
(90.75%), only the second (90.53%). All are worse than leaving both
longhand, so that is how they stay, and the file now has the operation in
two spellings. I do not like it either, but that is what the object says
and I would rather leave the inconsistency visible than pick the tidier
version and lose the match.

Marker changed from `// fabricated` to `// Possibly inline`. `fabricated`
asserts "I made this up", and that is no longer the honest claim about
something that measurably improves two functions when used.
91.20% -> 92.09%.

The 100-shine check reads

    (!unk50 && shines >= 100) || (unk50 && shines > 100)

but retail evaluates the second arm the other way round. The branch
structure shows it plainly -- on the unk50 path it compares the count
before it re-tests the flag:

    708c: cmpwi  r25, 0x64   ; shines vs 100
    7090: ble    -> skip
    7094: cmplwi r0, 0       ; unk50
    7098: beq    -> skip

We emitted the flag test first and the count second.

Worth being explicit about why this is not a fake match, because it looks
like one: `&&` operand order is short-circuit evaluation order, so it is
semantically meaningful and directly visible in the branch layout. That is
different from swapping the operands of a commutative arithmetic
expression to nudge a frame, which produces identical semantics and is
exactly the thing this project rejects. I had this change in front of me
earlier, called it a fake match, and reverted it. That was wrong, and the
disassembly above is why.

It does cost the parallel structure of the two arms, which is a real
readability loss and reads oddly. I am keeping it because the object says
so, but it is the sort of thing worth a second opinion.
83.82% -> 99.12%.

Three separate things, all readable off the register save mask. The target saves
f28-f31 and only r20-r31; we were saving f30/f31 and r17-r31. Retail holds two
more values in float registers and three fewer in integer ones, which says the
quad's vertical extent was computed as f32 and ours was not.

  - `top` and `bottom` are `f32` locals. Retail converts each exactly once,
    before `GXBegin`, and keeps them in f29/f28 across all four vertices.

  - `left` and `right` are *not* locals. The target re-loads `unk2BC[layer].x1`
    and `.x2` from memory and re-converts them at every vertex -- four loads and
    four `xoris` conversions for two values. That is what an inline
    `(f32)unk2BC[layer].x1` at each call site does, and hoisting them to locals
    (either int or f32) is what was costing the two extra float registers.

  - the height is the picture's, not the cached rect's. The target reads +0x18
    and +0x20 off `unk2A0[layer]`, which is `J2DPane::mBounds.y1`/`.y2` --
    i.e. `J2DPane::getHeight()` -- where we were subtracting inside
    `unk2BC[layer]`. The two rects hold the same numbers here, so this is not a
    behaviour change, but it is the one retail actually reads.

The first `GXSetTevColor` also needed the same `JUtility::TColor(u32)`
construction as the second. The target stores the colour to one stack slot and
copies it to a second before the call; passing the array element as an lvalue
only produces one. Both calls now read the same way, which is how they should
have been written regardless.

Left unresolved: the frame is still 72 bytes short, and the two texture-count
tests emit `beq` where the target emits `ble` after the same `cmplwi r0, 0`.
Those are equivalent for an unsigned compare, so `mTextureNum > 0` is folding to
`!= 0` for us and did not for retail. Adding a `getTextureNum()` accessor
returning `int` does not do it -- measured, 99.12% -> 98.85%, reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92.09% -> 95.99%.

The two emitter-centring blocks at the end were `JUTRect bounds(...)` and
`JUTRect bounds2(...)`. The target only has one rect: it calls `JUTRect::copy`
for the first and then inlines the second copy member-wise -- four `lwz`/`stw`
pairs -- into the *same* stack slot the first one used.

That asymmetry is the tell. Copy-construction goes through the out-of-line
`JUTRect::copy`; assigning to an existing rect inlines. `drawWater` in this same
file already shows both forms side by side for the same reason, so this is the
shape the file was written in.

Still off: the frame is 56 bytes short, and `blueCoinValue` gets an extra
`mr r25, r0` because the target allocates the subtraction straight into the
register already holding `blueCoins`. Collapsing the two into one variable would
produce that, but the name would then be wrong for what it holds, so I have left
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
74.68% -> 83.86%.

The `else if (unk48)` and `else` arms both ended in the same
`drawGaugeQuadF32(bounds, bounds.y1, bounds.y2, 0.0f, 1.0f)` call, so we emitted
two identical quads and the target emits one. Its `unk48 == 0` and
`unk30C == 0` tests both branch to the *same* `GXBegin`, which is a single
guarded block followed by an unconditional draw:

    } else {
        if (unk48 && unk30C != 0) { ... }
        drawGaugeQuadF32(bounds, bounds.y1, bounds.y2, 0.0f, 1.0f);
    }

Worth noting for anyone applying the `drawWater` change to this function: it does
*not* transfer. `drawWater` hoists the quad's top and bottom into f32 locals
because the target converts them once and keeps them in f29/f28. Here the target
converts all eight position components separately at each vertex, which is what
`drawGaugeQuadF32`'s `int top, int bottom` parameters already produce. The two
functions genuinely differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
83.86% -> 84.83%. Two things.

The background texture lookup was written out longhand as an if/else over
`mTextureNum` into a local. That is exactly `J2DPicture::getTexture(0)`, which
already exists and reads `idx < mTextureNum ? mTextures[idx] : nullptr`. Using
it collapses five lines to one and also explains a mnemonic that had been
bothering me: the target emits `ble` after `cmplwi r0, 0` where we emitted
`beq`. `mTextureNum > 0` canonicalises to `!= 0` and gives `beq`; the accessor's
`0 < mTextureNum`, with the constant on the left, does not canonicalise and
gives `ble`. Same test either way -- it is just which side the zero is on.

`drawWater` has the same `ble`, but its two calls are guarded, not a ternary, so
the accessor does not fit there and those two branches are still wrong.

Second, `waterGun` is read before the rect copy, not after. The target's
`lwz r31, 0x3e4(r5)` sits between the copy's argument setup and the `bl`, and a
load cannot be scheduled across a call, so that statement precedes it in source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
84.83% -> 87.20%.

`if (unk30C >= 25) unk30C = 0;` sat in the caller ahead of the colour
computation. It is actually the final `else` of the colour chain itself: the
target's `cmpwi r4, 0x19 / bge` lands on `li r0, 0 / stb r0, 0x30c(r29)`, which
then falls into the shared `color + 0xc8`. So the counter is cleared *instead of*
picking a fade colour, not before picking one.

The two are equivalent, which is why this was easy to miss. Resetting first meant
the frame-0 branch ran with `frame == 0`, and both of its terms are
`(f32)0 * k`, so the colour came out as the unmodified base either way. The
difference is only in where the reset lives.

The helper now takes the counter by reference so the whole flash cycle -- fade
in, hold, fade out, reset -- reads in one place. Its comparisons are also signed
in the target (`cmpwi`, not `cmplwi`), and the fade's int-to-float conversion is
`xoris` rather than our `clrlwi` zero-extend, so the frame index is an `int`
there, not the `u8` we were passing.

One thing I cannot explain: the target compares `< 15` and `< 25` signed but
`< 10` *unsigned*, on the same register, in the same chain. Mixing the spellings
in source to reproduce that would make the chain read arbitrarily, so I have left
the one instruction wrong rather than write it that way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
startAppearBalloon 92.46% -> 94.17%, processDisappearBalloon 99.72% -> 99.89%.

This is the TODO that was already sitting in `startAppearBalloon`. The target
copies the contents rect twice there -- once out of `mContentsBounds` at +0xec
into a stack temporary, then again from that temporary into the named local --
which is what a by-value return does and what a `const JUTRect&` return cannot
produce.

`processDisappearBalloon` looked like it contradicted that, because it copies
only once. It does not: the target copies straight into a slot it reads
`getHeight()` out of two instructions later and never touches again. That is an
unnamed temporary, not a local. The rect was only ever there to be measured, so
the local goes and the call reads as one expression.

`perform` moves 28.09% -> 27.94%. It has 3634 real instruction differences and
is nowhere near aligned, so its percentage is not measuring anything useful yet;
the third call site is inlined into it and I would rather not make that line read
worse to chase noise. Project function count and matched data are both unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@KakarottoCake KakarottoCake changed the title GC2D/GCConsole2: inline boundaries and initial-bounds accessors (27 -> 33) GC2D/GCConsole2: inline boundaries, bounds accessors, and the water gauges (27 -> 33) Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants