From 3c42da13259ffdce6ed4bc0ff55b5a4bcb82505b Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:04:08 -0700 Subject: [PATCH] Fix Kuwanger co-op lift support and laser target ownership --- docs/coop-interaction-ownership.md | 4 + src/mmx_coop.c | 152 ++++++++++++++++++++++++++++- tests/mmx_coop_kuwanger_test.inc | 91 +++++++++++++++++ tests/mmx_coop_test.inc | 2 + tools/apply_coop_hooks.py | 3 +- 5 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 tests/mmx_coop_kuwanger_test.inc diff --git a/docs/coop-interaction-ownership.md b/docs/coop-interaction-ownership.md index d91d29d..462ed96 100644 --- a/docs/coop-interaction-ownership.md +++ b/docs/coop-interaction-ownership.md @@ -56,6 +56,10 @@ Each player gets the native result. Behaviour is the same for X and Zero. | Item platforms `$0E/$0F/$10/$13/$14` | `platform_hook`, `$84:AB81/AB56` (returns `$84:AC34/AB80`) | Top and side contact retried for the partner, with the caller's entry registers. `.2C` rider bits are kept per seat. | — | | 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. | +| Kuwanger small moving lift (enemy `$16`) | `platform_hook`, `$84:AB81/AB56` | Uses the same per-seat `.2C` support as item platforms, from an enemy slot. | Both characters in either seat. | +| Kuwanger main elevator (enemy `$3D`) | `kuwanger_lift_hook`, `$87:AF10..AF5C`; `kuwanger_carry_hook`, `$82:C715..C733` | Retry only the custom boarding check, then carry each actual rider by the native delta. Its stage/camera script runs once. `.3F` keeps the seat bits in WRAM. | A partner boarding alone does not carry the player left on the ledge. | +| Kuwanger red platforms (enemy `$3F`) | `lift_contact_hook`, `$82:D7D7`; `kuwanger_carry_hook`, `$82:C715..C733` | Retry contact and carry only the actual riders. `.3F` keeps the seat bits in WRAM. | Native movement and firing advance once. | +| Kuwanger laser sensors/turrets (enemies `$43/$44`) | `laser_contact_hook`, `$84:9C0E`; `laser_target_hook`, `$87:BA5C/BB09/BB0D` | Either seat may trip a sensor. Its `.3F` seat is copied to each armed turret, which projects that player only while calculating its delayed shot direction. | Target ownership survives snapshots; normal world projection is restored afterwards. | | 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. | | 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. | — | diff --git a/src/mmx_coop.c b/src/mmx_coop.c index 0b1b986..c259409 100644 --- a/src/mmx_coop.c +++ b/src/mmx_coop.c @@ -839,6 +839,9 @@ static struct { bool ready;uint8_t pass,first,flags,previous,p,db; uint16_t d,s,a,x,y,ra,rx,ry;uint8_t rp,rdb; } cart; +static struct { + uint8_t pass,first,original,p,db,rp,rdb;uint16_t d,s,a,x,y,ra,rx,ry; +} elevator_move; static uint32_t lift_stack_ret(const CpuState *cpu) { return cpu->S<0x1ffd ? (uint32_t)(g_ram[cpu->S+1]|g_ram[cpu->S+2]<<8|g_ram[cpu->S+3]<<16) : 0; } @@ -862,12 +865,16 @@ static bool lift_elevator(unsigned d) { /* Solid enemies that query $82:D7D7 for the current seat only: * 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), and Armored Armadillo's minecart ($2B). */ + * ($2A, from $87:9C7B/9D89), Armored Armadillo's minecart ($2B), and + * Kuwanger's red moving platforms ($3F). */ 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; + return c==0x59 || c==0x5a || c==0x2a || c==0x2b || c==0x3f; +} +static void laser_reset(void); +static void lift_reset(void) { + lift.pass=0;lift.carry=0;cart.ready=false;cart.pass=0;elevator_move.pass=0;laser_reset(); } -static void lift_reset(void) { lift.pass=0;lift.carry=0;cart.ready=false;cart.pass=0; } static void lift_close(void) { if(lift.pass==2) MmxCoopSelect(g_ram,lift.first); lift.pass=0; @@ -886,6 +893,130 @@ static void lift_contact_hook(CpuState *cpu,uint32_t pc) { lift.a=cpu->A;lift.x=cpu->X;lift.y=cpu->Y;lift.p=cpu->P;lift.db=cpu->DB; lift.entry_2c=g_ram[cpu->D+0x2c];lift.ret=lift_stack_ret(cpu); } +/* Kuwanger's main elevator uses its own fixed-width top check rather than + * $82:D7D7. Replay only that check; its movement and camera script run once. */ +static void kuwanger_lift_hook(CpuState *cpu,uint32_t pc) { + unsigned d=cpu->D; + if(!enabled || !state.initialized || state.menu_owner || state.scene_owner || + d<0xe68 || d>=0x1228 || (d-0xe68)%64 || !g_ram[d] || g_ram[d+10]!=0x3d) return; + if((pc&65535)==0xaf10) { + if(lift.pass || state.players[state.current^1].status!=MMX_COOP_ALIVE || + !(state.players[state.current^1].body[0x27]&127)) return; + cpu_mirrors_to_p(cpu); + lift.pass=1;lift.first=state.current;lift.d=(uint16_t)d;lift.s=cpu->S; + lift.a=cpu->A;lift.x=cpu->X;lift.y=cpu->Y;lift.p=cpu->P;lift.db=cpu->DB; + lift.entry_2c=g_ram[d+0x2c];g_ram[d+0x3f]=0;return; + } + if(!lift.pass || cpu->S!=lift.s || d!=lift.d) return; + if(lift.pass==1) { + if(g_ram[d+0x2c]&4) g_ram[d+0x3f]|=1u<A;lift.rx=cpu->X;lift.ry=cpu->Y;lift.rp=cpu->P;lift.rdb=cpu->DB; + MmxCoopSelect(g_ram,lift.first^1);lift.pass=2;g_ram[d+0x2c]=lift.entry_2c; + cpu->A=lift.a;cpu->X=lift.x;cpu->Y=lift.y;cpu->P=lift.p;cpu->DB=lift.db;cpu_p_to_mirrors(cpu); + interp_bridge_pre_opcode_redirect(0x87af10);return; + } + if(g_ram[d+0x2c]&4) g_ram[d+0x3f]|=1u<A=lift.ra;cpu->X=lift.rx;cpu->Y=lift.ry;cpu->P=lift.rp;cpu->DB=lift.rdb;cpu_p_to_mirrors(cpu); + lift.pass=0; +} +/* .3F records the elevator/red platform's two answers. Apply the native delta only + * to those riders, so a partner boarding alone cannot drag the other body. */ +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; + if((pc&65535)==0xc715) { + if(elevator_move.pass) return; + unsigned riders=g_ram[d+0x3f]&3; + for(unsigned seat=0;seat<2;++seat) + if(state.players[seat].status!=MMX_COOP_ALIVE || !(state.players[seat].body[0x27]&127)) riders&=~(1u<S; + cpu_mirrors_to_p(cpu);elevator_move.a=cpu->A;elevator_move.x=cpu->X;elevator_move.y=cpu->Y; + elevator_move.p=cpu->P;elevator_move.db=cpu->DB; + MmxCoopSelect(g_ram,elevator_move.first);return; + } + if(!elevator_move.pass || elevator_move.d!=d || elevator_move.s!=cpu->S) return; + if(elevator_move.pass==1 && (g_ram[d+0x3f]&(1u<<(elevator_move.first^1))) && + state.players[elevator_move.first^1].status==MMX_COOP_ALIVE && + (state.players[elevator_move.first^1].body[0x27]&127)) { + cpu_mirrors_to_p(cpu);elevator_move.ra=cpu->A;elevator_move.rx=cpu->X;elevator_move.ry=cpu->Y; + elevator_move.rp=cpu->P;elevator_move.rdb=cpu->DB; + MmxCoopSelect(g_ram,elevator_move.first^1);elevator_move.pass=2; + cpu->A=elevator_move.a;cpu->X=elevator_move.x;cpu->Y=elevator_move.y; + cpu->P=elevator_move.p;cpu->DB=elevator_move.db;cpu_p_to_mirrors(cpu); + interp_bridge_pre_opcode_redirect(0x82c715);return; + } + MmxCoopSelect(g_ram,elevator_move.original); + if(elevator_move.pass==2) { + cpu->A=elevator_move.ra;cpu->X=elevator_move.rx;cpu->Y=elevator_move.ry; + cpu->P=elevator_move.rp;cpu->DB=elevator_move.rdb;cpu_p_to_mirrors(cpu); + } + elevator_move.pass=0; +} + +/* Laser sensors ($43) test a body directly through $84:9C0E. A successful + * contact arms all $44 turrets, whose delayed aim must keep that seat rather + * than use whichever body happens to drive the world twenty frames later. + * Native $43/$44 code leaves .3F unused; ownership stays in snapshot WRAM. */ +static struct {uint8_t pass,first,p,db,aim_return,sensor_return;uint16_t d,s,a,x,y;} laser; +static void laser_reset(void) {memset(&laser,0,sizeof(laser));} +static void laser_contact_hook(CpuState *cpu,uint32_t pc) { + unsigned d=cpu->D,at=pc&65535; + if(!enabled || !state.initialized || state.menu_owner || state.scene_owner || + d<0xe68 || d>=0x1228 || (d-0xe68)%64 || !g_ram[d] || g_ram[d+10]!=0x43) return; + if(at==0x9c0e) { + if(laser.pass || cpu->X!=0xba8) return; + cpu_mirrors_to_p(cpu);laser.pass=1;laser.first=state.current;laser.d=(uint16_t)d;laser.s=cpu->S; + laser.a=cpu->A;laser.x=cpu->X;laser.y=cpu->Y;laser.p=cpu->P;laser.db=cpu->DB;return; + } + if(!laser.pass || laser.s!=cpu->S || laser.d!=d) return; + if(cpu->_flag_C) g_ram[d+0x3f]=(uint8_t)(state.current+1); + else if(laser.pass==1 && state.players[laser.first^1].status==MMX_COOP_ALIVE && + (state.players[laser.first^1].body[0x27]&127)) { + MmxCoopSelect(g_ram,laser.first^1);laser.pass=2; + cpu->A=laser.a;cpu->X=laser.x;cpu->Y=laser.y;cpu->P=laser.p;cpu->DB=laser.db;cpu_p_to_mirrors(cpu); + interp_bridge_pre_opcode_redirect(0x849c0e);return; + } + if(laser.pass==2) { + /* Directional sensors also aim an immediate shot after this query. + * Keep its tripper projected through that handler, then restore the + * original world actor even if the one-shot sensor deletes itself. */ + if(cpu->_flag_C) laser.sensor_return=(uint8_t)(laser.first+1); + else MmxCoopSelect(g_ram,laser.first); + } + laser.pass=0; +} +static void laser_target_hook(CpuState *cpu,uint32_t pc) { + if(!enabled || !state.initialized) return; + unsigned d=cpu->D,at=pc&65535; + if(at==0xb92f && laser.sensor_return && d==laser.d) { + MmxCoopSelect(g_ram,laser.sensor_return-1);laser.sensor_return=0;return; + } + if(d<0xe68 || d>=0x1228 || (d-0xe68)%64 || !g_ram[d]) return; + if(at==0xb91c || at==0xba72) { + if(!g_ram[d+1]) g_ram[d+0x3f]=0; + return; + } + if(g_ram[d+10]!=0x44) return; + if(at==0xba5c) { + unsigned sensor=word(g_ram+2); + if(sensor>=0xe68 && sensor<0x1228 && !((sensor-0xe68)%64) && g_ram[sensor+10]==0x43) + g_ram[d+0x3f]=g_ram[sensor+0x3f]; + } else if(at==0xbb09) { + unsigned owner=g_ram[d+0x3f]; + if(owner && owner<=2 && state.players[owner-1].status==MMX_COOP_ALIVE && + (state.players[owner-1].body[0x27]&127)) { + laser.aim_return=(uint8_t)(state.current+1);MmxCoopSelect(g_ram,owner-1); + } + } else if(at==0xbb0d && laser.aim_return) { + MmxCoopSelect(g_ram,laser.aim_return-1);laser.aim_return=0; + } +} static void lift_rtl_hook(CpuState *cpu,uint32_t pc) { /* Same stack depth, slot and return address: D7D7's own RTL, not another * bank $82 routine returning to a different caller. */ @@ -904,6 +1035,9 @@ 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) + g_ram[d+0x3f]=(uint8_t)(((lift.first_2c&1)?1u<=0xe68 && d<0x1228 && !((d-0xe68)%64)) return g_ram[d] && g_ram[d+10]==0x16; if(d<0x1628 || d>=0x1928 || (d-0x1628)%48) return false; unsigned c=g_ram[d+10]; return (c>=0x0e && c<=0x10) || c==0x13 || c==0x14; @@ -1659,6 +1795,13 @@ void MmxCoopRegisterHooks(void) { interp_bridge_set_pre_opcode_hook(0x87c0ae,eagle_lift_hook); interp_bridge_set_pre_opcode_hook(0x87c0b4,eagle_lift_hook); interp_bridge_set_pre_opcode_hook(0x82d7d7,lift_contact_hook); + interp_bridge_set_pre_opcode_hook(0x87af10,kuwanger_lift_hook); + interp_bridge_set_pre_opcode_hook(0x87af5c,kuwanger_lift_hook); + interp_bridge_set_pre_opcode_hook(0x82c715,kuwanger_carry_hook); + interp_bridge_set_pre_opcode_hook(0x82c733,kuwanger_carry_hook); + const unsigned turrets[]={0x87b91c,0x87b92f,0x87ba72,0x87ba5c,0x87bb09,0x87bb0d}; + for(unsigned i=0;iD>=0xe68 && cpu->D<0x1228 && !((cpu->D-0xe68)%64) && g_ram[cpu->D+10]==0x44) + ++kuwanger_aims[MmxCoopGetState().current]; +} +static void coop_kuwanger_checks(const char *directory) { + for(unsigned roster=0;roster<2;++roster) for(unsigned anchor=0;anchor<2;++anchor) { + fprintf(stderr,"kuwanger support roster=%u anchor=%u\n",roster,anchor); + owner_wide_load(directory,11);kuwanger_roster(roster,anchor); + coop_frames(60,SNES_PAD_RIGHT,SNES_PAD_RIGHT);coop_frames(120,0,0); + MmxCoopState s=MmxCoopGetState();kuwanger_dump(180); + check(abs((int)coop_y(&s.players[0])-(int)coop_y(&s.players[1]))<=2 && + coop_y(&s.players[0])<4440 && (g_ram[0xea8+0x3f]&3)==3, + "both players naturally board and ascend Kuwanger's main elevator"); + owner_wide_load(directory,11);kuwanger_roster(roster,anchor); + s=MmxCoopGetState();unsigned original_x=coop_x(&s.players[0]); + coop_frames(60,0,SNES_PAD_RIGHT);coop_frames(120,0,0);s=MmxCoopGetState();kuwanger_dump(180); + check(coop_x(&s.players[0])<=original_x+16 && coop_y(&s.players[0])==4431 && + coop_y(&s.players[1])<4440 && g_ram[0xea8+0x3f]==2, + "P2 can start and ride the elevator without carrying P1 from the ledge"); + owner_wide_load(directory,8);kuwanger_roster(roster,anchor); + kuwanger_place(1,eagle_word(g_ram+0xe68+5)+4,eagle_word(g_ram+0xe68+8)-36); + coop_frames(50,0,0);s=MmxCoopGetState();kuwanger_dump(50); + check(g_ram[0xe68+0x2c]==3 && abs((int)coop_y(&s.players[0])-(int)coop_y(&s.players[1]))<=2, + "P2 lands on and follows the small moving lift beside P1"); + owner_wide_load(directory,9);kuwanger_roster(roster,anchor); + unsigned px=eagle_word(g_ram+0xe68+5),py=eagle_word(g_ram+0xe68+8); + /* Board the exposed right end; the left half initially crosses a wall. */ + kuwanger_place(0,px+24,py-28);kuwanger_place(1,px+28,py-28); + coop_frames(40,0,0);s=MmxCoopGetState();kuwanger_dump(40); + check(g_ram[0xe68+0x3f]==3 && abs((int)coop_y(&s.players[0])-(int)coop_y(&s.players[1]))<=2 && + coop_x(&s.players[0])>px+24 && coop_x(&s.players[1])>px+28, + "both players land on and move with the red platform"); + owner_wide_load(directory,9);kuwanger_roster(roster,anchor); + kuwanger_place(1,px+28,py-28);coop_frames(40,0,0);s=MmxCoopGetState(); + check(g_ram[0xe68+0x3f]==2 && coop_y(&s.players[0])==2495 && + coop_x(&s.players[1])>px+28 && coop_y(&s.players[1])<2440, + "a lone P2 rides the red platform without carrying P1 underneath"); + } + interp_bridge_add_pre_opcode_hook(0x87bb09,kuwanger_aim_probe); + for(unsigned roster=0;roster<2;++roster) for(unsigned anchor=0;anchor<2;++anchor) + for(unsigned tripper=0;tripper<2;++tripper) { + owner_wide_load(directory,0);kuwanger_roster(roster,anchor); + unsigned d=0xe68,turret=0xea8,x=eagle_word(g_ram+d+5),y=eagle_word(g_ram+d+8); + g_ram[d+1]=2;g_ram[d+0x34]=50;g_ram[d+0x3a]=0;g_ram[d+0x33]=1; + kuwanger_place(tripper,x+20,y+10);kuwanger_place(tripper^1,x+110,y+59); + memset(kuwanger_aims,0,sizeof(kuwanger_aims));coop_frames(1,0,0);kuwanger_dump(1); + check(g_ram[turret+0x36] && g_ram[turret+0x3f]==tripper+1, + "either player's laser contact arms the turret with its actual tripper"); + check(MmxCoopGetState().current==anchor,"sensor returns the normal world actor after its trigger"); + const size_t cap=2u*1024u*1024u;uint8_t *before=malloc(cap),*expected=malloc(cap),*actual=malloc(cap); + check(before && expected && actual,"laser ownership replay buffers allocate"); + size_t n=RtlSaveSnapshotToMemory(before,cap);coop_frames(40,0,0); + check(kuwanger_aims[tripper]>0 && !kuwanger_aims[tripper^1], + "the native delayed laser shot aims at the player who tripped the sensor"); + size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(before,n),"armed laser snapshot restores");coop_frames(40,0,0); + same(expected,en,actual,RtlSaveSnapshotToMemory(actual,cap),"laser target ownership survives snapshot replay"); + free(before);free(expected);free(actual); + } + puts("MMX CO-OP KUWANGER CHECKS PASSED"); +} diff --git a/tests/mmx_coop_test.inc b/tests/mmx_coop_test.inc index da631d4..8c684e9 100644 --- a/tests/mmx_coop_test.inc +++ b/tests/mmx_coop_test.inc @@ -1043,6 +1043,7 @@ static void coop_slime_checks(const char *fixture,uint8_t *start,uint8_t *expect #include "mmx_coop_views_test.inc" #include "mmx_coop_owner_wide_test.inc" #include "mmx_coop_death_armor_test.inc" +#include "mmx_coop_kuwanger_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"); @@ -1050,6 +1051,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_KUWANGER_TEST")) {coop_kuwanger_checks(getenv("MMX_COOP_KUWANGER_TEST"));return;} if(getenv("MMX_COOP_DEATH_ARMOR_TEST")) { coop_armor_withdraw_checks(getenv("MMX_COOP_DEATH_ARMOR_TEST")); if(!getenv("MMX_COOP_ARMOR_ONLY")) coop_team_pit_checks(getenv("MMX_COOP_DEATH_ARMOR_TEST")); diff --git a/tools/apply_coop_hooks.py b/tools/apply_coop_hooks.py index 3596a18..8d6ced2 100644 --- a/tools/apply_coop_hooks.py +++ b/tools/apply_coop_hooks.py @@ -8,7 +8,8 @@ TARGETS = {0x01812e, 0x048fca, 0x0280b4, 0xd2bd, 0xd3dd, 0xd3fa, 0xd43a, 0xd457, 0x049b03, 0x049b43, 0x019d67, 0xde9d, 0xdebc, 0xe543, 0x049c0e, 0x018a5c, 0x018a92, 0x018add, 0x9d9e, 0x01e70d, 0x01ec98, 0xd48d, - 0xde40, 0x03a8bd, 0x04ab81, 0x04ab56, 0x07c07a, 0xf478, 0x019c70, 0x0897ba} + 0xde40, 0x03a8bd, 0x04ab81, 0x04ab56, 0x07c07a, 0xf478, 0x019c70, 0x0897ba, + 0x079794, 0x07add8, 0x07af10, 0x07b91c, 0x07ba72, 0x07ba44, 0x07bb09, 0x02c715} VIEW_TARGETS={0x00dc36,0x00dcdb,0x02806e,0x02808f,0x00d4aa} TARGETS |= VIEW_TARGETS # Routed through the interpreter when the generated code has them as entries,