diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54f29248..6452a682 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,21 @@ Run `bash tools/regen.sh usa` when changing the recompiler or generated-code configuration; omitting `--no-tests` also runs the real framework test suite. Use `--strict-idempotent` when a change can affect emitted C. +For the ROM-backed X3 Zero saber benchmark, build `mmx_state_tests` with +`-DMMX_STATE_TESTS=ON` and run it with the X1 ROM from an empty working +directory. Set `MMX_ZERO_SABER_PERF_TEST=1`, `MMX_REPLAY_RECORD=0`, +`SDL_AUDIODRIVER=dummy` and `MMX_ZERO_TEST_ASSETS` to the locally extracted +X3 Zero cache. Add `MMX_BOSS_RUSH_TEST=1` and set `MMX_BOSS_RUSH_ZERO` to +the same cache to measure Boss Rush. The test writes `saber-performance.csv`, +warms each scenario before comparing diagnostics off/on, separates simulation +and drawing costs, and requires byte-identical resulting snapshots. Cases +cover idle, repeated swings, positioned real boss hits, and the full native +charge/two-buster/saber input chain. Readiness is bypassed only for the isolated +swing probes; the full-chain probe instead protects the player while charging. +For the same comparison in local co-op, add `MMX_PERF_COOP=1` for campaign, or +set `MMX_BOSS_RUSH_COOP` to the Zero cache and `MMX_BOSS_RUSH_COOP_P1_ZERO=1` +for Boss Rush. Port 1 attacks as Zero; port 2 remains X. + ## Change the framework dependency The `snesrecomp` gitlink is the single source of truth for the framework diff --git a/docs/weapons/gravity-well.md b/docs/weapons/gravity-well.md index 48ec6e1f..f37b7800 100644 --- a/docs/weapons/gravity-well.md +++ b/docs/weapons/gravity-well.md @@ -87,3 +87,21 @@ movement recovery, and save/replay. Real enemy tests cover pull with AI held, native dissolve, charged acceleration/offscreen death, protected-category exclusion, and replay of both enemy paths. Original X3 and port orb/field/body captures were visually reviewed. + +## Boss Rush entrances + +Boss Rush body identity excludes the boss from Gravity Well from the moment +its slot is allocated. Native bosses temporarily use state 2 and an ordinary +damage category while their entrance and health fill run; those transient +bytes alone cannot establish that an actor is an ordinary enemy. The same +body check rejects retained victims and their renderer/AI override, so +an older snapshot containing an illegal boss capture releases it without +moving it or entering the ordinary-enemy dissolve path. +Ordinary minions remain eligible when their ownership tag refers to that boss. + +The ROM-backed regression runs with `MMX_BOSS_RUSH_TEST=1`, +`MMX_BOSS_RUSH_GRAVITY_TEST=1` and `MMX_WEAPONS_TEST_ASSETS` pointing to a +locally extracted combined weapon cache. It casts with actual Y input before +allocating each of the eight bosses in both seats, checks independent full +entrances and zero defeats, and covers an illegal-capture snapshot and replay. +Initial encounter removal is fixture setup, not a validated defeat. diff --git a/src/mmx_weapon_combat.c b/src/mmx_weapon_combat.c index be2fe4da..e3d7268f 100644 --- a/src/mmx_weapon_combat.c +++ b/src/mmx_weapon_combat.c @@ -1,6 +1,7 @@ #include "mmx_weapon_combat.h" #include "mmx_weapons.h" #include "mmx_zero.h" +#include "mmx_boss_rush.h" #include static MmxWeaponCombatState combat; diff --git a/src/mmx_weapon_gravity.inc b/src/mmx_weapon_gravity.inc index caf7ed41..f694f6ab 100644 --- a/src/mmx_weapon_gravity.inc +++ b/src/mmx_weapon_gravity.inc @@ -2,6 +2,10 @@ * deliberately not damage. See docs/weapons/gravity-well.md. * Shot variants 0 well/orb, 1 body; effect variants 2 enemy, 3 particles. * Victim origin_x/y retain native slot/type, vx retains anchored X. */ +static bool gravity_rush_boss(unsigned object) { + int seat=MmxBossRushOwner(object); + return seat>=0 && MmxBossRushGetState().bosses[seat].object==object; +} const MmxWeaponShot *MmxWeaponsMovedEnemy(const MmxWeaponCombatState *c,unsigned enemy) { for(unsigned i=0;i<8;++i) { const MmxWeaponShot *s=c->shots+i; @@ -9,7 +13,8 @@ const MmxWeaponShot *MmxWeaponsMovedEnemy(const MmxWeaponCombatState *c,unsigned } for(unsigned i=0;i<16;++i) { const MmxWeaponShot *s=c->effects+i; - if(s->active && s->page==2 && s->weapon==6 && s->variant==2 && s->origin_x==(int)enemy) return s; + if(s->active && s->page==2 && s->weapon==6 && s->variant==2 && + s->origin_x==(int)enemy && !gravity_rush_boss(enemy)) return s; } return NULL; } @@ -48,7 +53,7 @@ static int gravity_slow_vector(unsigned angle) { static bool gravity_host(const uint8_t *r,const MmxWeaponShot *s) { unsigned d=(unsigned)s->origin_x; return d>=0xe68 && d<0x1228 && (d&63)==0x28 && r[d] && r[d+1]==2 && - r[d+10]==s->origin_y && (r[d+0x27]&127); + r[d+10]==s->origin_y && (r[d+0x27]&127) && !gravity_rush_boss(d); } static void gravity_destroy(uint8_t *r,MmxWeaponShot *s) { unsigned d=(unsigned)s->origin_x; @@ -88,6 +93,11 @@ static void gravity_effect_tick(uint8_t *r,MmxWeaponShot *s) { } static void gravity_capture(uint8_t *r,MmxWeaponShot *well) { for(unsigned d=0xe68;d<0x1228;d+=64) { + /* Boss entrances temporarily use state 2 and an ordinary damage class. + * Rush ownership is established at allocation, before those transient + * bytes can make a boss look like a utility victim. Ordinary minions + * remain eligible even when they share the boss's ownership tag. */ + if(gravity_rush_boss(d)) continue; if(!r[d] || r[d+1]!=2 || !(r[d+0x27]&127) || r[d+0x28]>=6 || !word(r+d+0x20) || !MmxWeaponsEnemyActive(r,d,1)) continue; int x=(int)word(r+d+5)-(int)camera_x(r),y=(int)word(r+d+8)-(int)camera_y(r); diff --git a/tests/mmx_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c index 301aaaa9..aef4d4fe 100644 --- a/tests/mmx_adaptive_state_test.c +++ b/tests/mmx_adaptive_state_test.c @@ -58,6 +58,7 @@ static void zero_capture(const char *base, const char *suffix) { #include "mmx_zero_modern_test.inc" #include "mmx_graphics_pacing_test.inc" #include "mmx_hitbox_overlay_test.inc" +#include "mmx_zero_saber_perf_test.inc" #include "mmx_saber_lock_test.inc" #include "mmx_coop_replay_test.inc" #include "mmx_diagnostic_replay_test.inc" @@ -1954,6 +1955,15 @@ int main(int argc, char **argv) { uint8 *start = malloc(cap), *expected = malloc(cap), *actual = malloc(cap); if(getenv("MMX_MAGIC_CARPET_TEST")) {magic_carpet_checks(getenv("MMX_MAGIC_CARPET_TEST"),start,expected,actual,cap);return 0;} if(getenv("MMX_BOSS_RUSH_TEST")) {rush_runtime_checks(start,expected,actual,cap);return 0;} + if(getenv("MMX_ZERO_SABER_PERF_TEST")) { + check(MmxZeroLoad(getenv("MMX_ZERO_TEST_ASSETS")),"performance test loads original X3 Zero"); + MmxZeroRegisterHooks(); + if(getenv("MMX_PERF_COOP")) { + check(MmxCoopEnable(MMX_COOP_ZERO),"performance test enables native Zero/X co-op");MmxCoopRegisterHooks(); + } + hitbox_boot();zero_replay(60); + saber_perf_checks("campaign",start,expected,actual,cap);return 0; + } const char *zero_assets = getenv("MMX_ZERO_TEST_ASSETS"); const char *zero_title = getenv("MMX_ZERO_TITLE_FIXTURE"); if(getenv("MMX_COOP_LIFT_REPLAY_FIXTURE")) { diff --git a/tests/mmx_boss_rush_runtime_test.inc b/tests/mmx_boss_rush_runtime_test.inc index e55258e8..61e1f7cd 100644 --- a/tests/mmx_boss_rush_runtime_test.inc +++ b/tests/mmx_boss_rush_runtime_test.inc @@ -928,6 +928,7 @@ static void rush_projectile_defeat(unsigned seat,unsigned weapon) { } #include "mmx_boss_rush_coop_combat_test.inc" #include "mmx_boss_rush_reports_test.inc" +#include "mmx_gravity_rush_test.inc" static void rush_runtime_checks(uint8_t *start,uint8_t *expected,uint8_t *actual,size_t cap) { const char *coop_assets=getenv("MMX_BOSS_RUSH_COOP"); const char *zero_assets=getenv("MMX_BOSS_RUSH_ZERO"); @@ -1112,6 +1113,14 @@ static void rush_runtime_checks(uint8_t *start,uint8_t *expected,uint8_t *actual return; } if(getenv("MMX_BOSS_RUSH_REPORT_TEST")) {rush_report_checks(start,expected,cap);return;} + if(getenv("MMX_BOSS_RUSH_GRAVITY_TEST")) {rush_gravity_checks(start,expected,actual,cap);return;} + if(getenv("MMX_ZERO_SABER_PERF_TEST")) { + for(unsigned t=0;t<1500;++t) { + frame(0);rush_keep_players_alive();MmxBossRushState s=MmxBossRushGetState(); + if(s.bosses[0].phase==MMX_RUSH_FIGHTING && s.bosses[1].phase==MMX_RUSH_FIGHTING)break; + } + saber_perf_checks("boss-rush",start,expected,actual,cap);return; + } if(getenv("MMX_BOSS_RUSH_COOP_COMBAT_TEST")) {rush_coop_combat_checks(start,expected,cap);return;} if(getenv("MMX_BOSS_RUSH_MENU_TEST")) {rush_return_title_checks();return;} if(getenv("MMX_BOSS_RUSH_STATE_FORMAT_TEST")) {rush_state_format_checks(start,expected,actual,cap);return;} diff --git a/tests/mmx_gravity_rush_test.inc b/tests/mmx_gravity_rush_test.inc new file mode 100644 index 00000000..17a8872e --- /dev/null +++ b/tests/mmx_gravity_rush_test.inc @@ -0,0 +1,106 @@ +/* Keep a real cast alive across fresh native boss allocation. Clearing the + * encounter below is fixture setup, never evidence of a successful defeat. */ +static void rush_gravity_frame(unsigned input) { + /* The native hurt latch keeps live bosses from interrupting + * the charge/cast. Do not change a boss's HP, AI or hit response. */ + g_ram[0xbd8]=120; + frame(input);rush_keep_players_alive(); +} +static void rush_gravity_clear(void) { + MmxBossRushState s=MmxBossRushGetState(); + for(unsigned d=0xe68;d<0x1d08;) { + if(d==0x1228) {d=0x1428;continue;} + if(MmxBossRushOwner(d)>=0) memset(g_ram+d,0,d<0x1628?64:d<0x1928?48:32); + d+=d<0x1628?64:d<0x1928?48:32; + } + memset(s.bosses,0,sizeof(s.bosses));memset(s.owner,0,sizeof(s.owner)); + s.defeated=0;s.queued=8; + for(unsigned i=0;i<8;++i)s.queue[i]=(uint8_t)i; + check(MmxBossRushSetState(&s),"gravity fixture clears encounter ownership"); +} +static void rush_gravity_checks(uint8_t *start,uint8_t *field,uint8_t *actual,size_t cap) { + const char *assets=getenv("MMX_WEAPONS_TEST_ASSETS"); + check(assets && MmxWeaponsLoad(assets),"rush gravity loads original weapon assets"); + rush_gravity_clear(); /* Use the same throwaway opening pair for both casts. */ + size_t baseline=RtlSaveSnapshotToMemory(start,cap); + uint8_t *expected=malloc(cap);check(expected!=NULL,"gravity replay scratch allocates"); + unsigned failures=0;bool stale_checked=false; + for(unsigned charged=0;charged<2;++charged) { + check(RtlLoadSnapshotFromMemory(start,baseline),"restore arena before gravity cast"); + MmxWeaponsState w=MmxWeaponsGetState();w.page=2;w.weapon=6;MmxWeaponsSetState(w); + /* Protect only the player: boss HP, states and success flags stay native. */ + /* Allow the throwaway opening pair's native intro/fire locks to finish + * before the charge fills; they are discarded only after a real cast. */ + for(unsigned t=0;t<(charged?900:1);++t) rush_gravity_frame(SNES_PAD_Y); + if(charged)fprintf(stderr,"gravity charge ready=%u arms=%u action=%u:%u flags=%u/%u/%u energy=%u\n", + MmxWeaponsGetState().charge,g_ram[0x1f99],g_ram[0xbaa],g_ram[0xbab],g_ram[0xc01],g_ram[0xc25],g_ram[0x1f0c],MmxWeaponsEnergyAmount(2,6)); + rush_gravity_frame(0); + for(unsigned t=0;t<(charged?100:65);++t) rush_gravity_frame(0); + MmxWeaponCombatState combat=MmxWeaponsGetCombatState();bool formed=false; + for(unsigned i=0;i<8;++i) formed|=combat.shots[i].active && combat.shots[i].page==2 && + combat.shots[i].weapon==6 && !combat.shots[i].variant && combat.shots[i].muzzle_pose==2; + if(!formed)for(unsigned i=0;i<8;++i)fprintf(stderr,"gravity setup slot=%u active=%u charged=%u age=%u variant=%u phase=%u player=%u:%u\n", + i,combat.shots[i].active,combat.shots[i].charged,combat.shots[i].age,combat.shots[i].variant,combat.shots[i].muzzle_pose,g_ram[0xbaa],g_ram[0xbab]); + check(formed,"actual Y input forms a live gravity field before replacement"); + rush_gravity_clear(); + size_t n=RtlSaveSnapshotToMemory(field,cap); + for(unsigned boss=0;boss<8;++boss) for(unsigned seat=0;seat<2;++seat) { + check(RtlLoadSnapshotFromMemory(field,n),"restore active field for each boss and seat"); + MmxBossRushState s=MmxBossRushGetState();unsigned other=(boss+1)%8,pos=2; + s.queue[seat]=(uint8_t)boss;s.queue[1-seat]=(uint8_t)other; + for(unsigned i=0;i<8;++i)if(i!=boss && i!=other)s.queue[pos++]=(uint8_t)i; + check(MmxBossRushSetState(&s),"gravity test selects native replacement queue"); + bool captured=false,complete=false; + unsigned t; + for(t=0;t<1500;++t) { + frame(0);rush_keep_players_alive();s=MmxBossRushGetState(); + combat=MmxWeaponsGetCombatState(); + unsigned body=s.bosses[seat].object; + if(!stale_checked && body && g_ram[body+1]==2 && (g_ram[body+0x27]&127)) { + MmxWeaponShot *bad=combat.effects+15;memset(bad,0,sizeof(*bad)); + bad->active=1;bad->age=1;bad->page=2;bad->weapon=6;bad->variant=2; + bad->origin_x=(int16_t)body;bad->origin_y=g_ram[body+10]; + bad->x=(int32_t)eagle_word(g_ram+body+5)*256; + bad->y=(int32_t)eagle_word(g_ram+body+8)*256; + MmxWeaponsSetCombatState(combat); + size_t saved=RtlSaveSnapshotToMemory(actual,cap); + check(RtlLoadSnapshotFromMemory(actual,saved),"legacy illegal gravity capture snapshot loads"); + combat=MmxWeaponsGetCombatState(); + check(!MmxWeaponsMovedEnemy(&combat,body),"legacy capture cannot suspend or replace a boss's native render/AI"); + unsigned hp=g_ram[body+0x27]&127; + frame(0);rush_keep_players_alive();combat=MmxWeaponsGetCombatState();s=MmxBossRushGetState(); + check(!combat.effects[15].active && (g_ram[body+0x27]&127)>=hp && !s.defeated, + "legacy capture retires without moving or dissolving the entering boss"); + stale_checked=true; + } + for(unsigned i=0;i<2;++i) if(s.bosses[i].object) + captured|=MmxWeaponsMovedEnemy(&combat,s.bosses[i].object)!=NULL; + if(captured || s.defeated) break; + if(s.bosses[0].phase==MMX_RUSH_FIGHTING && s.bosses[1].phase==MMX_RUSH_FIGHTING && + s.bosses[0].health==32 && s.bosses[1].health==32) {complete=true;break;} + } + fprintf(stderr,"gravity-rush charged=%u boss=%u seat=%u ticks=%u captured=%u defeats=%u phases=%u/%u hp=%u/%u classes=%u/%u\n", + charged,boss,seat,t,captured,s.defeated,s.bosses[0].phase,s.bosses[1].phase, + s.bosses[0].health,s.bosses[1].health,g_ram[s.bosses[0].object+0x28],g_ram[s.bosses[1].object+0x28]); + failures+=captured || s.defeated || !complete; + if(captured) { + /* Preserve the exploit's native consequence as well as its capture. */ + for(unsigned k=0;k<100;++k) {frame(0);rush_keep_players_alive();} + fprintf(stderr,"gravity-rush consequence defeats=%u\n",MmxBossRushGetState().defeated); + } + if(!charged && !boss && !seat) { + size_t replay=RtlSaveSnapshotToMemory(actual,cap); + for(unsigned k=0;k<20;++k) {frame(0);rush_keep_players_alive();} + size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(actual,replay),"gravity/boss entrance snapshot restores"); + for(unsigned k=0;k<20;++k) {frame(0);rush_keep_players_alive();} + size_t result=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,result,"gravity/boss entrance replays deterministically"); + } + } + } + free(expected); + check(stale_checked,"legacy-capture defense exercises a real boss entrance"); + check(!failures,"Gravity Well cannot capture or defeat entering bosses in either seat"); + puts("MMX BOSS RUSH GRAVITY CHECKS PASSED"); +} diff --git a/tests/mmx_weapon_gravity_test.inc b/tests/mmx_weapon_gravity_test.inc index 45ad6e22..2e5376b8 100644 --- a/tests/mmx_weapon_gravity_test.inc +++ b/tests/mmx_weapon_gravity_test.inc @@ -57,11 +57,14 @@ static void weapon_gravity_checks(const char *assets,const char *fixture,uint8 * (g_ram[d+0x27]&127) && g_ram[d+0x28]<6 && (g_ram[d+0x20]|g_ram[d+0x21])) {victim=d;break;} } check(victim!=0,"real native enemy found for gravity interaction"); + /* Ownership alone also tags ordinary minions; immunity belongs to the + * boss body, not every actor spawned by it. Keep the real enemy mechanics. */ + MmxBossRushSetOwner(victim,0); uint8_t enemy[64];memcpy(enemy,g_ram+victim,64); MmxWeaponsState w=MmxWeaponsGetState();w.page=2;w.weapon=6;MmxWeaponsSetState(w); frame(0);frame(SNES_PAD_Y);frame(0);zero_replay(60); MmxWeaponCombatState c=MmxWeaponsGetCombatState();const MmxWeaponShot *p=MmxWeaponsMovedEnemy(&c,victim); - check(p!=NULL,"formed well captures ordinary native enemy"); + check(p!=NULL,"formed well still captures an ordinary enemy with a boss ownership tag"); unsigned px=g_ram[victim+5]|g_ram[victim+6]<<8,py=g_ram[victim+8]|g_ram[victim+9]<<8; uint8_t action[3];memcpy(action,g_ram+victim+1,3);zero_replay(10); check(!memcmp(action,g_ram+victim+1,3) && ((unsigned)(g_ram[victim+5]|g_ram[victim+6]<<8)!=px || @@ -75,6 +78,7 @@ static void weapon_gravity_checks(const char *assets,const char *fixture,uint8 * w=MmxWeaponsGetState();w.page=2;w.weapon=6;MmxWeaponsSetState(w);g_ram[0x1f99]|=2; for(unsigned i=0;i<330;++i) frame(SNES_PAD_Y);frame(0);zero_replay(90); memcpy(g_ram+victim,enemy,64); + MmxBossRushSetOwner(victim,1); unsigned ex=(g_ram[0x1e4d]|g_ram[0x1e4e]<<8)+170,ey=(g_ram[0x1e50]|g_ram[0x1e51]<<8)+130; g_ram[victim+5]=(uint8_t)ex;g_ram[victim+6]=(uint8_t)(ex>>8); g_ram[victim+8]=(uint8_t)ey;g_ram[victim+9]=(uint8_t)(ey>>8);g_ram[victim+0x28]=6; diff --git a/tests/mmx_zero_saber_perf_test.inc b/tests/mmx_zero_saber_perf_test.inc new file mode 100644 index 00000000..fa42ea65 --- /dev/null +++ b/tests/mmx_zero_saber_perf_test.inc @@ -0,0 +1,125 @@ +/* Compare the same ROM-backed scene/input with developer diagnostics off/on. + * Readiness is a fixture shortcut; Y still starts the real X3 Zero swing. */ +#include "snes/tier2_capture.h" +#include "saber/mmx_saber_plugin.h" +#include "saber/mmx_saber_attack.h" +static void saber_perf_activate(void) { + const char *root=getenv("MMX_PERF_MOD_ROOT"); + check(root && snes_mod_runtime_initialize_c(root,"megaman-x-us", + "b8f70a6e7fb93819f79693578887e2c11e196bdf1ac6ddc7cb924b1ad0be2d32"), + "performance test opens its private mod catalog"); + const RecompLauncherCModProvider *p=snes_mod_runtime_launcher_provider_c(); + check(p && p->feature_enable(p->ctx,"megaman-x.character.zero","zero",1) && + p->feature_set_option(p->ctx,"megaman-x.character.zero","zero","behavior","saber") && + p->feature_resource_set_path(p->ctx,"megaman-x.character.zero","zero","x3-rom",getenv("MMX_PERF_X3_ROM")) && + p->commit(p->ctx,getenv("MMX_PERF_X1_ROM")),"performance test selects Saber Zero and the original X3 ROM"); + check(MmxSaberActivate("megaman-x.character.zero","zero",MmxCoopEnabled()), + "performance test activates the complete Saber Zero renderer and controller"); +} +static int saber_perf_compare(const void *a,const void *b) { + double x=*(const double *)a,y=*(const double *)b;return (x>y)-(xperiods)periods=RtlLastFramePeriods(); + swings+=saber?MmxSaberAttackSnapshotGet().phase!=SABER_PHASE_IDLE:saber_perf_zero().slash!=0; + if(enemy && (g_ram[enemy+0x27]&127)=100,"measured frames include repeated real saber swings"); + if(attack==2 && !strcmp(scene,"boss-rush"))check(damage>0,"measured attack frames deliver real boss damage"); + if(attack==3)check(swings>0,"ordinary full-charge/two-buster input reaches and measures the X3 saber"); + if(!diagnostics)en=RtlSaveSnapshotToMemory(expected,cap); + else { + size_t an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"diagnostics do not change the repeated saber simulation"); + } + MmxReplayEnable(false); + } + fclose(csv);tier2_capture_configure(1,0,0); + puts("MMX X3 ZERO SABER PERFORMANCE CHECKS PASSED"); +}