diff --git a/docs/coop-interaction-ownership.md b/docs/coop-interaction-ownership.md index d91d29d..4b1375b 100644 --- a/docs/coop-interaction-ownership.md +++ b/docs/coop-interaction-ownership.md @@ -57,9 +57,11 @@ 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. | +| 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. | 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 0b1b986..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" @@ -138,8 +139,8 @@ static void diagnostic_pool(const uint8_t *r,unsigned base,unsigned stride,unsig for(unsigned i=0;i=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")) && @@ -293,9 +304,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,15 +356,17 @@ 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); } 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)); @@ -368,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"); @@ -1228,7 +1242,127 @@ 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;} } +/* 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,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; } +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]; + 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 && ghost_near(d,64)) return true; + return false; +} +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.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; +} +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);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(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.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; @@ -1244,8 +1378,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,GHOST_SHOTS,(pc&0xff0000)|0xd3dd); return; } + 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; @@ -1621,11 +1760,22 @@ static void view_world_hook(CpuState *cpu,uint32_t pc) { if(at==0x80c3) interp_bridge_pre_opcode_redirect(outside?0x8280d4:0x8280d9); if(at==0x8957) interp_bridge_pre_opcode_redirect(0x838966); } +/* Couch co-op runs enemy AI against the projected world actor. Enemies that + * act on the body they chase also need the nearest player there: Launch + * Octopus's Gulpfer ($1D) homes in on and swallows $0BA8, so it never went + * for P2. The enemy loops ($00:D4EA/D507) are always interpreted. */ +static bool couch_nearest_target(unsigned d) { + if(d<0xe68 || d>=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); @@ -1650,6 +1800,9 @@ void MmxCoopRegisterHooks(void) { const unsigned actors[]={0xd4f6,0xd4f9,0xd515,0xd522,0xd499,0xd49c,0xd4b8,0xd4c5}; for(unsigned i=0;i 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);