From 54deba3338f402433f528500a28ec2a08f3a6a19 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:29:20 +0000 Subject: [PATCH 1/3] Co-op: let both players ride either player's Shotgun Ice sled Each seat's projectile loop ($00:D3DD..D3F9) runs with that seat's own shots and body, so a native shot that tests its rider against $0BA8 only ever saw its owner. Before each seat's loop, replay it once with the other seat's body projected over the same shots when a native shot is near that body. Keep only that body's position, velocity and ground flags, and restore WRAM, weapon combat, Zero state and renderer pieces, so every shot still advances once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tu7jQG5NbXoKrTYTT7y7n3 --- docs/coop-interaction-ownership.md | 1 + src/mmx_coop.c | 64 +++++++++++++++++++++++++++++- src/mmx_renderer.c | 8 ++++ src/mmx_renderer.h | 10 +++++ 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/docs/coop-interaction-ownership.md b/docs/coop-interaction-ownership.md index d91d29d..9f2e41e 100644 --- a/docs/coop-interaction-ownership.md +++ b/docs/coop-interaction-ownership.md @@ -57,6 +57,7 @@ Each player gets the native result. Behaviour is the same for X and Zero. | Solid enemies using `$82:D7D7`: Storm Eagle E-tank elevator (`$59` top, `$5A` column), Flame Mammoth scrap blocks (`$2A`) | `lift_contact_hook`, `lift_rtl_hook`, `lift_elevator()` | The contact query is retried for the partner and both answers are OR-ed into `.2C`. The handler gets the first seat's registers back. Only the elevator top carries the partner (`MmxCoopLiftCarry`). | `$82:D7D7` callers that are not listed still touch only the current seat. The `lift-contact` trace rows name every caller class. | | Armored Armadillo minecart (enemy `$2B`) | `lift_contact_hook`, `cart_hook`, `$82:D7D7`, `$88:9821..9867` | Query each player, move the cart once, then apply its native carry code separately to both riders. `.2C/.38` retain per-seat rider bits in WRAM; bit 0 remains the native "any rider" test. | Old single-rider snapshots are imported on first contact. | | Pickups (health, energy, Sub Tanks, Heart Tanks, 1-ups) | `pickup_hook`, `$84:9C0E..9D06`, `$00:D2E6..D31B` | Contact retried for the partner. The first seat to touch an item owns it (`pickup_owner`) and receives its effect. While a collector is parked for a refill (`$1F19`, the Heart Tank pause, or action `$18` alone, as a Sub Tank does), the other player's update is parked too (`refill_paused`, `partner_parked`). | **Review:** Heart Tanks and Sub Tanks are shared progression. Decide whether Zero may collect them, or only X. | +| Native player shots that test `$0BA8` (charged Shotgun Ice's sled) | `object_hook` (`shot_ghost_*`), `$00:D3DD..D3F9` | Each seat's projectile loop first replays once with the other seat's body projected over the same shots, when a native shot is within 64 px of that body. Only that body's position, velocity and ground flags (`.04..09`, `.1A..1D`, `.2B`) are kept; WRAM, weapon combat, Zero state and renderer pieces are restored, so every shot still advances once. A `shot-rider` trace row marks frames where the replay moved the other body. | Untested in play. If the sled's rider test is in the player's terrain code rather than the shot, this does nothing. | | Player-terrain and object helpers | `object_hook`, `$00:D2BD..D47F`, `$81:9D67` | The anchor's helper call is replayed for the partner, then the anchor's registers are restored. | — | | Dash effects | `dash_effect_hook` (`$81:9C86`, `$80:F478/F47C`) | Each seat's dust and effects belong to that seat. | — | | Enemy AI target | `view_actor_hook` (`$00:D4F6/D515/D499/D4B8`, returns `D4F9/D522/D49C/D4C5`) | Online co-op: each enemy's update runs with the nearest living player projected, so it aims at and reacts to that player. Couch co-op: the native current body. | Includes story actors; check capsule/Light objects if online behaviour differs. | diff --git a/src/mmx_coop.c b/src/mmx_coop.c index 0b1b986..748e2a9 100644 --- a/src/mmx_coop.c +++ b/src/mmx_coop.c @@ -293,9 +293,10 @@ bool MmxCoopTransitionActive(void) { } bool MmxCoopEnabled(void) { return enabled; } +static void shot_ghost_reset(void); static void lift_reset(void); void MmxCoopReset(void) { - platform_entry.valid=false;lift_reset(); + platform_entry.valid=false;lift_reset();shot_ghost_reset(); MmxCoopViewsResetWorld(); MmxWeaponsCameraQuery(enabled?weapon_view:NULL); platform_entry.valid=false; @@ -344,7 +345,7 @@ bool MmxCoopValidState(const MmxCoopState *s) { return s->players[0].character != s->players[1].character; } void MmxCoopSetState(const MmxCoopState *s) { - platform_entry.valid=false;lift_reset(); + platform_entry.valid=false;lift_reset();shot_ghost_reset(); if (enabled && MmxCoopValidState(s)) { state = *s; MmxWeaponsPartnerCombat(state.initialized ? &state.players[state.current^1].combat : NULL); @@ -1228,7 +1229,61 @@ static void pickup_hook(CpuState *cpu,uint32_t pc) { interp_bridge_pre_opcode_redirect(0x849c0e); } else {TRACE(PICKUP,pc,2,0,cpu);MmxCoopSelect(g_ram,state.anchor);state.pickup_pass=0;} } +/* Player projectiles run once per seat ($00:D3DD..D3F9), with that seat's + * own shots and body projected. A native shot that tests its rider against + * $0BA8 (charged Shotgun Ice's sled) therefore only ever sees its owner. + * Before each seat's loop, replay it once with the other seat's body + * projected over the same shots, keep only that body's motion and ground + * result, and restore everything else, so each shot still advances once. */ +static struct { + uint8_t pass,p,db;uint16_t a,x,y,s,d; + uint8_t body[0x90]; + MmxWeaponCombatState combat;MmxZeroState zero;MmxRendererPieceMark pieces; +} shot_ghost; +static uint8_t shot_ghost_ram[0x20000]; +static void shot_ghost_reset(void) { shot_ghost.pass=0; } +/* Position (with subpixels), velocity and the ground/contact flags. */ +static bool shot_ghost_field(unsigned i) { return (i>=4 && i<=9) || (i>=0x1a && i<=0x1d) || i==0x2b; } +static bool shot_ghost_wanted(void) { + const MmxCoopPlayer *o=&state.players[state.current^1]; + if(o->status!=MMX_COOP_ALIVE || !(o->body[0x27]&127) || o->body[2]==12 || o->zero.swap_phase) return false; + int ox=(int)word(o->body+5),oy=(int)word(o->body+8); + /* Native shots only: imported X2/X3 shots carry the port's +3E tag. */ + for(unsigned d=0x1228;d<0x1428;d+=64) { + if(!g_ram[d] || word(g_ram+d+0x3e)==0x5758) continue; + int dx=(int)word(g_ram+d+5)-ox,dy=(int)word(g_ram+d+8)-oy; + if(dx>=-64 && dx<=64 && dy>=-64 && dy<=64) return true; + } + return false; +} +static void shot_ghost_begin(CpuState *cpu) { + cpu_mirrors_to_p(cpu); + shot_ghost.a=cpu->A;shot_ghost.x=cpu->X;shot_ghost.y=cpu->Y;shot_ghost.s=cpu->S; + shot_ghost.d=cpu->D;shot_ghost.p=cpu->P;shot_ghost.db=cpu->DB; + memcpy(shot_ghost_ram,g_ram,sizeof(shot_ghost_ram)); + shot_ghost.combat=MmxWeaponsGetCombatState();shot_ghost.zero=MmxZeroGetState(); + shot_ghost.pieces=MmxRendererMarkPieces(); + memcpy(shot_ghost.body,state.players[state.current^1].body,sizeof(shot_ghost.body)); + memcpy(g_ram+0xba8,shot_ghost.body,sizeof(shot_ghost.body)); + shot_ghost.pass=1; +} +static void shot_ghost_end(CpuState *cpu,uint32_t pc) { + uint8_t result[0x90];memcpy(result,g_ram+0xba8,sizeof(result)); + memcpy(g_ram,shot_ghost_ram,sizeof(shot_ghost_ram)); + MmxWeaponsSetCombatState(shot_ghost.combat);MmxZeroSetState(shot_ghost.zero); + MmxRendererRewindPieces(shot_ghost.pieces); + uint8_t *body=state.players[state.current^1].body; + bool moved=false; + for(unsigned i=0;iA=shot_ghost.a;cpu->X=shot_ghost.x;cpu->Y=shot_ghost.y;cpu->S=shot_ghost.s; + cpu->D=shot_ghost.d;cpu->DB=shot_ghost.db;cpu->P=shot_ghost.p;cpu_p_to_mirrors(cpu); + shot_ghost.pass=2; + interp_bridge_pre_opcode_redirect((pc&0xff0000)|0xd3dd); +} static void object_hook(CpuState *cpu, uint32_t pc) { + if ((pc&65535)==0xd3f9 && shot_ghost.pass==1) { shot_ghost_end(cpu,pc); return; } if (!enabled || !state.initialized || state.menu_owner || state.scene_owner || g_ram[0xd1]!=2 || g_ram[0xd2]!=4 || g_ram[0xd3]>=10 || state.players[state.anchor^1].status != MMX_COOP_ALIVE) return; @@ -1244,8 +1299,13 @@ static void object_hook(CpuState *cpu, uint32_t pc) { TRACE(OBJECT,pc,4,at,cpu); if (at==0x9d67) {TRACE_MARK(state.current,TERRAIN);TRACE_MARK(state.current,NON_ANCHOR);} } + if (at==0xd3dd && !shot_ghost.pass && state.object_entry==0xd3dd && + ((state.object_pass==1 && state.current==state.anchor) || + (state.object_pass==2 && state.current!=state.anchor)) && shot_ghost_wanted()) + shot_ghost_begin(cpu); return; } + if (at==0xd3f9 && shot_ghost.pass==2) shot_ghost.pass=0; if (state.object_pass == 1) { state.object_a = cpu->A; state.object_x = cpu->X; state.object_y = cpu->Y; state.object_s = cpu->S; state.object_d = cpu->D; state.object_db = cpu->DB; diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index f02b685..bf9bbd5 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -246,6 +246,14 @@ void MmxRendererObserveObject(const uint8_t ram[0x20000], uint16_t object) { observed_lists = true; expand_queues(ram); } +MmxRendererPieceMark MmxRendererMarkPieces(void) { + return (MmxRendererPieceMark){building_count,expanded_building_count,current_object,building_stage,observed_lists}; +} +void MmxRendererRewindPieces(MmxRendererPieceMark mark) { + if (mark.building<=building_count) building_count=mark.building; + if (mark.expanded<=expanded_building_count) expanded_building_count=mark.expanded; + current_object=mark.object;building_stage=mark.stage;observed_lists=mark.observed; +} void MmxRendererRecordPiece(const uint8_t ram[0x20000], uint16_t d) { if (!g_mmx_custom_renderer || !ram || d > 0xffe0 || building_count >= MAX_PIECES) return; if (building_stage != ram[0x1f7a]) { diff --git a/src/mmx_renderer.h b/src/mmx_renderer.h index aa32b99..e4e4594 100644 --- a/src/mmx_renderer.h +++ b/src/mmx_renderer.h @@ -31,6 +31,16 @@ void MmxRendererSetRom(const uint8_t *rom, size_t size); /* Capture drawing data before native clipping; never modify guest state. */ void MmxRendererRecordPiece(const uint8_t ram[0x20000], uint16_t scratch); void MmxRendererObserveObject(const uint8_t ram[0x20000], uint16_t object); +/* Co-op replays an object loop speculatively and discards its guest state; + * these drop the pieces that replay recorded. */ +typedef struct MmxRendererPieceMark { + unsigned building, expanded; + uint16_t object; + uint8_t stage; + bool observed; +} MmxRendererPieceMark; +MmxRendererPieceMark MmxRendererMarkPieces(void); +void MmxRendererRewindPieces(MmxRendererPieceMark mark); void MmxRendererLatchSprites(void); void MmxRendererBeginFrame(const uint8_t ram[0x20000]); void MmxRendererCoopFrame(const MmxCoopState *state); From 7c7082c1c762d41b20c3d98ff9abe2cfad869df5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:38:57 +0000 Subject: [PATCH 2/3] Co-op: let Gulpfer chase and swallow the nearest player in couch co-op Couch co-op runs enemy AI against the projected world actor, so Launch Octopus's Gulpfer ($1D) only ever homed in on and swallowed P1. Its update now projects the nearest living player, as online co-op already does for every enemy. The physics trace's pool columns also record each object's .0E visibility, to diagnose the fish that sometimes do not draw. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tu7jQG5NbXoKrTYTT7y7n3 --- docs/coop-interaction-ownership.md | 2 +- src/mmx_coop.c | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/coop-interaction-ownership.md b/docs/coop-interaction-ownership.md index 9f2e41e..2872661 100644 --- a/docs/coop-interaction-ownership.md +++ b/docs/coop-interaction-ownership.md @@ -60,7 +60,7 @@ Each player gets the native result. Behaviour is the same for X and Zero. | Native player shots that test `$0BA8` (charged Shotgun Ice's sled) | `object_hook` (`shot_ghost_*`), `$00:D3DD..D3F9` | Each seat's projectile loop first replays once with the other seat's body projected over the same shots, when a native shot is within 64 px of that body. Only that body's position, velocity and ground flags (`.04..09`, `.1A..1D`, `.2B`) are kept; WRAM, weapon combat, Zero state and renderer pieces are restored, so every shot still advances once. A `shot-rider` trace row marks frames where the replay moved the other body. | Untested in play. If the sled's rider test is in the player's terrain code rather than the shot, this does nothing. | | Player-terrain and object helpers | `object_hook`, `$00:D2BD..D47F`, `$81:9D67` | The anchor's helper call is replayed for the partner, then the anchor's registers are restored. | — | | Dash effects | `dash_effect_hook` (`$81:9C86`, `$80:F478/F47C`) | Each seat's dust and effects belong to that seat. | — | -| Enemy AI target | `view_actor_hook` (`$00:D4F6/D515/D499/D4B8`, returns `D4F9/D522/D49C/D4C5`) | Online co-op: each enemy's update runs with the nearest living player projected, so it aims at and reacts to that player. Couch co-op: the native current body. | Includes story actors; check capsule/Light objects if online behaviour differs. | +| Enemy AI target | `view_actor_hook` (`$00:D4F6/D515/D499/D4B8`, returns `D4F9/D522/D49C/D4C5`) | Online co-op: each enemy's update runs with the nearest living player projected, so it aims at and reacts to that player. Couch co-op: the native current body, except Launch Octopus's Gulpfer (`$1D`, `couch_nearest_target`), which chases and swallows the body it targets and also uses the nearest player. | Includes story actors; check capsule/Light objects if online behaviour differs. | ## 3. Either player triggers, and that player becomes the world actor diff --git a/src/mmx_coop.c b/src/mmx_coop.c index 748e2a9..5e12950 100644 --- a/src/mmx_coop.c +++ b/src/mmx_coop.c @@ -138,8 +138,8 @@ static void diagnostic_pool(const uint8_t *r,unsigned base,unsigned stride,unsig for(unsigned i=0;i=0x1228 || (d-0xe68)%64 || !g_ram[d]) return false; + return g_ram[d+10]==0x1d; +} static void view_actor_hook(CpuState *cpu,uint32_t pc) { - if(!MmxCoopViewsOnline() || !enabled || !state.initialized) return; + if(!enabled || !state.initialized) return; unsigned at=pc&65535; MmxCoopViewWorldState world=MmxCoopViewsGetWorldState(); - if(at==0xd4f9 || at==0xd522 || at==0xd49c || at==0xd4c5) { + bool returning=at==0xd4f9 || at==0xd522 || at==0xd49c || at==0xd4c5; + if(!MmxCoopViewsOnline() && !(returning ? world.actor_return : + (at==0xd4f6 || at==0xd515) && couch_nearest_target(cpu->D))) return; + if(returning) { if(world.actor_return) { MmxCoopSelect(g_ram,world.actor_return-1);MmxCoopViewsActorReturn(0); select_world_survivor(g_ram); From ee2a626e2912d58f5fc87cd399ef8fe07f3c34ac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:47:49 +0000 Subject: [PATCH 3/3] Co-op: replay enemy updates for the partner so currents lift both players Enemies and enemy projectiles update once, with the world actor in $0BA8, so an object that moves the body it finds there (Launch Octopus's upward currents) only lifted P1. In couch co-op, an object near the partner now first replays its update with the partner's body projected; the partner keeps that run's motion, ground flags and action, and everything else is restored before the real update. Bosses and objects with their own seat handling are skipped. The shot-sled replay shares the same machinery, which now also restores co-op, view, lift and cart state, and seat switches are suppressed while a replay runs. The physics trace logs the enemy-projectile pool and a body-moved row for any object whose update moved the projected body. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tu7jQG5NbXoKrTYTT7y7n3 --- docs/coop-interaction-ownership.md | 1 + src/mmx_coop.c | 144 ++++++++++++++++++++++------- 2 files changed, 114 insertions(+), 31 deletions(-) diff --git a/docs/coop-interaction-ownership.md b/docs/coop-interaction-ownership.md index 2872661..4b1375b 100644 --- a/docs/coop-interaction-ownership.md +++ b/docs/coop-interaction-ownership.md @@ -58,6 +58,7 @@ Each player gets the native result. Behaviour is the same for X and Zero. | Armored Armadillo minecart (enemy `$2B`) | `lift_contact_hook`, `cart_hook`, `$82:D7D7`, `$88:9821..9867` | Query each player, move the cart once, then apply its native carry code separately to both riders. `.2C/.38` retain per-seat rider bits in WRAM; bit 0 remains the native "any rider" test. | Old single-rider snapshots are imported on first contact. | | Pickups (health, energy, Sub Tanks, Heart Tanks, 1-ups) | `pickup_hook`, `$84:9C0E..9D06`, `$00:D2E6..D31B` | Contact retried for the partner. The first seat to touch an item owns it (`pickup_owner`) and receives its effect. While a collector is parked for a refill (`$1F19`, the Heart Tank pause, or action `$18` alone, as a Sub Tank does), the other player's update is parked too (`refill_paused`, `partner_parked`). | **Review:** Heart Tanks and Sub Tanks are shared progression. Decide whether Zero may collect them, or only X. | | Native player shots that test `$0BA8` (charged Shotgun Ice's sled) | `object_hook` (`shot_ghost_*`), `$00:D3DD..D3F9` | Each seat's projectile loop first replays once with the other seat's body projected over the same shots, when a native shot is within 64 px of that body. Only that body's position, velocity and ground flags (`.04..09`, `.1A..1D`, `.2B`) are kept; WRAM, weapon combat, Zero state and renderer pieces are restored, so every shot still advances once. A `shot-rider` trace row marks frames where the replay moved the other body. | Untested in play. If the sled's rider test is in the player's terrain code rather than the shot, this does nothing. | +| Enemies and enemy projectiles that move `$0BA8` directly (Launch Octopus's upward currents) | `object_ghost_hook`, `$00:D4F6 -> D4F9` (enemies), `$00:D499 -> D49C` (enemy projectiles) | Couch co-op: when an object is within 128 px of the partner, its update first replays once with the partner's body projected. The partner keeps that run's position, velocity, ground flags and action (`.02..09`, `.1A..1D`, `.2B`); everything else is restored and the real update runs for the world actor. Skipped: bosses, lifts and minecarts, Dr. Light's capsule, Gulpfer (`$1D`, nearest-player instead) and Slimer's puddle (`$19`). `body-moved` trace rows name any object whose update moved the projected body; `ghost-moved` rows mark replays that moved the partner. | Untested in play. Review any enemy that grabs or scripts the player. | | Player-terrain and object helpers | `object_hook`, `$00:D2BD..D47F`, `$81:9D67` | The anchor's helper call is replayed for the partner, then the anchor's registers are restored. | — | | Dash effects | `dash_effect_hook` (`$81:9C86`, `$80:F478/F47C`) | Each seat's dust and effects belong to that seat. | — | | Enemy AI target | `view_actor_hook` (`$00:D4F6/D515/D499/D4B8`, returns `D4F9/D522/D49C/D4C5`) | Online co-op: each enemy's update runs with the nearest living player projected, so it aims at and reacts to that player. Couch co-op: the native current body, except Launch Octopus's Gulpfer (`$1D`, `couch_nearest_target`), which chases and swallows the body it targets and also uses the nearest player. | Includes story actors; check capsule/Light objects if online behaviour differs. | diff --git a/src/mmx_coop.c b/src/mmx_coop.c index 5e12950..156047a 100644 --- a/src/mmx_coop.c +++ b/src/mmx_coop.c @@ -3,6 +3,7 @@ #include "mmx_renderer.h" #include "mmx_coop_trace.h" #include "mmx_rtl.h" +#include "mmx_wide_policy.h" _Static_assert(sizeof(MmxCoopState) == MMX_COOP_LEGACY_STATE_SIZE + 2 * sizeof(MmxZeroModernState), "Update the legacy co-op importer when its layout changes"); #include "cpu_state.h" @@ -167,8 +168,8 @@ static void diagnostic_event(const uint8_t *r,const CpuState *cpu,uint32_t pc,co if (!out) return; bool frame_end=!strcmp(event,"frame-end"); bool platform=!strncmp(event,"platform",8) || !strncmp(event,"contact",7) || - !strcmp(event,"lift-contact"); - char flags[15],owners[33],scratch[129],items[16*39+1]={0},caller[12]={0},regs[24]={0},slot[64*2+1]={0},enemies[15*39+1]={0},shots[12*39+1]={0}; + !strcmp(event,"lift-contact") || !strcmp(event,"body-moved") || !strcmp(event,"ghost-moved"); + char flags[15],owners[33],scratch[129],items[16*39+1]={0},caller[12]={0},regs[24]={0},slot[64*2+1]={0},enemies[23*39+3]={0},shots[12*39+1]={0}; diagnostic_hex(flags,r+0x1f13,7); diagnostic_hex(owners,state.pickup_owner,16); diagnostic_hex(scratch,r,64); @@ -187,14 +188,24 @@ static void diagnostic_event(const uint8_t *r,const CpuState *cpu,uint32_t pc,co const uint8_t *e=r+0xe68+i*64; if(e[0] && e[10]>=0x58 && e[10]<=0x5a) elevator=true; } - if(frame_end || elevator || platform) diagnostic_pool(r,0xe68,64,15,enemies,sizeof(enemies)); + if(frame_end || elevator || platform) { + diagnostic_pool(r,0xe68,64,15,enemies,sizeof(enemies)); + /* Enemy projectiles and effects ($1428, eight 64-byte slots), after "p". */ + size_t used=strlen(enemies); + if(used+2D>=0x1628 && cpu->D<0x1928 && !((cpu->D-0x1628)%48)) diagnostic_hex(slot,r+cpu->D,48); - else if(platform && cpu && cpu->D>=0xe68 && cpu->D<0x1228 && !((cpu->D-0xe68)%64)) + else if(platform && cpu && ((cpu->D>=0xe68 && cpu->D<0x1228 && !((cpu->D-0xe68)%64)) || + (cpu->D>=0x1428 && cpu->D<0x1628 && !((cpu->D-0x1428)%64)))) diagnostic_hex(slot,r+cpu->D,64); /* Entry to a long subroutine: the JSL return address names the item code. */ if(platform && cpu && (!strcmp(event,"platform-enter") || !strcmp(event,"lift-contact")) && @@ -352,8 +363,10 @@ void MmxCoopSetState(const MmxCoopState *s) { } else MmxCoopReset(); } +static bool ghost_active(void); void MmxCoopCapture(uint8_t *r) { - if (!enabled || !state.initialized || !r) return; + /* A ghost replay projects the other seat's body without selecting it. */ + if (!enabled || !state.initialized || !r || ghost_active()) return; MmxCoopPlayer *p = &state.players[state.current]; memcpy(p->body, r + 0xba8, sizeof(p->body)); memcpy(p->auxiliaries, r + 0xc38, sizeof(p->auxiliaries)); @@ -369,7 +382,7 @@ void MmxCoopCapture(uint8_t *r) { p->shot_command = r[0x1f0d]; p->hud_state = r[0x1f12]; } bool MmxCoopSelect(uint8_t *r, unsigned player) { - if (!enabled || !state.initialized || !r || player > 1) return false; + if (!enabled || !state.initialized || !r || player > 1 || ghost_active()) return false; if (player == state.current) return true; TRACE(SELECT,0,state.current,player,NULL); diagnostic_event(r,NULL,0,"select-before"); @@ -1229,40 +1242,79 @@ static void pickup_hook(CpuState *cpu,uint32_t pc) { interp_bridge_pre_opcode_redirect(0x849c0e); } else {TRACE(PICKUP,pc,2,0,cpu);MmxCoopSelect(g_ram,state.anchor);state.pickup_pass=0;} } -/* Player projectiles run once per seat ($00:D3DD..D3F9), with that seat's - * own shots and body projected. A native shot that tests its rider against - * $0BA8 (charged Shotgun Ice's sled) therefore only ever sees its owner. - * Before each seat's loop, replay it once with the other seat's body - * projected over the same shots, keep only that body's motion and ground - * result, and restore everything else, so each shot still advances once. */ +/* Ghost replays. Native code that acts on the body it finds in $0BA8 only + * ever sees the seat that is projected while it runs: + * - player projectiles run once per seat ($00:D3DD..D3F9) with that seat's + * shots and body, so charged Shotgun Ice's sled only carried its owner; + * - enemies and enemy projectiles run once ($00:D4EA, $00:D48D) with the + * world actor projected, so Launch Octopus's upward currents only lifted + * the world actor. + * Before such a run, replay it once with the other seat's body projected, + * keep only that body's motion result, and restore everything else (WRAM, + * weapon combat, Zero and co-op state, renderer pieces), so every object + * still advances once. Couch co-op only for objects: online views already + * project the nearest player for AI. */ +enum { GHOST_SHOTS=1, GHOST_OBJECT }; static struct { - uint8_t pass,p,db;uint16_t a,x,y,s,d; + uint8_t pass,kind,p,db;uint16_t a,x,y,s,d;uint32_t resume; uint8_t body[0x90]; MmxWeaponCombatState combat;MmxZeroState zero;MmxRendererPieceMark pieces; + MmxCoopViewWorldState world; } shot_ghost; +static MmxCoopState shot_ghost_state; +static uint8_t shot_ghost_lift[sizeof(lift)],shot_ghost_cart[sizeof(cart)]; static uint8_t shot_ghost_ram[0x20000]; static void shot_ghost_reset(void) { shot_ghost.pass=0; } -/* Position (with subpixels), velocity and the ground/contact flags. */ -static bool shot_ghost_field(unsigned i) { return (i>=4 && i<=9) || (i>=0x1a && i<=0x1d) || i==0x2b; } -static bool shot_ghost_wanted(void) { +static bool ghost_active(void) { return shot_ghost.pass==1; } +/* Position (with subpixels), velocity and the ground/contact flags; object + * replays also keep the action an object forced (a current's lift). */ +static bool shot_ghost_field(unsigned i) { + return (i>=4 && i<=9) || (i>=0x1a && i<=0x1d) || i==0x2b || + (shot_ghost.kind==GHOST_OBJECT && (i==2 || i==3)); +} +static bool ghost_partner_ready(void) { const MmxCoopPlayer *o=&state.players[state.current^1]; - if(o->status!=MMX_COOP_ALIVE || !(o->body[0x27]&127) || o->body[2]==12 || o->zero.swap_phase) return false; - int ox=(int)word(o->body+5),oy=(int)word(o->body+8); + return o->status==MMX_COOP_ALIVE && (o->body[0x27]&127) && o->body[2]!=12 && !o->zero.swap_phase; +} +static bool ghost_near(unsigned d,int range) { + const MmxCoopPlayer *o=&state.players[state.current^1]; + int dx=(int)word(g_ram+d+5)-(int)word(o->body+5),dy=(int)word(g_ram+d+8)-(int)word(o->body+8); + return dx>=-range && dx<=range && dy>=-range && dy<=range; +} +static bool shot_ghost_wanted(void) { + if(!ghost_partner_ready()) return false; /* Native shots only: imported X2/X3 shots carry the port's +3E tag. */ - for(unsigned d=0x1228;d<0x1428;d+=64) { - if(!g_ram[d] || word(g_ram+d+0x3e)==0x5758) continue; - int dx=(int)word(g_ram+d+5)-ox,dy=(int)word(g_ram+d+8)-oy; - if(dx>=-64 && dx<=64 && dy>=-64 && dy<=64) return true; - } + for(unsigned d=0x1228;d<0x1428;d+=64) + if(g_ram[d] && word(g_ram+d+0x3e)!=0x5758 && ghost_near(d,64)) return true; return false; } -static void shot_ghost_begin(CpuState *cpu) { +static bool lift_elevator(unsigned d); +static bool capsule_slot(unsigned d); +static bool object_ghost_wanted(unsigned d) { + if(MmxCoopViewsOnline() || state.menu_owner || state.scene_owner || state.contact_pass || + state.object_pass || g_ram[0xd3]!=4 || state.current!=state.anchor || !ghost_partner_ready()) return false; + bool enemy=d>=0xe68 && d<0x1228 && !((d-0xe68)%64); + bool projectile=d>=0x1428 && d<0x1628 && !((d-0x1428)%64); + if((!enemy && !projectile) || !g_ram[d]) return false; + unsigned c=g_ram[d+10]; + /* Objects with their own seat handling: lifts and minecarts, + * Dr. Light's capsule, Gulpfer's nearest-player chase, Slimer's puddle. */ + if(enemy && (lift_elevator(d) || capsule_slot(d) || c==0x1d)) return false; + /* Bosses script the player (intros, victory pose); they stay single-seat. */ + if(enemy && MmxWidePolicy_IsBossEncounter((uint8_t)c)) return false; + if(projectile && c==0x19) return false; + return ghost_near(d,128); +} +static void shot_ghost_begin(CpuState *cpu,unsigned kind,uint32_t resume) { cpu_mirrors_to_p(cpu); shot_ghost.a=cpu->A;shot_ghost.x=cpu->X;shot_ghost.y=cpu->Y;shot_ghost.s=cpu->S; shot_ghost.d=cpu->D;shot_ghost.p=cpu->P;shot_ghost.db=cpu->DB; + shot_ghost.kind=(uint8_t)kind;shot_ghost.resume=resume; memcpy(shot_ghost_ram,g_ram,sizeof(shot_ghost_ram)); shot_ghost.combat=MmxWeaponsGetCombatState();shot_ghost.zero=MmxZeroGetState(); - shot_ghost.pieces=MmxRendererMarkPieces(); + shot_ghost.pieces=MmxRendererMarkPieces();shot_ghost.world=MmxCoopViewsGetWorldState(); + shot_ghost_state=state; + memcpy(shot_ghost_lift,&lift,sizeof(lift));memcpy(shot_ghost_cart,&cart,sizeof(cart)); memcpy(shot_ghost.body,state.players[state.current^1].body,sizeof(shot_ghost.body)); memcpy(g_ram+0xba8,shot_ghost.body,sizeof(shot_ghost.body)); shot_ghost.pass=1; @@ -1271,19 +1323,46 @@ static void shot_ghost_end(CpuState *cpu,uint32_t pc) { uint8_t result[0x90];memcpy(result,g_ram+0xba8,sizeof(result)); memcpy(g_ram,shot_ghost_ram,sizeof(shot_ghost_ram)); MmxWeaponsSetCombatState(shot_ghost.combat);MmxZeroSetState(shot_ghost.zero); - MmxRendererRewindPieces(shot_ghost.pieces); + MmxRendererRewindPieces(shot_ghost.pieces);MmxCoopViewsSetWorldState(&shot_ghost.world); + state=shot_ghost_state; + memcpy(&lift,shot_ghost_lift,sizeof(lift));memcpy(&cart,shot_ghost_cart,sizeof(cart)); uint8_t *body=state.players[state.current^1].body; bool moved=false; for(unsigned i=0;iA=shot_ghost.a;cpu->X=shot_ghost.x;cpu->Y=shot_ghost.y;cpu->S=shot_ghost.s; cpu->D=shot_ghost.d;cpu->DB=shot_ghost.db;cpu->P=shot_ghost.p;cpu_p_to_mirrors(cpu); + if(moved) diagnostic_event(g_ram,cpu,pc,shot_ghost.kind==GHOST_SHOTS ? "shot-rider" : "ghost-moved"); shot_ghost.pass=2; - interp_bridge_pre_opcode_redirect((pc&0xff0000)|0xd3dd); + interp_bridge_pre_opcode_redirect(shot_ghost.resume); +} +/* Per-object ghost and attribution at the enemy ($00:D4F6 -> D4F9) and enemy + * projectile ($00:D499 -> D49C) update calls. A "body-moved" trace row names + * any object whose update moved the projected body. */ +static struct { uint16_t d,x,y; bool armed; } object_watch; +static void object_ghost_hook(CpuState *cpu,uint32_t pc) { + if(!enabled || !state.initialized) return; + unsigned at=pc&65535; + bool call=at==0xd4f6 || at==0xd499; + if(call) { + if(shot_ghost.pass==2 && shot_ghost.kind==GHOST_OBJECT) shot_ghost.pass=0; + else if(!shot_ghost.pass && object_ghost_wanted(cpu->D)) { + shot_ghost_begin(cpu,GHOST_OBJECT,pc&0xffffff);return; + } + if(!shot_ghost.pass) { + object_watch.armed=true;object_watch.d=(uint16_t)cpu->D; + object_watch.x=(uint16_t)word(g_ram+0xbad);object_watch.y=(uint16_t)word(g_ram+0xbb0); + } + return; + } + if(shot_ghost.pass==1 && shot_ghost.kind==GHOST_OBJECT) { shot_ghost_end(cpu,pc);return; } + if(object_watch.armed && cpu->D==object_watch.d && + (word(g_ram+0xbad)!=object_watch.x || word(g_ram+0xbb0)!=object_watch.y)) + diagnostic_event(g_ram,cpu,pc,"body-moved"); + object_watch.armed=false; } static void object_hook(CpuState *cpu, uint32_t pc) { - if ((pc&65535)==0xd3f9 && shot_ghost.pass==1) { shot_ghost_end(cpu,pc); return; } + if ((pc&65535)==0xd3f9 && shot_ghost.pass==1 && shot_ghost.kind==GHOST_SHOTS) { shot_ghost_end(cpu,pc); return; } if (!enabled || !state.initialized || state.menu_owner || state.scene_owner || g_ram[0xd1]!=2 || g_ram[0xd2]!=4 || g_ram[0xd3]>=10 || state.players[state.anchor^1].status != MMX_COOP_ALIVE) return; @@ -1302,10 +1381,10 @@ static void object_hook(CpuState *cpu, uint32_t pc) { if (at==0xd3dd && !shot_ghost.pass && state.object_entry==0xd3dd && ((state.object_pass==1 && state.current==state.anchor) || (state.object_pass==2 && state.current!=state.anchor)) && shot_ghost_wanted()) - shot_ghost_begin(cpu); + shot_ghost_begin(cpu,GHOST_SHOTS,(pc&0xff0000)|0xd3dd); return; } - if (at==0xd3f9 && shot_ghost.pass==2) shot_ghost.pass=0; + if (at==0xd3f9 && shot_ghost.pass==2 && shot_ghost.kind==GHOST_SHOTS) shot_ghost.pass=0; if (state.object_pass == 1) { state.object_a = cpu->A; state.object_x = cpu->X; state.object_y = cpu->Y; state.object_s = cpu->S; state.object_d = cpu->D; state.object_db = cpu->DB; @@ -1721,6 +1800,9 @@ void MmxCoopRegisterHooks(void) { const unsigned actors[]={0xd4f6,0xd4f9,0xd515,0xd522,0xd499,0xd49c,0xd4b8,0xd4c5}; for(unsigned i=0;i