Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/coop-interaction-ownership.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ The partner never triggers or advances these.
| Stage death and checkpoint | `death_hook` (`$00:9D9E`, `$00:9AC7`, `$81:8A5C..8B0B`) | A death with a living partner runs the native death pose, but the partner keeps playing (`solo_death`). A whole-team death resets to P1 at the checkpoint. | No change expected. |
| E-tank elevator carry | `lift_rtl_hook`, `MmxCoopLiftCarry` | The handler carries the projected body; co-op carries the partner by the same distance. | Both ride; not ownership. |
| Sprite priority (`.11` bits 4-5) | `MmxCoopSyncPriority` (frame end) | Stage sections write the priority to the world actor once (Storm Eagle's ship sets priority 3). The living partner copies the world actor's bits every frame. | No change expected. |
| Partner "in play" bit (`.27` bit 7) | `MmxCoopSyncPriority` (frame end) | Spawns store HP\|`$80` and death stores `$80`; the world actor's arrival clears the bit, but a placed partner kept it permanently. Launch Octopus's current generator (`$28`) ignores bodies with it set. A living partner in play now has it cleared each frame. | Both seats. |
| Ground-shock stun (action `$36`, Flame Mammoth's stomp) | `MmxCoopSyncPriority` (frame end) | The boss tests only the projected world actor. When the world actor enters `$36`, a grounded, living partner enters it the same frame. | Both seats. |

## 2. Both players, through a partner retry
Expand Down
41 changes: 41 additions & 0 deletions docs/coop-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 7 additions & 0 deletions src/mmx_coop.c
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,13 @@ void MmxCoopSyncPriority(uint8_t *r) {
* Left 0, X's wrapped to 255 and kept him stunned ~288 frames. */
body[2]=0x36;body[3]=0;body[0x85]=world[0x85];
}
/* .27 bit 7 marks a body not yet (or no longer) in play: spawns store
* HP|$80 and death stores $80. The world actor's arrival clears it, but a
* placed partner kept it for good. Contact damage ignores it; Launch
* Octopus's current generator ($28) does not, so it never lifted him. */
if((body[0x27]&128) && (body[0x27]&127) && !(world[0x27]&128) && action!=0x0c &&
!MmxCoopTransitionActive())
body[0x27]&=127;
}
static void eagle_lift_hook(CpuState *cpu,uint32_t pc) {
if(!enabled || !state.initialized || state.menu_owner || state.scene_owner) return;
Expand Down
9 changes: 9 additions & 0 deletions src/mmx_rtl.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions src/mmx_wide_policy.c
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
11 changes: 11 additions & 0 deletions src/mmx_wide_policy.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions tests/mmx_wide_policy_test.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading