Skip to content

Fix Master Hand's moves breaking and the crash on defeat#50

Merged
JRickey merged 4 commits intomainfrom
agent/boss-event32-cache-eviction
May 1, 2026
Merged

Fix Master Hand's moves breaking and the crash on defeat#50
JRickey merged 4 commits intomainfrom
agent/boss-event32-cache-eviction

Conversation

@JRickey
Copy link
Copy Markdown
Owner

@JRickey JRickey commented May 1, 2026

Summary

This branch fixes two distinct Master Hand boss-fight bugs:

  1. Master Hand's moves breaking — okutsubushi (palm slap), okupunch, drill leaving him stuck behind the stage. Root cause: port_aobj_register_halfswapped_range only evicted one of three Event32 walker caches on figatree heap reload, so re-entry into the stage walked stale halfswapped streams. Fix: evict sUnswappedHeads and sRejectedHeads alongside sUnhalfswappedVisited whenever a heap range is re-registered.
    See docs/bugs/boss_event32_cache_invalidation_2026-05-01.md.

  2. Audio-thread SIGSEGV on defeat — crash ~1s into the death animation in the FGM bytecode interpreter. Root cause: D_8009EDD0_406D0 is defined as ALWhatever8009EDD0 in n_env.c but extern'd as alSoundEffect in sc1pgame.c. On N64 the two views aligned at offset 0x28, making the boss-defeat sfx_max = 0 an intentional FGM-gate type-pun. On LP64 pointer widening shifts the layouts independently — sfx_max now lands inside the low 16 bits of fgm_table_data (a u8 **), so the write zeros half of a live pointer and the audio thread later faults dereferencing it. Fix: explicit #ifdef PORT accessors portAudioSaveAndBlockFGMs / portAudioRestoreFGMs that target fgm_ucode_count directly.
    See docs/bugs/boss_defeat_sfx_max_typepun_2026-05-01.md. Same family as the OSContPad shadowing bug.

Both fixes preserve the non-PORT decomp paths verbatim so byte-matching builds are undisturbed.

Commits

  • 2b25c69 Evict event32-walker caches on figatree heap reload
  • 68f3b6a PORT_START_BOSS dev shortcut (off by default; useful for future boss-stage debugging)
  • 2d4020f Fix audio-thread SIGSEGV at boss defeat — sfx_max type-pun trap
  • 0c6a9e9 Remove obsolete endgame-SIGSEGV handoff stub

Test plan

  • Master Hand performs okutsubushi, okupunch, and drill correctly across multiple stage entries
  • Master Hand defeat sequence completes without audio-thread crash
  • Post-defeat camera pan and credits transition reach completion
  • No regressions on attract demo / 1P stage flow

🤖 Generated with Claude Code

JRickey and others added 4 commits May 1, 2026 09:20
port_aobj_register_halfswapped_range was called every time a fighter
figatree file was loaded (`reloc_animations/FT*` / `reloc_submotions/FT*`
match in lbreloc_bridge.cpp).  The walker behind it tracks three sets of
"already processed" verdicts keyed on raw heap pointer:

- sUnhalfswappedVisited: per-block one-shot fixup tracker for spline
  data (interp.c).
- sUnswappedHeads: event32 stream entry points the walker successfully
  un-halfswapped.
- sRejectedHeads: stream entry points the walker decided not to touch
  (didn't look like halfswap-corrupted event32).

The register call evicted only the first cache when a new range was
registered; the other two retained stale entries from a previous
animation's load at the same heap addresses.  Fighter figatree heaps
are bump-reset between status changes, so cache entries from a prior
load alias bytes that have since been overwritten — the walker
short-circuits at the top of walk(), the freshly-loaded halfswap-
corrupted bytes never get fixed up, and gcParseDObjAnimJoint reads a
command word with an out-of-range opcode and bails with "UNHANDLED
opcode=64 — ending anim".

Surfaced as Master Hand's okutsubushi (palm slam), okupunch (rocket
punch), and drill animations rendering wrong: TopN.translate.z was
pinned at the -15000.0F set by ftBossOkutsubushiSetStatus and never
integrated forward, because TransN.translate stayed at (0,0,0) every
frame after the parser bailed.  The X-chase logic in the boss state
ProcPhysics still ran (vel_air.x = -40 / +40), so the boss moved across
but never forward.

Fix factored into a small lambda evict() applied to all three caches.
Same shape as the existing project_fixup_idempotency_heap_reuse
pattern: cache keyed on pointer, heap reuses addresses, fresh bytes
skip fixup unless eviction fires at the load site.

Systemic audit confirmed there are no other at-risk caches in port/.
Seven pointer-keyed caches total across the port:
- 3 in port/port_aobj_fixup.cpp — now all evicted via this register call
- 4 in port/bridge/lbreloc_byteswap.cpp — already evicted via
  portEvictStructFixupsInRange called from lbRelocLoadAndRelocFile.
The architecture is sound now: one eviction call site per cache
bucket, every cache wired through it.  Going forward, a new
pointer-keyed cache in either file must be added to the existing
eviction site in the same change.

