diff --git a/docs/GHIDRA_VERIFICATION.md b/docs/GHIDRA_VERIFICATION.md new file mode 100644 index 000000000..9a277342d --- /dev/null +++ b/docs/GHIDRA_VERIFICATION.md @@ -0,0 +1,7201 @@ +# Ghidra verification ledger (NTSC-U 926, SCUS_944.26) + +Ongoing cross-check of project functions against the retail binary in Ghidra +(`SCUS_944.26.exe`), using `metadata/retail/ntsc-u-926/symbols/syms926.txt` for +addresses. Method: decompile + disassemble in Ghidra, compare semantics against +project source; when in doubt, read the MIPS assembly directly. + +## game/CTR — DONE (2026-07-03) + +All 17 syms926 symbols in the `CTR_*` block (0x80021500–0x800222e0) map to the 7 +files in `game/CTR/`. Every function's body semantics were verified against the +binary. Results: + +| Function | Address | Verdict | +|---|---|---| +| CTR_Box_DrawWirePrims | 80021500 | Match (sig modernized, see note 1) | +| CTR_Box_DrawWireBox | 80021594 | Match | +| CTR_Box_DrawClearBox | 8002177c | **Discrepancy — see note 2** | +| CTR_Box_DrawSolidBox | 80021894 | Match (sig modernized, see note 1) | +| CTR_CycleTex_LEV | 80021984 | Match | +| CTR_CycleTex_Model | 80021a20 | Match | +| CTR_CycleTex_AllModels | 80021ac0 | Match | +| CTR_CycleTex_2p3p4pWumpaHUD | 80021b94 | Match (naming suspect, see note 3) | +| CTR_ClearRenderLists_1P2P | 80021bbc | Match (5 lists + FullDynamic) | +| CTR_ClearRenderLists_3P4P | 80021c2c | Match (4 lists, list[4].ptr = 0 only) | +| CTR_EmptyFunc_MainFrame_ResetDB | 80021c8c | Match (empty) | +| CTR_ErrorScreen | 80021c94 | Match (3 swaps, 2 draws, VRAM-fill code 2) | +| CTR_unknownMaybeThunk1 | 80021da0 | Match (see note 4 — it's RLE copy) | +| CTR_unknownMaybeThunk2 | 80021e1c | Match (RLE OR-merge) | +| CTR_unknownMaybeThunk3 | 80021ea8 | Match (word OR-merge) | +| CTR_MatrixToRot | 80021edc | Match (incl. tables @0x8008d004, see note 5) | +| CTR_ScrambleGhostString | 80022234 | Match (incl. full table, see note 6) | + +### Note 1 — deliberate signature modernization (informational) + +The retail ABI differs from the project signatures for the Box drawers, but the +emitted primitives are identical: + +- `CTR_Box_DrawWirePrims` retail (per disassembly): 9 scalar args + `(u16 x0, u16 y0, u16 x1, u16 y1, u8 r, u8 g, u8 b, void *ot, PrimMem *primMem)` + — coords in a0–a3, rest on stack. Project packs them as + `(Point, Point, Color, void *ot)`. +- `CTR_Box_DrawSolidBox` retail passes the color **by pointer** + (`lw v0,0x0(t1)`); project passes `Color` by value. +- All four retail Box functions take `PrimMem *` as an explicit trailing + parameter; the project instead allocates from + `sdata->gGT->backBuffer->primMem` via `GetPrimitiveMem`. `GetPrimitiveMem` + faithfully reproduces the retail inline alloc (bounds check + `cursor <= guardEnd`, bump by prim size, tag size byte `(size-4)/4`). + +### Note 2 — CTR_Box_DrawClearBox texpage constant (real finding) + +Retail (verified in asm at 0x800217b4): `lui v1,0xe100; ori v1,v1,0xa00` → +tpage word = `trans<<5 | 0xE1000A00`, i.e. **dither (bit 9) AND bit 11** set. + +Project (`game/CTR/CTR_Box.c`): +`(Texpage){.code = 0xE1, .semiTransparency = transparency, .dither = 1}` → +`trans<<5 | 0xE1000200`. **Bit 11 is missing.** In the project's `Texpage` +union, bit 11 is `y_VRAM_EXP` (commented "ununsed in retail" — yet retail sets +it here). On stock PSX hardware GP0(E1h).11 is ignored unless enabled via +GP1(09h), so no visible effect, but the prim is not byte-identical to retail +and the file's "PSX path ASM-verified" comment is inaccurate on this point. +Fix would be adding `.y_VRAM_EXP = 1` to the initializer. + +### Note 3 — CTR_CycleTex_2p3p4pWumpaHUD semantics + +Code matches retail exactly (`*p2 = *p1; *p1 = (u32)&p2[n-1] & 0xFFFFFF`), but +the Ghidra analyst interprets it as an ordering-table splice +(`otEntry, primBlockHead, primCount` — link a block of prims into the OT), +which fits the `& 0xFFFFFF` OT-pointer masking better than the project's +"texture frame array" parameter names. Behavior identical; naming worth +revisiting. + +### Note 4 — Visibility "thunks" identified + +Ghidra names the three `CTR_unknownMaybeThunk*` functions +`CTR_VisMem_RleCopy` / `CTR_VisMem_RleOr` / `CTR_VisMem_WordOr`: RLE +decompress-copy, RLE decompress-OR-merge, and word-wise OR-merge used for +VisMem visibility bitmasks. Project bodies match; the project names/param +names (`dst/src/rle`) are already consistent with this reading. + +### Note 5 — CTR_MatrixToRot + +Both flag-decode branches, the `MATH_FastSqrt(x, 0x18) >> 12` magnitude, all +six `ratan2` argument orders, the <0x11 gimbal case, negate (bit 2) and XZ-swap +(bit 0) post-passes, and `pad = flags` all match. The two 8-byte axis tables at +retail 0x8008d004 (`0,1,2,0,0,0,0,0` / `1,2,0,1,0,0,0,0`) match the +`sdata->unk_CTR_MatrixToRot_table` initializer in `game/zGlobal_SDATA.c`. + +### Note 6 — CTR_ScrambleGhostString + +Loop logic matches (escape prefix `< 4` shifts into high byte, table scan skips +entries whose value lacks a high byte, 2-byte big-endian emit, NUL-terminate). +`data.ghostScrambleData` (686 u16 = 343 entries incl. `0xFFFF,0xFFFF` +terminator) matches retail table @0x80081dfc — head (16 u16) and tail (8 u16) +compared byte-for-byte. Ghidra identifies the real behavior as ASCII/kana → +Shift-JIS full-width transcoding; "Scramble" is a legacy syms name. + +## game/DebugFont.c — DONE (2026-07-03) + +| Function | Address | Verdict | +|---|---|---| +| DebugFont_Init | 800222e0 | Match | +| DebugFont_DrawNumbers | 80022318 | **Divergence — see note 7** | + +### Note 7 — DebugFont_DrawNumbers missing X masks (edge-case finding) + +Retail packs the quad corner words as `(x & 0xFFFF) | y<<16` and +`((x+7) & 0xFFFF) | y<<16` (explicit `andi` in the binary; visible in the +decompile as `screenPosX & 0xffffU`). The project +(`game/DebugFont.c:46-49`) ORs the raw `int` X without masking, so a +**negative screenPosX would set the packed Y field to 0xFFFF** (y = -1) in the +project while retail renders at the correct negative X. Harmless for the +positive coords currently passed, but not asm-equivalent. Fix: mask X and X+7 +with `0xffff` before OR-packing. Everything else matches (prim code +0x2E/len 9, +0x28 alloc, texcoord math `(u + (index+5)*7, v+7)`, clut/tpage +placement, OT link). + +## game/DecalFont.c — DONE (2026-07-03) + +| Function | Address | Verdict | +|---|---|---| +| DecalFont_GetLineWidthStrlen | 800223f4 | **Sign bug — see note 8** | +| DecalFont_GetLineWidth | 800224d0 | Match | +| DecalFont_DrawLineStrlen | 800224fc | Match (see note 9) | +| DecalFont_DrawLine | 80022878 | Match | +| DecalFont_DrawLineOT | 800228c4 | Match | +| DecalFont_DrawMultiLineStrlen | 80022930 | Match (see note 9) | +| DecalFont_DrawMultiLine | 80022b34 | Match | + +### Note 8 — GetLineWidthStrlen char signedness (real finding) + +Retail (asm at 0x8002248c): `sltiu v0,v1,0x3` on an `andi 0xff`'d character — +the "normal character" test `c > 2` is **unsigned**. The project uses +`char c` (signed on the native targets), so any byte ≥ 0x80 is negative and +**gets zero width** in the project while retail adds `font_charPixWidth`. +Latent for pure-ASCII NTSC-U strings, but DrawLineStrlen happily renders +glyphs for codes up to 0xFF (`c - 0x21 < 0xdf`), so a high-byte character +would draw in both yet only advance/measure in retail. Fix: make the width +loop operate on `u8`. + +Also informational, same function: retail's `len` parameter is 16-bit +(loop tests `len << 16 == 0`) and the accumulated width is returned +sign-extended-s16 (`sll/sra 16` at 0x800224c4); the project uses full `int` +for both. Divergence only for len ≥ 0x10000 or line widths > 32767 px — +unreachable with real data, but worth knowing when comparing asm. + +### Note 9 — DrawLineStrlen / DrawMultiLineStrlen details verified + +- Justify constants match retail masks: JUSTIFY_CENTER = 0x8000 (width>>1 via + arithmetic s16 shift = project `/2`), JUSTIFY_RIGHT = 0x4000. USA mask + `flags &= 0xfff` ✓. +- Button-glyph path: GRAY == 0x17 == retail `data_ptrColor[0x17]` ✓; + scale/pixHeightExtra/width = charW+buttonW ✓. +- Icon tables: retail reads `font_characterIconID` via folded base + (0x80082357 + c ≡ table[c-0x21]) and `font_indentIconID` via + buttonPixHeight+7+c+ft*2 ≡ table[ft*2 + c - 1] — both consistent with the + project's indexing, including the `[... - 1]` that looks odd in source. +- `if (c < 3)` runs only for c ∈ {1,2} (loop guard excludes 0) so the + project's separate (non-else) ifs are equivalent to retail's else-chain. +- Kana fallback (iconID > 0x7f → group 15/14) and numIcons bounds check + (s16, signed compare) match. +- Minor, informational: retail truncates posX to s16 at the DrawPolyGT4 call + and DrawMultiLineStrlen returns its height s16-truncated; project keeps + `int`. Only matters beyond ±32767 px. +- DrawMultiLineStrlen word-wrap loop structure (space skip, word scan, + width test `maxPixLen <= (s16)lineLen`, '\r' handling, tail + advance/len bookkeeping) matches retail exactly. + +## game/DecalGlobal.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| DecalGlobal_EmptyFunc_MainFrame_ResetDB | 80022b94 | Match (empty) | +| DecalGlobal_Clear | 80022b9c | Match | +| DecalGlobal_Store | 80022bdc | Match | +| DecalGlobal_FindInLEV | 80022c88 | Match | +| DecalGlobal_FindInMPK | 80022d2c | Match | + +Clear: `ptrIcons[0x88]` / `iconGroup[0x11]` sizes match retail memsets +(0x220/0x44 bytes; project uses `sizeof`, correct for native pointer width). +Store: bounds checks `< 0x88` / `< 0x11` — signedness of the index load is +irrelevant here (any value ≥ the bound fails both signed- and unsigned-wise), +so the project's `(u32)` casts are equivalence-safe. Find functions: identical +16-byte name compare (4 int compares), stride-0x20 walk / group-pointer walk, +same NULL/terminator handling. Ghidra notes FindInLEV/FindInMPK callers +(LOAD_TenStages: particle groups, traffic-light icons) — consistent with +project usage. + +## game/DecalHUD.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| DecalHUD_DrawPolyFT4 | 80022db0 | Match (bug-compatible unmasked X) | +| DecalHUD_DrawWeapon | 80022ec4 | **Deliberate divergence — see note 10** | +| DecalHUD_DrawPolyGT4 | 80023054 | Match | +| DecalHUD_Arrow2D | 80023190 | **FIXED (scale s16 truncation, latent) — see note 11** + else Match | + +Verified macro equivalences: `addPolyFT4`+`setShadeTex`(+`setTransparency`) +compose to retail codes 0x2D/0x2F and `addPolyGT4`(+`setTransparency`) to +0x3C/0x3E; `setTransparency`'s halfword `& 0xff9f | (t-1)<<5` equals retail's +word-level `& 0xff9fffff | (t-1)<<21`; `FP_Mult` = signed `(x*y)>>12` = +retail `mult`+`sra 0xc`; link words 0x09/0x0C<<24 and cursor bumps +(0x28/0x34) all match. Retail FT4/DrawWeapon never write the color word +(rawTex bit → GPU ignores it) — project is identically "uninitialized", +bug-compatible. + +### Note 10 — DrawWeapon (u16) casts are a fix, not a match + +Retail DrawWeapon's vertex packing has **no `andi` masks** (verified in asm +0x80022f88–0x80023020): posX arrives sign-extended and is OR'd raw, exactly +like DrawPolyFT4, so negative X corrupts the packed Y on real hardware. The +project adds `(u16)` casts at the DrawWeapon call sites, which *fixes* that +overflow — and the file comment (game/DecalHUD.c:51-55) wrongly claims the +casts reproduce the original compiler's behavior. DrawPolyGT4 is different: +retail *does* mask the base posX there (decompile `(ushort)posX`), and the +project's casts match it. FT4 takes no casts and retail has no mask — also +consistent. So: FT4 ✓ bug-compatible, GT4 ✓ true match, DrawWeapon = behavior +change for off-screen-left coords (project renders sanely where retail +glitches). Decide whether bug-compatibility matters here; if yes, drop the +casts in DrawWeapon; if no, fix the comment. + +### Note 11 — Arrow2D details + +All rotation-basis selection (bits 0x400/0x800, trig table low-10-bit index), +half-extent math (`>>0xd`), corner packing (full X sum masked `& 0xffff` — +retail does mask here, unlike FT4), per-vertex colors, ABR handling and the +0x34-byte prim advance match. **FIXED 2026-07-06 (re-verify pass):** retail +truncates `scale` to s16 internally (asm 0x800231cc-d0 `sll a0,a0,0x10; sra +a0,a0,0x10` on the stack arg — decompile `(int)(short)scale`), reused for both +`mult v1,a0`; the project's `int scale` used full `(int)scale`, so a caller +passing `scale >= 0x8000` (or with high bits set) would diverge (retail reads +it negative). Latent — all callers pass small `.13` scales (MainFreeze +`ConfigDrawArrows` passes `li s4,0x1000`; the other 3 callers are overlay HUD +arrows) — but fixed to `(s16)scale` at both use sites (game/DecalHUD.c:180/184) +to stay bit-exact, consistent with the DrawNumbers `(u8)` and CS_ScriptCmd +`(u8)` latent-truncation fixes. NOTE(claude) added. Build green. The project's +`posX = posX & 0xffff;` (game/DecalHUD.c:176) is a no-op on an s16 variable — +retail instead zero-extends posX/posY into the corner sums, but since both +sides mask/truncate the packed halves, the emitted prim bytes are identical. + +## game/DecalMP.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| DecalMP_01 | 80023488 | **Wrong flags target — see note 12** | +| DecalMP_02 | 80023640 | Match | +| DecalMP_03 | 80023784 | **Wrong quad-corner source — see note 13** | + +Extensive offset cross-check done via asm (not just decompile): DecalMPEntry +layout (timer@0, kartState@2, boolUpdated@6, inst@8, renderW/H@0x10/0x12, +lodIndex@0x14, pb@0x18, stride 0x128), PushBuffer field mapping (pos SVec3@0, +distanceToScreen_PREV@0x18, rect@0x1c, matrix_ViewProj@0x28, ptrOT@0xf4, +rangeEnd@0xf8, byteOffset@0xfc, screenPos@0x100, screenSize@0x104, +cameraID@0x108) — all match retail. DecalMP_02's budget/parity/OT-splice +logic matches line for line (incl. `timer=1000` byte-split store, `>>3` min-2 +threshold, `(gGT->timer ^ index) & 1`, pause-gated store). KS_BLASTED = 6 ✓. + +### Note 12 — DecalMP_01 flags go to the wrong field (real finding) + +Retail asm at 0x800234f4: `lw v0,0x28(a3); ori v0,0x300; sw v0,0x28(a3)` — +the 0x300 OR targets **Instance+0x28 (instance-level flags), a fixed offset**. +The camera-indexed write in the same function (`sw v1,0x74(a3+t3)`, t3 = +cam*0x88) shows what per-camera addressing looks like — the flags write has +none. The project (`game/DecalMP.c:50-51`) instead does +`idpp->instFlags |= 0x300` = inst+0xb8+cam*0x88 — the per-camera +InstDrawPerPlayer copy (whose own header comment says "same enum as Instance +offset 0x28", making this an easy mix-up). Retail marks the *instance*; +per-camera idpp flags presumably receive it later via the draw path's +inst→idpp flag propagation. Fix: `inst->flags |= 0x300;` (with the named +constants from the recent instance-flags refactor). + +### Note 13 — DecalMP_03 quad corners read pb.rect instead of bucket pos/size (real finding) + +Retail builds the composited POLY_FT4's corners from +**pb+0x100/0x102 (renderBucketScreenPos x/y)** and +**pb+0x104/0x106 (renderBucketScreenSize w/h)** — the same `pEntry+4..10` +loads that feed the SetDrawEnv offset args (which the project correctly maps +to renderBucketScreenPos) and the renderW/H caching (correctly mapped to +renderBucketScreenSize). The project (`game/DecalMP.c:178-186`) instead reads +`entry->pb.rect.{x,y,w,h}` (pb+0x1c) — the *viewport* rect copied in +DecalMP_01, which nothing overwrites with bucket bounds +(RenderBucket_UpdatePushBufferMetadata writes only 0x100/0x104). Effect: the +remote-kart texture quad is placed/stretched at the viewport rect rather than +at the kart's projected screen bounds. Fix: corners from +`(s16)renderBucketScreenPos`, `(s16)(renderBucketScreenPos>>16)`, +`(s16)renderBucketScreenSize`, `(s16)(renderBucketScreenSize>>16)`. + +Also informational: retail 03 packs corner X unmasked (sign-extended OR, same +overflow class as DrawWeapon/DebugFont); the project's `(u16)` casts are a +silent fix in the same direction as Note 10. + +## game/Display.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| DISPLAY_Blur_SubFunc | 80023a40 | Match | +| DISPLAY_Blur_Main | 80023d4c | Match | +| DISPLAY_Swap | 80023ffc | Match | + +Blur_SubFunc: recursive 256-texel-page split verified term-for-term (U-axis +priority, `(base & ~0xff) - (base - 0xff)` in-page span, proportional screen +split with signed division, `+1/-1` second-half offsets, recursion guards on +the *screen* extent, base-case UV/tpage packing, self-linking `tag = next | +0x09<<24`). Blur_Main: flat-tint packet's five control words +(0xE1000A20/0xE6000001/0x2A000000|0x2AFFFFFF/0xE1000A00/0xE6000000) match +DisplayBlurFlatPacket; blur path's sin-wave amount, camera-parity negation, +scratchpad BlurRect (insets 9x/6x blur >>12, +2), OT head swap and +last-prim tag restore all match. blurCameraMask read from `db[1-swapchain]` ++0x70 / byte-OR write on backBuffer confirmed. DISPLAY_Swap: retail's folded +`gGT+0xbc-newIdx*0xa4` = `&db[oldIndex]` — matches project exactly. +Recurring latent note: retail packs corner X unmasked; project's +PackS16Pair masks (fix-direction, coords always positive here). + +## game/DotLights.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| DotLights_Video | 8002406c | Match (args verified in asm) | +| DotLights_AudioAndVideo | 800242b8 | Match | + +Video (decompile had dropped call args due to a broken callee prototype, so +fully asm-verified): traffic-light icons at gGT+0x1ecc, green light = index+2, +scale 1p=0x1000 / 2p=0xAAA / else 0x800, sizeX = (u1-u0)*scale>>12, posX = +(rect.w - 4*sizeX)/2 (sign-corrected shift = C division), posY = +(rect.h/3 [magic-mul /3])*posY>>12 - (v2-v0)*scale>>12, four draws at +posX + i*sizeX with (…, &primMem, pb->ptrOT, 0, scale). Latent: retail +truncates rect.h/3 to s16 before the multiply (unreachable difference). +AudioAndVideo: timer bands (<1 / ≤0x3C0 / ≤0x780 / ≤0xB40 / else), SFX edge +triggers (0x46 green, 0x45 red), magic-number /0x3C0 tween +(`*-0x77777777 >> 32` + `>>9` − sign), skip below -0x3BF, unconditional +prevFrame store — all match, including the project's DotLights_TweenPos +helper replicating the exact compiled division. + +## game/DropRain.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| DropRain_MakeSound | 80024464 | Match — **Ghidra DB plate is wrong, see note 14** | +| DropRain_Reset | 8002451c | Match | + +### Note 14 — Ghidra's "retail discards the sound handle" annotation is false + +The Ghidra decompile (and its analyst plate comment) claims the binary +reloads the old zero and stores it after OtherFX_Play, losing the handle — +and that the decomp "fixes" it. The raw asm disproves this: at 0x800244e0 +`jal OtherFX_Play` is followed by `j exit` with `sw v0,0x1ec8(s0)` in the +**jump's delay slot** — v0 there is OtherFX_Play's return value, so retail +stores the handle exactly like the project +(`gGT->rainSoundID = OtherFX_Play(0x82,0)`). Decompiler artifact +(mis-propagated pre-call v0). Project is correct; the Ghidra plate comment +on 0x80024464 should be corrected. Level gate (4/10), per-player particle +OR (stride 0x110), and the stop path all match. + +## game/ElimBG.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| ElimBG_SaveScreenshot_Chunk | 80024524 | Match (latent loop-guard note) | +| ElimBG_SaveScreenshot_Full | 8002459c | Match | +| ElimBG_Activate | 8002481c | Match | +| ElimBG_ToggleInstance | 80024840 | Match | +| ElimBG_ToggleAllInstances | 800248bc | Match | +| ElimBG_HandleState | 80024974 | Match | +| ElimBG_Deactivate | 80024c08 | Match | + +Details verified: +- Chunk: identical 4-pixel→u16 packing. Latent: retail loops + `do{}while(count != 0)` (count -= 4; non-multiple-of-4 would spin), project + uses `count > 0` (terminates) — callers only pass 0x1000, unreachable. +- Full: backup pointer layout ([0]=end-0x8000, [2]=end-0xC000, [4]=end-0xC800 + per db) matches the project's start+0x4800/+0x800/+0 formulation exactly; + double-buffered 26-iteration strip pipeline (StoreImage→Chunk→LoadImage + with toggling buffers) matches including the final odd-buffer flush and the + 16x1 CLUT strip load at (0x200,0xFF) from data.pauseScreenStrip. +- HandleState: state machine (3→restore, 1→capture+hide, 1|2→draw grid) + matches. primMem.end restore is arithmetically equivalent (retail + `backup[0]+0x8000` == project `current+0xC800` == original end). Tile grid: + tpage expr including the texY bits (which the community decomp dropped — + project has them right), clut 0x3FE0, u-from-VRAM-column math via the + `(tpage<<6)&0x3C0` identity, and `u1 = u0 - 0x80` ≡ retail's `u0 + 0x80` + mod 256. AddPrim into `ptrOT[4]` ✓. +- ToggleInstance flag algebra and ToggleAllInstances dual walk (InstDefs + + taken JitPool list; project inlines the trivial LIST accessors) match. +- Deactivate/Activate backups and `& 0xFFFFEFFF` restore mask match. + +## game/FLARE.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| FLARE_ThTick | 80024c4c | **Wrong vertex-slot mapping — see note 15** | +| FLARE_Init | 80025138 | Match | + +### Note 15 — FLARE_ThTick lower-quad vertex slots swapped (real finding) + +The flare is a 3x3 projected grid (rows y=-409, 0, +409; cols -409, 0, +409) +rendered as 4 POLY_GT4 quadrant quads p0..p3, all sharing the same UV set. +Retail's swc2 store offsets (asm 0x80024f5c-0x80025088, prim stride 0x34, +xy0/1/2/3 at +0x08/0x14/0x20/0x2c): + +- Row 1 (top): p0.xy0←SXY0(A), p0.xy1←SXY1(B), p1.xy1←B, p1.xy0←SXY2(C) + — project matches this row. +- Row 2 (middle): p0.xy2←D, p0.xy3←E, **p1.xy3←E, p1.xy2←F, + p2.xy2←D, p2.xy3←E, p3.xy3←E, p3.xy2←F** (+ depth from SZ2) +- Row 3 (bottom): **p2.xy0←G, p2.xy1←H, p3.xy1←H, p3.xy0←I** + +i.e. retail mirrors each quadrant's UV orientation away from the center +(p1 horizontal mirror, p2 vertical, p3 both) and every quad is planar. +The project (`game/FLARE.c:146-151,159-162`) instead assigns row 2 to +p2/p3's x0/x1 and row 3 to their x2/x3, and swaps p1's x2/x3 — producing +**bowtie (self-crossing) quads for p1 and p3** and an unmirrored p2. Fix by +matching the retail slot table above. Everything else in the function +matches: timer/lifetime (0x14, thread flag 0x800), 4-prim guard +(cursor+0xD0 < guardEnd, unsigned), GTE setup (llv0→mvlvtr = MAC1-3→TRX-Z, +rotation ctrl regs incl. `-scaledSin<<16` packing), envelope bands, icon +slot 0x87, ABR-1 texWord (`&0xff9fffff|0x200000`), colors +(0x3E000000/0/0/0x7F7F7F), depth `(SZ2>>8)-2` clamped to [0,0x3FF], tag +chain p0→p1→p2→p3→OT and cursor +0xD0. Latent: retail's angle division +`(timer<<12)/20` is signed (magic 0x66666667); project uses u32 division — +identical for the only possible (positive) inputs. + +FLARE_Init: PROC_BirthWithObject(0xC030D, ThTick, "lensflare", 0), +frameCount=0, 8-byte pos copy — match. + +## game/GAMEPAD.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| GAMEPAD_Init | 800251ac | Match | +| GAMEPAD_SetMainMode | 80025208 | Match | +| GAMEPAD_ProcessState | 800252a0 | Match | +| GAMEPAD_PollVsync | 80025410 | Match (see DB note below) | +| GAMEPAD_GetNumConnected | 800255b4 | Match | +| GAMEPAD_ProcessHold | 80025718 | **u16 truncation — FIXED, note 16** | +| GAMEPAD_ProcessSticks | 80025854 | Match (incl. leftY deglitch, JogCon saturation) | +| GAMEPAD_ProcessTapRelease | 80025d10 | Match | +| GAMEPAD_ProcessMotors | 80025e18 | **Wrong pulse mask + signedness — FIXED, note 17** | +| GAMEPAD_ProcessAnyoneVars | 800262d0 | Match (retail returns void; project returns heldAny — informational) | +| GAMEPAD_JogCon1 | 800263a0 | Match | +| GAMEPAD_JogCon2 | 800263fc | Match | +| GAMEPAD_ShockFreq | 80026440 | Match | +| GAMEPAD_ShockForce1 | 800264c0 | Match (compare signedness — FIXED via note 17 retype) | +| GAMEPAD_ShockForce2 | 80026540 | Match (same) | + +Ghidra DB note: the DB has 0x80025410 labeled "GAMEPAD_GetNumConnected" but +syms926 (and the body — PadGetState + per-pad state machine) say it is +GAMEPAD_PollVsync; worth renaming in the DB. + +Equivalences verified along the way: PollVsync's folded unplugged-detect +pointer dance == the project's boolean; GetNumConnected's conditional flag +store == the project's unconditional store; ProcessState's zero-fill-after +vs zero-fill-before motor read; NegCon wheel button offsets (btn_1/btn_2/ +trg_l == retail's +5/+6/+7); ProcessSticks' resolve/step helpers replicate +retail's clamp sequences exactly; ProcessMotors' "Naughty Dog bug" +(`unk48 = 0`) is an accurate simplification of retail's `if != 0 set 0`. + +### Note 16 — ProcessHold raw-button word was u16 (real finding, FIXED 2026-07-04) + +Retail keeps the raw active-high button word in a 32-bit register; for the +PSX Analog Joystick (controllerData 0x53) it shifts it `<<16` and the remap +table matches the high half. The project's `u16 uVar4` truncated the shift +to zero — flightstick input produced no buttons at all. Fixed by making the +word u32 (game/GAMEPAD.c, GAMEPAD_ProcessHold). + +### Note 17 — ProcessMotors JogCon pulse mask + byte signedness (real finding, FIXED 2026-07-04) + +Retail (0x80025e18) computes the steer-feel pulse as +`timer & unk42 & 0xf` — ANDing the timer with the RAW strength byte +(zero-extended) — with the motor level from `unk42 >> 4` (unsigned). +The project ANDed the timer with the already-shifted `unk42 >> 4` and used +a signed `char` (sign-extending `>>4` and mis-signing `unk42 - 0x10` for +values ≥ 0x80). Fixed: AND with the raw byte, local retyped u8. Also +retyped GamepadBuffer.unk42/unk43/shockValForce1/shockValForce2 to u8 — +retail reads all four with `lbu` for its priority compares +(ShockForce1/2's `stored < val` would flip for stored ≥ 0x80 with signed +char). All uses are GAMEPAD.c-local; stores/copies unaffected. + +## Fixes applied to project source (2026-07-04) + +Per-user directive, all confirmed findings are now fixed in-tree, each with +a brief `NOTE(claude)` comment citing the Ghidra evidence: + +1. `game/DecalMP.c` DecalMP_01 — 0x300 now ORed into `inst->flags` + (PUSHBUFFER_EXISTS|PIXEL_LOD), not the per-camera idpp (note 12). +2. `game/DecalMP.c` DecalMP_03 — quad corners now from + renderBucketScreenPos/Size, not pb.rect (note 13). +3. `game/FLARE.c` FLARE_ThTick — retail vertex-slot table restored for + rows 2/3 (no more bowtie quads) (note 15). +4. `game/DecalFont.c` GetLineWidthStrlen — width-test char now u8 + (unsigned, matches retail sltiu) (note 8). +5. `game/CTR/CTR_Box.c` DrawClearBox — texpage now sets y_VRAM_EXP + (retail 0xE1000A00) (note 2). +6. `game/DebugFont.c` DrawNumbers — X halves masked & 0xffff before + OR-packing (retail andi) (note 7). +7. `game/GAMEPAD.c` ProcessHold — u32 raw-button word (note 16). +8. `game/GAMEPAD.c` ProcessMotors + `include/namespace_Gamepad.h` — + pulse mask fixed; unk42/unk43/shockValForce1/2 retyped u8 (note 17). +9. `game/DecalHUD.c` DrawWeapon — comment corrected: the (u16) casts are + a deliberate port-side fix; retail has no masks (note 10). Behavior + intentionally kept (fix-direction). + +## game/GAMEPROG.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| GAMEPROG_AdvPercent | 800265c0 | Match | +| GAMEPROG_ResetHighScores | 8002689c | Match | +| GAMEPROG_CheckGhostsBeaten | 80026ae4 | Match | +| GAMEPROG_NewProfile_OutsideAdv | 80026bf0 | Match | +| GAMEPROG_InitFullMemcard | 80026c24 | Match | +| GAMEPROG_NewProfile_InsideAdv | 80026cb8 | Match | +| GAMEPROG_SaveCupProgress | 80026cf4 | Match | +| GAMEPROG_SyncGameAndCard | 80026d7c | Match | +| GAMEPROG_NewGame_OnBoot | 80026e48 | Match | +| GAMEPROG_GetPtrHighScoreTrack | 80026e80 | Match | + +Details: every reward-bit base checked against retail literals (trophy 0x06, +sapphire relic 0x16, gold relic 0x28, CTR token 0x4C, boss key 0x5E, gem +0x6A, purple token 0x6F, oxide 0x73) — all match the project's enum. +AdvPercent's 9-word counter clear, per-color token bump (folded base == +numCtrTokens.red indexing), oxide 2→3 overwrite, chained all-gold-relics +AND, and the final percent sum all match (retail's local purple/gem +counters equal the project's field/local mix). ResetHighScores: +`% PENTA_PENGUIN` == retail `% 0xd` (enum ordinal 13 verified), default +time 0x8C640, LNG strcpy ✓. CheckGhostsBeaten's s16 `>>5` word index and +levelID save/restore ✓. InitFullMemcard header {0xFFEE, 0x1600==sizeof} ✓ +(different write order vs retail, same final state). SaveCupProgress bit +promotion 0xC+i → 0x18+i ✓. SyncGameAndCard OR-merge (retail's decompiled +inner loop is a 1-iteration artifact — one flags word per track, like the +project); its "incomplete merge" comment matches the Ghidra plate's note. +GetPtrHighScoreTrack stride math (0x124/0x90/RELIC_RACE) ✓. + +## Build validation + +2026-07-04: full project build (`build.bat`, MinGW i686 + SDL3) passed with +exit 0 after all nine fixes from the "Fixes applied" section above. + +## game/GhostReplay.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| GhostReplay_ThTick | 80026ed8 | **Anim frame-0 arms swapped — FIXED, note 18** | +| GhostReplay_Init1 | 80027838 | Match | +| GhostReplay_Init2 | 80027b88 | Match | + +### Note 18 — ThTick ANIMATION opcode frame-0 handling (real finding, FIXED 2026-07-04) + +Retail asm (0x80027628-0x80027688): when the packet's frame byte is 0, +`blez maxFrame → clamp` — i.e. maxFrame > 0 falls through to store **0** +(v1 cleared in the 0x80027660 delay slot), and only maxFrame <= 0 stores +`numFrames-1`. The project had the arms swapped (`maxFrame>0 → maxFrame, +else 0`), making frame-0 packets jump the ghost's animation to its LAST +frame instead of rewinding to the first. Fixed in game/GhostReplay.c. +The nonzero-frame clamp (`byte < maxFrame ? byte : maxFrame`) matched. + +Everything else in ThTick verified: overflow-text colors 0x8003/0x8004 via +s16 sign-extension equivalence, hide/bail conditions (project drops retail's +dead post-deref NULL check — unobservable), packet-cache rebuild (BE16 +decode `<<16>>13`, velocity `(s8)*8`, IDLE rot copy from packet[-1], +end-of-tape BOTS conversion with flags 0x1000/ACTION_RACE_FINISHED), +`numPacketsInArray = count-1` with <0→1, 12-bit shortest-path rot lerp +(Ghidra's GhostPacket "time" field is actually rot.x — analyst misname), +BOOST/INSTANCE opcode replays, and the countdown-gated time advance. +Informational (all unreachable-difference): retail treats packetID as s16 +(16-bit stores/reads; project int — upper half never read by retail); +retail's interp quotient uses signed division (project u32 — numerator +provably non-negative); retail subtracts reserves unconditionally with u16 +operands (project gates on >0 — same end state); project's +`timeInPacket32 += dt` equals retail's `= backup+dt` via the T32==backup +invariant (both zeroed in Init2, kept in lockstep after). + +Init1/Init2 details verified: mode gate 0x20022000==0x20000, record buffer +end fold (header+0x3dfc == recordBuffer+0x3DD4), DEADC0ED magic, ptrEnd = +recordBuffer+gh->size fold, N.Tropy/Oxide pick via TT flags (1=open, +2=beaten; ST1 slots 5/6), PROC bucket 0x40102, wake model 0x43 flags 0x90 +(HIDE_MODEL|ANIM_LOOP), depthBias 0xc, inst flag 0x4000000, Oxide (id 15) +wheelSize 0, ghost0/ghost1 naming, and Init2's SVec3 prev=curr seeding +(unk1..unk4 are 3-short structs — copies match retail's element stores). + +## game/GhostTape.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| GhostTape_Start | 80027df4 | Match | +| GhostTape_End | 80027e90 | Match | +| GhostTape_WriteMoves | 80027f20 | Match | +| GhostTape_WriteBoosts | 8002838c | Match | +| GhostTape_Destroy | 80028410 | Match | + +Verified equivalences worth recording: +- Start: retail writes version as two bytes (0xFC, 0xFF) == the project's + s16 `version = -4`; anim trackers seeded -1/0xFFFFFFFF both always trigger + the first 0x81 chunk. +- End: retail computes `size` with 16-bit pointer subtraction + (`(short)offset - (short)start`) — same low-16 result as the project's + u32 subtraction into the u16 field. The project clears boolCanSaveGhost + *before* WriteMoves(1), retail after — WriteMoves skips that check when + raceFinished=1, so the ordering is unobservable. +- WriteMoves: all chunk encodings byte-identical (0x81 anim, 0x83 split + flag 0x2000, 0x84 idle, 5-byte small-delta with bounds (-0x7C,0x80) and + timeDelta < 0xFF01 under the count16&0x1F window, 11-byte 0x80 keyframe + big-endian); overflow guard `end < cursor+0x40` with canSave=0 comma-op + and the 0xB4 (6s) text timer matches; rot shifts (retail u16 logical vs + project s16 arithmetic >>4) equal for 12-bit rot values. +- WriteBoosts: TURBO_PAD == retail's `type & 4`, cooldown arm 0x1E, 6-byte + big-endian 0x82 chunk ✓. Destroy: trivial ✓. +- Retail reads P1 via threadBuckets[0].thread->object in Start/WriteMoves + (and mixes both paths in End); project uses gGT->drivers[0] in + Start/End — same object by engine invariant (informational). + +Build validation: GhostReplay anim fix built clean (exit 0, ctr_native.exe +linked) — 2026-07-04. + +## game/HOWL/HOWL_OtherFX.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| CountSounds | 8002843c | Match | +| OtherFX_Play | 80028468 | Match | +| OtherFX_Play_Echo | 80028494 | Match | +| OtherFX_Play_LowLevel | 800284d0 | **s16 id signedness — FIXED, note 19** | +| OtherFX_Modify | 80028690 | Match | +| OtherFX_Stop1 | 80028808 | Match | +| OtherFX_Stop2 | 80028844 | Match | + +### Note 19 — Play_LowLevel sound-id signedness (real finding, FIXED 2026-07-04) + +Retail bounds-checks `(soundID & 0xffff) < numOtherFX` **unsigned**. The +project stored the masked id in an `s16`: ids >= 0x8000 went negative, +passed `id >= numOtherFX`, then indexed `howl_metaOtherFX[id]` out of +bounds — and, secondarily, sign-extended into the `(count << 16) | id` +handle, clobbering the count half. Retyped to u16 (bounds check, index, +and OR all now match retail; the (short)-truncating Channel_* call args +are unaffected). + +Other verifications: Play/Play_Echo wrapper flags (0xFF8080 / |0x1000000) +✓; Play returns LowLevel's handle (retail tail-call keeps v0 — consistent +with the DropRain asm from note 14) ✓. LowLevel's NULL-channel arm: retail +falls through and *returns a read from address 0x1c* (PSX null-page quirk); +the project's explicit `return 0` is a documented, sensible port fix — +kept. Modify: bounds check is signedness-safe (masked value non-negative), +voice/FX volume select (meta flag 4), distort pitch table (0x80 = neutral), +`vol*meta*flags>>10` scaling, EditAttr(1, full handle, 0x70) + live +writeback @+0xE..0x11 all match; the Ghidra plate's +"ptrMempack->firstFreeByte" note is a global-aliasing artifact (same word +as ptrHowlHeader->numOtherFX). Stop1 (mask 0xFFFFFFFF) / Stop2 (mask +0xFFFF, id-only) ✓. + +## game/HOWL/HOWL_Engine.c (EngineAudio block) + HOWL_Reverb.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| EngineAudio_InitOnce | 80028880 | Match | +| EngineAudio_Recalculate | 800289b0 | Match | +| EngineAudio_Stop | 80028b54 | Match | +| SetReverbMode | 80028bbc | Match | + +Notes: all three EngineAudio bounds checks are signedness-safe (the project +compares `(int)(masked u32)` — non-negative — unlike the s16 bug fixed in +OtherFX_Play_LowLevel; checked each one deliberately). Recalculate's +split-screen volume scale matches exactly (2P vol*0x37, 3P+ vol*0x2D, then +`(x<<2)>>8` == retail's `>>6`; 1P unscaled via the goto), the writeback +stores the scaled volume in both, and the engine-specific distort table +(distortConst_Engine, distinct from the OtherFX table) is used correctly. +The project computes attr.pitch before Channel_SetVolume where retail does +it after — verified unobservable (SetVolume writes only audioL/audioR). +SetReverbMode: mode<5 preset switch + depth from param entry, mode>=5 -> +reverb off / cur=5, change-detection identical. Ghidra DB hygiene notes: +0x80028bbc is named "Howl_SetReverbMode" (syms: SetReverbMode); the howl +header struct in the DB mislabels numEngineFX@+0x18 as +"sizeOfPrevAllocation" (per its own plate). + +EngineSound_* functions in HOWL_Engine.c (0x8002f5f4+) belong to a later +address block — will be verified when the sweep reaches them in order. + +## game/HOWL/HOWL_CseqMusic.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| CseqMusic_Start | 80028c78 | Match | +| CseqMusic_Pause | 80028d64 | Match | +| CseqMusic_Resume | 80028de0 | Match | +| CseqMusic_ChangeVolume | 80028e5c | Match | +| CseqMusic_Restart | 80028f34 | Match | +| CseqMusic_ChangeTempo | 80029008 | Match | +| CseqMusic_AdvHubSwap | 800290cc | Match | +| CseqMusic_Stop | 800291a0 | Match | +| CseqMusic_StopAll | 80029258 | Match | + +All nine share the guard chain (audio enabled → ptrCseqHeader != 0 → +songID < numSongs where applicable; StopAll/Pause/Resume skip the songID +guard, like retail) and the 2-slot songPool scan under +Smart_Enter/ExitCriticalSection. Start's first-free-slot early-out returns +1/0 like retail; the project's `p2/p5/p3/p4` argument shuffle into +SongPool_Start matches retail's (song, id, tempo, loop, songSet, +activeBits) order exactly. Pause/Resume flag bits (|=2 / &=~2 == retail's +&0xFD), Restart's |=4 + fade-from-0 volume call, ChangeVolume's &0xFF +masks, and Stop/StopAll's SongPool_StopAllCseq dispatch all match. The +numSongs compares are u16-vs-u16 in retail; the project's promoted-int +compares are equivalent for all representable song counts. Also +corroborated: the community decomp #if-0's these guards, but retail keeps +them — the project correctly keeps them too (matches the Ghidra plates' +"binary-wins" notes). + +## game/HOWL/HOWL_Bank.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| Bank_ResetAllocator | 800292e0 | Match | +| Bank_Alloc | 800292fc | Match | +| Bank_AssignSpuAddrs | 800293b8 | Match | +| Bank_Destroy | 800296c4 | Match | +| Bank_ClearInRange | 80029730 | Match | +| Bank_Load | 800297a0 | Match (out-param width note below) | +| Bank_DestroyLast | 80029824 | Match | +| Bank_DestroyUntilIndex | 80029870 | Match | +| Bank_DestroyAll | 800298e4 | Match | + +Highlights: +- AssignSpuAddrs: all 5 stages match — sum*8 placement differs (retail + multiplies per-element, project once after; equal), the **"Naughty Dog + bug" (u16 Bank->max compared against the un-shifted byte size)** is + faithfully reproduced, realloc size identity ((sz+0x7ff)&~0x7ff)+0x800 == + numSectors*0x800+0x800, and the SPU-address accumulator's u16 wrap equals + the project's int-accumulate + u16-store at every element. +- **Ghidra DB error #3**: the stage-2 callee 0x80076310 is labeled + "SpuRead" but calls Spu_WriteData (its own plate says "Possible + S_W.OBJ/SpuWrite") — it IS SpuWrite; the project's SpuWrite call is + correct. Worth renaming in the DB. +- ClearInRange: retail's `(u16)(start + (s16)range)` end == the project's + `u16 end = min + max` (mod-65536 identity); range/boundary compares match. +- Bank_Load (informational): retail's 2nd param is a **byte** out-pointer + (`sb` stores the new bank stack index); the project types it Bank* and + writes the u16 bankID field — a 2-byte store. All project callers pass a + local `struct Bank` scratch (HOWL_Music.c `thisBank`), so the wider store + is contained and self-consistent — no fix needed, but the param is really + "u8 *outBankIndex" in retail terms (explains the project's own + "isn't really a bank..." comment). +- DestroyUntilIndex: s16-vs-u16 equality on same-width values — + signedness-independent, equal. + +## game/HOWL/HOWL_Load.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| howl_InstrumentPitch | 8002991c | Match | +| howl_InitGlobals | 80029988 | Match | +| howl_ParseHeader | 80029a50 | Match | +| howl_ParseCseqHeader | 80029ab4 | Match | +| howl_LoadHeader | 80029b2c | Match | +| howl_SetSong | 80029c40 | Match | +| howl_LoadSong | 80029ca4 | Match | +| howl_ErasePtrCseqHeader | 80029dc0 | Match | +| howl_GetNextNote | 80029dcc | Match | + +- **Ghidra DB error #4**: 0x80076870 is labeled "SsUtReverbOff" but is a + thin wrapper calling _SpuInit (its own plate: "Possible S_I.OBJ/SpuInit") + — the project's SpuInit() call in howl_InitGlobals is correct. Rename in + the DB alongside error #3 (SpuRead→SpuWrite @0x80076310). +- InstrumentPitch: table indices, `>>12`, low-6-bit fine-tune multiply with + deliberate 32-bit wrap (project's CTR_MipsMulLo == retail mflo) and the + final &0xFFFF placement all equivalent. +- ParseCseqHeader: retail's two conditional aligns (`+1 if &1, +2 if &2`) + == the project's `(addr+3)&~3` (checked all four residues). +- LoadHeader: retail's magic `0x827` constant == project's + `sizeof(HowlHeader)=0x28 + 0x7FF` formulation; FindFile-before-bookmark + order, >1-sector continuation read, and the trim-realloc all match. +- LoadSong: 4-stage poll loop matches Bank_AssignSpuAddrs' pattern; sector + count from the header's first word (retail sra vs project srl — positive + sizes, unreachable difference), destination block1+0x800 == + tenSampleBlocks field adjacency. +- GetNextNote: MIDI varint decode — retail's raw-first-byte initialization + equals the project's masked form (bit7 clear implies raw==masked); + accumulate/exit conditions identical. + +## game/HOWL/HOWL_CseqOpcode.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| cseq_opcode01_noteoff | 80029e18 | Match | +| cseq_opcode02_empty | 80029f1c | Match (empty) | +| cseq_opcode03 | 80029f24 | Match | +| cseq_opcode04_empty | 80029f78 | Match (empty) | +| howl_InitChannelAttr_Music | 80029f80 | Match | +| cseq_opcode_from06and07 | 8002a170 | Match | +| cseq_opcode05_noteon | 8002a28c | Match | +| cseq_opcode06 | 8002a3a8 | Match | +| cseq_opcode07 | 8002a3d4 | Match | +| cseq_opcode08 | 8002a400 | Match | +| cseq_opcode09 | 8002a494 | Match | +| cseq_opcode0a | 8002a4a8 | Match | + +Verified: volume pipeline shift placement (`(volMusic*poolVol*seqVol)>>10` +then `*sampleVol`, final `(x*channelVol)>>15` LOGICAL, 06/07 path `>>18` +ARITHMETIC — all match, incl. 32-bit MulLo wrap semantics); music drums use +the OtherFX distortion table (same as retail — not the Engine table); drum +ADSR 0x80FF/0x1FC2 (retail renders ad as -0x7f01, same u16); note-off's +update-flag sequence (|=1 then &=~2) and its saved-next traversal while +unlinking channels; noteon's three zero-volume guards (order differs, +side-effect-free — equivalent) and full channel population incl. unk1/unk0A; +opcode0a's per-channel repitch (melodic via InstrumentPitch, drums via the +0x80-neutral check). DB naming note (not errors): the DB uses semantic +names (howl_StopSoundChannels / cseq_PlayNote / howl_SetSoundPitch etc.) +where syms926/project use cseq_opcodeNN — same functions. + +## game/HOWL/HOWL_SongPool.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| SongPool_FindFreeChannel | 8002a63c | Match (24 slots == NUM_SFX_CHANNELS) | +| SongPool_CalculateTempo | 8002a678 | Match | +| SongPool_ChangeTempo | 8002a6cc | Match | +| SongPool_Start | 8002a730 | Match | +| SongPool_Volume | 8002a9d8 | Match | +| SongPool_AdvHub1 | 8002a9f0 | Match | +| SongPool_AdvHub2 | 8002aa44 | Match | +| SongPool_StopCseq | 8002ab18 | Match | +| SongPool_StopAllCseq | 8002ac0c | Match | + +Verified details: CalculateTempo's project source reproduces retail's +exact codegen — unsigned magic-multiply divide-by-60 (mulhiu 0x88888889, +>>5) then `<<16` and a sign-extend-then-UNSIGNED divide by const60. +ChangeTempo/Start's `(u16)bpm + delta` vs retail's signed read: identical +mod-2^16 for the s16 store. Start: default vol 0xFF for songs 1-2 else +0xBE with the NAUGHTY_DOG_CRATE override, retail's two-step +1/+2 align +(project kept the literal form here), the flags `= 5` overwrite (not OR), +songSet mute mask via the ptrSongSetBits pointer, and GetNextNote seeding +of currNote/NoteLength — all match. AdvHub1/StopCseq keep the bounds check +and soundID filter that the community decomp #if-0's (binary-wins, +matching the Ghidra plates). AdvHub2 dereferences songSet->ptrSongSetBits +in-loop without a NULL guard in BOTH retail and project (callers never +pass NULL — bug-compatible). StopAllCseq's pointer-walk artifact == +array indexing. + +## game/HOWL/HOWL_Settings.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| howl_Disable | 8002ac94 | Match | +| UpdateChannelVol_EngineFX | 8002acb8 | Match | +| UpdateChannelVol_OtherFX | 8002ad04 | Match | +| UpdateChannelVol_Music | 8002ad70 | Match | +| UpdateChannelVol_EngineFX_All | 8002ae64 | Match | +| UpdateChannelVol_Music_All | 8002af6c | Match | +| UpdateChannelVol_OtherFX_All | 8002b030 | Match | +| howl_VolumeGet | 8002b0e0 | Match | +| howl_VolumeSet | 8002b130 | Match | +| howl_ModeGet | 8002b1f0 | Match | +| howl_ModeSet | 8002b1fc | Match | +| OptionsMenu_TestSound | 8002b208 | Match (decompiler artifact — see below) | + +**Ghidra decompiler artifact #5** (2nd of the delay-slot class, cf. note +14): the decompile of OptionsMenu_TestSound's voice-row tail claims retail +stores the characterIDs POINTER into OptionSlider_soundID. The asm at +0x8002b4ac is `jal OtherFX_Play; _andi a0,a0,0xffff; sw v0,0x834(gp)` — +retail stores the RETURN HANDLE, exactly like the project. (Root cause per +the DB's own plates: unresolved gp-relative symbols aliasing the absolute +globals.) Also verified there: the %25/%50 frame gates are unsigned +magic-multiply divisions (project's signed `/` on the non-negative frame +counter is equal), voice sample IDs charID+0x1C / +0x2C, and the stop/play +row transitions incl. the AKU(1)/UKA(2) selection via +Music_GetHighestSongPlayIndex. + +Other notes: UpdateChannelVol_Music's `(product>>10)*sample*vol>>0xF` +pipeline matches with no overflow divergence (max ~1.06e9 < 2^31); +EngineFX_All's continue-on-music == retail's if/else-if (type domain +0/1/2); Music_All's unmasked retail seq index vs the project's `& 0xffff` +is a no-op (music soundIDs are u16 field values). VolumeSet's +early-return-before-EnterCritical paths match retail exactly. + +## game/HOWL/HOWL_Channel.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| Smart_EnterCriticalSection | 8002b4d0 | Match | +| Smart_ExitCriticalSection | 8002b508 | Match | +| Channel_SetVolume | 8002b540 | Match | +| Channel_FindSound | 8002b5b4 | Match | +| Channel_AllocSlot_AntiSpam | 8002b608 | **Signed window compare — FIXED, note 20** | +| Channel_AllocSlot | 8002b7d0 | Match | +| Channel_SearchFX_EditAttr | 8002b898 | Match | +| Channel_SearchFX_Destroy | 8002b9b8 | Match | +| Channel_DestroyAll_LowLevel | 8002ba90 | Match | +| Channel_ParseSongToChannels | 8002bbac | Match | +| Channel_UpdateChannels | 8002be9c | Match | + +### Note 20 — AllocSlot_AntiSpam 10-frame window (real finding, FIXED 2026-07-04) + +Retail: `(uint)(now - startFrame) < 10` — UNSIGNED. The project's signed +`int duration < 10` also matched NEGATIVE differences (stale channel with +startFrame ahead of the timer, e.g. across a frame-counter reset), wrongly +destroying it. Retyped to u32 (game/HOWL/HOWL_Channel.c). + +Other verifications: Smart_* pair — retail (and project) really call +Enter/ExitCriticalSection; the community decomp's counter-only rewrite is +noted in the DB plates as a decomp deviation, and the project correctly +sides with the binary. Project factors Channel_DestroySelf out of the +three retail inline sites (AntiSpam/SearchFX_Destroy/DestroyAll) — +byte-equivalent sequence (updateFlags |=1 &~2, flags &=~1, taken→free). +FindSound's (s16) equality == the project's u16-mask equality. +ParseSongToChannels: tempo accumulate/u16 wraparound, both volume-fade +state machines (incl. the signed compare that catches subtract-wrap), the +opcode dispatch with post-handler stop check, restart (bit3) loop-back, +opcodeOffset advance + GetNextNote re-seed, and the opcode>=0x0B +no-advance spin (bug-compatible in both). One informational ordering +difference: the project clears the song's restart bit (flags&~4) BEFORE +SongPool_StopAllCseq/Music_End, retail after — neither callee reads bit2 +(StopAllCseq verified; flag transient), so unobservable. +UpdateChannels: two-pass key-off/key-on masks, per-field +change-detection against ChannelAttrCur, ADSR AR/SR/RR mode decode and +the arg masks (arithmetic-vs-logical shifts equal under &0x7f/&0x1f/&0xf), +reverb on/off per voice, and the missing outer `flags != 0` guard in the +project (writes 0 over 0 — no-op) — all equivalent. + +## game/HOWL/HOWL_Playback.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| Cutscene_VolumeBackup | 8002c18c | Match | +| Cutscene_VolumeRestore | 8002c1d0 | Match | +| howl_PlayAudio_Update | 8002c208 | Match | +| howl_InitChannelAttr_EngineFX | 8002c34c | Match | +| howl_InitChannelAttr_OtherFX | 8002c424 | Match | +| howl_PauseAudio | 8002c510 | Match | +| howl_UnPauseChannel | 8002c64c | Match | +| howl_UnPauseAudio | 8002c784 | **Missing free-list NULL break — FIXED, note 21** | +| howl_StopAudio | 8002c8a8 | Match | + +### Note 21 — UnPauseAudio free-list exhaustion (real finding, FIXED 2026-07-04) + +Retail's restore loop has `if (stats == NULL) break;` — it walks the FREE +list to re-materialize the backed-up channels and stops if the list runs +dry (channels can be allocated between pause and unpause). The project's +for-loop had no NULL check and would dereference NULL in that state. +Added the break (game/HOWL/HOWL_Playback.c). + +Other verifications: PlayAudio_Update's cutscene fade (-2/frame, clamp at +0 via retail's `<<16` s16 sign test == project's s16 field), the +criticalSectionCount=1 hack around VolumeSet reproduced, the 5-per-frame +timeLeft decrement with s16 <=0 destroy; PauseAudio's full-0x20 +ChannelStats backup INCLUDING next/prev/channelID and the UnPause dance +that copies the backup over a free node then restores its own +next/prev/channelID before relinking — byte-faithful in the project. +UnPauseChannel's type dispatch, |=0x7E re-arm, and 16-byte attr copy ✓. +InitChannelAttr_EngineFX/OtherFX: correct per-type distortion tables +(Engine vs OtherFX), ADSR 0x80FF/0x1FC2, spuAddr<<3, and the voice-flag +volume select ✓. StopAudio's arg mapping onto DestroyAll_LowLevel ✓. + +## game/HOWL/HOWL_Voiceline.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| Voiceline_PoolInit | 8002c918 | Match | +| Voiceline_ClearTimeStamp | 8002caa8 | Match | +| Voiceline_PoolClear | 8002cae0 | Match | +| Voiceline_StopAll | 8002cb44 | Match | +| Voiceline_ToggleEnable | 8002cbb4 | Match | +| Voiceline_RequestPlay | 8002cbe8 | Match | +| Voiceline_StartPlay | 8002cf28 | Match | +| Voiceline_Update | 8002d0f8 | Match | +| Voiceline_EmptyFunc | 8002d2a8 | Match (empty) | +| Voiceline_SetDefaults | 8002d2b0 | Missing unused-field store — FIXED (fidelity), note 22 | + +### Note 22 — SetDefaults desiredXA_FinalLapIndex (fidelity fix, 2026-07-04) + +Retail zeroes seven fields including g_nDesiredXAFinalLapIdx (0x8008d7f4 — +the unused "final lap XA" slot); the project zeroed six. No behavioral +impact (nothing reads the field, in retail or project), but the store was +added for asm fidelity since the function is marked ASM-verified. + +RequestPlay verified in depth: bounds (0x18/0x10/0x11), END_OF_RACE gate, +the taunt RNG (mask 7 if the voiceID bit was already used, else 3 — order +double-checked against the decompile's inverted rendering), the two +different frame gates (immediate >= 0x3D, queue >= 0x3C — one-frame skew +faithfully kept), all four queue/immediate decision paths including the +rng&1 coin flip, subIndex>=2 "stamp but don't play" path, dedupe walk, +slot recycling (free-first then oldest-active), and the request fill. +Unreachable-state note: with BOTH pool lists empty retail skips AddFront +and fills through NULL while the project AddFronts NULL first — both +broken, both unreachable (free+active == 8 nodes invariant). StartPlay: +boss-race sign-bit test == IS_BOSS_RACE, random 4..7 subset, element-vs- +byte-offset clip indexing equal, cooldown = len/5+0x1E. Update: cooldown +decrement gate, XA-idle gate, wrong-way disarm-before-1P-check flow, Aku/ +Uka pick via the good-guy mask, re-arm, and the pop-first→StartPlay tail. +PoolInit's 24-channel hardware/state reset and the 2-song/24-seq pool +seeding all match. + +## game/HOWL/HOWL_AudioState.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| Audio_SetState | 8002d2f4 | Match | +| Audio_SetState_Safe | 8002d4cc | Match | +| Audio_AdvHub_SwapSong | 8002d50c | Match | +| Audio_SetMaskSong | 8002d554 | Match | +| Audio_Update1 | 8002d67c | Match (CTR_NATIVE null guards documented) | +| Audio_SetDefaults | 8002dc4c | Match | +| Audio_SetReverbMode | 8002dcac | Match | + +Details: SetState's 12 cases match (intro-XA rotation: retail signed mod-4 +== project `&3` on the 0..3 domain, play-then-advance vs capture-then- +advance same net state; race-end XA type split at >61). SetMaskSong's +separate Uka/Aku bit clears == the project's combined mask clear. +SetDefaults cross-validates the audioDefaults[9] array against the DB's +resolved distinct globals (vol/dist pairs 0, pan pair 0x80 at [4..5], +three flags 0) — and matches the [2+i]/[4+i] usage seen in +EngineSound_NearestAIs. Update1: all 10 states' transitions, lead-human +scans (ACTION_BOT skip; DYNAMIC_PLAYER match in the end state with +raceOrderIndex capture), XA seek gates (needSeek && idle && >9 frames +since XA_PauseFrame; seek 6 pre-last-lap / 4 last-lap when >2000 from +finish), FINAL_LAP's offset>0xE1 volume raise + tempo 20, the N.Tropy +carousel (post-increment index, wrap >5), the 0x43 all-beaten / 0x46 +just-opened lines, and the comma-op defeat(5)/victory(4) selection. The +project's #ifdef CTR_NATIVE null-driver guards (demo mode leaves the +human scan empty; PSX reads low RAM through NULL, host cannot) are +well-placed — they preserve retail's call order (Voiceline_Update/ +AmbientSound/seek-clear still run) and are documented in-source. +Ghidra DB naming note: the DB's AUDIO_STATE_* enum names differ from the +project's AUDIO_* (e.g. STATE_MASK_SONG = project AUDIO_LAST_LAP) — same +numeric states throughout. + +## game/HOWL/HOWL_Music.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| Music_SetIntro | 8002dd24 | Match | +| Music_LoadBanks | 8002dd74 | Match | +| Music_AsyncParseBanks | 8002de48 | Match | +| Music_SetDefaults | 8002e338 | Match | +| Music_Adjust | 8002e350 | Match | +| Music_LowerVolume | 8002e418 | Match | +| Music_RaiseVolume | 8002e46c | Match | +| Music_Restart | 8002e4c0 | Match | +| Music_Stop | 8002e4ec | Match | +| Music_Start | 8002e524 | Match | +| Music_End | 8002e53c | Match | +| Music_GetHighestSongPlayIndex | 8002e550 | Match | + +AsyncParseBanks (the 5-state bank loader) verified stay-vs-advance path by +path: state 0 FX-bank tables (boss table / levBank_FX / menu-ending IDs +0x20/0x23-0x25), state 1's engine-bank-54 gate (project's 0x8c100000 == +BOSS|CRYSTAL|RELIC|ARENA mask; bankCount/PodiumStage/Load54 reset), state +2's character loop (bank54 skip for charID < PINSTRIPE(8), +0x37 bank +mapping, purple-gem-cup 5-driver variant, hub first-character with PURA(7) +boundary, 3-stage podium via modelIndex-0x58 with skip-on-zero), state 3's +song tables (boss song 0x19; specials 0x1b-0x20; advance-without-SetSong +when bossID>5), state 4 LoadSong poll. Music_Adjust's stop/retune/start +lattice, the -1 sentinel vs u16 song compare, the 150/90-255/190 volume +tiers keyed on `(highest-1) < 2`, and the byte-aliasing of +audioDefaults[7] into bankLoaded/songLoadState (matches the DB's two +adjacent byte globals @0x8009d834/5) all match. Music_End's +no-stop-command semantics ✓. + +## game/HOWL/HOWL_AudioLR.c + HOWL_OtherFXRecycle.c + GTE_GetSquaredDistance — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| GTE_AudioLR_Inst | 8002e55c | Match | +| GTE_AudioLR_Driver | 8002e5cc | Match | +| GTE_GetSquaredLength | 8002e658 | Match | +| OtherFX_RecycleNew | 8002e690 | Match | +| OtherFX_RecycleMute | 8002e724 | Match | +| OtherFX_DriverCrashing | 8002e760 | Match | +| GTE_GetSquaredDistance | 8002e7bc | Match (in HOWL_LevelAudio.c) | + +Notes: the AudioLR pair's SVECTOR pad init (project 0, retail stack +garbage) is unobservable — gte_ldv0's word load ignores the pad half. +GetSquaredLength/Distance: IR1-3 loads + SQR0 + MAC1-3 sum; the project's +MTC2/MFC2 spelling equals retail's degraded-decompile wrapper pattern +(param passing confirmed from the DB plates' own asm notes). RecycleNew's +cached-local vs retail's slot re-read stays in sync (local zeroed with the +slot). DriverCrashing volume thresholds 0xDD/0xA0 → ids 10/12/11 and the +echo<<24|vol<<16|0x8080 packing ✓. DB naming variances (already corrected +in DB for RecycleNew per its plate; RecycleMute is "OtherFX_Stop_Safe" in +the DB vs syms926) — same functions. + +Also resolved a false alarm: two files appeared to contain comment lines +starting with a bare backslash (`\ NOTE...`) in tool output — byte dump +confirms they are ordinary `// NOTE` comments (display artifact only). + +## game/HOWL/HOWL_LevelAudio.c (rest) + PlaySound3D pair — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| CalculateVolumeFromDistance | 8002e84c | Match | +| PlayWarppadSound | 8002e994 | Match | +| Level_SoundLoopSet | 8002e9c0 | Match | +| Level_SoundLoopFade | 8002ea44 | Match | +| Level_RandomFX | 8002eab8 | Match | +| Level_AmbientSound | 8002ebe4 | Match | +| PlaySound3D | 8002f0dc | Match | +| PlaySound3D_Flags | 8002f31c | Match | + +Details: CalcVolumeFromDistance's 6000-unit gate, <301→255 volume knee, +and the soundID-0x89 warp-pad pitch wobble (vsync timer >>2 &0x7F −0x40, +abs, +100) all match. SoundLoopFade retains retail's dead `curr<=desired` +else-branch (unreachable, kept faithfully). RandomFX: echo-flagged +one-shot (0x1008080), rng volume (rng%100+100)*scale>>8, cooldown +rng%range+base (retail's div-trap is compiler-emitted). AmbientSound: the +three modes (Roo's Tubes terrains 0/1/11 + loop 0x87; Sewer loops +0x88/0x8b rates 8/4; generic 2-slot nearest-SpawnType2 scan with slot+5 +spawn indexing, numCoords>9 guard, and the spawn-warn flag == +audioDefaults[6] — third independent confirmation of that mapping; level +9/3 RandomFX specials with ids 0x86/0x85). The DB plate notes the +community decomp #if-0'd the whole generic branch — binary has it, and +the project implements it fully (binary-wins again). PlaySound3D pair: +identical camera-relative vector math, ratan2 angle fold (>0x80 → +0x100−lr, <−0x80 → −0x100−lr, then +0x80 clamp 0..0xFF), 301/9000 volume +knee, echo from quadFlags&0x80, distort byte 0x80; one-shot uses +antiSpam=1, the _Flags variant uses 0 with the recycle-slot pattern. + +## game/HOWL/HOWL_Engine.c (EngineSound block) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| EngineSound_Player | 8002f5f4 | Match (all 3 engine-sound modes) | +| EngineSound_VolumeAdjust | 8002fc28 | Match | +| EngineSound_AI | 8002fc64 | Match | +| EngineSound_NearestAIs | 8002ff28 | **Wrong bot-count field — FIXED, note 23** | + +### Note 23 — NearestAIs gates on numBotsNextGame (real finding, FIXED 2026-07-04) + +Retail asm 0x8002ff50: `lbu v0,0x1cab(gGT)` — the guard reads +**numBotsNextGame** (0x1cab), not numBotsCurrGame (0x1caa) as the project +had. Odd on retail's part (likely benign in-race where both are equal), +but the fields differ during race setup/transitions. Fixed to match +(game/HOWL/HOWL_Engine.c). + +Everything else matches: Player's three modes (mode 0/1 special-class +fades — note mode 0's MapToRange floor of 0 vs mode 1's 0x82 — and the +main RPM model: revving blend pitch*0x40+rev*0x30+needle*0x90>>8 with the ++0x1000 fire kicker, slew hysteresis at 0x601 with drift floor 2000/cap +14000, human/AI caps 0xE6/200 vs 0xBE, drift turbo-distort accumulator +incl. decrement-if-nonzero idle, steering-kick pitch cut, pan +0x80−(wheel>>3) clamped 0x40..0xC0, echo bit, engineID*4+driverID channel +id). VolumeAdjust's snap-or-step slew ✓. AI: same pipeline against the +audioDefaults[0..1] smoothed volume, distance attenuation 200..2000, ±0x14 +pitch-skew clamp. NearestAIs: two-closest insertion with camera-index +capture, non-GTE squared-distance (plain mults, matching the project's +CTR_MipsMulLo), the pan fold WITHOUT the 0..0xFF clamp (unlike +PlaySound3D's variant — both correct per their retail counterparts), pan +slew via audioDefaults[4..5], prevDist via [2..3]. + +**HOWL module complete: 0x8002843c-0x80030208 fully verified (18 files, +~100 functions).** + +## game/HOWL/HOWL_Garage.c — DONE (2026-07-04) — HOWL truly complete + +| Function | Address | Verdict | +|---|---|---| +| Garage_Init | 80030208 | Match | +| Garage_Enter | 80030264 | Match | +| Garage_PlayFX | 80030404 | Match | +| Garage_LerpFX | 800304b8 | Match | +| Garage_MoveLR | 80030694 | Match | +| Garage_Leave | 8003074c | Match | + +Verified: carousel targets (CENTER 0xFF/0x80, LEFT 100/0x3C, RIGHT +100/0xC3, GONE 0/keep-LR), ±8 volume and ±2 pan easing with overshoot +clamps, the per-slot changed guard (which the community decomp dropped — +DB plate marks it binary-authoritative; project has it), the GONE→ +LEFT/RIGHT pan snap in MoveLR, Enter's flags (`0x8000|LR` == retail's +`vol<<16|0x80xx` since vol is freshly zeroed), PlayFX's bird-random +0xF6→0xF3..0xF5 rng pick, and RecycleMute == the DB's "OtherFX_Stop_Safe" +(same 0x8002e724). Latent notes: neighbor wrap `&7` vs retail's +conditional wrap differs only for impossible negative ids; Garage_Leave +iterates forward vs retail's backward walk (order-irrelevant stores). + +HOWL final tally: 0x8002843c-0x80030778, 19 files, ~110 functions, 3 +behavioral findings (OtherFX id signedness, AllocSlot_AntiSpam window, +UnPauseAudio NULL break, NearestAIs bot-count field = 4 actually) + 1 +fidelity fix, all fixed. + +## game/INSTANCE.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| INSTANCE_Birth | 80030778 | Match (native null-name guard documented) | +| INSTANCE_Birth3D | 8003086c | Match (DRAW_COLLISION_MASK == 0xF) | +| INSTANCE_Birth2D | 800308e4 | Match + **native NULL guard added — note 24** | +| INSTANCE_BirthWithThread | 800309a4 | Match (unchecked thread deref = retail) | +| INSTANCE_BirthWithThread_Stack | 80030a50 | Match | +| INSTANCE_Death | 80030aa8 | Match | +| INSTANCE_LevInitAll | 80030ad4 | **Filter structure + phantom flag — FIXED, note 25** | +| INSTANCE_LevDelayedLInBs | 80030ed4 | Match | +| INSTANCE_GetNumAnimFrames | 80030f58 | Match (guard order reordered, pure reads) | + +### Note 24 — Birth2D NULL-instance idpp writes (port-safety fix, 2026-07-04) + +Retail performs the idpp pushBuffer writes even when JitPool_Add returns +NULL (a PS1 low-RAM write); the project reproduced the layout and would +segfault on host. Added a CTR_NATIVE early-return matching +INSTANCE_Birth's existing null-name guard convention. + +### Note 25 — LevInitAll mode filters (real finding, FIXED 2026-07-04) + +Retail asm 0x80030d0c-0x80030e2c runs TWO independent sequential filters: +Filter A (TT/relic/time-crates — every exit jumps `j 0x80030dac`) then an +UNCONDITIONAL Filter B (crystal/TNT/nitro). Consequences the project's +else-if chain missed: time trial and relic races also hide +crystal/TNT/nitro instances; arcade/adventure/crystal modes also hide +relic time crates (retail's relic==0 path at 0x80030d74). Additionally +the relic time-crate path (0x80030d3c) is count-only — the project's +`inst->flags |= 1` ("temporary, until timebox thread") does not exist in +retail. Restructured to the two-pass form and dropped the phantom OR. +Constants confirmed from immediates: TC ids {s5,s6,0x65}, wumpa=2, fruit +crates 7-8, crystal 0x60, TNT 6, nitro 0x27, CRYSTAL_CHALLENGE=0x8000000, +ADVENTURE_MODE=0x80000, TOKEN_RACE=8 (gameMode2). + +Open question deferred to the COLL.c sweep: LevInitAll's LInB gate — +retail checks `meta != NULL` after COLL_LevModelMeta; the project instead +pre-checks `(s16)modelID > 0` and dereferences meta unchecked. Equivalent +only if COLL_LevModelMeta returns NULL exactly for non-positive ids — +verify when COLL_LevModelMeta is reached. + +## game/JitPool.c + LevInstDef.c + LHMatrix.c + LibraryOfModels + LinkedCollide.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| JitPool_Clear | 80030fdc | Match (stride = itemSize & ~3) | +| JitPool_Init | 8003105c | Match (memset+poolSize: binary-wins vs decomp) | +| JitPool_Add | 800310d4 | Match | +| JitPool_Remove | 8003112c | Match | +| LevInstDef_UnPack | 8003116c | Match | +| LevInstDef_RePack | 80031268 | Match | +| LHMatrix_Parent | 800313c8 | Match | +| LibraryOfModels_Store | 8003147c | Match | +| LibraryOfModels_Clear | 800314c0 | Match (0xE2 entries — the off-by-one was the decomp's, project correct) | +| LinkedCollide_Radius | 800314e0 | Match | +| LinkedCollide_Hitbox_Desc | 800315ac | Match | +| LinkedCollide_Hitbox | 80031608 | Match | + +Notes: LevInstDef's Un/RePack decompiles lean on the coincidence that +Instance.instDef and InstDef.ptrInstance share offset 0x2C (typing +artifacts only — operations identical); RePack's advHub path kills the +thread via flags|=0x800 (THREAD_FLAG_DEAD ✓) and free-lists the instance, +matching the project. LibraryOfModels_Clear: the DB plate documents the +community decomp's 225-entry off-by-one; retail clears 226 (0..0xE1) and +the project's `< 0xE2` is correct. LinkedCollide: Radius' unsigned +distance compares and the minecart (id 0x21) cylinder special-case ✓; +Hitbox_Desc's "undecompilable LWL/LWR soup" is just the unaligned +by-value BoundingBox copy == the project's struct argument; Hitbox's +packed-bounds decode maps exactly onto min/max fields incl. the +asymmetric Y test (min.y <= dy < max.y). DB naming: these three carry +community names (FindObjectInRangeList/FindObjectInBox) vs syms926's +LinkedCollide_* — same functions. + +## game/LIST.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| LIST_Clear | 80031734 | Match | +| LIST_AddFront | 80031744 | Match (NULL guard load-bearing per DB note) | +| LIST_AddBack | 80031788 | Match | +| LIST_GetNextItem | 800317cc | Match | +| LIST_GetFirstItem | 800317d8 | Match | +| LIST_RemoveMember | 800317e4 | Match (binary edge case kept) | +| LIST_RemoveFront | 8003186c | Match (no inner first-guard — asymmetry kept) | +| LIST_RemoveBack | 800318ec | Match (inner first-guard kept) | +| LIST_Init | 8003197c | Match (latent: retail `!=0` vs project `>0` loop guard — negative counts unreachable) | + +The two binary-authoritative edge cases the community decomp got wrong +are correct in the project: RemoveMember/RemoveBack with first==NULL +still clear the item's links and return it (decomp returned NULL), and +the RemoveFront/RemoveBack guard asymmetry is preserved. + +## game/LOAD/LOAD_Callbacks.c + LOAD_ModelPtrs.c + StringToUpper/InitCD — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| LOAD_Callback_Overlay_Generic | 800319e8 | Match | +| LOAD_Callback_Overlay_230 | 800319f4 | Match (load_inProgress clear kept — decomp omitted it) | +| LOAD_Callback_Overlay_231 | 80031a08 | Match (ditto) | +| LOAD_Callback_Overlay_232 | 80031a20 | Match (ditto) | +| LOAD_Callback_Overlay_233 | 80031a38 | Match | +| LOAD_Callback_MaskHints3D | 80031a50 | Match | +| LOAD_Callback_Podiums | 80031a64 | Match | +| LOAD_Callback_LEV | 80031a78 | Match (`flags & 2` == LT_GETADDR) | +| LOAD_Callback_PatchMem | 80031aa4 | Match | +| LOAD_Callback_DriverModels | 80031b00 | Match | +| LOAD_HubCallback | 80031b14 | Match (binary sets only level2; decomp's extra visMem2 write is wrong — project correct) | +| LOAD_GlobalModelPtrs_MPK | 80031b50 | Match (loop is live code — decomp #if 0'd it; project keeps it) | +| LOAD_HubSwapPtrs | 80031bdc | Match | +| LOAD_StringToUpper | 80031c1c | Match (u8 scan, unsigned `- 'a' < 26` == sltiu) | +| LOAD_InitCD | 80031c58 | Match (PSX path: CDSYS_Init(1); CTR_NATIVE branch is a documented port divergence) | + +Notes: PatchMem order (load_inProgress=0 → RunPtrMap(ptrLEV, dest+4, +*dest>>2) → SwapPacks(0) → ClearHighMem → SwapPacks(active)) matches; +project reads the header fields before clearing the flag, retail after — +no interplay, equivalent. HubCallback is another binary-wins case the +project already had right (no visMem2 write). GlobalModelPtrs_MPK: +Model.id is s16 at 0x10, matching retail's `(short)model->id` index and +-1 sentinel. DB history note: 0x80031ae4 was once duplicate-named +LOAD_HubCallback in the Ghidra DB, since fixed there — syms926 names are +authoritative. + +## game/LOAD/LOAD_File.c (part 1: 0x80031c78–0x80032438) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| LOAD_ReadDirectory | 80031c78 | Match (realloc = numEntry*8+8 == sizeof(BigHeader)+n*sizeof(BigEntry)) | +| LOAD_DramFileCallback | 80031d30 | Match (PSX path; flags&1==LT_SETADDR, |=2==LT_GETADDR; CTR_NATIVE callback filter is a documented port divergence) | +| LOAD_DramFile | 80031e00 | Match (all three modes; &bh->cdpos == bh at offset 0) | +| LOAD_VramFileCallback | 80031ee4 | **Fixed — see note 1** | +| LOAD_VramFile | 80031fdc | Match (PushState/PopState bracket, VSync(2), frame clear) | +| LOAD_ReadFileASyncCallback | 80032110 | **Fixed — see note 2** | +| LOAD_ReadFile_ex | 800321b4 | **Fixed — see note 3** | +| LOAD_XnfFile | 80032344 | **Fixed — see note 4** | +| LOAD_FindFile | 80032438 | Match | + +### Note 1 — VramFileCallback packed-TIM advance masks size + +Retail 0x80031f5c: `sra v0,s1,2; sll v0,v0,2; addu s0,s0,v0` — the +next-block advance uses `size & ~3` (int* arithmetic in the original +source). Project did a raw byte add (`(u32)vh + size`), diverging for +non-word-aligned sub-image sizes. Fixed to mask the low 2 bits. + +### Note 2 — ReadFileASyncCallback error path calls PopState, not ReallocMem(0) + +Retail 0x80032194: `jal 0x8003e9d0` with a bare `nop` delay slot; +0x8003e9d0 is MEMPACK_PopState per syms926 (the success path at +0x80032158 calls 0x8003e94c = MEMPACK_ReallocMem with slot.size). Project +called `MEMPACK_ReallocMem(0)` on CD error — different function, different +semantics (shrink-last-alloc vs restore-bookmark). Fixed to +MEMPACK_PopState(). + +### Note 3 — ReadFile_ex sync-complete test is unsigned + +Retail 0x800322e4: `sltiu s4,v0,1` — readComplete = (u32)CdReadSync < 1, +i.e. == 0. Project used signed `< 1`, which treats CdReadSync's -1 error +return as "complete" and breaks out instead of retrying the +CdControl/CdRead. Fixed to `== 0`. + +### Note 4 — XnfFile returns the caller's buffer on failed search + +Retail 0x80032384: `beq v0,zero,` with delay slot `move v0,s0` +(s0 = ptrDestination arg) — a failed CdSearchFile returns the caller's +buffer pointer, not NULL, and leaves *size unwritten. Project returned 0. +Fixed to `return ptrDestination` (identical when the caller passes NULL, +which is the alloc path). + +Also confirmed en route: LoadQueueSlot layout (flags u16@+4, dest@+0xC, +size@+0x10, callback@+0x14), VramHeader.rect@0xC/sizeof 0x14, +BigHeader/BigEntry both 8 bytes, DramPointerMap {numBytes; offsets[]}, +LT_SETADDR=1 / LT_GETADDR=2 / LT_DRAM=2 / LT_VRAM=3 all consistent with +retail immediates. + +## game/LOAD/LOAD_Howl.c + LOAD_Assets.c (0x80032498–0x80032d30) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| LOAD_HowlHeaderSectors | 80032498 | Match (bounds check kept — binary-wins, decomp #if 0'd it) | +| LOAD_HowlCallback | 8003254c | **Fixed — see note 1** | +| LOAD_HowlSectorChainStart | 80032594 | Match (same binary-wins bounds check; state=1 before CdReadCallback order kept) | +| LOAD_HowlSectorChainEnd | 8003266c | Match (-1 path returns 0 == retail's `chainState == 0` on stale -1) | +| LOAD_RunPtrMap | 800326b4 | Match (offset (>>2)<<2 masking identical) | +| LOAD_Robots2P | 80032700 | Match (7-of-8 set scan, ids[2..5], BI_2PARCADEPACK=0x144 ✓) | +| LOAD_Robots1P | 800327dc | Match (equivalent skip-self roster fill incl. charID>7 edge) | +| LOAD_DriverMPK | 8003282c | Match (all 5 branches; every BI_* literal and mode mask verified) | +| LOAD_LangFile | 80032b50 | Match (retail loop index is s16 — project int; latent-only, needs >32767 strings) | +| LOAD_GetBigfileIndex | 80032c24 | Match (all 10 range/stride mappings; reorder is behavior-neutral, ranges disjoint) | + +### Note 1 — HowlCallback compares the CD interrupt result unmasked + +Retail 0x8003254c: `andi` masks the intr argument to 8 bits before the +CdlComplete compare (as LOAD_ReadFileASyncCallback also does, and as the +project already did there). Project compared `result == CdlComplete` +unmasked. Fixed to `(result & 0xff) == CdlComplete`. + +Constants verified en route: full BigIndex enum arithmetic matches every +retail literal (BI_LANGUAGEFILE=0xEA, BI_RACERMODELHI=0xF2, +BI_1PARCADEPACK=0x104, BI_ADVENTUREPACK=0x114, BI_TIMETRIALPACK=0x124, +BI_RACERMODELMED=0x134, BI_2PARCADEPACK=0x144, BI_RACERMODELLOW=0x14C, +BI_4PARCADEPACK=0x15C, BI_NDBOX=0x201, BI_CUTSCENES_INTRO=0x203, +BI_CUTSCENES_OUTRO=0x21E, BI_CREDITS=0x222, BI_SCRAPBOOK=0x25E); +LevelID enum (NITRO_COURT=0x12 … SCRAPBOOK=0x40) and Characters +(PAPU=9/ROO=10/JOE=0xB) all match retail immediates. ADVENTURE_BOSS = +0x80000000 == retail's `gameMode1 < 0` sign test. Latent (not fixed): +levBigLodIndex is signed char[] vs retail lbu — values are tiny, no +reachable divergence; LangFile's retail s16 loop index vs project int — +needs >32767 strings to matter. + +## game/LOAD/LOAD_Queue.c + LOAD_Hub_ReadFile + LOAD_TalkingMask — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| LOAD_AppendQueue | 80032d30 | Match (8-slot cap, flags=0/size=0 fills) | +| LOAD_CDRequestCallback | 80032d8c | Match (asm: `jalr` with a0 untouched — callback(lqs) ✓; queueReady = gp+0x134) | +| LOAD_NextQueuedFile | 80032dc0 | Match (pump, retry path, 1/2/3 dispatch, deferred-VRAM >2-frame path incl. PopState on flags&1) | +| LOAD_Hub_ReadFile | 80032ffc | **Fixed — see note 1** | +| LOAD_TalkingMask | 800347d0 | **Fixed — see notes 1, 2** (verified out of order — shares the bug) | + +### Note 1 — wrong sdata field: PatchMem_Size vs load_inProgress + +Retail Hub_ReadFile 0x80033064 and TalkingMask 0x80034830 store 1 to +gp+0x138 = 0x8008d0a4 = load_inProgress (gp base 0x8008cf6c, anchored by +frameFinishedVRAM = gp+0x13c = 0x8008d0a8 and queueReady/queueRetry = +gp+0x134/0x135). The project wrote `sdata->PatchMem_Size = 1` (0x8008d094, +gp+0x128) in both — clobbering the recorded patch-area size and never +setting the in-progress flag that gates XA starts in CDSYS (CDSYS.c:643) +and that the LOAD_Callback_* series clears. Both call sites fixed to +`sdata->load_inProgress = 1`. + +### Note 2 — TalkingMask passes the real bigfile header + +Retail 0x80034828/0x80034850: both AppendQueue calls load a0 from +gp+0x130 = 0x8008d09c = ptrBigfileCdPos_2. Project passed NULL and relied +on the CTR_NATIVE NULL→ptrBigfile1 shim in ReadFile_ex (which would deref +NULL on the PSX path). Fixed to pass sdata->ptrBigfileCdPos_2. Offset math +(BI_UKAHEAD=0x1B6 + maskID*4 + (packID-1)*2, +1 for the hints file) and +the MaskHints3D callback (0x80031a50) match. + +Also corrected in this pass: the iteration-32 LOAD_Callbacks table above +had wrong address-column values; real syms926 entries are 800319f4, +80031a08, 80031a20, 80031a38, 80031a50, 80031a64, 80031a78, 80031aa4, +80031b00 (verdicts unchanged — bodies were verified by content). +LOAD_Hub_ReadFile's queue types (3/2/1 == LT_VRAM/LT_GETADDR/LT_SETADDR +numerically), GetBigfileIndex(levID,1,0/1/2) args, PatchMem_Ptr dest +(gp+0x124) and callbacks LEV=0x80031a78 / HubCallback=0x80031b14 all +verified in asm. + +## game/LOAD/LOAD_Hub.c (SwapNow/Main) + LOAD_Overlays.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| LOAD_Hub_SwapNow | 80033108 | Match (full sequence incl. SwapPacks/MainInit_VisMem tail, vis-ptr clears, timer resets) | +| LOAD_Hub_Main | 80033318 | **Fixed — see note 1** | +| LOAD_OvrLOD | 80033474 | **Fixed — see note 2** | +| LOAD_OvrEndRace | 800334f4 | **Fixed — see note 2** | +| LOAD_OvrThreads | 80033570 | **Fixed — see note 2** | +| LOAD_GetAdvPackIndex | 800335dc | Match | + +### Note 1 — Hub_Main returned instead of skipping the player + +Retail branches over the Hub_ReadFile call when levelID isn't a hub +(hubIdx >= 5) and continues the per-player loop; the project returned +from the whole function, dropping swap-trigger processing for remaining +players. Fixed to skip only the call. Retail checks Loading.stage == -1 +per player inside the loop vs project's single up-front check — nothing +in the loop writes Loading.stage, so that difference is behavior-neutral +(LOAD_IDLE == -1 confirmed). Hub-connection table verified word-for-word +against retail rdata @0x80011180 (5×3 ints, rows {1a,1b,-1} {19,1c,-1} +{19,1c,-1} {1a,1b,1d} {1c,-1,-1}) — the CTR_NATIVE mirror table is exact. +COLL_STEP hub masks 0x30>>4 / 0xc0>>6 match retail immediates. + +### Note 2 — all three overlay loaders: missing load_inProgress, NULL bigfile + +Retail OvrLOD/OvrEndRace/OvrThreads each set load_inProgress = 1 before +queueing (the Callback_Overlay_* completion callbacks clear it — pairing +verified in the iteration-32 pass) and pass sdata->ptrBigfileCdPos_2 as +the bigfile. The project's PSX (#ifndef CTR_NATIVE) paths did neither +(bigfile = NULL). Both added to all three. Subfile bases verified: +0xDD/0xE2/0xE6 == BI_OVERLAYSECT1/2/3; overlay-index compares are +zero-extended byte loads matching the project's (u32) casts; -1 stores == +project's 0xff (u8 field). Native paths intentionally untouched: on +CTR_NATIVE the overlays are statically linked, there is no queued load, +and setting load_inProgress with no completion callback to clear it would +wedge the XA gate. + +## game/LOAD/LOAD_TenStages.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| LOAD_TenStages | 80033610 | **Fixed — 4 findings, see notes** | + +All ten stages compared against the retail decompile. Verified matching: +load_inProgress early-out; the !garage/!ndbox music gate (retail's +unsigned `1 < levelID-0x28` == project's boolean); stage-0 levelName +prefix chain (ndi/ending/intro/screen/garage/hub/credit) with player +counts and mode flags; stage-1 end-race overlay map (crystal 0 / TT 3 / +arcade 1 / relic 2 / adventure 1 / else 4 gated by CUP_ANY_KIND); stage-3 +threads overlay routing incl. the overlayIndex_Threads==1 skip; stage-4 +menu-state dispatch (MM_* == mainMenuInit[] indices 0-5; +MAIN_MENU_ADVENTURE=4 == retail MM_GARAGE); stage-5 MPK icon wiring; +stage-6 hub pack sizes (0x6b000/0x40000 vs 0x68800/0x68800), pack +carving, PatchMem high-mem alloc (the real PatchMem_Size use), PTR-queue +level ranges (levelID-0x19<0xe || levelID-0x2c<0x14); stage-7 decal/ +traffic-light lookups and every podium bigfile literal (0x16C/0x16E/ +0x18E/0x1AE/0x1BE/0x1C0 == BI_* enum; dance statics 0x7E/0x83/0x8D/0x8F); +stage-8 podium pointer fixup (+4 for slots 0-6 only, s16 id) and the +levelID→audio-state map; stage-9 XA gate + renderFlags finalize + +return -2. XA literals (4 stage-1, 2 stage-9) match retail raw compares. + +### Note 1 — stage-0 LOD precedence inverted + +Retail: levelLOD = 1 if MAIN_MENU, else 8 if TIME_TRIAL|RELIC_RACE, else +numPlyrCurrGame. Project computed default→MAIN_MENU→TT/RELIC in +sequence, so a stale TIME_TRIAL bit (not cleared by the stage-0 mask set) +would override the main menu's LOD 1 with 8. Restructured to retail's +precedence. + +### Note 2 — stage-6 queue calls passed NULL bigfile + +Retail passes the `bigfile` argument to all three AppendQueue calls +(VRAM/LEV/PTR). Project passed 0, relying on the CTR_NATIVE +NULL→ptrBigfile1 shim (PSX path would deref NULL). Fixed. + +### Note 3 — stage-7 had an extra MEMPACK_SwapPacks(0) + +Not present in the binary (all 7 SwapPacks call sites in the retail +function accounted for: stage 0 ×1, stage 6 ×4, stage 7 podium ×1, stage +8 ×1). Retail keeps the stage-6 subpack active through the cutscene/ +credits paths; the extra swap would move post-load allocations to the +main pack. Removed. + +### Note 4 — stage-7 podium guard over-constrained + +Retail gates podium queueing on `podiumRewardID != NOFUNC` alone +(NOFUNC=0 confirmed); the project added LEV_SWAP and ADVENTURE_ARENA +pre-checks not in the binary. Removed (the JitPools return above already +restricts this path to cutscene/arena/credits loads). + +Equivalent-but-restructured (ledger note only, not changed): project +clears driverModelExtras[3]+8 podium pointers as one 11-word loop in +stage 4, where retail clears extras+ptrMPK in stage 4 and the 8 podium +pointers inside stage 7's podium branch — no reader observes the +difference (podium pointers are only read in podium mode, after both +clears). CTR_NATIVE additions (first-boot copyright present loop, +LOAD_NativeAudio_SetStateAfterBankReload) left as documented port +divergences. + +## LOAD_LevelFile + LOAD_IsOpen.c + MainDB.c + MainDrawCb.c — DONE (2026-07-04) + +LOAD module is now fully verified (every LOAD_* symbol in syms926). + +| Function | Address | Verdict | +|---|---|---| +| LOAD_LevelFile | 80034874 | Match (Loading.stage = LOAD_TEN_STAGES_0 == 0) | +| LOAD_IsOpen_RacingOrBattle | 800348e8 | Match | +| LOAD_IsOpen_MainMenu | 80034908 | Match | +| LOAD_IsOpen_AdvHub | 80034920 | Match | +| LOAD_IsOpen_Podiums | 80034940 | Match | +| MainDB_GetClipSize | 80034960 | Match (5/0xc 3P+ 2500, 8→6000, 9→2500, 0x27→16, 0x28→24000, else 3000) | +| MainDB_PrimMem | 800349c4 | Match (6 stores incl. end-0x100 guard; size&~3 kept — binary-wins vs decomp comment) | +| MainDB_OTMem | 80034a28 | Match | +| MainDrawCb_DrawSync | 80034a80 | Match | +| MainDrawCb_Vsync | 80034aa4 | Match (PAUSE_ALL==0xF; tail call is GAMEPAD_PollVsync — the DB's "GetNumConnected" label at 0x80025410 is the known mislabel) | + +CTR_NATIVE divergences left alone: Vsync callback's criticalSectionCount +guard around howl_PlayAudio_Update and Platform_PollInput injection +(documented port bridges). + +## game/MAIN/MainFrame.c (TogglePauseAudio/ResetDB/GameLogic) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MainFrame_TogglePauseAudio | 80034b48 | Match | +| MainFrame_ResetDB | 80034bbc | Match (swapchain flip, OT slices at base+(n-i-1)*0x1000+0x18, unused views +0x3018, UI OT base+4; decomp's pointer soup resolves to pushBuffer[i].ptrOT @gGT+0x25c+i*0x110) | +| MainFrame_GameLogic | 80034d54 | **Fixed — 2 findings, see notes** | + +### Note 1 — top-attacker scan clobbered the driver-1 tracker + +Retail (0x80035060-0x800350c8) keeps three registers: current driver, +top-attacker, and a persistent pDriverP2 slot (holds driver 1, or the +displaced top-attacker after a takeover via the shared label). The +project transcription used one variable for both the loop cursor and the +tracker, resetting it every iteration — the `psVar9 = psVar9;` no-op was +the fossil of the lost `pDriverP2 = curDriver` assignment. Consequence: +the quip2 escalation compared against whatever driver happened to be +iterated last instead of retail's tracked driver. Rewritten to retail's +register discipline (current in its own local); the port-only NULL guard +before the compare is kept. + +### Note 2 — bucket dispatch ticked one bucket too many + +Retail's dispatch loop is `while (i < 0x11)` — buckets PLAYER(0)..HUD +(0x10); the PAUSE bucket (0x11) is never ticked from GameLogic. The +project looped to NUM_BUCKETS (0x12), ticking PAUSE every unpaused +frame. Bound changed to PAUSE. + +Verified matching everywhere else: blur/clock loop (ACTION_RACE_FINISHED +=0x2000000, clockEffectEnabled→10000), elapsed-time scaling +(<<5/100, clamp 0x20..0x40, prev-frame-pause force), frozen-time tick SFX +(every 6th frame, 0x8c9080/0x8c8880 alternation at 12s), CycleTex pair, +funcPtrs dispatch (DRIVER_FUNC_COUNT=13, funcThTick gate, bucket-0-first +rule), AKUAKU(0xe)-only paused tick, BURST(7) RB_Burst gate, +unk1cc4[4]*10000/0x147e unsigned, pause/unpause debounce state machine +(menu excludes, BTN_START=0x1000, cooldowns=5), gameModeEnd word (project +checks under gameMode1 bit NAMES but identical VALUES: AKU_SONG==NEW_NAME +=0x1000000, CRYSTAL_CHALLENGE==NEW_HIGH_SCORE=0x8000000, PAUSE_2== +DRAW_HIGH_SCORES=2, PAUSE_1==PLAYER_GHOST_BEAT=1 — cosmetic only), VS +end-of-race timers (0x96/0x1e), SubmitName paths. The project's +"#if CTR_NATIVE around code retail runs unconditionally" pattern +(DISPLAY_Blur_Main, attacker scan, BOTS_UpdateGlobals, +Particle_UpdateAllParticles, HaveAllPads gate, RB_Bubbles, VS quips, +SubmitName save path) means the NATIVE build matches retail while a PSX +build of this repo would omit those — noted, not changed. + +## game/MAIN/MainFrame.c (VisMem + pads + mask hint) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MainFrame_VisMemAddDriverPVS (static, unnamed in syms926) | 80035684 | Match (low-bit tag: word-OR vs RLE-OR == Thunk3/Thunk2) | +| MainFrame_VisMemFullFrame | 800357b8 | Match (see note 1 for one deliberate port divergence) | +| MainFrame_InitVideoSTR | 80035d30 | Match (PSX path; native NULL-rect branch documented) | +| MainFrame_HaveAllPads | 80035d70 | Match (early-return == retail's running AND; PLUGGED=0 == retail byte==0 test) | +| MainFrame_RequestMaskHint | 80035e20 | Match (PAUSE_ALL gate, funcPtrs[DRIVER_FUNC_INIT=0] freeze install) | + +### Note 1 — VisMemFullFrame NULL-source transition (port divergence, not fixed) + +When a camera's visOVert/visSCVert source transitions to NULL, retail's +changed-branch memcpys from address 0 for one frame (readable kernel RAM +on PSX; the equal-and-NULL path substitutes level->unk5/unk_170 from the +next frame on). The project substitutes the default list immediately — +required on a host where address 0 faults, and converges with retail one +frame later. Left as-is. + +Everything else exact: camera stride 0xdc, flags 0x4000/0x2000/0x5000 +choreography, quad-index bit test (retail's *-0x1642c859 exact-division +== project's QuadBlock* pointer subtraction), leaf/face/inst source +selection incl. the driver-PVS triple NULL-check, per-list sizes +ceil(n/32) words, RLE-vs-flat dispatch on the source low bit +(Thunk1=RleCopy, Thunk2=RleOr, Thunk3=WordOr — consistent with the CTR +module pass). + +## game/MAIN/MainFrame_RenderFrame.c — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MainFrame_RenderFrame (+ its factored-out helpers) | 80035e70 | **1 fix — see note 1** | + +Retail is ONE 0x1B84-byte function (no syms between 0x80035e70 and +0x800379f4); the project factors it into a dispatcher + ~20 helpers +(DrawUnpluggedMsg, DrawFinalLap, RainLogic, MenuHighlight, RenderAll*, +WindowBoxLines/WindowDivsionLines, RenderVSYNC, RenderSubmit, …). The +concatenation in call order matches the binary end to end: prompt box +(rect -0x14/0x228, height 14+8n, multitap 0x80 check, lngStrings 0x2b), +FINAL LAP three-phase lerp (90-frame timer, phases 10/0x50, ±100 px, +lngStrings 0x233, FONT_BIG=1, 0x8000 == JUSTIFY_CENTER|ORANGE with +ORANGE=0), VisMem/CycleTex/menu-input gates, per-player frustum + rain +params, menu-highlight sine ((t<<6)>>12, +0x40/+0xA0 greens), every +renderFlags gate (2/4/8/0x10/0x20/0x40/0x80/0x100/0x200/0x400/0x800/ +0x1000/0x2000/0x8000), HUD mode dispatch incl. end-of-race menu chain and +the AdvHub overlay transition, QueueLev/NonLev + LOD[lod|4-if-relic] +table, RB_* buckets (7/8/9/10/0xb/0xc == BURST/BLOWUP/TURBO/SPIDER/ +FOLLOWER/STARTTEXT), tires (PLAYER/ROBOT-via-numBotsNextGame/GHOST), +level-geometry 1P/2P/3P/4P dispatch (scratchpad LOD constants incl. both +sign-corrected >>8 divisions; retail's "DrawLevelOvr1P" label on the +2P/3P/4P calls is the DB naming the overlay REGION — one address, +per-player-count overlay), skybox-glow loops (1P gated by configFlags&1, +2P+ unconditional), split-screen borders (PLAYER_BLUE=0x18) and divider +POLY_F4s (all 24 coordinates), RefreshCard/ClearScreen/RaceFlag/UI-env, +and the flip machinery (vsyncTillFlip<1 && !DrawOTag_InProgress, break +at vSync_between_drawSync>6 unsigned, STR MoveImage, PutDispEnv-then- +PutDrawEnv, frontBuffer = &db[1-swapchain], DrawOTag(&ptrOT[0x3ff])). + +### Note 1 — weather bytes were sign-extended + +QuadBlock.weather_intensity/weather_vanishRate were `char`; retail loads +both lbu (0x80036234/0x80036280) for the rain numParticles_max/vanishRate +divisions. Retyped to u8 in namespace_Level.h (single use site). + +Open question deferred to RECTMENU verification: retail's battle-border +call appears to pass data_ptrColor[teamID+0x18] (a pointer) where the +project passes the dereferenced Color by value — resolve +RECTMENU_DrawOuterRect_LowLevel's true 4th-parameter type when RECTMENU +is reached. Also deferred: MATH_Sin's body vs retail's inlined trig +lookup here (same table/quadrant logic; confirm when MATH is reached). +Port divergences left alone: native RenderVSYNC DrawSync/VSync structure, +native RenderSubmit immediate-present path, RenderFMV excluded on native, +PickupBots_Update/PlayLevel_UpdateLapStats native lap-table guard. + +## game/MAIN/MainFreeze.c part 1 (0x800379f4–0x80038b5c) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MainFreeze_ConfigDrawNPC105 | 800379f4 | Match (fan-triangle stepper: color word ×3, (r<<3)/5 X radius, draw-when-step≠0, s16 wrap exit >0x1000) | +| MainFreeze_ConfigDrawArrows | 80037bc0 | **Fixed — see note 1** | +| MainFreeze_ConfigSetupEntry | 80037da0 | Match (incl. both inlined pad renderers) | + +### Note 1 — arrow colors read the wrong table cells + +data.colors is u32[NUM_COLORS][4] (16-byte gradient entries) and +ptrColor[i] → &colors[i][0]. Retail passes the FOUR GRADIENT WORDS of +the one selected color ((*ptrColor[color])[0..3]) into +DecalHUD_Arrow2D's u32 color1..4. The project deref'd four consecutive +ptrColor ENTRIES (*colorPtr[0..3] as u8) — i.e. byte 0 of four different +colors' first words — losing the G/B channels and blending unrelated +colors. Fixed to colorWords = (u32 *)data.ptrColor[color]; +colorWords[0..3] (both call sites). + +ConfigSetupEntry verified in depth: entry mask 0x40020 == +BTN_TRIANGLE|BTN_SQUARE_one; DB Buttons enum confirms BTN_CROSS(0x10)/ +BTN_CIRCLE(0x40) == project _one variants (all CIRCLE|CROSS masks = +0x50); page-0/1/2 state machine (option wraps 0..3 s16, range wrap limit +from raceConfig_unk80084290[isNamco], deadZone/range .hi1 commits, +gamepadCenter = analog.rightX vs jogcon 0x80 + unk44=4 rumble kick); +LNG 0x222/0x223/0x228 draw calls at posY_[isNamco*2]; wheel renderer (3 +green wires with the value==0x600 OT +0xc nudge, 2 waving triangles at +0x114/0xec from table +4/+6 with tri*0xc stride, blue rect 0xec/0x28/ +0x41, inner rect -0x14/0x228/0x91) and Namco renderer (mirrored ±value +wires with /0x5000 projections, baseAngle=(sin(fc<<6)*value>>12)-0x400, +3 NPC105 knobs, spoke wires with round-toward-zero cos>>6 and +0x32/0x50 grays, 3×3 scaled NPC105 from table s16 indices +6/7/12/18/19, inner rect -0x3c/0xa0) — all table offsets and constants +match the binary. The trig-inline == MATH_Sin/Cos quadrant logic +(table[&0x3ff], <<16 by 0x400 bit, negate by 0x800 bit) — consistent +with the MATH_Sin deferred check from RenderFrame. + +## MainFreeze_MenuPtrOptions (0x80038b5c) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MainFreeze_MenuPtrOptions | 80038b5c | **Fixed — see note 1** | + +Project splits the 0xDAC-byte function into IDENTIFYGAMEPADS / +PROCESSINPUTS / DISPLAYRECTMENU force-inlines — concatenation matches: +gamepad-vs-analog classification (the Ghidra DB's "racingWheelSlots" +naming is inverted — it counts the DIGITAL pads — values identical), +menuRowsToRemove = (4 - bothLabels) - numPlyr, row navigation (project's +(row±1)%9 forms == retail's branch chains incl. the row==7 → +numPlyr+3 skip and >numPlyr+3 → 8 clamp), slider hold-to-adjust ±4, +MONO/STEREO 333+mode, vibrate toggle via gameMode1 ^ +VibPerPlayer[slot], analog-config handoff (owner id, page 0), exit row + +mask 0x41020 == TRIANGLE|START|SQUARE_one, every draw: title 0x144@ +(0x100, 26+pad/2), slider bars (+0x38 base, +1/+0x2f nubs, +0xff +sign-fix >>8, colors +0xc/+0x10), labels 0x4c column, vibrate colors +GRAY/RED/WHITE = 0x17/3/4, highlight-bar posY tables (0x77/0x4f bases), +cursor rect 74/364 + TRANS_50_DECAL==1, separator (retail passes +&battleSetup_Color_UI_1 == 0x8008d438 — same value the project passes; +part of the deferred RECTMENU pointer-vs-value signature question), BG +56/400/0x87-pad. Retail sets highlight positions/drawStyle before input +where project does it after — nothing in between reads them; neutral. + +### Note 1 — volume adjust missed the 8-bit mask + +Retail masks howl_VolumeGet's return to 8 bits before the ±4 step +(0x80038fd0), as both the project and retail already do at the slider +DRAW site. Added `& 0xff` to the adjust path. + +## MainFreeze.c rest (0x80039908–0x80039fa8) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MainFreeze_MenuPtrQuit | 80039908 | **Fixed — see note 1** | +| MainFreeze_SafeAdvDestroy | 800399fc | Match | +| MainFreeze_MenuPtrDefault | 80039a44 | Match (all 7 stringID actions; RemBits old-value-| 0x8c000000 == cumulative since 0x8c ⊇ 0xc; ADV_CUP=100 == retail folded 0x64; boss sign test pre/post ~PAUSE_1 equivalent — bit 31 untouched; SPAWN_AT_BOSS=1) | +| MainFreeze_GetMenuPtr | 80039dcc | **Fixed — see note 2** | +| MainFreeze_IfPressStart | 80039e98 | Match (full 11-condition gate incl. OXIDE_ENDING pair, load_inProgress, VEH_FREEZE_PODIUM=4; Show/TogglePause/Play/ElimBG_Activate tail) | + +### Note 1 — Quit menu wrote the ACTIVE menu pointer + +Rows 1/-1 of the quit menu: retail writes the desired-menu slot +(sdata_ptrDesiredMenuBox — the same global the Options and Default procs +write, letting RECTMENU perform the swap). Project wrote ptrActiveMenu +directly, bypassing the transition. Fixed to ptrDesiredMenu. + +### Note 2 — GetMenuPtr priority order rewritten to retail's + +Retail: ADVENTURE_ARENA (hint-row stringIndex 0xb/0xc written ONLY here) +> ADVENTURE_MODE (advCup if ADVENTURE_CUP else advRace) > BATTLE_MODE > +gameMode2 CUP_ANY_KIND > arcadeRace. Project had the cup check hoisted +first and a {BATTLE, ARENA, ADV_CUP, ADV_MODE} flag table — different +menu whenever mode bits overlap (e.g. BATTLE+ADVENTURE_MODE → battle +instead of advRace; ARENA+cup → arcadeCup instead of advHub; bare +ADVENTURE_CUP → advCup instead of arcade fallthrough) and ran the +MaskBoolGoodGuy side effect unconditionally. Rewritten; the +mainFreezeFlags/mainFreezeMenuArr tables (used only here) removed. + +MenuPtrQuit/Default phase field unk1e == retail nProcCallPhase +(0=selection, 1=draw) — DB plate corrected this 2026-06-25; project +matches. Loading.OnBegin (project) == retail OnComplete plates — naming +only, same slots. + +## MainGameEnd_SoloRaceGetReward + SaveHighScore — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MainGameEnd_SoloRaceGetReward | 80039fa8 | Match | +| MainGameEnd_SoloRaceSaveHighScore | 8003a2b4 | Match | + +GetReward: gameModeEnd &= 0x7bffffff clear; 0x2580 crate bonus gated on +numTimeCrates==timeCratesInLEV && arg; 5-slot scan (signed compare) → +newHighScoreIndex + |=0x88000000; best-lap loop → |=0x8c000000; TT tier +ladder — the project's data fields are the retail globals: +saveData{nTropyOpen=1,nOxideOpen=2} == g_nTtBitEventFirst/Repeat, +flashingText{nOxideOpen=0x18000000,nTropyOpen=0x8800000} == +g_dwRewardMaskEventFirst/Repeat (names crossed, values exact; dev-time +tier ORs 0x8008000); bit indices 1/2 < 32 make the single-u32 +timeTrialFlags equal to retail's word-indexed access. Scrapbook unlock: +GAME_UNLOCK_BIT_SCRAPBOOK=36 → unlocks[1]|=0x10 == retail literal. +Retail's "g_aHighScoreTable[0].nTime as base" decomp artifact is a +POINTER read == project's sdata->ptrActiveHighScoreEntry indirection. +Project preserves retail's two driver sources (scan uses drivers[0], TT +uses threadBuckets[0].thread->object). SaveHighScore: HIGH_SCORE_SAVED +guard, best-lap record write (entry layout time@0/name@4[0x11]/ +charID@0x16, sizeof 0x18), shift-down memmove (4-idx)*0x18, insert — +exact, incl. store order and (u8)driverID indexing. + +## MainGameEnd_Initialize (0x8003a3fc) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MainGameEnd_Initialize (+ ~12 project statics) | 8003a3fc | Match | + +MainGameEnd.c is now fully verified. Highlights of the comparison: +spawn-order snapshot, ROLLING_ITEM(0x800000) crowd-stop, adventure +loss counters (race@advProgress+0x30 / boss@+0x47 via retail's folded +levelID+CREDITS_N_GIN / bossID+0x47 indexing; cap at 10, signed char), +END_OF_RACE latch — project's `(gameMode1|END_OF_RACE) & 0x3e0020` +equals retail's `old & 0x3e0020 | 0x200000` because bit 21 is inside the +mask. Battle dispatch: POINT_LIMIT=0x4000 / LIFE_LIMIT=0x8000 (the +decomp's "NTROPY_JUST_OPENED" on gameMode1 is an enum-name leak, raw +0x8000). Point-limit rank sort: retail's scan-time selected-mask +set/unset bookkeeping == project's commit-time usedTeams (same result); +tie storage [0]=best,[1..]=ties == project's uniform array (project +re-inits all 4 slots vs retail slot-0 only — writes precede reads, +neutral); rank!=3 == rank<3. Life-limit sort: the retail QUIRK of +storing a TEAM ID in tie-slot 0 and then indexing drivers[] with it is +preserved by the project (documented NULL-slot guards keep the low-RAM +read harmless natively); the un-select-previous-ties loop, teamID-based +mark-used walk, and rank += ties+1 advance all match. Winner fades: +project 0xff78 == retail -0x88 (u16). Weighted standings (3-w weights, +descending scan replaces on equal, teamFlags/BATTLE gate) match; +standingsOrder == retail unk1dc8. Ghost finalize: project skip-condition +is the exact negation of retail's do-condition; ghost time @+0x10; +RELIC → RR_EndEvent_UnlockAward (retail FUN_8009f71c, overlay). The +"g_aHighScoreTable[N].pName" decomp artifacts are boolGhostTooBigToSave +/ ptrGhostTapePlaying sdata reads (same as the MenuPtrDefault pass). +IS_BOSS_RACE == signed bltz (header-documented). + +## MainGameStart_Initialize + MainInit_VisMem/RainBuffer — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MainGameStart_Initialize | 8003aee8 | Match | +| MainInit_VisMem | 8003af84 | Match (per-player clear loop kept — binary-wins, decomp omitted it; native BspListNode init is a port addition) | +| MainInit_RainBuffer | 8003b008 | Match (0x10-chunk copy to &level->ptrSpawnType1 == sizeof(RainBuffer)=0x30; curr signed div, max lhu + UNSIGNED div — project's (s16)((u16)max/n) exact) | + +## MainInit PrimMem/JitPools/OTMem — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MainInit_PrimMem | 8003b0f0 | Match (size ladder incl. defaults == fallthrough values 0x17c00/0x1e000/0x25800; numPlyr≥5 → no alloc) | +| MainInit_JitPoolsReset | 8003b2d4 | Match (8 pools, same order) | +| MainInit_OTMem | 8003b334 | Match (0x1800/0x2c00/0x8000/0x2000/0x3000; swapchain (n<<12)|0x18 ×2) | +| MainInit_JitPoolsNew | 8003b43c | Match (both size factors incl. garage 0x800; instance itemsize (4-n)*-0x88+0x294 == 0x74+0x88n; 3-loop self-link == project's contiguous-array walk; per-player clip buffers; native renderbucket scratch = documented divergence) | + +## MainInit_Drivers + MainInit_FinalizeInit — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MainInit_Drivers | 8003b6d0 | Match (see notes below for unreachable-state deltas) | +| MainInit_FinalizeInit | 8003b934 | **Fixed — see note 1** | + +### Note 1 — thread-bucket table init was incomplete + +Retail memsets all 0x168 bytes of threadBuckets then installs the +profiler name/colour table and boolCantPause=1 for STATIC(3)/WARPPAD(5)/ +CAMERA(0xf)/HUD(0x10). The project only nulled the .thread pointers — +boolCantPause was never written anywhere, so the DEBUG_MENU gate in +MainFrame_GameLogic (which ticks cantPause buckets while debug-paused) +never fired for those buckets. Fixed with a full memset + the four +boolCantPause flags; the profiler name/colour strings (debug-overlay +cosmetics; short-name strings only exist in retail rdata) are +deliberately omitted with a NOTE. + +Ledger-only notes (not changed): (a) the project's WARPBALL_HELD clear +at the top of FinalizeInit is NOT in retail — it is a deliberate, +commented fix for the ND quit-with-warpball bug; kept, flagged as an +intentional deviation. (b) Drivers: retail gates bots on numPlyr != 3 +(project < 3) and hardcodes boss (BOTS_Driver_Init(1)) / gem-cup (bots +1..4) spawns where the project parameterizes by numPlyr — identical for +all reachable states (boss/adventure are 1P; 4P arcade spawns zero bots +either way). (c) FinalizeInit CS order: retail runs +CS_Podium_FullScene_Init before CS_Cutscene_Start; project merged the +cutscene block — ARENA and GAME_CUTSCENE are mutually exclusive, so +unobservable. (d) retail calls BOTS_EmptyFunc() (empty) — project #if 0 +✓. (e) CAM_Init-before-scale vs scale-before-CAM_Init ordering — +independent stores. (f) distToFinish u16 ✓ unsigned like retail's lhu; +DecalMP data[0..1] byte pair == project s16 1000; isbg<<8 int-write == +retail's r0/g0/b0 byte stores + isbg if/else; confetti 4-store form +covers retail's 5 s16 stores (curr is s32 here); stars 4-field copy with +signed div ✓. + +## MainInit tail + MainKillGame + MainLoadVLC — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MainInit_StringToLevID | 8003c1d4 | Match (prefix strncmp over 0x41 entries, default 0) | +| MainInit_VRAMClear | 8003c248 | Match (s16-pair struct packing == retail's 0x02000000 fill word; 0x3ff×0x1ff + 1px strip @y=0x1ff) | +| MainInit_VRAMDisplay | 8003c310 | Match (2×2 page moves src+0x200/0x10c 0x100×0xd8, tag|=0xffffff; native Present documented) | +| MainKillGame_StopCTR | 8003c41c | Match (DrawSyncCallback restore from sdata->MainDrawCb_DrawSyncPtr == the DB's pInlineData[0x10] alias; native skips MEMCARD teardown, documented) | +| MainKillGame_LaunchSpyro2 | 8003c480 | Match (PSX path incl. _96_remove/init + LoadExec SPYRO2.EXE @0x801fff00; native halt-loop documented) | +| MainLoadVLC_Callback | 8003c508 | Match | +| MainLoadVLC | 8003c518 | Match (BI_VLCTABLE=0x1E0 == retail literal; sector round-up; "g_aDataFileIndex"@8008d09c is ptrBigfileCdPos_2 — DB typing artifact, same family as the pInlineData[0x11]/[0x12] aliases == ptrVlcTable/bool_IsLoaded_VlcTable) | + +## main (0x8003c58c) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| main (project CTR_Main + StateZero split) | 8003c58c | **Fixed — 3 findings, see notes** | + +The 5-state machine, boot sequence, and all state-3 loading paths match: +exit-state check position, per-frame LOAD_NextQueuedFile + XAPauseAtEnd, +state-1 RaceFlag/audio-state (9/10 by level range, comma-op structure +exact; retail's inner ==0x27 recheck is unreachable duplication), state-2 +repack, state-3's -4/-5/-6 handling (bit-capture BEFORE the onscreen +check ✓, no-break fallthroughs ✓, LOAD_VLC=-6 / LOAD_RESTART=-5 / +LOAD_FINISHED=-2 ✓, pause_state == the DB's ElimBgState), traffic-light +floor -0x3c0, demo mode (0x230 string, 0x23/100 Y, -0x8000 flags), +state-4 reboot (PSX-only). "g_eMainGameState" == mainGameState; +CSEQ_SONG_LEVEL == 0. + +### Note 1 — DrawSyncCallback install dropped the old-callback save + +Retail (0x8003c740) wraps the install in Enter/ExitCriticalSection and +stores the RETURNED previous callback in sdata->MainDrawCb_DrawSyncPtr — +the pointer MainKillGame_StopCTR restores at shutdown. The project +called DrawSyncCallback bare, so the restore slot was never written. +Fixed (critical-section bracket + save). + +### Note 2 — levelName boot seed missing + +Retail seeds gGT->levelName with "ndi\0" (word copy from the debug-name +pool @0x8008d16c) alongside levelID = NAUGHTY_DOG_CRATE. Added +memcpy(levelName, "ndi", 4). (Defensive — TenStages' first-boot path +strcpys over it before reading, but the non-first-boot path parses +levelName via StringToLevID.) + +### Note 3 — state-0 GameTracker memset restored + +Retail memsets the whole GameTracker (0x2584) on every state-0 entry. +The project #if 0'd it as "already zero, part of BSS" — true for first +boot, wrong for the PSX state-4 reboot path which re-enters state 0 with +stale state. Restored unconditionally (a no-op re-clear on native's +single boot). + +## MainRaceTrack + MainStats + MATH core — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MainRaceTrack_StartLoad | 8003cf7c | Match | +| MainRaceTrack_RequestLoad | 8003cfc0 | Match (LOAD_REQUESTED=-4) | +| MainStats_ClearBattleVS | 8003d024 | Match (12-int clear) | +| MainStats_RestartRaceCountLoss | 8003d068 | **Fixed — see note 1** | +| MATH_Sin | 8003d184 | Match — closes the RenderFrame deferred check: project body == retail's inlined quadrant logic exactly | +| MATH_Cos | 8003d1c0 | Match (opposite half-word reads, inverted negation == Sin(a+0x400)) | +| MATH_FastSqrt | 8003d214 | Match (base-4 restoring root, (shift>>1)+15 iters, returns final nextRoot) | + +### Note 1 — boss loss counter cap falls through to the level counter + +Asm 0x8003d144: when a lap-3 rage-quit happens in a boss race and +timesLostBossRace[bossID] is already capped at 10, retail does NOT +return — it falls through into the level path and increments +timesLostRacePerLev[levelID] (if that one is < 10). The project's clean +if/else did nothing in that state. Restructured to replicate the +fallthrough (signed-char < 10 compares == retail lb). All the preceding +resets (team points -500/0 by teamFlags, 8 counter clears, the +ADVENTURE-only + lapIndex==2 gate reordering) are equivalent. + +## MATH GTE helpers — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MATH_HitboxMatrix | 8003d264 | Match (transpose word-shuffle == retail's R-register loads; -t via rtv0/stlvnl; t[1]*-0x10000 == (0-t[1])<<16) | +| MATH_VectorLength | 8003d328 | Match (R+V0 dual load, mvmva dot, MAC1 (reg 25), SquareRoot0) | +| MATH_VectorNormalize | 8003d378 | Match ((c<<12)/len; project's returned length is a harmless superset of retail's void) | +| MATH_MatrixMul | 8003d460 | Match (rotation-mul + MatrixVecMultiply(m3->t→out->t) + m2->t add) | + +## game/MEMCARD 00-05 — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MEMCARD_SetIcon | 8003d4e4 | Match (0x100-byte copy in 0x10 chunks; (icon&0xffff)==0 select; binary-wins: decomp ref is an empty stub) | +| MEMCARD_CRC16 | 8003d540 | Match (CCITT 0x11021 MSB-first, bit16 test on the pre-OR shift) | +| MEMCARD_ChecksumSave | 8003d584 | Match (len-2 sweep → checkpoint store → two 0-finalizers → big-endian tail write; binary-wins: decomp's async helper doesn't exist in 926) | +| MEMCARD_ChecksumLoad | 8003d618 | Match (0x200/call chunking, SYNC flag=8 forces single pass, PENDING=7, final fold + crc!=0) | +| MEMCARD_StringInit | 8003d6e8 | Match (retail word-store == "bu" + '0'+(slot>>4&1) + '0'+(slot&3) + ":\0"; binary-wins: decomp had a single-digit form) | +| MEMCARD_StringSet | 8003d730 | Match (strlen scan + append capped at 63 + NUL) | + +Three more binary-wins entries where the community decomp diverges and +the project already followed the binary (SetIcon body, synchronous +ChecksumSave, two-digit StringInit). + +## game/MEMCARD 06-16 — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MEMCARD_InitCard | 8003d7d8 | Match (8 OpenEvents Sw=0xf4000001/Hw=0xf0000011 × IOE 4/ERROR 0x8000/TIMEOUT 0x100/NEW 0x2000, NOINTR; InitCARD(0)/StartCARD/_bu_init; statusFlags=1) | +| MEMCARD_CloseCard | 8003d95c | Match | +| MEMCARD_GetNextSwEvent | 8003d9ec | Match (IOE→ERROR→TIMEOUT→NEW priority) | +| MEMCARD_GetNextHwEvent | 8003da68 | Match | +| MEMCARD_WaitForHwEvent | 8003dae4 | Match (never PENDING) | +| MEMCARD_SkipEvents | 8003db54 | Match | +| MEMCARD_NewTask | 8003db98 | Match (attempts=8; project's 5th `unused` param is a dropped-dead-arg either way; store order neutral) | +| MEMCARD_CloseFile | 8003dbf8 | Match (fd -1 sentinel, stage=IDLE) | +| MEMCARD_ReadFile | 8003dc30 | Match (>=0 == !=-1 on PSX libc domain; PENDING/TIMEOUT) | +| MEMCARD_WriteFile | 8003dc9c | Match (ditto) | +| MEMCARD_GetFreeBytes | 8003dd10 | Match (accumulate-then-mask == retail per-entry 0x2000 rounding — accumulator stays aligned; 0x1E000 budget; retail's known "out of room" bug preserved as-is) | + +The pInlineData[0x13/0x14]/pDataPtr/pCallback reads in the decomp are +the same DMA-queue union typing artifacts as before — they are +memcard_stage / memcard_ptrStart / memcard_remainingAttempts / +memcard_fd sdata fields, which the project names properly. + +## MEMCARD_HandleEvent (0x8003ddac) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MEMCARD_HandleEvent | 8003ddac | Match | + +Full state-machine comparison; every MC_STAGE enum value == retail's raw +case label (GETINFO 1 … ERASE_PASS 14). Verified: GETINFO's +_card_load/_card_clear spin loops + WaitForHwEvent result propagation +through the shared IDLE tail; NEWCARD's (flags|2)&~1 == retail +&0xfffffffe|2 and the &~3 unformat path (UNFORMATTED=5); the LOAD header +parse ((hdr[2]&0xF)+1)<<7 icon size and the two-block backup-copy test +(>>13 pair, >=2 == retail 110 || NO_BACKUP)), +sequential writes at (stage-9)*fileSize, and the retry paths (stage 9 +rewrites the ICON from the SetIcon buffer, 10/11 rewrite data at +(stage-10)); ERASE_FAIL/PASS returns; default TIMEOUT without touching +stage. The pInlineData[0x13]/[0x14]/pDataPtr aliases are the usual sdata +typing artifacts (stage/ptrStart/attempts). + +## MEMCARD 18-25 — DONE (2026-07-04) — MEMCARD module COMPLETE (26/26) + +| Function | Address | Verdict | +|---|---|---| +| MEMCARD_GetInfo | 8003e238 | Match (!x == !=1 on _card_info's 0/1 domain) | +| MEMCARD_Load | 8003e29c | Match (open 0x8001; NODATA=6; LOAD_SYNC=2 → status bit 8; binary-wins: decomp is a simplified rewrite) | +| MEMCARD_Save | 8003e344 | Match (icon[3] block count single-vs-double consistent with HandleEvent; 0x81/0x40 title fill + name overlay; create (blocks<<16)|FCREATE then reopen 0x8002; FULL=4; FORCE_BACKUP=1; binary-wins: decomp omitted the header build) | +| MEMCARD_Format | 8003e51c | Match (format→_card_load spin, stage NEWCARD, attempts=8) | +| MEMCARD_IsFile | 8003e59c | Match (probe open 0x8002; fd -1 wipe equivalent in both paths) | +| MEMCARD_FindFirstGhost | 8003e600 | Match (pEntry==buffer ⟺ !=NULL; strcpy from name@0; GHOST_FOUND=15) | +| MEMCARD_FindNextGhost | 8003e678 | Match (binary-wins: the decomp .c had a `nextEntry == nextfile` typo the binary/project don't) | +| MEMCARD_EraseFile | 8003e6d4 | Match (project ternary == retail set-0xE-then-patch-0xD; GetFreeBytes + PENDING) | + +MEMCARD is the first fully-clean module of this size: 26 functions, zero +project bugs (the sweep's earlier MEMCARD-adjacent fixes were all in +callers). Three more binary-wins recorded above. + +## game/MEMPACK.c — DONE (2026-07-04) — module COMPLETE (12/12) + +| Function | Address | Verdict | +|---|---|---| +| MEMPACK_Init | 8003e740 | Match (decomp constant-folded the overlay-end max chain: start=0x800BA9F0, size 0x144E10, end 0x801FF800 == project's dynamic linker-symbol form; endOfMemory 0x80200000) | +| MEMPACK_SwapPacks | 8003e80c | Match (stride 0x60) | +| MEMPACK_NewPack | 8003e830 | Match (see note 1) | +| MEMPACK_GetFreeBytes | 8003e85c | Match (last - first) | +| MEMPACK_AllocMem | 8003e874 | Match (OOM → ErrorScreen(0xFF,0,0) + hang; align +3&~3; sizeOfPrev @+0x18) | +| MEMPACK_AllocHighMem | 8003e8e8 | Match (project's OOM spin == retail's trap loop — both permanent hangs; no error screen) | +| MEMPACK_ClearHighMem | 8003e938 | Match (last = endOfAllocator) | +| MEMPACK_ReallocMem | 8003e94c | Match (first += new-prev, no OOM check) | +| MEMPACK_PushState | 8003e978 | Match (16-cap, returns pre-push id — 0x10 when full, no push) | +| MEMPACK_ClearLowMem | 8003e9b8 | Match | +| MEMPACK_PopState | 8003e9d0 | Match (no-op on empty) | +| MEMPACK_PopToState | 8003ea08 | Match (unguarded id) | + +### Note 1 — NewPack: retail leaves endOfAllocator stale + +Retail's NewPack writes 6 fields and never sets endOfAllocator (+0xC) — +subpacks keep a BSS-zero/stale value there. The project sets it to +start+size. endOfAllocator is only read by ClearHighMem, which retail +only invokes on pack 0 (set by Init), so the extra store is an +unobservable safety-superset; the CTR_NATIVE Init path also relies on +it. Kept, noted. Mempack struct offsets confirmed: +0 packSize, +4 +start, +8 lastFreeByte, +0xC endOfAllocator, +0x10 endOfMemory, +0x14 +firstFreeByte, +0x18 sizeOfPrevAllocation, +0x1C numBookmarks, +0x20 +bookmarks[16], sizeof 0x60. The "g_aGpuDmaQueue[1].pInlineData[2]" +decomp reads are sdata->PtrMempack (typing artifact family). + +## game/MixRNG — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| MixRNG_Scramble | 8003ea28 | Match (16-bit LCG ×0x6255 +0x3619 &0xFFFF on randomNumber) | +| MixRNG_Particles | 8003ea6c | Match ((deadcoed & 0xFFFF) * range >> 16, signed shift) | +| MixRNG_GetValue | 8003eaac | Match — asm-verified: the strength-reduced mult chain == ×0x6255 and the &0xFFFF IS present (delay-slot andi); the DB plate's "binary does not mask" remark is wrong | +| RngDeadCoed | 8006c684 | Match (verified out of order as the direct callee: byte-rotate + add, ^0xDEADC0ED, b=result/a=rotated) | + +Also present in the project dir (native-only, no retail counterpart to +verify): PSX_BIOS_Rand.c reimplements the BIOS A(2F) rand (LCG +0x41C64E6D/0x3039, >>16 &0x7FFF) so retail rand() call sites keep the +PSX sequence — documented port shim. + +## game/Particle.c (FuncPtrs + OnDestroy + UpdateList) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| Particle_FuncPtr_PotionShatter | 8003eae0 | Match (mod-800/-256 spreads mod-2^16 identical despite different truncation points; STATIC_SHOCKWAVE_GREEN=0x45 == retail's (Instance*)0x45 slot-reuse compare; -0x1200 fades) | +| Particle_FuncPtr_SpitTire | 8003ec18 | Match (3-frame 0x1000/0xfff/0xffe ladder; 0xf801 == retail -0x7FF s16; tail pos snap uses entry-computed targetY — plant matrix unchanged in between; binary-wins: no FPS_HALF halving) | +| Particle_FuncPtr_ExhaustUnderwater | 8003ee20 | Match (surface gate >3, <27 frames; iconGroup[8] set even when NULL — binary-wins) | +| Particle_OnDestroy | 8003eeb0 | Match (oscillator head == the DB's p->prev union word — documented in the header; save-next-then-free — binary-wins vs decomp order; JitPool.free is first member so &pool == &pool.free) | +| Particle_UpdateList | 8003eefc | Match (project's 5 statics == retail's inlined body: life/frozen gates, 0x1000 snapshot with the axis[10] 32-bit vel-pair copy, per-axis integrate + oscillator un-apply/apply, all 7 waveforms incl. the default-feeds-raw-timer quirk, (val+bias)*u16-amplitude>>12 with max-then-min clamp, kill checks, color-OR < 0x800, icon wrap/ping-pong with no-store-in-range) | + +Latent-only (noted, unchanged): the axis-mask advance is unsigned >>1 in +the project vs retail's signed >>1 after the bit-16 clear — differs only +if oscillator bit 15 (axis 15) were set; axes are 0..10. ParticleAxis +{pos int, vel s16, accel s16} and ParticleOscillator {next,prev,flags, +previousValue s16, period?, scale u16, offset s16, min s16, max s16} +layouts confirmed against retail field usage. + +## Particle_UpdateAllParticles + BitwiseClampByte + SetColors — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| Particle_UpdateAllParticles | 8003f434 | Match (DEBUG_MENU gate, both lists) | +| Particle_BitwiseClampByte | 8003f48c | Match ([0,0xFF00] clamp write-back; return local vs re-read — same value) | +| Particle_SetColors | 8003f4c4 | Match (channel defaults bit-equivalent: G-off → R|R<<8, B-off → R<<16; white 0x1000000; alpha |0x2000000 composes identically in both branches) | + +## Particle_RenderList (0x8003f590) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| Particle_RenderList (+11 project statics) | 8003f590 | Match | + +Full 0xD78-byte comparison. GTE mapping exact (CTC2 8-12 == +SetLightMatrix; CTC2(MFC2(25..27),5..7) == mvlvtr; MFC2 12/13/14/17/19 == +SXY0/SXY1/SXY2/SZ1/SZ3 at the same points; the R33 low-u16-only +scratchpad write matches on both sides). All 8 billboard-matrix +rotation/scale cases line up incl. (cos&0x7fff)<<1 == (cos<<1)&0xffff, +the >>11 doubled-X scale quirk, and the shared both-axis tail. Special +packet: 0x2000 color swap to the same words, 0xe1000a00|(f&0x60), +|0x50000000, and retail's deliberate UNSIGNED >>16 on the scaled X delta +(protects the Y half of VXY1) == project's (u32) cast. Culling: ±30001 +box, MAC3<0, maxRenderDist<<2; idpp attach (inst+camID*0x88+0x74, flags +0x40/0x100 @+0x44, clamp to depthOffset[0..1] @+0x68, OT base @+0x70). +Links 0x09000000/0x06000000 with 10/7-word advances; corner order +(-w,-h),(w,-h),(-w,h),(w,h); guard-end early-out leaves cursor untouched +— all identical. The native Ptr24/CtrGpu_* write shims are documented +port bridges over the same bit patterns. + +## Particle_Init (0x80040308) — DONE (2026-07-04) — Particle module COMPLETE + +| Function | Address | Verdict | +|---|---|---| +| Particle_Init (+6 project statics) | 80040308 | Match | + +Emitter walk exact: entry type byte @+2, 0xc particle-setup (funcPtr/ +colorFlags/lifespan/Type @+4/8/0xa/0xc, gated on !(flags&0xc0)), branch +precedence 0x80 > 0x40 > axis-init; axis init base/rng field offsets and +signedness (int pos seed, s16 vel/accel seeds sign-extended); oscillator +alloc with pool-empty goto == project early-return (entry skipped, walk +continues); 4-word copy == retail's 8 s16 stores; phase -= +frameTimer_Confetti (u16) on flag 0x20; waveform-6 previousValue=phase; +randomize pass mixed signedness (unsigned period/amplitude, signed +phase/bias/min/max @+0x18..0x22). Link pass: retail's &p->prev fake-node +walk == project's pointer-to-pointer chain (head slot == oscillator +field). Color seed: retail passes the LOCAL flags (bit 12 possibly set) +where project re-reads the masked store — SetColors only reads bits +7-9, equivalent. Tail defaults 0x400/-1/0 and 0x50000000/0x52000000 +seeds ✓. The redundant numIcons !=0 && >0 retail check is preserved. + +With this, game/Particle.c is fully verified (9 syms926 entries). + +## game/PickupBots — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| PickupBots_Init | 80040850 | Match (s16 hubID idiom, OXIDE_STATION=0xD → hub 0, >-1 gate) | +| PickupBots_Update | 800408b8 | Match (retail DB name RobotcarWeapons_Update) | + +Update verified end to end. Key artifact resolved: retail's +drivers[driverRank+7] == project's driversInRaceOrder[driverRank-1] — +the race-order array sits 8 slots after drivers[] (gGT+0x250c), same +address. Arcade path: entry gates (0x4b00/0x12c0, boss sign test), +weapon-ready chain order, unsigned (d²-0x90001)<0x13affff proximity +trick, rng%200 0/1/2 arms with even→beaker(4)/odd→tnt(3) mapping, wumpa +top-up (%100<0x32), voices 0xf/10/11 gated on the PLAYER not being a bot, +cooldown (rng&0xff)+0xf0, unconditional held=NONE(0xf) resets; behind-bot +block (%800<2 missile unless last lap — comma-op fallthrough to <4 bomb +— always VOICE_MISSILE, lap/dist gate before proximity). Boss path: +bail conditions (incl. speed<0x1f41) → cooldown reseed from the MAIN RNG +(g_mainRNG == sdata const_0x30215400/const_0x493583fe pair) with +(rng&0x10)+meta->cooldown+0xc+bossLosses*4; meta advance via +next->throwFlag@+9 (sizeof MetaDataBOSS=8, throwFlag@+1) with checkpoint +distToFinish<<3 thresholds and the juice-preserve (weaponType 0x64/0x66 +&& counter==5) carrying throwFlag across the reset/advance; path-request +two-phase machine (timer 0x1e, flags 0x40/0x80, phase stores botPath); +weapon decode 0x64/0x65/0x66/0xf → 3/1/4/-1 with raw passthrough; the +juice state machine exact (BOMB's first-juice returns TNT; TNT/POTION +NORMAL/THROW flag dance; common tail throw=2/counter=0/juiced-clear); +throw flags (==THROW)|2-for-bomb-voice; Oxide double-potion; TNT +counter=5 backstop; wumpa save/restore. Boss-path voices are +UNCONDITIONAL (no bot-gate) in both. All enum values == retail literals +(BOT_FLAG 0x2/0x40/0x80 static-asserted; weapon codes above). + +## PlayLevel_UpdateLapStats (0x800414f4) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| PlayLevel_UpdateLapStats | 800414f4 | Match (retail DB name StartLine_Update — closes the GameLogic-pass identification) | + +Verified: farthest-human scan; per-driver progress with store-then-clamp +== clamp-then-store backwards distance; forward/backward line crossings +(1200/32000, 600 penalty, flag 0x1000000); UNSIGNED checkpoint gate +(checkpoint-curr <= (trackLen>>2)<<3, both u32); lap bookkeeping (mask +0x4A0000, UI_SaveLapTime, 90-frame final-lap text for humans, OtherFX +0x66 + voice-timestamp clear for the lead human); finish block +(rank from numPlayersFinishedRace under !END_OF_RACE, confetti 250/250, +fades 0x1fff/0x1000/0xff78==-0x88, noItemTimer clear, Driver_Convert); +rank sort (lapIndex minus actionsFlagSet byte3&1 == bit 24, sentinels +-10/0x3fffffff, traffic-light spawn-order ranks + humanPlayerPositions +mirror); rebuild of driversInRaceOrder; overtake voice (drivers[rank+7] +== driversInRaceOrder[rank-1] folding again, (s8) position compare, +VOICE_OVERTAKE=8, victim 0x10); race-end trigger (three mode conditions, +AI-skip end-all loop, VehPickState_NewState(d,2,d,0), attack-counter +decrements, MainGameEnd_Initialize). + +## Podium_InitModels (0x80041c84) — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| Podium_InitModels | 80041c84 | **Fixed — see note 1** | + +Match otherwise: dance model = characterID + STATIC_CRASHDANCE (0x7e); +tawna picks by winner (Crash/Coco→TAWNA2 0x90, Cortex/NGin→TAWNA4 0x92, +Polar/Pura→TAWNA3 0x91) — retail switches on the ALREADY-OFFSET model +byte (0x7e/0x81 etc.), same mapping; the project's explicit `default: +TAWNA1` equals retail's no-write (pre-loop init 0x8f); the indexed +First/Second/Third write relies on the consecutive byte fields, matching +retail's per-rank arms. + +### Note 1 — negative-rank guard + +Retail dispatches ranks with ==0/==1/==2 equality arms; a driver with +rank -1 falls through untouched. The project's indexed write behind a +bare `rank < 3` would store at index -1 (the byte before +podium_modelIndex_First) for a stray unsorted rank. Guard tightened to +`rank >= 0 && rank < 3` (semantically identical to retail's arms). + +## game/PROC.c destroy/reap block — DONE (2026-07-04) + +| Function | Address | Verdict | +|---|---|---| +| PROC_DestroyTracker | 80041dc0 | Match (DB name Missile_Destroy; signed numMissiles floor) | +| PROC_DestroyInstance | 80041dfc | Match | +| PROC_DestroyObject | 80041e20 | Match (flags&0x300 pool select, -8 node rewind) | +| PROC_DestroySelf | 80041e9c | Match (destroy-cb → timesDestroyed++ → object free → thread recycle) | +| PROC_DestroyBloodline | 80041f04 | Match (children-first recursion) | +| PROC_CheckBloodlineForDead | 80041f58 | Match (unlink via replaceSelf; retail's duplicated pre/post-recursion assignment is a compiler artifact — same value) | +| PROC_CheckAllForDead | 80041ff4 | Match — sweeps ALL 0x12 buckets incl. PAUSE (correctly the opposite bound from GameLogic's 0x11 dispatch fixed in the MainFrame pass) | + +## game/PROC.c rest — DONE (2026-07-04) — PROC module COMPLETE (14/14) + +| Function | Address | Verdict | +|---|---|---| +| PROC_BirthWithObject | 8004205c | Match (bucket byte inherit, 0x300 pool select — retail hardcodes 0x670/0x88/0x48 == project's pool->itemSize; unsigned size>16 vs project's signed >>16-then-cast — differs only for sizes ≥0x8000, far above any pool) | +| PROC_CollidePointWithSelf | 8004228c | Match (0x1800 gate, per-axis <0x10000000, best-dist record; the decomp's cDC/driverToFollow reads are folded inst->matrix.t) | +| PROC_CollidePointWithBucket | 80042348 | Match | +| PROC_SearchForModel | 80042394 | Match (s16 compare, children-then-siblings) | +| PROC_PerBspLeaf_CheckInstances | 800423fc | Match (DB THREAD_SearchBucketProximity; BSP stride 0x20 + COLLIDABLE 0x80 static-asserted; instDef→ptrInstance@0x2c→flags@0x28 & DRAW_COLLISION_MASK skip logic == retail's OR form; ≤0xFFFFFFF axis gates) | +| PROC_StartSearch_Self | 80042544 | Match (DB COLL_SearchObjectsInRadius; ±radius AABB in u16==s16 arithmetic; SearchBSP callback) | +| PROC_CollideHitboxWithBucket | 800425d4 | Match (children-first recursion, self-skip, same sphere test) | + +Also read alongside (ThTick_* at 0x800715e8+, out of block order): the +native ThTick_RunBucket pending-stack form and FastRET/SetAndExec/Set +are marked native-equivalent divergences — verify the retail recursion +when the sweep reaches 0x800715e8. + +## game/PushBuffer.c — DONE (2026-07-04) — PushBuffer module COMPLETE (9/9) + +| Function | Address | Verdict | +|---|---|---| +| PushBuffer_Init | 800426f8 | Match (all layout arms: 1P 0x200x0xD8 dist 0x100 aspectX 4; 2P 0x200x0x6A dist 0x100 aspectX 8, id1 y=0x6E; 3/4P 0xFDx0x6A dist 0x80 aspectX 4, x=0x103/y=0x6E per quadrant; matrix_Proj 0x1C71/0x1000 diag; fade 0x88/0x1000/0x1000. Ghidra plate claims "aspectX 8 for 1P" — plate wrong, decompiled code has 4; project matches code) | +| PushBuffer_SetPsyqGeom | 80042910 | Match (signed s16 w/2, h/2 geom offset; GeomScreen from distanceToScreen_PREV) | +| PushBuffer_SetDrawEnv_DecalMP | 80042974 | Match (full DRAWENV copy; tpage=(upper<<8)\|lower via LE byte stores; ofs[0]=offsetX only written inside the prim-allocated branch — project preserves this quirk; cursor<=guardEnd bounds check, +0x40 advance) | +| PushBuffer_SetDrawEnv_Normal | 80042a8c | Match (NULL 4th arg → clip/ofs shifted by pb->rect; else copy from arg[0..3] — project's s16* view of DRAWENV.clip is layout-identical) | +| PushBuffer_SetMatrixVP | 80042c04 | Match (transpose word-packing view0..viewC == retail uTransRot0..3 bit-for-bit; ViewProj = transpose with row 1 (m[1][0..2], word1-high/word2) and t[1] scaled *0x360/0x600; final light-matrix reload is only 4 ctc2s (L33 skipped, t22==m22) — project PSX path identical, native SetLightMatrix writes same value) | +| PushBuffer_SetFrustumPlane | 80042e50 | Match (param2→IR, param4→R rotation rows; LZCS/LZCR min over |x|/|y|/|z|, shift if <0x12; <<12/len normalize; planeD=(n·camPos)>>0xD — Ghidra plate says "·pointB", code says camPos, code wins; sign flags bit0/1/2) | +| PushBuffer_UpdateFrustum | 800430f0 | Match (halfH: floor(floor(h*0x600000/0x360)/0x1000)==h*0x600/0x360 identity; corner order (+X,+Y)(−X,+Y)(+X,−Y)(−X,−Y) → fc[3]..fc[0] descending; all 6 world-bound clip blocks incl. retail quirk that the last +Z block never updates the running t — project preserves; frustumData is char[0x28] so [0x8]/[0x10]/[0x18]/[0x20] indices are byte offsets matching retail's 8-byte plane stride; near-plane flags computed on un-negated MACs; planeD −(dist/4 trunc-toward-zero via +3 if neg); data6 fwd point at dist/2) | +| PushBuffer_FadeOneWindow | 80043928 | Match (prim NOT bounds-checked in retail either; 0xE1000A40 black <0x1001 / 0xE1000A20 white; strength 0xFFF−v vs v−0x1000, >>4; +0x20 cursor advance == sizeof(multiCmdPacket); s16 step+clamp both directions. Native-only: tpage \|= 0x400 dfe — documented PsyCross divergence, kept) | +| PushBuffer_FadeAllWindows | 80043ab8 | Match (per-player pushBuffer[i] stride 0x110 + pushBuffer_UI tail call) | + +No fixes needed — the whole module is faithful, including three easy-to-miss +retail quirks the project already preserves (DecalMP's late ofs[0] write, +UpdateFrustum's non-updating final clip block, SetMatrixVP's 4-register +light-matrix reload). Two Ghidra DB plate comments contradicted their own +decompiled code (Init aspectX, SetFrustumPlane planeD operand) — code/asm +trusted, plates ignored. + +## game/QueueLoadTrack.c + game/RaceConfig.c — DONE (2026-07-04) — both COMPLETE (2/2 + 2/2) + +| Function | Address | Verdict | +|---|---|---| +| QueueLoadTrack_MenuProc | 80043b30 | Match (TT forces charIDs[2]=N_TROPY(0xC)/[3]=NITROS_OXIDE(0xF) — project enum values confirmed; ADV → \|=ADVENTURE_ARENA; !BATTLE → originalEventTime=0x2A300 + clear ~0x1C000 == ~(POINT\|LIFE\|TIME_LIMIT) with TIME_LIMIT=0x10000 confirmed; RequestLoad(currLEV); Hide) | +| QueueLoadTrack_GetMenuPtr | 80043c04 | Match (&data.menuQueueLoadTrack) | +| RaceConfig_LoadGameOptions | 80043c10 | Match (boolHasLoadedOptions latch; saved→live rwd copy repeated inside the 3-volume loop — retail lwl/lwr codegen artifact, project's in-loop memcpy mirrors it harmlessly; gameMode1 \|= saved & 0xF00 (P1-4_VIBRATE); howl_ModeSet(audioMode&1)) | +| RaceConfig_SaveGameOptions | 80043d24 | Match (VolumeGet&0xff into s16 volumes; live→saved rwd; gameMode1&0xF00; audioMode = ModeGet()!=0 as 16-bit store) | + +## game/RaceFlag.c — DONE (2026-07-04) — RaceFlag module COMPLETE (14/14) — 2 FIXED (4 fixes) + +| Function | Address | Verdict | +|---|---|---| +| RaceFlag_MoveModels | 80043e34 | Match (sine ease-in-out; <0→0, >numFrames→0x1000; signed mid=numFrames/2; boundary frameIndex==mid takes the second half in both) | +| RaceFlag_IsFullyOnScreen | 80043f1c | Match | +| RaceFlag_IsFullyOffScreen | 80043f28 | Match ((pos+4999)&0xffff<9999^1 — u16-wrap identical for both cast orders) | +| RaceFlag_IsTransitioning | 80043f44 | Match (pos not 0/±5000 && renderFlags&0x1000) | +| RaceFlag_SetDrawOrder | 80043f8c | Match (!=0→1 else −1) | +| RaceFlag_BeginTransition | 80043fb0 | Match (dir1: textAnim=−1,pos=5000,type=0; dir2: SetDrawOrder(0),pos=0,type=2; always renderFlags\|=0x1000) | +| RaceFlag_SetFullyOnScreen | 8004402c | Match | +| RaceFlag_SetFullyOffScreen | 80044058 | Match | +| RaceFlag_SetCanDraw | 80044088 | Match (s16) | +| RaceFlag_GetCanDraw | 80044094 | Match (sign-extended s16) | +| RaceFlag_GetOT | 800440a0 | Match (split ifs == retail else-if since type-0 never mutates animType; retail's <1 "arrived" bound == project's ==0 after the negative→5000 snap; slide decrement (pos>>3)*ms>>5 min 1; type-2 speed ramp to 1000, snap at <−4999; DrawOrder 1/−1/other → UI-OT/UI-OT+clear/ptrOT[0x3FF]. Latent-only: project's logical (u32)speed>>2 vs retail sra — differs only for negative speeds, unreachable (300 base, add-only)) | +| RaceFlag_ResetTextAnim | 80044290 | Match | +| RaceFlag_DrawLoadingString | 800442a0 | FIXED ×3 (1. loop bound was hardcoded 10 — retail jal strlen at 0x800442f4, bound in s6: measures the localized string; 2. 2-byte-glyph test used signed char<4 — retail lbu+sltiu at 0x800443c8 is unsigned, bytes ≥0x80 misparsed; locals → u8; 3. draw X was passed as int — retail sll/sra 0x10 at 0x80044414 truncates to s16, and Transition&0xffff≈64500 during the idle scroll NEEDS the wrap to slide text off the LEFT edge; posX param is int so added (s16) cast) | +| RaceFlag_DrawSelf | 800444e8 | FIXED ×1 (column loop ran `< 36` — retail slti 0x23 at 0x80044e88 runs columns 1..34 = 35 projected columns incl. unrolled first; 36 drew one extra strip past the flag edge. Everything else matches: checkerFlagVariables local/global dance, phase math (0x200/200 reseed, ±0x20/0x40 amps → 0x96/0xb4, 0xc80 light offset, +300/vert, +0x100/column, +0x11a row Y, +100 column X, −0xbbe−pos base X), scratchpad ping-pong posL=buf−4/posR quirk, 2-quads-first-row via i==0 skip, parity ((col>>2)+(i>>2))&1→dark, brightness formulas algebraically identical to retail's blend form (0x82/0xff and 0x69/0xa0 blends == −125·s+0x1FE000 / −55·s+0x140000), 0x80008000/0xd80200 clip masks, POLY_G4 word layout + manual OT link, ElapsedTime += ms·100. Documented native divergences kept: no per-quad primMem bounds check (retail sltu/abort at 0x80044cfc — native prim buffer ample; retail's check also has a stale-pPrim reuse quirk on exhaustion), host scratchpad buffer, addPrim done manually) | + +## game/RECTMENU.c — DONE (2026-07-04) — RECTMENU module COMPLETE (21/21) — 3 FIXED (6 fixes), 2 deferred questions RESOLVED + +| Function | Address | Verdict | +|---|---|---| +| RECTMENU_DrawPolyGT4 | 80044ef8 | Match (NULL-icon guard → DecalHUD forward) | +| RECTMENU_DrawOuterRect_Edge | 80044f90 | Match — DEFERRED QUESTION RESOLVED: retail passes the color as a POINTER (callee 0x80021894 does lw 0(a1)); the native CTR_Box_DrawSolidBox takes Color BY VALUE and the project threads the value consistently — identical 24-bit color dataflow, calling-convention refactor only. ClearBox mode 1 == TRANS_50_DECAL ✓ | +| RECTMENU_DrawTime | 80044ff8 | Match (all 5 fields algebraically identical: retail's a/N − k·(a/M) forms == project's %-forms; ms·10/0x3c0 == ms/0x60 exactly) | +| RECTMENU_DrawRwdBlueRect_Subset | 80045134 | FIXED (packed X halves: retail ands the low half with 0xffff / (ushort) casts on words 4/6/8; project ORed sign-extended sums — negative x during menu slide-in clobbered the packed Y) | +| RECTMENU_DrawRwdBlueRect | 80045254 | Match (4-byte stops, 'd'==0x64 terminator, meta[3]·h/100 band mapping, [g0,g0,g1,g1] corner colors) | +| RECTMENU_DrawRwdTriangle | 800453e8 | Match (colors 0/4/0/8, y0=pos[1]−1 quirk) | +| RECTMENU_DrawOuterRect_LowLevel | 80045534 | Match — second deferred question resolved with Edge: 4th param IS the color (by value in native), 5th the flags; 4-edge decomposition incl. the <<0x10>>0xf 2·yThick idiom | +| RECTMENU_DrawOuterRect_HighLevel | 80045650 | Match (3,2 thickness) | +| RECTMENU_DrawQuip | 8004568c | Match (auto-width +0xc, 0x8000 un-center shift, params[font]/params[font+4] box/baseline; retail otMem.startPlusFour == project otMem.uiOT. Latent-only: sizeX>>1 logical vs retail signed-s16 half — differs only for widths ≥0x8000) | +| RECTMENU_DrawInnerRect | 800457b0 | Match (0x10 UI2 border, 0x2 skip-border + inset 3/2/6/4, 0x8 skip-fill, 0x1 solid[0] vs clear [1]/mode2 (0x100) or [2]/mode0, 0x4 skip-shadow, 0x80→4/0xc h-shadow, 0x40→2/6 v-shadow) | +| RECTMENU_DrawFullRect | 800459ec | Match (title separator y: +9+fontH[1] big / +6+fontH[2] small / +6+fontH[1] small+bigtitle; h=2 w=inner−6; startPlusFour==uiOT confirmed here too) | +| RECTMENU_GetHeight | 80045b1c | Match (small fontH[2] / big fontH[1]+3; rows / highlit-only / title-only−6; title +lineH+6 or fontH[1]+9; recursion condition order commutes) | +| RECTMENU_GetWidth | 80045c50 | Match (max lineWidth+1; title font forced big by 0x4000; retail's unmasked title index safe under the ≥0 guard, project's &0x7fff a no-op there; latent s16-truncated compare) | +| RECTMENU_DrawSelf | 80045db0 | Match (full walkthrough incl. asm-verified recursion args a1=posX+posX_prev, a2=baseY+posY_prev+vertCenterY+rowHeight+0xc, a3=width; state&8 transient clear; border h −(u8)state>>7 trick; DOWN+X-glitch row-write order preserved) | +| RECTMENU_ClearInput | 80046404 | Match | +| RECTMENU_CollectInput | 80046458 | Match (4 players iff activeSubMenu ALL_PLAYERS else numPlyrNextGame byte; binary has no PC-port CROSS-broadcast block and neither does the project) | +| RECTMENU_ProcessInput | 80046534 | FIXED ×3 (1. retail claims activeSubMenu + first-touch ClearInput at 0x800465a0-c4 BEFORE loading tap buttons at 0x800465e0 — the opening press is swallowed; project read buttons first, letting stale input navigate the fresh menu. Restructured to retail order. 2. select mask: retail andi 0x50 at 0x80046680 = CROSS_one|CIRCLE; project's composite BTN_CROSS added raw bit 0x4000 → BTN_CROSS_one. 3. back mask: retail 0x40020 at 0x80046754 = TRIANGLE|SQUARE_one; composite BTN_SQUARE added raw 0x8000 → BTN_SQUARE_one. Verified rest: 0x4007f nav gate, 0xc00 L1/R1 cheat guard on held[0], lbu row-nav bytes Up/Down/Left/Right priority, MUTE 0x800000, CANT_GO_BACK 0x100000, rowSelected write order incl. the 3P-VS DOWN+X glitch, double ClearInput, recursion overwrites result. Latent: project signed-char row-nav read vs retail lbu — row indices < 0x80) | +| RECTMENU_ProcessState | 8004680c | FIXED ×2 (retail reloads ptrActiveMenu AND state fresh at 0x800468b0/0x800468d4/0x80046910/0x80046960 — after the 0x420 funcPtr, after ProcessInput, and before the 0x800 + NEEDS_TO_CLOSE checks; project cached both in locals, so a funcPtr calling RECTMENU_Show/Hide mid-frame processed/drew/closed the WRONG menu. Added the three reloads. Also confirmed via asm: input-skip bit is 0x20 (project enum value correct; the Ghidra DB's enum NAMES for 0x20/0x400 are swapped — plate contradicted its own decompile, asm arbitrated). DrawSelf(menu,0,0,width) args asm-confirmed) | +| RECTMENU_Show | 80046990 | Match | +| RECTMENU_Hide | 800469c8 | Match | +| RECTMENU_BoolHidden | 800469dc | Match | + +## game/RefreshCard.c — DONE (2026-07-04) — RefreshCard module COMPLETE (17/17) — 2 FIXED (3 fixes) + +| Function | Address | Verdict | +|---|---|---| +| RefreshCard_BoolGhostForLEV | 800469f0 | Match (s16 count of trackID matches over numGhostProfilesSaved, stride 0x34) | +| RefreshCard_Unknown1 | 80046a74 | Match ((f\|6)&~8 == retail (f&~8)\|6 — bit 3 not in 6) | +| RefreshCard_GetResult | 80046a90 | Match (==8/PENDING busy special-case; &6 clear + run==wait action/slot + desired==expected) | +| RefreshCard_NextMemcardAction | 80046b1c | Match (frame4/frame2 wait+queued pairs, descriptor, &~8) | +| RefreshCard_GhostEncodeByte | 80046b60 | Match (URL-safe base64: +0x30/+0x37/+0x3d/'-'/'_') | +| RefreshCard_GhostDecodeByte | 80046bc0 | Match (inverse; '['==0x5b boundary == retail !(0x5a>4&0x1f, time>>9&0xfffff, slot u32>>29; 0x15-byte name copy; alwaysOne=0; trackID high byte cleared) | +| RefreshCard_StartMemcardAction | 80047198 | Match | +| RefreshCard_StopMemcardAction | 800471ac | Match | +| RefreshCard_SetScreenText | 800471c4 | Match | +| RefreshCard_Unknown2 | 800471e8 | Match (init latch + flag=1/state=0; ptrToMemcardBuffer1/2 are ALIASES — both init to 0x800992e4 / &memcardBytes[0], so the project's mixed Buffer1/Buffer2 usage == retail's mixed d470/d474 usage) | +| RefreshCard_GetNumGhostsTotal | 80047224 | Match (=0; syms-name-vs-body mismatch documented in Ghidra too) | +| RefreshCard_GameProgressAndOptions | 80047230 | Match (flag/state/advProfileIndex=-1 first; Sync(card+0x144); 0x14bc copy; LoadGameOptions) | +| RefreshCard_Unknown3 | 800472d0 | FIXED ×2 (1. ghost-ERASE path: retail copies the doomed file's name (0x15 B) to the separate buffer @0x80099284 == sdata->ghostFileNameFinal; project clobbered the s_BASCUS_94426G template, so the follow-up ghost SAVE (QueueGhostSave after READY_SAVE with rowSelect>=0) wrote the new ghost under the ERASED file's name — wrong char/level/time metadata. 2. NODATA result: retail's comma-op (MVar4=MC_SCREEN_NULL, 2>8) | +| SelectProfile_UI_ConvertY | 80047fd8 | Match ((y−0x6C)·s signed >>8) | +| SelectProfile_DrawAdvProfile | 80047ff8 | FIXED (character icon OT: retail links into pushBuffer_UI.ptrOT, project used otMem.uiOT — they diverge when ptrOT is redirected, changing draw order. Rest matches: color quads per drawStyle&0x10, EMPTY lng 0xb5, stat prints at +0x6a/+0xb5 rows +5/+0x17 fmt 0 color\|0x4000, % sign at +0x70 WITHOUT 0x8000, 3 icon instances at +0xc3/+0x1f, +0x78/+0xd, +0xc3/+0xd with t[2]=0x100 + ~HIDE, card 0xdc×0x3d, highlight +6/+4 0xd0×0x35 mode 1, frame/highlight OT = startPlusFour+0xc == uiOT[3]) | +| SelectProfile_GetTrackID | 800485a8 | Match (greenLoadSave row 1; HubLevYouSavedOn = levelID) | +| SelectProfile_Init | 800485cc | Match (BirthWithObject 0x8030d == SIZE_RELATIVE_POOL_BUCKET(8,...); slots @0x90 memset; per-card cfg stride 0xe: modelID@0/scale@2/RGB@0xa-0xc; flags 0x480 +0x20000 non-center; idpp[0]=&pushBuffer_UI, others NULL; green dims R and B only (not G) — project LoadSave_Color matches; rot.z=spinOffset[i%3]; identity matrix as word writes. Native-only reorder of the thread-store null check kept as documented divergence) | +| SelectProfile_Destroy | 800488e0 | Match (12 INSTANCE_Death, thread flags \|= 0x800 DEAD; store order swap harmless) | +| SelectProfile_AdvPickMode_MenuProc | 80048960 | Match (phase draw: Init + DrawAdvProfile(advProgress,0x92,0x32,0,0,0x10); rows 0-2 → ToggleMode(row\|0x20)+FourAdvProfiles; −1/3 → Hide+Destroy) | +| SelectProfile_DrawGhostProfile | 80048a30 | FIXED (same icon-OT fix as DrawAdvProfile. Rest matches: card 0xc8×0x29, inner +6/+3 0xbc×0x23; NOT-AVAILABLE lng 0x6d + red box mode 2 (Ghidra plate prose "GHOST header" wrong, code wins); empty lng 0x6c/0xb5 colors 0x8001/0x8003 by isLoading; level name 0x801d, time 0x8001, tint ghostIconColor; highlight mode 1; frame at startPlusFour) | +| SelectProfile_MuteCursors | 80048da0 | Match | +| SelectProfile_UnMuteCursors | 80048de4 | Match | +| SelectProfile_ToggleMode | 80048e2c | Match (memcardAction = mode&0xf, bbb[0] = mode&0xf0; six bbb state s16s zeroed — retail zeroes them in a different order (bbb[2] last vs project's bbb[2] last too, [12] mid) but the same set, no interleaved reads; green bit on FourAdvProfiles + the overwrite/confirm menu @80085d44; Init; rowSelected restore from d73c) | +| SelectProfile_InitAndDestroy | 80048edc | Match | +| SelectProfile_InputLogic | 80048f0c | FIXED ×4 sites (select mask retail andi 0x50 = CROSS_one\|CIRCLE at 0x80048fd4/0x80049084; back mask 0x40020 = TRIANGLE\|SQUARE_one at 0x80049024/0x80049050 — composite BTN_CROSS/BTN_SQUARE added raw 0x4000/0x8000 bits. Rest matches: taps from P1 only, 0x4007f gate, ±2/^1 grid nav with clamp then SFX-on-change, numRows==0 select block unless memcardAction==1, UNFORMATTED select resets row 0, confirm-only mode flags&1/&2, ClearInput inside the gate) | + +Vector_SpecLightSpin3D re-checked as a dependency: pNormal is read-only (gte_ldv0) — confirms ThTick's pointer-vs-copy equivalence. + +## game/SelectProfile.c (part 2 of 2) — AllProfiles_MenuProc DONE (2026-07-04) — SelectProfile module COMPLETE (16/16) — 8 fix sites in the monster + +| Function | Address | Verdict | +|---|---|---| +| SelectProfile_AllProfiles_MenuProc | 800490c4 | FIXED ×8 across its helper decomposition (full FSM traced against the ~0x1800-byte retail body; the "Partial retail audit" flag is now retired): | + +1. ProcessOverwritePrompt masks — retail 0x40070 group / 0x50 select (CROSS_one/SQUARE_one); composites added raw 0x4000/0x8000. +2. Cup-save (mode 0x40) NOCARD select mask — retail andi 0x50. +3. Selection gating restructure — retail's bProceed logic (post-LAB_800495b0): back-out (row −1) checked FIRST; NOCARD only latches ActionActive/Done for memcardAction==1 and only lets mode-0 GHOST selection proceed (a live ghost row still starts a load under NOCARD in retail); the low-space adventure condition merely VETOES. The old HandleNoCardOrSpace helper latched ActionActive/ActionDone for NOCARD+mode-0-ghost and for the low-space case — force-finalizing the menu where retail keeps it open — and ran after the NOCARD latches so a back-press under NOCARD set extra flags. Helper deleted; gating folded into HandleSelection in retail order. +4. DrawAll draw gate — retail requires RefreshFlag AND (RefreshState OR screen==NULL); project's OR-of-all-three drew rows in message states. +5. DrawAll screen overrides — retail shows SAVING on the save-start frame and ERROR_FULL when space is short (both while suppressing rows); project fell through to the current screen's (often blank) message. doSave now threaded into DrawAll; screenOverride passed to the message drawer. +6. DrawMemcardMessage — added retail's ActionDone(d8fe)==0 gate; added the adventure-mode NODATA out-of-date line (retail draws it in BOTH the rows path and the message path); line spacing now fontCharPixHeight[SMALL]+2 instead of hardcoded 10. +7. FinalizeAdventure mode 0x20 — retail records advProfileIndex whenever the exit flag is clear (comma-op, including save mode — this is what suppresses the overwrite prompt when re-saving the same slot), and rowSelected=3 targets menuGreenLoadSave in BOTH arms (project wrote menuQueueLoadHub's row in the hub arm). +8. DrawGhostRows subtitle-visibility — retail's ghost branch strlens the ADV subtitle table @80085d8a (copy-paste quirk) while drawing from lngIndex_LoadSave; quirk replicated since yBase/titleEndY layout depends on it. + +Verified equal (highlights): confirm-box UP/DOWN/select chain + row sync to both confirm menus; ghost row-count math incl. the mode-0 8-row case and the exactly-one-past-NULL ghost cursor; row X/Y geometry (0xd4/0x2e/0x98 columns, pair·0x2b/0x2f strides == retail's (stride−1) fold); wrong-track flag from currLEV; adv 4-card grid + title y (subtitle? 0x12 : 0x1a); overwrite-menu draws (deleteIndex ghost card @0x9c,0x3c / adv card @0x92,0x3c); phase-7 TIMEOUT reset; doSave block (RequeryLatch clear, ghost encode + add/delete indices + PLAYER_GHOST_BEAT, adv save order Sync→copies commutes, countdown 0x3c); SAVE COMPLETED blink (ORANGE/WHITE alternation, lng 0x13d) + its 5-term gate; ShouldFinalize == retail's condition incl. the countdown decrement path; FinalizeGhost (menu224@800a0458 / NoSave@800a04a4, ghost-tape charID@+6, TrackSelect back-path); Finalize modes 0/0x10 (currLEV 0x1a fallback == DINGO_CANYON 0 check, dual name copies) and 0x40 (NEW_NAME|NEW_HIGH_SCORE clear vs menuWarning2). Deliberate native divergences kept: NULL guards on ghost-tape memset/charID, redundant boolError writes matching Unknown3's steady state, defensive range clamps. + +## SubmitName (2/3) + TakeCupProgress + Timer + Torch — DONE (2026-07-04) — 1 FIX (in SelectProfile.c via cross-ref) + +| Function | Address | Verdict | +|---|---|---| +| SubmitName_RestoreName | 8004aa08 | Match (mode → bbb[0xd]; 0x11-byte name restore; cursor 0/1001) | +| SubmitName_MenuProc | 8004b144 | Match (DrawMenu(0x13f) → rowSelected; TT: cancel→menu224, save→ToggleMode(0x31)+GhostSelection; Adv: cancel→CS_Garage+ZoomOut(1), save→advProgress.name copy+ToggleMode(1)+FourAdvProfiles) | +| TakeCupProgress_Activate | 8004b230 | Match (stringIndexSaveCupProgress; ShowMenu(menuSaveGame)) — CROSS-REF FIX: this confirmed data_menuBox_saveGame == data.menuSaveGame ≠ menuWarning2, exposing that SelectProfile FinalizeAdventure's mode-0x40 exit path routed to menuWarning2 (the save screen) where retail routes to menuSaveGame (the "Save Game?" prompt). Fixed in SelectProfile.c. | +| TakeCupProgress_MenuProc | 8004b258 | Match (draw phase MultiLine(idx,0x100,0x3c,0x1cc,BIG,0x8000); row 0 → boolSaveCupProgress=1+ToggleMode(0x41)+menuWarning2; row 1/−1 → Hide) | +| Timer_Init | 8004b31c | Match (RCnt2 crit-section start, 0xffff/0x2000) | +| Timer_Destroy | 8004b370 | Match | +| Timer_GetTime_Total | 8004b3a4 | Match — including the rollover guard (re-read accumulator when rcnt<100) that the old community decomp omitted; project already has it | +| Timer_GetTime_Elapsed | 8004b41c | Match (wrap +0xc7e18) | +| Torch_Main | 8004b470 | Match — full asm trace (Ghidra decompile dead-ends on the custom register convention): scratch save-area 0x00-0x2c (native uses host locals, documented), list head@0x30/swapchain@0x38/0x5e=0, ViewProj→light regs 8-15 + identity R (reg 2 deliberately unset outer), OFX/OFY/H + clip rect 0x48-0x56, per-particle pos>>8 pack, radius bytes +0x3d/45/4d, RGB pack→0x44, FLAG<<14 sign gate, sxy0−0x80 gate, radius pipeline order (load next radius vectors before building the previous card), visibility (top>>16−0x18 + nor/and mask vs pb+0x20 size word), OT index = signed(particle+0x18) + viewZ>>6 clamped via shifted 0xffc == min(·,0x3ff)<<2 off ptrOT@pb+0xf4, GLOBAL 12-particle budget across viewports (t9 set once — project matches), per-viewport list-head reload, prim cursor threaded through v0 | +| Torch_Subset1 (BuildCard) | 8004b914 | Match (register-only card computation — renders empty in decompile; project's Subset1_BuildCard mirrors Helper_1's consumed t3-t6 values) | +| Torch_Subset2 (StoreCard) | 8004b94c | Match (SXY2 via MFC2(14); stores left/right/top/bottom @ring+0x1c/0xc/0x4/0x14 + 4 corners @0x20/0x08/0x18/0x10) | +| Torch_Subset3 (SetTpage) | 8004b9cc | Match — asm-resolved the scratch-0x58 question: retail keeps tile<<6 in REGISTER s7 (zeroed at entry, recomputed at end; the subu of pre-zeroed s7 is dead code); project's scratch 0x58 slot is the native stand-in, and WriteUvPair's (u8) truncation is exact since the prim U is u8 | +| Torch_Subset4-9 (emitters) | 8004ba4c-8004c348 | Match-by-pairing — all seven SetTpage+FT3+FT4 emit triples verified in exact order/args from Torch_Main's asm (0x6c/6e→S6+S9(8,4) … 0x88/6e→S5+S8(0x20,4)); emitter internals carry the porter's per-address ASM stamps and share the verified EmitFT3/FT4 skeleton (code 0x24/0x2c, tags 0x07/0x09<<24) | + +## SubmitName_DrawMenu + UI (part 1) — DONE (2026-07-04) — SubmitName COMPLETE (3/3) — 1 function FIXED (3 fixes) + +| Function | Address | Verdict | +|---|---|---| +| SubmitName_DrawMenu | 8004aa60 | FIXED ×3 (1. blink phase: retail samples (typeTimer & 1) AFTER the ++ at every blink site; project computed blinkWhite before the increment — inverted phase vs the underscore's live &2 read. 2/3. unsigned lbu compares: the glyph-count >2 and backspace lead-byte <3 tests used signed char — bytes ≥0x80 miscounted/wrongly zeroed. Verified rest: 13×3 grid decode incl. 0x1000-flag strip + 2-byte glyph split, header 0x13e/name/underscore(<16)/SAVE(0x4000\|blink)@472/CANCEL 0x141@40, border 32/448/62/2 + inner 39/130 via Edge(0x20)+InnerRect(0), input tree (START→CANCEL-jump, Triangle\|SQUARE_one backspace with the [-1]-into-prevName adjacency quirk kept, CROSS_one\|CIRCLE type/backspace-key/SAVE/CANCEL incl. the goto that skips ClearInput on non-1000, no NUL after typing — faithful), D-pad wrap incl. the documented ND 1001-bound bug, sfx table stride-4 u16 reads. Project masks here already used the _one variants ✓. Native SDL keyboard block = documented addition) | + +## UI module (part 1) — 7/…? (2026-07-04) — all clean + +| Function | Address | Verdict | +|---|---|---| +| UI_SaveLapTime | 8004c55c | Match (slot=driverID·7+lap; digit forms algebraically identical: /0x2580−min·6 == %6, /0x60−(/0x3c0)·10 == (·10/0x3c0)%10; 9:59:99 clamp) | +| UI_ThTick_CountPickup | 8004c718 | Match (0xffff0000 tint; 1P wumpa shine (result−0x80)·0x10 gated <10; spin 0x80 wumpa / 0x40 crate via the same goto ladder; packed (bool,hudFlags,demoMode,numLaps) word & 0xff0100 == 0x100 visibility) | +| UI_ThTick_Reward | 8004c850 | Match (spin 0x40, SpecLightSpin2D, visibility + fade>0xfff) | +| UI_ThTick_CtrLetters | 8004c914 | Match (END_OF_RACE+IsTransitioning → scale 0; grow angle ((scale−0x800 or −0x401)>>10 +1)·0x200) | +| UI_ThTick_big1 | 8004ca04 | Match (uniform scale from rot[3]; project's s16→int word-writes equal retail's explicit zero stores for the always-positive scale — negative-scale sign-extension latent only) | +| UI_ConvertX_2 | 8004caa8 | Match (== SelectProfile_UI_ConvertX, cross-verified last pass) | +| UI_ConvertY_2 | 8004cac8 | Match | + +## UI module (part 2) — UI_Instance.c DONE (2026-07-04) — 1 FIX + +| Function | Address | Verdict | +|---|---|---| +| UI_INSTANCE_BirthWithThread | 8004cae8 | Match (per-driver-thread loop; PROC_BirthWithObject 0x380310; BIG1→instBigNum / FRUITDISP→instFruitDisp (retail BigNumber[0]/[1] adjacency); gem/relic/key lightDir (−0xc98,0x99f,0x232)==0xf368/… as s16, crystal (−0xb60,0xb60,−0x2d8)==0xf4a0/0xb60/0xfd28; colors 0x6c08080/0x60a5ff0/0xd22fff0/0xdca6000; C/T/R vel[0]=(id−0x94)·4 + 0x30000 flags + 0xffc8000; token color from AdvCups[group] stride 8, <<0x14/<<0xc/<<4; layout stride 8 + viewport advance 0xa0 == +=0x14 elements; pushBuffer path idpp[0]+0x100+(0,0,0x200); depth bias 0x80 byte-identical to retail's −0x80; faceCamera −ratan2(t1,t2); rot[3]=0x1000) | +| UI_INSTANCE_InitAll | 8004cec4 | FIXED (relic-time fractional digits: retail 10ms=(t/0x60)%10 (tenths), 1ms=((t·100)/0x3c0)%10 (hundredths) — matching RECTMENU_DrawTime and the OTHER writer in 223.c; project computed hundredths+THOUSANDTHS, shifting the HUD relic target time one decimal place. UI_Clock.c consumes positionally ([5]=tenths,[6]=hundredths) so the shift was user-visible. Rest matches: menuReadyToPass&=~1, renderFlags\|=0x8000; crystal-challenge dual crystal + hidden token; arena relic/key/trophy + AdvPercent; rank seeding (retail reads through NULL driver slots — native guard documented); relic branch births + scale-0 with retail's own null check; reward-bit offsets +0x16/+0x28/+0x3a == ADV_REWARD enum exactly; RelicTime[levelID·3+type]; decalMP pushbuffer copy (five field groups) + fruitdisp/big1/letters/token per mode incl. numPlyr<3 && !BATTLE gate; letters hidden — retail null-unsafe, native guards documented) | + +## UI module (part 3) — UI_Map.c DONE (2026-07-04) — 2 FIXED + +| Function | Address | Verdict | +|---|---|---| +| UI_Map_DrawMap | 8004d614 | FIXED (top-half spawn guard: retail tests ptrSpawnType1->count != 0, not the pointer — count==0 with a live pointer must leave the map-spawn 0; pointer null-check kept as native guard. Rest matches: color modes 0x808080/0(black)/0x402000(blue) with transparency→0, tpage blend bits &0xff9f\|mode<<5, code\|=2 always, top-half gate (spawn+0x12==0 \|\| MAIN_MENU), y stacking from u8 V spans, cursor +0x28 per quad; ExtraFunc == retail's inlined copy) | +| UI_Map_GetIconPos | 8004d8b4 | Match (4 rotation quadrants exact incl. −(a/b)==(−a)/b trunc equivalence; Y uses iconSizeY·2; 3P nudge −0x3c/+10; −0x10 Y bias; retail traps = div guards) | +| UI_Map_DrawAdvPlayer | 8004dbac | Match (t[0]/t[2] projection; sprite blink by timer&2; HubArrow arg order (scale, rot) — the same 5th/6th swap in both) | +| UI_Map_DrawRawIcon | 8004dc44 | Match (ptrColor[colorID] pointer-table + 4 gradient words — the ConfigDrawArrows pattern, correct here; iconGroup[5] icons; OT = pushBuffer_UI.ptrOT ✓ correct in this file) | +| UI_Map_DrawDrivers | 8004dd5c | FIXED (retail's odd-player test is INSIDE the loop — 2P/4P still walk the thread list and increment *pCounter per thread, only drawing is skipped; project's early return left the caller's counter unadvanced. Rest matches: charIDs[driverID]+5, AI icon 0x31, human 0x32 blinking WHITE on timer&2, arena → DrawAdvPlayer(rotCurr.y+0x800\|0x1000, 0x800) with counter still bumped via continue) | +| UI_Map_DrawGhosts | 8004dee8 | Match (gate +0x632; human ghost CORTEX_RED(6)/CRASH_BLUE(5) blink — enum values verified == retail 6/5; tropy TROPY_LIGHT_BLUE(0x11), oxide-unlocked RED(3)/WHITE(4) blink on timeTrialFlags&2) | +| UI_Map_DrawTracking | 8004dffc | Match (DYNAMIC_WARPBALL filter; ball icon 0x20 color 0; target via inst->thread->object word 0 == TrackerWeapon.driverTarget@0; target icon 0x21 colors 4/3 by timer&1 at instSelf->matrix.t) | + +## UI module (part 4) — UI_Icon.c + UI_Lerp2D.c DONE (2026-07-04) — all clean + +| Function | Address | Verdict | +|---|---|---| +| UI_WeaponBG_AnimateShine | 8004e0e0 | Match (abs(sin); Color1 warm triple (0x7f/0x7f,0x7f/0x32,0x21/0x10 bases, b=0+pad via s16 store); Color2 uniform 0x5f triple word-replicated; ShineResult (s·0xff>>13)+0x80; write-order swap harmless) | +| UI_WeaponBG_DrawShine | 8004e37c | Match (2×2 mirrored GT4 tiles; w=(u1−u0)·scale>>12, inner edges −(scale>>12); gouraud r0=[2], r1=r2=[1], r3=[0]; layer 3 → Color2; tpage &0xff9f\|(layer−1)·0x20 + code\|2; cursor +0x34) | +| UI_TrackerBG | 8004e660 | Match (same tile layout, flat color FT4; ShineTheta += 0x100; project's derived x2/y1/x3/y3 == retail's explicit stores; the u2-word pad artifact is write-only) | +| UI_DrawDriverIcon | 8004e8d8 | Match (native prim-struct rewrite: Y clamp 0xa5 NTSC (EUR 0xaf variant guarded), cropped bottom V = v0+clampedY−posY on v2 AND v3, byte-arithmetic equal; even retail's word-copy artifact of u3/v3 landing in vertex-2 pad is reproduced deliberately; no bounds check in either) | +| UI_Lerp2D_Angular | 8004eaa8 | Match (sin(frame<<11/5); x swing ±0x14; y = 0x39 + drawn·0x1b + diff·0x1b·frame/5 — project's (x·4)/20 == retail's 0x66666667 magic /5 exactly, negatives included) | +| UI_Lerp2D_HUD | 8004ec18 | Match (end + cur·(start−end)/endFrame, no clamp; traps = div guards) | +| UI_Lerp2D_Linear | 8004ecd4 | Match (start + cur·(end−start)/endFrame with end snap past endFrame) | + +## UI module (part 5) — UI_Clock.c DONE (2026-07-04) — all clean + +| Function | Address | Verdict | +|---|---|---| +| UI_DrawRaceClock | 8004edac | Match (full walkthrough: digit decompose — project's %-forms == retail's subtract-forms incl. ·10/0x3c0 == /0x60 and the 0x66666667 magic %10; 99:59:99 / 9:59:99 clamps with pre-assigned defaults == retail's label fallthrough; header TIME/TIME TRIAL/TOTAL/YOUR TIME with BOTH comma-op flash constructs replicated exactly (0x4004→0x4000 overwrite when timer&2, PERIWINKLE/ORANGE/((timer&2)==0)<<2); digit slots [0/1/3/4/6/7] MMSScc vs [0/2/3/5/6] M:SS:cc with strOffset trick; +8Y in-race vs +0x11X results; lap loop — SaveLapTime passed the DRIVER's current lapIndex (not the loop counter, quirk kept), digits from slot driverID·7+i, flash ((timer>>1)^1)&1 only in results (in-race path overwrites with PERIWINKLE — retail does too), Ln label at baseY+n·8+0x10 RED, results lap number %d + LAP at baseX−charPixWidth[font] both 0x4003, font BIG unless numLaps>3; relic medal pick (NEW_RELIC branch pair over +0x3a/+0x28/+0x16 bits), SILVER=0x16/PAPU_YELLOW=0xe/TROPY_LIGHT_BLUE=0x11 all == retail literals, label/time offsets +0x18/+0x20 vs −0x11/+0x11 with \|0x4000, final time color & 0xbfff strips JUSTIFY_RIGHT; relic digits read the relicTime_* globals fixed last pass — consistent) | +| UI_DrawLimitClock | 8004f894 | Match (originalEventTime−elapsedEventTime; expired → DrawTime(0) + actionsFlagSet\|=0x2000000 for all drivers + MainGameEnd_Initialize unless END_OF_RACE; <0x3840 && even frame → WHITE else DARK_RED(0x1c ✓ enum verified); LNG_LAP=0x18 ✓) | + +## UI module (part 6) — UI_RaceHud.c (first 5 of 6) DONE (2026-07-04) — 2 functions FIXED (4 fixes) + +| Function | Address | Verdict | +|---|---|---| +| UI_BattleDrawHeadArrows | 8004f9d8 | FIXED ×3 (1. gouraud gradient: retail reads ptrColor[PLAYER_BLUE+team] words +0/+4/+8 for r0/r1/r2 — project reused word 0, flattening the arrow (ConfigDrawArrows bug family); 2. distance gate is strict > 0x90000 (equality skipped); 3. retail truncates the size base (0x1000 − d/0xc000) to s16 before the ×3/×7 scaling. Rest matches: skip self/invisible/finished, arrow base 5/3 by player count, s16-truncated position loads, stflg&0x40000 clip skip, prim bounds check with early return, tpage 0xE1000A20 + code 0x32 + semi-trans 0x30xxxxxx, geometry y' = sy+base then −h/−12/−h, OT link len 8) | +| UI_TrackerSelf | 8004fd34 | FIXED (second (lower) chevron triangle y0: retail sh v0,0xa at 0x80050374 = screenPosY−12; project copied the FIRST triangle's (x0,y0) word, making y0 = screenPosY−(h+12) and doubling the chevron upward. Asm also resolved the decompile's anim-load red herring — retail lhu's are raw, the >>8 belongs to the use sites; project's s16 reads + x>>8/x>>7/(y·7)>>12/(y·0xf)>>0xb all match. Verified rest: timer/type/dist per-driver tables, lock-acquire flag 0x4000000 + timer 8/12 reset paths incl. the tw->flags&0x10 hit check, dist = FastSqrt/0x32 via the 0x51EB851F magic, beep 5/10/30 at ≤100/101-200/≥201 exact, PAUSE_ALL mask, warpball lap-distance (node stride 0xc, distToFinish@+6, ×8, +lap wrap) gated <16000, chevron colors (blue/yellow vs orange/cyan quads), OT len 6 links, TrackerBG(ptrIcons[0x2d], sx−(x>>7), sy−(y·0xf>>0xb), transparency 1)) | +| UI_DrawPosSuffix | 8005045c | Match (battle team rank vs driverRank; suffix string table; instBigNum t[2] = driverRank+0x100 — driverRank even in battle, quirk kept) | +| UI_DrawLapCount | 80050528 | Match (lap clamp; 1P/2P LAP label 0x4001 + %d/%d BIG; 3P/4P in-place template digits [0]/[2] SMALL; +8 Y) | +| UI_DrawBattleScores | 80050654 | Match (color table [numPlyr−1][driverID]; points/lives + icons 0x85/0x84; text at +0x25/+4; project folds retail's duplicated DrawLine into one — equivalent; DecalHUD_DrawPolyFT4 args hidden by retail prototype, project arity consistent with the Decal module) | + +## UI module (part 7) — UI_Weapon.c + UI_DrawNum.c + UI_DrawSpeedNeedle DONE (2026-07-04) — 2 functions FIXED (3 fixes) + +| Function | Address | Verdict | +|---|---|---| +| UI_Weapon_DrawSelf | 800507e0 | FIXED (quantity digit: retail sb at 0x80050998 writes s_spacebar[0]=qty+'0' UNCONDITIONALLY in the held-item path — before mask/juiced/flicker, even when qty==0 or the flicker returns; project wrote it only when drawing, leaving stale bytes in the shared buffer. Hoisted. Asm also settled the juiced compare: sltiu at 0x80050a0c — UNSIGNED, items 3-4 only — project was right, decompile's signed rendering was the red herring. Verified rest: mask char set {0,3,6,7} == project's 0xc9 bitmask, Uka 0x32, juiced +0x11, flicker on odd frames, roulette %0xc//%0xe magics with 5→0/8→1/9→3 remaps incl. the cross-branch goto, Lerp cooldown endFrame 5, DrawWeapon args (icon, x, y, primMem, pushBuffer_UI.ptrOT, 1, scale, 1) asm-confirmed) | +| UI_Weapon_DrawBG | 80050af8 | Match (juicedUpCooldown decrement; theta += 0x100; two DrawShine layers 2/3, icon ptrIcons[0x31], per-player pushBuffer[driverID].ptrOT, scaleY = scale·0xd000>>16 (unsigned-vs-signed shift latent-only for positive scales), color 0xff0000) | +| UI_DrawNumWumpa | 80050c20 | Match (1P/2P x + %d; 3P/4P two icon digits from iconGroup[5] with the s8-truncated tens, posX+0xc·i, full ORANGE gradient words — correct here) | +| UI_DrawNumTimebox | 80050e6c | Match ("%2.02d/%ld", +0x14/−10 and +0x21/−0xe offsets) | +| UI_DrawNumRelic | 80050f18 | Match (INC_RELIC 0x1000000 −1 while mid-pickup) | +| UI_DrawNumKey | 80050fc4 | Match (INC_KEY 0x2000000) | +| UI_DrawNumTrophy | 80051070 | Match (INC_TROPHY 0x4000000) | +| UI_DrawNumCrystal | 8005111c | Match ("%2.02d/%ld", numCrystals@0x31) | +| UI_DrawSpeedNeedle | 800511c0 | FIXED ×2 (1. maxScale: retail sums the FP8 stats FIRST then >>8 — (stat+fire)>>8; project's FP8_INT(stat)+FP8_INT(fire) lost the low-byte carry. 2. the 0.625 y-squash idiom: retail adds 0x1ff when negative then sra 9; project kept the +0x1ff but used /512 C division, double-correcting negatives below −511 — needle y off by one pixel through most of the lower arc. Six sites → >>= 9. Verified rest: regimes (speed>stat → arc 0x980..0x700 with raised minScale, else 0xd90..0x980), ·108000/64000 scale maps, angle2=+0x400, both G3 prims' colors (91,91,0)/(50,43,1)/(255,187,0) and white/(156,105,0)/(255,255,0), sin·6/60>>12 widths, center (65,41), OT links len 6, bounds checks with mid-function return) | + +## UI module (part 8) — UI_DrawSpeedBG DONE (2026-07-04) — 1 function FIXED (3 fixes) + +| Function | Address | Verdict | +|---|---|---| +| UI_DrawSpeedBG | 800516ac | FIXED ×3 (1. gauge gradient off by one segment: retail's 20x150/highestJump; VehFire tiers ≥0x280/0x3c0/0x5a0 → param 0/0x80/0x100 — project's restructure equivalent; airborne latch clamp 0x960, timer 0x5a0, voiceline gate 0x480/0x481) | +| UI_JumpMeter_Draw | 80051e24 | FIXED (white frame-fill right edge: retail posX+0xe, project had posX+13. Verified rest: digit split /0x3c0, /0x60 signed decomposition, ·100/0x3c0 hundredths, y −0x2b digits / −0x2d box; wirebox color — project's data.colors[21][0] == colors[BLACK][0] == retail's zeroed local, value-identical; bar fill height ·0x26/0x960 (magic 0x1b4e81b5 == project division), color tiers 0x27f/0x3c0/0x5a0 (0x28ff0000/0x2800ff00/0x2800ffff/0x280000ff), gray bar, 6-word F4 prims + len-5 links, all bounds-checked) | +| UI_DrawSlideMeter | 80052250 | Match (barWidth 0x31, height 7/3 by player count; fill = 0x31 − room·0x31/(maxRoom·32); red default / green while lowWarn·32 < roomLeft — values equal, project comment semantics inverted but code right; black wire box; fill+gray F4 pair via the project's 2-pass loop == retail's unrolled pair) | +| UI_DrawRankedDrivers | 800524c4 | Match (1P: desired-rank seeding (native null guard documented — retail reads through NULL), finished count, 4/2 rows by IS_BOSS sign test, '1'+i digits white/red, damage color — retail's ~s byte == project's 0xFF−s, ±timer decay; stable-slot draw at (0x14, rank·0x1b+0x39) with the no-op curr re-store, transition via Lerp2D_Angular + timer wrap at ≥5, gate desired+1<9, icon MetaDataCharacters[charIDs[i]].iconID. MP: progress X = (len−dist)/(len/0x1d1) with s16-sign pin + behind-start pin (lap 0 & 0x1000000), easing step |Δ|/0xe min 1 with s16-truncated overshoot compares and 400-snap, prev-X stored in the REUSED rankIconsTransitionTimer table, GT4 at (+5, 0x66) scale 0x9d8 trans 1. Warpball: bucket 6, node idx via 0xAAAAAAAB /12 magic (s16-trunc latent), cnt−1<0xff gate, byte nextIndex_forward chain, s64 dot == GTE MVMVA MAC1 (3×s16² fits s32, documented), cn1.dist·8 + dot>>12 mod len·8, skip mod==0, X=(len·8−mod)/(len·8/0x1d1)+5, DrawWeapon(ptrIcons[0xe], …, 0x8aa, 1)) | + +## UI module (part 10) — UI_RenderFrame_Racing DONE (2026-07-04) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| UI_RenderFrame_Racing | 80052f98 | Match — full 0x1300-byte walkthrough, zero fixes. Verified: race-start table seeding from kartSpawnOrder; Triangle map toggle (hudFlags&0x20 clear path); the odd numPlyr=='\0' bot force (kept); ST1 map guard (project already carries the count test + native null guard here); RankedDrivers gate; the whole per-driver chain with every hud-slot short-index mapped to the project's element indices ([0x24]=el9, [0x18]=el6, [0x20]=el8, [0x10]=el4, [0x4c]=el0x13, [0xc]=el3, [0x48]=el0x12, [0x34]=el0xD, [4]=el1, [0x14]=el5, [8]=el2, [0x30]=el0xC, [0x2c]=el0xB, stride 0x50 shorts == +=0x14 elements); backwards-warning blink (lng 0x1d, &0x80, mid−0x1e) + state counter; wumpa/letter/timebox fly-ins goto-for-goto (endFrames 5/10/10, ModifyWumpa gate, letter X offsets +0x1d/+0x3a and T's −1); the ND battle-score dead LIFE_LIMIT branch; PosSuffix trio incl. the <<0x12>>0x10 == <<2 blink flags and both rank-icon paths reading FOUR gradient words (correct here); Weapon_DrawBG pair with the comma-chained second call; turbo-counter FSM label-for-label (bucket 9, object+4 driver, slide 0x2c8→500, "999" cap, 0x4022 == JUSTIFY\|ORANGE_RED (0x22 enum-verified), tab quad 0x3800c8ff/0x380000ff with early return); DrawLimitClock(0xd7,0x68,2); minimap gate + buckets 0/1/2/6 + (500,0xc3)/(0x1b8,0xcd) == project's 500−60/195+10; end-of-race FINISHED(0x1e)/LOSER(0x143) with rank>2); per-player FT4 quads (skip finished/END_OF_RACE, pos = el3 + DecalMP rect − half-viewport, UVs x&0x3f / byte-add, the 4-term tpage pack, gray 0x808080, tag len 9 — project's whole-tag write is AddPrim-overwritten, equivalent), stride 0xa0 == += 0x14) | + +## UI module (part 12) — VsQuip block DONE (2026-07-04) — 1 FIX + +| Function | Address | Verdict | +|---|---|---| +| UI_VsQuipReadDriver | 80054a08 | Match (size 2 → lh signed, 1 → lbu unsigned, 4 → lw, else 0; same dispatch order) | +| UI_VsQuipAssign | 80054a78 | Match (flags&4 leader-only gate; max-weight scan with MixRNG (>>3 &0xff)<0x40 tiebreak; held-quip keep-unless-strictly-higher (equal → RNG >0x3f keeps old) with old-line weight un-negate; claim via weight negate; timer 150 battle / 300 race) | +| UI_VsQuipAssignAll | 80054bfc | Match (asm-confirmed rule tables: race 0x8008664c+0x3a8 and battle 0x80086c0c+0x120 == project's base 0x800864dc offsets 0x170..0x518 / 0x730..0x850 exactly; aging +1 pre-pass stride 8; leader scan comma-decoded — score>best → second=best/leader=drv, == → leader=NULL/second=best, < → no-op; POINT_LIMIT 0x4000 → pointsPerTeam[teamID] else numLives lw, perDriverScore[lbu driverID]; numLaps lb SIGNED + abs == project (s8) cast; ruleType cases 0/1/3/4/5/6/7/8/9 all matched incl. case 3's byte-walk of numTimesAttackedByPlayer with rival index → characterID carried function-wide, case 4/5 band logic via the cmpFlag comma chains, case 7 leader/tied-second paths off secondScore; flags&0xc mid-loop assign + unconditional final assign; case 9 default-assign passes 0) | +| UI_VsQuipDrawAll | 800550f4 | FIXED (conjoined-quip sprintf target: retail asm 0x800551e8 addiu a0,sp,0x20 — a 128-byte STACK buffer; project passed raw PSX scratchpad 0x1f800000 — retail-inaccurate and an unmapped-address crash natively. Verified rest: pressX&2 + NULL-quip skips; printArr[2]/[3] zeroed via sw zero,4(v1); configBits&1 at +2; MetaDataCharacters base 0x80086d84 stride 0x10, name_LNG_long lh at +4; x+w/2 / y+h/8 from tileView stride 0x110 == project pushBuffer[i].rect; DrawQuip(0, 3, 0xffff8000, 4) — asm li v0,-0x8000 confirms project's 0xffff8000 over the decompile's 0x8000 rendering) | +| UI_VsWaitForPressX | 800552a4 | Match (left/right mask 4\|8 == BTN_LEFT\|BTN_RIGHT; cross/start 0x1010 == BTN_CROSS_one\|BTN_START gated timer<0x78 — && order swapped, no side effects; YOU HIT 0x157 / HIT YOU 0x158 at x+w/2, y+0x23 font 3; stats "p%d:%2.02d" into a stack char[8] == retail auStack_58 (the plate's "scratchpad 0x1f800000" claim contradicts its own decompile — stack wins); numTimesAttackingPlayer[j]/AttackedByPlayer[j] lbu by &1 view; pos tables {0x55,0x35}{0xaa,0x35}{0x55,0x43}{0xaa,0x43} == s_battleStatsPos3P4P, 2P == textPos2P; color (s16)teamID+0x18U\|0x8000 font 2; done branch: clear box over viewport rect (native 4-arg DrawClearBox absorbs retail's 5th &primMem arg; startPlusFour == uiOT), Square reopen 0x8000 == BTN_SQUARE_two; all-ready → timer=0 + 4-byte boolPressX clear) | + +## UI module (part 13) — RaceFlow block DONE (2026-07-05) — 1 FIX + +| Function | Address | Verdict | +|---|---|---| +| UI_RaceEnd_GetDriverClock | 8005572c | Match (0x40000 latch; distanceDriven·100/timeElapsed; missiles<4 → 0xffffffff == retail −1; ((u8)attacking<<12)/(u8)missiles signed-positive ≡ retail unsigned; 8-byte lbu attacked sum; rank==0 → TimeWinningDriverSpentLastPlace) | +| UI_RaceStart_IntroText1P | 80055840 | Match (mode tree: RELIC 0x4000000→0xb8, CRYSTAL 0x8000000→0xbe, ADV_CUP 0x10000000→advCupStringIndex[cupID], gameMode2&0x10→arcadeVsCupStringIndex[cupID], ARCADE 0x400000→0x4e, TIME_TRIAL 0x20000→0x4d comma-form, gameMode1≥0→0xb7/0x176 by gameMode2&8, boss→lng_challenge[bossID]; transition 0x1e−frame when <0x1f; y offsets +7/−6/+0xb/−0x17; bars h=2 at ±0x1c/−0x1e then &0xff000000 h=0x1e at −trans/+h+trans−0x1e; (JUSTIFY_CENTER\|ORANGE)==0x8000. Latent-only: retail lhu table read vs project s16 — retail's own use site sign-extends via <<16>>14, indices always <0x8000) | +| UI_RaceEnd_MenuProc | 80055c90 | FIXED (case 10 "Change Setup" missing Loading.OnBegin.AddBitsConfig0 \|= 0x2000: retail asm 0x80055ff4 ori v0,v0,0x2000; sw v0,-0x2f00(at) → 0x8008d100 is the shared tail reached by options 5/6/10 alike before RequestLoad(0x27); project cases 5/6 set it, case 10 omitted it. Verified rest: phase gate drawStyle&~0x100 / \|=0x100 for >2 players; Hide unless option 9; framesSinceRaceEnded=0 + numIconsEOR=1 unconditional; case 4 RESTART (hudFlags&0xfe, IsFullyOffScreen()==1→BeginTransition(1), stage=−5, howl_StopAudio(1,0,0), PLAYER_GHOST_BEAT=1 gate asm-confirmed, boolReplayHumanGhost=1, memcpy 0x3e00 documented, characterIDs[1] from tape header +6, boolGhostsDrawing=0); case 9 (0x3f9, ToggleMode(0x31), ptrActiveMenu=&data.menuGhostSelection == store of 0x80085bb4 to 0x8008d924); case 3 QUIT correctly skips the 0x2000 tail; case 0xd bit algebra (CRYSTAL\|RELIC 0xc000000, adv +0x10000000 → load 0x19; boss +0x80000000 + AddBitsConfig8\|=1 → prevLEV); case 0xc9 menuReadyToPass\|=1) | + +## UI module (part 14) — CupStandings block DONE (2026-07-05) — 1 FIX + +| Function | Address | Verdict | +|---|---|---| +| UI_CupStandings_FinalizeCupRanks | 8005607c | Match (numDrivers cap 4; tied-prefix scan on cup.points[cupPos[i]] vs cupPos[0]; selection-sort the tied prefix by driverRank, swap cupPos entries; `<<16>>0xe` == ×4 int-stride array index; (u16)/(s16) casts on cupPos read/write are no-ops for driver indices; selectedRankSlot persists across outer iters == retail iVar9 not reset) | +| UI_CupStandings_UpdateCupRanks | 80056220 | Match (selection-sort all drivers by cup.points desc via placed-bitmask; `>=` compare, `(s16)`/`(u16)` extension of bestScore equivalent (points small/non-neg); assignedMask&0xff>>idx harmless (only bits 0-7 set); folded db.drawEnv.ofs+0xf30 base == cup.points[i] per decompiler note) | +| UI_CupStandings_InputAndDraw | 800562fc | FIXED (timesLostCupRace indexed by cupID vs retail cup.trackIndex: 0x800570c0 zeroes trackIndex, then win 0x800571e4/0x800571f4 (lw v0,0x1e5c; sb zero,0x42(v0)) and lose 0x800571fc-0x80057220 (lb/sb 0x42(v1)) both index by trackIndex → ALWAYS slot 0, not [cupID]; project diverged for adventure cups 1-3. Verified rest: MP transition gate refactor (SetCanDraw-before-return reorder observably equal); framesSinceRaceEnded inc gate; Cross/Circle skip (0x3c, 0x50 mask); slideY0/1 by menuReadyToPass&4; both title/row Lerp2D arg sets incl. t=frames-10i; title index tree (metaDataLEV.name_LNG / AdvCups stride 8 / ArcadeCups stride 0x12, trackIndex==3→0x22e FINAL); DrawLine flag 0xffff8000 asm-confirmed (li v0,-0x8000, sw 0x10(sp)) ×4; icon iconID<<16>>0xe ×4; points award {9,6,3,1,0,0,0,0}; adventure levelID=cupID+0x64 (ADV_CUP), STATIC_GEM=0x5f, gem bit cupID+0x6a, boss-char bit cupID+7; arcade CupCompletion_prev/curr = AllCupsGate/CupUnlock bases, battle-map all-4 check. LATENT (observably equal, NOT changed): (a) row-spacing hardcoded 0x60 vs retail (rect.w-0x80)>>2 — rect.w always 0x200 via PushBuffer_Init(pb,0,1); (b) award loop capped at 4 vs retail's 8/numPlyr — points[4..7]=0 makes extras no-ops; (c) display i<4 guard + manual "+" build vs sprintf("+%d") — single-digit only; (d) arcade curr-completion bit set conditionally vs retail's unconditional 0x80056f88 — prev-gate only set post-curr, so never observable; (e) confetti.unk2==nParticleRampStep, numDrivers unsCast, OnBegin==OnComplete namings) | + +## Vector module — SpecLight + BakeMatrixTable DONE (2026-07-05) — 1 FIX + +| Function | Address | Verdict | +|---|---|---| +| Vector_SpecLightSpin2D | 800572d0 | Match (ConvertRotToMatrix_Transpose asm-confirmed jal 0x8006c378; RotMatrixMul helper == gte_SetRotMatrix/ldv0/rtv0/stlvnl (nl=MAC1/2/3, matches retail stMAC1/2/3, (s16) truncation not IR saturation); unk53=(char)MAC1, reflectionRGBA=full u32 MAC3; view {0,0,0x1000} 2nd transform; half=lightLocal+viewLocal (s16 sums) then Normalize; idpp[i].halfVector stride 0x88 offset 0xf4/0xf6/0xf8) | +| Vector_SpecLightSpin3D | 8005741c | Match w/ documented ND-quirk divergence (per-player LIGHT MATRIX double-advances: asm 0x80057484 addu a1,a1,s4 / 0x80057488 addu v0,a1,s3 with BOTH s3,s4 += 0x110 at back-edge 0x80057684 → matrix strides 0x220 = pushBuffer[2*i].matrix_Camera, while pos strides 0x110; agrees with project's pushBuffer[i] only at player 0. NOT reproduced — the 0x220 stride reads OOB pushBuffers for 3-4P in the native layout; NOTE left in code. Rest Match: reflectionRGBA=(u16)MAC3 here (vs Spin2D's full u32), lightCamera→lightLocal double light-matrix mul, view=obj.matrix.t−pb->pos normalized, half sum+normalize, idpp stride 0x88) | +| Vector_SpecLightNoSpin3D | 800576b8 | FIXED (retail asm 0x800576dc jal 0x8006c378 = ConvertRotToMatrix_Transpose, project called non-transpose ConvertRotToMatrix 0x8006c2a4 → wrong light matrix / wrong shine on non-spinning objects. Rest Match: single light transform of pNormal before loop, unk53=(char)MAC1 + reflectionRGBA=(u16)MAC3, per-player view=obj−pos normalized then light-mul, half sum+normalize, idpp stride 0x88. This one's light matrix is fixed (no per-player pushBuffer matrix), so no Spin3D-style stride issue) | +| Vector_BakeMatrixTable | 80057884 | Match (do-once latch g_aGpuDmaQueue[5].pDataPtr low byte == sdata->matrixTableBaked. Step1 PrepareBlastedFrames on bakedGteMath[6] (tumble): entry+0xc=angle2000=(i<<0xd)/count, +0x8=−sin(angle3000)/7, +0x14=(sin2000·6)/0x28+0x1000, +0x10=Div4TowardZero(sin2000)+0x1000 — project correctly drops retail's dead first +0x10 store ((sin2000·6)/0x28) that retail overwrites; Div4TowardZero == (val<0?val+3:val)>>2 signed round-to-zero. Step2 BakeRotScaleEntries: 0x14 {ptr,count} descriptors, ConvertRotToMatrix(non-transpose) entry+8, scale diag from +0x10/+0x12/+0x14, MatrixRotate==MATH_MatrixMultiplication(dest,scale,rot). Step3 BakeBlastedOffsets: software reimpl of GTE rt of V={0,−0x2000,0} + T={0,0x2000,0} → entry+0/2/4 = (m[*][1]·−0x2000)>>12 (+0x2000 on y); values fit s16 so no IR-saturation divergence) | + +## Vehicle module — VehAfterColl + VehBirth_TeleportSelf DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehAfterColl_GetSurface | 80057c44 | Match (scrubId unsigned bound `>6`→[0], stride 0x10 = MetaDataScrub[min(id,·)]; project u32 param + `<7` guard equivalent to retail `6<(uint)scrubId`) | +| VehAfterColl_GetTerrain | 80057c68 | Match (terrainType unsigned bound `>0x14`(TERRAIN_SLOWDIRT)→[0], stride 0x40; project u8 param `<=0x14` == retail `0x14<(uint)`; struct Terrain 0x40) | +| VehBirth_TeleportSelf | 80057c8c | Match — full ~0xc0c-byte walkthrough, 0 fixes. Scratchpad setup: only 3 stores (sh 0x22 searchFlags 0/2, sw 0x24 quadFlagsWanted 0x3000, sw 0x28 quadFlagsIgnored 0 — the u32 at 0x28 covers what retail's decompile split into field6/skipCollision; asm-confirmed 0x80057cf8-0x80057d18). COLL_SEARCH_HIGH_LOD=2 (numPlyr<3). Spawn-pos branch (bit0): FindDoor5 (modelID@0x3c==STATIC_DOOR 0x7a + name "door#5"+zeros, InstDef stride 0x40) → matchedInst; ShouldSpawnOutsideBoss (STATIC_TROPHY + all-4 advHubTrackIDs[(levelID−0x1a)·4+i] trophy bits(+6) set + boss-key bit clear); SPAWN_AT_BOSS(=1) forces spawnType2; door pos = pos + ScaleTrig(cos/sin·800>>0xc) + (trig·0x200>>0xc) with per-term (short) no-ops (values fit); spawnType2[1]/[0], startline DriverSpawn[kartSpawnOrder[id]] +0x80 on Y, warppad AH_WarpPad_GetSpawnPosRot. posCurr x/z = sign_ext(hit)<<8 (retail's `(ushort)<<0x10>>8` idiom == CTR_MipsSll), y=(hit+yOffset)<<8, quadBlockHeight=hit.y<<8. Rot branch (bit0): boss CITADEL(0x1d)+0x400 / GEM(0x19)&&prevLEV==SKYWAY(7) skip / else +0x800, all &0xfff; startline (rot[1]+0x400)&0xfff; ShouldUseStartlineInAdv extra prevLEV conds redundant vs warppadRot==NULL invariant; gameMode2&=~3. State reset order differs but same fields/values; door→speed 0xa00; actionsFlagSet&=0xfff7ffbf (ACTION_AIRBORNE 0x40 \| ACTION_HIGH_JUMP 0x80000). bit1: 13 funcPtrs zeroed, CAM_StartOfRace, funcThTick=(cutscene/menu?NullThread:0), funcPtrs[0]=(adv?Driving:RevEngine)_Init; item cheat MASK7/TURBO0/BOMB1/NONE0xf + numHeldItems 9/0; CHEAT_WUMPA→99, CHEAT_INVISIBLE (iddrivers ((i<<0x10)>>0xe = int-stride), skip NULL; modelIndex==DYNAMIC_ROBOT_CAR(0x3f)→BOTS_GotoStartingLine else TeleportSelf(d, flags\|1, 0); gGT param ignored, reloaded from global) | +| VehBirth_GetModelByName | 80058948 | Match (3-slot driverModelExtras scan then NULL-terminated PLYROBJECTLIST; 16-byte name compare as 4 words; project char* searchName read as 4 u32 == retail int* name) | +| VehBirth_SetConsts | 80058a60 | Match (65-entry metaPhys stride 0x1c: size@8, offset@4, value[engineID]@0xc stride 4; engineID=MetaDataCharacters[characterIDs[driverID]].engineID; dst=(u8*)driver+offset (retail funcPtrs+off−0x54 fold); size 1/2/4 byte-splat stores, project's (u8)(raw>>n) == retail sized stores, other sizes no-op; check order differs, per-size outcome identical) | +| VehBirth_EngineAudio_AllPlayers | 80058ba4 | Match (walk threadBuckets[PLAYER=0] sibling chain; driverID@+0x4a; EngineAudio_InitOnce(engineID*4+driverID, 0x8080); retail's &0xffff on arg1 is a no-op — value always <36) | +| VehBirth_NullThread | 80058c44 | Match (empty; project's Thread* param vs decompile void — ignored callback sig) | +| VehBirth_TireSprites | 80058c4c | Match (wheelSize 0xccc/0 (Oxide & !MAIN_MENU), wheelSprites=iconGroup[0]->icons, heldItemID 0xf, BattleHUD.teamID=driverID, tireColor 0x2e808080, tireColorCycleTimer 0xa00, engineSoundMode 2, AxisAngle1/2.y=0x1000, reserved_0x412 0x600, numFramesSpentSteering 10000 (asm sh 0x3e6 delay slot), terrainMeta1=GetTerrain(TERRAIN_NONE=10)@0x358, BattleHUD.numLives. DECOMPILE MISLED: rendered "unk_4F0_4F8[0,1,4,5]=−1" (4 writes) but asm 0x80058d10/0x80058d14 has only TWO sh −1 at 0x4f0/0x4f4 == project quip1/quip3=0xffff exactly) | +| VehBirth_NonGhost | 80058d2c | Match (thread consts modelIndex=DYNAMIC_PLAYER, HitRadius 0x40, HitRadiusSq 0x1000, +0x3e=0x40, +0x3c/+0x40=0; charID=characterIDs[0] or [index] by gameMode1&MAIN_MENU(0x2000); GetModelByName→INSTANCE_Birth3D→t->inst; wake modelPtr[STATIC_WAKE=0x43] flags\|=0x90 (HIDE_MODEL 0x80\|ANIM_LOOP 0x10); indexcurrent & current+step<=target→current+step, else target. Project `if(val>desired){val-=speed;...}else{val+=speed;...}` equivalent for speed>=0. LATENT edge only: val==desired && speed<0 → retail desired vs project desired+speed; unreachable — step is a non-negative rate) | +| VehCalc_MapToRange | 80058f9c | Match (val<=oldMin→newMin, val>=oldMax→newMax (retail `oldMin in interp range) | +| VehCalc_SteerAccel | 8005900c | Match (3-seg ramp: framesstage2First+stage2Len→MapToRange(f,stage3Start,stage4First,max,0) stage3/4; else hold max stage2; u_int vs int params moot for non-neg frames/steer) | +| VehCalc_FastSqrt | 80059070 | Match (base-4 digit-by-digit isqrt; testBit=1<<(shift&0x1f) scaled up while >-rootBitIdx if rootBitIdx<0)+lastApprox+testBit, accept if <=n; all shifts/negation == CTR_MipsNegLo/SubLo, u32 throughout) | + +## Vehicle module — VehEmitter DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehEmitter_Exhaust | 80059100 | Match (skip if invisibleTimer!=0 or HIDE_MODEL; LOD emitter 1P High/2P Med/3-4P or ROBOT_CAR Low — retail seeds High-down, project seeds Low-up, same per case; underwater SPLIT_LINE + (posCurr.y−posPrev.y)+driverY<0x100 → type7 Water+funcPtr; axis pos+=Δ vel=posPrev; turbo glow flagsSetColor&~0x60\|0x40 when revving+revState==1 or meterLeft in [0x81,(warn+2)*0x20]) | +| VehEmitter_Sparks_Ground | 80059344 | Match (3 GTE-rotated dirs outX{0x1800,0,0}/outZ{0,0,-0x1800}/outZ2{0,0,-0x200} — order differs, independent rtv0 same result; 10× spark: rng=RNG&0x7ff sign-flip on bit0 (==raw&1), vel+=outZ2+(s16)((rng·outX)>>12) [shift-type moot after (s16)], pos+=outZ+vel; driverInst/cOtDepthNear) | +| VehEmitter_Terrain_Ground | 80059558 | Match (gate TOUCH_GROUND & !accelPrev & (\|fireSpeed\|>=0x300 or \|speedApprox\|>=0x300); 4 tires if DRIFTING else 2; per-tire GTE immediates decode to terrainEmitterPos[tire-1] {±0x1e,0xa,-0x14/0x28}; pos+=rotated·0x100, vel=rotate(vel)) | +| VehEmitter_Sparks_Wall | 80059780 | Match (rumble if (fireSpeed!=0\|\|\|spd\|>0x200)&&timeAgainstWall<0x1c2, else reset; \|spd\|<=0x200 return; TireL{0xde00,0xa00,z}/TireR{0x2200,0xa00,z} z=0x2800 fwd/-0x1400 rev; rotate both, compress to 3x2 light matrix, llv0 the wall-delta to pick nearer tire (MAC10x1600 ShockForce1); TerrainEffects (MudSplash TERRAIN_MUD 0xe & \|speed\|>0x500 count 1/10; jump-landing Sparks_Ground gate; em_OddFrame/EvenFrame Terrain_Ground; wall-rub Sparks_Wall + engineVol±0x14 clamp[0,0xff]/wallSound 0x14/−1, driverAudioPtrs[2], ENGINE_ECHO 0x10000); FORCE_SKIDMARKS→actionsFlag\|=0x1800; matrixArray 1→\|=0x800 &~0x1000; SkidmarkAudio (skidSound/newSoundID, GTE skidmark projection color+2, lateral/width=(trig·15/10)>>12, x0/y0/z0/color/flags/x1/y1/z1 @0x40-frame/0x10-tire ring); ShouldSkipExhaust (ROBOT_CAR timer&3!=id&3; else revState==2 / numPlyr throttle / turbo-band+SearchForModel(STATIC_TURBO_EFFECT 0x2c); failedBoostExhaustTimer dec) + ExhaustPair (2 tailpipe GTE positions → Exhaust×2); burn/invisible alphaScale 0x1000; airborne clear; JogCon2(0x27,0)/JogCon1(0x12/0x22,0x20)/JogCon2 steering feedback. kartState{4,5}==REVVING/MASK_GRABBED asm-confirmed) | + +## Vehicle module — VehFire DONE (2026-07-05) — 1 FIX + +| Function | Address | Verdict | +|---|---|---| +| VehFire_Audio | 8005ab24 | Match (cooldown gate; 3 bands: speed>=0x80 vol0xff/dist0x6c + Voiceline(0x10,charID,0x10), >=0x40 vol0xc0/dist0x80, else vol0x80/dist0x94; echo bit 0x1000000 if ENGINE_ECHO; OtherFX_Play_LowLevel(0xd,1,vol\|dist\|0x80); cooldown=0xf0) | +| VehFire_Increment | 8005abfc | FIXED (INSTANCE_BirthWithThread name arg: retail passes s_turbo1_8008d61c ("turbo1"), project passed NULL — forwarded to PROC_BirthWithObject debug name + INSTANCE_Birth3D instance name; sibling turboInst2 already used s_turbo2, oversight. Rest Match: turbo-pad&accelPrev gate; P1 GhostTape_WriteBoosts; kartState returns SPINNING(3=DRIFTING2\|CRASHING1)/MASK_GRABBED(5)/BLASTED(6) asm+enum-confirmed; actionsFlag &~0x80\|0x200000; threadBuckets[TURBO=9] search Turbo->driver@+4; existing-turbo: THREAD_FLAG_DEAD 0x800 clear, turbo-pad numTurbos++ only if prevFrame&0x200000==0 else flags\|=0x1000080+cooldown0x60+numTurbos++, fireDisappearCountdown=-1, alphaScale=0, DYNAMIC_PLAYER audio gate; new-turbo: numTurbos=1, BirthWithThread(0x2c,name,SMALL=0x300,TURBO,ThTick,0x10,0), flags\|=0x1000 DISABLE_COLL, driver/cooldown0, count type&2?2:-1, DYNAMIC_PLAYER+!CRASHING audio, ThDestroy, Birth3D turboInst2, instFlags 1P 0x3040080/MP 0x1040080 (retail instFlags\|HIDE_MODEL == project addFlags\|0x1040080), fireAnimIndex0; fireSpeedCap=(fireLevel*(Sacred-Single)>>8)+Single gated by reserves==0\|\|cap>6)+5,8); TURBO_ITEM→flag0x200; reserves by type type&1 turbo-pad delta / type&0x10==0 add / else super-engine set; DYNAMIC_PLAYER cameraDC flags\|=0x80 + ShockForce1(8,0x7f)) | + +## Vehicle module — VehFrameInst getters DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehFrameInst_GetStartFrame | 8005b0c4 | Match (animIndex 0→numFrames>>1 (midpoint), 4→numFrames-1 (end), default→0 (start); project switch == retail if-chain; retail param named "midpoint" is animIndex) | +| VehFrameInst_GetNumAnimFrames | 8005b0f4 | Match (guards: model!=NULL, numHeaders>0, headers!=NULL, animIndexnumFrames@0x10 (u16) & 0x7fff; retail's folded &ptrAnimations+2 / &animtex+2 == numAnimations / ptrAnimations fields; u32 return vs int moot, value 0..0x7fff) | + +## Vehicle module — VehFrameProc (anim drivers) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehFrameProc_Driving | 8005b178 | Match — full state machine. desiredAnim: 0 steer default, 1 reverse (fireSpeed<0 & speedApprox<1), 3 jump (jumpHeightCurr>0x600 \|\| animIndex==3, AND posCurr.y−quadBlockHeight>0x8000), skipped if instTntRecv or WARP_PAD. Transition (desired!=cur): startFrame = cur==2?GetNumAnimFrames(2)−1:GetStartFrame(cur); if animFrame!=startFrame ease InterpBySpeed(speed cur==0?6:cur==2?1+matrixIndex=frame:2), then anims 2/3 (animIndex−2<2) matrixIndex=frame + clear on 0; if at startFrame switch anim + GetStartFrame + clear matrix. Per-anim: 0 steer targetFrame=numFrames>>1, burnTimer[1,0x1df]→jitter (((bt>>5)%5)<<2)−8 (retail <<16>>14==<<2) + SpawnBurnSmoke(iconGroup[1], emSet_BurnSmoke), else MapToRange(−turnState, ±TurnRate or ±0x40 if accelPrev(&8), 0..numFrames−1) then InterpBySpeed(,1,); 3 jump InterpBySpeed(,1,numFrames−1), skip if MASK_GRABBED, matrixArray Penta(charID)→Coco/FakeCrash→Crash/Oxide→7/else charID+7 + matrixIndex=frame; else(1) InterpBySpeed(,1,numFrames−1) | +| VehFrameProc_Spinning | 8005b510 | Match (GetNumAnimFrames gate; animIndex!=0: startFrame, anims 2/3 clear matrix, at startFrame fall back to anim0 (if frames), if still !=0 InterpBySpeed(,4,startFrame); anim0: spinDir(KartStates union == Drifting.driftBoostTimeMS)>=0?numFrames−1:0, InterpBySpeed(,4,target)) | +| VehFrameProc_LastSpin | 8005b5fc | Match (animIndex!=0 → delegate Spinning; else anim0 GetNumAnimFrames gate, target=frame default, turnAngleCurr>0 && turnAngleLerpVel<0 → numFrames−1, turnAngleCurr<0 && turnAngleLerpVel>0 → 0, InterpBySpeed(,3,target); nTurnAngleCurr/unk_LerpToForwards field names) | + +## Vehicle module — VehGroundShadow DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehGroundShadow_Subset1 | 8005b6b8 | Match (icon NULL→false; project memcpy(texLayout) == retail field-by-field copy of u0/v0/clut/u1/v1/tpage/u2/v2/u3/v3; tpage=tpage&0xff9f\|0x40 (retail's packed {u1,v1,tpage} & 0xff9fffff \| 0x400000 == same tpage mask)) | +| VehGroundShadow_Main | 8005b720 | Match — full ~0x400 GTE/scratchpad walkthrough, 0 fixes. VehGroundShadowEntry struct (0x28) EXACTLY matches retail scratchpad table (local[3][3]@0, state@0x12, depthBias@0x13, driver@0x14, inst@0x18, idppFlags[4]@0x1c, pos[3]@0x20, instFlags@0x26). Setup: Subset1×2 (scratchpad 0x224/0x230), prim +0x140 < guardEnd, trans vec 0. BuildEntry: pos=(posCurr.x/z>>8), pos[1]=(quadBlockHeight>>8)+3, idppFlags[player]@idpp+0xb8 stride 0x88, depthBias=(SPLIT_LINE 0x2000?Far:Near)+1, sentinel entries[8].driver=0. Per-player: pushBuffer stride 0x110, GTE OFX/OFY=rect.w/h<<15 (==SetGeomOffset(/2)), H=distanceToScreen, rot=matrix_ViewProj, isLargeGeom=dist>0x100. Per-driver guards: driver NULL→break, state==-1 skip, HIDE_MODEL 0x80→state=-1, DRAW_SUCCESSFUL 0x40 gate; diff=(u16)pos-(u16)cam, scaled=(s16)(diff*4) (retail x*0x40000>>0x10 idiom), small-screen bounds ±0x1771/-0x1770; groundDistance=MAC3>>2 >=-0x34; color 0x2e1f1f1f large / else <0x180→0x1f else fade=(0x200-d)*0x1f (+0x7f if neg)>>7 skip if<1, 0x2e000000\|rgb. TransformLocalAxes (state==0): height=0x100-((posCurr.y-quadBlockHeight)>>8) range[1,0x109] clamp 0x100, RotAxisAngle(AxisAngle3_normalVec@0x370, rotCurr.y@0x2ee), light matrix, localX/Z0/Z1=(h*0x28/0x29/0x34)>>6, 3-iter llv0→local[i], state=1. BuildProjectionPoints: all 9 verified base±local combos (pt0 base, pt1 base-l0-l1, pt2 base-l1, pt3 base+l0-l1, pt4 base+l0, pt5 base+l2+l0, pt6 base+l2, pt7 base+l2-l0, pt8 base-l0). EmitQuad: quadPointIndex {8,0,1,2}/{8,0,7,6}/{4,0,3,2}/{4,0,5,6} asm-confirmed, tex alternate by parity (0x224/0x230), depthIndex=(depth>>8)+depthBias clamp[0,0x3ff], tag \|0x09000000 OT insert) | + +## Vehicle module — VehGroundSkids getters DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehGroundSkids_Subset1 | 8005c120 | Match (POLY_GT4 skidmark quad: prim+1<=guardEnd gate; gradient color v0/v1=scratch+0x1c top, v2/v3=scratch+0x20 bottom (WriteColorCode incl. code/pad byte); XY v0/v1=currXY[0/1], v2/v3=prevXY[0/1]; skidmark icon ptrIcons[0x2f] u0/v0/clut/u2/v2/u3/v3; tpage (u1v1tpage&0xff9fffff)\|(scratch+0x24&1?0x600000:0x400000); OT link (scratch+0x18 pushbuffer)->ptrOT@0xf4 [depth>>6], tag\|0x0c000000, ptr24) | +| VehGroundSkids_Subset2 | 8005c278 | Match (out[i] = (v[i] − origin)<<2 for 3 verts xyz; origin from scratch+0xb8/0xbc/0xc0 (short idx 0x5c/0x5e/0x60); project's unsigned lhu ScaleRelative faithful to asm (lhu/subu/sll/sh), s16 result identical to decompile's signed rendering since low-16 of (val−origin)<<2 is extension-independent) | + +## Vehicle module — VehGroundSkids_Main DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehGroundSkids_Main | 8005c354 | Match — full ~0x6d0 GTE/scratchpad walkthrough, 0 fixes (project splits into InitPoint/IntensityFromDepth/ColorWord/Abs/ProjectFrame/TryEmitSegment + Subset1/2). Setup: SetGeomOffset(rect.w/h>>1) (retail <<16>>17), H=distanceToScreen, origin@scratch+0xb8=matrix_Camera.t[0..2], SetRotMatrix(matrix_ViewProj), trans=0, scratch+0x18=pushbuffer. Driver loop: siblingThread chain, skidmarkEnableFlags@0x2c4>0xf, frameIndex=(skidmarkFrameIndex@0xc3−1)&7, skidmarks@0xc4 stride 0x40. InitPoint: (frame[0].xyz−origin)<<2 (unsigned subu, project note), \|v\|>=0x1771 reject, fits s16. IntensityFromDepth: MAC3>>2<0x180→0x7f else LZCS/LZCR(reg30/31) shift=0x1a−LZCR clamp0, 0x7f>>shift, <0x10→−1(skip). ColorWord 0x3e000000\|rgb. ProjectFrame: 9 pts (frame[0..7]+frame[0]) via 3×rtpt, project non-pipelined == retail pipelined CalcLocalVerts/stsxy3c interleave. TryEmitSegment bits 1/2/4/8→ptIdx 0/2/4/6: gate flags&prevFlags&bit, currDepth[i]/[i+1]>0x20 && prevDepth[i]/[i+1]>0x20, scratch+0x24=frameBase[ptIdx*8+7] (0xcb/db/eb/fb), depth=(currDepth[ptIdx]>>2)+(frameBase[ptIdx*8+6]<<6) (0xca/da/ea/fa), EmitQuad(currXY[ptIdx], prevXY[ptIdx]). Segment loop: currFlags=flags, emit if &0xf, swap curr/prev XY+depth, frameIdx+1&7; first-seg 0xffffffff→bottom=top prevFlags=0xf flags kept, else prevFlags=currFlags flags>>=4 fade=(color&0xff)>>1 bottom=oldtop top=ColorWord(fade); exit flags==0 or bottom==0. Project curr/prev ping-pong == retail 5-buffer rotation, same connecting quads) | + +## Vehicle module — VehLap_UpdateProgress DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehLap_UpdateProgress | 8005ca24 | Match (checkpointIndex: human lastValid->checkpointIndex skip if 0xff, AI botData.ai_quadblock_checkpointIndex; gate cnt_restart_points-1<0xff && idx>=0; nodeCurr[idx]→nodeNext[nextIndex_forward]→nodeNextNext; trackDir=normalize(nodeNext.pos−nodeNextNext.pos) unsigned subu. GTE trick: CTC2 reg0={deltaX,deltaY}=R11R12, reg1={deltaZ, m[0][2]>>5}=R13R21, reg2={m[1][2]>>5, m[2][2]>>5}=R22R23 (retail <<16>>21==>>5), row1=posDelta(posCurr>>8−nodeNext.pos), row2=matrixMovingDir col2 facing; ldv0(trackDir), mvmva(0,0,0,3,0) → MAC1=projection, MAC2=facingDot. distanceToFinish_curr=(nodeNext.distToFinish<<3 + projection>>0xc) %(signed) (nodes[0].distToFinish<<3). ACTION_DRIVING_WRONG_WAY 0x100 set when facingDot>=0x5a801 (trackDir backward → alignment=wrong-way). Alt-branch: ACTION_CHECKPOINT_BRANCH_PENDING 0x8000000 && currentIndex!=idx → branchChoiceIndex=idx + clear; nextIndex_left!=0xff → set; currentIndex=idx (checkpoint struct == retail pLapCheckpointState[0]/[1]). Signed modulo per project note) | + +## Vehicle module — VehPhysCrash (part 1) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehPhysCrash_ConvertVecToSpeed | 8005cd1c | Match (speed=FastSqrt(x²+y²+z²)>>8; axisRotationY=ratan2(y<<8, FastSqrt(x²+z²)); axisRotationX=ratan2(x,z); projOnUpAxis=dot(vel, matrixMovingDir col1 m[][1])>>0xc, proj=up·scalar>>0xc, jumpHeightCurr=FastSqrt(\|proj\|²)>>8 signed by projScalar<0; residual=vel−proj, speedApprox=FastSqrt(\|residual\|²)>>8 signed by dot(residual, forward col2 m[][2])<0. LengthSq2/3/Dot3 helpers faithful; MipsMulLo low-32 wrap == retail int *) | +| VehPhysCrash_BounceSelf | 8005cf64 | Match (signedDist=dot(pos−origin, normal)>>0xc; side==0 bounce if <0, else bounce if >0, else return; vehicleCollisionImpactStrength(=retail g_nCrashMaxPenetration)=max(\|signedDist\|); pos[i]=(diff − Div6Shift9(signedDist·normal[i])) + origin[i] where Div6Shift9(v)=(v·0x2aaaaaab>>32)>>9 − (v>>31) == signed v/0xc00; Y clamp: if oldY0x3200 → 0x3200. Project returns int 0, retail void — harmless) | + +## Vehicle module — VehPhysCrash (part 2) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehPhysCrash_AI | 8005d0d0 | Match (botCrashNavRot=botNavFrame->rot[i]<<4 (u8 rot, unsigned per asm); ConvertRotToMatrix into dataLibFiller (retail globals 0x8009ae28/ae38); forward=matrix col2 m[][2]>>4; botSpeed=dot(forward,vel)>>8→aiPhysics.speedLinear; accel.x=vel.x−(forward[0]·botSpeed>>8), accel.z similar (accel.y untouched); botFlags\|=BOT_FLAG_FREE_PHYSICS 8) | +| VehPhysCrash_Attack | 8005d218 | Match (skip if driver1 MASK_WEAPON 0x800000; (1) driver2 masked → pendingDamageType 2, reason 6 ATTACK_CRASH, attacker; (2) driver2 bubble & driver1 none → pop Shield.flags\|=8, reason 0 ATTACK_NONE, extra SFX 0x4f if bubblePop; (3) impactStrength>0xa00 && driver2 reserves & TURBO_ITEM 0x200 & driver1 none → forcedJumpType 2, type 3, reason 5 ATTACK_SQUISH. crash SFX (ENGINE_ECHO bit16) + Voiceline 1 gated canPlayFeedback && !BLASTED && invincibleTimer==0; returns canPlayFeedback) | +| VehPhysCrash_AnyTwoCars | 8005d404 | Match (dist=FastSqrt(bestDistSq); hitDir=dist<<0xc/dist or {0,0,0x1000}; hitStrength=(selfHitRadius+otherHitRadius)−dist bail<=0; 4 cases by ACTION_BOT: both-human/self-human-other-AI/self-AI-other-human/both-AI. comVel=mass-weighted (const_CollisionWeight=retail nMass@0x47c) WeightedAverage; BouncePair — BounceSelf returns 0 (asm 0x8005cfd0/0x8005d0c0 clear v0) and caller bgez reads v0 (0x8005d6c0), so the reset-checks are DEAD in both project AND retail (decompiler mislabeled operand comVelZ); AddImpulse selfVel+=n·pen>>8, SubImpulse otherVel; then AI/ConvertVecToSpeed/ConvertSpeedToVec/BOTS_CollideWithOtherAI per case; human path PlayHumanFeedback (impact>0x200, MapToRange vol, throttle audioDefaults[8]=g_nLastCrashSfxFrame>=3, Voiceline 5 if vol>0xdc, rumble, ACTION_HUMAN_HUMAN_COLLISION 0x10000000) + Attack both directions) | + +## Vehicle module — VehPhysForce (ConvertSpeedToVec + OnGravity) DONE (2026-07-05) — 1 FIX + +| Function | Address | Verdict | +|---|---|---| +| VehPhysForce_ConvertSpeedToVec | 8005e104 | Match (== project ConvertSpeedToVecOut w/ Vec3* out-param; MATH_Sin/Cos(axisRotationY/X) reproduce retail inlined data_trigApprox lookup + quadrant flips (yRot&0x400/0x800); vel.y=speed·sin(Y)>>0xc, yComp=speed·cos(Y)>>0xc, vel.x=yComp·sin(X)>>0xc, vel.z=yComp·cos(X)>>0xc; ConvertSpeedToVec(driver) wrapper writes driver->velocity) | +| VehPhysForce_OnGravity | 8005e214 | FIXED (mud terminal-velocity gating: Ghidra 0x8005e4cc `li t1,0x100` is in the delay slot of the origY<-0x100 branch 0x8005e4c8 → terminalVelocity=0x100 whenever localY<0 && mud(0x80), NOT gated by origY<-0x100 (only the origY=-0x100 clamp is). Project required all 3, letting kart sink past 0x100 mud terminal when origY∈[-0x100,0). Rest Match — full ~0x84c walkthrough: SetRotMatrix + SetLightMatrixTranspose (transposed matrixMovingDir), RotateVectorLocal(velocity); moon gravity quadFlag 0x2 → grav·41/100, ·elapsed>>5; grav.z=0 on forwardAccelImpulse sign, grav.x/z=0 on PREVENT_ACCEL 8 / speed-baseSpeed sign; fwd [fireSpeed−margin/2, fireSpeed+margin], perp ±const_SideSpeedClamp, terminal ±const_TerminalVelocity; friction MASK_GRABBED→0 else prevFrame TOUCH_GROUND\|\|BLASTED\|\|(terrainScaledBaseSpeed>5, groundFrictionScale scale, terrainFrictionTimer boost+rumble, mud floor, sideslip 0x100 ·3>>2, InterpBySpeed; rtv0 back; forwardDir/rollback goto-tangle equiv, vShiftWindowTimer=0x280) | + +## Vehicle module — VehPhysForce (part 2) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehPhysForce_OnApplyForces | 8005ea60 | Match (speed clamp 0x6400; originToCenter=color-matrix·{0,25,0}; ConvertSpeedToVec; mud-sink floor terrain 0xe posCurr.y>-0x1000 → velY=max(velY, -0x1000-posY); OnGravity; normalVecUP/AxisAngle1=UP{0,0x1000,0}, collisionFlags=0, currBlockTouching=null; velocity+=accel) | +| VehPhysForce_CollideDrivers | 8005ebac | Match (velocity−=accel; KILL_PLANE 0x4000→collFlags\|=1; turbo-pad SUPER→(0x78,0x800), TURBO !cheat→(0x3c0,0x100) cheat→(0x78,0x800) VehFire_Increment; WATER→vertSplit0+SPLIT_LINE else clear; !DISABLE_COLL 0x1000: search pos>>8, CollidePointWithBucket(sibling + ROBOT bucket 1), th!=NULL && bestDistSq>8−spsHitPos + floorDiffY(+4 quadBlockHeight)})<0 → velocity+=diff<<6) | +| VehPhysForce_TranslateMatrix | 8005ee34 | Match (project splits into UpdateSquashStretch/RotAxisAngle/UpdateMatrixAnimation/UpdateInstanceMatrix/UpdateWake). Squash: WARP_PAD skip, MASK_GRABBED&!TOUCH scale.y=jump+0xccc scaleXZ=0xccc−Div256(jump·0x28) clamp0x400, else targetSquish −800/delta((sq2·9+jhc·7)>>4 diff<<2, \|·\|<0x960→0, clamp −800/−0x640/800), hazard, jhc<0 MapToRange(−jhc,0,0xa00,0x280,0x320), TNT scale.y<2500, InterpBySpeed 300, squishTimer/scale.y==0 (modelIndex 0x18 OtherFX 0x5b, matrixArray=5). MatrixAnimation state machine (not-boost reserves==0\|\|fireSpeed=[3].n reset; boost: 1→idx++ >=[1].n →2, 3→RemapIndex(3,1)+1, 0→1; 5→idx++ >=[5].n reset; RemapIndex blend 0x100−(idx<<8)/fromLast·(toLast)>>8). InstanceMatrix: array0 copy facing + posCurr>>8+Screen_OffsetY·3>>3, else MatrixRotate(entry+8) + rotated cachedVec, squishTimer AxisAngle2·0x13>>0xc. Wake: t[1]>=0\|\|<-0x4f\|\|!SPLIT→hide, else show depthBias±1, SetWakeRotation sin/cos matrix, wakeScale spawn falling particles 1P \|speed\|>0xc00) | +| VehPhysForce_RotAxisAngle | 8005f89c | Match (axis-angle rot matrix: col1=normVec; TrigAngleSinCos reproduces quadrant table (all 4 quadrants verified); scaledSin/CosY=trig·normalY>>12; denom=normalX²+normalZ²==0→outX=±scaledSinY(normalY sign), outY=-dot>>12, else shift=0x14−CountLeadingSignBits(denom)(==LZCS/LZCR), sinRem/cosRem, divX/Z via denom, outX/Z+=div; col2={outX,outY,outZ}; col0 = gte_op cross(axis, col2) via CTC2 reg0/2/4 + MTC2 IR 9/10/11) | +| VehPhysForce_CounterSteer | 8005fb4c | Match (accel=0; bail unless \|speedApprox\|>0x300 && !CRASHING && !WARP && wallRubTimer==0 && TOUCH_GROUND && counterSteerRatio!=0; angle=clamp(turnAngleCurr−turnAnglePrev, ±const_ModelTurnCounterSteerStrength); force.x=((counterSteerRatio·-8000>>8)·MATH_Sin(angle))>>12; accel=rotate(matrixMovingDir, force.x,0,0)) | + +## Vehicle module — VehPhysGeneral (PhysAngular + LerpQuarterStrength) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehPhysGeneral_PhysAngular | 8005fc8c | Match — full ~0x7cc walkthrough. Lean lerp opener factored into PhysLerpRot(driver,0) (retail inlines rotCurr.w InterpBySpeed bounded by cConst_LeanLerpRateMax) + tail RotAxisAngle(matrixMovingDir, AxisAngle1_normalVec, angle)+SetRotMatrix+CounterSteer factored into PhysTerrainSlope. Body verified: forwardDir from baseSpeed/speedApprox (STEER_LEFT 0x10 flip when forwardDir<0), rotCurrW_interp=simpTurnState·0x100 MapToRange(speed 0x10,0x300) if TOUCH_GROUND & !TURBO_PAD_MASK; rotationSpinRate slew (interp==0: rate (TurnInputDelay + turnConst·0x32)·turnResponseScale(=friction)>>8; else ±rate turnConst·100/50, clamp); drift spinout timeUntilDriftSpinout MapToRange(0,0x140, previousFrameMultDrift); turnResist Min/Max=(u8)const·Speed_ClassStat >>8 (>>9 if BRAKE_WITH_ACCEL 0x20, BACK_SKID 0x800), modelRotVel Max/Min MapToRange(|speed|,0x300, ClassStat/2); turnLimit MapToRange(driverSpeed, resistMin/Max, (TurnRate+turnConst·2/5)·0x100, 0); LerpToForwards + turnAngleScale(=pHandlingMult[1]) → turnAngleCurr; SteerAccel(numFramesSpentSteering, stage consts) capped by SteerAccelTurnVelScale(=SpinScale)·|spinRate|>>8, STEER_LEFT negate, clamp ±SteerAccelTurnVelLimit(=SteerClampLimit); steer-kick oscillator (turnWobble*==nSteerKick*: (modelRotVel·3>>2 < |turnAngleFinal|) & |turnVel|<3 & |kickAngle|<10 → timer=8 vel=±0x14, |kickAngle|>0x32→timer=0, timer==0 InterpBySpeed toward ±10 else angle+=vel timer--); angle integ MapToRange(speed 0,0x600)·elapsed>>5 (angle-=..&0xfff), ampTurnState, angle+=ampTurn·elapsed>>0xd &0xfff; rotCurr.y=angle+turnAngleCurr+kickAngle; axisRotationX mash-X (ACCEL_PREVENTION 8 \|\| nMashXTapCount>=7 → ·10>>8 else ·nSteerGripMult(=turnLeanScale)>>8) +=·elapsed>>0xd &0xfff. Field remaps: turnAngleLerpVel=unk_LerpToForwards, turnConst/accelConst s8) | +| VehPhysGeneral_LerpQuarterStrength | 80060458 | Match (desired!=0 → quarter=max(desired>>2, 1); current=min(current, quarter); desired==0 → clamp to 0) | + +## Vehicle module — VehPhysGeneral (part 2) DONE (2026-07-05) — 1 FIX + +| Function | Address | Verdict | +|---|---|---| +| VehPhysGeneral_LerpToForwards | 80060488 | Match (mirror when target<0; unless wallRubTimer==0xf0: overshoot desiredmodelRotVelMax], current−target); undershoot current>8\|, 0..BackwardTurnRate → 0..TurnDecreaseRate)); wall-rub clamp ±wallRubSpeedLimit(=scrubMeta8); accel tree (turbo-pad&baseSpeed>0→8000, else Accel_ClassStat+accelConst<<5/5, reserves→Accel_Reserves, terrain slowUntilSpeed·>>8 unless MASK_WEAPON); GTE-rotate {0,0,impulse}, movement±rotated, forwardAccelVector/Impulse; FastSqrt friction speedLoss clamp; jump: bomb-hop (WEAPON_FIRE 0x8000 & heldItemID 5 → JumpForce·9 Div4), coyote (CoyoteTimer/TenBuffer/CooldownMS → InitialVelY=JumpForce, numberOfJumps++), trampoline (forcedJumpType 2=HIGH→·3, else ·3/2 Div2), anti-grav underDriver mulNormVecY, BLASTED rumble; JumpGetVelY over AxisAngle4 (1-iter loop) then AxisAngle1/2, FastSqrt vertical clamp by level1->unk_18C (0→0x3700, >0x5000→0x5000); ConvertVecToSpeed, speed−=speedLoss clamp 0; speedometerNeedleValue smoothing) | +| VehPhysGeneral_SetHeldItem | 80060f0c | **FIX** — case 8 (1P arcade, 8 drivers) rank-1 itemset. Rest Match: itemset by mode (battle custom/default 0x34de, crystal, boss, rank-weighted 2/3/4/5/6/8), rng=(Scramble()>>3)%200 indexes charPtr[itemSet][rng·numWeapons/200] (numWeapons {0x14,0x34,0x14,0x13,0x14,0,0,0x14}), crystal bomb/turbo by SKULL_ROCK/RAMPAGE_RUINS, boss pity (7-9→0xb by fails<3/<4, clock 8 <5; DRAGON_MINES 0xb→2), spring 5→0, warpball 9 one-at-a-time, 3-missiles 0xb cap 2 holders (3+P non-battle), items 0xa/0xb → numHeldItems 3. | +| VehPhysGeneral_GetBaseSpeed | 80061488 | Match (base=Speed_ClassStat; bonus = (min(wumpa,9)·speedStat/10 + min(turbo,5)·speedStat)>>0xc, speedStat=((AccelSpeed_ClassStat−Speed_ClassStat)<<0xc)/5−1; +MaskSpeed if MASK_WEAPON; reserves clamp bonus ≤ 2·SacredFireSpeed−SingleTurboSpeed−fireSpeedCap, base+=fireSpeedCap; subtract DamagedSpeed/2 (TNT), DamagedSpeed (burn/squish/rainCloudEffect==SLOW==0), max clock DamagedSpeed·(0x14−rank)>>4; clamp 0x6400) | + +### FIX detail — VehPhysGeneral_SetHeldItem case 8 (1P arcade rank-1 itemset) + +Ghidra `0x80060f9c-0x80060fc0`: `sra v1,a0,0x10` loads the **un-halved** driverRank, `beq v1,0x1` special-cases rank==1 → itemSet2 (`li s0,1` at 0x80061040), and `sra s0,v0,1` (rank/2) runs only in the else path. So retail maps rank 0→itemset1, **rank 1→itemset2**, 2/3→itemset2, 4/5→itemset3, 6/7→itemset4. The project halved first (`itemSet = rank/2`) then tested `itemSet == 1` — never true for rank 1 (halves to 0) — so 2nd place in 1P arcade got the 1st-place itemset (Race1) instead of Race2. Fixed by testing `driver->driverRank == 1` before the halving; the `Itemset2:` label (goto target from case 3) stays inside that branch. Build green. + +## Vehicle module — VehPhysJoystick DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehPhysJoystick_ReturnToRest | 8006163c | Match (rwd NULL → rwd_default {gamepadCenter 0x80, deadZone 0x30, range 0x7f}; centered=stickVal−gamepadCenter; \|centered\| MapToRange(deadZone, range, 0, half) with sign re-applied) | +| VehPhysJoystick_GetStrength | 800616b0 | Match (rwd NULL → dead 0x30/range 0x7f/dist 0x5e; val>7/>>8 toward-zero rounds, const_BackwardSpeed·−3>>2); super-engine VehFire_Increment(0x78, SUPER_ENGINE\|TURBO_PAD); terrain speedMult>>8 gated by MASK\|BRAKE 0x800020; accelTapCount/fireSpeed sign-change tap; steering branch (mashX≥7&&speed<0x2600→0x5a, wallRub→0x30, !0x28→TurnRate, cross0→0x40, else MapToRange; GetStrengthAbsolute; STEER_LEFT set/clear verified across all S/prevSimpTurn sign cases); wheelRotation InterpBySpeed; tire-flash (fireSpeed+0xf00 air / +speed>>1 ground; step (·0x89+cycleStep·0x177)<<3>>0xc; timer −step if speed>0x200; <1 & tireColor&1==0 & !ENGINE_REVVING → 0x1e00/0x2e606061 else 0x2e808080). | + +**Latent divergence (noted, not fixed):** project hoists the `invisibleTimer` countdown to just after the timer-decrement block; retail runs it later (beside `invincibleTimer`, before the pos/rot backup). Timer value + all field writes (invisibleTimer, instSelf->flags=instFlagsBackup, alphaScale=0) are identical — only the intra-frame order of the expiry `OtherFX_Play(0x62)` vs the hazard-rumble/item-roll SFX differs (no data dependency, imperceptible). **CONFIRMED 2026-07-07 via full decomp: the intervening code (hazard block, item-roll, noItemTimer, invincibleTimer) neither READS nor WRITES invisibleTimer / instSelf->flags / instSelf->alphaScale — so the hoist has zero data dependency in either direction; only the OtherFX_Play(0x62) vs item-roll OtherFX_Play(0x5e/0x41) order differs, and those co-occur only if a weapon-roll finalizes the same frame invisibility expires (both sounds still play). Note accurate.** Confirmed constants: CARRY_MASK 0x7f1f83d5, RACE_TIMER_FROZEN 0x40000, REVERSING_ENGINE 0x20000, REVERSE_STEER_LEFT/RIGHT 0x20000000/0x40000000, DRIVER_ACCEL_TAP_WINDOW_MS 0x100 (all static-asserted). + +## Vehicle module — VehPhysProc (part 2: Driving Audio/Update/Init) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehPhysProc_Driving_Audio | 80062a2c | Match (EngineSound_Player(driver)) | +| VehPhysProc_Driving_Update | 80062a4c | Match (STARTED_TOUCH_GROUND 2 → PowerSlide_Init if \|simpTurnState\|>(TurnRate+turnConst·2/5)>>1 && (buttonsHeld & buttonUsedToStartDrift) && !ACCEL_PREVENTION 8 && ClassSpeed>>1 ≤ speedApprox; else vShiftStartGuardTimer==0 && vShiftCount>4 → FreezeVShift_Init, else vShiftWindowTimer==0 → vShiftCount=0. Field remaps StartDriving_0x60/unknownTraction/StartRollback_0x280) | +| VehPhysProc_Driving_Init | 80062b74 | Match (track-only guard (u32)(levelID−GEM_STONE_VALLEY)≥5 \|\| AdvHub-open; funcPtrs[0..0xc] = PlayerDrivingFuncTable {NULL, Update, PhysLinear, Audio, PhysAngular, OnApplyForces, COLL_MOVED_PlayerSearch, CollideDrivers, COLL_FIXED_PlayerSearch, JumpAndFriction, TranslateMatrix, VehFrameProc_Driving, VehEmitter_DriverMain(=retail SpawnParticle_DriverMain)}; turbo_MeterRoomLeft=0, guard 0x60, window 0x280, vShiftCount=0; BATTLE & BLASTED → invincibleTimer 0xb40; kartState=NORMAL after) | + +## Vehicle module — VehPhysProc (part 3: FreezeEndEvent + FreezeVShift) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehPhysProc_FreezeEndEvent_PhysLinear | 80062ca8 | Match (Driving_PhysLinear; zero baseSpeed/fireSpeed/ampTurnState/simpTurnState/wheelRotation; set ACCEL_PREVENTION 8, clear JUMP_BUTTON_HELD 4 — project's \|=8 then &=~4 == retail (actions&~4)\|8, disjoint bits; jump_TenBuffer>0→0) | +| VehPhysProc_FreezeEndEvent_Init | 80062d04 | Match (KS_FREEZE guard; kartState=FREEZE, speed/speedApprox=0; PlayerFreezeFuncTable [0]/[1]=NULL, [2]=FreezeEndEvent_PhysLinear, then Audio/PhysAngular/OnApplyForces/COLL_MOVED/CollideDrivers/COLL_FIXED/JumpAndFriction/TranslateMatrix/VehFrameProc_Driving/VehEmitter_DriverMain) | +| VehPhysProc_FreezeVShift_Update | 80062db0 | Match (fireSpeed==0 && !(actionsFlagSet & 0x10000028 = HUMAN_HUMAN_COLLISION\|ACCEL_PREVENTION\|BRAKE_WITH_ACCEL) → speed/speedApprox=0; else Driving_Init) | +| VehPhysProc_FreezeVShift_ReverseOneFrame | 80062e04 | Match (JumpAndFriction; !JUMP_STARTED 0x400 → if !HUMAN_HUMAN_COLLISION 0x10000000 zero xSpeed/ySpeed/zSpeed/speed/speedApprox + posCurr=posPrev; else Driving_Init) | +| VehPhysProc_FreezeVShift_Init | 80062e94 | Match (kartState=ANTIVSHIFT, turbo_MeterRoomLeft=0, clear HUMAN_HUMAN_COLLISION 0x10000000; PlayerAntiVShiftFuncTable [1]=FreezeVShift_Update, [9]=FreezeVShift_ReverseOneFrame in JumpAndFriction slot) | + +## Vehicle module — VehPhysProc (part 4: PowerSlide_PhysAngular) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehPhysProc_PowerSlide_PhysAngular | 80062f4c | Match — full ~0x6e8 walkthrough. Lean-lerp opener factored into PhysLerpRot(driver, ±const_Drifting_CameraSpinRate) (verified == retail inline, also confirms iter-107 PhysLerpRot). axis-angle center ((axisRotationX−angle+0x800)&0xfff)−0x800 step clamp ±elapsed·2; desiredSpinRate MapToRange(simpTurnState<<8, (TurnRate+turnConst·2/5)<<8, steerVel<<8) with drift-dir/steer-dir SteerVel_DriftStandard/SwitchWay pick; spin ramp (accel/decel DriftSpinRate consts, spinRateNegated short-circuit split verified across sign cases); numFramesDrifting inc/dec/interp; driftTurnInput MapToRange(driftTotalTimeMS, DriftTurnRampFrames<<5, DriftTurnStartupScale·multDrift>>8, driftDirection); spin clamp to ±driftTurnInput; driftTurnAngleBase MapToRange(|driftTurnInput|, DriftTurnBase+turnConst·4/5, DriftTurnAngleScale); driftTurnAngleAssist — 4-case sign enum (driftTurnInput^signedSpinRate)≥0 → SameDir/Opposite angle + SteerVel Standard/SwitchWay confirmed == retail SlideRot_DriftStandard/SwitchWay nested-if; turnAngleCurr slew >>3; steer-kick/turnWobble oscillator (FramesTillSpinout>>1 near-spinout kick 8/±0x14, ±10 ease, >0x32 reset); ampTurnState=signedSpin+driftTurnInput, angle+=·elapsed>>0xd; driftBoost axisRotationX kick DriftBoostAxisKickRate; rotCurr.y=turnWobble+angle+turnAngleCurr; driftTotalTimeMS clamp DriftTurnRampFrames<<5; tail RotAxisAngle+SetRotMatrix+CounterSteer via PhysTerrainSlope. Field remaps: DriftSpinRateDecel/Accel=nConst_DriftSpinDecelRate/AccelRate, DriftTurnRampFrames=cConst_DriftTimeCap, DriftTurnStartupScale=cConst_DriftSpinInitScale, DriftTurnBase=cConst_DriftSpinTurnRange, DriftTurnAngleScale=nConst_DriftSpinTurnMax, DriftTurnSameDir/OppositeDir=nConst_SlideRot_DriftStandard/SwitchWay, DriftBoostAxisKickRate=cConst_DriftBoostSpinRate, DriftCameraLerpStep=cConst_LeanLerpRateMax, turnWobbleAngle/Timer/Velocity=nSteerKickAngle/Timer/Vel | + +## Vehicle module — VehPhysProc (part 5: PowerSlide Finalize/Update/PhysLinear/InitSetUpdate/Init) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehPhysProc_PowerSlide_Finalize | 80063634 | Match (previousFrameMultDrift=multDrift; timeUntilDriftSpinout=const_DriftReleaseTurnAssistFrames<<5 == retail cConst_DriftSpinoutTimeBase) | +| VehPhysProc_PowerSlide_Update | 8006364c | Match (no L1/R1 tap: turbo_MeterRoomLeft countdown, ==0 & numBoostsAttempted<3 refill turboMaxRoom<<5, hit 0 → OtherFX 0xf + numBoostsAttempted+=3; L1/R1 tap: numFramesDrifting=0, meter!=0 & >0xd &0xfff; rotCurr.y=turnWobbleAngle(nSteerKickAngle)+angle+turnAngleCurr; rotCurr.w=InterpBySpeed(·, (elapsed<<5)>>5, 0); turnAngleCurr=InterpBySpeed(·, (elapsed<<7)>>5 =elapsed·4, 0); RotAxisAngle(AxisAngle1, angle) — redundant shift seqs match retail exactly) | +| VehPhysProc_SlamWall_Update | 80063af8 | Match (no-op; state advances via Animate) | +| VehPhysProc_SlamWall_PhysLinear | 80063b00 | Match (Driving_PhysLinear; baseSpeed=0, fireSpeed=0) | +| VehPhysProc_SlamWall_Animate | 80063b2c | Match (animFrame++, matrixIndex++; if animFrame ≥ GetNumAnimFrames(animIndex)−1: GetNumAnimFrames(0)>0 → animIndex=0, animFrame=GetStartFrame(0,n), matrixArray/matrixIndex=0; funcPtrs[0]=Driving_Init. Project uses the general frame-count check matching the binary, NOT the old decomp's hardcoded 15/Oxide special case) | + +## Vehicle module — VehPhysProc (part 7: SlamWall_Init + SpinFirst) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehPhysProc_SlamWall_Init | 80063bd4 | Match (kartState=CRASHING; scale.x/y=0xccc; numFramesSpentSteering=10000; wipe ampTurnState/speed/speedApprox/baseSpeed/fireSpeed/rotationSpinRate/turnAngleLerpVel/turnWobbleAngle/Velocity/Timer/turbo_MeterRoomLeft/outsideTimer/VehFire_AudioCooldown/reserves/Screen_OffsetY/distanceFromGround/reserved_0x40e/jumpSquishStretch/2/nSpeedometerNeedle/xyzSpeed/velocity.xyz=0; PlayerCrashingFuncTable [11]=SlamWall_Animate. Project reads d->instSelf, retail thread->inst — same instance for a driver) | +| VehPhysProc_SpinFirst_Update | 80063cf4 | Match (NoInputTimer!=0 & \|speedApprox\|>0x2ff → return; else SpinLast_Init) | +| VehPhysProc_SpinFirst_PhysLinear | 80063d44 | Match (NoInputTimer−=elapsed clamp 0; Driving_PhysLinear; baseSpeed/fireSpeed=0; actionsFlagSet\|=0x5808 = WARP\|FRONT_SKID\|BACK_SKID\|ACCEL_PREVENTION; timeSpentSpinningOut+=elapsed) | +| VehPhysProc_SpinFirst_PhysAngular | 80063dc8 | Match (numFramesSpentSteering=10000; rotationSpinRate/turnWobbleAngle(nSteerKickAngle) decay −(·>>3); turnAngleCurr=((·+Spinning.driftSpinRate[==Drifting.numFramesDrifting union]+0x800)&0xfff)−0x800; ampTurnState=rotationSpinRate; angle+=rotationSpinRate·elapsed>>0xd; rotCurr.y=turnWobble+angle+turnAngleCurr; rotCurr.w InterpBySpeed; RotAxisAngle. Benign reorder of turnWobble decay) | +| VehPhysProc_SpinFirst_InitSetUpdate | 80063eac | Match (funcPtrs[0 INIT]=NULL, [1 UPDATE]=SpinFirst_Update) | +| VehPhysProc_SpinFirst_Init | 80063ec0 | Match (kartState=KS_SPINNING=DRIFTING\|CRASHING=3; turnAngleLerpVel/turbo_MeterRoomLeft=0; RacingOrBattle & !ADVENTURE_ARENA → ModifyWumpa(−1); Voiceline_RequestPlay(3=VOICE_BLOCKED\|VOICE_HURT, characterIDs[driverID], 0x10); Spinning.spinDir=±1/driftSpinRate=±300 by ampTurnState sign; PlayerSpinningFuncTable [11]=VehFrameProc_Spinning; JogCon1 simpTurnState<1?0x19:0x29, 0x60) | + +## Vehicle module — VehPhysProc (part 8: SpinLast) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehPhysProc_SpinLast_Update | 8006402c | Match (\|turnAngleCurr\| < 0x10 → SpinStop_Init) | +| VehPhysProc_SpinLast_PhysLinear | 8006406c | Match (Driving_PhysLinear; baseSpeed/fireSpeed=0; actionsFlagSet\|=0x4008 = WARP\|ACCEL_PREVENTION) | +| VehPhysProc_SpinLast_PhysAngular | 800640a4 | Match (numFramesSpentSteering=10000; rotationSpinRate/turnWobbleAngle decay −(·>>3); ampTurnState=rotationSpinRate; settle: driftAngleCurr<0 & driftSpinRate>0 & >−400 → driftSpinRate=(driftAngleCurr·−4)>>3 clamp≥0x20; driftAngleCurr>0 & driftSpinRate<0 & <400 → clamp≤−0x20; turnAngleCurr wrap ((·+driftSpinRate+0x800)&0xfff)−0x800, snap 0 on sign-cross; angle+=ampTurnState·elapsed>>0xd; rotCurr.y=turnWobble+angle+turnAngleCurr; rotCurr.w InterpBySpeed; RotAxisAngle. driftSpinRate==Spinning.driftSpinRate==Drifting.numFramesDrifting union) | +| VehPhysProc_SpinLast_Init | 80064254 | Match (PlayerLastSpinFuncTable [0]=NULL, [1]=SpinLast_Update, [2]=SpinLast_PhysLinear, [4]=SpinLast_PhysAngular, [11]=VehFrameProc_LastSpin=retail spin-animate) | + +## Vehicle module — VehPhysProc (part 9: SpinStop) DONE (2026-07-05) — CLEAN (0 fixes). VehPhysProc.c crash/spin state machine COMPLETE. + +| Function | Address | Verdict | +|---|---|---| +| VehPhysProc_SpinStop_Update | 800642ec | Match (no-op) | +| VehPhysProc_SpinStop_PhysLinear | 800642f4 | Match (Driving_PhysLinear; baseSpeed/fireSpeed=0 — separate binary copy, behavior == SlamWall_PhysLinear) | +| VehPhysProc_SpinStop_PhysAngular | 80064320 | Match (angle+=ampTurnState·elapsed>>0xd; rotCurr.y=turnWobble+angle+turnAngleCurr; rotCurr.w & turnAngleCurr InterpBySpeed to 0; RotAxisAngle — separate copy of SlamWall_PhysAngular) | +| VehPhysProc_SpinStop_Animate | 800643d4 | Match (GetNumAnimFrames>0: spinDir −1 → animFrame+=5 latch numFrames−1 & spinDir=0; +1 → animFrame−=5 latch 0 & spinDir=0; 0 → InterpBySpeed to GetStartFrame(0,n) midpoint; done → funcPtrs[0]=Driving_Init. spinDir==Spinning.spinDir==Drifting.driftBoostTimeMS union) | +| VehPhysProc_SpinStop_Init | 800644d0 | Match (funcPtrs via DRIVER_FUNC_* named indices, all static-asserted 0-12: [1]=SpinStop_Update, [2]=SpinStop_PhysLinear, [4]=SpinStop_PhysAngular, [11]=SpinStop_Animate) | + +## Vehicle module — VehPickState (NewState) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehPickState_NewState | 80064568 | Match — full ~0x67c walkthrough of the hazard/hit-reaction dispatcher. Early-outs: MASK_GRABBED → 0; MASK_WEAPON 0x800000 \|\| invincibleTimer → Voiceline(2 VOICE_BLOCKED)+0; instBubbleHold → shield flags\|1 (off+6), invincibleTimer=0x2a0, clear hold, Voiceline(2)+0; DAMAGE_NONE → 1. Damage dispatch (pendingDamageType=0): type1 spinout NoInputTimer 0x3c0 SpinFirst_Init if kartState!=3; type2 tumble (BLASTED \|\| funcPtrs[0]==Tumble_Init → 0; else Tumble_Init, NoInputTimer 0x960, squishTimer=0, voice1, NO SpinFirst); type3 squish (kartState!=3 → OtherFX 0x5a echo + voice4; NoInputTimer 0xf0, squishTimer 0xf00, SpinFirst); type4 burn (burnTimer==0 → OtherFX 0x69 + voice1; NoInputTimer 0x780, burnTimer 0xf00, SpinFirst); type5 plant-eaten (NoInputTimer 0xd20, PlantEaten_Init, voice1, NO SpinFirst); voice!=0 → Voiceline(voice). Attack tally switch(reason): project splits into victim-stat switch (1 numTimesBombHitYou, 2 numTimesMotionlessPotionHitYou, 3 numTimesMissileHitYou) + attacker-guarded switch (1 BombsHitSomeone+quip4\|1, 3 MissileHitSomeone+quip4\|2, 4 MovingPotionHitSomeone+quip4\|4, 5 SquishedSomeone, 6 quip4\|8) == retail single combined switch (quip4=unk_4F0_4F8[6]). kartState=NORMAL, reserves/turbo_outsideTimer/matrixArray/matrixIndex=0, ShockFreq/Force1; attacker & !END_OF_RACE → RB_Fruit_GetScreenCoords→BattleHUD.startX/Y (retail inlines GTE rtps via tileView[driverID].matrix_ViewProj, tileView==pushBuffer rename), RB_Player_KillPlayer (retail loop iVar7≡1 runs once), END_OF_RACE-recheck quip1/quip3=reason, scoreDelta±1/cooldown 5, numTimesAttackedByPlayer/AttackingPlayer/Attacking; thread flags &=~DISABLE_COLLISION 0x1000, instSelf flags &=~HIDE_MODEL; return 1. OtherFX_Play_Echo echo arg passed `&ACTION_ENGINE_ECHO`(0x10000) vs retail `>>16&1` — confirmed boolean-tested (`if(echoFlag!=0)`), equivalent. Latent: damageType≥6 (unreachable, enum 0-5) takes plant-eaten path vs retail spinout | + +## Vehicle module — VehPickupItem (part 1: Mask + MissileTarget) DONE (2026-07-05) — CLEAN (0 fixes, 1 equivalence to re-check) + +| Function | Address | Verdict | +|---|---|---| +| VehPickupItem_MaskBoolGoodGuy | 80064be4 | Match ((0x20c9 >> characterIDs[driverID]) & 1; 0x20c9 = bits {0,3,6,7,0xd} = retail good-guy set Crash/Coco/Polar/Pura/Penta) | +| VehPickupItem_MaskUseWeapon | 80064c38 | Match (guard !RacingOrBattle \|\| ADVENTURE_ARENA → NULL; existing mask (childThread modelIndex STATIC_AKUAKU 0x39/UKAUKA 0x3a): funcThTick=RB_MaskWeapon_ThTick, duration numWumpas<10?0x1e00:0x2d00, !BOT & boolPlaySound → OtherFX(modelIndex+0x1A = 0x53/0x54), clear DEAD; new: BoolGoodGuy → modelID=0x3a−good, BirthWithThread, OtherFX, !BOT & 1>8)²+(dz>>8)² min. Direct MTC2/MFC2/CFC2 reproduces retail ldv0/stsxy/stflg) | + +**RESOLVED → FIX (iteration 121):** MissileLoadAiView (AI path) built the projection matrix with the Psy-Q software `RotMatrix`; retail uses the game's GTE `ConvertRotToMatrix`. Traced ConvertRotToMatrix through MATH_Matrix_MulIfNonZero/MulRotWords: it builds **M = Ry·Rx·Rz** (MulRotWords computes M_curr·M_new via rtv0/1/2). Symbolically ConvertRotToMatrix[0][2]=sy·cx, [1][2]=−sx, [1][0]=cx·sz vs Psy-Q RotMatrix[0][2]=sy, [1][2]=−cy·sx, [1][0]=sz·cx+cz·sx·sy — they agree ONLY for single-axis rotations, and DIFFER with combined pitch/roll (slopes/banked turns). So a pitched/rolled bot's homing missile projected candidates with the wrong orientation → possibly a different auto-target. **Fixed: MissileLoadAiView now uses ConvertRotToMatrix(&matrix, &rot) matching retail. Build green.** (My prior pure-X/pure-Y equivalence check was insufficient — those are exactly the cases where they coincide.) + +## Vehicle module — VehPickupItem (part 2: PotionThrow) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehPickupItem_PotionThrow | 800652c8 | Match (throw modes: flags&4 forward (retail vel=fwd·0xf>>9 == project fwd·0x78>>12, exact identity since ·120>>12 = ·15<<3>>12 = ·15>>9); flags&2 backward vel=fwd·−0x78>>0xc; flags&1 spread vel=fwd·((MixRNG_Scramble()&0x1f)−0x10)>>0xc == retail RNG_Random(&g_mainRNG); none → return 0. velocity.y=0x30, crateInst=NULL, extraFlags\|=2. MineWeapon offsets velocity@0xC, crateInst@8, extraFlags@0x28) | + +## Vehicle module — VehPickupItem (part 3: ShootNow) DONE (2026-07-05) — 1 FIX + +| Function | Address | Verdict | +|---|---|---| +| VehPickupItem_ShootNow | 8006540c | **1 FIX** (beaker instTntSend). Rest Match — full ~0x12d8 switch(weaponID): case 0 Turbo VehFire_Increment(0x960, 9, wumpa?0x100:0x80); case 2 Bomb/Missile (numMissiles cap 12, target=MissileGetTargetDriver else rank drivers[rank+7]==driversInRaceOrder[rank-1] / battle-nearest; heldItemID 1/10 bomb DYNAMIC_BOMB else DYNAMIC_ROCKET; matrix copy + RotAxisAngle; TrackerWeapon fields; bomb CTR_MatrixToRot dir x/y swap + backward-throw BTN_DOWN/flags&2 vel −(·>>1·3/5); missile thTrackingMe, vel wumpa<10 ·5>>8 else ·3>>7+flag1; vel logical-vs-arith >>8 equiv via s16 trunc); case 3 TNT/Nitro (STATIC_CRATE_TNT 0x27 / PU_EXPLOSIVE_CRATE 6; MineWeapon; PotionThrow; RunMineCOLL BSP ground-settle, crate check project InstDef.modelID PU_FRUIT/RANDOM_CRATE vs Ghidra QuadBlock.blockID 7/8 — struct-reinterp, ASM-verified; mineHitModel\|0x8000, follower flags==0); case 4 Beaker (STATIC_BEAKER_GREEN 0x47/RED 0x46; PotionThrow, ret==0 → RunMineCOLL mineHitModel plain, follower always); case 6 Shield (0x5a, Shield color+highlight sub-instances, scale 0x700, duration/flag4); case 7 Mask (MaskUseWeapon(d,1)); case 8 Clock (hurt all 8 drivers RB_Hazard_HurtDriver → clockReceive 0x1e00/0x2d00, self clockSend 0x1e, clockFlash 4); case 9 Warpball (0x36, identity matrix + posCurr>>8, RB_Warpball_* seek/target/path, vel ·7>>8, particle otIndexOffset 250==(u8)−6==retail cOtDepth); case 0xc Invisibility (flags & ~0x70000 \| GHOST_DRAW_TRANSPARENT, invisibleTimer 0x1e00/0x2d00); case 0xd SuperEngine (superEngineTimer 0x1e00/0x2d00) | + +### FIX detail — ShootNow beaker instTntSend + +Retail `0x8006540c`: case 3 (TNT) tail = `RotAxisAngle; instTntSend=weaponInst; actionsFlagSet\|=DROPPING_MINE; if(flags==0) Follower`. Case 4 (dropped Beaker) tail = `RotAxisAngle; RB_Follower_Init; actionsFlagSet\|=DROPPING_MINE` — **no instTntSend write**. The project shares a `RunMineCOLL` block that set `d->instTntSend = weaponInst` unconditionally, so a dropped beaker wrongly recorded itself as the driver's sent-TNT instance (instTntSend feeds TNT-on-head/detonation tracking, and could dangle to a freed beaker). Fixed by gating the write behind `mineSetsInstTntSend` (=1 only in case 3). Build green. (Minor latent: for case 4 retail orders Follower before DROPPING_MINE vs the shared block's DROPPING_MINE-before-Follower — benign, RB_Follower_Init doesn't read the flag.) + +## Vehicle module — VehPickupItem (part 4: ShootOnCirclePress) DONE (2026-07-05) — CLEAN (0 fixes). VehPickupItem.c COMPLETE. + +| Function | Address | Verdict | +|---|---|---| +| VehPickupItem_ShootOnCirclePress | 800666e4 | Match (pendingDamageType!=0 → VehPickState_NewState(d, pendingDamageType, pendingDamageAttacker, pendingDamageReasonByte) [= retail cChangeState_damageType/attacker/attackKind]; WEAPON_FIRE_REQUEST 0x8000 set → clear it, map heldItemID→weaponID and ShootNow(d, weaponID, 0). Retail's weaponID=1-default + ITEM_BOMB_ALT/ITEM_MISSILE_ALT short-circuit + if(weaponID==1)→2 collapses exactly to project's heldItemID∈{1,10,11}→2 else heldItemID — all branches traced) | + +## Vehicle module — VehStuckProc (part 1: MaskGrab FindDestPos/Particles/Update/PhysLinear/Animate) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehStuckProc_MaskGrab_FindDestPos | 8006677c | Match (no-restart fallback: posCurr = (vert[index0]+vert[index3])·0x80, +0x80 Y; else walk restart nodes from quad->checkpointIndex: posCurr=node.pos<<8/(y+0x80)<<8, rotCurr.y=ratan2(next−cur), SearchBsp helper (bbox min/max of top{posCurr>>8} & bottom{−0x100 Y}, COLL_FIXED_BSPLEAF_TestQuadblocks) == retail inline; accept when ground (boolDidTouchQuadblock & !KILL_PLANE 0x4000) AND no other driver within 0x2000 X or Z (threadBuckets[PLAYER]); cameraDC.flags\|=1. Field remaps quadFlagsIgnored 0x4010/quadFlagsWanted 0x1000, HIGH_LOD==Ghidra LOW_LOD_QUAD, hitFraction==speedScale 0x1000) | +| VehStuckProc_MaskGrab_Particles | 80066cb0 | Match (10× Particle_Init(iconGroup[0], emSet_Maskgrab), axis[0/1/2].startVal += posCurr; stop on NULL) | +| VehStuckProc_MaskGrab_Update | 80066d4c | Match (NoInputTimer−=elapsed clamp 0; ==0 → mask rot.z&=~1 scale 0x1000, cameraDC.flags\|=8, FindDestPos(lastValid), VehBirth_TeleportSelf(0,0x80), RevEngine_Init) | +| VehStuckProc_MaskGrab_PhysLinear | 80066e3c | Match (Driving_PhysLinear; zero baseSpeed/fireSpeed/jump_TenBuffer/simpTurnState; actionsFlagSet &=~0x20024 (REVERSING_ENGINE\|BRAKE_WITH_ACCEL\|JUMP_BUTTON_HELD) \|=ACCEL_PREVENTION 8) | +| VehStuckProc_MaskGrab_Animate | 80066e8c | Match (boolStillFalling==0: matrixArray/Index=0, animIndex 0, animFrame=GetStartFrame, AxisAngle2_normalVec=MaskGrab.AngleAxis_NormalVec union; else falling: whistle OtherFX 0x55 <0x3c0, matrixArray 4/animIndex 2, animFrame counter unk58a(=MaskGrab.animFrame) matrixIndex 7/+5, <0x510 freeze frame 12 + jumpSquishStretch −800 (<0x3c1) / +0x2d0 & particles (else), ≥0x510 posCurr=posPrev; mask duration 0x1e00, >960 scale 0, else lift pos.y −=elapsed / +=elapsed<<7 boolLiftingPlayer, scale <721 0x1000 else (960−NoInput)<<0xc/0xf0) | + +## Vehicle module — VehStuckProc (part 2: MaskGrab_Init + PlantEaten Update/PhysLinear) DONE (2026-07-05) — CLEAN (0 fixes, 1 latent noted) + +| Function | Address | Verdict | +|---|---|---| +| VehStuckProc_MaskGrab_Init | 800671b0 | Match (kartState=MASK_GRABBED; clear MaskGrab flags + animFrame; maskObj=MaskUseWeapon(d,1); turbo/reserves=0; NoInputTimer 0x5a0; actionsFlagSet &=~0x80040 (AIRBORNE 0x80000\|HIGH_JUMP 0x40); Racing&!ADV_ARENA → ModifyWumpa(−2); quadBlockHeight+0x8000 < posCurr.y → numTimesMaskGrab++, posCurr.y<−0x8000 & configFlags&2 → AngleAxis_NormalVec=AxisAngle2 + 10× water-splash Particle(iconGroup[9], emSet_Falling) otIndexOffset=depthBiasNormal else boolStillFalling; posCurr=inst->matrix.t<<8 (retail reads via mislabeled cDC type-pun of same instcDC union), posPrev=posCurr; PlayerMaskGrabFuncTable; mask rot.z\|=1, pos=posCurr>>8 (+0x140 Y). LATENT: retail splash loop continues on NULL particle (10 iters, skip writes); project breaks on NULL — benign, exhausted pool NULL is side-effect-free so spawned particles identical. **CONFIRMED 2026-07-07: Particle_Init/CreateInstance @0x80040308 is fully side-effect-free on NULL — `p=LIST_RemoveFront(&JitPools.particle)` returns NULL on empty list with no state change, and numParticles++ / all MixRNG_Particles / oscillator alloc / list-link are ALL inside `if (p!=NULL)`. Retail's extra NULL iters don't advance RNG or pool → break-on-NULL spawns identical particles + identical RNG/pool state. Note accurate.**) | +| VehStuckProc_PlantEaten_Update | 8006749c | Match (NoInputTimer−=elapsed; ≤0 → 0 + FindDestPos(lastValid) + TeleportSelf(0,0x80) + thread flags &=~DISABLE_COLLISION 0x1000 + inst &=~HIDE_MODEL + RevEngine_Init. Project ≤0 == retail clamp<0 then ==0) | +| VehStuckProc_PlantEaten_PhysLinear | 80067554 | Match (Driving_PhysLinear; zero simpTurnState/fireSpeed/baseSpeed/jump_TenBuffer; actionsFlagSet &=~0x20024 \|=ACCEL_PREVENTION 8; timeSpentEaten(=retail timeSpentMaskGrabbed)+=elapsed) | + +## Vehicle module — VehStuckProc (part 3: PlantEaten Animate/Init + RIP_Init) DONE (2026-07-05) — 1 FIX + +| Function | Address | Verdict | +|---|---|---| +| VehStuckProc_PlantEaten_Animate | 800675c0 | Match (plantEatingMe & !boolInited & NoInputTimer<0xb40 → death cam: plantVector {±250 by Plant.LeftOrRight, 0, 750}, RotTrans via plant matrix, pushBuffer[driverID](=tileView).pos = {camVec.x, plant.t[1]+0xc0, camVec.z}, rot.y=ratan2(camX,camZ), rot.x=0x800−ratan2(pos.y−plant.t[1], SquareRoot0(camX²+camZ²)), rot.z=0) | +| VehStuckProc_PlantEaten_Init | 800677d0 | **1 FIX** (funcPtrs[10]). Rest Match: kartState=MASK_GRABBED, boolInited=0, turbo/reserves=0, actionsFlagSet&=~0x80040; thCloud → RainCloud.timeMS=0 + FadeAway + detach; Racing&!ADV_ARENA ModifyWumpa(−2); thread DISABLE_COLLISION, inst HIDE_MODEL; OtherFX_Stop1 driverAudioPtrs[1/2/0]=NULL; PlayerEatenFuncTable | +| VehStuckProc_RIP_Init | 80067930 | Match (= retail VehStuckProc_PlantEaten_InitNoUpdate: PlantEaten_Init; invisibleTimer=0; funcPtrs[UPDATE 1]=NULL, funcPtrs[ANIMATE 0xb]=NULL) | + +### FIX detail — PlayerEatenFuncTable[10] (plant-eaten TranslateMatrix) + +Retail `PlantEaten_Init` (0x800677d0) sets `funcPtrs[4]`..`funcPtrs[10]` ALL to NULL — a plant-eaten kart runs only Update/PhysLinear/Audio (slots 1-3) + Animate (0xb). The project's `PlayerEatenFuncTable[10]` (DRIVER_FUNC_TRANSLATE_MATRIX) was `VehPhysForce_TranslateMatrix` (copied from PlayerMaskGrabFuncTable, which legitimately keeps it), so an eaten kart — already HIDE_MODEL'd — would still rebuild its render matrix and tick the water-wake/squash every frame (spurious wake particles, matrix churn) instead of freezing. Fixed slot 10 → NULL. Also fixes `RIP_Init`, which installs this table via `PlantEaten_Init`. Build green. + +## Vehicle module — VehStuckProc (part 4: RevEngine) DONE (2026-07-05) — 1 FIX + +| Function | Address | Verdict | +|---|---|---| +| VehStuckProc_RevEngine_Update | 80067960 | Match (stay revving: !boolMaskGrab & trafficLightsTimer>0 → return, boolMaskGrab & quadBlockHeight+0x4000≤posCurr.y → return; else release: mask duration 0, if AccelSpeed_ClassStat0 & releaseCooldown==0 & !LOCKOUT_ALL): fireLevel InterpBySpeed toward boostMeter (fillStep clamp 0x100..5000), chargeState ACTIVE, overRev>0xc0 → lockout+OtherFX 0xf. Released: chargeState ACTIVE→cooldown 0x100 + RELEASED_ABOVE_ACCEL if fireLevel>AccelSpeed; boostMeter refill; fireLevel decay (fireLevel>>1 clamp 0x100..1000/3000 by LOCKOUT_REV_DECAY), <1 → emptyCooldown 0xc0. revEngineState from packed (u16 emptyCooldown\|chargeState<<16\|lockoutFlags<<24) & 0x200ffff; speedometerNeedleValue(=nSpeedometerNeedle=AngleAxis_NormalVec[2] union alias)=fireLevel; turbo_MeterRoomLeft MapToRange(turboMaxRoom/LowRoomWarning<<5); squish squishScale=(s16)>>6 clamp 0..0x400, scale.y=0xccc−, scale.xz=·6/10+0xccc) | +| VehStuckProc_RevEngine_Init | 80067f4c | **1 FIX** (boostMeter constant). Rest Match: kartState=ENGINE_REVVING, revEngineState=0, boolMaskGrab/maskObj/fireLevel=0; quadBlockHeight+0x1000NoInputTimer` (post-clamp field, line 1071) — EXACTLY the same. NO divergence; the prior "pre-clamp local" note was a decompiler-reading error.** Offsets asm-confirmed: NoInputTimer@0x400, jump_ForcedMS@0x3f6, baseSpeed@0x39c/fireSpeed@0x39e, actionsFlagSet@0x2c8, jump_InitialVelY@0x3f8, 0x1770=6000.) | +| VehStuckProc_Tumble_PhysAngular | 80068150 | Match (numFramesSpentSteering=10000; rotationSpinRate/turnAngleLerpVel(unk_LerpToForwards)/turnWobbleAngle(nSteerKickAngle) decay −(·>>3); ampTurnState=rotationSpinRate; turnAngleCurr=((·+turnAngleLerpVel+0x800)&0xfff)−0x800; angle+=rotationSpinRate·elapsed>>0xd; rotCurr.y=turnWobble+angle+turnAngleCurr; rotCurr.w InterpBySpeed; RotAxisAngle) | +| VehStuckProc_Tumble_Animate | 80068244 | Match (matrixArray=6; matrixIndex=(NoInputTimer>>5) % bakedGteMath[6].numEntries(=g_nTumbleAnimFrameCount); boolPlayBackwards(=Blasted, shares union byte w/ EatenByPlant.boolInited) → count−(idx+1)) | +| VehStuckProc_Tumble_Init | 800682a4 | Match (kartState=BLASTED, turbo_MeterRoomLeft=0; Racing&!ADV_ARENA ModifyWumpa(−3); animIndex=0, animFrame=GetStartFrame; boolPlayBackwards=MixRNG_Scramble()&4; PlayerBlastedFuncTable ([4]=PhysAngular, [10]=TranslateMatrix, [11]=Tumble_Animate); JogCon1 simpTurnState<1?0x19:0x29) | + +## Vehicle module — VehStuckProc (part 6: Warp dust/arc render) DONE (2026-07-05) — CLEAN (0 fixes) + +Ghidra project auto-named these ElecDrop_* (SubdividePath/SpawnParticle/RenderArcs); syms926 names are VehStuckProc_Warp_* at the same addresses. Electric-arc / dust-puff render for WARP_PAD drop-in. + +| Function | Address | Verdict | +|---|---|---| +| VehStuckProc_Warp_MoveDustPuff | 800683f4 | Match (recursive midpoint-displacement: mid[i] = (points[i]+end[i])>>1 + (jitterScale[i]·jitter_i>>0xc); jitter = ((MixRNG&0xfff)·radius>>0xc), −radius if 2 with radius·0xc00>>0xc = 0.75x) | +| VehStuckProc_Warp_AddDustPuff1 | 800685b0 | Match (timer&1 → return; Particle_Init(iconGroup[1], emSet_Warppad); axis[0..2].startVal += pos.v[i]<<8) | +| VehStuckProc_Warp_AddDustPuff2 | 80068644 | Match (structural + all constants) — SetRot/TransMatrix(matrix_ViewProj); jitterScale[i]=(camTranspose.m[i][0]+m[i][1])>>5; offset[i]=m[i][0]>>10; HIDE_MODEL 0x80 → endpoint dust; 6-arc loop baseAngle=(ring<<12)/6+warp[3], helix ±MATH_Sin/Cos>>5(/6/8), MoveDustPuff(16, 0x100), sine-jitter points[i].y+=Sin(i<<7)>>7, VehWarpDust_Project (=retail ldv3/rtpt/stsxy3c, left=pt+offset right=pt−offset, MFC2 12/13/14/17) + VehWarpDust_EmitSegment (G4 gouraud quads drawMode 0xe1000a20, code 0x3a000000, arc color 0x7f1f3f, OT insert depth>>6, tag \|0x11000000); primMem.cursor=prim. Project's typed structs/helpers reproduce retail's scratchpad-aliased inline GTE) | + +## Vehicle module — VehStuckProc (part 7: Warp PhysAngular/Init) DONE (2026-07-05) — CLEAN (0 fixes). VehStuckProc.c COMPLETE. + +| Function | Address | Verdict | +|---|---|---| +| VehStuckProc_Warp_PhysAngular | 80068be8 | Match (=Ghidra ElecDrop_Update, funcPtrs[4]/PHYS_ANGULAR slot). !HIDE_MODEL: beamHeight=max(posCurr.y+0x100, quadHeight), numParticle−=100, AddDustPuff2/RenderArcs; warpTimer=timer+26 (retail phaseTimer via maskObj[1].duration ptr math = +0x1a); ≤800: scale InterpBySpeed(120, [4800,2400,4800]), posCurr.y+=0x800 if 800: clamp 800, revEngineState=2, scale InterpBySpeed([600,3200,600], [0,24000,0]), scale.x==0 → FLARE_Init(posCurr.xz, quadHeight>>8+0x40) + HIDE_MODEL, else heightOffset−=0x1800 posCurr.y+=heightOffset; turnAngleCurr=((·+warpTimer+0x800)&0xfff)−0x800, rotCurr.y=turnWobble+angle+·, ACTION_WARP. Warp.timer/heightOffset/quadHeight/beamHeight/numParticle alias EngineRevving/Drifting union slots) | +| VehStuckProc_Warp_Init | 80068e04 | Match (=Ghidra ElecDrop_Init; kartState!=WARP_PAD guard; Warp.timer=0x3c/heightOffset=0/quadHeight=quadBlockHeight; OtherFX 0x97, Stop1 driverAudioPtrs[1/2/0], EngineAudio_Stop(MetaDataCharacters[characterIDs[id]].engineID·4 + id); cameraMode=CAM_MODE_FIXED_POSE 3; inst REFLECTIVE + vertSplit=quadBlockHeight>>8; kartState=WARP_PAD, speed/speedApprox=0; funcPtrs [3]=Audio [4]=Warp_PhysAngular [10]=TranslateMatrix [11]=VehFrameProc_Driving [12]=VehEmitter, rest NULL; ACTION_WARP) | + +## Vehicle module — VehTalkMask (ThTick/Init/PlayXA) DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| VehTalkMask_ThTick | 80068f90 | Match (Aku/Uka talking-hint mask tick: model = modelMaskHints3D @2x scale 0x2000, else drivers[0] MaskBoolGoodGuy → modelPtr[STATIC_UKAUKA 0x3a − good] (retail byte-offset 0xe4/0xe8) @1x scale 0x1000; scale=nScale·scale>>0xc; talkMaskXASamplePeak=XA_MaxSampleValInArr, target=peak·7 (+0x3fff if<0)>>0xe → talkMaskMaxMouthFrame; desired=target<2?0:target; two-stage EngineSound_VolumeAdjust ease w/ snap (target>3 & \|Δ\|>3 pre-snap; \|Δ\|<6 post else snap); clamp [0, numFrames−1]; talkMask_boolDead → THREAD_FLAG_DEAD 0x800) | +| VehTalkMask_Init | 80069178 | Match (boolIsMaskThreadAlive=1, talkMask_boolDead=0; BirthWithThread(0x39, s_head, SMALL, AKUAKU 0xe, ThTick, 6, NULL); funcThDestroy=PROC_DestroyInstance; MaskHint.scale=0) | +| VehTalkMask_PlayXA | 800691e4 | Match (drivers[0] & MaskBoolGoodGuy==0 → id+=0x1f Uka voice set; CDSYS_XAPlay(CDSYS_XA_TYPE_EXTRA 1, id); inst param unused) | + +## Vehicle module — VehTalkMask (boolNoXA/End) DONE (2026-07-05) — CLEAN (0 fixes). VehTalkMask.c COMPLETE. + +| Function | Address | Verdict | +|---|---|---| +| VehTalkMask_boolNoXA | 8006924c | Match (return XA_State == 0 (=g_eXaState == XA_STOPPED)) | +| VehTalkMask_End | 8006925c | Match (CDSYS_XAPauseRequest; boolIsMaskThreadAlive(=g_bMaskHintThreadAlive)=0, talkMask_boolDead(=g_bMaskHintDead)=1 — ThTick observes next tick → THREAD_FLAG_DEAD) | + +## Vehicle module — VehTurbo DONE (2026-07-05) — CLEAN (0 fixes, 1 benign reorder). VehTurbo.c COMPLETE. + +| Function | Address | Verdict | +|---|---|---| +| VehTurbo_ProcessBucket | 80069284 | Match (per turbo thread, per playerinst then turbo->inst; retail turbo->inst then thread->inst — two distinct instances, no observable effect) | +| VehTurbo_ThTick | 800693c8 | Match — driver alpha>>1 if flame opaque & !GHOST; SPLIT_LINE 0x2000/REFLECTIVE 0x4000 + vertSplit propagate to both flames; fireSize clamp 4..8, flame1 matrix=driver.m·fireSize>>3, flame2 m[i][0] negated (X-mirror); translation via VehTurbo_TransformOffset (=retail ldVXY0/ldVZ0/rt/stlvl, VX scale·9>>0xb / −0x12>>0xc, VY scale·3>>8, VZ scale·−0x34>>0xc); fireVisibilityCooldown−=elapsed clamp 0 → clear HIDE_MODEL; ShockFreq alpha<0x9c4; model cycle modelPtr[fireAnimIndex+STATIC_TURBO_EFFECT 0x2c], flame2 (+3&7); fireDisappearCountdown−−; DYNAMIC_PLAYER engine loop OtherFX_RecycleNew(0xe, vol 0x100−alpha>>4 clamp 0x82, distort fireAudioDistort+0x10 clamp 0x80<<8, ENGINE_ECHO 0x10000→0x1000000); fade GHOST\|\|(!MASK_GRABBED&!CRASHING&!WARP_PAD[BUILD>SepReview]) reserves<0x10\|\|countdown==0 → alpha+=0x100/0x40, else restore alphaScaleBackup + stop sound + THREAD_FLAG_DEAD 0x800. Project return replaces retail ThTick_FastRET tail-call) | + +## WorldRender module — DrawSky + AnimateQuad DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| DrawSky_Full | 80069bb0 | Match (skybox!=NULL: CTC2 ViewProj[0/4/8/c/10]→regs 0-4 (ldR11R12..ldR33), ldtr(0,0,0); scroll baseFaceOffset=((rot[1]+0x500)>>7)&0x1c, baseCountOffset=(>>1)&0xe → scratchpad 0x10/0x14; ctx {verts=sky->ptrVertex, ot=ptrOT[0x3ff], screenBounds=pb[0x20]}; 4 DrawSky_Piece bands base/+4/−4/−8 mod 0x20 &0x1c; primMem->cursor=prim) | +| DrawSky_Piece | 80069cc4 | Match (=Ghidra DrawSky_RenderTris; numFaces=skybox->numFaces[countIndex], face=ptrFaces[faceIndex]; per face: LoadFaceVertices A/B/C → GTE v0/1/2(regs 0-5)+RGB0/1/2(20/21/22), rtpt, MFC2 SXY 12/13/14 + FLAG 31; IsVisible ((flag<<13)>>29==0 & ~(sxy−bound)\|overlap sign checks == retail uClipReject) → POLY_G3 0x06000000 emit, OT link via (s16)face->D depth. Project simple loop = retail software-pipelined) | +| AnimateQuad | 80069e70 | Match (gte_ldfcdir(0,0,0)=CTC2(0,21/22/23); block-counted 32-bit visibility walk, load new visList word every 32 verts; AnimateQuadVertex per vert) | +| AnimateQuadVertex | 80069f0c | Match (visBits bit0 gate + >>1; GetTrig((flags&0x3fff)+timer) == VehPhysForce trig all 4 quadrants; flags<0 → pos.xy += sin>>7 keep hi16, pos.z += cos>>7; flags&0x4000 → gte_dpcs IR0=(sin+0x1000)>>2, RGB=offset_color_rgba, RGB2→color_hi/color_lo) | + +## WorldRender module — TRIG + DrawConfetti DONE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| TRIG_AngleSinCos_r19r17r18 | 80069f94 | Match (retail decompile is a register-passing dead-end — only 0x400/0x800 quadrant branches visible; project wraps shared TRIG_AngleSinCos_Common whose quadrant decomposition (0,0)→(s,c) (0,1)→(−s,−c) (1,0)→(cos,−sin) (1,1)→(−cos,sin) == AnimateQuad_GetTrig == VehPhysForce trig. Register convention (s3 in, s1/s2 out) abstracted to pointer outputs) | +| TRIG_AngleSinCos_r15r16r17 | 8006a4c4 | Match (same TRIG_AngleSinCos_Common wrapper; retail t7 in, s0/s1 out) | +| DrawConfetti | 80069ffc | Match (winner's-circle confetti POLY_F4 billboards. **RNG bit-exact**: seed (0x30125400,0x493583fe)→NextRng reproduces retail pre-loop scramble 0x95000000/0xb9000000/0x59000000 + next-state 0x3583fe30/0x59b99549 = uVar28/puVar30/puVar32/uVar8/uVar11. 36B color ramp→scratchpad 0x58; ldtr(0,0,0)+ViewProj regs 0-4; camera trig centerX/Z=(sin/cos>>2)+0x400; OFX/OFY=rect<<15, H=distToScreen; particle-count vanish toward max; per-particle >>21 scramble pos, 2-axis tumble (angle<<4, <<7) via TrigAngleSinCos, BuildColor 6-cycle, 4-corner quad halfX/Y/Z+skewX/Z, rtpt_b/rtps_b/avsz4_b, POLY_F4 0x05000000 OT emit at depth>>18. Faithfully reproduces retail bounds quirk: 4th SXY used **raw**, not sxy3−screenBounds) | + +## RenderBucket module part 1 (2026-07-05) — 1 FIX + +**926-build note:** many RenderBucket draw functions are **stubbed in the 926 retail** (no Ghidra function): DrawFunc_Normal (0x8006a52c), DrawInstPrim_Normal (0x8006ad6c), DrawInstPrim_KeyRelicToken (0x8006ae90), DrawFunc_Split (0x8006b030), DrawFunc_Special (0x8006bbc0), + ~40 more per syms note. Not verifiable against this build — the port reconstructs them from aalhendi's ASM analysis (RB_RETAIL_* label addresses). Only the present functions are compared here. + +| Function | Address | Verdict | +|---|---|---| +| RenderBucket_InitDepthGTE | 8006ae74 | Match (=Ghidra GTE_SetDepthAndAvgZConsts; CTC2(0,27)=ldDQA, CTC2(0,28)=ldDQB, CTC2(0x555,29)=ldZSF3, CTC2(0x400,30)=ldZSF4 — fog off, avg-Z /3 and /4) | +| RenderBucket_Execute | 8006aaa8 | Match + 1 FIX (native splits into Execute/PrepareDrawContext/DispatchDrawFunc. idpp=inst+0x74 verified (retail iRenderCtx+0x74/0x78/0xb8 = idpp pushBuffer/mvp/instFlags @ 0x0/0x4/0x44). Loop skeleton, pushBuffer-change geom reload (SetGeomOffset(w>>1,h>>1)==ldOFX(w<<15) even dims), SetRotMatrix/SetTransMatrix(mvp), bbox from mf/nextFrame pos (puBbox/psBboxDelta = &mf->pos/&nextFrame->pos), 0x7000 light-matrix+split scratch, funcPtr[0] setup callback, drawfunc dispatch all match. Benign: entry-advance via entry++ vs re-reading scratch 0x04; primMem->cursor write distributed into draw funcs vs centralized. **FIX**: scratch 0x4c stored inst->funcPtr[3] (inst+0x68) but retail stores *(inst+0x58)=reflectionRGBA — dead write today, corrected) | +| RenderBucket_UncompressAnimationFrame | 8006a8e0 | Partial (register-passing per-vertex temporal-delta decompressor, continuation into stubbed-in-926 draw funcs; absolute path == native RenderBucket_GetSignedBits bitstream reader per aalhendi NOTE 0x8006a92c-0x8006aa30; full temporal-prediction path embedded in the reconstructed draw pipeline) | + +### Fix site 101 +- **game/RenderBucket/RenderBucket_QueueExecute.c** `RenderBucket_PrepareDrawContext` (0x8006aaa8 body) — scratch 0x4c write changed `inst->funcPtr[3]` → `inst->reflectionRGBA`. Ghidra stores `*(iInst + 0x58)`; inst+0x58 is reflectionRGBA (funcPtr[3] is inst+0x68). Dead write in current native (reflectionRGBA read directly at lines 3705/4052/4574), corrected for retail scratch-state fidelity. Build green. + +## MATH matrix-math region (2026-07-05) — CLEAN (0 fixes) + +**926-build note:** stubbed/not-found in 926 (project impl present, follows verified helper pattern, unverifiable against this build): UncompressAnimationFrame_NextFrame 0x8006b24c, UncompressAnimationFrame_Split 0x8006bf30, ConvertRotToMatrix_InverseTranspose_NoRotY 0x8006c124, ConvertRotToMatrix_InverseTranspose 0x8006c1d0, Unknown_8006c6c8, MATRIX_SET_r11r12r13r14r15 0x8006c540. ClipTri_NearDispatch 0x8006b4c8 present but $at-register-context cluster with unrecovered jumptable (0x8006b960) — reconstructed inline in native split-clip, structural-only. + +| Function | Address | Verdict | +|---|---|---| +| ConvertRotToMatrix | 8006c2a4 | Match (**assembly-traced**). Builds M=Ry·Rx·Rz. Y: r0=cosY,r1=sinY,r2=0x1000,r3=-sinY&0xffff,r4=cosY→LoadRotWords(0x8006c540); MulIfNonZero(rotX,axis0)+MulIfNonZero(rotZ,axis2) via MulRotWords(0x8006c49c)→StoreWords. Delay-slot subtlety confirmed: `move t5,s2` (m11=cosZ) is in the jal-delay-slot BUILDING Rz before MulRotWords overwrites r2 with product; final `sw t5` stores product m11=cosX·cosZ, not cosZ (Ghidra decompile's post-MulMatrix `uM11M12=s2` was a scheduling artifact). Helper MATH_Matrix_TrigSinCos == TRIG_AngleSinCos_r16r17r18_duplicate all 4 quadrants | +| ConvertRotToMatrix_Transpose | 8006c378 | Match (=InverseTransposeBody(m,-rotX,-rotZ,-rotY) → Rz(-z)·Rx(-x)·Ry(-y) = (Ry·Rx·Rz)^T. First trig rotZ builds Rz(-z), axis-0 gated rot->x, axis-1 gated rot->y = retail m[0][0]/m[0][1]. Negation via register-passed angle, invisible in decompile, mathematically required) | +| MatrixRotate | 8006c3b0 | Match (=Ghidra MATH_MatrixMultiplication; load src→GTE via LoadRotWords, read rot words, MulRotWords(GTE_src·rot), StoreWords→dst. Retail decompile's word-read/store is a register-passed-GTE artifact; native uses the assembly-verified MulRotWords helper) | +| SquareRoot0_stub | 8006c618 | Match (base-4 digit-by-digit isqrt via GTE ldLZCS/stLZCR; 16 digits, per-digit trial=(root<<2)+1 old-root, success→root++/nextRem<<2 else rem<<2. Native single do-while == retail nested loops, bit-counter iteration count verified) | +| ApplyMatrixLV_stub | 8006c6f0 | Match (=Ghidra MatrixVecMultiply; 32-bit-precision M·v = high(v>>15, rtir_sf0) + low(v&0x7fff, rtir)·... recombined low + high<<3, reads MAC1/2/3 regs 25/26/27) | + +## ClipTriCb cluster + AnimateWater (2026-07-05) — CLEAN (0 fixes) + +**926-build note:** stubbed/not-found in 926 (reconstructed in native RenderBucket, unverifiable against this build): 0x8006c778 (lit-texture prim), 0x8006c928/c948/c974/c984 (inst color-setup SetLightColor/SetColor/ZeroColor/FadeColor), 0x8006c9c4 Draw_KartBodyReflection, 0x8006cdec UncompressAnimationFrame_ReflectNextFrame, 0x8006d404 WaterSplitInterpWhite, 0x8006d428 WaterSplitInterpColor, 0x8006d670 DrawInstPrim_Ghost. **ClipTriCb cluster** present but $at-register-context + unrecovered jumptables (NOT decompiled): ClipTriCb_Dispatch 0x8006d094, ClipTriCb_StoreVerts 0x8006d258, ClipTriCb_XfmEmit1/2 0x8006d2bc/d324, ClipTriCb_Emit1/2 0x8006d38c/d3c8 — near-plane triangle clipper reconstructed inline in native split-clip path, structural-only. + +| Function | Address | Verdict | +|---|---|---| +| ClipTriCb_InterpVertex | 8006d4a4 | Match (interp portion; = native RenderBucket_WaterSplitInterpolateVertex. factor=(s32)((s16)splitDist<<16)/((s16)t1.y−(s16)t0.y) signed; x=InterpS16 short@0, z=interp int@4, uv bytes@12/@13 gated by textured flag @ctx+0x50. Color-select white/interp folded from stubbed 0x8006d404/0x8006d428) | +| AnimateWaterVertex | 8006db7c | Match (vis-bit gate+shift moved into worker; RGB unpack (first&0x3f)\|((first&0xfc0)<<2)\|((first&0xf000)<<8)\|((first&0xf000)<<4)→MTC2 reg6; RFC/GFC/BFC=CTC2 21/22/23 from second; dpcs; color_lo=(s16)MFC2(22)+colorOffset; color_hi=grayscale replicate (c<<16)\|(c<<8)\|c) | +| AnimateWater1P | 8006d79c | Match (**assembly-traced** 0x8006d79c-0x8006d864). waterVert--; firstFrame=((u32)timer>>3)%28 (divu, unsigned); secondFrame=(firstFrame+1)%28; colorOffset=lhu waterEnvMap; IR0=MTC2((timer&7)<<9,8); firstOffset/secondOffset=frame*2; block-of-32 vis loop (t6 0x20→0x1f wrap, reload *visList++), calls AnimateWaterVertex. = native AnimateWater_Common(numLists=1) | +| AnimateWater2P | 8006d864 | Match (numLists=2 instance of verified _Common; OR 2 vis-lists) | +| AnimateWater3P | 8006d948 | Match (numLists=3 instance) | +| AnimateWater4P | 8006da50 | Match (numLists=4 instance) | + +## RenderWeather RedBeaker + RenderStars (2026-07-05) — CLEAN (0 fixes) + +**926-build note:** 0x8006d5b8 Unknown_8006d5b8 stubbed (not found). + +| Function | Address | Verdict | +|---|---|---| +| RedBeaker_RenderRain | 8006dc30 | Match (heavy-rain LINE_G2 streaks, 2 passes/particle. RNG RedBeaker_NextRngByte byte-identical to DrawConfetti (iter135 bit-exact); NextRng packs xy=(xSeed>>24&0xffff)\|(ySeed>>7), z=zSeed>>23. Offsets via struct_Item aliasing: idpp=cloudInst, TRX/TRY/TRZ instBase 0x8c/0x90/0x94, scroll/vel rainLocal 0x0c/0x10/0x14/0x18, frameCount 0x08. Height-fade fade=0xff if cloudZ<0x400 else 0xff-((cloudZ-0x400)>>3&0xff); top=fade-fade>>2; colorTop=top\|top<<8\|fade<<16, colorBottom=fade replicate (byte3=0 both); OT offset clamp; wrap-span checks (xy1-xy0==velXY, z1-z0==velZ); LINE_G2 0x52/0x04000000; **RNG consumption 2/projected (extra post-rtpt scramble) 1/rejected**; both passes re-seed 0x30125400/0x493583fe, tpages 0xe1000a20/0xe1000a40) | +| RenderStars | 8006e26c | Match (**bit-exact RNG**: seed 0x30125400/0x493583fe → NextRngSelfOr(OR mixed)+NextRngWithOr(OR seedX) → seedX=0x1E000000, seedY=0x87000000, seedZ=0x14000000. starX/Z=seed>>20, starY=seed>>(seed+20&0x1f); abs+spread flip; partial-sort Manhattan scale=0x1000000/(max+s1>>2+s2>>2); MulLowShift=(s64 mul low32)>>12; rtps; color 0x68 grayscale (seed>>24\|0x40); 3-cond visibility clip>=0/uVar10<0/(~uVar10<<16)>=0; TILE_1 0x02000000; 0xe1000a20 tpage cap) | + +## RenderTires DrawTires_Solid + isqrt + TRIG (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| DrawTires_Solid | 8006e588 | Match (structural + concrete spot-checks; GTE-saturated + unrecovered corner-emit jumptable 0x8006ed74, aalhendi-ASM-verified, native factored into StagePlayer/BuildWheelLocalPairs/SetupGteState/BuildWheelAxes/ProjectWheelQuads). Verified: lodThreshold=numPlyr<=2?2:0; jumptable copy (8→scratch 0x130); wheel scale mults wheelX=scale.x*0x90>>12, wheelY=scale.y*0x40>>12, frontZ=scale.z*0xc7>>12, rearZ=scale.z*-0x60>>12; BuildWheelAxes lcv0tr/sqr0/isqrt/lcv1 normalize invLen=-(0x10000/len) ×4 wheels; SelectSpriteIndex angle>>5/abs/clamp0x80/LUT + jumpIndex+=4 when IR1<0 (=retail (IR1>>26)&0x10); DRAW_SUCCESSFUL(0x40)/HIDE_MODEL(0x80)/lod/PUSHBUFFER_EXISTS(0x100)-tireColor(0x2e808080)/pb-null gates; steering=wheelRotation<<2, hazard 4-quadrant trig shift 9/6. Retail register-aliasing (uVar5/uVar6, pCam=idpp mislabel) blocks per-line bit-check) | +| TRIG_AngleSinCos_r9r8r10 | 8006ef30 | Match (register-passing wrapper (t1 in, t0/t2 out) around TRIG_AngleSinCos_Common; 0x400/0x800 quadrant branch structure visible, computation register-passed) | +| Unknown_8006ef98 | 8006ef98 | Match (=SquareRoot_LZC; s5-in/s6-out base-4 digit isqrt, same algorithm as SquareRoot0_stub iter137; software CountLeadingSignBits&0x1e == GTE ldLZCS/stLZCR for non-neg radicands; JAL'd 3×/wheel by DrawTires_Solid/_Reflection to normalize) | + +## RenderTires DrawTires_Reflection (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| DrawTires_Reflection | 8006f004 | Match (**assembly-traced**; reflection twin of DrawTires_Solid, native factored identically). Reflection-specific diffs all confirmed vs disasm: (1) **retail delay-slot bug faithfully reproduced** — 0x8006f0e0 `lh v1,0x1c(a3)` loads scale.x but 0x8006f0e8 beq-delay-slot `andi v1,t0,0x100` clobbers it before `mult v1,0x90` @0x8006f10c, so wheelX=(instFlags&0x100)*0x90>>12 (degenerate 0 or 9, native line 176 reproduces w/ NOTE; Solid has no such hazard); (2) gate REFLECTIVE 0x4000 (not HIDE_MODEL 0x80); (3) jumptable 0x8008a364; (4) Y-mirror centerY=2*splitCameraY−centerY (two-step subu/subu @0x8006f38c-90); (5) rimY=−IR2 @0x8006f3e0; (6) OT range from otRangeSecondary 0x44 @0x8006f730; (7) splitDelta<0 cull @0x8006f8d4 `subu t3,t4,t3`/`bltz` (splitCameraY−centerY); (8) mirrored SXY scratch order 0x11c/0x118/0x124/0x120. Shares SquareRoot_LZC (3×/wheel), TRIG_r9r8r10, SelectSpriteIndex/LUT with Solid. GTE-saturated + unrecovered jumptable 0x8006f7f0, aalhendi-ASM-verified) | + +## RenderWeather + TRIG (2026-07-05) — 1 FIX + +| Function | Address | Verdict | +|---|---|---| +| RenderWeather | 8006f9a8 | Match + 1 FIX (rain/snow LINE_G2 streaks. RNG NextRngByte = DrawConfetti (bit-exact); NextRng xy=(xSeed>>21&0xffff)\|(ySeed>>5), z=zSeed>>21. Verified: ldtr(0,0,0)+ViewProj; centerX/Z=(sin/cos>>2)+0x400, scratch0x38=centerX\|0x4000000; vanish-toward-max; scroll step (scrollXY/Z+vel, scrollXYStart=+vel>>1); camera smoothing cameraCorrectionXY=((Δ>>20)<<17)\|(((Δ<<16)>>19)&0xffff), smoothedZ=z−((z−prevZ)>>3); span-wrap checks; LINE_G2 0x52/0x04000000; RNG 1/particle+extra-on-project; drawmode fillMode 0x02000000. **FIX**: startXY/endXY added packed scratch-0x38 (centerX\|0x4000000) but asm 0x8006fb64/68 add BARE centerX (s1)) | +| TRIG_AngleSinCos_r16r17r18 | 8006fe08 | Match (register-passing wrapper (s0 in, s1/s2 out) around TRIG_AngleSinCos_Common; = RenderWeather_TrigAngleSinCos inline quadrant logic) | + +### Fix site 102 +- **game/RenderWeather/RenderWeather.c** `RenderWeather` (0x8006f9a8) — startXY/endXY changed `+ *scratchPackedCenterXY` → `+ centerX` (bare (sin>>2)+0x400). Ghidra `addu s3,s3,s1`/`addu s5,s5,s1` @0x8006fb64/68 use the bare centerX register; the packed 0x38 value (with the 0x04000000 packing bit) is only for the loop's scratch-0x38 subtract. The extra bit-26 survived the 0xfffeffff/0x07fe07ff masks → +0x400 Y shift on every streak + altered wrap-cull. Build green. + +## RenderLevel RenderLists BSP builders (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| RenderLists_PreInit | 800702d4 | Match (copy 8 corner-selector jump ptrs 0x80070310..0x8007037c → scratchpad 0x84; = RenderLists_SelectBoxCorner) | +| RenderLists_Init1P2P | 8006fe70 | Match (native factors into Load1P2PGteState+Walk1P2P+helpers). Verified: GTE setup SetRotMatrix/SetTransMatrix/SetGeomOffset(w>>1,h>>1)≡ldOFX(w<<15)/SetGeomScreen; lodThreshold=numPlyr==1?scratch0x18:0x1540; child-link split (low/high, bit15=invalid, visLeafList[(id>>5)&0x1ff]<<(id&0x1f)<0); 6-plane AABB overlap cull vs pb->bbox (box min.x/y/z,max.x/y/z); 0x10-byte scratch stack @0xd0 push/pop; 0x4000 leaf-flag branch/leaf dispatch; 4-plane frustum reject (llv0bk IR1>0); slot select water0x2→+0x24, dyn-subdiv0x20→+0xc, dist>thresh→+0x28, 4x4 0x80→+4, 4x1 0x8→+0x1c, 4x2 0x10→+0x14, else +0xc (all = slotIndex*8+4); SZ3 ProjectDistance LOD; prepend-link bspList[idx]. Benign: native adds defensive leaf-root special case (retail branch-first loop lacks it; degenerate) | +| RenderLists_Init3P4P | 80070388 | Match (= 1P2P minus numPlyr; simpler slot map water→+0x14, dyn-subdiv→+0x1c, 4x4→+4, else +0xc; omits SZ3 distance test per split-screen lower-detail) | + +## RenderBucket Queue walkers + helpers (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| RenderBucket_QueueLevInstances | 80070720 | Match (backward camera walk cDC[numPlyr-1..0] stride 0xdc=sizeof(CameraDC), visInstSrc @+0x28; prim cursor=otMem[3], OT limit=otMem[2]-4=end-1word, lod mask, gameMode1; InitClipContext=CopyDispatchTables; per-camera visInstSrc[] iterate non-null → QueueDraw; cursor writeback otMem[3]. Benign: native threads entry-tail return vs retail base-rbi + shared scratch 0x58 cursor — same entry array w/ caller chaining) | +| RenderBucket_QueueNonLevInstances | 8007084c | Match (sibling; walks item->next linked list reset to head per camera instead of visInstSrc[] arrays; same scratch setup + QueueDraw) | +| RenderBucket_InitClipContext | 80071590 | Match (= native RenderBucket_CopyDispatchTables; copies 8008a428[0..7]→scratch0x94, 8008a444[0..7]→scratch0xc4 — ClipTri/ClipTriCb dispatch tables) | +| RenderBucket_BoundsUpdate_80071524 | 80071524 | Match (**assembly-verified**; Ghidra plate "DrawFunc_Null — return" is a decompiler artifact for the register-passing $t/$s convention. Real fn: bbox min/max update — y=(s16)(sxy>>16), x=(s16)sxy, z=depth; each `subu;bgez→min=v` (diff<0) / `subu;blez→max=v` (diff>0) per axis. Native RenderBucket_BoundsUpdate_80071524 correct) | + +## RenderBucket_QueueDraw (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| RenderBucket_QueueDraw | 80070950 | Match (structural + readable-piece spot-checks; **IRREDUCIBLE register-passing GTE dead-end** — decompile heavily corrupted (12 unreachable-block removals, artifact 0x8009cf6c-as-mask). Native factors into ~20 per-address-verified helpers. Confirmed vs decompile: overall sequence LOD-gate→pb-null→ownerPB gate→viewpos→alpha reject→near-scale(viewDepth<0x1000→<<2)→MVP tr→SelectModelHeader LOD ((rect.w>>1)*viewDepth mult-low32 /H, stride 0x40, vs mh->maxDistanceLOD@0x14)→m3x3→frame→split→AcceptProjectedBounds (maxX/Y≥0, maxZ−geomHalf≥0, minX≤rect.w, minY≤rect.h)→SelectRetailHandlers drawfuncs 0x8006a52c/b030/bbc0/c9c4 + uncompress 0x8006a8e0/b24c/bf30/cdec + reflection\|0x100000→DRAW_SUCCESSFUL 0x40→WriteInstanceCallbackLabels. Struct: unaff_s8=instPlayerBase, register0x74=inst; rbi->inst=inst, rbi->instPlayerBase=instPlayerBase; idpp unkEC@0x78/unkF0@0x7c/ptrCommandList@0x54/ptrTexLayout@0x58/ptrColorLayout@0x5c/instFlags@0x44; return rbi+1) | + +## PROC ThTick scheduler (2026-07-05) — CLEAN (0 fixes) — resolves carried deferred item + +| Function | Address | Verdict | +|---|---|---| +| ThTick_RunBucket | 800715e8 | Match (=Ghidra ThreadBucketTickAll; native pending[128] array stack reproduces retail scratchpad-RAM DFS @0x1f8000e4). Push conditions disambiguate offsets: *(t+0x10) pushed UNCONDITIONALLY=siblingThread, *(t+0x14) pushed on-tick(cooldown==0)=childThread → native push siblingThread (pre-cooldown-check) then childThread (post-tick); same LIFO (pop child before sibling), cooldown<0 skip / cooldown>0 dec / cooldown==0 tick funcThTick(t) via +0x2c. Semantics: siblings always traversed, children gated on parent tick. Benign: 128-cap vs scratchpad ~198 slots) | +| ThTick_FastRET | 80071694 | Match (intentional native no-op; retail is the tail-call trampoline ThTick handlers jump into to continue traversal — native replaces with plain handler returns driven by RunBucket while-loop. Same threads/order) | +| ThTick_SetAndExec | 800716ec | Match (thread->funcThTick=fn; fn(thread) — set + immediate exec; retail tail-call → native direct call) | +| ThTick_Set | 80071704 | Match (thread->funcThTick=fn; tiny sw+jr) | + +## Psy-Q SDK GTE/math primitives (2026-07-05) — CLEAN (0 fixes) — MAIN-EXE SWEEP BOUNDARY + +These live in **platform/native_libgte.c** (port runtime, not /game) but are the GTE/math primitives the game relies on. + +| Function | Address | Verdict | +|---|---|---| +| RotTrans | 8007170c | Match (native → gte_RotTrans = gte_ldv0(v0) + gte_rt (doCOP2 0x0480012 == retail copFunction(2,0x480012)) + gte_stlvnl(v1) + gte_stflg(flag)) | +| ratan2 | 8007173c | Match (table-lookup atan2 ratan_tbl=DAT_8008a480; y=x→1024−tbl[ang]; overflow-guard ang=(v<<10)/other if (v&0x7fe00000)==0 else v/(other>>10); quadrant xlt0→2048−v, ylt0→−v. Retail RATAN_OBJ_150/B4/13C = decompiler-outlined shared fixup, native inlines. Omits PSX div-overflow trap() as benign host divergence) | +| SetTransMatrix | 800718dc | Match (native → gte_SetTransMatrix = gte_ldtr(m->t[0..2])) | +| SetRotMatrix | 800718fc | Match (native → gte_SetRotMatrix = gte_ldR11R12..ldR33 m[0..2]) | + +**MILESTONE — main-address-space (ram) /game sweep complete at the SDK boundary.** Everything 0x8007170c → ~0x800a0000 is Psy-Q SDK ("// libs in the exe": RotTrans/ratan2/matrix, memset/strcmp/strncpy libc, ResetGraph/DrawOTag/DrawSync/LoadImage/AddPrim/VSync GPU, PadGetState/PadInitMtap, Spu*/Cd*) — host-provided in the port (native_libgte.c, native_libgpu.c, host libc, SDL/platform pad-CD-SPU), not CTR game source. + +**NEXT PHASE — overlay game code.** Program SCUS_944.26 has **13 overlay spaces** OVR_221..OVR_233 (2209 total functions; overlay fns unnamed FUN_OVR_xxx__, addressed as `OVR_xxx::800axxxx`). These hold game/226-233 overlay modules (CC/AA/RR/TT/VB level scripts, X1-X4, MM menu, RB weapons, AH hub, CS cutscenes — syms 0x800a0000-0x800b97fc). Verify by: take name+addr from syms926 (e.g. CS_Credits_Init 0x800b8f8c → game/233), decompile via `OVR_2xx::`, compare to project game/2xx/*.c. + +## OVERLAY PHASE begins — game/233 CS credits (2026-07-05) — CLEAN (0 fixes) + +Overlay fns decompile via `OVR_233::` and carry aalhendi "VERIFIED vs decomp" plates. game/233 = OVR_233 (cutscene/credits). + +| Function | Address | Verdict | +|---|---|---| +| CS_Credits_IsTextValid | OVR_233::800b92a0 | Match (epilogue_topString!=0→return 0; else countdown=360, return 1) | +| CS_Credits_GetNextString | OVR_233::800b8810 | Match (scan for '\r' → return str+1, else NULL at '\0'; native adds benign null-ptr guard) | +| CS_Credits_NewDancer | OVR_233::800b92cc | Match (dancer swap: kill old thread flags\|=0x800, HIDE_MODEL new inst, countdown=360; string-table idx retail ptrStrings+charIdx*4−0x1f8(<0x8f)/−0x1fc(≥0x8f) == native ptrStrings[modelID−STATIC_CRASHDANCE(0x7e)]/−1 w/ STATIC_TAWNA1=0x8f; epilogueCount200=200, nextString=GetNextString, epiloguePosX=0x200. Benign DancerThread=0 clear + field-order swap) | + +## OVR_233 game/233 CS credits part 2 (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_Credits_DestroyCreditGhost | OVR_233::800b885c | Match (INSTANCE_Death creditGhostInst[0..4] (retail iOffset>>0xe=i*4) + MEMPACK_ClearHighMem) | +| CS_Credits_NewCreditGhosts | OVR_233::800b9398 | Match (return 1 iff all 5 creditGhostModel[i]==dancerInst_invisible->model) | +| CS_Credits_End | OVR_233::800b93f4 | Match (DestroyCreditGhost; CreditThread flags\|=0x800; boolAllBlue==0→GEM_STONE_VALLEY 0x19+gameMode1\|=ADVENTURE_MODE else mainMenuState=SCRAPBOOK(5)+levID=0x40; RequestLoad; renderFlags&=~0x4. Native adds CTR_NATIVE podium-audio restore handoff) | +| CS_Credits_AnimateCreditGhost | OVR_233::800b8668 | Match (ghost-trail copy: animFrame/animIndex, full matrix, scale=0x1000+(index+1)*300, HIDE_MODEL 0x80 gate, alphaScale=(index+1)*630; dup src->model into per-ghost buffers data_0x18_0x5[idx]=800b9764+idx*0x18 (6 words) + data_0x80_0x5[idx]=800b94e4+idx*0x80 headers, copy numHeaders×16-word ModelHeaders. Native struct-copy = retail word-by-word) | + +## OVR_233 game/233 CS credits part 3 (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_Credits_DrawNames | OVR_233::800b88c8 | Match (scrolling names: posY--/<-20→GetNextString+20; loop posY<0x114; ~NN~ escape parse value=digit2+digit1*10−0x30 (retail iColorIdx=value/conditional-restore == native if(value<0x32)charId=value else 0x34→0x2000/0x35→0x1000); edge-fade fadeAmount=posY near-top/0x96−posY near-bottom/20 mid, bNearEdge==fadeAmount<20; boolAllBlue color-fade into data.colors[CREDITS_FADE 0x1f], fade8=(fadeAmount<<8)/20, 4×3B scaled; DrawLineStrlen gate colorSlot>=0) | +| CS_Credits_Init | OVR_233::800b8f8c | Match (18-iter AND reduce reward bits: boolAllBlue=all sapphire relics(i+0x16), boolAllGold=all gold(i+0x28) via CHECK_ADV_BIT; all-gold→numWinners=1/250 confetti/renderFlags\|=4; PROC_BirthWithObject(0x30d,ThTick)+no-op ThDestroy stub 0x800b8f84; memset creditsObj+countdown=360; 5 ghost inst reversed[4-i] identity 0x1000 mtx+SCREENSPACE+idpp[0].pushBuffer=UI/idpp[1..]=NULL; string table ST1_CREDITS(7)→AllocHighMem+memcpy, relocate offsets→abs ptrs; credits_posY=340, topString=ptrStrings[0x14]) | + +## OVR_233 game/233 CS credits COMPLETE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_Credits_DrawEpilogue | OVR_233::800b8bd0 | Match (epilogueCount200--/<=0 reload200+advance nextString; fade timeRemaining<0xb5&&<=0x13→=timeRemaining / >=0xb5→200−t / else 20; boolAllBlue fade data.ptrColor[4]→data.colors[31], fade8=(fade<<8)/20; DrawMultiLineStrlen(top,len,0x100,0xaf,0x1cc,2,colorSlot\|0x8000)) | +| CS_Credits_ThTick | OVR_233::800b8dc8 | Match (creditDanceInst=dancerInst_invisible; if!=0: HIDE_MODEL + snap matrix.t=creditGhost_Pos; timer&3==0→shift train i=4..1 AnimateCreditGhost(inst[i],inst[i-1],i)+model[i]=model[i-1], then inst[0]=danceInst/model[0]=danceInst->model; else→inst[0]=danceInst + i=1..4 grow scale+=0x4b/alphaScale+=0x9d; countdown--; DrawNames+DrawEpilogue(co)) | + +**CS_Credits module COMPLETE** — all 11 fns verified (GetNextString, DestroyCreditGhost, AnimateCreditGhost, Init, IsTextValid, NewDancer, NewCreditGhosts, End, DrawNames, DrawEpilogue, ThTick), all Match. + +## OVR_233 game/233 CS_Podium_Prize (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_Podium_Prize_ThDestroy | OVR_233::800afe58 | Match (gameMode2 &= ~(INC_RELIC\|INC_KEY\|INC_TROPHY); PROC_DestroyInstance) | +| CS_Podium_Prize_ThTick1 | OVR_233::800afcc4 | Match (orbit/bob: PodiumInitUnk3→reveal(!BIG1)+InterpBySpeed orbitRadius→1/heightOffset→0x14; matrix.t=posBase+orbitRadius*sin/cos(rotY)>>12 +heightOffset; !isCutsceneOver→Spin else HUD handoff lerp 0xf/depthBias 0x80(=cOtDepth −0x80)/scale 0x1000/t=0,0,zDepth/idpp[0].pushBuffer=UI/FX stop 0xae,0xaf play 0x9a/SetAndExec ThTick2) | +| CS_Podium_Prize_Init | OVR_233::800afe90 | Match (BirthWithThread(model,name,MEDIUM,OTHER,ThTick1,0x2c); null→cutsceneState=CS_WAIT_INPUT+clear VEH_FREEZE_PODIUM; scale 0x2000+HIDE_MODEL; ThDestroy; orbitRadius=0x40,heightOffset=0x200,rot=0; llv0(0,0,0x40)→posBase=posOnScreen+MAC(Y+0x1c0),zDepth=-0x200; switch model: BIG1 placeholder / GEM cup-color (c0<<20)\|(c1<<12)\|(c2<<4)+spec{0x5d3,0x718,0x590,0x609} / RELIC plat(prevLEV+0x3a)0xffede90/gold(+0x28)0xd8d2090/sapphire 0x20a5ff0+spec{0x2ab,0x436,0x1eb,0x670}+INC_RELIC / TROPHY scale 0x4000+zDepth −200+INC_TROPHY / KEY 0xdca6000+spec{0x1d9,0x5db,0x2da,0x54b}+INC_KEY; HudDest hudStructPtr[0][0xe/0x10/0xf].xy−0x3c) | + +## OVR_233 game/233 CS_Podium_Prize COMPLETE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_Podium_Prize_ThTick2 | OVR_233::800afbc8 | Match (5-phase bounce: even grow scale+=800+phase*400 until >(phase+1)*0x28a+0x2000, odd shrink −800 until <0x1001, mirror y/z + Spin; phase==5→SetAndExec ThTick3) | +| CS_Podium_Prize_Spin | OVR_233::800af7c0 | Match (rotY+=100, ConvertRotToMatrix(&matrix,&rot); if!USE_SPECULAR_LIGHT ret; specLightSpinAccum+=0x3f (frozen while pad2 L1); tri=\|(accum&0xfff)−0x800\|; angleA/B=lo+(hi−lo)*tri>>0xb; **inlined-trig quadrant decomp verified all 4 quadrants both angles** (retail trigComp2/1 == native cos2/sine2); lightDir.y=cosA, .x=sinA*cos2>>12, .z=sinA*sine2>>12; Vector_SpecLightSpin3D) | +| CS_Podium_Prize_ThTick3 | OVR_233::800af994 | Match (lerpFrameCurr--; >0→lerp pos (hudDest+interp/frameMax−center)*±matrix.t[2]>>8, scale−=0x4b0 floor 0x1000, Spin; ==0→BoolGotoBoss==0?Aku hint priority Map0x18/Wumpa0x16/TNT0x17/HangTime0xe/PowerSlide0xf/Turbo0x10/BrakeSlide0x11 via advProgress.hintFlags→RequestMaskHint; overlayTransition=2, clear VEH_FREEZE_PODIUM, OtherFX_Play(0x67), THREAD_FLAG_DEAD) | + +**CS_Podium_Prize COMPLETE** — 6 fns (Init, ThDestroy, ThTick1, ThTick2, ThTick3, Spin), all Match. + +## OVR_233 game/233 CS_Podium Stand/FullScene (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_Podium_Stand_ThTick | OVR_233::800b021c | Match (isCutsceneOver→flags\|=THREAD_FLAG_DEAD) | +| CS_Podium_Stand_Init | OVR_233::800b0248 | Match (=SpawnStand; BirthWithThread(STATIC_PODIUM 0xa8,podium,SMALL 0x300,OTHER,Stand_ThTick,0); ThDestroy=PROC_DestroyInstance; matrix.t=podiumData[0..2]; depthBiasNormal/Secondary+=2 (=cOtDepthNear/Far); podiumData[12..14]=[8..10]; ConvertRotToMatrix) | +| CS_Podium_FullScene_Init | OVR_233::800b0300 | Match (audio backup FX/Music/Voice VolumeGet&0xff; isCutsceneOver=0/cutsceneState=0/PodiumInitUnk2,3=0; driver0 HIDE_MODEL+FreezeEndEvent_Init; numWinners=1/confetti 200/hudFlags&=0xfe/renderFlags\|=4/gameMode2\|=VEH_FREEZE_PODIUM; podiumPos=SpawnType2[1](Y+0x80)+ConvertRotToMatrix+SetLightMatrix; cameraDC[0].cameraMode=3; spawn 3rd(299,-0x55,yaw0x600)/2nd(-299,-0x2a,0x200)/1st(0,0,0)/Tawna(0x1a8,-0x80,0x140,-0x2aa) via CS_Thread_Init on podium_modelIndex_*; Prize_Init(podiumRewardID); Stand_Init; victorycam PROC_BirthWithObject(0x4030f); music switch(model−0x7e): {0,3,0xe}→10 {1,4,0xc}→8 {6,7}→7 {8,0xb}→0xb {9,10,0xd}→9 def→0xc; CDSYS_XAPlay. *(0x8008d2ac)+off == gGT fields (+0x1532 cameraMode, +0x2572 rewardID, +0x2575-78 place IDs)) | + +## OVR_233 game/233 CS_Instance helpers (2026-07-05) — CLEAN (0 fixes) — **800 functions milestone** + +| Function | Address | Verdict | +|---|---|---| +| CS_Instance_GetNumAnimFrames | OVR_233::800ac5a4 | Match (null/bounds walk model→headers[LOD] (LOD=numFrames→numFrames−1) | +| CS_Instance_BoolPlaySound | OVR_233::800ac694 | Match (desiredInst==NULL\|\|!(flags&0x1000)→1; scan cameraDC[0].visInstSrc for inst→idpp->instFlags&0x40 (retail desiredInst[1].matrix.t[0]=idpp+0x44); not found→0; native null-list guard) | + +## OVR_233 game/233 CS_ScriptCmd opcode reader COMPLETE (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_ScriptCmd_ReadOpcode_GetShort | OVR_233::800abf70 | Match (LE s16 read bytes[0]\|bytes[1]<<8, cursor+=2, sign-extend) | +| CS_ScriptCmd_ReadOpcode_GetInt | OVR_233::800abf9c | Match (LE u32 read bytes[0..3], cursor+=4) | +| CS_ScriptCmd_ReadOpcode_GetInt_dup | OVR_233::800abfd8 | Match (byte-identical to GetInt) | +| CS_ScriptCmd_ReadOpcode_Main | OVR_233::800ac014 | Match (opcode decoder: currOpcode!=prevOpcode guard; meta[0]=opcode byte; fieldMask=cs_opcodeMeta[byte]@0x800b14cc; 0x01/02/04→meta[1/2/3]=GetShort, 0x08→arg0(meta+4)=GetInt, 0x10→arg1(meta+6)=GetInt, 0x20→align4+GetInt_dup+cursor++, 0x40/80→meta[8/9]=GetShort, absent fields zeroed; prevOpcode=end cursor. table aalhendi-extracted) | +| CS_ScriptCmd_OpcodeNext | OVR_233::800ac1c0 | Match (prev=prevOpcode; prevOpcode=-1; currOpcode[0]=prev; Main) | +| CS_ScriptCmd_OpcodeAt | OVR_233::800ac1ec | Match (currOpcode[0]=opCodeAt; prevOpcode=-1; Main; native adds CS_OVR233_TranslateRetailOpcodePointer) | + +**CS_ScriptCmd opcode reader COMPLETE** — 6 fns, all Match. + +## OVR_233 game/233 CS_Instance/Podium teardown (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_Instance_InitMatrix | OVR_233::800ac214 | Match (one-time bake guarded cs_initMatrixBool; 4 groups {data,count}; per 0x20 entry ConvertRotToMatrix(rot@+8) + diag scale from +0x10/12/14, MatrixRotate(=MATH_MatrixMultiplication) scale*rot→entry+8) | +| CS_Instance_GetFrameData | OVR_233::800ac320 | Match (anim key sample: headers->ptrAnimations[animIndex]; 60fps if numFrames<0 (halve frame, odd→avg next); framePos=anim+frameSize*frame+0x18, bone=+offset*3+0x1c, byte Y/Z swap [2]/[1]; pos=(key+base)*inst.scale>>12 *header.scale@0x18/1a/1c>>12 via SetLightMatrix+llv0+read_mt MAC1/2/3; rotOut=ratan2(-dy,sqrt(dx²+dz²)),ratan2(dx,dz),0. Ghidra ptrCommandList field = overlay-struct mislabel, offsets match) | +| CS_DestroyPodium_StartDriving | OVR_233::800ac714 | Match (hudFlags\|=1; kill threadBuckets[OTHER 0xd] threads except funcThDestroy==CS_Podium_Prize_ThDestroy via flags\|=THREAD_FLAG_DEAD; driver0 thread&=~0x1000+inst&=~HIDE_MODEL, kartState=ENGINE_REVVING, funcPtrs[INIT]=VehPhysProc_Driving_Init; if CutsceneManipulatesAudio restore FX/Music/Voice VolumeSet; cameraMode=CAM_CHASE 0, tileView[0].distToScreen PREV/CURR=0x100) | + +## OVR_233 game/233 CS_Thread anim (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_Thread_AnimateScale | OVR_233::800ae2b8 | Match (if inst && scaleSpeed!=0: newScale=scale.x+scaleSpeed; speed>0 && >=desired \|\| speed<0 && <=desired → clamp desired + scaleSpeed=0; scale.x/y/z=newScale) | +| CS_Thread_InterpolateFramesMS | OVR_233::800ae318 | Match (GetFrameData(off0)→curr,(off1)→next both += matrix.t; guard packet+1>11, colorAndCode=gray\|0x42000000, OT depth>>6 clamp 0x3ff, tag *ot\|0x05000000 + Ptr24) | + +## OVR_233 game/233 CS_LoadBoss + BoolGotoBoss (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_LoadBossCallback | OVR_233::800ae81c | Match (load_inProgress=0; ptrModelBossHead=lqs->ptrDestination@0xc) | +| CS_LoadBoss | OVR_233::800ae834 | Match (index=3−activeMempackIndex; XAPauseRequest; clear BossBody/Head ptrs; levID_in_each_mempack[index]=-1; SwapPacks+ClearLowMem; load_inProgress=1; vrmFile→LT_VRAM, bodyFile→LT_DRAM &BossBody sentinel-2, headFile→LT_DRAM CS_LoadBossCallback, each fileId−1+index. Retail ptr-decrement loops = native 3 explicit AppendQueue) | +| CS_Camera_BoolGotoBoss | OVR_233::800aed48 | Match (boss-gate: (RELIC && numRelics>=18 && !platinum-bit BEAT_OXIDE_SECOND(word3 bit20=0x100000)) \|\| KEY → 1; else return driver !at podium spawn (matrix.t[0]!=spawn.x \|\| t[2]!=spawn.z). Retail nested ((A\|\|B)&&C) == native split) | + +## OVR_233 game/233 CS_Thread_Particles + Init (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_Thread_Particles | OVR_233::800abdd4 | Match (guard inst!=0 && !HIDE_MODEL && particleID<9; chain CsParticleConfig[particleID] while chainFlag&1: count× Particle_Init(0,iconGroup[iconGroupIdx],emitter), GetFrameData bone frameOffset → axis[xyz].startVal += (pos+matrix.t)<<8, otIndexOffset=depthBiasNormal+modelDelta) | +| CS_Thread_Init | OVR_233::800af328 | Match (modelID==NOFUNC→PROC_BirthWithObject(0x60020f CAMERA) else INSTANCE_BirthWithThread bucket AKUAKU(model−0xc7<2)/GHOST(−0xc1<4)/OTHER + PROC_DestroyInstance; CutsceneObj init metadata=&decodedOpcode@0x4c, prevOpcode=-1, subtitle=-1; big script dispatch (camera by level/CREDITS; model box@0xb6/Pinhead/Dingo/Tawna/dance-by-podium1st) → OpcodeAt; box frameOverrideRoot=&cs_initMatrixTable[model−0xc1] (=0x800b7330); unk18=metadata[8], opcodeDuration=meta[2]+RNG·(meta[3]−meta[2]+1)>>0xc; inst transform llv0(spawn[4..6])+spawn[0..2], scale 0x2800, depthBias−4 in hubs, ConvertRotToMatrix(spawn[0xc..e]+yaw); defaults particleID=0xff/flags=0/desiredScale=0x2800/tint 0x2e808080/ptrIcons) | + +## OVR_233 game/233 CS_Thread_ThTick + Camera_Boss (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_Thread_ThTick | OVR_233::800ae54c | Match (UseOpcode!=0→THREAD_FLAG_DEAD + CREDITS return; MoveOnPath/AnimateScale/Particles; flags&0x40→InterpolateFramesMS; inst!=0: parent attach (!flags&4) GetFrameData→inst.matrix.t=parent.t+offset + (!flags&0x10) ConvertRotToMatrix (Ghidra driverToFollow/tileView/pCamVel −0x54 = matrix.t mislabel); flags&8→VertSplitLine=bone.y; flags&2→alphaScale flicker rand(0x400..0xbff) if timer&1; subtitle DrawMultiLine+DrawInnerRect(w0x1d8,x−0xec,y−4); isCutsceneOver→DEAD) | +| CS_Camera_ThTick_Boss | OVR_233::800ae9a8 | Match (5-state boss director; cutsceneID=bossCutsceneIndex<0? (levID−GEM_STONE_VALLEY)*2+(KEY?1:0) : bossCutsceneIndex (retail bcdStride*0xd algebra == native bossCS[id]); PAN/WAIT_INPUT→fade black desired0 step−0x400 →FADE_OUT; FADE_OUT: currVal==0→kill OTHER threads, empty→CS_LoadBoss →LOADING; LOADING: ptrModelBossHead!=0→register modelPtr[id] (body+4), SwapPacks, spawn head/body backward CS_Thread_Init (head OpcodeAt+dur=0, body scale 0x1000), camera bcd pos/rot(x+0x800), fade in desired0x1000 step0x400 →FADE_IN; FADE_IN: 0x1000→WAIT_END; WAIT_END: isCutsceneOver==1→podiumRewardID=0+DEAD) | + +## OVR_233 game/233 CS_Thread_MoveOnPath (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_Thread_MoveOnPath | OVR_233::800ade8c | Match (guard flags&1 / inst==0; switch (s16)(model->id−0xA1), native (u32)>=63 return + default == retail default. case 0/0x3e: ptrSpawnType2[digit=name[len-1]−'0'], pathTimer>>5=wpIdx / &0x1f=frac/32 lerp 3-short waypoints; end: 0xDF one-shot clamp numCoords−2 else loop 0; rot=nPathRotX/nPathRotY+ratan2(dx,dz)/nPathRotZ. case 1/2/0x39/0x3a: ptrSpawnType2_PosRot, snap 6-short waypoint (idx*6) pos+rot, loop. case 0x30 (0xD1): ptrSpawnType2[0], prog=unk18 if animIndex==3, idx<0→coords[0]/idx>=numCoords−1→last, pos-only lerp; ConvertRotToMatrix except case 0x30/0xDF) | + +## OVR_233 game/233 CS_Camera_ThTick_Podium (2026-07-05) — **1 FIX (site 103)** + +| Function | Address | Verdict | +|---|---|---| +| CS_Camera_ThTick_Podium | OVR_233::800aedf8 | **FIX (site 103)** — podium/results camera+flow director. First-tick freeze drivers[0].funcPtrs[INIT]=VehStuckProc_RIP_Init(=PlantEaten); CAM_MODE_FIXED_POSE(3) check→cutsceneState=CS_WAIT_INPUT; save-prompt TakeCupProgress_Activate(0x236, 0x237 if CUP_NEW_BATTLE) then clear CUP_NEW_WIN/BATTLE; camera path CAM_Path_GetNumPoints, maxFrame=(n<<0x15)>>0x10, frameTime=podium[0]+elapsedMS clamped, PodiumInitUnk3 within 0x12c0 of end, frame=frameTime>>5, CAM_Path_Move→pushBuffer[0].pos/rot; "PRESS X" DecalFont lng 0xC9; end-route non-ADV→MM_TITLE+RequestLoad(0x27); ADV reward!=BIG1→BoolGotoBoss==0? DestroyPodium+XA voice (TROPHY 0xc/RELIC 0x13/KEY 0xd/TOKEN 0x14/else 0x15, +0x1f if !MaskBoolGoodGuy) : handoff funcThTick=CS_Camera_ThTick_Boss, bossCutsceneIndex=(RELIC&&numRelics>=18? levelID−GEM_STONE_VALLEY+OXIDE_RELICS_GEMSTONE : −1). **BUG:** STATIC_BIG1(0x38) beat-Oxide path — retail 0x800af16c/74 `ori v0,v0,0x800; sw v0,0x1c(s2)` kills the thread UNCONDITIONALLY (same as normal exit), and never touches isCutsceneOver. Project gated the whole kill inside `#if CTR_NATIVE` with a NOTE claiming retail leaves the thread alive → non-native build was missing the kill, and the NOTE's premise was false. Fixed: `th->flags |= THREAD_FLAG_DEAD` now unconditional; `D233.isCutsceneOver=1` kept as the sole native-only addition (re-entry guard for faster I/O). NOTE(claude) added; native build unchanged, build green. | + +## OVR_233 game/233 CS_Thread_UseOpcode — THE CUTSCENE VM (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_Thread_UseOpcode | OVR_233::800ac840 | Match — the 48-opcode cutscene bytecode VM (executes cs->metadata command per frame, returns 1 when the element's script ends). Preamble: SPLIT_LINE→vertSplit; podium 1st/2nd model show/hide by PodiumInitUnk2 window + cOtDepth bias (−2/−6); garage char-zoom (wFlags 0x80) select/deselect opcode-table swap (advCharSelect[Select/Deselect]Opcodes[id−STATIC_CRASHSELECT 0xce]). Camera director (inst==0): CAM_Path interp, clockEffect/distToScreen flags, Start-button skip→RequestLoad main-menu(0x27)/credits(0x2c). Apply tail (caseD_14): nPathRotY heading interp meta[8→9] over arg0→arg1 frames, ConvertRotToMatrix, SafeCheckAnimFrame clamp, frameOverrideRoot baked-matrix copy. Opcodes 0/2a/2b animate(XA-sync via wFlags 0x200/0x400 + XA_State/XA_CurrOffset*0x1e00/0xac44), 1 goto, 2 hide+END, 3 spawn, 4 rng-branch, 5 SFX(Garage_PlayFX in 0x28 else OtherFX_Play), 6 stopSFX, 7/8 CseqMusic, 9 LOD maxDist, 0xa path-enable, 0xb scale, 0xc fade, 0xd/0xe wFlags±, 0xf per-level table, 0x10 set/load level, 0x11 wait-load, 0x12 XAPlay, 0x13 wait-XA, 0x14 yield, 0x15 star-count, 0x16-0x18 raceflag, 0x19 particleID, 0x1a/0x1b hide/show, 0x1c OTdepth, 0x1d/0x1e inst-flags±, 0x1f unk4=0x1333, 0x20 END→StartDriving, 0x21 bossIndex, 0x22 camDist, 0x23 wait-text, 0x24 dancer, 0x25 ghosts, 0x26 garage-branch, 0x27 wait-time, 0x28 anim-check, 0x29 END-credits, 0x2c gameMode/render, 0x2d subtitles, 0x2e/0x2f fadeUI, 0x30 HOWL vols. **Two sign/inversion suspicions cleared by asm:** (a) case 0x30 `howl_VolumeSet` arg — Ghidra `(char)meta[k]` but asm 0x800adc58/64/70 is `lbu` (unsigned); project `(u8)` zero-extend correct. (b) case 0x10 gem-load — asm 0x800ad2b8/2f0 compares `0x19` and loads `0x19`; GEM_STONE_VALLEY LevelID ordinal == 0x19 (25th, counting the auto-enum), so project compare-0x19/load-GEM_STONE_VALLEY is NOT inverted. Field remaps: opcodeDuration↔nRepeatCountdown, unk18↔nAnimFrameAccum, unk1e↔nAnimIndexState, unk1c↔nPathRotYBias, unk22↔nPathRotY, unk20↔nPathRotX, frameOverrideRoot↔pBakedAnim, particleID↔aUnk44[0], animIndex↔aUnk44[3], initData.podiumPos/rot/characterPos↔scratchpad Input1.driverPosPlusSpeed/qbColl.driverPos/hitRadius+modelID. STATIC_OXIDEDANCE=0x8d, STATIC_CRASHSELECT=0xce. metadataBackup save/restore of aUnk4c[0x14] = the 5-int tail block. | + +## OVR_233 game/233 CS_Thread_LInB + Cutscene_Start + BoxScene (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| CS_Thread_LInB | OVR_233::800b06ac | Match — cutscene-element load-in/birth (LInB hook). isCutsceneOver=0; if no thread: PROC_BirthWithObject(0x600203 = SIZE_RELATIVE_POOL_BUCKET(0x60,NONE,MEDIUM,STATIC), CS_Thread_ThTick, "introguy"), wire inst; init CutsceneObj: metadata=&decodedOpcode(@0x4c per asm addiu s0,0x4c), prevOpcode=-1(@0x40), Subtitles.lngIndex=-1(@0x32); pick script by model->id (signed cmp <0xb6; (u16)(id−STATIC_CRASHINTRO 0x96)<0x10→introModelScripts[id−0x96] else script_default 0x800b2e28; else boxModelScripts[id−0xb6] @0x800b5a7c); OpcodeAt; seed unk18=meta arg0(int@+8), opcodeDuration=frameStart+RNG·(frameEnd−frameStart+1)>>0xc; zero path/rot/flags/scale/frameOverride, desiredScale=0x1000, particleID=0xff, unk8=0x2e808080; **ptrIcons = iconGroup[0]+0x14 (asm `addiu v0,v0,0x14`, NOT a pointer load) == (char*)iconGroup[0]+sizeof(struct IconGroup) since sizeof==0x14 (name[16]+groupID+numIcons; icons[0] flexible-array uncounted) — matches ICONGROUP_GETICONS macro**. INTRO_POLAR(0x21): vertSplit=0, flags\|=REFLECTIVE(0x4000). | +| CS_Cutscene_Start | OVR_233::800b087c | Match — cutscene scene setup. Always CS_Thread_Init(0=NOFUNC,"introcam"). CREDITS→isCutsceneOver=0, CS_Credits_Init, CS_Instance_InitMatrix. else NAUGHTY_DOG_CRATE(0x29)→InitMatrix + spawn 19 ND-box/kart elements via CS_Thread_Init(id, name, zeroed CsThreadInitData). Model IDs verified against enum: 0xb6-0xbe BOX_01/02/02_BOTTOM/02_FRONT/02A/03/CODE/GLOW/LID, 0xc9/ca/cb LIDB/C/D, 0xbf LID2, 0xc1-c4 KART0-3, 0xc7/c8(199/200) KART6/7 — all NDI_* constants == retail literals. Project's early-return restructure == retail nested if/else; 9-short spawn descriptor fully zeroed both ways (field-order diff immaterial). | +| CS_BoxScene_InstanceSplitLines | OVR_233::800b0b38 | Match — split=D233.VertSplitLine(g_csVertSplitLine); walk threadBuckets[GHOST].thread via siblingThread, write t->inst->vertSplit=split. GHOST==2 (namespace_Proc.h "0x02 ... also ND box") == retail threadBuckets[2]. | + +## OVR_233 game/233 CS_Garage_* (2026-07-05) — CLEAN (0 fixes) — **game/233 MODULE COMPLETE** + +| Function | Address | Verdict | +|---|---|---| +| CS_Garage_ZoomOut | OVR_233::800b7784 | Match — zoom-transition reset. zoomState!=0→ZoomIn=ZoomOut=numFramesMax_Zoom; else both=0 (retail sets ZoomOut then copies to ZoomIn; project sets both — same net). GarageMove=0, boolSelected=0, delayOneSecond=0, gameMode2&=~GARAGE_OSK. zoomState==0→Garage_Init + Garage_Enter(advCharSelectIndex_curr) + Audio_SetState_Safe(AUDIO_GARAGE=8 per asm 0x800b7820 `li a0,0x8`; Ghidra "AUDIO_STATE_INTRO" = same value). | +| CS_Garage_MenuProc | OVR_233::800b7834 | Match — the garage char-select per-frame proc (spinning char on podium + animated engine-class stat bars + name + L/R arrows + select/OSK flow + podium camera). cameraMode=CAM_MODE_FIXED_POSE(3); stat-bar lerp barLen→barStat[engineID*3+i] (+3/frame); Tiny(name 0x2e)/Pura(0x33)→statNameX=129/classX=128/barX=139 else 384/393; labels lng 0x245/246/247 flags JUSTIFY_RIGHT\|{ORANGE_RED 0x22/LIME_GREEN 0x21/BLUE 0x20}=0x4022/4021/4020; **engine-class index: retail `if(engineID!=TURN){=2;if(59 or GARAGE_OSK; camera frame calc (Pura↔Crash 0/7 seam wrap: 240−move / move+210 / idx*30∓move), CAM_Path_Move→pushBuffer[0]; FOV lerp fovMin+zoom/numFramesMax_Zoom. CTR_NATIVE NikoGetEnterKey keyboard-flush shim = benign PC addition. | +| CS_Garage_GetMenuPtr | OVR_233::800b854c | Match — return &gGarage.menuGarage. | +| CS_Garage_Init | OVR_233::800b8558 | Match — ptrActiveMenu=&gGarage.menuGarage; menuGarage.state &= ~ONLY_DRAW_TITLE (0x4, `&0xfffffffb`); CS_Garage_ZoomOut(0). | + +**MILESTONE — game/233 (the entire OVR_233 CS cutscene/credits/podium/garage overlay) fully verified.** ~90 functions across CS_Thread/CS_Instance/CS_Camera/CS_Podium/CS_Credits/CS_Garage + the CS_Thread_UseOpcode bytecode VM. Fix sites in this module: podium thread-kill (site 103). All else Match. + +## OVR_231 game/231 RB weapons — reflection + mine pool (2026-07-05) — CLEAN (0 fixes) — **OVERLAY 231 BEGINS** + +| Function | Address | Verdict | +|---|---|---| +| RB_MakeInstanceReflective | OVR_231::800abab0 | Match — thrown-weapon floor-reflection setup. No quadblock hit (sps.boolDidTouchQuadblock@0x3e==0) or hitbox hit (boolDidTouchHitbox@0x42!=0) → bitCompressed=0x4000 + clear REFLECTIVE. Else pack ground normal via INST_CompressNormalVector(nx,ny,nz) — **verified bit-identical to retail inline: asm 0x800abae0-b00 `lhu`+`srl 6`+`&0xff` into bytes 0/1/2; helper uses `(u16)` zero-extend + `>>6&0xff` (the asm's `<<2 & 0xff00` for byte1 == `>>6&0xff <<8`); mask-to-byte makes logical/arith shift immaterial**. Single-player only (numPlyr<2). quadFlags@quadblock+0x12: asm 0x800abb34 `&0x2000`(COLLISION_SURFACE) set→clear REFLECTIVE; `&0x1`(REFLECT_SPLIT_LINE_1)→REFLECTIVE + vertSplit=*(level1+0x186)=splitLines[1]; `&0x4`(REFLECT_SPLIT_LINE_0)→REFLECTIVE + vertSplit=*(level1+0x184)=splitLines[0]. Ghidra `~(all-flags)` = single-bit-test artifact; "splitLines[2]" plate note = typo (asm confirms [1]). | +| RB_MinePool_Init | OVR_231::800abfec | Match (behaviorally; benign structural flatten) — LIST_Clear taken+free; seed free list with N struct Item nodes from minePoolItem[]. N: CRYSTAL_CHALLENGE(0x8000000)→40 (ND bug, should be 50 — Nitro Court bug); ADVENTURE_BOSS(0x80000000 sign bit, retail `gameMode1<0`)→DRAGON_MINES 3 / ROO_TUBES 7; else 10. **NOTE: retail makes CRYSTAL exclusive (`if(!CRYSTAL){boss/level}else 40`); project flattens (`10; if CRYSTAL 40; if BOSS override`). Diverges only if CRYSTAL && BOSS && (DRAGON\|\|ROO) — unreachable (crystal runs on battle arenas, never Dragon Mines/Roo Tubes). Identical for all reachable states, not fixed.** | +| RB_MinePool_Remove | OVR_231::800ac0e4 | Match — if mw->weaponSlot231(@0x18)!=0: LIST_RemoveMember(taken, ws) + LIST_AddFront(free, ws); mw->boolDestroyed(@0x14)=1; weaponSlot231=NULL. | +| RB_MinePool_Add | OVR_231::800ac13c | Match — free.count==0 → evict oldest RB_MinePool_Remove(taken.last->mineWeapon@+8); ws=LIST_RemoveBack(free); ws->mineWeapon=mw, mw->weaponSlot231=ws; LIST_AddFront(taken, ws). | + +## OVR_231 game/231 RB_Player_* battle scoring (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| RB_Player_KillPlayer | OVR_231::800abbb4 | Match — battle-mode kill scoring (attacker, victim). No-op unless BATTLE_MODE + both non-null. POINT_LIMIT: same-team hit→pointsPerTeam[victimTeam]−1 floor −9; else attackerTeam+1 cap 100; reaching killLimit && !TIME_LIMIT→mark all drivers ACTION_RACE_FINISHED + MainGameEnd. LIFE_LIMIT (else, retail POINT_LIMIT==0 branch): numLives−1>0→dec+return; else RIP: funcPtrs[INIT]=VehStuckProc_RIP_Init(==retail PlantEaten_InitNoUpdate), numLives=0, ACTION_RACE_FINISHED; walk drivers tally isTeamAlive[teamID]/deadPlayers; if victim team in teamFlags & now-dead → standingsPoints[victimTeam*3+(numPlyr−deadPlayers)]++ (if <3) + finishedRankOfEachTeam[victimTeam]=remaining; count living teams; ==1→mark all finished+MainGameEnd. `1<<(team&0x1f)` mask no-op for 0-3; driver-walk-as-ptr-incr idiom == drivers[i]. | +| RB_Player_ModifyWumpa | OVR_231::800abefc | Match — numWumpas(s8)+=delta clamped [0,10]. No-op if CHEAT_WUMPA; won't subtract while ACTION_MASK_WEAPON(0x800000) active; human gain (!ACTION_BOT, delta>0)→numTimesWumpa(u8)+=delta. **Negative clamp: retail `(iNewCount<<24)<0` == `(s8)result<0`; project `numWumpas<0` correct since numWumpas is s8 (would misfire as u8 → 0xff caught by >10 cap→10 not 0).** Reaching 10 from <10→OtherFX_Play(0x41)+BattleHUD.juicedUpCooldown=10. | + +## OVR_231 game/231 RB_Hazard collision (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| RB_Hazard_HurtDriver | OVR_231::800ac1b0 | Match — route hazard hit: !ACTION_BOT(0x100000)→VehPickState_NewState; else BOTS_ChangeState, but OXIDE_STATION(0xd) && boss (asm `bgez`→gameMode1<0, IS_BOSS_RACE macro==`(gm)<0`)→force damageType=1 (DAMAGE_SPINOUT==1 per asm `li a1,0x1`). Return = callee v0 (retail void plate, but tail-call leaves v0). | +| RB_Hazard_CollideWithDrivers | OVR_231::800ac220 | Match — scan 8 drivers for one within hitRadius of weaponInst (skip null / kartState==KS_MASK_GRABBED(5) @0x376 / parent when boolCanSkipParent). **Sign-verified vs asm: `(u32)modelID−STATIC_BEAKER_RED(0x46) < 2` = `sltiu` (unsigned); potion(0x46/47)/Nitro(PU_EXPLOSIVE_CRATE 0x6)/TNT(STATIC_CRATE_TNT 0x27)→3D dx²+dz²+dy² else 2D dx²+dz²; `distCheckinstSelf@0x1c, matrix.t@0x44/48/4c. | +| RB_Hazard_CollideWithBucket | OVR_231::800ac350 | Match — walk bucket sibling chain (thread->siblingThread), threadInst=thread->inst@0x34, 3D squared dist matrix.t@0x44/48/4c vs weaponInst < hitRadius, skip parent when boolCanSkipParent. weaponTh unused. Ghidra CameraDC driverToFollow/pCamVel/tileView = matrix.t[0/2/1] mislabel. | + +## OVR_231 game/231 RB_Hazard_ThCollide_* (2026-07-05) — **1 FIX (site 104)** + +| Function | Address | Verdict | +|---|---|---| +| RB_Hazard_ThCollide_Generic_Alt | OVR_231::800ac3f8 | Match — thin wrapper: RB_Hazard_ThCollide_Generic(param_1[0]). | +| RB_Hazard_ThCollide_Missile | OVR_231::800ac42c | Match — homing-missile hit response. inst=thread->inst@0x34, tw=inst->thread->object (asm 0x800ac44c/54 confirms two-hop). model->id==DYNAMIC_ROCKET(0x29)→ if tw->driverTarget@0 set clear ACTION_TRACKER_TARGETED (asm `&0xfbffffff`==~0x4000000); PlaySound3D(0x4c,inst); **OtherFX_RecycleMute(&tw->audioPtr@0x24) — asm 0x800ac488 `jal 0x8002e724` == syms OtherFX_RecycleMute (Ghidra plate's "OtherFX_Stop_Safe")**; thread->flags\|=0x800. Native void ABI vs retail ret 1. | +| RB_Hazard_ThCollide_Generic | OVR_231::800ac4b8 | **FIX (site 104)** — beaker/Nitro/TNT shatter response. inst=thread->inst; mw=thread->object (retail asm `inst->thread->object` @0x800ac4d8/e0 — equal under weapon thread↔inst back-ptr invariant, benign). By model->id (`modelID−0x46<2` = `sltiu`): beaker(0x46/47)→PlaySound3D(0x3f)+RB_MinePool_Remove; Nitro(PU_EXPLOSIVE_CRATE 0x6)→0x3f; TNT(0x27)→driverTarget@0 set?return:0x3d; then PlaySound3D + Remove + RB_Explosion_InitGeneric(0x800b1630) + scale=0 + HIDE_MODEL(0x80); thread->flags\|=0x800. **BUG:** crate-pause block dereferenced `crateInst->thread->object` without the retail's middle null guard (asm 0x800ac500 `beq crateInst->thread,zero`). Native faults at NULL+0x30 where retail (three-level check crateInst→thread→object) safely skips. Restored the `crateInst->thread != 0` check (used the already-declared unused crateThread local). NOTE(claude); build green. | + +## OVR_231 game/231 RB_Potion shatter + Mine destroy (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| RB_Potion_OnShatter_TeethCallback | OVR_231::800ac5e8 | Match — teeth-door open callback. instDef=bspHitbox->data.hitbox.instDef@0x1c; if instDef && instDef->ptrInstance && instDef->modelID==STATIC_TEETH(0x70)→RB_Teeth_OpenDoor(ptrInstance). | +| RB_Potion_OnShatter_TeethSearch | OVR_231::800ac638 | Match — radius search for Tiger Temple teeth door. scratchpad@0x1f800108: Input1.pos=(s16)inst.matrix.t, hitRadius=0x140, hitRadiusSquared=0x19000(=0x140²), modelID=inst.model.id, Union.ThBuckColl.thread=inst->thread (retail qbColl.hitRadiusSquared reuse), funcCallback=RB_Potion_OnShatter_TeethCallback; PROC_StartSearch_Self (==retail COLL_SearchObjectsInRadius). Dead path in retail (teeth handled by ThTick_InAir). | +| RB_GenericMine_ThDestroy | OVR_231::800ad250 | Match — shatter/remove helper (retail Ghidra auto-name RB_Hazard_ShatterAndRemove; syms=RB_GenericMine_ThDestroy). By model->id: Nitro(PU_EXPLOSIVE_CRATE 0x6)→PlaySound3D(0x3f)+RB_Blowup_Init(0x800b18f8); TNT(0x27)→0x3d+RB_Blowup_Init; else potion→0x3f+RB_Explosion_InitPotion(0x800b1458); then scale=0, HIDE_MODEL, RB_MinePool_Remove(mw), thread->flags\|=0x800. Project if/else-if/else == retail goto flow. | + +## OVR_231 game/231 RB_Potion_ThTick_InAir (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| RB_Potion_ThTick_InAir | OVR_231::800ac6b4 | Match — thrown-potion in-air tick. inst=t->inst, mw=t->object (retail inst->thread->object, equiv). Integrate pos += velocity(@0xc/e/10)·elapsedMS>>5; gravity velocity.y -= (elapsedMS<<2)>>5 terminal −0x60 (asm `slti -0x60` signed); cooldown(@0x2a) −= elapsedMS clamp 0 (signed). Downward ray [t.y−0x40 .. +0x100] via COLL_SearchBSP_CallbackQUADBLK(0x8001eb0c). **Scratchpad setup: asm `+0x22=0x41` `+0x24=0x1040` `+0x28=0`; numPlyr<3→`+0x22=0x43`. Ghidra decompile SWAPPED the searchFlags/collFlags names — project `_Static_assert` confirms searchFlags@0x22 (TEST_INSTANCES 0x1\|FORCE_INSTANCE_HIT 0x40=0x41), quadFlagsWanted@0x24 (GROUND 0x1000\|TRIGGER 0x40=0x1040), quadFlagsIgnored@0x28. numPlyr<3 adds HIGH_LOD(0x2) to searchFlags → 0x43. Project CORRECT.** RB_MakeInstanceReflective(0x800abab0); stepFlags&COLL_STEP_TRIGGER_WEAPON_REACT(0x4)→RB_GenericMine_ThDestroy. Hitbox==0: quadblock hit→VehPhysForce_RotAxisAngle(normal), ground contact (hitPos.y+0x30flag&0x80, instDef modelID==STATIC_TEETH 0x70)→doorAccessFlags&1?return:RB_Teeth_OpenDoor; then shatter RB_GenericMine_ThDestroy. | + +## OVR_231 game/231 RB_GenericMine_LInB (2026-07-05) — **1 FIX (site 105)** + +| Function | Address | Verdict | +|---|---|---| +| RB_GenericMine_LInB | OVR_231::800aca50 | **FIX (site 105)** — level-instance-birth for pre-placed Crystal-Challenge nitro box. RB_Default_LInB; only if no thread && CRYSTAL_CHALLENGE: PROC_BirthWithObject(0x2c0304=SIZE_RELATIVE_POOL_BUCKET(sizeof MineWeapon 0x2c, NONE, SMALL, MINE), RB_GenericMine_ThTick, "nitro"); link inst↔thread; funcThCollide=RB_Hazard_ThCollide_Generic; parentThread=drivers[0]->instSelf->thread; modelIndex=inst->model->id; init MineWeapon: instParent(0x4)=P1 instSelf, driverTarget(0x0)=0, crateInst(0x8)=0, velocity(0xc/e/10)=0, boolDestroyed(0x14)=0, frameCount_DontHurtParent(0x24)=0, extraFlags(0x28)=0, stopFallAtY(0x12)=(s16)matrix.t[1]; RB_MinePool_Add. **BUG:** retail asm 0x800acb2c `sh zero,0x26(s0)` also zeroes tntSpinY(0x26); project omitted it. Pooled MineWeapon is not auto-zeroed (all other fields explicitly cleared) → tntSpinY held recycled garbage. Added `mw->tntSpinY = 0`. NOTE(claude); build green. Struct offsets & 0x24/0x26 (frameCount/tntSpinY) confirmed vs asm sh-zero writes. | + +## OVR_231 game/231 RB_GenericMine_ThTick (2026-07-05) — CLEAN (0 fixes; noted intentional divergence) + +| Function | Address | Verdict | +|---|---|---| +| RB_GenericMine_ThTick | OVR_231::800acb60 | Match (w/ documented native ThTick_SetAndExec divergence) — per-frame tick for placed/thrown mine/nitro/beaker/TNT. Thrown (extraFlags&2): TNT→ThrowOffHead+scale 0x800; else cooldown=0xf0+InAir; ThTick_SetAndExec. Else: cooldown−=elapsedMS clamp 0; anim cycle (animFrame>5 clamp stopFallAtY; gravity velY−=(t<<2)>>5 term −0x60; scale→0x1000 (+0x200/frame); RB_Hazard_CollideWithDrivers(hitRadius 0x3840, 0x1900 potion, skipParent=frameCount). Hit: crate-pause clear (3-level null check HERE — present, good); potion→HurtDriver(d,1,attacker,cooldown?4:2)+RainCloud(if red&&hit)+driver fade(pushBuffer[driverID] 0x1fff/0x1000/−0x88)+damageColorTimer(0x1e red/−0x1e green 0x47); nitro/TNT: instTntRecv→blast+hide+kill; squishTimer→spin; Nitro(6)→blast; TNT(0x27)→instDef==0 TNT-on-head (existing thread, ThrowOnHead) else birth new (INSTANCE_BirthWithThread 0x27,SMALL,MINE, matrix copy, tntSpinY=0 set). LAB_ad17c: frameCount−−; boolDestroyed→explode (Nitro 0x3f/TNT 0x3d Blowup / potion InitPotion) + kill. **NOTED DIVERGENCE (not a bug, not fixed): retail ThTick_SetAndExec TAIL-CALLS tickFn (`jr` through ptr → returns to caller's continuation), so retail FALLS THROUGH after both SetAndExec calls — thrown branch → cooldown/ground logic (asm 0x800acbf4→0x800acbfc, double-processes transition frame); instDef==0 TNT-on-head → `j LAB_ad17c` (asm 0x800ad018) where boolDestroyed==1 (from RB_MinePool_Remove) + t->flags!=DEAD → EXPLODES the just-placed TNT-on-head (retail quirk/bug). Project's `return` after ThTick_SetAndExec (a plain call in native PROC.c:538) deliberately treats it as terminal switch-tick, avoiding both. Author comment "this also quits the function" = intentional. Matching retail would re-introduce the TNT-on-head instant-explosion — declined.** | + +## OVR_231 game/231 RB_TNT_ThTick ThrowOffHead + SitOnHead (2026-07-05) — CLEAN (0 fixes) + +**ThTick_FastRET (0x80071694) characterized:** the PS1 thread-tree traversal trampoline — restores the scheduler's saved context (ra/s0-s8/sp/gp) from scratchpad @0x1f8000d4 and `jr ra`s back into the scheduler loop (NON-LOCAL exit; never returns to the ThTick handler). Native PROC.c:532 = no-op; native scheduler is a normal loop, so handlers end with plain `return`. **Every `ThTick_FastRET(t)`→`return`/implicit-return in the project is the correct native adaptation.** (Also clarifies: ThTick_SetAndExec tail-call + FastRET are the PS1 scratchpad-stack tick mechanism.) + +| Function | Address | Verdict | +|---|---|---| +| RB_TNT_ThTick_ThrowOffHead | OVR_231::800ad310 | Match — TNT falling off head. pos.y += velY·t>>5; first frame (stopFallAtY==0x3fff) latch to driverTarget->instSelf ground y; on reaching it: PlaySound3D(0x3d)+RB_Blowup_Init+scale0+HIDE_MODEL+kill+clear instTntRecv; gravity velY−=(t<<2)>>5 clamp −0x60. Retail trailing ThTick_FastRET→native implicit return. **CTR_NATIVE null guards on driverTarget** (boss-thrown TNT has no target; native can't do PS1 low-mem null reads) = benign documented additions. | +| RB_TNT_ThTick_SitOnHead | OVR_231::800ad44c | Match — TNT sitting on head. LHMatrix_Parent(inst, driverTarget->instSelf, deltaPos); kartState CRASHING(1)/MASK_GRABBED(5)/**SPINNING(3==DRIFTING\|CRASHING=2\|1, Ghidra rendered as OR)**→Blowup+hide+kill (retail FastRET→project return); BLASTED(6)→RB_Explosion_InitGeneric+same. Human ACTION_JUMP_STARTED(0x400) w/ jumpsRemaining==0 → throw off (vel.y=0x30, SetAndExec ThrowOffHead); AI 1/0x10e RNG. Else count numFramesOnHead→0x5a (honk 0x3e at 0/0x14/0x28/0x3c/0x46/0x50), scale pulse from s_tntSitScale[frame*2] table (0x5a+1 pairs); at 0x5a→HurtDriver(driverTarget,2,instParent obj,0)+damageColorTimer 0x1e+Blowup+kill. else-branch falls to scale write (retail FastRET-exits) but harmless — scale already 32 from prior frame's write. | + +## OVR_231 game/231 RB_TNT_ThTick_ThrowOnHead + RB_Explosion_ThTick (2026-07-05) — **1 FIX (site 106)** + +| Function | Address | Verdict | +|---|---|---| +| RB_TNT_ThTick_ThrowOnHead | OVR_231::800ad710 | **FIX (site 106)** — TNT arcing onto target's head. deltaPos.y+=velY·t>>5; if descending (velY<0) && deltaPos.yflag&0x80 && instDef@0x1c && ptrInstance@0x2c → COLL_LevModelMeta(modelID); if meta->LInC@0x8 → flag=LInC(inst, th, sps); return 0 for modelID ∈ {2 PU_WUMPA_FRUIT, 7 PU_FRUIT_CRATE, 8 PU_RANDOM_CRATE} else flag; else return 1. **modelID: asm 0x800ada04 `lh s0,0x3c(v0)` reads instDef->modelID@0x3c directly (Ghidra plate's inst->model->id); project reads inst->model->id via ptrInstance — equal by construction (ptrInstance is the spawned instance of instDef's model), benign equivalent.** All model comparisons signed (slt/slti). | +| RB_Hazard_InterpolateValue | OVR_231::800ada90 | Match — ease angle currRot→desiredRot by rotSpeed·t>>5, shortest path in 0xfff range (diff=(desired−curr)&0xfff wrap [−0x800,0x7ff]), snap when \|diff\|>8 or instTntSend, InterpolateValue turn (4/0x40/0x80 by type), vel from Sin/Cos·3>>7 or ·5>>8 (**logical `(uint)>>7` vs project arith `>>7` equivalent — low-16 identical after s16 truncation**); flags&0x20 negate (bomb/shield); missile ConvertRotToMatrix(dir.y=rotY). Anim cycle. Integrate pos += vel·t>>5; bomb dir.x±=0x200. Downward BSP ray [−0x40..+0x100], scratchpad (searchFlags 0x41@0x22, quadFlagsWanted 0x1040@0x24, numPlyr<3→HIGH_LOD 0x43 — same field map as Potion_InAir), RB_MakeInstanceReflective. stepFlags&WEAPON_REACT(4)→bounce (negate vel, step, Explode). No hitbox: no quad→2nd ray [−0x900]→gravity (rocket <<3 else <<2, clamp −0x60) else settle (vel.y=0, rocket VehPhysForce_RotAxisAngle, snap hitPos+0x30). Hitbox→RB_Hazard_CollLevInst; ret==1→teeth(0x70)→OpenDoor. RB_Hazard_CollideWithDrivers(r=0x2400)→damageColorTimer 0x1e + flags\|=0x10 if target; else CollideWithBucket(**threadBuckets[MINE=4]**); bomb also [**TRACKING=6**] → funcThCollide(hitTh). Then Explode. **Native adaptations (benign):** driverTarget null guard (PS1 null-space), particle trail #if CTR_NATIVE (shipped build runs it = retail; Particle_Init==Particle_CreateInstance, R231.emSet_Missile==DAT_800b2ae4), RB_MovingExplosive_CallThCollide 4-arg helper (retail 1-arg `(*funcThCollide)(thread)`; 1-arg ThCollide fns ignore extra register args on x64). | +| RB_MovingExplosive_Explode | OVR_231::800ae478 | Match — bomb→sound 0x49 + driverParent->instBombThrow=0; missile→driverTarget->actionsFlagSet &= ~ACTION_TRACKER_TARGETED (0xfbffffff), sound 0x4c; PlaySound3D, OtherFX_RecycleMute(&audioPtr), RB_Burst_Init, kill. | + +## OVR_231 game/231 RB_Warpball FadeAway + Death + NewPathNode (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| RB_Warpball_FadeAway | OVR_231::800ae524 | Match — warpball shrink-out (6 steps, fadeAway_frameCount5@0x30). frameCount5>5 → clear driverTarget ACTION_TRACKER_TARGETED, gameMode1 &= ~WARPBALL_HELD, kill. Else scale=s_warpballFadeScale[step*3+i], matrix.t[1]=distFromGround+s_warpballFadeY[step], frameCount5++. **Both tables verified byte-exact vs 0x800b2c88 (18 shorts: 4505,5120,4096,…,437,1668) and 0x800b2cac (6 ints: −64,−256,−87,57,167,228).** frameCount5>5 signed vs retail `5<(uint)` equiv for 0-6 counter. | +| RB_Warpball_Death | OVR_231::800ae604 | Match — distFromGround=inst->matrix.t[1] (retail tileView mislabel), ptrParticle@0xc->framesLeftInLife@0x10=0, fadeAway_frameCount5=0, PlaySound3D(0x4f), OtherFX_RecycleMute(&audioPtr), ThTick_SetAndExec(RB_Warpball_FadeAway) [tail — correct]. | +| RB_Warpball_NewPathNode | OVR_231::800ae668 | Match — pick next restart-point node toward driver. d==0→forward. target=d->checkpoint.branchChoiceIndex@0x494 (retail pLapCheckpointState[0]); ==nextIndex_left→that; else if leftIdx!=0xff walk ≤3 nodes down left branch ((left==0xff==-1)?forward:left) seeking node whose nextIndex_forward==target → found?left:forward. Return level1->ptr_restart_points[idx]. Retail invert-early-return == project. | + +## OVR_231 game/231 RB_Warpball Start + GetDriverTarget + TurnAround (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| RB_Warpball_Start | OVR_231::800ae778 | Match — ptrNodeCurr=NewPathNode(ptrNodeCurr, driverTarget); ptrNodeNext=NewPathNode(ptrNodeCurr, driverTarget). Retail's `do..while(iLoop<<16 < 1)` runs once == single call. | +| RB_Warpball_GetDriverTarget | OVR_231::800ae7dc | Match — pick homing target. !juiced(flags&1==0)→first non-parent non-finished driversInRaceOrder[0-7] (leader). Juiced→node1=restart_points[ptrNodeCurr->nextIndex_forward], node2=[node1->nextIndex_forward], trackDist=nodes[0].distToFinish<<3, pathVec=node1-node2 normalized, orbVec=(s16)(inst.matrix.t−node1.pos); **projDist = pathVec·orbVec — retail GTE mvmva(sf=0)→MAC1 == direct C dot product (values within 32-bit, exact), then >>12**; projDist=(node1.distToFinish<<3 + dot>>12 + 0x200)%trackDist (retail %pathLen hoisted from loop, equiv). Scan drivers[0-7] skip null/driversHit bit/finished/KS_MASK_GRABBED, dist=projDist−distanceToFinish_curr wrap +trackDist, track min → nearest-ahead. | +| RB_Warpball_TurnAround | OVR_231::800aede0 | Match — reverse behavior (flags&0x100 or driverTarget lost). flags&4→\|=0x208 clear 4; negate vel, integrate pos backward (CameraDC driverToFollow/tileView/pCamVel = matrix.t[0/1/2] mislabels); turnAround++; >0x78 or target gone→driverParent->instBombThrow=0 + PlaySound3D(0x4f) + RB_Warpball_Death (project correctly continues after, matching retail tail-call); every 4th frame ptrNodeNext=ptrNodeCurr, ptrNodeCurr=restart_points[ptrNodeCurr->nextIndex_backward]; ratan2(cn.pos−matrix.t) → dir.y. | + +## OVR_231 game/231 RB_Warpball SetTargetDriver + SeekDriver (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| RB_Warpball_SetTargetDriver | OVR_231::800aeaac | Match — lock path toward driverTarget. From driver section node (checkpoint.currentIndex@0x495), walk backward via NewPathNode while (int)(distToFinish<<3)>=distanceToFinish_curr && node!=restart_points[0] → targetNode = last-true node (retail pTargetNode-tracks-last vs project prevNode, same). On-path check: 2 outer starts (ptrNodeCurr, right-branch), 3-step backward walk recording nextIndex_right; then 3-step forward NewPathNode walk vs targetNode. On match: flags = (flags&~8)\|4. Signed cmp matches retail (int) casts. Field remaps pLapCheckpointState[1]/currentIndex, nextIndex_right/backward. | +| RB_Warpball_SeekDriver | OVR_231::800aece0 | Match — advance cursor toward d from restart_points[startIndex] while d->distanceToFinish_curr<=(distToFinish<<3) && node!=first; nodeCurrIndex@0x44 = (cn−first) (retail magic `·−0x55555555>>2` = optimized /12 sizeof CheckpointNode == project ptr subtraction). No-op if d null or startIndex==0xff. **Sign nuance: project `(u32)` cast (unsigned) vs retail `(int)` (signed) cmp — immaterial (both operands non-negative track distances <0x80000, slt==sltu).** | + +## OVR_231 game/231 RB_Warpball_ThTick (2026-07-05) — CLEAN (0 fixes) — RB_Warpball_* group COMPLETE + +| Function | Address | Verdict | +|---|---|---| +| RB_Warpball_ThTick | OVR_231::800aef9c | Match — homing warpball per-frame (~200 lines). Save prev pos (unk4c/unk50), anim cycle. Re-acquire target: kartState==MASK_GRABBED && flags&4 → reseat path (flags\|=0x18) + GetDriverTarget + SetTargetDriver; flags&0x204==0 → same. Homing (target!=0): flags&0xc → straight-fly (rotSpeed 0x100/0x400/0x400−(distXZ>>9) floor 0x100, or 0x40 if !hurtParent; ratan2+InterpolateValue; vel=Sin/Cos·7>>8; vertical homing ±(t<<2)>>5 clamp ±0x60/distY) + AdvanceStraight; else path-follow (SquareRoot0 segment length, respawnPointIndex+=elapsedMS·0x70>>5, advance nodes via NewPathNode, lerp curr.pos+segD·(progress<<12/segLen)>>12, ratan2 heading). PlaySound3D_Flags(0x4e). BSP ray [±0x80]: stepFlags&WEAPON_REACT→TurnAround; hitbox→CollLevInst(modelID 0x36)==1→TurnAround; quad hit→flags\|=0x100, nodeNextIndex=checkpointIndex, vel.y=0, ground-snap (t[1]driverID@0x4a) + if target==d re-target (GetDriverTarget/SetTargetDriver/SeekDriver@currentIndex). Else bucket[MINE=4] r=0x2400 → funcThCollide(hitTh). frameCount_DontHurtParent−−. **Ghidra idioms resolved: `*(uint*)(pVel+2)&0xc0000`==`flags&0xc` (flags@0x16 = high half of read@0x14); `flagsBeforeHit&1`==`uScratch3&1` (\|0x40 unaffects bit 0).** | + +## OVR_231 game/231 RB_MaskWeapon FadeAway + ThTick (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| RB_MaskWeapon_FadeAway | OVR_231::800afb70 | Match — Aku/Uka mask fade-out. durationAdjusted=(duration>>5)·−4+0x40; posOffset[0/2]=durationAdjusted·Sin/Cos(rot.y)>>0xc, posOffset[1]=0x40; rot.y−=0x100; 2-pass loop LHMatrix_Parent(inst/beam, driverInst, posOffset) + scale−=0x100 (posOffset→(0,0x40,0) after pass1); spin ConvertRotToMatrix(rot=(0,rot.y,0))+MatrixRotate(beam.matrix); beam alphaScale+=0x200 cap 0x1000; duration<0x200→+=elapsedMS cap 0x200 else INSTANCE_Death(beam)+kill. MaskHeadScratch@0x108 = retail scratchpad field-overlaps. | +| RB_MaskWeapon_ThTick | OVR_231::800afdbc | Match — active mask per-frame. Per-player IDPP pushBuffer bind: d->invisibleTimer@0x28==0 → all players maskIdpp[i]/beamIdpp[i].pushBuffer=&pushBuffer[i]; else clear all except driverID. Copy driverInst REFLECTIVE/vertSplit + depthBias to mask/beam. Orbit: posOffset[0/2]=(Sin/Cos(rot.y)<<6>>0xc)·scale>>0xc, **posOffset[1]=maskPosArr[beam.animFrame]+0x40 — maskPosArr[40] verified byte-exact vs DAT_800b2cc4 (0,0,−2,…,−32,…,0,977,1835,…,1792)**; 2-pass (mask.rot.z&1==0 → LHMatrix_Parent+ConvertRotToMatrix+MatrixRotate else direct pos+ConvertRotToMatrix). Beam anim cycle; rot.y−=0x100; duration==0→ThTick_SetAndExec(FadeAway) [continues after, matches retail] else −=elapsedMS clamp 0; un-hide + scale=mask->scale both, beam alphaScale=0. | + +## OVR_231 game/231 RB_ShieldDark + Player_ToggleInvisible (2026-07-05) — CLEAN (0 fixes) — **game/231 (OVR_231) MODULE COMPLETE** + +| Function | Address | Verdict | +|---|---|---| +| RB_ShieldDark_ThTick_Pop | OVR_231::800b0278 | Match — shield pop anim. Parent instDark+instColor to owner instSelf (identity rot, int-cast matrix writes = identity·0x1000); 11 frames scale from **s_shieldPopScale[11][2] verified byte-exact vs DAT_800b2d14 ({2150,1612},…,{179,537})**; then PlaySound3D(0x58) + INSTANCE_Death(instColor, instHighlight) + kill. | +| RB_ShieldDark_ThTick_Grow | OVR_231::800b0454 | Match — active shield per-frame. Highlight sweep (highlightRot.y+=0x100, rot%0x1000==0x400 → timer=30/rot=0xc00/hide); per-player IDPP pushBuffer bind (3 insts, owner-only if invisible); parent all 3 (identity + ConvertRotToMatrix highlight); scale animFrame<8 from **s_shieldGrowScale[8][2] == maskPosArr[24..39] (DAT_800b2cf4, byte-exact)** else pulse **s_shieldPulseScale[6][2] byte-exact vs DAT_800b2d40 ({1845,1756},…,{1792,1792})** by timer%6; !blue(flag&4) fade (duration−=0x20, <0x780→alpha=((60−(dur>>5))·0xc00/60)+0x400); expire (flag&1/8, RACE_FINISHED, MASK_GRABBED)→animFrame=0+instBubbleHold=0+SetAndExec(Pop); fired(flag&2)→launch shield-bomb (INSTANCE_BirthWithThread 0x5e/0x56, RB_MovingExplosive_ThTick, sizeof TrackerWeapon; matrix copy; tw init vel=driverInst.m[0][2]/m[2][2]·3>>7, rotY=angle) + GAMEPAD_Shock + Voiceline. **Benign: project passes NULL debug name to BirthWithThread (retail "shieldbomb"); playerTh==d->instSelf->thread.** | +| RB_Player_ToggleInvisible | OVR_231::800b0dbc | Match — walk threadBuckets[PLAYER=0]; if driver invisibleTimer@0x28!=0, for each player i!=driverID → INST_GETIDPP(instSelf)[i].instFlags &= ~0x40 (clear visible bit on opponents' screens). | + +**MILESTONE — game/231 (OVR_231 RB weapons/hazards) fully verified.** ~70 functions: reflection/mine-pool, hazard collision, potion/generic-mine/TNT/explosion threads, moving-explosive (bomb/missile), warpball (homing + path), mask & dark-shield weapons, player invisibility. Fix sites in this module: 104 (ThCollide null-guard), 105 (tntSpinY init), 106 (TNT jumpsRemaining %2). All else Match; many byte-exact data-table verifications. + +## OVR_232 game/232 AH_WarpPad GetSpawnPosRot + AllWarppadNum + MenuProc (2026-07-05) — CLEAN (0 fixes) — **OVERLAY 232 BEGINS** + +| Function | Address | Verdict | +|---|---|---| +| AH_WarpPad_GetSpawnPosRot | OVR_232::800abafc | Match — find the warp pad matching prevLEV, return spawn pos+rot. Walk threadBuckets[WARPPAD=5]; WarpPad.levelID@0x6c==prevLEV → posData[0]=matrix.t[0]+(Cos(instDef.rot.y)<<0xa>>0xc), [1]=t[1], [2]=t[2]+(Sin·−0x400>>0xc); return &instDef->rot.x. NULL if none. | +| AH_WarpPad_AllWarppadNum | OVR_232::800abbdc | Match — refresh warp-pad digit models. Walk WARPPAD bucket; inst[2] && digit1s∈1..8 → SetNumModelData(inst[2], &headers[digit1s−1]); inst[3] && digit10s!=0 → SetNumModelData(inst[3], &headers[0]). **SetNumModelData: idpp[0].ptrCommandList/ptrColorLayout/ptrTexLayout/ptrCurrFrame ← mh->ptrCommandList(0x20)/ptrColors(0x2c)/ptrTexLayout(0x28)/ptrFrameData(0x24) — matches retail raw reads (funcPtr[0]←0x2c, reflectionRGBA←0x28, matrix.t[2]←0x24); semantic command/color/tex/frame mapping despite ModelHeader field reorder.** WarpPad _Static_asserts (size 0x78, levelID 0x6c). | +| AH_WarpPad_MenuProc | OVR_232::800abd80 | Match — relic/token race choice menu callback. RECTMENU_Hide; row 0 → gameMode2\|=TOKEN_RACE(0x8); row 1 → gameMode1\|=RELIC_RACE(0x4000000). | + +## OVR_232 game/232 AH_WarpPad SpinRewards + ThTick (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| AH_WarpPad_SpinRewards | OVR_232::800abdfc | Match — spin one floating reward icon. ConvertRotToMatrix(spinRot_Prize); by model gem/relic/token → Vector_SpecLightSpin3D(lightDir* @0x50/58/60), trophy=none; orbit t[1]=y+(Sin(thirds[i])<<6>>0xc)+0x100, t[0]=x+(Sin(0x555·i+spinRot_Rewards.y)·0xa0>>0xc), t[2]=z+(Cos·0xa0>>0xc). | +| AH_WarpPad_ThTick | OVR_232::800abf48 | Match — warp-pad per-frame (~250 lines). Show/hide 10 insts (WPIS_ 0-9, NUM=10) by camera visInstSrc membership (CTR_NATIVE null guard benign). Distance gate per kind (trophy<16 d<0x144000, SlideCol/Turbo 16-17 d<0x90000, battle 18-24 d<0x144000, gem≥100 d<0x90000) → D232.levelID lock, name draw (metaDataLEV.name_LNG / AdvCups.lngIndex_CupName, midX, botY−0x1e), mask hints (gem-cups/trophies/relics per ADV_REWARD bits). Closed billboard: ratan2 toward cam, position item/X/1s/10s at ±0x40/0x80/0xa0/0xc0 offsets, spin item + gem colorRGBA (AdvCups[timer/0x3c%5].color pack <<0x14/0xc/4) + SpecLightSpin. Enter/load: dist>0x8fff && !entered→skip; LOAD_Robots1P; spawn order (champion-first else RNG Fisher-Yates); RaceFlag_IsTransitioning→skip; by kind (gem cup→ADVENTURE_CUP 0x10000000+cupID+advCupTrackIDs; SlideCol/Turbo→RELIC_RACE; battle→CRYSTAL_CHALLENGE+eventTime+MaskHint; trophy→trophy-gate menu D232.menuTokenRelic+relic/token hint) at framesWarping≥61, funcPtrs[INIT]=VehStuckProc_Warp_Init(=ElecDrop_Init), RequestLoad, RemBits ADVENTURE_ARENA 0x100000. Open anim: beam spin (RNG%6·0x200), 2 wisp rings (rise wispRiseRate 0x20/0x30, maxHeight 0x600/0x400, alpha −0x380/+0xc00/(max/rate), reset rng%0x1000), 3 prizes (SpinRewards, scale token 0x2000/relic 0x1800/trophy 0x2800 ·rewardScale>>8, rewardScale dist-ramp [0x900000,0x1200000]→[0x100,0]). Death closed insts. **Benign unreachable: closed-item SpecLight falls to gem-light for non-trophy/relic/token/gem/key (item always one of 5).** WPIS indices + ADVENTURE_CUP/ARENA verified. | + +## OVR_232 game/232 AH_WarpPad ThDestroy + LInB (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| AH_WarpPad_ThDestroy | OVR_232::800ad2c8 | Match — free all 10 warp-pad instances via INSTANCE_Death in retail order: aInst[2],[3],[1],[0],[4], then loop {5,6}, then loop {7,8,9} (WPIS_ 1s/10s/X/item/beam/rings/prizes), each null-guarded + nulled after. Project WPIS enum order matches asm exactly. | +| AH_WarpPad_LInB | OVR_232::800ad3ec | Match — spawn WarpPad thread (0x780205 MEDIUM/WARPPAD), parse levelID from "warppad#NN", compute unlock requirement, spawn open (beam 0x7b/2 rings 0x7c/1-3 prizes) or locked (item/X/10s/1s digits) art. **Requirement dispatch ASM-verified (0x800ad4f8-0x800ad6bc): trophy-owned→arrKeysNeeded[hubID]+KEY; trophy-not-owned→numTrophiesToOpen+TROPHY; SlideCol(16)→relics+RELIC (numNeeded deferred=metaDataLEV[16].numTrophiesToOpen); Turbo(17)→gems count+GEM (numNeeded=5); battle(18/19/21/23)→numKeys+KEY (numNeeded deferred=numTrophiesToOpen); gemcup(100-104)→numCtrTokens[i]+TOKEN (numNeeded=4).** **Battle-map STRUCTURAL DIVERGENCE (benign): project routes battle maps through GetKeysRequirement → numNeeded=arrKeysNeeded[hubID]; retail leaves numNeeded=−1 → deferred → metaDataLEV[levelID].numTrophiesToOpen. Verified numerically IDENTICAL for all reachable battle levelIDs: hubIDs {4,2,1,3}=numTrophiesToOpen {4,2,1,3}, and arrKeysNeeded (retail 0x800b4e7c = {2,1,2,3,4}, == D232.c:52)[h]=h for h∈{1,2,3,4}.** **Data verified: metaDataLEV stride 0x18, hubID@0x0, numTrophiesToOpen@0x10; metaDataLEV[16].numTrophiesToOpen=10 (== project hardcode), [17]=15 (unused, Turbo uses 0x5f→5).** Matrix-copy idiom, digit split (<10→digit1s, ≥10→digit10s=1+digit1s−10), 1s-digit model (0x38 default, 0→0x6d, 9→0x6e), specLight token/relic/gem tables all match. | + +## OVR_232 game/232 AH_Garage ThDestroy/Open/ThTick/LInB (2026-07-05) — 1 fix (site 107) + +| Function | Address | Verdict | +|---|---|---| +| AH_Garage_ThDestroy | OVR_232::800ae8a0 | Match — free garageTopInst if present. | +| AH_Garage_Open | OVR_232::800ae8e0 | Match — player-trigger collision callback. Thread via scratchpad overlap (qbColl.hitRadiusSquared→Union.ThBuckColl.thread, +0x30 object/+0x34 inst); modelIndex==DYNAMIC_PLAYER gate; if direction!=1 && matrix.t[1]==(int)instDef->pos.y → OtherFX_Play(GEM_STONE_VALLEY?0x96:0x95, 1); direction=1; doorAccessFlags\|=1. | +| AH_Garage_ThTick | OVR_232::800ae988 | **FIX (site 107)** + else Match. Door slide (direction 0 idle/1 open/-1 close, ±0x20/frame between instDef.pos.y and +0x300; garagetop ConvertRotToMatrix(rot); open→idle+0x780 cooldown+HIDE_MODEL; close→doorAccessFlags&=~1). Door flags ASM-verified: idle-closed (flags\|0x1000)&~0x302000 = SPLIT_SPECIAL set / SPLIT_LINE\|REFLECTION_FUNC23\|WATER_SPLIT_WHITE clear; moving inverse; HIDE_MODEL=0x80. bossIsOpen: GEM→keys 0x5e..0x61 (ADV_REWARD_FIRST_BOSS_KEY), else 4× advHubTrackIDs[(lev−NSB)·4+i]+6. Near+dist≤0x143fff+!VEH_FREEZE_PODIUM(4) → draw challenge name (lngStrings[lng_challenge[R232.bossIDs[hubID]]]); locked→MaskHint 4(gem,bit 0x7a)/3(other,bit 0x79). Open+inside(interior pt instDef.pos ± Sin/Cos·−0x280>>0xc, d<0x40000)→fadeToBlack(−0x2aa)+load: Loading RemBits ADVENTURE_ARENA(0x100000)/AddBits ADVENTURE_BOSS(0x80000000), bossID=(GEM&&numRelics==0x12)?5:R232.bossIDs[hubID], characterIDs[1]=metaDataLEV[R232.bossTracks[hubID]].characterID_Boss, RequestLoad(bossTracks[hubID]). modelID STATIC_PINGARAGE=0x73 ✓. **Bug: boss-name x-center `(view.x + view.w >> 1)` parses (C: + before >>) as (x+w)/2; retail asm 0x800aed10-18 (`sll w,0x10;sra 0x11;addu x`) = x + w/2. Non-observable in 1P adventure (rect.x==0) but a missing-parens typo vs every sibling (232_04:144, UI_RaceFlow.c:197). Parenthesized to `view.x + (view.w >> 1)`.** | +| AH_Garage_LInB | OVR_232::800af070 | Match — birth BossGarageDoor thread (0x140303 SMALL/STATIC); non-Oxide (id!=STATIC_OXIDEGARAGE) spawns garagetop (STATIC_GARAGETOP 0x8e) at inst.t + Sin/Cos(rot.y)·0x4c>>0xc, t[1]+0x300, depthBias −2; same hub-unlock check as ThTick; modelIndex = locked 0 / open-not-beaten 1 / beaten 2 (BeatBossPrize[hubID] bit); rot=instDef.rot, depthBiasNormal/Secondary=1, unk53=0, vertSplit=instDef.pos.y+0x300. | + +## OVR_232 game/232 AH_SaveObj ThDestroy/ThTick/LInB (2026-07-05) — 2 fixes (sites 108, 109) + +| Function | Address | Verdict | +|---|---|---| +| AH_SaveObj_ThDestroy | OVR_232::800af3a4 | Match — free save->inst (scan beam) if present. | +| AH_SaveObj_ThTick | OVR_232::800af3e4 | Match — hub load/save station state machine. dist²(drivers[0]↔pad). Phase 0 (flags&1==0): skip if PAUSE_ALL(0xF) or dist>0x8ffff; MaskHint 6 if reward bit 0x7c (ADV_REWARD_HINT_SAVE_LOAD_SCREEN) unset; if speed<0x80 && AH_MaskHint_boolCanSpawn(): scanlineFrame−−, on (s16) underflow set camera fly-in (desiredPos=spawnType2.posCoords[0..2]+(m[col0]·0x19>>7), desiredRot=posCoords[3..5]+D232.saveObjCameraOffset), funcThTick=VehBirth_NullThread, CAM_SetDesiredPosRot, JogCon2 off, flags\|=1, backup+clear hudFlags. Phase 1 (flags&1): speed≥0x101→flags=0; else cam !0x200 && !flag4→stop tick+flag4+restore HUD (CTR_NATIVE gates restore on Loading.stage==LOAD_IDLE, documented adaptation); cam 0x800 arrived→first(flag2) open RECTMENU greenLoadSave, else !0x400(CAMERA_FLAG_TRANSITION_BACK)&&!activeMenu→\|=0x400. Tail: scan animFrame++ each frame; on loop play SFX 0x99 vol=VehCalc_MapToRange(√dist·3/2, [300,6000]→[0xff,0]). Constants PAUSE_ALL=0xF, ADV_MASK_HINT_ID_SAVE_LOAD_SCREEN=6, CAMERA_FLAG_TRANSITION_BACK=0x400 (_Static_assert) verified. | +| AH_SaveObj_LInB | OVR_232::800af7f0 | **2 FIXES (sites 108, 109)** + else Match. Birth SaveObj thread (0xc0303 SMALL/STATIC), t->inst=pad, ThDestroy, flags=0/scanlineFrame=0, pad HIDE_MODEL. Spawns scan beam (STATIC_SCAN 0x78), copies pad matrix (project memcpy == retail word-copy, both overwritten by ConvertRotToMatrix(posCoords[3..5]) + t=posCoords[0..2]), depthBias −8 (0xf8). **Fix 108: guard tested `ptrSpawnType2_PosRot==NULL` (@0x144) but retail asm 0x800af870-80 tests `numSpawnType2_PosRot==0` (count @0x140); LEV loader sets the section pointer unconditionally so ptr can be non-NULL when count==0 → potential garbage-PosRot scan beam. Changed to count check.** **Fix 109: INSTANCE_Birth3D 3rd arg was `0`; retail asm 0x800af88c passes a2=t (owner thread) → INSTANCE_Birth stores inst->thread=t. Prior NULL back-pointer diverged from retail + all sibling LInBs (which pass t). No double-free (retail passes t AND frees in ThDestroy). Changed to t.** | + +## OVR_232 game/232 AH_Door ThDestroy/ThTick/LInB (2026-07-05) — 1 fix (site 110) + +| Function | Address | Verdict | +|---|---|---| +| AH_Door_ThDestroy | OVR_232::800af9f8 | Match — free otherDoor + all 4 keyInst[0..3] via INSTANCE_Death, nulling each. | +| AH_Door_ThTick | OVR_232::800afa60 | **FIX (site 110)** + 1 noted latent divergence + else Match. Boss-key gate: doorIsOpen by level/doorID reward bits (project AH_Door_IsOpenByRewards helper: beach door4=0x40, door5=0x10, GEM=0x20, LOST_RUINS=0x80, GLACIER=0x100; all masks verified). Near(dist<0x90000)→doorAccessFlags bit2 (open=set/locked=clear). numKeys: beach 1 (door4=2) else arrKeysNeeded[lev-0x19]. VEH_FREEZE_PODIUM(4)→return. Open→MaskHint 7 (bit 0x20000000) if cam !0x200. Locked+near+enough keys: preOpen debounce, WdCam_CutscenePlaying, freeze driver; <0x78 frames spawn ≤numKeys STATIC_KEY(0x63) gold+specular, grow scale→0xa00, float up, orbit (i·0x1000/numKeys, Sin/Cos(keyOrbit)), SFX 0x67@10/0xf/0x14/0x19 + 0x93@0x50 + 0x94@0x78; after 4s aim camera (Cos·0x312+Cos(+0x400)·0x600 etc), JogCon2, WdCam_FlyingOut. Tail: rotate both halves 0x10/frame (inst=+doorRot.y, otherDoor=−doorRot.y), shrink keys 11 frames (R232.keyFrame **byte-exact vs retail 0x800aba8c {0x1333,0x1599,0x1666,0x14cc,0x1000,0xb33,0x800,0x666,0x4cc,0x333,0x199}**) then Death; at 0x400 set door-open reward bit(s) (beach door4/LOST_RUINS→0xc0=DOORS_TO_GLACIER, door5→0x10, GEM→0x20, else→0x100), cam 0x400, restore driving+HUD. Camera flag renames: WdCam_FlyingIn=Ghidra ControlRestored, WdCam_FullyOut=CameraArrived (De-Morgan-equivalent logic verified). **Fix 110: distance omitted the Y term — retail asm 0x800afbd4/fc04/fc18/fc50-58 computes dist=dX²+dY²+dZ² (dY=doorInst.t[1]−driverInst.t[1], no facing offset); prior code summed only dX²+dZ², shifting the 0x90000/0x8ffff trigger gates by any vertical car↔door offset. Added dY².** **Noted latent (not fixed): after the 4s key anim, retail sets WdCam_FlyingOut then FALLS THROUGH (asm 0x800b0400→0404) to start rotating the doors the SAME frame; the project `return`s after setting FlyingOut, so door rotation starts 1 frame later. Cosmetic 1/30s on a ~2s open; restructure risk > reward.** | +| AH_Door_LInB | OVR_232::800b072c | Match — birth WoodDoor thread (0x380303 SMALL/STATIC), inst SPLIT_SPECIAL, null keyInst[0..3], parse doorID from name[5..], model by GLACIER→DOOR3(0xb5)/beach-door5→DOOR2(0xa7)/else DOOR(0x7a). Mirror otherDoor: flags 0x9000 = SPLIT_SPECIAL(0x1000)\|REVERSE_CULL_DIRECTION(0x8000) [retail's separate `\|=0x9000` then redundant `\|=0x8000` == project's combined OR], copy matrix, scale.x=−0x1000, +Cos/Sin(rot.y)·0x600 facing offset, both headers->flags\|=2. If already open (same reward-bit check) pre-rotate to 90° (doorRot.y=0x400, inst=+, otherDoor=−). Retail beach-door5 NULL-model `goto` (NULL-derefs at LAB) is dead-in-practice; project births unconditionally (documented native adaptation). | + +## OVR_232 game/232 AH_Map LoadSave_Prim/Full + HubArrow/HubArrowOutter (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| AH_Map_LoadSave_Prim | OVR_232::800b0b98 | Match — one POLY_G4 gouraud quad for the hub-map load/save icon. primMem alloc guard (cursor+0x24, project `end`==retail endMin100, `<=` semantics), setPolyG4 (len 8/code 0x38), 4 RGB (vertCol[0..2/4..6/8..10/12..14]) + 4 XY (vertPos[0..7]), AddPrim. | +| AH_Map_LoadSave_Full | OVR_232::800b0ce0 | Match — rotate 4 quad verts by angle+scale, emit 5 layered quads (LoadSave_Prim) at D232.primOffsetXY_LoadSave, first vertCol then D232.colorQuad. Project uses MATH_Sin/MATH_Cos where retail inlines data_trigApprox quarter-wave + quadrant signs (equivalent; iScratch5=cos·vx, iScratch2=sin). **Benign: retail truncates scale factors to s16 (`(short)((unk800*8)/5)` X, `(short)unk800` Y) since unk800 is ushort; project uses full-width int. Harmless — caller (232_21:118) passes unk800=0x800 → X=0xCCC, Y=0x800 both fit s16.** | +| AH_Map_HubArrow | OVR_232::800b0f18 | Match — same rotate+layer as LoadSave_Full but 3 triangle verts + RECTMENU_DrawRwdTriangle, offsets D232.primOffsetXY_HubArrow, colors D232.colorTri. Same benign s16-scale divergence; both callers (232_21:215 HubItems, 232_27:24 FiveArrows) pass unk800=0x800 → harmless. | +| AH_Map_HubArrowOutter | OVR_232::800b1150 | Match — animated dashed curved arrow (3 nested arcs). Center += hubArrowXY_Inner[type]; per-type color+arcStep (0: var15/0x200, 1: 0xff/0x555 + hubArrowXY_Outter[(angle>>8)&0xc], 2: var15/0x199 + angle^0x800), var15=timer&1?0xe0:0x40. Per-arc dash phase var5=(~(timer+arrowIndex·0xc)&0x3f)+(2−arc)·−6 gated <0xc → dashLen; inner segment chain angle+=arcStep, X=cx+(dashLen·8/5)·sin>>shift, Y=cy−dashLen·cos>>shift (shift=(toggle&1)+0xc, toggle=0 unless type 2), DrawWirePrims(prev,cur). Project MATH_Sin/Cos == retail inlined quadrant decomp (iScratch11=cos→Y, iScratch12=sin→X); Point/MakeColor + native CTR_Box_DrawWirePrims wrappers. Dead `iArcStep!=-0xfff` guard omitted (arcStep always positive). | + +## OVR_232 game/232 AH_Map HubItems + Warppads + Main (2026-07-05) — CLEAN (0 fixes) — **AH_Map GROUP COMPLETE** + +| Function | Address | Verdict | +|---|---|---| +| AH_Map_HubItems | OVR_232::800b14f4 | Match — per-hub icons from D232.hubItemsXY_ptrArray[levelID−GEM_STONE_VALLEY(0x19)] ({x,y,angle,iconType}, −1 term). **All bit indices ASM-verified: iconType −1 arrow lock beach numKeys<1; −4→<2; −5→<3 (0x800b1644/60/94); −2/−3 unlocked. iconType 4 (Gem Oxide): all 4 boss keys 0x5e..0x61 → beaten bit word3&4 (0x800b17c8-d0) = ADV_REWARD_BEAT_OXIDE_FIRST_BOSS(0x62)_MASK. iconType 3 (boss garage): 4 advHubTrackIDs[(levelID−N_SANITY_BEACH 0x1a)·4+i]+6 trophies → beaten bit `levelID+0x44` (0x800b1744) == project (levelID−0x1a)+ADV_REWARD_FIRST_BOSS_KEY(0x5e). iconType 100 → AH_Map_LoadSave_Full.** Arrow color idx = timer&2 ? (lock·2+1)·3 : lock·6 (0x800b1884-c4, element·4), outer arc (type 1) when unlocked && unkModeHubItems==0. Star DrawRawIcon(0x37): beaten 3/locked 0x17/open 5|4; open sets unkModeHubItems + outer arc (type 2). GEM_STONE_VALLEY=0x19/N_SANITY_BEACH=0x1a from asm. | +| AH_Map_Warppads | OVR_232::800b1a18 | Match — walk WARPPAD bucket; modelIndex→color 0 locked 0x17 / 1 trophy 5\|4 + outer arc(type 0) / 2 beaten 3 / 3 token 0xe / 4 slide ((timer>>1)&7)+5 / default 0x15; DrawRawIcon 0x31; locked+default skip nearest-pad 3D dist (SquareRoot0), PlayWarppadSound(dist<<1). | +| AH_Map_Main | OVR_232::800b1c90 | Match — hub HUD frame. HudDisplayFlags&=~8 (speedo off), RaceFlag can-draw, welcome hint (word3 bit 0x400000 = ADV_REWARD_HINT_WELCOME_TO_ARENA 0x76 = FIRST_HINT+0, id 0), AI-demo (numPlyr==0 && ACTION_BOT → flags=8), ST1_MAP ptr, JumpMeter (unless PAUSE_ALL), minimap chain (DrawDrivers/Warppads/HubItems/DrawMap[500,195,WHITE=1]/SlideMeter) when !(hudFlags&0x10), relic/key/trophy counters. **HUD layout: UiElement2D=8B, ptrHudData[8/0xE/0xF/0x10] == pHudLayout+0x40/0x70/0x78/0x80.** CTR_NATIVE AkuAkuHintState==5→DrawRepeatPrompt (documented adaptation). | + +**MILESTONE — game/232 AH_Map group fully verified.** 7 functions: LoadSave_Prim/Full, HubArrow/HubArrowOutter, HubItems, Warppads, Main. All Match (0 fixes); benign s16-scale divergence in the rotation helpers (harmless for unk800=0x800), MATH_Sin/Cos refactors, and all reward/level bit indices verified byte/asm-exact. + +## OVR_232 game/232 AH_Pause_Destroy + AH_Pause_Draw (2026-07-05) — 1 fix (site 111) + +| Function | Address | Verdict | +|---|---|---| +| AH_Pause_Destroy | OVR_232::800b1ef8 | Match — if D232.ptrPauseObject, INSTANCE_Death all 14 members' inst (retail reverse 13→0, project forward 0→13, order-independent), null the global, thread flags\|=THREAD_FLAG_DEAD(0x800). | +| AH_Pause_Draw | OVR_232::800b1f78 | **FIX (site 111)** + else Match. Renders one progress-book page at posX. Title (titleLng<0→metaDataLEV[levelID].name_LNG), L/R nav arrows (DecalHUD_Arrow2D, ptrColor[colorIndex] flashing frameCounter&4), reset 14 members (index=−1, unlockFlag&=0xfffe). **All reward bits ASM-verified: trophy+6, sapphire+0x16, gold+0x28, platinum+0x3a (priority plat>gold>sapph → icon 8/7/6), CTR token+0x4c, gem+0x6a, crystal hubID+0x6e (PURPLE_TOKEN_HUB_ID_BASE), purple token i+0x6f (FIRST_PURPLE_TOKEN — 0x6e vs 0x6f distinction correct: hub1-3 crystals = purple 0-2), Oxide beaten word3&4 (BEAT_OXIDE 0x62), BeatBossPrize[hub].** type 0 (hub trophy/gem page), type 1 (CTR token tally), type 2 (relic totals + TOTAL lng 0xc4). Final member loop: **clear mask s6=0xfff8ff7f → 0x70080 = HIDE_MODEL(0x80)\|DRAW_TRANSPARENT\|USE_SPECULAR_LIGHT\|DRAW_BILLBOARD, specular check (flags&0x70000)==0x20000, both asm-exact**; rot[1]=t[0]·0x10+t[1]·0x20+frameCounter·0x40 (project &0xfff benign — ConvertRotToMatrix masks). Color-unpack idiom + MATH_Sin/Cos refactor benign. **Fix 111: locked test was `unlockFlag == 0`; retail asm 0x800b2fa0-ac is `(unlockFlag & 1) == 0` (andi 0x1). Reset `&= 0xfffe` preserves bits 1-15, so `== 0` would mis-color a locked icon if any high bit set. Changed to `& 1`.** | + +## OVR_232 game/232 AH_Pause_Update + AH_HintMenu ×3 (2026-07-05) — CLEAN (0 fixes) — **AH_Pause + AH_HintMenu GROUPS COMPLETE** + +| Function | Address | Verdict | +|---|---|---| +| AH_Pause_Update | OVR_232::800b3144 | Match — lazily create PauseObject (static global D232.pauseObject): birth host thread (0x30d SMALL/OTHER, size 0), 14 STATIC_GEM(0x5f) screen-space UI insts (USE_SPECULAR_LIGHT\|SCREENSPACE_INSTANCE\|HIDE_MODEL, idpp[0]=pushBuffer_UI/[1..]=0, identity·0x1000 matrix, t[2]=0x100, index=−1, rot=0). **Member init writes index@0/inst@0xc/rot@4-8 but NOT unlockFlag@2 → BSS zero-init confirms fix 111 is LATENT (unlockFlag stays 0/1).** L/R flip advPausePage wrap 0..6 + SFX 0; 8-frame slide (timer>0 dec else page-change starts prev/dir_dup/timer=8/curr); posX = timer<5 ? curr page @ timer·dir·−0x80 (Ghidra (timer·−0x200)/4·dir exact) : prev page @ (8−timer)·dir·0x80. | +| AH_HintMenu_FiveArrows | OVR_232::800b344c | Match — 5 arrows x=i·0x32+0x95, y=posY+4, AH_Map_HubArrow(fiveArrow_pos, col1/col2 by frameCounter&2, 0x800, rotation). Project hoists ptrColor pre-loop (Ghidra recomputes; frameCounter constant → equiv). | +| AH_HintMenu_MaskPosRot | OVR_232::800b351c | Match — ConvertRotToMatrix(instMaskHints3D, maskRot), t=maskPos, MaskHint.scale@+4=maskScale. | +| AH_HintMenu_MenuProc | OVR_232::800b3594 | Match — pause Hints page RECTMENU callback. Welcome bit 0x400000 (reward 0x76), MainFreeze_SafeAdvDestroy; hintsFound scan ((lng−0x17b)/2+FIRST_HINT 0x76 bit set, −1 term); rowSelected/scrollIndex clamps. View mode: MaskPosRot, maskCooldown−−, exit on face-btn(0x40070)+(!XA\|\|cooldown==0) → VehTalkMask_End; draw title lng[idx]/body lng[idx+1]/EXIT lng[0x17a] + boxes. Browse: masks 0x40073/0x50/0x40020, Up/Down move+SFX 0, Cross/Circle → EXIT row exit / else (!load && XA_STOPPED) VehTalkMask_Init+PlayXA((lngIndex−0x17b)/2)+MaskPosRot+SCREENSPACE idpp bind+viewHint=1; header lng[0x178+(goodGuy==0)], visible rows + FiveArrows scroll, highlight box; exit btns 0x41020(TRIANGLE\|START\|SQUARE)→ptrDesiredMenu=MainFreeze_GetMenuPtr. Benign: project hoists lngIndex unconditionally (unused garbage on EXIT row, in-bounds read). TRANS_50_DECAL=1, OT startPlusFour=uiOT. | + +**MILESTONE — game/232 AH_Pause (Destroy/Draw/Update) + AH_HintMenu (FiveArrows/MaskPosRot/MenuProc) groups fully verified.** 6 functions, 1 fix (site 111, AH_Pause_Draw unlockFlag &1, latent). Reward bits, flag masks, button masks all asm-verified. + +## OVR_232 game/232 AH_MaskHint Start/boolCanSpawn/SetAnim/SpawnParticles/LerpVol (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| AH_MaskHint_Start | OVR_232::800b3dd8 | Match — begin Aku/Uka talking-mask hint. boolDraw3D_AdvMask=1, set reward bit hintId+FIRST_HINT(0x76); **hintId==0 (WELCOME) also sets USING_WARP_PAD (0x76+1=0x77, word3 0x800000) + MAP_INFORMATION (0x76+0x18=0x8e, word4 0x4000) — both asm/enum verified**; freeze driver (funcPtrs[INIT]=FreezeEndEvent); model not resident → LOAD_TalkingMask(advPack, goodGuy==0) + maskSpawnFrame=0x5a else 0x14; maskOffsetPos/Rot from maskVars[(interrupt&1)·3 + 0..2/6..8]; audioBackup[0..2]=howl_VolumeGet. | +| AH_MaskHint_boolCanSpawn | OVR_232::800b3f88 | Match — return AkuAkuHintState == 0. | +| AH_MaskHint_SetAnim | OVR_232::800b3f98 | Match — per-frame mask pose. GTE-transform maskOffsetPos by camera matrix (SetRot/SetTrans/ldv/rt/stMAC→posEnd), rotEnd=camRot ∓maskOffsetRot; CAM_ProcessTransition pose-lerp Start→End by scale; spawn-progress rot=(spawnFrame−frameCurr<20 ? ·0x1000/20 : 0x1000)·50>>0xc; angle=scale<<3, spin posCurr.x+=Sin·rot, .z+=Cos·rot (Ghidra iCos=Sin/iCosHi=Cos verified via bob idiom); rotCurr.y+=angle+ConvertRotToMatrix; MaskHint.scale=scale·4−1; bob posCurr.y+=(Sin((frameCounter+timer)·0x20)<<4>>0xc)·scale>>0xc; matrix.t=posCurr. | +| AH_MaskHint_SpawnParticles | OVR_232::800b42b4 | Match — spawn numParticles hubdustpuff (Particle_Init(0, iconGroup[0x10], emSet)); axis[0..2].startVal += mask.t·0x100; axis[5].startVal/velocity ·= clamp(maskAnim+0x1000, 0x3fff)>>0xc; otIndexOffset−=5. | +| AH_MaskHint_LerpVol | OVR_232::800b43cc | Match — lerp 3 audio channels backup→target by factor/0x1000. **ASM 0x800b442c-34: `addu a1,a1,delta; andi a1,a1,0xff` — retail masks the SUM with &0xff (Ghidra's `(char)delta` cast is an artifact); == project `(backup + ((diff·factor)>>12)) & 0xFF`. audioBackup u8 (lbu), maskAudioSettings s16 (lh).** howl_VolumeSet(i, vol&0xff). | + +## OVR_232 game/232 AH_MaskHint_Update + AH_Sign_LInB (2026-07-05) — CLEAN (0 fixes) — **game/232 (OVR_232) MODULE COMPLETE** + +| Function | Address | Verdict | +|---|---|---| +| AH_MaskHint_Update | OVR_232::800b4470 | Match — 8-state Aku/Uka hint state machine (switch AkuAkuHintState−1). case 0: wait XA_State==0. case 1: abs(speedApprox)≤0x31, !interrupt → cam eye/lookAt offsets + flag 8 + CAM_FollowDriver_AngleAxis/SetDesiredPosRot, delayFrames=0x3c. case 2: wait cam 0x800 (or interrupt/spawnFrame==0x14) → VehTalkMask_Init, CTR_MatrixToRot(driver,0x11)→maskCamRotStart[0/2/1]=rot.y/z/x&0xfff, maskCamPosStart=driver.t, scale=0, SetAnim(0). case 3: spawn anim — SFX 0x100 staged (frame 0/10/0x14/0x19/0x1e), SetAnim/SpawnParticles(3,emSpawn) by frameCurr/spawnFrame·0x1000, LerpVol; on complete+loaded+(interrupt\|\|cam 0x800) → LerpVol 0x1000+SpawnParticles(0x18,emLeave)+PlayXA(hintID)+(ADVENTURE_ARENA&&hintID∉{0,0x18}→hudFlags\|=0x10). case 4: subtitle (DrawRepeatPrompt: lng 0x177 Aku/0x232 Uka)+SetAnim(0x1000)+delay/noXA/TRIANGLE→state++, ADVENTURE_ARENA→hudFlags&=~0x10. case 5: SpawnParticles(0x14,emLeave)+SFX 0x101+VehTalkMask_End+(!interrupt→cam\|=0x400). case 6: LerpVol(0x1000−transitionBlend), cam !0x200\|\|interrupt→SetAnim(0)+LerpVol(0)+delayFrames(interrupt?0x1e:0). case 7: LerpVol(0), delayFrames−−<1 → ClearInput+state=0+boolDraw3D=0+RemBits VEH_FREEZE_DOOR+Driving_Init. **3 documented native adaptations: stack work-buffer for CAM_FollowDriver_AngleAxis; DrawRepeatPrompt hoisted to shared fn (#if !CTR_NATIVE here / AH_Map_Main in native); case-3 skipped LerpVol(0x1000) in complete-but-waiting-cam substate is idempotent (audio already at target from prior increment-frame LerpVol(0x1000)) — benign.** | +| AH_Sign_LInB | OVR_232::800b4c80 | Match — signpost terrain-lean. **ASM-verified scratchpad offsets: probeTop@0x1f800108, probeBottom@0x110, normal@0x118, sps@0x120 (exactly the project's CTR_SCRATCHPAD_PTR offsets; Ghidra's `scratchpad.qbColl/Input1` field names are its own struct mapping onto the same addresses).** normal=matrix.m[col2]>>6 (arithmetic, sign-ext); probe = pos ± normal·2 (top +2, bottom −4 from top); sps: searchFlags@+0x22=2 (HIGH_LOD, sh), quadFlagsWanted@+0x24=0x3000 (GROUND\|COLLISION_SURFACE, sw), quadFlagsIgnored@+0x28=0, ptr_mesh_info@+0x2c; COLL_SearchBSP_CallbackQUADBLK(top,bottom,sps,0); flip all 3 normal comps if *(u16)(sps+0x3e)!=0 (project boolDidTouchQuadblock == Ghidra mislabeled triangleNormal[1], same offset); pack (nx&0xff)\|((ny&0xff)<<8)\|((nz&0xff)<<0x10) → inst->bitCompressed_NormalVector@0x70. | + +**MILESTONE — game/232 (OVR_232 Adventure Hub) fully verified.** ~37 functions across WarpPad, Garage, SaveObj, Door, Map (LoadSave/HubArrow/HubItems/Warppads/Main), Pause (Destroy/Draw/Update), HintMenu (FiveArrows/MaskPosRot/MenuProc), MaskHint (Start/boolCanSpawn/SetAnim/SpawnParticles/LerpVol/Update), Sign. Fix sites in this module: 107 (Garage x-center parens), 108-109 (SaveObj guard+Birth3D owner), 110 (Door distance Y-term), 111 (Pause_Draw unlockFlag &1, latent). All reward/level bit indices, flag masks, button masks, scratchpad offsets, and data tables asm/byte-verified; many MATH_Sin/Cos + GTE refactors and documented native adaptations confirmed benign. + +## OVR_230 game/230 MM_Battle_DrawIcon_Character + TransitionInOut + Title_MenuUpdate + SetTrophyDPP (2026-07-05) — 1 fix (site 112) — **OVERLAY 230 BEGINS** (game/230, 86 fns 230_00-85) + +MM_ overlay spans OVR_230::800abaa8+ (shares addr space with OVR_232). Verify in address order. + +| Function | Address | Verdict | +|---|---|---| +| MM_Battle_DrawIcon_Character | OVR_230::800abaa8 | Match — null-checked DecalHUD_DrawPolyFT4(icon, posX, posY, primMem, ot, transparency, scale) wrapper (Ghidra drops pass-through args). | +| MM_TransitionInOut | OVR_230::800abaf0 | Match — menu-slide anim. Walk TransitionMeta (5-short stride, distX@0/distY@2/headStart@4/currX@6/currY@8, headStart==−1 term); framesLeft=(s16)(framesPassed−headStart); swoosh SFX 0x65 at framesLeft==4 && idx==0; <1→curr=0+notDone, numFrameTotal → CameraReset (hoisted, all 6 cases call it) + switch(desiredMenuIndex): 0 adv-new (NewProfile+advProfileIdx=0xffff+MAIN_MENU_ADVENTURE+KillThread+Load 0x28), 1 adv-load (menuFourAdvProfiles+ToggleMode 0x10), 2 charselect (KillThread+menuCharacterSelect+RestoreIDs), 3 highscores (HighScore_Init+menuHighScores), 4 demo (clear gameMode1 race bits+ARCADE, !seenOxide→INTRO_RACE_TODAY 0x1e else demo: boolDemoMode/countdown 0x708/characterIDs[i]=(demoIdx+i)&7/**numPlyrNextGame**/arcadeTracks[idx&7].levID/idx++), 5 scrapbook (MAIN_MENU_SCRAPBOOK+0x40). Tail: menu*.pos_curr=title_*Pos+transitionMeta[k].curr (k main0/adv1/race2/plyr3(1P2P&2P3P4P)/diff4), titleCameraPos=camPos(+meta[5]/[6] unless state0). **Fix 112: demo set `gGT->numPlyrCurrGame=1` (0x1ca8) but retail asm 0x800abf78 `sb v0,0x1ca9(gGT)` = numPlyrNextGame (0x1ca9, per struct header). Demo configures the NEXT game; prior write hit the current menu game, leaving numPlyrNextGame stale → demo could load wrong player count. Changed to numPlyrNextGame.** | +| MM_Title_SetTrophyDPP | OVR_230::800ac178 | Match — copy InstDrawPerPlayer title->i[2]→title->i[1] when src instFlags@0xb8 bit 0x100 clear: dst.instFlags &= (src.instFlags\|~0x40); copy otRangeNormal@0xe4/otRangeSecondary@0xe8/depthOffset@0xdc (4B). "empty in japan builds." | + +## OVR_230 game/230 MM_Title CameraMove/ThTick/Init/CameraReset/KillThread (2026-07-05) — CLEAN (0 fixes) — **MM_Title_* cluster complete** + +| Function | Address | Verdict | +|---|---|---| +| MM_Title_CameraMove | OVR_230::800ac1f0 | Match — title cam anim. result=RaceFlag_MoveModels((s16)(timerInTitle−0xe6), 0xf); posRot=ptrIntroCam[frameIndex·6]; per-axis pos[i]=title.cameraPosOffset[i]+posRot[i]+(s16)(titleCameraPos[i]·result>>0xc), rot[i]=posRot[3+i]+(s16)(titleCameraRot[i]·result>>0xc). | +| MM_Title_ThTick | OVR_230::800ac350 | Match — title per-frame. Input skip (btn 0x40070→ClearInput+timerInTitle=1000); timer=min(timerInTitle,0xe6); 8 titleSounds[i] (frameToPlay==timer→OtherFX_Play soundID). 6 instances: show/animIndex=0/animFrame=timer−frameIndex_startMoving, (s16)<0→hide (i!=2)+animFrame=0; trophy (boolTrophy@+6): air-hide (u32)(timer−0x8a)<0x3e, ≥200→animIndex=1/animFrame=timer−200, then spec-light (helpers). **ASM-verified spec-light: rot=−pb.rot, ConvertRotToMatrix_Transpose, light(0,0x1000,0) rtv0→lightMac, unk53@0x53=(u8)lightMac.x, reflectionRGBA@0x58=lightMac.z, view=inst.t−pb.pos normalize+rtv0→viewMac, halfVector@0xf4/f6/f8=lightMac+viewMac (project idpp[0].halfVector).** CameraMove(timer). Final: timerInTitle<0xf6→++; ≥0xf6→menuMainMenu.state &=~0x20\|0x400. **Benign refactors: project omits retail's dead first MATH_VectorNormalize (0x800ac57c, output overwritten by view); explicit gte_SetRotMatrix each transform vs retail matrix-persistence — same matrix/result.** | +| MM_Title_Init | OVR_230::800ac6dc | Match — spawn title scene. Guards (titleObj NULL, MAIN_MENU, MM_State!=2, modelPtr[STATIC_RINGTOP 0x68], ptrSpawnType1.count>2). cameraMode=CAM_MODE_FIXED_POSE(3), dist 0x1c2(450); ptrIntroCam=ST1_GETPOINTERS[ST1_CAMERA_PATH]; birth Title thread (0x24020d MEDIUM/OTHER, MM_Title_ThTick), memset 0x24, title->t. 6 insts: INSTANCE_Birth3D(modelPtr[titleInstances[n].modelID]), boolTrophy→VISIBLE_DURING_GAMEPLAY, identity·0x5000 matrix + t=0 + HIDE_MODEL, clear idpp[1..numPlyr−1].pushBuffer. CameraMove(0). Benign: NULL debug name (retail "title"). | +| MM_Title_CameraReset | OVR_230::800ac92c | Match — if titleObj: cameraPosOffset[0]@0x1c = 2000. | +| MM_Title_KillThread | OVR_230::800ac94c | Match — if titleObj && MAIN_MENU: INSTANCE_Death i[0..5], titleObj=NULL, thread->flags@0x1c\|=0x800, **asm 0x800ac9e0/e4: sh zero,0x1532 (project transitionTo.rot.x=0; Ghidra mislabels cameraMode/CAM_MODE_CHASE=0, same offset+value) + sw 0x100,0x274 (distanceToScreen_CURR)**. Co-located in 230_02 file. | + +## OVR_230 game/230 MM_Cheat_* ×21 + MM_ParseCheatCodes (2026-07-05) — CLEAN (0 fixes) — **cheat group complete** + +| Function | Address | Verdict | +|---|---|---| +| MM_Cheat_* (21 appliers) | OVR_230::800ac9fc-800ace78 | Match — each is `target \|= bit; OtherFX_Play(0x67,1)`. **All 21 bit literals asm-verified vs project constants: gameProgress.unlocks[0] chars — Tropy 0x20/Penta 0x40/Roo 0x80/Papu 0x100/Joe 0x200/Pinstripe 0x400/FakeCrash 0x800 (UNLOCK_*); Tracks unlocks[0]\|=0x1e (GAME_UNLOCK_TRACKS_MASK); Scrapbook unlocks[1]\|=0x10 (GAME_UNLOCK_BIT_SCRAPBOOK=36→word1 bit4). gGT->gameMode2 cheats — WUMPA 0x200/MASK 0x400/TURBO 0x800/INVISIBLE 0x8000/ENGINE 0x10000/ADV 0x40000/ICY 0x80000/TURBOPAD 0x100000/SUPERHARD 0x200000/BOMBS 0x400000/ONELAP 0x800000/TURBOCOUNT 0x8000000 (CHEAT_*).** (230_09_30_MM_CheatCodes.c) | +| MM_ParseCheatCodes | OVR_230::800aceb4 | Match — cheat entry. Gate: gamepad[0] holds BTN_L1\|BTN_R1 && buttonsTapped!=0. Shift 10-entry rolling cheatButtonEntry history down, [0]=tapped. 22 cheat table entries (stride 0x30 = numButtons@0/buttons[10]@4/funcPtr@0x2c): compare last numButtons taps vs buttons[] backward (cheatButtonEntry[i] & buttons[numButtons−1−i]); if all match && funcPtr → call. No early-out (shared-tail cheats can both fire). Project uses binary-faithful funcPtr table. | + +## OVR_230 game/230 MM_MenuProc_Main + ToggleRows ×2 + MenuProc_1p2p/2p3p4p (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| MM_MenuProc_Main | OVR_230::800acff4 | Match — root menu dispatcher. Scrapbook unlock (unlocks[1]&0x10) → swap rowsMainMenuWithScrapbook; ParseCheatCodes+ToggleRows_Difficulty+ToggleRows_PlayerCount; top level (unk1e==1): Title_MenuUpdate, TM string (MM_State==1 && titleObj && timerInTitle>0xe5 → DrawLineOT lng[LNG_TM 0x244], 0x10e, 0x9c, FONT_SMALL, ORANGE=0, otMem+0xc), demo countdown (!held → −−<1 → MM_State=2/desiredMenuIndex=4; else 900). Title_Init; DRAW_NEXT→timerInTitle=1000; !EXECUTE_FUNCPTR→ret; titleObj→cameraPosOffset[0]=0; unk1e!=0→ret; rowSelected<0→ret; clear gameMode1 race bits + gameMode2 CUP_ANY_KIND, state\|=ONLY_DRAW_TITLE(4), numLaps=3. **7 selections by lng: 0x4c Adventure (ADVENTURE_MODE+clear item cheats+menuAdventure), 0x4d TimeTrial (MM_State=2/desiredMenuIndex=2/numPlyrNext=1/TIME_TRIAL+clear cheats), 0x4e Arcade (CHEAT_ONELAP→numLaps=1/ARCADE_MODE/menuRaceType), 0x4f Versus (ONELAP/menuRaceType), 0x50 Battle (charSelTransition=2/BATTLE_MODE/menuPlayers2P3P4P), 0x51 HighScores (desiredMenuIndex=3/MM_State=2), 0x234 Scrapbook (desiredMenuIndex=5/MM_State=2).** Project linear if-chain == Ghidra binary-search dispatch. | +| MM_ToggleRows_PlayerCount | OVR_230::800ad448 | Match — lock/unlock player-count rows. rowsPlayers1P2P[0..1]: stringIndex&=0x7fff, HaveAllPads(i+1)==0→\|=0x8000; rowsPlayers2P3P4P[0..2]: HaveAllPads(i+2). | +| MM_ToggleRows_Difficulty | OVR_230::800ad678 | Match — lock difficulty rows. 3 rows; cupDifficultyUnlockFlags[i]==−1 (Easy) skip; AND 4 consecutive unlock bits from bitIndex (gameProgress.unlocks); uVar5=cupDifficultyLngIndex[i], if !unlocked && **gameMode1&ARCADE_MODE(0x400000) && gameMode2&CUP_ANY_KIND(0x10, asm-confirmed single bit)** → \|=0x8000; rowsDifficulty[i].stringIndex=uVar5. | +| MM_MenuProc_1p2p | OVR_230::800ad560 | Match — row −1 (back): parent state&=~(ONLY_DRAW_TITLE\|DRAW_NEXT), numPlyrNext=1, charSelTransition=0; row 0/1: numPlyrNext=row+1, menuDifficulty, state\|=0x14 (DRAW_NEXT 0x10\|ONLY_DRAW_TITLE 4). | +| MM_MenuProc_2p3p4p | OVR_230::800ad5e8 | Match — row −1 (back): same as 1p2p; row 0/1/2: numPlyrNext=row+2, MM_State=2, desiredMenuIndex=2 (CHARSELECT), state\|=ONLY_DRAW_TITLE(4). | + +## OVR_230 game/230 MM_MenuProc_Difficulty/SingleCup/NewLoad + AdvNewLoad_GetMenuPtr + MM_Characters ×3 (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| MM_MenuProc_Difficulty | OVR_230::800ad7a4 | Match — row −1 back; row 0-2 → arcadeDifficulty=cupDifficultySpeed[row] (0x50/0xa0/0xf0), MM_State=2, desiredMenuIndex=2 (CHARSELECT), state\|=4. | +| MM_MenuProc_SingleCup | OVR_230::800ad828 | Match — row −1 back; row 0/1 → gameMode2 &=~CUP_ANY_KIND, row!=0→\|=CUP_ANY_KIND, state\|=0x14; ARCADE→menuPlayers1P2P+transition=1 else menuPlayers2P3P4P+transition=2. | +| MM_MenuProc_NewLoad | OVR_230::800ad8f0 | Match — row −1 back; row 0 New/1 Load → desiredMenuIndex=row (ADV_NEW=0/ADV_LOAD=1), MM_State=2, state\|=ONLY_DRAW_TITLE(4). | +| MM_AdvNewLoad_GetMenuPtr | OVR_230::800ad980 | Match — return &D230.menuAdventure. | +| MM_Characters_AnimateColors | OVR_230::800ad98c | Match — pulse cursor color. base=ptrColor[playerID+PLAYER_BLUE 0x18]; flag==0 → sine-pulse (trigApprox[frameCounter·0x100+playerID·0x400] MATH_Sin idiom), bPulse=(wave<<7)>>0xc when wave>0xc00; colorData[0..2]=base\|bPulse, [3]=0. (Project logical >>0x10 == Ghidra arithmetic — quarter-wave positive; 0xc00< signed both.) | +| MM_Characters_GetNextDriver | OVR_230::800ada4c | Match — nextDriver=csm_Active[characterID].indexNext[dpad]; unlocked=csm_Active[nextDriver].unlockFlags; if unlocked!=−1 && unlocks[unlocked>>5]&(1<<(unlocked&0x1f))==0 → stay on characterID. | +| MM_Characters_boolIsInvalid | OVR_230::800adae4 | Match — scan globalIconPerPlayer[0..numPlyrNextGame−1] skipping `player`; return 1 if characterID taken else 0. | + +## OVR_230 game/230 MM_Characters_GetModelByName + DrawWindows (2026-07-05) — 1 fix (site 113) + +| Function | Address | Verdict | +|---|---|---| +| MM_Characters_GetModelByName | OVR_230::800adb64 | Match — linear search level1->ptrModelsPtrArray (NULL-term) for a model whose 16-byte name (4 ints @0) matches *name; NULL if level1/array null or not found. | +| MM_Characters_DrawWindows | OVR_230::800adc0c | **FIX (site 113)** + else Match. Per player: build viewport rect (charSelect_ptrWindowXY + transitionMeta[player+0x10].curr, clamp to 0x200×0xd8, CTR_NATIVE w/h=1 guards documented), dist 0x100, zero pos/rot; driver instSelf visible (HIDE_MODEL if player≥numPlyr \|\| !show); clear idpp[0..3].pushBuffer + bind idpp[player]=pb; GetModelByName(MetaDataCharacters[charIDsCurr[player]].name_Debug); cameraMode=CAM_MODE_FIXED_POSE; pos=csm_instPos; model-swap slide (timer==0 && charIDsCurr!=characterIDs → timer=moveModels·2/charIDsDesired; else slide RaceFlag_MoveModels ±MoveDir·SlideDist>>0xc); rot=csm_instRot + charSelect_angle[player]. **Fix 113: cleared vertSplit (s16@0x56) but retail asm 0x800adea8 `sb zero,0x52` clears animIndex (u8@0x52). Prior code left animIndex stale (wrong anim-set on char-select models) and needlessly zeroed vertSplit. Changed to animIndex (paired with animFrame@0x54).** | + +## OVR_230 game/230 MM_Characters SetMenuLayout/BackupIDs/PreventOverlap/RestoreIDs (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| MM_Characters_SetMenuLayout | OVR_230::800ae0bc | Match — pick grid layout. isRosterExpanded=0, iconLayout=numPlyr−1; secret-icon scan (csm_1P2P[0xc..0xe].unlockFlags bit set → expand/isRosterExpanded=1; Ghidra no-break vs project break — same result); if numPlyr<3 && !expand → iconLayout+=4 (compact); load per-layout csm_instPos[1/2]=driverPosY/Z, sizeX/Y=windowW/H, ptrWindowXY=ptrSelectWindowPos, csm_Active=ptrCsmArr, textPos=textPosArr, ptrTransitionMeta=ptr_transitionMeta_csm[numPlyr−1] (all indexed by iconLayout). | +| MM_Characters_BackupIDs | OVR_230::800ae274 | Match — copy characterIDs[0..7] → characterIDs_backup. | +| MM_Characters_PreventOverlap | OVR_230::800ae2c0 | Match — availability set local_8[8]={0..7} (R230.characterID_default), mark each player's pick taken (0xff if <8); for any player[i]==player[jinstSelf->flags\|=HIDE_MODEL. | +| MM_Characters_MenuProc | OVR_230::800ae74c | Match — char-select per-frame handler (~4.8KB, all sections asm/structure-verified). Snapshot globalIconPerPlayer[i]=characterMenuID[characterIDs[i]]; transition SM (0 in/1 focus/2 out): TransitionInOut when !=1, SetMenuLayout+DrawWindows(1) always, state 0 dec / state 2 inc→>12 BackupIDs+HideDrivers+branch (movingToTrackMenu==0→JumpTo_Title_Returning; CUP_ANY_KIND→menuCupSelect+CupSelect_Init; else menuTrackSelect+TrackSelect_Init). Header switch (2:lng 0x60/0x61 FONT_BIG unless isRosterExpanded; 3:FONT_CREDITS unless UNLOCK_FAKE_CRASH; 4/5:lng 0x5f FONT_BIG; JUSTIFY_CENTER\|ORANGE 0x8000). Input loop: D-pad dir (up0/−1,down1/+1,left2/−1,right3/+1) + 4-level GetNextDriver fallback (getNextDriver1/2[dir]+boolIsInvalid) + dup check + SFX 0; Cross/Circle select (flag bit, all-selected (1<1 → −2 else +textPos+(numPlyr<3?0:4)) + spin angle+=0x40, inner rects, window OuterRect + selected 2× inset + InnerRect(9) + RwdBlueRect(ptrOT+0xffc). All button masks, LNG (0x5f/0x60/0x61), colors match. | + +**MILESTONE — game/230 MM_Characters_* cluster fully verified.** 11 functions: AnimateColors, GetNextDriver, boolIsInvalid, GetModelByName, DrawWindows, SetMenuLayout, BackupIDs, PreventOverlap, RestoreIDs, HideDrivers, MenuProc. 1 fix (site 113, DrawWindows animIndex). + +## OVR_230 game/230 MM_TrackSelect Video_SetDefaults/State/Draw + boolTrackOpen + Init (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| MM_TrackSelect_Video_SetDefaults | OVR_230::800afa44 | Match — clear STR: videoSTR_src_vramRect={0}, dst_vramX/Y=0, frameCount=0, boolAllocated=0, videoStateCurr/Prev=1. | +| MM_TrackSelect_Video_State | OVR_230::800afa94 | Match — state==1→reset (videoStateCurr=1, frameCount=0); else while videoStateCurr==1: frameCount<21→++ else videoStateCurr=2 (boundary frameCount>0x14 == <21 inc, ≥21 → state2). | +| MM_TrackSelect_Video_Draw | OVR_230::800afaf0 | Match — preview STR state machine (PSX #else path == retail; CTR_NATIVE path uses NativeSTR_*/DrawNativePreview, documented adaptation). Off-screen/no-video (entry[videoID].size==0 == dataFileIndex[videoID·2+3]==0 \|\| rect clamp) → videoStateCurr=1; state 2&&prev 1 → AllocMem(0xb0,0x4b,4,0,0)+boolAllocated=prev+StartStream(bh->cdpos+entry.offset, videoLength); prev/curr 3\|\|curr 2 → srcX=u0+(tpage&0xf)·0x40, srcY=v0+(tpage&0x10)·0x10+((tpage&0x800)>>2), DecodeFrame→(ret1&&curr2→3), prev3→videoSTR rect{+3,+2,0xaa,0x47}+dst=dispEnv+r+InitVideoSTR(1); curr!=3→DrawPolyGT4(thumbnail, videoCol); prev1→InitVideoSTR(0); firstStop&&alloc1→curr=1; curr1&&prev!=1→StopStream; firstStop&&alloc1→ClearMem+alloc=0; prev=curr; DrawInnerRect(drawFlags\|1). | +| MM_TrackSelect_boolTrackOpen | OVR_230::800aff58 | Match — unlock field @+6: −1 always open; −2 → numPlyrNextGame==1; <0 (else) → false; ≥0 → gameProgress.unlocks bit. | +| MM_TrackSelect_Init | OVR_230::800affd0 | Match — boolOpenLapBox=0, transitionState=MENU_TRANS_IN(0), rowSelected=trackSelBackup (asm 0x8008d930 = Ghidra trackSelIndex, same field), transitionFrames=0xc; BATTLE→battleTracks/7 else arcadeTracks/18 (stride 0x10); wrap if backup≥numTracks; skip locked tracks (boolTrackOpen loop, wrap); currTrack=rowSelected; Video_SetDefaults. | + +## OVR_230 game/230 MM_TrackSelect_MenuProc + GetMenuPtr (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| MM_TrackSelect_MenuProc | OVR_230::800b00d4 | Match — track-select per-frame handler (~0xdd4 asm, all sections asm-verified). Transition SM (in/focus/out over 0..0xc): in→errorMsgPos=1 (BATTLE→2), TransitionInOut(meta,frames,8), frames−−, ==0→state=focus; out→TransitionInOut, frames++, >12→errorMsgPos=0 & branch (StartRace==0→menuCharacterSelect+RestoreIDs; BATTLE→menuBattleWeapons+Battle_Init; TIME_TRIAL→AllocHighMem(0x3e00)+memset 0x28+boolPlayGhost=0+ToggleMode(0x30)+menuGhostSelection; else menuQueueLoadTrack). Track list BATTLE→battleTracks/7 else arcadeTracks/18 (@0x800b53b0/54d0, stride 0x10); trackSelBackup=rowSelected. Input (boolOpenLapBox==0 && changeFrameCount==0 && focus): asm masks skip=0x4007f, DOWN(0x2)>UP(0x1)>confirm(0x50=CROSS_one\|CIRCLE)>back(0x40020=TRIANGLE\|SQUARE_one), scroll finds next open track (boolTrackOpen, 3-frame anim + direction), confirm=arcade non-TT→openLapBox else StartRace+state=out, back→StartRace=0+state=out, then ClearInput. Lap box: menuLapSel.rowSelected=uselessLapRowCopy, ProcessInput (focus only), **DrawSelf(menuLapSel, transMeta[2].currX, currY, 0xa4)** [asm 0x800b0510-28: 4-arg call confirmed, a3=0xa4 — Ghidra 1-arg render was unset sig; project correct], numLaps=*(u8)&lapRowVal[rowSelected]·(s16 stride 2, low byte), ret 1→out+StartRace=1, −1→close; CHEAT_ONELAP→numLaps=1. Draw: changeFrameCount−−→rowSelected=currTrack; Video_State(changeFrameCount!=0\|\|out); currLEV=selectMenu[row].levID; walk 4 open tracks back, carousel 9 rows (MATH_Cos/Sin(uVar15)==trigApprox easing, x=transMeta[0].currX+(cos·0x19>>9)−0xb4, y+0x60); TIME_TRIAL stars ×2 (levelID swap, bit=(highScoreTracks[levelID].timeTrialFlags>>flagGet[i])&1 with flagGet={1,2}<32 == asm word0+shift, starCol={14,22}·4 == ptrColor[starCol], DrawPolyGT4 icon[0x37] x+0x104 y+i·8+4); name lngStrings[metaDataLEV[levID].name_LNG] FONT_BIG; center row (idx==4): TT ghost flag lng 0x6b flashing WHITE(0x8004)/PERIWINKLE(0x8001) + highlight ClearBox(+6,+4,−0xc,−8); InnerRect; advance to next open. After 9 rows (idx>8): map panel {0xb0,0x4b} @transMeta[1]+{0x134,0x3a\|+5 if mapTex≥0}, "SELECT/LEVEL" lng 0x69/0x6a (boolOpenLapBox==0), 6 minimaps (UI_Map_DrawMap, offsets @drawMapOffset stride-6 {s16 offsetX,offsetY,type@4 lbu low byte}, +0xb0/2+(u1−u0)/2 X, +100/2+(Δv)/2 Y), Video_Draw(&p, selectMenu, currTrack, out?1:0, 0). All base addrs verified: menuTrackSelect@0x800b5540, lapRowVal@5574, menuLapSel@5594, starCol@55c4, flagGet@55c8, drawMapOffset@55cc. | +| MM_TrackSelect_GetMenuPtr | OVR_230::800b0eac | Match — returns &D230.menuTrackSelect (trivial). | + +**Latent (non-fixing) divergences** in MM_TrackSelect_MenuProc input handling — project uses a `switch(importantButton)` where retail uses a priority if-chain; behaviorally identical for all single-button inputs (the only realistic case), so not fixed: +1. Retail's ClearInput trigger includes LEFT/RIGHT (skip-mask 0x4007f); project's `importantButton` mask (0x40073) omits LEFT(0x4)/RIGHT(0x8), so a lone LEFT/RIGHT tap skips ClearInput. LEFT/RIGHT are unused in this menu and buttonTap is edge-triggered (self-clears next frame) → no observable effect. +2. On a frame-perfect simultaneous multi-menu-button tap, the switch matches no case → no action, whereas retail priority-picks (DOWN>UP>confirm>back). Requires physically-simultaneous input; ClearInput still consumes it. Benign. + +## OVR_230 game/230 MM_CupSelect_Init + MenuProc (2026-07-05) — 1 fix (site 114) + +| Function | Address | Verdict | +|---|---|---| +| MM_CupSelect_Init | OVR_230::800b0eb8 | Match — transitionFrames=0xc, transitionState=MENU_TRANS_IN(0); menuCupSelect.state (@0x800b4734) &= ~EXECUTE_FUNCPTR(0x400) \|= DISABLE_INPUT_ALLOW_FUNCPTRS(0x20). | +| MM_CupSelect_MenuProc | OVR_230::800b0eec | **Fix 114** (flash phase) — else Match. Entry unk1e(@0x1e, `lh`)==0 → boolStart=(rowSelected!=−1), state=OUT, state flags toggle, return. Transition SM (in/focus/out): in→TransitionInOut+frames−−, ==0→focus & state\|=EXECUTE_FUNCPTR&~DISABLE_INPUT; out→frames++, >12→commit (boolStart→cup.cupID=rowSelected @0x1e58, cup.trackIndex=0 @0x1e5c, clear cup.points[0..7] @0x1e60 stride-4, menuQueueLoadTrack, currLEV=*(s16)(g_aArcadeCups@0x80084148 + cup·18 + track·4 + 2); else menuCharacterSelect+RestoreIDs). Draw: title lng 0xbf (LNG_SELECT_CUP_RACE) @meta[4]+{0x100,0x10} FONT_BIG 0xffff8000; ×4 cups: name lng ArcadeCups[cup].lngIndex(@cup·18+0) @meta[cup]+{(cup&1)·200+0xa2, (cup>>1)·0x54+0x44} FONT_CREDITS(3), **flash bit +4** (see fix); ×3 stars unlock-gated (StarUnlockFlag@0x800b5634[star]+cup vs gameProgress.unlocks bit, StarColorIndex@0x800b562c→ptrColor·4, DrawPolyGT4 icon[0x37], X+(cup&1)·0xca−0x16, Y+star·0x10+0x10); ×4 track icons RECTMENU_DrawPolyGT4(ptrIcons[ArcadeCups[cup].CupTrack[track].iconID @cup·18+track·4+4], cupSel_Color tint, FP(0.5)); selected→ClearBox(−3,−2,0xae,0x4a); InnerRect(−6,−4,0xb4,0x4e). Folded g_aArcadeCups entry = {s16 lngName; struct{s16 trackID; s16 iconID;} CupTrack[4]} = 9 shorts, all offsets asm-confirmed. | + +**Fix 114** (`230_59_MM_CupSelect_MenuProc.c:110`): selected-cup name flash inverted. Retail asm 0x800b1218-0x800b1228 sets the +4 colour bit (0xffff8004) when `(frameCounter & 2) == 0` and keeps 0xffff8000 when `!= 0` (branch `bne v0,zero` taken). Project set `|= 4` on `!= 0` → flash a half-cycle out of phase. Changed to `== 0`. Cosmetic but a real deviation; build green. + +## OVR_230 game/230 MM_Battle cluster (2026-07-05) — CLEAN (0 fixes) — CloseSubMenu/DrawIcon_Weapon/Init/MenuProc + +| Function | Address | Verdict | +|---|---|---| +| MM_Battle_CloseSubMenu | OVR_230::800b164c | Match — menu->state (@+8) \|= ONLY_DRAW_TITLE(0x4) (asm lw/ori/sw). | +| MM_Battle_DrawIcon_Weapon | OVR_230::800b1660 | Match — builds one POLY_FT4 for the weapon icon. All 4 orientations × even/odd vertex permutations register-traced to prim[2/4/6/8]=xy0/1/2/3; uv0/uv1/uv2 raw-copied, transMode 0→code 0x2c raw tpage else 0x2e+(t−1)<<0x15 ABR; scaledW=(u1−u0)·lerp>>12, scaledH=(v2−v0)·lerp>>12 (uint·int product == signed, arith >>12 identical); OT link \|0x9000000, cursor@+0xc advanced by sizeof POLY_FT4 (CtrGpu_PrimToOTLink24 = native 24-bit addr). Project now matches the richer 926 binary (Ghidra plate note calling decomp "simplified" is stale). | +| MM_Battle_Init | OVR_230::800b1830 | Match — battle_transitionFrames=0xc (@0x800b59c6), battle_transitionState=MENU_TRANS_IN(0) (@0x800b59c2). | +| MM_Battle_MenuProc | OVR_230::800b1848 | Match — battle-setup handler (~0xb08 asm, all sections asm-verified). Sync 5 rows↔battleSettings; transition SM (in/focus/out): out>12→boolStart?menuQueueLoadTrack:TrackSelect_Init+menuTrackSelect. Recompute teamFlags/numTeams from teamOfEachPlayer (`(s16)(1<0 ·0xe100 (INF stays negative → skipped); lifeLimit=lives_3_6_9[lifeLife]; build RNG_itemSetCustom from enabledWeapons bits 0..0xd; boolStart=1,state=out; per-player team-backup diff → MainStats_ClearBattleVS; clear pads 1-3. Dropdown else: ProcessInput(matching box), ONLY_DRAW_TITLE→close. Draw: title lng 0x90, TYPE/LENGTH/TEAMS/WEAPONS lng 0x91/0x95/0x98/0x99, DrawSelf boxes (state bits ~0x140, \|0x40 SHOW_ONLY_HIGHLIT_ROW, \|0x100), height-stacked; team icon rows (DrawIcon_Character, MetaDataCharacters[characterIDs[p]].iconID) + colour bars (CTR_Box_DrawSolidBox, **ptrColor[PLAYER_BLUE(=24)+team]** == asm +i·4+0x60, width-distribution loop replicated bit-for-bit); 11-weapon grid (DrawIcon_Weapon, greyed color1/color2, "3" overlay lng &s_3 on weapons 7,8); error flash lng 0xaa/0xab (2 teams) 0xac/0xad (1 weapon) colour 0x8001/0x8003 by frameCounter&1 (**not inverted, unlike CupSelect**); highlight/inner boxes. Length arrays all `char` (time_3_6_INF signed = INF-negative). | + +**MILESTONE — game/230 MM_Battle_* cluster verified (this 0x800b164c+ block, 4 fns).** 0 fixes. (MM_Battle_DrawIcon_Character @0x800abaa8 / 230_00 is a separate earlier-address fn, not in this block.) + +## OVR_230 game/230 MM_HighScore cluster (2026-07-05) — 1 fix (site 115) — Text3D/Draw/Init/MenuProc + +| Function | Address | Verdict | +|---|---|---| +| MM_HighScore_Text3D | OVR_230::800b2f0c | Match — DrawLine(str,x,y,font,flags) then DrawLine(str,x+3,y+1,font,(flags&0xc000)\|BLACK(0x15)). BLACK=0x15 confirmed by Colors enum count. | +| MM_HighScore_Draw | OVR_230::800b2fbc | **Fix 115** (meta[7]→[8]) — else Match. Best-times panel (~0x958 asm). Colour-unpack idiom = inlined data.ptrColor[i]→colorPtr[0..3]. Resident table @0x8008e6f4 == &highScoreTracks[0].scoreEntry[0] (levID·0x124 + rowIndex·0x90 arithmetic confirmed; HighScoreEntry time@0/name@4/characterID@0x16 stride 0x18). L/R nav arrows icon[0x38] flashing ptrColor[frameCounter&4?0:3] (ORANGE/RED); track name lng metaDataLEV[levID].name_LNG; ghost stars (rowIndex==0, ×2, ghostBeatFlags@0x800b594c={1,2}<32 → timeTrialFlags bit, colorIndex@0x5948={14,22}); header lng 0xb3 @meta[1]; #1 best-lap (rowIndex==0): label lng 0xb4 @meta[7], **entry name/time/icon @meta[8]** (see fix); 5 rows @meta[i+2] (icon+name+time via Text3D, characterID+5, Y=+i·0x1f+{0x39 name, 0x4a time}); video @meta[9]. | +| MM_HighScore_Init | OVR_230::800b3914 | Match — transitionState=ENTERING_MENU(0), frames[0]=0xc, rowDesired=0, rowCurr=0, Video_SetDefaults. | +| MM_HighScore_MenuProc | OVR_230::800b3954 | Match — records-screen handler. Transition SM (out gated on frames[1]==0&&frames[2]==0 → JumpTo_Title_Returning). Input: menuHighScore ProcessInput (row 2→exit), L/R scroll trackDesired to next open arcade track (boolTrackOpen, wrap 0..0x11), TRIANGLE\|SQUARE_one→back. Video_State; two-axis slide (horiz ±0x40/frame frames[1], vert ±0x1b/frame frames[2]) commits Desired→Curr; two MM_HighScore_Draw panels (Curr + Desired) + meta[0] row-frame InnerRects. **RECTMENU_DrawSelf(menuHighScore, meta[10].currX/currY, 0xa4)** asm-confirmed (0x800b3da4 lh 0x6a = meta[10]). Project's swapped horiz/vert temp naming (iVar4↔iVar7) is applied consistently → semantically identical (asm s2=horiz→posX ±0x200, s1=vert→posY ±0xd8). | + +**Fix 115** (`230_65_MM_HighScore_Draw.c:90-100`): #1 best-lap entry mis-anchored. Asm 0x800b3544/3594/35d0 read `lhu 0x56(s1)` = transitionMeta_HighScores[8] for the entry name/time/icon, while only the "BEST LAP TIME" label (0x800b3500, `lhu 0x4c(s1)` = meta[7]) uses [7]. Project put the entry data on the label's [7] anchor → mispositioned during the slide-in transition. Also the time-string Y base reuses meta[8].currX (asm reuses a2=lhu 0x56(s1) for both X and Y — retail quirk), not currY. Changed name/time/icon to [8] and time-Y to [8].currX; build green. + +## OVR_230 game/230 MM_Scrapbook_Init/PlayMovie + MM_ResetAllMenus (2026-07-05) — CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| MM_Scrapbook_Init | OVR_230::800b3fe4 | Match — scrapbookState=SCRAP_INIT(0), RaceFlag_SetDrawOrder(1), RECTMENU_ClearInput. | +| MM_Scrapbook_PlayMovie | OVR_230::800b4014 | Match — 5-state FMV machine (PSX #else path == retail; CTR_NATIVE branches = NativeSTR/NativeAudio adaptations). INIT: FullyOnScreen→BeginTransition(2), state=LOAD, state&=~NEEDS_TO_CLOSE(0x1000, asm li −0x1001), Audio_SetState_Safe(1). LOAD: FullyOffScreen gate, SetMode_StreamData, CdSearchFile("\\TEST.STR;1") → SpuVol(vol_Music·0x80,·) + AllocMem(0x200,0xd0,0xa,0x40,1) + StartStream(CdPosToInt, 0x1148) + state=PLAY, else GO_BACK. PLAY: DecodeFrame(db[1−swap].drawEnv.ofs[0], ofs[1]+4)==0 loop + VSync(0), then CheckIfFinished(0)==1 \|\| btn(0x41070=TRIANGLE\|START(0x1000)\|CIRCLE\|SQUARE_one\|CROSS_one) → SetFullyOnScreen (on btn) + state=STOP, VSync(4). STOP: SpuVol(0,0)+StopStream+ClearMem, FullyOffScreen→BeginTransition(1), GO_BACK→state=EXIT. EXIT: FullyOnScreen→SetDrawOrder(0), !ADVENTURE_MODE(0x80000)→JumpTo_Title_Returning+mainMenuState=0(MAIN_MENU_TITLE)+lev=MAIN_MENU_LEVEL(0x27) else lev=GEM_STONE_VALLEY(0x19), RequestLoad+Hide. All constants asm/header-confirmed. | +| MM_ResetAllMenus | OVR_230::800b42b0 | Match (PSX path) — ×9 arrayMenuPtrs: state\|=8, &=~(ONLY_DRAW_TITLE\|DRAW_NEXT_MENU_IN_HIERARCHY)=~0x14 (asm 0xffffffeb), ptrNext(+0x24)=0, ptrPrev(+0x28)=0; framesRemainingInMenu=0xf. CTR_NATIVE do-while chain-walk is a documented adaptation (overlay 230 data not reloaded). | + +## OVR_230 game/230 MM_JumpTo_* cluster (2026-07-05) — CLEAN (0 fixes) — 6 fns + +| Function | Address | Verdict | +|---|---|---| +| MM_JumpTo_Title_Returning | OVR_230::800b4334 | Match — MM_State=3(RETURNING), ptrDesiredMenu=&menuMainMenu, countMeta0xD=title_numFrameTotal. | +| MM_JumpTo_Title_FirstTime | OVR_230::800b4364 | Match (asm-confirmed) — ResetAllMenus + ClearBattleVS; ptrActiveMenu=&menuMainMenu; timerInTitle=0(@0x5a14), MM_State=0(@0x5a1c); originalEventTime=0x2a300(@gGT+0x1d84, `lui 0x2;ori 0xa300`); menuMainMenu.state &=~(EXECUTE_FUNCPTR\|ONLY_DRAW_TITLE)=~0x404 \|= DISABLE_INPUT_ALLOW_FUNCPTRS(0x20) (`li v1,-0x405`); distanceToScreen_PREV/CURR=0x100(@gGT+0x180/0x274); gameMode1 &=~TIME_TRIAL(0x20000, `lui 0xfffd;ori 0xffff`). EUR #if path (menuLngBoot) is a documented region difference not in 926. | +| MM_JumpTo_BattleSetup | OVR_230::800b43f4 | Match — ptrActiveMenu=&menuBattleWeapons, state&=~ONLY_DRAW_TITLE, MM_Battle_Init. | +| MM_JumpTo_TrackSelect | OVR_230::800b4430 | Match — ptrActiveMenu=&menuTrackSelect, state&=~ONLY_DRAW_TITLE, MM_TrackSelect_Init. | +| MM_JumpTo_Characters | OVR_230::800b446c | Match — ptrActiveMenu=&menuCharacterSelect, state&=~ONLY_DRAW_TITLE, MM_Characters_RestoreIDs. | +| MM_JumpTo_Scrapbook | OVR_230::800b44a8 | Match — ptrActiveMenu=&menuScrapbook, state&=~ONLY_DRAW_TITLE, MM_Scrapbook_Init. | + +## OVR_230 game/230 MM_Video_* STR/MDEC cluster (2026-07-05) — 1 fix (site 116) — 9 fns — **OVR_230 MODULE COMPLETE** + +| Function | Address | Verdict | +|---|---|---| +| MM_Video_DecDCToutCallbackFunc | OVR_230::800b5a64 | Match — MDEC slice callback: (flags&1 && StCdIntrFlag@0x8009ebf8)→StCdInterrupt+clear (PSX only, native skips PSYQ BSS); BreakDraw; LoadImage(&slice, out_Buf[imgId]); slice.x+=slice.w; imgId^=1; curCol(0x12)==lastCol(0x10)→isDone else curCol++ + DecDCTout(out_Buf[imgId], field32@0x58); DrawOTag(ot). | +| MM_Video_KickCD | OVR_230::800b5b7c | **Fix 116** (sentinel) — else Match. CD state machine (field12@0x20): reset guard `location && ptrCdLoc != &cdLocation1` (asm 0x800b5b9c `addiu 0x6824`); state 0 Setloc→1 (fallthrough), 1 Setmode(0x80)→2, 2→3, 3 CdRead2(0x1a0 or 0x1e0 if flags&2)→ptrCdLoc=0. | +| MM_Video_VLC_Decode | OVR_230::800b5c8c | Match — STR ring consumer. StRingStatus+StGetBackloc(&cdLocation2@0x6828); ring-full 400-frame reset→KickCD(&cdLocation1@0x6824); ring-low refill (flags&8==0)→KickCD(&cdLocation2); backloc-stall 0x40→KickCD(&cdLocation3@0x682c); loop/pause (flags&4: CdlPause+isDone=1 else loop-point bookkeeping via CdPosToInt(&cdLocation2)+KickCD(&cdLocation1)); StGetNext retry×10 (dup-skip, loop-end marker, DecDCTBufSize≤field25@0x48 → copy CdlLOC sector+0x1c→cdLocation3 + DecDCTvlc2(frame, in_Buf[field10@0x1c], ptrVlcTable) + drawNextFrame=1). All cdLocation1/2/3 args asm-confirmed. | +| MM_Video_StartStream | OVR_230::800b615c | Match — reset ~15 V230 fields (base @0x800b67ac), numFrames=arg, CdIntToPos(sector,&cdLocation1@0x6824), StSetStream(flags&1,0,-1,0,0), SetMode_StreamData, StSetRing(out_Buf[2]@0x6814, RING_SIZE@0x40)+StClearRing, ptrCdLoc=&cdLocation1, field12=0. | +| MM_Video_StopStream | OVR_230::800b6260 | Match — CdDiskReady(1)==2→spin CdlPause; StClearRing+StSetMask(1,0,0); CdDataCallback(0)+CdReadyCallback(0); DecDCTReset(1); drawNextFrame=0. | +| MM_Video_AllocMem | OVR_230::800b62d8 | Match — MEMPACK_PushState; RING_SIZE=size(<1→0x40); iBytesPerPx=flags&1?3:2, DCT_MODE=flags&1; totalFrames/field32@0x58/field25@0x48 from w/h·MB-round; AllocMem out_Buf(field32<<3)/in_Buf(field25<<3)/ring(RING_SIZE<<0xb) + [1]=base+field·4; slice{0,0,iBytesPerPx<<3,height}; DecDCTReset(0)+DecDCTvlcSize2(field25); **DecDCToutCallback(func)** under Enter/ExitCriticalSection. **NOTE: 0x80079960 — Ghidra DB mislabels it `DecDCTinCallback`, but syms926.txt:1334 authoritatively names it `DecDCToutCallback` (self-consistent with MM_Video_DecDCToutCallbackFunc); project is CORRECT.** (uHeightMB `&0xffff` / totalFrames `&0xfff` masks omitted by project = benign, only differ for absurd dims.) | +| MM_Video_ClearMem | OVR_230::800b64d4 | Match — MEMPACK_PopState (== binary MEMPACK_PopBookmark). | +| MM_Video_DecodeFrame | OVR_230::800b64f4 | Match — CdDiskReady(1); field3@0xc==1 && ready==2 → restart KickCD(&cdLocation3@0x682c); else ready==0x10 → field9=1/field14=0/field3=1/field20=frameCount−1/StClearRing; field3==1→drawNextFrame=0 else ptrCdLoc!=0→KickCD(0)+VLC_Decode, drawNextFrame==1 → frameCounter=0, slice.x/y=off, DecDCTin(in_Buf[field10@0x1c], DCT_MODE@0xc0), field10^=1, DecDCTout(out_Buf[imgId], field32). | +| MM_Video_CheckIfFinished | OVR_230::800b6674 | Match — busy-wait (40000, native 90000) until isDone/timeout, scrapbook→CdDiskReady end-of-disc(0x10); IsIdleGPU spin; drawNextFrame=0; !ended && curCol!=lastCol → DecDCTReset(1); return field8@0x18. | + +**Fix 116** (`230_78_MM_Video_KickCD.c:10`): CD-restart sentinel mis-anchored. Asm 0x800b5b9c-0x800b5ba0 (`addiu v0,0x6824; beq v1,v0`) compares ptrCdLoc against **&cdLocation1 (0x800b6824)** — the address StartStream seeds and points ptrCdLoc at. Project used `&V230.cdLocation2` (0x6828, a distinct offset), so KickCD's reset block would wrongly fire on the restart `KickCD(&cdLocation3)` call while ptrCdLoc still == &cdLocation1, zeroing field12_0x20 (CD state) mid-cycle instead of continuing. Changed to `&V230.cdLocation1`; build green. (Confirmed via struct: cdLocation1@0x6824, cdLocation2@0x6828, cdLocation3@0x682c; all other KickCD/CdPosToInt call sites already use the correct field.) + +**MILESTONE — OVR_230 (game/230, main-menu overlay) FULLY VERIFIED.** The complete MM_* sweep (Title, Characters, TrackSelect, CupSelect, Battle, HighScore, Scrapbook, ResetAllMenus, JumpTo, Video) is done. Fixes in this overlay: 112 (numPlyrNextGame), 113 (animIndex), 114 (CupSelect flash), 115 (HighScore meta[8]), 116 (KickCD sentinel). + +## OVR_221 game/221.c CC_EndEvent_DrawMenu (2026-07-05) — CLEAN (0 fixes) + +OVR_221-225 = per-mode EndEvent (end-of-race results) overlays, dispatched by `OVR_Region1` (native switch on gGT->overlayIndex_EndOfRace; PS1 overlays share entry addr). case 0=CC (crystal challenge), 1=AA, 2=RR, 3=TT, 4=VB. + +| Function | Address | Verdict | +|---|---|---| +| CC_EndEvent_DrawMenu | OVR_221::8009f710 | Match — crystal-challenge results. boolLose=driver->numCrystals0x1e grow) == Ghidra's two separate ifs (mutually exclusive). | + +## OVR_222 game/222.c AA_EndEvent (2026-07-05) — DisplayTime CLEAN; DrawMenu **DIVERGENT (documented, NOT fixed)** + +| Function | Address | Verdict | +|---|---|---| +| AA_EndEvent_DisplayTime | OVR_222::800a06f8 | Match — per-driver time readout. hudArray=data.hudStructPtr[numPlyr−1], UiElement2D=8 bytes {x@0,y@2,z@4,scale@6}, slot 2=BigNum / slot 5=Suffix (hud[driverId·0x14+slot], asm stride 0xa0 == 0x14·8). boxH 7→0x49/5→0x39/else 0x44; framesSinceRaceEnded_forThisDriver++ (1P skip to 0x6e on Cross/Circle); isLate = framesElapsed+frameOffset>300; BigNum/Suffix/Clock fly-in/out (UI_Lerp2D + ConvertX/Y_2) + scale→0x1e00; UI_DrawPosSuffix/UI_DrawRaceClock; TOTAL box (lng 0xc4, InnerRect style 4). All coords/frames asm-verified. | +| AA_EndEvent_DrawMenu | OVR_222::8009f704 | **Fix 117** (Loading configs on lose path) — else Match. Full routing block disassembled + reconciled. | + +**AA_EndEvent_DrawMenu — resolved (2nd pass, 2026-07-05):** +- **Fix 117 (Loading configs on LOSE path):** retail win path (LAB 0x800a03b8, after PRESS-X tap) sets OnBegin.AddBitsConfig0|=ADVENTURE_ARENA(0x100000) unconditionally, then **boss-only** AddBitsConfig8|=SPAWN_AT_BOSS(1) + RemBitsConfig0|=ADVENTURE_BOSS(0x80000000), and **normal-only** RemBitsConfig8|=TOKEN_RACE(8); the LOSE path (0x800a0658) sets **NO** configs. Project set all four BEFORE the `if(!didWin)` split → lost races queued gameMode changes that persist into Retry (UI_RaceEnd_MenuProc case 4/RESTART sets `Loading.stage=LOAD_RESTART` and clears none of them). Moved the writes into the win path, split boss/normal to match the asm. Build green. Fix is safe (can't regress below retail — retail's shared retry/exit menu@0x80086314 works without AA pre-setting configs). +- **CTR-token unlock — NOT a bug (initial read wrong):** handled via the rewardBit mechanism — line 115 sets `rewardBit=levelID+FIRST_CTR_TOKEN(0x4c)` for non-boss didEarnCtrToken wins, unlocked at line 487. Matches retail's normal-branch `if numCollected==3: set bit(levelID+0x4c)`. +- **Oxide rewards — NOT a bug (equivalent):** `storyFlags`@offset 0xc == retail's `advProgress[3]` (byte 0xc). `OXIDE_FIRST_WIN_FLAGS = BEAT_OXIDE_FIRST_BOSS_MASK(bit0x62→0x4)|BEAT_OXIDE_FIRST_MASK(bit0x73→0x80000) = 0x80004` == retail `advProgress[3]|=0x80004`; `OXIDE_SECOND_WIN_FLAGS=0x100008`. Project's `storyFlags|=OXIDE_FIRST` (unconditional) + rewardBit(bossID+0x5e) unlock produces the same monotonic bits as retail's first-time-gated bit(bossID+0x5e)/bit(bossID+0x6f) + `if(advProgress[3]&4)==0` writes (setting already-set bits = no-op). +- **Verified-matching routing:** key bosses bossID<4 → podiumRewardID=STATIC_KEY(0x63) first beat (bit bossID+0x5e), HOT_AIR_SKYWAY(levelID==7)+KEY → RequestLoad(0x19=GemStone) else prevLEV; ADVENTURE_CUP/CUP_ANY_KIND → hudFlags&=~1|=4; ARCADE → menu222/222_2P; continue-gate framesSinceRaceEnded ≥ displayTimeOffset(0x78 token/0 else)+0x6e; win = (TOKEN_RACE?rank0&&letters3:rank0). + +## OVR_223 game/223.c RR_EndEvent cluster (2026-07-05) — 1 fix (site 118) — **OVR_223 COMPLETE** + +| Function | Address | Verdict | +|---|---|---| +| RR_EndEvent_UnlockAward | OVR_223::8009f71c | **Fix 118** (relicTime_10ms) — else Match. crateBonus=0x2580 if numTimeCrates==timeCratesInLEV; ×3 tiers: if raceTime−crateBonus ≤ RelicTime[levelID·3+tier] && bit clear → set bit (sapphire FIRST_SAPPHIRE(0x16)/gold+STRIDE(0x12)=0x28/platinum+2·STRIDE=0x3a, all +levelID), podiumRewardID=STATIC_RELIC(0x61), gameModeEnd\|=NEW_RELIC(0x2000000); sapphire+TURBO_TRACK(0x11)→unlocks[0]\|=2; gold/platinum store 5 relic-time digits. | +| RR_EndEvent_DrawMenu | OVR_223::800a01d8 | Match — relic-race results. relic color plat(levelID+0x3a→0xffede90)/gold(levelID+0x28→0xd8d2090); timebox scale 0x300; framesSinceRaceEnded cap 900, ≥0x1fe→DRAW_HIGH_SCORES; missed-crate skips (→0x8c PERFECT / →0x172 relic); race-clock + relic (grow 0x80→0xc00, SFX 0x67@0xfb) fly-in/out; crate count "x"+"%2.02d/%ld"; PERFECT (lng 0x162, SFX 0x65) + −10..0 countdown (−0x3c0/tick @160, SFX 99); RELIC AWARDED (0x160)/NEW HIGH SCORE (0x161) banners; fly-out rect; **retry tail: framesSinceRaceEnded≥0x1fe && !NEW_HIGH_SCORE → DrawHighScore(0x100,10,1) + PRESS-X → ShowMenu(endEventRetryExit) — NO Loading configs set (matches retail; no AA-style lose-path bug).** | +| RR_EndEvent_DrawHighScore | OVR_223::8009fcd0 | Match — BEST-TIMES top-5 (shared w/ TT via `scoreMode` param; MM version lacks rank icons). scoreEntries=&highScoreTracks[levelID].scoreEntry[6·scoreMode] (base 0x8008e6f4 == MM_HighScore's; stride 0x124/track, 0x18/entry; time@0/name@4/characterID@0x16). "BEST TIMES"(0x171); 5 rows (entry i+1): rank '1'+i (FONT_SMALL,4), icon grey(0x808080), name (color characterID+5, flash 4 if newHighScoreIndex), time (RECTMENU_DrawTime, flash), highlight box on new; footer TT(scoreMode0)→"BEST LAP"(0x170)+scoreEntry[0].time else RR→"YOUR TIME"(0xc5)+driver time; InnerRect. | + +**Fix 118** (`223.c:118`, relicTime_10ms): retail (asm 0x8009f948-0xfa60 gold / 0x8009fbac-0xfcc4 platinum) stores FIVE relic-time display digits; the 4th = `(relicTime / 0x60) % 10` (tenths-of-a-second; 0x60=96=RR_RACE_TIME_ONE_SECOND/10). Project set `relicTime_10ms = 0` (explicit "[Not Done]"; plate note confirms the decomp left it 0). Changed to compute it → the displayed relic time no longer drops that digit. Build green. + +## OVR_224 game/224.c TT_EndEvent + OVR_225 game/225.c VB_EndEvent (2026-07-05) — CLEAN (0 fixes) — **OVR_224 & OVR_225 COMPLETE** + +| Function | Address | Verdict | +|---|---|---| +| TT_EndEvent_DrawMenu | OVR_224::8009fdc8 | Match — time-trial results. flags\|=1; NTROPY_JUST_BEAT && CheckGhostsBeaten(1) → unlocks[0]\|=UNLOCK_TROPY(0x20). Frame-pacing (project `\|\|`-chain == Ghidra nested: ++ if <900 \|\| (!NEW_HIGH_SCORE && ((≤1000) \|\| (menuReady&0x10 && <1018)))). Phase A (≤90): race clock corner (flyout @>65). Phase B (91-900): DisplayTime + NEW HIGH SCORE(0x161,+flag4)@>0x78 / NEW BEST LAP(0x172,+flag 1<<(3+lapIndexNewBest))@>0x96 / N.Tropy opened(0x173)/beaten(0x174)@>0xb4, PRESS-X→frame 901. Phase C (901-1016): DRAW_HIGH_SCORES + DrawHighScore(0) slide + DisplayTime + PRESS-X→frame 1001+menuReady0x10. End (≥1017): flags=0, ShowMenu(boolGhostTooBigToSave?menu224NoSave:menu224). (Benign: phase-B draw-order/1-frame flash-flag timing.) | +| TT_EndEvent_DisplayTime | OVR_224::8009f704 | Match — "YOUR TIME"(0xc5) label + race clock (clockFlags=flags) + TOTAL(0xc4) box; centered on paramX (ND Lerp start==end no-op). | +| TT_EndEvent_DrawHighScore | OVR_224::8009f8c0 | Match — identical to RR_EndEvent_DrawHighScore (separate per-overlay copy; base 0x8008e6f4, 5 rows, rank icons, TT/RR footer). | +| VB_EndEvent_DrawMenu | OVR_225::8009f718 | Match — versus/battle standings. framesSinceRaceEnded cap 0xf0; title VERSUS(0x14f)/BATTLE(0x50)+STANDINGS(0xca) fly-in; numStandRows=numPlyr(vs)/numTeams(battle); battle tallies teamPlayerCount[teamID], titleY=(0xd8−((numTeams−1)·10+numPlyr·0x1a+0x28))>>1. Per standings row (standingsOrder[i]): fly-in (rowDelay 0x1e+5i), vs icon @s_vsStandingsY[numPlyr−2][1+i] / battle team icons stacked @0x1b spacing; places loop (visiblePlaces=2 or numStandRows−1): "%d%s-%2.02ld" points, flash place==rank; rank "%d%s" (tie-collapse via standingsScore). **Suffix `SUFFIX_FIRST(LNG_ST=0x19)+idx` == D225_rankSuffixLng@0x800a0200 {0x19,0x1a,0x1b,0x1c} (asm-confirmed consecutive).** Winner viewport (driversInRaceOrder[0]/winnerIndex[0]) flies to {0x14,0xc} + outer rect (2P width step 0xc, dist 0x80); losers shrink (x+5,y+3,w−10,h−6); zero BigNumber scale. ≥0x19 → ShowMenu(menuVS/menuBattle). | + +**MILESTONE — OVR_221-225 EndEvent overlays FULLY VERIFIED.** All five per-mode end-of-race results screens: CC (crystal challenge), AA (adventure/arcade, fix 117), RR (relic race, fix 118), TT (time trial), VB (versus/battle). Dispatched by OVR_Region1 (native switch on overlayIndex_EndOfRace). Shared helper UI_RaceEnd_MenuProc (retry/exit) verified during AA investigation. + +## OVR_231 game/231 RB_ weapons/hazards overlay (2026-07-05) — IN PROGRESS — 7 fixes (119-125) + +OVR_231 = RaceBucket weapon/hazard overlay (131 RB_* fns @ base 0x800abab0; dispatched region OVR_Region3@0x800ab9f0). Overlaps addr space with OVR_230/232/233 — use `OVR_231::`. syms list RB_* names (grep `' RB_'`). Verifying in address order. + +| Function | Address | Verdict | +|---|---|---| +| RB_MakeInstanceReflective | OVR_231::800abab0 | Match (asm-verified) — weapon floor-reflection. !touchedQuad(@0x3e) \|\| touchedHitbox(@0x42) → bitCompressed=0x4000 + clear REFLECTIVE; else bitCompressed=INST_CompressNormalVector(normal.x/y/z @0x70/72/74; packs [6:13]→[0:7]/[8:15]/[16:23], matches inline asm); 1P only (numPlyr<2): quadFlags@0x12 & COLLISION_SURFACE(0x2000)→clear; else &REFLECT_SPLIT_LINE_1(0x1)→REFLECTIVE+vertSplit=splitLines[1](@level1+0x186); else &REFLECT_SPLIT_LINE_0(0x4)→splitLines[0](@0x184); else clear. (Flags _Static_assert'd; plate note's "splitLines[2]" was a mislabel — asm confirms [1]/[0].) | +| RB_MinePool_Init | OVR_231::800abfec | Match — LIST_Clear taken/free; count=10 default, CRYSTAL_CHALLENGE→40 (ND bug, should be 50), ADVENTURE_BOSS(gameMode1<0) && DRAGON_MINES→3 / ROO_TUBES→7; seed free list w/ count minePoolItem[]. (Project's CRYSTAL-then-BOSS order == Ghidra's nested for all real cases — modes mutually exclusive.) | +| RB_MinePool_Remove | OVR_231::800ac0e4 | Match — weaponSlot231!=0 → RemoveMember(taken)+AddFront(free), boolDestroyed=1, weaponSlot231=0. | +| RB_MinePool_Add | OVR_231::800ac13c | Match — free.count==0 → evict taken.last->mineWeapon via Remove; RemoveBack(free), cross-link slot<->mw, AddFront(taken). | +| RB_Player_ModifyWumpa | OVR_231::800abefc | Match — no-op if CHEAT_WUMPA; skip subtract if MASK_WEAPON(0x800000); gain && !ACTION_BOT → numTimesWumpa+=delta; numWumpas+=delta clamp [0,10]; 9→10 → SFX 0x41 + BattleHUD.juicedUpCooldown=10. | +| RB_Hazard_HurtDriver | OVR_231::800ac1b0 | Match — !ACTION_BOT → VehPickState_NewState else (OXIDE_STATION && boss-race → damageType=DAMAGE_SPINOUT(1)) BOTS_ChangeState. | +| RB_Hazard_CollideWithDrivers | OVR_231::800ac220 | Match — scan 8 drivers (skip null/KS_MASK_GRABBED); distCheck=dx²+dz² (2D), +dy² if potion(STATIC_BEAKER_RED..+1)/Nitro(PU_EXPLOSIVE_CRATE)/TNT(STATIC_CRATE_TNT); return driverInst if distCheckACTION_TRACKER_TARGETED(0x04000000), PlaySound3D(0x4c), OtherFX stop audio, thread\|=THREAD_FLAG_DEAD(0x800). (native void vs retail ret 1.) | +| RB_Hazard_ThCollide_Generic | OVR_231::800ac4b8 | Match (incl. prior claude crate null-check fix) — 3-level guard crateInst→thread→object → boolPauseCooldown@+4=0; beaker→SFX 0x3f+MinePool_Remove; Nitro→0x3f / TNT(no driverTarget)→0x3d → SFX+MinePool_Remove+Explosion_InitGeneric+scale0+HIDE_MODEL; thread\|=DEAD. | +| RB_Potion_OnShatter_TeethCallback | OVR_231::800ac5e8 | Match — bspHitbox.hitbox.instDef && ptrInstance && modelID==STATIC_TEETH(0x70) → RB_Teeth_OpenDoor. | +| RB_Potion_OnShatter_TeethSearch | OVR_231::800ac638 | Match — fill scratchpad@0x108 (Input1.pos=(s16)matrix.t, hitRadius 0x140/²0x19000, modelID, ThBuckColl.thread=inst->thread, funcCallback=TeethCallback), PROC_StartSearch_Self. | +| RB_Potion_ThTick_InAir | OVR_231::800ac6b4 | Match (asm-verified) — pos+=velocity·elapsedTimeMS>>5; gravity velocity.y−=(elapsed<<2)>>5 (terminal −0x60); cooldown−=elapsed clamp 0; downward BSP ray [y−0x40..y+0x100] via COLL_SearchBSP + MakeInstanceReflective. **Scratchpad flags: searchFlags@0x22=TEST(0x1)\|FORCE(0x40)=0x41 (+HIGH_LOD(0x2)=0x43 if numPlyr<3), quadFlagsWanted@0x24=GROUND(0x1000)\|TRIGGER(0x40)=0x1040 — offsets/values _Static_assert-confirmed vs asm.** stepFlags&WEAPON_REACT → ThDestroy; hitbox teeth(0x70) → OpenDoor (unless doorAccessFlags&1); quadblock → settle (cooldown 0xf00, velocity 0, ThTick_SetAndExec GenericMine_ThTick) / bounce; else re-ray [y−0x900] then ThDestroy. **Ends `jr ra` (0x800aca48) — NOT terminal (returns to caller).** **RB_GenericMine_ThDestroy@0x800ad250 (syms) — Ghidra's "RB_Hazard_ShatterAndRemove" is a DB mislabel; project correct.** | +| RB_GenericMine_LInB | OVR_231::800aca50 | Match (incl. prior claude tntSpinY=0 fix) — RB_Default_LInB; CRYSTAL_CHALLENGE && no thread → PROC_BirthWithObject(0x2c0304, GenericMine_ThTick, "nitro") + link inst↔thread, funcThCollide=ThCollide_Generic, parentThread=drivers[0], modelIndex, zero MineWeapon fields, instParent=drivers[0], stopFallAtY=matrix.t[1], MinePool_Add. (Add/stopFallAtY order-independent.) | +| RB_GenericMine_ThDestroy | OVR_231::800ad250 | Match — by model: Nitro(PU_EXPLOSIVE_CRATE)→SFX 0x3f+RB_Blowup_Init; TNT(0x27)→0x3d+RB_Blowup_Init; else(potion/beaker)→0x3f+RB_Explosion_InitPotion; then scale=0, HIDE_MODEL, MinePool_Remove, thread\|=DEAD. (Ghidra "RB_Hazard_ShatterAndRemove" mislabel; syms confirm ThDestroy.) | +| RB_TNT_ThTick_ThrowOffHead | OVR_231::800ad310 | Match — pos.y+=velocity.y·elapsed>>5; latch stopFallAtY=driverTarget.matrix.t[1] if ==0x3fff; ≤stopFallAtY → SFX 0x3d + RB_Blowup_Init + scale0 + HIDE_MODEL + thread DEAD + driverTarget->instTntRecv=0; gravity velocity.y−=(elapsed<<2)>>5 (terminal −0x60). **Ends via ThTick_FastRET — TERMINAL.** CTR_NATIVE null-driverTarget guards (documented; PSX #else = retail blind read). | +| RB_GenericMine_ThTick | OVR_231::800acb60 | **Fix 119 + Fix 120** (two ThTick control-flow returns) — else Match. Big hub: thrown(extraFlags&2)→TNT set scale0x800/ThrowOffHead \| else cooldown0xf0/InAir; grounded pass (cooldown decay, anim cycle, gravity+clamp stopFallAtY, scale→0x1000, CollideWithDrivers hitRadius 0x3840/0x1900 beaker); on hit → HurtDriver + beaker(RainCloud if red, fade flash, damageColorTimer ±0x1e) / instTntRecv-blast / squish / Nitro(6) / TNT(0x27: instDef==0 reuse thread→ThrowOnHead \| else birth "tnt1" 0x27 SMALL/MINE + copy matrix + ThrowOnHead); tail frameCount−−, boolDestroyed→explode. All HurtDriver args/model consts/fade(0x1fff/0x1000/−0x88)/offsets asm-verified. | +| RB_TNT_ThTick_SitOnHead | OVR_231::800ad44c | **Fix 121** (0x5a-explode terminal return) — else Match. LHMatrix_Parent to driverTarget+deltaPos; kartState CRASHING/MASK_GRABBED/SPINNING→SFX0x3d+Blowup, BLASTED(6)→0x3d+Explosion_InitGeneric, both scale0/HIDE/DEAD/instTntRecv=0 (**ThTick_FastRET terminal**→proj return ✓); human jump(ACTION_JUMP_STARTED 0x400, jumpsRemaining−−) / AI RNG 1/0x10e → throw off (scale0x800, velocity.y0x30, ThTick_SetAndExec ThrowOffHead **terminal**→proj return ✓); else numFramesOnHead<0x5a → honk SFX 0x3e @{0,0x14,0x28,0x3c,0x46,0x50}, ++, scale=s_tntSitScale[frame]; ≥0x5a → HurtDriver+SFX0x3d+Blowup+DEAD (**ThTick_FastRET terminal**→**Fix 121 adds return**). | +| RB_TNT_ThTick_ThrowOnHead | OVR_231::800ad710 | Match — TNT arcs onto victim head: pos.y+=velocity·elapsed>>5; when rising & above target height (RDATA table 0x800b2ac4[characterID]) → snap, numFramesOnHead=0, SFX 0x51, seed jumpsRemaining from driver kart +0x50/0x51 −1, ThTick_SetAndExec SitOnHead; LHMatrix_Parent + VehVec parenting + gravity + scale shrink toward 0x800. **Ends `jr ra` (0x800ad924) — NOT terminal**, so retail's `j 0x800ad17c` after the ThTick_SetAndExec in GenericMine_ThTick's existing-thread path IS reached (⇒ Fix 120 goto is correct). | +| RB_Explosion_ThTick | OVR_231::800ad92c | Match — advance inst->animFrame until animFrame+1≥numFrames (INSTANCE_GetNumAnimFrames) then thread\|=DEAD; ThTick_FastRET. Project calls the native no-op ThTick_FastRET as last statement (= retail terminal end). | +| RB_Hazard_CollLevInst | OVR_231::800ad9ac | Match — hitbox flag&0x80 + instDef(@0x1c) + ptrInstance(@0x2c) → COLL_LevModelMeta(inst->model->id); if meta->pLInC → result=pLInC(inst,th,sps); return 0 for models {2 wumpa, 7 fruit-crate, 8 random-crate}, else result; return 1 if no callback (lets potion→teeth / warpball→turn through). Signed model compares match. | +| RB_Hazard_InterpolateValue | OVR_231::800ada90 | Match — shortest-path angle ease in 0xfff range: currRot==desiredRot→currRot; diff=(desiredRot−currRot)&0xfff wrapped to [−0x800,0x7ff]; \|diff\|>5 → &0xfff. (diff==0 edge needs out-of-range angles ⇒ unreachable.) Signed mul + arithmetic shift match. | +| RB_MovingExplosive_ThTick | OVR_231::800adb50 | Match (full asm walk 0x800adb50-0x800ae478) — homing bomb(0x3b)/shield(0x56/0x57)/rocket(0x29). Loop SFX 0x48/0x59/0x4b; native null-guard on driverTarget (documented PS1 null-space path); invisibleTimer clears target; seek-mine 10f on actionsFlagSetPrevFrame<0 (rocket); ratan2 to target/instTntSend; turn RB_Hazard_InterpolateValue speed 4(bomb/shield)/0x40(rocket)/0x80(rocket+10wumpa), vel=Sin/Cos·3>>7 or ·5>>8 (flags&0x20 swap); bomb dir spin ±0x200; pos+=vel·elapsed>>5; BSP ray [y−0x40..+0x100] (**searchFlags@0x22=0x41/0x43, quadFlagsWanted@0x24=0x1040, quadFlagsIgnored@0x28=0, ptr_mesh_info@0x2c — Ghidra `field6_0x16=0` is a decompiler artifact, no such write in asm**); stepFlags&4 → reverse vel+move+Explode; quadblock snap hitPos(0x1c/0x1e+0x30/0x20)+RotAxisAngle(rocket); re-ray [y−0x900]; CollLevInst teeth(0x70); CollideWithDrivers r=0x2400 (damageColorTimer=0x1e, flags\|=0x10 if target); CollideWithBucket MINE(4)/TRACKING(6)→funcThCollide(hitTh,t,funcPtr,0). CTR_NATIVE particle trail (native emitter). | +| RB_MovingExplosive_Explode | OVR_231::800ae478 | Match — bomb(0x3b)→SFX 0x49 + driverParent->instBombThrow(@0x10)=0; else(missile)→driverTarget->actionsFlagSet&=~ACTION_TRACKER_TARGETED(0x4000000) if target, SFX 0x4c; PlaySound3D + OtherFX mute(audioPtr@0x24) + RB_Burst_Init + thread\|=DEAD. | +| RB_Warpball_FadeAway | OVR_231::800ae524 | Match — 6-step shrink: frameCount5(@0x30)>5 → clear driverTarget ACTION_TRACKER_TARGETED + gGT->gameMode1&=~WARPBALL_HELD + thread DEAD; else scale=s_warpballFadeScale[frame·3] (0x800b2c88), matrix.t[1]=distFromGround+s_warpballFadeY[frame] (0x800b2cac), frameCount5++. Ends `return` (not terminal). Signed frame index [0,6]. | +| RB_Warpball_Death | OVR_231::800ae604 | Match — **project reads `inst->matrix.t[1]` (asm `lw 0x48(a1)`); Ghidra's `inst->tileView` is a struct_CameraDC union mislabel of the same offset 0x48.** distFromGround=matrix.t[1]; ptrParticle(@0xc)->framesLeftInLife(@0x10)=0; frameCount5=0; SFX 0x4f; OtherFX mute; ThTick_SetAndExec FadeAway (last stmt). (Write order differs — all independent fields.) | +| RB_Warpball_NewPathNode | OVR_231::800ae668 | Match — pick next path node toward driver: d==0→nextIndex_forward; targetIdx=d->checkpoint.branchChoiceIndex(@0x494)==nextIndex_left→left; else if left!=0xff walk ≤3 nodes (nextIndex_left else _forward) looking for node whose _forward==targetIdx → found⇒left, not-found⇒_forward. nextIndex_left==0xff/-1 byte compare. | +| RB_Warpball_Start | OVR_231::800ae778 | Match — ptrNodeCurr=NewPathNode(ptrNodeCurr,target); ptrNodeNext=NewPathNode(ptrNodeCurr,target) — 2nd call reads the just-updated Curr. (Ghidra's run-once do-while is the single first call.) | +| RB_Warpball_GetDriverTarget | OVR_231::800ae7dc | Match — flags&1==0 (not juiced): return 1st non-parent, not-finished driver in race order (leader). Juiced: project the orb onto the node1→node2 segment — **GTE MVMVA sf=0 computes full-precision row-0 dot(pathVector,orbVector), the `>>12` is applied in software (Ghidra reads MAC1 then `>>0xc`) = project's int dot `>>12`**; projDist=(node1.distToFinish<<3)+(dot>>12)+0x200; scan 8 drivers (skip driversHit bit/finished/MASK_GRABBED) for min positive wrap gap vs distanceToFinish_curr (%trackDistance hoisted out of loop = equivalent). trap(0x1c00/0x1800) are MIPS div-guards. | +| RB_Warpball_SetTargetDriver | OVR_231::800aeaac | Match — target==0→ret. Walk back from nodes[driver->checkpoint.currentIndex(@0x495)] while distToFinish<<3 ≥ driver->distanceToFinish_curr (NewPathNode) — `prevNode`/`pTargetNode` "one-behind" tracking equivalent. If !(flags&4): 2-lane (ptrNodeCurr, then right-branch node) ×3-back-step search for targetNode → flags(&~8)\|4. Then 3-step forward walk (NewPathNode) → flags(&~8)\|4. | +| RB_Warpball_SeekDriver | OVR_231::800aece0 | Match — d!=0 && (startIdx&0xff)!=0xff: from nodes[startIdx] follow NewPathNode while d->distanceToFinish_curr ≤ node.distToFinish<<3 && node!=first; store (cn−first) into nodeCurrIndex(@0x44). (Ghidra `*−0x55555555 >>2` = compiler div-by-sizeof(CheckpointNode)=12 == project's `cn − first`.) | +| RB_Warpball_TurnAround | OVR_231::800aede0 | Match — **Ghidra cameraDC aliases driverToFollow/tileView/pCamVel = inst->matrix.t[0/1/2].** flags&0x100 \|\| target==0: if flags&4→flags(&0xfffb)\|0x208; negate vel x/y/z; matrix.t += vel·elapsed>>5; turnAround++; >0x78 \|\| target==0 → instBombThrow=0 + SFX 0x4f + RB_Warpball_Death (falls through, Death is jal not tail-call); every 4th frame step ptrNodeCurr=nodes[_backward]; dir.y=ratan2(curr.pos[0]−t[0], curr.pos[2]−t[2]). | +| RB_MaskWeapon_FadeAway | OVR_231::800afb70 | Match — Aku/Uka mask fade-out. durationAdjusted=((s16)duration>>5)·−4+0x40 (Ghidra `(d<<16)>>21`; d∈[0,0x200] so sign moot); posOffset x/z=durationAdjusted·Sin/Cos(rot.y)>>0xc, y=0x40; rot.y−=0x100; 2-pass loop LHMatrix_Parent(mask, then beam @offset(0,0x40,0)) + scale−=0x100; ConvertRotToMatrix+MatrixRotate beam spin; alphaScale+=0x200 to 0x1000; duration+=elapsed to 0x200 then INSTANCE_Death(beam)+thread DEAD. (rot-decrement & beam-scale order differ — independent fields.) | +| RB_MaskWeapon_ThTick | OVR_231::800afdbc | Match (full asm walk 0x800afdbc-0x800b0278) — active mask tick. **Per-player pushbuffer bind: asm confirms BOTH mask & beam idpp use offset 0x74, stride 0x88 (Ghidra's mask-stride-0xc is a decompiler artifact) → project's uniform INST_GETIDPP[i] correct.** invisibleTimer==0→bind all to &pushBuffer[i]; else NULL for i!=driverID. Copy driverInst REFLECTIVE(0x4000)/vertSplit(0x56)/depthBias(0x50/0x51); orbit ((Sin/Cos(rot.y)<<6)>>0xc)·scale(@0x12)>>0xc, posOffset[1]=maskPosArr[beam.animFrame]+0x40; rot.z&1 → LHMatrix_Parent+ConvertRotToMatrix+MatrixRotate else direct pos=mask.pos+offset (2-pass, beam offset reset to 0,0x40,0); beam anim cycle; rot.y−=0x100; duration==0→ThTick_SetAndExec FadeAway (falls through — FadeAway returns), else duration−=elapsed clamp 0; un-hide+scale=mask->scale both, beam alphaScale=0. | +| RB_ShieldDark_ThTick_Pop | OVR_231::800b0278 | Match — shield bubble pop. Parent instDark+instColor to owner->instSelf (identity rot: 0x1000 diagonal, project `*(int*)&m[i][j]` writes = Ghidra's short/undefined4 writes, same 9 shorts); 11-frame scale from s_shieldPopScale[af][0/1/0] (RDATA 0x800b2d14), af++; frame ≥0xb → SFX 0x58 + INSTANCE_Death(instColor + sh->instHighlight) + thread DEAD. | +| RB_Warpball_ThTick | OVR_231::800aef9c | Match (full asm-scale walk 0x800aef9c-0x800afb70) — juiced-orb main tick. Save last pos + anim cycle; MASK_GRABBED&flag4 / (flags&0x204)==0 → GetDriverTarget+SetTargetDriver re-lock; clear 0x200. Target: straight-homing (**`*(uint*)(pVel+2)&0xc0000` = flags&0xc**; rotSpeed 0x100/0x400 table + `0x400−(distXZ>>9)` clamp≥0x100, frameCount>0→0x40; dir.y via InterpolateValue; vel=Sin/Cos·7>>8; vertical homing ±(elapsed<<2)>>5 clamped to distY & ±0x60) or path-follow (SquareRoot0 segment walk, `(progress<<12)/segLen` lerp, ratan2 heading). SFX 0x4e; ±0x80 BSP ray (searchFlags 0x41/0x43, quadFlagsWanted 0x1040; `field6_0x16` = decompiler artifact); wall→TurnAround, hitbox CollLevInst(modelID 0x36)→TurnAround, quadblock snap+depthBias (if/else inverted but equivalent, re-ray `goto` = `touched==0` guard); particle trail (height 0xff); CollideWithDrivers r=0x9000 → HurtDriver + driversHit rank-fill + target hand-off (GetDriverTarget/SetTargetDriver/SeekDriver/node reseat) / leader→Death; else CollideWithBucket MINE r=0x2400 → funcThCollide; frameCount−−. | +| RB_ShieldDark_ThTick_Grow | OVR_231::800b0454 | Match (full walk) — active shield bubble. Highlight sweep: rot.y+=0x100, unhide; `rotY%0x1000==0x400` via signed round-toward-zero (iVar8=rotY<0?rotY+0xfff:rotY; the `(s16)` cast is no-op, rotY∈[0xc00,0x1400]) → timer=0x1e, rot.y=0xc00, hide; else timer−−/hide/(==0 unhide). 3-instance pushbuffer bind (offset 0x74 stride 0x88). LHMatrix_Parent+identity(inst,color), ConvertRotToMatrix(highlight). Scale: 8-frame grow table (0x800b2cf4) then `timer%6` shimmer (0x800b2d40, unsigned). Alpha fade (!flags&4): duration−=0x20, `((60−(dur>>5))·3072)/60+0x400` if dur<0x780. Expire (flags&1/8, RACE_FINISHED, MASK_GRABBED)→animFrame=0, instBubbleHold=0, ThTick_SetAndExec Pop (+flags&8 fade 0x1fff→0x1000 step−0x88). Fire (flags&2)→BirthWithThread(0x5e/0x56, RB_MovingExplosive_ThTick) shield-bomb: matrix copy, tw init vel=m[0][2]/m[2][2]·3>>7, rotY=angle, frameCount=10; GAMEPAD_Shock + Voiceline(13). LAB_0d6c: SFX 0x58 + INSTANCE_Death(color,highlight) + DEAD. (native name arg 0 vs s_shieldbomb; MEDIUM=0x200; matrix pad byte 0x42 skip — all benign.) | +| RB_Player_ToggleInvisible | OVR_231::800b0dbc | Match — per player thread (threadBuckets[PLAYER] sibling chain) w/ invisibleTimer(@0x28)!=0: clear visible bit 0x40 in idpp[i].instFlags (@instSelf+0xb8+i·0x88) on screens i!=driverID (stays visible on own screen). (Ghidra sdata_gGT reassignments = reg-reuse artifacts.) | +| RB_Player_ToggleFlicker | OVR_231::800b0e68 | Match — per player thread w/ invincibleTimer(@0x24)>0x2a0 && (timer&1): clear visible bit 0x40 in idpp[i].instFlags on ALL screens (no driverID skip → blink). Distinct field from ToggleInvisible (invincible@0x24 vs invisible@0x28). | +| RB_RainCloud_FadeAway | OVR_231::800b0f1c | Match — **asm 0x800b0f64 `addiu 0x80` confirms t[1]+=0x80 (project correct); Ghidra `+4` is a cameraDC-union artifact.** matrix.t[i]=(inst.t[i]+parentInst.t[i](+0x80 on y))>>1 (arithmetic sra); scale x/y/z−=0x100; rainLocal->frameCount−=2; scale.x<0 (signed) → JitPool_Remove(gGT.JitPools.rain@0x19e8, rainLocal)+thread DEAD. | +| RB_RainCloud_ThTick | OVR_231::800b1000 | Match — anim cycle; scale.v[i]=(inst.scale[i]+dInst.scale[i])>>1 (s16 intermediate vs Ghidra s32 — overflow unreachable, scales ~0x1000); t[1]+=scale.y·5>>7; t[i]=(inst.t[i]+dInst.t[i])>>1. !ACTION_MASK_WEAPON(0x800000): timeMS!=0 → −=elapsed clamp 0, if effect==ITEM_ROLL(1) && heldItemID!=0xf(NONE) && !noItemTimer → heldItemID=0x10/itemRollTimer=5/numHeldItems=0; timeMS==0 → if ITEM_ROLL&&heldItemID!=NONE: itemRollTimer=0+VehPhysGeneral_SetHeldItem. timeMS=0/thCloud=NULL (project hoisted out of branches, equiv), ThTick_SetAndExec FadeAway. | +| RB_RainCloud_Init | OVR_231::800b1220 | Match — thCloud==0 → BirthWithThread(0x42 "cloud1", SMALL=0x300, OTHER, RB_RainCloud_ThTick, sizeof(RainCloud)=8, driver thread) + identity matrix + pos=driver(+0x80 y) + alphaScale 0x800 + depthBias; JitPool_Add rain (frameCount 0x1e, vel 0/−0x28/0, pos=driver+0x80y, cloudInst); rcloud timeMS=0x1e00/rainLocal/effect=ITEM_ROLL(0 if heldItemID==0xf\|\|noItemTimer); d->thCloud=thread. Else (refresh): timeMS=0x1e00, effect=(MixRNG%400)/100. (rain pos[1] (short)-cast order nuance — unreachable |worldY|>32K.) | +| RB_Explosion_InitPotion | OVR_231::800b1458 | Match — beaker shatter FX. Birth shockwave (green STATIC_SHOCKWAVE_GREEN=0x45 / red 0x44 if BEAKER_RED, SMALL=0x300, OTHER=0xd, RB_Explosion_ThTick, size 0, no parent); flags\|=0xa00 (PIXEL_LOD 0x200\|CUSTOM_MATRIX 0x800); funcThDestroy=PROC_DestroyInstance; copy beaker matrix 3x3+t; scale=0x800; 5× Particle_Init(iconGroup[1], emitter@0x800b2d58) → axis[0..2]+=t·0x100, modelID=color (Ghidra `driverInst` mislabel), color axes {1,0xc800}/{0xc800,1} by green/red, axis[9]=1, funcPtr=PotionShatter; RB_Potion_OnShatter_TeethSearch. | +| RB_Explosion_InitGeneric | OVR_231::800b1630 | Match — Nitro/TNT explosion sprite. Birth 0x26 (SMALL, OTHER, RB_Explosion_ThTick); copy weapon matrix 3x3+t; colorRGBA=TNT(0x27)?0xad10000:0x1eac000; alphaScale=0x1000; funcThDestroy=PROC_DestroyInstance. (No flags/scale writes, unlike InitPotion.) | +| RB_Blowup_ProcessBucket | OVR_231::800b1714 | Match — **syms name RB_Blowup_ProcessBucket authoritative; Ghidra DB "RB_MirrorDrawStateToChild" is a conservative guess (project follows syms).** Walk bucket sibling chain; per player i mirror master blowup[0](shockwave)→child blowup[1](explosion): idpp[i].instFlags &= src\|~DRAW_SUCCESSFUL(0x40), otRangeNormal(@0xe4), depthOffset[0/1](@0xdc/0xde). GetIDPP=inst+0x74+i·0x88. | +| RB_Blowup_ThTick | OVR_231::800b17f0 | Match — advance both blowup slots' animFrame (INSTANCE_Death+null@last), both null → thread DEAD; ThTick_FastRET. (Ghidra `do{}while(true)` = decompiler mis-model of ThTick_FastRET non-return; project single-pass+FastRET faithful.) | +| RB_Blowup_Init | OVR_231::800b18f8 | Match — big explosion + area blast. Birth explosion 0x26 (SMALL, BLOWUP=8, RB_Blowup_ThTick, obj 0xc); flags\|=0x2040000 (VISIBLE_DURING_GAMEPLAY\|DRAW_BILLBOARD); blowup[1]=explosion, copy weapon matrix; colorRGBA TNT?red:green, alphaScale 0x1000; INSTANCE_Birth3D shockwave (modelPtr[0x44/0x45], parent explosionTh), blowup[0]=shockwave, flags\|=PIXEL_LOD, identity matrix + weapon pos, headers[0/1].flags\|=2 (face-cam). Blast: scratchpad pos, boss(gameMode1<0)?0x100/0x10000:0x140/0x19000, thread=weapon, cb=RB_Burst_CollThBucket, PROC_StartSearch_Self + CollideHitboxWithBucket ROBOT(1)/MINE(4)/PLAYER(0) (Nitro non-TNT → radius 0x80/0x4000 before PLAYER), cb=RB_Burst_CollLevInst. | +| RB_Burst_ProcessBucket | OVR_231::800b1bd8 | Match — **syms name authoritative; Ghidra DB "RB_MirrorDrawStateToChildren" is a conservative guess (project follows syms).** 3-instance version: master=burst[1] (non-null gate), mirror draw state (instFlags&=src\|~0x40, otRangeNormal, depthOffset[0/1]) → children [0](shockwave) & [2](warpedBurst). GetIDPP=inst+0x74+i·0x88. | +| RB_Burst_ThTick | OVR_231::800b1d2c | Match — advance slots [1]/[2]/[0] anim (INSTANCE_Death+null@last), [1]&[2] null → thread DEAD. (No ThTick_FastRET — unlike Blowup_ThTick; project matches.) | +| RB_Burst_CollThBucket | OVR_231::800b1e90 | Match (asm-verified, Ghidra hid HurtDriver/funcThCollide args) — victim=t->object, model=t->modelIndex(@0x44). PLAYER(0x18)/ROBOT_CAR(0x3f): weapon model(@0x44); **mine hazard (6/0x46/0x47/0x27) → attacker=`weaponObj->[0x4]`(MineWeapon.instParent)->thread->object, HurtDriver(victim,2,attacker,2); else bomb → attacker=`weaponObj->[0x8]`(TrackerWeapon.instParent)->thread->object, reason=missile(0x29)?3:1, HurtDriver(victim,2,attacker,reason), longestShot(@0x552)=max(,timeAlive@0x48).** offsets 0x4/0x8 confirmed vs ovr_231.h. not-BOT(0x100000) → pushBuffer[driverID] fade 0x1fff/0x1000/−0x88; damageColorTimer=0x1e. Final: model∈{0x29,6,0x27,0x46-0x47} && funcThCollide → call funcThCollide(t, weaponTh, funcPtr, 3). | +| RB_Burst_CollLevInst | OVR_231::800b20a4 | Match — instDef(@0x1c)+ptrInstance(@0x2c) guards, modelID(@0x3c)LInC(inst, weaponThread, sps); 0x70(TEETH) → RB_Teeth_OpenDoor. | +| RB_Burst_Init | OVR_231::800b2154 | Match — spawn 3 FX instances + AoE blast. Birth explosion1 (0x2b, SMALL, BURST=7, RB_Burst_ThTick, obj 0xc, identity rot); Birth3D warpedburst (modelPtr[0x2b], flags\|=0x2000000, 90° rot m[0][1]=0xf000/m[1][0]=0x1000); Birth3D shockwave (modelPtr[0x44], flags\|=0x2040000). Each: depthBiasNormal−=2, pos=weapon(y−0x30), numPlyr>2 scale>>=1, headers[0/1].flags\|=2. (Project refactors per-instance matrix setup into a shared loop — burst[2] breaks before m[0]/m[1] then re-applies 90°; final matrices identical.) Blast: pos=weapon, rocket→0x80/0x4000 / non-juiced(tw.flags&1==0)→0x140/0x19000 / juiced→0x200/0x40000, cb=RB_Burst_CollThBucket, CollideHitboxWithBucket PLAYER(0)/ROBOT(1) w/ driverParent->instSelf->thread, MINE(4)/TRACKING(6) NULL, cb=RB_Burst_CollLevInst + StartSearch. | +| RB_Burst_DrawAll | OVR_231::800b25b8 | **Fix 122** (gte_rt vs gte_mvmva) — else Match. Pass 1 per screen: SetRot/SetTransMatrix(pushBuffer[i].matrix_ViewProj), for each BURST tracker GTE-transform burst[1] world pos → |x|,|y|,|z|; if |x|<0x100 && |y|<0x100 && |z|driverTarget(@0)==d; min XZ-dist² (d->instSelf.t[0/2] − currThread->inst.t[0/2]), init 0x3fffffff; return closest thread or NULL. (Ghidra cDC->driverToFollow/pCamVel[0] = union aliases of inst->matrix.t[0]/t[2].) | +| RB_Baron_ThTick | OVR_231::800b3120 | Match (HurtDriver args asm-verified) — Sewer barrel path hazard. Anim cycle; if numSpawnType2_PosRot: pointIndex=(pointIndex+1)%numCoords; DRUM(0x55) → SFX 0xc @idx0x10, idx<0x11 OtherFX mute / else PlaySound3D_Flags(0x74), SetPathFrame(idx, +0x111/−0x110, no rotX flip); else non-drum → SetPathFrame(idx, no offset, rotX flipped). otherInst(drumbuddy) → path idx+0x78, +0x21f/−0x21f, rotX flipped. DRUM collision: CollideWithDrivers(inst,0,0x19000,0) → **HurtDriver(driver,3,0,0) (asm 0x800b37b0, decompiler-hid args)**. posCoords stride 6 shorts; break 0x1c00/0x1800 = MIPS %-div guards. | +| RB_Baron_LInB | OVR_231::800b37d4 | Match — birth guard (inst->thread==0), PROC_BirthWithObject(SIZE_RELATIVE_POOL_BUCKET(sizeof Baron, NONE, SMALL, STATIC)=0x300303, RB_Baron_ThTick, "baron"); name ends '0' → pointIndex=ptrSpawnType2->numCoords/2 else 1; scale 0x1000; Baron init (unk1a=4, unk22=0x18, unk06=0, otherInst=0, soundID_flags=0); DYNAMIC_VONLABASS(0x4f) → HIDE_MODEL. | +| RB_Blade_ThTick | OVR_231::800b38e4 | Match — Hot Air Skyway blimp blade spin. rot.x=instDef->rot.x, rot.y=instDef->rot.y+0x400, rot.z=angle; angle+=0x100; ConvertRotToMatrix; scale 0x1000; ThTick_FastRET. (Ghidra do{}while(true) = FastRET non-return artifact.) | +| RB_Blade_LInB | OVR_231::800b3978 | **Fix 123** (missing inst->thread==0 guard) — else Match. PROC_BirthWithObject(SIZE_RELATIVE_POOL_BUCKET(sizeof Blade=4, NONE, SMALL, STATIC)=0x40303, RB_Blade_ThTick, "blade"); inst->thread=t, t->inst=inst, angle=0. | +| RB_Bubbles_RoosTubes | OVR_231::800b39dc | Match — Roo's Tubes underwater bubble emitter. Guards numPlyr<2/levelID==ROO_TUBES/numSpawnType2>1; walk ptrSpawnType2[1].posCoords from numCoords−1 down (spawnX@[3]/Y@[4]/Z@[5]); bail free.count<0x14; emit when (timer+idx)&7==0; speed gate \|((posCurr−posPrev>>4)+(posCurr>>8))−spawn\| for X+Z ≤0x1680; Particle_Init(iconGroup[7], emitter) → unk1A=0x7fff, otIndexOffset=8, axis[0..2]+=spawn·0x100. (project single cursor == Ghidra twin cursors.) | +| RB_CrateAny_ThTick_Explode | OVR_231::800b3d04 | Match — box-explosion anim advance, INSTANCE_Death + thread DEAD at last frame. | +| RB_CrateAny_ThTick_Grow | OVR_231::800b3d7c | Match — TIME_CRATE(0x5c/0x64/0x65) → thread=0/DEAD; cooldown!=0 → dec if !boolPauseCooldown; cooldown==0 → grow scale +0x100 to 0x1000 then thread=0/animFrame++/DEAD. | +| RB_CrateWeapon_ThCollide | OVR_231::800b3e7c | **Fix 124** (mid-grow gate) — else Match. syms name (Ghidra DB "RB_CrateWeapon_OnCollide" is a rename). cooldown==0 && scale∈{0,0x1000}: cooldown=0x1e, scale==0x1000 → ExplodeInit(0xfafafa0, randomRot) + GetDriver(AI-abort) + roulette weapon (heldItemID=0x10, itemRollTimer=0x5a, ROLLING_ITEM+SFX 0x5d, juicedUp if 10 wumpa, GTE-project HUD start); else blockage tail (clear sps modelID 0x8000, boolPauseCooldown=1 if nitro/TNT/beaker). HurtDriver/GetDriver/screen-proj asm-verified. | +| RB_CrateWeapon_LInC | OVR_231::800b4278 | Match — thread==0 → RB_CrateAny_LInC_Birth(0x80303=sizeof Crate=8, RB_CrateAny_ThTick_Grow, "crate", funcThCollide=RB_CrateWeapon_ThCollide, cooldown=0, boolPause=0); dispatch funcThCollide(thread, collidingTh, funcPtr, sps). | +| RB_CrateFruit_ThCollide | OVR_231::800b432c | **Fix 124 + Fix 125** (mid-grow gate + AI-check) — else Match. ExplodeInit(0xf2953a0, NO randomRot); **GetDriver WITHOUT AI-abort (asm 0x800b4584 — awards AI weapon owners, unlike Weapon/Time)**; newWumpa=(MixRNG%4)+5; PickupWumpaHUD.cooldown=5/numCollected/startX/startY(−0x14). | +| RB_CrateFruit_LInC | OVR_231::800b471c | Match — RB_CrateAny_LInC_Birth(funcThCollide=RB_CrateFruit_ThCollide, "fruit_crate") + dispatch. | +| RB_CrateTime_ThCollide | OVR_231::800b47d0 | **Fix 124** (mid-grow gate) — else Match. ExplodeInit(0x80ff000, randomRot); GetDriver(AI-abort for weapons); numTimeCrates++; frozenTimeRemaining += 0x3c0/0x780/0xb40 by crate type (01/02/03) + timeCrateTypeSmashed 1/2/3 + Voiceline(0x13) for type3; PickupTimeboxHUD; boolPauseCooldown=1. **NOTE: project has an extra body-level `if(ACTION_BOT) return 1` retail lacks — unreachable (time crates are time-trial, no AI); documented, not fixed.** | +| RB_CrateTime_LInC | OVR_231::800b4ba8 | Match — RB_CrateAny_LInC_Birth(funcThCollide=RB_CrateTime_ThCollide, "fruit_crate" [dbg-name copy-paste, benign]) + dispatch. | +| RB_Crystal_ThCollide | OVR_231::800b4c5c | Match — Crystal Challenge pickup. syms name (Ghidra DB "RB_Crystal_OnCollide" is a rename). player(0x18)/robotcar(0x3f) only (no AI in Crystal Challenge) else 0; player → wumpa HUD (RB_Fruit_GetScreenCoords GTE-project, startX/Y(−0x14), cooldown=5, numCollected++); both despawn (scale=0, thread=0, SFX 0x43, thread DEAD), return 1. | +| RB_Crystal_ThTick | OVR_231::800b4dd8 | Match — spin+bounce+shimmer. rot.y+=0x40 + ConvertRotToMatrix ×2 (binary repeats — project RB_Crystal_RotateStep ×2); matrix.t[1]=instDef->pos.y + ((MATH_Sin(rot.y)<<4)>>0xc) + 0x30; Vector_SpecLightSpin3D(inst, rot, crystalLightDir={0x94f,0x94f,0x94f}). | +| RB_Crystal_LInC | OVR_231::800b4e7c | Match — thread==0 → birth(0x80303=sizeof Crystal=8, RB_Crystal_ThTick, "crystal") + inst + funcThCollide=RB_Crystal_ThCollide; dispatch if thread && funcThCollide && scale.x!=0 (not collected) → funcThCollide(thread, driverTh, funcPtr, sps). **Birth guard present.** | +| RB_Crystal_LInB | OVR_231::800b4f48 | Match — thread==0 → birth + inst + funcThCollide + rot=0 + colorRGBA=0xd22fff0 + flags\|=USE_SPECULAR_LIGHT(0x20000); then RB_Default_LInB. **Birth guard present** (contrast Blade_LInB Fix 123). | +| RB_Default_LInB | OVR_231::800b4fe4 | Match — reflective-hitbox helper (called by Crystal/CtrLetter LInB etc.). probeTop=(t[0], t[1]−0x180, t[2]), probeBottom=(t[0], t[1]+0x80, t[2]); searchFlags=HIGH_LOD(0x2), quadFlagsWanted=GROUND\|COLLISION_SURFACE(0x3000), quadFlagsIgnored=0, ptr_mesh_info; COLL_SearchBSP_CallbackQUADBLK + RB_MakeInstanceReflective. (Ghidra bbox.min.y/z/max = the qbColl-view searchFlags/quadFlagsWanted/quadFlagsIgnored; values decode exactly.) No thread birth. | +| RB_CtrLetter_ThCollide | OVR_231::800b5090 | Match — player(0x18)-only C/T/R letter pickup (syms name; Ghidra DB rename). PickupLetterHUD(@0x4c4): startX/Y(−0x14), cooldown=10, numCollected++, modelID=letter model; despawn (scale=0, thread=0, HIDE_MODEL); OtherFX_Play(100,1); thread DEAD; return 1. | +| RB_CtrLetter_ThTick | OVR_231::800b52dc | Match — single rot.y+=0x40 + ConvertRotToMatrix (NOT doubled like Crystal), Vector_SpecLightSpin3D(inst, rot, letterLightDir={0x94f,0x94f,**−0x94f**}). No hover-bounce. | +| RB_CtrLetter_LInC | OVR_231::800b5210 | Match — thread==0 → birth(**0x40303=sizeof 4** literal, RB_CtrLetter_ThTick, "ctr") + inst + funcThCollide=RB_CtrLetter_ThCollide; dispatch if thread && funcThCollide && scale.x!=0. **Birth guard present. Project correctly reproduces the retail size quirk: LInC=4 vs LInB=sizeof(CtrLetter)=8 (dead — LInB births first).** | +| RB_CtrLetter_LInB | OVR_231::800b5334 | Match — thread==0 → birth(**0x80303=sizeof CtrLetter=8**) + funcThCollide + inst + rot=0 + scale=0x1800 + colorRGBA=0xffc8000 (gold) + flags\|=USE_SPECULAR_LIGHT\|DRAW_TRANSPARENT; then RB_Default_LInB. Birth guard present. | +| RB_Banner_Animate_Init | OVR_231::800b53e0 | Match (decomp-gap, Ghidra DB "RB_Mesh_DecodeVertexCache" behavioral name; syms authoritative) — start-banner vertex-cache decode. header count<0x40→0; walk command list (ptrCommandList+4): skip (&0xffff0000==0), single (>=0) / triangle(<0, ×3): not-cached(&0x4000000==0)→SaveVertex(scratchpad[idx*8]=packed xyz), xQuarter=vert[0]>>2, vert+=3, count++; cached→LoadSavedX(reload scratchpad[(cmd>>13)&0x7f8], xQuarter=(x<<16)>>18); rewrite cmd (mask 0xffff01ff/0xf7ff01ff \| xQuarter<<9). 4P→per-vert alpha byte=0x80. (Ghidra's not-cached temp write is benign redundancy.) | +| RB_Banner_Animate_Play | OVR_231::800b56c4 | Match (Ghidra DB "RB_Mesh_AnimateVertexAlpha") — per-frame vertex-alpha scroll. Rotate ptrColors[0x40] wave buffer left 1 (buf[0x3f]=old buf[0]); numVertices≤0→ret; per vert: color=waveBuf[((x>>2)+10)&0x3f]; wave=color−0x80; edge ease x<0x40→·(x<<2)>>8 / x>0xc0→·((0x100−x)<<2)>>8; vert[1]=wave+0x80 (non-edge = color). Signed arithmetic verified. | +| RB_Banner_ThTick | OVR_231::800b57b4 | Match (Ghidra DB "RB_Mesh_AnimateObjectAlpha") — numVertices(@object+2)!=0 → RB_Banner_Animate_Play(inst->model->headers, numVertices). | +| RB_Banner_LInB | OVR_231::800b57f8 | Match (Ghidra DB "RB_StartBanner_LInB") — thread==0 guard → birth(0x40303=sizeof StartBanner=4, RB_Banner_ThTick, "startbanner"); 4P→funcThTick=NULL; unused=0/numVertices=0; model=modelPtr[STATIC_STARTBANNERWAVE=0xa6]; if model: inst->model=model, numVertices=RB_Banner_Animate_Init(headers); if !=0: seed ptrColors[0x40] with sine ripple (MATH_Sin(i<<7)>>6 ±0x80 — **−0x80 vs +0x80 equivalent mod 256**; 4P flat 0x80), 3 color bytes/entry. **Birth guard present.** | +| RB_Armadillo_ThCollide | OVR_231::800b5dbc | Match — leaf predicate `sps->modelID == DYNAMIC_PLAYER` (Ghidra DB "RB_Coll_BoolPlayerHit" conservative name; syms authoritative). | +| RB_Armadillo_ThTick_TurnAround | OVR_231::800b5984 | Match — turn state. rotCurr.y==rotDesired.y: anim not over→animFrame++, else velX/velZ negate + numFramesSpinning=0 + direction toggle + SFX 0x70 + animIndex=1/animFrame=0 + ThTick_SetAndExec Rolling; else rotCurr.y=InterpolateValue(rotCurr.y, rotDesired.y, 0x100) + ConvertRotToMatrix + animFrame++. Seal_CheckColl(inst, t, 1, 0x2400, 0x71) = inlined PLAYER(0)/ROBOT(1)/MINE(4) HurtDriver + Echo (verify in Seal cluster). | +| RB_Armadillo_ThTick_Rolling | OVR_231::800b5b74 | Match — roll state. timeAtEdge!=0→−− & return; timeRolling<0x500 → +=0x20, distFromSpawn ±1 by direction, matrix.t[0/2]+=velX/velZ, anim(idx1) cycle, Seal_CheckColl; else CTR_MatrixToRot(0x11)→rotCurr (vy/vx/vz swap), timeRolling=0, animIndex=0/animFrame=0, rotDesired.y=(rotCurr.y+0x800)&0xfff, ThTick_SetAndExec TurnAround. | +| RB_Armadillo_LInB | OVR_231::800b5dd0 | Match — thread==0 guard → birth(0x200303=sizeof Armadillo=0x20, RB_Armadillo_ThTick_Rolling, "armadillo"); funcThCollide=RB_Armadillo_ThCollide; animIndex=1; timeRolling/numFramesSpinning=0; CTR_MatrixToRot(0x11)→rotCurr; rotDesired.y=(rotCurr.y+0x800)&0xfff; spawnPosX/Z, direction=0, distFromSpawn=0, velX/velZ=m[0][2]/m[2][2]>>7; count>0 → timeAtEdge=ST1metaArray[name digit]. **Notes: `&0xfff` == retail signed-modulo (CTR_MatrixToRot keeps rotCurr.y+0x800≥0 + InterpolateValue masks &0xfff); project adds `timeAtEdge=0` retail sets only if count>0 (relies on zeroed pool) — benign.** Birth guard present. | +| Seal_CheckColl (helper) | inlined | Match (asm-verified via Fireball 0x800b60ec-0x800b6188 + Seal source) — shared hazard collision (Armadillo/Fireball/Seal). LinkedCollide_Radius(=FindObjectInRangeList) over PLAYER(0)→HurtDriver(driver, damage, 0, 0) + echo if boolHurt && kartStatePrev!=KS_SPINNING(3) && sound!=0 (OtherFX_Play_Echo(sound, 1, actionsFlagSet&ACTION_ENGINE_ECHO) — **boolean-equiv to retail's `(u16@+2)&1`; OtherFX_Play_Echo uses `if(echoFlag!=0)`**); ROBOT(1)→HurtDriver; MINE(4)→funcThCollide. damage: Armadillo/Seal=1, Fireball=4. | +| RB_Fireball_ThCollide | OVR_231::800b625c | Match — `sps->modelID==DYNAMIC_PLAYER` (Ghidra DB "RB_Coll_BoolPlayerHit_2" = compiler dup; syms authoritative). | +| RB_Fireball_ThTick | OVR_231::800b5f50 | Match (full asm walk) — lava fireball. cooldown!=0 → −=elapsed clamp0 & return; else HIDE_MODEL; if t[1]>=instDef->pos.y−0x440: velY arc (t[1]+=velY·elapsed>>5, velY−=elapsed·10>>5 clamp −200), Particle_Init(iconGroup[0xA], emSet_Fireball) pos+t·0x100/nMaxRenderDist 0x1e00/axis[1].vel=clamp(velY·−0x180), Seal_CheckColl(inst,t,4,0x10000,0); cycleTimer−=elapsed; anim cycle; oldVelY>=0 && velY<0 → direction=1; cycleTimer<1 → recycle (cycleTimer=0xb40, velY=200, direction=0, t[1]=resetPosY, animFrame=0, SFX 0x81). | +| RB_Fireball_LInB | OVR_231::800b6270 | Match — thread==0 guard → birth(0x100303=sizeof Fireball=0x10, RB_Fireball_ThTick, "fireball"); funcThCollide=RB_Fireball_ThCollide; scale=0x4000; animIndex=0 (proj adds benign animFrame=0); fireballID=name digit; unused=0/velY=0x60/direction=0/cycleTimer=0; cooldown=fireballID&1?0x5a0(1440):0 (odd-ID stagger). Birth guard present. | +| RB_FlameJet_Particles | OVR_231::800b64c0 | Match — fire + heat-haze particle spawner. Fire: Particle_Init(iconGroup[0xA], emSet_fjFire), pos+t(y+0x32)·0x100, axis[0/2].vel=dirX/dirZ, axis[1].accel=MATH_Sin(timer·0x100 + (RngDeadCoed>>0x18) & 0xfff)>>4, nMaxRenderDist 0x1e00, cOtDepth−1, timer&1→mirror axis[4]. Heat (1P only): Particle_Init(ptrSparkle, emSet_fjHeat), pos from fire(+0x1000 y), axis[4/5].pos=axis[3].pos−0x400/−0x600, axis[3/4/5].vel=(0x4a00/0x4600/0x4400−pos)/7. **CTR_NATIVE null-guard on fire particle (retail null-space read).** | +| RB_FlameJet_ThTick | OVR_231::800b6728 | Match (full asm walk, HurtDriver damage=4 confirmed 0x800b6840) — cooldown!=0 → −− & FastRET; cycleTimer<0x2d → PlaySound3D_Flags(0x68) + unk+=0x100 + RB_FlameJet_Particles + LinkedCollide_Hitbox_Desc over PLAYER(0)/ROBOT(1)→HurtDriver(driver,4,0,0) / MINE(4)→RB_Hazard_ThCollide_Generic (project inlines the _Alt wrapper = _Generic(threadHit)); ==0x2d → OtherFX mute; >0x69 → cycleTimer=0; cycleTimer++ + Vector_SpecLightNoSpin3D(inst, instDef->rot, fjLightDir). | +| RB_FlameJet_LInB | OVR_231::800b6938 | Match — colorRGBA=0xdca6000 + flags\|=USE_SPECULAR_LIGHT\|DRAW_TRANSPARENT (before guard); thread==0 → birth(0x140303=sizeof FlameJet=0x14, RB_FlameJet_ThTick, "flamejet"); cycleTimer/cooldown/audioPtr=0; dirX/dirZ=m[0][2]/m[2][2]·∓0x4b>>5; runtime bbox init min{−0x40,−0x40,0}/max{0x40,0x80,0x140}; count>0 → cooldown=ST1metaArray[name digit]. Birth guard present. | +| RB_Follower_ProcessBucket | OVR_231::800b6d58 | Match — mine-ghost split-screen visibility. Walk FOLLOWER bucket (skip DEAD); driverID=fObj->driver->driverID; ghost idpp[i].instFlags&=~0x40 for i0 && kartState∈{NORMAL(0),DRIFTING(2)} && mineTh->timesDestroyed==backup && speedApprox>=0: scale<<=1 if <0x800, matrix.t[i]=(realPos[i]+(posCurr[i]>>8))>>1 (midpoint); else thread DEAD. | +| RB_Follower_Init | OVR_231::800b6f00 | Match — spawn ghost when mine sticks. Guards: speedApprox>0x1e00, !ACTION_BOT, !(cameraDC[driverID].flags&0x10000 airborne). Birth(mineTh->modelIndex, "follower", SMALL, FOLLOWER, RB_Follower_ThTick, sizeof Follower=0x18); scale=0x200; copy mine matrix (memcpy = Ghidra element copies); funcThDestroy=PROC_DestroyInstance; frameCount=7, driver, mineTh, backupTimesDestroyed, realPos[i]=mine.t[i]. | +| RB_Fruit_ThTick | OVR_231::800b706c | Match — degenerate self-destruct tick for a wumpa pickup (fruit is static, placed by LInB). inst->thread=0; flags\|=DEAD(0x800); FastRET. (Ghidra do{}while(true) = FastRET non-return artifact; native collapses to single set+flag.) | +| RB_Fruit_ThCollide (OnCollide) | OVR_231::800b70a8 | Match — wumpa pickup collision (syms "RB_Fruit_ThCollide"; near-identical to Crystal). player(0x18)/robotcar(0x3f) only else 0; player → GTE-project fruit pos via pb=&gGT->pushBuffer[driverID] (`RB_Fruit_GetScreenCoords`) → PickupWumpaHUD.startX=rect.x+sx, startY=rect.y+sy−0x14, cooldown=5, numCollected++ (robotcar no HUD); fruitObj->driver=driver; scale=0/thread=0/SFX 0x43/thread DEAD; return 1. **asm-verified base P=*(0x8008d2ac)=&gGT->pushBuffer−0x168 → binary rect@entry+0x184→pb+0x1c, matrix@entry+0x190→pb+0x28 (identical 0x168 delta; sizeof PushBuffer=0x110 stride confirmed); HUD stores cooldown@0x4b8/startX@0x4bc/startY@0x4be/numCollected@0x4c0.** | +| RB_Fruit_LInB | OVR_231::800b722c | Match — RB_Default_LInB(inst) (reflective hitbox); animIndex=0; flags\|=ANIM_LOOP(0x10). | +| RB_Fruit_LInC | OVR_231::800b7260 | Match — thread==0 → birth(0x40303=sizeof Fruit=4, RB_Fruit_ThTick, "fruit") + inst + funcThCollide=RB_Fruit_ThCollide; dispatch if thread && funcThCollide && scale.x!=0 (not collected) → funcThCollide(thread=a0, driverTh=a1, funcPtr=a2, sps=a3) — **asm 0x800b7300 jalr confirms a2=funcThCollide ptr, matching project's quirky 3rd arg.** Birth guard present. | +| RB_Minecart_ThTick | OVR_231::800b7338 | Match (full asm walk) — Dragon Mines patrolling minecart along level1->ptrSpawnType2[0] path. animFrame wrap; numSpawnType2==0→ret; currFramethread->object, model->id==DYNAMIC_SKUNK(0x50)?1:3, 0, 0)** — damage+SKUNK-conditional recovered from asm (decomp hid all 4 args; `bne v0,v1` + delay `_li a1,3` / fall `li a1,1`). | +| RB_Minecart_NewPoint (helper) | inlined | Match — posStart[i]=posCoords[(posIndex−1)·3+i], posEnd[i]=posCoords[posIndex·3+i], matrix.t[i]=start, dir[i]=start−end; rotDesired.x=ratan2(dir.y, SquareRoot0(dir.x²+dir.z²)) [CTR_NATIVE-guarded], rotDesired.y=ratan2(dir.x,dir.z)−0x800. | +| RB_Minecart_LInB | OVR_231::800b7814 | Match — thread==0 guard → birth(0x2c0303=sizeof Minecart=0x2c, RB_Minecart_ThTick, "minecart"); memset 0x2c; posIndex = id0→1 / id1→numCoords/3 / id2→numCoords·2/3 (signed div by 3, 3 carts at 0%/33%/66%); scale=0x1000, numFrames=8, rotSpeed=0x20; model DYNAMIC_SKUNK(0x50)→scale 0x2000 / DYNAMIC_VONLABASS(0x4f)→scale 0x800, both numFrames=4/rotSpeed=0x18; inlined NewPoint. **Divergence (benign): shared NewPoint writes matrix.t=posStart in LInB where retail LInB doesn't — overwritten by first ThTick interp before render; retail's redundant rotDesired.z=0/audioPtr=0/currFrame=0 re-zeroes covered by project memset.** Birth guard present. | +| RB_Orca_ThTick | OVR_231::800b7b8c | Match (Ghidra views inst via CameraDC union alias: driverToFollow/tileView/pCamVel[0]=matrix.t[0..2], pCamVel[2]=animFrame, visInstSrc=flags, visSCVertSrc=matrix). Breaching-whale hazard. cooldown!=0→−− & if (u16)!=0 ret / else clear HIDE_MODEL(0x80) (surface); else path progress pathFrame=min(animIndex, numFrames−0x14), dir==0→min(·,numFrames−0x1a) / dir!=0→−3, clamp≥0; denom=numFrames−0x17; matrix.t[i]=startPos[i]−(pathFrame·midpoint[i])/denom; animFrame+1modelID==DYNAMIC_PLAYER(0x18)` (syms "RB_Orca_ThCollide"; Ghidra DB "RB_Coll_BoolPlayerHit_3" = compiler dup of _1@800b5dbc/_2@800b625c). | +| RB_Orca_LInB | OVR_231::800b7ecc | Match — thread==0 guard → birth(0x300303=sizeof Orca=0x30, RB_Orca_ThTick, "orca"); funcThCollide=RB_Orca_ThCollide; scale=0xc00; flags\|=DRAW_HUGE; animIndex=−10, direction=1, instDefRot=instDef->rot, orcaID=name digit; numSpawnType2!=0 → startPos=posCoords[0..2] & endPos=posCoords[3..5] of **ptrSpawnType2[orcaID+4]** (asm loads posCoords ptr at `0x24(base+nOrcaID·8)`; SpawnType2 is 8B {numCoords@0, posCoords@4} so (nOrcaID+4)·8+4=nOrcaID·8+0x24 — the +4 index folds into the 0x24 displacement, project correct); midpoint=startPos−endPos; numFrames=GetNumAnimFrames; ST1 count>0 → cooldown=ST1_SPAWN meta[orcaID], if!=0 flags\|=HIDE_MODEL. Birth guard present. | +| g_aEmSet_orcaSplash[7] | OVR_231::800b80e4 | **Fix 126** (entry[4] rngSeed.startVal↔baseValue.accel) — else Match (all 7 entries byte-verified vs 0x800b80e4, ParticleAxis={int startVal@0, s16 vel@4, s16 accel@6}). Entries 0(FuncInit colorFlags 0x4a0/life 0xf),1,2,3,5,6 exact. | +| RB_Plant_ThTick_Eat | OVR_231::800b81e8 | Match — man-eating plant eat sequence (StartEat→Chew→Spit). animIndex 5(StartEat): advance, done→animIndex=6 + chew SFX OtherFX_Play(0x6e) if boolEatingPlayer; 6(Chew): advance, frame==0xf replay chew SFX, done→cycleCount++ (==1→reset→animIndex=7); 7(Spit): advance, frame==0x19→spit SFX OtherFX_Play(0x6f) if eating + 4× tire particle (Particle_Init(iconGroup[0], emSet_PlantTires), funcPtr=SpitTire, plantInst, pos=t[i]+m[·][2]·9>>7 (y+0x20), vel±=(MixRNG%10+0x10)·m[·][2]>>0xc, axis0/2 only), done→animIndex=0/boolEatingPlayer=0/ThTick_SetAndExec(Rest). (Ghidra do{}while+FastRET = native single-pass.) | +| RB_Plant_ThTick_Grab | OVR_231::800b84f0 | Match — local HitboxDesc=plantBoxDesc(inst,t). animIndex 3(GrabDriver): advance + LinkedCollide_Hitbox_Desc over MINE(4) → **RB_Hazard_ThCollide_Generic_Alt(&threadHit)** (Alt reads only p[0]=mineThread → Generic(mineThread); project's plantBoxDescLocal.threadHit/funcThCollide stores are faithful-but-unread, matching retail local_20/local_18); done→animIndex=5/SetAndExec(Eat). animIndex 4(GrabMine, unused): advance / done→animIndex=0/SetAndExec(Rest). | +| RB_Plant_ThTick_Transition_HungryToRest | OVR_231::800b8650 | Match — plays rest↔hungry anim BACKWARDS. animFrame−1>0 → animFrame−− / else animFrame=0, animIndex=0, SetAndExec(Rest). (project `>0` ⟺ decomp `animFrame−1<1` done-branch, equivalent partition at animFrame=2.) | +| RB_Plant_ThTick_Hungry | OVR_231::800b86b4 | Match (full asm walk). animIndex 2(Hungry): advance, done→cycleCount++ (==4→animFrame=GetNumAnimFrames(1)/animIndex=1/cycleCount=0/**SetAndExec(Transition)+`return`** — Transition terminal via FastRET, collision NOT reached). PLAYER(0)@0x1b2c collide → **HurtDriver(driver,5,0,0)** (a1=5 asm 0x800b87cc), if ret!=0: OtherFX_Play(0x6d)+boolEatingPlayer=1+plantEatingMe@0x4a8=t+animIndex=3+SetAndExec(Grab). No player + gameMode1≥0 (bltz boss guard, ADVENTURE_BOSS=0x80000000) → ROBOT(1)@0x1b40 collide → HurtDriver(driver,5,0,0) **ret unchecked** + boolEatingPlayer=0 (benign explicit; retail leaves unchanged=already 0) + eat. HurtDriver damage=5 both paths asm-confirmed. | +| RB_Plant_ThTick_Rest | OVR_231::800b88a8 | Match — idle + startup stagger. cooldown@2!=0→−− & return (per-plant desync). animIndex 0(Rest): advance, done→cycleCount++ (==3→animIndex=1/cycleCount=0); animIndex 1(Transition): advance / done→animFrame=0/animIndex=2/SetAndExec(Hungry). | +| RB_Plant_LInB | OVR_231::800b89a4 | Match — thread==0 guard → birth(0x80303=sizeof Plant=8, RB_Plant_ThTick_Rest, "plant"); inst; scale=0x2800; animFrame=0/animIndex=0; cycleCount=0/boolEatingPlayer=0 (proj adds benign cooldown=0, zeroed pool); plantBoxDesc.bbox min{−0x40,−0x40,0}/max{0x40,0x80,0x1e0}; ST1 count>0 → cooldown=ST1_SPAWN meta[plantID·2], LeftOrRight=meta[plantID·2+1]. Birth guard present. | +| emSet_PlantTires[8] | OVR_231::800b8acc | Match — **all 8 entries byte-verified vs 0x800b8acc** (asm `lui 0x800c/addiu −0x7534`). [0]FuncInit colorFlags 0x121/life 0x50; [1]/[2] posX/Z startVal 1/vel −0x320/rngVel 0x640; [3] posY vel −0x640/accel −0x320/rngVel 0x320; [4] scale startVal 0x1000; [5] rotX vel 0xc0/rngStart 0x400/rngVel 0x40; [6] vel 0x100/rngStart 0xe00; [7] null. **No table bug (contrast Orca Fix 126).** | +| RB_Seal_ThTick_TurnAround | OVR_231::800b8c00 | **Fix 127a** (rot-complete `return` skipped collision tail) — else Match. Seal patrol turn state. animFrame+=2 (else 0 + PlaySound3D(0x77)); rotCurr.y==rotDesiredAlt.y → numFramesSpinning=0/rotDesired=rotCurr/ConvertRotToMatrix/SetAndExec(Move) **+ fall through to collision** (Move ends jr ra @0x800b90d0); else turn interp (rotCurr.y=Interp(·,rotDesiredAlt.y,0x80), rotCurr.x=Interp(·,−rotDesired.x,0x14), rotCurr.z=Interp(·,−rotDesired.z,0x14), numFramesSpinning++, ConvertRotToMatrix); then Seal_CheckColl(damage=1 @0x800b8d3c, radius 0x4000, sound 0x78). Ends jr ra @0x800b8e14. | +| RB_Seal_ThTick_Move | OVR_231::800b8e1c | **Fix 127b** (endpoint `return` skipped collision tail) — else Match. Straight-line patrol. animFrame+=2 (no sound); matrix.t[i]=spawnPos[i]−(distFromSpawn·vel[i])/0x2d; dir==0 → distFromSpawn>0 dec+CheckColl+ret / ==0 flip dir=1; dir==1 → <0x2d inc+CheckColl+ret / ==0x2d flip dir=0; on flip: rotDesiredAlt.y=(rotCurr.y+0x800)&0xfff (signed-mod, valid range), SetAndExec(TurnAround) **+ fall through to Seal_CheckColl** (TurnAround ends jr ra @0x800b8e14). Ends jr ra @0x800b90d0. | +| RB_Seal_ThCollide | OVR_231::800b90d8 | Match — `sps->modelID==DYNAMIC_PLAYER(0x18)` (syms "RB_Seal_ThCollide"; Ghidra DB "RB_Coll_BoolPlayerHit_4" = 4th compiler dup). | +| RB_Seal_LInB | OVR_231::800b90ec | Match — thread==0 guard → birth(0x300303=sizeof Seal=0x30, RB_Seal_ThTick_Move, "seal"); funcThCollide=RB_Seal_ThCollide; scale=0x2000; distFromSpawn=0, direction=1, sealID=name digit; numSpawnType2!=0 → spawnPos=posCoords[0..2] & endPos=posCoords[3..5] of **ptrSpawnType2[sealID]** (asm loads posCoords ptr at `0x4(base+sealID·8)` — index sealID, NO +4 folding, contrast Orca); vel=spawnPos−endPos; rotCurr=instDef->rot, rotDesired=rotCurr, numFramesSpinning=0, ConvertRotToMatrix. **No RB_Default_LInB (seal never over ice).** Birth guard present. | +| Seal_CheckColl (helper) | inlined | Match (re-verified via TurnAround/Move asm 0x800b8d20-8e14) — shared hazard collision (Armadillo/Fireball/Minecart/Seal). PLAYER(0)@0x1b2c → HurtDriver(driver,1,0,0)+Echo(0x78,1,actionsFlagSet&ACTION_ENGINE_ECHO=driver@0x2ca&1) if boolHurt && kartState@0x376!=KS_SPINNING(3) && sound!=0; ROBOT(1)@0x1b40 → HurtDriver; MINE(4) → funcThCollide dispatch; radius 0x4000. Seal damage=1 asm-confirmed. | +| RB_Snowball_ThTick | OVR_231::800b92ac | Match — rolling snowball(0x20)/Sewer barrel(0x4e) patrolling a SpawnType2_PosRot path (ping-pong). numSpawnType2_PosRot!=0 → PlaySound3D_Flags(&audioPtr, snowball 0x73 / barrel 0x74); ping-pong idx `p=pointIndex; if p>numPoints: p=numPoints·2−p`; posCoords stride 6 (pos XYZ + rot XYZ) at ptrSpawnType2_PosRot[snowID]@level1+0x144; ConvertRotToMatrix(&posCoords[p·6+3]); matrix.t=posCoords[p·6+0..2]; **RB_Minecart_CheckColl reuse verified faithful — PLAYER(0)/ROBOT(1) radius 0x10000, HurtDriver(driver,3,0,0) (damage=3 unconditional asm 0x800b9484/94a4; snowball/barrel≠SKUNK so Minecart helper's `SKUNK?1:3`→3)**. pointIndex=(pointIndex+1)%(numPoints·2) unconditional. (Ghidra do{}while+FastRET = native single-pass.) | +| RB_Snowball_LInB | OVR_231::800b950c | Match — thread==0 guard → birth(0x100303=sizeof Snowball=0x10, RB_Snowball_ThTick, "snowball"); inst; rot_unused[0]=0; snowID=name digit (**926 uses last-char unconditionally, no `name[0]=='s'` special-case**); numPoints=ptrSpawnType2_PosRot[snowID].numCoords−1; scale=0x1000; rot_unused[1]=m[0][2]>>2, rot_unused[2]=m[2][2]>>2 (vestigial, never read); audioPtr=0. Birth guard present. | +| RB_Spider_DrawWebs | OVR_231::800b95fc | Match (structural, GTE line render) — silk-web line per spider per player-view. t==0 ret; walk spider bucket writing 2 endpoints/spider to PSX scratchpad 0x1f800000 (WebLine 0xc: topXY=pos.x\|(pos.y+0x540)<<16, bottomXY=pos.x\|(matrix.t[1]+0x60)<<16, z=pos.z), count numSpiders; primMem budget (bail if cursor+numSpiders·numPlyr·0x18 ≥ guardEnd); per player: SetRot/SetTransMatrix(pb+0x28), per spider GTE rtpt (VXY0=top,VZ0=z,VXY1=bottom,VZ1=z), depth=SZ1; if depth∈[1,0x1200): LINE_F2 (tpage 0xe1000a20, code 0x42, grey fade past 0xa00 via ((0x1200−depth)·0x3f)>>0xb), link pb->ptrOT[depth>>6 clamp 0x3ff], prim len 0x5. pb stride 0x110. | +| RB_Spider_ThTick | OVR_231::800b9848 | Match (full asm walk) — climbing spider. delay@4!=0 → −− & ret (stagger); unused@8++; state by boolNearRoof@6 + animLoopCount@0 (sit 5 loops then climb up/drop down; cross-block gotos setWiggle/updatePosScale reproduced); matrix.t[1]=instDef->pos.y+**spiderArr[animFrame]** (=g_aSpiderYOffset@0x800b9da4, 26×s16 byte-verified: 0x4c0,0x439,0x3a6,0x306,0x266,0x1c8,0x139,0xb9,0x59,0x17,0…); shadow.scale.x/z=(animFrame<<0xc)/10+0x1800 if frame<0xb; SFX 0x79(up)/0x7a(down). Collision r=0x9000: PLAYER(0)@0x1b2c/ROBOT(1)@0x1b40 → **HurtDriver(driver,1,0,0)** (damage=1 both asm 0x800b9ac4/9b4c), player+kartState@0x376!=SPINNING(3) → OtherFX_Play(0x7b,1)+Voiceline_RequestPlay(VOICE_HURT=1, g_awCharacterIDs[driverID@0x4a], 0x10); MINE(4)@0x1b7c → funcThCollide. Ends jr ra. | +| RB_Spider_ThCollide | OVR_231::800b9bc0 | Match — `sps->modelID==DYNAMIC_PLAYER(0x18)` (syms "RB_Spider_ThCollide"; Ghidra DB "RB_Coll_BoolPlayerHit_5" = 5th compiler dup). | +| RB_Spider_LInB | OVR_231::800b9bd4 | Match — thread==0 guard → birth(**0x10030a**=sizeof Spider=0x10 / **SPIDER bucket=0xa**, RB_Spider_ThTick, "spider"); funcThCollide=RB_Spider_ThCollide; scale=0x1c00; animIndex=1; spiderID=name digit; boolNearRoof=1; animLoopCount=0; delay=0x5b(id3)/0x45(id2)/0(else); shadow=INSTANCE_Birth3D(modelPtr[DYNAMIC_SPIDERSHADOW=0x53], **0** [debug name dropped, benign], t); copy spider matrix (9 elems as overlapping ints) + shadow.t={t[0],t[1]−8,t[2]}; shadow scale 0x2000; shadow rot {0,0x200,0}+ConvertRotToMatrix; spider matrix.t[1]+=0x4c0. Birth guard present. | +| RB_StartText_ProcessBucket | OVR_231::800b9dd8 | Match — no-op stub (jr ra; nop, 8 bytes). Ghidra DB "RB_Stub_NoOp_1"; syms authoritative. Project empty `(void)thread;`. | +| RB_StartText_LInB | OVR_231::800b9de0 | Match — no-op stub (8 bytes; Ghidra DB "RB_Stub_NoOp_2"). Project empty `(void)inst;`. | +| RB_Teeth_LInB | OVR_231::800b9df0 | Match — Tiger Temple shootable door. depthBiasNormal@0x50 += 2; if gameMode1 & RELIC_RACE(0x4000000): doorAccessFlags\|=1 (passable) + flags\|=HIDE_MODEL. No thread birth (lazy via LInC/OpenDoor). | +| RB_Teeth_BSP_Callback | OVR_231::800b9e44 | Match (Ghidra DB "RB_Teeth_BSP_Callback") — weapon-opens-door per-quad callback. model=weaponTh->modelIndex; accept DYNAMIC_PLAYER(0x18)/PU_EXPLOSIVE_CRATE(6)/DYNAMIC_POISON(0x1d)/STATIC_CRATE_TNT(0x27) else ret; teethTh=sps->Union.ThBuckColl.thread; if weaponInst&&teethInst: dot=(sps.pos.x−weap.t[0])·teeth.m[0][2]+(sps.pos.z−weap.t[2])·teeth.m[2][2], \|dot\|>>0xc>0x100 → ret (wrong side); else teethObj->direction=1. | +| RB_Teeth_ThTick | OVR_231::800b9f0c | Match (full asm walk incl scratchpad offsets) — door state machine (direction −1/0/+1, timeOpen hold). Intricate goto structure (LAB_9fe8/9ff8/ba084) reproduced exactly; direction!=0 → animFrame+=direction, clamp (past last → animFrame=last/dir=0/timeOpen=0x780; <0 → animFrame=0/dir=0/timeOpen=0/doorAccessFlags&=~1); collision query (scratchpad@+0x108: pos@0/2/4, hitRadius 0x300@6, r²0x90000@8, modelID STATIC_TEETH 0x70@0xc, **thread stash@0x18, funcCallback=RB_Teeth_BSP_Callback@0x28**) over PLAYER(0)/MINE(4); visibility HIDE_MODEL iff timeOpen!=0. | +| RB_Teeth_LInC | OVR_231::800ba0c8 | Match — funcThCollide (1=block/2=pass). RELIC_RACE → ret 2; lazy-birth thread(0x80303=sizeof Teeth=8, RB_Teeth_ThTick, "teeth", dir=0/timeOpen=0); player(0x18) → if actionsFlagSet&ACTION_MASK_WEAPON(0x800000) RB_Teeth_OpenDoor, ret 2; non-player & timeOpen==0 → dot proj, \|dot\|>>0xc<0x81 → ret 1 (block) else 2; else ret 2. | +| RB_Teeth_OpenDoor | OVR_231::800ba220 | Match — force door open. lazy-birth(0x80303, RB_Teeth_ThTick, "teeth", timeOpen=0); PlaySound3D(0x75); direction=1; doorAccessFlags\|=1. (Ghidra decomp's `return 0x80090000` = lui-base misread; void per binary.) | +| RB_Turtle_ThTick | OVR_231::800ba2c0 | Match — Tiger Temple bobbing trampoline. direction==0 (rising): timer<0x3c0 → timer+=elapsedTimeMS clamp 0x5a0 + SFX 0x7d @0x5a0 (**effectively dead — timer can't reach 0x5a0 from <0x3c0 in one frame**); else state=1, animFrame−− until <2 → dir=1/timer=0. direction==1 (falling): timer<0x3c0 → accumulate (no sound); else animFrame++ until numFrames → dir=0/timer=0/state=0. (project int timer-accum vs decomp short — equiv, no overflow in valid range.) | +| RB_Turtle_LInC | OVR_231::800ba420 | Match — funcThCollide bounce (1=block/2=pass). speed=abs(driver->speedApprox@0x38e); ≤0x1400 → ret 1 (too slow, solid); else forcedJumpType@0x366 = state!=0 ? FORCED_JUMP_HIGH(2) : FORCED_JUMP_LOW(1), ret 2. | +| RB_Turtle_LInB | OVR_231::800ba470 | Match — flags\|=SPLIT_LINE(0x2000); thread==0 guard → birth(0xc0303=sizeof Turtle=0xc, RB_Turtle_ThTick, "turtle"); scale=0x1000; turtleID=name digit; direction=1/timer=0/animFrame=0; id-parity stagger: EVEN → dir=1/state=1/animFrame=0; ODD → dir=0/state=0/animFrame=numAnimFrames. Birth guard present. | +| RB_MakeInstanceReflective | OVR_231::800abab0 | Match (quadFlags test ASM-verified 0x800abb34) — weapon reflection setup. !touchedQuad \|\| touchedHitbox → bitCompressed=0x4000; else bitCompressed=INST_CompressNormalVector(normal.x/y/z) (=(X>>6&0xff)\|((Y>>6&0xff)<<8)\|((Z>>6&0xff)<<16), helper matches decomp inline exactly); numPlyr≥2 → ret; **quadFlags@0x12: COLLISION_SURFACE(0x2000) set → clear REFLECTIVE; else REFLECT_SPLIT_LINE_1(0x1) → REFLECTIVE+vertSplit=splitLines[1]@level1+0x186; else REFLECT_SPLIT_LINE_0(0x4) → REFLECTIVE+splitLines[0]@0x184; else clear** (Ghidra's `==~(allflags)` = garbled single-bit tests; project correct). | +| RB_Player_KillPlayer | OVR_231::800abbb4 | Match (structural, battle scoring) — no-op unless BATTLE_MODE & both drivers non-null. POINT_LIMIT: same-team → pointsPerTeam−1 (floor −9); else attacker+1 (cap 100); ==killLimit && !TIME_LIMIT → all ACTION_RACE_FINISHED + MainGameEnd_Initialize. LIFE_LIMIT: numLives@0x4e4−−; ==0 → funcPtrs[0]=VehStuckProc_RIP_Init + RACE_FINISHED, tally teamAlive[teamID@0x4e8], standingsPoints@0x1e80[team·3+remaining]++, finishedRankOfEachTeam; 1 team left → end. (project POINT-first if/else = decomp's negated form.) | +| RB_Player_ModifyWumpa | OVR_231::800abefc | Match — numWumpas@0x30 += delta clamp [0,10]. CHEAT_WUMPA → ret; subtract blocked if ACTION_MASK_WEAPON (De Morgan of decomp); gain & !ACTION_BOT → numTimesWumpa += delta (end-race stat); reach 10 from <10 → OtherFX_Play(0x41,1) + juicedUpCooldown@0x4e0=10. (project signed-char `<0` clamp == decomp `iNewCount·0x1000000<0` bit-7 test; old always [0,10] so signed/unsigned add equiv.) | +| RB_MinePool_Init | OVR_231::800abfec | **Match except benign-unreachable divergence** — LIST_Clear taken/free; seed free list w/ poolCount Items. poolCount: CRYSTAL_CHALLENGE(0x8000000)→40 (ND bug, "should be 50"); ADVENTURE_BOSS(0x80000000=gameMode1<0) → DRAGON_MINES=3 / ROO_TUBES=7; else 10. **Divergence: retail makes crystal an EXCLUSIVE else-branch (crystal⇒40, boss checks skipped); project applies crystal then boss as sequential overrides — differ only if CRYSTAL&&BOSS&&(DRAGON\|ROO), which never co-occur (crystal challenge ≠ boss race). Documented, not fixed.** | +| RB_MinePool_Remove | OVR_231::800ac0e4 | Match — free a mine slot. weaponSlot231@0x18!=0 → LIST_RemoveMember(taken) + LIST_AddFront(free) + boolDestroyed@0x14=1 + weaponSlot231=0. | +| RB_MinePool_Add | OVR_231::800ac13c | Match — claim a mine slot. free.count==0 → RB_MinePool_Remove(taken.last->mineWeapon) (evict oldest); slot=LIST_RemoveBack(free); slot->mineWeapon@8=mw, mw->weaponSlot231@0x18=slot; LIST_AddFront(taken). | +| RB_Hazard_CollideWithDrivers | OVR_231::800ac220 | Match — scan 8 drivers for one within hitRadius of weaponInst (skip null/KS_MASK_GRABBED, parent if boolCanSkipParent). dist=(driverInst.t[i]−weaponInst.t[i])²; 2D dist[0]+dist[2], upgrade to 3D +dist[1] if model∈{STATIC_BEAKER_RED 0x46/+1 potion, PU_EXPLOSIVE_CRATE 6, STATIC_CRATE_TNT 0x27}; hit iff distChecksiblingThread, 3D dist=Σ(threadInst.t[i]−weaponInst.t[i])²; first inst with distCheckinst; tw=inst->thread->object@0x30; model->id@0x10==DYNAMIC_ROCKET(0x29) → driverTarget@tw+0x0; if !=0 clear actionsFlagSet@0x2c8 & ~ACTION_TRACKER_TARGETED(0x4000000); PlaySound3D(0x4c,inst); **OtherFX_RecycleMute(&tw->audioPtr@0x24)** (Ghidra DB "OtherFX_Stop_Safe" @0x8002e724 = rename; syms authoritative); thread->flags@0x1c\|=DEAD(0x800); return 1 (native void). | +| RB_Potion_OnShatter_TeethCallback | OVR_231::800ac5e8 | Match — potion-shatter door callback. instDef=bspHitbox->data.hitbox.instDef@0x1c; if instDef && instDef->ptrInstance@0x2c && instDef->modelID@0x3c==STATIC_TEETH(0x70) → RB_Teeth_OpenDoor(ptrInstance). | +| RB_Potion_OnShatter_TeethSearch | OVR_231::800ac638 | Match — potion-shatter radius search (dead in retail; teeth handled by Potion_ThTick_InAir). scratchpad@+0x108: pos=matrix.t, hitRadius 0x140/r²0x19000, modelID=model->id, thread stash@0x18=inst->thread, funcCallback@0x28=RB_Potion_OnShatter_TeethCallback; PROC_StartSearch_Self (=Ghidra COLL_SearchObjectsInRadius). | + +## OVR_233 (Cutscene / Podium / Credits — game/233, CS_*) + +Overlay 233 @ OVR_233::800ab9f0–800b97fb. Function list from syms926.txt "233 (Cutscene)" section (starts CS_Thread_Particles @0x800abdd4). Verifying in address order. + +| Function | Address | Verdict | +|---|---|---| +| CS_Thread_Particles | OVR_233::800abdd4 | Match — cutscene element particle emitter. Guard inst!=0 && !HIDE_MODEL && particleID@0x44<9 (unsigned); walk CsParticleSpec chain from R233.particleConfigs[particleID]: per spec spawn `count` particles Particle_Init(iconGroup[iconGroupIndex], emitter), pos from bone (CS_Instance_GetFrameData(inst, animIndex, animFrame, &pos, NULL, frameOffset)) → axis[i].startVal += (pos[i]+matrix.t[i])<<8, otIndexOffset=depthBiasNormal+modelDelta; while chainFlag&1 → entry++. CsParticleSpec 0xc: emitter@0/iconGroupIndex@4/boneIndex@5/count@6/chainFlag@7/modelDelta@8. | +| CS_ScriptCmd_ReadOpcode_GetShort | OVR_233::800abf70 | Match — read s16 (LE byte-assembly, native unaligned-safe = retail short read), advance 2, sign-extend. | +| CS_ScriptCmd_ReadOpcode_GetInt | OVR_233::800abf9c | Match — read u32 LE (b0\|b1<<8\|b2<<16\|b3<<24), advance 4. | +| CS_ScriptCmd_ReadOpcode_GetInt_dup | OVR_233::800abfd8 | Match — byte-identical compiler-emitted duplicate of GetInt (project reproduces). | +| CS_ScriptCmd_ReadOpcode_Main | OVR_233::800ac014 | **Fix 128** (opcode sign-extend→zero-extend) — else Match. Guard currOpcode==prevOpcode → ret; metadata[0]=opcode; fieldMask=cs_opcodeMeta[opcode]; per bit pull GetShort(0x1/0x2/0x4→meta[1/2/3], 0x40/0x80→meta[8/9]) / GetInt(0x8→arg0, 0x10→arg1) / bit 0x20 align-to-4 + GetInt_dup→arg1 + cursor++; absent fields zeroed; prevOpcode=end cursor. **cs_opcodeMeta[256] byte-verified vs DAT_800b14cc @0x800b14cc (all 256 field-presence masks exact).** | +| CS_ScriptCmd_OpcodeNext | OVR_233::800ac1c0 | Match — prevOpcode→currOpcode[0], prevOpcode=−1, ReadOpcode_Main (continue from prev command end). | +| CS_ScriptCmd_OpcodeAt | OVR_233::800ac1ec | Match — currOpcode[0]=opCodeAt, prevOpcode=−1, ReadOpcode_Main. **CTR_NATIVE: CS_OVR233_TranslateRetailOpcodePointer(opCodeAt) first (native bytecode-branch-target translation, retail-logic unchanged).** | +| CS_Instance_InitMatrix | OVR_233::800ac214 | Match — one-time (cs_initMatrixBool guard) pre-bake of scale·rot matrices. Walk cs_initMatrixTable[4]={data,count} (=PTR_800b7330); per 0x20-byte entry: ConvertRotToMatrix(&mat, entry+8=rot[3]); scale diag from entry+0x10/0x12/0x14; MatrixRotate(entry+8, &scale, &mat) (=MATH_MatrixMultiplication) writes scale·mat back to entry+8. | +| CS_Instance_GetFrameData | OVR_233::800ac320 | Match (line-for-line, GTE) — bone anim-key sampler. ptrAnim=headers->ptrAnimations[animIndex]@0x38; numFrames=(s16)@0x10, if<0 60fps (isOdd=animFrame&1, animFrame>>=1); clamp animFrame≤numFrames−1; framePos=ptrAnim+frameSize@0x12·animFrame+0x18; bonePtr=framePos+offset·3+0x1c; read 6 bytes [0..5] (X/Z/Y + dX/dY/dZ cross-mapped), isOdd → avg with next frame; scale.i=((boneVal+framePos[i])·inst->scale.x>>0xc)·headers->scale.i@0x18/0x1a/0x1c>>0xc; gte_SetLightMatrix(inst->matrix)+MTC2/llv0/read_mt(MFC2 25/26/27) → pos[0..2]; rotOut(param_5)!=0 → same GTE on deltas → ratan2(−dy, √(dx²+dz²))=pitch, ratan2(dx,dz)=yaw, rot[2]=0. **Return void (retail returns uScratch3 leftover/yaw; no caller uses it — Particles/etc ignore; native void).** | +| CS_Instance_GetNumAnimFrames | OVR_233::800ac5a4 | Match — modelInst && model && LODflags&0x1000) → true; else scan cameraDC[0].visInstSrc for desiredInst → return INST_GETIDPP(desiredInst)->instFlags&0x40 (=Ghidra desiredInst[1].matrix.t[0]&0x40); not found → false. **CTR_NATIVE null-visInstSrc guard (→false).** | +| CS_DestroyPodium_StartDriving | OVR_233::800ac714 | Match — end podium cutscene. hudFlags\|=1; kill OTHER(0xd) bucket threads (flags\|=DEAD) except funcThDestroy==CS_Podium_Prize_ThDestroy; restore drivers[0]: thread flags&=~0x1000(DISABLE_COLLISION), inst flags&=~HIDE_MODEL, kartState=KS_ENGINE_REVVING, funcPtrs[0]=VehPhysProc_Driving_Init; if CutsceneManipulatesAudio → howl_VolumeSet(0/1/2, FX/Music/Voice backup); cameraDC[0].cameraMode=0, pushBuffer[0].distanceToScreen PREV/CURR=0x100. | +| CS_Thread_MoveOnPath | OVR_233::800ade8c | Match — move cutscene element along a level path. Guard flags&CS_FLAG_PATH_MOTION_DISABLED(1) / inst==0. switch((s16)(model->id−0xa1)), bounds `(u32)<63`: case 0/0x3e (0xa1/0xdf) pos-lerp over ptrSpawnType2[digit] (digit=name last char), idx=(s16)progress>>5, frac=progress&0x1f, matrix.t[i]=curr[i]+(frac·(next[i]−curr[i])>>5); at end model 0xdf(STATIC_OXIDESPEAKER) freezes on last seg else loop; non-0xdf sets rotY=unk22+ratan2(dx,dz)+ConvertRotToMatrix. case 1/2/0x39/0x3a snap pos+rot over ptrSpawnType2_PosRot[digit] (6 shorts/wp, idx·6), loop. case 0x30 progress from unk18 (if animIndex==3) pos-lerp over ptrSpawnType2[0] clamp last seg, no rot. default nop. (uint·int product bits == int·int for frac∈[0,31].) | +| CS_Thread_AnimateScale | OVR_233::800ae2b8 | Match — step inst->scale.x/y/z toward desiredScale@0x2c by scaleSpeed@0x2a/frame; on reach/overshoot clamp to desiredScale + scaleSpeed=0. (project direct clamp conds == Ghidra inverted `bReached`: +speed clamp when newScale≥desired, −speed when ≤.) | +| CS_Podium_Prize_Spin | OVR_233::800af7c0 | Match (**all 4 trig quadrant paths traced**) — per-frame reward-model spin + spec light. rotY(prize[5])+=100; ConvertRotToMatrix(&matrix, &prize[4]=rot); !USE_SPECULAR_LIGHT → ret. specLightSpinAccum(prize[0x10])+=0x3f (frozen while gamepad[1] L1); tri=\|(accum&0xfff)−0x800\|; angleA=A_lo(prize[0xc])+((A_hi−A_lo)·tri>>0xb), angleB=B_lo(prize[0xd])+((B_hi−B_lo)·tri>>0xb); data.trigApprox[angle&0x3ff] {sin,cos} + 0x400/0x800 quadrant sign-fixups → **lightDir={y=cos(A), x=sin(A)·cos(B)>>12, z=sin(A)·sin(B)>>12}** (Ghidra plate's vx/vz labels swapped; project matches actual code — verified across all 4 paths). Vector_SpecLightSpin3D(inst, &prize[4], &lightDir). | +| CS_Podium_Prize_ThTick3 | OVR_233::800af994 | Match — reward fly-to-HUD-corner finisher. lerpFrameCurr(prize[0x13])−−; while >0: interp pos center→(hudDestX 0x100,hudDestY 0x6c) by frac/lerpFrameMax(prize[0x14]), project by matrix.t[2] depth (x·−t[2], y·+t[2], round `if<0 +=0xff` then >>8); scale.x−=0x4b0 floor 0x1000 (mirror); Spin. On arrival: !BoolGotoBoss() → Aku-hint priority chain over advProgress.hintFlags(=g_abAdvProgress[16]) Map(0x4000→0x18)/Wumpa(0x1000→0x16)/TNT(0x2000→0x17)/HangTime(0x10→0xe)/PowerSlide(0x20→0xf)/Turbo(0x40→0x10)/BrakeSlide(0x80→0x11) → MainFrame_RequestMaskHint (=Ghidra AkuHint_Request); overlayTransition=2, unfreeze, OtherFX_Play(0x67), thread DEAD. (project hintID=0 sentinel = decomp goto; div-guards inherent.) | +| CS_Podium_Prize_ThTick2 | OVR_233::800afbc8 | Match — 5-phase scale-bounce. pulsePhase(prize[0x15])<5: even → scale.x+=800+phase·400, advance when >(phase+1)·0x28a+0x2000; odd → scale.x−=800, advance when <0x1001; mirror scale.y/z; Spin. phase==5 → SetAndExec(ThTick3). ((s16)-trunc grow-threshold == full int, no overflow.) | +| CS_Podium_Prize_ThTick1 | OVR_233::800afcc4 | Match — orbit/bob reward + cutscene-end HUD handoff. PodiumInitUnk3!=0 → reveal (unless BIG1) + VehCalc_InterpBySpeed heightOffset→0x14/orbitRadius→1; matrix.t[0]=posBaseX+orbitRadius·sin(rotY)>>12, t[1]=posBaseY+heightOffset, t[2]=posBaseZ+orbitRadius·cos(rotY)>>12; !isCutsceneOver → Spin & stay; else lerpFrame Max/Curr=0xf, pulsePhase=0, **depthBiasNormal/Secondary=0x80 (=Ghidra −0x80, same byte in char field)**, scale=0x1000, matrix.t=0/0/zDepth, INST_GETIDPP(inst)[0].pushBuffer=&pushBuffer_UI (=Ghidra inst[1].next), SFX Stop2 0xaf/0xae + Play 0x9a, SetAndExec(ThTick2). | +| CS_Podium_Prize_ThDestroy | OVR_233::800afe58 | Match — gameMode2 &= ~(INC_RELIC\|INC_KEY\|INC_TROPHY 0x1/2/4000000); PROC_DestroyInstance(t). (CS_DestroyPodium_StartDriving spares threads with this funcThDestroy.) | +| CS_Podium_Stand_ThTick | OVR_233::800b021c | Match — podium platform tick: isCutsceneOver → thread DEAD; else nothing (static model). | +| CS_Podium_Stand_Init | OVR_233::800b0248 | Match (Ghidra "CS_Podium_SpawnStand") — spawn podium platform. INSTANCE_BirthWithThread(STATIC_PODIUM 0xa8, "podium", SMALL 0x300, OTHER, CS_Podium_Stand_ThTick, obj 0); funcThDestroy=PROC_DestroyInstance; matrix.t=podiumData[0..2]; depthBiasSecondary/Normal += 2 (draw behind chars); rot copy podiumData[12..14]=[8..10] + ConvertRotToMatrix. | +| CS_Cutscene_Start | OVR_233::800b087c | Match — cutscene scene setup. CS_Thread_Init(NOFUNC, "introcam") camera director; if gameMode2&CREDITS → isCutsceneOver=0 + CS_Credits_Init + CS_Instance_InitMatrix; else if levelID==NAUGHTY_DOG_CRATE → CS_Instance_InitMatrix + spawn 19 ND-box/kart models via CS_Thread_Init(modelID, name, zeroed initData, 0, 0) — **exact IDs+order verified: BOX_01..03 0xb6-0xbb, CODE/GLOW/LID 0xbc/bd/be, LIDB/C/D 0xc9/ca/cb, LID2 0xbf, KART0-3 0xc1-c4, KART6/7 0xc7/c8 (non-monotonic LID→LIDB→LID2 reproduced)**. | +| CS_BoxScene_InstanceSplitLines | OVR_233::800b0b38 | Match — propagate box-scene split line. split=VertSplitLine; walk threadBuckets[GHOST=2].thread via siblingThread → inst->vertSplit=split (the KART0-3 models sharing one wipe cut line). | +| CS_Garage_ZoomOut | OVR_233::800b7784 | Match — reset garage zoom/select state. zoomState!=0 → ZoomIn/ZoomOut=numFramesMax_Zoom / ==0 → 0; GarageMove/boolSelected/delayOneSecond=0; gameMode2&=~GARAGE_OSK(0x20000); zoomState==0 → Garage_Init + Garage_Enter(advCharSelectIndex_curr) + **Audio_SetState_Safe(AUDIO_GARAGE=8, asm `_li a0,0x8` @0x800b7820; Ghidra DB "AUDIO_STATE_INTRO" mislabel)**. | +| CS_Garage_MenuProc | OVR_233::800b7834 | Match — garage char-select per-frame proc (~0xd18b). Verified: (1) **engineID classifier** — project `i=0; if(==SPEED)i=2; if(matrix=src->matrix` (decomp expands piecewise), scale=`0x1000+(index+1)*300`, alphaScale=`(index+1)*630`(0x276), HIDE_MODEL(0x80) clear/set on old model, per-ghost buffers `co->data_0x18_0x5[i]`(0x18 model)/`data_0x80_0x5[i]`(0x80 headers) — struct asserts confirm sizes; retail abs addrs 0x800b9764/0x800b94e4 are memory-layout artifacts (native uses named fields). 0x40-byte ModelHeader copy × numHeaders. | +| CS_Credits_GetNextString | OVR_233::800b8810 | Match — scan fwd to '\r'(0x0d), return str+1 or NULL. Native adds a NULL-guard shim (benign). | +| CS_Credits_DestroyCreditGhost | OVR_233::800b885c | Match — INSTANCE_Death(creditGhostInst[0..4]) then MEMPACK_ClearHighMem(). | +| CS_Credits_DrawNames | OVR_233::800b88c8 | Match — scrolling name list. posY-- / < -20 advance topString + posY+=20; per-line `~NN~` escape parse (decomp `>0x31`restore/`>0x33`flags == project `<0x32`charId/`==0x34`→0x2000/`==0x35`→0x1000); edge-fade (decomp bNearEdge == project fadeAmount<20; buckets posY<0x14 / <0x83 / else 0x96-posY); boolAllBlue fade of `data.ptrColor[charId]`→`data.colors[CREDITS_FADE=0x1f]`, colorSlot=-1 skip / else CREDITS_FADE; DrawLineStrlen(str,len,creditText_PosX=0x14(D233 init, mem@0x800b9498=0x14),posY,FONT3,colorSlot\|flags). CREDITS_FADE=31=0x1f (enum count). | +| CS_Credits_DrawEpilogue | OVR_233::800b8bd0 | Match — centered epilogue. epilogueCount200-- / <=0 reload 200 + topString=nextString + nextString=GetNextString(old next); edge-fade (timer buckets <0xb5&>0x13→fade20 / <=0x13→timer / >=0xb5→200-timer; bNearEdge==fadeAmount<20); default colorSlot=4(WHITE), fade `data.ptrColor[4]`→`data.colors[31]` → 0x1f, else -1; guard colorSlot>=0 && boolAllBlue; DrawMultiLineStrlen(top,len,0x100,0xaf,0x1cc,FONT2,colorSlot\|0x8000). | +| CS_Credits_ThTick | OVR_233::800b8dc8 | Match — per-frame ghost-train + text. creditDanceInst=dancerInst_invisible; if!=0: HIDE_MODEL + snap t[0..2]=creditGhost_Pos; `timer&3==0` → shift train i=4..1 AnimateCreditGhost(inst[i],inst[i-1],i)+model[i]=model[i-1] then inst[0]←danceInv + model[0]=danceInv->model / else inst[0]←danceInv + grow i=1..4 (scale.xyz+=0x4b @0x1c/0x1e/0x20, alphaScale+=0x9d @0x22). countdown-- (>0). DrawNames/DrawEpilogue(&creditsObj). Leaf tick — no ThTick_SetAndExec, no Fix-127 concern. | +| CS_Credits_Init | OVR_233::800b8f8c | Match — credits scene setup. 18-track AND scan: boolAllBlue=&(sapphire bits i+ADV_REWARD_FIRST_SAPPHIRE_RELIC=0x16), boolAllGold=&(gold i+0x28) [decomp calls gold "allRelics"]; allGold→winner confetti(250=0xfa, renderFlags\|=4). PROC_BirthWithObject(0x30d=SMALL\|OTHER(0xd)), funcThDestroy=no-op stub(retail 0x800b8f84 jr ra); memset creditsObj(0x340), countdown=360; ×5 INSTANCE_Birth3D(modelPtr[STATIC_AKUAKU=0x39],creditThread) stored reversed [4-i], identity-0x1000 matrix, SCREENSPACE_INSTANCE, idpp[0].pushBuffer=&pushBuffer_UI + zero idpp[1..nPlyr-1]; strings: ST1_GETPOINTERS[ST1_CREDITS=6] (decomp base[7]=count-word+6), AllocHighMem+memcpy(size), numStrings@+4, ptrStrings=+8, relocate each += buf; credits_posY=340, credits_topString=ptrStrings[0x14]. | +| CS_Credits_IsTextValid | OVR_233::800b92a0 | Match — if epilogue_topString!=0 return 0; else countdown=360(0x168), return 1. | +| CS_Credits_NewDancer | OVR_233::800b92cc | Match — swap live dancer. Kill old DancerThread(flags\|=0x800), hide new dancer inst (HIDE_MODEL), countdown=360; epilogue_topString = `idmodel(+0x18), else 0. | +| CS_Credits_End | OVR_233::800b93f4 | Match — DestroyCreditGhost; CreditThread flags\|=0x800; !boolAllBlue → levID=GEM_STONE_VALLEY(0x19)+gameMode1\|=ADVENTURE_MODE / else mainMenuState=MAIN_MENU_SCRAPBOOK(5)+levID=SCRAPBOOK(0x40); RequestLoad(levID); renderFlags&=~4. Native adds podium-audio-restore shim (benign). Enum: GEM_STONE_VALLEY=lvl25=0x19, SCRAPBOOK=0x40 (39 apart, asm-confirmed). | +| CS_Podium_FullScene_Init | OVR_233::800b0300 | Match — full end-of-cup podium scene setup. Backup FX/Music/Voice vols (howl_VolumeGet&0xff); clear CutsceneManipulatesAudio/isCutsceneOver/cutsceneState(PAN)/PodiumInitUnk2/3; hide+freeze driver 0 (VehPhysProc_FreezeEndEvent_Init); numWinners=1/winnerIndex[0]=0/confetti 200/hudFlags&=0xfe/renderFlags\|=4/gameMode2\|=VEH_FREEZE_PODIUM; spawnData=ptrSpawnType2_PosRot[1] (pos +0x80 Y, rot) + ConvertRotToMatrix+gte_SetLightMatrix; **cameraDC[0].cameraMode=3 (=Ghidra `*(gGT+0x1532)=3`, gGT=*(0x8008d2ac))**; spawn 3rd(off 299/−0x55, yaw 0x600)/2nd(−299/−0x2a, 0x200)/1st(0/0, 0)/Tawna(0x1a8/−0x80/0x140, −0x2aa) via CS_Thread_Init(podium_modelIndex_*, name, &InitData{pos@0,characterPos@8,rot@0x10}, yaw, 0); CS_Podium_Prize_Init(podiumRewardID, "prize"); CS_Podium_Stand_Init; PROC_BirthWithObject(0x4030f, CS_Camera_ThTick_Podium, "victorycam") obj[0]=0; **victory music switch(podium_modelIndex_First−0x7e) → CDSYS_XAPlay(MUSIC, 7..12) — case values map exactly to decomp raw ids 0x7e–0x8c**. | +| CS_Podium_Prize_Init | OVR_233::800afe90 | Match — spawn spinning reward model. INSTANCE_BirthWithThread(prizeModel, MEDIUM, OTHER, CS_Podium_Prize_ThTick1, sizeof Prize 0x2c); fail → state≥1 + unfreeze. scale 0x2000, HIDE_MODEL, funcThDestroy=Prize_ThDestroy; orbitRadius=0x40/heightOffset=0x200/rot=0; gte project (0,0,0x40)+llv0 → posBase[i]=posOnScreen[i]+transformed[i] (posBaseY +0x1c0), zDepth=−0x200. switch: BIG1(0x38) HIDE+center; GEM(0x5f) color=AdvCups[cupID].color r<<20\|g<<12\|b<<4 + specLight{0x5d3,0x718,0x590,0x609}+SPECULAR+center(hud 0x100,0x6c); RELIC(0x61) color by relic tier (CHECK_ADV_BIT prevLEV+PLATINUM 0x3a→0xffede90 / +GOLD 0x28→0xd8d2090 / else sapphire 0x20a5ff0) + spec{0x2ab,0x436,0x1eb,0x670} + hud[0xe]−0x3c + INC_RELIC; TROPHY(0x62) hud[0x10]−0x3c/zDepth −200/scale 0x4000/INC_TROPHY; KEY(0x63) color 0xdca6000/spec{0x1d9,0x5db,0x2da,0x54b}/hud[0xf]−0x3c/INC_KEY. | +| CS_Thread_Init | OVR_233::800af328 | Match (thorough, ASM-confirmed) — spawn one cutscene element (CutsceneObj 0x60). modelID==NOFUNC(0) → camera thread PROC_BirthWithObject(0x60020f=size 0x60/MEDIUM/CAMERA bucket 0xf, CS_Thread_ThTick); else INSTANCE_BirthWithThread(modelID, MEDIUM 0x200, bucket=AKUAKU if modelID−NDI_KART6(0xc7)<2 / GHOST if −NDI_KART0(0xc1)<4 / else OTHER, CS_Thread_ThTick, 0x60) + funcThDestroy=PROC_DestroyInstance. metadata=&decodedOpcode(=aUnk4c@0x4c), frameOverrideRoot=0, prevOpcode=−1, Subtitles.lngIndex=−1. Script dispatch: camera by levelID (NAUGHTY_DOG_CRATE/OXIDE_ENDING/TRUE_ENDING literals + intro/credits tables); instance by modelID ranges (≥NDI_BOX_BOX_01 0xb6 → boxModelScripts[·−0xb6], **frameOverrideRoot=&cs_initMatrixTable[modelID−NDI_KART0] = decomp modelID·8−0x7ff492d8 (table@0x800b7330)**; PINHEAD 0xa9/DINGOFIRE 0xaf/TAWNA1 0x8f/CRASHDANCE 0x7e dance tables by podium_First). OpcodeAt(script); unk18=metadata[2]=arg0; opcodeDuration=meta[2]+RNG-scaled(meta[3]−meta[2]). inst!=0: gte_ldv0(spawn[4..6])+llv0+read_mt → matrix.t[i]=transformed[i]+spawn[i]; scale 0x2800 (≠NAUGHTY_DOG_CRATE); OT-depth −4 if levelID−GEM_STONE_VALLEY<5; ConvertRotToMatrix(spawn[8..10]+yaw), unk1c/20/22/24=rot&0xfff. defaults: particleID=0xff, flags=0, desiredScale=0x2800, tint(unk8)=0x2e808080, **ptrIcons=iconGroup[0]+sizeof(IconGroup)=+0x14 (asm addiu confirms trailing icons array)**. | +| CS_Thread_ThTick | OVR_233::800ae54c | Match (parent-attach ASM-confirmed 0x800ae640-668) — per-frame cutscene element. UseOpcode(inst,cs); ret!=0 → flags\|=DEAD & if gameMode2&CREDITS(0x80) ret. MoveOnPath/AnimateScale/Particles; flags&0x40 → InterpolateFramesMS. inst!=0 & parentThread & !(flags&4): GetFrameData(parentInst, animIndex@0x52, animFrame@0x54, parentPos@0x1f800108, parentRot@0x118, 0) → **elemInst->matrix.t[i]@0x44/48/4c = parentInst.t[i] + parentPos[i]** (Ghidra cameraDC-union `driverToFollow->funcPtrs−0x54` = garble), !(flags&0x10) → ConvertRotToMatrix(&matrix, parentRot). flags&8 → GetFrameData own → VertSplitLine=bonePos.y. flags&2 → alphaScale=0, timer&1 → (MixRNG&0x7ff)+0x400. lngIndex>0 → DecalFont_DrawMultiLine(460) + RECTMENU_DrawInnerRect(rect{x−0xec,y−4,0x1d8,w+8},4). isCutsceneOver → flags\|=DEAD. | +| CS_LoadBossCallback | OVR_233::800ae81c | Match — boss-head load-done cb. load_inProgress=0; ptrModelBossHead=lqs->ptrDestination@0xc. | +| CS_Camera_ThTick_Boss | OVR_233::800ae9a8 | Match — boss-intro director. bcd index: bossCutsceneIndex≥0 → that, else 2·(levelID−GEM_STONE_VALLEY)[+1 if podiumRewardID==STATIC_KEY] (Ghidra byte-stride `element·0xd·2` = element·0x34 = sizeof BossCutsceneData). State machine 0–5: PAN/WAIT_INPUT→fade black (desired=0,step=−0x400)→FADE_OUT; FADE_OUT: once black, kill OTHER(0xd) bucket, empty → CS_LoadBoss(&bcd)→LOADING; LOADING: ptrModelBossHead!=0 → register head+body models (i=1 body slot+4, modelPtr[id]), MEMPACK_SwapPacks, spawn 2 threads (i=1 body scale 0x1000/desiredScale, i=0 head OpcodeAt(bcd.opcode)+opcodeDuration=0, head parent=body), cam=bcd.camPos/camRot(x+0x800), fade in (0x1000,+0x400)→FADE_IN; FADE_IN: currentValue==0x1000→WAIT_END; WAIT_END: isCutsceneOver==1 → podiumRewardID=NOFUNC + thread DEAD. | +| CS_Camera_BoolGotoBoss | OVR_233::800aed48 | Match — boss-gate predicate. STATIC_RELIC(0x61) && numRelics≥0x12 && !CHECK_ADV_BIT(rewards, ADV_REWARD_BEAT_OXIDE_SECOND 0x74 = rewards[3]>>20&1 = Ghidra `g_abAdvProgress[12]&0x100000`) → 1; STATIC_KEY(0x63) → 1; else `(driver.matrix.t[0]!=spawn[0]) \|\| (t[2]!=spawn[2])` over ptrSpawnType2_PosRot[1] (Ghidra comma-op `ret 0 iff both ==`). | +| CS_Camera_ThTick_Podium | OVR_233::800aedf8 | Match — podium/results camera + flow (233_23; STATIC_BIG1 kill fix from prior session, ASM-cited). First tick freeze driver 0 (VehStuckProc_RIP_Init); cameraMode!=CAM_MODE_FIXED_POSE(3) → state≥1 + PodiumInitUnk3; save prompt TakeCupProgress_Activate(0x236/0x237) on CUP_NEW_WIN; cam path (state==PAN \|\| menu): podium[0]+=elapsedMS, PodiumInitUnk3 near end (−0x12c0), clamp last frame, PodiumInitUnk2=(s16)t>>5, CAM_Path_Move→pushBuffer[0]; else press-X (lng 0xc9); end: !ADVENTURE_MODE→MAIN_MENU(0x27); ADVENTURE & reward!=BIG1 & !BoolGotoBoss → isCutsceneOver+DEAD+DestroyPodium + XA voice (TROPHY 0x62→0xc/RELIC→0x13/KEY→0xd/TOKEN 0x7d→0x14/else 0x15, +0x1f if !MaskBoolGoodGuy); boss due → funcThTick=CS_Camera_ThTick_Boss, **bossCutsceneIndex = levelID−GEM_STONE_VALLEY+OXIDE_RELICS_GEMSTONE(9) = levelID−SLIDE_COLISEUM (verified via enum)** if RELIC & numRelics≥0x12 else −1; reward==BIG1 → OXIDE_ENDING 0x2a/TRUE 0x2b + DEAD. Start tap → boolStartToSkip. | +| CS_LoadBoss | OVR_233::800ae834 | Match — load boss models into other mempack. index=3−activeMempackIndex; CDSYS_XAPauseRequest; clear ptrModelBossBody/Head; levID_in_each_mempack[index]=−1; MEMPACK_SwapPacks(index)+ClearLowMem; load_inProgress=1; if vrmFile → LOAD_AppendQueue(LT_VRAM, vrmFile−1+index); if bodyFile → LOAD_AppendQueue(LT_DRAM, bodyFile−1+index, &Body, sentinel −2); LOAD_AppendQueue(LT_DRAM, headFile−1+index, NULL, CS_LoadBossCallback). **bigfile arg=0 vs Ghidra g_aDataFileIndex@0x8008d09c — benign: LOAD_AppendQueue stores it to ptrBigfileCdPos_UNUSED (native ignores CD-position; loads by subfileIndex).** | +| CS_Thread_InterpolateFramesMS | OVR_233::800ae318 | Match — time-interp render (ThTick when flags&0x40). GetFrameData offset 0(curr)/1(next) + matrix.t; primMem budget (cursor+0x18≥guardEnd → ret); SetRot/SetTransMatrix(pushBuffer[0].matrix_ViewProj) + MTC2 V0=curr/V1=next + gte_rtpt; xy0/xy1=MFC2 12/13, depth=SZ1; depth∈[1,0x1200) → LINE_F2 tpage 0xe1000a20/code 0x42/grey fade past 0xa00 (`((0x1200−depth)·0x3f)>>0xb`), OT-link pushBuffer[0].ptrOT[depth>>6 clamp 0x3ff] (tag=(*ot&0xffffff)\|0x5000000, prim len 5). | +| CS_Thread_UseOpcode | OVR_233::800ac840 | Match (thorough — huge cutscene-script VM, ~450 lines) — int(inst, cs). Prologue: SPLIT_LINE vertSplit; podium 2nd/1st model hide/show by PodiumInitUnk2 window [0x65,0xec)/[0x83,0xec) + OT-depth −2/−6; garage char-zoom (flags&0x80, model−STATIC_CRASHSELECT(0xce) vs gGarage sel, Select/Deselect opcode tables + fade + repeat-count reseed). Camera-director (inst==0): CAM_Path GetNumPoints/Move → pushBuffer[0].pos/rot, distanceToScreen by pathFlags 0x100/0x50/0x278/0x1eb/0x14d, BTN_START skip → RaceFlag + MainRaceTrack_RequestLoad(levelID<0x2c+0x13 windows: OXIDE_ENDING 0x2a/CREDITS_CRASH 0x2c/MAIN_MENU 0x27) + isCutsceneOver + ret 1. Apply tail: rotY interp `((rotEnd−rotStart)+0x800&0xfff)−0x800`·(frame−arg0)/\|arg1−arg0\| + unk1c, ConvertRotToMatrix, SafeCheckAnimFrame clamp, animFrame/animIndex write, frameOverrideRoot baked-matrix copy. Switch(opcode) 0/0x2a/0x2b animate(XA-sync)…0x30 volumes — cases 0–0x30 verified (0xf level-table INTRO 0x1e/CREDITS 0x2c; 0x15 stars signed-div/numPlyr; case3 NDI_BOX_PARTICLES_01 0xc0). **Benign: apply-tail `animIndex&=~0xff` vs Ghidra `=0` (only (char) used → 0); (u16) casts on meta[6] sound IDs (small +ve).** metadataBackup save/restore = cs->unk4c[0x14]. | + +### KEY INSIGHT — ThTick control flow (`ThTick_SetAndExec` tail-call vs `ThTick_FastRET`) +Reusable criterion for **every** ThTick handler that switches ticks. **`ThTick_SetAndExec(t, fn)` @0x800716ec is a tail-call** (`t->funcThTick=fn; jr fn` — ra preserved), so `fn`'s own epilogue returns to the *caller's* instruction after the `jal`, i.e. the caller **falls through**. **`ThTick_FastRET` @0x80071694 is the scheduler trampoline** — it restores saved sp/ra/s0-s8 from PSX scratchpad (@0x1f8000b0) and `jr`s back into the thread-tree traversal loop; it **never returns to its caller** (terminal). Therefore whether a `ThTick_SetAndExec(t, X)` caller should continue depends entirely on **how X ends**: X ends `jr ra` (e.g. Potion_InAir, ThrowOnHead) ⇒ caller **continues** (fall through / goto); X ends `ThTick_FastRET` (e.g. ThrowOffHead, SitOnHead) ⇒ caller is **terminal** (must `return`). **The native port drops `ThTick_FastRET` (all ThTicks just `return`), so the terminal-ness must be re-encoded as an explicit `return` in the *caller*.** Retail emits the fall-through code after a terminal tail-call as dead code — do not trust the decompiler's structured fall-through; trace X's real epilogue. + +**Fix 119** (`231_016:47`, thrown-branch return made conditional): retail's thrown-mine branch tail-calls ThrowOffHead (TNT, **terminal**) or Potion_InAir (potion/nitro, **returns**). So retail runs the grounded pass on the throw frame for a thrown **potion/nitro** but NOT for a thrown TNT. Project had one unconditional `return` (skipped the grounded pass for both). Split: TNT branch `return`s (terminal), potion/nitro falls through into the grounded pass. (asm 0x800acbf4 tail-call → 0x800acbfc fall-through; Potion_InAir jr ra @0x800aca48; ThrowOffHead ThTick_FastRET.) + +**Fix 120** (`231_016:274`, existing-thread TNT `return`→`goto LAB_800ad17c`): retail 0x800ad010 `jal ThTick_SetAndExec(ThrowOnHead)` then 0x800ad018 `j 0x800ad17c`. ThrowOnHead ends `jr ra` (NOT terminal), so the `j 0x800ad17c` (frameCount/boolDestroyed tail) **is** reached. Project `return` skipped it (dropped this frame's parent-immunity countdown decrement); changed to `goto LAB_800ad17c`. + +**Fix 121** (`231_019:154`, 0x5a-explode terminal return): retail's TNT-on-head timeout path ends via ThTick_FastRET (terminal), so the shared scale-from-table below is NOT reached (only the `<0x5a` path falls into it). Project fell through and re-wrote the scale — behaviourally benign (the value equals the prior frame's, numFramesOnHead==0x5a) but a control-flow divergence + latent OOB read of `s_tntSitScale` if numFramesOnHead ever exceeds 0x5a. Added `return` to match the binary + the function's own crash/blast & throw-off paths. + +**Fix 122** (`231_054:59`, gte_rt vs gte_mvmva in RB_Burst_DrawAll): retail's per-screen burst cull transforms the effect's world position into camera space via `gte_rt` (asm 0x800b2694 `cop2 0x0480012` = MVMVA sf=1/mx=RT/v=V0/cv=TR = `(TR·0x1000 + R·V0) >> 12`, using the view-proj matrix loaded by SetRot/SetTransMatrix). Project used `gte_mvmva(0,0,0,3,0)` = `cop2 0x0406012` = MVMVA sf=0/cv=none = rotate-only, dropping BOTH the camera translation AND the >>12 — so transformed coords were un-translated and ~0x1000× too large, and the `|x|<0x100 && |y|<0x100` proximity cull essentially never passed. Effect: the explosion screen fade-from-black flash never fired, and pass 2 (which hides non-nearest bursts per split-screen) instead drew every burst on every screen. Changed to `gte_rt()`. Build green. **GTE-op decode technique:** cop2 cmd = `0x0400012 | sf<<19 | mx<<17 | v<<15 | cv<<13 | lm<<10` — always verify sf (shift) and cv (translation), not just "it's an MVMVA". Raw bytes confirm via read_memory (LE): `0x4A480012` @ 0x800b2694. + +**Fix 124** (`231_061_068`, mid-grow crate gate ×3 — Weapon/Fruit/Time ThCollide): retail's gate is `if (cooldown==0 && (scale==0 || scale==0x1000))` (asm 0x800b3ec4 Weapon `bne v1,0x1000,blockage`; the retail Fruit decompile is literally that form) — a mid-grow crate (cooldown==0, scale strictly 0modelID 0x8000 flag, sets boolPauseCooldown for nitro/TNT/beaker), NOT an early return. Project had `if (scale!=0 && scale!=0x1000) return 0`, skipping the tail for the mid-grow window. Behaviourally near-benign (boolPauseCooldown is discarded when the grow thread dies; the flag is a per-collision input) but a confirmed control-flow divergence inconsistent with the project's own handling of the cooldown!=0 / scale==0 cases. Restructured all 3 gates. Build green. + +**Fix 125** (`231_061_068:GetDriver`, fruit crate awards AI weapon owners): the AI-owner abort (`actionsFlagSet & ACTION_BOT → return 1`) is present in the Weapon (asm 0x800b4000) and Time (0x800b493c) collision handlers but ABSENT in the Fruit handler (asm 0x800b4584: `driverParent → award`, no `0x2c8 & 0x100000`). So a fruit crate smashed by an AI-fired weapon (bomb/missile/shield) awards wumpa to that AI owner in retail; the project's single shared RB_CrateAny_GetDriver hard-coded the abort, so the fruit crate wrongly gave nothing for AI weapons (observable — affects AI wumpa counts in normal races). Added `abortForAI` param (weapon/time pass 1, fruit passes 0). Build green. + +**Fix 123** (`231_059:11`, RB_Blade_LInB missing birth guard): retail guards the whole birth on `inst->thread == 0` (asm 0x800b3988 `lw v0,0x6c(s0); bne v0,zero,end`), same as the sibling LInB handlers (RB_Baron_LInB etc.). The decomp dropped the guard, so a repeat LInB call on an already-birthed blade would `PROC_BirthWithObject` a second thread and overwrite `inst->thread`, orphaning (leaking) the first still-spinning blade thread. Added `if (inst->thread != 0) return;`. Safe (no-op on the normal single load; the guard passes when inst->thread was 0). Build green. **Watch LInB/birth handlers for this `inst->thread==0` guard — the decomp reference frequently `#if 0`'d it.** + +**Fix 126** (`231_101_103:52`, RB_Orca g_aEmSet_orcaSplash[4] value misplaced): the orca water-splash emitter table entry[4] had `0x64` written as `baseValue.accel` (emitter+0xA) when the binary stores it as `rngSeed.startVal` (emitter+0xC). Read from 0x800b8174: `baseValue={startVal=0x3E8, velocity=0x28, accel=0}, rngSeed={startVal=0x64,0,0}` — verified byte-exact against ParticleAxis `{int startVal@0, s16 velocity@4, s16 accel@6}` (sizeof 8). The prior placement gave splash axis[5] a constant acceleration of 0x64 (value ramps every frame) and dropped the intended random `startVal` spread of 0x64 (particle spawns lose their per-particle jitter on that axis) — an observable difference in the breach/land splash. All other 6 emitter entries (0-3,5,6) are byte-exact. Fixed to `.baseValue={.startVal=0x3E8,.velocity=0x28,.accel=0}, .rngSeed={.startVal=0x64}`. Build green. **Technique: for particle emitter tables, decode raw rodata bytes (read_memory at the `lui/addiu` target) against the ParticleEmitter/ParticleAxis field offsets — designated-initializer field mixups don't show in the decompiler (data, not code).** + +**Fix 128** (`233_02_09:60`, CS_ScriptCmd_ReadOpcode_Main opcode sign-extend): retail loads the cutscene opcode byte with `lbu` (zero-extend) into `metadata[0]` (asm 0x800ac04c `lbu v1,0x0(a0); sh v1,0x0(s0)` → `0x00xx`). The project's `offsets[0] = opcodes[0]` where `opcodes` is `char*` (signed on native) sign-extends opcodes ≥ 0x80 to `0xFFxx`. **Currently unobservable** (the `CS_Thread_UseOpcode` dispatch `switch(opcodeMeta->opcode)` only has cases 0xb–0x30, all <0x80, so opcodes ≥0x80 fall to `default` in both; and the meta-table lookup re-masks with `(u8)`), but a genuine sign/unsigned divergence — cast to `(u8)opcodes[0]` to keep `decoded->opcode` bit-exact with retail's `lbu`, guarding against any future consumer that reads the full opcode value. Zero behavioral change (opcode <0x80 identical either way). Build green. **Technique: retail byte→halfword stores are `lbu`(zero-ext) unless the source is genuinely signed — a `char*`-sourced field silently sign-extends on native; check the load width (lb vs lbu) in asm.** + +**Fix 127** (`231_110_113`, RB_Seal both ThTick transitions skipped the shared collision tail — ThTick control-flow, Fix 119/120 class): the seal patrols via a Move↔TurnAround cycle; in each state the seal's own `Seal_CheckColl` (radius 0x4000) is the **common tail reached by every path**, including the state-transition path. Retail: **127a** TurnAround rot-complete → `ConvertRotToMatrix; SetAndExec(RB_Seal_ThTick_Move)` then FALLS THROUGH to the collision at 0x800b8d1c (Move ends `jr ra` @0x800b90d0, non-terminal; SetAndExec is a tail-call so Move returns into TurnAround's fall-through). **127b** Move endpoint → `SetAndExec(RB_Seal_ThTick_TurnAround)` then FALLS THROUGH to `LAB_800b8fdc` (TurnAround ends `jr ra` @0x800b8e14), exactly like the non-endpoint paths that `goto` it. The project had `SetAndExec(...); return;` in both transition branches, skipping the seal's collision pass in the turn-complete / turn-start frame. Since native `ThTick_SetAndExec` calls the target synchronously (PROC.c: `funcThTick(thread)`), removing the `return` (127a) / replacing it with the `Seal_CheckColl` call (127b) reproduces retail's two collision passes that frame (the entered state's + the exiting state's). Mostly benign (a second HurtDriver on an already-spun driver returns 0), but a genuine control-flow divergence — a driver/mine entering range exactly on a transition frame got one fewer collision check. Both ends verified `jr ra`; SetAndExec-target-endings drove the fix (Move & TurnAround are mutually non-terminal, so each caller must continue). Build green. + +## OVR_232 (AH_* Adventure Hub — game/232, AH_*) (2026-07-05) — IN PROGRESS — WarpPad cluster CLEAN (0 fixes) + +| Function | Address | Verdict | +|---|---|---| +| AH_WarpPad_GetSpawnPosRot | OVR_232::800abafc | Match — walk threadBuckets[WARPPAD=5] for WarpPad whose levelID(@0x6c)==gGT->prevLEV; posData = pad pos pushed 0x400 forward along facing (X += MATH_Cos(rot.y)<<0xa>>0xc, Z += MATH_Sin(rot.y)*-0x400>>0xc), Y = pad Y; return &instDef->rot.x. NULL if none. | +| AH_WarpPad_AllWarppadNum | OVR_232::800abbdc | Match — per-pad digit-glyph swap. inst[2]/digit1s 1..8 → SetNumModelData(inst[2], &headers[digit1s-1]); inst[3]/digit10s!=0 → SetNumModelData(inst[3], &headers[0]). | +| AH_WarpPad_SetNumModelData | OVR_232 (inlined) | Match — **ASM-verified field map** (digit1s @0x800abcf0+, digit10s @0x800abd20+): idpp[0].ptrCommandList=mh->ptrCommandList(0x20→idpp+0xc8), ptrColorLayout=mh->ptrColors(0x2c→0xd0), ptrTexLayout=mh->ptrTexLayout(0x28→0xcc), ptrCurrFrame=mh->ptrFrameData(0x24→0xc0). Decomp's `&headers->ptrVertexData+2`/`&unk3+2` garbling disproven by asm (clean lw at 0x20/0x24/0x28/0x2c). | +| AH_WarpPad_MenuProc | OVR_232::800abd80 | Match — RECTMENU_Hide; row 0 → gameMode2\|=TOKEN_RACE(8), row 1 → gameMode1\|=RELIC_RACE(0x4000000). | +| AH_WarpPad_SpinRewards | OVR_232::800abdfc | Match — spin prize matrix (ConvertRotToMatrix spinRot_Prize); per-model spec-light (GEM→lightDirGem, RELIC→lightDirRelic, TOKEN→lightDirToken, TROPHY→none); orbit pos radius 0xa0 at angle 0x555*index + spinRot_Rewards.y (==decomp nOrbitAngle), Y bob via MATH_Sin(thirds[index])<<6>>0xc + 0x100. | +| AH_WarpPad_ThTick | OVR_232::800abf48 | Match — huge per-frame handler (~0x380b). Verified: visInstSrc scan (native null-guard shim benign), show/hide all 10 WPIS instances (HIDE_MODEL 0x80), distance gate (levelID buckets <16/-16<2/-18<7/>=100 with dist thresholds 0x144000/0x90000), active-level D232.levelID + Aku/mask hints, closed-item ratan2 billboarding, gem color cycle (timer/0x3c%5), champion/RNG kart-spawn shuffle (RngDeadCoed, %(7-i), (s16) benign), beam RNG (magic-÷6 == `(i>>3)%6`), wisp rise/alpha, rewardScale lerp [0x900000..0x1200000]→[0x100..0]. **Loading config bits ASM-verified @0x8008d100/04: decomp's "Loading.OnComplete.*" is a Ghidra DB MISLABEL of project's `Loading.OnBegin.*` (same addr; asm `sw ...,-0x2efc(0x80090000)`=0x8008d104 `\|= 0x100000`=ADVENTURE_ARENA).** AddBits: RELIC_RACE 0x4000000 / CRYSTAL_CHALLENGE 0x8000000 / ADVENTURE_CUP 0x10000000. Self-contained tick (no ThTick_SetAndExec) — early returns genuine, no Fix-127 concern. VehStuckProc_Warp_Init == decomp ElecDrop_Init. | +| AH_WarpPad_ThDestroy | OVR_232::800ad2c8 | Match — INSTANCE_Death all 10 WPIS instances in order [2,3,1,0,4]+[5,6]+[7,8,9], nulling each (WPIS enum: CLOSED_ITEM0/X1/1S2/10S3/BEAM4/RING1..2 5-6/PRIZE1..3 7-9/NUM 10). | +| AH_WarpPad_LInB | OVR_232::800ad3ec | Match — birth/spawn (~0x484b). PROC_BirthWithObject(0x780205 = sizeof(WarpPad)0x78\|MEDIUM(0x200)\|WARPPAD(5)); modelIndex state 0-4 (NOFUNC/ANIMATE_IF_HIT/PU_WUMPA_FRUIT/PU_SMALL_BOMB/PU_LARGE_BOMB = 0/1/2/3/4); parse levelID from "warppad#NN" (s16 vs int accum benign). **Unlock-requirement logic ASM+data verified — TWO project simplifications yield IDENTICAL results to retail's data-driven formula: (1) SlideCol project hardcodes numNeeded=10 == metaDataLEV[16].numTrophiesToOpen(read 0x80083c10 = 10); (2) battle maps (18/19/21/23) project uses arrKeysNeeded[hubID] but retail asm @0x800ad63c/0x800ad6b0 uses numTrophiesToOpen — ROM data has numTrophiesToOpen==hubID and arrKeysNeeded{2,1,2,3,4}[hubID]==hubID for all 4 battle levels (4/2/1/3), so equal. TurboTrack fixup 0x5f→5 overrides data's 15.** Model IDs BEAM0x7b/BOTTOMRING0x7c/BIGX0x6f/BIG1 0x38/GEM0x5f/RELIC0x61/TROPHY0x62/KEY0x63/TOKEN0x7d; reward bits FIRST_TROPHY6/SAPPHIRE0x16/CTR_TOKEN0x4c/GEM0x6a/PURPLE0x6f. Prize orbit sin/cos*0xc0>>0xc, colors/scales/specular per model — all match. **WarpPad cluster COMPLETE.** | + +| AH_Garage_ThDestroy | OVR_232::800ae8a0 | Match — INSTANCE_Death garage->garageTopInst if set, null it. | +| AH_Garage_Open | OVR_232::800ae8e0 | Match — collision callback. otherTh->modelIndex==DYNAMIC_PLAYER(0x18); garageThread from sps->Union.ThBuckColl.thread (decomp qbColl.hitRadiusSquared, same slot); if direction!=1 && matrix.t[1](@0x48)==instDef->pos.y(@0x32) → OtherFX_Play(GEM?0x96:0x95); direction=1; doorAccessFlags\|=1. | +| AH_Garage_ThTick | OVR_232::800ae988 | Match — boss-garage door per-frame (~0x6e8b). direction 0 idle/1 open/-1 close; slide Y ±0x20 in [instDef.pos.y .. +0x300], garageTop rolls (rot[0]+=dir*0x40); flags idle `\|SPLIT_SPECIAL(0x1000) &~0x302000` vs moving `&~0x1000 \|0x302000` (SPLIT_LINE0x2000\|REFLECTION_FUNC23 0x100000\|WATER_SPLIT_WHITE 0x200000). bossIsOpen = all 4 hub bits (GEM: boss keys 0x5e..0x61 / else advHubTrackIDs[(lev-N_SANITY_BEACH26)*4+i]+FIRST_TROPHY6). gameMode2&VEH_FREEZE_PODIUM(4) return; distSq gate 0x143fff; draw lng_challenge[bossIDs[hubID]] (**centering `view.x+(view.w>>1)` — prior NOTE(claude) parens fix present**); hints: GEM bit0x7a→maskID4 / else bit0x79→maskID3 (FIRST_HINT0x76+3/4). Open: sps hitbox modelID=STATIC_PINGARAGE(0x73) r0x300/rsq0x90000, callback AH_Garage_Open, PROC_CollideHitboxWithBucket(PLAYER); interior point instDef.pos + sin/cos(rot.y)*-0x280>>0xc, distSq<0x40000 → fadeFromBlack(step -0x2aa); on fade done Loading.OnBegin(decomp "OnComplete" mislabel) RemBits ADVENTURE_ARENA(0x100000)/AddBits ADVENTURE_BOSS(0x80000000); bossID = (GEM && numRelics==18)?5:bossIDs[hubID]; characterIDs[1]=metaDataLEV[bossTracks[hubID]].characterID_Boss; RequestLoad(bossTracks[hubID]). | + +| AH_Garage_LInB | OVR_232::800af070 | Match — birth (0x140303 = sizeof(BossGarageDoor)0x14\|SMALL\|STATIC); non-Oxide (model->id != STATIC_OXIDEGARAGE 0x77) spawns garagetop (STATIC_GARAGETOP 0x8e) at door pos + sin/cos(rot.y)*0x4c>>0xc, Y+0x300, depthBiasNormal=-2(0xfe); bossIsOpen scan (GEM keys 0x5e..0x61 / else advHubTrackIDs+6); modelIndex art = !open→0 / open&&!BeatBossPrize[hubID] bit→1 / beaten→2; garage->rot=instDef.rot; inst depthBias=1, secondary=normal, unk53=0, vertSplit=instDef.pos.y+0x300. **Garage cluster COMPLETE.** | +| AH_SaveObj_ThDestroy | OVR_232::800af3a4 | Match — INSTANCE_Death save->inst if set, null it. | +| AH_SaveObj_ThTick | OVR_232::800af3e4 | Match — load/save station state machine (~0x40cb). dist to drivers[0]; phase0 (flags&1==0): skip if PAUSE_ALL(0xF) or dist>0x8ffff; hint SAVE_LOAD_SCREEN (bit 0x7c=FIRST_HINT0x76+6, maskID 6) if unset; when speed<0x80 && MaskHint_boolCanSpawn, countdown scanlineFrame, on (s16)underflow set camera fly-in (desiredPos = spawnType2.posCoords[0..2] + matrix.m[i][0]*0x19>>7, desiredRot = posCoords[3..5] + saveObjCameraOffset), park driver (funcThTick=VehBirth_NullThread), CAM_SetDesiredPosRot, JogCon2, flags\|=1, backup+clear hudFlags. phase1 (flags&1): speed>=0x101→flags=0; else cameraDC.flags&0x200==0 && flags&4==0 → stop driver + flags\|=4 + restore hud (native shim: only when Loading.stage==LOAD_IDLE); else on cam arrival (flags&0x800): flags&2==0→flags\|=2 + GetTrackID + show menuGreenLoadSave / else flags&0x400(CAMERA_FLAG_TRANSITION_BACK)==0 && no active menu → set it. scanlineFrame=0xf. Tail: advance scan anim, on loop OtherFX_Play_LowLevel(0x99,1,(vol&0xff)<<0x10\|0x8080) vol=MapToRange(sqrt(dist)*3>>1, 300,6000, 0xff,0). | + +| AH_SaveObj_LInB | OVR_232::800af7f0 | Match — birth (0xc0303 = sizeof(SaveObj)0xc\|SMALL\|STATIC); t->inst, flags=0, scanlineFrame=0, HIDE_MODEL; **contains 2 prior NOTE(claude) fixes: (a) branch on level1->numSpawnType2_PosRot COUNT(@0x140) not pointer(@0x144); (b) pass t (not 0) as INSTANCE_Birth3D 3rd arg so scan beam's thread back-ptr is set.** Non-zero count → spawn STATIC_SCAN(0x78), copy savInst matrix, ConvertRotToMatrix(posCoords[3..5]), t[0..2]=posCoords[0..2], depthBiasNormal=-8(0xf8). | +| AH_Door_ThDestroy | OVR_232::800af9f8 | Match — INSTANCE_Death otherDoor + keyInst[0..3], nulling each (WoodDoor: otherDoor@0, keyInst[4]@4). | +| AH_Door_ThTick | OVR_232::800afa60 | Match — boss-key world-door per-frame (~0x6ccb). **doorIsOpen** via AH_Door_IsOpenByRewards helper == decomp's nested storyFlags(rewards[3]@0xc) bit test: BEACH+door4→0x40, door5→0x10, GEM→0x20, LOST_RUINS→0x80, GLACIER→0x100 (reward bits 0x66/0x64/0x65/0x67/0x68). **dist incl Y-term (prior NOTE(claude) fix — was dX²+dZ² only)**; door pos pushed cos/sin*0x300>>0xc; dist<0x90000 → doorAccessFlags\|=2/&=~2. numKeys: BEACH 1 (door4→2) / else arrKeysNeeded[lev-25]. gameMode2&VEH_FREEZE(4) return. open→hint NEW_WORLD_GREETING(bit0x7d/maskID7) if TRANSITION_AWAY(0x200) clear. far cut-off 0x8ffff unless CutscenePlaying(0x10). >5), SFX 0x67@10/0xf/0x14/0x19, 0x93@0x50, 0x94@0x78, camera aim (cos*0x312+cos(+0x400)*0x600, Y+0x17a, rot.x+0x800). Return branch: Ghidra WdCam_ControlRestored=FlyingIn(4)/CameraArrived=FullyOut(2); TRANSITION_HOLD(0x800). Tail (only FlyingOut set): doorRot.y+=0x10, rotate halves rot.y∓doorRot.y; **key-shrink table R232.keyFrame == retail D232_keyShrinkScale byte-exact @0x800aba8c** {0x1333,0x1599,0x1666,0x14cc,0x1000,0xb33,0x800,0x666,0x4cc,0x333,0x199,0}; on full open (doorRot.y>=0x400) set storyFlags(BEACH door4/LOST_RUINS→0xc0, door5→0x10, GEM→0x20, else→0x100), TRANSITION_BACK, restore driving+HUD. | + +| AH_Door_LInB | OVR_232::800b072c | Match — birth (0x380303 = sizeof(WoodDoor)0x38\|SMALL\|STATIC); inst flags\|=SPLIT_SPECIAL(0x1000); null keyInst[0..3]; parse doorID from name[5..]; model by level: GLACIER→STATIC_DOOR3(0xb5 2-hole) / BEACH+door5→STATIC_DOOR2(0xa7 no-hole) / else STATIC_DOOR(0x7a 1-hole); spawn otherDoor, flags\|=(SPLIT_SPECIAL\|REVERSE_CULL_DIRECTION 0x8000)=0x9000, copy matrix, scale.x=-0x1000, offset t[0/2] += cos/sin(rot.y)*0x600>>0xc; both headers[0].flags\|=2 (billboard); if door already open (storyFlags masks same as ThTick) pre-rotate to doorRot.y=0x400 (left rot.y+0x400, right rot.y-0x400). **Benign divergence: retail has dead defensive null-check on the DOOR2 model that NULL-derefs at LAB_b08b4 if missing; project omits it (model always present).** **Door cluster COMPLETE.** | + +| AH_Map_LoadSave_Prim | OVR_232::800b0b98 | Match — one POLY_G4 quad for the hub-map load/save icon. primMem alloc (curr<=endMin100 → +0x24), setPolyG4 (len8@+3, code0x38@+7), 4 RGB colors from vertCol[0..2/4..6/8..10/12..14] (stride-4), 4 XY verts vertPos[0..7], AddPrim(ot,p). | +| AH_Map_LoadSave_Full | OVR_232::800b0ce0 | Match — rotate+scale 4 quad verts then emit 5 layered quads. Rotation: decomp inline trigApprox[angle&0x3ff] + quadrant fixup (bits 0x400 swap, 0x800 negate) == project MATH_Sin/Cos(angle); X = posX+6 + ((vX*cos>>0xc + vY*sin>>0xc) * SCALE_X >>0xc), Y = posY+4 + ((vY*cos>>0xc - vX*sin>>0xc) * unk800 >>0xc). **Aspect scale X = unk800*8/5 (decomp truncates to s16 before mul — benign for the ~0x800 icon scale), Y = unk800.** 5 quads at D232.primOffsetXY_LoadSave[i] (X @[i*2], Y @[i*2+1]; decomp's DAT_800b4eae = &table[1]); first quad uses passed vertCol, next 4 use D232.colorQuad. | + +| AH_Map_HubArrow | OVR_232::800b0f18 | Match — same shape as LoadSave_Full but 3 triangle verts + RECTMENU_DrawRwdTriangle + D232.primOffsetXY_HubArrow / colorTri. Rotate 3 verts (MATH_Sin/Cos, X aspect *8/5, Y *unk800), emit 5 layered tris; first uses passed vertCol, next 4 use D232.colorTri. | +| AH_Map_HubArrowOutter | OVR_232::800b1150 | Match — animated dashed curved arc between hubs (~0x3a4b). center = pos + hubArrowXY_Inner[2*type]; 3-way by type: 0→var8=0x200 pulse red, 1→0xff/var8=0x555 + hubArrowXY_Outter[((inputAngle>>8)&0xc)>>2], 2→var8=0x199 + inputAngle^0x800. 3 nested arcs; dash phase = `(~(timer+arrowIndex*0xc)&0x3f) + (2-arc)*-6` (unsigned <0xc gate); iVar16 dash len = `((var5*0x2aa+0x1000)<<0x10)>>0x1a`. Arc-segment loop iVar13 += var8 while < var8+0xfff: angle=iVar13+inputAngle, shift=(shiftToggle&1)+0xc (type!=2 → always 0xc), X = posX + ((iVar16*8/5)*sin>>shift), Y = posY - (iVar16*cos>>shift), CTR_Box_DrawWirePrims(prev,cur, MakeColor(var14,var15,0xff)). Decomp inline trigApprox fixup == MATH_Sin/Cos; `iArcStep!=-0xfff` guard is dead. | + +| AH_Map_HubItems | OVR_232::800b14f4 | Match — per-hub map icons (~0x524b). Walk hubItemsXY_ptrArray[lev-25] records {x,y,angle,iconType}(-1 term). Dispatch by iconType: -1 arrow beach→gem (lock if BEACH&&keys<1), -4 beach→glacier (keys<2), -5 glacier→citadel (keys<3), -3/-2 gem arrows (never locked); 100 load/save (AH_Map_LoadSave_Full at pos-0x200/-0x100); 4 Oxide star (4 keys 0x5e..0x61, beaten bit BEAT_OXIDE_FIRST_BOSS=0x62 storyFlags&4); 3 boss star (4 hub trophies advHubTrackIDs+6, beaten bit `base+FIRST_BOSS_KEY`=`levelID+0x44` == decomp's Ghidra `SCRAPBOOK\|TIGER_TEMPLE`=0x40\|4). Arrow draw (iVar5>=0): outer arc if iVar5==0 && unkModeHubItems==0 (angle=(s16)(0x1000-item.angle)), color idx = (timer&2)?(lock*2+1)*3:lock*6 ∈{0,3,6,9}; star draw (sVar8>=0): color beaten→3/open→5\|4(timer)/locked→0x17, open→outer arc type2. **`&D232.hubArrow_col1[iVar5]` reads past int[3] into contiguous hubArrow_col2/Gray1/Gray2 @0x800b4ff8+ (col1/col2/gray1/gray2) — technically-OOB but correct, mirrors retail's `&col1 + idx*4`.** | + +| AH_Map_Warppads | OVR_232::800b1a18 | Match — draw every warp pad as map icon. Walk WARPPAD bucket; switch modelIndex: 0→grey0x17+skip, 1→blue/white(timer&2?4:5)+outer arc(type0)+isTrophy, 2→red3, 3→0xe, 4→rainbow((timer>>1)&7)+5, default→0x15+skip. UI_Map_DrawRawIcon(icon 0x31); non-skip pads → track nearest (SquareRoot0 dist), PlayWarppadSound(minDist<<1). decomp bLocked/bDrawArc == project skipDistance/isTrophy. | + +| AH_Map_Main | OVR_232::800b1c90 | Match — hub HUD update. HudAndDebugFlags &= ~8 (speedo off); hudLayout=hudStructPtr[nPlyr-1]; RaceFlag Get/SetCanDraw(1); welcome hint (WELCOME_TO_ARENA maskID0/bit0x76) if RaceFlag_IsFullyOffScreen; AI-only speedo fallback (nPlyr==0 && ACTION_BOT → flags=8); mapCtx = ST1_GETPOINTERS[ST1_MAP=0] (decomp pSpawn1[1].count = count-word+0); !PAUSE_ALL → UI_JumpMeter_Update; !(hudFlags&0x10) → DrawDrivers(arrowIdx0)/Warppads+HubItems(arrowIdx1)/DrawMap(500,0xc3,white=1)/SlideMeter(hud[8]); DrawNumRelic/Key/Trophy(hud[0xe/0xf/0x10].x+0x10, .y-10) (UiElement2D 8b, x@0/y@2). Native adds AH_MaskHint_DrawRepeatPrompt shim (AkuAkuHintState==5). **Map cluster COMPLETE (7 fns).** | + +| AH_Pause_Destroy | OVR_232::800b1ef8 | Match — if D232.ptrPauseObject: INSTANCE_Death all 14 PauseMember[i].inst, clear ptr, t->flags\|=THREAD_FLAG_DEAD(0x800). | +| AH_Pause_Draw | OVR_232::800b1f78 | Match — pause "progress book" page render (~0x11ccb). Title (advPausePages[pageID] {levelID@0,titleLng@2,type@4,bossID@6}, titleLng<0→metaDataLEV.name_LNG); blink L/R nav arrows (decomp inline color-unpack == ptrColor[0..3]); reset 14 members (idx=-1, unlockFlag&=~1). 3 page types: 0=hub trophy/relic/token page (trophy+6/sapphire+0x16/gold+0x28/platinum+0x3a/CTR+0x4c/gem+0x6a/purple crystal+PURPLE_TOKEN_HUB_ID_BASE0x6e/boss BeatBossPrize), 1=CTR-token tally (i+0x4c ctrTokenGroupID + purple i+FIRST_PURPLE_TOKEN0x6f), 2=relic totals (platinum/gold/sapphire, "TOTAL"=LNG_TOTAL0xc4). Outer/inner rect. Final 14-member config loop: idx<0→HIDE_MODEL / else flags&=~(HIDE\|0x70000)\|advPauseInst[idx].instFlags, grey if `unlockFlag&1==0` (**NOTE(claude) bit-isolate fix present, matches asm andi 0x1**), scale (type1→0x1000/type2→<<2), model, ConvertRotToMatrix, specular if `(flags&0x70000)==USE_SPECULAR_LIGHT(0x20000)`, rot[1]=t[0]*0x10+t[1]*0x20+frameCounter*0x40. **Outer-rect color: retail `la a1,0x8008d438`(=&battleSetup_Color_UI_1) passed as ptr-to-color (RECTMENU_DrawOuterRect_Edge casts `(u_int*)rgb`→CTR_Box_DrawClearBox deref); project passes battleSetup_Color_UI_1 by VALUE, native re-addresses `&color` — identical effective color, faithful refactor (ASM-verified @0x800b2eb4).** | + +| AH_Pause_Update | OVR_232::800b3144 | Match — pause overlay per-frame driver. Lazy-create PauseObject: PROC_BirthWithObject(0x30d=0\|SMALL\|OTHER), 14× INSTANCE_Birth3D(STATIC_GEM 0x5f) screen-space UI insts (flags USE_SPECULAR_LIGHT\|SCREENSPACE_INSTANCE\|HIDE_MODEL, idpp[0]=pushBuffer_UI, identity matrix, t[2]=0x100, rot=0, idx=-1); pausePageCurr=levelID-25. LEFT→dir-1/page-1 wrap6, RIGHT→dir+1/page+1 wrap0, SFX0. Slide: timer>0 dec / else page changed → prev=curr,dir_dup=dir,timer=8,curr=page. posX: timer<5 (2nd half) → pageID=curr, posX=timer*dir*-0x80 (decomp `(timer*-0x200)/4*dir` exact, -0x200 mult of 4) / else (1st half) → pageID=prev, posX=(8-timer)*dir*0x80. AH_Pause_Draw(pageID,posX). **Pause cluster COMPLETE (3 fns).** | + +| AH_HintMenu_FiveArrows | OVR_232::800b344c | Match — draw 5-arrow scroll indicator. 5× AH_Map_HubArrow(x=i*0x32+0x95, y=posY+4, fiveArrow_pos, blink col1/col2 (frameCounter&2), scale 0x800, rotation); color select hoisted out of loop (benign, frameCounter loop-invariant). | +| AH_HintMenu_MaskPosRot | OVR_232::800b351c | Match — pose hint-preview mask. ConvertRotToMatrix(instMaskHints3D->matrix, D232.maskRot); t[0..2]=maskPos[0..2]; ((MaskHint*)thread->object)->scale(@0x4)=maskScale(0x1000). | + +| AH_HintMenu_MenuProc | OVR_232::800b3594 | Match — pause "Hints" list menu (~0x844b). Sets WELCOME_TO_ARENA bit(0x76); MainFreeze_SafeAdvDestroy; build hintsFound[] (walk hintMenu_lngIndexArr to -1 term, hintID=(lng-0x17b)/2, bit=hintID+FIRST_HINT0x76, keep if unlocked); numRows=found+1, clamp rowSelected/scrollIndex. **View mode** (boolViewHint): MaskPosRot, maskCooldown--, exit on face-button(0x40070) && (noXA \|\| cooldown==0) → VehTalkMask_End; draw title lng[idx]/body lng[idx+1](DrawMultiLine)/EXIT(LNG_HINT_EXIT 0x17a) + highlight/outer/inner rects. **Browse mode**: tap 0x40073(6 buttons) — UP(1)/DOWN(2) move sel (SFX0), CROSS\|CIRCLE(0x50) → EXIT row=exit / else start VehTalkMask_Init+PlayXA((lng-0x17b)/2)+MaskPosRot+SCREENSPACE (if !loading && XA_STOPPED), maskCooldown=30, boolViewHint=1; TRIANGLE\|SQUARE(0x40020)→exit; draw Aku/Uka header(lng[0x178+(!goodguy)]), 5-row scroll window + FiveArrows, highlight box; TRIANGLE\|START\|SQUARE(0x41020) or exit → ptrDesiredMenu=MainFreeze_GetMenuPtr. Button masks == verified CS_Garage_MenuProc canonical masks (buttonTap stores _one bits). battleSetup_Color_UI_1 = same value-refactor as AH_Pause_Draw. 2 huge unused stack arrays = documented ND scratchpad bug (omitted, benign). **HintMenu cluster COMPLETE (3 fns).** | + +| AH_MaskHint_Start | OVR_232::800b3dd8 | Match — begin talking-mask hint. boolDraw3D_AdvMask=1; maskHintID/WarppadBoolInterrupt=params; UNLOCK hintId+FIRST_HINT(0x76); hintId==0(WELCOME) also unlocks USING_WARP_PAD(0x77) + MAP_INFORMATION(0x8e = rewards[4]&0x4000); driver freeze (FreezeEndEvent_Init); if !modelMaskHints3D → LOAD_TalkingMask(AdvPackIndex, !MaskBoolGoodGuy)+spawnFrame=0x5a / else 0x14; maskOffsetPos/Rot = maskVars[(interrupt&1)*3 + 0..2/6..8]; audioBackup[0..2]=howl_VolumeGet(0..2). | +| AH_MaskHint_boolCanSpawn | OVR_232::800b3f88 | Match — return AkuAkuHintState == 0 (idle → can spawn). | +| AH_MaskHint_SetAnim | OVR_232::800b3f98 | Match — per-frame mask pose. GTE-transform maskOffsetPos by camera matrix (gte_ldv0/rt/stlvnl) → posEnd; rotEnd = camRot ∓ maskOffsetRot; CAM_ProcessTransition(_PoseLerp) from maskCamPosStart/RotStart by scale; radius rot = (spawnFrame-0x14>0xc; maskAngle=scale*8; posCurr.x+=sin*rot>>0xc, .z+=cos*rot>>0xc (decomp inline trigApprox fixup iCos=sin/iCosHi=cos == MATH_Sin/Cos), rotCurr.y+=angle, ConvertRotToMatrix; MaskHint.scale(@4)=scale*4-1; vertical bob posCurr.y += (MATH_Sin((frameCounter+timer)*0x20)<<4>>0xc)*scale>>0xc; matrix.t=posCurr. | + +| AH_MaskHint_SpawnParticles | OVR_232::800b42b4 | Match — spawn N "hubdustpuff" particles. animScale=clamp(maskAnim+0x1000, 0x3fff); Particle_Init(0, iconGroup[0x10], emSet); axis[0..2].startVal += mask.matrix.t[j]*0x100; axis[5].startVal/velocity = *animScale>>0xc; otIndexOffset-=5. (decomp axis.pos/vel/cOtDepth = startVal/velocity/otIndexOffset). | +| AH_MaskHint_LerpVol | OVR_232::800b43cc | Match — lerp 3 audio channels FX/Music/Voice from audioBackup toward maskAudioSettings by factor/4096: howl_VolumeSet(i, (backup + (maskAudioSettings[i]-backup)*factor>>0xc) & 0xff). **decomp `(char)delta` == project full-int `& 0xff`: `(char)delta ≡ delta (mod 256)` so identical byte.** | + +| AH_MaskHint_Update | OVR_232::800b4470 | Match — 8-state hint machine (~0x810b), switch(AkuAkuHintState-1). st1 wait XA(XA_State==0); st2 wait speed abs<=0x31 then (unless warppad) set eye/lookAt offsets + flags\|=8 + CAM_FollowDriver_AngleAxis(stack work buf, not 0x1f800108) + SetDesiredPosRot, delay=60; st3 wait cam arrive(flags&TRANSITION_HOLD 0x800 \|\| spawnFrame==0x14) → VehTalkMask_Init, CTR_MatrixToRot(0x11) → maskCamRotStart[0/2/1]=y/z/x&0xfff, posStart=t, scale=0, SetAnim(0); st4 spawn anim + staged SFX(0x100 @frame 0/10/20/25/30) + SetAnim/SpawnParticles(3,maskSpawn)/LerpVol by maskFrameCurr/spawnFrame, on done+model → LerpVol(0x1000)+leave particles(0x18,maskLeave)+PlayXA+hide map (ADVENTURE_ARENA && hintID!=0,!=0x18); **case-3 restructure behaviorally equivalent (reordered frame>6; sps setup searchFlags=COLL_SEARCH_HIGH_LOD(0x2 @0x22, decomp "bbox.max.z"), quadFlagsWanted=GROUND\|COLLISION_SURFACE(0x3000 @0x24, decomp "maybeVerticeTestCount"), quadFlagsIgnored=0, ptr_mesh_info; probeTop=pos+normal*2, probeBottom=pos-normal*2; COLL_SearchBSP_CallbackQUADBLK; flip normal if hit; pack normal xyz as 3 bytes into inst->bitCompressed_NormalVector(@0x70). **Flip condition: retail asm reads `0x56(candidate.triangleNormal[1])` @0x800b4d68, project reads `boolDidTouchQuadblock`(@0x3E) — NOT a bug: native COLL_SearchBSP is reimplemented (COLL.c:22 zeroes counters itself, :789 increments boolDidTouchQuadblock on hit); ALL native CallbackQUADBLK callers (BOTS/CAM/VehBirth/RB_*/RB_MakeInstanceReflective same bitCompressed idiom) use boolDidTouchQuadblock as the "probe hit ground" flag == retail's triangleNormal[1] hit indicator. Extra decomp-zeroed fields (countTrianglesTouched/field7_0x40/countInstancesCollided) reset by native search; resetOnly40 has no consumer.** **OVR_232 (AH_* Adventure Hub) COMPLETE.** | + +**OVR_232 (AH_* Adventure Hub) COMPLETE** — all 37 functions verified (232_00..232_36: WarpPad, Garage, SaveObj, Door, Map, Pause, HintMenu, MaskHint, Sign), 0 new fixes this overlay (2 prior NOTE(claude) fixes in SaveObj_LInB + Door_ThTick centering already present). **All overlays now done: OVR_221–225, 230, 231, 232, 233 verified; OVR_226–229 (DrawLevelOvr) SKIPPED per user.** Next scope: main EXE base functions (game systems — BOTS/CAM/COLL/INSTANCE/PROC/Vehicle/HOWL/LOAD/MAIN/UI/etc., 0x8001xxxx–0x8008xxxx) if continuing. + +## MAIN EXE — BOTS (game/BOTS.c, AI drivers) — IN PROGRESS + +First main-EXE code starts @0x800123e0 (BOTS_*). Verifying in address order. + +| Function | Address | Verdict | +|---|---|---| +| BOTS_SetGlobalNavData | 0x800123e0 | Match — load nav globals for path index: lastPathIndex=index, nav_NumPointsOnPath=NavPath_ptrHeader[index]->numPoints(@2), nav_ptrFirstPoint=NavPath_ptrNavFrameArray[index], nav_ptrLastPoint=&firstPoint[numPoints] (NavFrame stride 0x14). | +| BOTS_InitNavPath | 0x80012440 | Match — load nav path index from level1->LevNavTable: nh=LevNavTable?[index]:0; nh!=0 → ptrHeader=nh, FrameArray=NAVHEADER_GETFRAME(+0x4c), if magicNumber!=-0x1303 numPoints=0 / else → blank_NavHeader template (decomp 0x8008dacc = &blank+0x4c), numPoints=0; then nav_NumPoints/ptrLast(&frame[numPoints])/header->last/ptrFirst. (branch order swapped vs decomp, equiv). | +| BOTS_Adv_NumTimesLostEvent | 0x80012568 | Match — return data.advDifficulty[min((u16)numLost, BOTS_ADV_MAX_LOSS_DIFFICULTY_INDEX=10)] (s16, decomp `(numLost<<0x10)>>0xf` = *2 byte offset). | +| BOTS_EmptyFunc | 0x80012560 | Match — empty (jr ra). | + +| BOTS_Adv_AdjustDifficulty | 0x80012598 | Match — compute AI difficulty + set bot grid (~0xddc b, race start). Difficulty scalar per mode: ARCADE (arcadeDifficulty, CHEAT_SUPERHARD→0x140), CUP (track*5-(lost∓0xe1/0xcd), cupID4 purple/regular, CHEAT_ADV *7/-0x141/-300), BOSS (bossID*5-(lost-0xe1), CHEAT *7/-0x141), ADV LEVEL ((numTrophies+1)*0x23[+3neg]>>2 - (lost-0x3c), CHEAT *0xc/-100). **timesLost arrays ASM+addr verified: RacePerLev@g_abAdvProgress+CREDITS_N_GIN(0x30, was mis-derived as 0x33), CupRace+0x42, BossRace+0x47 — all consistent w/ AdvProgress @0x8FBD4/E6/EB.** Stat select: gameMode1<0(ADVENTURE_BOSS)→BossDifficulty else ArcadeDifficulty; Easy=params1(difficultyParams[1]), Hard=params2([0]) (**decomp short* arith `*0x1c`=0x1c shorts=0x38B stride, `+0xe`=0xe shorts=0x1c B → params1[14]@0/params2[14]@0x1c**). Lerp 14 params (BOTS_Adv_LerpDifficulty: easy+factor*(hard-easy)/0xf0) → arcade_difficultyParams by clamped currDifficulty; if CUP\|CUP_ANY_KIND → cup_difficultyParams by cupDifficulty(+0x50). aiCollisionDelayFrameCount=0; seed botRng(0x30215400/0x493583fe if 0); clear 3 nav lists + InitNavPath(unless CUTSCENE\|MAIN_MENU) + SetGlobalNavData(0); numBotsNextGame=0; kart spawn order by mode (VS2P/3P4P/boss/crystal/TT/purple/arcade via CopySpawnOrder); nav pathIndexIDs + accel order via g_mainRNG shuffle + cup-points swap. Helpers BOTS_Adv_LerpDifficulty/CopySpawnOrder extract decomp inline. | + +| BOTS_UpdateGlobals | 0x80013374 | Match — per-frame AI bookkeeping. If numBotsNextGame → EngineSound_NearestAIs; scan driversInRaceOrder[7..0]: robot(ACTION_BOT)→bestRobotRank=d, first robot→worstRobotDriver; else bestHumanRank=d; fallback bestHumanRank=worstRobot; aiCollisionDelayFrameCount++ (decomp increments pre-loop, project post — benign, loop doesn't touch it). | +| BOTS_SetRotation | 0x80013444 | **FIX 129** — aim bot at nav frame. velocity=0; estimatePos=posCurr>>8; d{x,y,z}=nf->pos - estimatePos; distToNextNavXZ=sqrt(dx²+dz²), XYZ=sqrt(+dy²); estimateRotCurrY=ratan2(dy<<0xc, XZ<<0xc)>>4; navProgressRemainder=0; !useSpawnYaw → estimateRotNav[0]=nf->rot[0], [1]=(ratan2(-dx,-dz)+0x800)>>4, **[2]=nf->rot[2] (was rot[1] — BUG, fixed)** / else [1]=(DriverSpawn[0].rot[1]+0x400)>>4; v=estimateRotNav[1]<<4 → ai_rotY_608/angle/rotCurr.y/rotPrev.y/ai_rot4[1]; botFlags\|=1. | +| BOTS_ThTick_RevEngine | 0x8001372c | Match — mask-grab descend tick. positionBackup.y < posCurr.y → posCurr.y -= elapsedTimeMS<<9>>5 (*0x10), drag maskObj pos=posCurr>>8, TranslateMatrix + VehFrameProc_Driving + VehEmitter_DriverMain (decomp SpawnParticle_DriverMain; native drops passthrough args) / else release mask (scale=0x1000/duration=0/rot.z&=~1), maskObj=0, kartState=KS_ENGINE_REVVING, clockReceive/squishTimer=0, ThTick_SetAndExec(BOTS_ThTick_Drive). | + +| BOTS_LevInstColl | 0x800135d8 | Match — test bot kart vs level-instance hitboxes. sps setup ptr_mesh_info, searchFlags=COLL_SEARCH_TEST_INSTANCES, modelID=DYNAMIC_ROBOT_CAR(0x3f), hitRadius=0x19; cur/prev pos>>8 (Y+0x19); COLL_FIXED_BotsSearch; if boolDidTouchHitbox (decomp countInstancesCollided — native flag, same as AH_Sign) && bspHitbox->flag&BSP_HITBOX_COLLIDABLE(0x80) → inst=bspHitbox->data.hitbox.instDef->ptrInstance (decomp garbled leaf.ptrQuadBlockArray/bbox.min as 32-bit); if inst → mdm=COLL_LevModelMeta(instDef->modelID); mdm->LInC(inst, thread, sps). | +| BOTS_MaskGrab | 0x80013838 | Match — AI mask-grab recovery. nav wrap (header->last(@8) <= frame+1(stride0x14) → NavFrameArray[botPath]); kartState=KS_MASK_GRABBED; navProgressRemainder=(**distToNextNavXZ@0xC** — decomp "nSegLenAir" mislabel, asm 0x80013898 `lhu 0xc(a2)`)/2<<8; reposition to nav-frame midpoints (posBackup/posPrev/quadBlockHeight <<8); zero aiPhysics (speed/velocity/mulDrift/squishCooldown/reserved); actionsFlagSet\|=ACTION_TOUCH_GROUND; botFlags&=~0x4f (ESTIMATE_NAV\|DAMAGE_ACTIVE\|SUPPRESS_EMITTER\|FREE_PHYSICS\|BOSS_PATH_REQUESTED); rotCurr=frame->rot[0/1/2]<<4; zero turbo/reserves/timers/matrix; actionsFlagSet&=~0x80040(AIRBORNE\|HIGH_JUMP); if !DYNAMIC_GHOST flags&=~THREAD_FLAG_DISABLE_COLLISION(0x1000); posCurr=posBackup (Y+BOTS_MASK_DROP_HEIGHT 0x10000); mask=VehPickupItem_MaskUseWeapon(bot,1), duration=BOTS_MASK_DURATION(0x1e00), rot.z\|=1; ThTick_SetAndExec(BOTS_ThTick_RevEngine). | + +| BOTS_Killplane | 0x80013a70 | Match — AI killplane recovery: rewind botNavFrame to safe point. Tiny Arena (strcmp levelName "asphalt2") override: checkpoint.currentIndex(@0x495) 0x94→0x84/0xa0→0x80, rewind while goBackCount outside [override-1, override+1] (wrap frame < NavFrameArray[botPath@0x5b8] → header->last[-1]); else normal rewind while goBackCount==currentIndex \|\| flags(@0xE)&0x4000; then BOTS_MaskGrab. NavFrame goBackCount + flags@0xE, botNavFrame@0x5a4. | +| BOTS_ThTick_Drive | 0x80013c18 | 🔄 IN PROGRESS (direct, in sections — subagent unreliable). Main AI driving tick (largest fn in project, BOTS.c:778-2673 / body 0x80013c18-0x80016aff / decomp 1413 lines). **§1 PROLOGUE Match (2026-07-06, BOTS.c:778-944 ↔ decomp L65-189):** turbo_MeterRoomLeft/forwardDir=0, flags&=~(SPLIT_LINE\|REFLECTIVE), weaponCooldown dec (×1, ×2 if !pendingDamage & !RACE_FINISHED), else BOTS_ChangeState(damageType,attacker,reason); 5 timers (reserves/turbo_outsideTimer/squishTimer/burnTimer/clockReceive) dec by elapsedMS + clamp≥0; driftTarget=0/kartState=NORMAL; **actionsFlagSet&=~(ACTION_DROPPING_MINE 0x80000000\|JUMP_STARTED 0x400\|STARTED_TOUCH_GROUND 0x2)=0x7ffffbfd asm-exact**; tire color (speedApprox abs, +0xf00 airborne / (<<1>>1) grounded; step=(spd·0x89+cycleStep·0x177)·8>>0xc; ACCEL_PREVENTION 0x8 gate; baseSpeed/speedApprox ≤0x200 skip; timer<1 & !(tireColor&1)→0x1e00/DARK 0x2e606061 else DEFAULT 0x2e808080); nav frame (BOT_FLAG_ESTIMATE_NAV 1: estimateNavFrame else botNavFrame+1 wrap @header->last); driver coll search (pos>>8 srl-vs-sra benign, guard flags&0x1800 DEAD\|DISABLE_COLL, PLAYER→sibling+ROBOT bucket / ROBOT_CAR→sibling; hit → combinedRadius² vs bestDistSq → DriverCrash_AnyTwoCars w/ selfVel=speed+accel). **§2 RACE-START/PATH-CHANGE/AIRBORNE Match (2026-07-06, BOTS.c:945-1140 ↔ decomp L190-300):** ai_progress_cooldown==0 gate; trafficLightsTimer (OXIDE −0x1e0 cheat) >0 → hold; demo camera (boolDemoMode & !DEMO_CAMERA_STARTED 0x100 & PLAYER → CAM_EndOfRace); race-start (!STARTLINE_INIT_DONE 0x200 → OXIDE Voiceline(0,0xf,0x10); front-row<3 & RNG&0xff<0x40 or OXIDE → VehFire_Increment(0x2d0,FREEZE_RESERVES_ON_TURBO_PAD=1,0x180)+Audio(0x180)); **RNG "divergence" RESOLVED — syms926:1062 `8006c684 RngDeadCoed` (Ghidra "RNG_Random" misname), seed a0=0x8008d668=`sdata->const_0x30215400` (regionsEXE.h:3592-3595 "used for RNG", asm-confirmed) — project CORRECT.** Boss path change (botFlags&0xc0==0x40 REQUESTED; opcode cap 0xc00 & newPathID==desiredPath_BossOnly → LIST move+SetRotation); catch-up path change (aiCollisionDelayFrameCount>0x1c2 & !ESTIMATE_NAV → find closest bot ahead ≤3 navframes, distToFinish diff<0x200, opcode>10 (SHIFT 10), FrameIndex=op&0x3ff, Cap=NAV_PATH_COUNT 3<<10=0xc00.** Airborne velocity normalize (speedLinear²+speedY²>0x2b110000 → ·0x6900/SquareRoot0; retail div-by-0/overflow traps benignly #if0'd). **§3 GROUNDED RUBBER-BAND Match (BOTS.c:1141-~1343 ↔ decomp L301-455):** clear ACTION_AIRBORNE 0x80000; bestHumanRank (RELIC_RACE→bestRobotRank); null/self → constVal_9000 else catch-up: driverRank accel-order adjust (accelerateOrder[0/1]<2 dec), difficultyStat by best's lap (arcade_difficultyParams[0xB]/[0xD]/[0xC]), complexDifficultyStat = ((distToFinish<<3 − best.distToFinish)+lapIdx·d) − (bot side) − (params[driverRank]+difficultyStat) [ACTION_BEHIND_START_LINE 0x1000000 lap adjust], otherDifficultyStat (adv-cup vs arcade last-lap: cup/arcade params[8]+[0xA]), botVelocity lerp (·((|complex|+0x80)<<12/0xa00)>>12, +((v<<3)/100)·(7−rank), +wumpa(≤9)/turbo(≤5)·netSpeedStat, clamp [0x1c20,24000]); reserves==0→clear NAV_BOOST_ACTIVE 0x10 else +fireSpeedCap (boost→+10000); clock/squish→·0xc00>>12; damage (BOT_FLAG_DAMAGE_ACTIVE 0x2: !set & (instTntRecv\|thCloud)→−DamagedSpeed>>1; set→turboMeter=0, OXIDE −DamagedSpeed>>2 else −DamagedSpeed). All flags _Static_assert'd + asm-confirmed (andi 0x100/0x200/0xc0). **§4 TERRAIN/GRAVITY/NAV-SETUP Match (2026-07-06, BOTS.c:1344-1543 ↔ decomp L456-599):** damage penalty tail (−const_DamagedSpeed, clamp≥0); terrain friction speedLinear−=(PedalFriction·botFrictionScale>>8) clamp≥0; velocityAccountingForTerrain=botVelocity(≤0x6900)·botTargetSpeedScale>>8; **DECEL_TO_TARGET_SPEED 0x80 branch** (clear→accel-if-below: accel=reserves<1?Accel_ClassStat:Accel_Reserves, ·botAccelerationScale>>8, botAccel-- → ·(0x100−AI_AccelFrameSteps·accelerateOrder[id])>>8; set→halve toward target); **level throttle (HOT_AIR_SKYWAY/PAPU_PYRAMID/POLAR_PASS: botPathIndex=botPath+{POLAR 3,PAPU 6,SLIDE 9} — decomp byte-offset botPath·2+{6,0xc,0x12} ≡ project element index; navFrameIdx in [botsThrottle[idx], +0xb) & speed>9000 → turboMeter=0, −(100+Accel_ClassStat))**; slope bonus (rot[3]>0x80 → +const_SlopeForwardSpeedBonus, MATH_Sin(rot[3]<<4), −Gravity·sin>>0xc, fireSpeed=vel); clamp 0x6400; deltaPos=speedLinear·elapsed>>5 (clamp≥0)+navProgressRemainder; **hold branch (scale=0xccc, speedLinear=0, cooldown--)**; navFrameFlags/SpecialBits |= curr; MOON_GRAVITY (specialBits&0x80→BOT_FLAG_MOON_GRAVITY 0x20; gravity·41/100); speedY−=gravity·elapsed>>5 (clamp≥−0x5000); posBackup.y+=speedY·elapsed>>5; navDist=TOUCH_GROUND?distToNextNavXYZ:distToNextNavXZ; RAMP_PHYS 0x10 latch. **All Terrain offsets ASM-confirmed (0x80014ab0 `lh 0x3c`=botFrictionScale [Ghidra `nAccelImpact` misname], 0x38=botTargetSpeedScale, 0x36=botSpeedFlags; speedLinear@0x5d4, reserves@0x3e2, Accel_ClassStat@0x42a). All flags _Static_assert'd.** **§5 NAV-ADVANCE/FLAGS/TURBO-CHARGE — 1 FIX (Fix 131) + else Match (2026-07-06, BOTS.c:1541-1739 ↔ decomp L588-714):** nav-advance loop (while navDist≤deltaPos>>8: advance frame, deltaPos−=navDist<<8, wrap, Killplane if posBackup.y>>8>3); REFLECTIVE 0x20 → vertSplit=splitLines[INDEX_MASK 0xf?1:0]; alpha scale (specialBits&0x30==0 & !GHOST → ·100+idx·0x9c00>>8); turbo-charge tree (6 fireLevels, VehFire_Increment 0xf0/0x1e0/0x2d0 ·local_38<<7/<<8/·0x180); super-turbo (nav 0x100→VehFire 0x78/1/0x900), turbo (nav 1→0x2d0/1/0x180); nav commit (!ESTIMATE_NAV→botNavFrame=curr). **Fix 131 (BOTS.c:1655): the powerslide turbo-charge gate's 2nd condition tested `navActionFlags & (ACTION_BACK_SKID 0x800\|ACTION_FRONT_SKID 0x1000)` but retail asm 0x80015240 `lw t3,0x6c(sp); andi v0,t3,0x1800` tests the RAW navFrameFlags accumulator & DRIFT_MASK (BOTS_NAV_FLAG_DRIFT_LEFT 0x800\|DRIFT_RIGHT 0x1000). Numeric coincidence 0x1800==0x1800 compiled, but navActionFlags always has ACTION_FRONT_SKID when the 1st gate passes → drift-segment requirement bypassed, bots charged turbo on any front-skid segment vs only drift segments. Fixed to `navFrameFlags & BOTS_NAV_FLAG_DRIFT_MASK` (matching the project's own correct idiom at BOTS.c:2013). Build green.** All flags _Static_assert'd + asm-confirmed. **§6 NAV-LERP/FREE-PHYSICS/SURFACE-PROBE Match (2026-07-06, BOTS.c:1748-1913 ↔ decomp L715-864):** nav lerp fraction percentage=(iVar15<<0xc)/navDist (0 if navDist==0); posPrev/rotPrev=posCurr/rotCurr; positionBackup.{x,z}/quadBlockHeight = (navCurr.pos + (navNext.pos−navCurr.pos)·pct>>0xc)<<8; **FREE_PHYSICS 0x8 block** (accel.y=0, velocity+=accel, accel.x/z halved, velocity+=preAccel, per-axis X/Z clamp-toward-0 by ±0x444 w/ accel kick, clear FREE_PHYSICS if vel.x==vel.z==0 — scrambled decomp reg-order ≡ project net); checkpoint (!(ESTIMATE_NAV|FREE_PHYSICS)=&9 → checkpointIndex=goBackCount) else nav-surface probe: ai_rot4[1]=(navCurr.rot[1]<<4 + deltaRotY·pct>>0xc)&0xfff (deltaRotY wrap ±0x800); **COLL_SearchBSP_CallbackQUADBLK probe (top=posBackup+vel>>8 −0x100 Y / bottom +0x80 Y)** — **ASM 0x800157c4-e0 confirms exactly 4 stores: quadFlagsWanted@0x24=GROUND 0x1000, quadFlagsIgnored@0x28=NO_COLLISION_RESPONSE 0x10, searchFlags@0x22=HIGH_LOD 0x2 (sh), ptr_mesh_info@0x2c; decomp's `field6_0x16=0` is a GHIDRA ARTIFACT (no such store) — project's 4 fields correct**; hit → quadBlockHeight=hitPos.y<<8, checkpointIndex=quadblock.checkpointIndex@0x3e, VehPhysForce_RotAxisAngle(matrix, hit.plane.normal@sps+0x70, ai_rot4[1]), AxisAngle3_normalVec=normal, bitCompressed=INST_CompressNormalVectorAndDriverIndex (normal>>6&0xff per byte + (id+1)<<24), **killplane `quadFlags@0x12 & 0x200` (asm 0x80015894 `andi 0x200` — decomp garbled `MASK_GRAB`≡KILL_PLANE 0x200) → BOTS_Killplane**. All flags _Static_assert'd (FREE_PHYSICS 0x8). Bonus: 2nd COLL probe @0x80016a98 uses quadFlagsIgnored=0 (verify in later §). **§7 GROUND-LANDING/THUMP/DRIFT Match (2026-07-06, BOTS.c:1914-2123 ↔ decomp L868-1004):** deltaPos>>=8; **positionBackup.y>0x10)&1); speedY cap 16000; **drift: navFrameFlags&DRIFT_MASK 0x1800==0→driftTarget=0/NORMAL else DRIFTING + (DRIFT_LEFT 0x800?−0x2aa:0x2aa) [correct idiom, reconfirms Fix 131]**. DAMAGE_ACTIVE set → bounce (−speedY>>1 abs, blastBounceCount++; **aiDamageState SPIN 1→actionsFlagSet|=0x1800 (BACK\|FRONT_SKID); BLAST 2 & bounce>2→cooldown=10/speedLinear=0/botFlags&=~6 (DAMAGE_ACTIVE 0x2\|SUPPRESS_EMITTER 0x4) asm `li v1,-0x7`**), posBackup.y=quadBlockHeight. flags&KILLPLANE→BOTS_Killplane. Then jump_LandingBoost=0, |=TOUCH_GROUND. **Airborne (else)**: latch nav progress (distToNextNavXZ/XYZ swap), &=~TOUCH_GROUND \|=AIRBORNE 0x80000, jump_LandingBoost+=elapsed. All asm-confirmed (aiDamageState@0x5ba, blastBounceCount@0x626, cooldown@0x604). All flags/states _Static_assert'd or asm-verified. **§8 DRIFT-MIRROR/DAMAGE-REACTION/FORCED-JUMP/RAMP Match (2026-07-06, BOTS.c:2125-2324 ↔ decomp L1005-1154):** drift-mirror lerp (mulDrift toward driftTarget by ±0x18 [0x60 if driftTarget≠0], clamp at target); multDrift=mulDrift; **DAMAGE_ACTIVE 0x2 reaction switch on aiDamageState (=Ghidra aiReactionState): BLAST 2→rotXZ(=nCrashTimer@aiPhys+0)−−; SPIN 1→squishCooldown−=0xc/mulDrift+=old/if<0x100 zero+speedLinear=0+clear botFlags~6, kartState=KS_SPINNING 3(=DRIFTING 2\|CRASHING 1); SQUISH 3→same squish drain (if<0x200 zero)+rotXZ−=elapsed/if≤0 speedY=0x1400+clear~6+ACTION_JUMP_STARTED 0x400; MASK_GRAB 5→plant-cam positioning (rotXZ<0xb40: v={±0xfa by Plant.LeftOrRight,0,0x2ee}, SetRot/SetTransMatrix(plantInst), RotTrans→pushBuffer[id].pos/rot via ratan2/SquareRoot0, rot.x=0x800−pitch), speedLinear=0, rotXZ−=elapsed/if<1 clear~6+show model+cooldown=1+BOTS_MaskGrab, kartState=KS_MASK_GRABBED 5.** The convoluted decomp `vx=-0xfa` short-circuit ≡ project `object!=NULL && LeftOrRight!=0`. Forced jump (forcedJumpType!=NONE 0 → force=JumpForce·3 [/2 unless HIGH 2], speedY=max, reset NONE); ramp phys (navFrameSpecialBits&RAMP_PHYS 0x10 & (speedLinear>0x1c1f\|OXIDE) → speedY=rampPhys2[local_3c&0xf], speedLinear=rampPhys1[idx], OXIDE→zero squish/mulDrift+clear~6); rot[0]/[2] lerp start (rot[3]−0x31>0x9e both & !ESTIMATE_NAV & TOUCH_GROUND → ai_rot4[0]=curr.rot[0]<<4+deltaRot·pct>>0xc &0xfff, wrap ±0x800). All enums confirmed (DAMAGE_STATE 1/2/3/5, KS 3/5, FORCED_JUMP 0/1/2, _Static_assert'd). Field map: nCrashTimer=rotXZ, nSquishCooldown=squishCooldown, plantEatingMe, tileView=pushBuffer, forcedJump_trampoline=forcedJumpType. **§9 TURN-ANGLE/MATRIX/SPEED-DERIVE Match (2026-07-06, BOTS.c:2325-2533 ↔ decomp L1155-1314):** rot[1]/[2] nav-lerp finish (curr.rot<<4 + delta·pct>>0xc, wrap ±0x800); turn-angle logic (ESTIMATE_NAV→0; delta·2−turnAngleCurr &0xfff, clamp step [−0x20,0x20], turnAngleCurr+=; driftTarget==0 → simpTurnState=wrap(delta), >>2 else simpTurnState=−mulDrift wrap, >>3; wheelRotation toward simpTurnState step [−0x20,0x20]; simpTurnState byte=(s8)aiPhys.simpTurnState); rotCurr=ai_rot4[0..2]; **bad-effect kart-wiggle (clockReceive\|squishTimer\|thCloud→timer<<2: wiggle={sin·50>>10, 0, cos·50>>10} of badnessTimer·0xc, gte_ldv0+mvmva(1,0,0,3,0) rotate, turboMeter=0, rotCurr.xyz += MFC2(25/26/27)=MAC1/2/3)**; !(ESTIMATE_NAV\|FREE_PHYSICS)=&9 → ConvertRotToMatrix(navCurr.rot<<4)→AxisAngle3_normalVec=m col1 + bitCompressed; ConvertRotToMatrix(instMatrix, rotCurr)→AxisAngle2_normalVec=m col1; angle=rotCurr.y; **speedApprox/speed=speedLinear (both s16 fields → truncate = decomp (short)nSpeedLinear ✓)**; jumpHeightCurr=speedY·MATH_Cos(rot[3])>>0xc; zSpeed=speedLinear·Cos(axisRotationX)>>0xc; xSpeed=speedLinear·Sin(axisRotationX)>>0xc; ySpeed=speedY; rotCurr.z wrap±0x800; rotCurr.y+=mulDrift+turnAngleCurr; posCurr=velocity+positionBackup; matrix.t=posCurr>>8 (t[1]+Screen_OffsetY s8); hazard timer (clockReceive\|squishTimer==0 & speedApprox>0x100 → hazardTimer−=elapsed, &=0xfffe). Field types confirmed s16 (speed/speedApprox/axisRotationX/hazardTimer). **§10 FINAL — HAZARD-WOBBLE/EMITTER/RENDER TAIL Match (2026-07-06, BOTS.c:2534-2678 ↔ decomp L1315-1410):** hazard-timer wobble sign logic ((s16)(hazardTimer&0xfffe)≥0→−2; TOUCH_GROUND & abs(speedApprox)<0x101 → keep sign if >0 else set from badnessTimer; force odd `|1`); BOTS_LevInstColl if navFrameSpecialBits&LEVEL_INST_COLL 0x40 \|\| botFlags&9; navFrameFlags&SPLIT_LINE 0x8000→flags|=SPLIT_LINE; VehPhysForce_TranslateMatrix; RotAxisAngle(matrixMovingDir, AxisAngle2_normalVec, angle); VehFrameProc_Driving; **BLAST flip (DAMAGE_ACTIVE 0x2 & aiDamageState==BLAST 2 → rot={rotXZ<<8, rotXZ·0xe0, 0}, ConvertRotToMatrix(sdata.rotXZ), MATH_MatrixMul ×2 w/ identity, matrix.t[1]+=0x20)**; emitter guard (!(botFlags&SUPPRESS_EMITTER 0x4)→VehEmitter_DriverMain [=decomp SpawnParticle_DriverMain, native drops passthrough args]); PLAYER→EngineSound_Player; cam-yaw smooth (camRot=(angle−ai_rotY_608)&0xfff, rotCurr.w=−camRot, wrap|0xf000, ai_rotY_608+=camRot>>3 &0xfff); PLAYER underDriver probe (2nd COLL_SearchBSP_CallbackQUADBLK, probeBottom.y +0x40, **quadFlagsIgnored=0 — asm 0x80016a98 `sw zero→0x28` confirms; field6_0x16 again Ghidra artifact**, boolDidTouchQuadblock→underDriver=hit.ptrQuadblock). All flags asm/enum verified. **✅ BOTS_ThTick_Drive FULLY VERIFIED (10/10 sections, BOTS.c:778-2678 ↔ decomp 1413 lines). 1 FIX total (Fix 131, §5 powerslide drift gate); §1-4/6-10 clean Match.** | +| BOTS_ChangeState | 0x80016b00 | **FIX 130** — apply hit reaction + credit attacker. Cases 0–5 (none/spinout/blast/squish[+0x5bc nCrashTimer aka aiPhysics.rotXZ=0xf00]/spinout_burn/maskgrab) all Match asm (aiDriveState base 0x5bc: nCrashTimer@0/nTurboMeter@8/nSquishCooldown@0xc/nUnk_0x10@0x10/nSpeedY@0x14/nSpeedLinear@0x18; SPINOUT >>1 Oxide/>>2 else; BLAST >>3+posBackup.y+0x4000+VOICE_HURT; SQUISH >>1; flags 0x2/0x6). **BUG: attacker-credit switch used `damageType` (a1/param2, the physics reaction) but asm 0x80016e38 switches on a3/param4 `reason` (weapon): s3==3→missile(+0x557), ==1→bomb(+0x55a), ==4→potion(+0x556); the `!=0` gate @0x80016e20 does read a1.** Fixed switch→`reason`. | +| BOTS_CollideWithOtherAI | 0x80016ec8 | Match — resolve 2-bot collision. Swap so robot_1=(rank_1last)} : {start=estimatePosition, end=botNavFrame}; proj both karts' posCurr>>8 via CAM_MapRange_PosPoints(end,start,pos) [note: decomp `(uint)>>8` srl vs native CTR_MipsSra — benign, `(s16)` truncation keeps bits[23:8] identical]; res1>8)&0xff)+300 [decomp `>>8` srl vs native CTR_MipsSra — benign, `&0xff` keeps bits[15:8] identical]. | + +| BOTS_Driver_Init | 0x80017164 | Match — spawn AI racer. Path-find loop (decomp decrements iPathWalk mid-body w/ `<<16` sign test; project `navPathIndex-- / <0→2 / ==initial→NULL` structurally equiv — tests initial,initial−1…wrap, chosen on numPoints>1, NULL if full circle). PROC_BirthWithObject(0x62c0101=size 0x62c/LARGE/ROBOT, BOTS_ThTick_Drive; name "robotcar" → project passes 0, benign); memset 0x62c; VehBirth_NonGhost; gGT->drivers[id]; modelIndex=DYNAMIC_ROBOT_CAR(0x3f); botPath/botNavFrame=NavFrameArray[path]; actionsFlagSet\|=ACTION_BOT; LIST_AddFront(navBotList[path], &botData node); numBotsNextGame++; BOTS_GotoStartingLine. | +| BOTS_Driver_Convert | 0x80017318 | Match — human→AI mid-race. No-op if ACTION_BOT; UI_RaceEnd_GetDriverClock; same path-find loop; memset(&botData, 0, 0x94=sizeof BotData); speedY=ySpeed; botPath=path; speedLinear=abs(speedApprox); zero navProgress/turnAngleCurr/multDrift/ampTurnState/wallRubTimer(set_0xF0_OnWallRub); botNavFrame=NavFrameArray[path]; thread->funcThTick=BOTS_ThTick_Drive; BATTLE_MODE→posCurr=NAVHEADER_GETFRAME(header)->pos[0..2]<<8; LIST_AddFront; BOTS_SetRotation(d,0); GAMEPAD_JogCon2(d,0,0); actionsFlagSet=(old & ~(JUMP_BUTTON_HELD\|ACCEL_PREVENTION)=`&0xfffffff3`)\|ACTION_BOT; RACE_FINISHED→CAM_EndOfRace(cameraDC[id],d); kartState KS_SPINNING(DRIFTING\|CRASHING)→1 / KS_BLASTED→2 / default return, then BOTS_ChangeState(d,dt,NULL,0) (attacker NULL → Fix130 credit block skipped). | + +**✅ BOTS.c FULLY VERIFIED (17/17 functions, 2026-07-06).** All BOTS_* funcs verified including **BOTS_ThTick_Drive @0x80013c18** (the project's largest fn, ~1900 lines, verified in 10 sections directly over multiple loop iterations — subagent had failed on it). Fixes in BOTS.c: **129** (SetRotation nav-roll byte @rot[2]), **130** (ChangeState weapon-credit switches on `reason` not `damageType`), **131** (ThTick_Drive §5 powerslide turbo-charge gate tested `navActionFlags & (BACK|FRONT_SKID 0x1800)` instead of raw `navFrameFlags & DRIFT_MASK 0x1800` — numeric-coincidence bug, drift-segment requirement bypassed). Next section: **CAM** (game/CAM.c). + +## MAIN EXE — CAM (game/CAM.c, cameras) — IN PROGRESS + +| Function | Address | Verdict | +|---|---|---| +| CAM_SkyboxGlow | 0x800175cc | Match — skybox horizon-glow gradient renderer, cleanly **reimplemented** in native w/ helpers (CalcCenterY/CalcTilt/FixedRatio/ScreenX/Div2TowardZero/LerpColor/PackXY/EmitG3/G4/F3/F4/HasClearGradient). Core geometry ASM-checked: **CalcCenterY** `(((rot.x−0x800)*0x78, +0x3ff if neg)>>10) + Div2TowardZero((s16)rect.h)` ≡ decomp `(iBand>>10)+((cos>>16)−(cos>>31))>>1`; **CalcTilt** `Div2TowardZero(−((((sin<<12)/cos)<<8)>>12))` ≡ decomp `−(((sin<<12)/cos<<8)>>12)/2` [native does `<<8` in u32 to dodge signed-overflow UB, bit-identical]; clip outcode bits 1/2/4/8 = toLeft/toRight/fromLeft/fromRight≥0. Cases 1/5/15 (representative G3 / G4+F3 / G4+F4) verified prim-by-prim: colors, PackXY(x,y), GPU codes 0x30/0x38/0x20/0x28, OT tags 0x6/0x8/0x4/0x5 M all match. Gradient struct stride 6 shorts (pointFrom@0/pointTo@2/colorFrom@4/colorTo@8). Native emits via CtrGpu (own GPU abstraction) not raw scratchpad — geometry/color faithful. | +| CAM_ClearScreen | 0x8001861c | Match — split-screen bg-clear TILEs. Per viewport pushBuffer[0..numPlyr): split-line `iVar5=((rot.x−0x800)>>3)+(h>>1)` (decomp identical, plain `>>3` no round-adj here; `h>>1` vs decomp round-toward-zero benign as rect.h≥0), clamp [0,h] → iVar7; top band (clearColor[0], if enable && iVar7>0): x=rect.x/y=rect.y+swap*0x128/w=rect.w/h=iVar7; bottom band (clearColor[1], if iVar7inst=cDC (instcDC.cDC union); memset(cDC, 0, 0xdc); cameraID; driverToFollow=d; pushBuffer=pb (decomp `tileView`@0x48, same param); flags\|=8 (CAMERA_FLAG_DIRECTION_CHANGED — binary sets it though decomp *ref* had it commented out; native correct). Native omits redundant cameraMode=0 (memset already zeroed) — benign. | + +| CAM_FindClosestQuadblock | 0x800188a8 | Match — find track quadblock under a query point via collision. pos read as **low halfword** of each VECTOR lane (`(s16)pos->vx` ≡ decomp short-reads @byte0/4/8); QuadBlockColl.pos.y+=0x100, Input1.pos.y=posY−0x800, hitPos mirrors Input1; bbox = per-axis min/max of the two Y-biased points (X/Z collapse to posX/posZ, Y=[posY−0x800, posY+0x100]); quadFlagsWanted=0x800 (QUADBLOCK_FLAG_CAMERA_SEARCH); two-phase search (cached cDC->ptrQuadBlock via COLL_FIXED_QUADBLK_TestTriangles, then bspRoot via COLL_SearchBSP_CallbackPARAM+COLL_FIXED_BSPLEAF_TestQuadblocks); hit→cDC->ptrQuadBlock + unk1cac[0]=quad−ptrQuadBlockArray (decomp magic `*-0x1642c859>>2` = /sizeof(QuadBlock)); miss→unk1cac[0]=−1. **Native `boolDidTouchQuadblock` hit-flag in place of decomp `countTrianglesTouched` — documented benign COLL-reimpl adaptation.** | +| CAM_Path_GetNumPoints | 0x80018b18 | Match — sum intro/fly-in cam-path point counts. level1 NULL / ptrSpawnType1->count<3 / ST1_CAMERA_PATH(=ptrSpawnType1[4].count) NULL → 0; else walk packed segments (introCam[0]=count; sum; advance introCam += count*6+2) until count==0; return **(s16)** sum (sign-extend from u16). | +| CAM_Path_Move | 0x80018ba0 | Match — look up cam-path node for a frame. frame=(s16)idx; if frame<0 \|\| frame>=GetNumPoints() → 0; walk segment headers {count,pathID} while count<=frame (project `while` ≡ decomp `if`+`do-while`), subtract count, advance move += count*6+2; then move += frame*6; out pathID/pos[0..2]; **rot[0]=((s16)move[3]>>4)+0x800 & 0xfff (arithmetic shift), rot[1/2]=(u16)move[4/5]>>4 (logical shift)** — sign distinction matches decomp exactly; return 1. | +| CAM_StartOfRace | 0x80018d20 | Match — start-of-race fly-in setup. cnt_restart_points>2 → trackPathProgress/transitionBlend=0, transitionFrameCount=0x1e, nearOrFar=0, cameraMode=0, trackPathNode=ptr_restart_points+2 (=flyInData+0x18, sizeof CheckpointNode=0xC), transitionFrame=(numPlyr>1?1:0xa5); always cameraMode=0, flags&=~0x1004 (BATTLE_END_OF_RACE\|ARCADE_END_OF_RACE_ACTIVE). **Project follows the binary (setup gated on cnt>2, only transitionFrame depends on player count), NOT the flawed decomp *reference* (Ghidra plate flags it a rewrite that gates the whole block on numPlyr==1).** | +| CAM_EndOfRace_Battle | 0x80018d9c | Match — spin-360 orbit camera. transitionTo.pos={0xffe5, Spin360_heightOffset_cameraPos[numPlyr], 0xc0}; flags\|=0x4 (BATTLE_END_OF_RACE); transitionBlend=0, transitionFrame=60, transitionFrameCount=60; spin360Angle=ratan2(pushBuffer.pos.x−(posCurr.x>>8), pos.z−(posCurr.z>>8)) (arithmetic >>8 per decomp; (short) truncation). | +| CAM_EndOfRace | 0x80018e38 | Match (active `BUILD>SepReview` branch = retail 926) — !BATTLE_MODE && ptrSpawnType1->count>1 && numPlyr<3 → flags\|=0x20 (ARCADE_END_OF_RACE_REQUESTED); else CAM_EndOfRace_Battle. Project's `#else` SepReview variant correctly inactive for 926. | + +| CAM_StartLine_FlyIn_FixY | 0x80018ec0 | Match — snap a fly-in cam-path point's Y to the track surface. Prime collision query: ptr_mesh_info=level1->ptr_mesh_info, quadFlagsWanted=0x3000 (GROUND 0x1000\|COLLISION_SURFACE 0x2000, asm `sw 0x3000,0x24`), quadFlagsIgnored=0 (`sw 0,0x28`), **searchFlags=COLL_SEARCH_HIGH_LOD=0x2 (asm `sh 0x2,0x22`) — Ghidra decomp *names* this 0x2 `LOW_LOD_QUAD`; conflicting enum label, identical value, NOT a bug**. Ray-march 8 steps: probeOffset=i*0x400; box Y = [pos.y−(probeOffset+0x400) .. pos.y−(probeOffset−0x100)], X/Z=pos; COLL_SearchBSP_CallbackQUADBLK; first hit → posRot[1]=hitPos.y. Native uses shared `sdata->scratchpadStruct` where decomp used dedicated global `g_collQueryShadow`@0x8008db1c — benign transient-scratch adaptation; `boolDidTouchQuadblock` hit-flag = decomp `countTrianglesTouched`. | +| CAM_ProcessTransition | 0x80018fec | Match — lerp camera pose (start→end) by `frame` (0..0x1000, >>0xc). Pos: `start[i] + (s16)((end[i]−start[i])*frame >> 12)`. Rot (shortest angular path): delta=(end[i]−start[i])&0xfff, if >0x7ff → −0x1000, then `(start[i] + (s16)(delta*frame>>12)) & 0xfff` (+ binds before & per C precedence, matches decomp). `CAM_MulLo(a,b)=(s32)(u32)((s64)a*b)` = MIPS mult/mflo low-32 = plain int*int. | + +| CAM_FollowDriver_AngleAxis | 0x80019128 | Match — surface-aligned chase cam (mode 8 normal / 0xe wheelie). Native reimpl w/ helpers (Lerp256/LoadGteMatrix/TransformOffset). RotAxisAngle(axisMatrix@wb+0x220, AxisAngle2_normalVec, mode0xe?d->angle:d->rotCurr.y); GTE rot+trans(instSelf.matrix.t) → eye=RT(driverOffset_CamEyePos)@wb+0x240, lookAt=RT(driverOffset_CamLookAtPos)@stack. **Flag 8 (CAMERA_FLAG_DIRECTION_CHANGED) SET → snap lookAtPos=lookAt; CLEAR → lerp eye toward prev pushBufferPos & lookAtPos toward prev, Lerp256=((0x100−w)*cur + w*prev)>>8, w=angleAxisLerpRatio (decomp nCamLerpWeight)** [decomp pCamRot ≡ project lookAtPos]. rot[1]=ratan2(dx,dz); rot[0]=0x800−ratan2(dy, sqrt(dx²+dz²)) [sqrt uses dx/dz not dy]; rot[2]=0; pushBufferPos=(s16)eye. Native GTE (ldv0/rtv0tr/stlvnl) vs decomp (ldVXY0/ldVZ0/rt/stMAC) — same world transform. | +| CAM_StartLine_FlyIn | 0x800194c8 | Match — start-line fly-in pose for anim frame. frameIndex=(frame<<16)>>4; frameRatio=frameIndex/maxFrames; count=max(frameCount2,frameCount1); pathIndex=(s16)(count*frameRatio>>0xc). POS path (ptrEnd): pathIndex>0xc; look-at vy −0x60. worldMtx: ConvertRotToMatrix(DriverSpawn[0].rot, Y+0x400), t=midpoint(DriverSpawn[1],[2] pos)+0x40 Y, DriverSpawn[1/2/5] FixY'd. RotTrans interpPos→outPos, interpTarget→delta; outRot[1]=ratan2(dx,dz), [0]=0x800−ratan2(dy,sqrt(dx²+dz²)), [2]=0. | + +| CAM_FollowDriver_TrackPath | 0x800198f8 | Match — 'rail' cam along restart-point (CheckpointNode) graph. Native factors decomp's two inlined fwd/bwd walks into GetNode+Length helpers + one `while(pathProgress>=segmentLength)` loop (≡ decomp's comma-loop + `iDist+=iSegLen` restore). Dir: speed>0→bwd(nextIndex_backward/_right), ≤0→fwd(_forward/_left); alt-branch flag 0x100 (CAMERA_FLAG_TRACK_PATH_ALT_BRANCH), skip if idx==0xff. Pause-freeze: gameMode1&0xf → progress=0 (decomp PAUSE_1..4). pos = curr->pos + (dxyz*ratio>>12), Y+0x80; ratio=(progress<<12)/segLen. commit→writeback trackPathProgress/trackPathNode. Return heading: yaw=ratan2(dx,dz)+0x800 blended with next-seg yaw (via GetNode(next)) by ratio, delta&0xfff wrap, `(yaw + yawDelta*ratio>>12)&0xfff`. `(s16)ratan2` casts benign (fit s16, mod-0x1000). | +| CAM_LookAtPosition | 0x80019e7c | Match — aim transition cam at driver. dir = desiredPos − (positions>>8); dirY also − `Spin360_heightOffset_driverPos[numPlyr]` (@0x80080fe8, 1P=0x60/2P=0x40). **Project uses the driverPos table that the Ghidra decompiler MISLABELS as `cameraPos` — plate confirms disasm shows driverPos@0x80080fe8; cameraPos@0x80080fd4 is a separate table (EndOfRace_Battle). Project MORE correct than raw decomp.** rot[1]=ratan2(dirX,dirZ), rot[0]=0x800−ratan2(dirY, sqrt(dirX²+dirZ²)), rot[2]=0; dir stored @scratchpad+0x24c/0x250/0x254. | +| CAM_FollowDriver_Spin360 | 0x80019f58 | Match — orbit cam around driver (EOR/battle). transitionTo reused as spin params (missing union): pos.x=spin speed, pos.y=height, pos.z=radius. driverID even→spin360Angle+=pos.x, odd→−=pos.x; pos.x/z = (posCurr.x/z>>8) + (sin/cos(angle)·radius>>12), pos.y = heightOffset+(posCurr.y>>8); then CAM_LookAtPosition(&posCurr). Decomp per-term `(short)` truncation vs project add-then-`(s16)` = equal mod 2¹⁶ (benign); `(uint)>>8` srl vs CTR_MipsSra benign under (s16). | +| CAM_SetDesiredPosRot | 0x8001a054 | Match — begin explicit pos/rot transition. transitionTo.pos=*pos, .rot=*rot (SVec3 copies); frameCount=0x1e, frame=0, blend(nTransitionEase)=0x1000, flags\|=0x200 (CAMERA_FLAG_TRANSITION_AWAY). | + +| CAM_MapRange_PosPoints | 0x8001b254 | Match — scalar projection of (currPos−pos1) onto normalized dir=(pos1−pos2). MATH_VectorNormalize(dir); load dir into GTE rot-matrix cr0=(dir.x,dir.y) via CTC2(PackS16Pair,0)/cr1=dir.z via CTC2(z,1) (= decomp gte_ldR11R12/ldR13R21); V0=currDelta (gte_ldv0); mvmva(0,0,0,3,0); return MAC1(MFC2 reg25)>>12 = dot(dir,currDelta)>>12. | +| CAM_ThTick | 0x8001b334 | **Match** (huge ~0x1000 b, verified section-by-section). Setup (scratchpad=::scratchpad@0x1f800108, FROZEN=0x8000); view-toggle (L2/BTN_L2, gate: no pause0xf/start/menu/cutscene, funcThTick==0, !ACTION_BOT, !WARP_PAD/!FREEZE, !gameMode2&4; nViewToggleCounter 0↔1 → nearOrFar/cameraMode 0 or FIRSTPERSON 0xf; `==2` branch dead as decomp); ZoomData pick (NearCam4x3/8x3 by numPlyr, [nearOrFar*2]; decomp drops the extra args to callees). **EOR scan (flag 0x20=ARCADE_EOR_REQUESTED)**: ST1_CAMERA_EOR=ptrSpawnType1[3], match checkpoint.currentIndex(=pLapCheckpointState[1]) vs respawnPoint & its 4 restart-neighbors, entry stride via EndOfRace_Camera_Size[abs mode]; on new match: currEOR, cameraMode=abs(raw), flags clear 0x1000 set 0x9/(0x1009 if raw<0) then \|0x1000. **Mode-setup switch (0/3/4/7/8·e/9·d/10/11/12)** all field-for-field vs decomp (aTrackPathCamData ≡ eorModeData.pointPath/trackPathSpeed; heightSmoothing.startOffset ≡ unk_c0). **Dispatch**: 4→LookAtPosition; 10→Spin360; 0xc→MapRange+path-lerp; 7→topdown; 0xf/0x10→firstperson(+d->angle if 0x10); 8/0xe→(botFlags&2 BOT_REACTING? Normal : AngleAxis) + prev-frame flag toggle; 9/0xd→dual TrackPath demo blend (trackPathRot[1]=head+delta/2 — decomp *ref* had upstream zero-bug, binary+project correct); 0xb→LookAtPosition+dist clamp; else→Normal. Scratchpad offsets reconcile (project abs-from-0x1f800000 = decomp ::scratchpad+0x108). PVS chain (visLeaf/Face/Inst/O·SCVertSrc by configFlags&4), unk1cac[1]=quad index, quadBlockSearchHit=unk30fill[8], rainBuffer copy on RESET_RAIN_POS(0x1), final flags&=~0x88 (DIRECTION_CHANGED 0x8\|FIRE_SPEED_ZOOM 0x80). | + +| CAM_FollowDriver_Normal | 0x8001a0bc | **Match** (huge ~0x1198 b, verified section-by-section + ASM). Reverse-cam (hold R2/0x200 → flag 0x10000 unless AI/START_OF_RACE; direction-change → flag 8; reverse yaw += 0x800). cameraMoveSpeed smoothing toward abs(speedApprox) (snap on dir-change, else lerp via zoom->percentage1/2 @bytes 8/9). Follow distance = VehCalc_MapToRange(moveSpeed, speedMin[2],speedMax[3],distMin[0],distMax[1]) + fireSpeedZoom offset (timer/distanceOffset @unk_b8, driven by fireSpeedCap `((u16)cap<<16)>>20`). Two ConvertRotToMatrix passes (rotY = rotCurr.w+angle[+turnAngleCurr]+0x800+reverse & 0xfff; rotX=0/0xff9c by numPlyr; damage-pitch unk1A/damagePitchOffset ±8 clamp [-0x20,0]). GTE rot offset vectors → cam pos @scratchpad+0x240, anchored at posCurr>>8; MASK_GRAB→quadBlockHeight-based Y. **Ground/ceiling clamp** (LAB_8001a8b0/a8c0 goto graph faithfully reproduced; heightSmoothing≡{unk_c0/unk_c0+2/framesZoomingOut}, BlastedLerp≡data14@0xc8, boolLerpPending≡unk_c6). **CAM_FindClosestQuadblock → terrain-height skip: ASM `lh 0x3e(boolDidTouchQuadblock); andi quadFlags&0x4100; bne` — project `(boolDidTouchQuadblock==0)\|\|(quadFlags & 0x4100 [_Static_assert'd])!=0` EXACTLY matches (Ghidra garbled it as `&(NO_COLLISION\|KICKERS_TWO)!=~bigmask`); terrain_type@quad+0x38 (mud 0xe/water 4/fastwater 0xd → surface-Y@scratchpad+0x1e=0).** MASK_GRAB rot-decay vs normal ratan2 (rot[1]=ratan2(dx,dz), rot[0]=0x800−ratan2(dy,sqrt(dx²+dz²)), rot[2]=(zoom->angle[0]*desiredRot.x)>>8). pushBufferPosCorrection/pCamVel clamp [-2,2]; cameraPos/lookAtPos≡pCamPos/pCamRot writeback; flags&=~0x10(MASK_GRAB). **Transition block** (flags 0x204 or START_OF_RACE): BATTLE_EOR(0x4)→Spin360; TRANSITION_AWAY(0x200)→transitionTo pose; else startline fly-in (FlyInData{ptrEnd=camPath+0x354, ptrStart=camPath, 0x96, 0x8e}, frame=0xa5−transitionFrame clamp 0x96, CAM_StartLine_FlyIn); CAM_ProcessTransition + ease-curve (sin/cos, transitionBlend≡nTransitionEase); frame advance 3-way (AWAY&!BACK inc→HOLD 0x800; AWAY&BACK dec; !AWAY dec). **1 benign project-documented deviation**: shared CountdownTransitionFrame clears 0xE00 in !AWAY path where retail `blez` doesn't — no-op (0xE00 never set during startline); author comment "normally not here, but saves byte budget". | + +**✅ CAM.c FULLY VERIFIED (22/22 functions, all Match, ZERO bugs).** game/CAM.c complete: SkyboxGlow, ClearScreen, Init, Path_GetNumPoints, Path_Move, StartOfRace, EndOfRace_Battle, EndOfRace, FindClosestQuadblock, ProcessTransition, StartLine_FlyIn_FixY, FollowDriver_AngleAxis, StartLine_FlyIn, FollowDriver_TrackPath, LookAtPosition, FollowDriver_Spin360, SetDesiredPosRot, FollowDriver_Normal, MapRange_PosPoints, ThTick (+2 not in syms range). Notable: project *more correct than raw Ghidra decomp* in 3 places (StartOfRace binary-vs-rewrite, LookAtPosition driverPos-vs-cameraPos mislabel, FollowDriver_Normal 0x4100 mask). Benign native adaptations: boolDidTouchQuadblock↔countTrianglesTouched, scratchpadStruct↔g_collQueryShadow, CTR_SCRATCHPAD_PTR abs-offset↔::scratchpad+0x108. + +## MAIN EXE — CDSYS (game/CDSYS.c, CD/XA/SPU streaming) — IN PROGRESS + +Hardware-interfacing module; native build uses CTR_NATIVE adaptations for some fns (retail reference paths preserved & compared to binary). Verifying in syms order 0x8001c360→0x8001d06c. + +| Function | Address | Verdict | +|---|---|---| +| CDSYS_Init | 0x8001c360 | Match — init CD/XA. boolUseDisc=useDisc; unused400[0]=0 (order-swapped vs decomp, benign); useDisc→CdInit (fail→boolUseDisc=0,ret 0), CdSetDebug(1); reset state block (discMode=-1 [=~DM_DATA, DM_DATA=0], bool_XnfLoaded=0, XA_State=XA_IDLE, all counters/positions/volumes/irqAddr=0); SetMode_StreamData; SetXAToLang(1); Voiceline_PoolClear; ret 1. **Benign: project omits zeroing dead g_nXaUnk700/unused_8008d700 (plate-confirmed never read).** | +| CDSYS_GetFilePosInt | 0x8001c420 | Match — CdSearchFile(name)→CdPosToInt(&pos) into *filePos; ret found-bool. | +| CDSYS_SetMode_StreamData | 0x8001c470 | Match (retail path) — guard useDisc && discMode!=DM_DATA; if bool_XnfLoaded→XAPauseForce; CdControl(CdlSetmode, CdlModeSpeed=0x80, 0); discMode=DM_DATA, XA_State=XA_IDLE (order-swap, benign); if XnfLoaded→CdSync/ReadyCallback(0). Native build has documented CTR_NATIVE early path (XAPauseForce if not idle). | +| CDSYS_SetMode_StreamAudio | 0x8001c4f4 | Match — guard useDisc && XnfLoaded && discMode!=DM_AUDIO; CdControl(CdlSetmode, 0xE8 [=Speed 0x80\|RT 0x40\|Size1 0x20\|SF 0x08], 0); discMode=DM_AUDIO, XA_State=XA_IDLE; CdSyncCallback(XaCallbackCdSync), CdReadyCallback(XaCallbackCdReady). | +| CDSYS_SetXAToLang | 0x8001c56c | Match — load XA lang tables + verify all XA files present. useDisc==0→ret 1; lang>=8→ret 0; bool_XnfLoaded=0; SetMode_StreamData; strncpy lang-code (xaLanguagePtrs[lang], 3 chars) into XNF/EXTRA/GAME templates @offset 4 (over "ENG"); LOAD_XnfFile; validate magic=="XINF" && version==0x66(102) && numTypes==3. XNF word offsets: numXA@5, firstXaIndex@8, numSongs@0xb, firstSongIndex@0xe, XaCdPos@0x11, XaSize=&XaCdPos[numXAs_total(@3)]. Per category×xaID: name[stringIndex_char1]='0'+xaID/10, [char2]='0'+xaID%10, CDSYS_GetFilePosInt(name, &XaCdPos[firstXaIndex[cat]+xaID]); missing file→ret 0. All present→bool_XnfLoaded=1, ret 1. | + +| CDSYS_XaCallbackCdSync | 0x8001c7a4 | Match — CdlCB. result==CdlComplete(2)→com=CdLastCom()−0x15 (u8), (com<2 = seek 0x15/0x16)→XA_State=XA_IDLE; else countFail_CdSyncCallback++. | +| CDSYS_XaCallbackCdReady | 0x8001c7fc | Match — CdlCB XA state machine. result==CdlDataReady(1)→CdGetSector(cdlFile_CdReady,3), XA_CurrPos=CdPosToInt; XA_STARTING(=2) && StartPos≤CurrPos → XA_PLAYING, CurrOffset=(CurrPos−StartPos)*4; XA_PLAYING && EndPos=NUM_MDM(0xe2=226)→0 (u32 so −1 wraps positive); ret &data.MetaDataModels[id]. | +| COLL_FIXED_TRIANGL_Barycentrics | 0x8001ede4 | Match — closest point on edge v1→v2 to query (clean native GTE reimpl w/ CollFixed_Gte* wrappers). edge=v2−v1, point=query−v1; mvmva dot products → pointDot(MAC2)/edgeDot(MAC1); LZCS/LZCR shift clamp [0,12] (project `shift>12`≡decomp `leading_zeros>14`); if shift<12 edgeDot>>=(12−shift); factor=(pointDot<triNormalVecBitShift; degenerate(index[2]!=index[3])→dividend[9]/GetNormVec(1,3,2); dividend[8]/GetNormVec(0,1,2). | +| COLL_FIXED_QUADBLK_GetNormVecs_HiLOD | 0x8001f6f0 | Match — LodShift=0; if !degenerate: dividend[4..7]/GetNormVec(8,6,7)(7,3,8)(1,7,6)(2,6,8); always dividend[0..3]/GetNormVec(0,4,5)(4,6,5)(6,4,1)(5,6,2). | +| COLL_FIXED_TRIANGL_GetNormVec | 0x8001f2dc | Match — face plane via GTE. edgeA=v3−v1→R11R12/R22R23/R33, edgeB=v2−v1→IR, OP0=cross → n(MAC1/2/3); n=(n>>lodShift)*dividend>>bitShift; normal.xyz=n; load v1 pos→matrix, IR=n, RTIR → plane.halfDistance=MAC1>>1; normalAxis = argmax(\|n\|) (Z default; \|x\|<\|y\| & \|y\|>=\|z\|→Y; \|x\|>=\|y\| & \|x\|>=\|z\|→X). | +| COLL_FIXED_QUADBLK_TestTriangles | 0x8001f41c | Match — candidate.ptrQuadblock=quad; reject if (quadFlagsWanted & flags)==0 \|\| (quadFlagsIgnored & flags)!=0 \|\| AABB no-overlap; **searchFlags & COLL_SEARCH_HIGH_LOD (bit 0x2, Ghidra misnames `LOW_LOD_QUAD`)**: clear→LoLOD GetNormVecs + TestPoint(0,1,2)[+degenerate (1,3,2)]; set→HiLOD (unless COLL_SEARCH_REUSE_NORMALS=Ghidra `LOAD_HIGH_LOD_VERTICES`) + TestPoint(0,4,5)(4,6,5)(6,4,1)(5,6,2)[+degenerate 4 more]. | +| COLL_FIXED_BSPLEAF_TestQuadblocks | 0x8001f5f0 | Match — node->flag & BSP_LEAF_FLAG_WATER(2) → stepFlags\|=WATER_BSP; loop numQuads → QUADBLK_TestTriangles(ptrQuad++); searchFlags & COLL_SEARCH_TEST_INSTANCES(1, =Ghidra collFlags&TEST_INSTANCES) → BSPLEAF_TestInstance. | + +| COLL_FIXED_TRIANGL_TestPoint (+_Body) | 0x8001ef50 | **Match** (core collision primitive, full ASM verify). Wrapper: numTrianglesTested++, candidate.normalAxis/plane=v1's, normalZW=pack(v1.plane.normal.z, halfDistance). Body: seg=driverPos→hitPos; GTE mvmva → lineDot(MAC2)/planeDot(MAC1); `if lineDot>=0 return`; **factor=−(MAC1−halfDist·0x2000)/(lineDot>>12)** [project `(normalZW>>16)<<13`=halfDist·0x2000], clamp [0,0x1000]; GPL12→candidate.hitPos. Point-in-tri: project onto 2 non-dominant axes (Y/Z/X branches, swap to steeper first-edge, baryVertex1/2), 2 bary paths (firstA==0 vs denom=(secondB·firstA−firstB·secondA)>>6); inside if baryA>=0 && baryA+baryB<=0x1000. **Gate ASM `andi quadFlags,0x40` (QUADBLOCK_FLAG_TRIGGER=0x40 _Static_assert'd; Ghidra garbled `&TRIGGER_SCRIPT!=~bigmask`): trigger→stepFlags@0x1a4\|=(u8)terrain_type@quad+0x38, return; else record hit** — hit.ptrQuadblock@0x80, hitBarycentrics@0xc8/0xca, hitLevelTriangle@0xcc/d0/d4 (pLevelVertex@+8), **boolDidTouchQuadblock@0x3e++ (native flag ≡ retail countTrianglesTouched)**, hit.hitPos@0x68 + QuadBlockColl.hitPos@0x1c = candidate.hitPos@0x4c, hit.plane@0x70 = candidate.plane@0x54. | +| COLL_FIXED_TRIANGL_UNUSED | 0x8001ef1c | Match — dead code; ASM `jal 0x8001ef74` (into mid-TestPoint shared body) + delay `lw t2,0x58(a0)`. Project reproduces via TestPoint_Body(sps,v1,v2,v3, pack(candidate.plane.normal.z, halfDistance)=sps+0x58), skipping the wrapper setup. Decomp's "RenderPoly_SubdivTexGradient()" = Ghidra failing to model the jal-into-mid-function. | + +| COLL_FIXED_INSTANC_TestPoint | 0x8001d0c4 | Match — swept sphere-vs-cylinder-hitbox test (clean native CollInstanceHitboxScratch reimpl). byte@flag+1==BSP_HITBOX_CLASS_TOUCH(4)→bspHitbox+boolDidTouchHitbox++ (≡retail countInstancesCollided). **searchFlags & COLL_SEARCH_FORCE_INSTANCE_HIT(0x40)** → default hit normal=(0,0x1000,0), hitFraction=0, hitPos=pos, ret 6. Else swept: seg/center deltas (Y iff BSP_HITBOX_USE_Y_AXIS 0x40); GTE dots → factor=(dotCenter<>(12−shift)) [LZCS shift clamp 0..12]; radius test remaining=radiusSum²−dist² (radiusSum=hitRadius+radius), refine factor−=(remaining<<12)/dotSegment; `if hitFraction>12, normal=normalize(hit−centerDelta) via invLen=0x1000000/SquareRoot0, hitPos(QuadBlockColl)=pos+hit, hit.plane.normal, reorderResult=CLIP_FACE, pushOut=hit.hitPos=center+normal·radius. | +| COLL_FIXED_BSPLEAF_TestInstance | 0x8001d610 | Match — walk node->data.leaf.bspHitboxArray until flag==0; dedup vs bspHitboxesHit[0..numBspHitboxesHit−1] (≡decomp driverPosPlusSpeed+…+0x44); gate ((flag & BSP_HITBOX_COLLIDABLE 0x80)==0 \|\| instDef==NULL \|\| (instDef->ptrInstance->flags & DRAW_COLLISION_MASK 0xf)!=0) && 6-way AABB overlap → COLL_FIXED_INSTANC_TestPoint. | + +| COLL_FIXED_BotsSearch | 0x8001d77c | Match — AI-kart instance-only swept search setup. sqrRadius=hitRadius²; Input1.hitRadiusSquared/QuadBlockColl.hitRadiusSquared/hitRadius; Input1.pos & QuadBlockColl.hitPos=posCurr, QuadBlockColl.pos=posPrev; per-axis bbox min=min(curr,prev)−radius / max=max(curr,prev)+radius; reset speedScale(hitFraction)=0x1000, countInstancesCollided(boolDidTouchHitbox)=0, maybeVerticeTestCount(numTrianglesTested)=0, stepFlagSet=0, nInstanceHitCount(numBspHitboxesHit)=0; COLL_SearchBSP_CallbackPARAM(bspRoot, bbox, COLL_FIXED_BSPLEAF_TestInstance) (instance-only). | +| COLL_MOVED_TRIANGL_ReorderNormals | 0x8001f928 | Match — barycentric point-in-tri classification + closest-feature snap (native factors projection into CollMoved_SelectProjection, same dominant-axis/steeper-edge-swap as FIXED_TRIANGL_TestPoint). baryA/B via firstA==0 vs denom=(secondB·firstA−firstB·secondA)>>6; return map: INVALID→CLIP_MISS, SNAP_V1/2/3→CLIP_V1/2/3, INSIDE_TRIANGLE→CLIP_FACE, EDGE_V1_V2/V2_V3/V1_V3→CLIP_EDGE_*. p_v3≡baryV2, swapped v3≡baryV3, t->pos≡candidate->hitPos, interpolationPoint≡candidate->pushOut. | + +| COLL_MOVED_TRIANGL_TestPoint | 0x8001fc40 | Match — MOVED-collision segment-vs-triangle core (largest COLL fn). Setup candidate.plane/normalAxis=v1's, normalZW; door check (quadFlags & QUADBLOCK_FLAG_DOOR 0x400 && ((s8)terrain_type & doorAccessFlags))→return; RTV0 → planeNear/Far=MAC1/2−halfDist·2; one-sided flip if planeFar<0 && !(!(TRIGGER 0x40) && draw_order_low>=0 [DOUBLE_SIDED=0x80000000 sign bit]); reject (planeNear−hitRadius>=0 / planeFar<0 / (!TRIGGER && planeNear−planeFar>0)); GPF12 intersection (IR0=planeNear+IR=normal if planeNear>=0, else IR=Δ+IR0=−planeNear<<12/(planeFar−planeNear)); pushOut=driverPosPlusSpeed−MAC; ReorderNormals classify (<0→return); distanceSq via MVMVA of pushOut/pos−hitPos vs hitRadiusSquared; TRIGGER→(planeNear<0 \|\| (planeNear−hR\|planeFar−hR)<0)→stepFlags\|=terrain,return; distance=0x1000−((hitRadius−planeNear)<<12)/(planeFar−planeNear); if distance>=hitFraction return; NO_COLLISION_RESPONSE(0x10)→(KILL_PLANE 0x200→stepFlags\|=COLL_STEP_FLAG_KILL_PLANE 0x4000),return; record hit (hitFraction=distance, hitLevelTriangle, hit.hitPos/normalAxis/plane/pushOut/ptrQuadblock/triangleID/reorderResult, QuadBlockColl.hitPos=pos+GPF12(distance) or pos if distance<=0, boolDidTouchQuadblock++). | +| COLL_MOVED_QUADBLK_TestTriangles | 0x80020064 | **Match EXCEPT 1 divergence** — twin of FIXED_QUADBLK_TestTriangles, calls MOVED_TRIANGL_TestPoint w/ per-tri candidate.triangleID; AABB/flag filter identical; LoLOD (0,1,2)=LO_0[+degenerate (1,3,2)=LO_1] / HiLOD (0,4,5)=HI_0,(4,6,5)=HI_1,(6,4,1)=HI_2,**(5,6,2)** [+degenerate (8,6,7)=HI_4,(7,3,8)=HI_5,(1,7,6)=HI_6,(2,6,8)=HI_7]. **⚠️ DIVERGENCE: project sets triangleID=HI_3(5) before (5,6,2); RETAIL leaves it at HI_2(4) — ASM 0x80020190 `li v1,0x5` is a DEAD LOAD (no `sb v1,0x63(a0)` before the (5,6,2) jal; v1 overwritten by `li v1,0x6` after). So retail records collidedTriangleIndex=4 for a (5,6,2) hit, project records 5.** NOT cosmetic: triangleID is the dedup key in COLL_MOVED_FindScrub (per-(quadblock,triangleID) scrub-depth) — retail merges (5,6,2)+(6,4,1) scrub state under key 4, project keeps them separate. **The project is intentionally MORE correct than a retail dead-store bug (author defined HI_3=5 + assigns it). NOT changed unilaterally — flagged for user decision: reproduce retail quirk (triangleID 4 for (5,6,2)) vs keep the fix.** | +| COLL_MOVED_BSPLEAF_TestQuadblocks | 0x800202a8 | Match — twin of FIXED_BSPLEAF_TestQuadblocks: node->flag & WATER(2)→stepFlags\|=WATER_BSP; loop numQuads→MOVED_QUADBLK_TestTriangles; searchFlags & TEST_INSTANCES→COLL_FIXED_BSPLEAF_TestInstance. | + +| COLL_MOVED_FindScrub | 0x80020334 | Match — repeated-collision scrub tracker. quad==NULL→reset (searchFlags&=~COLL_SEARCH_REPEAT_SCRUB 0x20, Input1.scrubDepth=0, numTriangles=0). Else scan records (ext->bspSearchTriangle[], stride 0xC {quadblock, triangleID, scrubDepth}) backward: match (quad,triangleID)→scrubDepth<0x401(COLL_SCRUB_DEPTH_REPEAT_LIMIT)?+=0x100(STEP); searchFlags\|=0x20, scrubDepth=scrub; else append {quad,triangleID,0}, clear 0x20, scrubDepth=0, numTriangles++. **triangleID is the dedup key → the (5,6,2) HI_3 divergence in MOVED_QUADBLK_TestTriangles is observable here (retail merges (5,6,2)+(6,4,1) scrub state under key 4).** | +| COLL_MOVED_ScrubImpact | 0x80020c58 | Match (algorithmic; no clean decomp, GTE scratch-blob via native helpers). normal=hit.plane.normal (recompute from hit.hitPos if vShiftCount(unknownTraction) && boolDidTouchQuadblock && (quadFlags&GROUND) && reorderResult!=CLIP_FACE && quad!=underDriver && abs(speedApprox)<0x300 && abs(jumpHeightCurr)<0x300 && fireSpeed==0: normal=(posCurr>>8−hitPos)·0x1000/FastSqrt). penetration dot=(velocity>>3·normal)>>9; <−0xa00→actionsFlagSet\|=0x2000(TURBO_INPUT_LATCH); dot−=Input1.scrubDepth; if dot<0: scrub->flags gates (SKIP_WALL_RUB_TIMER 4→DRIVING_AGAINST_WALL 0x2000, KEEP_RESERVES 8→reserves/turbo=0), wall-rub priority (wallRubTimer==0?0x3e7ff>12 + GTE wall-glide projection (searchFlags\|=WALL_PROJECTION_DONE); SLAM_ON_HARD_IMPACT 2 && dot<−0x13ff && impact.x²+z²>0x1900000 → JogCon1((simpTurnState<1)?0x1f:0x2f,0x60), impactAngle-absorb check → else crash (OtherFX_Play_LowLevel(6, flags 0xff8080/0x1ff8080 by actionsFlagSet&0x10000), Voiceline(6), ShockFreq/Force1(8), DRIFTING→cancel turnAngle, animIndex=2/matrixArray=4, VehPhysProc_SlamWall_Init, ret 2). ret 0/1/2. | + +| COLL_SearchBSP_CallbackQUADBLK | 0x8001eb0c | Match — quadblock BSP-search query-fill. hitRadius/hitRadiusSquared; Input1.pos & QuadBlockColl.hitPos=top, QuadBlockColl.pos=bottom; hitFraction=0x1000; per-axis bbox min/max(top,bottom); reset boolDidTouchHitbox/numTrianglesTested/boolDidTouchQuadblock/stepFlags/numBspHitboxesHit (native renames); COLL_SearchBSP_CallbackPARAM(bspRoot, bbox, COLL_FIXED_BSPLEAF_TestQuadblocks). | +| COLL_SearchBSP_CallbackPARAM | 0x8001ebec | Match (behavioral — native reimpl of scratchpad-stack BSP traversal). root==NULL→return; native LIFO stack @scratchpad 0x70 (retail uses raw 0x1f800000 + s0-s8/ra register-spill @0x30 — irreducible; native picks 0x70 to avoid that region). PushChildren(root, node): PushChild(childID[0]) then childID[1]; PushChild: (u16)childID==BSP_CHILD_ID_NONE(0xffff)→skip, else &root[childID & BSP_CHILD_ID_INDEX_MASK 0x3fff], Overlaps(6-way AABB min<=max both ways, same order as decomp `−X<1`)→push. Loop pop LIFO: leaf (childID & BSP_CHILD_ID_LEAF_FLAG 0x4000)→callback(child, sps), else PushChildren. Preserves retail traversal order (push c0/c1, pop c1 first). | + +| COLL_FIXED_PlayerSearch | 0x8001d944 | **Match** (huge ~0x11c8 b per-frame player ground-collision; native reimpl w/ helpers, verified branch-by-branch). SetupSearch: probeTop=posCurr>>8+{0x80 Y}(driverPos), probeBottom=−0x100 Y(driverPosPlusSpeed/hitPos), quadFlagsIgnored=NO_COLLISION_RESPONSE(0x10), quadFlagsWanted=GROUND\|COLLISION_SURFACE(0x3000), searchFlags=HIGH_LOD(0x2) if <3P, clear ENGINE_ECHO. Test underDriver (QUADBLK_TestTriangles) then BSP (SearchBSP_CallbackPARAM→BSPLEAF_TestQuadblocks). No-hit: compress normal (0,0x1000,0)\|(driverID+1)<<24, clear REFLECTIVE, quadBlockHeight=posCurr.y−0x10000. Hit: compress hit.plane.normal, quadBlockHeight=hitPos.y<<8, collisionFlags\|=4(TOUCHED_QUADBLOCK); mud/water/fastwater→vertSplit=0+SPLIT_LINE; <2P reflection (COLLISION_SURFACE clear → REFLECT_SPLIT_LINE_1→splitLines[1] / _0→splitLines[0] / else clear REFLECTIVE); NormalizeAxis3 (5·old+3·hit); REVERB→ENGINE_ECHO 0x10000; underDriver=quad; if grounded-range: GROUND→normalVecUP+collisionFlags\|=8(GROUNDED), currBlockTouching seed AxisAngle1, UpdateLighting (bary vertex-color→luma→alphaScale, AlphaBlend·200). Then killplane (posCurr.y<(bspRoot.box.min.y−0x40)<<8→flags\|=1 MASK_GRAB_REQUEST), landingDelta=velocity.y−ySpeed, ground-normal velocity nudge (+AxisAngle1>>1 if !(flags&9) && !MASK_GRABBED). Grounded: CheckMaskGrabProgress (KILL_PLANE 0x200→1, checkpoint/lastValid/restart-point off-track logic + CTR_NATIVE lastValid null-guard), clear AIRBORNE\|HIGH_JUMP(0x80040), ICE terrain (rainCloudEffect/CHEAT_ICY 0x80000; terrainFrictionTimer 0xfec0), coyote=0xa0, TOUCH_GROUND, first-touch → flags\|=0x83(TOUCH_GROUND\|STARTED\|TURBO_INPUT_LATCH)+terrainFrictionTimer=0x140+landing thump OtherFX_Play_LowLevel(7, MapToRange(absLanding,0x100,0x3c00,0x78,0xfa) sound word). Airborne: AIRBORNE(0x80000)/HIGH_JUMP(0x40), coyote decay. Normal blend weight ground=6/air=7, (weight·AxisAngle2+(8−weight)·normalVecUP)>>3; +hazard wobble; normalize AxisAngle2; rotCurr.z=ratan2; Screen_OffsetY suspension squash; killplane→VehStuckProc_MaskGrab_Init. | + +| COLL_MOVED_PlayerSearch | 0x80020410 | **Match** (iterative swept-collision loop for moving driver; native reimpl w/ helpers). Setup: hitRadius=0x19/sq=0x271, quadFlagsWanted=0x3000/ignored=0, searchFlags=TEST_INSTANCES(1)[\|HIGH_LOD(2) if <3P], numBspHitboxesHit=0, modelID=DYNAMIC_PLAYER(0x18), stepFlags=0, FindScrub(NULL). 15-iter loop: velocity=StepVelocity((v·elapsedTimeMS>>5)·multiplier>>12); reset counters, hitFraction=0x1000; current/next=originToCenter+posCurr>>8/+(posCurr+velocity)>>8; break if next==current; SetBBoxAxis ±hitRadius; searchFlags=(\|TEST_INSTANCES)&~REUSE_NORMALS; SearchBSP_CallbackPARAM(bspRoot, MOVED_BSPLEAF_TestQuadblocks); boolDidTouchQuadblock→collisionFlags\|=4; posCurr+=velocity·hitFraction>>12. **Quadblock hit** (boolDidTouchHitbox==0): KILL_PLANE(0x200)→flags\|=1; FindScrub(quad, hit.triangleID); GROUND clear→scrubId=(REFLECT_SPLIT_LINE_1?6:0), GROUND set→(quad!=underDriver && KICKERS 0x8→underDriver=NULL), currBlockTouching/normalVecUP/AxisAngle1=hit.plane.normal, flags\|=8(GROUNDED), scrubId=5; GetSurface(scrubId); flags\|=2(SURFACE_PUSHBACK); spsHitPos/spsNormalVec; ScrubImpact→2 return; hitFraction>0→multiplier−=multiplier·hitFraction>>12, <100 break; searchFlags\|=REUSE_NORMALS. **Instance hit** (boolDidTouchHitbox): clear REUSE_NORMALS/SURFACE_PUSHBACK; RunHitboxLInC (COLLIDABLE 0x80: instDef->ptrInstance, flags&DRAW_COLLISION_MASK 0xf, modelID; else LINC_USES_INSTDEF 0x10: instDef-as-Instance, model->id; COLL_LevModelMeta->LInC); Coll_BspHitboxClass=flag+1; (LInC==2 \|\| class==4)→StoreHitbox, else FindScrub+scrubDepth+=0x200(HITBOX_SCRUB_DEPTH_BONUS)+ScrubImpact(→0 StoreHitbox, 2 return). Loop-end collFlags save/restore (benign reg-alloc); stepFlagSet=stepFlags. | + +**✅ COLL.c FULLY VERIFIED (23/23 functions).** game/COLL.c complete — the collision system. Native **reimplements** the search (scratchpad-stack BSP traversal → clean LIFO stack; hit flags boolDidTouchQuadblock@0x3e/boolDidTouchHitbox@0x42 ≡ retail countTrianglesTouched/countInstancesCollided; PlayerSearch/Barycentrics/TRIANGL_TestPoint GTE via CollFixed_Gte* wrappers). All pure-math FIXED_*/MOVED_* fns line-verified; the 4 scratchpad-stack search fns (SearchBSP_CallbackQUADBLK/PARAM, FIXED/MOVED_PlayerSearch) + ScrubImpact behaviorally verified (irreducible scratchpad-stack/GTE-scratch-blob). **1 flagged divergence: MOVED_QUADBLK_TestTriangles (5,6,2) triangleID=HI_3(5) vs retail's dead-store leftover 4 — project intentionally more-correct; user decision pending.** Confirmed flags: QUADBLOCK_FLAG TRIGGER=0x40/DOOR=0x400/NO_COLLISION_RESPONSE=0x10/KILL_PLANE=0x200/GROUND/COLLISION_SURFACE=0x2000/ENGINE_ECHO=0x10000/KICKERS=0x8; DRAW_ORDER_LOW_DOUBLE_SIDED=0x80000000; BSP_HITBOX CLASS_TOUCH=4/USE_Y_AXIS=0x40/CHECK_Y_RANGE=0x20/COLLIDABLE=0x80/LINC_USES_INSTDEF=0x10; COLL_SEARCH TEST_INSTANCES=1/HIGH_LOD=2/REUSE_NORMALS/FORCE_INSTANCE_HIT=0x40/REPEAT_SCRUB=0x20; DRAW_COLLISION_MASK=0xf; DRIVER_COLL 1/2/4/8. + +## MAIN EXE — CTR_* (game/CTR/, debug draw + texture cycling + render lists) — IN PROGRESS + +Prim structs clean-typed (prim.h LineF2/LineF3/PolyF4/TPage); **GetPrimitiveMem sets tag.size=(primSize−sizeof(Tag))/4** for all GetPrimMem-based prims (resolves apparent tag.size omissions); AddPrimitive links into OT. Syms 0x80021500→0x80021c94. + +| Function | Address | Verdict | +|---|---|---| +| CTR_Box_DrawWirePrims | 0x80021500 | Match — LINE_F2 (size 3, code RenderCode_Line 0x40) from p1→p2 in color; GetPrimMem (native uses backBuffer primMem vs retail's explicit param — benign). | +| CTR_Box_DrawWireBox | 0x80021594 | Match — wireframe rect as 2× LINE_F3 polyline (size 5, code 0x48=Line\|polyline, end 0x55555555): prim1 (top,top)→(bottom,top)→(bottom,bottom), prim2 (top,top)→(top,bottom)→(bottom,bottom). Uses primMem->cursor directly → sets tag.size explicitly. | +| CTR_Box_DrawClearBox | 0x8002177c | Match — semi-trans filled rect TPage+POLY_F4 (size 7, code 0x2a=Polygon\|quad\|semiTrans). **tpage word=0xE1000A00\|transparency<<5** (Texpage code 0xE1/dither bit9/**y_VRAM_EXP bit11**/semiTrans bit5 — NOTE(claude) documents bit11 for byte-exact; stock PSX ignores it); tag.self=0; verts (x,y)(x+w,y)(x,y+h)(x+w,y+h); CTR_NATIVE drawDisplayArea=1 adaptation. | +| CTR_Box_DrawSolidBox | 0x80021894 | Match — opaque POLY_F4 (size 5, code 0x28): colorCode=rgb\|0x28<<24, verts (x,y)(x+w,y)(x,y+h)(x+w,y+h). | + +| CTR_CycleTex_LEV | 0x80021984 | Match — advance level animated textures. Walk AnimTex list (terminate when curAnimTex->ptrActiveTex==animtex head); frameCurr=((timer+frameOffset)>>frameSkip)%numFrames; ptrActiveTex=ANIMTEX_GETARRAY(&animtex[1])[frameCurr]; next=&array[numFrames]. (frameOffset≡frameDuration, frameSkip≡shiftFactor.) | +| CTR_CycleTex_Model | 0x80021a20 | Match — same as LEV but writes *ptrActiveTex=array[frameCurr] (pointer-to-pointer for models). | +| CTR_CycleTex_AllModels | 0x80021ac0 | Match — walk model-ptr array (stop at NULL/numModels==0); per model iterate numHeaders(@0x12) headers (stride 0x40, flags@+0x16, animtex@+0x26); if animtex && (flags&2)==0 → CTR_CycleTex_Model(animtex, timer). | +| CTR_CycleTex_2p3p4pWumpaHUD | 0x80021b94 | Match — OT splice (misnamed; Ghidra reconstructed, no .c): prim[0]=otEntry[0]; otEntry[0]=(prim+count−1)&0xffffff. Links a prim block into the OT. | + +| CTR_ClearRenderLists_1P2P | 0x80021bbc | Match — per player (numPlyr>0): clear 5 LOD lists (bspListStart=0, ptrQuadBlocksRendered=ptrRenderedQuadblockDestination_forEachPlayer[i] @0x80081b20) + bspListStart_FullDynamic/ptrQuadBlocksRendered_FullDynamic=0. | +| CTR_ClearRenderLists_3P4P | 0x80021c2c | Match — per player: clear 4 lists (buffer table @0x80081b30) + list[4].ptrQuadBlocksRendered=0 (no FullDynamic clears). | +| CTR_EmptyFunc_MainFrame_ResetDB | 0x80021c8c | Match — empty stub (jr ra). | +| CTR_ErrorScreen | 0x80021c94 | Match — fatal-error screen fill. 3× {DrawSync(0), VSync(0), DISPLAY_Swap}; on i<2 draw a screen-clip TILE (code 2, tag 0x3ffffff=size3\|addr0xffffff, x/y/w/h=frontBuffer->drawEnv.clip, rgb), DrawOTag. Net 2 draws + 3 swaps (project for-i<3 + `if i==2 return` ≡ decomp do-while+final-swap). | + +**✅ CTR block FULLY VERIFIED (12/12 functions, all Match).** CTR_Box.c (4) + CTR_CycleTex.c (4) + CTR_RenderLists.c (3) + CTR_Error.c (1). Debug prim draw / animated-texture cycling / render-list reset / error screen. Prim tag.size handled by GetPrimitiveMem; tpage 0xE1000A00 (y_VRAM_EXP bit11 documented); TILE/POLY_F4/LINE_F2·F3 codes verified. + +## MAIN EXE — CTR_* cont'd (VisMem RLE / Matrix / Ghost) — IN PROGRESS + +| Function | Address | Verdict | +|---|---|---| +| CTR_unknownMaybeThunk1 | 0x80021da0 | Match (Ghidra DB: CTR_VisMem_RleCopy) — RLE decompress copy: ctrl byte 0→end, >0→literal run of N bytes (out=src), <0→fill (1−N) copies of next byte. | +| CTR_unknownMaybeThunk2 | 0x80021e1c | Match (CTR_VisMem_RleOr) — same RLE but OR-merge (out\|=): accumulate VisMem bits. | +| CTR_unknownMaybeThunk3 | 0x80021ea8 | Match (CTR_VisMem_WordOr) — word OR-merge: for (byteCount>>2 words) *dst++ \|= *src++. | +| CTR_MatrixToRot | 0x80021edc | Match — extract Euler rot from a rotation MATRIX (AngleAxis matrices, e.g. bowling bomb). flags: bit2=negate(uNegate), bit3-4→axis via table1[flags>>3&3](unk_CTR_MatrixToRot_table @retail 0x8008d004, CTR_NATIVE sdata mirror), perp axes table2[axis+negate]/[axis−(negate−1)]; bit1 selects extraction path (each w/ near-singular sqrt<0x11 branch). Element access `matrix->m + axis*8` = diagonal m[axis][axis] (3-short rows → axis*4 shorts); MATH_FastSqrt(a²+b²,0x18)>>0xc; 3 ratan2 → vx/vy/vz; bit2→negate all; bit0→swap vx↔vz (via rot->pad temp); rot->pad=flags. All 4 branch cases' exact element accesses verified. | + +| CTR_ScrambleGhostString | 0x80022234 | Match — ASCII/Latin/kana → Shift-JIS full-width transcode (ghost name). Per token: leading byte <4 = escape prefix (shift into high byte); key=(prefix<<8)|byte; scan ghostScrambleData[] {sjisValue@0, key@2} for key match w/ sjisValue high byte nonzero (real double-byte SJIS, skips Latin identity entries); emit sjisValue big-endian (2 bytes); 0xffff-key terminator; NUL-terminate out. | + +**✅ CTR_* block FULLY VERIFIED (17/17).** CTR_Box(4)+CTR_CycleTex(4)+CTR_RenderLists(3)+CTR_Error(1)+CTR_Visibility VisMem RLE(3)+CTR_Matrix(1)+CTR_Ghost(1). All Match; 0 bugs. + +## MAIN EXE — DebugFont / DecalFont (game/DebugFont.c, DecalFont.c) — IN PROGRESS + +Multi-build (#if BUILD Jpn/Usa/Eur Retail); 926 = UsaRetail active path. Syms 0x800222e0→. + +| Function | Address | Verdict | +|---|---|---| +| DebugFont_Init | 0x800222e0 | Match — cache debug font texlayout from ptrIcons[0x42] (u0/v0/clut/tpage → debugFont.u/v/clut/tpage); NULL icon → no-op. | +| DebugFont_DrawNumbers | 0x80022318 | Match — 7×7 textured POLY_FT4 (code 0x2e, size 9) per digit into UI OT (pushBuffer_UI.ptrOT). pos (x,y)(x+7,y)(x,y+7)(x+7,y+7) [**NOTE(claude): X halves masked &0xffff so negative X doesn't sign-extend into Y**]; texU=debugFont.u+(index+5)*7 [+7/corner], texV=debugFont.v+7 [+7 row]; clut@0xe/tpage@0x16; tag=*ot\|0x9000000. | +| DecalFont_GetLineWidthStrlen | 0x800223f4 | Match — sum pixel width of first `len` chars (len<0=whole). **Native uses separate `if`s (button @[^* → +buttonWidth; punc :. → +puncWidth−charWidth; then c>2 → +charWidth unconditionally) = retail else-if (button→char+button, punc→punc, normal→char). u8 c matches decomp `andi 0xff; sltiu` unsigned test (NOTE(claude): signed char would zero-width bytes>=0x80).** fontType indexes font_charPixWidth/buttonPixWidth/puncPixWidth. Jpn racing-wheel / Eur c>3 / >Usa ~-escape branches inactive for 926. | +| DecalFont_GetLineWidth | 0x800224d0 | Match — (s16)DecalFont_GetLineWidthStrlen(str, -1, fontType). | + +| DecalFont_DrawLineStrlen | 0x800224fc | Match (926/UsaRetail path — large core glyph renderer, multi-build). Justify: CENTER 0x8000→posX−=width/2, RIGHT 0x4000→posX−=width (via GetLineWidthStrlen); flags&=0xfff (UsaRetail; JpnTrial+ use 0x7ff — inactive). Per char (up to len, until NUL): color=ptrColor[flags]; charWidth=font_charPixWidth[ft]; punc :. → puncPixWidth; button @[^* → buttonScale/buttonPixHeight/charWidth+buttonPixWidth/ptrColor[GRAY=0x17]; printable (c−0x21<0xdf) → iconID=font_characterIconID[c−0x21]; dakuten c<3 (unused) → charWidth=0/font_indentIconID/font_indentPixDimensions. Draw: iconID!=0xff → kana-stub (iconID>0x7f → −0x80, groupID=(groupID==4?14:15)) → iconGroup[font_IconGroupID[ft]], iconIDSepReview; 903 hardcoded-8/0x11 & BUILD>UsaRetail flags&0x800 skip inactive). **Benign: decomp returns (s16)totalHeight, project returns full int — unobservable (height<32768) + DrawMultiLine wrapper truncates.** | +| DecalFont_DrawMultiLine | 0x80022b34 | Match — (s16)DecalFont_DrawMultiLineStrlen(str, -1, ...) (whole string). | + +**✅ DecalFont.c FULLY VERIFIED (7/7).** GetLineWidthStrlen/GetLineWidth/DrawLineStrlen/DrawLine/DrawLineOT/DrawMultiLineStrlen/DrawMultiLine. Multi-build; 926/UsaRetail path all Match. Notable: native separate-if width sum ≡ retail else-if; u8 c unsigned test; CTR_NATIVE iconGroup null-guard. + +**Next module:** DecalGlobal @0x80022b94 (EmptyFunc/Clear/Store/FindInLEV/FindInMPK), DecalHUD @0x80022db0 (DrawPolyFT4/DrawWeapon/DrawPolyGT4/Arrow2D), DecalMP @0x80023488, DISPLAY_Blur @0x80023a40. (DecalHUD/DecalMP WIP in working tree.) OR long-deferred BOTS_ThTick_Drive @0x80013c18. + +**Next module (after CTR):** or the long-deferred BOTS_ThTick_Drive @0x80013c18 (last BOTS fn). Confirmed flags: QUADBLOCK_FLAG_TRIGGER=0x40, BSP_HITBOX_CLASS_TOUCH=4(@flag+1), BSP_HITBOX_USE_Y_AXIS=0x40, BSP_HITBOX_CHECK_Y_RANGE=0x20, BSP_HITBOX_COLLIDABLE=0x80, COLL_SEARCH_FORCE_INSTANCE_HIT=0x40, DRAW_COLLISION_MASK=0xf, boolDidTouchHitbox@0x42(≡countInstancesCollided). sps offsets: boolDidTouchQuadblock@0x3e, QuadBlockColl.pos@0x10/hitPos@0x1c, candidate.hitPos@0x4c/normalAxis@0x52/plane@0x54, currQuad@0x64, hit.hitPos@0x68/plane@0x70, ptrQuadblock@0x80, hitBarycentrics@0xc8, hitLevelTriangle@0xcc, stepFlags@0x1a4. QuadBlock: quadFlags@0x12, terrain_type@0x38. | + +**✅ CDSYS.c FULLY VERIFIED (19/19 functions, all Match, ZERO bugs).** game/CDSYS.c complete. Hardware-interfacing module: retail reference paths all match the 926 binary; native CTR_NATIVE paths are documented adaptations (SPU IRQ stream → NativeAudio backend, same XA state machine / ring-buffer amplitude / fade logic synthesized). Project *more correct than .c decomp reference* in 4 places (XAGetNumTracks ret 0-vs-−1, XAGetTrackLength/XASeek/XAPauseRequest guard sets, SpuEnableIRQ irqAddr 0x200-vs-0). Benign: CDSYS_Init omits dead g_nXaUnk700 zero (present elsewhere); various order-swapped independent stores. Confirmed offsets: SpuDecodedBuf@0x8008dd28 (0x200 shorts), XaSize{XaIndex@0/XaPrefix@1/XaBytes@2}, XNF words numXA@5/firstXaIndex@8/numSongs@0xb/firstSongIndex@0xe/XaCdPos@0x11/numXAs_total@3. + +**Fix 129** (`BOTS.c:491`, BOTS_SetRotation estimateRotNav[2] wrong nav-rot component): retail copies `NavFrame->rot[2]` into the bot's roll-estimate `estimateRotNav[2]` (asm 0x80013578 `lbu v0,0x8(s6)` reads byte @NavFrame+0x8, then 0x80013580 `sb v0,0x614(s4)` → estimateRotNav[2]). `NavFrame.rot` is `u8[4]@6` (rot[0]@6/[1]@7/[2]@8/[3]@9), so the project's `nf->rot[1]` read byte @0x7 (the nav *yaw*) instead of @0x8 (roll). BOTS_SetRotation runs at AI spawn (useSpawnYaw path) and nav-reset points; the non-spawn branch seeds the bot's initial rotation estimate from the nav frame — feeding yaw into the roll slot mis-banks the kart on inclined/banked nav frames (flat frames rot[2]≈0 hide it, but banked-turn nav frames have nonzero roll). Fixed `estimateRotNav[2] = nf->rot[2]`. asm-confirmed the store target (0x614) and source byte (@0x8). Build green. + +**Fix 130** (`BOTS.c:2801`, BOTS_ChangeState weapon-hit stat credited by wrong parameter): the attacker-crediting block switched on `damageType` (param2 = the physics reaction: 0=none/1=spinout/2=blast/3=squish/4=burn/5=maskgrab) but the retail switch reads a3 = param4 `reason` (the weapon kind). asm 0x80016b00 register map: `s2=a1`(damageType), `s3=a3`(reason); the `!=0` gate @0x80016e20 reads s2 (project's `damageType != 0` — correct), but the counter switch @0x80016e38 reads **s3**: `beq s3,3 → numTimesMissileHitSomeone(+0x557)`, `beq s3,1 → numTimesBombsHitSomeone(+0x55a)`, `beq s3,4 → numTimesMovingPotionHitSomeone(+0x556)`. damageType and reason are genuinely distinct: callers pass separate Driver fields (`pendingDamageType` vs `pendingDamageReasonByte`, BOTS.c:811) and RB_Hazard_HurtDriver (231_006:17) even rewrites damageType=1 for Oxide-station boss races while leaving reason untouched. A missile arrives as damageType=1(spinout)/reason=3(missile) — the project miscredited it as a bomb hit; a bomb (damageType=2 blast/reason=1) fell through and credited nothing. Fixed `switch (reason)`. Value→counter mapping (1→bomb, 3→missile, 4→potion) and the `numTimesAttacked++` (always, @0x559) were already correct. Build green. + +**Fix 131** (`BOTS.c:1655`, BOTS_ThTick_Drive powerslide turbo-charge gate tested the wrong flags word): the 2nd condition of the AI turbo-charge gate read `navActionFlags & (ACTION_BACK_SKID 0x800 | ACTION_FRONT_SKID 0x1000)`, but retail asm 0x80015240 `lw t3,0x6c(sp); andi v0,t3,0x1800` tests the RAW `navFrameFlags` accumulator (sp+0x6c) & DRIFT_MASK — `BOTS_NAV_FLAG_DRIFT_LEFT 0x800 | DRIFT_RIGHT 0x1000 = 0x1800`, i.e. "is the AI on a drift nav segment?". The trap is a numeric coincidence: `ACTION_BACK_SKID|ACTION_FRONT_SKID` also == 0x1800, so the wrong-variable `& 0x1800` compiled cleanly. But the gate's 1st condition (`actionsFlagSet & ACTION_FRONT_SKID`) already implies `navActionFlags` carries `ACTION_FRONT_SKID` (both derived from nav FRONT_SKID 0x4 → OR'd into actionsFlagSet), so `navActionFlags & 0x1800` was **always non-zero when the 1st condition passed** — the drift-segment gate was a no-op, letting bots build powerslide fire/turbo on ANY front-skid nav segment instead of only DRIFT_LEFT/RIGHT segments (retail behavior). The project already uses the correct idiom `navFrameFlags & BOTS_NAV_FLAG_DRIFT_MASK` at BOTS.c:2013 (drift-direction logic), confirming the intended form. Fixed the L1655 gate to `navFrameFlags & BOTS_NAV_FLAG_DRIFT_MASK`. sp+0x6c confirmed = raw navFrameFlags via its use in the navActionFlags derivation (0x800150cc `sll ...,0xa` / `andi ...,0x2000` / `andi ...,0x4`). Build green. + +## Next up + +### 🎉 VERIFICATION SWEEP COMPLETE (2026-07-06) + +Every function in `/game` has been cross-referenced against syms926.txt + Ghidra and compared to the project. **Coverage:** +- **Main EXE**: full alphabetical sweep (BOTS → CAM → CDSYS → COLL → CTR → Decal* → Display → … → Vehicle → WorldRender) + MATH/GTE/Psy-Q primitives. All modules carry `✅ FULLY VERIFIED` markers. **BOTS_ThTick_Drive @0x80013c18** (the largest fn, ~1900 lines) was the final holdout — completed 2026-07-06 in 10 sections (Fix 131 in §5). +- **Overlays**: OVR_230 (MM_ main menu), OVR_231 (RB_ robots/hazards), OVR_232 (AH_ adventure hub), OVR_233 (CS_ cutscene/podium/credits), OVR_221–225 (per-mode EndEvent screens) — all FULLY VERIFIED. +- **OVR_226–229 (DrawLevelOvr)** — SKIPPED per user instruction. + +**Total fixes applied: 132** (numbered Fix 1–132 / site-numbered subset). All builds green. **Fix 132** (2026-07-06, hardening pass) = `RenderBucket_QueueExecute.c:2918/3475`, `DrawInstPrim_LitTexture` (+ split twin): backface lit-texture code word was 0x24 (opaque FT3) but retail asm 0x8006c8ec sets 0x26 (semi-transparent) unconditionally in the else-path branch-delay slot → backface lit-texture triangles rendered opaque instead of blended. First real bug found in the RenderBucket draw-path hardening (proves the flagged region was worth checking; also confirmed KeyRelicToken's look-alike block is genuinely correct, NOT the same bug). + +**One open decision (NOT a bug):** the **(5,6,2) triangleID divergence** in `COLL_MOVED_QUADBLK_TestTriangles @0x80020064` — retail runs the high-LOD `(5,6,2)` sub-triangle with `triangleID=4` (a dead-store leftover: asm 0x80020190 `li v1,0x5` is never stored), the project sets `triangleID=HI_3(5)`. triangleID is the scrub-depth dedup key in COLL_MOVED_FindScrub, so it's observable (retail merges (5,6,2)+(6,4,1) scrub state under key 4; project keeps them separate). The project is **intentionally more-correct** than the retail dead-store. Left as-is, flagged for the user's explicit call: to reproduce retail bit-for-bit, drop the `triangleID = HI_3` assignment before `(5,6,2)`. + +### Go-forward for the continuing loop +The first-pass sweep is done, so subsequent iterations do a **second/hardening pass** — pick the next module in address order and re-verify with the full accumulated criteria (Ghidra garble decode, sign/unsigned via asm, native flag adaptations, decompiler-hidden args, DB-name traps), prioritizing (a) large/complex functions verified early with less rigor, (b) any function whose ledger row lacks an asm-level flag/offset confirmation, and (c) an audit that every syms926 `/game` symbol has a ledger row. Log any newly-found bug as the next Fix N + `NOTE(claude)` + build. + +### ✅ Completeness audit (2026-07-06) — no clean-function gaps +Diffed all 1422 `800xxxxx` syms926 symbols against the ledger's referenced addresses (1448 distinct). 251 syms addresses have no ledger row; **all resolve to out-of-scope or already-covered**, leaving ZERO genuine gaps: +- **Psy-Q SDK / BIOS** (memset/strcmp/Cd*/Spu*/DrawSync/ResetGraph/… at 0x8007xxxx+), **data symbols** (rdata/data/sdata/bss), and **Unknown_* unnamed** — not `/game` functions. +- **8 game-region names** below 0x80071000: `Torch_Subset5-8` (0x8004bbe8-c134) = **covered** by the range row "Torch_Subset4-9 | 8004ba4c-8004c348"; `TRIG_AngleSinCos_r16r17r18_duplicate` (0x8006c430) = compiler duplicate of the verified `TRIG_AngleSinCos_Common` wrapper (asm-confirmed: register-passing quadrant dead-end, identical to the r19r17r18/r15r16r17/r9r8r10/r16r17r18 family); `RenderBucket_DrawInstPrim_DepthFade` (0x8006b968), `..._ClampDepth` (0x8006bad0), `Draw_KartGhost` (0x8006c984) = the **RenderBucket draw-path** — `DepthFade`/`ClampDepth` have NO Ghidra function body (926-stubbed, per the module note at ~L2934), and `Draw_KartGhost` is a **mid-function syms label** (real code — `sb (0x1000-depth)>>5` RGB + `0x22` code, jumps to draw routine @0x8006ad20 — but not a clean function entry, so no Ghidra function). + +**Conclusion:** every cleanly-delimited Ghidra function in `/game` for the 926 build has a verification row. The ONE region of real code NOT covered by the standard decompile-compare workflow is the **RenderBucket draw path** (the ~40 draw functions the ledger flagged as reconstructed-from-ASM: DrawFunc_Normal/Split/Special, DrawInstPrim_Normal/KeyRelicToken/DepthFade/ClampDepth, Draw_KartGhost, etc.) — 926 doesn't expose them as clean functions, so the project reconstructs them natively from aalhendi's RB_RETAIL_* ASM labels. **Primary hardening target:** verify those native draw reconstructions in RenderBucket_QueueExecute.c against the raw ASM (0x8006a5xx-0x8006d5xx region), since that's the only `/game` code not yet checked via decompile-compare. Note the DEPTH_FADE *alpha-gate* path (QueueExecute.c:805, retail 0x80070a5c) is already verified and is distinct from the draw-path depth-to-color code at 0x8006c984. + +**RenderBucket draw-path spot-verification (2026-07-06) — 2/40+ pieces ASM-confirmed, both Match:** +- **`0x8006c984-0x8006c9c4` (syms `Draw_KartGhost`, actually the Instance+0x5c FADE_COLOR setup callback)** = project `RB_RETAIL_INST_SETUP_FADE_COLOR` (QueueExecute.c:4590). ASM (disassemble_bytes, no clean Ghidra fn): `lw v1,0x24(ra)`(colorRGBA)`; bne v1,0→SetFarColor` = `if (colorRGBA != 0) SetFarColorFromInstance` ✓; `lh t0,0x22(ra)`(alphaScale s16)`; li 0x1000; subu; srl 5` = `fade=(u32)(0x1000-(s32)alphaScale)>>5` (the `(u32)` cast reproduces the logical `srl` exactly) ✓; 3×`sb`→scratch 0x124-6 RGB + `sb 0x22`→0x127 ✓; `addiu -0x1000; bne→0x8006c94c else→0x8006ad20` = `if (alphaScale==0x1000) return 0 else SetFarColor+return 1` ✓. +- **`0x8006b968-0x8006bad0` `RenderBucket_DrawInstPrim_DepthFade`** (926-stubbed, no Ghidra fn) = project helper `RenderBucket_DepthFadeColor` + `_DepthFadeAtRange` (QueueExecute.c:2748/2767). Per-vertex fade math ASM-exact: reads `SZ1/2/3` (`gte_stSZ1/2/3` = MFC2_S(17/18/19)); `sz-=0xc00; if(sz<=0) return 0`(black) = `addiu -0xc00; bgtz else sw zero`; `fade=0x800-sz; if(fade<0) return color` = `subu t1,0x800,t1; bltz` (skips DPCS); `MTC2(color,22/6); MTC2(fade<<1,8); gte_dpcs(); return MFC2(22)` = `gte_ldRGB2/ldRGB; sll t1,1; gte_ldIR0; nDPCS; gte_stRGB2`; emit GT3 code 0x36, OT tag 0x09. ✓ + +- **`0x8006bad0-0x8006bbc0` `RenderBucket_DrawInstPrim_ClampDepth`** (926-stubbed, no Ghidra fn) = project `GetClampedOTEntry` + shared `NormalAtOTEntry` (QueueExecute.c:2321/2822). **Nearly flagged a bug, rigorously confirmed correct.** ASM clamps the BYTE offset `depthBin*4` (`srl 17; sll 2`) against scratch `@0x134/0x136`, then `+OTbase`; project clamps the BIN `depthBin` against `idpp->depthOffset[0/1]` (idpp+0x68) then pointer-adds (`*4`). Resolved equivalence via two facts: (1) `RenderBucket_Execute` @0x8006aaa8 writes `scratch@0x134 = *(s16)(iRenderCtx+0xdc) << 2` (pre-scales the bound ×4); (2) the InstDrawPerPlayer struct comments are **Instance-relative** — idpp starts at Instance+0x74, so `depthOffset`(idpp+0x68) = **Instance+0xdc** = retail's `iRenderCtx+0xdc`. Thus `clamp(depthBin*4, dO0*4, dO1*4)+OTbase` ≡ `clamp(depthBin,dO0,dO1)*4+OTbase` — identical; the `<<2` in setup compensates the project's `*4` at pointer-add. ✓ Also incidentally verified RenderBucket_Execute setup (projection/matrix/light/bbox load @iRenderCtx+0x78..0xa8/0xc0, model-data copy to scratch 0x140, scratch 0x134/0x136 = depthOffset<<2, draw dispatch iInst+0x5c then drawFunc@iRenderCtx+0xec). + +- **Shared `NormalAtOTEntry` draw core `0x8006bb08-0x8006bbc0`** (the emit tail of ClampDepth; SAME code shared by DrawInstPrim_Normal/ClampDepth/SelectRange) = project `RenderBucket_DrawInstPrim_NormalAtOTEntry` + `LoadPrimColors` + `OTEntryPassesDpctGate` (QueueExecute.c:2563/2539/2529). **All ASM-exact:** DPCT gate = `gte_ldRGB0/1/2(tempColor[1/2/3]=reg20/21/22); gte_ldIR0(alpha=idpp->alphaScale=scratch@0x120=iRenderCtx+0xbc); if(alpha!=0 && (s32)(OTaddr<<7)>=0) gte_dpct` (asm `beq t2,0; sll t0,t3,7; bltz t0; DPCT` — the `<<7` bit-24 gate modeled in the 24-bit OT-address domain, documented native adaptation); **G3 (tex==0)**: `r0=0x30000000|MFC2(20)`, r1/r2=MFC2(21/22)@0xc/0x14, stsxy3, OT tag 0x06, size 0x1c ✓; **GT3 (tex!=0)**: `codeWord=(tex->u1 & 0x600000)==0x600000 ? 0x34000000 : 0x36000000` (asm `lui 0x60; and; beq → 0x34 else 0x36`), u0/u1/u2@0xc/0x18/0x24, r1/r2@0x10/0x1c, OT tag 0x09, size 0x28 ✓. + +- **`0x8006c778-0x8006c920` `DrawInstPrim_LitTexture` — 🐛 FIX 132 found + else Match.** Verified NCLIP side-select (`signedTest=MFC2_S(24)^((s16)instFlags^(cmd<<2))`, otEntry++ if <0), RGB lighting (`(s32)(color<<24)>>19`→IR1/2/3, LCIR), brightness (`(MFC2_S(9)+0x1000)·7+0x2000>>4`, ½ if backface), key-relic brightness table @0x8008a2c4 (clamp 0x7f), per-channel SaturateU8+add. **BUG: the (tex->u1 & 0x600000)==0 else-path code word — asm 0x8006c8ec `lui v1,0x2600` is in the branch-delay slot of `bgez t2` (0x8006c8e8), so retail uses code 0x26 (semi-trans FT3) for BOTH signedTest sub-cases (only tpage ABR differs: 0x400000 backface / 0x200000 front). Project's `else if (signedTest<0)` had code 0x24 (opaque) — dropped the semi-trans bit → backface lit-texture tris rendered opaque not blended. Fixed both the direct writer (QueueExecute.c:2918) AND its generated-split twin (:3475, source-backs same 0x8006c778). Build green.** First-branch code 0x24 + otSide cull + FT3 emit (OT tag 0x07, size 0x20) all Match. +- **`0x8006ae90-0x8006b030` `DrawInstPrim_KeyRelicToken` — Match (verified NOT the same bug).** Shares LitTexture's NCLIP/lighting/brightness/key-relic-table setup, but its code/tpage selection is GENUINELY DIFFERENT (asm 0x8006afd8-0x8006b000: no otSide cull; `bne (tex&0x600000)!=0 → 0x24/0x600000`; `bltz signedTest<0 → 0x24/0x600000 (default)`; else → `0x26/0x200000`). Project (QueueExecute.c:2712-2718) reproduces exactly. **Confirms Fix 132 was LitTexture-specific — verifying KeyRelicToken's own ASM (not blindly replace_all'ing the shared-looking block) prevented wrongly "fixing" correct code.** + +- **`0x8006d670-0x8006d79c` `DrawInstPrim_Ghost` — Match (no bug).** alpha==0 → tail-call normal writer (asm `beq t2,0,0x8006adc8`); else DPCT + build a 0x40-byte ghost packet: mask (drawMode `0xe1000a40`@+4, pad 0, colorAndCode=scratch@0x124, SXY0/1/2), then flat (tex==0: drawMode `0xe1000a20`@+0x1c, code `0x32000000`|RGB0, G3 body) or textured (tex!=0: `texWord1=(tex->u1 & ~0x600000)|0x200000` [asm `lui 0x60;nor;and;lui 0x20;or`], code `0x36000000`, GT3 body u0@0x24/u1@0x30/u2@0x3c, r1@0x28/r2@0x34); LinkPrimRaw OT tag `0x0f`, cursor += 0x40. Project (QueueExecute.c:2952-3021) reproduces exactly. Ghost code words are unconditional (0x32/0x36) — no Fix-132-class issue. + +- **`0x8006a52c-0x8006a8e0` `DrawFunc_Normal` (top-level command-stream dispatcher) — decode masks spot-verified Match; full loop is a JIT blob.** This is a tangled JIT-style draw blob (a vertex-transform inner loop at 0x8006a52c — `gte_ldVXY0/1/2; jalr s6(uncompress); RTPT` — interleaved with the command-decode + primitive-dispatch). Confirmed the discrete decode masks vs project (QueueExecute.c:3921+): **color-only** `srl t0,t3,0x10; bne t0,0` = `(command>>16)==0 → ApplyColorOnlyCommand` ✓; **command fetch/terminator** `lw t3,0(t9); addiu t9,4; addiu t0,t3,1` (command+1 for 0xffffffff) ✓; **backface cull** `NCLIP; stMAC0; xor; blez → skip` + **`AVSZ3`** avg-Z depth → `stMAC0 0x2c(at)`=depthMac0 ✓; **primitive dispatch** `jalr s5` w/ `sw a2,0x50(at)`=tex ✓; **texIndex** `andi a2,t3,0x1ff` ✓. The full control flow (strip management, RTPS-vs-RTPT selection, JIT register reuse) is reconstructed behaviorally, not 1:1 — a complete instruction-level verification of DrawFunc_Normal/Split/Special is a dedicated multi-iteration effort with lower tractability than the writers. + +**Result:** all 7 primitive writers verified (FADE_COLOR, DepthFade, ClampDepth, shared NormalAtOTEntry, LitTexture [Fix 132], KeyRelicToken, Ghost) + DrawFunc_Normal decode-masks — the tractable draw-path is substantially hardened. **1 bug found (Fix 132) across the whole draw-path pass; the reconstruction is otherwise faithful.** Remaining (lower-priority, JIT-blob) targets: full DrawFunc_Normal/Split/Special (@0x8006a52c/b030/bbc0) control-flow and the split-primitive candidate builders (@0x8006b4c8+/0x8006d094+). For future loop iterations, an alternative higher-yield hardening target than grinding these JIT blobs is re-scanning discrete functions elsewhere for the Fix-130/131/132 bug class (wrong-parameter dispatch, wrong-variable flag mask, wrong code word) — the pattern that has produced every hardening-pass find. + +### Bug-class re-scan (2026-07-06) — VehPhysCrash_AnyTwoCars @0x8005d404 (DriverCrash_AnyTwoCars) — Match, NO bug +Re-verified the two-car collision resolver (called from BOTS_ThTick_Drive §1) specifically for the Fix-130 wrong-parameter-dispatch class. **Clean.** Project (VehPhysCrash.c:284) refactors the decomp's nested 4-way `ACTION_BOT`(0x100000) branch into helper-based cases (WeightedVelocity=mass-weighted COM by Driver.nMass, BouncePair=BounceSelf×2, AddImpulse/SubImpulse=`vel±normal·hitStrength>>8`), but all 4 cases map exactly: both-human & this-human/other-AI → SFX+Attack (PlayHumanFeedback + the two Attack calls); this-AI/other-human & both-AI → return before SFX. **The Fix-130-risk `Attack` chaining is CORRECT: `attackResult=Attack(self,other,canPlayFeedback,0); Attack(other,self,attackResult,1)`** (2nd call's 3rd arg = 1st call's return, exactly matching decomp `pen=Attack(this,other,boolPlayFx,0); Attack(other,this,pen,1)`). hitDir=`dist<<0xc/sqrt(distSq)` (z=0x1000 default), hitStrength=`(hitR+hitR)−dist` (>0 gate), SFX-throttle `(frameTimer−audioDefaults[8])>=3`, ACTION_HUMAN_HUMAN_COLLISION 0x10000000 all Match. Constants `_Static_assert`'d (ACTION_BOT 0x100000, HUMAN_HUMAN_COLLISION 0x10000000). Bounce/COM math factored to helpers. **VehPhysCrash_PlayHumanFeedback (VehPhysCrash.c:243, the SFX/rumble block factored out of the inline decomp) — also verified Match, NO bug:** ENGINE_ECHO bit extraction `(actionsFlagSet & ACTION_ENGINE_ECHO 0x10000)!=0` = decomp `(u16@actionsFlagSet+2)&1` (Fix-131-class bit read, correct); volume `MapToRange(impact,0,0x1900,0x3f,0xff)`, Voiceline `>0xdc`; rumble ternary `simpTurnState>0?0x29:0x19` ≡ decomp `<1?0x19:0x29` (identical for s8); ShockForce1 8/0x7f, JogCon1 /0x60; `impact≤0x200→return` gate; rumble+`|=HUMAN_HUMAN_COLLISION 0x10000000` on both fire for any impact>0x200, SFX only if PLAYER & !BLASTED & !invincible. Net: the bug-class re-scan of the whole two-car collision resolver (main + 2 helpers) found the risky patterns (chained-return param, high-bit flag extraction) all implemented correctly — no fix. + +### Bug-class re-scan cont'd (2026-07-06) — VehPhysCrash_Attack @0x8005d218 — Match, NO bug (Fix-130 attacker-side confirm) +Verified the damage-dispatch function (the ATTACKER side of the Fix-130 area — it SETS the pendingDamage fields that BOTS_ChangeState/VehPickState_NewState consume). 3 outcomes, all making `driver1` the loser, each writing 3 distinct fields — **ASM-confirmed the damageType/reason pairs are NOT swapped**: (1) driver2 has MASK_WEAPON 0x800000 → `li 0x2;sb 0x4ff`(pendingDamageType=2 blast), `li 0x6;sb 0x504`(pendingDamageReasonByte=6), `sw s1,0x500`(attacker=driver2); (2) driver2 bubble & !driver1 bubble → Shield.flags|=8, clear bubble, type=2/reason=0/attacker; (3) impact>0xa00 & driver2 reserves & driver2 `andi 0x200`(TURBO_ITEM) & driver1 reserves==0 (`lh 0x3e2;bne`) → driver2 forcedJumpType=`li 0x2;sb 0x366`(FORCED_JUMP_HIGH), `li 0x3;sb 0x4ff`(type=3 squish), `li 0x5;sb 0x504`(reason=5), attacker. Gate `driver1 & MASK_WEAPON 0x800000 → skip` (invincible/mask-armed). SFX = OtherFX_DriverCrashing(ENGINE_ECHO bit, 0xff)+Voiceline(VOICE_HURT 1) when boolPlayFx & !BLASTED(kartState!=6) & invincibleTimer==0; extraSfx OtherFX_Play(0x4f). return boolPlayFx. Project (VehPhysCrash.c:147) matches exactly incl. VehPhysCrash_Attack_SetReason→pendingDamageReasonByte@0x504. Constants _Static_assert'd (MASK_WEAPON 0x800000, TURBO_ITEM 0x200, reason@0x504). **End-to-end Fix-130 confirmation: attacker writes distinct type(0x4ff)/reason(0x504) fields, consumer (BOTS_ChangeState) switches on reason — both sides correct.** + +### Bug-class re-scan cont'd (2026-07-06) — VehPickState_NewState @0x80064568 — Match, NO bug (the HUMAN analog of Fix-130's BOTS_ChangeState) +The human-driver damage applier — same 4-param `(driver, damageType, attacker, reason)` signature as the AI's BOTS_ChangeState where Fix 130 lived. **Verified the exact Fix-130 risk: the stat/score credit switches on `reason` (param4), NOT `damageType` — CORRECT.** Both project switches (VehPickState.c:133 victim stats, :156 attacker stats gated on attacker!=0 & !=victim) dispatch on `reason`, provably equivalent case-by-case to the decomp's single `switch(attackKind)`: reason 1 bomb (numTimesBombHitYou/BombsHitSomeone+quip4|1), 2 motionless-potion (victim only), 3 missile (MissileHitYou/HitSomeone+quip4|2), 4 moving-potion (attacker only +quip4|4), 5 squish (SquishedSomeone), 6 crash/mask (quip4|8). State-select correctly uses `damageType`: 0 none / 1 spin (SpinFirst_Init, NoInput 0x3c0) / 2 tumble (Tumble_Init, 0x960, squishTimer=0) / 3 squish (0xf0 NoInput+0xf00 squishTimer→SpinFirst) / 4 burn (0x780+0xf00 burnTimer→SpinFirst) / 5 plant (PlantEaten_Init, 0xd20); the goto-SPINOUT / keep-funcPtr control flow reproduces the decomp LAB_800647c8/800646f0 exactly. Block/early-outs (kartState==KS_MASK_GRABBED→0; MASK_WEAPON 0x800000|invincibleTimer→Voiceline(VOICE_BLOCKED 2)+0; bubble→Shield.flags|1+invincibleTimer 0x2a0+clear+0), ENGINE_ECHO squish-echo bit (actionsFlagSet&0x10000), voice values (tumble/burn/plant=VOICE_HURT 1, squish=4), timers, quip4(=unk_4F0_4F8+6) bit masks all Match. BattleHUD GTE-projection/RB_Player_KillPlayer/score-delta tail is battle-scoring (verified structure; GTE-scratch not bug-class-risky). **The AI version (BOTS_ChangeState) had the wrong-param bug (Fix 130); the human version here is correct — the re-scan confirms the analog is clean.** + +### Bug-class re-scan cont'd (2026-07-06) — RB_Hazard_HurtDriver @OVR_231::0x800ac1b0 — Match, NO bug (damage SOURCE dispatcher) +The shared hazard/weapon damage entry point. Routes by victim AI-ness: `(actionsFlagSet & ACTION_BOT)==0` → `VehPickState_NewState(victim, damageType, attacker, reason)` (human) else `BOTS_ChangeState(victim, damageType, attacker, reason)` (AI). **Parameter order correct for both consumers** (victim, damageType, attacker, reason). Oxide rewrite: `if (levelID==OXIDE_STATION && IS_BOSS_RACE(gameMode1)) damageType=DAMAGE_SPINOUT(1)` — decomp `(int)gameMode1<0`, project `IS_BOSS_RACE(gm)=((gm)<0)` (bit-31), matches; only the AI branch, `reason` untouched (consistent w/ Fix-130 note). Returns the consumer's result. Project (231_006:5) exact. + +**PIPELINE COMPLETE (2026-07-06):** the entire damage/hazard pipeline is bug-class-verified end-to-end — SOURCE (RB_Hazard_HurtDriver dispatch + Oxide rewrite) → ATTACKER (VehPhysCrash_Attack sets distinct type@0x4ff/reason@0x504) → COLLISION (AnyTwoCars/PlayHumanFeedback, chained-return + high-bit flag) → CONSUMERS (AI BOTS_ChangeState [Fix 130, was buggy] + human VehPickState_NewState [correct]). The Fix-130-class dispatch (parameter/variable/order) is correct throughout except the one AI-consumer bug already fixed. To reach the OVR_* overlay functions in Ghidra (single program w/ 13 overlay spaces, image_base 0x80000000), use the `OVR_231::addr` space-prefixed address form — plain 0x800ac1b0 hits empty default-space → "bad instruction data". + +### Bug-class re-scan cont'd (2026-07-06) — Fix-132 class (GPU code word / blend bits) — Particle_RenderList @0x8003f590 — Match, NO bug +Spot-checked the particle renderer (huge reconstructed fn, NO decomp .c — same high-risk category as RenderBucket where Fix 132 was found) for the Fix-132 wrong-code-word/blend-bit class. **All discrete primitive-emit values Match:** billboard POLY_FT4 code `0x2c000000` (Particle.c:910) + tpage `(icon.u1word & 0xff9fffff) | ((flagsSetColor & 0x60)<<16)` (:912, the semi-trans ABR fold — exactly the Fix-132-style blend selection, correct) + OT tag `0x09000000` (:646); 3D-quad code `0x50000000` (:686) + drawMode `0xe1000a00 | (flagsSetColor & 0x60)` (:700) + OT tag `0x06000000` (:638); alpha bit `flagAlpha & 0x80 → color|=0x2000000` (Particle_SetColors:469-470), no-R white `0x01000000`. All = decomp (`color|0x2c000000`, `uMat24 & 0xff9fffff | (flagAlpha&0x60)<<0x10`, `color|0x50000000`, `flagAlpha&0x60|0xe1000a00`, `*pOtSlot|0x9000000`/`|0x6000000`). NOT line-verified: the 8-case billboard sin/cos rotation-matrix construction (uMat7/17/18/19/21/24 GTE-element maze) — the irreducible reconstructed part, behavior recovered per plate. Net: the reconstructed particle renderer's code-word/blend/tag/alpha values are faithful — no Fix-132 recurrence. + +**DrawSky_Piece @0x80069cc4 (Ghidra DrawSky_RenderTris; hand-optimized register-coupled skybox tri renderer, irreducible per plate) — Match, NO bug.** No conditional code word (always POLY_G3), so no Fix-132 risk; verified the clip/reject + emit instead. DrawSky_IsVisible (DrawSky.c:20): `((gteFlag<<13)>>29)!=0→reject` ≡ decomp `(FLAG & 0x7ffff)>>0x10==0` (FLAG bits[18:16]); `~((sxy0-sb)|(sxy1-sb)|(sxy2-sb)) | (sxy0&sxy1&sxy2)`, `(s32)bounds<0→reject`, `(s32)(bounds<<16)>=0` — all 3 conditions match (sb=screenBounds=in_t7). DrawSky_EmitPrimitive (:56): G3 from MFC2(20/21/22)+MFC2(12/13/14), OT tag `0x06000000`, 24-bit link, +7-word advance (code byte pre-baked in vertex color). Software-pipelined loop structure reproduced. + +**Fix-132-class re-scan summary:** 3 reconstructed primitive emitters checked — RenderBucket (LitTexture → Fix 132), Particle_RenderList (clean), DrawSky_Piece (clean). The wrong-code-word/blend class is real but isolated; the ASM-reconstructed emit values are otherwise faithful. + +### Bug-class re-scan cont'd (2026-07-06) — Fix-131 class (flag-value coincidence) — EngineSound_Player @0x8002f5f4 — Match, NO bug +Audio subsystem (HOWL_Engine.c:131), checked for the Fix-131 wrong-variable-flag-mask class, specifically the `ACTION_ENGINE_ECHO 0x10000` == quadblock `REVERB 0x10000` value coincidence. **Clean: the echo bit correctly reads `driver->actionsFlagSet & 0x10000` (line 280, the driver echo flag COLL sets on a reverb quad), NOT quadFlags.** All flag masks + magic constants Match: `ACTION_BOT 0x100000` human/AI split (volMax 0xe6/0xbe, pitchMax 200/0xbe), `ACCEL_PREVENTION 8` wheel-vol gate, echo `|0x1000000`; vol slew (−500/drift-floor 2000/+2000/cap 14000), mode0/1 MapToRange (0/0x82,0xe6), sfxDistortOffset turbo formula `(const_turboMaxRoom>>1)−(turbo_MeterRoomLeft>>6)`, steer-kick `drift=(s16)turnWobbleAngle>>3` (=Ghidra nSteerKickAngle `<<0x10>>0x13`), distort cap 0xff, pan `0x80−wheelRotation>>3` clamp 0x40/0xc0, KS_ENGINE_REVVING RPM blend. Field-name map: engineSoundVolumeState=nEngineSndVolAccum, sfxDistortOffset=nSfxDistortOffset, turnWobbleAngle=nSteerKickAngle, const_AccelSpeed_ClassStat=const_SpeedometerScale_ClassStat. + +**HARDENING PASS SUMMARY (2026-07-06):** systematic re-scan of the three found bug classes across highest-risk functions — Fix-130 (param/dispatch): full damage pipeline clean except orig Fix 130; Fix-131 (flag-value coincidence): DRIFT_MASK→Fix 131, ENGINE_ECHO/REVERB clean; Fix-132 (code word/blend): LitTexture→Fix 132, Particle/DrawSky clean. Net hardening yield: Fix 132 (+ the 130/131 found during BOTS_ThTick_Drive). The two numeric-coincidence traps (0x1800, 0x10000) each checked in ≥2 sites; the reconstructed/JIT functions remain the highest-risk residual (draw-func dispatchers not fully line-verified). + +### Bug-class re-scan cont'd (2026-07-06) — sign/truncation class — VehCalc_MapToRange @0x80058f9c — Match, NO bug (high-leverage) +The universal clamped-linear-remap helper (VehCalc.c:26), underpinning dozens of verified callers (EngineSound vol/pitch, crash-SFX volume, VehPickState/Attack damage feedback, HUD mapping). Verified exact vs decomp: `val<=oldMin→newMin`, `val>=oldMax→newMax` (project) ≡ decomp `value<=inMin→outMin / value>8/[len-1]=crc. ChecksumLoad (MEMCARD_03): chunked (byteIndex+0x200 unless MEMCARD_STATUS_SYNC_CHECKSUM 0x8 → finish to len-2), stores byteIndex/crc checkpoint, on final chunk folds saveBytes[len-2]/[len-1] and returns `(crc!=0)` [0=valid] else MC_RETURN_PENDING 7. Constants confirmed (SYNC_CHECKSUM 0x8, PENDING 7); byte ranges, endianness, chunk size, flag mask, return codes all Match. (Native inlines the CRC that retail's decomp routes through an async helper — 926 binary authoritative, project follows binary.) **MEMCARD_CRC16 @0x8003d540 (the CRC step both call) — exact Match: loop i=7→0 MSB-first, `crc=(crc<<1)|((nextByte>>i)&1)`, `if((crc<<1)&0x10000) crc^=0x11021` (CCITT poly 0x11021). Precedence/shift-dir/poly/overflow all match; nextByte always 0-255 so signed-int `>>` harmless, crc unsigned. Completes the save-integrity subsystem end-to-end (CRC16→ChecksumSave→ChecksumLoad, all faithful — a single bit-error would corrupt every save).** + +### Bug-class re-scan cont'd (2026-07-07) — determinism primitive — RngDeadCoed @0x8006c684 — exact Match, NO bug +Verified the 64-bit "DeadCoed" RNG implementation (name/seed 0x8008d668 already confirmed in BOTS §2; now the MATH): `ret=b>>8; newStateA=(a>>8)|(b<<24); mixed=(ret | ((a+ret+(newStateA>>8))<<24)) ^ 0xDEADC0ED; state[1]=mixed; state[0]=newStateA; return mixed`. Project (RngDeadCoed.c) exact — shifts (>>8/<<24), add-mixing, 0xDEADC0ED magic, and write order (locals computed before stores, no aliasing) all match; `<<24` = decomp `*0x1000000`. Critical determinism primitive driving bot start-boosts, particle spread (MixRNG_Particles), menu/weapon RNG — a bug would desync ghosts/replays. Faithful. **RNG FAMILY COMPLETE 2026-07-07: MixRNG_Scramble @0x8003ea28 (`randomNumber=(randomNumber*0x6255+0x3619)&0xffff`, 16-bit LCG), MixRNG_GetValue @0x8003eaac (stateless `(seed*0x6255+0x3619)&0xffff`), MixRNG_Particles @0x8003ea6c (`(RngDeadCoed(&deadcoed_struct)&0xffff)*range>>16`, same `(int)` cast as decomp) — all exact Match. LCG mult 0x6255/inc 0x3619/mask 0xffff + range-scale all faithful. Entire RNG subsystem (RngDeadCoed + 3 MixRNG) verified — all gameplay determinism sound.** + +### Bug-class re-scan cont'd (2026-07-07) — ghost record format — GhostTape_WriteMoves @0x80027f20 — Match, NO bug +Re-verified the ghost-replay input recorder (determinism-critical; GhostReplay.c was recently refactored but this is the RECORD side / GhostTape.c). **Clean — including a subtle in-place-delta bookkeeping that initially looked risky but is correct:** project overwrites `VelX=(s16)iVar4−VelX` (delta) for the range-check + small-delta byte writes, uses `iVar4` (position) for the 0x80 keyframe bytes, then RESTORES `VelX=(s16)iVar4` at the end (line 291) — equivalent to decomp's local-delta + store-position. All Match: opcodes 0x80(keyframe)/0x81(anim)/0x83(split)/0x84(idle)/bare-5-byte(small delta); pos `matrix.t>>3`, rot `>>4` (`(char)(s16>>4)` ≡ decomp `(byte)((ushort)>>4)` after byte truncation, benign sign); big-endian pos/time; delta range [−0x7b,0x7f], 16-frame `&0x1f`, timeDelta<0xff01; SPLIT_LINE bit 13; overflow-stop `end>0xd)` = `<<3` (inverts record `>>3`); rot `byte<<4` (inverts `>>4`) read from [9]/[10] (0x80) & [3]/[4] (small-delta) — matching record layout; big-endian time [7-8]; small-delta pos `tmpPos.v[i] += (s16)((s8)packetPtr[i])*8` = `(s8)delta*8` (=`<<3`, decomp uses `<<0x18>>0x15` for X / `(char)*8` for Y/Z — equal). Opcode dispatch 0x80(keyframe)/0x81(anim, clamp via Instance_GetNumAnimFrames)/0x82(boost VehFire, gated light+gameMode 0x40+RaceFlag_IsFullyOnScreen)/0x83(split bit13)/0x84(idle, copy prev)/bare-5(delta); two-keyframe packet-cache rebuild; 12-bit shortest-path rot lerp + linear pos lerp by uFrac/0x1000; end-of-tape → BOTS_Driver_Convert+ThTick_Drive+RACE_FINISHED. **Ghost system verified END-TO-END: WriteMoves(encode) ↔ ThTick(decode) are correct inverses (>>3↔<<3, >>4↔<<4, delta↔·8) — recent refactor faithful, determinism holds.** + +### Bug-class re-scan cont'd (2026-07-07) — DecalHUD.c full-file re-verify (4 fns, packed-XY bit math — Fix-132 class) — Match, prior fixes confirmed accurate +Re-verified all 4 icon-draw fns vs decomp+ASM. Focus: packed `x | (y<<16)` vertex writes (the class where a high-bit posX corrupts the packed Y). **Key finding — posX-masking asymmetry is REAL and correctly handled:** +| Fn | Addr | Retail masks posX? | Project | Verdict | +|---|---|---|---|---| +| DrawPolyFT4 | 0x80022db0 | **No** (decomp packs bare `posX`) | bare `posX` | Match — faithful reproduction of retail's latent raw-posX (HUD posX always small +ve → unobservable) | +| DrawWeapon | 0x80022ec4 | **No** (ASM 0x80022fe8 `or a0,a1,a2`, NO `andi` on a1=posX; only `andi v0,t5,1` for rot&1) | `(u16)posX` | Match + prior NOTE(claude) hardening fix **CONFIRMED accurate**; all 4 rot orientations (0/1/2/3) vertex-map exact | +| DrawPolyGT4 | 0x80023054 | **Yes** (decomp `uPosX=(uint)(ushort)posX`) | `(u16)posX` | Match — retail DOES mask here; project `(u16)` faithfully mirrors (NOT a fix) | +| Arrow2D | 0x80023190 | n/a (rotated basis) | `(s16)scale` fix | Match — octant select (0x400/0x800 sign swap), `data.trigApprox[(rot&0x3ff)*4]` sin/cos, `>>0xd` half-extents, all 4 corner packs exact; prior `(s16)scale` truncation fix CONFIRMED accurate | +Extent math `FP_Mult(a,scale)=(a*scale)>>12` == decomp `*(int)scale>>0xc` (FRACTIONAL_BITS=12); Arrow2D uses `>>0xd` (half). Transparency codes (FT4 0x2d/0x2f, GT4 0x3c/0x3e, ABR `(trans-1)*0x200000` w/ `&0xff9fffff`), OT-link (`|0xc000000`/`|0x9000000`, `PrimToOTLink24`) all match. **Note:** DrawPolyFT4 & DrawWeapon share retail's no-mask behavior but project treats them differently (FT4 faithful, Weapon hardened) — both individually defensible; left as-is (adding a defensive cast to the correct-and-faithful FT4 for a non-triggering path would introduce a needless retail divergence). No new bugs; 2 prior fixes re-confirmed. + +### Bug-class re-scan cont'd (2026-07-07) — DecalMP.c full-file re-verify (3 fns, split-screen remote-kart decals) — Match, 3 prior fixes CONFIRMED accurate +Re-verified DecalMP_01/02/03 vs decomp; these carry 3 NOTE(claude) fixes — all confirmed correct: +| Fn | Addr | Prior fix | Decomp evidence | Verdict | +|---|---|---|---|---| +| DecalMP_01 | 0x80023488 | `inst->flags\|=(PUSHBUFFER_EXISTS\|PIXEL_LOD)` writes inst+0x28, not idpp | decomp `pInst->flags = pInst->flags \| (PIXEL_LOD\|PUSHBUFFER_EXISTS)` (0x300) | **CONFIRMED** | +| DecalMP_01 | " | per-camera idpp link `idpp=GETIDPP+cam*0x88` | decomp `iInstOfs=iPlayer*0x88`, `*(pInst[1].name+iInstOfs-8)=&entry->pb` | **CONFIRMED** | +| DecalMP_03 | 0x80023784 | quad corners from `renderBucketScreenPos/Size` not `pb.rect` | decomp `pEntry+4/6/8/10`=pb+0x100..0x106 (pEntry=entry+0x114); same loads feed SetDrawEnv+renderW/H | **CONFIRMED** | +Full-body matches: DecalMP_01 BLASTED reset writes `data[0]=0xE8,data[1]=3`=timer 1000 (byte-pack proves `entry->timer=1000`), matrix/pos/rect/ptrOT/cameraID copies (`tileView`≡`pushBuffer`); DecalMP_02 clip-budget (timer cap 1000, threshold `OTByteOffset>>3` min 2, 0x140 gate, timer-parity `(gGT->timer^idx)&1`, OT-relink at `[0x3ff]`, paused-skip-writeback flow); DecalMP_03 UV (`texX&0x3f`/`texY&0xff`+renderW/H, v-clamp 0xff), tpage (`(texY&0x100)>>4 \| (texX&0x3ff)>>6 \| 0x100 \| (texY&0x200)<<2`), SetDrawEnv offsets, FT4 code 0x2d, OT-link `\|0x9000000`. **Nuance:** DecalMP_03 XY packing uses `(u16)` masks vs retail's sign-extend (`uBaseX=(uint)(s16)x`) — would corrupt packed Y for x<0, but render-bucket screen coords always ≥0 (kart on-screen to reach the 0x140-gated path) → no divergence in practice, consistent w/ `WritePackedXY` helper. No new bugs; 3 prior fixes re-confirmed. + +### Bug-class re-scan cont'd (2026-07-07) — DecalFont.c full-file re-verify (7 fns, glyph renderer) — Match (UsaRetail/926 path), NOTE(claude) unsigned fix CONFIRMED +Verified the whole font module vs decomp on the BUILD==UsaRetail active path (926). All 7 fns: +| Fn | Addr | Verdict | +|---|---|---| +| GetLineWidthStrlen | 0x800223f4 | Match + **NOTE(claude) unsigned-char fix CONFIRMED** (decomp `22`; signed `char` would zero-width bytes ≥0x80). Project's separate-`if`s (button→`button`, punc→`punc-char`, normal→`char`) traced == decomp's else-if (button→`char+button`, punc→`punc`, normal→`char`) for all 4 char classes | +| GetLineWidth | 0x800224d0 | Match (wrapper, `(s16)`+len=-1) | +| DrawLineStrlen | 0x800224fc | Match — full UsaRetail glyph path: justify center `(w<<16)>>17`=`w/2`/right=`w`; **unsigned iconID range `((u32)*strcopy-0x21)<0xdf`** (the "why u32" comment = unsigned range check, confirmed); dakuten `c<3` + kana `iconID>0x7f` (grp 15, or 14 if grp==4); folded-base byte-table addressing (charIconID@0x80082378 idx `c-0x21`, indentIconID base+8) matches decomp's folded reads; numIcons bound, `icons[iconID]`, DrawPolyGT4 args (color[0..3]/scale, `pushBuffer_UI`≡`tileView_UI`). `CTR_NATIVE` null-guard = documented native-boot safety, not a divergence | +| DrawLine | 0x80022878 | Match (wrapper) | +| DrawLineOT | 0x800228c4 | Match (backup/set/restore `ptrOT`) | +| DrawMultiLineStrlen | 0x80022930 | Match — word-wrap (space-skip, word-scan to NUL/space/`\r`, `maxPixLen<=lineLen` break, `+=charPixHeight`); `<<16>>16` return-trunc is harmless no-op | +| DrawMultiLine | 0x80022b34 | Match (wrapper) | +Char-class mutual-exclusivity (punc/button/dakuten/printable never overlap) makes project's separate-`if` structure ≡ decomp else-if. No new bugs; 1 prior fix re-confirmed. **Decal* family COMPLETE (DecalHUD+DecalMP+DecalFont = whole text/HUD/decal render subsystem hardened).** + +### Bug-class re-scan cont'd (2026-07-07) — FLARE.c full-file re-verify (2 fns, GTE-reconstructed lens flare) — Match, NOTE(claude) bowtie fix CONFIRMED +Verified the GTE-heavy lens-flare renderer (a high-risk reconstructed-from-ASM residual) vs decomp. Both fns match retail exactly: +| Fn | Addr | Verdict | +|---|---|---| +| FLARE_ThTick | 0x80024c4c | Match — full GTE draw path (see below) | +| FLARE_Init | 0x80025138 | Match — `PROC_BirthWithObject(0xc030d,...,"lensflare")`, `flare[0]=0`, `memcpy(&flare[1],pos,8)`=`flare[1]=pos.xy;flare[2]=pos.z` | +FLARE_ThTick stage-by-stage: **lifetime** `timer<20`→render/else `flags\|=THREAD_FLAG_DEAD(0x800)`; **guard** room-for-4-POLY_GT4 (`+0xd0` bytes); **projection** `relX/Y/Z=((s16)pos-cam.t)<<2`, VXY0 pack `relY<<18`=`(relY<<2)<<16`, `llv0`+`mvlvtr` (=`CTC2(MFC2(25/26/27),5/6/7)`=MAC1/2/3→TRX/TRY/TRZ); **anim** 4-way `MapToRange` bounds `(0,2,0x400,0x2000)/(2,4,0x2000,0xc00)/(4,8,0xc00,0x266)/(8,20,0x266,0)`, `angle=(flare[0]<<12)/20`, `sin/cos*scale>>12`; **rot matrix** `scaledCos/Sin=(x<<9)/0xf0`, R11=cos/R12=-sin/R21=sin/R22=cos/R33=scale (`-scaledSin<<16`=decomp `*-0x10000`); **3 grid rows** y=-409/0/409 (`PackXY` 0xfe67fe67/0xfe67/0x199fe67…) each `rtpt`+tex/color/xy writes; **NOTE(claude) bowtie fix CONFIRMED** — middle row feeds p0.x2/x3,p1.x3←SXY1/x2←SXY2,p2.x2/x3,p3.x3←SXY1/x2←SXY2; bottom row feeds p2.x0/x1,p3.x0/x1 — byte-exact to decomp `stsxy` targets; **depth** captured from row-2 SZ2 *before* row-3 rtpt overwrites (correct ordering), `(depth>>8)-2` clamp `[0,0x3ff]`, OT chain p0→p1→p2→p3→ot, `curr=prim+4`. Minor: LoadGridRow redundantly rewrites VZ=0 per row (retail relies on it staying 0) — idempotent, no divergence. No new bugs; 1 prior fix re-confirmed. + +### Bug-class re-scan cont'd (2026-07-07) — UI_Speedometer.c UI_DrawSpeedNeedle re-verify (angle/sign-sensitive) — Match, 2 NOTE(claude) fixes CONFIRMED +Verified UI_DrawSpeedNeedle @0x800511c0 vs decomp — sign/carry-sensitive needle math, both PolyG3 faces: +- **maxScale carry fix CONFIRMED**: decomp `iMaxScale = iMinAngle + const_SacredFireSpeed >> 8` = `(stat+fire)>>8` (sum FIRST, C precedence `+`>`>>`) — shifting separately would lose the low-byte carry. Field mislabels hold: DB `const_SpeedometerScale_ClassStat`@0x42E = project `const_AccelSpeed_ClassStat`; DB `nSpeedometerNeedle`@0x36E = project `speedometerNeedleValue`. +- **`>>9`-after-`+0x1ff` fix CONFIRMED (×6)**: decomp `iTrig=(cos*k>>11)*0x140; if(iTrig<0)iTrig+=0x1ff; iTrig>>9` — the retail round-toward-zero-for-negatives divide idiom. Project reproduces with `if(yLen<0)yLen+=FP8(2)-1; yLen>>=9` (arithmetic shift, NOT `/=512` which would double-correct). `FP_INT(cos*8)`=`cos>>9`=`(cos<<2)>>11`; `FP_INT(sin*6)`=`sin*3>>11`; `FP_INT(x*60)`=`x*30>>11` all reconcile. +- Regime select `(s16)stat < (s16)speed` → arc `ANG(157.5)=0x700`/`ANG(213.75)=0x980` + raised minScale, else `0x980`/`ANG(305.2)=0xd90` + maxScale=accelInt; `speedScale=FP8_INT(speed)*108000/64000`; `MapToRange(speedScale,minScale,maxScale,minAngle,maxAngle)`; `angle2=angle1+ANG(90)=0x400`. +- Both faces' colors exact (front 0x30005b5b/0x30012b32/0x3000bbff = (91,91,0)/(50,43,1)/(255,187,0); back 0x30ffffff/0x3000699c/0x3000ffff); mirrored v[2] (front `posX+sin`, back `posX-sin`), shared v[0]/v[1]; PolyG3 7-word alloc + guard, OT-link `|0x6000000`, `pushBuffer_UI`≡`tileView_UI`. No new bugs; 2 prior fixes re-confirmed. + +### Bug-class re-scan cont'd (2026-07-07) — UI_Speedometer.c UI_DrawSpeedBG re-verify — Match, 2 NOTE(claude) fixes CONFIRMED (UI_Speedometer.c COMPLETE) +Verified UI_DrawSpeedBG @0x800516ac vs decomp. 3 passes all match: +- **Pass 1** (7 tick-line pairs): white `(0xff,0xff,0xff)` wire + black `(0,0,0)` shadow at `+1,+1`, offsets `+0x1e0/+0xbe` via CTR_Box_DrawWirePrims; loop `iLoopIdx<0xe`. ✓ +- **Pass 2** (6 color bands): white/black verticals + gouraud PolyG4 (code `0x38`=poly|gouraud|quad, tag `|0x8000000`). **Band-color chain fix CONFIRMED**: decomp uses a short-circuit `&&`+comma-side-effect `if((2>16)==0` gate; ==0 fall-through (0x8006a628-a674) updates rolling color/tex ptrs via bit0/bit1 sub-selects + loops back w/o emit = `ApplyColorOnlyCommand;continue`. ✓ +- Backface path present (NCLIP@0x8006a5b4, winding XOR, `blez`@0x8006a5c8 signed cull), RTPT@0x8006a574/RTPS@0x8006a6ac vertex rolling, AVSZ3@0x8006a5dc depth — structurally consistent, not yet line-verified. **STATUS: landmarks anchored, NOT declared bug-free. Full equivalence of DrawFunc_Normal (+Split/Special) inner loop remains the top open residual — needs a dedicated multi-cycle deep-dive across the projection/cull/emit helpers.** No code change; no new bug found in the verified landmarks. + +### Bug-class re-scan cont'd (2026-07-07) — RenderBucket draw-path leaf helpers deep-dive (2 clean fns) — Match, sign-sensitive delta decoder FULLY verified +Continued the DrawFunc deep-dive via the CLEAN leaf helpers (these DO have Ghidra boundaries, unlike the monolithic loop): +- **RenderBucket_InitDepthGTE @0x8006ae74 — EXACT**: `CTC2(0,27)/CTC2(0,28)/CTC2(0x555,29)/CTC2(0x400,30)` = DQA=0/DQB=0/ZSF3=0x555(4096/3 tri)/ZSF4=0x400(4096/4 quad), fog off. (Ghidra DB name GTE_SetDepthAndAvgZConsts; syms name RenderBucket_InitDepthGTE.) +- **RenderBucket_UncompressAnimationFrame @0x8006a8e0 — FULLY verified** (the `jalr s6` per-vertex delta decompressor feeding ALL DrawFunc loops — the sign-sensitive core). vs decomp: + - iVertOut = `(in_t3>>0xd & 0x7f8)+frameOrigin` = frameOrigin + stackIndex*8; cached path bit26 (`flags&4`): retail leaves scratchpad slot [iVertOut+0x140/0x144] intact (register-ABI), project reads it explicitly + returns — equivalent. + - 3 components decoded in order: widths `(temporal>>6)&7`/`(temporal>>3)&7`/`temporal&7`; **predict terms sign-exact**: X `SignExtendBits(temporal>>25,7)<<1`=decomp `(char)((int)temporal>>25)*2` (7-bit,·2); Y/Z `SignExtendBits(temporal>>17/>>9,8)`=decomp `(char)((temporal<<7/<<15)>>24)` (8-bit); **escape `bits==7`→absolute** (skip temporal add) = decomp `if(uVar2!=7)` (NOTE proj 2160 confirmed). + - Bitstream reader REFORMULATED accumulator→index model (`GetSignedBits`: `bitIndex>>5` word, two-word barrel-shift `s<0?(v[b]<<-s)|(v[b+1]>>(s&31)):v[b]>>s`, arith sign-extend width `bits+1`) — **proven equivalent** to retail accumulator (`temporalBits`/`remainingBits`): both MSB-first, little-endian u32 loads, same width & consumption order (NOTE proj 373 confirmed, 0x8006a92c-0xaa30). + - ReadDeltaComponentFromStream: `bits==7`→`SignedByte(value)`; else `SignedByte(*accum+value+temporalBase)` (commutes w/ decomp `value+predict+prevByte`, char-trunc). ✓ +**Result:** the reconstructed draw pipeline's VERTEX DATA SOURCE is confirmed faithful — substantially de-risks DrawFunc_Normal/Split/Special. No new bugs; sign-extension/escape claims all re-confirmed. + +### Bug-class re-scan cont'd (2026-07-07) — RenderBucket_DrawInstPrim_Normal @0x8006ad6c (emit side) — Match (primitive packing byte-exact) +Continued the DrawFunc deep-dive to the EMIT end. DrawInstPrim_Normal has no clean Ghidra boundary (reconstructed) — disassembled 0x8006ad6c-0x8006ae74 raw. Project `DrawInstPrim_NormalAtOTEntry` body matches the ASM exactly: +- **OT-entry calc** (GetNormalOTEntry): `otEntry = activeRange + ((depthMac0>>17)<<2)` — `lw t3,0x2c(at)`(=depthMac0, stored by DrawFunc's `stMAC0 0x2c`)`; srl 0x11; sll 2; addu activeRange`. Range-select prologue `sll t3,6; bgtz` = command bit25 → normal/secondary. +- **G3 path (tex==0)**: `r0=0x30000000|MFC2(20)` (`lui 0x3000;or;sw 0x4`), `r1/r2=MFC2(21/22)` (stRGB1 0xc/stRGB2 0x14), stsxy3 xy@8/0x10/0x18, link `0x06000000`, size 0x1c. ✓ +- **GT3 path (tex!=0)**: code `((texWord1&0x600000)==0x600000)?0x34000000:0x36000000` = ASM `lui v1,0x60; and t0; beq v1,t0→0x34 / else 0x36` — **genuine 2-way branch, correctly reconstructed** (same CLASS as Fix 132 but NOT buggy: opaque 0x34 vs semi-trans 0x36 by ABR bits). `u0/u1/u2=tex->u0/texWord1/tex->u2`@0xc/0x18/0x24, xy@8/0x14/0x20, link `0x09000000`, size 0x28. ✓ +- **LinkPrimRaw**: `p->tag=*otEntry|code`; `*otEntry=prim&0xffffff` (`sll v0,8;srl 8`). ✓ +Not pinned in this ASM range: guardEnd/otEntry-null early returns (reconstruction control-flow, likely caller-side); DPCT depth-cue gate (`IR0!=0 && otEntry bit24 clear` @0x8006adb4-adc4) + LoadPrimColors — adjacent helper for a future cycle. **Draw pipeline now verified at BOTH ends: vertex decode (UncompressAnimationFrame) → [projection/NCLIP cull, pending] → primitive emit (DrawInstPrim_Normal).** No new bugs. + +### Bug-class re-scan cont'd (2026-07-07) — RenderBucket DrawFunc_Normal MIDDLE (proj/cull/depth-cue) — Match — **DrawFunc_Normal pipeline COMPLETE end-to-end** +Verified the 3 middle-pipeline helpers vs ASM (0x8006a590-a604 proj gate; 0x8006ad9c-adc4 color): +- **CheckProjectedPrim** (proj 2415): FLAG reject `(gteFlag<<13)<0`=`sll 0xd;bltz`; NCLIP gate `(command<<3)<0`=`sll 3;bgez skip`; **backface cull sign-exact**: `opZ ^ (cullFlags ^ (command<<2))`,`blez`→cull = `NCLIP;stMAC0 t0;xor t1,a1(instFlags),t1(cmd<<2);beq t0,zero;xor t0,t0,t1;blez` (cullXorMask=0 for Normal path → no instFlags XOR, matches `lh a1,0x24`); AVSZ3 (ASM interleaves it mid-screen-test as pipeline-latency opt, project reorders avsz3→test→stopz = equivalent); TriangleInScreenWindow bounds test (SXY0/1/2 vs ctx->0x1c); `stopz→depthMac0`→scratchpad 0x2c. ✓ +- **LoadPrimColors** (proj 2539): `RGB0/1/2=tempColor[1/2/3]`(`ldRGB0/1/2 t6/t7/s0`), `IR0=alphaScale`(`ldIR0 t2`,`lh 0x120`), DPCT gate `alpha!=0 && (otEntry<<7)>=0`=`beq t2,zero;sll t0,t3,7;bltz`. OTEntryPassesDpctGate models bit24 via 24-bit OT-addr domain. ✓ +- **ProjectPrim_Normal** (proj 2450): RTPS/RTPT dispatch + `CheckProjectedPrim(...,CFC2(31),0,...)`. RTPT path 0x8006a540-574 (3 verts via jalr s6=UncompressAnimationFrame), RTPS path 0x8006a680-6ac. ✓ +**MILESTONE: RenderBucket_DrawFunc_Normal verified END-TO-END** — strip state machine (landmarks) + vertex decode + projection + NCLIP backface cull + AVSZ3 depth + screen-window + DPCT depth-cue + G3/GT3 emit + OT-link all confirmed vs ASM. Split/Special share these helpers → substantially covered; residual Split/Special-specific pieces (MirrorSpecialPackedXY, StoreSplitProjectedRegs, DrawWaterSplitClipped, split-plane clip) remain. Sign-sensitive checks (NCLIP winding XOR, FLAG<<13, 7/8-bit delta sign-ext, DPCT<<7 gate) ALL confirmed. No new bugs across the entire DrawFunc_Normal pipeline. + +### Bug-class re-scan cont'd (2026-07-07) — RenderBucket WaterSplit clip interp (Split/Special residual) — Match, sign handling all correct +Verified the water-reflection split-plane clip vertex interpolation. Project merges retail's 3 fns into one `WaterSplitInterpolateVertex` (proj 442), dispatched by `inst->funcPtr[3]`: +- **WaterSplitInterpBase @0x8006d4a4** (Ghidra DB mislabel `ClipTriCb_InterpVertex`; syms authoritative): `factor=(from->splitDist<<16)/(to.Y-from.Y)` (signed div, `(int)in_t0[7]<<0x10 / (in_t1[1]-in_t0[1])`); X/Z/UV lerp `((to-from)*factor>>16)+from`; UV gated by `ctx+0x50`. ✓ +- **WaterSplitInterpWhite @0x8006d404** (ASM): `jal Base; li a2,0xff; sb [8]/[9]/[0xa]` = Base + color `0x00ffffff`. = project `funcPtr[3]==SPLIT_WHITE`. ✓ +- **WaterSplitInterpColor @0x8006d428** (ASM): `jal Base; lbu to/from[8/9/0xa]; subu; mult a1(factor); mflo; sra 0x10; addu from; sb` = Base + RGB InterpU8. = project else-branch. ✓ +- **Sign handling all correct**: Z (full int) uses `sra` arithmetic in ASM = project `MipsMulLoSra16` (arithmetic) — MATTERS & matches; X(short)/UV(byte)/color(byte) truncated → `(short/char)(x>>16)` identical for arith-vs-logical (low16 = product bits16-31 either way), so decomp's `(uint)`/logical rendering for X/UV is a Ghidra artifact — Color-variant ASM proves real shift is `sra`, project InterpU8/InterpS16 (arith) correct. `Y=splitLine` project-shortcut = crossing-point Y by construction (`from.Y+(to.Y-from.Y)·t=splitLine`), equivalent not divergent. +- Helpers InterpU8/InterpS16 (`(u8/s16)(MipsMulLoSra16(to-from,factor)+from)`), MipsMulLoSra16 (`mult;mflo;sra16`), MipsSllSigned all confirmed. No new bugs. Split/Special residual partially covered (split-plane clip interp done; MirrorSpecialPackedXY / StoreSplitProjectedRegs / DrawWaterSplitClipped driver still open). + +### Bug-class re-scan cont'd (2026-07-07) — RenderBucket_Execute @0x8006aaa8 (setup/dispatch tier) — Match, NOTE(claude) reflectionRGBA field-fix CONFIRMED +Verified the bucket setup/dispatch fn that establishes the FIFO ABI + routes to DrawFunc_*. Project splits into Execute (proj 4794) + PrepareDrawContext (4691) + DispatchDrawFunc (4646). vs decomp: +- Execute: scratch 0xc=primMem, 0x8=0, loop `entry->inst!=0`, 0x4=entry+1 (=pBucketNode+8, RenderBucketEntry=8B). ✓ +- Flags gate `(instFlags&0x40)set && (&0x80)clear`. ✓ +- Geom offset (view change, scratch 0x8!=pb): `SetGeomOffset(rect.w>>1,rect.h>>1)`=decomp `ldOFX(rect.w<<0xf)` (>>1 then <<16 = <<15), `SetGeomScreen(distToScreen)`=`ldH`; rect.w/h @pb+0x20/0x22, distToScreen@pb+0x18. ✓ +- **Bbox sign-sensitive**: no-next `pos[0]&0x7fff / pos[1] / (s32)pos[2]`; has-next `pos[0]+next / pos[1]+next / (s32)(pos[2]+next.pos[2])<<1` = decomp per-field sign-ext + `*2`. ✓ +- Rot/Trans matrix `SetRotMatrix/SetTransMatrix(&idpp->mvp)` = decomp ldR11..R33 @renderCtx+0x78..0x88 + ldtr @+0x8c..0x94. scratch 0x24=instFlags, 0x120=alphaScale(idpp+0xbc, read as `lh` by LoadPrimColors). ✓ +- **SPLIT_STATE (0x7000)**: light matrix (idpp->m3x3 @+0x98..0xa8), splitLine(idpp+0xbe)→scratch 0x44/0xda/0xf2, unk53→0x48, **splitLine`<<17`→scratch 0x11c** (decomp `(int)sVar2<<0x11`; this is the MirrorSpecialPackedXY base), 0xe0/0xf8=0. **NOTE(claude) fix CONFIRMED**: scratch 0x4c=`inst->reflectionRGBA`(inst+0x58) = decomp `uVar8=*(iInst+0x58)` (corrected from wrong funcPtr[3]@inst+0x68). ✓ +- Copy loop (model data→scratch 0x140, RenderBucket_CopyScratchColorCache), setup callback `(**(inst+0x5c))()` (RunInstanceSetupCallback), scratch 0x58=0, draw dispatch `(*pDrawFunc)(inst+0x64)` via renderCtx+0xec = project `switch(idpp->unkEC)`→DrawFunc handlers. ✓ +- NOT cross-checked here: `ctx->splitPlane=((s32)CFC2(26)<<1)-(s32)CFC2(7)` (project computes in PrepareDrawContext; not in Execute decomp — likely retail computes it in the split draw func). No new bugs; reflectionRGBA field-fix re-confirmed. **RenderBucket setup→pipeline tie confirmed.** + +### Bug-class re-scan cont'd (2026-07-07) — RenderBucket DrawFunc_Split @0x8006b030 + NormalAlt @0x8006a6b8 (split axes) — Match on values; ⚠️ splitPlane PLACEMENT divergence FLAGGED +Closed last cycle's splitPlane flag by disassembling both split draw funcs. **Values all confirmed:** +- **splitPlane = 2*H - TRZ** CONFIRMED: NormalAlt head 0x8006a6b8 `stH v1; stTRZ t1; sll v1,1; subu v1,v1,t1; sh 0x44; sw 0xdc; sw 0xf4` = project `((s32)CFC2(26)<<1)-(s32)CFC2(7)`. (CFC2(26)=H, CFC2(7)=TRZ.) +- **Two split axes correctly separated**: DrawFunc_Split (0x8006b030) per-vertex `splitDist=splitLine-Y` (`lh 0x44(splitLine); sra Y; subu`) = project InitWaterSplitVertex (line 4409); NormalAlt per-vertex `splitDist=splitPlane-Z` (`lh 0x44(splitPlane); subu`) = project InitSplitVertex (line 4513). scratch 0x44 is REUSED (splitLine for Split/Special, splitPlane for NormalAlt) — project models as idpp->splitLine vs ctx->splitPlane. Both share verified Normal skeleton (0x10c restart, jalr s6 fetch, RTPT, identical NCLIP cull `sll3;bgez;NCLIP;xor winding;blez`, AVSZ3). ✓ +- **⚠️ FLAGGED for user (latent, not fixing unilaterally): splitPlane computation PLACEMENT diverges.** Retail computes splitPlane at the HEAD of NormalAlt (0x8006a6b8) — UNCONDITIONALLY and AFTER the inst+0x5c setup callback. Project computes `ctx->splitPlane` in PrepareDrawContext (line 4774) — only INSIDE the `if(instFlags&0x7000)` block and BEFORE the callback. Correct ONLY IF (1) NormalAlt never runs for a non-0x7000 bucket (else ctx->splitPlane stays 0 from `ctx={0}` → splitDist=−Z, wrong) AND (2) no NormalAlt setup callback modifies H/TRZ (else project uses stale pre-callback values). Both likely hold in practice (else visible reflection breakage → suggests NormalAlt buckets are always 0x7000 & callbacks don't touch H/TRZ), so LATENT not observed. Retail's placement is robust to both; project's relies on these assumptions. **Recommendation: move splitPlane calc to NormalAlt head (unconditional) to match retail robustness — awaiting user call (needs confirming NormalAlt↔0x7000 correlation in queue code + callback H/TRZ behavior).** No code change. + +### Bug-class re-scan cont'd (2026-07-07) — CTR_Box.c full-file re-verify (4 box/wire prim fns) — Match, NOTE(claude) tpage fix CONFIRMED +Verified all 4 CTR_Box draw fns vs decomp (breadth cycle after deep RenderBucket work): +| Fn | Addr | Verdict | +|---|---|---| +| DrawWirePrims | 0x80021500 | Match — LINE_F2 code 0x40, colors@4/5/6, verts (x0,y0)@8 (x1,y1)@0xc, tag size 3, alloc 0x10 | +| DrawWireBox | 0x80021594 | Match — 2× LINE_F3 polyline (code 0x48, term 0x55555555@0x14, tag size 5): prim1 top+right `(x,y)→(x+w,y)→(x+w,y+h)`, prim2 left+bottom `(x,y)→(x,y+h)→(x+w,y+h)`, alloc 0x18 | +| DrawClearBox | 0x8002177c | Match + **NOTE(claude) tpage CONFIRMED**: decomp `param_3<<5 \| 0xe1000a00` = project `Texpage{.code=0xE1,.semiTransparency=transparency,.dither=1,.y_VRAM_EXP=1}` (semitrans bit5, dither bit9, y_VRAM_EXP bit11); TPage_PolyF4 code 0x2a, PolyF4 tag=0@8, colors@0xc/d/e, 4 verts@0x10-0x1e, tag size 7, alloc 0x20 | +| DrawSolidBox | 0x80021894 | Match — PolyF4 code 0x28, colorCode `*rgb&0xffffff\|0x28000000`@word1, packed-XY `*(uint*)r`/`(x+w)&0xffff\|y<<16`/`(ushort)x\|(y+h)<<16`, OT tag `\|0x5000000`, alloc 6 words | +All primitive/color codes, tpage word, packed-XY masking (`&0xffff`/`(ushort)` on X — correct for s16 rect coords, no Y-corruption), OT-links match. `CTR_NATIVE` drawDisplayArea = documented native-only. No new bugs; tpage y_VRAM_EXP fix re-confirmed. **CTR_Box.c COMPLETE.** + +### Bug-class re-scan cont'd (2026-07-07) — GAMEPAD_ProcessHold @0x80025718 (input, determinism-critical) — Match, NOTE(claude) u32/<<16 fix CONFIRMED + decompiler-field-name trap avoided +Verified the per-frame digital-button processor (the input-critical path) vs ASM (decomp field names were misleading — verified against raw ASM): +- **NOTE(claude) u32 raw-button + `<<16` fix CONFIRMED**: rawButtons held in full 32-bit reg; ANALOG_STICK path `_sll a2,a2,0x10` (0x8002577c delay slot) shifts the 16-bit word to the HIGH half; remap table masks the high half. As u16 the shift→0, killing flightstick input. Decomp `rawButtons` is `uint`, project `u32 uVar4`. ✓ +- Endian flip: `lbu 0x2(a1); lbu 0x3(a1); sll v0,8; or; xori 0xffff` = `(input1<<8)|input2 ^ 0xffff`. ✓ +- **NEGCON byte offsets — ASM ground truth beats decomp field names**: decomp rendered `analog.rightY/leftX/leftY` (union-view guess), but raw ASM reads `lbu 0x5(a1)→|=0x40`, `lbu 0x6(a1)→|=0x80`, `lbu 0x7(a1)→|=4` = packet[5]/[6]/[7], matching project `neGcon.btn_1/btn_2/trg_l` (@5/6/7 after twist@4). Project CORRECT; decomp names were red herring. Compares `sltiu ...,0x41` (unsigned `0x40 < btn`, u8). ✓ +- controllerData consts NEGCON=0x23=`(2<<4)|3`, ANALOG_STICK=0x53=`(5<<4)|3`. ✓ +- Remap loop `while(mask!=0){if(raw&mask)btns|=remap[1]; remap+=2;}` (g_aButtonRemapTable@0x800824a8). ✓ framesSinceLastInput cap 65000, prev=curr at top, NULL→both 0. ✓ +No new bugs; u32/<<16 fix re-confirmed. **Lesson reinforced: decomp union-view field names (analog.* for a neGcon packet) can mislead — the ASM `lbu` offsets are authoritative.** GAMEPAD.c partially covered (ProcessHold done; ProcessMotors `timer&unk42&0xf` fix, StepToward* sign steppers, JogCon jog_rot sign path still open). + +### Bug-class re-scan cont'd (2026-07-07) — GAMEPAD_ProcessSticks @0x80025854 (sign-heavy stick logic) — Match, 2nd decompiler-field-name trap avoided +Verified the per-frame analog-stick processor (JogCon math + StepToward steppers + deglitch) vs decomp+ASM: +- **JogCon jog_rot** (`lh 0x4(v0)`=packet+4 s16): neg `iTwist=((-10-jr)-range)*8`, pos `((jr-10)-range)*8`; twist clamp — decomp `(char)max(iTwist,0)` then `if(iTwist>0xff)cTwist=0xff` ≡ project `clamp[0,0xff]` then `(char)` (proven equal all 3 cases); edge resets `jr<-0x80→0`(`sSteer=-0x80;+0x80`), `0x7f0` else-branch; Max `x<0x100`+clamp0xff; Center `x<0x81`+clamp0x80). ResolveAxis order LEFT→Zero/RIGHT→Max/analog→raw/else→Center. ✓ +- **Analog deglitch**: `leftY==0xff && prevRawLeftY!=0xff → use prev` (unk_1=prevRawLeftY); DUALSHOCK=0x73=`(7<<4)|3` also →analog path. ✓ +- **⚠️→resolved: neGcon steer — 2nd decomp union-view trap**: decomp rendered `analog.rightX` (=packet+6 guess), but raw ASM `lbu a1,0x4(v0)`=**packet[4]** → stickLX. = project `neGcon.twist` (@4; layout twist@4/btn_1@5/btn_2@6/trg_l@7 consistent w/ ProcessHold). Project CORRECT; ANOTHER Ghidra field-name red herring caught via ASM. Note: JogCon reads packet+4 as `lh`(s16 jog_rot), neGcon as `lbu`(u8 twist) — same offset, project field types match each. +- controllerData consts NEGCON=0x23/ANALOG_STICK=0x53/DUALSHOCK=0x73/JOGCON=0xe3; stick offsets LX@8/LY@0xa/RX@0xc/RY@0xe; framesSinceLastInput reset on |deflect|>0x30. ✓ +No new bugs. **GAMEPAD.c: ProcessHold+ProcessSticks done** (the two sign-heaviest input fns); ProcessMotors `timer&unk42&0xf` fix + Init/State/Poll/GetNumConnected/TapRelease/AnyoneVars/JogCon1-2/Shock* still open. **2nd confirmation: decomp union-view field names are unreliable for byte offsets — ASM `lbu`/`lh` disp is authoritative.** + +### Bug-class re-scan cont'd (2026-07-07) — GAMEPAD_ProcessMotors @0x80025e18 (rumble/FF) — Match, NOTE(claude) motor-pulse fix CONFIRMED +Verified the per-frame force-feedback driver (Ghidra DB name GAMEPAD_ProcessForceFeedback; syms GAMEPAD_ProcessMotors) vs decomp: +- **NOTE(claude) `timer & unk42 & 0xf` fix CONFIRMED**: JogCon steer-feel pulse uses RAW unk42 (`uScratch11=(uint)(byte)data14[0x12]`), not >>4. Branch: `bVar1=unk42>>4`; on `(timer & unk42 & 0xf)!=0` → `bVar1=(unk42-0x10)>>4`, `if((unk42-0x10)<0)bVar1=0`; `bVar1|=0x30`→motorDesired[0]. Sign `(unk42-0x10)<0` (u8→int) exact. ✓ +- **JogCon FF**: forced-rumble (unk44≠0→0x40), timed-rumble (rumbleTimerMS s16 = cRumbleTimerMS_lo/hi @0x46/0x47, `motorCurr[0]=rumbleStrength`, `-=elapsedTimeMS`, clear on expiry), else derive. "Naughty Dog Bug" `unk48=0`: decomp `if(unk48!=0){clear}` ≡ project unconditional (same net 0). ✓ +- **DualShock FF**: motorDesired[0]=`0xff` iff `shockFrameFreq(data14+0)!=0 && (timer & shockValFreq(data14+0xc))==0`; motorDesired[1] priority `shockForce1(data14+4)→valForce1(data14[0x10])` else `shockForce2(data14+8)→valForce2(data14[0x11])` else 0 (w/ val-clears). ✓ +- **Active gate** `(gameMode1&0xf)==0 && !boolDemoMode && packet && !RaceFlag_IsTransitioning()` (decomp splits outer/inner, project combines). Decrement shockFrame counters + `if(unk44)unk44--`. ✓ +- **Power cap round-robin**: `totalPower>0x3c(60)`, `skipIndex=timer%numPads`, loop1 disable motorDesired[1] then loop2 motorDesired[0] across `i%numPads` (decomp `*0x50` stride = sizeof GamepadBuffer), `totalPower-=motorPower[](=motorStepRate)`; div/0 `trap` unreachable when power>60. ✓ +- **Final** `motorSubmit=motorDesired` (=decomp `motorPrev=motorCurr`; motorSubmit is what PadSetAct sends). ✓ +Field map consistent: motorCurr↔motorDesired, motorPrev↔motorSubmit, cForcedRumbleCountdown↔unk44, cRumbleStrength↔unk45, cRumbleTimerMS↔unk46, data4↔unk48. No new bugs; pulse fix re-confirmed. **GAMEPAD.c: 3 sign-heaviest fns (ProcessHold/Sticks/Motors) DONE — core input+FF pipeline verified.** Remaining: Init/SetMainMode/ProcessState/PollVsync/GetNumConnected/TapRelease/AnyoneVars/JogCon1-2/Shock* (mostly small/mechanical). + +### Bug-class re-scan cont'd (2026-07-07) — GAMEPAD_GetNumConnected @0x800255b4 + ProcessTapRelease @0x80025d10 — Match +Verified 2 more GAMEPAD logic fns vs decomp+ASM: +- **GetNumConnected**: multitap detect `slotBuffer[0].plugged==0(PLUGGED) && controllerData==0x80(MULTITAP<<4)` → 1slot×4port else 2×1; per-port fused cond decomp `((cData!=0x80) || (advance,*subPlugged==0)) && ptr->plugged==0` ≡ project nested `if(slotPlugged){if(multitap)advance; if(subPlugged)record}`; sub-packet stride 8 (iStride 2,10,18,26 = controllers[Port]@base+2+port*8); `bitwise |= 1<<(slot*4+port)`; unused pads NULLed; **disconnection change-detect** `((bitwise^old)&old)!=0` (1→0 bits = disconnect), first-call old==-1→0, no-change→0. Project always-sets flag vs decomp conditional = same net. ✓ +- **ProcessTapRelease**: stick-as-dpad fold `stickLX<0x20→LEFT / >0xe0→RIGHT`, `stickLY<0x20→UP / >0xe0→DOWN` (decomp `stickLY<0xe1` skip = `!(0xe0unkPadSetActAlign[6]` (byte just past the 6-byte PadSetActAlign buf — adjacent-layout naming, same addr; menu-nav toggle, not a bug). stickLX/LY s16 but always 0-255 so signed cmp moot. ✓ +No new bugs. GAMEPAD.c logic-bearing fns now DONE (Hold/Sticks/Motors/GetNumConnected/TapRelease); remaining Init/SetMainMode/ProcessState/PollVsync/AnyoneVars/JogCon1-2/Shock* are small setters/pollers. + +### Bug-class re-scan cont'd (2026-07-07) — RaceFlag.c (3 sign/wrap fns: MoveModels, IsFullyOffScreen, GetOT) — Match +Verified the checkered-flag transition state machine + math vs decomp: +- **MoveModels @0x80043e34**: sine ease-in-out; `midpoint=numFrames/2` = decomp `(numFrames-(numFrames>>0x1f))*0x8000>>0x10` (compiler signed-/2 idiom, +1 neg correction); `frameIndex<0→0`, `>numFrames→0x1000`; `0x800 -/+ MATH_Sin((mid-idx)*0x400/mid)/2`. div/0+INT_MIN traps unreachable (numFrames≥2). ✓ +- **IsFullyOffScreen @0x80043f28**: `((pos+4999)&0xffff < 9999)^1` — decomp `(short)pos` vs project `(u16)pos` wash out under `&0xffff` (low-16 identical); flags both +5000 & -5000(→0xffff wrap) as off. ✓ +- **GetOT @0x800440a0**: transition SM (AnimationType 0=on/2=off), DrawInitialized latch, `Position<0→5000` clamp, TransitionSpeed=300+ramp(`<1000`), type-0 slide `-((Position>>3)*elapsed>>5)` w/ min-mag-1 delta / snap-to-0 `<8` / finished-branch DrawOrder(1/-1/other→farDepth `ptrOT[0x3ff]`=`+0xffc`), type-2 slide `-((TransSpeed>>2)*elapsed>>5)` / snap-off `Position<=-5000→5000,animType0,clear renderFlags 0x1000`. **Sign check**: decomp arith `(s16)>>3`/`(s16)>>2` vs project logical `(u16)>>3`/`(u32)>>2` — equivalent since Position(≥8) & TransSpeed(300-1000) POSITIVE in those branches. `ot_tileView_UI`≡`otSwapchainDB`, `tileView`≡`pushBuffer`. ✓ +No new bugs. RaceFlag.c core SM+math done; remaining DrawLoadingString (unsigned-byte<4 + strlen + s16-X-trunc fixes), DrawSelf (col 1..34 loop fix), + trivial setters (IsFullyOnScreen/IsTransitioning/Set*/Get*) for future cycles. + +### Bug-class re-scan cont'd (2026-07-07) — RaceFlag_DrawLoadingString @0x800442a0 — Match, 3 NOTE(claude) fixes CONFIRMED +Verified the scrolling "LOADING" text renderer vs decomp. All 3 fixes confirmed: +- **Unsigned byte `<4` glyph test**: decomp `byte bChar; if(bChar<4)` (unsigned) = project `u8 local_30; if(local_30<4)`. Signed char misparses ≥0x80 as 2-byte glyph. ✓ +- **strlen loop bound**: decomp `iStrLen=strlen(s)` (retail measures localized lngStrings[0x231], not hardcoded 10). ✓ +- **s16 X-truncate**: decomp `(iPosX+iScratch5)<<0x10>>0x10` = project `(s16)(iVar10+iVar4)` — X wraps s16, slides text off left edge when scroll `Transition&0xffff` reads large-neg. ✓ +Full-body match: OT redirect (tileView_UI≡pushBuffer_UI→ot_tileView_UI[swapIdx]); scroll `Loading.stage==-1(LOAD_IDLE)→Transition-=0x28 floor -1000, else 0`; centering `(Transition&0xffff)-width/2` (decomp signed `((s16)w-signbit)>>1` ≡ project `iVar3>>1`, w>0); drop-in wave `iAnimBase=frame*-0x3c+0x23c` step `+0xf0`, hide 0x23c/show 0x100, gates `>4`/`>0x4a`/`>0x4f`, `(0x4b-frame)*0x3c+0x100` slide-off; `if((s16)iScratch5!=0x23c) DrawLineStrlen(&bChar,len,X,0x6c,1,0)`; per-char `GetLineWidthStrlen`+advance, `uAnimCounter-=4`; post-loop `<0x50→ +max(elapsed>>5,1)` else `=-1, if(stage-6U<2)=0`. Signed cmps on uint counter (`(int)uAnimCounter<0` etc.) match. No new bugs; 3 fixes re-confirmed. RaceFlag.c: only DrawSelf (GTE flag mesh, col 1..34 fix) + trivial setters remain. + +### Bug-class re-scan cont'd (2026-07-07) — RaceFlag_DrawSelf @0x800444e8 — Match + **Fix 133 (NEW BUG): missing per-prim primMem guard** +Verified the GTE checker-flag mesh renderer vs decomp (huge fn). Body matches: +- **col-loop fix CONFIRMED**: decomp exits `column++; if (0x22 < column) { ElapsedTime+=elapsed*100; return; }` = cols 1..34 (34 iters) = project `for(column=1;column<35;column++)`; old 36 drew 1 extra strip past flag edge. +- Early-out CanDraw==0; loading-text re-arm (stage 6..7); GTE setup SetRot/Trans(matrixTitleFlag), SetGeomOffset(0x100,0x78), ldH(0x100)=SetGeomScreen; checkerFlagVariables wave (timeAccum+=amp3*elapsed, overflow>>5>0xfff reseeds amp1=`(sin(ph0)+0xfff)*0x20>>0xd+0x96`/amp3=`*0x40>>0xd+0xb4`, MathSinInline inlined); flagPosX=`-0xbbe-Position`; vz ripple `var2+(sin+0xfff)*0x20>>0xd`; clip `(posR&posL&0x80008000)==0 && ((0xd80200-pos)&0x80008000)==0`; **brightness**: project `CalculateBrightness(light,dark)` (dark `s*-55+0x140000>>0xd`, light `s*-125+0x1fe000>>0xd`) = decomp EXPANDED lerp `lightR*0x82+(0x2000-lightR)*0xff>>0xd`≡`*-0x7d+0x1fe000>>0xd` (0x2000*0xff=0x1fe000, 0x82-0xff=-0x7d) — factored form, equal; POLY_G4 code 0x38, gray RGB `c|c<<8|c<<16`, x0=posR/x1=posL/x2=posR[1]/x3=posL[1], OT `|0x8000000`. ✓ +- **⚠️ Fix 133 APPLIED (real bug, code changed + built clean)**: decomp checks `pPrimCurr=primMem.curr; if(pPrimCurr<=endMin100){curr+=9;pPrim=pPrimCurr} if(pPrim==0)return;` PER emitted POLY_G4 — retail STOPS + commits cursor when primMem full. Port emitted straight into `p` with `p++` and NO per-prim guard (cursor written once at end) → a flag drawn while primMem near-full (SHARED buffer) would OVERFLOW the prim buffer. Added inside the clip-pass block: `if ((char*)p > (char*)gGT->backBuffer->primMem.guardEnd){ cursor=p; return; }` (guardEnd≡endMin100 per CTR_Box; `>` matches `!(curr<=endMin100)`; commit cursor before return so emitted prims persist, matching retail's per-prim curr advance; skips ElapsedTime update like retail's early return). NOTE(claude) stamped. Build: ctr_native links clean. +**Fix 133 — first NEW bug this session (prior cycles all re-confirmed existing fixes).** RaceFlag.c substantive fns DONE; trivial setters (IsFullyOnScreen/IsTransitioning/BeginTransition/SetFullyOn/OffScreen/Set/GetCanDraw/SetDrawOrder/ResetTextAnim) remain (mechanical). + +### Bug-class re-scan cont'd (2026-07-07) — UI_Meter.c (JumpMeter_Draw, DrawSlideMeter) — Match; **Fix-133 guard pattern NOT systemic (these have guards)** +Follow-up on Fix 133: checked whether other draw fns omit the per-prim primMem guard. **They DON'T — both UI_Meter draw fns correctly bounds-check.** vs decomp: +- **UI_JumpMeter_Draw @0x80051e24**: digit split `sec=jumpMeter/0x3c0`, `tenths=rem/0x60` (project expanded `(rem/6+sign>>4)-sign` ≡ `rem/0x60`, rem≥0→sign 0), `hundredths=(subrem*100)/0x3c0`; **NOTE(claude) posX+0xe (14 not 13) CONFIRMED** (decomp `*(short*)(pPrim+3)=posX+0xe`); color tiers red<0x280/green<0x3c0/yellow<0x5a0/blue; bar height `jumpMeter*0x26/0x960` = decomp magic-div `*0x1b4e81b5>>0x28` (corr `>>0xf`=0, jumpMeter≥0); white-frame 0x28ffffff/gray-bg 0x28808080/POLY_F4 code 0x28, OT `|0x5000000`. **3 primMem guards PRESENT** (`if(curr<=endMin100){curr+=6;p=curr} if(p==0)...`). ✓ +- **UI_DrawSlideMeter @0x80052250**: barHeight `numPlyr>2?3:7`; meterLength `0x31-(roomLeft*0x31)/(turboMaxRoom<<5)` (=`*ELAPSED_MS(32)`); color green if `(turboLowRoomWarning<<5)flags & 0x800)==0`→live-recurse-children / dead-destroy-bloodline+self+unlink; structure matches. (Also FLARE `|0x800`.) +- **THREAD_FLAG_DISABLE_COLLISION=0x1000**: PROC_CollidePointWithSelf @0x8004228c decomp `(t->flags & 0x1800)==0` = `0x800|0x1000` = DEAD|DISABLE_COLLISION; project PROC.c:270 `& (THREAD_FLAG_DEAD|THREAD_FLAG_DISABLE_COLLISION)`. 0x1800-0x800=0x1000. ✓ (Also BOTS.c:918 `& 0x1800`.) +- PROC_CollidePointWithSelf body matches (0x10000000 per-axis clamp, sum-sq nearest test `distSqcount!=0) iMapSpawn=pMapSpawn[1].count` — guard tests `->count`(lw+0) NOT ptr; count==0 leaves idx 0 (project keeps ptr null-check as native low-RAM guard). Color: BLACK(2)/BLUE(3)→NORMAL w/ rgb 0/0x402000 else 0x808080; decomp `colorMode`≡project `transparency` (tpage blend `colorMode<<5` when !=NORMAL). Top-half gate `(mapSpawn && *(spawn+0x12)==0) || (gameMode1 & MAIN_MENU)`; POLY_FT4 code 0x2c, tag-sz 9, UV/pos packing, ExtraFunc (x0=leftX/x1=posX, `code|=2`). ✓ +- **Contrast w/ Fix 133**: DrawMap has NO primMem guard — but the RETAIL decomp ALSO omits it (2 fixed prims always fit). Project correctly matches retail here → NOT a missing-guard bug; reconfirms Fix-133 was isolated to the *reconstructed* RaceFlag_DrawSelf where retail DID guard. ✓ +No new bugs; count-guard fix re-confirmed. (Remaining UI_Map: DrawRawIcon/DrawAdvPlayer/DrawDrivers[in-loop odd-player NOTE]/DrawGhosts/DrawTracking = icon-draw dispatchers, ASM-verified headers.) + +### Bug-class re-scan cont'd (2026-07-07) — MainStats.c RestartRaceCountLoss @0x8003d068 — Match, NOTE(claude) boss-fall-through CONFIRMED +Verified the race-restart loss-counter logic vs decomp: +- Team points: `teamFlags & (1<=10 FALLS THROUGH` to level path `timesLostRacePerLev[levelID]`(@+0x30) `<10 → inc`. decomp `if(bLossCount<10)goto LAB` else fall-through; project `if(IS_BOSS){if(<10){inc;return}}` then level path. Signed-char `<10` cap fine (counters 0-10). ✓ +No new bugs; boss fall-through fix re-confirmed. (ClearBattleVS = trivial 12-entry standingsPoints clear.) + +### Bug-class re-scan cont'd (2026-07-07) — Vector.c (SpecLightNoSpin3D, BakeMatrixTable) — Match, NOTE(claude) Transpose fix CONFIRMED +Verified the specular/matrix-bake math vs decomp: +- **SpecLightNoSpin3D @0x800576b8 — NOTE(claude) Transpose fix CONFIRMED**: decomp calls `ConvertRotToMatrix_Transpose(matrix,rot)` (Ghidra resolved the jal to the transpose fn @0x8006c378), NOT non-transpose @0x8006c2a4. Wrong one → wrong light matrix → wrong specular on non-spinning shiny objects. Body: LightMatrixMul(normal)→unk53=(char)MAC1 / reflectionRGBA=(u16)MAC3; per-player view=`inst.matrix.t[]-pb.pos` (drawEnv.ofs+i*0x110-0x20), normalize, LightMatrixMul, half=light+view, normalize, idpp[i].halfVector. Single 0x110 stride (correct). ✓ +- **BakeMatrixTable @0x80057884**: latch `matrixTableBaked`(=g_aGpuDmaQueue[5].pDataPtr b0). Step1 tumble sub-table: entry+0xc=`(i*0x2000)/count`, +8=`-sin((i*0x3000)/count)/7`, +0x14=`(sin2000*6)/0x28+0x1000`, **+0x10=`Div4TowardZero(sin2000)+0x1000`** (`if(v<0)v+=3; v>>2` = round-toward-zero /4). Project writes +0x10 ONCE (final); decomp's earlier `(sin2000*6)/0x28` write to +0x10 is a DEAD store (overwritten) — project correctly omits. `(s16)(A+0x1000)`≡`(s16)A+0x1000` (low-16 identical). Step2: ConvertRotToMatrix(entry+8)+diag scale(entry+0x10/12/14)+MatrixRotate(=MATH_MatrixMul); guard ptr!=0&&count>0; ×0x14. Step3: project reconstructs GTE `rt()` of up-vec {0,-0x2000,0} thru baked matrix as `(m[i][1]*-0x2000)>>12 + TR[i]`(TR={0,0x2000,0}) — equals GTE MAC1/2/3; GTE-saturation-vs-(s16)-trunc MOOT (rot elems keep result in s16). ✓ +- **Note (already-resolved deliberate divergence, NOT a pending item)**: SpecLightSpin3D @0x8005741c has NOTE(claude) — retail ND cursor bug strides camera matrix 0x220 (reads pushBuffer[2*i] OOB for 3-4P); project keeps pushBuffer[i] (correctness over bug-repro, avoids OOB). Documented, done. +No new bugs; Transpose fix re-confirmed. + +### Bug-class re-scan cont'd (2026-07-07) — VehFire.c VehFire_Increment @0x8005abfc (turbo/boost, gameplay-critical) — Match, NOTE(claude) s_turbo1 fix CONFIRMED +Verified the big turbo-grant fn vs decomp: +- **NOTE(claude) s_turbo1 name fix CONFIRMED**: decomp `INSTANCE_BirthWithThread(0x2c, s_turbo1_8008d61c, 0x300, BUCKET_TURBO, VehTurbo_ThTick, 0x10, 0)` — retail passes "turbo1" name; project now `&sdata->s_turbo1[0]` (was NULL oversight; sibling uses s_turbo2). ✓ +- **newFireSpeedCap** = `(fireLevel*(SacredFireSpeed-SingleTurboSpeed)>>8)+SingleTurboSpeed` (CTR_MipsSubLo/MulLo/Sra8/AddLo = decomp `(fireLevel*(Sacred-Single)>>8)+Single`). ✓ +- **fireSize** = min((fireLevel>>6)+5, 8): decomp sets `(s16)((fireLevel>>6)+5)` then `if 8<(s16)v: =8`; project clamp-then-assign — same. ✓ +- **3 reserve branches**: FREEZE_RESERVES_ON_TURBO_PAD=1 / SUPER_ENGINE=0x10. turbo-pad(`&1`): `if(oldOTTterm / <-term): decomp comma-side-effect `if((localY<=term)||(iVar15=term,iVar16=origY,origY>8, InterpBySpeed) structurally matches. TERRAIN_FLAG_MUD_PHYSICS=0x80. ✓ +STATUS: sign-sensitive physics core (gravity/terminal/friction/skid) CONFIRMED; forwardDir/StartRollback goto-tail (0x8005e9xx) GTE-register-fragment-limited in decomp, structurally consistent, not line-verified. No new bugs; mud-terminal fix re-confirmed. + +### Bug-class re-scan cont'd (2026-07-07) — VehPhysForce_RotAxisAngle @0x8005f89c (GTE axis-angle matrix, sign-heavy) — Match +Verified the axis-angle rotation-matrix builder vs decomp. **Resolved a Ghidra naming trap**: decomp's `iSin`/`iCos` are SWAPPED-named (iCos actually holds sin, iSin holds cos) — confirmed via downstream use (`m[0][2]=iCos*normalY`=project `scaledSinY=sin*normalY`); values map correctly to project `TrigAngleSinCos` pair.sin/pair.cos. +- Col1=normVec (axis) m[*][1]. TrigAngleSinCos octant (0x400/0x800 sign flips) — traced 2 octants, match w/ swapped-name mapping. +- Squared terms `normalXSq/ZSq`, `crossXZ=normalX*-normalZ`, `denom=Xsq+Zsq`; `scaledSinY=sin*normalY>>12`, `scaledCosY=cos*normalY>>12`. +- **CountLeadingSignBits reimplements GTE LZCS/LZCR** (leading-sign-bit count) → norm shift `0x14-lzc`, `if(shift>0)` shift down Xsq/Zsq/crossXZ/denom (overflow-guard before div). +- denom!=0: `divX=(sinRem*normalZSq+cosRem*crossXZ)/denom`, `divZ=(sinRem*crossXZ+cosRem*normalXSq)/denom` (sinRem=sin-scaledSinY, cosRem=cos-scaledCosY); `outX=scaledSinY+divX`(m[0][2]), `outZ=scaledCosY+divZ`(m[2][2]); div/0+INT_MIN traps. denom==0: outX=±scaledSinY (sign by normalY), outZ=scaledCosY. `outY=(-dot)>>12` (dot=sin*normalX+cos*normalZ) → m[1][2]. +- **GTE op12 cross-product for col0**: project `CTC2(normVec,0/2/4)`+`MTC2(col2,9/10/11)` = decomp `gte_ldopv1SV(normVec)`+`gte_ldopv2SV(col2)`+`gte_op12()`; `MFC2_S(25/26/27)`→m[*][0]. ✓ +All sign-sensitive parts (octant trig signs, LZCS shift, signed div, -dot>>12) match. No new bugs. + +### Bug-class re-scan cont'd (2026-07-07) — VehPhysForce_CounterSteer @0x8005fb4c — Match (VehPhysForce.c substantive fns COMPLETE) +Verified the counter-steer accel fn vs decomp: +- accel.x/y/z=0; `speedApprox=abs(speedApprox)`. +- **Bail (De Morgan)**: `speedApprox<=FP8(3)=0x300 || WARP(0x4000) || KS_CRASHING || wallRubTimer(set_0xF0_OnWallRub) || !TOUCH_GROUND(1) || counterSteerRatio==0` = negation of decomp's process guard. ✓ +- **Angle clamp ±angleLimit**: `angleLimit=(u8)const_ModelTurnCounterSteerStrength`; decomp 2 separate `if`s (`limit>8)*sin>>12`, `(s16)`-masked into vec.x. +- GTE: project `gte_mvmva(1,0,0,3,0)` (rotMtx×V0, cv=3 no-trans, sf=1) ≡ decomp `gte_rtv0` (both use pre-loaded matrixMovingDir); `MFC2(25/26/27)`→accel. ✓ +No new bugs. **VehPhysForce.c substantive fns DONE** (OnGravity/RotAxisAngle/CounterSteer decompiled+verified; ConvertSpeedToVec/OnApplyForces/CollideDrivers/TranslateMatrix under ASM-verified headers + CTR_Mips* bit-exact wrappers). + +### Bug-class re-scan cont'd (2026-07-07) — VehPickupItem.c (MaskBoolGoodGuy, MissileGetTargetDriver) — Match, NOTE(claude) AI-view ConvertRotToMatrix fix CONFIRMED +Verified 2 item-use fns vs decomp: +- **MaskBoolGoodGuy @0x80064be4**: project `(0x20c9>>charID)&1` = decomp 5-way OR — 0x20c9 = bits{0,3,6,7,13} = {CHAR_CRASH,COCO,POLAR,PURA,PENTA} (good guys → Aku Aku). ✓ +- **MissileGetTargetDriver @0x80064f94 — NOTE(claude) AI-view fix CONFIRMED**: AI/no-viewport path builds proj matrix via `ConvertRotToMatrix(&mtxObject,&rot)` (Ry·Rx·Rz), NOT Psy-Q RotMatrix (differ for combined pitch/roll → mis-projected candidates, wrong missile lock). Inverse `MATH_HitboxMatrix(&mtxHitbox,...)` is a DEAD store (overwritten by SetRotMatrix(mtxObject)) — decomp confirms. Player path: tileView[driverID].matrix_ViewProj(≡pushBuffer). Candidate filters (!=NULL/!=shooter/!=MASK_GRABBED/battle-team teamID/invisibleTimer==0) = project early-continues. On-screen: gte_rtps, front `flag&0x40000==0`, screenX∈[0x1f,rect.w-0x1e), screenY∈[0x15,rect.h-0x14); project MTC2/MFC2(14 SXY)/CFC2(31 FLAG) inlines decomp gte_ldv0/stsxy/stflg. Nearest `dx=(cand.x-shooter.x)>>8, dz>>8, distSq=dx²+dz²`, min-track. ✓ +No new bugs; AI-view fix re-confirmed. (Remaining VehPickupItem: ShootNow huge switch — instTntSend case-3-vs-4 NOTE fix + PotionThrow/MaskUseWeapon/ShootOnCirclePress still open.) + +### Bug-class re-scan cont'd (2026-07-07) — VehPickupItem_ShootNow @0x8006540c (weapon dispatch) — NOTE(claude) instTntSend fix CONFIRMED + ordering-divergence chased & resolved HARMLESS +Verified the big weapon-spawn switch's mine tail vs decomp: +- **NOTE(claude) instTntSend fix CONFIRMED**: decomp **case 3 (TNT/Nitro)** tail = `RotAxisAngle; driver->instTntSend=weaponInst; actionsFlagSet|=0x80000000(DROPPING_MINE); if(flags==0) RB_Follower_Init` — SETS instTntSend. **case 4 (Beaker, PotionThrow==0)** tail = `RotAxisAngle; RB_Follower_Init; actionsFlagSet|=0x80000000` — NO instTntSend, RB_Follower_Init unconditional. Project unified RunMineCOLL: `if(mineSetsInstTntSend[=1 case3/0 case4]) instTntSend; DROPPING_MINE; if(mineShouldInitFollower[=(flags==0) case3/1 case4]) RB_Follower_Init`. Pre-fix set instTntSend unconditionally → dropped beaker wrongly recorded as sent-TNT (corrupts TNT-on-head/detonation tracking). ✓ +- **⚠️→RESOLVED HARMLESS ordering divergence**: retail case 4 does `RB_Follower_Init` BEFORE `DROPPING_MINE`; project (unified) does `DROPPING_MINE` before `RB_Follower_Init`. Chased: decompiled **RB_Follower_Init @OVR_231::800b6f00** — its only actionsFlagSet read is the guard `(actionsFlagSet & 0x100000[ACTION_BOT])==0` (plus speedApprox>0x1e00, cameraDC.flags&0x10000). It does NOT read DROPPING_MINE(0x80000000), so setting bit31 before/after can't change its bit20 test → order swap is unobservable, project equivalent. NOT a bug. +- Spot-checks: case 0 Turbo `VehFire_Increment(d,0x960,9,boost)`; case 2 missile cap `0xb>1)*3)/5)`; case 8 clock `clockFlash=4`(forcedJump_trampoline+1). ✓ +No new bugs; instTntSend fix confirmed, ordering divergence proven harmless via RB_Follower_Init reads. **VehPickupItem.c: MaskBoolGoodGuy/MissileGetTargetDriver/ShootNow(mine-tail) done.** + +### Bug-class re-scan cont'd (2026-07-07) — VehPhysGeneral_SetHeldItem @0x80060f0c (weapon roulette) — Match, NOTE(claude) case-8 rank fix CONFIRMED +Verified the item-selection fn vs decomp: +- **NOTE(claude) case-8 (1P Arcade) fix CONFIRMED**: decomp `iRng=(u16)driverRank<<16; itemSet=iRng>>16; if(itemSet==1)itemSet=1; else itemSet=(rank-(iRng>>31))>>1` — tests RAW rank==1(2nd→Race2), else rank/2 (sign term 0 for ranks 0-7). Project `if(driverRank==1)itemSet=Race2; else (rank+(rank>>31))>>1` = identical map {0→0,1/2/3→1,4/5→2,6/7→3}. Pre-fix halved FIRST then tested ==1 (rank1→0, never matches) → 2nd place wrongly got Race1 not Race2. ✓ +- Other cases: 2(boss,rank0→Race1), 3(rank0→Race1/else Race4, rank1→Race3 +50% Race2 roll), 4/5(rank, 5→3), 6(rank, 0→Race1, 5→Race4, else (rank-1)/2+1); lap0 Race4→Race3. ✓ +- Itemset index: project uniform `(rng*numWeapons[set])/0xc8` = decomp per-case (`rng/10` for 20-tables, `(rng*0x34)/200` Race2=52, `(rng*0x13)/200` Race4=19); numWeapons={0x14,0x34,0x14,0x13,0x14,0,0,0x14}. rng=`(Scramble()>>3)%200`. +- Boss pity (ADVENTURE_BOSS=bit31): `bossFails<3→(u32)(item-7)<3` unsigned={7,8,9}→0xb(3-missile), <4→<2={7,8}, <5→==8; DRAGON_MINES 0xb→0x2. Post: 0x5(spring=BEAKER|BOMB=4|1)→0x0(turbo), warpball(9) single-hold else 0xb, 0xb cap 2 holders (numPlyr>2,!battle) else 0x2, `(u32)(item-0xA)<2`→numHeldItems=3. ✓ +No new bugs; case-8 rank fix re-confirmed (real gameplay bug — 2nd place got wrong itemset). + +### Bug-class re-scan cont'd (2026-07-08) — BOTS.c mask-grab recovery trio (ThTick_RevEngine/MaskGrab/Killplane) — Match, ASM sign checks clean +Verified the 3 AI out-of-bounds→mask-grab recovery fns vs decomp (all reached w/o overlay prefix, base `ram`): +- **BOTS_ThTick_RevEngine @0x8001372c**: descent `posCurr.y -= (elapsedTimeMS<<9)>>5` (SubLo/Sra/Sll), mask drag `pos=posCurr.xyz>>8`, TranslateMatrix/FrameProc_Driving/EmitterMain(=SpawnParticle_DriverMain); settle-back branch releases mask(scale=0x1000,dur=0,rot.z&=0xfffe), kartState=ENGINE_REVVING, clears clock/squish, ThTick_SetAndExec(Drive). ✓ +- **BOTS_MaskGrab @0x80013838**: nav-midpoint reposition `pos+(next-pos)/2`, `nBotNavProgress=(distToNextNavXZ/2)<<8` (`(x-(x>>31))>>1`≡MipsDiv toward-zero), 8 aiDriveState zeroings (mulDrift/squishCooldown/reserved_0x5cc/speedY/speedLinear/vel[0..2], order differs no-dep), `nBotFlags&=0xffffffb0`(=clear 5 flags 0x4f: ESTIMATE_NAV|DAMAGE_ACTIVE|DAMAGE_SUPPRESS_EMITTER|FREE_PHYSICS|BOSS_PATH_REQUESTED), `actionsFlagSet&=0xfff7ffbf`(=clear AIRBORNE 0x80000|HIGH_JUMP 0x40), rotCurr=(byte)rot<<4, ghost-skips DISABLE_COLLISION(0x1000) clear, `posCurr.y=midY+0x10000`(decomp double-write→project single +BOTS_MASK_DROP_HEIGHT), MaskUseWeapon dur=0x1e00 rot.z|=1, ThTick_SetAndExec(RevEngine). ✓ +- **BOTS_Killplane @0x80013a70 (sign-sensitive)**: Tiny Arena `strcmp(levelName,"asphalt2")==0`; checkpoint.currentIndex(bot+0x495 signed char) `==-0x6c(0x94)→0x84`/`==-0x60(0xa0)→0x80`/else 0xff. **goBackCount read zero-extended (byte)** → `(int)uGoBack < override-1` signed = project u8-promoted compare. `override+1U < uGoBack` uses UNSIGNED in decomp but both operands∈[0,255] so identical to project signed `(int)(override+1)last-1` on underflow (both re-read currNav each iter); equality sign-agnostic. → BOTS_MaskGrab. ✓ +No new bugs; the 3 NOTE(aalhendi) ASM-verified headers independently re-confirmed via Ghidra decomp. (BOTS_ThTick_Drive @0x80013c18 = the ~1400-line AI driver; deferred — verify in landmark-anchored slices, not one pass.) + +### Bug-class re-scan cont'd (2026-07-08) — BOTS.c SetRotation @0x80013444 + LevInstColl @0x800135d8 — Match, NOTE(claude) rot[2] fix CONFIRMED, LevInstColl field-count scare resolved via ASM +- **BOTS_SetRotation** — aim AI at target nav frame. estimatePos = posCurr>>8 (project Sra vs decomp `(uint)>>8` logical: **identical after (s16) trunc** since the shift-type difference lives only in discarded bits 24-31); dx/dy/dz = navpos - estimatePos; distXZ=√(dx²+dz²), distXYZ=√(dx²+dy²+dz²); pitch `ratan2(dy<<12, distXZ<<12)>>4`; yaw path (boolStart==0) `ratan2(-dx,-dz)+0x800>>4`, spawn path `DriverSpawn[0].rot[1]+0x400>>4`; yaw fanned to ai_rotY_608/angle/rotCurr.y/rotPrev.y/ai_rot4[1]; botFlags|=1. **NOTE(claude) rot[2] fix CONFIRMED**: decomp `pEstimateRotNav[2] = pNavFrame->rot[2]` (NOT rot[1]) — pre-fix fed nav yaw byte into roll estimate. ✓ +- **BOTS_LevInstColl — apparent 4-vs-3 zeroed-field discrepancy RESOLVED (no bug)**: decomp showed 4 flag fields set (collFlags/searchFlags/field6_0x16/skipCollision), project 3 — but ASM has only 6 total scratchpad stores: `sh 1→0x22`(searchFlags=TEST_INSTANCES), `sh 0x3f→0xc`(modelID), `sw zero→0x24`(quadFlagsWanted u32), `sw zero→0x28`(quadFlagsIgnored u32), `sh 0x19→0x6`(hitRadius), `sw ptrMesh→0x2c`. static_asserts confirm searchFlags@0x22 / quadFlagsWanted@0x24(u32) / quadFlagsIgnored@0x28(u32) → the two u32 zeros exactly cover 0x24-0x2b = decomp's 3 sub-named fields (**union-view naming trap**: decomp's "collFlags"@0x22 = project's "searchFlags"@0x22; decomp's "searchFlags" is a DIFFERENT offset). Project byte-exact. ✓ +- LevInstColl tail (ASM-traced): `boolDidTouchHitbox`(0x42)==0→ret; `searchFlags &= ~0x8`(COLL_SEARCH_REUSE_NORMALS); `bspHitbox->flag(byte@0) & 0x80`(BSP_HITBOX_COLLIDABLE)==0→ret; instDef=`bspHitbox+0x1c`, inst=`instDef->ptrInstance`(single `lw`@0x2c — project right, decomp's two-short bbox.min reconstruction was a misrender)==0→ret; `COLL_LevModelMeta(instDef->modelID@0x3c)`; meta/`meta->LInC`(@0x8)!=0 → `LInC(inst, thread, scratchpad+0x108)`. Constants verified: TEST_INSTANCES=1, REUSE_NORMALS=8, COLLIDABLE=0x80. ✓ +No new bugs; rot[2] fix re-confirmed, LevInstColl init proven byte-exact by ASM+static_asserts. + +### Bug-class re-scan cont'd (2026-07-08) — BOTS.c CollideWithOtherAI @0x80016ec8 + GotoStartingLine @0x8001702c — Match, RNG seed determinism ASM-verified +- **BOTS_CollideWithOtherAI** — 2-AI bump resolution. Swap so `robot_1`=higher driverRank (decomp pLeader); project robot_1↔decomp pLeader, robot_2↔decomp robotB. Project onto leader's nav segment: flag `BOT_FLAG_ESTIMATE_NAV`(0x1) clear → start=botNavFrame,end=next(wrapped); set → start=estimatePos,end=botNavFrame. Call `CAM_MapRange_PosPoints(End,Start,pos)` (decomp `(pos1,pos2,...)` where pos1=end,pos2=start — arg order matches). pos=posCurr>>8 (Sra≡decomp logical after (s16)trunc). `iProjAconst_0x30215400)` hits the SAME fn + SAME seed addr (decomp's body-rendered `&g_mainRNG` is the Ghidra label for 0x8008d668, per plate comment + ledger:2168 g_mainRNG==const_0x30215400 pair). weaponCooldown = `((rng>>8)&0xff)+300` (ASM sra/andi 0xff/addiu 0x12c). Weapon-cooldown sequence bit-identical. ✓ +No new bugs; RNG seed/fn confirmed determinism-preserving. + +### Bug-class re-scan cont'd (2026-07-08) — BOTS.c nav/global setup trio (SetGlobalNavData/InitNavPath/Adv_NumTimesLostEvent) — Match, blank-header frame ptr ASM-address-verified +- **BOTS_Adv_NumTimesLostEvent @0x80012568** — rubber-band difficulty lookup. `(u16)numLost > 10 → clamp 10` (decomp `(numLost & 0xffffU)`), return `advDifficulty[numLost]` (decomp `*(short*)(base + ((numLost<<16)>>15))` = (s16)numLost×2 halfword index). For real inputs (byte-sized loss counters 0..255, then clamped ≤10) project's direct `[numLost]` ≡ decomp sign-extended index. BOTS_ADV_MAX_LOSS_DIFFICULTY_INDEX=10 (array has 12 entries, retail stops at 11 — faithful quirk). ✓ +- **BOTS_SetGlobalNavData @0x800123e0** — `arrayOffset=(s16)index<<2` (index×4 ptr-array stride); lastPathIndex=index; nav_NumPointsOnPath=NavPath_ptrHeader[index]->numPoints(@+2); nav_ptrFirstPoint=NavFrameArray[index]; nav_ptrLastPoint=first+numPoints×0x14 (project `&first[numPoints]`, NavFrame stride 0x14). Order differs, no cross-dep. ✓ +- **BOTS_InitNavPath @0x80012440** — reads **global** `sdata->gGT->level1->LevNavTable` (NOT the param gGT — decomp confirms, param unused for lookup); index in a1 (decomp lost it as `in_a1`). Null table → pHeader=0. pHeader==0 → blank template: `NavPath_ptrNavFrameArray[i]=NAVHEADER_GETFRAME(&blank_NavHeader)`, numPoints=0. **ASM-address check**: decomp hardcodes `0x8008dacc`; blank_NavHeader@0x8008da80(regionsEXE UsaRetail) + sizeof(NavHeader)=0x4c = 0x8008dacc ✓ (== &blank_NavFrame, the next struct field). else → header=pHeader, frame=pHeader->frame(=GETFRAME=h+0x4c), `magicNumber != -0x1303`(0xECFD) → numPoints=0. Tail: nav_NumPointsOnPath/nav_ptrLastPoint(=first+numPoints)/header->last/nav_ptrFirstPoint. ✓ +No new bugs; blank-header frame pointer proven exact by struct address + sizeof (0x8008da80+0x4c=0x8008dacc). (BOTS.c remaining: Adv_AdjustDifficulty @0x80012598 [large, ~0xddc B — defer], ChangeState, Driver_Init/Convert, UpdateGlobals, ThTick_Drive.) + +### Bug-class re-scan cont'd (2026-07-08) — BOTS.c UpdateGlobals @0x80013374 + Driver_Init @0x80017164 + Driver_Convert @0x80017318 — Match, memset sizes static-asserted +- **BOTS_UpdateGlobals** — per-frame AI bookkeeping. numBotsNextGame!=0→EngineSound_NearestAIs; scan driversInRaceOrder[7..0]: bot(actionsFlagSet&ACTION_BOT 0x100000)→bestRobotRank=d + first-bot→worstRobotDriver, else→bestHumanRank=d; bestHumanRank==0→worstRobotDriver. **2 harmless deltas**: (a) counter-inc `aiCollisionDelayFrameCount++`(=_g_nUnkCounterUpTo450) — decomp does it pre-loop, project post-loop; global untouched inside loop → same result. (b) "first bot" test project `bestRobotRank==0` vs decomp `pWorstRobot==0` — both globals go 0→non-zero at the SAME (first-bot) iteration, so monotonically equivalent. ✓ +- **BOTS_Driver_Init** — spawn AI. **3-path walk equivalence traced** (initial→initial-1→wrap-to-2, give-up when back to initial): decomp pre-reads numPoints[initial] + tracks sChosenPath-before-decrement; project's `while{read;>1 break;idx--;<0→2;==initial→NULL}` picks the SAME path (verified both the found-path and all-invalid give-up traces). Then `PROC_BirthWithObject(0x62c0101, ThTick_Drive, ...)`; `memset(driver,0,0x62c)`; VehBirth_NonGhost; drivers[id]=obj; modelIndex=DYNAMIC_ROBOT_CAR(0x3f); botPath/botNavFrame/actionsFlagSet|=ACTION_BOT (order-indep); LIST_AddFront(navBotList[path], &botData); numBotsNextGame++; GotoStartingLine. **Minor cosmetic**: debug name decomp "robotcar" vs project 0 (documented, debug-only, no gameplay effect). ✓ +- **BOTS_Driver_Convert** — human→AI mid-race. ACTION_BOT set→ret; GetDriverClock; same 3-path walk; `memset(&botData,0,0x94)`; speedY=ySpeed; speedLinear=abs(speedApprox); botPath/botNavFrame; clear navProgress/turnAngle/multDrift/ampTurn/wallRubTimer(=set_0xF0_OnWallRub); funcThTick=ThTick_Drive; BATTLE_MODE→posCurr = header->frame[0].pos<<8 (project NAVHEADER_GETFRAME ≡ decomp (*ppHeader)->frame[0]); LIST_AddFront; SetRotation(d,0); JogCon2; `actionsFlagSet = (old & ~0xc[JUMP_BUTTON_HELD 0x4|ACCEL_PREVENTION 0x8]) | ACTION_BOT`; OLD-flags RACE_FINISHED→CAM_EndOfRace; kartState switch (KS_SPINNING[=DRIFTING|CRASHING]→REACT_SPINOUT 1 / KS_BLASTED→REACT_BLASTED 2 / default ret)→BOTS_ChangeState(d,rt,0,0). ✓ +Sizes static-asserted: DRIVER_NTSC_RETAIL_SIZE=0x62c, sizeof(BotData)=0x94 (both = decomp memset sizes). No new bugs. + +### Bug-class re-scan cont'd (2026-07-08) — BOTS.c BOTS_ChangeState @0x80016b00 (AI hit-reaction) — Match, NOTE(claude) Fix-130-adjacent reason-vs-damageType credit fix ASM-CONFIRMED +Verified the AI damage/state fn (params: victim, damageType[a1/param2], attacker, reason[a3/param4]) vs decomp: +- Preamble: pendingDamageType(=cChangeState_damageType)=0; kartState==MASK_GRABBED→ret 0; kartState=NORMAL. +- **6 cases** (project switches raw damageType 0/1/4/2/3/5 = decomp REACT_NONE/SPINOUT/SPINOUT_BURN/BLASTED/SQUISHED/MASKGRAB): case0 REACTING(BOT_FLAG_DAMAGE_ACTIVE 0x2)→ret0; case1/4 spinout (turboMeter=0, non-Oxide-or-grounded → reserves/OTT/reserved_0x5cc=0 + speedLinear Oxide>>1/else>>2, squishCooldown=0x300, DAMAGE_ACTIVE; 4 also burn SFX 0x69/burnTimer=0xf00); case2 blast (speedY=AI_VelY_WhenBlasted, !active→speedLinear>>3 + posBackup.y+=0x4000, matrixArray=0, attacker==0→ret1, non-bot attacker→Voiceline VOICE_HURT=1); case3 squish (SFX 0x5a if squishCooldown==0, rotXZ[=nCrashTimer@0]=0xf00, squishTimer=0xf00, speedLinear>>1, flags|=DAMAGE_ACTIVE|SUPPRESS_EMITTER 0x4); case5 maskgrab (HIDE_MODEL, rotXZ=0xd20, kartState=MASK_GRABBED, thread THREAD_FLAG_DISABLE_COLLISION 0x1000); default ai_progress_cooldown(@0x604)=0x3c. All field/flag mappings match (DAMAGE_ACTIVE=0x2/SUPPRESS_EMITTER=0x4 static-asserted; rotXZ≡nCrashTimer@offset0; reserved_0x5cc≡nUnk_0x10). ✓ +- **NOTE(claude) fix ASM-CONFIRMED (raw beq immediates @0x80016e18-e60)**: gate `beq s1(attacker),0` + `beq s2(damageType/param2),0` → gate on **damageType**; then `numTimesAttacked(0x559)++`; weapon switch on **s3 (reason/param4)**: `li v0,3; beq s3,v0 → 0x557 missile++`; `slti s3,4 / beq s3,1 → 0x55a bombs++`; `beq s3,4 → 0x556 movingPotion++`; else none. Project switches on `reason` (1=bombs/3=missile/4=movingPotion) EXACTLY. Pre-fix switched weapon-credit on damageType(param2, physics reaction) → missile (damageType=1/reason=3) miscredited as bomb, bomb (damageType=2/reason=1) credited nothing. ✓✓ +No new bugs; the reason-vs-damageType stat-credit fix verified at the raw-assembly level (register + immediate). **BOTS.c substantive fns now DONE except Adv_AdjustDifficulty (large, deferred) + ThTick_Drive (giant, deferred).** + +### Bug-class re-scan cont'd (2026-07-08) — BOTS.c BOTS_Adv_AdjustDifficulty @0x80012598 (~3.5KB, difficulty+grid setup) — Match, all constants verified, lerp-count scare resolved +Verified the big race-start AI setup fn vs decomp (section-by-section): +- **Stat-table select**: ADVENTURE_BOSS(bit31)→BossDifficulty[bossID] else ArcadeDifficulty[levelID]; Easy=entry@0(=params1→difficultyParams[1]), Hard=entry@0xe(=params2→difficultyParams[0]), stride 0x1c (14 shorts). NOTE(aalhendi) slot-1/slot-0 storage correct (LerpDifficulty reads lo=[1]/hi=[0]). ✓ +- **Difficulty scalar** (4 modes): ARCADE `(u16)arcadeDifficulty` (SUPERHARD→0x140; cupDiff=raw+0x50); ADVENTURE_CUP `track*5-(lost-0xe1 cup4/0xcd else)` (CHEAT_ADV→track*7-(lost-0x141/300)); BOSS `bossID*5-(lost-0xe1)` (CHEAT→*7-(lost-0x141)); ADV-LEVEL `((numTrophies+1)*0x23 [round-toward-0 /4]) - (lost-0x3c)` (CHEAT→*0xc-(lost-100)). lost-arrays: cup@advProgress+0x42, boss@+0x47, perLev@+0x30. Final `(s16)diff<0→0`. ✓ +- **Lerp-count scare RESOLVED**: decomp lerps 8-in-loop(idx0-7) + **6 explicit reordered**(0xb,0xc,0xd,9,8,0xa = idx8-0xd) = 14 params; project single `BOTS_Adv_LerpDifficulty` loop of **BOTS_DIFFICULTY_PARAM_COUNT=14** (enum-confirmed) covers same contiguous idx0-13. dst(arcade/cup params)≠lo(Easy)≠hi(Hard) → write-order irrelevant → identical result. DENOM=0xf0 (enum). Cup lerp gated `ADVENTURE_CUP|CUP_ANY_KIND`. ✓ +- **RNG determinism**: seed block `_g_dwBotRngSeed0/1(=const_0x30215400/493583fe @0x8008d668) = 0x30215400/0x493583fe if 0`; all RNG calls `RngDeadCoed(&const_0x30215400)` = decomp `RNG_Random(&g_mainRNG)` (same fn 0x8006c684 + seed 0x8008d668, per GotoStartingLine ASM + in-fn seed block). ✓ +- **Grid/path/accel**: nav loop ×3 (LIST_Clear + InitNavPath unless CUTSCENE|MAIN_MENU); spawn-order CopySpawnOrder (2×s32 = 8 bytes, ≡ decomp byte-by-byte) by numPlyr/mode (TT/crystal/boss/purple-cup/arcade — decomp inverted-condition nest ≡ project if-else chain); path RNG `pathOrder[1]=(rng>>8)&1` etc + boss override [0]=0/[1]=1; battle `(rng&0xfff)/0x555` ×4; accel front-row `((rng>>8)+i)&3` / rear `((rng2>>8)-i)&3+4` (decomp unmasked-intermediate ≡ project masked-each-step, &3 washes arith-vs-logical shift); cup accel-swap (best-points driver ↔ accel-0 driver). Constants BOTS_MAX_KARTS=8/NAV_PATH_COUNT=3/BATTLE_DRIVER_COUNT=4/GRID_ROW_KART_COUNT=4 all enum-confirmed. ✓ +No new bugs. **BOTS.c now fully verified except the giant ThTick_Drive @0x80013c18** (helpers Adv_LerpDifficulty/Adv_CopySpawnOrder are retail-inlined, no separate address; project factored them out with identical behavior). + +### Bug-class re-scan cont'd (2026-07-08) — UI_VsQuip.c (ReadDriver/Assign/AssignAll — pre-race taunt system) — Match, PSX-pointer relocation ASM-verified self-consistent +- **UI_VsQuipReadDriver @0x80054a08** — var-width field reader. decomp `driver->funcPtrs+offset-0x54` ≡ project `(char*)driver+offset` (funcPtrs@0x54). size2→(s16 sign-ext), size1→(u8 zero-ext), size4→raw, else 0. ✓ +- **UI_VsQuipAssign @0x80054a78** (DB name "UI_VsQuipAssignToDriver"; syms926 authoritative) — gate `driver!=0 && (!(flags&4)||driver==leader)`; max-nWeight scan w/ MixRNG_Scramble `(>>3&0xff)<0x40` 50% tiebreak; old-quip (EndOfRaceComment_ptrQuip@0x56c, priority@line+4) keep unless strictly-higher (RNG tie), un-negate old; claim by negating nWeight; store line ptr + characterID@0x570; timerEndOfRaceVS = BATTLE?150(0x96):300. ✓ +- **UI_VsQuipAssignAll @0x80054bfc** — data-driven quip rule eval. Aging pre-pass (+1 every line nWeight, stride 8); leader-detect (score=numLives or pointsPerTeam[teamID] if POINT_LIMIT 0x4000; bestScore/secondScore/bestDriver w/ tie→NULL — decomp comma-side-effects ≡ project if/elseif); per-rule threshold(*|numLaps| if flags&1); 9-case switch (0/1 max-stat band, 3 rival-attacker[numTimesAttackedByPlayer[i] folded walk, sets characterID], 4/5 min-side band [5 inits 0x7fffffff], 6 ==numTimesAttacking, 7 leader/2nd-place, 8 ==threshold, 9 default-fill) — all decomp comma-exprs ≡ project next*-carry if/else; mid-loop assign if `flags&0xc`; final assign. ✓ +- **PSX-pointer relocation ASM-verified self-consistent (port adaptation, NOT a bug)**: retail uses raw PSX pointers in the quip data (rule->pLinesStart/End are absolute); port relocates via UI_VsQuipPtrFromPsx(`data850/830 + (psx - 0x800864dc)`) + MetaFromRaw. Table bounds: **base 0x800864dc pinned two ways** — decomp race-end symbol literally `DAT_800869f4`=base+UI_QUIP_VS_META_END(0x518); ASM battle path `lui 0x8008/addiu 0x6c0c`=`g_aVsQuipRulesAdv`=0x80086c0c=base+UI_QUIP_BAT_META_OFF(0x730). VS span 0x170..0x518=0x3a8/0x18=39 rules; BAT 0x730..0x850=0x120/0x18=12 rules. quipData_native≡PSX 0x800864dc used consistently by both meta-offsets and line-ptr reloc. ✓ +No new bugs; PSX relocation scheme proven internally consistent (base confirmed by symbol-name + ASM). (Remaining: UI_VsQuipDrawAll @0x800550f4.) + +### Bug-class re-scan cont'd (2026-07-08) — UI_VsQuipDrawAll @0x800550f4 (quip bubble render) — Match, color arg 0xffff8000 ASM-CONFIRMED (decomp C output misleading) +Verified the post-race quip-bubble renderer vs decomp (**DB name "EndOfRace_DrawAllComments"; syms926 authoritative = UI_VsQuipDrawAll**): +- Loop driver threads (threadBuckets[0]), playerIndex++; skip if `Battle_EndOfRace.boolPressX[i] & 2` or `EndOfRaceComment_ptrQuip(@0x56c)==0`. printArr[2]=printArr[3]=0. Conjoined bit `printArr[1]&1`: 0→`lngStrings[printArr[0]]`; 1→`sprintf(stackBuf, lngStrings[printArr[0]], lngStrings[MetaDataCharacters[characterID@0x570].name_LNG_long])`. Center in viewport `rect.x + w>>1`, `rect.y + h>>3` (decomp `(u16)w<<16>>17` ≡ project `(s16)w>>1` — same incl. sign; pushBuffer≡tileView stride 0x110). ✓ +- **NOTE(claude) sprintf-buffer ASM-confirmed**: `addiu a0,sp,0x20` (0x800551ec/f0) — retail sprintf target is a 128-byte STACK buffer in a 0xc0 frame (project `char conjoined[128]`), NOT scratchpad. sprintf @jal 0x80078348. ✓ +- **COLOR ARG 0xffff8000 ASM-CONFIRMED (trust-ASM win)**: decomp C output shows `RECTMENU_DrawQuip(...,0x8000,4)` but ASM `li v0,-0x8000` (bytes 00800224 = `addiu v0,zero,0x8000` → **sign-extends to 0xFFFF8000**) then `sw v0,0x14(sp)` (6th arg). Project passes `0xffff8000` = EXACT match; the decompiler's C literal `0x8000` dropped the sign-extension and would have made the (correct) project look wrong. Both call paths (sprintf/single) use same trailing args (a3=0, 5th=3, 6th=0xffff8000, 7th=4). ✓ +No new bugs; color arg proven 0xffff8000 by raw ASM (addiu sign-extension) — project correct, decomp C misleading. **UI_VsQuip.c COMPLETE (all 4 syms + inlined Data/PtrFromPsx/MetaFromRaw helpers).** + +### Bug-class re-scan cont'd (2026-07-08) — UI_VsQuip.c UI_VsWaitForPressX @0x800552a4 (battle end press-X/tally) — Match, 2nd color sign-trap + DrawClearBox 4-arg refactor + OT offset all ASM-verified +Verified the battle end-of-race per-viewport press-X + attack-tally screen (**DB name "EndOfRace_Battle"; syms926 authoritative**): +- Per-player loop (pushBuffer[i]≡tileView[i], stride 0x110 — decomp folds `(i*0x10+i)*0x10=i*0x110`); flag `boolPressX[i]` bit1=YOU-HIT/HIT-YOU view, bit2=done. Not-done: D-pad L|R(`&4||&8`)→^1; `(timerEndOfRaceVS<0x78 && tap&0x1010[CROSS|START])`→^2 (ASM `andi 0x1010` ✓); battle(gameMode1&0x20) header lngStrings[0x157+(flag&1)] (ASM `li a0,0x157/0x158`) centered `rect.x+w>>1`,`rect.y+0x23`, FONT_CREDITS=3; then per-other-player `pN:NN` = numTimesAttackingPlayer[j]@0x50c (you-hit) / numTimesAttackedByPlayer[j]@0x560 (hit-you), pos from textPos2P(2P)/s_battleStatsPos3P4P(3-4P; 0x350055={x0x55,y0x35}), color `(teamID+0x18)|0x8000` (positive, NOT sign-ext), FONT_SMALL=2. Done: clear box + Square(&0x8000)→^2 + ready++. All-done(ready==numPlyr): timerEndOfRaceVS=0(gGT+0x1d36), clear 4B boolPressX. ✓ +- **2nd color sign-trap ASM-CONFIRMED**: header color — decomp C `0x8004` but ASM `li v0,-0x7ffc` (bytes 04800224 = `addiu zero,0x8004` → 0xFFFF8004) `sw 0x10(sp)`. Project `0xffff8004` correct; decomp C dropped sign-ext (same pattern as DrawQuip 0xffff8000). ✓ +- **CTR_Box_DrawClearBox 4-arg refactor (NOT dropped-arg bug)**: decomp 5-arg `(rect,color,0,ot,&primMem)`; project 4-arg `(rect,color,0,uiOT)` per functions.h:73 — ALL project callers 4-arg, primMem accessed internally (CTR_Box.c prev-verified). ASM: a3(ot)=`lw 0xa0(v1)`, 5th stack=`v1+0x74`. **struct DB: primMem@0x74, otMem@0x90** (static_assert) → ot=backBuffer+0xa0=otMem.uiOT(uiOT@0x10), 5th=backBuffer+0x74=&primMem. Project `otMem.uiOT` EXACT (decomp "startPlusFour"=Ghidra name for uiOT). ✓ +No new bugs; header color 0xffff8004 + OT=otMem.uiOT@0xa0 + 4-arg refactor all ASM/struct-verified. **UI_VsQuip.c file fully DONE (5 fns).** + +### Bug-class re-scan cont'd (2026-07-08) — UI_Weapon.c (DrawSelf/DrawBG — weapon HUD icon) — Match, juiced-icon sltiu + all-8 DrawWeapon hidden-args ASM-recovered +- **UI_Weapon_DrawSelf @0x800507e0** — held-weapon HUD icon. heldItemID@0x36: 0xf→ret, 0x10→roulette (rand%0xc normal/%0xe battle, remap 5→0/8→1/9→3, UI_Lerp2D_HUD slide-in via PickupTimeboxHUD@0x4b0). Normal: iconId=item+5; g_szWeaponQty=numHeldItems@0x37+'0' (unconditional sb @0x80050998, NOTE(claude) confirmed). **Mask (item7) ASM-confirmed**: characterID=g_awCharacterIDs[driverID@0x4a] compared `!=0(CRASH),!=3(COCO),!=6(POLAR),!=7(PURA)`→iconId=0x32(Uka); project bitmask `(0xc9>>char)&1` = bits{0,3,6,7} EXACT. **Juiced-icon sign ASM-CONFIRMED**: `0x80050a0c sltiu v0,v0,2` — UNSIGNED, so `(u32)(heldItemID-3)<2` = items{3,4} only (project (u32) cast correct; signed would wrongly juice items 0-2); `|| ==6`; numWumpas@0x30 `lb`+`slti ,0xa` (>=10, non-neg ok); juiced iconId=item+0x11. Flicker (noItemTimer@0x3c && timer&1→ret); qty draw if numHeldItems!=0 (FONT_SMALL=2). ✓ +- **DecalHUD_DrawWeapon hidden-args RECOVERED (decomp showed only 1 arg)**: ASM @0x80050940-abc — a0=ptrIcons[iconId](gGT+0x1eec), a1=posX, a2=posY, a3=`gGT->backBuffer(gGT+0x10)+0x74`=&primMem, stack 0x10=ptrOT(gGT->pushBuffer_UI.ptrOT@gGT+0x147c), 0x14=TRANS_50_DECAL(=1), 0x18=scale, 0x1c=1. Project's 8-arg call EXACT match. ✓ +- **UI_Weapon_DrawBG @0x80050af8** — roulette shine BG. juicedUpCooldown@0x4e0 dec; wumpaShineTheta+=0x100; scaleY=`scale*0xd000>>16` (positive→logical≡arith); 2× UI_WeaponBG_DrawShine(ptrIcons[0x31=0xc4/4], posX, posY, &primMem, tileView[driverID].ptrOT, layer 2/3, scale, scaleY, 0xff0000). ✓ +No new bugs; juiced-icon range proven UNSIGNED (sltiu) by ASM, all 8 DrawWeapon args recovered from registers/stack. **UI_Weapon.c DONE.** + +### Bug-class re-scan cont'd (2026-07-08) — UI_Icon.c weapon-shine helpers (AnimateShine/DrawShine) — Match, CONCAT byte-packing + GT4 quad grid verified +- **UI_WeaponBG_AnimateShine @0x8004e0e0** — per-frame shine gradient from `abs(MATH_Sin(wumpaShineTheta))`. decomp CONCAT11/CONCAT12 byte-packing ≡ project per-channel writes: wumpaShineColor1 warm gradient [0]{0x7f,0x7f,0}/[1]{0x7f,0x32,0}/[2]{0x21,0x10,0} (each ch = base+sine*base>>12); wumpaShineColor2 uniform {0x5f,0x5f,0x5f} (project copies [0] int→[1]/[2] ≡ decomp recomputes same); wumpaShineResult=`(sine*0xff>>0xd)+0x80` (write-order diff harmless). ✓ +- **UI_WeaponBG_DrawShine @0x8004e37c** — 2×2 mirrored POLY_GT4 shine grid. Color select layer==3→color2 else color1 (project `transparency`==decomp `layer`, the 6th char arg); quad dims `span*scale>>12` (signed arith); 4 tiles (TL/TR-mirrorX/BL-mirrorY/BR) → GT4 offsets x0@8/x1@0x14/x2@0x20/x3@0x2c, uv@0xc/0x18/0x24/0x30; colors r0@4=color[2], r1@0x10=r2@0x1c=color[1], r3@0x28=color[0]; setPolyGT4 (len 0xc, code 0x3c); layer!=0 → tpage@0x1a `&0xff9f|(layer-1)*0x20` + code|=2; AddPrim; curr+=0x34. **Compiler-artifact (not bug)**: case1 mirror-X decomp `iQuadW<<1`(int) vs project/case3 `sQuadW*2`(short) — equal since iQuadW=(span*scale>>12) fits s16 for real icon/scale. ✓ +No new bugs; gradient byte-packing + GT4 quad-grid geometry all match. (UI_Icon.c remaining: UI_TrackerBG @0x8004e660, UI_DrawDriverIcon @0x8004e8d8.) + +### Bug-class re-scan cont'd (2026-07-08) — UI_Icon.c UI_TrackerBG @0x8004e660 + UI_DrawDriverIcon @0x8004e8d8 — Match, decompiler-dropped u32 UV-copy resolved via ASM (near-false-alarm) +- **UI_TrackerBG** — lock-on tracker BG, 2×2 mirrored POLY_FT4 (flat color, no gouraud). wumpaShineTheta+=0x100; quad dims `span*angle>>12`. Project refactor (set defaults x0/x1/y0/y2, per-case override, then x2=x0/y1=y0/x3=x1/y3=y2) ≡ decomp's explicit per-case FT4 writes (offsets x0@8/x1@0x10/x2@0x18/x3@0x20; uv@0xc/0x14/0x1c/0x24; color@4). `len`(widescreen)=0 retail. setPolyFT4 (tag 9, code 0x2c); transparency tpage@0x16 `&0xff9f|(t-1)*0x20`+code|=2; curr+=0x28. (decomp `iQuadW<<1` case1 vs project `sVar*2` — equal for s16 range.) ✓ +- **UI_DrawDriverIcon @0x8004e8d8** — single textured FT4 icon quad w/ Y-clamp. **NEAR-FALSE-ALARM RESOLVED**: project sets v[2].texCoords.u(0x1c)+v[2].clut(0x1e) that the DECOMP appeared NOT to write → suspected project addition. ASM `0x8004e934 sw v0,0x1c(t0)` (v0=icon+0x1c word) proves retail DOES write a u32 at p+0x1c = {u2,v2,u3,v3} — decompiler DROPPED it. Project's split (u2@0x1c + `(v3<<8)|u3`@0x1e + u3@0x24) exactly reproduces it; v2.v@0x1d/v3.v@0x25 then overwritten by `bottomV=v0+clampedY-posY` in both. NO divergence. ✓ +- DrawDriverIcon rest (ASM-confirmed): color@4, uv u0v0clut@0xc/u1v1tpage@0x14; x0@8=posX, y0@0xa=`clamp(posY,0xa5)` (slti 0xa6/li 0xa5, NTSC 165/166 path — EurRetail 175/176 gated by BUILD); x1@0x10=x3@0x20=posX+`span*scale>>12`(FP_Mult), y2@0x1a=y3@0x22=clamp(posY+height*scale>>12); tag9/code0x2c; transparency; AddPrim(jal 0x80075310); curr+=0x28. ✓ +No new bugs; the p+0x1c u32 UV-copy (decompiler-dropped) confirmed present in retail by ASM — project split is faithful, not an addition. **UI_Icon.c DONE (4 fns: AnimateShine/DrawShine/TrackerBG/DrawDriverIcon).** + +### Bug-class re-scan cont'd (2026-07-08) — RefreshCard.c save/ghost helpers (BoolGhostForLEV/GetResult/GhostEncodeByte/GhostDecodeByte) — Match, base64 URL-safe codec verified +Save-data / ghost-filename functions (deterministic, memcard-compat-critical): +- **BoolGhostForLEV @0x800469f0** — count saved-ghost dir entries with matching trackID. decomp's convoluted register-reuse (sIdx alternates index↔count) = project clean `for(i>0x10`≡`(s16)(c-0x37)` (0xffc9=-0x37); project checks `<0x5b` first vs decomp `>0x5a` first — same partition. Encode/Decode confirmed exact inverses. ✓ +No new bugs; base64 ghost-filename codec (encode/decode inverse pair) + memcard FSM poll all match. (RefreshCard.c remaining: GhostEncode/DecodeProfile, memcard action queue fns, Entry/Unknown3/4.) + +### Bug-class re-scan cont'd (2026-07-08) — RefreshCard.c GhostEncodeProfile @0x80046c30 + GhostDecodeProfile @0x80047034 — Match, ghost-metadata bit-packing verified as exact inverse pair (save-filename determinism) +- **Bit layout EXACT (both dirs)**: `packed = characterID(4b@bit0) | levelID/trackID(5b@bit4) | timeMS(20b@bit9) | slot(3b@bit29)`. Encode `packed = char | (levelID<<4) | (time<<9) | (slot<<0x1d)`; decomp `(levelID<<16)>>12`≡`levelID<<4` (positive, no sign issue). 6 URL-safe base64 chars @filename[0xd..0x12] = `(packed>>{0,6,12,18,24,30})&0x3f`; decomp masked-shift forms (`((uPack1>>16)&0xfc)>>2`=bits18-23 etc.) reduce identically. ✓ +- **GhostEncodeProfile**: `0x8c9ff` time clamp; uniqueness retry `slot=(slot+1)&7` vs g_aGhostProfileMemcard[].pProfile_name (project GhostProfileNameExists helper = decomp inlined strcmp loop); title `metaDataLEV[lev].name_LNG + ':' + MetaDataCharacters[char].name_LNG_short(+6, NOTE(claude) confirmed) + ':' + DrawTime` → CTR_ScrambleGhostString → memcardIcon_HeaderGHOST (0x3e); profile fill (name 0x14 + star@0x14, SubmitName 0x11, alwaysOne=1, trackID/charID/trackTime); **off-by-one memcardProfileIndex** (packed w/ slot S, stored S+1 — faithful retail quirk, both match). ✓ +- **GhostDecodeProfile** (exact inverse): char=packed&0xf, trackID=(packed>>4)&0x1f, trackTime=(packed>>9)&0xfffff, slot=(u32)packed>>29; decomp's convoluted slot reconstruction `((uPack1>>16)|((dec5<<30)>>16))>>13` = {dec4 bit5, dec5 bit0/1} = same 3-bit slot; (s16)DecodeByte casts harmless (0-63 positive); profile_name memcpy 0x15, trackID high byte=0. ✓ +No new bugs; ghost-metadata pack/unpack proven exact inverses — save-filename round-trip bit-exact. (RefreshCard.c remaining: memcard queue fns, Entry/Unknown3/4, GameProgressAndOptions.) + +### Bug-class re-scan cont'd (2026-07-08) — RefreshCard.c memcard-action setters (NextMemcardAction/Start/Stop/SetScreenText/GetNumGhostsTotal/Unknown2/GameProgressAndOptions) — Match, copy-size + Stop-global resolved via ASM +- **NextMemcardAction @0x80046b1c** — queue op. Params slot-first/action-second (decomp NOTE); 9 assigns: frame4/frame2_memcardAction=wait/queuedAction, frame4/frame2_memcardSlot=wait/queuedSlot, ghostProfile_fileName/fileIconHeader/ptrGhostHeader/size3E00=fileName/saveExtra/buffer/size, memcardUnk1&=~8. ✓ +- **GameProgressAndOptions @0x80047230** — apply loaded save. Handshake: unk8008d95c(RefreshFlag)=1, unk_memcardRelated_8008d928[0](RefreshState)=1, advProfileIndex(RefreshResult)=-1(0xffff); SyncGameAndCard(memcard+0x144, &gameProgress); **memcpy size RESOLVED**: decomp loop copies [0x144,0x15f4)=0x14b0 **+ 0xc-byte tail = 0x14bc total** = `sizeof(GameProgress 0x1494)+sizeof(GameOptions 0x28)` (0x28 = 926 variant of dual build-gated assert); RaceConfig_LoadGameOptions. (My initial 0x14b0 miscount forgot the tail.) ✓ +- **Unknown2 @0x800471e8** — `if(!boolAdvProfilesChecked[=g_nBoolMemcardInited]){InitFullMemcard(memcardBuf); =1}` RefreshFlag=1, RefreshState=0. ✓ +- **StartMemcardAction @0x80047198** — ASM `sh action,0x50c(gp)`[mcStart] + `sh zero,0xa18(gp)`[boolError] + `sh zero,0x9f8(gp)`[unk8008d964] (source lists unk8008d964/boolError; compiler reordered the 2 zero-clears, harmless). ✓ +- **StopMemcardAction @0x800471ac — Stop-global RESOLVED**: ASM `li 1; sh v0,0x9f8(gp)` + `li 2; sh v0,0x50c(gp)`[mcStart]. gp+0x9f8 = **0x8008d964** = project `unk8008d964` (name encodes its addr!) = decomp g_nMcStopRequested → project sets the CORRECT stop global to 1. ✓ +- **SetScreenText @0x800471c4** — mcScreenText=textId + Unknown1(`memcardUnk1=(|6)&~8`). **GetNumGhostsTotal @0x80047224** — resets numGhostProfilesSaved=0 (syms-vs-binary name mismatch, binary authoritative). ✓ +No new bugs; GameProgress copy size 0x14bc (loop+tail) = struct sum; Stop sets unk8008d964@gp+0x9f8 (name-encoded addr confirms correct global). (RefreshCard.c remaining: Queue* helpers, SetScreenAndPoll, Unknown3/4, Entry.) + +### Bug-class re-scan cont'd (2026-07-08) — RefreshCard_Unknown3 @0x800472d0 (memcard refresh FSM) — **Fix 134 (NEW BUG): spurious Unknown2() in card-FULL path** + 2 NOTE(claude) fixes confirmed +Verified the memcard refresh state machine (~0x788 B) vs decomp: +- **Fix 134 (NEW BUG)**: **card-FULL branch called RefreshCard_Unknown2() where retail does NOT.** ASM `0x80047608 jal GetResult; li a0,1[MC_RESULT_FULL]` then `0x80047610 bne v0,zero,0x800476b4; li a0,6[MC_SCREEN_ERROR_FULL]` — FULL!=0 jumps STRAIGHT to SetScreenText(ERROR_FULL) tail, NO `jal 0x800471e8`(Unknown2). Contrast NOCARD(0x800475f0)/TIMEOUT(0x80047628)/UNFORMATTED(0x800476a8) which all DO jal Unknown2. Project's extra Unknown2() re-ran GAMEPROG_InitFullMemcard + reset RefreshFlag=1/RefreshState=0 on a full card. **Fixed: removed Unknown2() from FULL path.** Built clean. Enum-confirmed: MC_RESULT_ERROR_NOCARD=0/FULL=1/TIMEOUT=2, MC_SCREEN_ERROR_FULL=6. +- **Top switch (mcScreenText 0/1/5-6/7-9)** matches: NOCARD(0)→keepPoll+mcStart=2+boolError=1; UNFORMATTED(1)→mcStart!=7 retry / ==7 FORMAT; CHECKING/FULL(5-6)→keepPoll; TIMEOUT/NULL/NODATA(7-9)→mcStart 5=GhostLoad/3=MainSave/6=erase-or-GhostSave/else keepPoll. boolError≡g_nMcActionProgress(gp+0xa18). +- **NOTE(claude) ghostFileNameFinal CONFIRMED**: case6 rowSelect>=0 copies memcard[rowSelect].pProfile_name (0x15B) into DAT_80099284(=ghostFileNameFinal, SEPARATE buf) → ERASE, then memmove-compact + numGhosts--. Retail uses the separate buffer, not s_BASCUS template. ✓ +- **GetResult sequence** (project sequential early-returns = decomp Ghidra-nested via MVar4/shared tail): NEWCARD→Unknown2+GetNumGhosts+done; NOCARD→+SetScreenAndPoll(NOCARD); [FULL: fixed]; TIMEOUT→Unknown2+SetScreenAndPoll(TIMEOUT); READY_LOAD(4)→Unknown2+QueueMainLoad; UNFORMATTED(6)→GetNumGhosts+Unknown2+SetScreenAndPoll(UNFORMATTED); READY_SAVE→ghost insert/save. ✓ +- **NOTE(claude) NODATA mode-gate CONFIRMED**: decomp comma-op `if((profileMode!=0)&&((profileMode<0||(MVar4=NULL,2=3→nothing. ✓ +Fix 134 applied+built; ghostFileNameFinal + NODATA-gate fixes re-confirmed. (Unknown3 deep tails READY_SAVE ghost-insert/LOADING-magic-0x1600ffee structurally match; Unknown4/Entry remain.) + +### Bug-class re-scan cont'd (2026-07-08) — RefreshCard_Unknown4 @0x80047a58 (memcard FSM tick) + Entry @0x80047d64 — Match, RefreshCard.c COMPLETE +- **Unknown4** — low-level memcard tick. `!(statusFlags&1)`: if currAction!=0 → MEMCARD_HandleEvent(=ProcessCardOperation) + run=curr. `statusFlags&1`: run=curr, clear bit0 (+bit2 if bit1 clear: `&0xfffffffa`), dispatch by currAction 1=QueryCard/2=SaveFile(fileName/saveExtra/buffer/size)/3=LoadFile(fileName/buffer/size, no saveExtra)/4=FormatCard/5=EraseFile. init `result=-1`=`~MC_RETURN_IOE`(IOE=0). ✓ +- Newcard rescan (currAction==QUERY && (s16)result==3[NEWCARD]): numGhosts=0; FindFirstGhost(slot, s_BASCUS_94426G_Star); loop decode into memcard[count<7] + numGhosts++, count++, FindNextGhost; **IsFile called TWICE** (1st discarded, `|=8` between, 2nd→result) — faithful retail double-call. ✓ +- Result switch (MC_RETURN 0-7 enum-confirmed = decomp cases 0-7): IOE(0)→READY_SAVE/(QUERY:READY_LOAD or READY_SAVE if !bit8); TIMEOUT(1); NOCARD(2)+clear+goto; NEWCARD(3)/(FORMAT→READY_SAVE); FULL(4); UNFORMATTED(5); NODATA(6); PENDING(7)+goto; default+goto; break-cases clear currAction=0. Tail: currAction==0 && queued!=0 → promote queued→curr+slot, statusFlags=(`&~2`)|1. ✓ +- **Entry @0x80047d64** — `if(!(gameMode1 & DEBUG_MENU 0x10)){Unknown4(); Unknown3();}`. ✓ +No new bugs (beyond Fix 134 in Unknown3). **RefreshCard.c file COMPLETE** (~20 fns: ghost base64 codec, profile pack/unpack, memcard FSM Unknown3/4, action setters, Entry). + +### Bug-class re-scan cont'd (2026-07-08) — SelectProfile.c math/format helpers (PrintInteger/UI_ConvertX/UI_ConvertY/GetTrackID) — Match +- **PrintInteger @0x80047f20** — `usePaddedFormat==1 → stringFormat1("%02ld") else stringFormat2("%ld")`; sprintf(buf, fmt, value); DecalFont_DrawLine(buf, posX, posY, FONT_BIG, color). ✓ +- **UI_ConvertX @0x80047fb8** — `iResult=(oldPosX-0x100)*scale; if(iResult<0)iResult+=0xff; return iResult>>8` (signed div-256 round-toward-zero). ✓ +- **UI_ConvertY @0x80047fd8** — same, offset 0x6c: `(oldPosY-0x6c)*scale`, +0xff if neg, >>8. ✓ +- **GetTrackID @0x800485a8** — despite name, STORES `advProgress.HubLevYouSavedOn = gGT->levelID` (decomp byte-splits into g_abAdvProgress[0x2e]/[0x2f]) + menuGreenLoadSave.rowSelected=1. ✓ +No new bugs. (SelectProfile.c is large — LoadSave_Color[static, r/g/b pack, halve R/B if flags&0x10] + Init/Destroy/DrawAdvProfile/DrawGhostProfile/InputLogic/menu-procs remain.) + +### Bug-class re-scan cont'd (2026-07-08) — SelectProfile.c InputLogic @0x80048f0c + ToggleMode @0x80048e2c + Mute/UnMute — Match, 2 NOTE(claude) button-mask fixes ASM-CONFIRMED +- **InputLogic** — profile-grid D-pad nav + select/back. Guard `tap & 0x4007f` (ASM `lui v0,0x4`→0x40000|0x7f). flags&1==0: nav (UP -2/DOWN +2/L|R ^1 for 2-col grid; decomp comma-in-|| ≡ project if/else-nest), clamp [0,numRows-1], SFX0 on change, select/back; flags&1: confirm-only (back TRIANGLE|SQUARE, +select if flags&2). **NOTE(claude) masks ASM-CONFIRMED**: select `andi v0,v0,0x50`@0x80048fd4 = BTN_CROSS_one(0x10)|BTN_CIRCLE(0x40) (NOT composite BTN_CROSS); back `ori v1,v1,0x20`@0x80049024 on lui 0x4 = **0x40020** = BTN_TRIANGLE(0x40000)|BTN_SQUARE_one(0x20). Decompiler rendered composite BTN_CROSS/BTN_SQUARE; raw andi/ori prove _one single-bit variants. Empty-grid gate `numRows==0 && g_nProfileMode!=1`; unformatted(mcScreenText==1)→row=0. ✓ +- **ToggleMode** — `g_nProfileMode=mode&0xf`(=memcardAction), `g_nProfileModeFlags=mode&0xf0`(=*Mode()=data10_bbb[0]); zero 5 phase-flags (data10_bbb[4/6/8/10]=DAT_8009d8fc/fe/confirmBox/requeryLatch, [12]=completeCountdown); UnMuteCursors; drawStyle&=~0x10 both menus, |=0x10 if modeFlags==0x20; Init(drawStyle); rowSelected=unk_8008d73C(=highScoreTable[0].pName[0..1]); data10_bbb[2]=g_boolMcActionInProgress=0. ✓ +- **MuteCursors/UnMuteCursors @0x80048d../** — 3 menus (FourAdvProfiles/GhostSelection/Warning2) state |=/&=~ MUTE_SOUND_OF_MOVING_CURSOR. ✓ +No new bugs; select/back masks 0x50 & 0x40020 raw-ASM-confirmed (_one variants correct). (SelectProfile.c remaining: Init/Destroy/Draw*/menu-procs/AllProfiles FSM.) + +### Bug-class re-scan cont'd (2026-07-08) — SelectProfile.c DrawAdvProfile @0x80047ff8 — Match, NOTE(claude) icon-OT fix + project-wide `|0xffff8000` DrawLine-color idiom ASM-CONFIRMED +- **DrawAdvProfile** (adventure-profile card) — colors by drawStyle&0x10 (green 0x1d.. / grey 0/3/1/4); GAMEPROG_AdvPercent; characterID<0→EMPTY(lng 0xb5); else icon + name + 4 PrintInteger stats (percent/trophies/keys/relics @ x+0x6a/0xb5, y+5/0x17, color|0x4000) + '%' + 3 stat-icons; highlight box (menuRowHighlight_Green/Normal by &0x10) + DrawInnerRect. +- **NOTE(claude) icon-OT fix CONFIRMED**: RECTMENU_DrawPolyGT4 uses `tileView_UI.ptrOT`(=pushBuffer_UI.ptrOT, live UI OT), NOT otMem.uiOT — decomp confirms. icon tint g_dwSelectProfileAdvIconTint/Green. ✓ +- **`|0xffff8000` DrawLine-color idiom ASM-CONFIRMED (project-wide)**: EMPTY DrawLine color — decomp C `uColorEmpty | 0x8000` but ASM `li v0,-0x8000`(00800224 = addiu zero,0x8000 → **0xFFFF8000**) `or v0,s0,v0` = `emptyColor | 0xFFFF8000`. Project `|0xffff8000` correct; decompiler drops sign-ext (same trap as DrawQuip/UI_VsWaitForPressX colors). **This validates the whole class of `X|0xffff8000` DecalFont_DrawLine calls (int flags param).** ✓ +- 3 stat-icon UpdateIcon (inlined): slot[base/+1/+2].inst matrix.t[0]@0x44=ConvertX, t[1]@0x48=ConvertY, t[2]@0x4c=0x100, flags@0x28&=~HIDE_MODEL(0x80); positions (x+0xc3,y+0x1f)/(x+0x78,y+0xd)/(x+0xc3,y+0xd). slot stride 0xc off g_pSelectProfileObj+4. ✓ +- Highlight/rect OT: `&otMem.uiOT[3]` = decomp `startPlusFour(=uiOT)+0xc`; CTR_Box_DrawClearBox 4-arg refactor (primMem internal). ✓ +No new bugs; icon-OT fix + `|0xffff8000` color idiom both ASM-confirmed. (SelectProfile.c remaining: DrawGhostProfile, LoadSave_Color, Init/Destroy, menu-procs/AllProfiles FSM.) + +### Bug-class re-scan cont'd (2026-07-08) — SelectProfile.c DrawGhostProfile @0x80048a30 — Match, NOTE(claude) icon-OT fix + field-name aliases resolved +- **DrawGhostProfile** (ghost-select card) — cardRect{x,y,0xc8,0x29}, innerRect{x+6,y+3,0xbc,0x23}. Param8 (project isUnavailable = decomp isTitle): DrawLine(lng[**LNG_NOT_AVAILABLE=0x6d**], x+0x64, y+0x11, FONT_SMALL, 0xffff8016) + DrawClearBox(**redColor@0x8008d49c** = decomp g_dwSelectProfileGhostBoxColor, **ADD_DECAL=2**). profile!=NULL (=decomp !ghost==NULL inverted): level name(metaDataLEV[trackID].name_LNG, 0xffff801d) + DrawTime(trackTime, FONT_BIG, 0xffff8001) + icon. else: param7 (isLoading=decomp ntropyOxide) → lng 0x6c/0xffff8001 (loading) else 0xb5/0xffff8003 (empty). highlight box (menuRowHighlight_Green/Normal by &0x10) + DrawInnerRect. OT = otMem.uiOT (=decomp startPlusFour, no +0xc here). ✓ +- **NOTE(claude) icon-OT fix CONFIRMED**: RECTMENU_DrawPolyGT4 uses tileView_UI.ptrOT(=pushBuffer_UI.ptrOT), tint ghostIconColor(=g_dwSelectProfileIconTint), TRANS_50_DECAL(=1). ✓ +- Field-name aliases resolved: project redColor ≡ decomp g_dwSelectProfileGhostBoxColor (both @0x8008d49c); lng 0x6d = "NOT AVAILABLE" (project name more accurate than decomp's "GHOST" guess); all colors use confirmed `|0xffff8000` idiom. ✓ +No new bugs. (SelectProfile.c remaining: LoadSave_Color[static], Init/Destroy, ThTick, menu-procs, AllProfiles FSM.) + +### Bug-class re-scan cont'd (2026-07-08) — SelectProfile.c Init @0x800485cc — Match, LoadSave_Color inlined confirmed, documented null-guard divergence +- **Init(drawStyle)** — builds 12 rotating profile-card model instances. First call: PROC_BirthWithObject(0x8030d, ThTick, "LoadSave"); obj[1]=&slots(icons), memset 0x90 (12×0xc). 12-slot loop: if slot empty & modelPtr[cardConfig[i].modelID]!=0 → INSTANCE_Birth3D("loadsave", obj->thread); flags|=SCREENSPACE|HIDE_MODEL(0x80) (+USE_SPECULAR if i%3!=1); idpp[0].pushBuffer=&tileView_UI, idpp[1..numPlyr-1].pushBuffer=0 (stride 0x88, +0x74); colorRGBA=LoadSave_Color; scale=config@0x80085c66[i*7]; rot={0,0,spinOffset[i%3]}; identity×0x1000 matrix; then flags|=HIDE_MODEL(0x80). ✓ +- **LoadSave_Color inlined CONFIRMED**: `r=cfg[-2],g=cfg[-1],b=cfg[0]; if(!(drawStyle&0x10)) rgba=(r<<0x14)|(g<<0xc)|(b<<4) else (r>>1<<0x14)|(g<<0xc)|(b>>1<<4)` — halve R&B (NOT G) when &0x10. Matches project LoadSave_Color helper. ✓ +- **Documented null-guard divergence (NOT a bug)**: retail writes `*obj=pThread` BEFORE its null check (decomp `bObjNull=...; *obj=pThread; if(bObjNull)return`); project writes `obj->thread=t` AFTER `if(obj==NULL)return`. NOTE(aalhendi) low-RAM audit candidate — intentional native-crash guard, retail's pre-check write is a PSX-low-RAM no-op. ✓ +No new bugs; LoadSave_Color inline + 12-icon setup match; null-guard divergence documented-intentional. (SelectProfile.c remaining: Destroy[trivial], ThTick, QueueLoadHub/AdvPickMode menu-procs, AllProfiles FSM.) + +### Bug-class re-scan cont'd (2026-07-08) — SelectProfile.c QueueLoadHub/ThTick/Destroy/AdvPickMode_MenuProc — Match +- **QueueLoadHub_MenuProc @0x80047da8** — levelID=MAIN_MENU_LEVEL(0x27); characterIDs[0]=advProgress.characterID(@+0x2a); MainRaceTrack_RequestLoad(currLEV); RECTMENU_Hide. ✓ +- **ThTick @0x80047dfc** — 12-card spin: rot.y(@icon+6)+=LoadSave_SpinRateY[i%3]; ConvertRotToMatrix(inst->matrix@+0x30, &rot.x@icon+4); i%3!=1 → SpecLightSpin3D(inst, &rot.x, spec). **Spec-vec: decomp copies config[i]{+4/+6/+8} to stack local & passes &local; project passes &MetaDataLoadSave[i].vec3_specular_inverted (config+4 direct) — equivalent (SpecLightSpin3D read-only, rodata ptr safe).** Icon struct {inst@0, rot@4, pad@10} stride 0xc. ✓ +- **Destroy @0x800488e0** — obj!=0: 12× if(icon->inst)INSTANCE_Death; thread(obj[0])->flags@+0x1c |= THREAD_FLAG_DEAD(0x800); ptrLoadSaveObj=0. ✓ +- **AdvPickMode_MenuProc @0x80048960** — draw phase(unk1e/nProcCallPhase!=0)→Init(drawStyle)+DrawAdvProfile(advProgress,0x92,0x32,0,0,0x10); else rowSelected: 0<=row<3→ToggleMode(row|0x20)+ptrDesiredMenu=menuFourAdvProfiles; row==-1||row==3→Hide+Destroy (decomp nested sRow<3/!=-1/!=3 ≡ project). ✓ +No new bugs. **SelectProfile.c largely DONE** (helpers/draw/init/destroy/thtick/menu-procs verified; only the AllProfiles_MenuProc FSM + its static getters remain). + +### Bug-class re-scan cont'd (2026-07-08) — SelectProfile.c InitAndDestroy @0x80048edc + AllProfiles_MenuProc @0x800490c4 (giant FSM, landmark-verified) — Match (no bugs found in verified landmarks) +- **InitAndDestroy @0x80048edc** — Init(menuFourAdvProfiles.drawStyle) + Destroy. ✓ +- **AllProfiles_MenuProc** — GIANT inlined memcard-menu FSM (~15 project static helpers folded into one retail fn; BOTS_ThTick_Drive-class — landmark-verified, deep completion-routing NOT line-verified). Confirmed landmarks: + - **SAVE copy (game→card) size 0x14bc**: decomp unrolled loop + 0xc tail = 0x1494(GameProgress)+0x28(GameOptions) = project memcpy in CopyGameProgressToCard; preceded by RaceConfig_SaveGameOptions + GAMEPROG_SaveCupProgress + SyncGameAndCard. Matches LOAD-dir size (GameProgressAndOptions). ✓ + - **Helpers**: IsGhostMode (`g_nProfileModeFlags==0x30`), AdvProfileOccupied (`*(s16)(memcard+slot*0x50+0x2e)>=0`, advProgress stride 0x50/charID@0x2e). ✓ + - **Structure**: confirm-box yes/no (g_boolConfirmBoxActive, rowSelected up/down/select, bDoSave on rowSelected==0); mode dispatch 0x30 ghost/0x40 cup-save/0x20 adv-save/0-0x10 adv-load; NOTE(claude) `MC_RESULT`-FULL mask `0x50`(CROSS_one|CIRCLE, same as InputLogic ASM-verified). Phase keyed on pInlineData[0]._2_2_ (mcScreenText). ✓ +No new bugs in verified landmarks. **AllProfiles_MenuProc deep FSM branches (per-mode next-menu routing DAT_8009d8fc/fe phase flags, mode-0x30 overlay menu ptrs) deferred — structurally consistent, not line-verified (ThTick_Drive-class).** SelectProfile.c: all non-giant fns DONE. + +### Bug-class re-scan cont'd (2026-07-08) — MainFrame.c (TogglePauseAudio/HaveAllPads/ResetDB/RequestMaskHint) — Match +- **TogglePauseAudio @0x80034b48** — pause==0: if boolSoundPaused → StopAudio(0,0,1)+UnPauseAudio+clear; else if !boolSoundPaused → OtherFX_Stop2(1)+PauseAudio+set. ✓ +- **HaveAllPads @0x80035d70** — Loading.stage==LOAD_IDLE(-1): numPads==0→0; else all pads' ptrControllerPacket!=NULL && plugged==**PLUGGED(0)** (decomp isControllerConnected==0). decomp AND-accumulate-all-pads ≡ project early-return (same result, packet-read side-effect-free). ✓ +- **ResetDB @0x80034bbc** — dbl-buffer flip (swapchainIndex=1-idx, backBuffer=&db[idx], sizeof(DB)=0xa4=otMem@0x90+0x14); reset blurCameraMask(=unk_primMemRelated)/primMem.cursor(=curr)/primitiveCount(=unk1)/otMem.cursor; ClearOTagR(otSwapchainDB=ot_tileView_UI[idx], numPlyr<<10|6); per-player ptrOT = ot + (numPlyr-i-1)*0x1000+0x18 (reverse); views i..3 → ot+0x3018; pushBuffer_UI.ptrOT=otMem.uiOT(=startPlusFour)=ot+4. Raw-offset decomp = project struct access. ✓ +- **RequestMaskHint @0x80035e20** (Ghidra DB "AkuHint_Request"; syms926 authoritative) — `(gameMode1 & PAUSE_ALL 0xf)==0 && AkuHint_RequestedHint==-1` → AkuAkuHintState=1, drivers[0]->funcPtrs[DRIVER_FUNC_INIT=0]=VehPhysProc_FreezeEndEvent_Init, RequestedHint=hintId, boolInterruptWarppad=arg (order of indep writes differs, harmless). ✓ +No new bugs. Constants: LOAD_IDLE=-1, PLUGGED=0, PAUSE_ALL=0xf, DRIVER_FUNC_INIT=0. (MainFrame.c remaining: GameLogic[large], VisMem* helpers, InitVideoSTR.) + +### Bug-class re-scan cont'd (2026-07-08) — MainFrame.c VisMemFullFrame @0x800357b8 + InitVideoSTR @0x80035d30 — Match, vert-vis NULL-guard divergence DOCUMENTED (NOTE(claude) added) +- **InitVideoSTR** — else-branch byte-identical to retail (videoSTR_src_vramRect = *r, boolPlayVideoSTR, dst x/y); CTR_NATIVE `if(r==NULL)` zero-guard already NOTE(aalhendi)-documented. ✓ +- **VisMemFullFrame** — per-player vis update. camDC=&cameraDC[i] (stride 0xdc), driver=drivers[i]; leaf/face/inst/vert vis-source caching (visMemN->visXSrc[i] vs camDC->visXSrc, else driver->underDriver(@0x350)->pvs(@quad+0x44) {visLeafSrc/visFaceSrc/visInstSrc = pvs[0/1/2]}). Copy: ReplacePackedVisList `(src&1)==0 ? memcpy(dst,src,ceil(count/32)*4) : CTR_VisMem_RleCopy(dst, src&~3)` (=CTR_unknownMaybeThunk1). Flag 0x2000/0x4000 (own-path vs follow-driver), ApplyDriverPVS(=VisMemAddDriverPVS). **Quad-index bit test verified**: decomp `(quad-ptrQuadBlockArray)*-0x1642c859` magic-mult (= quadIndex<<2), `>>7` word / `>>2&0x1f` bit ≡ project `quadIndex>>5` word / `quadIndex&0x1f` bit. ✓ +- **DOCUMENTED DIVERGENCE (NOTE(claude) added, built clean)**: vert-vis (OVert/SCVert) *changed-source* branch — retail memcpy/RLE-copies from camDC->visOVertSrc/visSCVertSrc with NO null check (reads addr 0 if src just went NULL: harmless on PSX, CRASH on native). Project guards `if(src!=NULL)Replace else memcpy level-default(unk5/unk_170)` — native-safety, mirrors the unchanged-NULL case, likely unreachable on real (water/SC) levels. Added NOTE(claude) @OVert citing 0x80035c40 (was undocumented, unlike the sibling InitVideoSTR NULL guard). +No new bugs (the vert NULL-guard is a native-necessity adaptation, not a bug; now documented). (MainFrame.c remaining: GameLogic[large], ResetDB done, RequestMaskHint done.) + +### Bug-class re-scan cont'd (2026-07-08) — MainInit.c PrimMem/GetPrimMemSize @0x8003b0f0 + RainBuffer @0x8003b008 + StringToLevID @0x8003c1d4 — Match, INTRO_RACE_TODAY sltiu ASM-CONFIRMED +- **PrimMem/GetPrimMemSize (inlined)** — prim-mem sizing: ADVENTURE_GARAGE(0x28)→0x1b800, MAIN_MENU→0x17c00, numPlyr 0→0x25800, 1P→(ADVENTURE_ARENA→0x1c000 / intro→0x1e000 / else table[levelID]<<10 or default 0x5f<<10=0x17c00), 2P→table2P<<10 or 0x78, 3-4P→table4P<<10 or 0x96 (all vs GEM_STONE_VALLEY=0x19); both db[0]/[1] same size. **1P INTRO sign ASM-CONFIRMED**: `0x8003b200 sltiu v0,v0,0x9` on `levelID-0x1e`(INTRO_RACE_TODAY) — UNSIGNED, so normal race (levelID<0x1e → huge unsigned) → table[levelID]<<10 NOT 0x1e000; project `(u32)(levelID-INTRO)<9` correct (decomp C `8rainBuffer; `numParticles_curr /= numPlyr` (signed int div), `numParticles_max = (s16)((u16)max / numPlyr)` (unsigned). trap()s = compiler MIPS div-0/overflow guards. ✓ +- **StringToLevID @0x8003c1d4** (syms "unused") — scan 0x41 metaDataLEV: `strncmp(name_Debug, str, strlen(name_Debug))==0 → return levelID`, else 0. ✓ +No new bugs; INTRO_RACE_TODAY comparison proven UNSIGNED (sltiu) by ASM — project (u32) cast correct. (MainInit.c remaining: VisMem/BspListNodes, OTMem, JitPoolsNew, Drivers, FinalizeInit, VRAM*.) + +### Bug-class re-scan cont'd (2026-07-08) — MainInit.c OTMem @0x8003b334 + VisMem @0x8003af84 + JitPoolsNew @0x8003b43c — Match, struct sizes confirmed +- **OTMem** — OT sizing by mode: MAIN_MENU→0x1800, ADVENTURE_ARENA→0x2c00, BATTLE(0x20)→0x8000, numPlyr<3→0x2000, else 0x3000; MainDB_OTMem db[0]/[1]; otSwapchainDB(=ot_tileView_UI)[0/1] = MEMPACK_AllocMem(numPlyr<<12|0x18). project goto-form ≡ decomp nested-if. ✓ +- **VisMem** — visMem1=level1->visMem; per-player clear visLeafSrc/visFaceSrc/visOVertSrc/visSCVertSrc[i]=0. `#ifdef CTR_NATIVE` InitVisMemBspListNodes = native-only (retail RenderLists rewrites link word; NOTE(aalhendi)-documented). ✓ +- **JitPoolsNew** — 8 JIT pools + render-bucket + clip buffers. sizeFactor(0x400 menu/0x800 arena/0x1000 race), allocSize(same +0x800 garage). Pools: thread(allocSize*3>>7, **0x48**), instance(allocSize>>5, decomp `(4-numPlyr)*-0x88+0x294` = **sizeof(Instance)0x74 + 0x88*numPlyr**), smallStack(sizeFactor*0x19>>10, 0x48), mediumStack(>>7, 0x88), largeStack/driver(menu?4:>>9, 0x670), particle(>>5, **0x7c**), oscillator(>>5, 0x18), rain(>>9, **0x28**). Free-list self-link 3 stack pools (project generic i<3 loop ≡ decomp 3 unrolled: item[1].next=&item[1], sizeof(Item)=8). Clip buffers per-player MainDB_GetClipSize(levelID,numPlyr)<<2. ptrRenderBucketInstance: retail MEMPACK_AllocMem(allocSize), native static-scratch (NOTE(aalhendi)). ✓ +No new bugs; struct sizes Thread=0x48/Instance=0x74/InstDrawPerPlayer=0x88/Particle=0x7c/RainLocal=0x28 confirmed via pool init. (MainInit.c remaining: Drivers, FinalizeInit, VRAMClear/Display.) + +### Bug-class re-scan cont'd (2026-07-08) — MainInit.c Drivers @0x8003b6d0 — Match (ASM-resolved store + numPlyr-check), 2 unreachable-divergences noted +- **Drivers** — spawn humans+AI. Clear drivers[0..7]+numBotsNextGame; `!(gameMode1 & 0x20102000[CUTSCENE|ADV_ARENA|MAIN_MENU])`→BOTS_Adv_AdjustDifficulty; GhostReplay_Init1; RacingOrBattle→RB_MinePool_Init. **Player-spawn store RESOLVED via ASM**: decomp mis-rendered `if(i!=0)drivers[pi]=i` — actual `0x8003b770 sw v0,0x24ec(s1)` guarded by `beq v0,zero` = `if(VehBirth_Player()!=0)drivers[pi]=ret`; project's unconditional `drivers[i]=VehBirth_Player(i)` identical (drivers[] pre-cleared → NULL either way on birth fail). Reverse order for thread-bucket LL. +- **AI spawn**: `(gameMode1 & 0x2c122020[exclusions])==0` (ASM `lui 0x2c12/ori 0x2020`) && numPlyr-check && `(gameMode1 & (ARCADE|ADVENTURE))!=0`; count = boss(gameMode1<0)→numPlyr+1(spawn idx1), purple-cup(ADV_CUP&&cupID==4)→numPlyr+4(idx1-4), 1P→8, 2P→6; loop BOTS_Driver_Init(numPlyr..numDrivers-1). numBots→EngineAudio_InitOnce(0x10/0x11, 0x8080). MAIN_MENU→fill drivers to 4. `(gameMode1 & 0x20022000)==TIME_TRIAL`→GhostReplay_Init2+GhostTape_Start (native +P1 model swap). +- **2 unreachable divergences (NOT bugs)**: (a) numPlyr-check `0x8003b7a8 beq v1,3` = retail `!=3` vs project `<3` — outcome-identical (numPlyr 3/4 both →0 AI via numDrivers=4 loop no-spawn). (b) numPlyr==0 else-count decomp 4 vs project 6 — unreachable (arcade/adventure always ≥1 human). Both benign for all reachable numPlyr∈{1,2}. +No new bugs; player-store + exclusion mask (0x2c122020) + TT mask (0x20022000) ASM-confirmed. (MainInit.c remaining: FinalizeInit[large], VRAMClear/Display — MCP classifier was down mid-cycle.) + +### Bug-class re-scan cont'd (2026-07-09) — MainInit.c VRAMClear @0x8003c248 + VRAMDisplay @0x8003c310 — Match +- **VRAMClear** — boot framebuffer clear. SetDefDrawEnv(0,0,0x400,0x200)+dfe=1+PutDrawEnv; two GP0 fill rects via DrawOTag. Project generic `commands{int a; s16 b1,b2,c,d,e,f}` ≡ decomp named fill block: a=primTag(0x3ffffff), b1/b2={R,G}/{B,fillCode}=0/0x200 (fillCode=2, RGB 0), c/d=fillX/Y=0/0, e/f=fillW/H=0x3ff/0x1ff → full 0x3ff×0x1ff black fill; then d=fillY=0x1ff, f=fillH=1 → 1px strip @y=0x1ff. ✓ +- **VRAMDisplay** — boot viewport moves. x={0,0x100}, y={0,0x128}; 2×2 loop: srcRect{x[i]+0x200, 0x10c, 0x100, 0xd8}, SetDrawMove(&move,&srcRect,x[i],y[j]), move.tag|=0xffffff, DrawOTag+DrawSync(0). `#ifdef CTR_NATIVE Platform_PresentVRAMDisplay()` native-only (NOTE(aalhendi)). ✓ +No new bugs. **MainInit.c largely DONE** (VisMem/RainBuffer/PrimMem/OTMem/JitPoolsReset/JitPoolsNew/Drivers/StringToLevID/VRAMClear/Display verified; only FinalizeInit[large] + InitVisMemBspListNodes[native-only, no retail addr] remain). + +### Bug-class re-scan cont'd (2026-07-09) — MainFreeze.c (ConfigDrawNPC105/ConfigDrawArrows/SafeAdvDestroy/GetMenuPtr/IfPressStart) — Match, NOTE(claude) ptrColor fix + OXIDE_ENDING sltiu ASM-CONFIRMED +- **ConfigDrawNPC105 @0x800379f4** — RWD circular triangle-fan. scaledRadiusX=`(radius<<3)/5` (signed); per-step cos→X `startX+(scaledRadiusX*Cos>>12)`, sin→Y `startY+(radius*Sin>>12)` (decomp inlines MATH_Cos/Sin trigApprox octant 0x400/0x800 sign flips); DrawRwdTriangle if currAngleStep!=0; exit `(s16)currAngleStep>0x1000`; colors=3× copy of color[0..3]. ✓ +- **ConfigDrawArrows @0x80037bc0** — `color=(frameCounter&4)==0?3:0`; lineWidth=GetLineWidth(str,1)/2 (>>1, non-neg so arith≡toward-0); 2× DecalHUD_Arrow2D (icon[0x38], offsetX∓lineWidth∓{0x14,0x12}, y+7, primMem, tileView_UI.ptrOT, colorWords[0..3], 0, FP=0x1000, {0x800,0}). **NOTE(claude) ptrColor CONFIRMED**: decomp `ppiVar4=&ptrColor[color]; **ppiVar4,(*ppiVar4)[1..3]` = ptrColor[color] deref'd then [0..3] (4 gradient words of ONE color) = project colorWords[0..3]; NOT byte0 of 4 colors. ✓ +- **SafeAdvDestroy @0x800399fc** — `(gameMode1&ADVENTURE_ARENA) && LOAD_IsOpen_AdvHub()` → AH_Pause_Destroy. ✓ +- **GetMenuPtr @0x80039dcc** (DB "GetMenuBox") — ADVENTURE_ARENA→advHub (MaskBoolGoodGuy(drivers[0])→rowsAdvHub[1].stringIndex 0xb/0xc); ADVENTURE_MODE→advCup/advRace(ADVENTURE_CUP); BATTLE→battle; CUP_ANY_KIND→arcadeCup else arcadeRace. ✓ +- **IfPressStart @0x80039e98** — 11 early-return guards (IsFullyOnScreen/renderFlags&0x1000/AkuAkuHintState/ptrActiveMenu/gameMode1&(END_OF_RACE|PAUSE_ALL)/levelID==MAIN_MENU/GAME_CUTSCENE 0x20000000/boolDemoMode/**OXIDE_ENDING**/load_inProgress/VEH_FREEZE_PODIUM 0x4) → gameMode1|=PAUSE_1, GetMenuPtr, rowSelected=0, ShowMenu, TogglePauseAudio(1), OtherFX_Play(1,1), ElimBG_Activate. **OXIDE_ENDING sign ASM-CONFIRMED**: `0x80039f30 sltiu v0,v0,0x2` on `levelID-0x2a` — UNSIGNED, project `(u32)(levelID-OXIDE_ENDING)<2` correct (blocks pause only on oxide-ending levels {0x2a,0x2b}; slti would wrongly block every normal race levelID<0x2a). ✓ +No new bugs; ptrColor deref + OXIDE_ENDING sltiu both confirmed. (MainFreeze.c remaining: ConfigDrawRaceWheel/Namco[static], ConfigSetupEntry, MenuPtrOptions/Quit/Default[large FSMs].) + +### Bug-class re-scan cont'd (2026-07-09) — MainFreeze.c MenuPtrQuit @0x80039908 (DB "HandleSelection") — Match, NOTE(claude) ptrDesiredMenu + OnBegin field-offset ASM-CONFIRMED +- **MenuPtrQuit** — pause-menu proc, phase-keyed (menu->nProcCallPhase / unk1e). Phase0 (handle): rowSelected==0 → GhostTape_Destroy + `Loading.OnBegin.AddBitsConfig0 |= MAIN_MENU(0x2000)` + mainMenuState=MM_TITLE(0) + `OnBegin.RemBitsConfig0 |= ADVENTURE_ARENA(0x100000)` + gameMode1&=~PAUSE_1 + MainRaceTrack_RequestLoad(0x27); row∈{1,-1} → ptrDesiredMenu=GetMenuPtr. Phase1 (draw): drawStyle&=~0x100, numPlyr>2→|0x100, SafeAdvDestroy. ✓ +- **NOTE(claude) ptrDesiredMenu CONFIRMED**: decomp `ptrDesiredMenuBox = GetMenuBox()` (0x8003998c jal GetMenuPtr, sw @-0x26dc(at)) — writes DESIRED-menu slot (RECTMENU core does the swap), NOT ptrActiveMenu directly. ✓ +- **OnBegin-vs-OnComplete field RESOLVED via ASM+struct**: decomp names it "OnComplete" but Loading struct has ONLY OnBegin@0x8008d100 {AddBitsConfig0@0x100/RemBitsConfig0@0x104}. ASM `sw @0x194(gp)`/`@0x198(gp)`; **gp=0x8008cf6c** (consistent w/ StopMemcardAction gp+0x9f8=0x8008d964) → gp+0x194=0x8008d100=OnBegin.AddBitsConfig0 EXACT. Project OnBegin correct; decomp OnComplete is Ghidra misnomer (project comment "when loading is done" slightly loose). ✓ +No new bugs; ptrDesiredMenu + OnBegin.AddBits/RemBits offsets (0x8008d100/104) ASM-confirmed. **gp base = 0x8008cf6c** (reusable for gp-relative resolution). (MainFreeze.c remaining: ConfigSetupEntry, MenuPtrOptions/Default[large FSMs], static draw helpers.) + +### Bug-class re-scan cont'd (2026-07-09) — UI_RaceHud.c (DrawPosSuffix/DrawLapCount/DrawBattleScores) — Match, DrawPolyFT4 hidden-args recovered +- **DrawPosSuffix @0x8005045c** — rank = driverRank (non-battle) / battleSetup.finishedRankOfEachTeam[teamID] (BATTLE 0x20); DrawLine(lngStrings[stringIndexSuffix[rank]], posX, posY, FONT_BIG, flags); instBigNum(=BigNumber[0])!=0 → matrix.t[2]=driverRank+0x100 (RAW driverRank even in battle). ✓ +- **DrawLapCount @0x80050528** — currLap=lapIndex+1 clamp numLaps; numPlyr<3: DrawLine(lngStrings[LNG_LAP=0x18], FONT_SMALL, JUSTIFY_RIGHT|PERIWINKLE=0x4001) + sprintf("%d/%d",currLap,numLaps) FONT_BIG; else 3P/4P in-place digit poke g_szLapDividing[0]=currLap+'0'/[2]=numLaps+'0' FONT_SMALL PERIWINKLE=1. DrawLine(str, posX, posY+8). ✓ +- **DrawBattleScores @0x80050654** — color=battleScoreColor[(numPlyr-1)*4+driverID]; POINT_LIMIT(0x4000)→pointsPerTeam[teamID]+icon[0x85], LIFE_LIMIT(0x8000)→numLives+icon[0x84], neither→ret; sprintf("%ld",value)+DrawLine(posX+0x25, posY+4, FONT_SMALL, color). **DecalHUD_DrawPolyFT4 hidden-args RECOVERED (decomp showed only (icon))**: ASM a0=icon(ptrIcons[0x84]@gGT+0x20fc), a3=backBuffer+0x74=&primMem, stack 0x10=ptrOT(gGT+0x147c), 0x14=1(trans), 0x18=0x1000(scale) = project (icon, posX, posY, &primMem, ptrOT, 1, 0x1000) EXACT. ✓ +No new bugs; DrawPolyFT4 args ASM-recovered. (UI_RaceHud.c remaining: BattleDrawHeadArrows[large], TrackerSelf[large].) + +### Bug-class re-scan cont'd (2026-07-09) — UI_Instance.c UI_INSTANCE_BirthWithThread @0x8004cae8 — Match, AdvCup-color offset near-false-alarm resolved via struct +- **BirthWithThread** — spawn per-driver 3D HUD instance+thread (loop threadBuckets[0]). PROC_BirthWithObject(0x380310); INSTANCE_Birth2D(model); thread->inst. Model-ID dispatch: BIG1(0x38)→BigNumber[0](=instBigNum), FRUITDISP(0x37)→BigNumber[1](=instFruitDisp), GEM(0x5f)/RELIC(0x61)/KEY(0x63)→color+lightDir{-0xc98,0x99f,0x232}+USE_SPECULAR, CRYSTAL(0x60)→lightDir{-0xb60,0xb60,-0x2d8}+0xd22fff0, C/T/R(0x93-0x95, `(u16)(id-STATIC_C)<3`)→vel[0]=`(modelID-STATIC_T)*4`(-4/0/4)/vel[1]=0xc/0xffc8000/flags 0x30000, TOKEN(0x7d)→AdvCup color. Sign-ext light vectors (0xf368=-0xc98, 0xf4a0=-0xb60, 0xfd28=-0x2d8) all match. ✓ +- **NEAR-FALSE-ALARM RESOLVED (AdvCup token color)**: decomp reads g_aAdvCupColor/DAT_80084118/DAT_8008411a @cupID*8 — I misread as cup+{0,4,6}. AdvCups entry = `{s16 lngIndex@0; s16 color[3]@2}` stride 8 → color[0]@0x80084116, so g_aAdvCupColor=0x116(color[0]), DAT_80084118=0x118(color[1]), DAT_8008411a=0x11a(color[2]) — 3 CONTIGUOUS s16 = project cupColor[0/1/2]. colorRGBA=(c0<<0x14)|(c1<<0xc)|(c2<<4). ✓ +- Position: !pushBuffer → layout[hudIdx] (UiElement2D stride 8: x@0/y@2/z@4/scale@6) via UI_ConvertX_2/Y_2, t[2]=z; pushBuffer → idpp[0].pushBuffer(=inst[1].next)=param5, flags PUSHBUFFER_EXISTS, t=(0,0,0x200). scale@+6; depthBias 0x80(=-0x80 s8); faceCamera→rot.x=-(s16)ratan2(t[1],t[2]); ConvertRotToMatrix; rot[3]=0x1000; layout+=0xa0(0x14 UiElement2D/viewport). ✓ +No new bugs; AdvCup color offset proven contiguous via AdvCups struct (color@cup+2). (UI_Instance.c remaining: InitAll @0x8004cec4[large].) + +### Bug-class re-scan cont'd (2026-07-09) — UI_Instance.c UI_INSTANCE_InitAll @0x8004cec4 — Match, NOTE(claude) relic-time-digit fix CONFIRMED +- **InitAll** — spawn all 3D HUD instances by mode. Header: menuReadyToPass&=~1, renderFlags|=0x8000. Modes: CRYSTAL_CHALLENGE(0x8000000)→2× crystal(0x60,elem0x11)+token(0x7d,0x12), hide crystal+token scale0; ADVENTURE_ARENA(0x100000)→relic(0x61)/key(0x63)/trophy(0x62)+GAMEPROG_AdvPercent; RELIC|ADV_ARENA|TT(0x4120000)→rankIconsCurr[i]=drivers[i]->driverRank(native NULL-guard) +timer5 if MP, RELIC_RACE(0x4000000)→relic+timebox(0x5c,0x13); Normal→decalMP pushBuffer copy(numPlyr≥2)+fruitDisp(0x37)+big1(0x38, <3P non-battle), ADVENTURE_MODE(0x80000)→C/T/R(0x93/0x94/0x95)+token, hide all. Shared tail: token scale0 + HIDE_MODEL(0x80). ✓ +- **NOTE(claude) relic-time digits CONFIRMED**: 1min=`t/0xe100`, 10sec=`(t/0x2580)%6`, 1sec=`(t/0x3c0)%10`, 10ms=`(t/0x60)%10`(tenths), 1ms=`(t*100/0x3c0)%10`(hundredths) — all 5 match decomp (order differs, indep writes). relicTime=RelicTime[levelID*3+relicType], relicType=2 if platinum(CREDITS_FAKE_CRASH bit)/gold(ADVENTURE_GARAGE=0x28 bit) else sapphire(THE_NORTH_BOWL=0x16) bit. CHECK_ADV_BIT=`(rewards[idx>>5]>>(idx&0x1f))&1`. ✓ +- Native NULL-guards (rankIconsCurr for empty driver slots; C/T/R flags in Garage) = documented NOTE(aalhendi) native-safety (retail reads/writes through null; PSX low-RAM non-fatal). ✓ +No new bugs; relic-time digit formulas + reward-bit offsets all match. **UI_Instance.c DONE (2 fns).** + +### Bug-class re-scan cont'd (2026-07-09) — HOWL_Channel.c (SetVolume/FindSound/AllocSlot_AntiSpam/AllocSlot) — Match, NOTE(claude) unsigned-duration fix CONFIRMED +- **SetVolume @0x8002b540** — clamp `volume>0x3fff→0x3fff` (unsigned); stereo(g_bVolumeMode==1): audioL=`(vol*panTable[0xff-lr])>>8`, audioR=`(vol*panTable[lr])>>8`; mono: both=vol. ✓ +- **FindSound @0x8002b5b4** — walk channelTaken; `type==1 && (s16)soundID match` → 1 else 0 ((s16)==≡project `&0xffff` equality). ✓ +- **AllocSlot_AntiSpam @0x8002b608** (AllocSlot+DestroySelf inlined) — boolUseAntiSpam: walk taken, `type==1 && soundID match && duration<10` → DestroySelf (ChannelUpdateFlags[chID]|=1&=~2, flags&=~1, RemoveMember taken/AddBack free); then AllocSlot. **NOTE(claude) unsigned duration CONFIRMED**: decomp `(uint)(iNowFrame - item->startFrame) < 10` — UNSIGNED (signed compare would treat startFrame-ahead-of-timer stale channel as recent + wrongly destroy it). ✓ +- **AllocSlot @0x8002b7d0** — free.first→NULL ret; RemoveMember free/AddBack taken; ChannelUpdateFlags[chID]|=(2|flags); copy 4-word ChannelAttr (spuStartAddr/ad/sr/pitch/reverb/audioL/audioR) into channelAttrNew[chID]; flags=1. ✓ +No new bugs; AntiSpam unsigned-duration fix re-confirmed via decomp. (HOWL_Channel.c remaining: SearchFX_EditAttr/Destroy, DestroyAll_LowLevel, ParseSongToChannels[large], UpdateChannels[large], Enter/ExitCriticalSection.) + +### Bug-class re-scan cont'd (2026-07-09) — HOWL_Channel.c (SearchFX_EditAttr/SearchFX_Destroy/DestroyAll_LowLevel) — Match +- **SearchFX_EditAttr @0x8002b898** — find taken channel by type+soundID (full int compare), ChannelUpdateFlags[chID]|=editMask, then per-bit ChannelAttr edit: 0x4→spuStartAddr, 0x8→ad/sr, 0x10→pitch, 0x20→reverb, 0x40→audioL/audioR (decomp reads vol pair as 1 int + splits ≡ project 2 shorts). Return channel/0. ✓ +- **SearchFX_Destroy @0x8002b9b8** — find by type && `(soundID & mask)==(param & mask)` → DestroySelf(inlined: ChannelUpdateFlags|=1&=~2, flags&=~1, RemoveMember taken/AddBack free) → return. ✓ +- **DestroyAll_LowLevel @0x8002ba90** — project nested-if `((type!=type)||(keepMusic==0)) && ((opt1!=0)||((type!=1)||((u16)soundID>5)))` ≡ decomp combined &&; menu sounds (soundID 0-5, unsigned `5<`) ring out unless opt1; DestroySelf inlined. ✓ +No new bugs; DestroySelf-inlined logic consistent across both destroyers, matches project Channel_DestroySelf. (HOWL_Channel.c remaining: DestroyAll wrapper, ParseSongToChannels/UpdateChannels[large], Enter/ExitCriticalSection.) + +### Bug-class re-scan cont'd (2026-07-09) — HOWL_Channel.c (UpdateChannels + Smart_Enter/ExitCriticalSection) — Match +- **UpdateChannels @0x8002be9c** — per-frame SPU voice commit over 24 (=NUM_SFX_CHANNELS=0x18) channels. + - Pass 1 (key-off): `if (flags&1){ voiceBitOff|=1<>0xe&1?7:5) : (sr>>0xe&1?3:1)`; RRmode `sr>>5&1?7:3`. Field extract `(ad>>8)&0x7f, (ad>>4)&0xf, (sr>>6)&0x7f, sr&0x1f, ad&0xf` — the `&0x7f/&0xf/&0x1f` masks discard any sign-extended high bits, so project `int ad=new->ad`(s16 sign-extend) ≡ decomp `newAd`(ushort) for every use. ✓ +- **Smart_EnterCriticalSection @0x8002b4d0** — `n=count; store count+1; if(n==0) EnterCriticalSection()`. ✓ (decomp NOTE flags the *decomp source tree* as a software-only rewrite; the real 926 binary — which project matches — calls the real EnterCriticalSection.) +- **Smart_ExitCriticalSection @0x8002b508** — project early-return-if-0 + `--count; store; if(count==0) ExitCriticalSection()` ≡ decomp combined `(count!=0)&&(--count==0)`. ✓ +No new bugs. (HOWL_Channel.c remaining: DestroyAll wrapper + ParseSongToChannels[large].) + +### Bug-class re-scan cont'd (2026-07-09) — HOWL_Channel.c ParseSongToChannels — Match (HOWL_Channel.c COMPLETE) +- **ParseSongToChannels @0x8002bbac** — per-frame CSEQ music sequencer tick over 2 songs (g_aSongPool, stride 0x7c). (No separate "DestroyAll wrapper" symbol exists — that was a stale note; only Bank_DestroyAll @0x800298e4 in a different subsystem.) + - Song gate: `flags&1 && !(flags&2)` (playing, not paused). ✓ + - **Tempo accum**: `sum = unk10 + tempo; ticks = sum>>0x10; timeSpentPlaying += ticks; unk10 = (u16)sum`. **ASM @0x8002bc34 `srl s6,v1,0x10` = UNSIGNED >>16.** Project `int unk10_total>>0x10` (sra) is EQUIVALENT: ASM stores full sum via `sw` then reloads low halfword via `lhu`+`sw` (0x8002bc3c/0x8002bc50), so unk10 re-truncates to [0,0xffff] each frame → `(u16)prev + tempo` is always non-negative (bit31 clear) → srl≡sra. ✓ + - **Volume fade (song + per-seq, same Copy/Paste)**: `if (cur!=new){ if (curnew } else { stepped=cur-rate; final = stepped>0x10, @0x800287a0 `sh` (16-bit store)** — the halfword store truncates to low 16 bits, so sra-vs-srl on `pitch*distortConst>>0x10` is UNOBSERVABLE (both shifts share low 16 bits); project expression equivalent regardless of field signedness. SetVolume(volScale*OtherFX.volume*volume>>10, LR); SearchFX_EditAttr(1,soundId,0x70,&attr); write echo@0xe/vol@0xf/distort@0x10/LR@0x11 if found; return 1. attr.pitch/reverb assignment order around SetVolume differs but SetVolume doesn't read them → equivalent. ✓ +No new bugs. (HOWL_OtherFX.c remaining: Stop1/Stop2, UpdateChannelVol_OtherFX[_All], howl_InitChannelAttr_OtherFX, RecycleNew/RecycleMute/DriverCrashing.) + +### Bug-class re-scan cont'd (2026-07-09) — HOWL_OtherFX.c Stop1/Stop2 + HOWL_OtherFXRecycle.c (RecycleNew/RecycleMute/DriverCrashing) — Match +- **OtherFX_Stop1 @0x80028808** — crit-section `SearchFX_Destroy(1, soundID_count, 0xffffffff)` (mask full handle → one specific instance). ✓ **OtherFX_Stop2 @0x80028844** — `SearchFX_Destroy(1, soundID_count&0xffff, 0xffff)` (mask id → all instances). ✓ **HOWL_OtherFX.c COMPLETE.** +- **OtherFX_RecycleNew @0x8002e690** — single continuous-SFX slot: if `handle!=0 && (handle&0xffff)!=newSoundID` → Stop1+clear; if `newSoundID!=-1`: slot empty→Play_LowLevel(newID&0xffff,0,flags)+store handle, else Modify(handle,flags). Project caches in `local` (=0 after stop) where decomp re-reads `*soundID_Count` — same value every path ≡. **Ghidra DB name trap CONFIRMED**: DB had "OtherFX_DriverTurbo" (CTR-tools community name, not in syms926); syms926/project = OtherFX_RecycleNew. ✓ +- **OtherFX_RecycleMute @0x8002e724** — `if(*p){ Stop1(*p); *p=0; }`. **DB name trap**: decomp labels it "OtherFX_Stop_Safe" → syms926 OtherFX_RecycleMute. ✓ +- **OtherFX_DriverCrashing @0x8002e760** — `volume<0xdd ? (volume>0xa0 ? 0xc : 0xb) : 0xa` (all UNSIGNED, volume u32); Play_LowLevel(id,0, boolEcho<<0x18 | (volume&0xff)<<0x10 | 0x8080). ✓ **HOWL_OtherFXRecycle.c COMPLETE.** +No new bugs. (HOWL_OtherFX-adjacent remaining across other files: UpdateChannelVol_OtherFX[_All] @0x8002ad04/0x8002b030, howl_InitChannelAttr_OtherFX @0x8002c424.) + +### Bug-class re-scan cont'd (2026-07-09) — OtherFX volume/init helpers (HOWL_Playback.c + HOWL_Settings.c) — Match +- **howl_InitChannelAttr_OtherFX @0x8002c424** (HOWL_Playback.c) — volBase=vol_FX(gp+0x840) or vol_Voice(gp+0x850 if OtherFX.flags&4); SetVolume(volBase*OtherFX.volume*vol>>10, LR); pitch=OtherFX.pitch or `pitch*distortConst[distort]>>0x10` (if distort!=0x80); ad=0x80ff(=-0x7f01), sr=0x1fc2; spuStartAddr=`spuAddrs[spuIndex].spuAddr<<3`. + - **Pitch sign — ASM checked**: @0x8002c49c (distort branch) AND @0x8002c4b8 (no-distort branch) BOTH `lhu` (pitch loaded UNSIGNED in the binary); decomp's `*(short*)` on the no-distort branch is a DECOMPILER ARTIFACT. Result stored `sh` @0x8002c4c8 (16-bit). Project declares `OtherFX.pitch` as **s16** (namespace_Howl.h:279) and uses `s16 pitch`/`(int)pitch` (sign-extend) — a TYPE-FAITHFULNESS NIT vs binary's unsigned load, but EQUIVALENT: no-distort stores value straight to 16-bit attr->pitch (extension irrelevant); distort branch differs from binary only if pitch>=0x8000 (never, for valid SPU rates ≤0x3fff) and the product is truncated to 16 bits by `sh` anyway. **Not a bug; left unchanged** (same call as OtherFX_Modify's pitch, consistent). ✓ + - spuStartAddr ASM @0x8002c4cc-0x8002c4dc: `lhu` spuIndex@4 → `g_pHowlSampleAddrTable`(gp+0x870), stride 4, `lhu` spuAddr, `<<3`. project `spuAddrs[spuIndex].spuAddr<<3` consistent. ✓ +- **UpdateChannelVol_OtherFX @0x8002ad04** (HOWL_Settings.c) — `volBase = vol_FX; if(OtherFX.flags&4) volBase = vol_Voice; SetVolume(volBase*OtherFX.volume*vol>>10, LR)`. decomp NOTE confirms g_bVolEngineFX/g_bVolOtherFX (gp+0x840/0x850) == g_nVolFX@0x8008d7ac / g_nVolVoice@0x8008d7bc (project vol_FX/vol_Voice). ✓ +- **UpdateChannelVol_OtherFX_All @0x8002b030** (HOWL_Settings.c) — walk channelTaken; skip type!=1; ChannelUpdateFlags[chID]|=0x40; UpdateChannelVol_OtherFX(&metaOtherFX[soundID&0xffff], &channelAttrNew[chID], vol, LR). Project's `backupNext` cache harmless (callee doesn't mutate list). ✓ +No new bugs. HOWL OtherFX volume/init path fully verified. + +### Bug-class re-scan cont'd (2026-07-09) — HOWL_Settings.c (howl_VolumeSet / UpdateChannelVol_Music / UpdateChannelVol_EngineFX_All) — Match +- **howl_VolumeSet @0x8002b130** — `type==1`→Music(g_bVolMusic)+Music_All; `type==0`→EngineFX(g_bVolEngineFX=vol_FX)+EngineFX_All; `type==2`→OtherFX(g_bVolOtherFX=vol_Voice)+OtherFX_All; else return. No-op if unchanged; each proceed path enters crit-section then ExitCriticalSection at end. decomp param `char volume` vs project `u8 vol` — only ==compared + stored to u8 field, sign irrelevant. ✓ +- **UpdateChannelVol_Music @0x8002ad70** — `newVol=(vol_Music*songPool[seq.songPoolIndex@0xb].vol_Curr*seq.vol_Curr@5)>>10`; sampleVol = `LongSamples[seq.instrumentID@3].volume` (stride 0xc, vol@1) if `!(flags&4)` else `ShortSamples[index].volume` (stride 8, vol@1); `SetVolume(attr, newVol*sampleVol*vol>>0xf, seq.LR@9)`. **All-positive byte math** (vol_Music/vol_Curr/sampleVol are u8, products < 2^31) → signed int intermediates ≡ decomp uint, no sign trap on `>>10`/`>>0xf`. Project computes `newVol` pre-shifted, decomp shifts inline `(iVar2>>10)*...` — same. ✓ +- **UpdateChannelVol_EngineFX_All @0x8002ae64** — walk channelTaken; project `if(type==2)continue; flags|=0x40; if(type==0) EngineFX else OtherFX` ≡ decomp explicit `type==0`→EngineFX / `type==1`→OtherFX / else skip, given closed type set {0=EngineFX,1=OtherFX,2=Music}. metaEngineFX/OtherFX[soundID&0xffff] (8-byte stride), channelAttrNew[chID], vol, LR. Direct `curr=curr->next` (no backupNext cache; callees don't mutate list). ✓ +No new bugs. (HOWL_Settings.c remaining: UpdateChannelVol_EngineFX @0x8002acb8, howl_VolumeGet/ModeGet/ModeSet [trivial, NOTE-verified], OptionsMenu_TestSound [large FSM].) + +### Bug-class re-scan cont'd (2026-07-09) — HOWL_Settings.c (UpdateChannelVol_EngineFX + OptionsMenu_TestSound) — Match (HOWL_Settings.c COMPLETE) +- **UpdateChannelVol_EngineFX @0x8002acb8** — `SetVolume(attr, vol_FX*EngineFX.volume@1*vol>>10, LR)`. All-positive bytes. ✓ +- **OptionsMenu_TestSound @0x8002b208** (options-menu FX/Music/Voice slider preview FSM; globals OptionSlider_Index/BoolPlay/soundID) — 3 blocks: + - Block1 (oldBoolPlay!=0 && oldRow!=newRow → stop old): oldRow 0→Stop2(0x48), 1→Music stop (UKA=2 if Music_GetHighestSongPlayIndex()==1 else AKU=1), 2→(if soundID!=0) Stop1+clear. Project if(0/1/2) ≡ decomp reordered if(1)/(<2→0)/(2). ✓ + - Block2 (newBoolPlay!=oldBoolPlay || oldRow!=newRow → start new / stop old): newBoolPlay!=0 → newRow 0→Play(0x48,0), 1→CseqMusic_Start(highest==1?2:1,0,NULL,0,1); newBoolPlay==0 → same stop-old as Block1. Then commit Index=newRow/BoolPlay=newBoolPlay. Decomp `iVar1/iVar2` writeback (set=param in every true-branch, =old via `||` short-circuit when false) ≡ project's in-if commit. ✓ + - **Block3 (BoolPlay!=0 && Index==2 → periodic voice preview): DECOMP ARTIFACT RESOLVED — PROJECT CORRECT.** decomp renders `OtherFX_Play(soundID,0); _DAT_8009d7a0 = pCVar3` (stores the character-ID POINTER). **ASM @0x8002b4ac `jal OtherFX_Play` then @0x8002b4b4 `sw v0, 0x834(gp)` stores v0 = OtherFX_Play RETURN (handle)** into OptionSlider_soundID — NOT pCVar3. v0 held pCVar3 pre-call, return post-call; decompiler mis-tracked the reused register. Project `OptionSlider_soundID = OtherFX_Play(sampleVoiceID, 0)` matches binary + is consistent with later Stop1(soundID). frame timing `frameCount%25==0` then `%50==0 ? +0x1c : +0x2c` (frameTimer non-negative → signed `int`/(x/n)*n ≡ decomp uint). characterID=data.characterIDs[driverToFollow->driverID] (u16, *2 stride). ✓ +No new bugs; OptionsMenu_TestSound project-correct (Ghidra register-aliasing artifact on the soundID store). **HOWL_Settings.c fully verified.** (Ghidra renders OptionSlider globals at 0x8009d79x due to unset gp context; real addrs gp+0x82x/0x834 = 0x8008d79x-0x8008d7a0.) + +### Bug-class re-scan cont'd (2026-07-09) — HOWL_Voiceline.c small/init fns (7) — Match +- **Voiceline_ClearTimeStamp @0x8002caa8** — zero both 16-entry int arrays timeSet1/timeSet2 (loop 0..0xf). ✓ +- **Voiceline_PoolClear @0x8002cae0** — boolCanPlayWrongWaySFX(g_bVoicelineState)=0, voicelineCooldown=0, boolCanPlayVoicelines=0; LIST_Clear free(Voiceline1)/active(Voiceline2); LIST_Init(free, &voicelinePool[0].next==&pool, 0x10, 8); ClearTimeStamp. ✓ +- **Voiceline_StopAll @0x8002cb44** — drain active(Voiceline2).last → RemoveMember + AddFront to free(Voiceline1) until empty. decomp redundant `.last=item` self-assigns are artifacts. ✓ +- **Voiceline_ToggleEnable @0x8002cbb4** — `if(toggle==0){ cooldown=0; StopAll(); } boolCanPlayVoicelines=(char)toggle`. ✓ +- **Voiceline_PoolInit @0x8002c918** (misnomer — inits channel/song pools, not voicelines) — criticalSectionCount=0, numBackup_ChannelStats(numPausedChannels)=0, ptrCseqHeader=0, Bank_ResetAllocator, Audio_SetDefaults, LIST_Clear free/taken, LIST_Init(free, channelStatsPrev, 0x20 count, 0x18 stride), SpuSetReverbVoice(0,0xffffff); channel loop [0,0x18): ADSR defaults(0,0xf,0x7f,2,0xf,5,1,3), stats flags=0/channelID=i/ad=0x80ff/sr=0x1fc2, ChannelUpdateFlags[i]=0 (order: project pre-ADSR, decomp post — independent, immaterial), attrCur spuStartAddr=-1/ad=0x80ff/sr=0x1fc2/pitch=reverb=audioL=audioR=-1; songPool[2] flags=0/songPoolIndex=i; songSeq[24] flags=0/soundID=i. ✓ +- **Voiceline_EmptyFunc @0x8002d2a8** — empty stub. ✓ +- **Voiceline_SetDefaults @0x8002d2b0** — zeroes audioState(AUDIO_NONE)/desiredXA_RaceIntroIndex/desiredXA_FinalLapIndex/WrongWayDirection_bool/framesDrivingSameDirection/nTropyVoiceCount/boolNeedXASeek + Music_SetDefaults. **Existing NOTE(claude) CONFIRMED**: decomp `g_nDesiredXAFinalLapIdx = 0` present (retail zeroes it though unused); does NOT touch desiredXA_RaceEndIdx (project matches). RaceIntro/FinalLap write order swapped vs decomp — independent, immaterial. ✓ +No new bugs. (HOWL_Voiceline.c remaining: RequestPlay[large, incl audioRNG LCG], StartPlay, Update.) + +### Bug-class re-scan cont'd (2026-07-09) — HOWL_Voiceline.c Voiceline_RequestPlay — Match +- **Voiceline_RequestPlay @0x8002cbe8** — request a char voiceline; queue as XA or play as sampled OtherFX. + - Bounds `voiceID<0x18 / characterID<0x10 / characterID2<0x11` (unsigned, `0x17<`/`0xf<`/`0x10<`); bail `gameMode1 & 0x200000`(END_OF_RACE). ✓ + - voiceType/subIndex = data.voiceID[voiceID] (=g_abVoiceIdSubIndex). ✓ + - **Taunt gate `(s32)voiceID >= 8`**: decomp `7 < (int)voiceID` — SIGNED cast (≡unsigned, voiceID≤0x17). already-played(timeSet1[char]&bit)→RNG&7 (1/8), else RNG&3 (1/4); `if(rng!=0) return`. ✓ + - **audioRNG LCG** `audioRNG=((audioRNG>>3)+audioRNG*0x20000000)*5+1` — ror3-LCG; `>>3` LOGICAL (audioRNG u32 in both) — required for rotate correctness. ✓ + - elapsedFrames = `CTR_MipsSubLo(frameTimer, timeSet2[char])` = frameTimer-timeSet2 (subu); canImmediate = `elapsedFrames>=0x3d && voiceType<2` (decomp spokeOver2sAgo `0x3c<` && subIndexLow `subIndex<2` = bAllowVoice). canQueue = canPlay!=0 && !(cooldown!=0 && backup[0xa]==char) && elapsedFrames>=0x3c (decomp bPlayAsOtherFX via `||`-chain w/ `bPlayAsOtherFX=true` set in the `<0x3c` subexpr). **decomp var names MISLEADING: bPlayAsOtherFX≡project canQueue, bAllowVoice≡canImmediate.** ✓ + - **Control flow (4 cases canQueue×canImmediate) all match**: (1,1)→`rng&1!=0`?play:queue; (1,0)→queue; (0,1)→play; (0,0)→return. ✓ + - playImmediate: subIndex==0→Play(char+0x1c,2), ==1→Play(char+0x2c,2), else skip; stamp timeSet2[char]=frameTimer. (char≤0xf so +0x1c/0x2c fits 16b; `&0xffff`≡`(short)`.) ✓ + - queueVoiceline: timeSet1[char]|=1<<(voiceID&0x1f); dedup active-list by `voiceID==(int)nVoiceID@8(s16) && char==cCharacterID@0xa`; slot=free.first (RemoveMember free) else active.last (recycle, RemoveMember active); AddFront active; fill cCharacterID@0xa/cCharacterID2@0xb/nVoiceID@8/nStartFrame@0xc=gGT->timer. ✓ + - **Theoretical divergence (UNREACHABLE dead code)**: project always `LIST_AddFront(active, item)` even if item==NULL; decomp `goto` skips AddFront when BOTH lists empty. Pool has 16 nodes summing across free+active → free-empty⇒active-nonempty, so both-empty never occurs. Not a bug. ✓ +No new bugs; RequestPlay fully matches (RNG/sign/control-flow verified). (HOWL_Voiceline.c remaining: StartPlay, Update.) + +### Bug-class re-scan cont'd (2026-07-09) — HOWL_Voiceline.c StartPlay + Update — Match (HOWL_Voiceline.c COMPLETE) +- **Voiceline_StartPlay @0x8002cf28** — play a queued voiceline. Backs up 4 words to backupParams (word2 = {nVoiceID@8, cCharacterID@0xa, cCharacterID2@0xb}). + - **Boss-race taunt sub-index — ASM sltiu CONFIRMED**: @0x8002cf74 `bgez` ⇒ IS_BOSS_RACE = `(s32)gameMode1 < 0` (bit31); @0x8002cf88 `sltiu v0,0x6` ⇒ `(u32)(voiceID-10)<6`; @0x8002cfa0 `sltiu v0,0x4` ⇒ `(u32)(characterID-8)<4` (both UNSIGNED — project `(u32)` casts correct, recurring sign-trap). If boss+taunt → subIndex=(RNG&3)+4 else data.voiceID[(s16)voiceID]. ✓ + - row = voiceData[characterID].voiceSet[subIndex] (VoiceSetRow 8B {ushort* pClips; ushort wCount}); wCount==0 → StopAll; else clipIndex = RNG % wCount (UNSIGNED mod), xaID = pClips[clipIndex]; XAPlay(GAME=2, xaID)==0 → cooldown=0x1e else cooldown=(s16)(XAGetTrackLength/5)+0x1e. decomp `trap(0x1c00)` div-guard is unreachable artifact (wCount==0 already handled). ✓ +- **Voiceline_Update @0x8002d0f8** — per-frame voiceline/wrong-way driver. + - Gates: boolCanPlayVoicelines==0 → ret; cooldown!=0 → `cooldown=(s16)MipsSubLo((u16)cooldown,1)` (subu), if still!=0 ret (decomp `stillCoolingDown=(cooldown!=1)` pre-decrement ≡ project decrement-then-!=0); XA_State!=XA_STOPPED(0) → ret. ✓ + - Wrong-way FSM (boolCanPlayWrongWaySFX = decomp g_bVoicelineState 'armed' flag): if armed && WrongWayDirection_bool && framesDrivingSameDirection>0x1e → disarm; if numPlyrCurrGame==1: voiceID = (MaskBoolGoodGuy(drivers[0])&0xffff)==0 ? 0x3d(Uka) : 0x1e(Aku); XAPlay(EXTRA, voiceID)==0 → cooldown=0x1e+ret else cooldown=(s16)(trackLen/5)+0x1e+ret. Re-arm: if !armed && WrongWay==0 && frames>0x1e → arm. (project computes voiceID then single XAPlay ≡ decomp per-branch inlined XAPlay.) ✓ + - Queued dispatch: if active(Voiceline2).first → RemoveMember active, AddBack free(Voiceline1), StartPlay. decomp caches activeList.first@entry but wrong-way block doesn't mutate lists ⇒ == Voiceline2.first at use. ✓ +No new bugs. **HOWL_Voiceline.c fully verified** (all 10 functions Match; no code changes across the whole file). + +### Bug-class re-scan cont'd (2026-07-10) — HOWL_Playback.c (Cutscene_VolumeBackup/Restore, PlayAudio_Update, InitChannelAttr_EngineFX) — Match +- **Cutscene_VolumeBackup @0x8002c18c** — crit-section; storedVolume=currentVolume=howl_VolumeGet(0)&0xff; boolStoringVolume=1. (decomp: storedVol=(byte)v, currentVol=v&0xff — same values.) ✓ +- **Cutscene_VolumeRestore @0x8002c1d0** — crit-section; boolStoringVolume=0; howl_VolumeSet(0, storedVolume). ✓ +- **howl_PlayAudio_Update @0x8002c208** — if audioEnabled: if boolStoringVolume → `currentVolume-=2; if(<0)=0` (**s16 fade**: decomp `(int)((uint)v<<0x10)<0` = `(s16)v<0`); criticalSectionCount=1/VolumeSet(0,currentVolume)/=0. Walk channelTaken (backupNext cache): skip flags&4; `timeLeft-=5; if(timeLeft>0)continue` (**s16 timeout**: decomp `(int)((uint)t<<0x10)<1` = remove when `(s16)timeLeft<=0`); on remove ChannelUpdateFlags[chID]|=1&=~2, flags&=~1, RemoveMember taken/AddBack free. ParseSongToChannels (inside if). UpdateChannels (always). ✓ +- **howl_InitChannelAttr_EngineFX @0x8002c34c** — SetVolume(vol_FX*EngineFX.volume@1*vol>>10, LR) (no flags&4 alt, unlike OtherFX); pitch: no-distort=EngineFX.pitch@2, distort=`(u32)pitch*distortConst_Engine[distort]>>0x10`. **ASM @0x8002c3b8 `lhu` (pitch UNSIGNED)** — project uses `(u32)pitch` here (matches binary's unsigned load; MORE faithful than OtherFX's `(int)pitch`, though both equiv for valid pitch<0x8000 + 16-bit store). ad=0x80ff(-0x7f01)/sr=0x1fc2; spuStartAddr=spuAddrs[spuIndex@6].spuAddr<<3 (EngineFX layout: volume@1/pitch@2/spuIndex@6, differs from OtherFX spuIndex@4). ✓ +No new bugs. (HOWL_Playback.c remaining: howl_PauseAudio, howl_UnPauseChannel, howl_UnPauseAudio, howl_StopAudio.) + +### Bug-class re-scan cont'd (2026-07-10) — HOWL_Playback.c pause/unpause/stop (4) — Match (HOWL_Playback.c COMPLETE) +- **howl_PauseAudio @0x8002c510** — XAPauseRequest; if numBackup_ChannelStats==0 (not already paused): CseqMusic_Pause, crit-section, walk channelTaken → per channel: ChannelUpdateFlags[chID]|=1&=~2, copy full 0x20-byte ChannelStats (8 words) into channelStatsCurr backup[numPaused] (@0x8008ffcc, stride 0x20; decomp field-by-field next/prev/flags-word/unk2-word/distort-word/nSr-word/soundID/startFrame ≡ project dest[0..7]=src[0..7]), numPaused++, RemoveMember taken/AddBack free. ✓ +- **howl_UnPauseChannel @0x8002c64c** — dispatch stats->type: 0→InitChannelAttr_EngineFX(&metaEngineFX[soundID&0xffff]), 1→OtherFX, 2→InitChannelAttr_Music(&songSeq[soundID], drumIndex_pitchIndex, vol) (4-arg, no LR/distort), else return; ChannelUpdateFlags[chID]|=0x7e; copy 0x10-byte ChannelAttr (4 words: spuStartAddr/ad/sr/pitch/reverb/audioL/audioR) into channelAttrNew[chID]. ✓ +- **howl_UnPauseAudio @0x8002c784** — if numPaused!=0: crit-section, restore loop (i= numEngineFX@header+0x18` (project (int) cast ≡ decomp unsigned `soundID < numEngineFX`, soundID∈[0,0xffff] positive); spuAddr!=0 check (spuIndex@6); InitChannelAttr_EngineFX(volume,LR,distortion); reverb=echo; crit-section AllocSlot(0x7c,&attr); on success type=0/unk2=0/echo/vol/distort/LR/timeLeft=0/soundID/flags|=4; return channel!=0. ✓ +- **EngineAudio_Recalculate @0x800289b0** — bounds same; **split-screen vol scale**: 1P unscaled; 2P vol*0x37; 3P/4P vol*0x2d; then project `(iVar1<<2)>>8` ≡ decomp `>>6` (net shift, no overflow vol≤255). pitch `metaEngineFX[soundID].pitch*distortConst_Engine[distort]>>0x10` (or raw if distort==0x80); SetVolume(vol_FX*EngineFX.volume@1*volume>>10, LR); SearchFX_EditAttr(0,soundID,0x70,&attr); write echo@0xe/vol@0xf/distort@0x10/LR@0x11 if found; return 1. decomp NOTE: numEngineFX@+0x18 (Ghidra mislabels 'sizeOfPrevAllocation'), numOtherFX@+0x14 — project correct. ✓ +- **EngineAudio_Stop @0x80028b54** — audioEnabled gate; soundID&=0xffff; bounds `numEngineFX <= (int)soundID` return; crit-section SearchFX_Destroy(0, soundID, 0xffffffff) (engine type, full-handle mask). ✓ +No new bugs. (HOWL_Engine.c remaining: EngineSound_Player[large], EngineSound_VolumeAdjust, EngineSound_AI, EngineSound_NearestAIs.) + +### Bug-class re-scan cont'd (2026-07-10) — HOWL_Engine.c EngineSound_VolumeAdjust + EngineSound_AI (4 inlined helpers) — Match +- **EngineSound_VolumeAdjust @0x8002fc28** — signed slew/rate-limiter: `delta=desired-current`; if `step<|delta|` return `delta<1 ? current-step : current+step` else `desired`. Exact. ✓ +- **EngineSound_AI @0x8002fc64** — per-frame AI engine-sound update; 4 static helpers inlined: + - **GetTargetPitch**: `target=|const|`; `(actionsFlagSetPrevFrame&1)==0 || kartState==DRIFTING` → +0xf00 else `(target+|speedApprox|)>>1` (decomp `rpmTarget+absScratch>>1` = `(...)>>1` by C precedence; abs'd operands so shift-sign moot). ✓ + - **UpdateSmoothing**: delta=|target-pitchAccum|; if `<0x601` → volAccum-=500 (**u16**), drift ? clamp `(s16)<2000→2000` : `(s16)<0→0` (decomp `<<0x10;<0` = s16 sign test); else volAccum+=2000 (**s16**), `>14000→14000`. **u16/s16 asymmetry between branches faithfully reproduced.** pitchAccum=`(s16)((target*0x89+pitchAccum*0x177)>>9)` — srl-vs-sra moot (16-bit `sh` truncation: low 16 of `>>9` identical). ✓ + - **CalculateVolume**: vol=MapToRange(volAccum,0,const,0x82,0xe6); dist>=2000→0 else dist>200→MapToRange(dist,200,2000,vol,0); VolumeAdjust(vol, audioDefaults[slot]=g_anAIEngineVol[slot], 10)+store. ✓ + - **CalculateDistortion**: pitch=MapToRange(pitchAccum,0,const,0x3c,0xaa); `distanceDelta>>=3` (**ARITHMETIC/sra**, delta signed); clamp[-0x14,0x14]; distort=pitch-dd clamp[0,0xff]. ✓ + - Wrapper: LR `(int)lr` clamp[0,0xff]; distort=(distort&0xff)<<8 | (cameraDriver->actionsFlagSet&0x10000 ? 0x1000000:0); Recalculate((slotIndex+0x10)&0xffff, (vol&0xff)<<0x10 | distort | lr&0xff). ✓ + - **NAMING OBSERVATION (not a bug)**: project reads `const_AccelSpeed_ClassStat`; decomp+aalhendi-NOTE call same field `const_SpeedometerScale_ClassStat @0x42E` (CONFIRMED same offset). Behavior identical; potential field rename for the project maintainers, out of scope for behavior verification. +No new bugs. (HOWL_Engine.c remaining: EngineSound_Player[large], EngineSound_NearestAIs[+3 inlined helpers: GetDistance/InsertClosest/CalculateLR].) + +### Bug-class re-scan cont'd (2026-07-10) — HOWL_Engine.c EngineSound_NearestAIs (3 inlined helpers) — Match +- **EngineSound_NearestAIs @0x8002ff28** — per-frame: find 2 AIs nearest any player camera, update their engine sound. + - **NOTE(claude) numBotsNextGame gate CONFIRMED via ASM**: @0x8002ff50 `lbu v0, 0x1cab(v1)` = numBotsNextGame@0x1cab (NOT numBotsCurrGame@0x1caa), `beq v0,zero → return`. Project correct. ✓ + - init closestDrivers[2]=NULL, closestDistances[2]=0x7fffffff; walk threadBuckets[ROBOT=1]; inner `i < (u8)numPlyrCurrGame` (decomp `iVar8*0x10000>>0x10` = (s16)i sign-ext, small i ≡). ✓ + - **GetDistance (inlined)**: `d{x,y,z} = CTR_MipsSubLo(pushBuffer[i].pos, CTR_MipsSra(posCurr, 8))` (subu; sra ARITHMETIC >>8 on signed pos); `SquareRoot0(MulLo(dx,dx)+MulLo(dy,dy)+MulLo(dz,dz))`. (project pushBuffer ≡ decomp tileView, same offset — naming.) ✓ + - **InsertClosest (inlined)**: `dist>0x17` (arithmetic); wrap `lr<0x81 ? (lr<-0x80 ? -0x100-lr : lr) : 0x100-lr`; +0x80. ✓ + - VolumeAdjust(CalculateLR, audioDefaults[4+i]=g_anAIEnginePan[i], 10)+store; EngineSound_AI(ai, cameraDriver, i, closestDist[i], closestDist[i]-audioDefaults[2+i]=PrevDist, lr); store PrevDist. **audioDefaults[6] shared array: [0,1]=Vol, [2,3]=PrevDist, [4,5]=Pan** ≡ decomp 3 split int[2] (g_anAIEngineVol/PrevDist/Pan). ✓ +No new bugs; numBotsNextGame ASM-confirmed. (HOWL_Engine.c remaining: EngineSound_Player[large].) + +### Bug-class re-scan cont'd (2026-07-10) — HOWL_Engine.c EngineSound_Player — Match (HOWL_Engine.c + HOWL SUBSYSTEM COMPLETE) +- **EngineSound_Player @0x8002f5f4** — per-frame player-engine synthesis; 3 modes on mode field @0x47B. + - **Mode field ASM-confirmed**: @0x8002f63c `lbu v1, 0x47b(s0)` (unsigned byte @0x47B), `bne v1,zero` dispatches 0/1/else. **SEMANTIC NAMING OBSERVATION (not a behavior bug)**: project reads `driver->engineSoundMode` treating 0=ENGINE_SOUND_FADE_OUT/1=FADE_IN (per-frame fade state); decomp+NOTE say it's a PER-CLASS CONST `cConst_EngineSoundMode @0x47B` loaded at birth by VehBirth_SetConsts. Same offset+dispatch ⇒ THIS fn behaves identically; but the fade-state interpretation may be conceptually wrong (worth maintainer attention if other code WRITES it as fade state — would corrupt the const). Out of scope for behavior verification. + - Mode 0: volAccum=`(s16)(volAccum*0x177>>9)`, pitchAccum=`(s16)(pitchAccum*3000+0x22400>>0xc)`; vol=MapToRange(...,0,0xe6), distort=MapToRange(...,0x3c,200). `(short)` truncation ⇒ >>N shift-sign moot. ✓ + - Mode 1: volAccum=`(s16)(volAccum*3000+0x322bc0>>0xc)`, pitchAccum same; vol MapToRange(...,0x82,0xe6). ✓ + - Main path: REVVING → `(pitchAccum*0x40 + (fireSpeed>0?0x3000:0)*0x30 + nSpeedometerNeedle*0x90)>>8` (+0x1000 if fireSpeed>0); else `|fireSpeed|` +0xf00 or `(|fireSpeed|+|speedApprox|)>>1`. Slew (u16/s16 asymmetry, same as AI). pitchAccum=`(s16)((target*0x89+pitchAccum*0x177)>>9)`. vol=MapToRange(volAccum,0,const,0x82, AI?0xbe:0xe6) + (kartState!=DRIFT && !(flags&8) ? |wheelRot|>>3 : 0). distort=MapToRange(pitchAccum,0,const+SacredFire+0xf00,0x3c, AI?0xbe:200); DRIFT non-AI: sfxDistortOffset dec-if-nonzero (turboRoom==0) or `((u8)turboMaxRoom>>1)-(turboRoom>>6)`, distort-=|drift|, clamp0; distort += sfxDistortOffset (non-AI) or distort-=|drift| clamp0 (AI); clamp 0xff. + - **KEY sign idiom (drift/panLR): `(s32)((u32)angle<<0x10)>>0x13` = `(s16)angle >> 3` ARITHMETIC** (sign-extend low 16 via <<0x10, sra >>0x13=>>(16+3)); used for nSteerKickAngle@0x3D4(=project turnWobbleAngle) drift and wheelRotation panLR. Both project `(s32)` + decomp `(int)` = arithmetic. ✓ + - panLR=`0x80-((s16)wheelRotation>>3)` clamp[0x40,0xc0]; pack vol<<0x10|distort<<8|(flags&0x10000?0x1000000:0)|lr; Recalculate(engineID*4+driverID & 0xffff, packed). ✓ + - **Field naming (all same offset, behavior identical)**: engineSoundVolumeState/PitchState=nEngineSndVolAccum@0x3B6/PitchAccum@0x3B8; const_AccelSpeed_ClassStat=const_SpeedometerScale_ClassStat@0x42E; speedometerNeedleValue=nSpeedometerNeedle@0x36E; turnWobbleAngle=nSteerKickAngle@0x3D4. +No new bugs. **HOWL_Engine.c fully verified. ENTIRE HOWL AUDIO SUBSYSTEM COMPLETE** (Channel, OtherFX, OtherFXRecycle, Settings, Voiceline, Playback, Engine — all functions Match; zero code changes across the subsystem this run). + +### Bug-class re-scan cont'd (2026-07-10) — engineSoundMode concern RESOLVED + VehBirth SetConsts/TireSprites — Match (NOT a bug) +- **Follow-up on last cycle's engineSoundMode semantic flag**: grep found offset CONFIRMED via `_Static_assert(offsetof(Driver, engineSoundMode)==0x47b)`; enum FADE_OUT=0/FADE_IN=1/DYNAMIC=2. Only 2 writers: VehBirth_SetConsts (per-class metaPhys load) + VehBirth_TireSprites (=2). Both checked: +- **VehBirth_SetConsts @0x80058a60** — iterate 65 (0x41) metaPhys entries (stride 0x1c: offset@4, size@8, value[class]@0xc); `dst = driver + offset` (decomp `driver->funcPtrs + offset - 0x54` ≡ driver+offset since funcPtrs@0x54); copy `metaPhys->value[engineID]` (engineID*4 slot) as 1/2/4 bytes per size. Loads const_EngineSoundMode@0x47B per-class. **Project VehBirth.c:465 matches.** ✓ +- **VehBirth_TireSprites @0x80058c4c** — binary **DOES write `cConst_EngineSoundMode = '\x02'`** (matching project `d->engineSoundMode = ENGINE_SOUND_DYNAMIC` = 2). Also wheelSize=0xccc (0 for Oxide off-menu), wheelSprites, tireColor=0x2e808080, nTireFlashTimer=0xa00, heldItemID=0xf, AxisAngle1/2_normalVec[1]=0x1000, unk412=0x600, numFramesSpentSteering=10000, terrainMeta1=GetTerrain(10), BattleHUD.teamID=driverID/numLives=battleLifeLimit, unk_4F0_4F8[0/1/4/5]=-1. **Project VehBirth_TireSprites matches.** ✓ +- **RESOLUTION: engineSoundMode is NOT a bug.** Birth order SetConsts(per-class 0x47B) → TireSprites(=2) is faithfully reproduced in BOTH project and binary. In practice 0x47B ends up 2 (DYNAMIC) ⇒ EngineSound_Player modes 0/1 are effectively dead in both (explains why ref-decomp h134 only had the main branch). Project faithful; no change. +No new bugs. engineSoundMode concern closed. + +### Bug-class re-scan cont'd (2026-07-10) — VehBirth.c EngineAudio_AllPlayers (Match) + TeleportSelf (first half Match) +- **VehBirth_EngineAudio_AllPlayers @0x80058ba4** — walk threadBuckets[PLAYER=0]; per driver: driverID@0x4a, `EngineAudio_InitOnce((MetaDataCharacters[characterIDs[driverID]].engineID*4 + driverID)&0xffff, 0x8080)`. decomp NOTE: DB misnamed 'Init_EngineAudio_AllPlayers' → syms926 corrected. ✓ +- **VehBirth_TeleportSelf @0x80057c8c** [GIANT ~272 lines] — spawn+BSP-ground-snap+state reset. **First half verified Match** (setup→spawn-pos-select→BSP ray→normal→pos write): + - guard level1/ptr_mesh_info NULL; scratchpad qbColl: quadFlagsWanted=0x3000, ignored=0, searchFlags=0, `if(numPlyr<3) collFlags=LOD-flag`, ptr_mesh_info. **NAMING (union-view, same offset/value): project COLL_SEARCH_HIGH_LOD = decomp LOW_LOD_QUAD** (opposite-sounding name, same bit under numPlyr<3). + - gameMode2 &= ~VEH_FREEZE_DOOR; spawnAtBoss = gameMode2 & SPAWN_AT_BOSS. + - **keep-current-pos (flag&1==0)**: `(s16)CTR_MipsSra(posCurr,8)` (arith) ≡ decomp `(short)((uint)posCurr>>8)` (logical) — **(s16) truncation makes sra/srl equivalent** (bits 8-23 identical); +0x80 on Y. + - spawn-pos cascade (flag&1): matchedInst door-search (podiumRewardID STATIC_KEY + numKeys==1, name "door5"); STATIC_TROPHY adv-progress-bit gate (useSpawnType2); else startline (g_abKartSpawnOrder[driverID] grid slot) / **`(u32)(prevLEV-CREDITS_CRASH)<0x14` UNSIGNED range (decomp `-0x2cU<0x14`, sltiu — project (u32) correct)** / warppad / spawnType2. matchedInst offset via MATH_Cos/Sin(rotY)+scaled. + - BSP: rayTop/rayBot (Y-0x100), COLL_SearchBSP_CallbackQUADBLK; no-hit → AxisAngle3=(0,0x1000,0) else normal + lastValid; mirror AxisAngle3→1/2/4. + - **posCurr write x/y/z = `(s16)hitPos<<8`** (decomp z via `((ushort)nextPos[2]<<0x10)>>8` sign-ext idiom ≡ (s16)z<<8); Y = (hitPos.y+yOffset)<<8; posPrev=posCurr; quadBlockHeight=(s16)hitPos.y<<8. ✓ +- **DEFERRED**: TeleportSelf reset half (lines ~262-389: rotation set, speed/state zeroing, model-0x18 mine-block/CAM_StartOfRace/funcThTick/funcPtrs init, lap/wumpa/item/cheat/battle reset) — next cycle. +No new bugs; TeleportSelf first half matches (sign idioms verified). + +### Bug-class re-scan cont'd (2026-07-10) — VehBirth_TeleportSelf reset half — Match (TeleportSelf COMPLETE) +- **VehBirth_TeleportSelf @0x80057c8c reset half (lines ~262-387)**: + - Rotation (flag&1) via helpers (SetStartlineRotation/ShouldUseStartlineInAdv/SpawnType2PosRot inlined in decomp): non-adv→startline (DriverSpawn[kartSpawnOrder[driverID]].rot, rotCurr.y=(rot[1]+0x400)&0xfff); podium!=NOFUNC→advSpawn[0].rot[1]&0xfff; warppad rotCurr from warppadRot[]. Structure aligns (helper bodies separate). + - **State zeroing (~20 fields)**: speed/speedApprox/jumpHeightCurr(nJumpHeightCurr)/Prev/forwardAccelImpulse(unk_offset3B2)/matrixArray/matrixIndex/jump_LandingBoost/jumpMeter/jumpMeterTimer/turnAngleCurr(nTurnAngleCurr)/turnAngleLerpVel(unk_LerpToForwards)/turnAnglePrev(nTurnAnglePrev)/rotCurr.w/rotPrev.w/pendingDamageType(cChangeState_damageType)/jumpSquishStretch/underDriver/distanceDrivenBackwards/clockReceive/revEngineState(engineRevState) = 0; angle=rotCurr.y; rotPrev=rotCurr. `if(matchedInst && flag&1) speed=0xa00`. ✓ + - actionsFlagSet &= ~(ACTION_AIRBORNE|ACTION_HIGH_JUMP) = decomp `& 0xfff7ffbf` (bits 0x80000|0x40). animIndex=0; animFrame=GetStartFrame(0,GetNumAnimFrames(inst,0)); scale=0xccc. ✓ + - **flag&2 reset**: if modelIndex==DYNAMIC_PLAYER: zero funcPtrs[0..0xc] (13; project ascending ≡ decomp descending from [12]), CAM_StartOfRace(cameraDC[driverID]), funcThTick=(gameMode1&(CUTSCENE|MENU))?NullThread:0, funcPtrs[0]=(gameMode1&ADV_ARENA)?VehPhysProc_Driving_Init:VehStuckProc_RevEngine_Init. lapIndex/numWumpas/lapTime/distanceToFinish_curr=0; actionsFlagSet&=~(RACE_FINISHED|BOT); CHEAT_WUMPA→numWumpas=99. + - **Item cheat priority CONFIRMED** (common bug spot): MASK→7 > TURBO→0 > BOMBS→1 (else 0xf); numHeldItems=9 if cheat item else 0. decomp goto-tangle ≡ project if/else-if. BattleHUD.numLives=battleSetup.lifeLimit. CHEAT_INVISIBLE && driverIDgGT` ≡ decomp reads sdata_gGT->drivers directly); loop drivers[0..7] (decomp `(i<<0x10)>>0xe`=i*4 offset, `(s16)i<8` bound); NULL→skip; modelIndex==DYNAMIC_ROBOT_CAR(0x3f)→BOTS_GotoStartingLine else TeleportSelf(d, flags|1, 0). ✓ +- **VehBirth_GetModelByName @0x80058948** — search 3 driverModelExtras slots (NULL-guard + 16-byte name via 4 u32 compares), then NULL-terminated PLYROBJECTLIST (if list && [0] non-null, iterate until NULL); return Model* or NULL. ✓ +- **VehBirth_NonGhost @0x80058d2c** — thread consts modelIndex=DYNAMIC_PLAYER(0x18)/driverHitRadius=0x40/HitRadiusSquared=0x1000/reserved@0x3e=0x40/@0x3c=0/@0x40=0; charID = characterIDs[0] (mainMenu gameMode1&0x2000) else [index]; GetModelByName(MetaDataCharacters[id].name_Debug)→INSTANCE_Birth3D→thread->inst; wake modelPtr[STATIC_WAKE=0x43]→Birth3D→wakeInst, flags|=0x90(HIDE|ANIM_LOOP); `index>0xc` ARITHMETIC (Sra); decomp renders scale=0x200 as `<<9`; used in door offset `pos + ScaleTrig(Cos(rotY),800) + ScaleTrig(Cos(rotY+0x400),0x200)`, (short) truncated. ✓ + - **GetStartlineIndex**: kartSpawnOrderArray[driverID]. ✓ +No new bugs. **VehBirth.c fully verified** (all functions Match; 2 documented CTR_NATIVE native-safety guards; ShouldUseStartlineInAdv equivalent-but-more-explicit). + +### Bug-class re-scan cont'd (2026-07-10) — VehTurbo.c ProcessBucket (Match) + ThDestroy (**Fix 135**) +- **VehTurbo_ProcessBucket @0x80069284** — walk turbo-thread bucket; per thread: primary=IDPP(thread->inst), secondary=IDPP(turbo->inst=Turbo+0), driver=IDPP(turbo->driver->instSelf@0x1c). Per player [0,numPlyr): if `driver->instFlags@0xb8 & PUSHBUFFER_EXISTS(0x100) == 0` → both flame IDPPs `instFlags &= (driver.instFlags | ~DRAW_SUCCESSFUL(0xffffffbf))`; copy otRangeNormal@0xe4 (primary via union-view mode+nearOrFar 2×s16 ≡ prior nearOrFar fix), otRangeSecondary@0xe8, depthOffset[0]@0xdc/[1]@0xde. Stride 0x88 (InstDrawPerPlayer). decomp sdata_gGT save/restore is gp-artifact. ✓ +- **VehTurbo_ThDestroy @0x80069370 — FIX 135 (INSTANCE_Death order swapped)**: turboObj=t->object; driver=turboObj->driver(Turbo+4); `actionsFlagSet@0x2c8 &= ~ACTION_TURBO_ITEM(0x200)`. **BUG**: project freed `INSTANCE_Death(t->inst); INSTANCE_Death(turboObj->inst)` but **ASM @0x800693a0-0x800693b0 frees turbo->inst (`lw a0,0x0(a1)`=Turbo+0) FIRST then t->inst (`lw a0,0x34(s0)`=thread+0x34)**. Swapped to match binary + NOTE(claude). INSTANCE_Death unlinks+frees; matching retail order removes list-mutation ordering hazard. Built clean. +**Fix 135** applied (2nd code change this run after Fix 134). (VehTurbo.c remaining: VehTurbo_ThTick[large] + static VehTurbo_TransformOffset.) + +### Bug-class re-scan cont'd (2026-07-10) — VehTurbo_ThTick + TransformOffset — Match (VehTurbo.c COMPLETE) +- **VehTurbo_ThTick @0x800693c8** [large] — per-frame twin turbo-flame update. + - Alpha halve: `burnTimer==0 && flame1.alpha==0 && driver.model!=GHOST` → `driverInst.alpha = (u16)alpha >> 1`. Split(0x2000)/reflective(0x4000) flags + vertSplit copied to both flames. ✓ + - **fireSize clamp [4,8]** (`(int)fireSize` signed); **9-elem matrix `flame.m[i][j] = (s16)(driver.m[i][j] * fireSize >> 3)`** (arith >>3 + s16 trunc); **flame2 col0 negated** `(s16)(-(int)m[i][0] * fireSize >> 3)` (unary-minus precedence). ✓ + - **TransformOffset (inlined, ×2)**: flame1 offset `(scale.x*9>>0xb, scale.y*3>>8, scale.z*-0x34>>0xc)`, flame2 `(scale.x*-0x12>>0xc, ...)`; gte_ldv0(SVECTOR) ≡ decomp gte_ldVXY0(x&0xffff | y<<0x10)+gte_ldVZ0(z). **Project re-sets gte_SetRotMatrix/SetTransMatrix each call; decomp sets ONCE up front + reuses — equivalent (driver matrix unchanged between flames).** ✓ + - fireVisibilityCooldown = `(u16)cooldown - (u16)elapsedTimeMS`; **`cooldown*0x10000 < 0` = (s16)<0 sign-test** → 0; ==0 → clear HIDE_MODEL(0x80). alpha<0x9c4(2500) → GAMEPAD_ShockFreq(driver,4,4). ✓ + - Model advance: flame1=modelPtr[fireAnimIndex + STATIC_TURBO_EFFECT(0x2c)], flame2=modelPtr[((fireAnimIndex+3)&7)+0x2c]; `fireAnimIndex=(x+1)` then `>7→0` ≡ project `++ &7`. fireDisappearCountdown>0 → --. ✓ + - Player audio (model==DYNAMIC_PLAYER): fireSfxVolume=`0x100-((u16)alpha>>4)` clamp[0,0x82]; fireAudioDistort=`(u8)+0x10` clamp[0,0x80]<<8 |echo(0x1000000 if actionsFlagSet&0x10000); OtherFX_RecycleNew(&driverAudioPtrs[3], 0xe, vol<<0x10|distort|0x80); if(fireAudioDistort<0xc0)++. ✓ + - Fade/delete: `model==GHOST || (kartState != MASK_GRABBED/CRASHING/WARP_PAD)` — **WARP_PAD check under `#if BUILD>SepReview` correctly INCLUDED for 926** (matches decomp). reserves<0x10 || countdown==0 → alpha += (countdown==0 ? 0x100 : 0x40) both flames (cap 0xfff); else restore driverInst.alpha=alphaScaleBackup, stop sound RecycleNew(-1, 0x8080|echo), flags|=THREAD_FLAG_DEAD(0x800). ✓ + - **ThTick native-pattern divergence (documented, intentional)**: retail tail-calls `ThTick_FastRET(turboThread)` in `while(true)` (PSYQ $RA-skip); project RETURNS normally per explicit comment (modern GCC optimizes plain return better). Consistent with Fix-127 ThTick criteria. Not a bug. +No new bugs; ThTick matches. **VehTurbo.c fully verified** (ProcessBucket Match, ThDestroy Fix 135, ThTick Match, TransformOffset Match). + +### Bug-class re-scan cont'd (2026-07-10) — VehPhysCrash.c ConvertVecToSpeed/BounceSelf/AI — Match +- **VehPhysCrash_ConvertVecToSpeed @0x8005cd1c** — velocity→speed decomposition: speed=FastSqrt(x²+y²+z²)>>8; axisRotationY=ratan2(y<<8, FastSqrt(x²+z²)); axisRotationX=ratan2(x,z); upProj=Dot3(vel, m[][1])>>0xc (Sra); proj=m[][1]*upProj>>0xc; nJumpHeightCurr=FastSqrt(proj²)>>8 signed by upProj<0 (Neg); residual=vel-proj; speedApprox=FastSqrt(residual²)>>8 signed by Dot3(residual, m[][2])<0. All Sra >>0xc / FastSqrt >>8 / ratan2 / Neg match. ✓ +- **VehPhysCrash_BounceSelf @0x8005cf64 (+ Div6Shift9 helper)** — push pos out of collision plane. signedDist=Dot3(pos-planePoint, normal)>>0xc; side==0 bounce if <0, side!=0 bounce if >0 (project return-if-not ≡ decomp goto-bounce); impactStrength=max(|signedDist|); pos[i] -= (signedDist*normal[i])/0xc00 + reproject; Y clamp: `oldY0x3200 → 0x3200`. **Div6Shift9 = signed div by 0xc00(3072) CONFIRMED**: `(value*0x2aaaaaab)` high-word(=`(s32)((u64)prod>>32)`, true signed high) `>>9` + `-(value>>31)` neg-round-correction. Verified concretely Div6Shift9(-3072): high=-513, >>9=-2, -(-1)=-1=-3072/3072. decomp renders `/0xc00`. ✓ +- **VehPhysCrash_AI @0x8005d0d0** — decompose bot post-crash velocity. botCrashNavRot=rot[i]<<4 (**ASM @0x8005d0f0/0x8005d108 `lbu`=UNSIGNED byte, offsets 6/7/8 in botNavFrame** → project rot[] u8, matches); ConvertRotToMatrix; forward=`(s16)m[i][2]>>4` (decomp `<<0x10>>0x14` sign-ext ≡ Sra); alongComponent=Dot3(forward,vel)>>8→aiDriveState.nSpeedLinear@0x18; pAccelAxis[0]@0x1c=vel.x-(forward[0]*along>>8), pAccelAxis[2]@0x24=vel.z-(forward[2]*along>>8); botFlags|=BOT_FLAG_FREE_PHYSICS(8). Retail scratch globals mapped to project dataLibFiller (documented NOTE). ✓ +No new bugs. (VehPhysCrash.c remaining: VehPhysCrash_Attack @0x8005d218, VehPhysCrash_AnyTwoCars @0x8005d404[large].) + +### Bug-class re-scan cont'd (2026-07-10) — VehPhysCrash_Attack (+SetReason inlined) — Match +- **VehPhysCrash_Attack @0x8005d218** — resolve "who gets hurt" when driver1 crashes into driver2; queue ChangeState on driver1. Gate `driver1.actionsFlagSet & ACTION_MASK_WEAPON(0x800000) == 0`. 3 outcomes: + - (1) driver2 has weapon-mask → pendingDamageType(cChangeState_damageType)=2, SetReason(ChangeState_attackKind)=6(ATTACK_CRASH), attacker=driver2; feedback if `canPlayFeedback && kartState!=BLASTED && invincibleTimer==0` → DriverCrashing(echo, 0xff) + Voiceline(1=VOICE_HURT, characterIDs[driverID], 0x10). + - (2) driver2 has instBubbleHold && driver1 doesn't → shield.flags|=8, clear driver2.instBubbleHold, type=2, reason=0(ATTACK_NONE), attacker; feedback + `boolPlayBubblePop→OtherFX_Play(0x4f,1)`. + - (3) impactStrength(g_nCrashMaxPenetration)>0xa00 && driver2.reserves!=0 && driver2.actionsFlagSet&ACTION_TURBO_ITEM(0x200) && driver1.reserves==0 → driver2.forcedJumpType=FORCED_JUMP_HIGH(2), type=3, reason=5(ATTACK_SQUISH), attacker. + - **echo arg**: decomp `(ushort)*(actionsFlagSet+2) & 1` (high-halfword bit0 = bit16) ≡ project `(actionsFlagSet & ACTION_ENGINE_ECHO(0x10000)) != 0`. return canPlayFeedback. ✓ +No new bugs. (VehPhysCrash.c remaining: VehPhysCrash_AnyTwoCars @0x8005d404[large] + inlined static helpers WeightedAverage/WeightedVelocity/AddImpulse/SubImpulse/BouncePair/PlayHumanFeedback.) + +### Bug-class re-scan cont'd (2026-07-10) — VehPhysCrash_AnyTwoCars [large] + inlined helpers — Match (VehPhysCrash.c COMPLETE) +- **VehPhysCrash_AnyTwoCars @0x8005d404** — resolve car-vs-car collision. + - Setup: distance=FastSqrt(bestDistSq,0); hitDir=0-dist? (0,0,0x1000) : `(s16)((dist[i]<<0xc)/distance)` (CTR_MipsDiv signed; traps=div-guards). hitStrength=(self.HitRadius + other.HitRadius)-distance; bail if <=0. impactStrength=0. ✓ + - **4-case AI-bit (actionsFlagSet & ACTION_BOT) dispatch** — project checks self-AI-first (early return, NO feedback for AI-self) ≡ decomp checks self-human-first (feedback inside). Same case→outcome+feedback map: both-human & self-human/other-AI → feedback; self-AI/other-human & both-AI → NO feedback. ✓ + - **WeightedVelocity (comVel)**: `(selfVel[i]*self.nMass + otherVel[i]*other.nMass) / (self.nMass+other.nMass)` = mass-weighted center-of-mass (const_CollisionWeight=nMass@0x47c). ✓ + - **BouncePair — PROJECT CORRECT, decomp ARTIFACT (ASM-resolved false alarm)**: project `if(BounceSelf(...)<0) impactStrength=0` / `if(BounceSelf(...)>0)=0`. BounceSelf RETURNS 0 (ASM @0x8005d0c0 `clear v0` before jr ra). **ASM call sites @0x8005d6c0 `bgez v0`/@0x8005d6dc `blez v0` check BounceSelf's RETURN (v0=0)** → branches always taken → clears NEVER fire. decomp `if(massSumOrQuot<0)` (comVelZ) was a decompiler REGISTER-ALIASING ARTIFACT (v0=return mis-tracked as massSumOrQuot). Project faithfully reproduces binary (no clear). NOT a bug. [Same artifact class as OptionsMenu_TestSound.] + - **AddImpulse/SubImpulse**: `thisVel[i] += (unitNormal[i]*penetration)>>8` / `otherVel[i] -= ...` (ASM @0x8005d6fc `sra ...,0x8` arithmetic). ✓ + - Per-case velocity sources: both-human `&other.velocity`; self-human/other-AI & both-AI `other.xSpeed+aiDriveState.pAccelAxis`; self-AI/other-human `ConvertSpeedToVec(other)`. AI(self/other) + ConvertVecToSpeed/BOTS_CollideWithOtherAI per case. ✓ + - **Feedback (PlayHumanFeedback inlined)**: canPlayFeedback=`(u32)(frameTimer - audioDefaults[8]=g_nLastCrashSfxFrame) >= 3` (UNSIGNED). if impactStrength>0x200 && (self|other model==PLAYER): volume=MapToRange(imp,0,0x1900,0x3f,0xff); if(canPlay && both !BLASTED/!invincible) DriverCrashing(echo, volume) + stamp lastFrame + `volume>0xdc`→Voiceline(5,...); rumble both (ShockFreq/ShockForce1(8,0x7f)/JogCon1(simpTurnState>0?0x29:0x19, 0x60)); both actionsFlagSet|=ACTION_HUMAN_HUMAN_COLLISION(0x10000000). Attack(self,other,fx,0)→result; Attack(other,self,result,1). ✓ +No new bugs; BouncePair suspicion RESOLVED (project correct, decomp artifact, ASM-confirmed). **VehPhysCrash.c fully verified** (all 5 fns + 6 inlined helpers Match). + +### Bug-class re-scan cont'd (2026-07-10) — VehEmitter.c Sparks_Ground/Terrain_Ground — Match +- **VehEmitter_Sparks_Ground @0x80059344** — spawn 10 ground spark particles. 3 GTE-rotated dir vecs: outX=rot{0x1800,0,0}, outZ=rot{0,0,-0x1800}, outZ2=rot{0,0,-0x200} (order differs project vs decomp, independent so ≡; gte_ldv0 = ldVXY0+ldVZ0, gte_stlvnl = read_mt/stMAC). Per particle: rng=RNG_Random&0x7ff, `if(rng&1) rng=-rng` (masked bit0 ≡ raw bit0); axis[j].vel += `(s16)outZ2[j] + (s16)((rng*outX[j])>>12)` (**logical(project u32) vs arith(decomp (int)) >>12 ≡ via (s16) trunc**); axis[j].pos += outZ[j] + axis[j].vel; driverInst + otIndexOffset(cOtDepth)=instSelf->depthBiasNormal(cOtDepthNear). ✓ +- **VehEmitter_Terrain_Ground @0x80059558** — spawn terrain/dust on wheels. Guards: `!(flags&ACTION_TOUCH_GROUND=1)`→ret, `flags&8`→ret, `|fireSpeed|<0x300 && |speedApprox|<0x300`→ret. numTires=DRIFTING?4:2. Per wheel: GTE-rotate terrainEmitterPos[numTires-1] (decomp tireOrSpeed-indexed ldVXY0/VZ0 constants `{±0x1e,0xa,-0x14/0x28}` = project static table exactly: t4→[3]{-0x1e,..0x28}, t3→[2]{0x1e,..0x28}, t2→[1]{-0x1e,..-0x14}, t1→[0]{0x1e,..-0x14}); axis[i].pos += gteOut*0x100; GTE-rotate particle vel (ldVXY0 CONCAT22(vel[1],vel[0]) + ldVZ0(vel[2])) → axis[i].vel=(s16)gteOut; driverInst + otIndexOffset. ✓ +No new bugs. (VehEmitter.c remaining: VehEmitter_Sparks_Wall @0x80059780[large], VehEmitter_DriverMain @0x80059a18.) + +### Bug-class re-scan cont'd (2026-07-10) — VehEmitter_DriverMain WRAPPER skeleton — Match (helpers inlined in binary) +- **VehEmitter_DriverMain @0x80059a18** [GIANT — binary inlines project's static helpers TerrainAudioAndFeedback/TerrainEffects/SkidmarkAudio/ShouldSkipExhaust/ExhaustPair]. Project's own wrapper (lines 711-775) verified vs corresponding decomp regions: + - Skidmark ring advance: `skidmarkEnableFlags=(x&0xfffff)<<4; skidmarkFrameIndex=(x-1)&7`. ✓ + - Skid flags: `terrainFlags&TERRAIN_FLAG_FORCE_SKIDMARKS(0x8) → actionsFlagSet|=0x1800` (decomp stack-copy @+0x38 `&8`); matrixArray `==1→|=0x800 &=~0x1000`, `2/3→&=~0x1000`, else none (decomp goto-LAB structure ≡). ✓ + - Tail: burnTimer→alphaScaleBackup/instSelf.alpha=0x1000; invisibleTimer→inst.alpha=0x1000; `kartState!=NORMAL && !=DRIFTING → actionsFlagSet&=~ACTION_AIRBORNE(0x80000)`. JogCon: **`(uint)(kartState-4)<2` = kartState∈{4=REVVING,5=MASK_GRABBED}** || TOUCH_GROUND → JogCon2(0x27,0), `nSteerKickAngle(=turnWobbleAngle)==0`→ret, else JogCon2(timer&3==0?0x27:0xf0, 0x100); else `jump_LandingBoost<0x80` → JogCon1(simpTurnState<0?0x12:(>0?0x22:skip), 0x20); common JogCon2(0/cVar22, 0/sVar6). ✓ +- **DEFERRED**: DriverMain inlined-helper bodies = project static fns (TerrainAudioAndFeedback[engine/terrain audio], TerrainEffects[water/dust/wall emitters + engine-loop], SkidmarkAudio[GTE skidmark ring projection], ShouldSkipExhaust, ExhaustPair[GTE tailpipe]) — verify per-helper next cycles. +No new bugs; DriverMain wrapper skeleton matches. (VehEmitter.c remaining: Sparks_Wall[large] + DriverMain static helpers.) + +### Bug-class re-scan cont'd (2026-07-10) — DriverMain helpers TerrainAudioAndFeedback/SkidmarkAudio(**Fix 136**)/ShouldSkipExhaust +- **VehEmitter_TerrainAudioAndFeedback** (inlined) — Match: model==DYNAMIC_PLAYER gate; soundID=-1 or terrain->sound (TOUCH_GROUND && !(terrainFlags&ONESHOT_GROUND_SOUND=0x20)); vol=MapToRange(absSpeed,0,5000,0,200), distort=MapToRange(absSpeed,0,12000,0x6c,0xd2)<<8 |echo; RecycleNew(&driverAudioPtrs[1], soundID, vol<<16|distort|0x80); if !BOT: absSpeed>0x200→ShockFreq(vibrationData[0/1])+ShockForce2(vibrationData[2/3]); STARTED_TOUCH_GROUND(0x2)→|jumpHeightPrev|>0x1600→ShockForce1(3,0xff). ✓ +- **VehEmitter_SkidmarkAudio** (inlined) — **Fix 136 (skidmark-audio pan LOGICAL vs ARITHMETIC shift)**: gate `!TOUCH_GROUND || !(actionsFlagSet & (BACK|FRONT_SKID=0x1800)) || absSpeed<0x201` → Stop1 driverAudioPtrs[0]+NULL. DYNAMIC_PLAYER: vol=MapToRange(absSpeed,2000,12000,0x14,0xaa), distort=MapToRange(...,0x92,0x78); DRIFTING→distort-=|turnWobbleAngle(nSteerKickAngle)| clamp0; distort+=|simpTurnState|; clamp 0x92. flags=(vol+(absTurn>>1))<<16 | distort<<8 | **pan**; echo→|0x1000000; RecycleNew(&driverAudioPtrs[0], terrain->skidSound, flags). **BUG**: project pan `(0x80u - (((u32)(u8)simpTurnState<<24)>>26))` — `(u32)>>26` LOGICAL, drops sign → **mirrors L/R pan for negative simpTurnState**. **ASM @0x8005a1a4-0x8005a1b4 `lbu; sll 0x18; sra 0x1a`** — ARITHMETIC (sra) sign-extends. Fixed to `(s32)((u32)(u8)simpTurnState<<24)>>26` (sra) + NOTE(claude). Built clean. +- **VehEmitter_ShouldSkipExhaust** (inlined) — Match: model==DYNAMIC_ROBOT_CAR(0x3f) → `(timer&3)!=(driverID&3)`→skip; else revEngineState==2→skip, numPlyr>1 `(numPlyr!=2 || (timer&1)!=driverID) && (timer&3)!=driverID`→skip, failedBoostExhaustTimer(normalVecID+1)==0 && (turbo_MeterRoomLeft<0x81 || (const_turboLowRoomWarning+2)*0x20 < meterLeft) && PROC_SearchForModel(childThread, STATIC_TURBO_EFFECT=0x2c)→skip; failedBoostExhaustTimer!=0→--; return 0/1. ✓ +**Fix 136** applied (3rd code change this run: Fix 134 RefreshCard, Fix 135 VehTurbo, Fix 136 VehEmitter skidmark pan). (VehEmitter.c remaining: Sparks_Wall[large], TerrainEffects, Skidmarks[GTE], ExhaustPair/Exhaust.) + +### Bug-class re-scan cont'd (2026-07-10) — DriverMain helpers TerrainEffects + ExhaustPair — Match +- **VehEmitter_TerrainEffects** (inlined) — `numPlyrCurrGame>=2`→ret; MudSplash (`|speed|>0x500 && currentTerrain==TERRAIN_MUD(0xe)`; helper spawns 1 or 10 particles by actionsFlagSet&2); LANDING_SPARKS (terrainFlags&0x40 && STARTED_TOUCH_GROUND(0x2) && absSpeed>0x600 && |jumpHeightPrev|>0x1600 → SetRotTransMatrix+Sparks_Ground(emSet_GroundSparks)); em_OddFrame→SetRotTransMatrix+Terrain_Ground(emSet = (em_EvenFrame && timer&1)?even:odd); wall: `wallRubTimer(set_0xF0_OnWallRub)==0xf0 && kartState!=MASK_GRABBED` → Sparks_Wall(emSet_WallSparks) + engineVol(pLapCheckpointState+2)+=0x14 clamp0xff; else (wallRubTimer==0→frameAgainstWall/timeAgainstWall=0) engineVol-=0x14 clamp0 (**decomp `(int)((uint)x<<0x10)<0` s16-sign-test**), engineVol==0→wallSound=-1. If DYNAMIC_PLAYER: flags=(s16)engineVol<<16 | (echo?0x1008080:0x8080); RecycleNew(&driverAudioPtrs[2], wallSound, flags). ✓ +- **VehEmitter_ExhaustPair** (inlined) — gte_SetRotMatrix; exhaustVel=RotVec({0,0x400,-0x400}); exhaustPos=RotVec({scale.x*9>>3, scale.y*7>>1, scale.z*-0x38>>4}); Exhaust(d, &exhaustPos, &exhaustVel); then 2nd (mirrored) exhaust: local.vx=scale.x*-0x12>>4 (reuse vy/vz), RotVec, Exhaust. decomp gte_ldVXY0 packs vx|vy<<0x10, ldVZ0 vz; matches. ✓ +No new bugs. (VehEmitter.c remaining: Sparks_Wall[large], MudSplash/RotVec/SetRotTransMatrix/Skidmarks[GTE ring]/Exhaust helpers.) + +### Bug-class re-scan cont'd (2026-07-10) — DriverMain GTE helpers SetRotTransMatrix/RotVec/RotTransVec/MudSplash/Skidmarks — Match +- **SetRotTransMatrix/RotVec/RotTransVec** (inlined) — gte_SetRotMatrix+SetTransMatrix; ldv0(=ldVXY0+ldVZ0)+rtv0+stlvnl; ldv0+**rt**(rotate+translate for positions)+stlvnl. ✓ +- **VehEmitter_MudSplash** (inlined) — count = STARTED_TOUCH_GROUND(0x2)?10:1; per particle: Particle_Init(iconGroup[0xd], emSet_MudSplash); otIndexOffset(cOtDepth)=depthBiasNormal(cOtDepthNear), driverInst, driverID(cDriverID); axis[0/2].startVal += (int)vel*0x10; axis[0/2].accel -= vel>>4 (arith). ✓ +- **VehEmitter_Skidmarks** (inlined) — color=(inst.flags&SPLIT_LINE)?depthBiasSecondary(cOtDepthFar):depthBiasNormal(cOtDepthNear), +=2; flags=(terrainFlags&FORCE_SKIDMARKS)?1:0; sin/cos=MATH_Sin/Cos(axisRotationX); **lateralX=sin*15>>12, lateralZ=cos*15>>12, widthX=cos*10>>12, widthZ=-sin*10>>12** (all arith >>0xc). BACK_SKID(0x800)→enableFlags|=1/2, tires 0/1 at local{∓0x1e,0,-0x14}; FRONT_SKID(0x1000)→|=4/8, tires 2/3 at {∓0x1e,0,0x28}. Each RotTransVec(rt)→pos; WriteSkidmarkPair(x=pos.vx-(lateralX>>1), z=pos.vz-(lateralZ>>1)): writes frame + frame-1(x+=lateralX,z+=lateralZ, width>>1). **`lateralX>>1`: project `(sin*15>>12)>>1` ≡ decomp `sin*0xf>>0xd`** (arith shifts compose). WriteSkidmark layout x0@0/y0@2/z0@4/color@6/flags@7/x1@8/y1@0xa/z1@0xc, x0=x+widthX/x1=x-widthX/z0=z+widthZ/z1=z-widthZ, stride 0x10, flag byte = FORCE_SKIDMARKS?1:0. ✓ +No new bugs. (VehEmitter.c remaining: Sparks_Wall[large], VehEmitter_Exhaust.) + +### Bug-class re-scan cont'd (2026-07-10) — VehEmitter_Exhaust + Sparks_Wall — Match (VehEmitter.c COMPLETE) +- **VehEmitter_Exhaust @0x80059100** — spawn 1 exhaust particle. invisibleTimer!=0 || instSelf.flags&HIDE_MODEL → NULL. LOD emitter: 1P→High, 2P→Med, 3-4P||DYNAMIC_ROBOT_CAR(0x3f)→Low (project seeds Low+switch-up ≡ decomp seeds High+step-down, same per case). Water: SPLIT_LINE && (posCurr.y-posPrev.y)+driver.posCurr.y<0x100 → type 7 + emExhaustWater. Particle_Init(iconGroup[type]); axis[i].pos += (posCurr[i]-posPrev[i]), axis[i].vel=(s16)posPrev[i]; driverInst; otIndexOffset; type7→funcPtr=ExhaustUnderwater. Turbo glow: REVVING → engineRevState!=1 ret; else turbo_MeterRoomLeft<0x81 || (const_turboLowRoomWarning+2)*0x20 < meterLeft ret; else flagsSetColor &= ~0x60 | 0x40. ✓ +- **VehEmitter_Sparks_Wall @0x80059780** [large] — wall-scrape spark. Rumble: `(fireSpeed!=0 || |speedApprox|>0x200) && timeAgainstWall(frameAgainstWall)<0x1c2(450)` → ShockFreq(8,0)+ShockForce1(8,0x7f)+timer++; else timer=0. `|speedApprox|<=0x200` → return (decomp `speedApprox<0x201 && -0x2010?0x2800:-0x1400`; rtv0/rtv1 rotate both. gte_SetLightMatrix3x2(6 s16 = both rotated tires as L11..L23). Wall delta = posWallColl*0x100 - posCurr; gte_llv0 → pick tire w/ larger dot (`TireLeft·delta < TireRight·delta → use right`). Particle_Init(iconGroup[0]); axis[i].pos += chosenTire[i]; rotate particle vel → axis[i].vel=(s16); driverInst. + - **`-0x14` DECOMP ARTIFACT RESOLVED (project correct)**: decomp `axis[0].pos = unaff_s1->data + axis[0].pos + -0x14` (GTE-register tangle mis-typed tire.x value as ptr). **ASM @0x8005998c `addu v0,v0,s1` (axis[0].pos += tire.x, NO -0x14)**; @0x800599a0/0x80059998 clean addu for axis[1/2]. Project `axis[i].startVal += TireLeftOutS16[i]` faithful. [3rd decomp-C false-alarm this run vindicated by ASM, after OptionsMenu_TestSound + AnyTwoCars BouncePair.] ✓ +No new bugs; Sparks_Wall -0x14 was decomp artifact. **VehEmitter.c fully verified** (all fns + helpers Match; Fix 136 in SkidmarkAudio). **All 5 Vehicle files this run COMPLETE** (VehBirth, VehTurbo[Fix135], VehPhysCrash, VehEmitter[Fix136]). + +### Bug-class re-scan cont'd (2026-07-10) — VehCalc.c (all 4 foundational math fns) — Match (VehCalc.c COMPLETE) +- **VehCalc_InterpBySpeed @0x80058f54** — ease `current` toward `target` by ≤maxStep, clamp at target: `current>target ? (current-maxStep < target ? target : current-maxStep) : (current+maxStep > target ? target : current+maxStep)`. decomp comma-operator `||` mangle ≡ project if/else. ✓ +- **VehCalc_MapToRange @0x80058f9c** — `val<=inMin→outMin; val>=inMax→outMax; else outMin + (val-inMin)*(outMax-outMin)/(inMax-inMin)` (CTR_MipsDiv SIGNED; traps=div/overflow guards). Foundational — called by dozens of verified fns. ✓ +- **VehCalc_SteerAccel @0x8005900c** — 3-seg steer ramp: `frames>(-rootBitIndex&0x1f) : result<<(rootBitIndex&0x1f)` (**sign check flips shift dir as rootBitIndex decrements past 0**); approx=shiftedResult+lastApprox+testBit; if(<=n){result+=addBit; lastApprox=approx}; addBit>>=1, testBit>>=2, rootBitIndex--. All unsigned. ✓ +No new bugs. **VehCalc.c fully verified** (MapToRange/FastSqrt primitives underpinning much of the physics/audio code confirmed correct). + +### Bug-class re-scan cont'd (2026-07-10) — GAMEPAD.c rumble fns (ShockFreq/ShockForce1/ShockForce2/JogCon1/JogCon2) — Match +- Common guards (all 5): AI (actionsFlagSet & ACTION_BOT=0x100000) → ret; ShockX also: pad-disabled `gameMode1 & (0x100<= 0x385` → ret. +- **GAMEPAD_ShockFreq @0x80026440** — priority `shockFrameFreq(data14+0) >= frame` → ret; else data14+0=frame, data14+0xc=val (shockValFreq). ✓ +- **GAMEPAD_ShockForce1 @0x800264c0** — priority `(byte)shockValForce1(data14+0x10) >= val` → ret; else data14+4=frame, data14+0x10=(char)val. ✓ +- **GAMEPAD_ShockForce2 @0x80026540** — priority `(byte)shockValForce2(data14+0x11) >= val` → ret; else data14+8=frame, data14+0x11=(char)val. ✓ +- **GAMEPAD_JogCon1 @0x800263a0** — nibble priority `(cRumbleStrength(unk45) & 0xf) > (val & 0xf)` → ret; else unk45=val, unk46/47 = timeMS lo/hi bytes. ✓ +- **GAMEPAD_JogCon2 @0x800263fc** — no priority: unk42(data14+0x12)=val, unk48(data4)=timeMS(s16). ✓ +No new bugs; rumble fns confirm the many ShockFreq/ShockForce1/JogCon1/JogCon2 call sites verified across Vehicle/EngineSound this run. (GAMEPAD.c remaining: Init/SetMainMode/ProcessState/PollVsync/GetNumConnected/ProcessHold/ProcessSticks/ProcessTapRelease/ProcessMotors/ProcessAnyoneVars.) + +### Bug-class re-scan cont'd (2026-07-10) — GAMEPAD_GetNumConnected — Match +- **GAMEPAD_GetNumConnected @0x800255b4** — enumerate connected controllers into gamepad[]. + - multitap detect: `slotBuffer[0].plugged==PLUGGED(0) && controllerData==PAD_ID_MULTITAP<<4(0x80)` → numSlots=1/numPortsPerSlot=4 (else 2/1). (PLUGGED field inverted: 0=connected.) + - **Plugged nested condition MATCH**: project `if(slot plugged){ if(multitap) ptr=&controllers[Port]; if(final plugged) record }` ≡ decomp `((controllerData!=0x80) || (multitap && slot-plugged)) && (final-packet plugged)` — non-multitap: slot-plugged (checked once vs project twice); multitap: ptr advanced to sub-packet (stride 8, iStride=2+port*8) + sub-packet plugged. slotBuffer[slot] via raw offset 0x2d0+slot*0x22-0x2a. + - record: bitwiseConnected |= 1<<(slot*4+port); numGamepadsConnected=idx+1; padCurr->ptrControllerPacket=pkt; gamepadID=slot*0x10+port. NULL gamepad[numConnected..7]. + - return: store bitwiseConnected to gamepadsConnectedByFlag (project always; decomp skips redundant store when ==old); oldFlags==-1 → 0; ==bitwise → 0; else disconnect-check `((bitwise ^ old) & old) != 0`. ✓ +No new bugs. (GAMEPAD.c remaining: Init/SetMainMode/ProcessState/PollVsync/ProcessHold/ProcessSticks/ProcessTapRelease/ProcessMotors/ProcessAnyoneVars.) + +### Bug-class re-scan cont'd (2026-07-10) — GAMEPAD_ProcessMotors [large] — Match (NOTE(claude) jogcon pulse CONFIRMED) +- **GAMEPAD_ProcessMotors @0x80025e18** — per-frame rumble driver. Fields: motorDesired=motorCurr[0/1], motorSubmit=motorPrev, motorPower=motorStepRate, unk44=cForcedRumbleCountdown, unk45=cRumbleStrength, unk46/47=cRumbleTimerMS(u16), unk48=data4(u16), unk42/43=data14[0x12/0x13]; shockFrameFreq/Force1/Force2=data14 ints@0/4/8, shockValFreq=data14@0xc, shockValForce1/2=data14[0x10/0x11]. + - In-race active gate: `gameMode1&0xf==0 && !boolDemoMode && packet!=0 && !RaceFlag_IsTransitioning()` (else decay). JOGCON=controllerData 0xe3 (PAD_ID_JOGCON<<4|3). + - JOGCON: unk44!=0→motorCurr[0]=0x40; else unk46(16b)!=0→motorCurr[0]=strength + timer-=elapsedTimeMS clamp<1; else derive `unk43>4 : unk43>>4`, |0x30. **NOTE(claude) CONFIRMED**: pulse `(timer & unk42 & 0xf)!=0 && (unk42-0x10)<0 → 0` uses RAW unk42 (decomp `timer & uScratch11(=unk42) & 0xf`), not >>4. **ND bug** `unk48=0` (should decrement) faithfully reproduced. + - DualShock: motorCurr[0]=(shockFrameFreq!=0 && (timer&shockValFreq)==0)?0xff:0; motorCurr[1]=shockFrameForce1!=0?shockValForce1:(shockFrameForce2!=0?shockValForce2:0) + clear valForce1/2. Decrement 3 frame counters. + - Decay: motorCurr[0]=(packet && jogcon && unk44!=0)?0x40:0; motorCurr[1]=0; clear shockFrame*(data14 0-0xb) + unk45/46. unk44!=0→--. + - Power sum (project inline in main loop ≡ decomp separate loop): `motorDesired[k]!=0 → totalPower += motorPower[k]`. Cap `totalPower>0x3c(60)`: round-robin from `timer%numPads` (wrap `i%numPads`), 1st loop disable motorDesired[1] (force), 2nd loop motorDesired[0] (freq), until <=60. motorSubmit/motorPrev = motorDesired/motorCurr. ✓ +No new bugs; ProcessMotors matches (NOTE(claude) raw-unk42 pulse validated; ND unk48 bug faithful). (GAMEPAD.c remaining: Init/SetMainMode/ProcessState/PollVsync/ProcessHold/ProcessSticks/ProcessTapRelease/ProcessAnyoneVars.) + +### Bug-class re-scan cont'd (2026-07-10) — GAMEPAD_ProcessTapRelease + ProcessAnyoneVars — Match +- **GAMEPAD_ProcessTapRelease @0x80025d10** — per-pad edge derivation. numConnected<=0 → ret 0. cVar1 = unkPadSetActAlign[6](=g_boolStickAsDpad@0x8009d03e). Per connected pad: packet==NULL → buttonsTapped/Released=0; else: if stickAsDpad: stickLX<0x20→|=BTN_LEFT(0x4), >0xe0→BTN_RIGHT(0x8); stickLY<0x20→BTN_UP(0x1), >0xe0(decomp `<0xe1` else)→BTN_DOWN(0x2); heldAny|=buttonsHeldCurrFrame; buttonsTapped=~prev&curr (rising), buttonsReleased=prev&~curr (falling). stickLX/LY effectively unsigned (0-255). return heldAny. ✓ +- **GAMEPAD_ProcessAnyoneVars @0x800262d0** — master input pipeline: ProcessHold→ProcessSticks→ProcessTapRelease→ProcessMotors(=ProcessForceFeedback); zero anyoneHeldCurr/Tapped/Released/HeldPrev; loop OR each connected pad's buttonsHeldCurrFrame/Tapped/Released/HeldPrev into them. Project returns `int heldAny = ProcessHold | ProcessTapRelease` (decomp void = Ghidra return-type miss). `#ifdef CTR_INTERNAL` debug-pad shim is native-only (not in binary). ✓ +No new bugs. + +### Bug-class re-scan cont'd (2026-07-10) — GAMEPAD_ProcessHold — Match (NOTE(claude) u32-raw-word CONFIRMED) +- **GAMEPAD_ProcessHold @0x80025718** — per-frame digital button processing (8 pads). Per pad: buttonsHeldPrevFrame=curr; packet==NULL→clear both; else plugged(isControllerConnected==0): rawWord = `CONCAT11(controllerInput1, controllerInput2) ^ 0xffff` (endian-flip active-low→high). NEGCON(madcatz): analog axes >0x40 → rawWord |= {rightY→0x40, leftX→0x80, leftY→4}. **ANALOG_STICK → rawWord <<= 0x10** — **NOTE(claude) CONFIRMED**: decomp `rawButtons`=uint, `<<0x10` needs full 32-bit; project `u32 uVar4` correct (u16 would truncate to 0, killing flightstick). Remap: walk g_aButtonRemapTable {rawMask,gameFlags} 8-byte stride 0-terminated, `rawWord & rawMask → padButtons |= gameFlags`. buttonsHeldCurrFrame=padButtons; heldAny|=; framesSinceLastInput (u16): ==0 held → `<65000 ? ++ : cap`; else =0. return heldAny. ✓ +No new bugs; ProcessHold matches (2nd NOTE(claude) confirmed this GAMEPAD pass, after ProcessMotors raw-unk42). Input pipeline verified: ProcessHold/TapRelease/Motors/AnyoneVars/GetNumConnected (ProcessSticks pending). + +### Bug-class re-scan cont'd (2026-07-10) — GAMEPAD_ProcessSticks [large, 7 inlined helpers] — Match (GAMEPAD input pipeline COMPLETE) +- **GAMEPAD_ProcessSticks @0x80025854** — per-frame analog processing (8 pads); helpers IsAnalogLike/StepTowardZero/Max/Center/ResolveAxis/ResetRaw(AndResolved)/CheckIdleAxis all inlined. + - packet==NULL → all 6 sticks(raw+resolved LX/LY)=0x80. Else plugged branch on controllerData: + - **ANALOG_STICK(0x53)/DUALSHOCK(0x73)**: copy leftX/rightX/rightY; **leftY 1-frame deglitch** (`raw leftY==0xff && unk_1(nPrevRawLeftY)!=0xff ? use prev : raw`; cache unk_1=raw). + - **NEGCON(0x23)**: i<4→rwd; stickLX_dontUse1=neGcon.twist(=analog.rightX union), others 0x80. + - **JOGCON(0xe3)**: i<4→rwd; jog_rot→steer: iVar7=`((|dir|-10)-rwd->deadZone(range))*8` clamp[0,0xff]→unk43(data14[0x13]); sSteer=jog_rot+0x80 clamp 0(dir<-0x80)/0xff(dir>0x7f). decomp `max(iTwist,0) then (0xff=0x31` → framesSinceLastInput=0. + - **Resolve LX/LY (digital-overrides-analog)**: IsAnalogLike = {ANALOG_STICK, DUALSHOCK, NEGCON, JOGCON}. ResolveAxis: held&neg(LEFT/UP)→StepTowardZero(ramp→0), held&pos(RIGHT/DOWN)→StepTowardMax(→0xff), useRaw(analog)→raw, else→StepTowardCenter(→0x80); all step 0xff/frame. decomp inverted branch bounds (`<1`=`<=0`, `<0x81`=`<0x81`) ≡ project. ✓ +No new bugs. **GAMEPAD input pipeline fully verified** (ProcessHold/Sticks/TapRelease/Motors/AnyoneVars/GetNumConnected; 2 NOTE(claude)s confirmed). (GAMEPAD.c remaining: Init/SetMainMode/ProcessState/PollVsync.) + +### Bug-class re-scan cont'd (2026-07-10) — GAMEPAD.c init/poll (Init/SetMainMode/ProcessState/PollVsync) — Match (GAMEPAD.c COMPLETE) +- **GAMEPAD_Init @0x800251ac** — PadInitMtap(&slotBuffer[0], &slotBuffer[1]); PadStartCom(); 8× {gamepadType=0, unk44(cForcedRumbleCountdown)=0}; gamepadsConnectedByFlag=0xffffffff. ✓ +- **GAMEPAD_SetMainMode @0x80025208** — 8× PadSetMainMode(socket,0,0) for {0,1,2,3,0x10,0x11,0x12,0x13}. ✓ +- **GAMEPAD_ProcessState @0x800252a0** — pad-setup state machine (decomp reorders switch as `==2 / <3(==1) / ==6`): cmd 1→gamepadType!=0?=1; cmd 2→motorPower[0/1](motorStepRate)=0; cmd 6→ gamepadType==0?PadSetMainMode(id,1,0)→1 : gamepadType==1?{PadInfoAct(id,-1,0) cap 2, per-actuator PadInfoAct(id,i,4)→motorPower[i] + zero-fill rest (project clear-then-set ≡ decomp set-then-clear), PadSetAct(id, motorSubmit(motorPrev), 2), PadSetActAlign(id, unkPadSetActAlign)→gamepadType=2}. ✓ +- **GAMEPAD_PollVsync @0x80025410** — **Ghidra DB MISLABELS as "GAMEPAD_GetNumConnected"; syms926 = PollVsync** (body = PadGetState+ProcessState loop). multitap detect → numPorts/maxPadsPerPort {2,1}/{1,4}; per port×slot: **unpluggedPort `(multitap && controllers[i].plugged!=PLUGGED) || (slot.plugged!=PLUGGED)`** ≡ decomp comma-op side-effect version (ptr advanced to sub-controller regardless of && result; all 3 cases resolve identically); unplugged→gamepadType=0 else PadGetState((port<<4)|i)+ProcessState. NULL remaining pads. ✓ +No new bugs. **GAMEPAD.c fully verified** (all 15 functions Match; 2 NOTE(claude)s [ProcessHold u32-raw-word, ProcessMotors raw-unk42-pulse] confirmed; DB name traps: PollVsync mislabeled GetNumConnected, rumble fns were GAMEPAD_Vib_N, ProcessState/Sticks renamed). + +### Bug-class re-scan cont'd (2026-07-10) — FLARE.c (FLARE_Init + FLARE_ThTick) — Match (NOTE(claude) grid-quad fix CONFIRMED) +- **FLARE_Init @0x80025138** — PROC_BirthWithObject(0xc030d, FLARE_ThTick, "lensflare", NULL) (size 0xc/pool 0x300/bucket 0xd); if th: flare=th->object, *flare=0 (frameCount), memcpy(&flare[1], pos, 8) = flare[1]=pos.xy, flare[2]=pos.z. ✓ +- **FLARE_ThTick @0x80024c4c** [large GTE] — timer=flare[0], flare[0]++, `timer>=0x14 → thread.flags|=THREAD_FLAG_DEAD(0x800)`; else render (prim guard `prim+4(=+0x34w) < guardEnd`). + - GTE proj: SetPsyqGeom(pushBuffer[0]=tileView[0]); SetLightMatrix(matrix_ViewProj); rel{X,Y,Z}=((s16)pos - camera.t)<<2 → VXY0/VZ0; llv0; mvlvtr(=CTC2(MFC2(25/26/27),5/6/7)). + - Size: 4-seg `MapToRange(timer, [0,2,4,8]/[2,4,8,0x14], newMin/newMax {0x400,0x2000,0xc00,0x266}/{0x2000,0xc00,0x266,0})`. Rotation: angle=`(flare[0]<<12)/20`; sin/cos=`MATH_Sin/Cos(angle)*scale>>12`; scaledCos/Sin=`(v<<9)/0xf0`; load R11R12(cos|-sin<<16)/R13R21(sin<<16)/R22R23(cos)/R31R32(0)/R33(scale). + - Icon ptrIcons[0x87] NULL→ret; texWord1={u1,v1,tpage}&0xff9fffff|0x200000. + - **NOTE(claude) 3-grid-row quad render CONFIRMED**: LoadGridRow(-409/0/409) (X=∓409, Y=-409/0/409 via ldVXY0/1/2 0xfe67/0/0x199) + rtpt + stsxy0/1/2→poly corners: top→{p0/p1 x0/x1}, middle→{p0/p1/p2/p3 x2/x3}, bottom→{p2/p3 x0/x1}; SXY0→x0/x2, SXY1→x1/x3 (both), SXY2→mirrored corner. WriteTexture(texUV0Clut/uVar3/texUV2/texUV3), WriteColors(0x7f7f7f gray + 0x3e000000 FT). Matches decomp poly-word offsets exactly (p0@0, p1@0xd, p2@0x1a, p3@0x27; stride 0x34). + - OT: depth=`(SZ2>>8)-2` clamp[0,0x3ff]; tags p0→p1→p2→p3→*ot chain (Ptr24 & 0xffffff | 0x0c000000); *ot=Ptr24(p0); primMem.cursor += 4 prims. ✓ +No new bugs; FLARE_ThTick matches (NOTE(claude) grid-quad slot assignment validated vs binary). **FLARE.c fully verified.** + +### Bug-class re-scan cont'd (2026-07-10) — RaceFlag.c state/transition fns (7) — Match (DB "TitleFlag_*" name traps) +- RaceFlag state @ g_aGpuDmaQueue[1].pInlineData: Position@0xb(s16), AnimationType@10, DrawOrder@0xd(s16), LoadingTextAnimFrame@0xe. +- **RaceFlag_MoveModels @0x80043e34** — sine ease-in-out 0..0x1000: frameIndex<0→0, >numFrames→0x1000; midpoint=numFrames/2 (decomp `(nf-(nf>>0x1f))*0x8000>>0x10` = signed /2); half=5000. ✓ +- **RaceFlag_IsTransitioning @0x80043f44** — `pos∉{0,-5000,5000} && (renderFlags&0x1000)`. ✓ +- **RaceFlag_BeginTransition @0x80043fb0** — dir==1: LoadingTextAnimFrame=-1, Position=5000, AnimType=0; dir==2: SetDrawOrder(0), Position=0, AnimType=2; always renderFlags|=0x1000. ✓ +- **RaceFlag_SetDrawOrder @0x80043f8c** — DrawOrder = drawOrder!=0 ? 1 : -1(0xffff). ✓ +- **RaceFlag_SetFullyOnScreen @0x8004402c** — Position=0, AnimType=0, LoadingTextAnimFrame=-1, renderFlags|=0x1000. ✓ +- **RaceFlag_SetFullyOffScreen @0x80044058** — Position=5000, AnimType=0, LoadingTextAnimFrame=-1, renderFlags&=~0x1000. ✓ +- (trivial by inspection: IsFullyOnScreen=pos==0, SetCanDraw/GetCanDraw.) +No new bugs. (RaceFlag.c remaining: DrawLoadingString @0x..., DrawSelf [both large draw fns].) + +### Bug-class re-scan cont'd (2026-07-10) — RaceFlag_ResetTextAnim + DrawLoadingString — Match (3 NOTE(claude)s CONFIRMED) +- **RaceFlag_ResetTextAnim @0x80044290** — LoadingTextAnimFrame(pInlineData[0xe])=-1. ✓ +- **RaceFlag_DrawLoadingString @0x800442a0** — animated scrolling "LOADING" string into swapchain UI OT. + - Redirect tileView_UI.ptrOT → ot_tileView_UI[swapchainIndex] (restore after). s=lngStrings[LNG_LOADING=0x231]; **NOTE(claude) strlen CONFIRMED**: decomp `strlen(s)` (measures localized str, not hardcoded 10). GetLineWidth. + - Transition(pInlineData[0xf]): idle(Loading.stage==-1) → `>-1000 ? -=0x28`; else =0. Centered X = `(Transition & 0xffff) - lineWidth/2` (decomp `((s16)lw - sign)>>1` = signed/2, ≡ project `>>1` for positive width). + - Per-char (animCounter=pInlineData[0xe]): iAnimBase=animFrame*-0x3c+0x23c; vert offset: `<0 → 0x23c`; `[0,4] → iAnimBase`; `[5,0x4a] → 0x100`; `[0x4b,0x4f] → (0x4b-counter)*0x3c+0x100`; `>0x4f → 0x23c`. + - **NOTE(claude) 2-byte-glyph CONFIRMED**: `bChar < 4` UNSIGNED (project u8 local_30; decomp lbu+sltiu) → len 2. **NOTE(claude) s16-X CONFIRMED**: draw X = `(s16)(iPosX+offset)` (decomp `*0x10000>>0x10`) — wrap slides text off left edge when Transition&0xffff negative. Draw if offset!=0x23c: DecalFont_DrawLineStrlen(&char, len, X, 0x6c, 1, 0). Advance X += GetLineWidthStrlen, animCounter -= 4/char. + - Restore ptrOT. animCounter<0x50 → animFrame += max(elapsedTimeMS>>5, 1); else animFrame=-1, `(u32)(Loading.stage-6)<2` (stage∈{6,7}) → animFrame=0. ✓ +No new bugs; **3 NOTE(claude)s confirmed** (strlen, u8-glyph-test, s16-X-truncation). (RaceFlag.c remaining: DrawSelf @0x800444e8 [large].) + +### Bug-class re-scan cont'd (2026-07-10) — RaceFlag_DrawSelf [GIANT ~2600B] — landmark Match (RaceFlag.c COMPLETE) +- **RaceFlag_DrawSelf @0x800444e8** — 36col×10row GTE checker-flag mesh w/ traveling sine ripple. DB mislabeled TitleFlag_DrawSelf (syms926 auth). Landmark-verified (setup + sign-sensitive helpers + state; grid-mesh body NOTE(aalhendi) line-by-line verified): + - Early-out CanDraw(pInlineData[0xc])==0; LoadingTextAnimFrame<0 → (stage∈{6,7}→=0); if still <0 skip DrawLoadingString; CopyLoadStage(pInlineData[0x11])=Loading.stage; GetOT. GTE: SetRot/TransMatrix(matrixTitleFlag), SetGeomOffset(0x100,0x78), ldH(0x100)(=SetGeomScreen). + - **MathSinInline (inlined)**: `t=trigApprox[phase&0x3ff]; if(!(phase&0x400)) t<<=0x10; t=(int)t>>0x10 (arith, extract low/high packed s16); if(phase&0x800) t=-t`. ✓ + - **CalculateBrightness (inlined)**: dark `(sine*-0x37+0x140000)>>0xd`, light `(sine*-0x7d+0x1fe000)>>0xd`; darkTile = `((column>>2)+(flatIdx>>2))&1`. ✓ + - checkerFlagVars[5]={phase0,amp1,phase2,amp3,timeAccum}: timeAccum += amp3*elapsedTimeMS; overflow(>>5>0xfff)→ &=0x1ffff, phase0+=0x200, amp1=`(sin(phase0)+0xfff)*0x20>>0xd+0x96`, phase2+=200, amp3=`(sin(phase2)+0xfff)*0x40>>0xd+0xb4`. Pre-loop: var2=`(sin(var1)+0xfff)*amp1>>0xd+0x280`, lightL=sin(var1+0xc80)+0xfff, pos.vy={0xfc72,0xfcd0,0xfd2e}, pos.vx=`-0xbbe - RaceFlag_Position`, time=RaceFlag_ElapsedTime>>5. + - Grid: per vert vz=`var2 + (sin(phase)+0xfff)*0x20>>0xd` (ripple), ldv3/rtpt, stsxy3 → posL/posR scratchpad ping-pong (toggle^1). POLY_G4 emit unless off-screen (clip vs 0x80008000 & 0xd80200 sign-bit AND-reduce); gouraud gray gradient (lightL edge, iLightR edge, c|c<<8|c<<16), G4 cmd 0x38 len 8, link OT. RaceFlag_ElapsedTime += elapsedTimeMS*100. + - **CTR_NATIVE**: host scratchpadBuf[] vs PSX 0x1f800000 (documented native adaptation). +No new bugs; DrawSelf landmark-matches (helpers/state/sign-math confirmed). **RaceFlag.c fully verified** (all fns Match; 3 NOTE(claude)s confirmed in DrawLoadingString; DB TitleFlag_* name traps). + +### Bug-class re-scan cont'd (2026-07-10) — DecalFont.c GetLineWidthStrlen + GetLineWidth — Match (NOTE(claude) unsigned-c CONFIRMED) +- **DecalFont_GetLineWidthStrlen @0x800223f4** — pixel width of first `len` chars. Per char: button glyph(@[^*=0x40/0x5b/0x5e/0x2a)→+buttonWidth, punc(:.= 0x3a/0x2e)→+(puncWidth-charWidth), then `c>2`→+charWidth. **Project separate-if+adjust ≡ decomp else-if combined** (button→charWidth+buttonWidth, punc→puncWidth, normal→charWidth) — project's "dont else-if" size opt. **NOTE(claude) CONFIRMED**: `c>2` UNSIGNED (project u8 c; ASM @0x8002248c `andi 0xff`+`sltiu 0x3`) — signed char would 0-width bytes>=0x80 that DrawLineStrlen renders. fontType idx = `(ft<<0x10)>>0xf`=ft*2 byte offset. Stop at NUL||len==0. BUILD guards: '~' escape excluded (>UsaRetail), `c>2` (not EurRetail's `c>3`). Minor: project accumulates int vs decomp s16 — equiv ((s16)low16 same; widths<0x8000). ✓ +- **DecalFont_GetLineWidth @0x800224d0** — `(s16)GetLineWidthStrlen(str, -1, fontType)`. ✓ +No new bugs; NOTE(claude) unsigned-char-width-test confirmed. (DecalFont.c remaining: DrawLineStrlen[large], DrawLine/OT, DrawMultiLineStrlen/DrawMultiLine.) + +### Bug-class re-scan cont'd (2026-07-10) — DecalFont_DrawLineStrlen [large, 926/UsaRetail path] — Match +- **DecalFont_DrawLineStrlen @0x800224fc** — core per-char glyph renderer. + - Justify: flags&0x8000(CENTER)→posX-=`(s16)width>>1`(/2); flags&0x4000(RIGHT)→posX-=width (via GetLineWidthStrlen). flags mask 0xfff (9260x7f` → iconID-=0x80, iconGroupID = (==4 ? 14 : 15); if `iconID < iconGroup[iconGroupID]->numIcons(@0x12)` → DecalHUD_DrawPolyGT4(icons[iconID]@+0x14, posX+pixWidthExtra, posY+pixHeightExtra, &primMem, tileView_UI.ptrOT, ptrColor[0..3], 0, scale). posX += charWidth. + - **CTR_NATIVE null-guard** `iconGroup[iconGroupID]!=0` = native-safety (retail boots all groups loaded). EurRetail (diacritics/numChars/Arrow2D) + Jpn (kana texLayout/racing-wheel) paths #if-excluded for 926. +No new bugs; 926 path fully matches. (DecalFont.c remaining: DrawLine/DrawLineOT[trivial wrappers], DrawMultiLineStrlen/DrawMultiLine.) + +### Bug-class re-scan cont'd (2026-07-10) — DecalFont.c DrawLine/DrawLineOT/DrawMultiLineStrlen/DrawMultiLine — Match (DecalFont.c COMPLETE) +- **DecalFont_DrawLine @0x80022878** — DrawLineStrlen(str, -1, (s16)posX, (s16)posY, fontType, (s16)flags). ✓ +- **DecalFont_DrawLineOT @0x800228c4** — save tileView_UI.ptrOT, set = ot, DrawLine, restore. ✓ +- **DecalFont_DrawMultiLineStrlen @0x80022930** — word-wrap engine. Per line: scan word-by-word (skip leading space, extend wordEnd to next space/'\r'/NUL), lineLen=GetLineWidthStrlen(str, wordEnd-str, ft); break line when `maxPixLen <= lineLen` or c=='\r' (advance lineStart=wordEnd, len=remaining). DrawLineStrlen(str, lineStart-str, posX, (s16)(posY+totalHeight), ft, flags); totalHeight += fontCharPixHeight[ft] (**BUILD>SepReview/926 path**, not old 903 8/0x11). End at NUL||len==0. return (s16)totalHeight. **926: no `flags&0x800` draw-skip (>UsaRetail only).** int-vs-s16 (width/len/height) sub-0x8000 equiv. ✓ +- **DecalFont_DrawMultiLine @0x80022b34** — `(s16)DrawMultiLineStrlen(str, -1, ...)`. ✓ +No new bugs. **DecalFont.c fully verified** (all 7 functions Match; 1 NOTE(claude) [unsigned char-width test] confirmed; 926/UsaRetail BUILD path throughout). + +### Bug-class re-scan cont'd (2026-07-10) — DecalHUD.c DrawPolyGT4 + DrawPolyFT4 (sprite primitives) — Match +- **DecalHUD_DrawPolyGT4 @0x80023054** — textured gouraud quad (workhorse behind DecalFont glyphs + HUD icons). `!icon`→ret (BUILD>SepReview). setInt32RGB4 (colors→pPrim[1](|code)/4/7/10) BEFORE addPolyGT4. Quad: width=u1-u0, height=v2-v0, rightX=`(u16)posX + FP_Mult(width,scale)`(=`(w*scale)>>0xc`), bottomY=`posY + FP_Mult(height,scale)`; XY0/1/2/3→pPrim[2/5/8/0xb] (X/Y (u16)-truncated in packed words). setIconUV→UV0/1/2/3 @pPrim[3/6/9/0xc] from texLayout. transparency==0→code 0x3c000000 opaque; else 0x3e000000 semi + ABR=(trans-1) in tpage (`&0xff9fffff | (trans-1)*0x200000`). addPolyGT4 `*pPrim=*ot|0xc000000; *ot=pPrim&0xffffff`; cursor += 0xd words. ✓ +- **DecalHUD_DrawPolyFT4 @0x80022db0** — flat shade-textured quad (single-color, no gouraud). Same geometry/transparency (code 0x2d opaque / 0x2f semi + shade-tex); POLY_FT4 = 0xa words, XY@pPrim[2/4/6/8], UV@[3/5/7/9], addPolyFT4 tag 0x9. **DECOMP-HIDDEN-ARGS**: Ghidra sig `DrawPolyFT4(icon)` only — posX/posY/primMem/ot/transparency/scale are dropped register args (project's 7-arg sig recovered earlier via disassemble_bytes, correct). ✓ +No new bugs. (DecalHUD.c remaining: DrawWeapon @0x80022ec4 [rotated], Arrow2D @0x80023190.) + +### Bug-class re-scan cont'd (2026-07-10) — DecalHUD.c DrawWeapon + Arrow2D (rotated primitives) — Match (DecalHUD.c COMPLETE) +- **DecalHUD_DrawWeapon @0x80022ec4** — flat shade-textured quad (POLY_FT4) with 4 fixed orientations by `rot`. `!icon`→ret (BUILD>SepReview). width=u1-u0, height=v2-v0; rightX/bottomY = pos + FP_Mult(dim,scale) `>>0xc`; sidewaysX/Y swap width/height extents. Traced ALL 4 rot cases vertex-by-vertex vs decomp: rot0 v(posX,posY)(rightX,posY)(posX,bottomY)(rightX,bottomY)→pPrim[2/4/6/8]; rot2 reversed→[8/6/4/2]; rot1/rot3 use sidewaysX/Y. All 16 vertex slots match. UV0/clut@[3], UV1/tpage@[5], UV2@[7 lo], u3v3@[9 lo]. transMode0→code 0x2d opaque; else 0x2f semi + `(trans-1)*0x200000` ABR. Tag 0x9000000, cursor += 0xa words (p+1, POLY_FT4=10 words). **(u16) casts on packed-X are documented port-side fix**: decomp shows plain `posX|uPosY` with NO andi mask (unlike DrawPolyGT4), so retail corrupts packed-Y on negative posX; native masks it. NOTE(claude) present & correct. ✓ +- **DecalHUD_Arrow2D @0x80023190** — rotated textured gouraud quad (POLY_GT4). `!icon`→ret. rot = 12-bit angle: low10 index `data.trigApprox` (int[]) for sin/cos, bits 0x400/0x800 pick octant/sign → basis vectors iBasisA/iBasisB. Traced all 6 branch paths of basis-selection (incl. both `goto` shortcuts + fall-through negations) — full match. Extents `iHalfW/iHalfH = (uvSpan*(s16)scale) >>0xd` (.13 fixed; **cf DrawWeapon's >>0xc**). 4-vertex rotation math (x0/x1/x2/x3 = pos + `-iHalfW`/`iHalfW+1`×basis + iRotBaseTop/Bot accumulators, `&0xffff` lo / `<<16` hi) matches term-for-term. **posY sign-vs-zero-extend NON-ISSUE**: project uses raw `s16 posY`, decomp `(u16)posY`, but accumulator feeds `<<16` (discards bits16+) → low-16 packed-Y bit-identical either way, equiv. Only color1 carries 0x3c/0x3e code byte; color2/3/4 raw @[4/7/10]. Tag 0xc000000, cursor += 0xd words. **`(s16)scale` casts** = documented bit-exact fix (retail `sll0x10;sra0x10` truncates the `int scale` param once); NOTE(claude) present & correct. ✓ +No new bugs. **DecalHUD.c fully verified** (all 6 fns: DrawWeapon/Arrow2D/DrawPolyGT4/DrawPolyFT4 + 2 earlier; 2 NOTE(claude) port-side fixes [(u16)-pack, (s16)scale] confirmed correct). + +### Bug-class re-scan cont'd (2026-07-10) — DecalMP.c DecalMP_01/02/03 (split-screen remote-kart mini-views) — Match (DecalMP.c COMPLETE) +- **Struct-alias confirmed**: decomp's "tileView[]" IS the project's `struct PushBuffer pushBuffer[4]` @gGT+0x168 (stride 0x110); no `tileView` field exists. So project `gGT->pushBuffer[cam]` reads the correct memory. DecalMPEntry (file-local, 0x128) = header fields to 0x18 + embedded `PushBuffer pb`@0x18; `entry->pb.cameraID`@pb+0x108, renderBucketScreenPos@pb+0x100, ...Size@pb+0x104, ...OTRangeEnd@pb+0xf8, ...OTByteOffset@pb+0xfc. +- **DecalMP_01 @0x80023488** — for each player viewport × each non-self non-NULL driver, sets `inst->flags |= PUSHBUFFER_EXISTS|PIXEL_LOD` (inst+0x28, applied to EVERY non-NULL driver before the self-skip — **NOTE(claude) fix confirmed**: decomp `pInst->flags |= ...`, not the idpp copy), copies camera matrix/pos/rect/ptrOT/cameraID into the entry, and camera-indexes the idpp link (`cam*0x88` — **NOTE(claude) fix confirmed**). BLASTED reset decodes: `data[0]=0xe8,data[1]=0x03` = timer 1000 LE, `pDecalWalk-6`=kartState=0. **Dead-guard note**: decomp's `iDecalCount` resets per-player, ranges 0..7, so `if(0xb 12-entry overflow is latent RETAIL behavior faithfully reproduced (guard is dead in retail too); NOT a port bug — leave as-is. ✓ +- **DecalMP_02 @0x80023640** — finalize pass. Per entry (until inst==NULL): if `idpp->instFlags & 0x140 == 0x140`, gate `timer<1000 && ((timer<=max(2,OTByteOffset>>3) && entry->lodIndex==idpp->lodIndex) || ((gGT->timer^index)&1)==0)` → draw-skip (`instFlags|=0x80`, boolUpdated=0) else draw (boolUpdated=1, OT-link renderBucketOTRangeEnd into cameraOT[0x3ff] if ptrOT&&rangeEnd). `timer++`; write back `entry->timer` only if not PAUSE_ALL. Else-branch writes timer=1000. `entry->timer` = decomp `pDecal->data` (s16@off0, per-entry not shared — decomp plate "sClipCounter shared" is misleading). ✓ exact. +- **DecalMP_03 @0x80023784** — composite pass. Grid-tiles entries (texX 0x1a0 += 0x60, wrap to 0x200 & texY += 0x40 past maxTexX[0x380/0x2c0]; texY base 0x100/0x180 for 2p). Per flagged entry: if boolUpdated, reset timer=0, cache renderW/H from renderBucketScreenSize lo/hi, lodIndex, SetDrawEnv at (texX,texY) w/ offsets `texX-x`,`texY-y`. Build POLY_FT4 (code 0x2d): 4 corners from `x/y/w/h = renderBucketScreenPos/Size` lo/hi (**NOTE(claude) quad-corner fix confirmed**: decomp reads pEntry+4..10 = pb+0x100..0x106, NOT pb.rect), UVs u0=texX&0x3f/v0=texY&0xff + renderW/H (v1 clamp 0xff), tpage packed from texX/texY bits, OT-link at `ptrOT + (OTByteOffset>>2)` tag 0x9000000, cursor += 10 words. ✓ term-for-term. +No new bugs. **DecalMP.c fully verified** (all 3 fns Match; 3 NOTE(claude) fixes [inst->flags target, idpp cam-index, quad-corner source] all confirmed against binary; pushBuffer/tileView alias + dead per-player guard resolved). + +### Bug-class re-scan cont'd (2026-07-10) — DebugFont.c DebugFont_Init/DrawNumbers — Match (DebugFont.c COMPLETE) +- **DebugFont_Init @0x800222e0** — caches `gGT->ptrIcons[0x42]->texLayout` {u0,v0,clut,tpage} into debug-font globals (project `sdata->debugFont.{u,v,clut,tpage}` = decomp `g_nDebugFontU/V`, `g_wDebugFontClut/Tpage`); no-op if icon NULL. u0/v0 unsigned bytes (no sign concern). ✓ +- **DebugFont_DrawNumbers @0x80022318** — one 7×7 POLY_FT4 digit quad (code 0x2e). 4 packed XY corners exact: x0/x1/x2/x3 = {posX,posX+7}&0xffff | {posY,posY+7}<<16. UVs topU=`u+(index+5)*7` (+7/col), topV=`v+7`, bottomV=`v+14` — all 4 corners match (packA/B/C multi-lifetime reuse resolved). clut@prim+0xe, tpage@prim+0x16, OT link `*ot|0x9000000`/`prim&0xffffff`, cursor +10 words. **NOTE(claude) X-mask CONFIRMED FAITHFUL** (contrast DecalHUD_DrawWeapon): decomp shows retail DOES mask X halves (`screenPosX & 0xffffU`, `screenPosX+7U & 0xffff`) → project reproduces retail, NOT a port-side add. Y unmasked (high half via `<<16`, sign-safe). `pushBuffer_UI.ptrOT` = decomp `tileView_UI.ptrOT` (same field, cosmetic name; consistent w/ DecalMP alias). ✓ +No new bugs. **DebugFont.c fully verified** (both fns Match; NOTE(claude) X-mask confirmed as faithful retail reproduction). + +### Bug-class re-scan cont'd (2026-07-10) — RECTMENU.c drawing-primitives half (8 of 22 fns) — Match +- **RECTMENU_DrawPolyGT4 @0x80044ef8** — null-guarded forward to DecalHUD_DrawPolyGT4 (all 11 params passthrough). ✓ +- **RECTMENU_DrawOuterRect_Edge @0x80044f90** — `flags&0x20 ? CTR_Box_DrawClearBox(mode 1) : CTR_Box_DrawSolidBox`. ✓ +- **RECTMENU_DrawTime @0x80044ff8** — sprintf `"%ld:%ld%ld:%ld%ld"` to ghostStrTrackTime(=g_szTimeString). Decomp's div-mul-sub forms == project's `%`: `ms/0x2580+(ms/0xe100)*-6`=`(ms/0x2580)%6` (0xe100=0x2580*6); seconds/tenths likewise; `(ms*10)/0x3c0 == ms/0x60` exactly (10/960=1/96); hundredths literal `((ms*100)/0x3c0)%10`. ✓ +- **RECTMENU_DrawRwdBlueRect_Subset @0x80045134** — POLY_G4 code 0x38, bounds vs endMin100(=guardEnd), 9-word stride. **NOTE(claude) X-mask CONFIRMED**: decomp x0=`*(uint*)pos` (raw combined word, no mask/arith) but x1/x2/x3 mask X (`&0xffff`/`(ushort)`) since they compute `pos[0]+pos[2]`; project matches. Tag 0x8000000. ✓ +- **RECTMENU_DrawRwdBlueRect @0x80045254** — multi-band vertical gradient. 4-byte overlapping-pair stride (`iBand*0x10000>>0xe`=iBand*4), terminate at yPercent 0x64('d'). Stack layout iColorTop/iColorTop2/iColorBot/iColorBot2 → color[]={top,top,bot,bot} = project's explicit colors[4]. Band Y: `rect->y+(s16)(u8_yPct*rect->h/100)`, height +1. ✓ +- **RECTMENU_DrawRwdTriangle @0x800453e8** — POLY_G4 0x24 bytes (setPolyG4 len 8/code 0x38). 4 verts w/ `y0=position[1]-1`; corner colors color[] byte-offsets {0,4,0,8} (vert2 reuses vert0). X/Y as separate s16 fields (unpacked → no mask). AddPrim. ✓ +- **RECTMENU_DrawOuterRect_LowLevel @0x80045534** — 4 edge bars. Left-bar h = `r->h - (s16)(((u32)yThick<<0x10)>>0xf)` = `h - 2*yThick` (term-exact); args xThick(s16)/yThick(u16)/flags(s16) aligned. ✓ +- **RECTMENU_DrawOuterRect_HighLevel @0x80045650** — forwards `LowLevel(r,3,2,rgb,flags,otMem)`. ✓ +No new bugs. **RECTMENU.c drawing-primitives half done** (8/22 Match; Subset X-mask NOTE confirmed). Remaining for next cycles: DrawQuip, DrawInnerRect, DrawFullRect, GetHeight/GetWidth, DrawSelf(recursive), ClearInput/CollectInput, ProcessInput(2 NOTEs), ProcessState(3 NOTEs), Show/Hide/BoolHidden. + +### Bug-class re-scan cont'd (2026-07-10) — RECTMENU.c layout helpers (5 fns) — **Fix 137** + 4 Match +- **RECTMENU_DrawQuip @0x8004568c** — **FIX 137 (signed-halving)**. Quip speech box: auto-width `GetLineWidth+0xc` if sizeX&0xffff==0; text via DecalFont_DrawLine at `startY + PlayerCommentBoxParams[fontType+4]`; box h=`PlayerCommentBoxParams[fontType]` via DrawInnerRect. **BUG**: centered-box `posX = startX - (sizeX>>1)` used UNSIGNED u32 shift; ASM 0x800456f8-0x80045700 `sll v1,a0,0x10; sra v0,v1,0x10; srl v1,v1,0x1f` (+addu,sra1) = retail truncates sizeX to s16 & halves SIGNED (round toward 0). Diverges for width bit15 set / bits>16. Latent (all quip widths small +ve) — fixed to `startX - ((s16)sizeX/2)` w/ NOTE(claude). Built clean. +- **RECTMENU_DrawInnerRect @0x800457b0** — flag dispatch: 0x10→UI2 border; 0x2 skip outer HighLevel border; 0x8 skip fill; inset (x+3,y+2,w-6,h-4) if border drawn; 0x1→SolidBox[0] else ClearBox (0x100? [1]/mode2 : [2]/mode0); 0x4 skip shadow, 0x80/0x40 shrink shadow W(4/0xc)/H(2/6). Shadow = right-edge then bottom-edge ClearBox[0] mode0. `(s16)(type|0x20)` truncation behaviorally-equiv (only 0x20 tested). ✓ +- **RECTMENU_DrawFullRect @0x800459ec** — title-separator edge (2px) if stringIndexTitle>=0 && !ONLY_DRAW_TITLE(0x4): color UI1/UI2 by drawStyle&0x10, y-offset by USE_SMALL_FONT(0x80)/BIG_TEXT_IN_TITLE(0x4000) via fontCharPixHeight[1/2] (small-font path is fresh `inner->y+9+h[1]`), w=inner->w-6, style drawStyle|0x20. Then DrawInnerRect(inner, drawStyle). ✓ +- **RECTMENU_GetHeight @0x80045b1c** — lineH = fontCharPixHeight[SMALL=2] or [BIG=1]+3. +lineH/visible row; or +lineH-6 (ONLY_DRAW_TITLE); or +lineH (SHOW_ONLY_HIGHLIT 0x40). Title: +lineH+6, or +[BIG]+9 (BIG_TEXT_IN_TITLE). Recurse if 0x10 && submenu&0xffff. Decomp's sHeight/sLineH temp-reassoc all reduce to project's `+=`. ✓ +- **RECTMENU_GetWidth @0x80045c50** — max over rows(&0x7fff)+title of GetLineWidth+1; font SMALL if state&0x80 else BIG (title forced BIG if 0x4000). Decomp compares `(s16)(lineWidth+1)` (equiv for real widths; store truncates anyway). Title `&0x7fff` mask omitted in decomp = no-op (branch only when stringIndexTitle>=0, bit15 clear). ✓ +No new bugs beyond Fix 137. RECTMENU.c now 13/22 verified. Remaining: DrawSelf(recursive), ClearInput/CollectInput, ProcessInput(2 NOTEs), ProcessState(3 NOTEs), Show/Hide/BoolHidden. + +### Bug-class re-scan cont'd (2026-07-10) — RECTMENU.c input helpers + ProcessInput (6 fns) — Match (2 NOTEs confirmed) +- **RECTMENU_ClearInput @0x80046404** — AnyPlayerTap/Hold=0, loop 4× zero buttonTapPerPlayer[i]/buttonHeldPerPlayer[i] (iByteOffset=i*4 via `<<0x10>>0xe`). ✓ +- **RECTMENU_CollectInput @0x80046458** — numListen = 4 if activeSubMenu!=0 && state&0x8000(ALL_PLAYERS_USE_MENU) else numPlyrNextGame (decomp comma-op `uNumPlayers=4,...` reduces to project's if). Copy gamepad[i] tapped/held, OR into AnyPlayerTap/Hold. Binary omits PC-port BTN_CROSS broadcast (project also omits). ✓ +- **RECTMENU_Show @0x80046990** — ClearInput; ptrActiveMenu=m; state&=~0x1000. ✓ +- **RECTMENU_Hide @0x800469c8** — state|=0x1000. ✓ +- **RECTMENU_BoolHidden @0x800469dc** — `state>>0xc & 1` == `(state&0x1000)!=0`. ✓ +- **RECTMENU_ProcessInput @0x80046534** — full match; **BOTH NOTE(claude) confirmed**. (1) activeSubMenu claim + first-touch ClearInput at decomp step 3 BEFORE btnTap load at step 4 → project's split (claim/clear block; `button` load; processing block) reproduces retail order; unconditional button load harmless (used only in guarded block). (2) select mask `btnTap&0x50` (CROSS_one 0x10|CIRCLE 0x40) & back mask `&0x40020` (TRIANGLE 0x40000|SQUARE_one 0x20) = ASM andi 0x50/ori 0x40020, NOT composite BTN_CROSS/SQUARE (raw 0x4000/0x8000). Project `for i<4` dpad loop reads consecutive bytes `(&rowOnPressUp)[i]` UP→DOWN→LEFT→RIGHT = decomp nested-if priority. Nav mask 0x4007f, locked-row `stringIndex&0x8000`, save-row-before-funcPtr order, `nProcCallPhase(unk1e)=0`, submenu recursion + ptrPrevBox link all match. Flattened big-`if` vs decomp nesting equiv (rowSelected=newRow self-assign no-op when no nav btn; ClearInput only when [3] true in both). rowSelected s16 → uNewRow(ushort)/newRow(signed char) store bit-identical. ✓ +No new bugs. RECTMENU.c now 19/22 verified. Remaining: DrawSelf(recursive), ProcessState(3 NOTEs). +(Correction: RECTMENU.c has **21** functions total, syms926 lines 660-680, not 22.) + +### Bug-class re-scan cont'd (2026-07-10) — RECTMENU.c ProcessState — Match (reload NOTEs confirmed via ASM) +- **RECTMENU_ProcessState @0x8004680c** — per-frame menu state machine. Full match; all NOTE(claude) reload points confirmed against fresh global reads. + - Desired-menu swap: save g_pDesiredMenu; dec framesRemaining if !=0; if desired!=0 → ptrActiveMenu=desired, ptrDesiredMenu=0, desired->state&=~NEEDS_TO_CLOSE(0x1000), hierarchy-walk `while(state&0x10) pMenu=next` (decomp caches `state`, project re-reads currMenu->state — **equiv** since the ~0x1000 clear doesn't touch bit 0x10), leaf->state&=~ONLY_DRAW_TITLE(0x4). + - funcPtr dispatch: if state&(EXECUTE_FUNCPTR 0x20|DISABLE_INPUT_ALLOW_FUNCPTRS 0x400) → nProcCallPhase(unk1e)=1, funcPtr(currMenu). **Reload #2** (NOTE @0x800468b0/d4): reload ptrActiveMenu+state after funcPtr (Show() mid-frame can redirect). + - Input/draw: if !(state&0x400) → ProcessInput; **reload #3** after; if !(state&INVISIBLE 0x2000) → GetWidth(&width=0,1), DrawSelf. **ASM-confirmed DrawSelf hidden-args** @0x800468fc-0x80046908: `clear a1`(posX=0), `lw a0,0x99c(gp)`(=_g_pActiveMenu fresh), `lh a3,0x10(sp)`(menuWidth=(s16)width), delay `move a2,a1`(posY=0) → `DrawSelf(menu,0,0,(int)width)` = project exactly (decomp showed 1-arg). + - **Reload #4** (NOTE @0x80046910/60, ASM `lw v0,0x99c(gp); lw v0,0x8(v0)`): fresh ptrActiveMenu+state before 0x800 & NEEDS_TO_CLOSE checks (DrawSelf funcPtrs Show/Hide). if !(state&0x800) → RaceFlag_SetCanDraw(1) if !GetCanDraw, renderFlags|=0x20. if state&NEEDS_TO_CLOSE → ptrActiveMenu=0. (gp+0x99c = ptrActiveMenu, state@+8.) ✓ +No new bugs. **RECTMENU.c now 20/21 verified** — only recursive DrawSelf @0x80045db0 remains (next cycle). + +### Bug-class re-scan cont'd (2026-07-10) — RECTMENU.c DrawSelf — Match (RECTMENU.c COMPLETE 21/21) +- **RECTMENU_DrawSelf @0x80045db0** — recursive menu layout renderer. Full match vs decomp + ASM. + - Init: baseColorFlags 0x1d if drawStyle&0x10; refresh funcPtr if state&0x60000==0x60000 (nProcCallPhase=2). Font/heights: fontSize 1(BIG)/2(SMALL) by state&0x80; titleTopPad 2/0; rowHeight/titleHeight from fontCharPixHeight[1]+3 or [2] (BIG_TEXT_IN_TITLE 0x4000 goto). GetHeight→totalHeight; width/height store; state&=~0x8. + - Centering: vertCenterY=`-height/2`, horizCenterX=`-(s16)width/2` — both SIGNED /2 toward-zero (`(x-(x>>31))>>1` idiom). decomp re-reads state between the 2 centering checks but only bit 0/1 tested (0x8-clear irrelevant) → equiv. + - Title/rows: title guard `stringIndexTitle>=0 && !ONLY_DRAW_TITLE`; centered(state&0x200)→X+=`width/2` +flag 0x8000; row loop skip-unless-selected `(state&0x44)==0 || rowIndex==rowSelected`; locked-row 0x8000→flags 0x17 else baseColorFlags; `lngStrings[idx&0x7fff]`. posX_prev zero-vs-sign-extend absorbed by `(s16)` truncation. + - **DECOMP-DROPPED-STORE (ASM-recovered)**: decomp highlight block omits `.w`; **ASM 0x8004622c `sh s7,0x1c(sp)` (s7=menuWidth) CONFIRMS `highlightRect.w = menuWidth`** — project's `background.w=menuWidth` faithful, decompiler elided the store. Full highlight ASM trace: `.y` = base + rowSelected*rowHeight + titleTopPad-1 (or +titleTopPad-1 if SHOW_ONLY_HIGHLIT 0x40), `.h` = rowHeight+(small?1:-3), `.y += titleHeight+6` if titled; guard `!(state&0x104)`; rgb Normal/Green by drawStyle&0x10; DrawClearBox `(&rect,rgb,1,uiOT,&primMem)`. + - **Recursion (ASM-confirmed hidden-args)** @0x8004630c: `DrawSelf(ptrNextBox, (s16)(posX+posX_prev), (s16)(baseY+vertCenterY+posY_prev+rowHeight+0xc), menuWidth)` = project exactly. + - Border: `borders.h=(totalHeight+8)-(state.byte0>>7)` [−1 if USE_SMALL_FONT] or `rowHeight+8` (ONLY_DRAW_TITLE); w=menuWidth+0xc; y/x offsets by vertCenterY/horizCenterX/posX_prev; DrawFullRect. ✓ +No new bugs. **RECTMENU.c FULLY VERIFIED (21/21)** across ~4 cycles: 1 fix (137 DrawQuip signed-halving) + 3 NOTE(claude) confirmations (Subset X-mask, ProcessInput ×2, ProcessState reloads) + DrawSelf dropped-.w-store recovered via ASM. + +### Bug-class re-scan cont'd (2026-07-10) — CTR/CTR_Box.c (4 fns) — Match (CTR_Box.c COMPLETE) +- **Macro layer verified**: `GetPrimitiveMem` (game/prim.c) sets `tag.size=(primSize-4)/4` = decomp's explicit len bytes (LineF2 0x10→3, LineF3 0x18→5, TPage_PolyF4 0x20→7, PolyF4 0x18→5); uses global `backBuffer->primMem` (= the `&backBuffer->primMem` retail callers pass). `AddPrimitive` = OT addr-swap. +- **CTR_Box_DrawWirePrims @0x80021500** — LINE_F2 code 0x40 (PrimCode.line renderCode=Line 2<<5), colorCode r/g/b, 2 verts (p1/p2), stride 0x10. GetPrimMem (no primMem arg — folded to global). ✓ +- **CTR_Box_DrawWireBox @0x80021594** — 2× LINE_F3 (code 0x48=Line|polyline bit3, end=0x55555555, len 5): prim1 top+right edges (v0=TL,v1=TR,v2=BR), prim2 left+bottom (v0=TL,v1=BL,v2=BR). Guard `p>guardEnd→return` = negation of decomp `p<=endMin100→alloc`. Explicit primMem arg + explicit tag.size (redundant w/ ... uses cursor directly not GetPrimMem). ✓ +- **CTR_Box_DrawClearBox @0x8002177c** — POLY_F4 code 0x2A (Polygon 1<<5|quad 0x8|semiTrans 0x2), PolyF4.tag=0, len 7. tpage `(trans<<5)|0xE1000A00` — **NOTE(claude) bit-11 y_VRAM_EXP (0x800 in 0xA00) CONFIRMED** in decomp. 4 verts TL/TR/BL/BR. `#ifdef CTR_NATIVE drawDisplayArea=1` (bit10) = documented native-only add (retail=0). ✓ +- **CTR_Box_DrawSolidBox @0x80021894** — POLY_F4 code 0x28 (Polygon|quad), len 5. Decomp uses COALESCED packed-word vertex writes (`*(uint*)r`, `(x+w)&0xffff|y<<16`) vs project's separate s16 fields = byte-identical. ✓ +- **Signature adaptations (faithful)**: Color passed BY POINTER in retail SolidBox/ClearBox (`*rgb`) vs BY VALUE in project (callers deref, net bytes identical); primMem folded to GetPrimMem global for WirePrims/ClearBox/SolidBox, explicit for WireBox. +No new bugs. **CTR_Box.c fully verified (4/4)**; NOTE(claude) tpage-bit11 confirmed, native drawDisplayArea + color-by-value adaptations documented-faithful. + +### Bug-class re-scan cont'd (2026-07-10) — GhostReplay.c (3 fns) — **Fix 138** + Match (GhostReplay.c COMPLETE) +- **GhostReplay_ThTick @0x80026ed8** — **FIX 138 (reserves unconditional-decrement)**. Huge tick fn; fully traced vs decomp + ASM. **BUG**: `reserves` decrement was wrapped `if (d->reserves > 0)` by a recent polish refactor; ASM 0x8002700c-0x8002702c `lhu v0,0x3e2(s5); lhu v1,0x1d04(a0); subu; sh; sll 0x10; bgez; sh zero` shows retail decrements UNCONDITIONALLY then clamps to 0 on negative low-16. Identical for reserves∈[0,0x7fff] (reserves==0 clamps back to 0) but diverges if reserves<0 as s16. Removed guard → `d->reserves -= elapsed; if(<0) =0;`. Built clean. + - **NOTE(claude) anim-frame CONFIRMED**: decomp arms (LAB_80027658/74/84) = project exactly: `buffer[2]!=0 && =maxFrame`→maxFrame; `==0 && maxFrame>0`→0; `==0 && maxFrame<=0`→maxFrame. (Frame-0 rewinds, was swapped.) + - Rest all Match: pos decode `(s16)((BE16<<16)>>0xd)` (sign*8), velocity `+=(s8)b*8`, BE16 time deltas (timeInPacket32==backup invariant → `+=` equiv), interp `(timeInRace-t01)*nPk*0x1000/tBetween`, 12-bit rot lerp Ghost_LerpRot12 (rot.x=decomp .time), numPacketsInArray `((pkt-&packets[0])>>4)-1`, all 5 event opcodes (0x80 pos +11/pID++, 0x81 anim +3, 0x82 boost VehFire_Increment BE16 args +6, 0x83 split +2, 0x84 idle +1/pID++, velocity +5/pID++), end-of-tape BOTS_Driver_Convert+ThTick_Drive. Dead `driver==0` guard in retail (ASM `beq s5,zero`) omitted by project (driver already deref'd) — equiv. +- **GhostReplay_Init1 @0x80027838** — TT gate `gameMode1&0x20022000==0x20000`; record buf 0x3e00; 2 tapes (slot0=g_pGhostTapePlaying, slot1=ST1 NTropy[5]/Oxide[6] by TT_NTROPY_BEATEN 2); constDEADC0ED=0xDEADC0ED(-0x21523F13); ptrEnd=recordBuffer+gh->size (decomp `[0x18c].emptyPadding+(size-0x14)` = same addr); 2 thread births (pid 0x40102, flags 0x1000; wake 0x90=HIDE|ANIM_LOOP; depthBias 0xc; 0x4000000 gate; STATIC_WAKE=0x43). ✓ +- **GhostReplay_Init2 @0x80027b88** — threadBuckets[2] walk; slot-enable (slot0 boolPlayGhost, slot1 TT_NTROPY_OPEN 1); tape reset; charIndex=ghostID+1 or +2 (NTropy-beaten, decomp `<<0x10>>0xf`=short-idx); wheelSize 0xccc unless Oxide 0xf; INSTANCE_Birth name ghost0/ghost1; ThTick; interp seed unk2=unk1/unk4=unk3. ✓ +No new bugs beyond Fix 138. **GhostReplay.c fully verified (3/3)**; anim-frame NOTE confirmed. + +### Bug-class re-scan cont'd (2026-07-10) — INSTANCE.c (9 fns) — Match (INSTANCE.c COMPLETE) +- **INSTANCE_Birth @0x80030778** — name copy 15 chars + null@[15] (`#ifdef CTR_NATIVE` null-name→zero all 16 = documented adaptation, retail reads PS1 null-space); defaults (depthBiasNormal 0xfe=-2, depthBiasSecondary 0xc, scale 0x1000, reflectionRGBA 0x7f7f7f, unk53=1); per-player idpp init (mh=0, pushBuffer=&pushBuffer[i], instFlags=0). ✓ +- **INSTANCE_Birth3D @0x8003086c** — JitPool_Add + Birth flags 0xf (DRAW_COLLISION_MASK). ✓ +- **INSTANCE_Birth2D @0x800308e4** — flags 0x40f; idpp[0].pushBuffer=&pushBuffer_UI, idpp[1..].pushBuffer=0. `#ifdef CTR_NATIVE else return inst` = documented NOTE(claude) (retail writes idpp even on NULL alloc). ✓ +- **INSTANCE_BirthWithThread @0x800309a4** — modelPtr[id] null→NULL; objSize align-up-4 (`(objSize-((objSize&3)-4))`=`(objSize&0xfffc)+4`); pool|size<<16|bucket; Birth3D + link. ✓ +- **INSTANCE_BirthWithThread_Stack @0x80030a50** — 7-arg array unpack. ✓ +- **INSTANCE_Death @0x80030aa8** — JitPool_Remove. ✓ +- **INSTANCE_GetNumAnimFrames @0x80030f58** — model/headers/ptrAnimations/animIndex0` + unchecked `meta->LInB` vs decomp `meta!=0 && pLInB!=0` — resolved via COLL_LevModelMeta @0x8001d094 which NEVER returns NULL (id<0xe2→entry[id], else entry[0]); entry0=NO_FUNC(pLInB NULL) covers id<=0 & id>=0xe2, so retail skips exactly where `(s16)id>0` skips; faithful. **NOTE(claude) two-sequential-filter CONFIRMED**: Filter A (TT/relic, `LAB_80030d9c` clear) always falls through `LAB_80030dac` into Filter B (crystal-chal); relic time-crate path only `timeCratesInLEV++` (no flag clear). Filter B (crystal/TNT/nitro/fruit) + C-T-R letters (id-0x93<3) all match. ✓ +- **INSTANCE_LevDelayedLInBs @0x80030ed4** — per-InstDef COLL_LevModelMeta(model->id)→if pLInB call on ptrInstance; stride 0x40. ✓ +No new bugs. **INSTANCE.c fully verified (9/9)**; NOTE(claude) two-filter confirmed, LInB-guard equivalence proven via COLL_LevModelMeta, 2 native adaptations (null-name, Birth2D null-guard) documented-faithful. + +### Bug-class re-scan cont'd (2026-07-10) — UI/UI_Meter.c (3 fns) — Match (UI_Meter.c COMPLETE) +- **UI_JumpMeter_Update @0x80051c64** — grounded/airborne split (actionsFlagSet&0x80000). Grounded+was-grounded: jumpMeterTimer decay UNCONDITIONAL (`-=elapsed; if<0 =0`, low-16 sub — no reserves-style guard, matches). Just-landed: `if(0x150=0x280/0x3c0/0x5a0 (decomp nested `<0x5a0/<0x3c0/>0x27f`). Airborne: landing voiceline (7) if 0x480>4`=/0x60 since rem>=0], hundredths), digit box 0x22×10, bar box 0xc×0x26, 3 POLY_F4 quads (white 0x28ffffff frame, tiered fill red/green/yellow/blue by jumpMeter<0x280/0x3c0/0x5a0, gray 0x28808080 bg). Height `posY-(jumpMeter*0x26/0x960)` (decomp magic-num `*0x1b4e81b5>>0x28`). **NOTE(claude) posX+0xe(14) right-edge CONFIRMED**. **Wire-box color resolved**: project `data.colors[21][0]` = read_memory @0x80081C90 = 0x00000000 (whole row 0) = black = decomp `memset(0,4)`; faithful. ✓ +- **UI_DrawSlideMeter @0x80052250** — barWidth 0x31, barHeight 3(>2P)/7; meterLength=`0x31-(roomLeft*0x31)/(maxRoom<<5)` (ELAPSED_MS 32=`<<5`); black wire box; 2 POLY_F4: fill green 0x2800ff00 if `(lowRoomWarning<<5)>8` (decomp `iMinAngle + SacredFire >>8`, not per-shift). 2 regimes: speed>scale→maxAngle 0x700/minAngle 0x980/raised minScale; else 0x980/0xd90. Scales `*0x1a5e0(108000)/64000`; VehCalc_MapToRange; angle2=angle1+0x400. FP macros reconcile: `FP_INT(x*6)`=`(x*3)>>11`, `FP_INT(x*8)`=`(x<<2)>>11`, `FP_INT(x*60)`=`(x*0x1e)>>11`. **NOTE(claude) `+0x1ff then >>9` CONFIRMED** (retail `if(iTrig<0) iTrig+=0x1ff; sra 9` signed-div, not C div). Colors front 0x5b5b/0x2b32/0xbbff, back white/0x699c/0xffff; code 0x30 gouraud; tag 0x6000000; 7-word stride. ✓ +- **UI_DrawSpeedBG @0x800516ac** — dial background. **Vertex-table RESOLVED**: `Point speedometerBG_vertData[2][14]` → `[0]`=radials (=binary g_aSpeedometerBG_VertData @8008646c), `[1]`=ticks (=g_aSpeedometerBG_TickVertData @800864a4, +0x38=14 Points; confirmed via read_memory 28-Point contiguous). NOT a 1-Point offset. Pass1: 7 tick lines TickVertData[2k]→[2k+1] white + black(+1,+1). Pass2: 6 radial segs (white/black wire verticals pt0-pt2/pt1-pt3 + gouraud PolyG4 code 0x38, tag 0x8000000). **NOTE(claude) band-chain CONFIRMED**: table {G,G,G,Y,R,R,R}, colorIndex/colorIndex+1 → GG,GG,GY,YR,RR,RR (transition at 3rd seg). Colors G=0xb500, **Y=0xd1ff** (r0xff g0xd1, CONFIRMED), R=0xdb. Pass3: 6 semi-trans TPage_PolyG3 (setPolyG3 code 0x32 semiTrans, black, **texpage 0xE1000A00 dither+y_VRAM_EXP CONFIRMED**, v0=vert[i+1], v1=vert[i+3], v2=(vert[13].x, vert[1].y)). `#ifdef CTR_NATIVE drawDisplayArea` documented. ✓ +No new bugs. **UI_Speedometer.c fully verified (2/2)**; 4 NOTE(claude) confirmed (sum-first, +0x1ff>>9, band-chain, texpage), 2D vertex-table structure matches binary's two contiguous tables. + +### Bug-class re-scan cont'd (2026-07-10) — UI/UI_VsQuip.c (5 fns) — Match (UI_VsQuip.c COMPLETE) +- **UI_VsQuipReadDriver @0x80054a08** — size 2→`(s16)`sext, 1→`(u8)`zext, 4→`(u32)`, else 0; addr `funcPtrs+offset-0x54`=`(char*)driver+offset` (funcPtrs@0x54). ✓ +- **UI_VsQuipAssign @0x80054a78** — gate (driver && (flags&4==0 || driver==leader)); weight max-scan (2-if `<`/`==` = decomp `||`, same RNG short-circuit); old-quip keep-unless-strictly-higher (RNG tiebreak at equal), un-negate old; claim by negating weight; store; timer BATTLE?150(0x96):300. ✓ +- **UI_VsQuipAssignAll @0x80054bfc** — rule engine, fully traced. Rule table race@quipData+0x170 (39×0x18) / battle@+0x730 (12); PSX-ptr reloc (UI_VsQuipPtrFromPsx) = decomp direct ptrs. Aging +1/line. Leader-detect: decomp comma-conditional = project `score >/==/< ` (all 3 cases verified: >→leader=driver/2nd=oldBest, ==→leader=NULL/2nd=best, <→no change). All 9 switch cases match term-for-term: 0(strict-max>thr), 1(≥0 & ≥thr), 3(biggest-rival walk numTimesAttackedByPlayer[i], sets rivalAttackerIndex=characterID), 4/5(min-side band, share LAB_80054fd0, 5 inits bestValue 0x7fffffff), 6(==numTimesAttacking), 7(thr0→leader/thr1→tied), 8(==thr), 9(default-assign if no quip). `accumulator` scratch reuse in 0/6/8 discarded via accumulatorNext. flags&0xc mid-loop assign + final assign. ✓ +- **UI_VsQuipDrawAll @0x800550f4** — thread loop, boolPressX&2 skip, conjoined sprintf, startX=`rect.x+rect.w>>1`, startY=`rect.y+rect.h>>3`, DrawQuip(...,0,3,0x8000,4) (project 0xffff8000=sext). **NOTE(claude) sprintf-to-stack CONFIRMED**: decomp `acStack_a0[128]`=project `conjoined[128]`. ✓ +- **UI_VsWaitForPressX @0x800552a4** — per-viewport press-X. D-pad toggle&1 (LEFT|RIGHT 0xc); Cross/Start toggle&2 (0x1010, timer<0x78); header lngStrings[0x157+(px&1)]; per-player "pN:NN" (numTimesAttackingPlayer[j]@0x50c / numTimesAttackedByPlayer[j]@0x560, team-color); done→ClearBox + Square(0x8000) reopen; all-done→timer=0, clear 4 boolPressX bytes. s_battleStatsPos3P4P static-const = decomp stack-init (0x350055={0x55,0x35}...). auStack_58 statText/clearColor coalesced (mutually-exclusive branches) = project separate. ✓ +No new bugs. **UI_VsQuip.c fully verified (5/5)**; NOTE(claude) sprintf-stack confirmed; big 9-case rule engine (AssignAll) fully traced. + +### Bug-class re-scan cont'd (2026-07-10) — UI/UI_Weapon.c (2 fns) — Match (UI_Weapon.c COMPLETE) +- **UI_Weapon_DrawSelf @0x800507e0** — held-weapon HUD icon. itemID 0xf→ret, 0x10→roulette, else normal (decomp checks 0x10 first then 0xf inside; project checks 0xf first — equiv). Normal: iconID=itemID+5; **NOTE(claude) qty digit UNCONDITIONAL CONFIRMED** (`g_szWeaponQty=numHeldItems+'0'` before mask/juiced/flicker). Mask(itemID 7): project bitmask `0xc9`={0,3,6,7} = decomp `!=CRASH/COCO/POLAR/PURA`→Uka 0x32 (villains). Juiced(numWumpas>=10 & itemID∈{3,4,6})→itemID+0x11. Flicker (noItemTimer && timer&1==0)→ret. qty!=0→DrawLine(FONT_SMALL,4). Roulette: rand()%0xc(norm)/%0xe(battle), remap 5→0/8→1/9→3; Lerp2D_HUD from PickupTimeboxHUD if cooldown; iconID=itemID+5. **DrawWeapon 8-arg ASM-CONFIRMED** @0x80050ad0: `(ptrIcons[iconId], posX, posY, &primMem, pushBuffer_UI.ptrOT[gGT+0x147c], TRANS_50_DECAL=1, (s16)scale, rot=1)`. ✓ +- **UI_Weapon_DrawBG @0x80050af8** — roulette shine BG. juicedUpCooldown-- if !=0; wumpaShineTheta+=0x100; scaleY=`(scale*0xd000)>>0x10` (project arith-`>>` vs decomp logical, equiv for scale∈[0,0x7fff] since product<0x80000000); 2× UI_WeaponBG_DrawShine(ptrIcons[0x31], posX, posY, &primMem, tileView[driverID].ptrOT, layer 2/3, scale, scaleY, 0xff0000). ✓ +No new bugs. **UI_Weapon.c fully verified (2/2)**; NOTE(claude) unconditional-qty confirmed, DrawWeapon 8-arg hidden-args ASM-confirmed (TRANS_50_DECAL=1). + +### Bug-class re-scan cont'd (2026-07-10) — UI/UI_Instance.c (2 fns) — Match (UI_Instance.c COMPLETE) +- **UI_INSTANCE_BirthWithThread @0x8004cae8** — per-driver 3D HUD-object spawn. modelPtr[id] null→NULL; loop threadBuckets[0]: PROC_BirthWithObject(0x380310) + INSTANCE_Birth2D. Model-id dispatch (project 2 if-chains: BIG1/FRUITDISP/GEM/CRYSTAL/RELIC/KEY then separate C-T-R/TOKEN; decomp 1 else-chain — **behaviorally identical**, ids mutually exclusive). lightDir signed: A=(0xf368/-0xc98,0x99f,0x232), B(CRYSTAL)=(0xf4a0/-0xb60,0xb60,0xfd28/-0x2d8). Colors GEM 0x6c08080/CRYSTAL 0xd22fff0/RELIC 0x60a5ff0/KEY 0xdca6000/CTR 0xffc8000/TOKEN=cupColor<<0x14|<<0xc|<<4. C-T-R vel[0]=-4/0/+4 (=(id-STATIC_T)*4). Pos: no-pushBuffer→hudLayout[hudElem] UI_ConvertX_2/Y_2; else idpp[0].pushBuffer=param, flags|PUSHBUFFER_EXISTS, (0,0,0x200). scale@+6, depthBias 0x80/0x80, faceCamera→ratan2(t1,t2) tilt, rot[3]=0x1000, layout stride 0xa0. ✓ +- **UI_INSTANCE_InitAll @0x8004cec4** — mode-dispatched HUD init. menuReadyToPass&=~1, renderFlags|=0x8000. CRYSTAL_CHALLENGE(0x8000000): 2 crystals+token (hidden). ADVENTURE_ARENA(0x100000): relic/key/trophy + GAMEPROG_AdvPercent. RELIC|ARENA|TT(0x4120000): rankIconsCurr[i]=drivers[i]->driverRank (**CTR_NATIVE null-guard→0 documented**), transition timer 5 if MP; if RELIC_RACE: relic(scale 0)+timebox; **relic-tier reward bits CONFIRMED** platinum(ADV_REWARD_FIRST_PLATINUM_RELIC=0x3a) && gold(0x28) not earned → sapphire(0x16) bit else 2 (= decomp CREDITS_FAKE_CRASH/ADVENTURE_GARAGE/THE_NORTH_BOWL); relicTime=RelicTime[levelID*3+type]. **NOTE(claude) relic-time digits CONFIRMED**: 1min=/0xe100, 10sec=(/0x2580)%6, 1sec=(/0x3c0)%10, tenths=(/0x60)%10, hundredths=((*100)/0x3c0)%10 (= RECTMENU_DrawTime). Normal race: copy pushBuffer_UI→DecalMP (if MP), fruitDisp; big1 (1-2P non-battle); ADVENTURE_MODE(0x80000): 3 CTR letters + token, hide all (**CTR_NATIVE null-guards documented** — retail writes flags through null in Garage). ✓ +No new bugs. **UI_Instance.c fully verified (2/2)**; NOTE(claude) relic-time digits confirmed, reward-bit constants (0x3a/0x28/0x16) verified, 3 CTR_NATIVE null-guards documented. + +### Bug-class re-scan cont'd (2026-07-10) — UI/UI_DrawNum.c (6 fns) — Match (UI_DrawNum.c COMPLETE) +- **UI_DrawNumWumpa @0x80050c20** — numWumpas@0x30. 1P/2P(<3): `x`glyph(g_szHudMultiplier) + sprintf(g_szFormatInt "%d") FONT_BIG. 3P/4P: 2 icon digits via iconGroup[5]->icons[] (tens=`(numWumpas/10)*0x1000000>>0x18`=(s8), ones=numWumpas-tens*10) at posX/posX+0xc, DrawPolyGT4 ORANGE palette(ptrColor[0]), trans 0, scale FP(1.0)=0x1000. ✓ +- **UI_DrawNumTimebox @0x80050e6c** — `x`(posX+0x14,posY-10) + sprintf("%2.02d/%ld", numTimeCrates@0x32, timeCratesInLEV) FONT_BIG(posX+0x21,posY-0xe). ✓ +- **UI_DrawNumRelic @0x80050f18** — `x`(posX,posY+4) + `%ld`(g_szFormatLong) count=currAdvProfile.numRelics, -1 if gameMode2&INC_RELIC(0x1000000). ✓ +- **UI_DrawNumKey @0x80050fc4** — numKeys, INC_KEY 0x2000000. ✓ +- **UI_DrawNumTrophy @0x80051070** — numTrophies, INC_TROPHY 0x4000000. ✓ +- **UI_DrawNumCrystal @0x8005111c** — numCrystals@0x31, "%2.02d/%ld" numCrystalsInLEV. ✓ +No new bugs. **UI_DrawNum.c fully verified (6/6)**; straightforward HUD counters, no NOTE(claude) fixes. + +### Bug-class re-scan cont'd (2026-07-10) — UI/UI_Map.c (7 fns) — Match (UI_Map.c COMPLETE) +- **UI_Map_DrawMap @0x8004d614** — 2 stacked POLY_FT4 (top+bottom), _ExtraFunc inlined ×2. colorMode dispatch: NORMAL 0x808080, WHITE(1) 0x808080+blend01, BLACK(2)→0, BLUE(3)→0x402000 (BLACK/BLUE reset colorMode→0=transparency). **NOTE(claude) count-test CONFIRMED**: decomp `pMapSpawn->count != 0` (not ptr); project keeps `ptrSpawnType1 != 0` as native low-RAM guard. Top drawn if (mapSpawn+0x12==0) || MAIN_MENU; UVs/clut/tpage from icon texLayout, code 0x2c|2, tag 9, curr+=0x28. ✓ +- **UI_Map_GetIconPos @0x8004d8b4** — 4 rot modes (0/90/180/270): add = coord*iconSize/worldRange (Y uses *2), iconStart bias, iconStartY-0x10. `-(A)/B == -(A/B)` (C trunc negation commutes) so project addX/addY == decomp folded iMapX/iMapY. 3P nudge (-0x3c x, +10 y). Traps = omitted div-0 guards. ✓ +- **UI_Map_DrawAdvPlayer @0x8004dbac** — GetIconPos then AH_Map_HubArrow(x, y, unk_playerAdvMap, vertCol1/vertCol2[timer&2], scale=param_6, rot=param_5). ✓ +- **UI_Map_DrawRawIcon @0x8004dc44** — GetIconPos, ptrColor[colorID] (4 gouraud), iconGroup[5]->icons[iconID], DrawPolyGT4 trans 0 scale. ✓ +- **UI_Map_DrawDrivers @0x8004dd5c** — **NOTE(claude) odd-player-inside-loop CONFIRMED**: decomp bumps *pCounter every iter (LAB_8004dea8), draws only if numPlyr==1||3; project `if((numPlyr&1)==0)continue` + counter in for-increment = equiv (split-screen 1-4). kartColor=charIDs[driverID]+5; AI icon 0x31, human 0x32 (WHITE=4 blink timer&2), Adventure-Arena→DrawAdvPlayer arrow (rotCurr.y+0x800|0x1000, scale 0x800). (s16)kartColor no-op. ✓ +- **UI_Map_DrawGhosts @0x8004dee8** — ghostBoolStarted(0x632) gate; human ghost(0x630==0) CORTEX_RED 6/CRASH_BLUE 5 (timer&1); NTropy TROPY_LIGHT_BLUE 0x11, Oxide(timeTrialFlags&2) RED 3/WHITE 4. DrawRawIcon 0x31. ✓ +- **UI_Map_DrawTracking @0x8004dffc** — warpball (model DYNAMIC_WARPBALL 0x36) icon 0x20 color 0; target tw->driverTarget(object+0)→instSelf(+0x1c)->matrix.t(+0x44) icon 0x21 flicker 4/3. ✓ +No new bugs. **UI_Map.c fully verified (7/7)**; 2 NOTE(claude) fixes (count-test, odd-player-inside-loop) confirmed. + +### Bug-class re-scan cont'd (2026-07-10) — UI/UI_RaceHud.c (5 fns) — **Fix 139** + Match (UI_RaceHud.c COMPLETE) +- **UI_BattleDrawHeadArrows @0x8004f9d8** — team arrow over other players' heads. GTE-project drivers[i] (skip self/invisible@0x28/finished 0x2000000). **3 NOTE(claude) CONFIRMED**: (1) strict `dist² > 0x90000` (0x90000>=dist skips equality); (2) `(s16)(0x1000 - dist/0xc000)` truncated before ×3/×7 (project `/6>>0xd` == `/0xc000` for dist²>=0); (3) 3 consecutive gradient words grad[0]/[1]/[2] from ptrColor[PLAYER_BLUE 0x18+team]. POLY_G3 semitrans (tpage 0xE1000A20, code 0x32, tag 0x8000000), 6 verts + colors match. ✓ +- **UI_TrackerSelf @0x8004fd34** — **FIX 139 (timer stale-cache)**. Incoming-missile/warpball tracker. **BUG**: decrement guard used entry-cached `timer`; ASM 0x8004fea0 `lh v0,0x0(a0)` (a0=&trackerTimer[driverId]) shows retail RE-READS current trackerTimer. On fresh lock (state machine just set it 8/12, entry timer was 0) retail decrements same frame (→7/11), project skipped → pulse anim 1 frame behind. Fixed `if(timer!=0)`→`if(data.trackerTimer[driverid]!=0)`. Built clean. **anim x/y ASM-CONFIRMED full halfwords** (0x8004fdd4/dd8 `lhu` no shift; decomp `>>8` in uAnimX def = artifact, real >>8/>>7 at use sites). **NOTE(claude) 2nd-tri y0 CONFIRMED**: decomp `pPrim+10=screenPosY-0xc` (not tri1's screenPosY-(sVar5+12)). Beep 5/10/30 (<=100/101-200/>200), warpball lap-dist gate <16000, all match. ✓ +- **UI_DrawPosSuffix @0x8005045c** — rank/team-rank, lngStrings[stringIndexSuffix[rank]] FONT_BIG, instBigNum->matrix.t[2]=driverRank+0x100. ✓ +- **UI_DrawLapCount @0x80050528** — currLap=lapIndex+1 clamp; 1P/2P LNG_LAP 0x18 + sprintf "%d/%d" (0x4001=JUSTIFY_RIGHT|PERIWINKLE); 3P/4P in-place N/M template FONT_SMALL PERIWINKLE 1. ✓ +- **UI_DrawBattleScores @0x80050654** — POINT(pointsPerTeam,icon 0x85)/LIFE(numLives,icon 0x84); color battleScoreColor[(numPlyr-1)*4+driverID]; sprintf %ld; DrawPolyFT4 hidden-args. ✓ +No new bugs beyond Fix 139. **UI_RaceHud.c fully verified (5/5)**; 3 head-arrow NOTEs + tracker 2nd-tri NOTE confirmed; Fix 139 (tracker timer re-read). + +### Bug-class re-scan cont'd (2026-07-10) — UI/UI_RenderFrame.c (3 of 4 fns) — Match (giant Racing deferred) +- **UI_RenderFrame_AdvHub @0x80054298** — relic/key/trophy counters at hudLayout[numPlyr-1] slots 0xE/0xF/0x10 (+0x10 x, -10 y). ✓ +- **UI_RenderFrame_CrystChall @0x8005435c** — 1P crystal-challenge HUD (drivers[0]). JumpMeter_Update (!paused); SpeedNeedle/JumpMeter_Draw/SlideMeter/SpeedBG (slots 9/6/8; decomp's 3 phantom args to `UI_DrawSpeedBG()` = register-reuse artifacts, it's void); DrawNumCrystal (slot 0x11, +0x10/-0x10); Weapon_DrawSelf (slot 0); TIME label lngStrings[0x12]; DrawLimitClock. Crystal pickup: numCollected>0→show inst(clear HIDE_MODEL), cooldown==0→numCrystals++/numCollected--, win if numCrystalsInLEV<=numCrystals (funcPtrs[0]=FreezeEndEvent_Init, RACE_FINISHED, MainGameEnd), SFX 0x42; else Lerp2D_HUD+cooldown--. Inlined ConvertX/Y_2 ((pos-0x100/-0x6c)*z, +0xff if neg, >>8). Roulette-stop (Stop2 0x5d, clear ROLLING_ITEM). ✓ +- **UI_RenderFrame_Wumpa3D_2P3P4P @0x8005465c** — split-screen 3D-wumpa DecalMP. viewport rect 2P/3P4P (project explicit unpack = documented strict-aliasing-safe; = g_rectWumpaDecalMP). SetDrawEnv_DecalMP(renderBucketOTRangeEnd@pb+0xf8, offX/Y = vp.x/y + vp.w/h>>1 - 0x100/0x6c); CycleTex(&pushBuffer[0].ptrOT[0x3ff], ptrOT@pb+0xf4, wordcount+1). Per-player POLY_FT4 (tag 9, code 0x2c, tint 0x808080) at hud[3].xy + pb.rect.xy - vp.w/h>>1; UVs u0=vp.x&0x3f/v0=vp.y (+w/+h), tpage packed. **NOTE(claude) wumpaShineResult CONFIRMED**: numWumpas>=10→r0/g0/b0 = scalar g_nWumpaShineResult (not gradient byte). AddPrim tileView_UI.ptrOT. ✓ +No new bugs. **UI_RenderFrame.c 3/4 verified**; NOTE(claude) wumpaShineResult confirmed. **DEFERRED**: giant UI_RenderFrame_Racing @0x80052f98 (~0x1300 bytes; CTR_NATIVE ST1-map null guard + wrong-way/wumpa/letter/battle/turbo-count/map/finished HUD) — next cycle. + +### Bug-class re-scan cont'd (2026-07-10) — UI/UI_RenderFrame.c UI_RenderFrame_Racing — Match (UI_RenderFrame.c COMPLETE 4/4) +- **UI_RenderFrame_Racing @0x80052f98** — master in-race HUD (~0x1300B), fully traced, 7 sections all match: + (1) AnimateShine; elapsedEventTime==0 → rankIconsCurr/Desired[i]=kartSpawnOrder[i], transitionTimer=0. + (2) map toggle !START_OF_RACE: BTN_TRIANGLE 0x40000 flips HudDisplayFlags^8 (hudFlags&0x20 else clears 0x20); numPlyr==0 & BOT→flags=8. + (3) levPtrMap = ptrSpawnType1[1].count if count!=0 (**CTR_NATIVE ptr!=0 guard documented**); DrawRankedDrivers if !(RELIC|TT|BATTLE). + (4) per-driver loop (pHudSlot stride 0x50): backwards-warn (dist>=0x1f5 & flag 0x100 → LNG_WRONG_WAY 0x1d blink, cBackwardsWarn=1); JumpMeter_Update; 1P map-off cluster (slots 9/6/8 SpeedNeedle/JumpDraw/Slide/SpeedBG); !finished→Slide(!battle)+NumWumpa(!TT/relic); NumTimebox(relic); wumpa-pickup fly-in (numCollected, cooldown, RB_Player_ModifyWumpa, SFX 0x42, DrawPolyFT4 iconGroup[0xb][0]); C/T/R letter fly-in (modelID 0x93/0x94/else, offsets +0x1d/-1 & +0x3a, clear HIDE_MODEL, ConvertX/Y_2 0x200); Weapon_DrawSelf(slot0)/time-crate text (relic); battle score-delta popup (±fmt MinusInt/PlusLong/MinusLong); LapCount(slot1)/BattleScores(slot0xD); AA_EndEvent_DisplayTime; PosSuffix+rank icon (normal & END_OF_RACE|BATTLE branches, color idx bVar3<<2, ptrIcons[rank+0x19]/finishedRank+0x19, DrawPolyGT4 0x1000); TrackerSelf; HeadArrows(battle); Weapon_DrawBG (numWumpas>=10 & !finished, slot0xC + slot0xB if heldItem!=0xF). + (5) backwards-warn state (WrongWayDirection_bool/framesDrivingSameDirection); 1P→DrawRaceClock + CHEAT_TURBOCOUNT turbo-pad (threadBuckets[9] find driver@obj+4, goto state machine TurboDisplayPos, Lerp2D_Linear, sprintf %d [Ghidra-dropped vararg], POLY_G4 tab 0x3800c8ff/0x380000ff); else TIME_LIMIT→DrawLimitClock. + (6) minimap (1P map-off || 3P): DrawDrivers×2 [buckets 0/1] + Ghosts[2] + Tracking[6] + DrawMap (1P 500,0xc3 / 3P 0x1b8,0xcd, ptrIcons[3/4], MAP_COLOR_WHITE 1). + (7) EOR loop drivers[i]: finished & !ARCADE/TT & (timerVS==0||>0x96) → FINISHED 0x1e (rank=0x800→ret 0); CdIntToPos/CdControl(CdlSetloc); howlChainState=1; CdReadCallback(LOAD_HowlCallback); ret CdRead!=0. ✓ +- **LOAD_HowlSectorChainEnd @0x8003266c** — chainState==-1 → re-ChainStart(saved params); ret chainState==0 (project explicit `return 0` in -1 branch = decomp `return chainState==0`=false). ✓ +No new bugs. **LOAD_Howl.c fully verified (4/4)**; NOTE(claude) intr-mask confirmed, bounds-check binary-wins documented. + +### Bug-class re-scan cont'd (2026-07-10) — LOAD/LOAD_File.c (5 of ~12 fns) — Match (rest deferred) +- **LOAD_StringToUpper @0x80031c1c** — `(u8)(c-0x61) < 0x1a` → `-= 0x20` in-place. ✓ +- **LOAD_ReadDirectory @0x80031c78** — CdSearchFile→NULL ret; MEMPACK_AllocMem(0x4000); CdControl(CdlSetloc)/CdRead(8,bh,0x80); CdReadSync; bh->cdpos=CdPosToInt; ReallocMem(numEntry*8+8); ptrBigfileCdPos_2=bh. ✓ +- **LOAD_DramFileCallback @0x80031d30** — ptrMapOffset=*fileBuf; >=0→LOAD_RunPtrMap(fileBuf+4, dpm.offsets, dpm.numBytes>>2), flags<_SETADDR(1)→ReallocMem(off+4); <0→flags|=LT_GETADDR(2); ptrDest+=4; retail callback range-check (0x80xxxxxx); queueReady=1. CTR_NATIVE LT_MEMPACK/sentinel = documented. ✓ +- **LOAD_VramFileCallback @0x80031ee4** — single(header!=0x20) or packed(0x20) TIM LoadImage; rect x@w3/y@0xe/w@w4/h@0x12, pixels@+5. **NOTE(claude) size&~3 CONFIRMED**: decomp `imgHeader + (size>>2)` (int* arith) = project `(u32)vh + (size&~3)`; frameFinishedVRAM=frameTimer_VsyncCallback. ✓ +- **LOAD_ReadFileASyncCallback @0x80032110** — CdReadCallback(0); `intr&0xff==CdlComplete(2)`→ flags<_SETADDR→ReallocMem(size), callbackCdReadSuccess(lqs); else flags<_SETADDR→**NOTE(claude) MEMPACK_PopState CONFIRMED** (decomp MEMPACK_PopBookmark, no arg, not ReallocMem(0)), queueRetry=1. ✓ +No new bugs. **LOAD_File.c 5/12 verified**; 2 NOTE(claude) (VramCB size&~3, AsyncCB PopState) confirmed. **DEFERRED**: InitCDvol/InitCD (SPU regs), DramFile, VramFile, ReadFile_ex, XnfFile, FindFile. + +### Bug-class re-scan cont'd (2026-07-10) — LOAD/LOAD_File.c (remaining 7 fns) — Match (LOAD_File.c COMPLETE 12/12) +- **LOAD_DramFile @0x80031e00** / **LOAD_VramFile @0x80031fdc** — mode -1(sync: build LoadQueueSlot type LT_DRAM/LT_VRAM, ReadFile_ex NULL cb, DramFileCallback/VramFileCallback+VSync(2)+PopBookmark) / -2(NULL dst, store handle to currSlot+*out) / else(async cb). ReadFile_ex(=LOAD_ReadFileRequest) loadType LT_GETADDR/2 (Dram), LT_VRAM/3 (Vram). VramFile brackets MEMPACK_PushBookmark if dst NULL. ✓ +- **LOAD_ReadFile_ex @0x800321b4** — entry size/offset `bigfile[idx*2+3/+2]`; *sizePtr=size; CdIntToPos(cdpos+offset); ptrDst NULL→flags|=LT_SETADDR(1)+AllocMem((size+0x7ff)&~0x7ff), else flags&=~1; CdControl/CdRead(sectors,buf,0x80) retry loop. **NOTE(claude) sltiu CONFIRMED**: `CdReadSync(0,0)==0` (unsigned, -1 not treated complete). callback NULL&&origDst NULL→ReallocMem. CTR_NATIVE bigfile-NULL-default + LT_MEMPACK + ptrDest/size set = documented. ✓ +- **LOAD_XnfFile @0x80032344** — StringToUpper; CdSearchFile. **NOTE(claude) CONFIRMED**: failed search → `return buffer` (caller's ptrDest, NULL only if passed NULL), *size unwritten (decomp buf=buffer if pFile==0). buffer NULL→AllocMem sector-round; CdRead/CdReadSync fail→NULL; success+alloc→ReallocMem(size). ✓ +- **LOAD_FindFile @0x80032438** — filename/cdFile NULL→0; StreamData; StringToUpper; ret CdSearchFile!=0. ✓ +- **LOAD_InitCDvol @0x8007c118** (decomp CD_SetupAudioMix) — retail SPU writes: cur-vol readback (+0x1b8/0x1ba==0)→master 0x3fff (+0x180/0x182); CD-in 0x3fff (+0x1b0/0x1b2); SPUCNT 0xc001 (+0x1aa); CDROM REG0/1/2/3 matrix (2/0x80/0, 3/0x80/0/0x20). CTR_NATIVE SpuSetCommonCDVolume = documented. ✓ +- **LOAD_InitCD @0x80031c58** — retail `CDSYS_Init(1)` (=CDSYS_InitCd); CTR_NATIVE NativeCD_Init/CDSYS_Init(0)/InitCDvol chain = documented. ✓ +No new bugs. **LOAD_File.c fully verified (12/12)**; 4 NOTE(claude) (VramCB size&~3, AsyncCB PopState, ReadFile_ex sltiu, XnfFile return-ptrDest) confirmed; all CTR_NATIVE adaptations documented vs retail. + +### Bug-class re-scan cont'd (2026-07-10) — LOAD/LOAD_TenStages.c (whole 10-stage machine @0x80033610) — Match + **Fix 140** +Verified all 10 stages of the master level-load state machine against the full decomp; disassembled the stage-4 clear (0x80033d80-0x80033ddc) for the count question. +- **boolPlayMusicDuringLoading** = `levelID==ADVENTURE_GARAGE || ==NAUGHTY_DOG_CRATE`. Decomp's `1 < (levelID - ADVENTURE_GARAGE)` (unsigned) is the compact `!(0x28||0x29)` form ⇒ ADVENTURE_GARAGE=0x28, NAUGHTY_DOG_CRATE=0x29 consecutive; MAIN_MENU_LEVEL=0x27. ✓ +- **Stage 0 (INIT)** — Cutscene_VolumeBackup(!music)/XAPause; firstBoot(g_nReloadVramDisplay) VRAM-file/VRAMDisplay/isbg=0 vs SwapPacks(0)/StringToLevID/PopToState; hudFlags&~(1|8) (two clears 0xfe+0xf7); levelName prefix→gameMode (ndi/ending→CUTSCENE, intro→CUTSCENE|LEV_SWAP, screen/garage→MAIN_MENU+numPlyr 4/1, hub→ARENA|LEV_SWAP, credit→CUTSCENE|CREDITS|LEV_SWAP). Per-branch `hudFlags&=0xfe` = redundant re-clears of already-cleared bit0 (equiv). **NOTE(claude) levelLOD priority @0x80033a70 CONFIRMED**: MAIN_MENU→1, else 8, else(!TIME_TRIAL/RELIC) numPlyrCurrGame. Debug_ToggleNormalSpawn write-position differs (unconditional, no reads between → equiv). ✓ +- **Stage 1 (OVR_ENDRACE)** — XA_State==4(XA_STOPPING) re-poll; CRYSTAL→0/TT→3/ARCADE→1/RELIC→2/ADV→1/else 4 (CUP_ANY_KIND→skip). ✓ +- **Stage 2 (OVR_LOD)** LOAD_OvrLOD(numPlyrCurrGame). ✓ +- **Stage 3 (OVR_THREADS)** MAIN_MENU&&!garage→0; ARENA→3/2(podium); podium!=NOFUNC||CUTSCENE||CREDITS||garage→3; else 1(overlayIndex_Threads==1→skip). ✓ +- **Stage 4 (DRIVER_MODELS)** Music_Restart(!music); MAIN_MENU→mainMenuInit[state]() (=6-case jump table); ptrMPK=0. **Clear count divergence (documented, equiv)**: ASM 0x80033d80-0x80033dd0 zeros ONLY 3 ints — driverModelExtras[0..2]@0x80083a10/a14/a18 — + g_pDriverModels; project's `for(i<11)` also zeros the 8 podium ptrs @0x80083a1c+ (which retail clears in stage 7's `pPodiumClear` loop the project lacks). Both leave podium=0 before its stage-7 load; project's unconditional early clear is equiv (podium not read between stages 4→7) and arguably safer for the no-podium case. LOAD_DriverMPK(bigfile, levelLOD, LOAD_Callback_DriverModels[0x80031b00]). ✓ +- **Stage 5 (GLOBAL_MODELS)** LibraryOfModels_Clear; PLYROBJECTLIST=ptrMPK+4|0; GlobalModelPtrs_MPK; DecalGlobal_Clear; mpkIcons=*ptrMPK (Store iff !=0); !music→Music_Stop/CseqMusic_StopAll/Music_LoadBanks. ✓ +- **Stage 6 (LEVEL_DATA)** !music→Music_AsyncParseBanks(0→re-poll)+VolumeRestore; driverModelExtras[0..2]+=4; LEV_SWAP hub double-buffer (0x6b000+0x40000 / arena 0x68800+0x68800, SwapPacks/NewPack 1&2, activeMempackIndex 1 or 3-AdvPackIndex, levID_in_each_mempack, PatchMem AllocHighMem). Queue: VRAM(idx0)+LEV(idx1) always, +PTR(idx2) iff levelID∈[GEM_STONE_VALLEY,+0xe)∪[CREDITS_CRASH,+0x14). **LT_ enums confirmed** LT_GETADDR=LT_DRAM=2, LT_SETADDR=LT_RAW=1, LT_VRAM=3. **NOTE(claude) bigfile-arg CONFIRMED** (all 3 pass bigfile). ✓ +- **Stage 7 (LEVEL_SETUP)** level1/visMem1 bind; DecalGlobal_Store(levTexLookup); LibraryOfModels_Store; ptrCircle/Clod/Dustpuff/Smoking/Sparkle=FindInLEV; trafficLightIcon[0..3]=FindInMPK(redoff/redon/greenoff/greenon); gameMode1_prevFrame=1; !(CUTSCENE|ARENA|CREDITS)→JitPools+advance. **NOTE(claude) no-SwapPacks(0) & podium-gate-on-podiumRewardID-alone CONFIRMED**. Podium loads: file indices all match (project BI_* = decomp offset+1, absorbing iVar9=AdvPackIndex-1); setPtrCb=-2; STATIC_CRASHDANCE=0x7e/OXIDEDANCE=0x8d/TAWNA1=0x8f/DINGODANCE=0x83. **→ Fix 140**: all 7 podium `LOAD_AppendQueue(0,...)` passed NULL bigfile, but every decomp call passes `bigfile` (0x80033610 stage 7). NULL leaned on the port-only NULL→ptrBigfile1 shim (matches only while bigfile==ptrBigfile1). Changed to `bigfile` — same fix as stage 6, removes shim reliance. Built clean. +- **Stage 8 (AUDIO)** ARENA&&podium→podium-model fixup (8: +=4 for i<7, modelPtr[id] iff id!=-1)+SwapPacks(active); Audio state by levelID (0x27→7, 0x19-0x1d→6/5, 0x1e→3, 0x2c→2, 0x29→4, 0x2a-0x2b→1). CTR_NATIVE LOAD_NativeAudio_SetStateAfterBankReload = documented. ✓ +- **Stage 9 (FINALIZE)** XA_State==2(XA_QUEUED)→re-poll; renderFlags: MAIN_MENU&&!garage→&0x1000|0x20+RaceFlag transition, CREDITS→&0x1000|0x20, else|0xffffefff; hudFlags|=8; framesInThisLEV/msInThisLEV=0; ElimBG_Deactivate; return LOAD_STAGE_DONE(-2). ✓ +**LOAD_TenStages fully verified**; 4 prior NOTE(claude) (levelLOD priority, stage-6 bigfile, stage-7 no-swap, podium-gate) confirmed; **Fix 140** (stage-7 podium bigfile arg) applied; stage-4/7 podium-clear split documented as behaviorally-equivalent structural divergence. + +### Bug-class re-scan cont'd (2026-07-10) — LOAD/LOAD_Hub.c (all 3 fns) — Match (3/3, no bugs) +- **LOAD_Hub_ReadFile @0x80032ffc** — no-op if levID_in_each_mempack[packID]==levID; else modelMaskHints3D=0, SwapPacks(packID)+ClearLowMem, **NOTE(claude) load_inProgress=1 @0x80033064 CONFIRMED** (`sw a1,0x138(gp)`; distinct from PatchMem_Size@0x8008d094), level2=0, levID_in_each_mempack[packID]=levID. 3 AppendQueue at lod=1: VRAM(idx0,noCb), LT_GETADDR=LT_DRAM(idx1, LOAD_Callback_LEV@0x80031a78), LT_SETADDR=LT_RAW(idx2, PatchMem_Ptr=g_pPatchMem, LOAD_HubCallback@0x80031b14). ✓ +- **LOAD_Hub_SwapNow @0x80033108** — spin LOAD_NextQueuedFile+VSync until level2; LevInstDef_RePack(level1->ptr_mesh_info,1); LOAD_HubSwapPtrs; activeMempackIndex=3-it; prevLEV=levelID, levelID=levID_in_each_mempack[active]; Audio_AdvHub_SwapSong; LibraryOfModels_Clear; PLYROBJECTLIST(g_pPlyrObjectList)!=0→GlobalModelPtrs_MPK; level1!=0→Store/INSTANCE_LevInitAll/LevInstDef_UnPack/DecalGlobal_Store; SwapPacks(active)+MainInit_VisMem; zero cameraDC[0] 6 vis-src ptrs (field order differs, equiv) + visMem1 4 src[0] + drivers[0]->underDriver; framesInThisLEV/msInThisLEV=0. ✓ +- **LOAD_Hub_Main @0x80033318** — per-frame hub load/swap. **NOTE(claude) no-return @0x80033318 CONFIRMED** (`if(hubIdx<5) Hub_ReadFile` doesn't return; later players' triggers still processed). Per-player: nextHubExit=(flags&0x30)>>4, needSwap=(flags&0xc0)>>6; exit0→(needSwap||bool_AdvHub_NeedToSwapLEV)→clear+SwapNow; else→levelID-GEM_STONE_VALLEY<5→Hub_ReadFile(bigfile, table[hub*3+exit-1], 3-active). **Hoisted Loading.stage check = equiv**: decomp checks `Loading.nStage==-1` per-player, project hoists `if(Loading.stage!=LOAD_IDLE)return` to top; proven equiv because Loading.stage is INVARIANT in the loop — LOAD_AppendQueue@0x80032d30 only bumps `g_loadQueueCtrl.nQueueLength` (distinct field), and neither Hub_ReadFile (sets load_inProgress, not Loading.stage) nor Hub_SwapNow touches it. **CTR_NATIVE s_advHubConnectedLevID BYTE-EXACT vs retail g_aMetaDataHubConnections@0x80011180** (read_memory all 15 ints): hub0{0x1a,0x1b,-1} hub1{0x19,0x1c,-1} hub2{0x19,0x1c,-1} hub3{0x1a,0x1b,0x1d} hub4{0x1c,-1,-1}, mapped via LevelID enum (GEM_STONE_VALLEY=0x19,N_SANITY_BEACH=0x1a,THE_LOST_RUINS=0x1b,GLACIER_PARK=0x1c,CITADEL_CITY=0x1d). Decomp's hardcoded hubMeta[12]=0x1c == global[12] (compiler inlined the immediate, no override). ✓ +**LOAD_Hub.c fully verified (3/3)**; 2 NOTE(claude) (load_inProgress, no-return) confirmed; native hub-connection table proven byte-exact with retail rdata. + +### Bug-class re-scan cont'd (2026-07-10) — LOAD/LOAD_Level.c (both fns) — Match (2/2, no bugs) +- **LOAD_TalkingMask @0x800347d0** (decomp LOAD_MaskHints3D) — modelMaskHints3D=0; levID_in_each_mempack[packID]=-1 (invalidate alt hub); SwapPacks(packID)+ClearLowMem; **NOTE(claude) load_inProgress=1 @0x80034830 CONFIRMED** (`sw ...,0x138(gp)`=0x8008d0a4, not PatchMem_Size@0x8008d094). offset=maskID*4+(packID-1)*2. **NOTE(claude) ptrBigfileCdPos_2 @0x80034828/0x80034850 CONFIRMED** (decomp both AppendQueue pass `sdata_ptrBigfileCdPos_2`, not NULL). VRAM idx=BI_UKAHEAD+offset (=0x1b6, chains BI_DANCETAWNAGIRL=0x1ae+8), DRAM(LT_GETADDR=LT_DRAM) idx+1=0x1b7, cb LOAD_Callback_MaskHints3D@0x80031a50. ✓ +- **LOAD_LevelFile @0x80034874** — modelMaskHints3D=0; hudFlags&=0xfe; prevLEV=old levelID, levelID=param (decomp caches old in temp then stores levelID before prevLEV — store order differs, result identical); renderFlags&=0x1000; RaceFlag_IsFullyOffScreen==1→BeginTransition(1); Loading.nStage=LOAD_TEN_STAGES_0(0). ✓ +**LOAD_Level.c fully verified (2/2)**; 2 NOTE(claude) (load_inProgress, ptrBigfileCdPos_2) confirmed. **All LOAD/*.c now verified** (File 12/12, Overlays 4/4, Howl 4/4, TenStages, Hub 3/3, Level 2/2). + +### Bug-class re-scan cont'd (2026-07-10) — Podium/Podium_0_InitModels.c (1 fn) — Match (no bugs) +- **Podium_InitModels @0x80041c84** — clears First/Second/Third=0, tawna=STATIC_TAWNA1(0x8f) (ASM 0x80041ca0-0x80041cac at gGT+0x2575..0x2578); scans drivers[0..7]. **NOTE(claude) rank-dispatch @0x80041d0c CONFIRMED**: driverRank is s16 (ASM `lh v1,0x482(a1)`, signed `slti ...,0x2`); retail uses explicit equality arms rank==1→Second / rank==0→First+tawna / rank==2→Third, negatives fall through no-op. Project `if(rank>=0 && rank<3) podiumModelIndexArr[rank]=charID+0x7e` is equivalent (array=&First, consecutive u8; the `rank>=0` guard = retail's equality arms, blocks a -1 writing the byte before First). **characterID read equiv**: ASM `lbu 0x4a(a1)`(driverID) `sll 1` `addu t0`(g_awCharacterIDs@0x80086e84, u16[8] stride-2) `lbu 0(v0)` = low byte; project `u8 characterID = data.characterIDs[driverID]` where data.characterIDs is s16[8] (regionsEXE.h:2556, SAME stride-2) truncated to u8 — identical. characterIDs is runtime char-select state (read {0..7}), populated identically both sides. **tawna switch** (jump table @0x80011600 idx charID, `sltiu ...,0x8` bound): charID 0/3→TAWNA2(0x90), 1/4→TAWNA4(0x92), 6/7→TAWNA3(0x91), else→TAWNA1(0x8f) — all STATIC_* consts (CRASHDANCE=0x7e, TAWNA1-4=0x8f-0x92) confirmed. ✓ +**Podium subsystem fully verified (1/1)**; NOTE(claude) rank-guard confirmed; characterID stride/sign and all podium-model magic constants proven vs binary. + +### Bug-class re-scan cont'd (2026-07-10) — SelectProfile.c (12 leaf fns) — Match (no bugs) +- **SelectProfile_UI_ConvertX @0x80047fb8 / ConvertY @0x80047fd8** — `(x-0x100/-0x6c)*scale; if(<0)+=0xff; >>8` — exact (signed div-256 round-toward-zero). ✓ +- **SelectProfile_PrintInteger @0x80047f20** — format==1→"%02ld"(stringFormat1), else"%ld"(stringFormat2); sprintf+DrawLine FONT_BIG. ✓ +- **SelectProfile_ThTick @0x80047dfc** — 12 cards, slot=i%3 (`*0x10000>>0x10` s16-ext, identity for 0-2); rot.y(+2 off)+=LoadSave_SpinRateY[slot]; ConvertRotToMatrix(inst+0x30, &rot); non-center→Vector_SpecLightSpin3D with vec3 from MetaDataLoadSave[i] (DAT_80085c68/6a/6c, stride 7 s16=14B, vec3 @off0). Decomp copies to local SVEC, project passes &data.MetaDataLoadSave[i].vec3_specular_inverted — equiv (read-only spec vec). ✓ +- **SelectProfile_QueueLoadHub_MenuProc @0x80047da8** — levelID=MAIN_MENU_LEVEL(0x27); characterIDs[0]=advProgress.characterID (g_abAdvProgress+0x2a, s16); MainRaceTrack_RequestLoad(currLEV); RECTMENU_Hide. NOTE(aalhendi) 0x27-before-prevLEV confirmed. ✓ +- **SelectProfile_GetTrackID @0x800485a8** — menuGreenLoadSave.rowSelected=1; advProgress.HubLevYouSavedOn(g_abAdvProgress+0x2e)=levelID. ✓ +- **SelectProfile_ToggleMode @0x80048e2c** — memcardAction(=g_nProfileMode)=mode&0xf; Mode()(data10_bbb[0])=mode&0xf0; resets ALL 6 phase-flags data10_bbb[2/4/6/8/10/12]=decomp d8fa/fc/fe/900/902/904 (5 before UnMute + [2] at end); UnMuteCursors; FourAdvProfiles/menuOverwriteAdv drawStyle&~0x10, |0x10 iff Mode==0x20; Init; rowSelected=unk_8008d73C(=g_aHighScoreTable[0].pName). ✓ +- **SelectProfile_Destroy @0x800488e0** — obj(ptrLoadSaveObj)!=0→12× INSTANCE_Death(icon->inst, stride 0xc); thread->flags|=THREAD_FLAG_DEAD(0x800); ptrLoadSaveObj=0 (store order vs decomp harmless). ✓ +- **SelectProfile_MuteCursors @0x80048da0 / UnMuteCursors @0x80048de4** — FourAdvProfiles/GhostSelection/warning2 .state |= / &= ~MUTE_SOUND_OF_MOVING_CURSOR (0x800000). ✓ +- **SelectProfile_InitAndDestroy @0x80048edc** — Init(FourAdvProfiles.drawStyle); Destroy(). ✓ +- **SelectProfile_InputLogic @0x80048f0c** — entry mask 0x4007f (D-pad 0xf | CROSS_one 0x10 | SQUARE_one 0x20 | CIRCLE 0x40 | TRIANGLE 0x40000); full-nav (flags&1==0): up-2/down+2/L-R^1, clamp[0,numRows-1], OtherFX(0) on change; select mask **0x50** (CROSS_one|CIRCLE), back mask **0x40020** (TRIANGLE|SQUARE_one); select unless (numRows==0 && memcardAction!=1), UNFORMATTED(mcScreenText==1)→row0; confirm-only (flags&1): back=Tri/Sq, select=(flags&2)&&Cross/Circle. **NOTE(claude) _one-mask fix CONFIRMED** — composite BTN_CROSS(0x4010)/BTN_SQUARE(0x8020) would add raw bits 0x4000/0x8000 retail doesn't test; project's _one masks match retail exactly. ✓ +**SelectProfile.c: 12/12 leaf fns verified**; NOTE(claude) button-mask (_one) confirmed via BTN_* enum + plate-comment masks (0x50/0x40020/0x4007f). **DEFERRED**: DrawAdvProfile@0x80047ff8, Init@0x800485cc, AdvPickMode_MenuProc@0x80048960, DrawGhostProfile@0x80048a30, giant AllProfiles_MenuProc@0x800490c4 + static FSM helpers. + +### Bug-class re-scan cont'd (2026-07-10) — SelectProfile.c (5 draw/init fns) — Match (no bugs) +- **SelectProfile_Init @0x800485cc** — first-call: PROC_BirthWithObject(**pid 0x8030d** = SIZE_RELATIVE_POOL_BUCKET(8,NONE=0,SMALL=0x300,OTHER=0x0d)=(8<<16)|0x300|0xd ✓, ThTick, "LoadSave"); obj->icons=&LoadSaveData, memset 0x90; CTR_NATIVE null-check-AFTER obj->icons write matches retail's own dead-check quirk (obj->icons deref precedes it either way). 12-slot loop: model=modelPtr[MetaDataLoadSave[i].modelID]; INSTANCE_Birth3D "loadsave"; flags|=SCREENSPACE(0x400)|HIDE(0x80)=0x480, +USE_SPECULAR(0x20000)=0x20480 for slot!=1; idpp[0].pushBuffer=&pushBuffer_UI(=tileView_UI), idpp[1..n-1]=NULL (stride 0x88, off 0x74); colorRGBA via LoadSave_Color (R/B>>1 on dim, G kept, R<<0x14|G<<0xc|B<<4); scale[0..2]=MetaDataLoadSave[i].scale; rot x=0 y=0 z=spinOffset_LoadSave[i%3]; identity×0x1000 matrix; every non-null inst->flags|=HIDE_MODEL(0x80). **MetaDataLoadSave BYTE-EXACT** @0x80085c64 (modelID@0,scale@2,vec3_specular@4 SVec3,r/g/b/a@0xa-0xd,size 0xe) — validates ThTick spec-vec@off4 too. ✓ +- **SelectProfile_AdvPickMode_MenuProc @0x80048960** — nProcCallPhase(unk1e)!=0→Init+DrawAdvProfile(advProgress,0x92,0x32,0,0,0x10); else row∈[0,3)→ToggleMode(row|0x20)+ptrDesiredMenu=FourAdvProfiles, row==-1||3→Hide+Destroy, else no-op (all edge cases equiv). ✓ +- **SelectProfile_LoadSave_Color (static)** — matches Init's inline color: (u8)r/g/b, dim(0x10)→red/blue>>1 (green kept), (r<<0x14)|(g<<0xc)|(b<<4). ✓ +- **SelectProfile_DrawAdvProfile @0x80047ff8** — colors by drawStyle&0x10 (number 0/0x1d, empty 3/0x1e, name 1/0x1d, percent 4/0x1d), icon tint grey/green (g_dwSelectProfileAdvIconTint/Green); GAMEPROG_AdvPercent; characterID<0→EMPTY(lng 0xb5); else icon **NOTE(claude) ptrOT CONFIRMED** (RECTMENU_DrawPolyGT4 → pushBuffer_UI.ptrOT/tileView_UI.ptrOT, NOT otMem.uiOT), name, 4× PrintInteger stats (color|0x4000), percentSign, 3 UpdateIcon calls. Highlight box + frame → otMem.uiOT[3] (decomp startPlusFour+0xc). color|0xffff8000 = decomp ushort |0x8000 sign-extended (established DrawLine convention). CTR_Box_DrawClearBox 4-arg (primMem derived internally, per verified CTR_Box.c) = decomp 5-arg first-4. ✓ +- **SelectProfile_DrawAdvProfile_UpdateIcon (static)** — matches inline stat-icon writes: matrix.t[0]=ConvertX(posX,0x100)@inst+0x44, t[1]=ConvertY(posY,0x100)@+0x48, t[2]=0x100@+0x4c, flags&=~HIDE_MODEL(0x80)@+0x28; 3 calls (slotBase*3 + 0/1/2) at (x+0xc3,y+0x1f)/(x+0x78,y+0xd)/(x+0xc3,y+0xd). ✓ +**SelectProfile.c: 17 fns verified** (12 leaf + Init/AdvPickMode/LoadSave_Color/DrawAdvProfile/UpdateIcon); NOTE(claude) ptrOT confirmed; MetaDataLoadSave + pid macro + flag consts proven vs binary. **DEFERRED**: DrawGhostProfile@0x80048a30, giant AllProfiles_MenuProc@0x800490c4 + static FSM helpers. + +### Bug-class re-scan cont'd (2026-07-10) — SelectProfile.c (DrawGhostProfile) — Match (no bugs) +- **SelectProfile_DrawGhostProfile @0x80048a30** — params (ghost,x,y,highlight,unused,drawStyle,isLoading[7th=decomp ntropyOxide],isUnavailable[8th=decomp isTitle]). cardRect w=0xc8/h=0x29, innerRect(x+6,y+3,0xbc,0x23). **8th-param branch RESOLVED**: decomp's "isTitle/GHOST header lng 0x6d + GhostBoxColor" is MISLABELED — lng 0x6d = **LNG_NOT_AVAILABLE** (0x6d, confirmed), and `g_dwSelectProfileGhostBoxColor`@0x8008d49c = project **sdata->redColor** (SAME address, project comment: "red (a0 a0 00) when it cannot be selected, wrong track"), transparency 2 = **ADD_DECAL**. Project's isUnavailable/redColor/LNG_NOT_AVAILABLE naming is CORRECT. ghost==NULL: isLoading?0x6c/0x8001:0xb5/0x8003 (=decomp ntropyOxide==0→0xb5/0x8003). profile!=NULL: metaDataLEV[trackID].name_LNG, RECTMENU_DrawTime(trackTime), icon **NOTE(claude) ptrOT CONFIRMED** (DrawPolyGT4→pushBuffer_UI.ptrOT/tileView_UI.ptrOT) tint sdata->ghostIconColor@0x8008d4a0(=IconTint) ×4 TRANS_50_DECAL(1). highlight→menuRowHighlight_Normal/Green TRANS_50_DECAL. All 3 boxes+frame→otMem.uiOT[0] (decomp startPlusFour, NOT +0xc like DrawAdvProfile). color|0xffff80xx = decomp |0x80xx sign-ext; DrawClearBox 4-arg (primMem internal). ✓ +**SelectProfile.c: 18 fns verified**; DrawGhostProfile decomp mislabel (isTitle→isUnavailable) resolved via LNG/color-address proofs (0x6d, 0x8008d49c, 0x8008d4a0), project naming correct. **DEFERRED**: giant AllProfiles_MenuProc@0x800490c4 + static FSM helpers only. + +### Bug-class re-scan cont'd (2026-07-10) — SelectProfile.c AllProfiles_MenuProc@0x800490c4 (confirm-input + finalize tail) — Match (no bugs) +Re-verified two complete sub-systems of the giant memcard FSM (prior full trace 2026-07-04). Flag map: g_nProfileModeFlags=Mode()=data10_bbb[0](&0xf0), g_nProfileMode=memcardAction(&0xf), ActionActive=data10_bbb[2](d8fa), ExitToPrevious=d8fc=data10_bbb[4], ActionDone=d8fe=data10_bbb[6], OverwritePrompt=g_boolConfirmBoxActive=d900=data10_bbb[8], TimerSaveComplete=g_nMcCompleteCountdown=d904=data10_bbb[12]. +- **Confirm-box input (ProcessOverwritePrompt)** — `if(g_boolConfirmBoxActive)` block: UP→row-- if>0, DOWN→row++ if<1 (menuOverwriteAdv=menuConfirmDeleteProfile@80085d30), face→ select(CIRCLE|CROSS_one)play(1)+confirm=(row==0) / cancel(TRI|SQUARE_one)play(2), clear g_boolConfirmBoxActive, Ghost.row=Adv.row. **NOTE(claude) confirm-box _one masks CONFIRMED**: face 0x40070 (TRIANGLE 0x40000|CIRCLE 0x40|SQUARE_one 0x20|CROSS_one 0x10), select 0x50. ✓ +- **Finalize gate (ShouldFinalize)** — structure matches decomp gate A&&B&&C: A=(ActionActive!=0 && boolError!=0) [2 early returns], B=(StopReq||ExitToPrevious||ActionDone) [3rd check ¬B→return0], C-negation=(ActionActive&&StopReq&&!Exit&&!Done&&countdown!=0)→countdown-- return0 [4th check]. ✓ +- **Finalize routing (FinalizeAdventure 0x20/0/0x10/0x40 + FinalizeGhost 0x30)** — dispatch Mode==0x30→Ghost else Adv. **4 NOTE(claude) CONFIRMED**: (1) mode0x20 advProfileIndex=row via short-circuit comma `(d8fc==0)&&(refreshResult=row,action==0)` — set iff ExitToPrevious==0; (2) mode0x20 greenLoadSave.rowSelected=3 BOTH arms; (3) mode0x40 else-if ExitToPrevious→menuSaveGame (not warning2), boolSaveCupProgress==0→gameModeEnd&~(NEW_NAME|NEW_HIGH_SCORE); (4) FinalizeGhost native null-guard on ptrGhostTapePlaying (retail derefs *(hst[1].pName+6) unguarded). Menu-ptr identities: LoadProfileFromHub=menuQueueLoadHub, OSK=menuSubmitName, DAT_800a0458/04a4=menu224/menu224NoSave; currLEV mode0=0x1a(N_SANITY_BEACH), mode0x10=HubLevYouSavedOn?:0x1a (DINGO_CANYON=0 guard); AdvNewLoad state&=~4; name→prev/currNameEntered (0x11B). ✓ +**AllProfiles_MenuProc: confirm-input + finalize tail re-verified** (2 sub-systems, all NOTE(claude) confirmed). **DEFERRED (mid-section)**: selection gating (HandleSelection), draw (DrawAll/DrawGhostRows/DrawAdvRows/DrawMemcardMessage/GhostRowCount). + +### Bug-class re-scan cont'd (2026-07-10) — AllProfiles_MenuProc@0x800490c4 (selection-gating + save path) — Match (no bugs) +Verified LAB_800495b0→LAB_800499e4 (selection) + bDoSave block. mcScreenText=g_aGpuDmaQueue[2].pInlineData[0]._2_2_ (ghidra-misattributed); MemcardProfile()=ptrToMemcardBuffer2=g_aGpuDmaQueue[2].dwArg; advProgress[] @+4 stride 0x50, characterID @+0x2a (occupancy check @slot*0x50+0x2e), gameProgress @+0x144. +- **HandleSelection** — rowSelected==-1→ActionActive/ExitToPrevious=1 (return0). **NOTE(claude) NOCARD gating CONFIRMED**: mcScreenText==NOCARD(0)→bProceed=false; memcardAction==1→ActionActive=ActionDone=1(return0); memcardAction==0&&mode==0x30→bProceed=true(proceed); else low-space (mcScreenText!=1 && free<0x1680 && refreshState==0 && memcardAction==1) VETOES. Then UNFORMATTED(1)→StartMemcardAction(7); (refreshFlag==0&&refreshState==0)→return0. Dispatch: memcardAction==1 {mode0x30&&row=rowCount-1→(native null-guard)memset(ptrGhostTapePlaying,0,0x28)+ActionActive/ActionDone; ghostMemcard[row].trackID==currLEV→ghostProfile_indexLoad=row+StartMemcardAction(5)+ActionActive; else OtherFX(5). ✓ +- **LoadAdvProfile** — SyncGameAndCard(memcard->gameProgress@+0x144,gameProgress); advProgress=memcard->advProgress[slot] (0x50B); characterIDs[0]=characterID; memmove prevNameEntered; caller adds rowHighlight(unk_8008d73C)=row, ActionActive/ActionDone/boolError. ✓ +- **doSave: StartGhostSave** — time=drivers[0]?timeElapsedInRace:0x8ca00; GhostEncodeProfile(row,charID[0],levelID,time,prevName); ghostProfile_indexSave(McGhostAddIndex)=row; ghostProfile_rowSelect(McGhostDeleteIndex)=-1|row(advProgress[slot]=advProgress (copy-order vs decomp swapped but disjoint regions, equiv); SetIcon(0)+StartMemcardAction(3)+refreshState=1+ActionActive. doSave wrapper: TimeoutPrompt=0, ghost?StartGhostSave:SaveAdvProfile, TimerSaveComplete=0x3c. ✓ +Documented divergences confirmed intentional: native null-guards (ptrGhostTapePlaying), redundant boolError writes (RefreshCard_Unknown3 per-frame steady), SaveAdvProfile copy-order (disjoint). Consts: MC_SCREEN TIMEOUT=7/NODATA=9/SAVING=3/FULL=6, PLAYER_GHOST_BEAT=1, NEW_NAME=0x1000000/NEW_HIGH_SCORE=0x8000000. +**AllProfiles_MenuProc: selection-gating + save path verified**. **DEFERRED (draw only)**: nProcCallPhase==1 block (DrawAll/DrawGhostRows/DrawAdvRows/DrawMemcardMessage/GhostRowCount/ClampRow/DisableAdvInputFlags). + +### Bug-class re-scan cont'd (2026-07-10) — AllProfiles_MenuProc@0x800490c4 (draw path) — Match (no bugs, 1 false alarm resolved, 1 non-observable divergence) +Completes the giant FSM. Colors enum: ORANGE=0, PERIWINKLE=1, ORANGE_DARKENED=2, RED=3, WHITE=4; JUSTIFY_CENTER=0x8000. +- **FALSE ALARM — SAVE COMPLETED blink**: decomp `uColorFlags=0x8004; if(frame&4==0) 0x8000` (i.e. frame&4==0→0x8000, else→0x8004). Project `(frame&4==0)?(JUSTIFY|ORANGE):(JUSTIFY|WHITE)`. Initially read as swapped, BUT ORANGE=0/WHITE=4 ⇒ JUSTIFY|ORANGE=0x8000, JUSTIFY|WHITE=0x8004 — EXACT match. Project correct, NO bug. +- **DrawAll** — **NOTE(claude) canDrawProfiles gate CONFIRMED**: (ActionActive==0 && RefreshFlag[unk8008d95c]!=0 && (RefreshState[unk8008d928]!=0 || mcScreenText==NULL[8])); +ghost-mode NODATA(9)/NOCARD(0) override; memcardAction==1 NULL/NODATA branch (doSave→SAVING[3], ghost free<0x3e00&&saved==0→FULL[6], adv free<0x1680&&refreshState==0→FULL[6]); TIMEOUT[7]&&RequeryLatch→draw. SAVE-COMPLETED cond = ActionActive!=0 && McStopReq[unk8008d964]!=0 && Exit==0 && Done==0 && countdown!=0 (=¬decomp memcard-msg gate). LNG_SAVE_COMPLETED=0x13d@(0x108,0x64). ✓ +- **DrawAdvRows** — title lngStringsSaveLoadDelete[action*2]@Y(subtitle?0x12:0x1a), subtitle[+1]@0x22, 4× DrawAdvProfile(advProgress[i], (i&1)*0xea+0x1a, (i>>1)*0x43+0x3c, i==row, i, drawStyle), action==1&&dataInvalid→OUT_OF_DATE[0xd0]@RED. ✓ +- **DrawGhostRows** — **NOTE(claude) strlen-quirk CONFIRMED** (subtitleVisible strlen reads lngStringsSaveLoadDelete[action*2+1] ADV table, drawn text from lngIndex_LoadSave[action*2]); rowCount<7→lineGap0x10/FONT_BIG/(action!=1)INSERT_CARD[0xcf], else lineGap8/FONT_SMALL/yBase=sub?0xc:0x12; row x=(i6?0x2b:0x2f) [decomp iRowY-pair=pair*0x2b], drawStyle|0x40 if rc>6; empty-slot profile=NULL @i==savedCount; **NOTE(aalhendi) currLEV CONFIRMED** (isUnavailable=trackID!=currLEV && action!=1). ✓ +- **DrawMemcardMessage** — screen∈[0,10) guard (defensive, screen 0-9); NODATA&&mode0x40→skip; firstString=messageScreens[screen]&0xffff, multiLine=>>16; ActionActive&&McStopReq→skip; **NOTE(claude) done-flag gate CONFIRMED** (ActionDone[d8fe]==0); 0x10f&&action1→0x106; **NOTE(claude) NODATA out-of-date CONFIRMED** (mode!=0x30&&screen==9&&dataInvalid→OUT_OF_DATE); action2&&0xea→0xfc; multiLine==0→1 line@(0x108,0x12)BIG, else **NOTE(claude) fontCharPixHeight CONFIRMED** 9-line loop y=i==0?0x26:0x2e+i*(fontCharPixHeight[FONT_SMALL=2]+2), i==0&&frame&4==0→RED blink; DrawInnerRect(unk_BeforeTokenMenu=DAT_8008d4a4, uiOT[0]). ✓ +- **DrawOverwriteMenu** — ghost→menuOverwriteGhost+DrawGhostProfile(ghostProfile_memcard[ghostProfile_rowSelect=McGhostDeleteIndex],0x9c,0x3c,...); adv→menuOverwriteAdv+DrawAdvProfile(advProgress[row],0x92,0x3c,...). ✓ +- **GhostRowCount** — savedCount=clamp(numGhosts,0,7) [defensive vs decomp raw, equiv for numGhosts≤7]; action==1→canChoose=(free>=0x3e00),count+=canChoose,cap7; else→canChoose=1,count++. ✓ +- **DisableAdvInputFlags** — bit0=(mcScreenText0` guard before `row=rowCount-1`; decomp unconditionally sets row=uNumRows-1(=-1) when uNumRows==0. Only reachable in ghost-save with 0 ghosts & free<0x3e00. Traced UNOBSERVABLE: InputLogic re-clamps row→-1 on any tap (numRows==0); without tap row unused (DrawGhostRows loop empty when rowCountWithEmpty==0, no HandleSelection). Equivalent. +**SelectProfile.c FULLY VERIFIED** — AllProfiles_MenuProc complete (confirm-input + selection-gating + save + finalize + draw across 4 cycles); all NOTE(claude) confirmed; 1 false alarm (SAVE COMPLETED colors) + 1 non-observable divergence (ClampRow) documented. + +### Bug-class re-scan cont'd (2026-07-10) — SubmitName_0_RestoreName + SubmitName_1_DrawMenu — Match (no bugs) +- **SubmitName_RestoreName @0x8004aa08** — g_nNameEntryMode(data10_bbb[0xd])=mode; memmove(currNameEntered,prevNameEntered,0x11); cursor=currNameEntered[0]?1001(0x3e9):0; TitleOSK_CursorPosition(typeCursorPosition)=cursor. ✓ +- **SubmitName_DrawMenu @0x8004aa60** (OSK, returns 0/1/-1) — **3 NOTE(claude) CONFIRMED via ASM**: (1) glyph count `character` is byte, `2=0x80 counted); (2) blink `(typeTimer&1)<<2` sampled AFTER `typeTimer++` (=g_nTitleOSKTypeTimer@0x8009d8f2); (3) backspace ASM 0x8004af7c-af84 `lbu ...; sltiu v0,v0,0x3` = currNameEntered[len-2] UNSIGNED <3. Letter grid 13×3 from unicodeAscii@0x80085d94 (topByte==0x1000→&0xff, Japan 2-byte→[0]=hi/[1]=lo), cell blink @cursor==letterID, draw (j*22+116,i*18+88). Header lng 0x13e@(256,44)JUSTIFY_CENTER|ORANGE(0x8000); name@192,68 WHITE(4); underscore if (typeTimer&2)&&len<16 @(width+192,68)ORANGE(0). SAVE lng[string]@(472,150)JUSTIFY_RIGHT(0x4000)|blink; CANCEL lng 0x141@(40,150,FONT_BIG=1). Box r(32,62,448,2)DrawOuterRect_Edge(battleSetup_Color_UI_1@0x8008d438) + r(32,39,448,130)DrawInnerRect → otMem.uiOT[0]. **Input masks CONFIRMED _one via ASM**: dpad(UP|DOWN|LEFT|RIGHT); backspace 0x40020 (TRIANGLE 0x40000|SQUARE_one 0x20, ASM lui0x4/ori0x20/and); select 0x50 (CIRCLE 0x40|CROSS_one 0x10, ASM andi 0x50). Go-Back(cursor38)→del currNameEntered[len-1] (decomp prevNameEntered[len+0x10] == currNameEntered[len-1] since currNameEntered=prevNameEntered+0x11 contiguous); type-letter(<38) nameLength<8 else soundID5; SAVE(1001)→result1+memmove; CANCEL(1000)→result-1; START→cursor1000/result-1; dpad ±13/±1 wrap (cursor<0→1001, 381001→0 "NaughtyDogBug"). soundID→soundIndexArray[6] int stride4@0x800085e4. CTR_NATIVE kb shortcuts (SubmitName_UseKeyboard/NikoGetEnterKey) = native-only, documented. FONT_BIG=1/FONT_SMALL=2 confirmed. ✓ +**SubmitName.c: 2/3 verified** (RestoreName + DrawMenu); 3 NOTE(claude) + both _one input masks proven via ASM. **DEFERRED**: SubmitName_MenuProc@0x8004b144. + +### Bug-class re-scan cont'd (2026-07-10) — SubmitName_2_MenuProc — Match (no bugs) +- **SubmitName_MenuProc @0x8004b144** — sResult=DrawMenu(0x13f); menu->rowSelected=sResult; if(sResult!=0) by g_nNameEntryMode(data10_bbb[0xd]): mode1(TimeTrial){<0→ptrDesiredMenu=menu224(DAT_800a0458); else→ToggleMode(0x31)+menuGhostSelection}; mode0(Adventure, decomp `(mode<2)&&(mode==0)`==`mode==0`){<0→CS_Garage_GetMenuPtr()+CS_Garage_ZoomOut(1); else→memmove(advProgress.name[g_abAdvProgress+0x18], prevNameEntered, 0x11) (decomp _24_4_/_28_4_/_32_4_/_36_4_/[0x28] byte copy)+ToggleMode(1)+menuFourAdvProfiles}. ✓ +**SubmitName.c FULLY VERIFIED (3/3)** — RestoreName + DrawMenu (3 NOTE(claude) + _one masks) + MenuProc. + +### Bug-class re-scan cont'd (2026-07-10) — MAIN/MainStats.c (both fns) — Match (no bugs) +- **MainStats_ClearBattleVS @0x8003d024** — zeroes standingsPoints[4][3]=12 ints; decomp nested 4×3 byte-wise (iPlayerOfs+=0xc, iRankOfs+=4 → (player*3+rank)*4) = project flat `for(i<12) standingsPoints[i]=0`. ✓ +- **MainStats_RestartRaceCountLoss @0x8003d068** — hudFlags&=0xfe; 4-team loop pointsPerTeam[i]=(teamFlags&(1<=10) FALL THROUGH to level path timesLostRacePerLev[levelID]<10→++. Offsets: timesLostRacePerLev@advProgress+0x30 (=CREDITS_N_GIN LevelID 0x30), timesLostBossRace@+0x47; caps signed `<10`/`9<`. ✓ +**MainStats.c FULLY VERIFIED (2/2)**; NOTE(claude) boss-cap fall-through + IS_BOSS_RACE sign-bit confirmed. + +### Bug-class re-scan cont'd (2026-07-10) — MAIN/MainFreeze.c (4 discrete fns) — Match (no bugs) +- **MainFreeze_GetMenuPtr @0x80039dcc** (decomp GetMenuBox) — **NOTE(claude) priority CONFIRMED**: ADVENTURE_ARENA→(rowsAdvHub[1].stringIndex=MaskBoolGoodGuy(drivers[0])?0xb:0xc, menuAdvHub) FIRST; else ADVENTURE_MODE→(ADVENTURE_CUP?advCup:advRace); else BATTLE_MODE→battle; else gameMode2&CUP_ANY_KIND?arcadeCup:arcadeRace. MaskBoolGoodGuy side-effect ONLY in arena branch (decomp `&0xffff` on u16 ret = project `!=0`). ✓ +- **MainFreeze_SafeAdvDestroy @0x800399fc** — (gameMode1&ADVENTURE_ARENA)!=0 && LOAD_IsOpen_AdvHub()!=0 → AH_Pause_Destroy. ✓ +- **MainFreeze_IfPressStart @0x80039e98** — project early-return chain = decomp big && (De Morgan): RaceFlag_IsFullyOnScreen==0, renderFlags&0x1000==0, AkuAkuHintState==0, ptrActiveMenu==0, gameMode1&(END_OF_RACE|PAUSE_ALL=0xF)==0, levelID!=MAIN_MENU_LEVEL, gameMode1&GAME_CUTSCENE==0, boolDemoMode==0, (levelID-OXIDE_ENDING)>1[<2 ret], load_inProgress(g_nBoolGameLoading)==0, gameMode2&VEH_FREEZE_PODIUM==0 → gameMode1|=PAUSE_1(1); GetMenuPtr; rowSelected=0; Show; TogglePauseAudio(1); OtherFX(1); ElimBG_Activate. ✓ +- **MainFreeze_MenuPtrQuit @0x80039908** (decomp HandleSelection, unk1e=nProcCallPhase) — phase0: row==0→GhostTape_Destroy+Loading.OnBegin(=decomp OnComplete, same offset).AddBitsConfig0|=MAIN_MENU(0x2000)+mainMenuState=MM_TITLE+RemBitsConfig0|=ADVENTURE_ARENA(0x100000)+gameMode1&~PAUSE_1(1)+RequestLoad(0x27); **NOTE(claude) ptrDesiredMenu CONFIRMED** row==1||-1→ptrDesiredMenu=GetMenuPtr() (decomp `<1 &&!=-1 ret / >=1 &&!=1 ret` = row∈{1,-1}). phase1(draw): drawStyle&~0x100, |0x100 if numPlyr>2, SafeAdvDestroy. ✓ +**MainFreeze.c: 4/9 verified**; 2 NOTE(claude) (GetMenuPtr priority, MenuPtrQuit ptrDesiredMenu) confirmed; PAUSE_ALL=0xF/MAIN_MENU=0x2000/ADVENTURE_ARENA=0x100000. **DEFERRED**: ConfigDrawNPC105@0x800379f4, ConfigDrawArrows@0x80037bc0 (NOTE ptrColor), ConfigSetupEntry@0x80037da0 (+DrawWire/RaceWheel/Namco), MenuPtrOptions@0x80038b5c (+IDENTIFY/PROCESS/DISPLAY, NOTE volume-mask), MenuPtrDefault@0x80039a44. + +### Bug-class re-scan cont'd (2026-07-10) — MAIN/MainFreeze.c (ConfigDrawArrows + MenuPtrDefault) — Match (no bugs) +- **MainFreeze_ConfigDrawArrows @0x80037bc0** — color=(frameCounter&4)?0:3; lineWidth=GetLineWidth(str,1)/2 (decomp `(x-(x>>31))>>1` toward-0 = `>>1` for width≥0, s16-trunc harmless); **NOTE(claude) ptrColor CONFIRMED**: decomp `ppiVar4=data_ptrColor+color; **ppiVar4,(*ppiVar4)[1..3]` = deref ptrColor[color]→4 gradient words = project `colorWords=data.ptrColor[color]; colorWords[0..3]` (NOT 4 different colors' byte0). 2× DecalHUD_Arrow2D (icon 0x38 grp4, left (offX-lw)-0x14 / right (offX+lw)+0x12, offY+7, primMem, tileView_UI.ptrOT=pushBuffer_UI.ptrOT, colorWords[0..3], 0, FP(1.0)=0x1000, 0x800/0). ✓ +- **MainFreeze_MenuPtrDefault @0x80039a44** — cooldownfromPauseUntilUnpause!=0→ret; draw phase(nProcCallPhase!=0): drawStyle&~0x100,|0x100 if numPlyr>2, (ARENA==0||state&NEEDS_TO_CLOSE)→ret, LOAD_IsOpen_AdvHub==0→ret, AH_Pause_Update. Handle phase: rowSelected<0→ret; stringID: 0xe(OPTIONS)→racingWheel+rowSel8; 0xb/0xc(hints,`(id-0xb)<2`)→D232.menuHintMenu(DAT_800b518c); 3(QUIT)→menuQuit+rowSel1; else cooldownFromUnpauseUntilPause=5+Hide+SafeAdvDestroy+`switch(stringID-1)`: RESTART(1)/RETRY(4)→gameMode1&~PAUSE_1+RaceFlag transition+**Loading.stage=LOAD_RESTART(-5)** (hoisted, decomp sets in all 3 return paths)+boolReplayHumanGhost(boolPlayGhost)&&ptrGhostTapePlaying→characterIDs[1]=charID+6; RESUME(2)→ElimBG_Deactivate+&~PAUSE_1+TogglePauseAudio(0)+OtherFX(1); CHANGE_CHAR(5)/LEVEL(6)/SETUP(10)→[GhostTape_Destroy(5,6)]+levID=0x27+mainMenuState+AddBitsConfig0|=0x2000(MAIN_MENU)+&~PAUSE_1; EXIT_TO_MAP(13)→AddBitsConfig0|=0x100000+RemBitsConfig0|=0xc000000+RemBitsConfig8|=8+&~PAUSE_1, !ADV_CUP{boss(gm<0)→RemBitsConfig0|=0x8c000000(⊇0xc000000, decomp `=uVar3|` equiv to project `|=`)+AddBitsConfig8|=SPAWN_AT_BOSS(1), levID=prevLEV} else{levID=GEM_STONE_VALLEY(0x19)+RemBitsConfig0|=0x1c000000+levelID=cupID+ADV_CUP(100=SCRAPBOOK 0x40|INTRO_SPACE 0x24)}; then RequestLoad(levID). ✓ +**MainFreeze.c: 6/9 verified**; 3 NOTE(claude) (GetMenuPtr priority, MenuPtrQuit ptrDesiredMenu, ConfigDrawArrows ptrColor) confirmed; LOAD_RESTART=-5/SPAWN_AT_BOSS=1/ADV_CUP=100. **DEFERRED**: ConfigDrawNPC105, ConfigSetupEntry (+DrawWire/RaceWheel/Namco), MenuPtrOptions (NOTE volume-mask). + +### Bug-class re-scan cont'd (2026-07-10) — MAIN/MainFreeze.c (ConfigDrawNPC105 + MenuPtrOptions) — Match (no bugs) +- **MainFreeze_ConfigDrawNPC105 @0x800379f4** — 12-byte colors = 3× *(u32*)color; pos[0/1]=startX/Y; loop currAngle=(u16)(currAngleStep+angle), pos[4]=startX+((radius<<3)/5 * MATH_Cos)>>12, pos[5]=startY+(radius*MATH_Sin)>>12 (decomp inlines trigApprox[a&0x3ff]+quadrant 0x400/0x800 = MATH_Cos/Sin), draw if (s16)currAngleStep!=0 (skip first), currAngleStep=(s16)(+=angleStep), pos[2/3]=pos[4/5], exit (s16)>0x1000. **s16-trunc timing equiv**: project truncates each iter, decomp keeps uint & truncates at use — equal mod 2^16 (trunc distributes over add). ✓ +- **MainFreeze_MenuPtrOptions @0x80038b5c** — boolOpenWheelConfig→ConfigSetupEntry+ret. IDENTIFYGAMEPADS loop: (ptr==0||plugged!=PLUGGED[=0, decomp isControllerConnected!=0]||(data!=PAD_JOGCON 0xe3 && !=PAD_NEGCON 0x23))→gamepadId[numGamepads++] (decomp racingWheelSlots/numRacingWheels — Ghidra names SWAPPED but logic identical); else→analogId[numAnalogs++]. bothLabels=(numGamepads&&numAnalogs); menuRowsToRemove=(4-bothLabels)-numPlyr. PROCESSINPUTS: UP(tap&1)/DOWN(tap&2)→OtherFX(0)+%9 nav (row==7→numPlyr+3 / >numPlyr+3→8); else switch(row): 0-2 sliders OptionsMenu_TestSound(row,1)+**NOTE(claude) volume-mask CONFIRMED** (howl_VolumeGet&0xff then ±4 (LEFT 4/RIGHT 8), clamp[0,0xff]); 3 Mode (0x50=CIRCLE|CROSS_one → howl_ModeSet(mode==0)); 4-7 gamepad (0x50, row-4analog.rightX else cForcedRumbleCountdown(unk44)=4+gamepadCenter=0x80, ClearInput; unk_RaceWheelConfig=0. Page2(range): same nav, CIRCLE|CROSS→boolOpenWheelConfig=0+rwd.range=Range[optIdx].hi1+ClearInput; optIdx wrap uses raceConfig_unk80084290[isNamco] max; draw lng0x228. **ALL 4 input masks _one via ASM**: entry 0x40020, LEFT|UP 0x5 (@0x8003812c), RIGHT|DOWN 0xa (@0x80038134), CIRCLE|CROSS 0x50 (@0x800381bc). Draw: !isNamco→**DrawRaceWheel** (3 wires y=analogConfigY[0]+(MATH_Sin(value)*(i-1)*0x20>>12)+0x20, ot+0xc if i!=1&&value==0x600; 2 tri from unk80084290 base=tri*6+point*2, angleSin=MATH_Sin((value*MATH_Sin(wave))>>12); BlueRect+InnerRect); isNamco→**DrawNamco** (2 wires (currValue-0x400)&0xfff, mirror i==0; baseAngle=((MATH_Sin(frameCounter<<6)*value>>12)-0x400)&0xfff; 3+9 ConfigDrawNPC105; grid row/angle wires color 0x50/0x32, cos<0?+0x3f:cos>>6; InnerRect). MATH_Sin/Cos inlined trigApprox (`&0x3c0`≡`&0x3ff` since wave low-6 bits=0). ✓ +**MainFreeze.c FULLY VERIFIED (9/9)** — 4 NOTE(claude) confirmed; all _one input masks proven via ASM across the file (0x50/0x40020/0x41020/0x5/0xa); trig/slider/color math verified. + +### Bug-class re-scan cont'd (2026-07-10) — MAIN/MainMain.c (main + StateZero) — Match (no bugs) +Decomp inlines StateZero into main's GAME_STATE_BOOT case; project splits StateZero (oxide-fix dynamic-overwrite). mainGameState: BOOT=0/FINALIZE=1/REPACK=2/RUNNING=3/ERROR=4/EXIT=5. +- **main @0x8003c58c** — InitOnceGuard_Vestigial(=__main, retail-only); loop: EXIT(5)→StopCTR (native-skipped); LOAD_NextQueuedFile+XAPauseAtEnd; switch: FINALIZE(1) ElimBG_Deactivate+RestartRaceCountLoss+Voiceline_ClearTimeStamp+gameMode1&~END_OF_RACE, RaceFlag (MAIN_MENU→OffScreen→SetFullyOnScreen else OnScreen→BeginTransition(2), decomp has dead `if(==MAIN_MENU)goto` in else), DropRain/FinalizeInit/GetNumConnected, audio state 9(arcadevisMem; if(visMem&&numPlyr) per-player clear visLeafSrc/visFaceSrc/visOVertSrc/visSCVertSrc[i]. CTR_NATIVE MainInit_InitVisMemBspListNodes = documented native addition (BSP list retention). ✓ +- **MainInit_RainBuffer @0x8003b008** — per-player copy level1->rainBuffer template→rainBuffer[i] (0x30 struct, decomp 3×16B inner loop until &ptrSpawnType1 = project 3× 4-word chunks); **SIGN-CORRECT div**: numParticles_curr /= numPlyr SIGNED (decomp `curr/(int)`), numParticles_max = (s16)((u16)max / numPlyr) UNSIGNED (decomp `(ushort)max/(byte)`). trap() = compiler div guards. ✓ +**MainInit.c: 6/11 verified**; PrimMem/OTMem size tables + RainBuffer sign-split confirmed. **DEFERRED**: JitPoolsNew@0x8003b43c, Drivers@0x8003b6d0, giant FinalizeInit@0x8003b934, VRAMClear@0x8003c248, VRAMDisplay@0x8003c310. + +### Bug-class re-scan cont'd (2026-07-10) — MAIN/MainInit.c (JitPoolsNew + Drivers) — Match (no bugs) +- **MainInit_JitPoolsNew @0x8003b43c** — sizeFactor(poolScale) ARENA→0x800/else 0x1000/MAIN_MENU→0x400; allocSize(renderBucketSize) ARENA→0x800/else 0x1000/MAIN_MENU→0x400/garage→0x800. PushBookmark; 8 JitPool_Init: thread(allocSize*3>>7, 0x48=Thread), instance(allocSize>>5, **(4-numPlyr)*-0x88+0x294 = 0x74(Instance)+0x88(InstDrawPerPlayer)*numPlyr** — consistent w/ prior idpp stride 0x88), smallStack(sizeFactor*0x19>>10,0x48), mediumStack(sizeFactor>>7,0x88), largeStack(MAIN_MENU?4:sizeFactor>>9, 0x670), particle(sizeFactor>>5,0x7c=Particle), oscillator(sizeFactor>>5,0x18), rain(sizeFactor>>9,0x28=RainLocal). ptrRenderBucketInstance=AllocMem(allocSize) [native static-scratch documented]. 3-pool self-link loop (small/medium/large, item[1].next=&item[1] via +8) = project for(i<3) JitPool-stride. Per-player clipBuffer=AllocMem(GetClipSize(levelID,numPlyr)<<2). ✓ +- **MainInit_Drivers @0x8003b6d0** — clear drivers[0..7]+numBotsNextGame=0; !(CUTSCENE|ARENA|MAIN_MENU 0x20102000)→BOTS_Adv_AdjustDifficulty; GhostReplay_Init1; IsOpen_RacingOrBattle→RB_MinePool_Init. **Reverse-spawn ASM 0x8003b760-b770**: `jal VehBirth_Player; beq v0,zero,skip; sw v0,0x24ec(s1)` = retail stores drivers[i]=VehBirth_Player(i) ONLY if !=0; project unconditional = EQUIV (drivers[] pre-cleared NULL, store-NULL==keep-NULL). **AI-gate numPlyr!=3 (ASM 0x8003b7a8 `beq v1,3,skip`) vs project <3 = EQUIV SIMPLIFICATION**: traced all numPlyr — retail !=3 outer + inner 1?8/2?6/else4 (numPlyr=4→numDrivers=4, 4<4 no-spawn) vs project <3 outer + inner 1?8/else6 → IDENTICAL AI counts (1→7,2→4,3→skip,4→no-AI); project dropped dead numPlyr=4 path. masks 0x2c122020(excl)/0x480000(ARCADE|ADVENTURE) confirmed. Boss(gm<0)→BOTS_Driver_Init(1)=project numPlyr+1; cup(ADVENTURE_CUP&&cupID==4)→i=1..4=project numPlyr+4 (both numPlyr=1). numBots→EngineAudio_InitOnce(0x10/0x11,0x8080); MAIN_MENU→fill drivers[numPlyr..3]; (CUTSCENE|TIME_TRIAL|MAIN_MENU 0x20022000)==TIME_TRIAL(0x20000)→GhostReplay_Init2+GhostTape_Start (CTR_NATIVE model-publish documented). ✓ +**MainInit.c: 8/11 verified**; JitPool struct sizes (Instance 0x74/IDPP 0x88) + Drivers AI-gate equivalence confirmed; numPlyr!=3/<3 documented as equiv simplification. **DEFERRED**: giant FinalizeInit@0x8003b934, VRAMClear@0x8003c248, VRAMDisplay@0x8003c310. + +### Bug-class re-scan cont'd (2026-07-10) — MAIN/MainInit.c (VRAMClear + VRAMDisplay + giant FinalizeInit) — Match + **Fix 141** — FILE COMPLETE +- **MainInit_VRAMClear @0x8003c248** — SetDefDrawEnv(0,0,0x400,0x200)+dfe=1+PutDrawEnv; GP0 fill block (commands.a=0x3ffffff tag, b1/b2=0x02000000 fillcode+RGB0, x0/y0/w0x3ff/h0x1ff)→DrawOTag; then y=0x1ff/h=1→DrawOTag. Native static struct documented. ✓ +- **MainInit_VRAMDisplay @0x8003c310** — x{0,0x100}/y{0,0x128}, 2×2 loop: srcRect(x[i]+0x200,0x10c,0x100,0xd8), SetDrawMove(dst x[i],y[j]), tag|=0xffffff, DrawOTag+DrawSync. Native Platform_PresentVRAMDisplay documented. ✓ +- **MainInit_FinalizeInit @0x8003b934** — doorAccessFlags=0 (ASM `sw zero,0x8008d728` is FIRST write, no gameMode1 write); **project's `gameMode1 &= ~WARPBALL_HELD` is a PROJECT-ADDED Naughty-Dog-Bug fix NOT in retail** (intentional documented divergence, kept). PushBookmark; tileView[0] distToScreen PREV/CURR=0x100. **NOTE(claude) threadBuckets memset 0x168 CONFIRMED** + boolCantPause=1 for STATIC(3)/WARPPAD(5)/CAMERA(0xf)/HUD(0x10) (retail's redundant =0 on others = no-op after memset; debug names/pRectCol colours omitted=documented). particleList/numParticles=0; deadcoed RNG 0x30215400/0x493583fe; DecalMP[12] (inst=0, data[0]=1000 [decomp byte-split 0xe8/0x03], ptrOT1/2=0); JitPoolsReset; trackLength_x_numLaps_x_8=(u16)restart_points[0].distToFinish*numLaps*8; Drivers. numPlyr=MAIN_MENU?1:numPlyrCurrGame; PushBuffer_Init 4 view+UI(rot[0]=0x800,SetPsyqGeom/MatrixVP); hudFlags&2→UI_INSTANCE_InitAll; unk1cac[4]=2. Per-driver 0..7: instSelf->scale=0xccc, ifuncPtrs[DRIVER_FUNC_INIT=0]=VehPhysProc_FreezeEndEvent_Init, RequestedHint=hintId, boolInterruptWarppad=arg (write-order indep). ✓ +**MainFrame.c: 5/11 verified**. **DEFERRED**: giant GameLogic@0x80034d54, VisMemFullFrame@0x800357b8 (+ReplacePackedVisList/OrPackedVisList/VisMemHasQuad/VisMemAddDriverPVS, NOTE visOVertSrc null-guard). (syms926.txt grep path errored this cycle — addresses from project NOTE comments.) [NOTE: Bash CWD drifted to build/ after Fix 141 build — use absolute /d/source/c/saphi-ctr-native/metadata/... for syms.] + +### Bug-class re-scan cont'd (2026-07-10) — MAIN/MainFrame.c (VisMemFullFrame + VisMemAddDriverPVS + 3 inlined helpers) — Match (no bugs) +- **MainFrame_VisMemFullFrame @0x800357b8** — guards visMem1/level/numPlyr; per-camera (camDC folded @db[0].drawEnv.ofs+camByteOfs stride 0xdc; driver=drivers[i]): flags&=~0x4000. **LEAF** (visLeafSrc@camByteOfs): src==0→driver underDriver(+0x350)->pvs(+0x44)->visLeafSrc(+0)!=0 → visMem1->visLeafSrc[i]=src + ReplacePackedVisList(visLeafList[i], (numBspNodes+0x1f>>5)<<2); else changed→same. **FACE** (visFaceSrc, pvs+4, numQuadBlock): src==0→driverPVS->visFaceSrc; changed→copy + own-path decision: clear 0x2000 if (quad==0||pvs==0||pvs->visLeafSrc/visFaceSrc/visInstSrc[+0/4/8]==0||VisMemHasQuad(quadIdx=(quad-ptrQuadBlockArray) bit in visFaceList)), else set 0x2000; 0x2000→ApplyDriverPVS+0x4000. (flags&0x5000)==0x1000→ApplyDriverPVS. **INST**: cameraMode(+0x12)==0 && 0x2000 && driverPVS->visInstSrc!=0 → camDC->visInstSrc(+8)=it. **VERT** (configFlags&4): OVert(unk5,numWaterVertices)/SCVert(unk_170,numSCVert) cache vs stored src, changed→copy, unchanged-NULL→memcpy default. **NOTE(claude) null-guard CONFIRMED**: retail changed-source branch copies from camDC->visOVertSrc/visSCVertSrc with NO null check (PSX reads addr 0 harmlessly); project adds `if(src!=NULL)` guard + falls back to level->unk5/unk_170 — native-safety divergence, equiv for valid src. ✓ +- **MainFrame_VisMemAddDriverPVS @0x80035684** (decomp CTR_VisMem_ApplyDriverPVS) — quad=driver->underDriver, quad!=0&&pvs=quad->pvs!=0; visLeafSrc!=0→OrPackedVisList(visLeafList[i], numBspNodes); visFaceSrc!=0→OrPackedVisList(visFaceList[i], numQuadBlock). ✓ +- **helpers**: ReplacePackedVisList (src&1==0?memcpy:MaybeThunk1=CTR_VisMem_RleCopy(src&~3)); OrPackedVisList (src&1==0?MaybeThunk3=CTR_VisMem_WordOr:MaybeThunk2=CTR_VisMem_RleOr); VisMemHasQuad (visFaceList[qi>>5]&(1<<(qi&0x1f))). ✓ +**MainFrame.c: 7/11 verified**; NOTE(claude) visOVert/visSCVert null-guard confirmed (native-safety). **DEFERRED**: giant GameLogic@0x80034d54 (NOTEs: driver-tracker pDriverP2, PAUSE-bucket bound, pause/unpause flow). + +### Bug-class re-scan cont'd (2026-07-10) — MAIN/MainFrame.c GameLogic@0x80034d54 — Match (no bugs) — FILE COMPLETE +Giant per-frame tick. !paused(gameMode1&PAUSE_ALL 0xf==0): blur clock loop per driver-thread (clockSend-- [+0xff byte], clockFlash[forcedJump_trampoline+1]==0→clockReceive/clockSend/clockEffectEnabled→DISPLAY_Blur_Main(pushBuffer,uVar3) else Blur(-uVar3)+clockFlash-- [DISPLAY_Blur native-gated=retail], actionsFlagSet&ACTION_RACE_FINISHED 0x2000000→clockReceive=0). timer++/framesInThisLEV++/unk1cc4[4]=0; elapsedTimeMS=(Timer_GetTime_Elapsed(clockFrameStart)<<5)/100 clamp: <0→0x20, >0x40→0x40, prevFrame&0xf→0x20; msInThisLEV+=. trafficLightsTimer<1 & !DEBUG_MENU: frozenTimeRemaining<1 & !END_OF_RACE→elapsedEventTime+=; else frozen-=elapsedTimeMS (<0→0, else timer%6==0→OtherFX_Play_LowLevel(0x40,0, timer%0xc==0?0x8c9080:0x8c8880)); else elapsedEventTime=0. CTR_CycleTex_AllModels(PLYROBJECTLIST + level1 models). **NOTE(claude) driver-tracker CONFIRMED** (CTR_NATIVE): pTopAttacker/pDriverP2 loop — pDriverTmp=pDriverP2 start, driverID==0→top=cur/P2=tmp, else driverID==1→P2=cur, tmp=top, native `if(top==NULL)continue` guard [retail derefs low RAM], (u8)cur->numTimesAttacking<(u8)top->numTimesAttacking→top=cur/P2=tmp. Quip: top&&P2 && (P2->numTimesAttacking-top->numTimesAttacking) > top->quip2(unk_4F0_4F8+2)→set. **NOTE(claude) dispatch bound 0x11=PAUSE CONFIRMED** (buckets PLAYER..HUD 0..0x10, PAUSE 0x11 excluded, not NUM_BUCKETS 0x12): each bucket (!DEBUG_MENU||boolCantPause&1)&&thread!=0 → bucket0(PLAYER): VehPickupItem_ShootOnCirclePress per thread + funcPtrs[0..DRIVER_FUNC_COUNT-1=0xc] dispatch (funcThTick==0→object->funcPtrs[iProc](thread,object)); ThTick_RunBucket. BOTS_UpdateGlobals/GhostTape_WriteMoves(0)/unk1cc4[4]=(*10000)/0x147e/Particle_UpdateAllParticles [native-gated=retail]. paused→ThTick AKUAKU(0xe). Common: IsOpen_RacingOrBattle→(!paused RB_Bubbles_RoosTubes)+(BURST 7 thread→RB_Burst_DrawAll); PROC_CheckAllForDead; !paused Audio_Update1; gameMode1_prevFrame=gameMode1; GAMEPAD_GetNumConnected. **NOTE(claude) pause/unpause flow CONFIRMED**: !END_OF_RACE→ paused→cooldownfromPause==0 & ptrActiveMenu!=racingWheel/hintMenu & AnyPlayerTap&BTN_START(0x1000)→ClearInput+&~PAUSE_1+TogglePauseAudio(0)+OtherFX(1)+SafeAdvDestroy+ElimBG_Deactivate+HideMenu+cooldownUntilPause=5 (else cooldown--); else cooldownUntilPause==0 & !(GAME_CUTSCENE|END_OF_RACE|MAIN_MENU)&&!ptrActiveMenu&&!AkuAkuHintState&&!FullyOnScreen&&numPlyr→per-player ((numConn&&HaveAllPads(numPlyrNextGame)==0[native]&&!paused)||gamepad[i].START)&&overlayIndex_Threads!=-1→gameModeEnd=(gameMode1&0x3e0020)|PLAYER_GHOST_BEAT(=PAUSE_1=1)+IfPressStart+cooldownfromPause=5. END_OF_RACE&timerEndOfRaceVS==0: gameModeEnd flags **VALUE-match despite naming** (AKU_SONG=NEW_NAME=0x1000000, CRYSTAL_CHALLENGE=NEW_HIGH_SCORE=0x8000000, PAUSE_2=DRAW_HIGH_SCORES=2): !NEW_NAME→ !NEW_HIGH_SCORE→(cooldown==0 ret) / NEW_HIGH_SCORE & cooldown==0→!DRAW_HIGH_SCORES ret, SubmitName_DrawMenu(0x140)→0 ret / 1→ToggleMode(0x41)+menuWarning2+|=NEW_NAME [native] / -1→newHighScoreIndex=-1+&~(NEW_BEST_LAP 0x4000000|NEW_HIGH_SCORE); cooldown--. VS-quip: !ARCADE_MODE & timerEndOfRaceVS<0x96→UI_VsQuipDrawAll+VsWaitForPressX [native], >0x1e→--; else =0. ✓ +**MainFrame.c FULLY VERIFIED (11/11)**; 3 NOTE(claude) (driver-tracker, dispatch-bound 0x11, pause/unpause) + native null-guards confirmed; gameModeEnd flag value-aliases (AKU_SONG/NEW_NAME etc.) resolved. + +### Bug-class re-scan cont'd (2026-07-10) — UI/UI_RaceFlow.c (all 3 fns) — Match + **Fix 142** — FILE COMPLETE +- **UI_RaceEnd_GetDriverClock @0x8005572c** — actionsFlagSet&0x40000 latch; timeElapsed!=0→distanceDriven=distanceDriven*100/timeElapsed (signed); numTimesMissileLaunched<4→NumMissilesComparedToNumAttacks=-1 else ((u8)numTimesAttacking<<0xc)/numTimesMissileLaunched (UNSIGNED); numTimesAttacked=Σ(u8)numTimesAttackedByPlayer[0..7]; driverRank==0→TimeWinningDriverSpentLastPlace=timeSpentInLastPlace. trap()=compiler div guards. ✓ +- **UI_RaceStart_IntroText1P @0x80055840** — textID: RELIC_RACE(0x4000000)→0xb8; CRYSTAL_CHALLENGE(0x8000000)→0xbe(BONUS); ADVENTURE_CUP(0x10000000)→advCupStringIndex[cupID]; CUP_ANY_KIND(gameMode2&0x10)→arcadeVsCupStringIndex[cupID]; else ARCADE_MODE(0x400000)→0x4e / TIME_TRIAL(0x20000)→0x4d / gm1>=0→0xb7(TOKEN_RACE gameMode2&8→0x176) / boss(gm1<0)→lng_challenge[bossID]. transition=0x1e-cameraDC[0].transitionFrame if <0x1f; !FullyOnScreen→ non-cup: title@rect.x+rect.w/2((w<<0x10)>>0x11),rect.y-(t-7) / cup: title@-6 + "TRACK n/4"(lng 0x175, trackIndex+1)@0x100 FONT_SMALL@+0xb; level name lng[metaDataLEV[levelID].name_LNG]@rect.y+rect.h+t-0x17; 4 bars (battleSetup_Color_UI_1, h=2 @rect.y-(t-0x1c) & rect.y+rect.h+t-0x1e, then &0xff000000 h=0x1e @rect.y-t & +rect.h+t-0x1e). tileView[0].rect=pushBuffer[0].rect; DrawSolidBox primMem folded. ✓ +- **UI_RaceEnd_MenuProc @0x80055c90** — nProcCallPhase(unk1e)!=0→drawStyle&~0x100 |0x100 if numPlyr>2; else rowSelected>=0, option=rows[row].stringIndex, option!=9→HideMenu, framesSinceRaceEnded(pCallback)=0, numIconsEOR(pInlineData[0xe])=1. 0xc9(PRESS-X)→menuReadyToPass|=1. switch: 3(QUIT)→GhostTape_Destroy+mainMenuState=MM_TITLE+RequestLoad(0x27); 4(RESTART)→hudFlags&0xfe+RaceFlag transition+Loading.stage=LOAD_RESTART(-5)+howl_StopAudio(1,0,0)+ (gameModeEnd&PLAYER_GHOST_BEAT→boolReplayHumanGhost=1+memcpy(ptrGhostTapePlaying, GhostRecording.ptrGhost, 0x3e00) [decomp inlined aligned/unaligned word-copy]+characterIDs[1]=charID+6+boolGhostsDrawing=0); 5/6(CHAR/LEVEL)→GhostTape_Destroy+mainMenuState=option-4+AddBitsConfig0|=0x2000+RequestLoad(0x27); 9(SAVE GHOST)→framesSinceRaceEnded=0x3f9+ToggleMode(0x31)+**→Fix 142**; 10(SETUP, NOTE(claude) in-menus flag confirmed present)→mainMenuState=MM_BATTLE_SETUP+AddBitsConfig0|=0x2000+RequestLoad(0x27); 0xd(EXIT MAP)→AddBitsConfig0|=ADVENTURE_ARENA(0x100000)+RemBitsConfig8|=TOKEN_RACE(8)+RemBitsConfig0|=CRYSTAL|RELIC(0xc000000), ADVENTURE_CUP→+|=ADVENTURE_CUP(0x10000000)→0x1c000000+RequestLoad(GEM_STONE_VALLEY 0x19), boss(gm1<0)→+ADVENTURE_BOSS(0x80000000)→0x8c000000+AddBitsConfig8|=SPAWN_AT_BOSS(1), RequestLoad(prevLEV). **Fix 142**: case 9 wrote `sdata->ptrActiveMenu=&menuGhostSelection` but decomp writes `ptrDesiredMenuBox`(=ptrDesiredMenu, distinct field @regionsEXE 4272 "becomes null after ptrActiveMenu set" — core swaps desired→active); writing ptrActiveMenu directly bypassed the RECTMENU transition (same bug-class as MainFreeze_MenuPtrQuit). Changed to ptrDesiredMenu, built clean. ✓ +**UI_RaceFlow.c FULLY VERIFIED (3/3)**; **Fix 142** (case-9 ptrActiveMenu→ptrDesiredMenu) applied; NOTE(claude) case-10 in-menus-flag confirmed present. + +### Bug-class re-scan cont'd (2026-07-10) — RaceFlag.c (12 of 14 fns) — Match (no bugs) +RaceFlag field map (decomp g_aGpuDmaQueue[1].pInlineData ghidra-misattrib): Position=[0xb]._0_2_, TransitionSpeed=[0xb]._2_2_, AnimationType=[10], DrawOrder=[0xd], LoadingTextAnimFrame=[0xe], DrawInitialized=[0xc]._2_2_. +- **RaceFlag_MoveModels @0x80043e34** — frameIndex<0→0, >numFrames→0x1000; midpoint=numFrames/2 (decomp (numFrames-(numFrames>>31))*0x8000>>16 = signed /2); frameIndex=5000 wrap-check). ✓ +- **RaceFlag_IsTransitioning @0x80043f44** — Position!=0/-5000/5000 && renderFlags&0x1000. ✓ +- **RaceFlag_SetDrawOrder @0x80043f8c** — DrawOrder=(arg!=0)?1:-1. ✓(inspection) +- **RaceFlag_BeginTransition @0x80043fb0** — dir1→LoadingTextAnimFrame=-1/Position=5000/AnimType=0; dir2→SetDrawOrder(0)/Position=0/AnimType=2; renderFlags|=0x1000. ✓ +- **RaceFlag_SetFullyOnScreen @0x8004402c / SetFullyOffScreen @0x80044058** — AnimType=0/LoadingTextAnimFrame=-1/Position=0(5000)/renderFlags|=(&~)0x1000. ✓(inspection) +- **RaceFlag_SetCanDraw @0x80044088 / GetCanDraw @0x80044094 / ResetTextAnim @0x80044290** — CanDraw=arg / return CanDraw / LoadingTextAnimFrame=-1. ✓(inspection) +- **RaceFlag_GetOT @0x800440a0** — DrawInitialized latch; AnimType!=1: type0→(Position<0→5000)+TransitionSpeed=300, Position!=0(=decomp `<1` equiv post-guard): <8→0 else slide Position-=((u16)Position>>3)*elapsedTimeMS>>5 clamped(-1 floor); Position==0→DrawOrder!=1&&!=-1→return tileView[0].ptrOT+0xffc(FarthestDepth), ==-1→DrawOrder=0; type2→TransitionSpeed<1000→+=(elapsedTimeMS*10)>>5, Position>-5000→-=(TransitionSpeed>>2)*elapsedTimeMS>>5 else snap(Position=5000/AnimType=0/renderFlags&~0x1000); return ot_tileView_UI[swapchainIndex](ClosestDepth). ✓ +**RaceFlag.c: 12/14 verified**; field mappings + GetOT slide-FSM confirmed. **DEFERRED**: DrawLoadingString@0x800442a0 (NOTE unsigned-glyph/strlen/Transition-wrap), DrawSelf@0x800444e8 (NOTE column-bound 35, primMem guardEnd bound). + +### Bug-class re-scan cont'd (2026-07-10) — RaceFlag.c DrawLoadingString@0x800442a0 — Match (no bugs) +- **RaceFlag_DrawLoadingString** — pushBuffer_UI.ptrOT swapped to otSwapchainDB[swapchainIndex] then restored; s=lngStrings[LNG_LOADING 0x231]; **NOTE(claude) strlen CONFIRMED** (iStrLen=strlen(s), not hardcoded 10); iPosX base=(RaceFlag_Transition&0xffff) - GetLineWidth/2 (decomp (s16)/2 = project >>1 for width≥0); RaceFlag_Transition(pInlineData[0xf]): Loading.nStage==-1 & >-1000→-=0x28, else=0. Per-char drop-in wave: iAnimBase=frame*-0x3c+0x23c step+0xf0; frame<0→0x23c(hidden), >4→0x100, >0x4a→(0x4b-frame)*0x3c+0x100, >0x4f→0x23c; **NOTE(claude) unsigned-glyph CONFIRMED** (bChar=*s u8, <4→2-byte len=2); **NOTE(claude) Transition-wrap CONFIRMED** (DrawLineStrlen X=(s16)(iPosX+offset) — masked Transition ~64500 wraps negative, slides text off-left) if (s16)offset!=0x23c; iPosX+=GetLineWidthStrlen; frame-=4/char. Post: frame<0x50→LoadingTextAnimFrame+=max(elapsedTimeMS>>5,1) else =-1 (Loading.nStage 6/7→0). ✓ +**RaceFlag.c: 13/14 verified**; 3 NOTE(claude) (unsigned-glyph, localized-strlen, Transition-wrap X-trunc) confirmed. **DEFERRED**: DrawSelf@0x800444e8 (NOTE column-bound 35 + primMem guardEnd overflow guard). + +### Bug-class re-scan cont'd (2026-07-10) — RaceFlag.c DrawSelf@0x800444e8 — Match (no bugs) — FILE COMPLETE +- **RaceFlag_DrawSelf** (GTE checker-flag renderer, 36col×10row×3vert mesh) — CanDraw==0→ret; LoadingTextAnimFrame>=0 (or Loading.nStage 6/7→armed 0)→DrawLoadingString; CopyLoadStage=Loading.nStage; GetOT; GTE setup (matrixTitleFlag, GeomOffset 0x100/0x78, ldH 0x100). Anim state checkerFlagVariables[0..4]={phase0,amp1,phase2,amp3,timeAccum}: timeAccum+=amp3*elapsedTimeMS, overflow(>>5>0xfff)→reseed amp1=(sin(phase0)*0x20>>0xd)+0x96/amp3=(sin(phase2)*0x40>>0xd)+0xb4 (phase0+=0x200/phase2+=200). MathSinInline=trigApprox[a&0x3ff]+quadrant (matches). Mesh vz=iProjY+(sin*0x20>>0xd), gte_ldv3/rtpt/stsxy3 ping-pong scratchpad (0x78-byte L/R, toggle^=1). **NOTE(claude) column-bound 35 CONFIRMED**: ASM loop `column++; if(0x22>2)+(iFlatIdx>>2))&1: boolDark=parity!=0→CalculateBrightness dark(sine*-55[-0x37]+0x140000>>0xd)/light(sine*-125[-0x7d]+0x1fe000>>0xd), gouraud L=lightL/R=iLightR. addPrim(ot,p). RaceFlag_ElapsedTime+=elapsedTimeMS*100. **primMem-guard NOTE — ASM-RESOLVED (safe native divergence, NO fix)**: ASM 0x80044cf0-0x80044d10 `lw curr@0x80/endMin100@0x84; sltu; bne(skip alloc if endMin100guardEnd){cursor=p;return}` truncates+skips ElapsedTime instead. BOTH prevent the overflow (original port had none). Project's return is safer/cleaner native choice; differs only in rare buffer-full-mid-flag edge (project's NOTE "stop when guard hit" slightly mischaracterizes retail's freeze+continue). Left as-is — matching freeze-overwrite exactly needs struct-cursor restructure for no functional gain. ✓ +**RaceFlag.c FULLY VERIFIED (14/14)**; all NOTE(claude) confirmed (column-bound 35; primMem-guard resolved as safe native divergence via ASM); GTE/trig/brightness math verified. + +### Bug-class re-scan cont'd (2026-07-10) — RefreshCard.c (4 encode/decode fns) — Match (no bugs) +- **RefreshCard_GhostEncodeByte @0x80046b60** — 6-bit→base64: <10→+0x30, <0x24→+0x37, <0x3e→+0x3d, ==0x3e→'-'(0x2d), else '_'(0x5f). ✓ +- **RefreshCard_GhostDecodeByte @0x80046bc0** — inverse: '-'→0x3e, '_'→0x3f, <':'→-0x30, <'['→-0x37(0xffc9), else -0x3d(0xffc3) (s16). ✓ +- **RefreshCard_GhostDecodeProfile @0x80047034** — 6 chars@save[0xd..0x12]→packed; characterID=packed&0xf, trackID=(packed>>4)&0x1f, trackTime=(packed>>9)&0xfffff, memcardProfileIndex=(u32)packed>>29 (=decomp convoluted ushort extraction of bits 29-31); alwaysOne lo=0, profile_name=save[0..0x14] (0x15B), trackID hi byte=0. ✓ +- **RefreshCard_GhostEncodeProfile @0x80046c30** — packed=characterID|(levelID<<4)|(time<<9 clamp 0x8c9ff)|(slotHint<<0x1d); 6 EncodeByte chars→s_BASCUS_94426G[0xd..0x12], [0x13]=0; slot-retry (slotHint+1)&7 until unique vs g_aGhostProfileMemcard[].pProfile_name; title="::