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
15 changes: 15 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions docs/weapons/gravity-well.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions src/mmx_weapon_combat.c
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "mmx_weapon_combat.h"
#include "mmx_weapons.h"
#include "mmx_zero.h"
#include "mmx_boss_rush.h"
#include <string.h>

static MmxWeaponCombatState combat;
Expand Down
14 changes: 12 additions & 2 deletions src/mmx_weapon_gravity.inc
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@
* 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;
if(s->active && s->page==2 && s->weapon==2 && s->variant==1 && s->origin_x==(int)enemy) return s;
}
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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions tests/mmx_adaptive_state_test.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")) {
Expand Down
9 changes: 9 additions & 0 deletions tests/mmx_boss_rush_runtime_test.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;}
Expand Down
106 changes: 106 additions & 0 deletions tests/mmx_gravity_rush_test.inc
Original file line number Diff line number Diff line change
@@ -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");
}
6 changes: 5 additions & 1 deletion tests/mmx_weapon_gravity_test.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand All @@ -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;
Expand Down
Loading
Loading