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
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
94 changes: 78 additions & 16 deletions src/mmx_coop.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand All @@ -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)
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1448,12 +1471,13 @@ 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(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);
Expand Down Expand Up @@ -1935,40 +1959,78 @@ 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) &&
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+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);
}
return;
}
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);
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);
Expand Down
43 changes: 43 additions & 0 deletions tests/mmx_coop_bounce_test.inc
Original file line number Diff line number Diff line change
@@ -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<lowest) lowest=y;
y=coop_y(&s.players[rider^1]);if(y<other_lowest) other_lowest=y;
}
check(active && lowest<1391,"either character in either seat activates and rides the bounce");
check(other_lowest==1439,"bounce does not launch the partner away from the pad");
check(!MmxCoopViewsGetWorldState().actor_return,"bounce restores the world actor after each update");
}
MmxCoopDisable();puts("MMX CO-OP BOUNCE CHECKS PASSED");
}
7 changes: 7 additions & 0 deletions tests/mmx_coop_test.inc
Original file line number Diff line number Diff line change
Expand Up @@ -1045,13 +1045,20 @@ 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"
#include "mmx_coop_bounce_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");
check(MmxZeroLoad(assets), "co-op uses original X3 Zero assets");
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")) {
if(getenv("MMX_COOP_BOUNCE_TEST")) coop_bounce_checks(getenv("MMX_COOP_VILE_SCRIPT_FIXTURE"));
else 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")) {
Expand Down
104 changes: 104 additions & 0 deletions tests/mmx_coop_vile_test.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/* 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_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");
/* 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])<x0 && coop_x(&s.players[1])>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");
}
Loading