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
32 changes: 32 additions & 0 deletions docs/coop-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<private-directory>` 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.
46 changes: 43 additions & 3 deletions src/mmx_coop.c
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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];
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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);
}
Expand All @@ -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(distance<best) {best=distance;nearest=seat;}
Expand All @@ -1956,6 +1994,8 @@ void MmxCoopRegisterHooks(void) {
const unsigned ghosts[]={0xd4f6,0xd4f9,0xd499,0xd49c};
for(unsigned i=0;i<sizeof(ghosts)/sizeof(ghosts[0]);++i)
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);
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
32 changes: 19 additions & 13 deletions src/mmx_render_assets.c
Original file line number Diff line number Diff line change
Expand Up @@ -94,24 +94,30 @@ const MmxSpriteAsset *MmxRenderAssetsChargedBuster(unsigned group,unsigned pose)
/* $83:89E3/$8C70 select pose-DMA tables $85:AAF3/$AB9A for groups $0E/$9E. Native
* $84:8FCA uploads both rows to $6200/$6300. Each pose has different
* row lengths; a second player's pose can erase the first shot's bottom. */
/* Poses 1 and 6 have no transfer; they reuse the preceding growth pose.
* The shared disappearance poses 8..10 retain their native live binding. */
/* Empty records retain the preceding upload. Pose 6 of group $0E also
* replaces only tile $30 of the lower row: its arrangement still reads
* tile $31 from pose 4/5. Start with that row before applying the short
* transfer, rather than making the bottom-right eight pixels transparent. */
unsigned upload_pose=kind ? (pose==1 ? 0 : pose==6 ? 5 : pose) :
pose<4 ? 0 : (pose==17 || pose==18 || pose==20) ? 12 : pose;
size_t table=kind ? 0x2ab9a : 0x2aaf3;
size_t list=table+word(table+upload_pose*2);
bool 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;}
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;
Expand Down
5 changes: 3 additions & 2 deletions src/mmx_renderer.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion tests/mmx_coop_followup_test.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading