From 4c455671584c8aaa23e00a3b0ba82d4f99976606 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:21:43 +0000 Subject: [PATCH] widescreen spawn: drop the wide cursor out of play so a checkpoint restart rescans kind-3 enemies Launch Octopus's mid-boss ($21, kind 3, x $111E) never spawned after a death and checkpoint restart (coop-physics-20261005 trace): kind-3 records belong to the wide spawn pass alone, whose host cursor was reset only on a state load or a stage change. A death restarts the level from its checkpoint in the same stage, the guest rebuilds its own cursor, and the stale host cursor skipped every kind-3 enemy between the checkpoint and the place of death. Not a co-op fault; single player is affected the same way. The cursor now persists only while the stage is in play ($D1/$D2/$D3 = 2/4/4). It is dropped at each wide scan and at frame end in the death, setup, arrival and clear phases, and re-syncs to the guest's cursor on the first scan back in play. Derived from guest RAM only, so state loads and rollback agree. Policy test added. The runtime file compiles with the project's flags; the fix has not been replayed in the running game. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FZA3wpU6vWHFYP1HLGBunZ --- docs/coop-port.md | 41 ++++++++++++++++++++++++++++++++++++ src/mmx_rtl.c | 9 ++++++++ src/mmx_wide_policy.c | 8 +++++++ src/mmx_wide_policy.h | 11 ++++++++++ tests/mmx_wide_policy_test.c | 37 ++++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+) diff --git a/docs/coop-port.md b/docs/coop-port.md index 200259d..9c214d4 100644 --- a/docs/coop-port.md +++ b/docs/coop-port.md @@ -943,3 +943,44 @@ below; it is kept as a defensive correction and is not covered by `MMX_COOP_STORM_LANDING_TEST`. The physics trace adds a `regs` column and a faster hex encoder; the netplay lag analysis is in `docs/storm-eagle-collision-handoff.md`. + +### Launch Octopus mid-boss missing after a checkpoint restart (2026-10-05) + +Report: in co-op the large mid-boss at x `$111E`, y `$0279` in Launch Octopus's +stage never activated. `coop-physics-20261005-010928-226894-1.csv` (offline, +Unified; 8,341 host frames over three attempts at the same stretch) shows the +enemy (class `$21`, slot 0) allocated in the first attempt (host frame 5677, +camera 4034 scrolling right, P2 not yet joined) and then absent after each +death: the camera resets to 0,0 at host frames 7099 and 9204, the level +restarts at the `4032/287` checkpoint, and the enemy pool stays empty through +every later approach (frames 7200..8300 and 9300..11112). It reappears only +once, at 8696, when the camera sweeps far right (4568) and scrolls back left +over its column. + +Cause: not co-op. Both of the stage's `$21` records (`$85:8640` x `$0BCE`, +`$85:86AD` x `$111E`) are kind 3 and `$21` is not in the native-owned list, so +`MmxWidePolicy_SpawnRecordAllowed` gives them to the early wide pass only, and +the native pass rejects them. The wide pass keeps its own host cursor +(`s_ws_spawn_cursor`), which was reset only on a state load or a change of +stage (`$1F7A`). A death restarts the level from its checkpoint in the *same* +stage: the guest rebuilds its event-list cursor, the host cursor keeps its old, +far-advanced value, and every kind-3 record between the checkpoint and the +place the player died is skipped by the wide pass for good, unless the camera +later scrolls back over it (the left-scroll scan walks the cursor backward). +It affects single player too, and any kind-3 enemy past a checkpoint. + +Fix: `MmxWidePolicy_WideSpawnCursorPersists` (`$D1/$D2/$D3` = stage, play) is +false in every phase of the stage scene except play (death 6, setup 0, +arrival 2, clear 8/10). The widened cursor is dropped then, both at each wide +scan and at frame end, and re-synchronizes to the guest's cursor on the first +scan back in play. Boss rooms, doors and scripted scenes run inside phase 4, so +the cursor still persists across them, which is what keeps a controller the +wide pass rejected for its native pass. The test is derived from guest RAM +only, so it is deterministic across state loads and rollback. +`tests/mmx_wide_policy_test.c` covers the phases and the re-sync. + +Not measured: a replay of the fix in the running game (the build compiles +with the project flags and the policy test passes). To confirm: die to the +mid-boss in the stage, take the checkpoint, and walk to its room; it should +spawn on the way in, solo and in co-op. `SNESRECOMP_WS_SPAWN=0` (authentic +4:3 spawn timing) is the fallback on builds without this change. diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c index f186b8f..88e076e 100644 --- a/src/mmx_rtl.c +++ b/src/mmx_rtl.c @@ -1026,6 +1026,10 @@ void RunOneFrameOfGame(void) { MmxCoopSyncPriority(g_ram); MmxCoopCapture(g_ram); MmxCoopTraceFrame(g_ram); + /* Out of play (death, level setup, arrival) the widened spawn cursor is + * stale; drop it even when no scan runs before play resumes. */ + if (!MmxWidePolicy_WideSpawnCursorPersists(g_ram)) + s_ws_spawn_cursor.valid = false; g_first_frame_done = true; MmxCoopDiagnosticFrame(g_ram); } @@ -1203,6 +1207,11 @@ static uint16 MmxWsSpawnPreparePasses(uint16 native_anchor, s_ws_spawn_cursor.valid = false; s_ws_spawn_cursor_stage = stage; } + /* A checkpoint restart stays in the same stage but rebuilds the guest's + * cursor: a widened cursor left from before the death would skip every + * record between the checkpoint and the death. */ + if (!MmxWidePolicy_WideSpawnCursorPersists(g_ram)) + s_ws_spawn_cursor.valid = false; s_ws_spawn_pass.native_cursor_before = MmxWsSpawnReadCursor(dpage); const uint16 wide_cursor = MmxWidePolicy_BeginWideSpawnPass( diff --git a/src/mmx_wide_policy.c b/src/mmx_wide_policy.c index 98ce8b5..d42033b 100644 --- a/src/mmx_wide_policy.c +++ b/src/mmx_wide_policy.c @@ -17,6 +17,14 @@ bool MmxWidePolicy_IsStageScene(const uint8_t ram[0x20000]) { !((ram[0x1f10] == 6 || ram[0x1f10] == 8) && (ram[0xc3] & 0x80)); } +bool MmxWidePolicy_WideSpawnCursorPersists(const uint8_t ram[0x20000]) { + /* $D1/$D2 are the game mode (2/4: a stage) and $D3 its phase: 0 level + * setup, 2 arrival, 4 play, 6 death, 8 and 10 clear/exit. Boss rooms, doors + * and scripted scenes all run inside phase 4, so the cursor still persists + * across them, which is what keeps a rejected controller for its native + * pass. */ + return ram && ram[0xd1] == 2 && ram[0xd2] == 4 && ram[0xd3] == 4; +} bool MmxWidePolicy_IsCollectible(uint8_t object_id) { /* Health/weapon-energy pickups, Sub Tanks, and Heart Tanks. Other kind-0 * records include vehicles and mechanisms and retain native timing. */ diff --git a/src/mmx_wide_policy.h b/src/mmx_wide_policy.h index 193d2a1..6ec6df4 100644 --- a/src/mmx_wide_policy.h +++ b/src/mmx_wide_policy.h @@ -50,6 +50,17 @@ uint16_t MmxWidePolicy_BeginWideSpawnPass(MmxWideSpawnCursor *cursor, void MmxWidePolicy_EndWideSpawnPass(MmxWideSpawnCursor *cursor, uint16_t wide_cursor); +/* The widened cursor is only meaningful while a level is being played. A + * death restarts the level from its checkpoint within the same stage: the + * guest rebuilds its own event-list cursor, so a widened cursor left over from + * before the death points past every record between the checkpoint and the + * place the player died, and the wide pass (which alone allocates most + * kind-3 enemies) never visits them again. Return false in every phase of the + * stage scene except play, so the cursor is re-synchronized to the guest's + * before the next scan. Derived from guest RAM only: deterministic, and + * identical across a state load or a rollback. */ +bool MmxWidePolicy_WideSpawnCursorPersists(const uint8_t ram[0x20000]); + /* Decide which half of the split scan owns a record. Most kind-3 objects are * ordinary enemies and belong to the early wide pass; kinds 0-2 retain native * timing. A small number of stable stage/object identities override that diff --git a/tests/mmx_wide_policy_test.c b/tests/mmx_wide_policy_test.c index 304548b..6f7b27e 100644 --- a/tests/mmx_wide_policy_test.c +++ b/tests/mmx_wide_policy_test.c @@ -179,6 +179,42 @@ static void test_spawn_cursors_are_independent(void) { assert(cursor.valid); } +static void test_spawn_cursor_survives_only_play(void) { + /* Launch Octopus, stage 1: after a death the level restarts at the + * checkpoint. The widened cursor must not carry its old position into the + * restart, or the kind-3 mid-boss ($21, x $111E) past the checkpoint is + * never allocated again. */ + memset(ram, 0, sizeof(ram)); + ram[0xd1] = 2; ram[0xd2] = 4; ram[0x1f7a] = 1; + MmxWideSpawnCursor cursor = {0}; + + /* Playing: the cursor advances with the camera and persists. */ + ram[0xd3] = 4; + assert(MmxWidePolicy_WideSpawnCursorPersists(ram)); + assert(MmxWidePolicy_BeginWideSpawnPass(&cursor, 0x86a0) == 0x86a0); + MmxWidePolicy_EndWideSpawnPass(&cursor, 0x86c0); /* past the mid-boss's record */ + assert(MmxWidePolicy_BeginWideSpawnPass(&cursor, 0x86a8) == 0x86c0); + + /* Death, then the checkpoint restart: setup (0), arrival (2). */ + const uint8_t phases[] = {6, 0, 2}; + for (unsigned i = 0; i < sizeof(phases); ++i) { + ram[0xd3] = phases[i]; + assert(!MmxWidePolicy_WideSpawnCursorPersists(ram)); + if (!MmxWidePolicy_WideSpawnCursorPersists(ram)) cursor.valid = false; + } + + /* Back in play, the first scan adopts the guest's rebuilt cursor (the + * checkpoint's, well before the mid-boss's record), not the stale one. */ + ram[0xd3] = 4; + assert(MmxWidePolicy_WideSpawnCursorPersists(ram)); + assert(MmxWidePolicy_BeginWideSpawnPass(&cursor, 0x8640) == 0x8640); + + /* Not a stage at all (a menu, the title): never persists. */ + ram[0xd1] = 1; + assert(!MmxWidePolicy_WideSpawnCursorPersists(ram)); + assert(!MmxWidePolicy_WideSpawnCursorPersists(NULL)); +} + static void test_streaker_entry_and_recovery(void) { memset(ram, 0, sizeof(ram)); ram[0xd1] = 2; ram[0xd2] = ram[0xd3] = 4; @@ -316,6 +352,7 @@ int main(void) { test_boss_door_stack(); test_non_door_stack(); test_spawn_cursors_are_independent(); + test_spawn_cursor_survives_only_play(); test_spawn_record_ownership(); test_streaker_entry_and_recovery(); test_chain_platform_switches();