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
4 changes: 2 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/mmx_zero.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
1 change: 1 addition & 0 deletions src/mmx_zero.h
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
10 changes: 9 additions & 1 deletion src/mods/mmx_zero_plugin.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions tests/mmx_adaptive_state_test.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")) {
Expand Down
60 changes: 60 additions & 0 deletions tests/mmx_zero_blocks_test.inc
Original file line number Diff line number Diff line change
@@ -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");
}
16 changes: 16 additions & 0 deletions tests/mmx_zero_test.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions tools/apply_zero_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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

Expand Down
Loading