From 2d949f43ab71568951eb7136939deb765079676e Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:02:05 -0700 Subject: [PATCH 1/4] Keep Sigma 1 Vile capture and rescue single-seat in co-op --- src/mmx_coop.c | 36 +++++++++++++++++++--- tests/mmx_coop_test.inc | 2 ++ tests/mmx_coop_vile_test.inc | 60 ++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 tests/mmx_coop_vile_test.inc diff --git a/src/mmx_coop.c b/src/mmx_coop.c index 900a42b..9f7167c 100644 --- a/src/mmx_coop.c +++ b/src/mmx_coop.c @@ -711,6 +711,28 @@ static bool floor_below(const uint8_t *r,const uint8_t *b) { static bool capsule_slot(unsigned d) { return d>=0xe68 && d<0x1228 && !((d-0xe68)%64) && g_ram[d] && g_ram[d+10]==0x4d; } +/* Sigma 1's armored Vile owns the playable body from the stun/grab through + * NPC Zero's rescue. Armored Vile's .1=$04 starts his destruction; the + * unarmored $69 intro and body lock then keep the existing scene active. */ +static bool vile_capture(const uint8_t *r) { + if(r[0x1f7a]!=9) return false; + for(unsigned d=0xe68;d<0x1228;d+=64) + if(r[d] && r[d+10]==0x67 && r[d+1]==2 && r[d+2]==0x16) return true; + return false; +} +static bool vile_script_object(unsigned d) { + if(g_ram[0x1f7a]!=9 || !g_ram[d]) return false; + if(d>=0xe68 && d<0x1228 && !((d-0xe68)%64)) { + unsigned c=g_ram[d+10]; + return c==0x66 || c==0x64 || (c==0x67 && g_ram[d+1]<4) || + (c==0x69 && g_ram[d+1]<4); + } + if(d>=0x1428 && d<0x1628 && !((d-0x1428)%64) && g_ram[d+10]==0x16) { + for(unsigned v=0xe68;v<0x1228;v+=64) + if(g_ram[v] && g_ram[v+10]==0x67 && g_ram[v+1]<4) return true; + } + return false; +} static void begin_scene(uint8_t *r) { unsigned other=state.anchor^1; if(state.scene_owner || state.players[other].status!=MMX_COOP_ALIVE || @@ -729,7 +751,7 @@ static bool scene_tick(uint8_t *r) { * until $87:CE06. $1F3B clears earlier, before that demonstration starts. */ if(!state.scene_owner && state.players[state.anchor^1].status==MMX_COOP_ALIVE && (r[0xbcf]&127) && r[0xbaa]!=12 && - (r[0x1f0c] || r[0x1f23] || r[0x1f48] || (r[0xc16] && (r[0x1f31] || r[0x1f3b])))) begin_scene(r); + (vile_capture(r) || r[0x1f0c] || r[0x1f23] || r[0x1f48] || (r[0xc16] && (r[0x1f31] || r[0x1f3b])))) begin_scene(r); /* Unified view only: a player the shared camera leaves below the screen * is beamed out, and returns beside the other once there is a landing. * Over a real pit (no floor down to the level's lowest camera position) @@ -786,7 +808,7 @@ static bool scene_tick(uint8_t *r) { } r[0xb9d]=r[0xba0]=0;return true; } - if(!r[0x1f0c] && !r[0xc16] && !r[0x1f23] && !r[0x1f13] && !r[0x1f48] && r[0x1f10]<6 && r[0xd3]==4) { + if(!vile_capture(r) && !r[0x1f0c] && !r[0xc16] && !r[0x1f23] && !r[0x1f13] && !r[0x1f48] && r[0x1f10]<6 && r[0xd3]==4) { uint16_t x,y; if(!MmxCoopFindLanding(r,&x,&y)) return false; place_other(r,x,y,true);p=&state.players[state.anchor^1]; @@ -1294,7 +1316,8 @@ static void contact_hook(CpuState *cpu,uint32_t pc) { * its body contact for the partner re-entered the capsule mid-dialogue: * Zero walking into it moved it from Light's dialogue (state 6) to the * upgrade (state 8), so the armor sequence played under the text. */ - if (!state.contact_pass && capsule_slot(cpu->D)) return; + if (!state.contact_pass && (capsule_slot(cpu->D) || + (at==0x9b03 && vile_script_object(cpu->D)))) return; if (!state.contact_pass) { MmxCoopViewsContactPlayer(state.current); state.contact_pass = 1; state.contact_entry = (uint16_t)at; @@ -1453,7 +1476,8 @@ static bool object_ghost_wanted(unsigned d) { if(enemy && (lift_elevator(d) || platform_item(d) || c==0x3d || c==0x43 || c==0x44 || 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(vile_script_object(d) || (enemy && + (c==0x67 || c==0x69 || MmxWidePolicy_IsBossEncounter((uint8_t)c)))) return false; if(projectile && c==0x19) return false; /* The current generator's boxes reach about 200 px from its origin. */ return ghost_near(d,256); @@ -1959,6 +1983,10 @@ static void view_actor_hook(CpuState *cpu,uint32_t pc) { } if(state.stage_pending || state.menu_owner || state.scene_owner || g_ram[0xd3]!=4 || world.actor_return) return; if(cpu->D<0xe68 || (cpu->D>=0x1228 && cpu->D<0x1428)) return; + /* Vile, his restraint projectile and story Zero always use the world + * actor, including independent views. Choosing a nearby partner here + * would hand the capture back and forth between bodies. */ + if(vile_script_object(cpu->D)) return; MmxCoopCapture(g_ram); unsigned nearest=state.anchor;uint64_t best=UINT64_MAX; bool fish=couch_nearest_target(cpu->D); diff --git a/tests/mmx_coop_test.inc b/tests/mmx_coop_test.inc index 00ddc2b..ca82121 100644 --- a/tests/mmx_coop_test.inc +++ b/tests/mmx_coop_test.inc @@ -1045,6 +1045,7 @@ static void coop_slime_checks(const char *fixture,uint8_t *start,uint8_t *expect #include "mmx_coop_death_armor_test.inc" #include "mmx_coop_kuwanger_test.inc" #include "mmx_coop_octopus_test.inc" +#include "mmx_coop_vile_test.inc" static void coop_checks(const char *assets, const char *fixture, uint8_t *start, uint8_t *expected, uint8_t *actual, size_t cap) { check(assets && fixture, "co-op check requires private source assets and a grounded fixture"); @@ -1052,6 +1053,7 @@ static void coop_checks(const char *assets, const char *fixture, MmxZeroRegisterHooks(); MmxCoopRegisterHooks(); const char *x2=getenv("MMX_COOP_X2_ASSETS"),*x3=getenv("MMX_COOP_X3_ASSETS"); if (x2 && x3) check(MmxWeaponsLoadPage(x2,1) && MmxWeaponsLoadPage(x3,2),"private original weapon source packs load"); + if(getenv("MMX_COOP_VILE_SCRIPT_FIXTURE")) {coop_vile_checks(getenv("MMX_COOP_VILE_SCRIPT_FIXTURE"));return;} if(getenv("MMX_COOP_OCTOPUS_TEST")) {coop_octopus_checks(fixture,start,expected,actual,cap);return;} if(getenv("MMX_COOP_KUWANGER_TEST")) {coop_kuwanger_checks(getenv("MMX_COOP_KUWANGER_TEST"));return;} if(getenv("MMX_COOP_DEATH_ARMOR_TEST")) { diff --git a/tests/mmx_coop_vile_test.inc b/tests/mmx_coop_vile_test.inc new file mode 100644 index 0000000..acbebc3 --- /dev/null +++ b/tests/mmx_coop_vile_test.inc @@ -0,0 +1,60 @@ +/* Private ROM-backed replay from the Sigma 1 door checkpoint. */ +static unsigned coop_vile_slot(unsigned kind) { + for(unsigned d=0xe68;d<0x1228;d+=64) + if(g_ram[d] && g_ram[d+10]==kind) return d; + return 0; +} +static void coop_vile_checks(const char *fixture) { + unsigned roster=getenv("MMX_COOP_VILE_ZERO_WORLD") ? 1 : 0; + check(MmxCoopEnable(roster),"Vile co-op roster enables"); + check(RtlLoadSnapshot(fixture),"private Sigma 1 door fixture loads"); + /* This checkpoint predates co-op. Match its native character context to + * the requested roster before Initialize captures the world actor. */ + MmxZeroState z=MmxZeroGetState();z.active_x=roster==MMX_COOP_X;MmxZeroSetState(z); + bool captured=false,hidden=false,rescued=false,returned=false; + unsigned partner_hp=0,partner_weapon=0; + for(unsigned tick=0;tick<7000;++tick) { + MmxCoopState s=MmxCoopGetState(); + unsigned vile=coop_vile_slot(0x67),zero=coop_vile_slot(0x66); + if(vile && g_ram[vile+1]==2 && g_ram[vile+2]==0x16 && !captured) { + captured=true;partner_hp=s.players[s.anchor^1].body[0x27]&127; + partner_weapon=s.players[s.anchor^1].body[0x33]; + check(s.players[s.anchor^1].status==MMX_COOP_ALIVE,"partner alive before Vile capture"); + check(RtlSaveSnapshot("vile-capture.sav"),"private pre-capture fixture saves"); + } + if(captured && s.scene_phase==2) { + if(!hidden) check(s.scene_owner==s.anchor+1,"Vile capture belongs to the world actor"); + hidden=true; + if((s.players[s.anchor^1].body[0x27]&127)!=partner_hp || + s.players[s.anchor^1].body[0x33]!=partner_weapon || + s.players[s.anchor^1].body[2]==0x3c) + check(false,"hidden partner retains health and avoids Vile restraint"); + } + if(captured && zero && g_ram[zero+2]>=14) rescued=true; + unsigned final=coop_vile_slot(0x69); + if(captured && hidden && rescued && final && g_ram[final+1]==4 && + !s.scene_owner && !g_ram[0xc16]) { + returned=true; + check(s.players[0].status==MMX_COOP_ALIVE && s.players[1].status==MMX_COOP_ALIVE && + (s.players[s.anchor^1].body[0x27]&127)==partner_hp, + "both players return alive for unarmored Vile"); + unsigned x0=coop_x(&s.players[0]),x1=coop_x(&s.players[1]); + coop_frames(12,SNES_PAD_LEFT,SNES_PAD_RIGHT);s=MmxCoopGetState(); + check(coop_x(&s.players[0])x1, + "both players regain movement after the rescue"); + check(RtlSaveSnapshot("vile-final-fight.sav"),"private playable final-fight fixture saves"); + break; + } + unsigned pad=(g_ram[0x1e4d]|g_ram[0x1e4e]<<8)<2688 ? SNES_PAD_RIGHT : 0; + if(g_ram[0x1f3b] && tick%30==0) pad|=SNES_PAD_B|SNES_PAD_Y|SNES_PAD_A; + coop_frames(1,pad,0); + } + if(!returned) { + MmxCoopState s=MmxCoopGetState(); + fprintf(stderr,"Vile incomplete: capture=%u hidden=%u rescue=%u scene=%u/%u action=%02x:%02x lock=%02x HP=%u/%u\n", + captured,hidden,rescued,s.scene_owner,s.scene_phase,g_ram[0xbaa],g_ram[0xbab],g_ram[0xc16],g_ram[0xbcf]&127,g_ram[0x1f9a]); + RtlSaveSnapshot("vile-incomplete.sav"); + } + check(captured && hidden && rescued && returned,"Sigma 1 capture, rescue and partner return complete"); + MmxCoopDisable();puts("MMX CO-OP VILE CHECKS PASSED"); +} From 059e6cc4602b212509e84e2a138ed92f61af5f78 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:51:32 -0700 Subject: [PATCH 2/4] Give X ownership of the post-Vile farewell trigger in co-op --- src/mmx_coop.c | 22 ++++++++++++++++++ tests/mmx_coop_vile_test.inc | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/mmx_coop.c b/src/mmx_coop.c index 9f7167c..b1d1c17 100644 --- a/src/mmx_coop.c +++ b/src/mmx_coop.c @@ -1964,15 +1964,37 @@ 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 bool vile_farewell(unsigned d) { + return g_ram[0x1f7a]==9 && d>=0xe68 && d<0x1228 && !((d-0xe68)%64) && + g_ram[d] && g_ram[d+10]==0x66 && g_ram[d+1]==2 && g_ram[d+2]==14; +} static void view_actor_hook(CpuState *cpu,uint32_t pc) { if(!enabled || !state.initialized) return; unsigned at=pc&65535; MmxCoopViewWorldState world=MmxCoopViewsGetWorldState(); bool returning=at==0xd4f9 || at==0xd522 || at==0xd49c || at==0xd4c5; + /* Kneeling story Zero checks $0BAD >= $0BB8 for his farewell. Evaluate + * that trigger against playable X even when Zero drives the world. Once + * it fires, X owns the dialogue and the partner's usual scene transport. + * A dead or beaming X must return before the conversation can begin. */ + if(at==0xd4f6 && vile_farewell(cpu->D) && g_ram[cpu->D+3]<=2 && + !state.stage_pending && !state.menu_owner && !state.scene_owner && !world.actor_return) { + unsigned xs=state.players[0].character==MMX_COOP_X ? 0 : 1; + const MmxCoopPlayer *x=&state.players[xs]; + if(x->character!=MMX_COOP_X || x->status!=MMX_COOP_ALIVE || + !(x->body[0x27]&127) || x->zero.swap_phase) { + interp_bridge_pre_opcode_redirect(0x00d4f9);return; + } + MmxCoopViewsActorReturn(state.current+1);MmxCoopSelect(g_ram,xs);return; + } if(!MmxCoopViewsOnline() && !(returning ? world.actor_return : (at==0xd4f6 || at==0xd515) && couch_nearest_target(cpu->D))) return; if(returning) { if(world.actor_return) { + if(at==0xd4f9 && vile_farewell(cpu->D) && g_ram[cpu->D+3]>=4 && + state.players[state.current].character==MMX_COOP_X) { + state.anchor=state.current;MmxCoopViewsActorReturn(0);begin_scene(g_ram);return; + } if(couch_nearest_target(cpu->D) && (g_ram[cpu->D+0x3b] || g_ram[cpu->D+0x3d])) g_ram[cpu->D+0x3f]=(uint8_t)(state.current+1); diff --git a/tests/mmx_coop_vile_test.inc b/tests/mmx_coop_vile_test.inc index acbebc3..5a82911 100644 --- a/tests/mmx_coop_vile_test.inc +++ b/tests/mmx_coop_vile_test.inc @@ -4,7 +4,51 @@ static unsigned coop_vile_slot(unsigned kind) { if(g_ram[d] && g_ram[d+10]==kind) return d; return 0; } +static void coop_vile_farewell_checks(const char *fixture) { + for(unsigned xs=0;xs<2;++xs) { + check(MmxCoopEnable(0),"farewell roster enables"); + check(RtlLoadSnapshot(fixture),"private post-Vile fixture loads"); + MmxCoopCapture(g_ram);MmxCoopState s=MmxCoopGetState(); + if(xs) {MmxCoopPlayer p=s.players[0];s.players[0]=s.players[1];s.players[1]=p;} + s.current=s.anchor=(uint8_t)(xs^1);s.scene_owner=s.scene_phase=s.stage_pending=0; + for(unsigned seat=0;seat<2;++seat) { + MmxCoopPlayer *p=&s.players[seat];unsigned x=seat==xs ? 2940 : 3020; + p->body[5]=(uint8_t)x;p->body[6]=(uint8_t)(x>>8); + p->body[8]=159;p->body[9]=5;p->body[2]=p->body[3]=0; + p->zero.swap_phase=0;p->body[0x27]=0x98;p->status=MMX_COOP_ALIVE; + } + memcpy(g_ram+0xba8,s.players[s.current].body,sizeof(s.players[0].body)); + MmxZeroSetState(s.players[s.current].zero);MmxCoopSetState(&s); + unsigned npc=coop_vile_slot(0x66); + check(npc && g_ram[npc+2]==14 && g_ram[npc+3]==2,"story Zero awaits X"); + coop_frames(40,0,0);s=MmxCoopGetState(); + check(g_ram[npc+3]==2 && !g_ram[0x1f3b] && !s.scene_owner && s.anchor==(xs^1), + "Zero crosses farewell trigger without starting X's conversation"); + bool started=false; + for(unsigned tick=0;tick<100;++tick) { + coop_frames(1,xs ? 0 : SNES_PAD_RIGHT,xs ? SNES_PAD_RIGHT : 0); + s=MmxCoopGetState(); + if(g_ram[npc+3]>=4) {started=true;break;} + } + check(started && s.anchor==xs && s.scene_owner==xs+1, + "X in either seat triggers and owns the farewell while Zero drives the world"); + check(s.players[xs].character==MMX_COOP_X && s.players[xs^1].zero.swap_phase, + "farewell transports playable Zero, retaining X"); + bool returned=false; + for(unsigned tick=0;tick<2000;++tick) { + unsigned pad=g_ram[0x1f3b] && tick%30==0 ? SNES_PAD_B|SNES_PAD_Y|SNES_PAD_A : 0; + coop_frames(1,xs ? 0 : pad,xs ? pad : 0);s=MmxCoopGetState(); + if(!s.scene_owner && !g_ram[0x1f3b] && (!g_ram[npc] || g_ram[npc+3]>=10)) { + returned=true;break; + } + } + check(returned && s.players[0].status==MMX_COOP_ALIVE && s.players[1].status==MMX_COOP_ALIVE, + "farewell completes and both players return alive"); + } + MmxCoopDisable();puts("MMX CO-OP VILE FAREWELL CHECKS PASSED"); +} static void coop_vile_checks(const char *fixture) { + if(getenv("MMX_COOP_VILE_FAREWELL_TEST")) {coop_vile_farewell_checks(fixture);return;} unsigned roster=getenv("MMX_COOP_VILE_ZERO_WORLD") ? 1 : 0; check(MmxCoopEnable(roster),"Vile co-op roster enables"); check(RtlLoadSnapshot(fixture),"private Sigma 1 door fixture loads"); From 088678dc1783842ee2517d77a4850f6c4ab0af8a Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:12:42 -0700 Subject: [PATCH 3/4] Keep Sigma bounce pads bound to their co-op rider --- src/mmx_coop.c | 36 ++++++++++++++++++---------- tests/mmx_coop_bounce_test.inc | 43 ++++++++++++++++++++++++++++++++++ tests/mmx_coop_test.inc | 7 +++++- 3 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 tests/mmx_coop_bounce_test.inc diff --git a/src/mmx_coop.c b/src/mmx_coop.c index b1d1c17..cd01fad 100644 --- a/src/mmx_coop.c +++ b/src/mmx_coop.c @@ -1471,10 +1471,10 @@ static bool object_ghost_wanted(unsigned d) { unsigned c=g_ram[d+10]; /* Objects with their own seat handling: D7D7 solids and minecarts, * AB81 lifts, Kuwanger's custom elevator and owned laser sensors/turrets, - * Dr. Light's capsule, Gulpfer's nearest-player chase, Slimer's puddle. + * bounce-pad riders, Dr. Light's capsule, Gulpfer's chase, Slimer's puddle. * Their hooks project seats, which a ghost replay deliberately forbids. */ if(enemy && (lift_elevator(d) || platform_item(d) || c==0x3d || - c==0x43 || c==0x44 || capsule_slot(d) || c==0x1d)) return false; + c==0x40 || c==0x43 || c==0x44 || capsule_slot(d) || c==0x1d)) return false; /* Bosses script the player (intros, victory pose); they stay single-seat. */ if(vile_script_object(d) || (enemy && (c==0x67 || c==0x69 || MmxWidePolicy_IsBossEncounter((uint8_t)c)))) return false; @@ -1959,10 +1959,12 @@ static void view_world_hook(CpuState *cpu,uint32_t pc) { /* 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. */ + * for P2. Sigma's bounce pad ($40) likewise activates and carries that body; + * a discarded ghost activation never advances its bounce for the partner. + * 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; + return g_ram[d+10]==0x1d || g_ram[d+10]==0x40; } static bool vile_farewell(unsigned d) { return g_ram[0x1f7a]==9 && d>=0xe68 && d<0x1228 && !((d-0xe68)%64) && @@ -1996,8 +1998,10 @@ static void view_actor_hook(CpuState *cpu,uint32_t pc) { state.anchor=state.current;MmxCoopViewsActorReturn(0);begin_scene(g_ram);return; } if(couch_nearest_target(cpu->D) && - (g_ram[cpu->D+0x3b] || g_ram[cpu->D+0x3d])) + g_ram[cpu->D+10]==0x1d && (g_ram[cpu->D+0x3b] || g_ram[cpu->D+0x3d])) g_ram[cpu->D+0x3f]=(uint8_t)(state.current+1); + if(couch_nearest_target(cpu->D) && g_ram[cpu->D+10]==0x40) + g_ram[cpu->D+0x3f]=g_ram[cpu->D+1]==4 ? (uint8_t)(state.current+1) : 0; MmxCoopSelect(g_ram,world.actor_return-1);MmxCoopViewsActorReturn(0); select_world_survivor(g_ram); } @@ -2011,14 +2015,22 @@ static void view_actor_hook(CpuState *cpu,uint32_t pc) { if(vile_script_object(cpu->D)) return; MmxCoopCapture(g_ram); unsigned nearest=state.anchor;uint64_t best=UINT64_MAX; - bool fish=couch_nearest_target(cpu->D); - unsigned owner=fish ? g_ram[cpu->D+0x3f] : 0; - bool captured=fish && (g_ram[cpu->D+0x3b] || g_ram[cpu->D+0x3d]); - /* Gulpfer's hold/escape/death states act on the body it swallowed, even - * when another player moves closer. .3F is unused by its native routine - * and travels with the enemy in snapshots and rollback. */ + bool fish=couch_nearest_target(cpu->D) && g_ram[cpu->D+10]==0x1d; + bool pad=couch_nearest_target(cpu->D) && g_ram[cpu->D+10]==0x40; + unsigned owner=fish || pad ? g_ram[cpu->D+0x3f] : 0; + bool captured=(fish && (g_ram[cpu->D+0x3b] || g_ram[cpu->D+0x3d])) || + (pad && g_ram[cpu->D+1]==4); + /* Gulpfer keeps the body it swallowed; a rising bounce pad keeps its + * rider even when the partner moves closer. Both native routines leave + * .3F unused, so ownership travels with snapshots and rollback. */ if(captured && owner>=1 && owner<=2) { - MmxCoopViewsActorReturn(state.current+1);MmxCoopSelect(g_ram,owner-1);return; + const MmxCoopPlayer *p=&state.players[owner-1]; + if(!pad || (p->status==MMX_COOP_ALIVE && (p->body[0x27]&127) && !p->zero.swap_phase)) { + MmxCoopViewsActorReturn(state.current+1);MmxCoopSelect(g_ram,owner-1);return; + } + /* A retired rider cannot keep dragging the projected survivor. The + * pad's idle animation re-arms its ordinary contact query. */ + g_ram[cpu->D+1]=2;g_ram[cpu->D+0x3f]=0;captured=false; } if(fish && !captured) g_ram[cpu->D+0x3f]=0; int ex=word(g_ram+cpu->D+5),ey=word(g_ram+cpu->D+8); diff --git a/tests/mmx_coop_bounce_test.inc b/tests/mmx_coop_bounce_test.inc new file mode 100644 index 0000000..e4a727a --- /dev/null +++ b/tests/mmx_coop_bounce_test.inc @@ -0,0 +1,43 @@ +/* ROM-backed Sigma bounce-pad checks using a private owner checkpoint. */ +static void coop_bounce_checks(const char *fixture) { + for(unsigned roster=0;roster<2;++roster) for(unsigned rider=0;rider<2;++rider) { + check(MmxCoopEnable(0),"bounce-pad roster enables"); + check(RtlLoadSnapshot(fixture),"private bounce-pad fixture loads"); + MmxCoopCapture(g_ram);MmxCoopState s=MmxCoopGetState(); + if(roster) {MmxCoopPlayer p=s.players[0];s.players[0]=s.players[1];s.players[1]=p;} + unsigned pad=0; + for(unsigned d=0xe68;d<0x1228;d+=64) { + if(!pad && g_ram[d] && g_ram[d+10]==0x40 && g_ram[d+1]==2) pad=d; + else memset(g_ram+d,0,64); + } + check(pad!=0,"fixture contains an idle bounce pad"); + unsigned px=g_ram[pad+5]|g_ram[pad+6]<<8; + s.current=s.anchor=(uint8_t)(rider^1);s.scene_owner=s.scene_phase=s.stage_pending=0; + for(unsigned seat=0;seat<2;++seat) { + /* The owner's X stands on clear ground 80 pixels right of this pad. */ + MmxCoopPlayer *p=&s.players[seat];unsigned x=seat==rider ? px : px+80; + p->body[5]=(uint8_t)x;p->body[6]=(uint8_t)(x>>8); + p->body[8]=159;p->body[9]=5;p->body[2]=p->body[3]=0; + memset(p->body+0x1a,0,4);p->body[0x2b]=p->body[0x2c]=0; + p->zero.swap_phase=0;p->body[0x27]=0x98;p->status=MMX_COOP_ALIVE; + memset(p->shots,0,sizeof(p->shots)); + } + memcpy(g_ram+0xba8,s.players[s.current].body,sizeof(s.players[0].body)); + memset(g_ram+0x1228,0,0x200); + MmxZeroSetState(s.players[s.current].zero);MmxCoopSetState(&s); + bool active=false;unsigned lowest=1439,other_lowest=1439; + for(unsigned tick=0;tick<80;++tick) { + coop_frames(1,0,0);s=MmxCoopGetState(); + if(g_ram[pad+1]==4) { + active=true; + if(g_ram[pad+0x3f]!=rider+1) check(false,"bounce pad retains its triggering seat"); + } + unsigned y=coop_y(&s.players[rider]);if(y Date: Mon, 5 Oct 2026 17:14:00 -0700 Subject: [PATCH 4/4] Release 2.1.4-alpha --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 90e7471..057f48b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(MegaManXSNESRecomp VERSION 2.1.3 LANGUAGES C CXX) +project(MegaManXSNESRecomp VERSION 2.1.4 LANGUAGES C CXX) set(CMAKE_C_STANDARD 11) find_package(Python3 COMPONENTS Interpreter REQUIRED) add_custom_command(OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/mmx_weapon_sources.h"