Initial wrong-hypothesis detour: a ftPhysicsGetRootMotionJoint
(TransN→XRotN→YRotN) resolver was written to "fix" the (false) belief
that fp->joints[TransN] was NULL for the boss.  Per-frame telemetry
showed TransN was non-NULL and the right joint — the bytes were just
wrong.  That whole change set is discarded.

Also enriches the gcParseDObjAnimJoint UNHANDLED log line with the raw
u32 word value, so a similar corruption will be diagnosable from log
alone in the future.

See docs/bugs/boss_event32_cache_invalidation_2026-05-01.md for full
write-up including the systemic-audit findings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
scmanager.c gets a #define PORT_START_BOSS that hard-defaults the boot
scene to nSCKind1PGame at nSC1PGameStageBoss with fkind=Mario, plus an
SSB64_SPGAME_FKIND env-var override alongside the existing
SSB64_START_SCENE / SSB64_SPGAME_STAGE.  Off by default; uncomment the
#define to enable.  Useful for reproducing boss-only bugs without
manually advancing through menus on every iteration — load-bearing for
the okutsubushi-fix verification cycle.

docs/boss_endgame_segfault_handoff_2026-05-01.md captures what's known
about the separate SIGSEGV-on-boss-defeat issue the user mentioned next
on the queue, and points to the saved log
(logs/boss-okutsubushi-FIXED-2026-05-01.log) so the next session
investigating the death-status transition has a starting point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Defeating Master Hand crashed the audio thread ~1s after the death
animation started, inside the FGM bytecode interpreter
(func_80027460_28060) dereferencing a NULL/garbage ucode pointer.

Root cause is an N64-only struct type-pun that no longer holds on LP64.
D_8009EDD0_406D0 is defined in n_env.c as ALWhatever8009EDD0 (with
`u8 **fgm_table_data`, `u16 fgm_ucode_count`) but declared in sc1pgame.c
as `extern alSoundEffect` (with `u16 sfx_max`).  On N64 the two struct
layouts coincidentally aligned at byte offset 0x28: alSoundEffect's
sfx_max (u16) overlaid ALWhatever8009EDD0's fgm_ucode_count (u16).  The
boss-defeat sequence's

    sSC1PGameBossDefeatSoundTerminateTemp = D_8009EDD0_406D0.sfx_max;
    D_8009EDD0_406D0.sfx_max = 0;
    /* … cinematic plays … */
    D_8009EDD0_406D0.sfx_max = sSC1PGameBossDefeatSoundTerminateTemp;

was an N64-only type-pun for "save/zero/restore the FGM playback gate" —
zeroing the count made func_800269C0_275C0 short-circuit on `id >= count`
and refuse to queue any more FGMs while the cinematic ran.

On LP64 the layouts diverge.  In ALWhatever8009EDD0, ALPlayer grew 16→32
bytes and three `**` fields each grew 4→8 bytes, shifting fgm_ucode_count
to offset 0x40.  In alSoundEffect, four `void *` fields each grew 4→8
bytes, shifting sfx_max to offset 0x38.  At LP64 offset 0x38 in
ALWhatever8009EDD0 lives the LOW 16 BITS of fgm_table_data — a u8**
pointer.  The boss-defeat write zeroed those 16 bits, leaving the high
48 intact: the array pointer became a wild address.  Every subsequent
`D_8009EDD0_406D0.fgm_table_data[id]` read garbage; one of those values
landed in an FGM node's `unk20` ucode pointer; the audio thread's next
bytecode-parse iteration faulted on `*ucode++`.

Diagnostic fingerprint: fgm_table_data observed transitioning from
0x100b251b8 → 0x100b20000.  Low 16 bits cleared, high 48 unchanged —
exactly the shape of a u16 write of 0 at the field's address, which
pinpointed the writer to alSoundEffect's sfx_max in sc1pgame.c.

Fix: two `#ifdef PORT` accessors in n_env.c that operate on
fgm_ucode_count directly (the field that actually carried the FGM-gate
semantic on N64) — `portAudioSaveAndBlockFGMs(out)` and
`portAudioRestoreFGMs(saved)`.  sc1pgame.c routes through them under
`#ifdef PORT`.  The non-PORT branch keeps the original alSoundEffect-
typed accesses so IDO byte-matching on the decomp source isn't
disturbed.

Class: LUS-vs-decomp typename shadow.  Same family as
oscontpad_lus_sizeof_overrun_2026-04-24 (libultraship's larger OSContPad
overrunning the decomp's 24-byte stack buffer).  General rule: any global
declared with two different struct types across translation units is OK
on N64 if the layouts coincide there, but LP64 widening of pointers/`**`
fields can shift the layouts independently in each view, breaking the
alignment.  Look for `extern X Y;` in one TU and `Z Y;` in another with
`X != Z` — those need explicit per-platform accessors or a single shared
type.

Detailed write-up at docs/bugs/boss_defeat_sfx_max_typepun_2026-05-01.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The handoff doc speculated the post-defeat crash was a cutscene/credits
loading issue. Root cause turned out to be the sfx_max type-pun trap,
fixed in 2d4020f. The bug write-up at
docs/bugs/boss_defeat_sfx_max_typepun_2026-05-01.md supersedes this stub.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JRickey JRickey merged commit 72d7cc6 into main May 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant