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();