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.4 LANGUAGES C CXX)
project(MegaManXSNESRecomp VERSION 2.1.5 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
46 changes: 41 additions & 5 deletions src/mmx_coop.c
Original file line number Diff line number Diff line change
Expand Up @@ -902,10 +902,10 @@ static bool lift_elevator(unsigned d) {
* Storm Eagle's E-tank elevator top ($59) and its column ($5A, 83 px
* below), Flame Mammoth's scrap blocks dropped onto the conveyor
* ($2A, from $87:9C7B/9D89), Armored Armadillo's minecart ($2B), and
* Kuwanger's red moving platforms ($3F). */
* Kuwanger's red moving platforms ($3F), and D-Rex's lower body ($62). */
if(d<0xe68 || d>=0x1228 || (d-0xe68)%64 || !g_ram[d]) return false;
unsigned c=g_ram[d+10];
return c==0x59 || c==0x5a || c==0x2a || c==0x2b || c==0x3f;
return c==0x59 || c==0x5a || c==0x2a || c==0x2b || c==0x3f || c==0x62;
}
static void laser_reset(void);
static void lift_reset(void) {
Expand Down Expand Up @@ -962,7 +962,7 @@ static void kuwanger_lift_hook(CpuState *cpu,uint32_t pc) {
static void kuwanger_carry_hook(CpuState *cpu,uint32_t pc) {
unsigned d=cpu->D;
if(!enabled || !state.initialized || d<0xe68 || d>=0x1228 || (d-0xe68)%64 ||
!g_ram[d] || (g_ram[d+10]!=0x3d && g_ram[d+10]!=0x3f)) return;
!g_ram[d] || (g_ram[d+10]!=0x3d && g_ram[d+10]!=0x3f && g_ram[d+10]!=0x62)) return;
if((pc&65535)==0xc715) {
if(elevator_move.pass) return;
unsigned riders=g_ram[d+0x3f]&3;
Expand Down Expand Up @@ -1071,7 +1071,7 @@ static void lift_rtl_hook(CpuState *cpu,uint32_t pc) {
}
bool second_rides=g_ram[d+0x2c]&1;
uint8_t combined=(uint8_t)(lift.first_2c|g_ram[d+0x2c]);
if(g_ram[d+10]==0x3f)
if(g_ram[d+10]==0x3f || g_ram[d+10]==0x62)
g_ram[d+0x3f]=(uint8_t)(((lift.first_2c&1)?1u<<lift.first:0)|
(second_rides?1u<<(lift.first^1):0));
if(g_ram[d+10]==0x2b) {
Expand Down Expand Up @@ -1418,7 +1418,7 @@ static void pickup_hook(CpuState *cpu,uint32_t pc) {
* 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, GHOST_CURRENT };
enum { GHOST_SHOTS=1, GHOST_OBJECT, GHOST_CURRENT, GHOST_EAGLE_WIND, GHOST_DREX_CONTACT };
static struct {
uint8_t pass,kind,p,db;uint16_t a,x,y,s,d;uint32_t resume;
uint8_t body[0x90];
Expand Down Expand Up @@ -1538,6 +1538,38 @@ static void object_ghost_hook(CpuState *cpu,uint32_t pc) {
diagnostic_event(g_ram,cpu,pc,"body-moved");
object_watch.armed=false;
}
/* Eagle's wind state directly offsets BA8 by two pixels. Replay only its
* height/direction/position check ($87:DAC4..DB0E), before the attack timer
* and animation advance. The boss itself must remain a single world actor.
* This also works when independent views project a different world seat. */
static void eagle_wind_hook(CpuState *cpu,uint32_t pc) {
if(!enabled || !state.initialized) return;
if((pc&65535)==0xdb0e) {
if(shot_ghost.pass==1 && shot_ghost.kind==GHOST_EAGLE_WIND) shot_ghost_end(cpu,pc);
return;
}
if(shot_ghost.pass==2 && shot_ghost.kind==GHOST_EAGLE_WIND) {shot_ghost.pass=0;return;}
unsigned d=cpu->D;
if(shot_ghost.pass || state.menu_owner || state.scene_owner || state.stage_pending ||
g_ram[0xd3]!=4 || !ghost_partner_ready() || d<0xe68 || d>=0x1228 ||
(d-0xe68)%64 || !g_ram[d] || g_ram[d+10]!=0x52) return;
shot_ghost_begin(cpu,GHOST_EAGLE_WIND,pc&0xffffff);
}
/* D-Rex's extra body-contact flags follow its ordinary solid query. Keep
* those flags private to each body too, without replaying its boss AI. */
static void drex_contact_hook(CpuState *cpu,uint32_t pc) {
if(!enabled || !state.initialized) return;
if((pc&65535)==0xc35f) {
if(shot_ghost.pass==1 && shot_ghost.kind==GHOST_DREX_CONTACT) shot_ghost_end(cpu,pc);
return;
}
if(shot_ghost.pass==2 && shot_ghost.kind==GHOST_DREX_CONTACT) {shot_ghost.pass=0;return;}
unsigned d=cpu->D;
if(shot_ghost.pass || state.menu_owner || state.scene_owner || state.stage_pending ||
g_ram[0xd3]!=4 || !ghost_partner_ready() || d<0xe68 || d>=0x1228 ||
(d-0xe68)%64 || !g_ram[d] || g_ram[d+10]!=0x62) return;
shot_ghost_begin(cpu,GHOST_DREX_CONTACT,pc&0xffffff);
}
/* Launch Octopus's vortex is effect $16, not the enemy current generator.
* Its native update ($81:F493) clears/sets BE4 and moves only BA8. Replay
* the effect for the other living body, retaining its contact and motion,
Expand Down Expand Up @@ -2058,6 +2090,10 @@ void MmxCoopRegisterHooks(void) {
interp_bridge_add_pre_opcode_hook(ghosts[i],object_ghost_hook);
interp_bridge_add_pre_opcode_hook(0x00d359,current_ghost_hook);
interp_bridge_add_pre_opcode_hook(0x00d35c,current_ghost_hook);
interp_bridge_set_pre_opcode_hook(0x87dac4,eagle_wind_hook);
interp_bridge_set_pre_opcode_hook(0x87db0e,eagle_wind_hook);
interp_bridge_set_pre_opcode_hook(0x88c333,drex_contact_hook);
interp_bridge_set_pre_opcode_hook(0x88c35f,drex_contact_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
120 changes: 120 additions & 0 deletions tests/mmx_coop_eagle_test.inc
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,126 @@ static void coop_eagle_checks(const char *directory,uint8_t *start,uint8_t *expe
puts("MMX CO-OP EAGLE CHECKS PASSED");
}

/* Keep private boss fixtures optional; exercise the real wind controller,
* including its directional and vertical exclusions and single timer tick. */
static void coop_eagle_wind_checks(const char *directory,uint8_t *start,
uint8_t *expected,uint8_t *actual,size_t cap) {
eagle_fixture(directory,2,0);
bool departed=false;unsigned boss=0;
for(unsigned tick=0;tick<1000 && !boss;++tick) {
coop_frames(1,departed?0:SNES_PAD_RIGHT,0);
MmxCoopState s=MmxCoopGetState();departed|=s.scene_phase!=0;
if(s.scene_phase) continue;
for(unsigned d=0xe68;d<0x1228;d+=64)
if(g_ram[d] && g_ram[d+10]==0x52 && g_ram[d+1]==4) boss=d;
}
check(boss!=0,"private Eagle checkpoint reaches combat");
if(getenv("MMX_COOP_EAGLE_WIND_SAVE")) check(RtlSaveSnapshot(getenv("MMX_COOP_EAGLE_WIND_SAVE")),"private Eagle combat checkpoint saves");
size_t checkpoint=RtlSaveSnapshotToMemory(start,cap);
for(unsigned anchor=0;anchor<2;++anchor) for(unsigned scenario=0;scenario<5;++scenario) {
check(RtlLoadSnapshotFromMemory(start,checkpoint),"Eagle combat restores");
MmxCoopCapture(g_ram);MmxCoopState s=MmxCoopGetState();
unsigned x=coop_x(&s.players[0]),y=coop_y(&s.players[0]);
for(unsigned seat=0;seat<2;++seat) {
MmxCoopPlayer *p=&s.players[seat];
eagle_putword(p->body+5,x);eagle_putword(p->body+8,y);
eagle_putword(p->body+0x22,x);eagle_putword(p->body+0x24,y);
memset(p->body+0x1a,0,4);p->body[2]=p->body[3]=0;
p->status=MMX_COOP_ALIVE;p->body[0x27]=0x98;p->zero.swap_phase=0;
memset(p->shots,0,sizeof(p->shots));
}
unsigned other=anchor^1;
if(scenario==2) eagle_putword(s.players[other].body+5,x+120);
if(scenario==3) eagle_putword(s.players[anchor].body+8,y-48);
if(scenario==4) {s.players[other].status=MMX_COOP_FALLEN;s.players[other].body[0x27]=0;}
unsigned before[2]={coop_x(&s.players[0]),coop_x(&s.players[1])};
s.current=s.anchor=(uint8_t)anchor;s.scene_owner=s.scene_phase=s.stage_pending=0;
memcpy(g_ram+0xba8,s.players[anchor].body,sizeof(s.players[0].body));
MmxZeroSetState(s.players[anchor].zero);MmxCoopSetState(&s);
/* Wind substate, facing toward the players, with time remaining. */
g_ram[boss+1]=4;g_ram[boss+2]=0;g_ram[boss+3]=4;
eagle_putword(g_ram+boss+5,scenario==1?x-100:x+100);
eagle_putword(g_ram+boss+8,scenario==3?y-48:y);eagle_putword(g_ram+boss+0x10,scenario==1?0x4000:0);
eagle_putword(g_ram+boss+0x34,100);g_ram[boss+0x39]=2;
coop_frames(1,0,0);s=MmxCoopGetState();
int delta=scenario==1?2:-2;
fprintf(stderr,"Eagle wind anchor=%u case=%u x=%u/%u -> %u/%u timer=%u state=%u/%u/%u\n",
anchor,scenario,before[0],before[1],coop_x(&s.players[0]),coop_x(&s.players[1]),
eagle_word(g_ram+boss+0x34),g_ram[boss+1],g_ram[boss+2],g_ram[boss+3]);
check(coop_x(&s.players[anchor])==before[anchor]+delta,"native world player receives wind");
if(scenario<4) check(coop_x(&s.players[other])==before[other]+(scenario<2?delta:0),
"partner wind obeys native facing and height rules");
check(eagle_word(g_ram+boss+0x34)==99,"wind timer advances only once");
}
size_t n=RtlSaveSnapshotToMemory(start,cap);
coop_frames(4,0,0);size_t en=RtlSaveSnapshotToMemory(expected,cap);
check(RtlLoadSnapshotFromMemory(start,n),"wind state reloads");coop_frames(4,0,0);
same(expected,en,actual,RtlSaveSnapshotToMemory(actual,cap),"wind snapshot replay is deterministic");
puts("MMX CO-OP EAGLE WIND CHECKS PASSED");
}

static bool drex_move_probe;
static void drex_carry_probe(CpuState *cpu,uint32_t pc) {
(void)pc;
if(drex_move_probe && cpu->D==0xe68 && g_ram[cpu->D+10]==0x62) {
eagle_putword(g_ram+cpu->D+5,eagle_word(g_ram+cpu->D+5)+1);
drex_move_probe=false;
}
}
static void coop_drex_rider_checks(const char *fixture,uint8_t *start,
uint8_t *expected,uint8_t *actual,size_t cap) {
interp_bridge_add_pre_opcode_hook(0x82c715,drex_carry_probe);
for(unsigned roster=0;roster<2;++roster) for(unsigned anchor=0;anchor<2;++anchor) {
check(MmxCoopEnable(0) && RtlLoadSnapshot(fixture),"private D-Rex checkpoint 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 boss=0xe68,x=eagle_word(g_ram+boss+5),y=eagle_word(g_ram+boss+8);
check(g_ram[boss+10]==0x62,"slot contains D-Rex's solid lower body");
/* Isolate the lower platform: preserve head state for boss coordination,
* but omit its solid update so landing cannot stop on the upper head. */
g_ram[0xea8]=0;
for(unsigned seat=0;seat<2;++seat) {
MmxCoopPlayer *p=&s.players[seat];
eagle_putword(p->body+5,x);eagle_putword(p->body+8,y-28);
eagle_putword(p->body+0x22,x);eagle_putword(p->body+0x24,y-30);
memset(p->body+0x1a,0,4);p->body[2]=8;p->body[3]=2;
p->body[0x2b]=p->body[0x2c]=0;p->body[0x26]=127;p->body[0x27]=0x98;
p->status=MMX_COOP_ALIVE;p->zero.swap_phase=0;
memset(p->shots,0,sizeof(p->shots));
}
s.current=s.anchor=(uint8_t)anchor;s.scene_owner=s.scene_phase=s.stage_pending=0;
memcpy(g_ram+0xba8,s.players[anchor].body,sizeof(s.players[0].body));
MmxZeroSetState(s.players[anchor].zero);MmxCoopSetState(&s);
unsigned together=0;
for(unsigned tick=0;tick<8;++tick) {
coop_frames(1,0,0);s=MmxCoopGetState();
if((g_ram[boss+0x3f]&3)==3) ++together;
if(tick%10==0) fprintf(stderr,"D-Rex roster=%u anchor=%u t=%u body=%u/%u boss=%u/%u riders=%u ground=%u/%u\n",
roster,anchor,tick,coop_y(&s.players[0]),coop_y(&s.players[1]),
eagle_word(g_ram+boss+5),eagle_word(g_ram+boss+8),g_ram[boss+0x3f],
s.players[0].body[0x2c],s.players[1].body[0x2c]);
}
check(together>=5,"both characters land on and remain riders of D-Rex");
check(abs((int)coop_x(&s.players[0])-(int)coop_x(&s.players[1]))<=2,
"D-Rex movement carries both riders equally");
unsigned bx=eagle_word(g_ram+boss+5),px[2]={coop_x(&s.players[0]),coop_x(&s.players[1])};
/* The owner's lower body is stationary in this phase. Introduce one
* pixel of movement at the actual native carry call, once for both seats. */
drex_move_probe=true;
coop_frames(1,0,0);s=MmxCoopGetState();
int dx=(int)eagle_word(g_ram+boss+5)-(int)bx;
fprintf(stderr,"D-Rex carry delta=%d players=%d/%d\n",dx,
(int)coop_x(&s.players[0])-(int)px[0],(int)coop_x(&s.players[1])-(int)px[1]);
check(dx!=0 && (int)coop_x(&s.players[0])-(int)px[0]==dx &&
(int)coop_x(&s.players[1])-(int)px[1]==dx,"moving D-Rex carries each actual rider");
size_t n=RtlSaveSnapshotToMemory(start,cap);
coop_frames(5,0,0);size_t en=RtlSaveSnapshotToMemory(expected,cap);
check(RtlLoadSnapshotFromMemory(start,n),"D-Rex riders reload");coop_frames(5,0,0);
same(expected,en,actual,RtlSaveSnapshotToMemory(actual,cap),"D-Rex rider ownership replays deterministically");
}
puts("MMX CO-OP D-REX RIDER CHECKS PASSED");
}

static void coop_x1_effect_checks(const char *fixture) {
check(MmxCoopEnable(0) && RtlLoadSnapshot(fixture),"X1 effect fixture loads");
check(g_ram[0xbdb]==4,"Sting fixture has native Chameleon Sting selected");
Expand Down
2 changes: 2 additions & 0 deletions tests/mmx_coop_test.inc
Original file line number Diff line number Diff line change
Expand Up @@ -1054,6 +1054,8 @@ 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_DREX_FIXTURE")) {coop_drex_rider_checks(getenv("MMX_COOP_DREX_FIXTURE"),start,expected,actual,cap);return;}
if(getenv("MMX_COOP_EAGLE_WIND_FIXTURES")) {coop_eagle_wind_checks(getenv("MMX_COOP_EAGLE_WIND_FIXTURES"),start,expected,actual,cap);return;}
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"));
Expand Down
3 changes: 2 additions & 1 deletion tools/apply_coop_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
0x049b03, 0x049b43, 0x019d67, 0xde9d, 0xdebc, 0xe543, 0x049c0e,
0x018a5c, 0x018a92, 0x018add, 0x9d9e, 0x01e70d, 0x01ec98, 0xd48d,
0xde40, 0x03a8bd, 0x04ab81, 0x04ab56, 0x07c07a, 0xf478, 0x019c70, 0x0897ba,
0x079794, 0x07add8, 0x07af10, 0x07b91c, 0x07ba72, 0x07ba44, 0x07bb09, 0x02c715}
0x079794, 0x07add8, 0x07af10, 0x07b91c, 0x07ba72, 0x07ba44, 0x07bb09, 0x02c715,
0x07dabd, 0x08c333}
VIEW_TARGETS={0x00dc36,0x00dcdb,0x02806e,0x02808f,0x00d4aa}
TARGETS |= VIEW_TARGETS
# Routed through the interpreter when the generated code has them as entries,
Expand Down
Loading