diff --git a/CMakeLists.txt b/CMakeLists.txt index 1a681c83..52bbecb6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(MegaManXSNESRecomp VERSION 2.2.12 LANGUAGES C CXX) +project(MegaManXSNESRecomp VERSION 2.3.0 LANGUAGES C CXX) set(CMAKE_C_STANDARD 11) find_package(Python3 COMPONENTS Interpreter REQUIRED) find_package(Git QUIET) @@ -22,7 +22,7 @@ add_custom_command(OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/mmx_weapon_sources.h" "${CMAKE_CURRENT_BINARY_DIR}/mmx_weapon_sources.h" DEPENDS tools/generate_x_weapon_tables.py tools/data/x_weapon_assets.json VERBATIM) -set(SNESRECOMP_BUILD_VERSION "${PROJECT_VERSION}-alpha" CACHE STRING "Release stamp for crash reports") +set(SNESRECOMP_BUILD_VERSION "${PROJECT_VERSION}" CACHE STRING "Release stamp for crash reports") set(SNESRECOMP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/snesrecomp" CACHE PATH "snesrecomp checkout") set(RECOMP_UI_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/recomp-ui" CACHE PATH "recomp-ui checkout") set(SNESRECOMP_ENABLE_MODS ON CACHE BOOL "Shared mod package runtime" FORCE) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6452a682..035ce164 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,6 +66,15 @@ 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. +For native block-crushing checks, run `mmx_state_tests` from an empty working +directory with the X1 ROM, `MMX_ZERO_BLOCKS_TEST=1`, `MMX_ZERO_TEST_ASSETS` +pointing to the extracted X3 Zero cache, `MMX_REPLAY_RECORD=0`, and +`SDL_AUDIODRIVER=dummy`. The test arranges contact with a real Flame Mammoth +block and calls the native head/foot destruction routines through compiled +and interpreted execution; it checks Zero's innate abilities, X's upgrade +gates, and unchanged inventory in both behavior +modes. It does not validate the full route to the capsule. + ## Change the framework dependency The `snesrecomp` gitlink is the single source of truth for the framework diff --git a/README.md b/README.md index a73e1518..263ed461 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,9 @@ Select **Modern** under **Zero behavior** in either Zero mod for saber combat and extra aerial movement. **X3 Behavior** is the default. **Add Zero** and **X / Zero Co-op** are alternatives; enabling one disables the other. +All Zero styles can break blocks with their head and feet from the start, +including in co-op. X still needs his corresponding armor upgrades. + For widescreen, enable **Widescreen** in Mods and choose your view aspect. **Display Aspect** in Settings controls pixel proportions. Netplay uses the original view or fixed 16:9, 21:9, or 32:9. diff --git a/src/mmx_zero.c b/src/mmx_zero.c index 48374b35..68b66221 100644 --- a/src/mmx_zero.c +++ b/src/mmx_zero.c @@ -465,6 +465,9 @@ unsigned MmxZeroUpgradeBits(unsigned pc, unsigned original) { if (!MmxZeroActive()) return original; switch (pc & 0x7fffff) { case 0x01971c: case 0x019793: case 0x0198fc: return original | 8; + /* Let the native ceiling probe pass helmet blocks, then let the player + * update destroy them. Keep the shared inventory (and X) unchanged. */ + case 0x019ea5: case 0x0499a4: return original | 1; default: return original; } } diff --git a/src/mmx_zero.h b/src/mmx_zero.h index c1477253..60e1884a 100644 --- a/src/mmx_zero.h +++ b/src/mmx_zero.h @@ -99,6 +99,7 @@ void MmxZeroSetTerrainQuery(bool (*solid)(const uint8_t *ram, int x, int y)); bool MmxZeroSlideHold(uint8_t ram[0x20000], unsigned pc); /* Before the player state dispatch: re-enter the dash if Zero cannot stand. */ void MmxZeroSlideTick(uint8_t ram[0x20000]); +/* Character abilities at native instruction PCs, without changing inventory. */ unsigned MmxZeroUpgradeBits(unsigned pc, unsigned original); void MmxZeroPlayerTick(uint8_t ram[0x20000]); void MmxZeroPlayerEnd(uint8_t ram[0x20000]); diff --git a/src/mods/mmx_zero_plugin.c b/src/mods/mmx_zero_plugin.c index 1ed606d9..34943c70 100644 --- a/src/mods/mmx_zero_plugin.c +++ b/src/mods/mmx_zero_plugin.c @@ -66,7 +66,14 @@ static void hook(CpuState *cpu, uint32_t pc) { 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; - case 0x01971f: case 0x019796: case 0x0198ff: if (MmxZeroActive()) cpu->A |= 8; break; + case 0x01971f: case 0x019796: case 0x0198ff: + case 0x019ea8: case 0x0499a7: { + unsigned value = MmxZeroUpgradeBits(pc - 3, 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 0x01815c: MmxZeroExtPrePlayer(g_ram); MmxZeroSlideTick(g_ram); @@ -191,6 +198,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, 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_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c index aef4d4fe..8535f4de 100644 --- a/tests/mmx_adaptive_state_test.c +++ b/tests/mmx_adaptive_state_test.c @@ -64,6 +64,7 @@ static void zero_capture(const char *base, const char *suffix) { #include "mmx_diagnostic_replay_test.inc" #include "mmx_boss_rush_runtime_test.inc" #include "mmx_magic_carpet_test.inc" +#include "mmx_zero_blocks_test.inc" static void zero_motion_checks(const char *fixture) { const char *path = getenv("MMX_ZERO_MOTION_REFERENCE"); if (!path) path = MMX_ZERO_MOTION_REFERENCE_DEFAULT; @@ -1953,6 +1954,7 @@ int main(int argc, char **argv) { MkDir("saves"); size_t cap = 2u * 1024u * 1024u; uint8 *start = malloc(cap), *expected = malloc(cap), *actual = malloc(cap); + if(getenv("MMX_ZERO_BLOCKS_TEST")) {zero_blocks_checks(getenv("MMX_ZERO_TEST_ASSETS"));return 0;} 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")) { diff --git a/tests/mmx_zero_blocks_test.inc b/tests/mmx_zero_blocks_test.inc new file mode 100644 index 00000000..b3ddc8df --- /dev/null +++ b/tests/mmx_zero_blocks_test.inc @@ -0,0 +1,60 @@ +/* Arrange contact with real Flame Mammoth terrain, then execute the original + * destruction routines. Inventory and destroyed tiles remain guest-owned. */ +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); +} +static void zero_blocks_checks(const char *assets) { + check(assets && MmxZeroLoad(assets),"block test loads X3 Zero"); + MmxZeroRegisterHooks(); + hitbox_boot(); + 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) { + frame(0); + if(g_ram[0xd3]==4 && g_ram[0xba9]==2) break; + } + check(g_ram[0x1f7a]==4 && g_ram[0xd3]==4 && g_ram[0xba9]==2, + "block test enters Flame Mammoth without armor"); + unsigned bx=0,by=0; + const uint8_t *rom=g_snes->cart->rom; + for(unsigned y=16;y<2048 && !bx;y+=16) for(unsigned x=16;x<8192;x+=16) { + unsigned cell=blocks_cell(x,y); + unsigned address=eagle_word(g_ram+0xb92)+(g_ram[0xb94]<<16)+eagle_word(g_ram+cell); + size_t offset=((address>>16)&127)*0x8000+(address&0x7fff); + if((address&65535)>=0x8000 && offset<(size_t)g_snes->cart->romSize && + (rom[offset]&63)==0x3c) {bx=x;by=y;break;} + } + check(bx!=0,"real stage contains a helmet-breakable block"); + printf("BLOCK fixture x=%u y=%u cell=%04x\n",bx,by,blocks_cell(bx,by)); + static uint8_t world[0x20000];memcpy(world,g_ram,sizeof(world)); + for(unsigned interpreted=0;interpreted<2;++interpreted) + for(unsigned foot=0;foot<2;++foot) for(unsigned modern=0;modern<2;++modern) + for(unsigned x=0;x<2;++x) for(unsigned owned=0;owned<2;++owned) { + memcpy(g_ram,world,sizeof(world)); + 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)); + 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; + cpu_p_to_mirrors(&cpu); + unsigned pc=foot?0x8198fc:0x819ea1; + if(interpreted) { + /* RTS sentinel for the bridge's balanced return past its entry. */ + cpu_write8(&cpu,0,cpu.S--,0x80);cpu_write8(&cpu,0,cpu.S--,0); + check(interp_bridge_run(&cpu,pc),"interpreted block routine returns"); + } else { + check(cpu_dispatch_call_pc(&cpu,pc,0x818000)==RECOMP_RETURN_NORMAL, + "compiled block routine returns"); + } + bool changed=eagle_word(g_ram+cell)!=before; + printf("BLOCK interpreted=%u foot=%u modern=%u x=%u owned=%u tile=%04x->%04x\n", + interpreted,foot,modern,x,owned,before,eagle_word(g_ram+cell)); + check(changed==(!x || owned),"Zero crushes by default; X requires the matching upgrade"); + check(g_ram[0x1f99]==inventory,"crushing leaves actual upgrade ownership unchanged"); + } + MmxZeroSetModern(false);MmxZeroResetState(); + puts("MMX ZERO NATIVE BLOCK CHECKS PASSED"); +} diff --git a/tests/mmx_zero_test.c b/tests/mmx_zero_test.c index 1baf2c6c..55a60e57 100644 --- a/tests/mmx_zero_test.c +++ b/tests/mmx_zero_test.c @@ -133,6 +133,22 @@ int main(void) { assert(MmxZeroUpgradeBits(0x81971c, 0) == 0); asset("zero-test.bin"); assert(MmxZeroLoad("zero-test.bin")); assert(MmxZeroUpgradeBits(0x81971c, 2) == 10); + const unsigned crush_sites[] = {0x819ea5, 0x8499a4, 0x8198fc}; + for (unsigned modern = 0; modern < 2; ++modern) { + MmxZeroSetModern(modern); + for (unsigned i = 0; i < sizeof(crush_sites) / sizeof(crush_sites[0]); ++i) { + unsigned bit = i == 2 ? 8 : 1; + for (unsigned inventory = 0; inventory < 256; ++inventory) { + assert(MmxZeroUpgradeBits(crush_sites[i], inventory) == (inventory | bit)); + assert(MmxZeroUpgradeBits(crush_sites[i] & 0x7fffff, inventory) == (inventory | bit)); + MmxZeroState x = MmxZeroGetState(); x.active_x = 1; MmxZeroSelectState(x); + assert(MmxZeroUpgradeBits(crush_sites[i], inventory) == inventory); + x.active_x = 0; MmxZeroSelectState(x); + } + } + } + MmxZeroSetModern(false); + MmxZeroResetState(); assert(MmxZeroUpgradeBits(0x8197da, 0) == 0); /* Special charge stays upgrade-gated. */ assert(MmxZeroMuzzle(ram,0x1228,0,0,16) == 24); assert(MmxZeroMuzzle(ram,0x1228,0,1,253) == 247); diff --git a/tools/apply_zero_hooks.py b/tools/apply_zero_hooks.py index bd89011b..f4f49bbd 100644 --- a/tools/apply_zero_hooks.py +++ b/tools/apply_zero_hooks.py @@ -5,7 +5,9 @@ import re MARKER = '/*MMX-ZERO*/' -PCS = {0x81971c, 0x819793, 0x8198fc} +PCS = {0x81971c, 0x819793, 0x8198fc, 0x819ea1, 0x8499a4} +# The head-crush block clears a body flag before loading the upgrade byte. +UPGRADE_LOAD_PCS = {0x819ea1: 0x819ea5} MUZZLE_PCS = {0x81a566, 0x81a578, 0x838b6a, 0x838d82, 0x838eb4, 0x839518, 0x83983c, 0x839974, 0x83a3a9} ORIGIN_PCS = {0x8283ed: 1, 0x83958c: 0, 0x839dc5: 1} @@ -114,7 +116,8 @@ def apply(text): if pc in PCS: load = re.search(r'uint8 (_v\d+) = cpu_read8\(cpu, cpu->DB, \(uint16\)\(0x1f99\)\);', line) if load: - output.append(f' {MARKER} {{ extern unsigned MmxZeroUpgradeBits(unsigned, unsigned); {load[1]} = (uint8)MmxZeroUpgradeBits(0x{pc:06x}, {load[1]}); }}\n') + source = UPGRADE_LOAD_PCS.get(pc, pc) + output.append(f' {MARKER} {{ extern unsigned MmxZeroUpgradeBits(unsigned, unsigned); {load[1]} = (uint8)MmxZeroUpgradeBits(0x{source:06x}, {load[1]}); }}\n') found.add(pc) return ''.join(output), found