From 11e1708751830abe36ea4443b4f4cb1391f1d01f Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:32:56 -0700 Subject: [PATCH] Fix Zero head-crush probe and tile-removal height Match the native helmet query, terrain removal, and debris origin to Zero standing collision height. Add a Storm Eagle normal-jump regression with native spawn completion and retain X upgrade gates and foot-crush coverage. --- src/mmx_zero.c | 15 ++++++++++++ src/mmx_zero.h | 3 +++ src/mods/mmx_zero_plugin.c | 20 +++++++++++++++- tests/mmx_zero_blocks_test.inc | 42 +++++++++++++++++++++++++++++++++- tests/mmx_zero_test.c | 6 +++++ tools/apply_zero_hooks.py | 16 +++++++++++++ 6 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/mmx_zero.c b/src/mmx_zero.c index 68b66221..9c13960c 100644 --- a/src/mmx_zero.c +++ b/src/mmx_zero.c @@ -471,6 +471,21 @@ unsigned MmxZeroUpgradeBits(unsigned pc, unsigned original) { default: return original; } } +unsigned MmxZeroHeadProbe(const uint8_t r[0x20000], unsigned object, unsigned original) { + /* $81:9EAC probes one pixel above X's standing terrain top (-18). + * Zero's standing top is -26, so the original -19 query misses a block + * that his real ceiling collision has already stopped against. Keep the + * native horizontal probes and destruction path, and keep X/dash boxes. */ + return MmxZeroActive() && r && object == 0xba8 && (original == 0xed || original == 0xffed) && + word(r + object + 0x20) == 0xa552 + ? original - 8 : original; +} +unsigned MmxZeroHeadBreakY(const uint8_t r[0x20000], unsigned object, unsigned original) { + /* $81:9EF9 stores the independently calculated removal coordinate. Move + * that coordinate by the same amount as the collision query, preserving + * the guest ADC flags just as the original STA does. */ + return (original + MmxZeroHeadProbe(r,object,0xed) - 0xed) & 65535; +} static bool own_projectile(const uint8_t *r, unsigned d) { return d >= 0x1228 && d < 0x1428 && (d & 63) == 0x28 && diff --git a/src/mmx_zero.h b/src/mmx_zero.h index 60e1884a..230ae9f4 100644 --- a/src/mmx_zero.h +++ b/src/mmx_zero.h @@ -101,6 +101,9 @@ bool MmxZeroSlideHold(uint8_t ram[0x20000], unsigned pc); void MmxZeroSlideTick(uint8_t ram[0x20000]); /* Character abilities at native instruction PCs, without changing inventory. */ unsigned MmxZeroUpgradeBits(unsigned pc, unsigned original); +/* Native helmet query must reach the active body's ceiling contact row. */ +unsigned MmxZeroHeadProbe(const uint8_t ram[0x20000], unsigned object, unsigned original); +unsigned MmxZeroHeadBreakY(const uint8_t ram[0x20000], unsigned object, unsigned original); void MmxZeroPlayerTick(uint8_t ram[0x20000]); void MmxZeroPlayerEnd(uint8_t ram[0x20000]); unsigned MmxZeroChargeTier(const MmxZeroState *snapshot); diff --git a/src/mods/mmx_zero_plugin.c b/src/mods/mmx_zero_plugin.c index 34943c70..ee34a3e9 100644 --- a/src/mods/mmx_zero_plugin.c +++ b/src/mods/mmx_zero_plugin.c @@ -63,6 +63,24 @@ static void weapon_menu_hook(CpuState *cpu, uint32_t pc) { static void hook(CpuState *cpu, uint32_t pc) { if (!MmxZeroEnabled() && !MmxWeaponsEnabled()) return; switch (pc & 0x7fffff) { + case 0x019ef9: + /* Intercept the STA, which preserves the native ADC flags. */ + cpu->A = (uint16_t)MmxZeroHeadBreakY(g_ram, cpu->D, cpu->A); + break; + case 0x019f59: { + unsigned value = MmxZeroHeadProbe(g_ram, cpu->D, cpu->Y); + cpu->Y = (uint16_t)value; + cpu->_flag_Z = !value; cpu->_flag_N = (value & 32768) != 0; + cpu->P = (cpu->P & ~0x82) | (cpu->_flag_Z ? 2 : 0) | (cpu->_flag_N ? 128 : 0); + break; + } + case 0x019eae: { + unsigned value = MmxZeroHeadProbe(g_ram, cpu->D, cpu->A & 255); + cpu->A = (cpu->A & 0xff00) | value; + cpu->_flag_Z = !value; cpu->_flag_N = (value & 128) != 0; + cpu->P = (cpu->P & ~0x82) | (cpu->_flag_Z ? 2 : 0) | (cpu->_flag_N ? 128 : 0); + break; + } case 0x009dca: MmxZeroHealthRespawn(g_ram); break; case 0x00d6a7: MmxRendererObserveObject(g_ram, (uint16_t)(cpu->D + cpu->X)); break; case 0x00d76a: MmxRendererRecordPiece(g_ram, cpu->D); break; @@ -198,7 +216,7 @@ static bool zero_terrain_solid(const uint8_t *r, int x, int y) { void MmxZeroRegisterHooks(void) { MmxZeroSetTerrainQuery(zero_terrain_solid); const unsigned pcs[] = {0x009dca, 0x00d6a7, 0x00d76a, 0x01971f, 0x019796, 0x0198ff, - 0x019ea8, 0x0499a7, + 0x019ea8, 0x019eae, 0x019ef9, 0x019f59, 0x0499a7, 0x01815c, 0x018165, 0x019d47, 0x0194af, 0x00d3e7, 0x00d4f4, 0x00d511, 0x049e1d, 0x049e3a, 0x049e45, 0x049e76, 0x049c19, 0x048f07, 0x048eea, 0x018b04, 0x0491dc, 0x02823e, 0x01898e, 0x018999, 0x018965, 0x028403,0x03958f,0x039dcf, diff --git a/tests/mmx_zero_blocks_test.inc b/tests/mmx_zero_blocks_test.inc index b3ddc8df..eeffd57c 100644 --- a/tests/mmx_zero_blocks_test.inc +++ b/tests/mmx_zero_blocks_test.inc @@ -4,10 +4,50 @@ static unsigned blocks_cell(unsigned x,unsigned y) { unsigned screen=g_ram[0xe800+(y>>8)*32+(x>>8)]; return 0x2000+((screen*512+((y&240)<<1)+((x&240)>>3))&65535); } +/* The old direct-call checks bypassed the movement/contact ordering that + * fails in #137. Arrange only an ordinary grounded body below the actual + * Storm Eagle column; the guest jump must find and destroy its own tiles. */ +static void zero_head_contact_checks(void) { + g_ram[0xd1]=2;g_ram[0xd2]=2;g_ram[0xd3]=g_ram[0xd4]=0; + g_ram[0x1f7a]=5;g_ram[0x1f81]=g_ram[0x1f82]=0;g_ram[0x1f99]=0; + for(unsigned i=0;i<1800;++i) { + frame(0); + if(g_ram[0xd2]==4 && g_ram[0xd3]==4 && g_ram[0xba9]==2 && + g_ram[0xbb6] && g_ram[0xbaa]==0 && g_ram[0xbab]==2 && (g_ram[0xbd3]&4)) break; + } + check(g_ram[0x1f7a]==5 && g_ram[0xd2]==4 && g_ram[0xd3]==4, + "head contact enters original Storm Eagle stage"); + check(g_ram[0xbb6] && g_ram[0xbaa]==0 && g_ram[0xbab]==2 && (g_ram[0xbd3]&4), + "head fixture waits for the native teleport and collision enable"); + uint8_t *start=malloc(2*1024*1024);check(start!=NULL,"head fixture allocates"); + size_t n=RtlSaveSnapshotToMemory(start,2*1024*1024);check(n,"head fixture saves"); + for(unsigned modern=0;modern<2;++modern) for(unsigned x=0;x<2;++x) + for(unsigned owned=0;owned<2;++owned) { + check(RtlLoadSnapshotFromMemory(start,n),"head fixture restores"); + MmxZeroSetModern(modern);MmxZeroResetState(); + MmxZeroState z=MmxZeroGetState();z.active_x=x;MmxZeroSelectState(z); + g_ram[0x1f99]=owned?1:0; + eagle_putword(g_ram+0xbad,3996);eagle_putword(g_ram+0xbb0,655); + eagle_putword(g_ram+0xbca,3996);eagle_putword(g_ram+0xbcc,655); + eagle_putword(g_ram+0xbc2,0);eagle_putword(g_ram+0xbc4,0); + g_ram[0xba9]=2;g_ram[0xbaa]=0;g_ram[0xbab]=2;g_ram[0xbd3]=4; + eagle_putword(g_ram+0x1e4d,3840);eagle_putword(g_ram+0x1e50,512); + unsigned cell=blocks_cell(3984,608),before=eagle_word(g_ram+cell); + check(before==0x48,"head fixture has intact original breakable column"); + for(unsigned i=0;i<50;++i) frame(SNES_PAD_B); + bool changed=eagle_word(g_ram+cell)!=before; + printf("HEAD CONTACT modern=%u x=%u owned=%u tile=%04x->%04x y=%u\n", + modern,x,owned,before,eagle_word(g_ram+cell),eagle_word(g_ram+0xbb0)); + check(changed==(!x || owned),"normal jump crushes for Zero or helmet-equipped X"); + check(g_ram[0x1f99]==(owned?1:0),"normal head contact preserves inventory"); + } + free(start);MmxZeroSetModern(false);MmxZeroResetState(); +} static void zero_blocks_checks(const char *assets) { check(assets && MmxZeroLoad(assets),"block test loads X3 Zero"); MmxZeroRegisterHooks(); hitbox_boot(); + zero_head_contact_checks(); g_ram[0xd1]=2;g_ram[0xd2]=2;g_ram[0xd3]=g_ram[0xd4]=0; g_ram[0x1f7a]=4;g_ram[0x1f81]=g_ram[0x1f82]=0;g_ram[0x1f99]=0; for(unsigned i=0;i<1800;++i) { @@ -35,7 +75,7 @@ static void zero_blocks_checks(const char *assets) { MmxZeroSetModern(modern);MmxZeroResetState(); MmxZeroState z=MmxZeroGetState();z.active_x=x;MmxZeroSelectState(z); g_ram[0x1f99]=owned?(foot?8:1):0; - eagle_putword(g_ram+0xbad,bx+8);eagle_putword(g_ram+0xbb0,by+(foot?-8:19)); + eagle_putword(g_ram+0xbad,bx+8);eagle_putword(g_ram+0xbb0,by+(foot?-8:x?19:27)); g_ram[0xbd1]=0; unsigned cell=blocks_cell(bx,by),before=eagle_word(g_ram+cell),inventory=g_ram[0x1f99]; CpuState cpu=g_cpu;cpu.PB=0x81;cpu.DB=0;cpu.D=0xba8;cpu.S=0x1e00;cpu.P=0x30; diff --git a/tests/mmx_zero_test.c b/tests/mmx_zero_test.c index 55a60e57..58cb2932 100644 --- a/tests/mmx_zero_test.c +++ b/tests/mmx_zero_test.c @@ -133,6 +133,12 @@ int main(void) { assert(MmxZeroUpgradeBits(0x81971c, 0) == 0); asset("zero-test.bin"); assert(MmxZeroLoad("zero-test.bin")); assert(MmxZeroUpgradeBits(0x81971c, 2) == 10); + ram[0xbc8]=0x52;ram[0xbc9]=0xa5; + assert(MmxZeroHeadProbe(ram,0xba8,0xed)==0xe5); + assert(MmxZeroHeadProbe(ram,0xba8,0)==0); + assert(MmxZeroHeadProbe(ram,0xe68,0xed)==0xed); + ram[0xbc8]=0x38;ram[0xbc9]=0xbb; + assert(MmxZeroHeadProbe(ram,0xba8,0xed)==0xed); /* Dash keeps X's terrain height. */ const unsigned crush_sites[] = {0x819ea5, 0x8499a4, 0x8198fc}; for (unsigned modern = 0; modern < 2; ++modern) { MmxZeroSetModern(modern); diff --git a/tools/apply_zero_hooks.py b/tools/apply_zero_hooks.py index f4f49bbd..07832244 100644 --- a/tools/apply_zero_hooks.py +++ b/tools/apply_zero_hooks.py @@ -19,6 +19,7 @@ # Dash exits that would stand Zero up, and the dash blocks that continue it. SLIDE_PCS = {0x81898e: '8991', 0x818999: '899C', 0x818965: '8971'} REQUIRED |= SLIDE_PCS.keys() | {0x81a062} +REQUIRED |= {0x819eac, 0x819ef3, 0x819f36} def apply(text): @@ -42,7 +43,22 @@ def apply(text): if store and int(store[1] == '8') == axis: output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern unsigned MmxZeroWeaponOrigin(const uint8_t *, unsigned, unsigned, unsigned); {store[2]} = (uint16)MmxZeroWeaponOrigin(g_ram, cpu->D, {axis}, {store[2]}); cpu_write_a_m(cpu, {store[2]}); }}\n') found.add(pc) + if pc == 0x819ef3: + store = re.search(r'cpu_write16\(cpu, cpu->DB, \(uint16\)\(0x0002\), (_v\d+)\);', line) + if store: + output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern unsigned MmxZeroHeadBreakY(const uint8_t *, unsigned, unsigned); {store[1]} = (uint16)MmxZeroHeadBreakY(g_ram,cpu->D,{store[1]}); cpu_write_a_m(cpu,{store[1]}); }}\n') + found.add(pc) output.append(line) + if pc == 0x819f36: + load = re.search(r'uint16 (_v\d+) = 0xffed;', line) + if load: + output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern unsigned MmxZeroHeadProbe(const uint8_t *, unsigned, unsigned); {load[1]} = (uint16)MmxZeroHeadProbe(g_ram,cpu->D,{load[1]}); }}\n') + found.add(pc) + if pc == 0x819eac: + load = re.search(r'uint8 (_v\d+) = 0xed;', line) + if load: + output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern unsigned MmxZeroHeadProbe(const uint8_t *, unsigned, unsigned); {load[1]} = (uint8)MmxZeroHeadProbe(g_ram,cpu->D,{load[1]}); }}\n') + found.add(pc) if (pc & 0x7fffff) == 0x049e15: load = re.search(r'uint16 (_v\d+) = cpu_read16\(cpu,', line) if '0x000a' in line and 'cpu->X' in line else None if load: