From e703b7b8beba8aa446d305a864483533187f4829 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:37:18 -0700 Subject: [PATCH] Fix Launch Octopus co-op captures, vortex, and charged busters --- docs/coop-port.md | 32 ++++++++ src/mmx_coop.c | 46 ++++++++++- src/mmx_render_assets.c | 32 +++++--- src/mmx_renderer.c | 5 +- tests/mmx_coop_followup_test.inc | 2 +- tests/mmx_coop_octopus_test.inc | 137 +++++++++++++++++++++++++++++++ tests/mmx_coop_test.inc | 2 + 7 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 tests/mmx_coop_octopus_test.inc diff --git a/docs/coop-port.md b/docs/coop-port.md index 9c214d4..ad90d96 100644 --- a/docs/coop-port.md +++ b/docs/coop-port.md @@ -984,3 +984,35 @@ with the project flags and the policy test passes). To confirm: die to the mid-boss in the stage, take the checkpoint, and walk to its room; it should spawn on the way in, solo and in co-op. `SNESRECOMP_WS_SPAWN=0` (authentic 4:3 spawn timing) is the fallback on builds without this change. + +### Launch Octopus player interactions + +Gulpfer (`$1D`, `$82:A341`) chooses the nearest eligible living player while +free. A player already hidden or frozen inside another fish is ineligible. +During capture, hold, escape and death, the fish retains its victim in the +native-unused `.3F` byte (seat plus one). This keeps release and motion tied +to the captured body even as the other player approaches. Older snapshots +without `.3F` recover ownership from the parked body. This applies in unified +and independent camera modes and survives ordinary snapshots and rollback. + +The tall water vortex is effect `$16` (`$81:F493`), separate from enemy `$28`'s +upward currents. Its `$00:D359..D35C` update receives a speculative second +body pass, keeping that body's contact, action and motion while restoring +world state. The real pass advances animation, timers and bubbles once. +Shotgun Ice sled replays now keep BD4 as well as BD3: dropping its newly +written landing flag let the partner ride with a falling animation. + +Charged-buster CHR isolation covers native projectile kinds `$01/$02/$03`. +Kind `$01` was omitted from the earlier renderer repair. Group `$0E` pose 6 +retains tile `$31` from pose 4/5 because its short bottom-row DMA replaces +only tile `$30`; the isolated ROM asset must retain that same tile. + +`MMX_COOP_OCTOPUS_TEST=` runs focused ROM checks using +`save3.sav` (fish) and `save6.sav` (vortex). It covers both character assignments, +returned players, separate captures and release, both world actors, vortex +entry/exit, standing and riding the ice sled, simultaneous native charged +busters, the retained ROM tile, and deterministic snapshot replay. Set +`MMX_COOP_ONLINE_FIXTURE=1` to exercise the fish and vortex in independent +camera paths without opening sockets or a lobby. Synthetic projectile +placement uses the unified view; independent views can cull that imposed +ice-sled fixture before landing. Fixtures and source ROM assets remain private. diff --git a/src/mmx_coop.c b/src/mmx_coop.c index aa86ab4..900a42b 100644 --- a/src/mmx_coop.c +++ b/src/mmx_coop.c @@ -1395,7 +1395,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 }; +enum { GHOST_SHOTS=1, GHOST_OBJECT, GHOST_CURRENT }; static struct { uint8_t pass,kind,p,db;uint16_t a,x,y,s,d;uint32_t resume; uint8_t body[0x90]; @@ -1414,9 +1414,11 @@ static bool ghost_active(void) { return shot_ghost.pass==1; } * Octopus's current generator ($28) only flags a body it finds in one of its * four $82:D7D7 boxes (.3C, action $08); the player's own movement lifts it. * Contact damage taken during the replay is kept too, and the real update's - * contact retry then finds the partner already hit and invulnerable. */ + * contact retry then finds the partner already hit and invulnerable. + * Shot riders also need BD4 (.2C), not just last frame's BD3 (.2B): the + * native landing controller consumes that flag on the following frame. */ static bool shot_ghost_field(unsigned i) { - return shot_ghost.kind==GHOST_OBJECT || (i>=4 && i<=9) || (i>=0x1a && i<=0x1d) || i==0x2b; + return shot_ghost.kind!=GHOST_SHOTS || (i>=4 && i<=9) || (i>=0x1a && i<=0x1d) || i==0x2b || i==0x2c; } static bool ghost_partner_ready(void) { const MmxCoopPlayer *o=&state.players[state.current^1]; @@ -1512,6 +1514,23 @@ static void object_ghost_hook(CpuState *cpu,uint32_t pc) { diagnostic_event(g_ram,cpu,pc,"body-moved"); object_watch.armed=false; } +/* 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, + * while the vortex animation, timer and bubbles advance only once. */ +static void current_ghost_hook(CpuState *cpu,uint32_t pc) { + if(!enabled || !state.initialized) return; + if((pc&65535)==0xd35c) { + if(shot_ghost.pass==1 && shot_ghost.kind==GHOST_CURRENT) shot_ghost_end(cpu,pc); + return; + } + if(shot_ghost.pass==2 && shot_ghost.kind==GHOST_CURRENT) {shot_ghost.pass=0;return;} + if(shot_ghost.pass || state.menu_owner || state.scene_owner || state.stage_pending || + state.current!=state.anchor || g_ram[0xd3]!=4 || !ghost_partner_ready() || + cpu->D<0x1928 || cpu->D>=0x1d08 || (cpu->D-0x1928)%32 || + !g_ram[cpu->D] || g_ram[cpu->D+10]!=0x16) return; + shot_ghost_begin(cpu,GHOST_CURRENT,pc&0xffffff); +} 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 || @@ -1930,6 +1949,9 @@ static void view_actor_hook(CpuState *cpu,uint32_t pc) { (at==0xd4f6 || at==0xd515) && couch_nearest_target(cpu->D))) return; if(returning) { if(world.actor_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); MmxCoopSelect(g_ram,world.actor_return-1);MmxCoopViewsActorReturn(0); select_world_survivor(g_ram); } @@ -1939,10 +1961,26 @@ static void view_actor_hook(CpuState *cpu,uint32_t pc) { if(cpu->D<0xe68 || (cpu->D>=0x1228 && cpu->D<0x1428)) 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. */ + if(captured && owner>=1 && owner<=2) { + MmxCoopViewsActorReturn(state.current+1);MmxCoopSelect(g_ram,owner-1);return; + } + if(fish && !captured) g_ram[cpu->D+0x3f]=0; int ex=word(g_ram+cpu->D+5),ey=word(g_ram+cpu->D+8); for(unsigned seat=0;seat<2;++seat) { const MmxCoopPlayer *p=&state.players[seat]; if(p->status!=MMX_COOP_ALIVE || !(p->body[0x27]&127) || p->body[2]==12 || p->zero.swap_phase) continue; + /* A free fish must not chase a body hidden/parked inside another fish. + * The native eligibility test would then refuse every swallow attempt. */ + if(fish && !captured && (!p->body[14] || p->body[0x30])) continue; + /* Older snapshots have no .3F owner yet. Recover it from the body + * the native capture parked, rather than a nearby uncaptured partner. */ + if(captured && !owner && p->body[14] && !p->body[0x30]) continue; int64_t dx=(int)word(p->body+5)-ex,dy=(int)word(p->body+8)-ey; uint64_t distance=(uint64_t)(dx*dx+dy*dy); if(distancesizeof(art->tiles)) return NULL; - memcpy(art->tiles+dest,rom+source,count); - if (rom[list+4]&128) {complete=true;break;} + unsigned first=(!kind && pose==6) ? 4 : upload_pose; + for(unsigned pass=0;pass<(first!=upload_pose ? 2u : 1u);++pass) { + size_t list=table+word(table+(pass ? upload_pose : first)*2); + complete=false; + for (unsigned n=0;n<32;++n,list+=5) { + if (!range(list,5)) return NULL; + unsigned count=rom[list]*16; + if (!count) {complete=true;break;} + int dest=(rom[list+4]&127)*512-0xc000; + size_t source=lorom(word(list+1)|(rom[list+3]<<16)); + if (!range(source,count) || dest<0 || dest+count>sizeof(art->tiles)) return NULL; + memcpy(art->tiles+dest,rom+source,count); + if (rom[list+4]&128) {complete=true;break;} + } + if (!complete) return NULL; } - if (!complete) return NULL; memcpy(art->colors,palette->colors,sizeof(art->colors)); art->attributes=6;art->live_colors=true;charged_buster_ready[kind][pose]=true; return art; diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index bf9bbd5..4ca7fcc 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -1162,7 +1162,8 @@ static void coop_partner_row(const Ppu *ppu,const Raster *r,int y,MmxRenderView * Another seat can replace the shared $6200/$6300 weapon upload. */ if(d>=0x1228 && ram[d+10]==0x0c && group==0x47 && (s.attr&0x0e00)==0x0600) asset=MmxRenderAssetsWeaponX(6,false); - if(d>=0x1228 && ((ram[d+10]==3 && group==0x9e) || (ram[d+10]==2 && group==0x0e))) { + if(d>=0x1228 && ((ram[d+10]==3 && group==0x9e) || + ((ram[d+10]==1 || ram[d+10]==2) && group==0x0e))) { const MmxSpriteAsset *beam=MmxRenderAssetsChargedBuster(group,ram[d+23]&127); if(beam) asset=beam; } @@ -1362,7 +1363,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { bool red_death = stage && s.animation == 0x1d && MmxZeroDeathOrbRed(frame.ram,s.object); bool coop_buster=stage && frame_coop.initialized && s.object>=0x1228 && s.object<0x1428 && ((frame.ram[s.object+10]==3 && s.animation==0x9e) || - (frame.ram[s.object+10]==2 && s.animation==0x0e)); + ((frame.ram[s.object+10]==1 || frame.ram[s.object+10]==2) && s.animation==0x0e)); bool menu_body = s.object == 0x1988 && (s.animation == 0 || s.animation == 0x18); bool zero_body = zero && (s.object == 0xba8 || menu_body); bool triad_x_body=cast_body && !zero && s.object==0xba8; diff --git a/tests/mmx_coop_followup_test.inc b/tests/mmx_coop_followup_test.inc index d24a158..3fb5593 100644 --- a/tests/mmx_coop_followup_test.inc +++ b/tests/mmx_coop_followup_test.inc @@ -156,7 +156,7 @@ static unsigned coop_buster_pixels_equal(const uint32_t *a,const uint32_t *b,con const uint8_t *rom=g_snes->cart->rom;unsigned checked=0; for(unsigned seat=0;seat<2;++seat) for(unsigned slot=0;slot<8;++slot) { const uint8_t *q=state->players[seat].shots+slot*64; - if(!q[0] || (q[10]!=2 && q[10]!=3)) continue; + if(!q[0] || q[10]<1 || q[10]>3) continue; const MmxSpriteAsset *art=MmxRenderAssetsChargedBuster(q[22],q[23]&127); if(!art) continue; size_t directory=0x68000+q[22]*3; diff --git a/tests/mmx_coop_octopus_test.inc b/tests/mmx_coop_octopus_test.inc new file mode 100644 index 0000000..43edb25 --- /dev/null +++ b/tests/mmx_coop_octopus_test.inc @@ -0,0 +1,137 @@ +/* Private Launch Octopus owner saves: slot 04 Gulpfer, slot 07 vortex. */ +static void octopus_replay(uint8_t *start,uint8_t *expected,uint8_t *actual,size_t cap) { + size_t n=RtlSaveSnapshotToMemory(start,cap); + coop_frames(8,0,0);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"Launch interaction snapshot restores"); + coop_frames(8,0,0); + same(expected,en,actual,RtlSaveSnapshotToMemory(actual,cap),"Launch interaction replays deterministically"); +} +static void octopus_fish_checks(const char *dir,uint8_t *start,uint8_t *expected,uint8_t *actual,size_t cap) { + for(unsigned roster=0;roster<2;++roster) for(unsigned target=0;target<2;++target) { + owner_wide_load(dir,3);kuwanger_roster(roster,target^1); + unsigned d=0xe68,x=eagle_word(g_ram+d+5),y=eagle_word(g_ram+d+8); + uint8_t fresh[64];memcpy(fresh,g_ram+d,64); + /* Re-enrol through the actual placement path: a returned X must still + * be capturable, regardless of character assignment or spawn order. */ + MmxCoopState s=MmxCoopGetState();s.players[target].status=MMX_COOP_ABSENT; + MmxCoopSetState(&s); + check(MmxCoopPlacePartner(g_ram,x+24,y+12),"returned player is placed beside Gulpfer"); + kuwanger_place(target,x+24,y+12);kuwanger_place(target^1,x+140,y+76); + MmxCoopSelect(g_ram,target);eagle_putword(g_ram+0xbc4,0); + MmxCoopCapture(g_ram);MmxCoopSelect(g_ram,target^1); + g_ram[d+1]=4;g_ram[d+2]=2;g_ram[d+3]=0;g_ram[d+17]|=64; + coop_frames(30,0,0);s=MmxCoopGetState(); + check(g_ram[d+0x3d] && g_ram[d+0x3f]==target+1 && + !s.players[target].body[14] && s.players[target].body[0x30] && + s.players[target^1].body[14] && !s.players[target^1].body[0x30], + "either returned character is swallowed without capturing its partner"); + octopus_replay(start,expected,actual,cap); + g_ram[d+0x3f]=0; /* Old saves predate the ownership byte. */ + coop_frames(1,0,0); + check(g_ram[d+0x3f]==target+1,"old swallowed-player saves recover the capture owner"); + /* The free fish must skip the nearer body already inside another fish; + * the holding fish must keep its own victim as the partner approaches. */ + x=eagle_word(g_ram+d+5);y=eagle_word(g_ram+d+8); + unsigned other=d+64;memcpy(g_ram+other,fresh,64); + eagle_putword(g_ram+other+5,x);eagle_putword(g_ram+other+8,y); + g_ram[other+1]=4;g_ram[other+2]=2;g_ram[other+3]=0;g_ram[other+17]|=64; + kuwanger_place(target^1,x+24,y+12); + MmxCoopSelect(g_ram,target^1);eagle_putword(g_ram+0xbc4,0);MmxCoopCapture(g_ram); + coop_frames(30,0,0);s=MmxCoopGetState(); + check(g_ram[d+0x3f]==target+1 && g_ram[other+0x3f]==(target^1)+1 && + g_ram[d+0x3d] && g_ram[other+0x3d] && + !s.players[0].body[14] && !s.players[1].body[14], + "two fish retain separate victims and can swallow both players"); + octopus_replay(start,expected,actual,cap); + g_ram[d+0x27]=1;coop_frames(1,SNES_PAD_B,SNES_PAD_B);coop_frames(2,0,0);s=MmxCoopGetState(); + check(s.players[target].body[14] && !s.players[target].body[0x30] && + !s.players[target^1].body[14] && s.players[target^1].body[0x30], + "killing one fish releases only its own captured player"); + } +} +static void octopus_current_checks(const char *dir,uint8_t *start,uint8_t *expected,uint8_t *actual,size_t cap) { + for(unsigned roster=0;roster<2;++roster) for(unsigned anchor=0;anchor<2;++anchor) { + owner_wide_load(dir,6);kuwanger_roster(roster,anchor); + MmxCoopState before=MmxCoopGetState(); + coop_frames(20,0,0);MmxCoopState s=MmxCoopGetState(); + check(s.players[0].body[0x3c] && s.players[1].body[0x3c] && + coop_y(&s.players[1])+10=1 && q[10]<=3) seats|=1u<vram+0x6200,sizeof(saved));memcpy(oam,g_ppu->oam,sizeof(oam)); + uint8_t dma=g_ram[0xa3];g_ram[0xa3]=0;unsigned pixels=0; + for(unsigned seat=0;seat<2;++seat) { + MmxCoopState isolated=s;unsigned partner=s.current^1; + memset(isolated.players[s.current].shots,0,sizeof(isolated.players[s.current].shots)); + memcpy(isolated.players[partner].shots,s.players[seat].shots,sizeof(isolated.players[partner].shots)); + memset(isolated.players[partner].body,0,sizeof(isolated.players[partner].body)); + memset(isolated.players[partner].auxiliaries,0,sizeof(isolated.players[partner].auxiliaries)); + for(unsigned i=0;i<128;++i)g_ppu->oam[i*2]=0xf000; + memcpy(g_ppu->vram+0x6200,saved,sizeof(saved));MmxRendererReset();coop_x_graphics_draw(original,&isolated); + memset(g_ppu->vram+0x6200,0,sizeof(saved));coop_x_graphics_draw(poisoned,&isolated); + pixels+=coop_buster_pixels_equal(original,poisoned,&isolated); + } + check(pixels>100,"both charged beams render independently of shared weapon VRAM"); + memcpy(g_ppu->vram+0x6200,saved,sizeof(saved));memcpy(g_ppu->oam,oam,sizeof(oam));g_ram[0xa3]=dma; + } + if(t==11)modern_image("octopus-dual-buster"); + } + const MmxSpriteAsset *art=MmxRenderAssetsChargedBuster(0x0e,6); + check(art && !memcmp(art->tiles+0x31*32,g_snes->cart->rom+0x178720,32), + "short buster upload preserves the original preceding bottom-right tile"); + check(partial && seats==3,"simultaneous native busters cover both seats and the short-upload pose"); +} +static void coop_octopus_checks(const char *fixture,uint8_t *start,uint8_t *expected,uint8_t *actual,size_t cap) { + (void)fixture;const char *dir=getenv("MMX_COOP_OCTOPUS_TEST"); + octopus_fish_checks(dir,start,expected,actual,cap); + octopus_current_checks(dir,start,expected,actual,cap); + /* The synthetic projectile placement uses the owner's unified-camera + * fixture; independent views can cull it before the imposed landing. */ + if(!getenv("MMX_COOP_ONLINE_FIXTURE")) { + octopus_ice_checks(dir,start,expected,actual,cap); + octopus_buster_checks(dir); + } else puts("Independent-view fixture checks fish and vortex; projectile fixtures require unified view"); + MmxCoopDisable();puts("MMX LAUNCH OCTOPUS CO-OP CHECKS PASSED"); +} diff --git a/tests/mmx_coop_test.inc b/tests/mmx_coop_test.inc index 8c684e9..00ddc2b 100644 --- a/tests/mmx_coop_test.inc +++ b/tests/mmx_coop_test.inc @@ -1044,6 +1044,7 @@ static void coop_slime_checks(const char *fixture,uint8_t *start,uint8_t *expect #include "mmx_coop_owner_wide_test.inc" #include "mmx_coop_death_armor_test.inc" #include "mmx_coop_kuwanger_test.inc" +#include "mmx_coop_octopus_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"); @@ -1051,6 +1052,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_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")) { coop_armor_withdraw_checks(getenv("MMX_COOP_DEATH_ARMOR_TEST"));