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
4 changes: 3 additions & 1 deletion docs/coop-interaction-ownership.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
177 changes: 165 additions & 12 deletions src/mmx_coop.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -138,8 +139,8 @@ static void diagnostic_pool(const uint8_t *r,unsigned base,unsigned stride,unsig
for(unsigned i=0;i<slots && n<cap;++i) {
const uint8_t *d=r+base+i*stride;
if(!d[0]) continue;
int w=snprintf(out+n,cap-n,"%s%u:%02x:%04x:%04x:%02x%02x%02x:%02x:%02x",n?";":"",
i,d[10],word(d+5),word(d+8),d[0],d[1],d[2],d[0x2c],d[0x27]);
int w=snprintf(out+n,cap-n,"%s%u:%02x:%04x:%04x:%02x%02x%02x:%02x:%02x:%02x",n?";":"",
i,d[10],word(d+5),word(d+8),d[0],d[1],d[2],d[0x2c],d[0x27],d[0x0e]);
if(w<0) break;
n+=(size_t)w;
}
Expand Down Expand Up @@ -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*36+1]={0},caller[12]={0},regs[24]={0},slot[64*2+1]={0},enemies[15*36+1]={0},shots[12*36+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);
Expand All @@ -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+2<sizeof(enemies)) {
char *tail=enemies+used;tail[0]='p';
diagnostic_pool(r,0x1428,64,8,tail+1,sizeof(enemies)-used-1);
if(!tail[1]) tail[0]=0;
}
}
/* The current seat's shots ($0C98, twelve 32-byte slots). */
if(frame_end || platform) diagnostic_pool(r,0xc98,32,12,shots,sizeof(shots));
/* The contacted item's whole 48-byte slot: a byte the first seat's call
* writes and the second seat's call reads shows up between their rows. */
if(platform && cpu && cpu->D>=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")) &&
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand All @@ -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");
Expand Down Expand Up @@ -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;i<sizeof(result);++i)
if(shot_ghost_field(i) && result[i]!=shot_ghost.body[i]) {body[i]=result[i];moved=true;}
cpu->A=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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -1650,6 +1800,9 @@ void MmxCoopRegisterHooks(void) {
const unsigned actors[]={0xd4f6,0xd4f9,0xd515,0xd522,0xd499,0xd49c,0xd4b8,0xd4c5};
for(unsigned i=0;i<sizeof(actors)/sizeof(actors[0]);++i)
interp_bridge_set_pre_opcode_hook(actors[i],view_actor_hook);
const unsigned ghosts[]={0xd4f6,0xd4f9,0xd499,0xd49c};
for(unsigned i=0;i<sizeof(ghosts)/sizeof(ghosts[0]);++i)
interp_bridge_add_pre_opcode_hook(ghosts[i],object_ghost_hook);
const unsigned views[]={0x00dcd7,0x00dd2d,0x82807d,0x82809e,0x8280c3,0x838957};
for(unsigned i=0;i<sizeof(views)/sizeof(views[0]);++i)
interp_bridge_set_pre_opcode_hook(views[i],view_world_hook);
Expand Down
8 changes: 8 additions & 0 deletions src/mmx_renderer.c
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
Loading
Loading