From 71843b5eafe7204cd193734c855bda5099a24d75 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:50:49 -0700 Subject: [PATCH 1/2] Fix Boss Rush co-op weapon graphics and stale hurt protection --- docs/boss-rush.md | 10 ++ src/mmx_boss_rush_runtime.c | 1 + src/mmx_coop.c | 17 ++ src/mmx_coop.h | 3 + src/mmx_render_assets.c | 45 ++++++ src/mmx_render_assets.h | 4 + src/mmx_renderer.c | 30 ++-- tests/mmx_boss_rush_coop_isolation_test.inc | 166 ++++++++++++++++++++ tests/mmx_boss_rush_runtime_test.inc | 3 + tests/mmx_coop_weapon_graphics_test.inc | 57 +++++++ tests/mmx_renderer_test.c | 3 +- 11 files changed, 325 insertions(+), 14 deletions(-) create mode 100644 tests/mmx_boss_rush_coop_isolation_test.inc create mode 100644 tests/mmx_coop_weapon_graphics_test.inc diff --git a/docs/boss-rush.md b/docs/boss-rush.md index a9a1dc1a..0554f77f 100644 --- a/docs/boss-rush.md +++ b/docs/boss-rush.md @@ -246,6 +246,16 @@ set `MMX_BOSS_RUSH_TEST=1` and pass the verified USA ROM to run the title-menu, 28-pair entrance, replacement, 600-frame combat and snapshot-replay checks in an empty private working directory. Add `MMX_BOSS_RUSH_COOP=` to run those checks in local co-op, including one-player and team deaths. +`MMX_BOSS_RUSH_COOP_ISOLATION_TEST=1` checks real fatal boss contacts and damage +to either survivor against all eight Mavericks. It separately restores an +interrupted-hurt state after a partner death and verifies that the native +protection countdown expires; active hurt, scripted locks, existing countdowns +and charged Sting remain intact for both seats. +`MMX_BOSS_RUSH_COOP_WEAPON_TEST=1` quick-switches both players through different +native specials and fires normal and charged shots together. It checks private +graphics for every visible projectile class and writes CSV traces and images. +Both fixtures use the same co-op asset setting above; add +`MMX_BOSS_RUSH_COOP_P1_ZERO=1` to exchange the two characters. `MMX_BOSS_RUSH_REPORT_TEST=1` checks issue #124: native death updates in both boss slots without permanent player protection, the retail subtank refill cap and empty-tank menu navigation, Penguin's native ice lock, and prolonged diff --git a/src/mmx_boss_rush_runtime.c b/src/mmx_boss_rush_runtime.c index 2cf6ef5e..02d25aec 100644 --- a/src/mmx_boss_rush_runtime.c +++ b/src/mmx_boss_rush_runtime.c @@ -615,6 +615,7 @@ void MmxBossRushAfterFrame(uint8_t *r) { MmxBossRushSetState(&s); for(unsigned i=0;i<2;++i) if(s.bosses[i].phase==MMX_RUSH_FIGHTING && !s.bosses[i].health) MmxBossRushDefeat(i); + MmxCoopResumeHurtTimers(r); bool alive=(r[0xbcf]&127)!=0; bool death_complete=r[0xbaa]==12 && r[0xbab]>=6; if(s.coop) { diff --git a/src/mmx_coop.c b/src/mmx_coop.c index 16115855..00c811b0 100644 --- a/src/mmx_coop.c +++ b/src/mmx_coop.c @@ -1458,6 +1458,23 @@ void MmxCoopLiftCarry(uint8_t *r) { uint16_t x=(uint16_t)((body[5]|body[6]<<8)+dx),y=(uint16_t)((body[8]|body[9]<<8)+dy); body[5]=(uint8_t)x;body[6]=(uint8_t)(x>>8);body[8]=(uint8_t)y;body[9]=(uint8_t)(y>>8); } +static void resume_hurt_timer(uint8_t *body) { + /* Native $0E starts $0C13, which later clears $0BD8/$0C32. A refill + * or stun can replace that action first while Rush bosses keep fighting. + * Resume the native countdown; never revoke a real hurt reaction, death, + * scripted hold, charged Sting or an already running protection interval. */ + if((body[0x27]&127) && body[0x30] && !body[0x6b] && !body[0x6e] && !body[0x89] && + body[2]!=0x0e && body[2]!=0x0c && body[2]!=0x1e) + body[0x6b]=0x3c; +} +void MmxCoopResumeHurtTimers(uint8_t *r) { + if(!r) return; + resume_hurt_timer(r+0xba8); + if(enabled && state.initialized) { + MmxCoopPlayer *partner=&state.players[state.current^1]; + if(partner->status==MMX_COOP_ALIVE) resume_hurt_timer(partner->body); + } +} /* Stage sections set the player's OBJ priority once, on the world actor: * entering Storm Eagle's ship writes .11 bit 4 (priority 3, in front of the * hull's high-priority foreground) to $0BB9 and clears it at the boss lift. diff --git a/src/mmx_coop.h b/src/mmx_coop.h index 301b0453..698be5c8 100644 --- a/src/mmx_coop.h +++ b/src/mmx_coop.h @@ -95,5 +95,8 @@ void MmxCoopRegisterHooks(void); void MmxCoopHostFrame(void); void MmxCoopLiftCarry(uint8_t *ram); void MmxCoopSyncPriority(uint8_t *ram); +/* Rush's end-of-frame recovery for a hurt reaction interrupted by a refill + * or another player action. Includes the live body in solo mode. */ +void MmxCoopResumeHurtTimers(uint8_t *ram); /* End-of-frame hook for --coop-trace; no effect unless tracing. */ void MmxCoopTraceFrame(const uint8_t ram[0x20000]); diff --git a/src/mmx_render_assets.c b/src/mmx_render_assets.c index 97e4ca5a..a0a56a81 100644 --- a/src/mmx_render_assets.c +++ b/src/mmx_render_assets.c @@ -20,6 +20,8 @@ static MmxSpriteAsset charged_buster[2][21]; static bool charged_buster_ready[2][21]; static MmxSpriteAsset player_weapons[9][2]; static uint8_t player_weapon_ready[9]; +static MmxSpriteAsset native_shots[2][8]; +static uint64_t native_shot_keys[2][8]; static uint8_t ready[256], sprite_resource[256]; static uint16_t asset_palette[256], rush_palette[13][256]; static unsigned cached_stage = ~0u, cached_section = ~0u; @@ -52,6 +54,7 @@ void MmxRenderAssetsSetRom(const uint8_t *bytes, size_t size) { memset(dash_effect_ready,0,sizeof(dash_effect_ready)); memset(charged_buster_ready,0,sizeof(charged_buster_ready)); memset(player_weapon_ready,0,sizeof(player_weapon_ready)); + memset(native_shot_keys,0,sizeof(native_shot_keys)); for(unsigned stage=0;stage<13;++stage) for(unsigned sprite=0;sprite<256;++sprite) { free(rush_assets[stage][sprite]);rush_assets[stage][sprite]=NULL; } @@ -139,6 +142,48 @@ const MmxSpriteAsset *MmxRenderAssetsChargedBuster(unsigned group,unsigned pose) art->attributes=6;art->live_colors=true;charged_buster_ready[kind][pose]=true; return art; } +const MmxSpriteAsset *MmxRenderAssetsNativeShot(const uint8_t ram[0x20000], + unsigned object,unsigned group,unsigned pose,unsigned seat) { + if(!rom || !ram || seat>1 || group>255 || pose>127 || + object<0x1228 || object>=0x1428 || (object-0x1228)%64) + return NULL; + unsigned kind=ram[object+10]; + unsigned weapon=kind>=7 && kind<=14 ? kind-6 : kind>=16 && kind<=23 ? kind-15 : 0; + if(!weapon) return NULL; + const MmxSpriteAsset *base=MmxRenderAssetsWeaponX(weapon,false); + if(!base) return NULL; + unsigned table=ram_word(ram,object+0x31); + uint64_t key=((uint64_t)kind<<48)|((uint64_t)group<<40)|((uint64_t)table<<24)| + (pose<<8)|1; + unsigned slot=(object-0x1228)/64; + MmxSpriteAsset *art=&native_shots[seat][slot]; + if(native_shot_keys[seat][slot]==key) return art; + native_shot_keys[seat][slot]=0; + *art=*base; + /* $84:8FCA reads the object's bank-$85 pose directory. Rebuild its + * uploads privately: empty/short records retain earlier rows. A shot can + * be restored mid-animation, so derive these rows from ROM, not host history. */ + if(table>=0x8000) { + size_t directory=lorom(0x850000|table); + for(unsigned frame=0;frame<=pose;++frame) { + size_t list=directory+word(directory+frame*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(dest<0 || dest+(int)count>sizeof(art->tiles) || !range(source,count) || rom[list+3]==0x7f) + return NULL; + memcpy(art->tiles+dest,rom+source,count); + if(rom[list+4]&128) {complete=true;break;} + } + if(!complete) return NULL; + } + } + native_shot_keys[seat][slot]=key;return art; +} bool MmxRenderAssetsStingPalette(unsigned phase,uint16_t colors[16]) { if(!rom || !colors || phase>14 || (phase&1)) return false; size_t list=0x30000+(word(0x30133+0x1a0+phase)&0x7fff); diff --git a/src/mmx_render_assets.h b/src/mmx_render_assets.h index a7db4767..1f5a6492 100644 --- a/src/mmx_render_assets.h +++ b/src/mmx_render_assets.h @@ -29,6 +29,10 @@ const MmxSpriteAsset *MmxRenderAssetsTeleportX(unsigned pose); const MmxSpriteAsset *MmxRenderAssetsWeaponX(unsigned weapon, bool body); /* Full native buster pose, independent of another co-op shot's CHR upload. */ const MmxSpriteAsset *MmxRenderAssetsChargedBuster(unsigned group, unsigned pose); +/* Native special-shot CHR/palette, including pose uploads and child objects. + * Index with the submitted native tile number, independently of shared VRAM. */ +const MmxSpriteAsset *MmxRenderAssetsNativeShot(const uint8_t ram[0x20000], + unsigned object,unsigned group,unsigned pose,unsigned seat); /* Charged Sting's original eight-step body palette sequence. */ bool MmxRenderAssetsStingPalette(unsigned phase, uint16_t colors[16]); const MmxSpriteAsset *MmxRenderAssetsObjectSprite(const uint8_t ram[0x20000], diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index 5edb9cdc..12eb39d1 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -47,8 +47,8 @@ static MmxBossRushState frame_rush; static MmxBossRushVisualState frame_rush_visual; typedef struct Piece { int16_t x, y; uint16_t attr; uint8_t size, animation; - /* Palette occupies bits 1..3; the five spare bits retain charged-shot - * pose 0..20 at submission, without changing the capture record size. */ + /* Palette occupies bits 1..3; the five spare bits retain native-shot + * pose 0..31 at submission, without changing the capture record size. */ uint8_t tile, palette_pose; uint16_t object; } Piece; static unsigned piece_pose(const Piece *p) { @@ -256,7 +256,7 @@ static Piece make_piece(const uint8_t *p, int x, int y, unsigned flip, y += flip & 0x80 ? -(int8_t)p[2] - size : (int8_t)p[2]; unsigned attr = (((p[4] & 0xce) | attributes) ^ flip) << 8; attr |= (p[3] + base) & 255; - unsigned retained_pose=(animation==0x0e || animation==0x9e) ? pose&31 : 0; + unsigned retained_pose=object>=0x1228 && object<0x1428 ? pose&31 : 0; return (Piece){(int16_t)x, (int16_t)y, (uint16_t)attr, (uint8_t)size, (uint8_t)animation, p[3], (uint8_t)((p[4]&14)|(retained_pose&1)|((retained_pose&30)<<3)), (uint16_t)object}; } @@ -1756,16 +1756,17 @@ static void coop_partner_row(const Ppu *ppu,const Raster *r,int y,MmxRenderView Piece s = make_piece(a+j*4,x,sy,ram[d+17]&64,ram[d+17]&63,ram[d+24],group,d,ram[d+23]&127); const MmxSpriteAsset *asset=!zero && weapon_colors && zero_actor(d,group) && (s.attr&0x0e00) ? &weapon_palette : NULL; - /* Normal Spark and its two wall fragments share original group $47. - * 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); + unsigned tile=s.tile; + if(d>=0x1228) { + const MmxSpriteAsset *shot=MmxRenderAssetsNativeShot(ram,d,group,ram[d+23]&127,frame_coop.current^1); + if(shot) {asset=shot;tile=s.attr&255;} + } 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; } - sprite(ppu,zero?r:&partner_raster,s.x,s.y,s.attr,s.size,y,view,objects,false,asset,s.tile,colors,true,false,false); + sprite(ppu,zero?r:&partner_raster,s.x,s.y,s.attr,s.size,y,view,objects,false,asset,tile,colors,true,false,false); } } } @@ -2217,6 +2218,10 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { 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]==1 || frame.ram[s.object+10]==2) && s.animation==0x0e)); + const MmxSpriteAsset *coop_special=stage && frame_coop.initialized && + s.object>=0x1228 && s.object<0x1428 && + !frame_weapon_combat.shots[(s.object-0x1228)/64].active ? + MmxRenderAssetsNativeShot(frame.ram,s.object,s.animation,piece_pose(&s),frame_coop.current) : NULL; 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; @@ -2227,7 +2232,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { bool zero_armor = zero && (s.object == 0xc38 || s.object == 0xc58 || s.object == 0xc78 || (zero_menu && (s.object == 0x1928 || s.object == 0x1948 || s.object == 0x1968))); bool oam_match = false; - if (g_mmx_render_asset_repairs || coop_buster || zero_body || zero_armor || zero_charge || swap_actor || red_ready || red_death || triad_x_body || triad_armor || frozen_enemy) for (int slot = 16; slot < 128; ++slot) { + if (g_mmx_render_asset_repairs || coop_buster || coop_special || zero_body || zero_armor || zero_charge || swap_actor || red_ready || red_death || triad_x_body || triad_armor || frozen_enemy) for (int slot = 16; slot < 128; ++slot) { unsigned pos = r->oam[slot * 2], hi = r->high_oam[slot / 4] >> (slot % 4 * 2); int ox = (pos & 255) | ((hi & 1) << 8); if (ox >= 256) ox -= 512; if (ox == s.x && (pos >> 8) == ((unsigned)s.y & 255) && r->oam[slot * 2 + 1] == s.attr) { @@ -2355,14 +2360,13 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { * the boots. */ if ((frame_zero.active_x || knc_bugfix) && weapon_colors && zero_actor(s.object, s.animation) && (menu || ((attr & 0x0e00) == 0x0200 && (!x_charging(frame.ram) || knc_bugfix)))) asset = &x_weapon_palette; - if(stage && frame_coop.initialized && s.object>=0x1228 && s.object<0x1428 && - frame.ram[s.object+10]==0x0c && s.animation==0x47 && (s.attr&0x0e00)==0x0600) - asset=MmxRenderAssetsWeaponX(6,false); + if(coop_special) asset=coop_special; if(coop_buster) { const MmxSpriteAsset *beam=MmxRenderAssetsChargedBuster(s.animation,piece_pose(&s)); if(beam) asset=beam; } - sprite(&p, r, s.x, s.y, attr, s.size, y, view, objects, !center, asset, s.tile, object_colors, true, false, red_ready || red_death); + sprite(&p, r, s.x, s.y, attr, s.size, y, view, objects, !center, asset, + coop_special?s.attr&255:s.tile, object_colors, true, false, red_ready || red_death); } if (stage) coop_partner_row(&p,r,y,view,objects,object_colors); int bar_first = -1, bar_count = 0; diff --git a/tests/mmx_boss_rush_coop_isolation_test.inc b/tests/mmx_boss_rush_coop_isolation_test.inc new file mode 100644 index 00000000..8ede1c60 --- /dev/null +++ b/tests/mmx_boss_rush_coop_isolation_test.inc @@ -0,0 +1,166 @@ +/* Exercise the native fatal contact and death controller before testing the + * survivor. Never reset the survivor's hurt latch after the fatal hit. */ +static void rush_coop_isolation_checks(uint8_t *arena,size_t cap) { + check(MmxCoopEnabled(),"isolation checks require two native controllers"); + size_t empty=RtlSaveSnapshotToMemory(arena,cap); + FILE *log=fopen("rush-coop-isolation.csv","w");check(log!=NULL,"isolation trace opens"); + fputs("boss,dead,frame,p1_hp,p2_hp,p1_action,p2_action,p1_immune,p2_immune,freeze,boss0_damage,boss1_damage\n",log); + /* Both projections need recovery, but legitimate native protection must + * retain its owner and duration. These are explicit state-machine fixtures. */ + for(unsigned guard=0;guard<5;++guard) { + check(RtlLoadSnapshotFromMemory(arena,empty),"hurt guard arena restores"); + for(unsigned seat=0;seat<2;++seat) { + MmxCoopSelect(g_ram,seat); + g_ram[0xbd8]=g_ram[0xc32]=1;g_ram[0xc13]=guard==1?17:0; + g_ram[0xbaa]=guard==2?14:0;g_ram[0xc16]=guard==3?1:0;g_ram[0xc31]=guard==4?1:0; + MmxCoopCapture(g_ram); + } + MmxCoopSelect(g_ram,0);MmxCoopResumeHurtTimers(g_ram);MmxCoopCapture(g_ram); + MmxCoopState c=MmxCoopGetState(); + for(unsigned seat=0;seat<2;++seat) + check(c.players[seat].body[0x6b]==(guard==0?60:guard==1?17:0), + "hurt recovery respects both seats' active timers, hurt actions, scripts and charged Sting"); + } + bool passed=true,recovered=true; + for(unsigned boss=0;boss<8;++boss)for(unsigned order=0;order<2;++order) { + unsigned dead=order^1; + check(RtlLoadSnapshotFromMemory(arena,empty),"isolation arena restores"); + MmxBossRushState s=MmxBossRushGetState(); + memset(s.bosses,0,sizeof(s.bosses));memset(s.owner,0,sizeof(s.owner)); + memset(g_ram+0xe68,0,0x3c0);memset(g_ram+0x1428,0,0x8e0); + s.queue[0]=(uint8_t)boss;s.queue[1]=(uint8_t)(boss==7?0:7);s.queued=8; + unsigned q=2;for(unsigned i=0;i<8;++i)if(i!=s.queue[0] && i!=s.queue[1])s.queue[q++]=(uint8_t)i; + check(MmxBossRushSetState(&s),"isolation selects each Maverick"); + for(unsigned t=0;t<1500;++t) { + frame(0);rush_keep_players_alive();s=MmxBossRushGetState(); + if(s.bosses[0].phase==MMX_RUSH_FIGHTING && s.bosses[1].phase==MMX_RUSH_FIGHTING)break; + } + check(s.bosses[0].phase==MMX_RUSH_FIGHTING && s.bosses[1].phase==MMX_RUSH_FIGHTING, + "isolation completes native boss introductions"); + unsigned x=s.camera_x+240,y=s.camera_y+164; + rush_coop_player(dead^1,s.camera_x+48,y,0); + rush_coop_player(dead,x,y,0); + g_ram[0xbcf]=0x81;MmxCoopCapture(g_ram);MmxCoopSelect(g_ram,0); + bool fatal=false,hurt=false,fallen=false; + unsigned after_death=0; + for(unsigned t=0;t<360;++t) { + memset(g_ram+0x1428,0,0x8e0); + MmxCoopState c=MmxCoopGetState(); + unsigned survivor=dead^1; + if(fallen) ++after_death; + const uint8_t *b=c.players[fallen?survivor:dead].body; + rush_coop_targets(0,fallen?eagle_word(b+5):x,y); + frame(0);c=MmxCoopGetState(); + fatal|=!(c.players[dead].body[0x27]&127); + fallen|=c.players[dead].status==MMX_COOP_FALLEN; + hurt|=fallen && (c.players[survivor].body[0x27]&127)<32; + fprintf(log,"%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u\n",boss,dead,t, + c.players[0].body[0x27]&127,c.players[1].body[0x27]&127, + c.players[0].body[2],c.players[1].body[2], + c.players[0].body[0x8a],c.players[1].body[0x8a],g_ram[0x1f0c], + g_ram[s.bosses[0].object+0x26],g_ram[s.bosses[1].object+0x26]); + if(hurt || after_death>120)break; + } + fprintf(stderr,"isolation boss=%u dead=%u fatal=%u fallen=%u survivor_hurt=%u damage=%u/%u\n", + boss,dead,fatal,fallen,hurt,g_ram[s.bosses[0].object+0x26],g_ram[s.bosses[1].object+0x26]); + passed&=fatal && fallen && hurt; + if(boss==0 && fallen) { + /* Regression state of a hit interrupted before the native hurt + * reaction starts its blink timer (for example by a health refill). + * Keep this separate from the real fatal-contact checks above. */ + unsigned survivor=dead^1; + rush_coop_targets(0,s.camera_x-128,y); + for(unsigned t=0;t<120;++t)frame(0); + MmxCoopSelect(g_ram,survivor); + g_ram[0xbaa]=g_ram[0xbab]=0; + g_ram[0xbd8]=g_ram[0xc32]=1;g_ram[0xc13]=0; + MmxCoopCapture(g_ram); + unsigned hp=g_ram[0xbcf]&127; + bool cleared=false,damaged=false; + for(unsigned t=0;t<240;++t) { + MmxCoopState c=MmxCoopGetState(); + const uint8_t *b=c.players[survivor].body; + rush_coop_targets(0,cleared?eagle_word(b+5):s.camera_x-128,y); + frame(0);c=MmxCoopGetState();b=c.players[survivor].body; + cleared|=!b[0x30] && !b[0x8a]; + damaged|=(b[0x27]&127)status=MMX_COOP_ALIVE;p->body[0x27]=32; + uint8_t *b=p->shots;b[0]=1;b[1]=2;b[10]=(uint8_t)(w+(charged?15:6)); + b[14]=128;b[17]=0x26;b[22]=(uint8_t)(0x40+w);b[24]=3; + b[5]=(uint8_t)(60+seat*100);b[8]=120; + } + memcpy(ram+0x1228,c.players[anchor].shots,0x200); + unsigned x=ram[0x122d],w=anchor?weapon%8+1:weapon; + put_word(0,x);put_word(2,120);ram[0xf]=0x26;ram[0x10]=3; + put_word(0x18,0xa000+w*8);ram[0x1a]=0x8d; + MmxRendererObserveObject(ram,0x1228);MmxRendererRecordPiece(ram,0);MmxRendererLatchSprites(); + ppu.oam[32]=(uint16_t)(120<<8|x);ppu.oam[33]=0x2620; + capture();MmxRendererCoopFrame(&c); + assert(MmxRendererDraw(output,(MmxRenderView){256,0,4.0/3.0},false)); + for(unsigned seat=0;seat<2;++seat) { + unsigned own=seat?weapon%8+1:weapon; + if(output[120*256+60+seat*100]!=(own&1?0xff0000u:0x00ff00u)) + fprintf(stderr,"special graphics weapon=%u charged=%u anchor=%u repairs=%u seat=%u pixel=%06x\n", + weapon,charged,anchor,repairs,seat,output[120*256+60+seat*100]); + assert(output[120*256+60+seat*100]==(own&1?0xff0000u:0x00ff00u)); + } + } + g_mmx_render_asset_repairs=true;g_mmx_custom_renderer=false;MmxRendererSetRom(NULL,0); +} diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c index 26585aed..57a3a2cf 100644 --- a/tests/mmx_renderer_test.c +++ b/tests/mmx_renderer_test.c @@ -1738,6 +1738,7 @@ static void rush_static_and_streamed_graphics(void) { } MmxBossRushReset();g_mmx_custom_renderer=false;MmxRendererSetRom(NULL,0); } +#include "mmx_coop_weapon_graphics_test.inc" static void weapons_menu_margins(void) { memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); MmxRendererReset(); MmxRendererSetRom(NULL, 0); @@ -1778,4 +1779,4 @@ static void rush_results_page(void) { assert((output[48*256+92]&0xffffff)==0x00ffff); MmxBossRushReset(); } -int main(void) { geometry(); raster_and_hud(); immutable_vram_raster(); stage_map_draw_lifetime(); sprite_coordinates(); independent_oam_visibility(); expanded_capacity(); background_resources(); dialogue_and_password(); highway_arena_sky(); highway_airship_binding(); distant_doors(); storm_background_prefill(); resource_decode(); spark_effects(); airport_panorama_edge(); airport_upper_sky_repair(); wide_water_plane(); buried_submarine(); background_continuations(); launch_background_palettes(); sting_background_palettes(); mammoth_background_palettes(); dialogue_backdrop(); fortress_actor_presentation(); storm_dormant_presentation(); sprite_priority_and_cutscene_binding(); weapons_menu_margins(); zero_blink_submission(); rush_private_pose_graphics(); rush_static_and_streamed_graphics(); rush_results_page(); return 0; } +int main(void) { geometry(); raster_and_hud(); immutable_vram_raster(); stage_map_draw_lifetime(); sprite_coordinates(); independent_oam_visibility(); expanded_capacity(); background_resources(); dialogue_and_password(); highway_arena_sky(); highway_airship_binding(); distant_doors(); storm_background_prefill(); resource_decode(); spark_effects(); airport_panorama_edge(); airport_upper_sky_repair(); wide_water_plane(); buried_submarine(); background_continuations(); launch_background_palettes(); sting_background_palettes(); mammoth_background_palettes(); dialogue_backdrop(); fortress_actor_presentation(); storm_dormant_presentation(); sprite_priority_and_cutscene_binding(); weapons_menu_margins(); zero_blink_submission(); rush_private_pose_graphics(); rush_static_and_streamed_graphics(); rush_results_page(); coop_special_graphics(); return 0; } From bbf420637d63e3ae0a0ad2490f47590728c2b0fa Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:02:36 -0700 Subject: [PATCH 2/2] Document tentative status of reported survivor invulnerability --- docs/boss-rush.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/boss-rush.md b/docs/boss-rush.md index 0554f77f..559b26ba 100644 --- a/docs/boss-rush.md +++ b/docs/boss-rush.md @@ -251,6 +251,10 @@ to either survivor against all eight Mavericks. It separately restores an interrupted-hurt state after a partner death and verifies that the native protection countdown expires; active hurt, scripted locks, existing countdowns and charged Sting remain intact for both seats. +Ordinary partner deaths did not reproduce the owner's report of permanent +survivor invulnerability on current source. The interrupted-hurt state is +explicitly seeded in its separate regression; recovery for the reported +death-triggered issue remains tentative pending a capture or owner retest. `MMX_BOSS_RUSH_COOP_WEAPON_TEST=1` quick-switches both players through different native specials and fires normal and charged shots together. It checks private graphics for every visible projectile class and writes CSV traces and images.