From 471d99e3fdb08666b7d04077b4cd46610f21e941 Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Mon, 28 Sep 2026 19:16:50 -0700
Subject: [PATCH 01/16] Add bounded X3 Zero playable-character experiment
---
.gitignore | 1 +
CMakeLists.txt | 17 +-
README.md | 4 +
docs/zero-spike.md | 179 +++++++++++++++
.../0.1.0/manifest.toml | 34 +++
src/main.c | 12 +-
src/mmx_renderer.c | 46 +++-
src/mmx_rtl.c | 17 +-
src/mmx_zero.c | 213 ++++++++++++++++++
src/mmx_zero.h | 28 +++
src/mods/mmx_zero_plugin.c | 82 +++++++
tests/mmx_adaptive_state_test.c | 107 ++++++++-
tests/mmx_zero_test.c | 89 ++++++++
tools/apply_zero_hooks.py | 66 ++++++
tools/extract_zero.py | 145 ++++++++++++
tools/mmx_render_capture.c | 6 +-
tools/test_renderer_captures.py | 4 +-
17 files changed, 1032 insertions(+), 18 deletions(-)
create mode 100644 docs/zero-spike.md
create mode 100644 mods/preloaded/packages/megaman-x.character.zero/0.1.0/manifest.toml
create mode 100644 src/mmx_zero.c
create mode 100644 src/mmx_zero.h
create mode 100644 src/mods/mmx_zero_plugin.c
create mode 100644 tests/mmx_zero_test.c
create mode 100644 tools/apply_zero_hooks.py
create mode 100644 tools/extract_zero.py
diff --git a/.gitignore b/.gitignore
index 36151b9..ab750df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,7 @@ rom.cfg
# Extracted ROM data (generated from ROM at build time — copyrighted)
smw_assets.dat
+zero-x3.bin
assets/
# Save files
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 8416b08..93e936e 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -35,7 +35,8 @@ function(add_mmx_variant TARGET GEN_DIR CFG_DIR)
src/mmx_00.c src/gen_stubs.c src/mmx_cpu_infra.c src/mmx_rtl.c
src/mmx_spc_player.c src/mods/mmx_diagnostics_plugin.c
src/mods/mmx_widescreen_plugin.c src/mods/mmx_msu1_plugin.c
- src/mods/mmx_password_plugin.c src/mmx_password_save.cpp)
+ src/mods/mmx_password_plugin.c src/mmx_password_save.cpp
+ src/mods/mmx_zero_plugin.c src/mmx_zero.c)
target_include_directories(${TARGET} PRIVATE
${CFG_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_BINARY_DIR} ${SNESRECOMP_RUNNER_INCLUDE_DIRS})
@@ -69,7 +70,8 @@ function(add_mmx_variant TARGET GEN_DIR CFG_DIR)
set_property(SOURCE ${mmx_generated} APPEND PROPERTY OBJECT_DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/tools/apply_overrides.py
${CMAKE_CURRENT_SOURCE_DIR}/tools/apply_renderer_capture.py
- ${CMAKE_CURRENT_SOURCE_DIR}/tools/apply_password_hooks.py)
+ ${CMAKE_CURRENT_SOURCE_DIR}/tools/apply_password_hooks.py
+ ${CMAKE_CURRENT_SOURCE_DIR}/tools/apply_zero_hooks.py)
add_custom_target(${TARGET}_widescreen_overrides
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/apply_overrides.py
--gen-dir ${GEN_DIR}
@@ -77,6 +79,8 @@ function(add_mmx_variant TARGET GEN_DIR CFG_DIR)
--gen-dir ${GEN_DIR}
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/apply_password_hooks.py
--gen-dir ${GEN_DIR}
+ COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/apply_zero_hooks.py
+ --gen-dir ${GEN_DIR}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
COMMENT "Applying Mega Man X generated-code mod hooks" VERBATIM)
add_dependencies(${TARGET} ${TARGET}_widescreen_overrides)
@@ -97,6 +101,9 @@ add_mmx_variant(RockmanXSNESRecomp "${CMAKE_CURRENT_SOURCE_DIR}/variants/jp/gen"
include(CTest)
if(BUILD_TESTING)
+ add_executable(mmx_zero_test tests/mmx_zero_test.c src/mmx_zero.c)
+ target_include_directories(mmx_zero_test PRIVATE src)
+ add_test(NAME mmx_zero_character COMMAND mmx_zero_test)
add_executable(mmx_password_save_test tests/mmx_password_save_test.cpp src/mmx_password_save.cpp)
target_include_directories(mmx_password_save_test PRIVATE src)
target_compile_features(mmx_password_save_test PRIVATE cxx_std_17)
@@ -107,12 +114,12 @@ if(BUILD_TESTING)
add_executable(mmx_wide_policy_test tests/mmx_wide_policy_test.c src/mmx_wide_policy.c)
target_include_directories(mmx_wide_policy_test PRIVATE src)
add_test(NAME mmx_wide_policy COMMAND mmx_wide_policy_test)
- add_executable(mmx_renderer_test tests/mmx_renderer_test.c src/mmx_renderer.c src/mmx_render_assets.c src/mmx_display.c src/mmx_wide_policy.c)
+ add_executable(mmx_renderer_test tests/mmx_renderer_test.c src/mmx_renderer.c src/mmx_render_assets.c src/mmx_display.c src/mmx_wide_policy.c src/mmx_zero.c)
target_include_directories(mmx_renderer_test PRIVATE src ${SNESRECOMP_ROOT}/runner/src)
add_test(NAME mmx_custom_renderer COMMAND mmx_renderer_test)
- add_executable(mmx_render_capture tools/mmx_render_capture.c src/mmx_renderer.c src/mmx_render_assets.c src/mmx_display.c src/mmx_wide_policy.c)
+ add_executable(mmx_render_capture tools/mmx_render_capture.c src/mmx_renderer.c src/mmx_render_assets.c src/mmx_display.c src/mmx_wide_policy.c src/mmx_zero.c)
target_include_directories(mmx_render_capture PRIVATE src ${SNESRECOMP_ROOT}/runner/src)
- foreach(mmx_test mmx_display_test mmx_wide_policy_test mmx_renderer_test mmx_password_save_test)
+ foreach(mmx_test mmx_display_test mmx_wide_policy_test mmx_renderer_test mmx_password_save_test mmx_zero_test)
if(MSVC)
target_compile_options(${mmx_test} PRIVATE /UNDEBUG)
else()
diff --git a/README.md b/README.md
index d88cb92..fcc6509 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,10 @@ image. It is disabled by default and enabled from the launcher's **Mods** page.
See [Adaptive widescreen support](#adaptive-widescreen-support) for
availability and controls.
+This branch also includes the disabled-by-default [X3 Zero experiment](docs/zero-spike.md).
+It requires locally extracted X3 USA assets. The prototype supports the two-shot
+and saber combo; complete animation, weapon and campaign validation is unfinished.
+
diff --git a/docs/zero-spike.md b/docs/zero-spike.md
new file mode 100644
index 0000000..32bd566
--- /dev/null
+++ b/docs/zero-spike.md
@@ -0,0 +1,179 @@
+# X3 Zero in X1: bounded feasibility experiment
+
+Implemented on `experiment/x3-zero-spike`, based on X1 commit `975b126`.
+Tracking: central Beads `beads-110f`, under the X1 game epic and related to
+the X3 game epic. This is an experimental playable prototype, not the finished
+full-character port.
+
+**Verdict:** the important integration points work with targeted investigation.
+Neither game needed a near-complete disassembly or an engine rewrite. The spike
+uses the existing X1 host/compositor and the local X3 project's original USA ROM.
+Zero keeps his original dimensions; no resizing was needed in the tested area.
+Full-stage clearance remains a playtest task, with changes justified by actual
+failures rather than an assumption that he will not fit.
+
+## Run it
+
+Use the normal X1 source-build procedure in the repository README. The CMake
+build installs the disabled-by-default **X3 Zero Experiment** package alongside
+the existing mods. The package targets the original X1 USA ROM only.
+
+Extract the local asset cache from the original X3 USA ROM:
+
+```powershell
+python tools/extract_zero.py ../MegamanX3SNESRecomp/mmx3.sfc build-zero/zero-x3.bin
+```
+
+The extractor accepts an optional 512-byte copier header and checks the
+normalized X3 SHA-256:
+`65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7`.
+It extracts 117 body, 21 saber-body and 14 blade poses, palettes and the ground/air
+saber bounds. `--sheet path.png` also writes a labeled contact sheet if Pillow
+is installed. ROMs, generated code and extracted graphics stay local.
+
+In the launcher's Mods page, enable **Play as Zero (experimental)**. The asset
+picker can point to the cache elsewhere; its default is `zero-x3.bin` beside the
+executable. Missing/invalid assets leave Zero inactive and produce a log message.
+Enable the existing widescreen mod separately if desired. The native-width view
+also uses the compositor while Zero is enabled.
+
+The local spike executable is `build-zero/MegaManXSNESRecomp.exe`. Its development
+mod state already enables Zero. It uses that build directory's config and saves.
+Use experimental saves separately from normal play: enabled saves contain Zero
+state and require the same mod/assets. The package's `requires-same-mods` label
+is descriptive; the shared runtime does not enforce save isolation.
+
+Controls use X1's button mappings:
+
+- Dash is immediately available; no equipment is granted.
+- With the buster selected, hold fire for 180 gameplay updates, then release for
+ the first full shot. After the short recovery, press fire for the second shot,
+ then again for the saber. Ground and aerial swings are supported.
+- Switching to a special weapon cancels the stored combo and uses X1's weapon
+ logic. Charged specials still require the actual arm upgrade.
+
+## What the spike implements
+
+`src/mmx_zero.c` owns a small character state, asset cache and combat adapter.
+The renderer replaces only the player object's pieces and suppresses X1's armor
+overlays; story NPC Zero remains a separate actor. Stage scripts, inventory,
+boss progression and special-weapon behavior continue through X1.
+
+X3's normal/dash body bounds are translated eight pixels vertically to X1's
+player origin, aligning the feet. Terrain and damage bounds retain X3's sizes.
+The normal and dash terrain heights are both 21 pixels; their damage half-heights
+are 18 and 11 pixels. The live cartridge copy is patched only after matching
+the expected X1 bytes, and disabling restores those bytes. Source ROM files
+are never modified.
+
+The first two attacks are X1's native full-charge projectile class. A narrowly
+scoped second-shot allocation bypasses X1's one-charge-shot restriction while
+leaving projectile initialization, travel and retirement to X1. The saber has
+X3's body/blade sequences and changing ground/air hitboxes. It uses a transient
+projectile slot and X1's collision/damage response. Positive damage becomes 16;
+native immunity/reflection paths remain. A target slot can be damaged only once
+per swing. This damage value is a prototype balance choice, not a claim that
+every X3/X1 enemy interaction is equivalent.
+
+Interpreter hooks support the normal faithful execution path. A checked,
+idempotent generated-code patcher covers the same seven capability/combat sites.
+The mod adds no framework changes. Optional 12-byte character state is appended
+to enabled saves (game chunk v4); disabled saves retain v3. Mid-swing renderer
+captures also retain the character state, with old capture v2 still readable.
+
+## Validation
+
+Release build and all five CTests pass. The ROM-backed state test additionally
+passes these checks using an unupgraded standing-highway fixture:
+
+| Check | Observed result |
+| --- | --- |
+| Player replacement | Original-size Zero at 256px native and 342px widescreen; captures inspected |
+| Dash | Native dash speed without changing the equipment byte |
+| Ground combo | Two full-charge native projectiles coexist, then a timed saber swing |
+| Aerial saber | Jump/gravity continue; Zero returns to the same floor height |
+| Homing Torpedo | Native weapon selection, projectile creation and energy consumption |
+| Charged special gate | Unavailable without arms; five charged Torpedo projectiles with arms |
+| Snapshot | Complete immediate mid-saber restore and byte-identical 10-frame replay |
+| Rollback | Byte-identical eight-frame replay from a mid-saber rollback state |
+| Rewind | The actual rewind path restores the complete mid-saber state |
+| Cleanup | Saber/projectile count retires; disabling returns to stock presentation |
+
+Separate live combat probes use copied fixtures, position the player and seed a
+stored saber; they do not write enemy HP. In the Armored Armadillo fixture, HP
+goes from 8 to 0 at the active arc and X1 enters its boss-death sequence. On the
+highway wheel enemy, HP goes from 2 to 1 and X1 enters its shell-break response;
+the same swing does not repeatedly hit its exposed core. These prove damage
+integration, not a complete boss battle or campaign playthrough.
+
+Asset extraction is deterministic; passing X1 to the X3 extractor is rejected.
+The generated hooks were checked for coverage and idempotence. Local evidence
+is in `_research/` (ignored): `state-check/run.log`, `boss-proof-final.log`,
+`enemy-proof-final.log`, `zero-saber-native.bmp`, `zero-saber-wide.bmp`,
+`zero-air-final.bmp`, `zero-dash-final.bmp`, and `zero-torpedo-final.bmp`.
+
+To rerun the ROM-backed checks, build with `-DMMX_STATE_TESTS=ON` and launch
+`mmx_state_tests` from an empty scratch directory. Supply absolute paths:
+
+```powershell
+$env:MMX_ZERO_TEST_ASSETS = '.../build-zero/zero-x3.bin'
+$env:MMX_ZERO_TEST_FIXTURE = '.../build-zero/saves/save0.sav'
+$env:MMX_ZERO_TEST_CAPTURE = '.../artifacts/saber.cap' # optional
+& '.../build-zero/mmx_state_tests.exe' '.../mmx.sfc'
+```
+
+The fixture must be a complete runtime save at a safe standing highway position,
+using the buster and no upgrades. The test grants Torpedo inventory and arms only
+inside its special-weapon fixtures. Without these environment variables, the
+existing state tests run normally. The debugger's `savestate`/`loadstate` commands
+use the complete state format; its differently named `save_state`/`load_state`
+commands contain only emulated-machine state and cannot validate this mod's
+host-side combo state.
+
+Replay a capture with the assets to see the replacement character:
+
+```powershell
+build-zero/mmx_render_capture.exe artifacts/saber.cap mmx.sfc 16:9 0 artifacts/saber.bmp build-zero/zero-x3.bin
+```
+
+The replay tool returns 1 for differences from the stock PPU, which are expected
+for Zero, and 2 for an error. The debug server's plain `screenshot` command captures
+the stock PPU before character replacement; use compositor captures or the
+presented frame for visual proof.
+
+## Remaining work for the full port
+
+1. Audit the complete animation/state mapping: wall slide/jump, ladders, damage,
+ death, teleport, capsules, ride armor and scripted player poses. The prototype
+ reuses the shared early-game pose numbering; unsupported indices fall back to
+ idle. Extracting all 152 poses does not prove that every gameplay state selects
+ the correct pose or timing.
+2. Match X3 movement/charge timing and projectile presentation where required.
+ Current movement physics, dash effects and the first two projectile graphics
+ are X1's. Charge/hit palette effects and saber audio still need adaptation.
+3. Adapt and validate all eight X1 weapons in their normal and charged forms,
+ including body/arm poses, emission points and weapon palettes. Only Homing
+ Torpedo received runtime acceptance in this spike.
+4. Play through representative tight spaces, moving platforms, doors, water,
+ ride armor and all bosses, then the full campaign in native and widescreen.
+ Story/NPC behavior is preserved by the scope of the hooks but has not received
+ an end-to-end playthrough with Zero enabled. Tune geometry only where those
+ tests demonstrate a problem.
+5. Finish mod/save UX, incompatible-mod handling and packaging before release.
+
+This result supports a staged full port. Exact fidelity and campaign acceptance
+remain substantial work, but the experiment did not uncover a requirement to
+reverse-engineer both games in their entirety.
+
+## Targeted research trail
+
+The investigation was limited to asset tables, player update/input gates,
+projectile allocation and damage handling. Useful X3 islands were `$04:A63C`
+(body selection), `$04:BCA2` (graphics transfers), `$01:805B` (palettes), and
+`$86:B40E/B422/B837/B84B` (body/saber bounds). X1 integration sites are recorded
+in `tools/apply_zero_hooks.py` and `src/mods/mmx_zero_plugin.c`.
+
+Public [MMX3 Zero Project](https://github.com/justin3009/MMX3-ZeroProject) and
+[MegaED X](https://github.com/rbrummett/megaedx_v1.3) sources supplied format/address
+leads. The asset decoder here is independently implemented and verifies the
+original ROM; this mod does not require installing the Zero Project ROM hack.
diff --git a/mods/preloaded/packages/megaman-x.character.zero/0.1.0/manifest.toml b/mods/preloaded/packages/megaman-x.character.zero/0.1.0/manifest.toml
new file mode 100644
index 0000000..d80a5dd
--- /dev/null
+++ b/mods/preloaded/packages/megaman-x.character.zero/0.1.0/manifest.toml
@@ -0,0 +1,34 @@
+format_version = 1
+id = "megaman-x.character.zero"
+version = "0.1.0"
+name = "X3 Zero Experiment"
+author = "mstan"
+description = "Experimental playable-character port from Mega Man X3. Work in progress."
+license = "MIT"
+resolver = "declarative"
+save_compatibility = "requires-same-mods"
+
+[[target]]
+game_id = "megaman-x-us"
+rom_sha256 = "b8f70a6e7fb93819f79693578887e2c11e196bdf1ac6ddc7cb924b1ad0be2d32"
+
+[[feature]]
+id = "zero"
+name = "Play as Zero (experimental)"
+description = "Use locally extracted X3 Zero assets. Development prototype; campaign validation is incomplete."
+group = "Characters"
+default_enabled = false
+
+[[resource]]
+feature = "zero"
+id = "assets"
+label = "Extracted X3 Zero assets"
+description = "Choose zero-x3.bin produced by tools/extract_zero.py from your X3 USA ROM. Defaults to zero-x3.bin beside the executable."
+format = "file"
+file_patterns = "*.bin"
+file_description = "Extracted Zero asset cache"
+required = false
+
+[[plugin]]
+feature = "zero"
+id = "megaman-x.zero"
diff --git a/src/main.c b/src/main.c
index 6e17b06..abcaf8d 100644
--- a/src/main.c
+++ b/src/main.c
@@ -11,6 +11,8 @@
#include "mmx_rtl.h"
#include "mmx_display.h"
#include "mmx_renderer.h"
+#include "mmx_zero.h"
+#include "snes/cart.h"
#include "mmx_spc_player.h"
#include "mmx_default_config.h"
@@ -33,9 +35,15 @@ static void MmxRomLoaded(const uint8_t *rom, size_t size) {
}
static void MmxPrepareFrame(int dw, int dh, int *w, int *h) {
- g_mmx_custom_renderer = !MMX_VARIANT_JP && g_config.widescreen;
+ g_mmx_custom_renderer = !MMX_VARIANT_JP && (g_config.widescreen || MmxZeroEnabled());
g_mmx_custom_view = MmxRendererViewport(g_mmx_custom_aspect, dw, dh,
SnesDisplayAspect_Clamp(g_config.display_aspect));
+ if (!g_config.widescreen) {
+ int pw, ph;
+ MmxDisplay_ComputePresentationSize(256, 224,
+ SnesDisplayAspect_Clamp(g_config.display_aspect), &pw, &ph);
+ g_mmx_custom_view = (MmxRenderView){256, 0, (double)pw / ph};
+ }
*w = g_mmx_custom_renderer ? g_mmx_custom_view.width : 256;
*h = 224;
}
@@ -51,6 +59,8 @@ int MmxDisplay_GetCurrentFrameWidth(void) { return snesrecomp_desktop_frame_widt
static void MmxBeforeFrame(void) {
/* MMX's draw hook runs the original per-line HDMA sequence itself. */
snes_set_hdma_beam_enabled(g_snes, false);
+ if (g_snes->cart)
+ MmxZeroSetCollisionRom(g_snes->cart->rom, g_snes->cart->romSize);
if (g_mmx_custom_renderer) MmxRendererLatchSprites();
}
static void MmxResetRenderer(void) {
diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c
index 0fd2ffd..3cdf816 100644
--- a/src/mmx_renderer.c
+++ b/src/mmx_renderer.c
@@ -2,6 +2,7 @@
#include "mmx_display.h"
#include "mmx_wide_policy.h"
#include "mmx_render_assets.h"
+#include "mmx_zero.h"
#include
/* Mode-1 decode/composition follows SuperMetroidRecomp's sm_renderer.c.
@@ -29,6 +30,7 @@ typedef struct Frame {
bool expand;
} Frame;
static Frame frame;
+static MmxZeroState frame_zero;
static Piece building[MAX_PIECES], latched[MAX_PIECES];
static unsigned building_count, latched_count;
static uint8_t building_stage, latched_stage;
@@ -78,6 +80,7 @@ void MmxRendererSetRom(const uint8_t *bytes, size_t length) {
rom = bytes; rom_size = length; MmxRenderAssetsSetRom(bytes, length);
}
void MmxRendererReset(void) {
+ memset(&frame_zero, 0, sizeof(frame_zero));
frame.valid = false; frame.captured = 0;
building_count = latched_count = 0;
building_stage = latched_stage = 0xff;
@@ -246,6 +249,7 @@ void MmxRendererBeginFrame(const uint8_t ram[0x20000]) {
trace_objects(ram);
frame.valid = false; frame.captured = 0;
memcpy(frame.ram, ram, sizeof(frame.ram));
+ frame_zero = MmxZeroGetState();
frame.piece_count = latched_stage == ram[0x1f7a] ? latched_count : 0;
memcpy(frame.pieces, latched, frame.piece_count * sizeof(*latched));
frame.expanded_count = latched_stage == ram[0x1f7a] ? expanded_latched_count : 0;
@@ -273,8 +277,9 @@ bool MmxRendererSaveCapture(const char *path) {
if (!frame.valid || !path) return false;
FILE *f = fopen(path, "wb");
if (!f) return false;
- uint32_t header[] = {0x4d4d5843, 2, sizeof(frame)};
- bool ok = fwrite(header, sizeof(header), 1, f) == 1 && fwrite(&frame, sizeof(frame), 1, f) == 1;
+ uint32_t header[] = {0x4d4d5843, 3, sizeof(frame) + sizeof(frame_zero)};
+ bool ok = fwrite(header, sizeof(header), 1, f) == 1 && fwrite(&frame, sizeof(frame), 1, f) == 1 &&
+ fwrite(&frame_zero, sizeof(frame_zero), 1, f) == 1;
return fclose(f) == 0 && ok;
}
bool MmxRendererLoadCapture(const char *path) {
@@ -283,9 +288,13 @@ bool MmxRendererLoadCapture(const char *path) {
if (!f) return false;
uint32_t h[3];
frame.valid = false;
+ memset(&frame_zero, 0, sizeof(frame_zero));
bool ok = fread(h, sizeof(h), 1, f) == 1 && h[0] == 0x4d4d5843 &&
- h[1] == 2 && h[2] == sizeof(frame) && fread(&frame, sizeof(frame), 1, f) == 1 &&
- frame.captured == 224 && frame.piece_count <= MAX_PIECES && frame.expanded_count <= MAX_PIECES && frame.valid && fgetc(f) == EOF;
+ ((h[1] == 2 && h[2] == sizeof(frame)) || (h[1] == 3 && h[2] == sizeof(frame) + sizeof(frame_zero))) &&
+ fread(&frame, sizeof(frame), 1, f) == 1 &&
+ frame.captured == 224 && frame.piece_count <= MAX_PIECES && frame.expanded_count <= MAX_PIECES && frame.valid;
+ if (ok && h[1] == 3) ok = fread(&frame_zero, sizeof(frame_zero), 1, f) == 1 && frame_zero.slash <= 46 && frame_zero.air <= 1;
+ ok = ok && fgetc(f) == EOF;
fclose(f); frame.valid = ok; return ok;
}
@@ -641,6 +650,8 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
MmxRenderAssetsDeathPaletteFade(frame.ram, frame.lines[0].palette) : 0;
const Piece *pieces = frame.expand && g_mmx_render_asset_repairs ? frame.expanded : frame.pieces;
unsigned piece_count = frame.expand && g_mmx_render_asset_repairs ? frame.expanded_count : frame.piece_count;
+ const uint8_t *zero = stage ? MmxZeroPose(frame.ram, &frame_zero) : NULL;
+ const uint8_t *blade = zero ? MmxZeroBlade(&frame_zero) : NULL;
Piece waiting[128];
unsigned waiting_count = stage && g_mmx_render_asset_repairs ?
fortress_waiting_pieces(waiting, pieces, piece_count) : 0;
@@ -690,6 +701,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
sprite(&p, r, s.x, s.y, s.attr, s.size, y, view, objects, true, asset, s.tile, object_colors, true);
}
bool replaced[128] = {false};
+ bool zero_drawn = false;
for (int i = (int)piece_count - 1; i >= 0; --i) {
Piece s = pieces[i]; const MmxSpriteAsset *asset = piece_assets[i];
if (g_mmx_render_asset_repairs && fortress_sound_actor(s.object) &&
@@ -698,7 +710,8 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
* their entire footprint, including x=255 which native D76A clips.
* Only the explicit expanded list can add pieces beyond that budget. */
bool center = g_mmx_render_asset_repairs;
- if (g_mmx_render_asset_repairs) for (int slot = 16; slot < 128; ++slot) {
+ bool zero_armor = zero && (s.object == 0xc38 || s.object == 0xc58 || s.object == 0xc78);
+ if (g_mmx_render_asset_repairs || (zero && s.object == 0xba8) || zero_armor) for (int slot = 16; slot < 128; ++slot) {
unsigned pos = r->oam[slot * 2], hi = r->high_oam[slot / 4] >> (slot % 4 * 2);
int ox = (pos & 255) | ((hi & 1) << 8); if (ox >= 256) ox -= 512;
if (ox == s.x && (pos >> 8) == ((unsigned)s.y & 255) && r->oam[slot * 2 + 1] == s.attr) {
@@ -712,6 +725,29 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
unsigned attr = asset ? (s.attr & 0xf000) | ((asset->attributes & 15) << 8) |
(asset->live_tiles ? s.attr & 255 : 0) : s.attr;
if (asset && asset->live_colors) attr = (attr & ~0x0e00u) | (s.attr & 0x0e00u);
+ if (zero_armor) continue;
+ if (zero && s.object == 0xba8) {
+ if (!zero_drawn) {
+ zero_drawn = true;
+ int zx = (int16_t)(word(frame.ram, 0xbad) - word(frame.ram, 0x1e4d));
+ int zy = (int16_t)(word(frame.ram, 0xbb0) - word(frame.ram, 0x1e50)) - 8;
+ int row = y - zy + 64;
+ if (row >= 0 && row < MMX_ZERO_HEIGHT) {
+ const uint16_t *colors = MmxZeroColors();
+ unsigned z = ((((s.attr >> 12) & 3) * 4 + 2) << 12) | 0x680;
+ for (int col = 0; col < MMX_ZERO_WIDTH; ++col) {
+ unsigned pixel = zero[row * MMX_ZERO_WIDTH + col];
+ if (blade && blade[row * MMX_ZERO_WIDTH + col]) pixel = blade[row * MMX_ZERO_WIDTH + col];
+ int dx = zx + ((frame.ram[0xbb9] & 64) ? 63 - col : col - 64) + view.extra;
+ if (pixel && dx >= 0 && dx < view.width) {
+ objects[dx] = (uint16_t)(z | (pixel & 15));
+ object_colors[dx] = colors[pixel];
+ }
+ }
+ }
+ }
+ continue;
+ }
sprite(&p, r, s.x, s.y, attr, s.size, y, view, objects, !center, asset, s.tile, object_colors, true);
}
int bar_first = -1, bar_count = 0;
diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c
index cff2892..ae61db6 100644
--- a/src/mmx_rtl.c
+++ b/src/mmx_rtl.c
@@ -1,6 +1,7 @@
#include "mmx_rtl.h"
#include "mmx_wide_policy.h"
#include "mmx_renderer.h"
+#include "mmx_zero.h"
#include "variables.h"
#include "common_cpu_infra.h"
#include "snes/snes.h"
@@ -327,7 +328,7 @@ void mmx_host_yield(uint8_t countdown) {
#include "snes/saveload.h"
#define MMX_SAV_CHUNK_MAGIC 0x4D4D5854u /* "MMXT" */
-#define MMX_SAV_CHUNK_VERSION 3u /* Execution state plus native-timed streakers. */
+#define MMX_SAV_CHUNK_VERSION 4u /* Optional experimental Zero state. */
typedef struct MmxSavChunk {
uint32_t magic, version;
@@ -357,12 +358,13 @@ static void MmxWideStateSave(struct SaveLoadInfo *sli);
static void MmxWideStateLoad(struct SaveLoadInfo *sli);
static void MmxWideStateApply(bool loaded);
static uint8_t g_load_frame_flags[4];
+static MmxZeroState g_load_zero;
void MmxStateSaveExtra(struct SaveLoadInfo *sli) {
MmxSavChunk c;
memset(&c, 0, sizeof(c));
c.magic = MMX_SAV_CHUNK_MAGIC;
- c.version = MMX_SAV_CHUNK_VERSION;
+ c.version = MmxZeroEnabled() ? MMX_SAV_CHUNK_VERSION : 3;
mmx_save_cpu(&c.main_cpu, &g_cpu);
for (int i = 0; i < MMX_NSLOTS; i++) {
c.occupied[i] = (g_slot_fiber[i] != NULL);
@@ -381,12 +383,17 @@ void MmxStateSaveExtra(struct SaveLoadInfo *sli) {
sli->func(sli, flags, sizeof(flags));
MmxWideStateSave(sli);
RtlSaveExecutionState(sli);
+ if (c.version >= 4) {
+ MmxZeroState zero = MmxZeroGetState();
+ sli->func(sli, &zero, sizeof(zero));
+ }
}
void MmxStateLoadExtra(struct SaveLoadInfo *sli, uint32_t version) {
(void)version;
g_load_chunk_ok = 0;
g_load_complete = g_load_native_streakers = false;
+ memset(&g_load_zero, 0, sizeof(g_load_zero));
memset(&g_load_chunk, 0, sizeof(g_load_chunk));
sli->func(sli, &g_load_chunk, sizeof(g_load_chunk));
if (g_load_chunk.magic == MMX_SAV_CHUNK_MAGIC &&
@@ -404,6 +411,11 @@ void MmxStateLoadExtra(struct SaveLoadInfo *sli, uint32_t version) {
sli->func(sli, g_load_frame_flags, sizeof(g_load_frame_flags));
MmxWideStateLoad(sli);
if (!RtlLoadExecutionState(sli)) g_load_chunk_ok = 0;
+ if (g_load_chunk.version >= 4) {
+ if (RtlStateBytesRemaining(sli) >= sizeof(g_load_zero))
+ sli->func(sli, &g_load_zero, sizeof(g_load_zero));
+ else g_load_chunk_ok = 0;
+ }
}
if (!g_load_chunk_ok)
fprintf(stderr, "[mmx_state] load: bad game chunk (magic=%08x ver=%u)\n",
@@ -431,6 +443,7 @@ void MmxOnStateLoaded(uint32_t version) {
g_did_reset = complete ? g_load_frame_flags[0] != 0 : true;
g_first_frame_done = complete ? g_load_frame_flags[1] != 0 : true;
MmxWideStateApply(complete);
+ MmxZeroSetState(g_load_zero);
if (version < 5 || !g_load_chunk_ok) {
/* Legacy v4 save: no chunk, no rebuild — preserve the historical
* behavior exactly (live fibers limp along; loads are only reliable
diff --git a/src/mmx_zero.c b/src/mmx_zero.c
new file mode 100644
index 0000000..2a01c6b
--- /dev/null
+++ b/src/mmx_zero.c
@@ -0,0 +1,213 @@
+#include "mmx_zero.h"
+#include
+#include
+#include
+
+static uint8_t *poses;
+static uint16_t colors[128];
+static uint8_t saber_bounds[40];
+static MmxZeroState state;
+static unsigned word(const uint8_t *p) { return p[0] | p[1] << 8; }
+static void putword(uint8_t *p, unsigned v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); }
+MmxZeroState MmxZeroGetState(void) { return state; }
+void MmxZeroResetState(void) { memset(&state, 0, sizeof(state)); }
+void MmxZeroSetState(MmxZeroState s) {
+ MmxZeroResetState();
+ if (poses && s.combo <= 2 && s.slash <= 46 && s.charge <= 180 &&
+ s.air <= 1 && (s.facing == 0 || s.facing == 64) &&
+ (!s.projectile || (s.projectile >= 0x1228 && s.projectile < 0x1428 && (s.projectile & 63) == 0x28))) state = s;
+}
+
+bool MmxZeroEnabled(void) { return poses != NULL; }
+void MmxZeroDisable(void) {
+ MmxZeroResetState();
+ free(poses); poses = NULL;
+}
+bool MmxZeroLoad(const char *path) {
+ FILE *f = path ? fopen(path, "rb") : NULL;
+ if (!f) return false;
+ uint8_t header[20], palette[256], bounds[40];
+ size_t size = (size_t)MMX_ZERO_POSES * MMX_ZERO_WIDTH * MMX_ZERO_HEIGHT;
+ uint8_t *data = NULL;
+ bool ok = fread(header, 1, sizeof(header), f) == sizeof(header) &&
+ !memcmp(header, "MMXZERO3", 8) && word(header + 8) == MMX_ZERO_WIDTH &&
+ word(header + 10) == MMX_ZERO_HEIGHT && word(header + 12) == 64 &&
+ word(header + 14) == 64 && word(header + 16) == 117 && word(header + 18) == 35 &&
+ fread(palette, 1, sizeof(palette), f) == sizeof(palette) &&
+ fread(bounds, 1, sizeof(bounds), f) == sizeof(bounds);
+ if (ok) { data = malloc(size); ok = data && fread(data, 1, size, f) == size && fgetc(f) == EOF; }
+ fclose(f);
+ if (ok) for (unsigned i = 0; i < sizeof(bounds); i += 4)
+ if (!bounds[i + 2] || bounds[i + 2] > 64 || !bounds[i + 3] || bounds[i + 3] > 64) { ok = false; break; }
+ if (ok) for (size_t i = 0; i < size; ++i) if (data[i] >= 128) { ok = false; break; }
+ if (!ok) { free(data); return false; }
+ MmxZeroDisable(); poses = data;
+ memcpy(saber_bounds, bounds, sizeof(bounds));
+ for (unsigned i = 0; i < 128; ++i) colors[i] = (uint16_t)(word(palette + 2 * i) & 0x7fff);
+ return true;
+}
+const uint16_t *MmxZeroColors(void) { return colors; }
+static unsigned slash_pose(const MmxZeroState *s) {
+ /* Vanilla X3 group $4B actions $00/$0E: duration, frame. */
+ static const uint8_t duration[] = {3,3,1,2,3,3,6,16,3,3,3};
+ static const uint8_t pose[] = {0,1,2,2,3,4,5,6,4,1,0};
+ unsigned age = s->slash ? s->slash - 1 : 0;
+ for (unsigned i = 0; i < sizeof(duration); ++i) {
+ if (age < duration[i]) return pose[i] + (s->air ? 7 : 0);
+ age -= duration[i];
+ }
+ return 0;
+}
+const uint8_t *MmxZeroPose(const uint8_t ram[0x20000], const MmxZeroState *s) {
+ if (!poses || !ram || !ram[0xbb6]) return NULL;
+ /* The shared early-X animation vocabulary includes idle, run, jump,
+ * dash and firing poses. X1-specific states remain a validation item. */
+ unsigned pose = ram[0xbbf] & 127;
+ if (pose >= 117) pose = 0;
+ if (s && s->slash) pose = 117 + slash_pose(s);
+ return poses + (size_t)pose * MMX_ZERO_WIDTH * MMX_ZERO_HEIGHT;
+}
+const uint8_t *MmxZeroBlade(const MmxZeroState *s) {
+ if (!poses || !s || s->slash < 7 || s->slash > 31) return NULL;
+ static const uint8_t duration[] = {3,3,3,3,7,3,3};
+ unsigned age = s->slash - 7, pose = 0;
+ while (pose < 6 && age >= duration[pose]) age -= duration[pose++];
+ return poses + (size_t)(138 + pose + (s->air ? 7 : 0)) * MMX_ZERO_WIDTH * MMX_ZERO_HEIGHT;
+}
+void MmxZeroSetCollisionRom(uint8_t *rom, size_t size) {
+ if (!rom || size < 0x37fd8) return;
+ /* X3 $86:B40E / B422, translated -8px vertically into X1's player origin.
+ * This preserves Zero's full body dimensions and aligns both games' feet.
+ * First four bytes are damage bounds; last six are terrain-probe geometry. */
+ static const uint8_t normal[10] = {0, 0xfb, 6, 18, 0, 0, 0xfb, 6, 21, 8};
+ static const uint8_t dash[10] = {0, 2, 6, 11, 0, 0, 0xfb, 6, 21, 8};
+ static const uint8_t old_normal[10] = {0,255,6,14,0,0,255,7,17,8};
+ static const uint8_t old_dash[10] = {0,5,6,8,0,0,255,9,17,8};
+ /* Original X3 ground/air arc bounds in verified X1 $FF padding. */
+ uint8_t empty[40]; memset(empty, 255, sizeof(empty));
+ if ((!memcmp(rom + 0x32552, old_normal, 10) || !memcmp(rom + 0x32552, normal, 10)) &&
+ (!memcmp(rom + 0x33b38, old_dash, 10) || !memcmp(rom + 0x33b38, dash, 10)) &&
+ (!memcmp(rom + 0x37fb0, empty, 40) || !memcmp(rom + 0x37fb0, saber_bounds, 40))) {
+ memcpy(rom + 0x32552, poses ? normal : old_normal, 10);
+ memcpy(rom + 0x33b38, poses ? dash : old_dash, 10);
+ memcpy(rom + 0x37fb0, poses ? saber_bounds : empty, 40);
+ }
+}
+unsigned MmxZeroUpgradeBits(unsigned pc, unsigned original) {
+ if (!poses) return original;
+ switch (pc & 0x7fffff) {
+ case 0x01971c: case 0x019793: case 0x0198fc: return original | 8;
+ default: return original;
+ }
+}
+
+static bool own_projectile(const uint8_t *r, unsigned d) {
+ return d >= 0x1228 && d < 0x1428 && (d & 63) == 0x28 &&
+ r[d] && word(r + d + 0x3e) == 0x5a53;
+}
+static void release_projectile(uint8_t *r) {
+ unsigned d = state.projectile;
+ if (own_projectile(r, d)) {
+ memset(r + d, 0, 64);
+ if (r[0xbdd]) --r[0xbdd];
+ }
+ state.projectile = 0;
+}
+void MmxZeroCancel(uint8_t ram[0x20000]) {
+ if (ram) release_projectile(ram);
+ MmxZeroResetState();
+}
+static unsigned free_projectile(const uint8_t *r) {
+ for (unsigned d = 0x1228; d < 0x1428; d += 64)
+ if (!word(r + d)) return d;
+ return 0;
+}
+static void clear_charge(uint8_t *r) {
+ memset(r + 0xbff, 0, 5);
+}
+void MmxZeroPlayerTick(uint8_t r[0x20000]) {
+ if (!poses || !r) return;
+ /* This runs after X1 has latched mapped controls, before its player state.
+ * Cutscenes, damage, death, ladders, ride armor and menus cancel the combo. */
+ unsigned action = r[0xbaa];
+ bool playable = r[0xd1] == 2 && r[0xd2] == 4 && r[0xba9] == 2 &&
+ (r[0xbcf] & 127) && !r[0x1f0c] && !r[0xbdb] &&
+ (action <= 8 || action == 0x10 || action == 0x12 || action == 0x14 || action == 0x20);
+ if (!playable) { MmxZeroCancel(r); return; }
+ if (state.cooldown) --state.cooldown;
+ bool held = (r[0xbdf] & 64) != 0, pressed = (r[0xbe3] & 64) != 0;
+ if (state.slash) {
+ if (++state.slash > 46) { MmxZeroCancel(r); return; }
+ } else if (state.combo && pressed && !state.cooldown) {
+ if (state.combo == 1) {
+ unsigned d = free_projectile(r);
+ if (d && !r[0x1f0d]) {
+ /* X1 rejects a second charged shot while $0C25 is nonzero.
+ * Allocate the same native class directly; its initializer owns
+ * position, velocity, graphics, lifetime and the charge-shot count. */
+ clear_charge(r); memset(r + d, 0, 64);
+ r[d] = 1; r[d + 10] = 3; ++r[0xbdd]; r[0x1f0d] = 4;
+ state.combo = 2; state.cooldown = 18;
+ }
+ } else {
+ unsigned d = free_projectile(r);
+ if (d) {
+ clear_charge(r); state.combo = 0; state.slash = 1;
+ state.air = !(r[0xbd3] & 4); state.facing = r[0xc11] & 64;
+ state.hit_slots = 0; state.projectile = (uint16_t)d;
+ memset(r + d, 0, 64); r[d] = 1; r[d + 1] = 2;
+ r[d + 10] = 3; /* X1's unarmored full buster damage class. */
+ r[d + 0x11] = r[0xbb9]; putword(r + d + 0x3e, 0x5a53);
+ ++r[0xbdd];
+ }
+ }
+ } else if (!state.combo) {
+ if (held) { if (state.charge < 180) ++state.charge; }
+ else {
+ if (state.charge == 180 && free_projectile(r) && !r[0x1f0d]) {
+ clear_charge(r); r[0xc01] = 8; state.combo = 1; state.cooldown = 18;
+ }
+ state.charge = 0;
+ }
+ }
+ if (state.combo) {
+ /* Stored attacks must not start another native charge or pellet. */
+ r[0xbdf] &= (uint8_t)~64; r[0xbe3] &= (uint8_t)~64;
+ }
+ if (state.slash) {
+ r[0xbde] = 0; r[0xbdf] &= 128; r[0xbe2] = r[0xbe3] = 0;
+ r[0xc11] = state.facing; clear_charge(r);
+ r[0xbc2] = r[0xbc3] = 0; /* Stop horizontal velocity, preserve gravity. */
+ if (!state.air && action != 0) { r[0xbaa] = 0; r[0xbab] = 0; }
+ unsigned d = state.projectile;
+ if (own_projectile(r, d)) {
+ putword(r + d + 5, word(r + 0xbad));
+ putword(r + d + 8, word(r + 0xbb0));
+ unsigned age = state.slash >= 7 ? state.slash - 7 : 99;
+ unsigned phase = age < 12 ? age / 3 : 4;
+ putword(r + d + 0x20, age < 19 ? 0xffb0 + (state.air ? 20 : 0) + phase * 4 : 0);
+ r[d + 0x30] = 0;
+ }
+ }
+}
+unsigned MmxZeroWeaponTick(uint8_t r[0x20000], unsigned d, unsigned active) {
+ if (!own_projectile(r, d)) return active;
+ if (!poses || d != state.projectile || !state.slash) {
+ memset(r + d, 0, 64); if (r[0xbdd]) --r[0xbdd];
+ }
+ return 0; /* Our transient melee object has no native projectile update. */
+}
+unsigned MmxZeroDamage(uint8_t r[0x20000], unsigned enemy, unsigned projectile, unsigned original) {
+ if (!poses || !own_projectile(r, projectile) || projectile != state.projectile ||
+ enemy < 0xe68 || enemy >= 0x1228 || (enemy & 63) != 0x28 || !original || (original & 128)) return original;
+ unsigned bit = 1u << ((enemy - 0xe68) / 64);
+ if (state.hit_slots & bit) return 0;
+ state.hit_slots |= (uint16_t)bit;
+ return 16;
+}
+unsigned MmxZeroHitbox(const uint8_t r[0x20000], unsigned enemy, unsigned projectile, unsigned original) {
+ if (poses && own_projectile(r, projectile) && projectile == state.projectile &&
+ enemy >= 0xe68 && enemy < 0x1228 && (enemy & 63) == 0x28 &&
+ (state.hit_slots & (1u << ((enemy - 0xe68) / 64)))) return 0;
+ return original;
+}
diff --git a/src/mmx_zero.h b/src/mmx_zero.h
new file mode 100644
index 0000000..388a062
--- /dev/null
+++ b/src/mmx_zero.h
@@ -0,0 +1,28 @@
+#pragma once
+#include
+#include
+#include
+
+enum { MMX_ZERO_WIDTH = 128, MMX_ZERO_HEIGHT = 128, MMX_ZERO_POSES = 152 };
+typedef struct MmxZeroState {
+ uint16_t charge, slash, projectile;
+ uint8_t combo, cooldown, air, facing;
+ uint16_t hit_slots;
+} MmxZeroState;
+bool MmxZeroLoad(const char *path);
+void MmxZeroDisable(void);
+bool MmxZeroEnabled(void);
+const uint8_t *MmxZeroPose(const uint8_t ram[0x20000], const MmxZeroState *snapshot);
+const uint8_t *MmxZeroBlade(const MmxZeroState *snapshot);
+const uint16_t *MmxZeroColors(void);
+void MmxZeroSetCollisionRom(uint8_t *rom, size_t size);
+unsigned MmxZeroUpgradeBits(unsigned pc, unsigned original);
+void MmxZeroPlayerTick(uint8_t ram[0x20000]);
+unsigned MmxZeroWeaponTick(uint8_t ram[0x20000], unsigned object, unsigned active);
+unsigned MmxZeroDamage(uint8_t ram[0x20000], unsigned enemy, unsigned projectile, unsigned original);
+unsigned MmxZeroHitbox(const uint8_t ram[0x20000], unsigned enemy, unsigned projectile, unsigned original);
+MmxZeroState MmxZeroGetState(void);
+void MmxZeroSetState(MmxZeroState state);
+void MmxZeroResetState(void);
+void MmxZeroCancel(uint8_t ram[0x20000]);
+void MmxZeroRegisterHooks(void);
diff --git a/src/mods/mmx_zero_plugin.c b/src/mods/mmx_zero_plugin.c
new file mode 100644
index 0000000..5602794
--- /dev/null
+++ b/src/mods/mmx_zero_plugin.c
@@ -0,0 +1,82 @@
+#include "mod_runtime.h"
+#include "host_paths.h"
+#include "recomp_launcher.h"
+#include "snes/interp_bridge.h"
+#include "mmx_renderer.h"
+#include "mmx_zero.h"
+#include
+
+extern uint8_t g_ram[0x20000];
+static void hook(CpuState *cpu, uint32_t pc) {
+ if (!MmxZeroEnabled()) return;
+ switch (pc & 0x7fffff) {
+ 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: cpu->A |= 8; break;
+ case 0x01815c: MmxZeroPlayerTick(g_ram); break;
+ case 0x00d3e7: {
+ unsigned value = MmxZeroWeaponTick(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 0x049e76: {
+ unsigned original = cpu_read8(cpu, cpu->DB, (uint16_t)(0xef37 + cpu->Y));
+ unsigned damage = MmxZeroDamage(g_ram, cpu->D, cpu->X, original);
+ if (damage == original) break;
+ /* Post-SBC: retain the interpreter's instruction timing, but recompute
+ * its value and all arithmetic flags with our damage operand. The HP
+ * store has not happened yet and SEC immediately precedes the SBC. */
+ unsigned hp = g_ram[cpu->D + 0x27] & 127, result;
+ if (cpu->_flag_D) {
+ unsigned complement = damage ^ 255;
+ int decimal = (hp & 15) + (complement & 15) + 1;
+ if (decimal < 16) decimal = (decimal - 6) & (decimal < 6 ? 15 : 31);
+ decimal += (hp & 240) + (complement & 240);
+ cpu->_flag_V = ((hp & 128) == (complement & 128)) && ((complement & 128) != (decimal & 128));
+ if (decimal < 256) decimal -= 96;
+ cpu->_flag_C = decimal > 255; result = (unsigned)decimal & 255;
+ } else {
+ result = (hp - damage) & 255;
+ cpu->_flag_C = hp >= damage;
+ cpu->_flag_V = ((hp ^ damage) & (hp ^ result) & 128) != 0;
+ }
+ cpu->A = (cpu->A & 0xff00) | result;
+ cpu->_flag_Z = result == 0; cpu->_flag_N = (result & 128) != 0;
+ cpu->P = (cpu->P & ~0xc3) | cpu->_flag_C | (cpu->_flag_Z ? 2 : 0) |
+ (cpu->_flag_V ? 64 : 0) | (cpu->_flag_N ? 128 : 0);
+ break;
+ }
+ case 0x049c19:
+ cpu->Y = (uint16_t)MmxZeroHitbox(g_ram, cpu->D, cpu->X, cpu->Y);
+ cpu->_flag_Z = cpu->Y == 0; cpu->_flag_N = (cpu->Y & 0x8000) != 0;
+ cpu->P = (cpu->P & ~0x82) | (cpu->_flag_Z ? 2 : 0) | (cpu->_flag_N ? 128 : 0);
+ break;
+ }
+}
+void MmxZeroRegisterHooks(void) {
+ const unsigned pcs[] = {0x00d6a7, 0x00d76a, 0x01971f, 0x019796, 0x0198ff,
+ 0x01815c, 0x00d3e7, 0x049e76, 0x049c19};
+ for (unsigned i = 0; i < sizeof(pcs) / sizeof(pcs[0]); ++i)
+ interp_bridge_set_pre_opcode_hook(pcs[i], hook);
+}
+static void activate(void) {
+ char path[4096];
+ const RecompLauncherCModProvider *provider = snes_mod_runtime_launcher_provider_c();
+ RecompLauncherCModResource resource = {0};
+ if (provider && provider->feature_resource_get &&
+ provider->feature_resource_get(provider->ctx, "megaman-x.character.zero", "zero", 0, &resource) && resource.path[0])
+ snprintf(path, sizeof(path), "%s", resource.path);
+ else if (!snesrecomp_exe_dir_path("zero-x3.bin", path, sizeof(path))) return;
+ if (!MmxZeroLoad(path)) {
+ fprintf(stderr, "[mmx-zero] Cannot load extracted Zero assets: %s\n", path); return;
+ }
+ MmxZeroRegisterHooks();
+ fprintf(stderr, "[mmx-zero] Experimental original-size Zero enabled\n");
+}
+static void reset(void) { MmxZeroCancel(g_ram); MmxZeroDisable(); }
+SNES_MOD_CONSTRUCTOR(mmx_register_zero_plugin) {
+ (void)snes_mod_register_reset_callback(reset);
+ (void)snes_mod_register_activation_plugin("megaman-x.zero", activate);
+}
diff --git a/tests/mmx_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c
index 98bb0ba..dd2f5fd 100644
--- a/tests/mmx_adaptive_state_test.c
+++ b/tests/mmx_adaptive_state_test.c
@@ -26,6 +26,106 @@ static void same(const void *a, size_t an, const void *b, size_t bn, const char
}
check(an && an == bn && !memcmp(a, b, an), what);
}
+static void zero_replay(int n) { for (int i=0;i 7 && state.projectile && !state.combo, "saber reaches active frames");
+ check(g_ram[0x1f99] == 0, "combo does not grant X1 upgrades");
+ const char *capture = getenv("MMX_ZERO_TEST_CAPTURE");
+ zero_capture(capture, "");
+ size_t n = RtlSaveSnapshotToMemory(start,cap);
+ zero_replay(10); size_t en = RtlSaveSnapshotToMemory(expected,cap);
+ check(RtlLoadSnapshotFromMemory(start,n), "mid-saber memory load");
+ size_t an = RtlSaveSnapshotToMemory(actual,cap);
+ same(start,n,actual,an,"mid-saber complete immediate restore");
+ zero_replay(10); an = RtlSaveSnapshotToMemory(actual,cap);
+ same(expected,en,actual,an,"mid-saber deterministic replay");
+ check(RtlLoadSnapshotFromMemory(start,n), "restore before rollback probe");
+ size_t rn = RtlRollbackSaveToMemory(start,cap);
+ zero_replay(8); en = RtlRollbackSaveToMemory(expected,cap);
+ check(RtlRollbackLoadFromMemory(start,rn), "mid-saber rollback load");
+ zero_replay(8); an = RtlRollbackSaveToMemory(actual,cap);
+ same(expected,en,actual,an,"mid-saber rollback replay");
+ check(RtlRollbackLoadFromMemory(start,rn), "restore before rewind probe");
+ snes_rewind_configure();
+ for (int i=0;i<12;++i) { frame(0); snes_rewind_note_frame(); }
+ en = RtlSaveSnapshotToMemory(expected,cap);
+ for (int i=0;i<6;++i) { frame(0); snes_rewind_note_frame(); }
+ check(snes_rewind_open(), "mid-saber rewind opens");
+ rewind_six_frames(); snes_rewind_commit();
+ an = RtlSaveSnapshotToMemory(actual,cap);
+ same(expected,en,actual,an,"rewind restores complete mid-saber state");
+ snes_rewind_shutdown();
+ zero_replay(60);
+ check(!MmxZeroGetState().slash && !MmxZeroGetState().projectile && !g_ram[0xbdd], "saber and projectile counts retire");
+ check(RtlLoadSnapshot(fixture), "restore for dash probe");
+ unsigned x = g_ram[0xbad] | g_ram[0xbae] << 8;
+ for (int i=0;i<8;++i) frame(SNES_PAD_A | SNES_PAD_RIGHT);
+ unsigned dx = (g_ram[0xbad] | g_ram[0xbae] << 8) - x;
+ check(dx >= 24 && !g_ram[0x1f99], "unupgraded dash moves at dash speed");
+ zero_capture(capture, ".dash.cap");
+ check(RtlLoadSnapshot(fixture), "restore for aerial saber probe");
+ unsigned floor = g_ram[0xbb0] | g_ram[0xbb1] << 8;
+ MmxZeroSetState((MmxZeroState){.combo=2});
+ for (int i=0;i<6;++i) frame(SNES_PAD_B);
+ frame(SNES_PAD_B | SNES_PAD_Y); zero_replay(12);
+ check(MmxZeroGetState().air && MmxZeroGetState().slash > 7 &&
+ (g_ram[0xbb0] | g_ram[0xbb1] << 8) < floor, "aerial saber retains jump and gravity");
+ zero_capture(capture, ".air.cap");
+ zero_replay(100);
+ check((g_ram[0xbb0] | g_ram[0xbb1] << 8) == floor && !MmxZeroGetState().slash,
+ "original-size Zero lands on the same floor");
+ check(RtlLoadSnapshot(fixture), "restore for special weapon probe");
+ /* Fixture inventory grant only: production code never grants weapons. */
+ g_ram[0x1f87] = 0; g_ram[0x1f88] = 0xdc;
+ for (int i=0;i<6;++i) frame(SNES_PAD_R);
+ zero_replay(1);
+ check(g_ram[0xbdb] == 2, "native weapon switch selects Homing Torpedo");
+ frame(SNES_PAD_Y); zero_replay(8);
+ check(zero_projectiles(7) && g_ram[0x1f88] != 0xdc, "normal Torpedo spawns and spends energy");
+ for (int i=0;i<181;++i) frame(SNES_PAD_Y);
+ frame(0); zero_replay(8);
+ check(!zero_projectiles(0x10) && !g_ram[0x1f99], "charged special remains unavailable without arms");
+ zero_replay(60); g_ram[0x1f99] = 2;
+ for (int i=0;i<181;++i) frame(SNES_PAD_Y);
+ frame(0); zero_replay(8);
+ check(zero_projectiles(0x10) == 5, "arm upgrade permits five charged Torpedo projectiles");
+ zero_capture(capture, ".torpedo.cap");
+ MmxZeroDisable(); MmxBeforeFrame(); MmxPrepareFrame(1280,720,&w,&h);
+ check(!g_mmx_custom_renderer && !MmxZeroEnabled(), "disabling returns to stock presentation");
+ puts("MMX ZERO RUNTIME CHECKS PASSED");
+}
int main(int argc, char **argv) {
check(argc == 2 || argc == 3, "ROM supplied");
SDL_SetMainReady();
@@ -111,6 +211,11 @@ int main(int argc, char **argv) {
MkDir("saves");
size_t cap = 2u * 1024u * 1024u;
uint8 *start = malloc(cap), *expected = malloc(cap), *actual = malloc(cap);
+ const char *zero_assets = getenv("MMX_ZERO_TEST_ASSETS");
+ if (zero_assets) {
+ zero_state_checks(zero_assets, getenv("MMX_ZERO_TEST_FIXTURE"), start, expected, actual, cap);
+ return 0;
+ }
if (argc == 3) {
check(RtlLoadSnapshot("saves/save11.sav"), "file loads in new process");
replay(10);
@@ -158,7 +263,7 @@ int main(int argc, char **argv) {
size_t n = RtlSaveSnapshotToMemory(start, cap);
for (int i = 0; i < 6; ++i) { frame(SNES_PAD_RIGHT); snes_rewind_note_frame(); }
check(snes_rewind_open(), "rewind opens");
- snes_rewind_step(-1); /* default interval is six simulated frames */
+ rewind_six_frames();
snes_rewind_commit();
size_t an = RtlSaveSnapshotToMemory(actual, cap);
same(start, n, actual, an, "rewind restores selected frame");
diff --git a/tests/mmx_zero_test.c b/tests/mmx_zero_test.c
new file mode 100644
index 0000000..7c972f2
--- /dev/null
+++ b/tests/mmx_zero_test.c
@@ -0,0 +1,89 @@
+#include "mmx_zero.h"
+#include
+#include
+#include
+#include
+
+static uint8_t ram[0x20000], before[0x20000], rom[0x180000], clean[0x180000];
+static void asset(const char *path) {
+ FILE *f = fopen(path, "wb"); assert(f);
+ const uint8_t header[] = {'M','M','X','Z','E','R','O','3',128,0,128,0,64,0,64,0,117,0,35,0};
+ uint8_t page[16384] = {0};
+ assert(fwrite(header, sizeof(header), 1, f) == 1);
+ assert(fwrite(page, 256, 1, f) == 1);
+ uint8_t bounds[40];
+ for (unsigned i = 0; i < 40; i += 4) { bounds[i] = 0; bounds[i+1] = 248; bounds[i+2] = 12; bounds[i+3] = 10; }
+ assert(fwrite(bounds, sizeof(bounds), 1, f) == 1);
+ for (unsigned i = 0; i < MMX_ZERO_POSES; ++i) assert(fwrite(page, sizeof(page), 1, f) == 1);
+ assert(!fclose(f));
+}
+static void player(void) {
+ memset(ram, 0, sizeof(ram));
+ ram[0xd1] = 2; ram[0xd2] = 4; ram[0xba9] = 2; ram[0xbcf] = 16;
+ ram[0xbd3] = 4; ram[0xc11] = 64; ram[0xbb9] = 0x62;
+ MmxZeroCancel(ram);
+}
+static void tick(unsigned held, unsigned pressed) {
+ ram[0xbdf] = held; ram[0xbe3] = pressed; MmxZeroPlayerTick(ram);
+}
+int main(void) {
+ player(); memcpy(before, ram, sizeof(ram));
+ tick(0,0); assert(!memcmp(before, ram, sizeof(ram)));
+ assert(MmxZeroUpgradeBits(0x81971c, 0) == 0);
+ asset("zero-test.bin"); assert(MmxZeroLoad("zero-test.bin"));
+ assert(MmxZeroUpgradeBits(0x81971c, 2) == 10);
+ assert(MmxZeroUpgradeBits(0x8197da, 0) == 0); /* Special charge stays upgrade-gated. */
+ const uint8_t normal[] = {0,255,6,14,0,0,255,7,17,8};
+ const uint8_t dash[] = {0,5,6,8,0,0,255,9,17,8};
+ memcpy(rom+0x32552,normal,10); memcpy(rom+0x33b38,dash,10);
+ memset(rom+0x37fb0,255,40); memcpy(clean,rom,sizeof(rom));
+ MmxZeroSetCollisionRom(rom,sizeof(rom));
+ assert(rom[0x32555] == 18 && rom[0x3255a] == 21);
+ assert(rom[0x33b3b] == 11 && rom[0x37fb2] == 12);
+ assert(!MmxZeroLoad("missing-zero-test.bin") && MmxZeroEnabled());
+ FILE *f = fopen("zero-test-bad.bin","wb"); assert(f); fputs("MMXZERO3",f); fclose(f);
+ assert(!MmxZeroLoad("zero-test-bad.bin") && MmxZeroEnabled());
+ player();
+ for (int i=0;i<179;++i) tick(64,i==0?64:0);
+ tick(0,0); assert(!MmxZeroGetState().combo); /* Almost charged cannot chain. */
+ for (int i=0;i<180;++i) tick(64,i==0?64:0);
+ tick(0,0); assert(MmxZeroGetState().combo == 1 && ram[0xc01] == 8);
+ assert(ram[0x1f99] == 0); /* Dash/charge never grant equipment. */
+ ram[0xc01] = 0;
+ /* Native first shot is live. Zero must allocate another slot despite X1's lock. */
+ ram[0x1228] = 1; ram[0x1229] = 2; ram[0x1232] = 3; ram[0xbdd] = ram[0xc25] = 1;
+ for(int i=0;i<20;++i) tick(0,0);
+ tick(64,64);
+ assert(MmxZeroGetState().combo == 2 && ram[0x1268] == 1 && ram[0x1272] == 3 && ram[0xbdd] == 2);
+ assert(ram[0xc25] == 1); /* Native initializer, not us, increments that counter. */
+ for(int i=0;i<20;++i) tick(0,0);
+ tick(64,64); MmxZeroState s = MmxZeroGetState();
+ assert(s.slash == 1 && s.projectile == 0x12a8 && !s.combo);
+ assert(!ram[s.projectile+0x20] && !ram[s.projectile+0x21]); /* Windup cannot hit. */
+ assert(!MmxZeroWeaponTick(ram,s.projectile,1));
+ for(int i=0;i<6;++i) tick(0,0);
+ assert(ram[s.projectile+0x20] == 0xb0 && ram[s.projectile+0x21] == 0xff);
+ assert(MmxZeroDamage(ram,0xe68,s.projectile,0) == 0);
+ assert(MmxZeroDamage(ram,0xe68,s.projectile,128) == 128);
+ assert(MmxZeroDamage(ram,0xe68,s.projectile,3) == 16);
+ assert(MmxZeroHitbox(ram,0xe68,s.projectile,0xffb0) == 0);
+ assert(MmxZeroHitbox(ram,0xea8,s.projectile,0xffb0) == 0xffb0);
+ assert(MmxZeroDamage(ram,0xe68,s.projectile,3) == 0);
+ assert(MmxZeroDamage(ram,0xea8,s.projectile,3) == 16);
+ s = MmxZeroGetState(); MmxZeroResetState(); MmxZeroSetState(s);
+ assert(MmxZeroGetState().hit_slots == s.hit_slots && MmxZeroGetState().slash == s.slash);
+ MmxZeroState invalid = s; invalid.air = 2; MmxZeroSetState(invalid);
+ assert(!MmxZeroGetState().slash); MmxZeroSetState(s);
+ /* Changing weapon cancels only our melee slot; native shots remain alive. */
+ ram[0xbdb] = 2; tick(0,0);
+ assert(!MmxZeroGetState().slash && !ram[s.projectile] && ram[0x1228] && ram[0x1268] && ram[0xbdd] == 2);
+ player(); s = (MmxZeroState){.combo=2}; MmxZeroSetState(s);
+ for(unsigned d=0x1228;d<0x1428;d+=64) ram[d] = 1;
+ tick(64,64); assert(MmxZeroGetState().combo == 2 && !MmxZeroGetState().slash);
+ ram[0xbaa] = 0x0e; tick(0,0); assert(!MmxZeroGetState().combo);
+ MmxZeroDisable(); MmxZeroSetCollisionRom(rom,sizeof(rom));
+ assert(!memcmp(rom,clean,sizeof(rom)));
+ remove("zero-test.bin"); remove("zero-test-bad.bin");
+ puts("Zero: asset validation, collision restoration, combo, damage, cancellation and state tests passed");
+ return 0;
+}
diff --git a/tools/apply_zero_hooks.py b/tools/apply_zero_hooks.py
new file mode 100644
index 0000000..052243b
--- /dev/null
+++ b/tools/apply_zero_hooks.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+"""Insert bounded, disabled-by-default Zero hooks in regenerated X1 code."""
+import argparse
+from pathlib import Path
+import re
+
+MARKER = '/*MMX-ZERO*/'
+PCS = {0x81971c, 0x819793, 0x8198fc}
+REQUIRED = PCS | {0x81815c, 0x00d3e5, 0x849e73, 0x849c16}
+
+
+def apply(text):
+ lines = [line for line in text.splitlines(keepends=True) if MARKER not in line]
+ output, found, pc = [], set(), 0
+ for line in lines:
+ block = re.search(r'cpu_trace_block\(cpu, 0x([0-9A-Fa-f]+)\);', line)
+ if block:
+ pc = int(block[1], 16)
+ output.append(line)
+ if pc == 0x849c16:
+ load = re.search(r'uint16 (_v\d+) = cpu_read16\(cpu,', line) if '0x0020' in line and 'cpu->X' in line else None
+ if load:
+ output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern unsigned MmxZeroHitbox(const uint8_t *, unsigned, unsigned, unsigned); {load[1]} = (uint16)MmxZeroHitbox(g_ram, cpu->D, cpu->X, {load[1]}); }}\n')
+ found.add(pc)
+ if pc == 0x81815c and 'cpu->coprocessor_master_cycles = cpu->master_cycles;' in line:
+ output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern void MmxZeroPlayerTick(uint8_t *); MmxZeroPlayerTick(g_ram); }}\n')
+ found.add(pc)
+ if pc == 0x00d3e5:
+ load = re.search(r'uint8 (_v\d+) = cpu_read8\(cpu, 0x00, \(uint16\)\(cpu->D \+ 0x0000\)\);', line)
+ if load:
+ output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern unsigned MmxZeroWeaponTick(uint8_t *, unsigned, unsigned); {load[1]} = (uint8)MmxZeroWeaponTick(g_ram, cpu->D, {load[1]}); }}\n')
+ found.add(pc)
+ # The ordinary damage table still decides immunity and reflection.
+ # Override only the final subtraction in the positive-damage path.
+ if pc == 0x849e6e and 'uint8 ' in line and 'cpu_read8' in line and '0xef37' in line:
+ variable = re.search(r'uint8 (_v\d+)', line)[1]
+ output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern unsigned MmxZeroDamage(uint8_t *, unsigned, unsigned, unsigned); {variable} = (uint8)MmxZeroDamage(g_ram, cpu->D, cpu->X, {variable}); }}\n')
+ found.add(0x849e73)
+ 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')
+ found.add(pc)
+ return ''.join(output), found
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--gen-dir', type=Path, required=True)
+ args = parser.parse_args()
+ found = set()
+ for path in args.gen_dir.glob('*.c'):
+ text = path.read_text(encoding='utf-8')
+ if not any(f'0x{pc:06X}' in text for pc in REQUIRED | {0x849e6e}):
+ continue
+ updated, sites = apply(text)
+ found |= sites
+ if updated != text:
+ path.write_text(updated, encoding='utf-8', newline='\n')
+ if found != REQUIRED:
+ raise SystemExit(f'Missing Zero hooks: {REQUIRED - found}')
+ print(f'MMX Zero: {len(found)} capability/combat sites verified')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/extract_zero.py b/tools/extract_zero.py
new file mode 100644
index 0000000..aed5d17
--- /dev/null
+++ b/tools/extract_zero.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""Extract vanilla X3 Zero's original-size poses from a user-supplied USA ROM.
+
+No ROM data is embedded in this tool. Tables were verified against the USA ROM:
+04:A63C (body selection), 04:BCA2 (graphics transfers), 01:805B (palettes).
+The output is a local, ROM-derived cache; do not distribute it.
+"""
+import argparse
+import hashlib
+from pathlib import Path
+import struct
+
+SHA256 = '65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7'
+WIDTH, HEIGHT, ORIGIN_X, ORIGIN_Y = 128, 128, 64, 64
+GROUPS = ((0x4a, 117, 0x85d6a8), (0x4b, 21, 0x85db47), (0x50, 14, 0x85e6e0))
+
+
+class Rom:
+ def __init__(self, path):
+ self.data = Path(path).read_bytes()
+ if len(self.data) % 32768 == 512:
+ self.data = self.data[512:]
+ if hashlib.sha256(self.data).hexdigest() != SHA256:
+ raise ValueError('Expected the original Mega Man X3 USA ROM')
+
+ def read(self, address, length):
+ if address & 0xffff < 0x8000:
+ raise ValueError(f'Not a ROM address: {address:06x}')
+ offset = ((address >> 16) & 127) * 32768 + (address & 32767)
+ result = self.data[offset:offset + length]
+ if len(result) != length:
+ raise ValueError(f'ROM read out of bounds: {address:06x}')
+ return result
+
+ def integer(self, address, length=2):
+ return int.from_bytes(self.read(address, length), 'little')
+
+
+def palette(rom):
+ colors = [0] * 256
+ for key in (0xd0, 0xd2):
+ address = 0x860000 | rom.integer(0x868180 + key)
+ for _ in range(32):
+ count, source, dest = struct.unpack(' 256:
+ raise ValueError('Palette transfer exceeds CGRAM')
+ colors[dest:dest + count] = struct.unpack('<' + 'H' * count, rom.read(0x8c0000 | source, count * 2))
+ address += 4
+ else:
+ raise ValueError('Unterminated palette list')
+ # The common weapon palette used by the saber ($81:8D1E, OBJ palette 3).
+ colors[176:192] = struct.unpack('<16H', rom.read(0x8cb5a0, 32))
+ return colors[128:]
+
+
+def frame(rom, group, number, dma, tiles=None):
+ # Several eye/hand poses have no transfer and inherit the previous CHR.
+ if tiles is None:
+ tiles = bytearray(8192)
+ address = (dma & 0xff0000) | ((dma + rom.integer(dma + number * 2)) & 0xffff)
+ for _ in range(32):
+ count = rom.integer(address, 1)
+ if not count:
+ break
+ source = rom.integer(address + 1, 3)
+ target = rom.integer(address + 4)
+ offset = ((target & 0x7fff) - 0x6000) * 2
+ if not 0 <= offset <= len(tiles) - count * 16:
+ raise ValueError(f'Invalid tile transfer {group:02x}/{number:02x}')
+ tiles[offset:offset + count * 16] = rom.read(source, count * 16)
+ if target & 0x8000:
+ break
+ address += 6
+ else:
+ raise ValueError('Unterminated tile transfer list')
+ table = rom.integer(0x8d8000 + group * 3, 3)
+ address = rom.integer(table + number * 3, 3)
+ count = rom.integer(address, 1)
+ if count > 64:
+ raise ValueError('Invalid sprite piece count')
+ pixels = bytearray(WIDTH * HEIGHT)
+ pieces = rom.read(address + 1, count * 4)
+ for piece in reversed(range(count)):
+ flags, x, y, tile = struct.unpack_from('> 4) + ty // 8) & 15) * 16 + ((tile + tx // 8) & 15)
+ bits = t * 32 + (ty & 7) * 2
+ shift = 7 - (tx & 7)
+ color = sum(((tiles[bits + (p // 2) * 16 + p % 2] >> shift) & 1) << p for p in range(4))
+ if color:
+ px, py = ORIGIN_X + x + dx, ORIGIN_Y + y + dy
+ if not (0 <= px < WIDTH and 0 <= py < HEIGHT):
+ raise ValueError(f'Pose {group:02x}/{number:02x} exceeds extraction canvas at {px},{py}')
+ base = 3 if group == 0x50 else 1
+ pixels[py * WIDTH + px] = color + (((flags >> 1) & 7) | base) * 16
+ return pixels
+
+
+def extract(path):
+ rom = Rom(path)
+ colors = palette(rom)
+ poses = []
+ for group, count, dma in GROUPS:
+ tiles = bytearray(8192)
+ poses.extend(frame(rom, group, n, dma, tiles) for n in range(count))
+ bounds = bytearray(rom.read(0x86b837, 40))
+ for i in range(1, 40, 4):
+ bounds[i] = (bounds[i] - 8) & 255
+ header = struct.pack('<8s6H', b'MMXZERO3', WIDTH, HEIGHT, ORIGIN_X, ORIGIN_Y, 117, 35)
+ return header + struct.pack('<128H', *colors) + bounds + b''.join(poses), colors, poses
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('rom', type=Path)
+ parser.add_argument('output', type=Path)
+ parser.add_argument('--sheet', type=Path, help='Optional contact sheet (requires Pillow)')
+ args = parser.parse_args()
+ data, colors, poses = extract(args.rom)
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_bytes(data)
+ if args.sheet:
+ from PIL import Image, ImageDraw
+ sheet = Image.new('RGB', (8 * WIDTH, ((len(poses) + 7) // 8) * HEIGHT), '#243040')
+ draw = ImageDraw.Draw(sheet)
+ for i, pixels in enumerate(poses):
+ x, y = i % 8 * WIDTH, i // 8 * HEIGHT
+ for p, color in enumerate(pixels):
+ if color:
+ c = colors[color]
+ sheet.putpixel((x + p % WIDTH, y + p // WIDTH), tuple(((c >> s) & 31) * 255 // 31 for s in (0, 5, 10)))
+ group, number = (0x4a, i) if i < 117 else (0x4b, i - 117) if i < 138 else (0x50, i - 138)
+ draw.text((x + 3, y + 3), f'{group:02X}:{number:02X}', fill='white')
+ sheet.save(args.sheet)
+ print(f'Extracted {len(poses)} original-size Zero poses to {args.output} ({len(data)} bytes)')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/mmx_render_capture.c b/tools/mmx_render_capture.c
index 0357319..257ef41 100644
--- a/tools/mmx_render_capture.c
+++ b/tools/mmx_render_capture.c
@@ -1,4 +1,5 @@
#include "mmx_renderer.h"
+#include "mmx_zero.h"
#include
static bool bmp(const char *path, const uint32_t *pixels, int width) {
@@ -12,9 +13,10 @@ static bool bmp(const char *path, const uint32_t *pixels, int width) {
return fclose(f) == 0 && ok;
}
int main(int argc, char **argv) {
- if (argc != 6) {
- fprintf(stderr, "usage: mmx_render_capture capture rom aspect hud(0|1) output.bmp\n"); return 2;
+ if (argc != 6 && argc != 7) {
+ fprintf(stderr, "usage: mmx_render_capture capture rom aspect hud(0|1) output.bmp [zero-assets.bin]\n"); return 2;
}
+ if (argc == 7 && !MmxZeroLoad(argv[6])) return 2;
FILE *f = fopen(argv[2], "rb");
if (!f || fseek(f, 0, SEEK_END)) return 2;
long size = ftell(f); rewind(f);
diff --git a/tools/test_renderer_captures.py b/tools/test_renderer_captures.py
index 3e3f0f8..00e3358 100644
--- a/tools/test_renderer_captures.py
+++ b/tools/test_renderer_captures.py
@@ -19,7 +19,7 @@ def sprite_matches(data):
ram_offset = 12 + 224 * 66656
pieces_offset = ram_offset + 0x20000 + 256 * 224 * 4
version = struct.unpack_from('= 2 else 8
count = struct.unpack_from('= 2:
expanded = pieces_offset + 2048 * stride + 10
expanded_count = struct.unpack_from('
Date: Mon, 28 Sep 2026 19:34:21 -0700
Subject: [PATCH 02/16] Show a Zero badge on the player health bar
---
docs/zero-spike.md | 14 ++++++++++++++
src/mmx_renderer.c | 17 ++++++++++++-----
src/mmx_zero.c | 21 +++++++++++++++++++++
src/mmx_zero.h | 2 ++
4 files changed, 49 insertions(+), 5 deletions(-)
diff --git a/docs/zero-spike.md b/docs/zero-spike.md
index 32bd566..6338f7c 100644
--- a/docs/zero-spike.md
+++ b/docs/zero-spike.md
@@ -143,6 +143,20 @@ presented frame for visual proof.
## Remaining work for the full port
+Owner playtest follow-up: projectile origins do not yet line up with Zero's arm,
+and some hurt/invulnerability flashes still show X. These confirmed issues are
+tracked in `beads-8wg.1.29`. The health-bar badge now uses a red Z while Zero is
+active, including when the player body is hidden; the native frame, health meter
+and weapon icon are retained (`beads-8wg.1.28`). Native and widescreen capture
+comparisons change only 50 badge pixels, and disabled-mod captures are identical.
+The updated local executable is `build-zero/hud-update/MegaManXSNESRecomp.exe`;
+it was built separately to leave the owner's running session intact.
+
+An optional Select-button X/Zero swap is a longer-term direction, tracked in
+`beads-8wg.1.30`. That feature will need an explicit active-character state
+separate from loaded assets, with the body, abilities, collision and HUD changing
+together. Swapping is not implemented by this HUD update.
+
1. Audit the complete animation/state mapping: wall slide/jump, ladders, damage,
death, teleport, capsules, ride armor and scripted player poses. The prototype
reuses the shared early-game pose numbering; unsupported indices fall back to
diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c
index 3cdf816..435350a 100644
--- a/src/mmx_renderer.c
+++ b/src/mmx_renderer.c
@@ -517,7 +517,7 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x,
static void sprite(const Ppu *p, const Raster *r, int x, int sy, unsigned attr, int size,
int y, MmxRenderView view, uint16_t *out, bool margins_only,
const MmxSpriteAsset *asset, unsigned raw_tile, int *object_color,
- bool full_coordinates) {
+ bool full_coordinates, bool zero_icon) {
int row = full_coordinates ? y - sy : (y - sy) & 255;
if (row < 0 || row >= size) return;
if (attr & 0x8000) row = size - 1 - row;
@@ -538,9 +538,12 @@ static void sprite(const Ppu *p, const Raster *r, int x, int sy, unsigned attr,
pixel = ((bits[0] >> shift) & 1) | (((bits[1] >> shift) & 1) << 1) |
(((bits[16] >> shift) & 1) << 2) | (((bits[17] >> shift) & 1) << 3);
} else pixel = tile_pixel(r->vram, base + tile * 16, cx & 7, row & 7, 4);
+ int hud_color = zero_icon ? MmxZeroHudColor(cx, row, r->palette + 128 + ((attr >> 9) & 7) * 16) : -1;
+ if (hud_color >= 0) pixel = 1;
if (pixel) {
out[dest] = (uint16_t)(z | pixel);
- object_color[dest] = asset && !asset->live_colors ? asset->colors[pixel] : -1;
+ object_color[dest] = hud_color >= 0 ? hud_color :
+ asset && !asset->live_colors ? asset->colors[pixel] : -1;
if (x + c < 0 || x + c >= 256) ++stats.margin_sprite_pixels;
}
}
@@ -698,7 +701,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
for (int i = (int)waiting_count - 1; i >= 0; --i) {
Piece s = waiting[i];
const MmxSpriteAsset *asset = s.animation == 0x53 ? waiting_zero : NULL;
- sprite(&p, r, s.x, s.y, s.attr, s.size, y, view, objects, true, asset, s.tile, object_colors, true);
+ sprite(&p, r, s.x, s.y, s.attr, s.size, y, view, objects, true, asset, s.tile, object_colors, true, false);
}
bool replaced[128] = {false};
bool zero_drawn = false;
@@ -748,7 +751,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
}
continue;
}
- sprite(&p, r, s.x, s.y, attr, s.size, y, view, objects, !center, asset, s.tile, object_colors, true);
+ sprite(&p, r, s.x, s.y, attr, s.size, y, view, objects, !center, asset, s.tile, object_colors, true, false);
}
int bar_first = -1, bar_count = 0;
if (hud) for (int slot = 16; slot <= 48; ++slot) {
@@ -768,9 +771,13 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
if (x >= 256) x -= 512;
int size = sizes[p.obsel >> 5][(hi >> 1) & 1];
if (x + size <= 0 || x >= 256) continue;
+ /* Only the player's HUD badge: not weapon icons, bosses or actors.
+ * This remains visible while the player blinks or has no body pieces. */
+ bool zero_icon = stage && MmxZeroEnabled() && slot == 0 &&
+ x == 8 && sy == 80 && attr == 0x3486 && size == 16;
bool anchored = hud && sy < 96 && (slot < 16 || (bar_count >= 4 && slot >= bar_first && slot < bar_first + bar_count));
if (anchored) { if (x < 25) x -= view.extra; else if (x >= 216) x += view.extra; }
- sprite(&p, r, x, sy, attr, size, y, view, objects, false, NULL, 0, object_colors, false);
+ sprite(&p, r, x, sy, attr, size, y, view, objects, false, NULL, 0, object_colors, false, zero_icon);
}
for (int sx = 0; sx < view.width; ++sx) {
int x = sx - view.extra;
diff --git a/src/mmx_zero.c b/src/mmx_zero.c
index 2a01c6b..23fe249 100644
--- a/src/mmx_zero.c
+++ b/src/mmx_zero.c
@@ -47,6 +47,27 @@ bool MmxZeroLoad(const char *path) {
return true;
}
const uint16_t *MmxZeroColors(void) { return colors; }
+int MmxZeroHudColor(unsigned x, unsigned y, const uint16_t palette[16]) {
+ /* A red Z in the existing X1 badge. Keep the surrounding bar/frame live;
+ * character identity is resolved here, independently of player visibility. */
+ if (!MmxZeroEnabled() || x < 2 || x > 13 || y < 3 || y > 11) return -1;
+ static const char badge[9][13] = {
+ ".##########.",
+ ".#rrrrrrrr#.",
+ "..#####rr#..",
+ ".....#rr#...",
+ "....#rr#....",
+ "...#rr#.....",
+ "..#rr#####..",
+ ".#rrrrrrrr#.",
+ ".##########.",
+ };
+ switch (badge[y - 3][x - 2]) {
+ case '#': return palette[15];
+ case 'r': return 31 | (5 << 5) | (4 << 10);
+ default: return palette[1];
+ }
+}
static unsigned slash_pose(const MmxZeroState *s) {
/* Vanilla X3 group $4B actions $00/$0E: duration, frame. */
static const uint8_t duration[] = {3,3,1,2,3,3,6,16,3,3,3};
diff --git a/src/mmx_zero.h b/src/mmx_zero.h
index 388a062..9888411 100644
--- a/src/mmx_zero.h
+++ b/src/mmx_zero.h
@@ -15,6 +15,8 @@ bool MmxZeroEnabled(void);
const uint8_t *MmxZeroPose(const uint8_t ram[0x20000], const MmxZeroState *snapshot);
const uint8_t *MmxZeroBlade(const MmxZeroState *snapshot);
const uint16_t *MmxZeroColors(void);
+/* Private BGR555 badge pixel, or -1 to retain the native health-bar frame. */
+int MmxZeroHudColor(unsigned x, unsigned y, const uint16_t palette[16]);
void MmxZeroSetCollisionRom(uint8_t *rom, size_t size);
unsigned MmxZeroUpgradeBits(unsigned pc, unsigned original);
void MmxZeroPlayerTick(uint8_t ram[0x20000]);
From e8624dc84db3a6ca94d95bd6fdf1d638b9bd66bd Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Mon, 28 Sep 2026 20:06:28 -0700
Subject: [PATCH 03/16] Use the original X3 Zero health badge and palette
---
docs/zero-spike.md | 21 +++++++++++++--------
src/mmx_renderer.c | 3 ++-
src/mmx_zero.c | 39 ++++++++++++++++-----------------------
src/mmx_zero.h | 4 ++--
tests/mmx_zero_test.c | 3 ++-
tools/extract_zero.py | 8 ++++++--
6 files changed, 41 insertions(+), 37 deletions(-)
diff --git a/docs/zero-spike.md b/docs/zero-spike.md
index 6338f7c..2e20a65 100644
--- a/docs/zero-spike.md
+++ b/docs/zero-spike.md
@@ -27,8 +27,10 @@ python tools/extract_zero.py ../MegamanX3SNESRecomp/mmx3.sfc build-zero/zero-x3.
The extractor accepts an optional 512-byte copier header and checks the
normalized X3 SHA-256:
`65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7`.
-It extracts 117 body, 21 saber-body and 14 blade poses, palettes and the ground/air
-saber bounds. `--sheet path.png` also writes a labeled contact sheet if Pillow
+It extracts 117 body, 21 saber-body and 14 blade poses, palettes, the ground/air
+saber bounds, and Zero's original X3 health-bar badge and palette. The current
+cache format is `MMXZERO4`; rerun extraction to replace older caches.
+`--sheet path.png` also writes a labeled contact sheet if Pillow
is installed. ROMs, generated code and extracted graphics stay local.
In the launcher's Mods page, enable **Play as Zero (experimental)**. The asset
@@ -145,12 +147,15 @@ presented frame for visual proof.
Owner playtest follow-up: projectile origins do not yet line up with Zero's arm,
and some hurt/invulnerability flashes still show X. These confirmed issues are
-tracked in `beads-8wg.1.29`. The health-bar badge now uses a red Z while Zero is
-active, including when the player body is hidden; the native frame, health meter
-and weapon icon are retained (`beads-8wg.1.28`). Native and widescreen capture
-comparisons change only 50 badge pixels, and disabled-mod captures are identical.
-The updated local executable is `build-zero/hud-update/MegaManXSNESRecomp.exe`;
-it was built separately to leave the owner's running session intact.
+tracked in `beads-8wg.1.29`. Full-port work is on `feat/x3-zero-port`, tracked in
+`beads-8wg.1.31`. The earlier hand-drawn red HUD letter was an approximation and
+has been replaced with the original X3 Zero badge, including its original frame
+and palette. X3's `$84:DCE6` selects DMA list `$5C` at `$86:9AB3`: four tiles from
+`$2C:8D20/$2C:8DE0`, with palette `$8C:B0E0`. An independent ROM-to-compositor
+comparison matches all 218 nontransparent pixels exactly. The badge remains
+visible while Zero blinks; X1's health amount and weapon icon are retained.
+The current development executable is `build-zero/port-work/MegaManXSNESRecomp.exe`;
+the owner's earlier screenshot session remains in `build-zero/hud-update`.
An optional Select-button X/Zero swap is a longer-term direction, tracked in
`beads-8wg.1.30`. That feature will need an explicit active-character state
diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c
index 435350a..c6312c6 100644
--- a/src/mmx_renderer.c
+++ b/src/mmx_renderer.c
@@ -538,8 +538,9 @@ static void sprite(const Ppu *p, const Raster *r, int x, int sy, unsigned attr,
pixel = ((bits[0] >> shift) & 1) | (((bits[1] >> shift) & 1) << 1) |
(((bits[16] >> shift) & 1) << 2) | (((bits[17] >> shift) & 1) << 3);
} else pixel = tile_pixel(r->vram, base + tile * 16, cx & 7, row & 7, 4);
- int hud_color = zero_icon ? MmxZeroHudColor(cx, row, r->palette + 128 + ((attr >> 9) & 7) * 16) : -1;
+ int hud_color = zero_icon ? MmxZeroHudColor(cx, row) : -1;
if (hud_color >= 0) pixel = 1;
+ else if (hud_color == -2) pixel = 0;
if (pixel) {
out[dest] = (uint16_t)(z | pixel);
object_color[dest] = hud_color >= 0 ? hud_color :
diff --git a/src/mmx_zero.c b/src/mmx_zero.c
index 23fe249..6802b97 100644
--- a/src/mmx_zero.c
+++ b/src/mmx_zero.c
@@ -6,6 +6,8 @@
static uint8_t *poses;
static uint16_t colors[128];
static uint8_t saber_bounds[40];
+static uint8_t hud_tiles[128];
+static uint16_t hud_colors[16];
static MmxZeroState state;
static unsigned word(const uint8_t *p) { return p[0] | p[1] << 8; }
static void putword(uint8_t *p, unsigned v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); }
@@ -26,15 +28,16 @@ void MmxZeroDisable(void) {
bool MmxZeroLoad(const char *path) {
FILE *f = path ? fopen(path, "rb") : NULL;
if (!f) return false;
- uint8_t header[20], palette[256], bounds[40];
+ uint8_t header[20], palette[256], bounds[40], hud[160];
size_t size = (size_t)MMX_ZERO_POSES * MMX_ZERO_WIDTH * MMX_ZERO_HEIGHT;
uint8_t *data = NULL;
bool ok = fread(header, 1, sizeof(header), f) == sizeof(header) &&
- !memcmp(header, "MMXZERO3", 8) && word(header + 8) == MMX_ZERO_WIDTH &&
+ !memcmp(header, "MMXZERO4", 8) && word(header + 8) == MMX_ZERO_WIDTH &&
word(header + 10) == MMX_ZERO_HEIGHT && word(header + 12) == 64 &&
word(header + 14) == 64 && word(header + 16) == 117 && word(header + 18) == 35 &&
fread(palette, 1, sizeof(palette), f) == sizeof(palette) &&
- fread(bounds, 1, sizeof(bounds), f) == sizeof(bounds);
+ fread(bounds, 1, sizeof(bounds), f) == sizeof(bounds) &&
+ fread(hud, 1, sizeof(hud), f) == sizeof(hud);
if (ok) { data = malloc(size); ok = data && fread(data, 1, size, f) == size && fgetc(f) == EOF; }
fclose(f);
if (ok) for (unsigned i = 0; i < sizeof(bounds); i += 4)
@@ -43,30 +46,20 @@ bool MmxZeroLoad(const char *path) {
if (!ok) { free(data); return false; }
MmxZeroDisable(); poses = data;
memcpy(saber_bounds, bounds, sizeof(bounds));
+ memcpy(hud_tiles, hud, sizeof(hud_tiles));
+ for (unsigned i = 0; i < 16; ++i) hud_colors[i] = (uint16_t)(word(hud + 128 + 2 * i) & 0x7fff);
for (unsigned i = 0; i < 128; ++i) colors[i] = (uint16_t)(word(palette + 2 * i) & 0x7fff);
return true;
}
const uint16_t *MmxZeroColors(void) { return colors; }
-int MmxZeroHudColor(unsigned x, unsigned y, const uint16_t palette[16]) {
- /* A red Z in the existing X1 badge. Keep the surrounding bar/frame live;
- * character identity is resolved here, independently of player visibility. */
- if (!MmxZeroEnabled() || x < 2 || x > 13 || y < 3 || y > 11) return -1;
- static const char badge[9][13] = {
- ".##########.",
- ".#rrrrrrrr#.",
- "..#####rr#..",
- ".....#rr#...",
- "....#rr#....",
- "...#rr#.....",
- "..#rr#####..",
- ".#rrrrrrrr#.",
- ".##########.",
- };
- switch (badge[y - 3][x - 2]) {
- case '#': return palette[15];
- case 'r': return 31 | (5 << 5) | (4 << 10);
- default: return palette[1];
- }
+int MmxZeroHudColor(unsigned x, unsigned y) {
+ /* Original X3 tile/palette data, independent of body visibility. */
+ if (!MmxZeroEnabled() || x >= 16 || y >= 16) return -1;
+ unsigned tile = (y / 8) * 2 + x / 8, shift = 7 - (x & 7);
+ const uint8_t *p = hud_tiles + tile * 32 + (y & 7) * 2;
+ unsigned pixel = ((p[0] >> shift) & 1) | (((p[1] >> shift) & 1) << 1) |
+ (((p[16] >> shift) & 1) << 2) | (((p[17] >> shift) & 1) << 3);
+ return pixel ? hud_colors[pixel] : -2;
}
static unsigned slash_pose(const MmxZeroState *s) {
/* Vanilla X3 group $4B actions $00/$0E: duration, frame. */
diff --git a/src/mmx_zero.h b/src/mmx_zero.h
index 9888411..d257d08 100644
--- a/src/mmx_zero.h
+++ b/src/mmx_zero.h
@@ -15,8 +15,8 @@ bool MmxZeroEnabled(void);
const uint8_t *MmxZeroPose(const uint8_t ram[0x20000], const MmxZeroState *snapshot);
const uint8_t *MmxZeroBlade(const MmxZeroState *snapshot);
const uint16_t *MmxZeroColors(void);
-/* Private BGR555 badge pixel, or -1 to retain the native health-bar frame. */
-int MmxZeroHudColor(unsigned x, unsigned y, const uint16_t palette[16]);
+/* Original X3 BGR555 badge pixel; -2 is transparent, -1 retains native art. */
+int MmxZeroHudColor(unsigned x, unsigned y);
void MmxZeroSetCollisionRom(uint8_t *rom, size_t size);
unsigned MmxZeroUpgradeBits(unsigned pc, unsigned original);
void MmxZeroPlayerTick(uint8_t ram[0x20000]);
diff --git a/tests/mmx_zero_test.c b/tests/mmx_zero_test.c
index 7c972f2..ab14a3e 100644
--- a/tests/mmx_zero_test.c
+++ b/tests/mmx_zero_test.c
@@ -7,13 +7,14 @@
static uint8_t ram[0x20000], before[0x20000], rom[0x180000], clean[0x180000];
static void asset(const char *path) {
FILE *f = fopen(path, "wb"); assert(f);
- const uint8_t header[] = {'M','M','X','Z','E','R','O','3',128,0,128,0,64,0,64,0,117,0,35,0};
+ const uint8_t header[] = {'M','M','X','Z','E','R','O','4',128,0,128,0,64,0,64,0,117,0,35,0};
uint8_t page[16384] = {0};
assert(fwrite(header, sizeof(header), 1, f) == 1);
assert(fwrite(page, 256, 1, f) == 1);
uint8_t bounds[40];
for (unsigned i = 0; i < 40; i += 4) { bounds[i] = 0; bounds[i+1] = 248; bounds[i+2] = 12; bounds[i+3] = 10; }
assert(fwrite(bounds, sizeof(bounds), 1, f) == 1);
+ assert(fwrite(page, 160, 1, f) == 1);
for (unsigned i = 0; i < MMX_ZERO_POSES; ++i) assert(fwrite(page, sizeof(page), 1, f) == 1);
assert(!fclose(f));
}
diff --git a/tools/extract_zero.py b/tools/extract_zero.py
index aed5d17..48f92df 100644
--- a/tools/extract_zero.py
+++ b/tools/extract_zero.py
@@ -112,8 +112,12 @@ def extract(path):
bounds = bytearray(rom.read(0x86b837, 40))
for i in range(1, 40, 4):
bounds[i] = (bounds[i] - 8) & 255
- header = struct.pack('<8s6H', b'MMXZERO3', WIDTH, HEIGHT, ORIGIN_X, ORIGIN_Y, 117, 35)
- return header + struct.pack('<128H', *colors) + bounds + b''.join(poses), colors, poses
+ # Vanilla $84:DCE6 selects DMA list $5C at $86:9AB3. Its two 64-byte
+ # transfers replace the four tiles of the 16x16 health badge at $6860/$6960.
+ # $81:804A with X=$20/Y=$1C loads its palette from $8C:B0E0 into CGRAM $A0.
+ hud = rom.read(0x2c8d20, 64) + rom.read(0x2c8de0, 64) + rom.read(0x8cb0e0, 32)
+ header = struct.pack('<8s6H', b'MMXZERO4', WIDTH, HEIGHT, ORIGIN_X, ORIGIN_Y, 117, 35)
+ return header + struct.pack('<128H', *colors) + bounds + hud + b''.join(poses), colors, poses
def main():
From c0c9f4ee91264e0abf837d6c2fa247334f9a3123 Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Mon, 28 Sep 2026 20:16:23 -0700
Subject: [PATCH 04/16] Keep Zero replacement tied to submitted sprites during
blinking
---
src/mmx_zero.c | 5 ++++-
tests/mmx_renderer_test.c | 41 ++++++++++++++++++++++++++++++++++++++-
2 files changed, 44 insertions(+), 2 deletions(-)
diff --git a/src/mmx_zero.c b/src/mmx_zero.c
index 6802b97..2e4cdbf 100644
--- a/src/mmx_zero.c
+++ b/src/mmx_zero.c
@@ -73,7 +73,10 @@ static unsigned slash_pose(const MmxZeroState *s) {
return 0;
}
const uint8_t *MmxZeroPose(const uint8_t ram[0x20000], const MmxZeroState *s) {
- if (!poses || !ram || !ram[0xbb6]) return NULL;
+ /* Visibility belongs to the submitted sprite list, not this RAM snapshot.
+ * During invulnerability the next update can hide the player while OAM
+ * still contains the preceding visible frame. The compositor owns blinking. */
+ if (!poses || !ram) return NULL;
/* The shared early-X animation vocabulary includes idle, run, jump,
* dash and firing poses. X1-specific states remain a validation item. */
unsigned pose = ram[0xbbf] & 127;
diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c
index 98b844a..5c0ec01 100644
--- a/tests/mmx_renderer_test.c
+++ b/tests/mmx_renderer_test.c
@@ -1,7 +1,9 @@
#include "mmx_renderer.h"
#include "mmx_render_assets.h"
+#include "mmx_zero.h"
#include
#include
+#include
static uint8_t ram[0x20000], rom_bytes[0x100000];
static Ppu ppu;
@@ -1106,6 +1108,43 @@ static void sprite_priority_and_cutscene_binding(void) {
g_mmx_custom_renderer = false; MmxRendererReset();
}
+static void zero_blink_submission(void) {
+ /* A submitted body belongs to the OAM epoch even if the next player update
+ * has already cleared its visibility flag. It must never turn back into X. */
+ FILE *f = fopen("zero-render-test.bin", "wb"); assert(f);
+ const uint8_t header[] = {'M','M','X','Z','E','R','O','4',128,0,128,0,64,0,64,0,117,0,35,0};
+ uint8_t pixels[16384] = {0}, palette[256] = {0}, bounds[40] = {0};
+ palette[2] = 31; pixels[64 * 128 + 64] = 1;
+ for (unsigned i = 0; i < 40; i += 4) bounds[i + 2] = bounds[i + 3] = 1;
+ uint8_t badge[160] = {0};
+ assert(fwrite(header,sizeof(header),1,f) == 1 && fwrite(palette,sizeof(palette),1,f) == 1 &&
+ fwrite(bounds,sizeof(bounds),1,f) == 1 && fwrite(badge,sizeof(badge),1,f) == 1);
+ for (unsigned i = 0; i < MMX_ZERO_POSES; ++i) assert(fwrite(pixels,sizeof(pixels),1,f) == 1);
+ assert(!fclose(f) && MmxZeroLoad("zero-render-test.bin"));
+ remove("zero-render-test.bin");
+ memset(&ppu,0,sizeof(ppu)); memset(ram,0,sizeof(ram)); memset(rom_bytes,0,sizeof(rom_bytes));
+ MmxRendererReset(); MmxRendererSetRom(rom_bytes,sizeof(rom_bytes));
+ g_mmx_custom_renderer = true; g_mmx_render_asset_repairs = false;
+ ram[0xd1] = 2; ram[0xd2] = ram[0xd3] = 4;
+ put_word(0xbad,40); put_word(0xbb0,48);
+ ppu.inidisp = 15; ppu.bgmode = 1; ppu.screenEnabled[0] = 16; ppu.cgram[129] = 31 << 10;
+ for (unsigned i = 0; i < 128; ++i) ppu.oam[i * 2] = 0xe000;
+ for (unsigned y = 0; y < 8; ++y) ppu.vram[y] = 255;
+ ppu.oam[32] = 0x2828; ppu.oam[33] = 0x2000;
+ put_word(0,40); put_word(2,40); put_word(0x18,0x8000); ram[0x1a] = 0x80; ram[0xf] = 0x20;
+ MmxRendererObserveObject(ram,0xba8); MmxRendererRecordPiece(ram,0); MmxRendererLatchSprites();
+ MmxRenderView view = {256,0,4.0/3.0};
+ for (unsigned visible = 0; visible < 2; ++visible) {
+ ram[0xbb6] = (uint8_t)visible; capture(); assert(MmxRendererDraw(output,view,false));
+ assert(output[40 * 256 + 40] == 0xff0000);
+ assert(output[40 * 256 + 41] == 0); /* Native X tile is fully suppressed. */
+ }
+ /* An actual blink submits no body; the renderer must preserve that gap. */
+ ppu.oam[32] = 0xe000; MmxRendererLatchSprites(); capture();
+ assert(MmxRendererDraw(output,view,false) && output[40 * 256 + 40] == 0);
+ MmxZeroDisable(); g_mmx_custom_renderer = false; g_mmx_render_asset_repairs = true;
+ MmxRendererReset();
+}
static void weapons_menu_margins(void) {
memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram));
MmxRendererReset(); MmxRendererSetRom(NULL, 0);
@@ -1126,4 +1165,4 @@ static void weapons_menu_margins(void) {
memset(stock, 0, sizeof(stock));
}
-int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); expanded_capacity(); background_resources(); dialogue_and_password(); highway_arena_sky(); distant_doors(); storm_background_prefill(); resource_decode(); spark_effects(); airport_panorama_edge(); wide_water_plane(); buried_submarine(); background_continuations(); launch_background_palettes(); sting_background_palettes(); mammoth_background_palettes(); dialogue_backdrop(); fortress_actor_presentation(); sprite_priority_and_cutscene_binding(); weapons_menu_margins(); return 0; }
+int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); expanded_capacity(); background_resources(); dialogue_and_password(); highway_arena_sky(); distant_doors(); storm_background_prefill(); resource_decode(); spark_effects(); airport_panorama_edge(); wide_water_plane(); buried_submarine(); background_continuations(); launch_background_palettes(); sting_background_palettes(); mammoth_background_palettes(); dialogue_backdrop(); fortress_actor_presentation(); sprite_priority_and_cutscene_binding(); weapons_menu_margins(); zero_blink_submission(); return 0; }
From b7d0dd655b026ed7d6580f6b8cadd4fad0566de3 Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Mon, 28 Sep 2026 20:39:48 -0700
Subject: [PATCH 05/16] Mirror original Zero animations and replace pause-menu
and life artwork
---
CMakeLists.txt | 2 +
docs/zero-spike.md | 36 ++++-
src/mmx_renderer.c | 69 ++++++---
src/mmx_rtl.c | 7 +-
src/mmx_zero.c | 73 +++++++++-
src/mmx_zero.h | 9 +-
src/mods/mmx_zero_plugin.c | 4 +-
tests/data/zero_x3_motion.csv | 249 ++++++++++++++++++++++++++++++++
tests/data/zero_x3_motion.md | 34 +++++
tests/mmx_adaptive_state_test.c | 67 +++++++++
tests/mmx_renderer_test.c | 6 +-
tests/mmx_zero_test.c | 6 +-
tools/apply_zero_hooks.py | 8 +-
tools/extract_zero.py | 7 +-
14 files changed, 535 insertions(+), 42 deletions(-)
create mode 100644 tests/data/zero_x3_motion.csv
create mode 100644 tests/data/zero_x3_motion.md
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 93e936e..e7d2143 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -145,6 +145,8 @@ include(${SNESRECOMP_ROOT}/runner/cmake/mmx_state_tests.cmake)
snesrecomp_target_mmx_state_tests(MegaManXSNESRecomp "${CMAKE_CURRENT_SOURCE_DIR}/src/main.c")
if(TARGET mmx_state_tests)
+ target_compile_definitions(mmx_state_tests PRIVATE
+ MMX_ZERO_MOTION_REFERENCE_DEFAULT="${CMAKE_CURRENT_SOURCE_DIR}/tests/data/zero_x3_motion.csv")
get_target_property(mmx_state_sources mmx_state_tests SOURCES)
list(REMOVE_ITEM mmx_state_sources "${SNESRECOMP_ROOT}/runner/tests/mmx_state_runtime.c")
set_property(TARGET mmx_state_tests PROPERTY SOURCES
diff --git a/docs/zero-spike.md b/docs/zero-spike.md
index 2e20a65..34e0f39 100644
--- a/docs/zero-spike.md
+++ b/docs/zero-spike.md
@@ -29,7 +29,9 @@ normalized X3 SHA-256:
`65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7`.
It extracts 117 body, 21 saber-body and 14 blade poses, palettes, the ground/air
saber bounds, and Zero's original X3 health-bar badge and palette. The current
-cache format is `MMXZERO4`; rerun extraction to replace older caches.
+cache format is `MMXZERO5`; rerun extraction to replace older caches. It also
+extracts the 136 original body-animation sequences, including Zero's additional
+hair, dash and jump poses.
`--sheet path.png` also writes a labeled contact sheet if Pillow
is installed. ROMs, generated code and extracted graphics stay local.
@@ -145,9 +147,29 @@ presented frame for visual proof.
## Remaining work for the full port
-Owner playtest follow-up: projectile origins do not yet line up with Zero's arm,
-and some hurt/invulnerability flashes still show X. These confirmed issues are
-tracked in `beads-8wg.1.29`. Full-port work is on `feat/x3-zero-port`, tracked in
+Full-port progress on `feat/x3-zero-port`:
+
+- The original Zero body animation now advances from X3's sequence records,
+ alongside X1's existing gameplay animation/events. All 81 X1 sequence entries
+ have mappings; X1's Hadouken poses use adapted Zero forward-firing poses.
+- The 248-frame [X3 movement reference](../tests/data/zero_x3_motion.md) matches
+ position, velocity and animation phase for run, full/short jump, dash and
+ dash-jump. X1/X3's 180-byte base movement tables are identical.
+- Hurt blinking follows the submitted sprite list, fixing the visibility-epoch
+ mismatch that let X's tiles reappear. A renderer regression covers both the
+ visible transition and the actual hidden frame.
+- The pause menu uses Zero's original standing body, including with all armor
+ upgrades owned. Menu and pickup life heads are adapted from original Zero
+ helmet/face pixels; vanilla X3 itself retains X's life icon. Native 1-up
+ collection increments the life count normally. Menu pixel comparisons change
+ only the character and head areas, including the fully upgraded menu.
+- Saves now use game chunk v5 for Zero's animation phase, retaining v4 combat
+ saves and older formats. Captures use v4, with v2/v3 still readable. Disabled
+ saves remain v3. Complete snapshot, replay, rollback and rewind checks pass.
+
+Owner playtest follow-up: projectile origins do not yet line up with Zero's arm.
+The reported X tiles during invulnerability are fixed as described above.
+These issues are tracked in `beads-8wg.1.29`. Full-port work is on `feat/x3-zero-port`, tracked in
`beads-8wg.1.31`. The earlier hand-drawn red HUD letter was an approximation and
has been replaced with the original X3 Zero badge, including its original frame
and palette. X3's `$84:DCE6` selects DMA list `$5C` at `$86:9AB3`: four tiles from
@@ -163,9 +185,9 @@ separate from loaded assets, with the body, abilities, collision and HUD changin
together. Swapping is not implemented by this HUD update.
1. Audit the complete animation/state mapping: wall slide/jump, ladders, damage,
- death, teleport, capsules, ride armor and scripted player poses. The prototype
- reuses the shared early-game pose numbering; unsupported indices fall back to
- idle. Extracting all 152 poses does not prove that every gameplay state selects
+ death, teleport, capsules, ride armor and scripted player poses. The full-port
+ branch maps all 81 native sequence entries to X3's original animations.
+ Extracting all 152 poses does not prove that every gameplay state selects
the correct pose or timing.
2. Match X3 movement/charge timing and projectile presentation where required.
Current movement physics, dash effects and the first two projectile graphics
diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c
index c6312c6..835cddb 100644
--- a/src/mmx_renderer.c
+++ b/src/mmx_renderer.c
@@ -71,6 +71,10 @@ MmxDisplayViewport MmxRendererDestination(MmxRenderView view, int width, int hei
return SnesDisplayAspect_FitViewport(view.aspect, width, height);
}
static unsigned word(const uint8_t *p, unsigned a) { return p[a] | (p[a + 1] << 8); }
+static bool zero_weapons_menu(void) {
+ return MmxZeroEnabled() && frame.ram[0xd1] == 2 && frame.ram[0xd2] == 4 &&
+ (frame.ram[0x1f10] == 6 || frame.ram[0x1f10] == 8) && (frame.ram[0xc3] & 128);
+}
static const uint8_t *rom_at(unsigned address, size_t length) {
if ((address & 0xffff) < 0x8000) return NULL;
size_t offset = ((address >> 16) & 0x7f) * 0x8000 + (address & 0x7fff);
@@ -277,7 +281,7 @@ bool MmxRendererSaveCapture(const char *path) {
if (!frame.valid || !path) return false;
FILE *f = fopen(path, "wb");
if (!f) return false;
- uint32_t header[] = {0x4d4d5843, 3, sizeof(frame) + sizeof(frame_zero)};
+ uint32_t header[] = {0x4d4d5843, 4, sizeof(frame) + sizeof(frame_zero)};
bool ok = fwrite(header, sizeof(header), 1, f) == 1 && fwrite(&frame, sizeof(frame), 1, f) == 1 &&
fwrite(&frame_zero, sizeof(frame_zero), 1, f) == 1;
return fclose(f) == 0 && ok;
@@ -290,10 +294,12 @@ bool MmxRendererLoadCapture(const char *path) {
frame.valid = false;
memset(&frame_zero, 0, sizeof(frame_zero));
bool ok = fread(h, sizeof(h), 1, f) == 1 && h[0] == 0x4d4d5843 &&
- ((h[1] == 2 && h[2] == sizeof(frame)) || (h[1] == 3 && h[2] == sizeof(frame) + sizeof(frame_zero))) &&
+ ((h[1] == 2 && h[2] == sizeof(frame)) || (h[1] == 3 && h[2] == sizeof(frame) + MMX_ZERO_LEGACY_STATE_SIZE) ||
+ (h[1] == 4 && h[2] == sizeof(frame) + sizeof(frame_zero))) &&
fread(&frame, sizeof(frame), 1, f) == 1 &&
frame.captured == 224 && frame.piece_count <= MAX_PIECES && frame.expanded_count <= MAX_PIECES && frame.valid;
- if (ok && h[1] == 3) ok = fread(&frame_zero, sizeof(frame_zero), 1, f) == 1 && frame_zero.slash <= 46 && frame_zero.air <= 1;
+ if (ok && h[1] >= 3) ok = fread(&frame_zero, h[1] == 3 ? MMX_ZERO_LEGACY_STATE_SIZE : sizeof(frame_zero), 1, f) == 1 &&
+ frame_zero.slash <= 46 && frame_zero.air <= 1 && frame_zero.anim_valid <= 1 && frame_zero.anim_pose < 117;
ok = ok && fgetc(f) == EOF;
fclose(f); frame.valid = ok; return ok;
}
@@ -494,9 +500,18 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x,
int cx = px & (size - 1), cy = py & (size - 1);
if (tile & 0x4000) cx = size - 1 - cx;
if (tile & 0x8000) cy = size - 1 - cy;
+ int life_color = -1;
+ if (!stage && layer == 1 && size == 8 && zero_weapons_menu() && (tile & 0xfc00) == 0x0400) {
+ unsigned number = tile & 1023;
+ if ((number >= 0xea && number <= 0xec) || (number >= 0xfa && number <= 0xfc)) {
+ unsigned col = (number & 15) - 10, row = number >= 0xfa;
+ life_color = MmxZeroLifeColor(col * 8 + cx - 4, row * 8 + cy);
+ tile = 0x080f; /* The menu's own empty panel tile supplies the backdrop. */
+ }
+ }
unsigned number = ((tile & 1023) + cx / 8 + cy / 8 * 16) & 1023;
unsigned address = (PPU_bgTileAdr(p, layer) + number * bpp * 4) & 0x7fff;
- const uint8_t *bits = g_mmx_render_asset_repairs && bpp == 4 && !(frame.ram[0x1f7a] == 1 && layer == 1) ?
+ const uint8_t *bits = stage && g_mmx_render_asset_repairs && bpp == 4 && !(frame.ram[0x1f7a] == 1 && layer == 1) ?
MmxRenderAssetsBackgroundTile(frame.ram, asset_x, address) : NULL;
unsigned pixel;
if (bits) {
@@ -505,9 +520,10 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x,
pixel = ((bits[0] >> shift) & 1) | (((bits[1] >> shift) & 1) << 1) |
(((bits[16] >> shift) & 1) << 2) | (((bits[17] >> shift) & 1) << 3);
} else pixel = tile_pixel(r->vram, address, cx & 7, cy & 7, bpp);
+ if (life_color >= 0) { pixel = 1; *private_color = life_color; }
if (!pixel) return 0;
unsigned index = (((tile >> 10) & 7) << bpp) | pixel;
- const MmxBackgroundPalette *palette = g_mmx_render_asset_repairs ?
+ const MmxBackgroundPalette *palette = stage && g_mmx_render_asset_repairs ?
(armadillo_lower_shaft ? MmxRenderAssetsBackgroundPalettePhase(frame.ram, 1) :
MmxRenderAssetsBackgroundPalette(frame.ram, asset_x)) : NULL;
if (palette && palette->valid[index]) *private_color = palette->colors[index];
@@ -517,7 +533,7 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x,
static void sprite(const Ppu *p, const Raster *r, int x, int sy, unsigned attr, int size,
int y, MmxRenderView view, uint16_t *out, bool margins_only,
const MmxSpriteAsset *asset, unsigned raw_tile, int *object_color,
- bool full_coordinates, bool zero_icon) {
+ bool full_coordinates, unsigned zero_icon) {
int row = full_coordinates ? y - sy : (y - sy) & 255;
if (row < 0 || row >= size) return;
if (attr & 0x8000) row = size - 1 - row;
@@ -538,7 +554,7 @@ static void sprite(const Ppu *p, const Raster *r, int x, int sy, unsigned attr,
pixel = ((bits[0] >> shift) & 1) | (((bits[1] >> shift) & 1) << 1) |
(((bits[16] >> shift) & 1) << 2) | (((bits[17] >> shift) & 1) << 3);
} else pixel = tile_pixel(r->vram, base + tile * 16, cx & 7, row & 7, 4);
- int hud_color = zero_icon ? MmxZeroHudColor(cx, row) : -1;
+ int hud_color = zero_icon == 1 ? MmxZeroHudColor(cx, row) : zero_icon == 2 ? MmxZeroLifeColor(cx, row) : -1;
if (hud_color >= 0) pixel = 1;
else if (hud_color == -2) pixel = 0;
if (pixel) {
@@ -647,6 +663,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
memset(door_cache, 0, sizeof(door_cache));
memset(out, 0, (size_t)view.width * 224 * sizeof(*out));
bool stage = MmxWidePolicy_IsStageScene(frame.ram);
+ bool zero_menu = zero_weapons_menu();
prepare_stage_planes();
LightBeam beams[2];
unsigned beam_count = stage ? spark_lights(beams, view.extra) : 0;
@@ -654,8 +671,8 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
MmxRenderAssetsDeathPaletteFade(frame.ram, frame.lines[0].palette) : 0;
const Piece *pieces = frame.expand && g_mmx_render_asset_repairs ? frame.expanded : frame.pieces;
unsigned piece_count = frame.expand && g_mmx_render_asset_repairs ? frame.expanded_count : frame.piece_count;
- const uint8_t *zero = stage ? MmxZeroPose(frame.ram, &frame_zero) : NULL;
- const uint8_t *blade = zero ? MmxZeroBlade(&frame_zero) : NULL;
+ const uint8_t *zero = zero_menu ? MmxZeroMenuPose() : stage ? MmxZeroPose(frame.ram, &frame_zero) : NULL;
+ const uint8_t *blade = stage && zero ? MmxZeroBlade(&frame_zero) : NULL;
Piece waiting[128];
unsigned waiting_count = stage && g_mmx_render_asset_repairs ?
fortress_waiting_pieces(waiting, pieces, piece_count) : 0;
@@ -680,7 +697,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
const Raster *r = &frame.lines[y]; Ppu p;
memcpy(&p, r->registers, PPU_SAVESTATE_REGS_SIZE);
if (beam_count) p.cgwsel = (p.cgwsel & 0xcf) | 0x20;
- if ((p.bgmode & 7) != 1 || !stage) {
+ if ((p.bgmode & 7) != 1 || (!stage && !zero_menu)) {
memcpy(out + y * view.width + view.extra, frame.stock + y * 256, 256 * sizeof(*out));
++stats.fallback_lines; continue;
}
@@ -705,6 +722,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
sprite(&p, r, s.x, s.y, s.attr, s.size, y, view, objects, true, asset, s.tile, object_colors, true, false);
}
bool replaced[128] = {false};
+ bool life_drawn[256] = {false};
bool zero_drawn = false;
for (int i = (int)piece_count - 1; i >= 0; --i) {
Piece s = pieces[i]; const MmxSpriteAsset *asset = piece_assets[i];
@@ -714,8 +732,15 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
* their entire footprint, including x=255 which native D76A clips.
* Only the explicit expanded list can add pieces beyond that budget. */
bool center = g_mmx_render_asset_repairs;
- bool zero_armor = zero && (s.object == 0xc38 || s.object == 0xc58 || s.object == 0xc78);
- if (g_mmx_render_asset_repairs || (zero && s.object == 0xba8) || zero_armor) for (int slot = 16; slot < 128; ++slot) {
+ bool zero_body = zero && s.object == (zero_menu ? 0x1988 : 0xba8);
+ /* Native group $11 is the four-piece life pickup ($81:E33A). Keep its
+ * real object, flashing submissions, terrain and collection behavior. */
+ bool zero_life = stage && MmxZeroEnabled() && s.animation == 0x11 &&
+ s.object >= 0x1628 && s.object < 0x1928 && (s.object - 0x1628) % 48 == 0 &&
+ frame.ram[s.object + 10] == 4;
+ bool zero_armor = zero && (zero_menu ? (s.object == 0x1928 || s.object == 0x1948 || s.object == 0x1968) :
+ (s.object == 0xc38 || s.object == 0xc58 || s.object == 0xc78));
+ if (g_mmx_render_asset_repairs || zero_body || zero_armor || zero_life) for (int slot = 16; slot < 128; ++slot) {
unsigned pos = r->oam[slot * 2], hi = r->high_oam[slot / 4] >> (slot % 4 * 2);
int ox = (pos & 255) | ((hi & 1) << 8); if (ox >= 256) ox -= 512;
if (ox == s.x && (pos >> 8) == ((unsigned)s.y & 255) && r->oam[slot * 2 + 1] == s.attr) {
@@ -730,11 +755,20 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
(asset->live_tiles ? s.attr & 255 : 0) : s.attr;
if (asset && asset->live_colors) attr = (attr & ~0x0e00u) | (s.attr & 0x0e00u);
if (zero_armor) continue;
- if (zero && s.object == 0xba8) {
+ if (zero_life) {
+ if (!life_drawn[s.object >> 5]) {
+ life_drawn[s.object >> 5] = true;
+ int lx = (int16_t)(word(frame.ram,s.object + 5) - word(frame.ram,0x1e4d)) - 8;
+ int ly = (int16_t)(word(frame.ram,s.object + 8) - word(frame.ram,0x1e50)) - 8;
+ sprite(&p,r,lx,ly,s.attr & 0x3fff,16,y,view,objects,false,NULL,0,object_colors,true,2);
+ }
+ continue;
+ }
+ if (zero_body) {
if (!zero_drawn) {
zero_drawn = true;
- int zx = (int16_t)(word(frame.ram, 0xbad) - word(frame.ram, 0x1e4d));
- int zy = (int16_t)(word(frame.ram, 0xbb0) - word(frame.ram, 0x1e50)) - 8;
+ int zx = zero_menu ? 128 : (int16_t)(word(frame.ram, 0xbad) - word(frame.ram, 0x1e4d));
+ int zy = zero_menu ? 152 : (int16_t)(word(frame.ram, 0xbb0) - word(frame.ram, 0x1e50)) - 8;
int row = y - zy + 64;
if (row >= 0 && row < MMX_ZERO_HEIGHT) {
const uint16_t *colors = MmxZeroColors();
@@ -742,7 +776,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
for (int col = 0; col < MMX_ZERO_WIDTH; ++col) {
unsigned pixel = zero[row * MMX_ZERO_WIDTH + col];
if (blade && blade[row * MMX_ZERO_WIDTH + col]) pixel = blade[row * MMX_ZERO_WIDTH + col];
- int dx = zx + ((frame.ram[0xbb9] & 64) ? 63 - col : col - 64) + view.extra;
+ int dx = zx + ((!zero_menu && (frame.ram[0xbb9] & 64)) ? 63 - col : col - 64) + view.extra;
if (pixel && dx >= 0 && dx < view.width) {
objects[dx] = (uint16_t)(z | (pixel & 15));
object_colors[dx] = colors[pixel];
@@ -776,12 +810,13 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
* This remains visible while the player blinks or has no body pieces. */
bool zero_icon = stage && MmxZeroEnabled() && slot == 0 &&
x == 8 && sy == 80 && attr == 0x3486 && size == 16;
- bool anchored = hud && sy < 96 && (slot < 16 || (bar_count >= 4 && slot >= bar_first && slot < bar_first + bar_count));
+ bool anchored = stage && hud && sy < 96 && (slot < 16 || (bar_count >= 4 && slot >= bar_first && slot < bar_first + bar_count));
if (anchored) { if (x < 25) x -= view.extra; else if (x >= 216) x += view.extra; }
sprite(&p, r, x, sy, attr, size, y, view, objects, false, NULL, 0, object_colors, false, zero_icon);
}
for (int sx = 0; sx < view.width; ++sx) {
int x = sx - view.extra;
+ if (zero_menu && (x < 0 || x >= 256)) { out[y * view.width + sx] = 0; continue; }
uint16_t screens[2] = {0x500, 0x500}, bg[3] = {0};
int bg_colors[3] = {-1, -1, -1};
for (int layer = 0; layer < 3; ++layer) if ((p.screenEnabled[0] | p.screenEnabled[1]) & (1 << layer)) {
diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c
index ae61db6..c0b0142 100644
--- a/src/mmx_rtl.c
+++ b/src/mmx_rtl.c
@@ -328,7 +328,7 @@ void mmx_host_yield(uint8_t countdown) {
#include "snes/saveload.h"
#define MMX_SAV_CHUNK_MAGIC 0x4D4D5854u /* "MMXT" */
-#define MMX_SAV_CHUNK_VERSION 4u /* Optional experimental Zero state. */
+#define MMX_SAV_CHUNK_VERSION 5u /* Zero combat plus independent X3 animation. */
typedef struct MmxSavChunk {
uint32_t magic, version;
@@ -412,8 +412,9 @@ void MmxStateLoadExtra(struct SaveLoadInfo *sli, uint32_t version) {
MmxWideStateLoad(sli);
if (!RtlLoadExecutionState(sli)) g_load_chunk_ok = 0;
if (g_load_chunk.version >= 4) {
- if (RtlStateBytesRemaining(sli) >= sizeof(g_load_zero))
- sli->func(sli, &g_load_zero, sizeof(g_load_zero));
+ size_t zero_size = g_load_chunk.version == 4 ? MMX_ZERO_LEGACY_STATE_SIZE : sizeof(g_load_zero);
+ if (RtlStateBytesRemaining(sli) >= zero_size)
+ sli->func(sli, &g_load_zero, zero_size);
else g_load_chunk_ok = 0;
}
}
diff --git a/src/mmx_zero.c b/src/mmx_zero.c
index 2e4cdbf..76bb124 100644
--- a/src/mmx_zero.c
+++ b/src/mmx_zero.c
@@ -8,7 +8,10 @@ static uint16_t colors[128];
static uint8_t saber_bounds[40];
static uint8_t hud_tiles[128];
static uint16_t hud_colors[16];
+static uint8_t animation[MMX_ZERO_ANIMATION_BYTES];
static MmxZeroState state;
+_Static_assert(offsetof(MmxZeroState, anim_offset) == MMX_ZERO_LEGACY_STATE_SIZE,
+ "Keep the v4 combat-state prefix readable");
static unsigned word(const uint8_t *p) { return p[0] | p[1] << 8; }
static void putword(uint8_t *p, unsigned v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); }
MmxZeroState MmxZeroGetState(void) { return state; }
@@ -16,7 +19,9 @@ void MmxZeroResetState(void) { memset(&state, 0, sizeof(state)); }
void MmxZeroSetState(MmxZeroState s) {
MmxZeroResetState();
if (poses && s.combo <= 2 && s.slash <= 46 && s.charge <= 180 &&
- s.air <= 1 && (s.facing == 0 || s.facing == 64) &&
+ s.air <= 1 && (s.facing == 0 || s.facing == 64) && s.anim_valid <= 1 &&
+ (!s.anim_valid || (s.anim_offset >= 272 && s.anim_offset + 3 <= sizeof(animation) &&
+ s.anim_timer && s.anim_pose < 117)) &&
(!s.projectile || (s.projectile >= 0x1228 && s.projectile < 0x1428 && (s.projectile & 63) == 0x28))) state = s;
}
@@ -28,30 +33,80 @@ void MmxZeroDisable(void) {
bool MmxZeroLoad(const char *path) {
FILE *f = path ? fopen(path, "rb") : NULL;
if (!f) return false;
- uint8_t header[20], palette[256], bounds[40], hud[160];
+ uint8_t header[20], palette[256], bounds[40], hud[160], anim[MMX_ZERO_ANIMATION_BYTES];
size_t size = (size_t)MMX_ZERO_POSES * MMX_ZERO_WIDTH * MMX_ZERO_HEIGHT;
uint8_t *data = NULL;
bool ok = fread(header, 1, sizeof(header), f) == sizeof(header) &&
- !memcmp(header, "MMXZERO4", 8) && word(header + 8) == MMX_ZERO_WIDTH &&
+ !memcmp(header, "MMXZERO5", 8) && word(header + 8) == MMX_ZERO_WIDTH &&
word(header + 10) == MMX_ZERO_HEIGHT && word(header + 12) == 64 &&
word(header + 14) == 64 && word(header + 16) == 117 && word(header + 18) == 35 &&
fread(palette, 1, sizeof(palette), f) == sizeof(palette) &&
fread(bounds, 1, sizeof(bounds), f) == sizeof(bounds) &&
- fread(hud, 1, sizeof(hud), f) == sizeof(hud);
+ fread(hud, 1, sizeof(hud), f) == sizeof(hud) &&
+ fread(anim, 1, sizeof(anim), f) == sizeof(anim);
if (ok) { data = malloc(size); ok = data && fread(data, 1, size, f) == size && fgetc(f) == EOF; }
fclose(f);
if (ok) for (unsigned i = 0; i < sizeof(bounds); i += 4)
if (!bounds[i + 2] || bounds[i + 2] > 64 || !bounds[i + 3] || bounds[i + 3] > 64) { ok = false; break; }
if (ok) for (size_t i = 0; i < size; ++i) if (data[i] >= 128) { ok = false; break; }
+ if (ok) for (unsigned i = 0; i < 136; ++i) {
+ unsigned p = word(anim + i * 2);
+ if (p < 272 || p + 3 > sizeof(anim) || !anim[p] || anim[p + 2] >= 117) { ok = false; break; }
+ }
if (!ok) { free(data); return false; }
MmxZeroDisable(); poses = data;
memcpy(saber_bounds, bounds, sizeof(bounds));
+ memcpy(animation, anim, sizeof(animation));
memcpy(hud_tiles, hud, sizeof(hud_tiles));
for (unsigned i = 0; i < 16; ++i) hud_colors[i] = (uint16_t)(word(hud + 128 + 2 * i) & 0x7fff);
for (unsigned i = 0; i < 128; ++i) colors[i] = (uint16_t)(word(palette + 2 * i) & 0x7fff);
return true;
}
const uint16_t *MmxZeroColors(void) { return colors; }
+const uint8_t *MmxZeroMenuPose(void) { return poses; }
+int MmxZeroLifeColor(unsigned x, unsigned y) {
+ if (!poses || x >= 16 || y >= 16) return -2;
+ /* Vanilla X3 keeps X's life icon for both characters. Adapt the original
+ * Zero idle helmet/face to X1's 16px icon, excluding neck and shoulders. */
+ unsigned sx = 56 + x * 20 / 16, sy = 40 + y * 24 / 16;
+ if ((sy >= 58 && (sx < 59 || sx > 72)) || (sy >= 60 && (sx < 60 || sx > 68)) || sy >= 63) return -2;
+ unsigned pixel = poses[sy * MMX_ZERO_WIDTH + sx];
+ return pixel ? colors[pixel] : -2;
+}
+static void animation_record(unsigned offset) {
+ if (offset < 272 || offset + 3 > sizeof(animation) || !animation[offset] || animation[offset + 2] >= 117) {
+ state.anim_valid = 0; return;
+ }
+ state.anim_offset = (uint16_t)offset;
+ state.anim_timer = animation[offset]; state.anim_flags = animation[offset + 1];
+ state.anim_pose = animation[offset + 2]; state.anim_valid = 1;
+}
+void MmxZeroAnimationStart(unsigned object, unsigned sequence) {
+ if (!poses || object != 0xba8) return;
+ /* X1 group 0 -> original X3 group $4A. Aliases are kept: firing overlays
+ * resume at interior records, not at the beginning of a movement cycle.
+ * X1's two Hadouken actions use Zero's forward buster/recovery poses. */
+ static const uint8_t map[] = {
+ 0x00,0x01,0x02,0x03,0x05,0x07,0x08,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f,0x10,0x11,0x12,
+ 0x13,0x14,0x15,0x16,0x17,0x1a,0x1e,0x1f,0x20,0x21,0x22,0x23,0x24,0x25,0x26,0x27,
+ 0x28,0x29,0x50,0x51,0x52,0x53,0x55,0x57,0x58,0x5a,0x5b,0x5c,0x5d,0x5e,0x5f,0x60,
+ 0x61,0x62,0x63,0x64,0x65,0x66,0x67,0x6a,0x6e,0x6f,0x70,0x71,0x72,0x73,0x74,0x75,
+ 0x76,0x77,0x78,0x79,0x7a,0x7b,0x7c,0x7d,0x7e,0x7f,0x80,0x81,0x82,0x83,0x84,0x30,
+ 0x34
+ };
+ if (sequence >= sizeof(map)) { state.anim_valid = 0; return; }
+ animation_record(word(animation + map[sequence] * 2));
+}
+void MmxZeroAnimationAdvance(unsigned object) {
+ if (!poses || object != 0xba8 || !state.anim_valid) return;
+ if (--state.anim_timer) return;
+ unsigned next = state.anim_offset + 3;
+ if (state.anim_flags & 128) {
+ if (next + 2 > sizeof(animation)) { state.anim_valid = 0; return; }
+ next = (unsigned)((int)next + (int16_t)word(animation + next));
+ }
+ animation_record(next);
+}
int MmxZeroHudColor(unsigned x, unsigned y) {
/* Original X3 tile/palette data, independent of body visibility. */
if (!MmxZeroEnabled() || x >= 16 || y >= 16) return -1;
@@ -77,9 +132,9 @@ const uint8_t *MmxZeroPose(const uint8_t ram[0x20000], const MmxZeroState *s) {
* During invulnerability the next update can hide the player while OAM
* still contains the preceding visible frame. The compositor owns blinking. */
if (!poses || !ram) return NULL;
- /* The shared early-X animation vocabulary includes idle, run, jump,
- * dash and firing poses. X1-specific states remain a validation item. */
- unsigned pose = ram[0xbbf] & 127;
+ /* Old saves without mirrored animation state use the shared pose vocabulary
+ * until the next native animation start. New saves retain the exact phase. */
+ unsigned pose = s && s->anim_valid ? s->anim_pose : ram[0xbbf] & 127;
if (pose >= 117) pose = 0;
if (s && s->slash) pose = 117 + slash_pose(s);
return poses + (size_t)pose * MMX_ZERO_WIDTH * MMX_ZERO_HEIGHT;
@@ -132,7 +187,9 @@ static void release_projectile(uint8_t *r) {
}
void MmxZeroCancel(uint8_t ram[0x20000]) {
if (ram) release_projectile(ram);
- MmxZeroResetState();
+ /* Combat cancellation (hurt, weapon switch, menus) must not reset the
+ * independent body animation. Full reset/load uses MmxZeroResetState. */
+ memset(&state, 0, MMX_ZERO_LEGACY_STATE_SIZE);
}
static unsigned free_projectile(const uint8_t *r) {
for (unsigned d = 0x1228; d < 0x1428; d += 64)
diff --git a/src/mmx_zero.h b/src/mmx_zero.h
index d257d08..a9a6b05 100644
--- a/src/mmx_zero.h
+++ b/src/mmx_zero.h
@@ -3,11 +3,14 @@
#include
#include
-enum { MMX_ZERO_WIDTH = 128, MMX_ZERO_HEIGHT = 128, MMX_ZERO_POSES = 152 };
+enum { MMX_ZERO_WIDTH = 128, MMX_ZERO_HEIGHT = 128, MMX_ZERO_POSES = 152,
+ MMX_ZERO_ANIMATION_BYTES = 0x474, MMX_ZERO_LEGACY_STATE_SIZE = 12 };
typedef struct MmxZeroState {
uint16_t charge, slash, projectile;
uint8_t combo, cooldown, air, facing;
uint16_t hit_slots;
+ uint16_t anim_offset;
+ uint8_t anim_timer, anim_pose, anim_flags, anim_valid;
} MmxZeroState;
bool MmxZeroLoad(const char *path);
void MmxZeroDisable(void);
@@ -15,11 +18,15 @@ bool MmxZeroEnabled(void);
const uint8_t *MmxZeroPose(const uint8_t ram[0x20000], const MmxZeroState *snapshot);
const uint8_t *MmxZeroBlade(const MmxZeroState *snapshot);
const uint16_t *MmxZeroColors(void);
+const uint8_t *MmxZeroMenuPose(void);
+int MmxZeroLifeColor(unsigned x, unsigned y);
/* Original X3 BGR555 badge pixel; -2 is transparent, -1 retains native art. */
int MmxZeroHudColor(unsigned x, unsigned y);
void MmxZeroSetCollisionRom(uint8_t *rom, size_t size);
unsigned MmxZeroUpgradeBits(unsigned pc, unsigned original);
void MmxZeroPlayerTick(uint8_t ram[0x20000]);
+void MmxZeroAnimationStart(unsigned object, unsigned sequence);
+void MmxZeroAnimationAdvance(unsigned object);
unsigned MmxZeroWeaponTick(uint8_t ram[0x20000], unsigned object, unsigned active);
unsigned MmxZeroDamage(uint8_t ram[0x20000], unsigned enemy, unsigned projectile, unsigned original);
unsigned MmxZeroHitbox(const uint8_t ram[0x20000], unsigned enemy, unsigned projectile, unsigned original);
diff --git a/src/mods/mmx_zero_plugin.c b/src/mods/mmx_zero_plugin.c
index 5602794..39d1c1b 100644
--- a/src/mods/mmx_zero_plugin.c
+++ b/src/mods/mmx_zero_plugin.c
@@ -14,6 +14,8 @@ static void hook(CpuState *cpu, uint32_t pc) {
case 0x00d76a: MmxRendererRecordPiece(g_ram, cpu->D); break;
case 0x01971f: case 0x019796: case 0x0198ff: cpu->A |= 8; break;
case 0x01815c: MmxZeroPlayerTick(g_ram); break;
+ case 0x048f07: MmxZeroAnimationStart(cpu->D, cpu->A & 255); break;
+ case 0x048eea: MmxZeroAnimationAdvance(cpu->D); break;
case 0x00d3e7: {
unsigned value = MmxZeroWeaponTick(g_ram, cpu->D, cpu->A & 255);
cpu->A = (cpu->A & 0xff00) | value;
@@ -57,7 +59,7 @@ static void hook(CpuState *cpu, uint32_t pc) {
}
void MmxZeroRegisterHooks(void) {
const unsigned pcs[] = {0x00d6a7, 0x00d76a, 0x01971f, 0x019796, 0x0198ff,
- 0x01815c, 0x00d3e7, 0x049e76, 0x049c19};
+ 0x01815c, 0x00d3e7, 0x049e76, 0x049c19, 0x048f07, 0x048eea};
for (unsigned i = 0; i < sizeof(pcs) / sizeof(pcs[0]); ++i)
interp_bridge_set_pre_opcode_hook(pcs[i], hook);
}
diff --git a/tests/data/zero_x3_motion.csv b/tests/data/zero_x3_motion.csv
new file mode 100644
index 0000000..403a99d
--- /dev/null
+++ b/tests/data/zero_x3_motion.csv
@@ -0,0 +1,249 @@
+case,tick,keys,x,y,vx,vy,action,sub,pose,group,ground,visibility
+run,1,Right,0,0,0,0,2,0,0,74,4,1
+run,2,Right,256,0,256,0,2,2,81,74,4,1
+run,3,Right,512,0,256,0,2,2,81,74,4,1
+run,4,Right,768,0,256,0,2,2,81,74,4,1
+run,5,Right,1024,0,256,0,2,2,81,74,4,1
+run,6,Right,1024,0,256,0,4,0,81,74,4,1
+run,7,Right,1400,0,376,0,4,2,3,74,4,1
+run,8,Right,1776,0,376,0,4,2,4,74,4,1
+run,9,Right,2152,0,376,0,4,2,4,74,4,1
+run,10,Right,2528,0,376,0,4,2,5,74,4,1
+run,11,Right,2904,0,376,0,4,2,5,74,4,1
+run,12,Right,3280,0,376,0,4,2,5,74,4,1
+run,13,Right,3656,0,376,0,4,2,6,74,4,1
+run,14,Right,4032,0,376,0,4,2,6,74,4,1
+run,15,Right,4408,0,376,0,4,2,6,74,4,1
+run,16,Right,4784,0,376,0,4,2,7,74,4,1
+run,17,Right,5160,0,376,0,4,2,7,74,4,1
+run,18,Right,5536,0,376,0,4,2,7,74,4,1
+run,19,Right,5912,0,376,0,4,2,8,74,4,1
+run,20,Right,6288,0,376,0,4,2,8,74,4,1
+run,21,Right,6664,0,376,0,4,2,9,74,4,1
+run,22,Right,7040,0,376,0,4,2,9,74,4,1
+run,23,Right,7416,0,376,0,4,2,10,74,4,1
+run,24,Right,7792,0,376,0,4,2,10,74,4,1
+run,25,Right,8168,0,376,0,4,2,10,74,4,1
+run,26,Right,8544,0,376,0,4,2,11,74,4,1
+run,27,Right,8920,0,376,0,4,2,11,74,4,1
+run,28,Right,9296,0,376,0,4,2,11,74,4,1
+run,29,Right,9672,0,376,0,4,2,12,74,4,1
+run,30,Right,10048,0,376,0,4,2,12,74,4,1
+run,31,0,10048,0,376,0,0,0,12,74,4,1
+run,32,0,10048,0,0,0,0,2,0,74,4,1
+run,33,0,10048,0,0,0,0,2,0,74,4,1
+run,34,0,10048,0,0,0,0,2,0,74,4,1
+run,35,0,10048,0,0,0,0,2,0,74,4,1
+run,36,0,10048,0,0,0,0,2,0,74,4,1
+run,37,0,10048,0,0,0,0,2,0,74,4,1
+run,38,0,10048,0,0,0,0,2,0,74,4,1
+jump,1,B,0,0,0,1363,6,0,0,74,0,1
+jump,2,B,0,-1299,0,1299,6,2,23,74,0,1
+jump,3,B,0,-2534,0,1235,6,2,23,74,0,1
+jump,4,B,0,-3705,0,1171,6,2,23,74,0,1
+jump,5,B,0,-4812,0,1107,6,2,24,74,0,1
+jump,6,B,0,-5855,0,1043,6,2,24,74,0,1
+jump,7,B,0,-6834,0,979,6,2,24,74,0,1
+jump,8,B,0,-7749,0,915,6,2,24,74,0,1
+jump,9,B,0,-8600,0,851,6,2,25,74,0,1
+jump,10,B,0,-9387,0,787,6,2,25,74,0,1
+jump,11,B,0,-10110,0,723,6,2,25,74,0,1
+jump,12,B,0,-10769,0,659,6,2,25,74,0,1
+jump,13,B,0,-11364,0,595,6,2,100,74,0,1
+jump,14,B,0,-11895,0,531,6,2,100,74,0,1
+jump,15,B,0,-12362,0,467,6,2,100,74,0,1
+jump,16,B,0,-12765,0,403,6,2,100,74,0,1
+jump,17,B,0,-13104,0,339,6,2,25,74,0,1
+jump,18,B,0,-13379,0,275,6,2,25,74,0,1
+jump,19,B,0,-13590,0,211,6,2,25,74,0,1
+jump,20,B,0,-13737,0,147,6,2,25,74,0,1
+jump,21,B,0,-13820,0,83,6,2,100,74,0,1
+jump,22,B,0,-13839,0,19,6,2,100,74,0,1
+jump,23,B,0,-13794,0,-45,6,2,100,74,0,1
+jump,24,B,0,-13730,0,-64,8,2,25,74,0,1
+jump,25,B,0,-13602,0,-128,8,2,25,74,0,1
+jump,26,B,0,-13410,0,-192,8,2,25,74,0,1
+jump,27,B,0,-13154,0,-256,8,2,100,74,0,1
+jump,28,B,0,-12834,0,-320,8,2,100,74,0,1
+jump,29,B,0,-12450,0,-384,8,2,100,74,0,1
+jump,30,B,0,-12002,0,-448,8,2,100,74,0,1
+jump,31,B,0,-11490,0,-512,8,2,26,74,0,1
+jump,32,B,0,-10914,0,-576,8,2,26,74,0,1
+jump,33,B,0,-10274,0,-640,8,2,26,74,0,1
+jump,34,B,0,-9570,0,-704,8,2,26,74,0,1
+jump,35,B,0,-8802,0,-768,8,2,27,74,0,1
+jump,36,B,0,-7970,0,-832,8,2,27,74,0,1
+jump,37,B,0,-7074,0,-896,8,2,27,74,0,1
+jump,38,B,0,-6114,0,-960,8,2,27,74,0,1
+jump,39,B,0,-5090,0,-1024,8,2,101,74,0,1
+jump,40,B,0,-4002,0,-1088,8,2,101,74,0,1
+jump,41,B,0,-2850,0,-1152,8,2,101,74,0,1
+jump,42,B,0,-1634,0,-1216,8,2,101,74,0,1
+jump,43,B,0,-354,0,-1280,8,2,27,74,0,1
+jump,44,B,0,222,0,-1344,8,2,27,74,4,1
+jump,45,0,0,222,0,-1344,10,2,28,74,4,1
+jump,46,0,0,222,0,-1344,10,2,28,74,4,1
+jump,47,0,0,222,0,-1344,10,2,29,74,4,1
+jump,48,0,0,222,0,-1344,0,0,29,74,4,1
+jump,49,0,0,222,0,0,0,2,0,74,4,1
+jump,50,0,0,222,0,0,0,2,0,74,4,1
+jump,51,0,0,222,0,0,0,2,0,74,4,1
+jump,52,0,0,222,0,0,0,2,0,74,4,1
+jump,53,0,0,222,0,0,0,2,0,74,4,1
+jump,54,0,0,222,0,0,0,2,0,74,4,1
+jump,55,0,0,222,0,0,0,2,0,74,4,1
+jump,56,0,0,222,0,0,0,2,0,74,4,1
+jump,57,0,0,222,0,0,0,2,0,74,4,1
+jump,58,0,0,222,0,0,0,2,0,74,4,1
+jump,59,0,0,222,0,0,0,2,0,74,4,1
+jump,60,0,0,222,0,0,0,2,0,74,4,1
+short_jump,1,B,0,0,0,1363,6,0,0,74,0,1
+short_jump,2,B,0,-1299,0,1299,6,2,23,74,0,1
+short_jump,3,B,0,-2534,0,1235,6,2,23,74,0,1
+short_jump,4,B,0,-3705,0,1171,6,2,23,74,0,1
+short_jump,5,B,0,-4812,0,1107,6,2,24,74,0,1
+short_jump,6,0,0,-4748,0,-64,8,2,25,74,0,1
+short_jump,7,0,0,-4620,0,-128,8,2,25,74,0,1
+short_jump,8,0,0,-4428,0,-192,8,2,25,74,0,1
+short_jump,9,0,0,-4172,0,-256,8,2,100,74,0,1
+short_jump,10,0,0,-3852,0,-320,8,2,100,74,0,1
+short_jump,11,0,0,-3468,0,-384,8,2,100,74,0,1
+short_jump,12,0,0,-3020,0,-448,8,2,100,74,0,1
+short_jump,13,0,0,-2508,0,-512,8,2,26,74,0,1
+short_jump,14,0,0,-1932,0,-576,8,2,26,74,0,1
+short_jump,15,0,0,-1292,0,-640,8,2,26,74,0,1
+short_jump,16,0,0,-588,0,-704,8,2,26,74,0,1
+short_jump,17,0,0,180,0,-768,8,2,27,74,0,1
+short_jump,18,0,0,244,0,-832,8,2,27,74,4,1
+short_jump,19,0,0,244,0,-832,10,2,28,74,4,1
+short_jump,20,0,0,244,0,-832,10,2,28,74,4,1
+short_jump,21,0,0,244,0,-832,10,2,29,74,4,1
+short_jump,22,0,0,244,0,-832,0,0,29,74,4,1
+short_jump,23,0,0,244,0,0,0,2,0,74,4,1
+short_jump,24,0,0,244,0,0,0,2,0,74,4,1
+short_jump,25,0,0,244,0,0,0,2,0,74,4,1
+short_jump,26,0,0,244,0,0,0,2,0,74,4,1
+short_jump,27,0,0,244,0,0,0,2,0,74,4,1
+short_jump,28,0,0,244,0,0,0,2,0,74,4,1
+short_jump,29,0,0,244,0,0,0,2,0,74,4,1
+short_jump,30,0,0,244,0,0,0,2,0,74,4,1
+short_jump,31,0,0,244,0,0,0,2,0,74,4,1
+short_jump,32,0,0,244,0,0,0,2,0,74,4,1
+short_jump,33,0,0,244,0,0,0,2,0,74,4,1
+short_jump,34,0,0,244,0,0,0,2,0,74,4,1
+short_jump,35,0,0,244,0,0,0,2,0,74,4,1
+short_jump,36,0,0,244,0,0,0,2,0,74,4,1
+short_jump,37,0,0,244,0,0,0,2,0,74,4,1
+short_jump,38,0,0,244,0,0,0,2,0,74,4,1
+short_jump,39,0,0,244,0,0,0,2,0,74,4,1
+short_jump,40,0,0,244,0,0,0,2,0,74,4,1
+short_jump,41,0,0,244,0,0,0,2,0,74,4,1
+short_jump,42,0,0,244,0,0,0,2,0,74,4,1
+short_jump,43,0,0,244,0,0,0,2,0,74,4,1
+short_jump,44,0,0,244,0,0,0,2,0,74,4,1
+short_jump,45,0,0,244,0,0,0,2,0,74,4,1
+dash,1,A+Right,885,0,885,0,20,1,54,74,4,1
+dash,2,A+Right,1770,0,885,0,20,1,55,74,4,1
+dash,3,A+Right,2655,0,885,0,20,1,55,74,4,1
+dash,4,A+Right,3540,0,885,0,20,1,104,74,4,1
+dash,5,A+Right,4425,0,885,0,20,1,104,74,4,1
+dash,6,A+Right,5310,0,885,0,20,1,56,74,4,1
+dash,7,A+Right,6195,0,885,0,20,1,56,74,4,1
+dash,8,A+Right,7080,0,885,0,20,1,105,74,4,1
+dash,9,A+Right,7965,0,885,0,20,1,105,74,4,1
+dash,10,A+Right,8850,0,885,0,20,1,106,74,4,1
+dash,11,A+Right,9735,0,885,0,20,1,106,74,4,1
+dash,12,A+Right,10620,0,885,0,20,1,107,74,4,1
+dash,13,A+Right,11505,0,885,0,20,1,107,74,4,1
+dash,14,A+Right,12390,0,885,0,20,1,56,74,4,1
+dash,15,A+Right,13275,0,885,0,20,1,56,74,4,1
+dash,16,A+Right,14160,0,885,0,20,1,105,74,4,1
+dash,17,A+Right,15045,0,885,0,20,1,105,74,4,1
+dash,18,A+Right,15930,0,885,0,20,1,106,74,4,1
+dash,19,A+Right,16815,0,885,0,20,1,106,74,4,1
+dash,20,A+Right,17700,0,885,0,20,1,107,74,4,1
+dash,21,A+Right,18585,0,885,0,20,1,107,74,4,1
+dash,22,A+Right,19470,0,885,0,20,1,56,74,4,1
+dash,23,A+Right,20355,0,885,0,20,1,56,74,4,1
+dash,24,A+Right,21240,0,885,0,20,1,105,74,4,1
+dash,25,A+Right,22125,0,885,0,20,1,105,74,4,1
+dash,26,A+Right,23010,0,885,0,20,1,106,74,4,1
+dash,27,A+Right,23895,0,885,0,20,1,106,74,4,1
+dash,28,A+Right,24780,0,885,0,20,1,107,74,4,1
+dash,29,A+Right,25665,0,885,0,20,1,107,74,4,1
+dash,30,A+Right,26550,0,885,0,20,1,56,74,4,1
+dash,31,A+Right,27435,0,885,0,20,1,56,74,4,1
+dash,32,A+Right,28320,0,885,0,20,1,105,74,4,1
+dash,33,A+Right,29205,0,885,0,32,0,105,74,4,1
+dash,34,A+Right,29205,0,885,0,4,0,54,74,4,1
+dash,35,A+Right,29581,0,376,0,4,2,3,74,4,1
+dash,36,A+Right,29957,0,376,0,4,2,4,74,4,1
+dash,37,A+Right,30333,0,376,0,4,2,4,74,4,1
+dash,38,A+Right,30709,0,376,0,4,2,5,74,4,1
+dash,39,A+Right,31085,0,376,0,4,2,5,74,4,1
+dash,40,A+Right,31461,0,376,0,4,2,5,74,4,1
+dash,41,0,31461,0,376,0,0,0,5,74,4,1
+dash,42,0,31461,0,0,0,0,2,0,74,4,1
+dash,43,0,31461,0,0,0,0,2,0,74,4,1
+dash,44,0,31461,0,0,0,0,2,0,74,4,1
+dash,45,0,31461,0,0,0,0,2,0,74,4,1
+dash,46,0,31461,0,0,0,0,2,0,74,4,1
+dash,47,0,31461,0,0,0,0,2,0,74,4,1
+dash,48,0,31461,0,0,0,0,2,0,74,4,1
+dash_jump,1,A+Right,885,0,885,0,20,1,54,74,4,1
+dash_jump,2,A+Right,1770,0,885,0,20,1,55,74,4,1
+dash_jump,3,A+Right,2655,0,885,0,20,1,55,74,4,1
+dash_jump,4,A+Right,3540,0,885,0,20,1,104,74,4,1
+dash_jump,5,A+Right,4425,0,885,0,20,1,104,74,4,1
+dash_jump,6,A+Right+B,4425,0,0,1363,6,0,104,74,0,1
+dash_jump,7,A+Right+B,5310,-1299,885,1299,6,2,23,74,0,1
+dash_jump,8,A+Right+B,6195,-2534,885,1235,6,2,23,74,0,1
+dash_jump,9,A+Right+B,7080,-3705,885,1171,6,2,23,74,0,1
+dash_jump,10,A+Right+B,7965,-4812,885,1107,6,2,24,74,0,1
+dash_jump,11,A+Right+B,8850,-5855,885,1043,6,2,24,74,0,1
+dash_jump,12,A+Right+B,9735,-6834,885,979,6,2,24,74,0,1
+dash_jump,13,A+Right+B,10620,-7749,885,915,6,2,24,74,0,1
+dash_jump,14,A+Right+B,11505,-8600,885,851,6,2,25,74,0,1
+dash_jump,15,A+Right+B,12390,-9387,885,787,6,2,25,74,0,1
+dash_jump,16,A+Right+B,13275,-10110,885,723,6,2,25,74,0,1
+dash_jump,17,A+Right+B,14160,-10769,885,659,6,2,25,74,0,1
+dash_jump,18,A+Right+B,15045,-11364,885,595,6,2,100,74,0,1
+dash_jump,19,A+Right+B,15930,-11895,885,531,6,2,100,74,0,1
+dash_jump,20,A+Right+B,16815,-12362,885,467,6,2,100,74,0,1
+dash_jump,21,A+Right+B,17700,-12765,885,403,6,2,100,74,0,1
+dash_jump,22,A+Right+B,18585,-13104,885,339,6,2,25,74,0,1
+dash_jump,23,A+Right+B,19470,-13379,885,275,6,2,25,74,0,1
+dash_jump,24,A+Right+B,20355,-13590,885,211,6,2,25,74,0,1
+dash_jump,25,A+Right+B,21240,-13737,885,147,6,2,25,74,0,1
+dash_jump,26,A+Right+B,22125,-13820,885,83,6,2,100,74,0,1
+dash_jump,27,A+Right+B,23010,-13839,885,19,6,2,100,74,0,1
+dash_jump,28,A+Right+B,23895,-13794,885,-45,6,2,100,74,0,1
+dash_jump,29,A+Right+B,24780,-13730,885,-64,8,2,25,74,0,1
+dash_jump,30,A+Right+B,25665,-13602,885,-128,8,2,25,74,0,1
+dash_jump,31,A+Right+B,26550,-13410,885,-192,8,2,25,74,0,1
+dash_jump,32,A+Right+B,27435,-13154,885,-256,8,2,100,74,0,1
+dash_jump,33,A+Right+B,28320,-12834,885,-320,8,2,100,74,0,1
+dash_jump,34,A+Right+B,29205,-12450,885,-384,8,2,100,74,0,1
+dash_jump,35,A+Right+B,30090,-12002,885,-448,8,2,100,74,0,1
+dash_jump,36,A+Right+B,30975,-11490,885,-512,8,2,26,74,0,1
+dash_jump,37,A+Right+B,31860,-10914,885,-576,8,2,26,74,0,1
+dash_jump,38,0,31860,-10274,0,-640,8,2,26,74,0,1
+dash_jump,39,0,31860,-9570,0,-704,8,2,26,74,0,1
+dash_jump,40,0,31860,-8802,0,-768,8,2,27,74,0,1
+dash_jump,41,0,31860,-7970,0,-832,8,2,27,74,0,1
+dash_jump,42,0,31860,-7074,0,-896,8,2,27,74,0,1
+dash_jump,43,0,31860,-6114,0,-960,8,2,27,74,0,1
+dash_jump,44,0,31860,-5090,0,-1024,8,2,101,74,0,1
+dash_jump,45,0,31860,-4002,0,-1088,8,2,101,74,0,1
+dash_jump,46,0,31860,-2850,0,-1152,8,2,101,74,0,1
+dash_jump,47,0,31860,-1634,0,-1216,8,2,101,74,0,1
+dash_jump,48,0,31860,-354,0,-1280,8,2,27,74,0,1
+dash_jump,49,0,31860,222,0,-1344,8,2,27,74,4,1
+dash_jump,50,0,31860,222,0,-1344,10,2,28,74,4,1
+dash_jump,51,0,31860,222,0,-1344,10,2,28,74,4,1
+dash_jump,52,0,31860,222,0,-1344,10,2,29,74,4,1
+dash_jump,53,0,31860,222,0,-1344,0,0,29,74,4,1
+dash_jump,54,0,31860,222,0,0,0,2,0,74,4,1
+dash_jump,55,0,31860,222,0,0,0,2,0,74,4,1
+dash_jump,56,0,31860,222,0,0,0,2,0,74,4,1
+dash_jump,57,0,31860,222,0,0,0,2,0,74,4,1
diff --git a/tests/data/zero_x3_motion.md b/tests/data/zero_x3_motion.md
new file mode 100644
index 0000000..3c3ca9e
--- /dev/null
+++ b/tests/data/zero_x3_motion.md
@@ -0,0 +1,34 @@
+# Original X3 Zero movement reference
+
+`zero_x3_motion.csv` contains measurements, not ROM or graphics data. Recorded
+from the original USA X3 ROM (SHA-256 `65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7`)
+through the X3 project's current interpreter build, with execution mode `off`.
+Zero was selected through the original pause-menu communication/swap sequence.
+The stationary fixture is on flat ground near the beginning of Neon Tiger.
+
+Each case starts from that same complete save with no buttons held, waits ten
+frames, then clears only the player's X/Y fractional bytes. Each row is the
+state after one input frame. X/Y are signed displacement in 1/256 pixels;
+VX/VY are signed native 8.8 values. The columns `action` and `sub` identify X3's
+internal states, not required X1 state numbers. Group 74 is Zero's `$4A` body.
+
+Inputs (default SNES bindings):
+
+| Case | Held inputs and durations |
+| --- | --- |
+| run | Right 30, neutral 8 |
+| jump | B 44, neutral 16 |
+| short_jump | B 5, neutral 40 |
+| dash | A+Right 40, neutral 8 |
+| dash_jump | A+Right 5, A+Right+B 32, neutral 20 |
+
+The X1 ROM-backed test replays these from its unupgraded Highway fixture.
+It compares every position and velocity, plus the mirrored original animation
+pose after the first frame. The first pose is excluded because the two saved
+fixtures can start at different idle-blink phases before the action starts.
+The native X1 animation fields remain responsible for gameplay events.
+
+Targeted ROM checks also find the 180-byte base movement tables identical:
+X1 `$86:B9B1`, X3 `$86:B272` (30 records of VX, VY, acceleration). This establishes
+the shared base values; these five trajectories do not establish water, ladders,
+wall contact, damage, stage-specific terrain, or full campaign coverage.
diff --git a/tests/mmx_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c
index dd2f5fd..cc7c355 100644
--- a/tests/mmx_adaptive_state_test.c
+++ b/tests/mmx_adaptive_state_test.c
@@ -42,6 +42,52 @@ static void zero_capture(const char *base, const char *suffix) {
char path[4096]; snprintf(path, sizeof(path), "%s%s", base, suffix);
check(MmxRendererSaveCapture(path), "Zero renderer capture saved");
}
+static void zero_motion_checks(const char *fixture) {
+ const char *path = getenv("MMX_ZERO_MOTION_REFERENCE");
+ if (!path) path = MMX_ZERO_MOTION_REFERENCE_DEFAULT;
+ FILE *f = fopen(path,"r"); check(f != NULL,"X3 movement reference opens");
+ char line[256], previous[32] = ""; unsigned x0 = 0, y0 = 0, checked = 0;
+ check(fgets(line,sizeof(line),f) != NULL,"X3 movement reference header");
+ while (fgets(line,sizeof(line),f)) {
+ char name[32], keys[32]; int tick,x,y,vx,vy,action,sub,pose,group,ground,visibility;
+ check(sscanf(line,"%31[^,],%d,%31[^,],%d,%d,%d,%d,%d,%d,%d,%d,%d,%d",
+ name,&tick,keys,&x,&y,&vx,&vy,&action,&sub,&pose,&group,&ground,&visibility) == 13,
+ "X3 movement reference row");
+ if (strcmp(name,previous)) {
+ check(RtlLoadSnapshot(fixture),"restore movement fixture");
+ zero_replay(10); g_ram[0xbac] = g_ram[0xbaf] = 0;
+ x0 = g_ram[0xbad] << 8 | g_ram[0xbae] << 16;
+ y0 = g_ram[0xbb0] << 8 | g_ram[0xbb1] << 16;
+ snprintf(previous,sizeof(previous),"%s",name);
+ }
+ unsigned input = strstr(keys,"Right") ? SNES_PAD_RIGHT : 0;
+ if (strchr(keys,'A')) input |= SNES_PAD_A;
+ if (strchr(keys,'B')) input |= SNES_PAD_B;
+ frame(input);
+ int dx = (int)(g_ram[0xbac] | g_ram[0xbad] << 8 | g_ram[0xbae] << 16) - (int)x0;
+ int dy = (int)(g_ram[0xbaf] | g_ram[0xbb0] << 8 | g_ram[0xbb1] << 16) - (int)y0;
+ MmxZeroState z = MmxZeroGetState();
+ if (dx != x || dy != y || (int16_t)(g_ram[0xbc2] | g_ram[0xbc3] << 8) != vx ||
+ (int16_t)(g_ram[0xbc4] | g_ram[0xbc5] << 8) != vy ||
+ (tick > 1 && (!z.anim_valid || z.anim_pose != pose))) {
+ fprintf(stderr,"X3 reference mismatch %s/%d: xy %d,%d vs %d,%d pose %u vs %d valid %u\n",
+ name,tick,dx,dy,x,y,z.anim_pose,pose,z.anim_valid); exit(1);
+ }
+ ++checked;
+ }
+ fclose(f); printf("ok: %u original-X3 movement/animation reference frames\n",checked);
+}
+static void zero_menu_pixels(void) {
+ static uint32_t pixels[256 * 224]; unsigned body = 0, icon = 0;
+ check(MmxRendererDraw(pixels,(MmxRenderView){256,0,4.0/3.0},false),"Zero menu renders");
+ const uint32_t *stock = MmxRendererStockFrame(); check(stock != NULL,"menu stock reference exists");
+ for (int y = 0; y < 224; ++y) for (int x = 0; x < 256; ++x) if (pixels[y * 256 + x] != stock[y * 256 + x]) {
+ if (x >= 104 && x < 152 && y >= 128 && y < 184) ++body;
+ else if (x >= 192 && x < 216 && y >= 143 && y < 159) ++icon;
+ else { fprintf(stderr,"Unexpected Zero menu change at %d,%d\n",x,y); exit(1); }
+ }
+ check(body > 100 && icon > 50,"menu replaces body and life head while preserving other pixels");
+}
static void zero_state_checks(const char *assets, const char *fixture, uint8 *start,
uint8 *expected, uint8 *actual, size_t cap) {
check(fixture != NULL && MmxZeroLoad(assets), "Zero local assets load");
@@ -53,6 +99,8 @@ static void zero_state_checks(const char *assets, const char *fixture, uint8 *st
g_config.widescreen = false;
int w,h; MmxPrepareFrame(1280,720,&w,&h);
check(w == 256 && g_mmx_custom_renderer, "Zero activates native-width compositor");
+ zero_motion_checks(fixture);
+ check(RtlLoadSnapshot(fixture), "restore for combo probe");
for (int i=0;i<181;++i) frame(SNES_PAD_Y);
frame(0);
check(MmxZeroGetState().combo == 1, "full charge stores two follow-ups");
@@ -122,6 +170,25 @@ static void zero_state_checks(const char *assets, const char *fixture, uint8 *st
frame(0); zero_replay(8);
check(zero_projectiles(0x10) == 5, "arm upgrade permits five charged Torpedo projectiles");
zero_capture(capture, ".torpedo.cap");
+ check(RtlLoadSnapshot(fixture), "restore for life pickup probe");
+ /* Spawn the retail 1-up actor in a copied test fixture. Collection and the
+ * life counter still execute the original game's collision/update path. */
+ memset(g_ram + 0x1628,0,48); g_ram[0x1628] = 1; g_ram[0x1632] = 4; g_ram[0x1633] = 128;
+ unsigned life_x = (g_ram[0xbad] | g_ram[0xbae] << 8) + 48;
+ unsigned life_y = (g_ram[0xbb0] | g_ram[0xbb1] << 8) - 24;
+ g_ram[0x162d] = (uint8_t)life_x; g_ram[0x162e] = (uint8_t)(life_x >> 8);
+ g_ram[0x1630] = (uint8_t)life_y; g_ram[0x1631] = (uint8_t)(life_y >> 8);
+ zero_replay(20); check(g_ram[0x163e] == 0x11,"retail life pickup submits its head animation");
+ zero_capture(capture, ".life.cap");
+ unsigned lives = g_ram[0x1f80];
+ memcpy(g_ram + 0xbad,g_ram + 0x162d,2); memcpy(g_ram + 0xbb0,g_ram + 0x1630,2);
+ zero_replay(8); check(g_ram[0x1f80] == lives + 1,"Zero collects a 1-up through native collision");
+ check(RtlLoadSnapshot(fixture), "restore for pause menu probe");
+ frame(SNES_PAD_START); zero_replay(120);
+ zero_capture(capture, ".menu.cap"); zero_menu_pixels();
+ check(RtlLoadSnapshot(fixture), "restore for upgraded menu probe");
+ g_ram[0x1f99] = 15; frame(SNES_PAD_START); zero_replay(120);
+ zero_capture(capture, ".menu-armor.cap"); zero_menu_pixels();
MmxZeroDisable(); MmxBeforeFrame(); MmxPrepareFrame(1280,720,&w,&h);
check(!g_mmx_custom_renderer && !MmxZeroEnabled(), "disabling returns to stock presentation");
puts("MMX ZERO RUNTIME CHECKS PASSED");
diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c
index 5c0ec01..b5c6e65 100644
--- a/tests/mmx_renderer_test.c
+++ b/tests/mmx_renderer_test.c
@@ -1112,13 +1112,17 @@ static void zero_blink_submission(void) {
/* A submitted body belongs to the OAM epoch even if the next player update
* has already cleared its visibility flag. It must never turn back into X. */
FILE *f = fopen("zero-render-test.bin", "wb"); assert(f);
- const uint8_t header[] = {'M','M','X','Z','E','R','O','4',128,0,128,0,64,0,64,0,117,0,35,0};
+ const uint8_t header[] = {'M','M','X','Z','E','R','O','5',128,0,128,0,64,0,64,0,117,0,35,0};
uint8_t pixels[16384] = {0}, palette[256] = {0}, bounds[40] = {0};
palette[2] = 31; pixels[64 * 128 + 64] = 1;
for (unsigned i = 0; i < 40; i += 4) bounds[i + 2] = bounds[i + 3] = 1;
uint8_t badge[160] = {0};
assert(fwrite(header,sizeof(header),1,f) == 1 && fwrite(palette,sizeof(palette),1,f) == 1 &&
fwrite(bounds,sizeof(bounds),1,f) == 1 && fwrite(badge,sizeof(badge),1,f) == 1);
+ uint8_t animation[MMX_ZERO_ANIMATION_BYTES] = {0};
+ for (unsigned i = 0; i < 136; ++i) { animation[i * 2] = 0x10; animation[i * 2 + 1] = 1; }
+ animation[272] = 1; animation[273] = 128; animation[275] = 253; animation[276] = 255;
+ assert(fwrite(animation,sizeof(animation),1,f) == 1);
for (unsigned i = 0; i < MMX_ZERO_POSES; ++i) assert(fwrite(pixels,sizeof(pixels),1,f) == 1);
assert(!fclose(f) && MmxZeroLoad("zero-render-test.bin"));
remove("zero-render-test.bin");
diff --git a/tests/mmx_zero_test.c b/tests/mmx_zero_test.c
index ab14a3e..3e8f661 100644
--- a/tests/mmx_zero_test.c
+++ b/tests/mmx_zero_test.c
@@ -7,7 +7,7 @@
static uint8_t ram[0x20000], before[0x20000], rom[0x180000], clean[0x180000];
static void asset(const char *path) {
FILE *f = fopen(path, "wb"); assert(f);
- const uint8_t header[] = {'M','M','X','Z','E','R','O','4',128,0,128,0,64,0,64,0,117,0,35,0};
+ const uint8_t header[] = {'M','M','X','Z','E','R','O','5',128,0,128,0,64,0,64,0,117,0,35,0};
uint8_t page[16384] = {0};
assert(fwrite(header, sizeof(header), 1, f) == 1);
assert(fwrite(page, 256, 1, f) == 1);
@@ -15,6 +15,10 @@ static void asset(const char *path) {
for (unsigned i = 0; i < 40; i += 4) { bounds[i] = 0; bounds[i+1] = 248; bounds[i+2] = 12; bounds[i+3] = 10; }
assert(fwrite(bounds, sizeof(bounds), 1, f) == 1);
assert(fwrite(page, 160, 1, f) == 1);
+ uint8_t animation[MMX_ZERO_ANIMATION_BYTES] = {0};
+ for (unsigned i = 0; i < 136; ++i) { animation[i * 2] = 0x10; animation[i * 2 + 1] = 1; }
+ animation[272] = 2; animation[273] = 128; animation[275] = 253; animation[276] = 255;
+ assert(fwrite(animation,sizeof(animation),1,f) == 1);
for (unsigned i = 0; i < MMX_ZERO_POSES; ++i) assert(fwrite(page, sizeof(page), 1, f) == 1);
assert(!fclose(f));
}
diff --git a/tools/apply_zero_hooks.py b/tools/apply_zero_hooks.py
index 052243b..d9ae628 100644
--- a/tools/apply_zero_hooks.py
+++ b/tools/apply_zero_hooks.py
@@ -6,7 +6,7 @@
MARKER = '/*MMX-ZERO*/'
PCS = {0x81971c, 0x819793, 0x8198fc}
-REQUIRED = PCS | {0x81815c, 0x00d3e5, 0x849e73, 0x849c16}
+REQUIRED = PCS | {0x81815c, 0x00d3e5, 0x849e73, 0x849c16, 0x848f07, 0x848eea}
def apply(text):
@@ -17,6 +17,12 @@ def apply(text):
if block:
pc = int(block[1], 16)
output.append(line)
+ if pc in (0x848f07, 0x848eea) and 'cpu->coprocessor_master_cycles = cpu->master_cycles;' in line:
+ if pc == 0x848f07:
+ output.append(f' {MARKER} {{ extern void MmxZeroAnimationStart(unsigned, unsigned); MmxZeroAnimationStart(cpu->D, cpu->A & 255); }}\n')
+ else:
+ output.append(f' {MARKER} {{ extern void MmxZeroAnimationAdvance(unsigned); MmxZeroAnimationAdvance(cpu->D); }}\n')
+ found.add(pc)
if pc == 0x849c16:
load = re.search(r'uint16 (_v\d+) = cpu_read16\(cpu,', line) if '0x0020' in line and 'cpu->X' in line else None
if load:
diff --git a/tools/extract_zero.py b/tools/extract_zero.py
index 48f92df..0cfcee4 100644
--- a/tools/extract_zero.py
+++ b/tools/extract_zero.py
@@ -116,8 +116,11 @@ def extract(path):
# transfers replace the four tiles of the 16x16 health badge at $6860/$6960.
# $81:804A with X=$20/Y=$1C loads its palette from $8C:B0E0 into CGRAM $A0.
hud = rom.read(0x2c8d20, 64) + rom.read(0x2c8de0, 64) + rom.read(0x8cb0e0, 32)
- header = struct.pack('<8s6H', b'MMXZERO4', WIDTH, HEIGHT, ORIGIN_X, ORIGIN_Y, 117, 35)
- return header + struct.pack('<128H', *colors) + bounds + hud + b''.join(poses), colors, poses
+ # Group $4A's 136 sequence offsets and duration/flags/pose records, up to
+ # group $4B. The host mirrors these independently of X1's gameplay flags.
+ animation = rom.read(0x3fcc74, 0x474)
+ header = struct.pack('<8s6H', b'MMXZERO5', WIDTH, HEIGHT, ORIGIN_X, ORIGIN_Y, 117, 35)
+ return header + struct.pack('<128H', *colors) + bounds + hud + animation + b''.join(poses), colors, poses
def main():
From 3f2105d9261bdb7330c3cec49509fb1ba6aebda1 Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Mon, 28 Sep 2026 20:47:26 -0700
Subject: [PATCH 06/16] Use original Zero muzzle offsets with native X1 weapons
---
docs/zero-spike.md | 17 +++++++++-----
src/mmx_zero.c | 39 ++++++++++++++++++++++++++++-----
src/mmx_zero.h | 5 ++++-
src/mods/mmx_zero_plugin.c | 18 ++++++++++++++-
tests/mmx_adaptive_state_test.c | 37 +++++++++++++++++++++++++++++++
tests/mmx_renderer_test.c | 4 +++-
tests/mmx_zero_test.c | 11 +++++++++-
tools/apply_zero_hooks.py | 10 ++++++++-
tools/extract_zero.py | 6 +++--
9 files changed, 128 insertions(+), 19 deletions(-)
diff --git a/docs/zero-spike.md b/docs/zero-spike.md
index 34e0f39..cc7e5a1 100644
--- a/docs/zero-spike.md
+++ b/docs/zero-spike.md
@@ -29,9 +29,9 @@ normalized X3 SHA-256:
`65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7`.
It extracts 117 body, 21 saber-body and 14 blade poses, palettes, the ground/air
saber bounds, and Zero's original X3 health-bar badge and palette. The current
-cache format is `MMXZERO5`; rerun extraction to replace older caches. It also
+cache format is `MMXZERO6`; rerun extraction to replace older caches. It also
extracts the 136 original body-animation sequences, including Zero's additional
-hair, dash and jump poses.
+hair, dash and jump poses, plus the original pose-specific firing offsets.
`--sheet path.png` also writes a labeled contact sheet if Pillow
is installed. ROMs, generated code and extracted graphics stay local.
@@ -163,12 +163,17 @@ Full-port progress on `feat/x3-zero-port`:
helmet/face pixels; vanilla X3 itself retains X's life icon. Native 1-up
collection increments the life count normally. Menu pixel comparisons change
only the character and head areas, including the fully upgraded menu.
+- Native projectile initializers use Zero's original pose-specific X/Y firing
+ offsets from X3 `$39:9161/$39:91D9`, translated to the shared foot position.
+ X1 still owns facing, spread patterns, trajectories and weapon effects.
+ All eight special weapons produce their expected normal/charged native
+ projectile classes and spend energy; both firing directions are checked.
- Saves now use game chunk v5 for Zero's animation phase, retaining v4 combat
saves and older formats. Captures use v4, with v2/v3 still readable. Disabled
saves remain v3. Complete snapshot, replay, rollback and rewind checks pass.
-Owner playtest follow-up: projectile origins do not yet line up with Zero's arm.
-The reported X tiles during invulnerability are fixed as described above.
+Owner playtest follow-up: projectile origins and the reported X tiles during
+invulnerability are fixed as described above.
These issues are tracked in `beads-8wg.1.29`. Full-port work is on `feat/x3-zero-port`, tracked in
`beads-8wg.1.31`. The earlier hand-drawn red HUD letter was an approximation and
has been replaced with the original X3 Zero badge, including its original frame
@@ -193,8 +198,8 @@ together. Swapping is not implemented by this HUD update.
Current movement physics, dash effects and the first two projectile graphics
are X1's. Charge/hit palette effects and saber audio still need adaptation.
3. Adapt and validate all eight X1 weapons in their normal and charged forms,
- including body/arm poses, emission points and weapon palettes. Only Homing
- Torpedo received runtime acceptance in this spike.
+ including body/arm poses, emission points and weapon palettes. All eight now
+ pass normal/charged activation and energy checks; visual effects need playtesting.
4. Play through representative tight spaces, moving platforms, doors, water,
ride armor and all bosses, then the full campaign in native and widescreen.
Story/NPC behavior is preserved by the scope of the hooks but has not received
diff --git a/src/mmx_zero.c b/src/mmx_zero.c
index 76bb124..c742f22 100644
--- a/src/mmx_zero.c
+++ b/src/mmx_zero.c
@@ -9,6 +9,7 @@ static uint8_t saber_bounds[40];
static uint8_t hud_tiles[128];
static uint16_t hud_colors[16];
static uint8_t animation[MMX_ZERO_ANIMATION_BYTES];
+static uint8_t muzzle[MMX_ZERO_MUZZLE_BYTES];
static MmxZeroState state;
_Static_assert(offsetof(MmxZeroState, anim_offset) == MMX_ZERO_LEGACY_STATE_SIZE,
"Keep the v4 combat-state prefix readable");
@@ -34,16 +35,18 @@ bool MmxZeroLoad(const char *path) {
FILE *f = path ? fopen(path, "rb") : NULL;
if (!f) return false;
uint8_t header[20], palette[256], bounds[40], hud[160], anim[MMX_ZERO_ANIMATION_BYTES];
+ uint8_t emission[MMX_ZERO_MUZZLE_BYTES];
size_t size = (size_t)MMX_ZERO_POSES * MMX_ZERO_WIDTH * MMX_ZERO_HEIGHT;
uint8_t *data = NULL;
bool ok = fread(header, 1, sizeof(header), f) == sizeof(header) &&
- !memcmp(header, "MMXZERO5", 8) && word(header + 8) == MMX_ZERO_WIDTH &&
+ !memcmp(header, "MMXZERO6", 8) && word(header + 8) == MMX_ZERO_WIDTH &&
word(header + 10) == MMX_ZERO_HEIGHT && word(header + 12) == 64 &&
word(header + 14) == 64 && word(header + 16) == 117 && word(header + 18) == 35 &&
fread(palette, 1, sizeof(palette), f) == sizeof(palette) &&
fread(bounds, 1, sizeof(bounds), f) == sizeof(bounds) &&
fread(hud, 1, sizeof(hud), f) == sizeof(hud) &&
- fread(anim, 1, sizeof(anim), f) == sizeof(anim);
+ fread(anim, 1, sizeof(anim), f) == sizeof(anim) &&
+ fread(emission, 1, sizeof(emission), f) == sizeof(emission);
if (ok) { data = malloc(size); ok = data && fread(data, 1, size, f) == size && fgetc(f) == EOF; }
fclose(f);
if (ok) for (unsigned i = 0; i < sizeof(bounds); i += 4)
@@ -53,10 +56,13 @@ bool MmxZeroLoad(const char *path) {
unsigned p = word(anim + i * 2);
if (p < 272 || p + 3 > sizeof(anim) || !anim[p] || anim[p + 2] >= 117) { ok = false; break; }
}
+ if (ok) for (unsigned i = 0; i < 117; ++i)
+ if ((emission[i] & 1) || emission[i] > 74) { ok = false; break; }
if (!ok) { free(data); return false; }
MmxZeroDisable(); poses = data;
memcpy(saber_bounds, bounds, sizeof(bounds));
memcpy(animation, anim, sizeof(animation));
+ memcpy(muzzle, emission, sizeof(muzzle));
memcpy(hud_tiles, hud, sizeof(hud_tiles));
for (unsigned i = 0; i < 16; ++i) hud_colors[i] = (uint16_t)(word(hud + 128 + 2 * i) & 0x7fff);
for (unsigned i = 0; i < 128; ++i) colors[i] = (uint16_t)(word(palette + 2 * i) & 0x7fff);
@@ -81,8 +87,7 @@ static void animation_record(unsigned offset) {
state.anim_timer = animation[offset]; state.anim_flags = animation[offset + 1];
state.anim_pose = animation[offset + 2]; state.anim_valid = 1;
}
-void MmxZeroAnimationStart(unsigned object, unsigned sequence) {
- if (!poses || object != 0xba8) return;
+static unsigned sequence_offset(unsigned sequence) {
/* X1 group 0 -> original X3 group $4A. Aliases are kept: firing overlays
* resume at interior records, not at the beginning of a movement cycle.
* X1's two Hadouken actions use Zero's forward buster/recovery poses. */
@@ -94,8 +99,11 @@ void MmxZeroAnimationStart(unsigned object, unsigned sequence) {
0x76,0x77,0x78,0x79,0x7a,0x7b,0x7c,0x7d,0x7e,0x7f,0x80,0x81,0x82,0x83,0x84,0x30,
0x34
};
- if (sequence >= sizeof(map)) { state.anim_valid = 0; return; }
- animation_record(word(animation + map[sequence] * 2));
+ return sequence < sizeof(map) ? word(animation + map[sequence] * 2) : 0;
+}
+void MmxZeroAnimationStart(unsigned object, unsigned sequence) {
+ if (!poses || object != 0xba8) return;
+ animation_record(sequence_offset(sequence));
}
void MmxZeroAnimationAdvance(unsigned object) {
if (!poses || object != 0xba8 || !state.anim_valid) return;
@@ -107,6 +115,25 @@ void MmxZeroAnimationAdvance(unsigned object) {
}
animation_record(next);
}
+unsigned MmxZeroMuzzle(const uint8_t r[0x20000], unsigned object,
+ unsigned native_index, unsigned axis, unsigned original) {
+ if (!poses || !r || object < 0x1228 || object >= 0x1428 ||
+ (object & 63) != 0x28 || axis > 1) return original;
+ unsigned pose = state.anim_valid ? state.anim_pose : r[0xbbf] & 127;
+ unsigned offset = pose < 117 ? muzzle[pose] : 0;
+ if (!offset) {
+ /* Delayed/formation projectiles can initialize after the firing overlay
+ * ends. X1 retains its firing sequence index in the projectile's $3C. */
+ unsigned record = sequence_offset(native_index / 2);
+ if (!record) return original;
+ pose = animation[record + 2]; offset = muzzle[pose];
+ }
+ if (!offset) return original;
+ /* X3 stores signed Y then left-facing X. X1's native helpers mirror a
+ * positive X and sign-extend Y. Keep their later spread/trajectory offsets. */
+ return axis ? (unsigned)(uint8_t)(muzzle[120 + offset] - 8) :
+ (unsigned)(uint8_t)(-(int8_t)muzzle[121 + offset]);
+}
int MmxZeroHudColor(unsigned x, unsigned y) {
/* Original X3 tile/palette data, independent of body visibility. */
if (!MmxZeroEnabled() || x >= 16 || y >= 16) return -1;
diff --git a/src/mmx_zero.h b/src/mmx_zero.h
index a9a6b05..86f7f3e 100644
--- a/src/mmx_zero.h
+++ b/src/mmx_zero.h
@@ -4,7 +4,8 @@
#include
enum { MMX_ZERO_WIDTH = 128, MMX_ZERO_HEIGHT = 128, MMX_ZERO_POSES = 152,
- MMX_ZERO_ANIMATION_BYTES = 0x474, MMX_ZERO_LEGACY_STATE_SIZE = 12 };
+ MMX_ZERO_ANIMATION_BYTES = 0x474, MMX_ZERO_MUZZLE_BYTES = 196,
+ MMX_ZERO_LEGACY_STATE_SIZE = 12 };
typedef struct MmxZeroState {
uint16_t charge, slash, projectile;
uint8_t combo, cooldown, air, facing;
@@ -27,6 +28,8 @@ unsigned MmxZeroUpgradeBits(unsigned pc, unsigned original);
void MmxZeroPlayerTick(uint8_t ram[0x20000]);
void MmxZeroAnimationStart(unsigned object, unsigned sequence);
void MmxZeroAnimationAdvance(unsigned object);
+unsigned MmxZeroMuzzle(const uint8_t ram[0x20000], unsigned object,
+ unsigned native_index, unsigned axis, unsigned original);
unsigned MmxZeroWeaponTick(uint8_t ram[0x20000], unsigned object, unsigned active);
unsigned MmxZeroDamage(uint8_t ram[0x20000], unsigned enemy, unsigned projectile, unsigned original);
unsigned MmxZeroHitbox(const uint8_t ram[0x20000], unsigned enemy, unsigned projectile, unsigned original);
diff --git a/src/mods/mmx_zero_plugin.c b/src/mods/mmx_zero_plugin.c
index 39d1c1b..c78ce09 100644
--- a/src/mods/mmx_zero_plugin.c
+++ b/src/mods/mmx_zero_plugin.c
@@ -16,6 +16,20 @@ static void hook(CpuState *cpu, uint32_t pc) {
case 0x01815c: MmxZeroPlayerTick(g_ram); break;
case 0x048f07: MmxZeroAnimationStart(cpu->D, cpu->A & 255); break;
case 0x048eea: MmxZeroAnimationAdvance(cpu->D); break;
+ case 0x01a57d: case 0x038b6f: case 0x038d87: case 0x038ed7:
+ case 0x03951d: case 0x039841: case 0x039993: case 0x03a3cd:
+ case 0x01a589: case 0x038b7b: case 0x038d93: case 0x038ee3:
+ case 0x039529: case 0x03984d: case 0x03999f: case 0x03a3d9: {
+ unsigned axis = (pc & 0xffff) == 0xa589 || (pc & 0xffff) == 0x8b7b ||
+ (pc & 0xffff) == 0x8d93 || (pc & 0xffff) == 0x8ee3 ||
+ (pc & 0xffff) == 0x9529 || (pc & 0xffff) == 0x984d ||
+ (pc & 0xffff) == 0x999f || (pc & 0xffff) == 0xa3d9;
+ unsigned value = MmxZeroMuzzle(g_ram,cpu->D,cpu->X,axis,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 0x00d3e7: {
unsigned value = MmxZeroWeaponTick(g_ram, cpu->D, cpu->A & 255);
cpu->A = (cpu->A & 0xff00) | value;
@@ -59,7 +73,9 @@ static void hook(CpuState *cpu, uint32_t pc) {
}
void MmxZeroRegisterHooks(void) {
const unsigned pcs[] = {0x00d6a7, 0x00d76a, 0x01971f, 0x019796, 0x0198ff,
- 0x01815c, 0x00d3e7, 0x049e76, 0x049c19, 0x048f07, 0x048eea};
+ 0x01815c, 0x00d3e7, 0x049e76, 0x049c19, 0x048f07, 0x048eea,
+ 0x01a57d,0x038b6f,0x038d87,0x038ed7,0x03951d,0x039841,0x039993,0x03a3cd,
+ 0x01a589,0x038b7b,0x038d93,0x038ee3,0x039529,0x03984d,0x03999f,0x03a3d9};
for (unsigned i = 0; i < sizeof(pcs) / sizeof(pcs[0]); ++i)
interp_bridge_set_pre_opcode_hook(pcs[i], hook);
}
diff --git a/tests/mmx_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c
index cc7c355..119fe42 100644
--- a/tests/mmx_adaptive_state_test.c
+++ b/tests/mmx_adaptive_state_test.c
@@ -88,6 +88,42 @@ static void zero_menu_pixels(void) {
}
check(body > 100 && icon > 50,"menu replaces body and life head while preserving other pixels");
}
+static void zero_weapon_checks(const char *fixture, const char *capture) {
+ for (unsigned weapon = 0; weapon <= 8; ++weapon) for (unsigned charged = 0; charged < 2; ++charged) {
+ check(RtlLoadSnapshot(fixture),"restore weapon fixture");
+ if (weapon) {
+ g_ram[0x1f85 + weapon * 2] = 0; g_ram[0x1f86 + weapon * 2] = 0xdc;
+ for (int i = 0; i < 6; ++i) frame(SNES_PAD_R);
+ zero_replay(1); check(g_ram[0xbdb] == weapon * 2,"native switch selects requested X1 weapon");
+ }
+ if (charged) {
+ g_ram[0x1f99] = 2;
+ for (int i = 0; i < 181; ++i) frame(SNES_PAD_Y);
+ }
+ unsigned seen = 0;
+ for (int i = 0; i < 35; ++i) {
+ frame(!charged && i == 0 ? SNES_PAD_Y : 0);
+ for (unsigned d = 0x1228; d < 0x1428; d += 64) if (g_ram[d] && g_ram[d + 10] < 32)
+ seen |= 1u << g_ram[d + 10];
+ if (i == 0 && !charged && !weapon) {
+ unsigned d = 0x1228;
+ check((int)(g_ram[d+5] | g_ram[d+6]<<8) - (int)(g_ram[0xbad] | g_ram[0xbae]<<8) == 27 &&
+ (int)(g_ram[d+8] | g_ram[d+9]<<8) - (int)(g_ram[0xbb0] | g_ram[0xbb1]<<8) == -7,
+ "standing shot uses original Zero pose 31 muzzle, translated to X1 feet");
+ }
+ if (i == 2) { char suffix[64]; snprintf(suffix,sizeof(suffix),".weapon%u-%u.cap",weapon,charged); zero_capture(capture,suffix); }
+ }
+ unsigned expected_kind = weapon ? weapon + (charged ? 15 : 6) : charged ? 3 : 0;
+ check(seen & (1u << expected_kind),"X1 weapon produces its expected normal/charged projectile class");
+ if (weapon) check((g_ram[0x1f85 + weapon*2] | (g_ram[0x1f86 + weapon*2] & 63)<<8) < 0x1c00,
+ "X1 weapon spends native energy");
+ }
+ check(RtlLoadSnapshot(fixture),"restore for left-facing muzzle");
+ frame(SNES_PAD_LEFT); frame(0); frame(SNES_PAD_Y);
+ check((int)(g_ram[0x122d] | g_ram[0x122e]<<8) - (int)(g_ram[0xbad] | g_ram[0xbae]<<8) == -27 &&
+ (int)(g_ram[0x1230] | g_ram[0x1231]<<8) - (int)(g_ram[0xbb0] | g_ram[0xbb1]<<8) == -7,
+ "native facing mirrors Zero muzzle without changing its height");
+}
static void zero_state_checks(const char *assets, const char *fixture, uint8 *start,
uint8 *expected, uint8 *actual, size_t cap) {
check(fixture != NULL && MmxZeroLoad(assets), "Zero local assets load");
@@ -189,6 +225,7 @@ static void zero_state_checks(const char *assets, const char *fixture, uint8 *st
check(RtlLoadSnapshot(fixture), "restore for upgraded menu probe");
g_ram[0x1f99] = 15; frame(SNES_PAD_START); zero_replay(120);
zero_capture(capture, ".menu-armor.cap"); zero_menu_pixels();
+ zero_weapon_checks(fixture,capture);
MmxZeroDisable(); MmxBeforeFrame(); MmxPrepareFrame(1280,720,&w,&h);
check(!g_mmx_custom_renderer && !MmxZeroEnabled(), "disabling returns to stock presentation");
puts("MMX ZERO RUNTIME CHECKS PASSED");
diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c
index b5c6e65..faa9ad8 100644
--- a/tests/mmx_renderer_test.c
+++ b/tests/mmx_renderer_test.c
@@ -1112,7 +1112,7 @@ static void zero_blink_submission(void) {
/* A submitted body belongs to the OAM epoch even if the next player update
* has already cleared its visibility flag. It must never turn back into X. */
FILE *f = fopen("zero-render-test.bin", "wb"); assert(f);
- const uint8_t header[] = {'M','M','X','Z','E','R','O','5',128,0,128,0,64,0,64,0,117,0,35,0};
+ const uint8_t header[] = {'M','M','X','Z','E','R','O','6',128,0,128,0,64,0,64,0,117,0,35,0};
uint8_t pixels[16384] = {0}, palette[256] = {0}, bounds[40] = {0};
palette[2] = 31; pixels[64 * 128 + 64] = 1;
for (unsigned i = 0; i < 40; i += 4) bounds[i + 2] = bounds[i + 3] = 1;
@@ -1123,6 +1123,8 @@ static void zero_blink_submission(void) {
for (unsigned i = 0; i < 136; ++i) { animation[i * 2] = 0x10; animation[i * 2 + 1] = 1; }
animation[272] = 1; animation[273] = 128; animation[275] = 253; animation[276] = 255;
assert(fwrite(animation,sizeof(animation),1,f) == 1);
+ uint8_t muzzle[MMX_ZERO_MUZZLE_BYTES] = {0};
+ assert(fwrite(muzzle,sizeof(muzzle),1,f) == 1);
for (unsigned i = 0; i < MMX_ZERO_POSES; ++i) assert(fwrite(pixels,sizeof(pixels),1,f) == 1);
assert(!fclose(f) && MmxZeroLoad("zero-render-test.bin"));
remove("zero-render-test.bin");
diff --git a/tests/mmx_zero_test.c b/tests/mmx_zero_test.c
index 3e8f661..060a81a 100644
--- a/tests/mmx_zero_test.c
+++ b/tests/mmx_zero_test.c
@@ -7,7 +7,7 @@
static uint8_t ram[0x20000], before[0x20000], rom[0x180000], clean[0x180000];
static void asset(const char *path) {
FILE *f = fopen(path, "wb"); assert(f);
- const uint8_t header[] = {'M','M','X','Z','E','R','O','5',128,0,128,0,64,0,64,0,117,0,35,0};
+ const uint8_t header[] = {'M','M','X','Z','E','R','O','6',128,0,128,0,64,0,64,0,117,0,35,0};
uint8_t page[16384] = {0};
assert(fwrite(header, sizeof(header), 1, f) == 1);
assert(fwrite(page, 256, 1, f) == 1);
@@ -19,6 +19,9 @@ static void asset(const char *path) {
for (unsigned i = 0; i < 136; ++i) { animation[i * 2] = 0x10; animation[i * 2 + 1] = 1; }
animation[272] = 2; animation[273] = 128; animation[275] = 253; animation[276] = 255;
assert(fwrite(animation,sizeof(animation),1,f) == 1);
+ uint8_t muzzle[MMX_ZERO_MUZZLE_BYTES] = {0};
+ muzzle[0] = 2; muzzle[122] = 255; muzzle[123] = 232;
+ assert(fwrite(muzzle,sizeof(muzzle),1,f) == 1);
for (unsigned i = 0; i < MMX_ZERO_POSES; ++i) assert(fwrite(page, sizeof(page), 1, f) == 1);
assert(!fclose(f));
}
@@ -38,6 +41,12 @@ int main(void) {
asset("zero-test.bin"); assert(MmxZeroLoad("zero-test.bin"));
assert(MmxZeroUpgradeBits(0x81971c, 2) == 10);
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);
+ assert(MmxZeroMuzzle(ram,0xe68,0,0,16) == 16); /* NPCs are outside the player pool. */
+ ram[0xbbf] = 100; /* A delayed shot uses its original firing sequence. */
+ assert(MmxZeroMuzzle(ram,0x1228,0,0,16) == 24);
+ assert(MmxZeroMuzzle(ram,0x1228,255,0,16) == 16);
const uint8_t normal[] = {0,255,6,14,0,0,255,7,17,8};
const uint8_t dash[] = {0,5,6,8,0,0,255,9,17,8};
memcpy(rom+0x32552,normal,10); memcpy(rom+0x33b38,dash,10);
diff --git a/tools/apply_zero_hooks.py b/tools/apply_zero_hooks.py
index d9ae628..740a066 100644
--- a/tools/apply_zero_hooks.py
+++ b/tools/apply_zero_hooks.py
@@ -6,7 +6,9 @@
MARKER = '/*MMX-ZERO*/'
PCS = {0x81971c, 0x819793, 0x8198fc}
-REQUIRED = PCS | {0x81815c, 0x00d3e5, 0x849e73, 0x849c16, 0x848f07, 0x848eea}
+MUZZLE_PCS = {0x81a566, 0x81a578, 0x838b6a, 0x838d82, 0x838eb4,
+ 0x839518, 0x83983c, 0x839974, 0x83a3a9}
+REQUIRED = PCS | MUZZLE_PCS | {0x81815c, 0x00d3e5, 0x849e73, 0x849c16, 0x848f07, 0x848eea}
def apply(text):
@@ -17,6 +19,12 @@ def apply(text):
if block:
pc = int(block[1], 16)
output.append(line)
+ if pc in MUZZLE_PCS:
+ load = re.search(r'uint8 (_v\d+) = cpu_read8.*0xbe3([9a])', line)
+ if load:
+ axis = int(load[2] == 'a')
+ output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern unsigned MmxZeroMuzzle(const uint8_t *, unsigned, unsigned, unsigned, unsigned); {load[1]} = (uint8)MmxZeroMuzzle(g_ram, cpu->D, cpu->X, {axis}, {load[1]}); }}\n')
+ found.add(pc)
if pc in (0x848f07, 0x848eea) and 'cpu->coprocessor_master_cycles = cpu->master_cycles;' in line:
if pc == 0x848f07:
output.append(f' {MARKER} {{ extern void MmxZeroAnimationStart(unsigned, unsigned); MmxZeroAnimationStart(cpu->D, cpu->A & 255); }}\n')
diff --git a/tools/extract_zero.py b/tools/extract_zero.py
index 0cfcee4..17a7bb2 100644
--- a/tools/extract_zero.py
+++ b/tools/extract_zero.py
@@ -119,8 +119,10 @@ def extract(path):
# Group $4A's 136 sequence offsets and duration/flags/pose records, up to
# group $4B. The host mirrors these independently of X1's gameplay flags.
animation = rom.read(0x3fcc74, 0x474)
- header = struct.pack('<8s6H', b'MMXZERO5', WIDTH, HEIGHT, ORIGIN_X, ORIGIN_Y, 117, 35)
- return header + struct.pack('<128H', *colors) + bounds + hud + animation + b''.join(poses), colors, poses
+ # Original Zero firing-pose map and signed Y/X pairs ($81:8BA9).
+ muzzle = rom.read(0x399161, 120) + rom.read(0x3991d9, 76)
+ header = struct.pack('<8s6H', b'MMXZERO6', WIDTH, HEIGHT, ORIGIN_X, ORIGIN_Y, 117, 35)
+ return header + struct.pack('<128H', *colors) + bounds + hud + animation + muzzle + b''.join(poses), colors, poses
def main():
From 1c1771ee8f10f6799e2e21baebbc39b62b836c74 Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Mon, 28 Sep 2026 21:04:58 -0700
Subject: [PATCH 07/16] Keep Zero through menu fades and draw a front-facing
life head
---
assets/zero/README.md | 24 +++++++++
assets/zero/life-head.png | Bin 0 -> 277 bytes
docs/zero-spike.md | 9 +++-
src/mmx_renderer.c | 85 +++++++++++++++++++++++++-------
src/mmx_zero.c | 28 ++++++++---
tests/mmx_adaptive_state_test.c | 42 ++++++++++++++++
tests/mmx_renderer_test.c | 5 +-
7 files changed, 166 insertions(+), 27 deletions(-)
create mode 100644 assets/zero/README.md
create mode 100644 assets/zero/life-head.png
diff --git a/assets/zero/README.md b/assets/zero/README.md
new file mode 100644
index 0000000..765e47f
--- /dev/null
+++ b/assets/zero/README.md
@@ -0,0 +1,24 @@
+# Zero life head
+
+`life-head.png` is the new 16x16 front-facing life icon, with transparency.
+Its runtime pixel source is `MmxZeroLifeColor` in `src/mmx_zero.c`; the game
+uses the palette extracted from the owner's original X3 ROM. The PNG previews
+those pixels. This is an adaptation for the mod, not an original X3 life icon:
+vanilla X3 retains X's life icon when Zero is selected.
+
+The initial side-view gameplay-head crop was rejected in playtesting.
+The replacement uses a symmetric helmet, green forehead crystal, white cheek
+armor, blue eyes and a closed mouth, fitted directly to the native 16px grid.
+
+The built-in image-generation tool was used for a frontal design reference.
+The game sprite was then redrawn as the explicit 16px source grid so it has
+no resampling blur or off-grid detail. The generated concept is retained locally
+in `_research/zero-life-concept.png`; it is not a shipped game asset.
+
+Generation prompt: “Create a game-ready replacement 1-up/life icon: ZERO from
+Mega Man X3, viewed straight from the front. Use the attached Mega Man X1
+life-head icon only as a style and scale reference. Red helmet, white cheek/ear
+armor, central green forehead crystal, fair face and blue eyes. Symmetric frontal
+head, no shoulders, no body, no side view. Authentic SNES pixel art on a 16x16
+logical grid, enlarged with nearest-neighbor scaling; flat limited colors,
+transparent background, no text, one centered icon.”
diff --git a/assets/zero/life-head.png b/assets/zero/life-head.png
new file mode 100644
index 0000000000000000000000000000000000000000..7ba9dc1b160fbfb9de2906dcc2f629f14cdd05f4
GIT binary patch
literal 277
zcmV+w0qXvVP)HwO{-55j(D
zGUB{i54LWE`I%
b#gE_%W@vm1g9yAa00000NkvXXu0mjf)HiTC
literal 0
HcmV?d00001
diff --git a/docs/zero-spike.md b/docs/zero-spike.md
index cc7e5a1..5dfa92f 100644
--- a/docs/zero-spike.md
+++ b/docs/zero-spike.md
@@ -159,10 +159,15 @@ Full-port progress on `feat/x3-zero-port`:
mismatch that let X's tiles reappear. A renderer regression covers both the
visible transition and the actual hidden frame.
- The pause menu uses Zero's original standing body, including with all armor
- upgrades owned. Menu and pickup life heads are adapted from original Zero
- helmet/face pixels; vanilla X3 itself retains X's life icon. Native 1-up
+ upgrades owned. Menu and pickup life heads use a new front-facing 16x16 sprite
+ shaded with Zero's original palette; vanilla X3 itself retains X's life icon.
+ See [the life-head asset](../assets/zero/life-head.png). Native 1-up
collection increments the life count normally. Menu pixel comparisons change
only the character and head areas, including the fully upgraded menu.
+- Menu fades retain player attribution while X1 repeats OAM without submitting
+ a fresh sprite list. Exact matches to live OAM preserve real hidden blinks.
+ A 480-frame opening/closing check covers unupgraded and fully upgraded menus;
+ both show the same Zero body, including the first visible entry frame.
- Native projectile initializers use Zero's original pose-specific X/Y firing
offsets from X3 `$39:9161/$39:91D9`, translated to the shared foot position.
X1 still owns facing, spread patterns, trajectories and weapon effects.
diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c
index 835cddb..3dc388c 100644
--- a/src/mmx_renderer.c
+++ b/src/mmx_renderer.c
@@ -73,7 +73,15 @@ MmxDisplayViewport MmxRendererDestination(MmxRenderView view, int width, int hei
static unsigned word(const uint8_t *p, unsigned a) { return p[a] | (p[a + 1] << 8); }
static bool zero_weapons_menu(void) {
return MmxZeroEnabled() && frame.ram[0xd1] == 2 && frame.ram[0xd2] == 4 &&
- (frame.ram[0x1f10] == 6 || frame.ram[0x1f10] == 8) && (frame.ram[0xc3] & 128);
+ (((frame.ram[0x1f10] == 6 || frame.ram[0x1f10] == 8) && (frame.ram[0xc3] & 128)) ||
+ (frame.ram[0x1989] == 1 && word(frame.ram,0x198d) == 128 &&
+ word(frame.ram,0x1990) == 160 && (frame.ram[0x199e] == 0 || frame.ram[0x199e] == 0x18)));
+}
+static bool zero_actor(unsigned object, unsigned animation) {
+ return object == 0xba8 || object == 0xc38 || object == 0xc58 || object == 0xc78 ||
+ (object == 0x1928 && animation == 0x5f) || (object == 0x1948 && animation == 0x5e) ||
+ (object == 0x1968 && animation == 0x5d) ||
+ (object == 0x1988 && (animation == 0 || animation == 0x18));
}
static const uint8_t *rom_at(unsigned address, size_t length) {
if ((address & 0xffff) < 0x8000) return NULL;
@@ -214,9 +222,19 @@ void MmxRendererRecordPiece(const uint8_t ram[0x20000], uint16_t d) {
ram[d + 0x10], animation, current_object);
}
void MmxRendererLatchSprites(void) {
- latched_count = building_count;
- latched_stage = building_stage;
- memcpy(latched, building, building_count * sizeof(*building));
+ /* Menu fades suspend OAM construction but the PPU keeps its previous
+ * sprites. Retain only Zero's attribution; drawing still requires an exact
+ * match in that raster's live OAM, so a real hidden blink stays hidden. */
+ if (MmxZeroEnabled() && !building_count && latched_stage == building_stage) {
+ unsigned count = 0;
+ for (unsigned i = 0; i < latched_count; ++i)
+ if (zero_actor(latched[i].object,latched[i].animation)) latched[count++] = latched[i];
+ latched_count = count;
+ } else {
+ latched_count = building_count;
+ latched_stage = building_stage;
+ memcpy(latched, building, building_count * sizeof(*building));
+ }
building_count = 0;
expanded_latched_count = expanded_building_count;
memcpy(expanded_latched, expanded_building, expanded_building_count * sizeof(Piece));
@@ -256,9 +274,34 @@ void MmxRendererBeginFrame(const uint8_t ram[0x20000]) {
frame_zero = MmxZeroGetState();
frame.piece_count = latched_stage == ram[0x1f7a] ? latched_count : 0;
memcpy(frame.pieces, latched, frame.piece_count * sizeof(*latched));
+ if (MmxZeroEnabled()) {
+ /* Loading a snapshot can begin on a fade before the first observed
+ * submission. Reconstruct candidates, then match them against live OAM. */
+ const unsigned actors[] = {0xba8,0xc38,0xc58,0xc78,0x1928,0x1948,0x1968,0x1988};
+ for (unsigned j = 0; j < sizeof(actors)/sizeof(actors[0]); ++j) {
+ unsigned d = actors[j]; bool found = false;
+ if (d >= 0x1928 && !zero_weapons_menu()) continue;
+ for (unsigned i = 0; i < frame.piece_count; ++i) found |= frame.pieces[i].object == d;
+ if (found || !ram[d + 14]) continue;
+ const uint8_t *a = sprite_arrangement(ram[d + 22],ram[d + 23] & 127);
+ if (!a) continue;
+ int x = (int16_t)(word(ram,d + 5) - word(ram,0x1e4d));
+ int y = (int16_t)(word(ram,d + 8) + (int8_t)ram[d + 25] - word(ram,0x1e50));
+ if (d >= 0x1928 && zero_weapons_menu()) { x = word(ram,d + 5); y = word(ram,d + 8); }
+ for (unsigned i = 0; i < a[0] && frame.piece_count < MAX_PIECES; ++i)
+ frame.pieces[frame.piece_count++] = make_piece(a + i * 4,x,y,ram[d + 17] & 64,
+ ram[d + 17] & 63,ram[d + 24],ram[d + 22],d);
+ }
+ }
frame.expanded_count = latched_stage == ram[0x1f7a] ? expanded_latched_count : 0;
memcpy(frame.expanded, expanded_latched, frame.expanded_count * sizeof(Piece));
frame.expand = g_mmx_expanded_sprites;
+ if (MmxZeroEnabled() && frame.expand) for (unsigned i = 0; i < frame.piece_count; ++i) {
+ Piece p = frame.pieces[i]; if (!zero_actor(p.object,p.animation)) continue;
+ bool found = false;
+ for (unsigned j = 0; j < expanded_latched_count; ++j) found |= frame.expanded[j].object == p.object;
+ if (!found && frame.expanded_count < MAX_PIECES) frame.expanded[frame.expanded_count++] = p;
+ }
}
void MmxRendererCaptureLine(const Ppu *p, unsigned line) {
if (!p || line < 1 || line > 224 || line != frame.captured + 1) return;
@@ -662,8 +705,8 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
memset(&stats, 0, sizeof(stats)); stats.pieces = frame.piece_count;
memset(door_cache, 0, sizeof(door_cache));
memset(out, 0, (size_t)view.width * 224 * sizeof(*out));
- bool stage = MmxWidePolicy_IsStageScene(frame.ram);
bool zero_menu = zero_weapons_menu();
+ bool stage = MmxWidePolicy_IsStageScene(frame.ram) && !zero_menu;
prepare_stage_planes();
LightBeam beams[2];
unsigned beam_count = stage ? spark_lights(beams, view.extra) : 0;
@@ -671,7 +714,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
MmxRenderAssetsDeathPaletteFade(frame.ram, frame.lines[0].palette) : 0;
const Piece *pieces = frame.expand && g_mmx_render_asset_repairs ? frame.expanded : frame.pieces;
unsigned piece_count = frame.expand && g_mmx_render_asset_repairs ? frame.expanded_count : frame.piece_count;
- const uint8_t *zero = zero_menu ? MmxZeroMenuPose() : stage ? MmxZeroPose(frame.ram, &frame_zero) : NULL;
+ const uint8_t *zero = stage || zero_menu ? MmxZeroPose(frame.ram, &frame_zero) : NULL;
const uint8_t *blade = stage && zero ? MmxZeroBlade(&frame_zero) : NULL;
Piece waiting[128];
unsigned waiting_count = stage && g_mmx_render_asset_repairs ?
@@ -723,7 +766,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
}
bool replaced[128] = {false};
bool life_drawn[256] = {false};
- bool zero_drawn = false;
+ bool zero_drawn[2] = {false,false};
for (int i = (int)piece_count - 1; i >= 0; --i) {
Piece s = pieces[i]; const MmxSpriteAsset *asset = piece_assets[i];
if (g_mmx_render_asset_repairs && fortress_sound_actor(s.object) &&
@@ -732,18 +775,21 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
* their entire footprint, including x=255 which native D76A clips.
* Only the explicit expanded list can add pieces beyond that budget. */
bool center = g_mmx_render_asset_repairs;
- bool zero_body = zero && s.object == (zero_menu ? 0x1988 : 0xba8);
+ bool menu_body = s.object == 0x1988 && (s.animation == 0 || s.animation == 0x18);
+ bool zero_body = zero && (s.object == 0xba8 || menu_body);
/* Native group $11 is the four-piece life pickup ($81:E33A). Keep its
* real object, flashing submissions, terrain and collection behavior. */
bool zero_life = stage && MmxZeroEnabled() && s.animation == 0x11 &&
s.object >= 0x1628 && s.object < 0x1928 && (s.object - 0x1628) % 48 == 0 &&
frame.ram[s.object + 10] == 4;
- bool zero_armor = zero && (zero_menu ? (s.object == 0x1928 || s.object == 0x1948 || s.object == 0x1968) :
- (s.object == 0xc38 || s.object == 0xc58 || s.object == 0xc78));
+ bool zero_armor = zero && (s.object == 0xc38 || s.object == 0xc58 || s.object == 0xc78 ||
+ (zero_menu && (s.object == 0x1928 || s.object == 0x1948 || s.object == 0x1968)));
+ bool oam_match = false;
if (g_mmx_render_asset_repairs || zero_body || zero_armor || zero_life) for (int slot = 16; slot < 128; ++slot) {
unsigned pos = r->oam[slot * 2], hi = r->high_oam[slot / 4] >> (slot % 4 * 2);
int ox = (pos & 255) | ((hi & 1) << 8); if (ox >= 256) ox -= 512;
if (ox == s.x && (pos >> 8) == ((unsigned)s.y & 255) && r->oam[slot * 2 + 1] == s.attr) {
+ oam_match = true;
replaced[slot] = true; center = true;
}
}
@@ -755,6 +801,8 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
(asset->live_tiles ? s.attr & 255 : 0) : s.attr;
if (asset && asset->live_colors) attr = (attr & ~0x0e00u) | (s.attr & 0x0e00u);
if (zero_armor) continue;
+ if (zero_body && !oam_match && !(frame.expand && frame.ram[s.object + 14] &&
+ (s.x + s.size <= 0 || s.x >= 256))) continue;
if (zero_life) {
if (!life_drawn[s.object >> 5]) {
life_drawn[s.object >> 5] = true;
@@ -765,18 +813,19 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
continue;
}
if (zero_body) {
- if (!zero_drawn) {
- zero_drawn = true;
- int zx = zero_menu ? 128 : (int16_t)(word(frame.ram, 0xbad) - word(frame.ram, 0x1e4d));
- int zy = zero_menu ? 152 : (int16_t)(word(frame.ram, 0xbb0) - word(frame.ram, 0x1e50)) - 8;
+ if (!zero_drawn[menu_body]) {
+ zero_drawn[menu_body] = true;
+ const uint8_t *body = menu_body ? MmxZeroMenuPose() : zero;
+ int zx = menu_body ? 128 : (int16_t)(word(frame.ram, 0xbad) - word(frame.ram, 0x1e4d));
+ int zy = menu_body ? 152 : (int16_t)(word(frame.ram, 0xbb0) - word(frame.ram, 0x1e50)) - 8;
int row = y - zy + 64;
if (row >= 0 && row < MMX_ZERO_HEIGHT) {
const uint16_t *colors = MmxZeroColors();
unsigned z = ((((s.attr >> 12) & 3) * 4 + 2) << 12) | 0x680;
for (int col = 0; col < MMX_ZERO_WIDTH; ++col) {
- unsigned pixel = zero[row * MMX_ZERO_WIDTH + col];
- if (blade && blade[row * MMX_ZERO_WIDTH + col]) pixel = blade[row * MMX_ZERO_WIDTH + col];
- int dx = zx + ((!zero_menu && (frame.ram[0xbb9] & 64)) ? 63 - col : col - 64) + view.extra;
+ unsigned pixel = body[row * MMX_ZERO_WIDTH + col];
+ if (!menu_body && blade && blade[row * MMX_ZERO_WIDTH + col]) pixel = blade[row * MMX_ZERO_WIDTH + col];
+ int dx = zx + ((!menu_body && (frame.ram[0xbb9] & 64)) ? 63 - col : col - 64) + view.extra;
if (pixel && dx >= 0 && dx < view.width) {
objects[dx] = (uint16_t)(z | (pixel & 15));
object_colors[dx] = colors[pixel];
@@ -808,7 +857,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
if (x + size <= 0 || x >= 256) continue;
/* Only the player's HUD badge: not weapon icons, bosses or actors.
* This remains visible while the player blinks or has no body pieces. */
- bool zero_icon = stage && MmxZeroEnabled() && slot == 0 &&
+ bool zero_icon = (stage || zero_menu) && MmxZeroEnabled() && slot == 0 &&
x == 8 && sy == 80 && attr == 0x3486 && size == 16;
bool anchored = stage && hud && sy < 96 && (slot < 16 || (bar_count >= 4 && slot >= bar_first && slot < bar_first + bar_count));
if (anchored) { if (x < 25) x -= view.extra; else if (x >= 216) x += view.extra; }
diff --git a/src/mmx_zero.c b/src/mmx_zero.c
index c742f22..70e6bd7 100644
--- a/src/mmx_zero.c
+++ b/src/mmx_zero.c
@@ -72,12 +72,28 @@ const uint16_t *MmxZeroColors(void) { return colors; }
const uint8_t *MmxZeroMenuPose(void) { return poses; }
int MmxZeroLifeColor(unsigned x, unsigned y) {
if (!poses || x >= 16 || y >= 16) return -2;
- /* Vanilla X3 keeps X's life icon for both characters. Adapt the original
- * Zero idle helmet/face to X1's 16px icon, excluding neck and shoulders. */
- unsigned sx = 56 + x * 20 / 16, sy = 40 + y * 24 / 16;
- if ((sy >= 58 && (sx < 59 || sx > 72)) || (sy >= 60 && (sx < 60 || sx > 68)) || sy >= 63) return -2;
- unsigned pixel = poses[sy * MMX_ZERO_WIDTH + sx];
- return pixel ? colors[pixel] : -2;
+ /* New front-facing 16px life art, shaded with Zero's original palette.
+ * Vanilla X3 retains X's life icon; no original Zero life tile exists. */
+ static const char half[16][9] = {
+ "........", "...K....", "..KRK...", "..KRRKKK",
+ ".KRRRWWG", ".KRrWWgG", "KRRrWKgg", "KRrWWKgg",
+ "KWWrKKKK", "KWWKWEKs", ".KWKssSS", ".KWWKSSS",
+ "..KWWSSS", "...KWKss", "....KKKK", "........"
+ };
+ unsigned color;
+ switch (half[y][x < 8 ? x : 15 - x]) {
+ case 'K': color = 31; break;
+ case 'R': color = 23; break;
+ case 'r': color = 24; break;
+ case 'W': color = 20; break;
+ case 'G': color = 28; break;
+ case 'g': color = 17; break;
+ case 's': color = 26; break;
+ case 'S': color = 27; break;
+ case 'E': color = 18; break;
+ default: return -2;
+ }
+ return colors[color];
}
static void animation_record(unsigned offset) {
if (offset < 272 || offset + 3 > sizeof(animation) || !animation[offset] || animation[offset + 2] >= 117) {
diff --git a/tests/mmx_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c
index 119fe42..4c45b46 100644
--- a/tests/mmx_adaptive_state_test.c
+++ b/tests/mmx_adaptive_state_test.c
@@ -79,6 +79,7 @@ static void zero_motion_checks(const char *fixture) {
}
static void zero_menu_pixels(void) {
static uint32_t pixels[256 * 224]; unsigned body = 0, icon = 0;
+ static uint32_t baseline[48 * 56]; static bool have_baseline;
check(MmxRendererDraw(pixels,(MmxRenderView){256,0,4.0/3.0},false),"Zero menu renders");
const uint32_t *stock = MmxRendererStockFrame(); check(stock != NULL,"menu stock reference exists");
for (int y = 0; y < 224; ++y) for (int x = 0; x < 256; ++x) if (pixels[y * 256 + x] != stock[y * 256 + x]) {
@@ -87,6 +88,14 @@ static void zero_menu_pixels(void) {
else { fprintf(stderr,"Unexpected Zero menu change at %d,%d\n",x,y); exit(1); }
}
check(body > 100 && icon > 50,"menu replaces body and life head while preserving other pixels");
+ bool same_body = true;
+ for (int y = 128; y < 184; ++y) for (int x = 104; x < 152; ++x) {
+ unsigned at = (y - 128) * 48 + x - 104;
+ if (have_baseline) same_body &= pixels[y * 256 + x] == baseline[at];
+ else baseline[at] = pixels[y * 256 + x];
+ }
+ check(same_body,"armor ownership leaves the same Zero menu body");
+ have_baseline = true;
}
static void zero_weapon_checks(const char *fixture, const char *capture) {
for (unsigned weapon = 0; weapon <= 8; ++weapon) for (unsigned charged = 0; charged < 2; ++charged) {
@@ -124,6 +133,37 @@ static void zero_weapon_checks(const char *fixture, const char *capture) {
(int)(g_ram[0x1230] | g_ram[0x1231]<<8) - (int)(g_ram[0xbb0] | g_ram[0xbb1]<<8) == -7,
"native facing mirrors Zero muzzle without changing its height");
}
+static void zero_menu_transition_checks(const char *fixture, const char *capture, unsigned armor) {
+ check(RtlLoadSnapshot(fixture),"restore for menu transition probe");
+ g_ram[0x1f99] = (uint8_t)armor;
+ bool repairs = g_mmx_render_asset_repairs; g_mmx_render_asset_repairs = false;
+ static uint32_t pixels[256 * 224]; unsigned previous = ~0u;
+ for (unsigned direction = 0; direction < 2; ++direction) for (unsigned i = 0; i < 120; ++i) {
+ frame(i == 0 ? SNES_PAD_START : 0);
+ check(MmxRendererDraw(pixels,(MmxRenderView){256,0,4.0/3.0},false),"menu transition renders");
+ MmxRenderStats stats = MmxRendererGetStats();
+ unsigned changed = 0, lit = 0;
+ const uint32_t *stock = MmxRendererStockFrame();
+ /* Keep background repair out of the comparison. Only the character can
+ * change this rectangle; the HUD badge and life head are outside it. */
+ for (int y = 80; y < 184; ++y) for (int x = 104; x < 152; ++x) {
+ unsigned p = y * 256 + x;
+ changed += pixels[p] != stock[p]; lit += stock[p] != 0;
+ }
+ if (lit && changed < 20) {
+ fprintf(stderr,"menu missing Zero %u/%u: changed %u lit %u\n",direction,i,changed,lit);
+ char suffix[80]; snprintf(suffix,sizeof(suffix),".missing%u-%u.cap",direction,i);zero_capture(capture,suffix);
+ check(false,"visible menu-transition body remains Zero");
+ }
+ unsigned signature = g_ram[0xd3] | g_ram[0xd4] << 8 | g_ram[0x1f10] << 16 | g_ram[0xc3] << 24;
+ if (signature != previous) {
+ fprintf(stderr,"menu transition %u/%u state %08x custom %u fallback %u\n",direction,i,signature,stats.custom_lines,stats.fallback_lines);
+ char suffix[80]; snprintf(suffix,sizeof(suffix),".transition%u-%u.cap",direction,i);zero_capture(capture,suffix);
+ previous = signature;
+ }
+ }
+ g_mmx_render_asset_repairs = repairs;
+}
static void zero_state_checks(const char *assets, const char *fixture, uint8 *start,
uint8 *expected, uint8 *actual, size_t cap) {
check(fixture != NULL && MmxZeroLoad(assets), "Zero local assets load");
@@ -225,6 +265,8 @@ static void zero_state_checks(const char *assets, const char *fixture, uint8 *st
check(RtlLoadSnapshot(fixture), "restore for upgraded menu probe");
g_ram[0x1f99] = 15; frame(SNES_PAD_START); zero_replay(120);
zero_capture(capture, ".menu-armor.cap"); zero_menu_pixels();
+ zero_menu_transition_checks(fixture,capture,0);
+ zero_menu_transition_checks(fixture,NULL,15);
zero_weapon_checks(fixture,capture);
MmxZeroDisable(); MmxBeforeFrame(); MmxPrepareFrame(1280,720,&w,&h);
check(!g_mmx_custom_renderer && !MmxZeroEnabled(), "disabling returns to stock presentation");
diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c
index faa9ad8..7a9228b 100644
--- a/tests/mmx_renderer_test.c
+++ b/tests/mmx_renderer_test.c
@@ -1145,7 +1145,10 @@ static void zero_blink_submission(void) {
assert(output[40 * 256 + 40] == 0xff0000);
assert(output[40 * 256 + 41] == 0); /* Native X tile is fully suppressed. */
}
- /* An actual blink submits no body; the renderer must preserve that gap. */
+ /* A menu fade repeats OAM without rebuilding the object list. */
+ MmxRendererLatchSprites(); capture();
+ assert(MmxRendererDraw(output,view,false) && output[40 * 256 + 40] == 0xff0000);
+ /* An actual blink hides OAM; stale attribution must preserve that gap. */
ppu.oam[32] = 0xe000; MmxRendererLatchSprites(); capture();
assert(MmxRendererDraw(output,view,false) && output[40 * 256 + 40] == 0);
MmxZeroDisable(); g_mmx_custom_renderer = false; g_mmx_render_asset_repairs = true;
From ab2d2a360027e1ee114e565f123f5f55c793188b Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Mon, 28 Sep 2026 21:31:33 -0700
Subject: [PATCH 08/16] Fit Zero's weapon effects and replace the life icon
---
assets/zero/README.md | 30 +++++++++---------------
assets/zero/life-head.png | Bin 277 -> 647 bytes
src/mmx_renderer.c | 32 +++++++++++++++++++------
src/mmx_zero.c | 40 ++++++++++++++------------------
src/mmx_zero.h | 2 ++
src/mmx_zero_life.h | 27 +++++++++++++++++++++
src/mods/mmx_zero_plugin.c | 8 +++++++
tests/mmx_adaptive_state_test.c | 19 +++++++++++++--
tests/mmx_renderer_test.c | 11 ++++++++-
tests/mmx_zero_test.c | 10 ++++++++
tools/apply_zero_hooks.py | 9 ++++++-
tools/compile_zero_life.py | 21 +++++++++++++++++
12 files changed, 156 insertions(+), 53 deletions(-)
create mode 100644 src/mmx_zero_life.h
create mode 100644 tools/compile_zero_life.py
diff --git a/assets/zero/README.md b/assets/zero/README.md
index 765e47f..a81b412 100644
--- a/assets/zero/README.md
+++ b/assets/zero/README.md
@@ -1,24 +1,16 @@
# Zero life head
-`life-head.png` is the new 16x16 front-facing life icon, with transparency.
-Its runtime pixel source is `MmxZeroLifeColor` in `src/mmx_zero.c`; the game
-uses the palette extracted from the owner's original X3 ROM. The PNG previews
-those pixels. This is an adaptation for the mod, not an original X3 life icon:
-vanilla X3 retains X's life icon when Zero is selected.
+`life-head.png` is a transparent 24x24 front-facing Zero life icon, shared by the weapons menu and life pickups. It replaces the rejected 16x16 head. The extra room preserves the tall helmet fins, low V-shaped brow, cyan crystal, white side blades, gray cheek pieces and simple peach face from the owner's references. It is new mod art, not an extracted X3 life icon.
-The initial side-view gameplay-head crop was rejected in playtesting.
-The replacement uses a symmetric helmet, green forehead crystal, white cheek
-armor, blue eyes and a closed mouth, fitted directly to the native 16px grid.
+The built-in image-generation tool produced the reference-guided raster. Asset import cropped its opaque bounds, sampled it onto the 24px grid without interpolation, and reduced it to 16 colors. The runtime uses native BGR555. No eyes or mouth were added. The original concept remains in the local ignored research directory as `zero-life-front-concept.png`.
-The built-in image-generation tool was used for a frontal design reference.
-The game sprite was then redrawn as the explicit 16px source grid so it has
-no resampling blur or off-grid detail. The generated concept is retained locally
-in `_research/zero-life-concept.png`; it is not a shipped game asset.
+Run `python tools/compile_zero_life.py` after editing the PNG. This deterministic asset compiler writes `src/mmx_zero_life.h`; no image library is needed at runtime. The larger pickup retains the original collection rules and hitbox.
-Generation prompt: “Create a game-ready replacement 1-up/life icon: ZERO from
-Mega Man X3, viewed straight from the front. Use the attached Mega Man X1
-life-head icon only as a style and scale reference. Red helmet, white cheek/ear
-armor, central green forehead crystal, fair face and blue eyes. Symmetric frontal
-head, no shoulders, no body, no side view. Authentic SNES pixel art on a 16x16
-logical grid, enlarged with nearest-neighbor scaling; flat limited colors,
-transparent background, no text, one centered icon.”
+References supplied by the owner:
+
+- `dacu4gt-2bc6f245-ea43-438a-90e7-3caaa5343b46.png`: frontal Zero helmet identity.
+- `512x512.png`: original X life icon's simple pixel-art style.
+
+Built-in generation prompt:
+
+> Use case: style-transfer. Asset: a production SNES Mega Man X 1-up life pickup sprite. Reference 1 provides Zero's front-facing helmet identity and silhouette. Reference 2 provides the VERY simple low-resolution pixel-art language of the original X life icon. Create ONE front-facing Zero head sprite that bridges these references. It must be a genuinely coarse 24 by 24 logical pixel sprite shown enlarged nearest-neighbor, centered, transparent background, no text or extras. Strict 24x24 pixel grid, each pixel a large uniform square, no smaller details. Recognizable long swept angular red helmet fins forming a low V-shaped brow, pale cyan central diamond, dark crimson crown, white side blades, gray cheek/ear blocks, simple peach face opening, dark outline. The forehead fins should point upward and outward, not cat ears. Use the original X icon's restrained blank face style: NO eyes, mouth, nose or facial expression. Only around 12 flat SNES-like colors. Height about 24 logical pixels, width 22. Preserve helmet silhouette from reference 1, but simplify ALL details to match reference 2. No smooth vector curves, no shaded illustration, no antialiasing, no drop shadow, no glow. Actual transparent background.
diff --git a/assets/zero/life-head.png b/assets/zero/life-head.png
index 7ba9dc1b160fbfb9de2906dcc2f629f14cdd05f4..23e22f68023d5278040b9fed9614a58ffdf3ea60 100644
GIT binary patch
literal 647
zcmV;20(kw2P)o^mu+W&8m}?1%vGJ!i6m~X3XJtcSLQSBd
z=jSehHdy%}9L8|rC(2v$%`waF`wJT7OWwV`o%z1mnc3Y(bW-p=PyY`sC4=v(c5=4z@KY+0*!0-%ns+xuv;R(FDYD4uCJ
zs`y%9v&F%MM(iBe6Kj{RCaxqrMyYJBXetaU)oLcOvbjoy)^nVNJXxha5(fQ^4G{3*>AC;pEKQ{*x{hld_w(P($7j$Lis}6#I@0j0<
z@6ChT5|2FxANYa;E;OJey1D2*{-nOeyGBapuYXEj5fSa~=c(R#$5h}=?*Jb@`?`qT
zML7D$MN89;^B>&Iy9fp}Y;JbOG^?ZkRcZjS!518GqaUF}@aaD610DXi>n^p2P3r&0
hpIiBm{LS2n;sE<$Rg0@rfNuZ*002ovPDHLkV1jhuIWGVJ
literal 277
zcmV+w0qXvVP)HwO{-55j(D
zGUB{i54LWE`I%
b#gE_%W@vm1g9yAa00000NkvXXu0mjf)HiTC
diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c
index 3dc388c..9319cc4 100644
--- a/src/mmx_renderer.c
+++ b/src/mmx_renderer.c
@@ -547,11 +547,12 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x,
if (!stage && layer == 1 && size == 8 && zero_weapons_menu() && (tile & 0xfc00) == 0x0400) {
unsigned number = tile & 1023;
if ((number >= 0xea && number <= 0xec) || (number >= 0xfa && number <= 0xfc)) {
- unsigned col = (number & 15) - 10, row = number >= 0xfa;
- life_color = MmxZeroLifeColor(col * 8 + cx - 4, row * 8 + cy);
tile = 0x080f; /* The menu's own empty panel tile supplies the backdrop. */
}
}
+ if (!stage && layer == 1 && size == 8 && zero_weapons_menu() &&
+ px >= 192 && px < 216 && py >= 140 && py < 164)
+ life_color = MmxZeroLifeColor(px - 192, py - 140);
unsigned number = ((tile & 1023) + cx / 8 + cy / 8 * 16) & 1023;
unsigned address = (PPU_bgTileAdr(p, layer) + number * bpp * 4) & 0x7fff;
const uint8_t *bits = stage && g_mmx_render_asset_repairs && bpp == 4 && !(frame.ram[0x1f7a] == 1 && layer == 1) ?
@@ -777,6 +778,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
bool center = g_mmx_render_asset_repairs;
bool menu_body = s.object == 0x1988 && (s.animation == 0 || s.animation == 0x18);
bool zero_body = zero && (s.object == 0xba8 || menu_body);
+ bool zero_charge = zero && stage && s.object == 0xc98 && s.animation == 0x71;
/* Native group $11 is the four-piece life pickup ($81:E33A). Keep its
* real object, flashing submissions, terrain and collection behavior. */
bool zero_life = stage && MmxZeroEnabled() && s.animation == 0x11 &&
@@ -785,7 +787,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
bool zero_armor = zero && (s.object == 0xc38 || s.object == 0xc58 || s.object == 0xc78 ||
(zero_menu && (s.object == 0x1928 || s.object == 0x1948 || s.object == 0x1968)));
bool oam_match = false;
- if (g_mmx_render_asset_repairs || zero_body || zero_armor || zero_life) for (int slot = 16; slot < 128; ++slot) {
+ if (g_mmx_render_asset_repairs || zero_body || zero_armor || zero_life || zero_charge) for (int slot = 16; slot < 128; ++slot) {
unsigned pos = r->oam[slot * 2], hi = r->high_oam[slot / 4] >> (slot % 4 * 2);
int ox = (pos & 255) | ((hi & 1) << 8); if (ox >= 256) ox -= 512;
if (ox == s.x && (pos >> 8) == ((unsigned)s.y & 255) && r->oam[slot * 2 + 1] == s.attr) {
@@ -806,9 +808,9 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
if (zero_life) {
if (!life_drawn[s.object >> 5]) {
life_drawn[s.object >> 5] = true;
- int lx = (int16_t)(word(frame.ram,s.object + 5) - word(frame.ram,0x1e4d)) - 8;
- int ly = (int16_t)(word(frame.ram,s.object + 8) - word(frame.ram,0x1e50)) - 8;
- sprite(&p,r,lx,ly,s.attr & 0x3fff,16,y,view,objects,false,NULL,0,object_colors,true,2);
+ int lx = (int16_t)(word(frame.ram,s.object + 5) - word(frame.ram,0x1e4d)) - 12;
+ int ly = (int16_t)(word(frame.ram,s.object + 8) - word(frame.ram,0x1e50)) - 12;
+ sprite(&p,r,lx,ly,s.attr & 0x3fff,24,y,view,objects,false,NULL,0,object_colors,true,2);
}
continue;
}
@@ -828,13 +830,29 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
int dx = zx + ((!menu_body && (frame.ram[0xbb9] & 64)) ? 63 - col : col - 64) + view.extra;
if (pixel && dx >= 0 && dx < view.width) {
objects[dx] = (uint16_t)(z | (pixel & 15));
- object_colors[dx] = colors[pixel];
+ /* Charged Sting cycles X1's live player palette. Map Zero's
+ * material/shade roles to that palette instead of discarding
+ * the native cycle; skin and the dark outline remain legible. */
+ static const uint8_t sting_shade[16] = {0,9,4,6,1,14,15,7,8,9,11,10,4,2,3,12};
+ bool sting = !menu_body && frame.ram[0xc31] && pixel >= 16 && pixel < 32;
+ if (sting) {
+ objects[dx] = (uint16_t)((z & ~255u) | (144 + sting_shade[pixel - 16]));
+ object_colors[dx] = -1; /* Live CGRAM already includes fades. */
+ } else object_colors[dx] = colors[pixel];
}
}
}
}
continue;
}
+ if (zero_charge) {
+ /* Spread the original motes around Zero's taller body. Keep each
+ * sparkle's pixel art and native animation, timing and visibility. */
+ int cx = (int16_t)(word(frame.ram,0xbad) - word(frame.ram,0x1e4d));
+ int cy = (int16_t)(word(frame.ram,0xbb0) - word(frame.ram,0x1e50));
+ s.x += (s.x + s.size / 2 - cx) / 4;
+ s.y += (s.y + s.size / 2 - cy) / 4 - 6;
+ }
sprite(&p, r, s.x, s.y, attr, s.size, y, view, objects, !center, asset, s.tile, object_colors, true, false);
}
int bar_first = -1, bar_count = 0;
diff --git a/src/mmx_zero.c b/src/mmx_zero.c
index 70e6bd7..8b1e588 100644
--- a/src/mmx_zero.c
+++ b/src/mmx_zero.c
@@ -1,4 +1,5 @@
#include "mmx_zero.h"
+#include "mmx_zero_life.h"
#include
#include
#include
@@ -71,29 +72,9 @@ bool MmxZeroLoad(const char *path) {
const uint16_t *MmxZeroColors(void) { return colors; }
const uint8_t *MmxZeroMenuPose(void) { return poses; }
int MmxZeroLifeColor(unsigned x, unsigned y) {
- if (!poses || x >= 16 || y >= 16) return -2;
- /* New front-facing 16px life art, shaded with Zero's original palette.
- * Vanilla X3 retains X's life icon; no original Zero life tile exists. */
- static const char half[16][9] = {
- "........", "...K....", "..KRK...", "..KRRKKK",
- ".KRRRWWG", ".KRrWWgG", "KRRrWKgg", "KRrWWKgg",
- "KWWrKKKK", "KWWKWEKs", ".KWKssSS", ".KWWKSSS",
- "..KWWSSS", "...KWKss", "....KKKK", "........"
- };
- unsigned color;
- switch (half[y][x < 8 ? x : 15 - x]) {
- case 'K': color = 31; break;
- case 'R': color = 23; break;
- case 'r': color = 24; break;
- case 'W': color = 20; break;
- case 'G': color = 28; break;
- case 'g': color = 17; break;
- case 's': color = 26; break;
- case 'S': color = 27; break;
- case 'E': color = 18; break;
- default: return -2;
- }
- return colors[color];
+ if (!poses || x >= 24 || y >= 24) return -2;
+ unsigned color = zero_life_pixels[y][x];
+ return color & 0x8000 ? -2 : (int)color;
}
static void animation_record(unsigned offset) {
if (offset < 272 || offset + 3 > sizeof(animation) || !animation[offset] || animation[offset + 2] >= 117) {
@@ -159,6 +140,19 @@ int MmxZeroHudColor(unsigned x, unsigned y) {
(((p[16] >> shift) & 1) << 2) | (((p[17] >> shift) & 1) << 3);
return pixel ? hud_colors[pixel] : -2;
}
+unsigned MmxZeroWeaponOrigin(const uint8_t r[0x20000], unsigned object,
+ unsigned axis, unsigned original) {
+ if (!poses || !r || object < 0x1228 || object >= 0x1428 ||
+ (object & 63) != 0x28) return original;
+ /* These retail routines bypass the shared muzzle table. Apply the offset
+ * where they assign the real object origin, so art and collision agree.
+ * Tornado remains centered vertically; Boomerang keeps its return arc. */
+ unsigned kind = r[object + 10];
+ int delta = axis == 0 && kind == 0x0b ? (r[object + 17] & 64 ? 6 : -6) :
+ axis == 1 && kind == 0x0d ? -8 :
+ axis == 1 && kind == 0x12 ? -6 : 0;
+ return (original + delta) & 0xffff;
+}
static unsigned slash_pose(const MmxZeroState *s) {
/* Vanilla X3 group $4B actions $00/$0E: duration, frame. */
static const uint8_t duration[] = {3,3,1,2,3,3,6,16,3,3,3};
diff --git a/src/mmx_zero.h b/src/mmx_zero.h
index 86f7f3e..e1776d5 100644
--- a/src/mmx_zero.h
+++ b/src/mmx_zero.h
@@ -30,6 +30,8 @@ void MmxZeroAnimationStart(unsigned object, unsigned sequence);
void MmxZeroAnimationAdvance(unsigned object);
unsigned MmxZeroMuzzle(const uint8_t ram[0x20000], unsigned object,
unsigned native_index, unsigned axis, unsigned original);
+unsigned MmxZeroWeaponOrigin(const uint8_t ram[0x20000], unsigned object,
+ unsigned axis, unsigned original);
unsigned MmxZeroWeaponTick(uint8_t ram[0x20000], unsigned object, unsigned active);
unsigned MmxZeroDamage(uint8_t ram[0x20000], unsigned enemy, unsigned projectile, unsigned original);
unsigned MmxZeroHitbox(const uint8_t ram[0x20000], unsigned enemy, unsigned projectile, unsigned original);
diff --git a/src/mmx_zero_life.h b/src/mmx_zero_life.h
new file mode 100644
index 0000000..69bc7ae
--- /dev/null
+++ b/src/mmx_zero_life.h
@@ -0,0 +1,27 @@
+/* Generated by tools/compile_zero_life.py from assets/zero/life-head.png. */
+static const uint16_t zero_life_pixels[24][24] = {
+ {0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000},
+ {0x8000,0x8000,0x0842,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x0842,0x8000,0x8000},
+ {0x8000,0x8000,0x145f,0x0842,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x0842,0x145f,0x8000,0x8000},
+ {0x8000,0x8000,0x145f,0x145f,0x0c14,0x8000,0x8000,0x8000,0x8000,0x0842,0x2071,0x2071,0x1c70,0x2071,0x0842,0x8000,0x8000,0x8000,0x8000,0x0c14,0x145f,0x145f,0x8000,0x8000},
+ {0x8000,0x8000,0x145f,0x145f,0x145f,0x0842,0x8000,0x8000,0x0842,0x1c70,0x1c70,0x2071,0x1c70,0x1c70,0x1c70,0x0842,0x8000,0x8000,0x0842,0x145f,0x145f,0x145f,0x8000,0x8000},
+ {0x8000,0x8000,0x0842,0x145f,0x145f,0x0c14,0x0842,0x0842,0x1c70,0x1c70,0x1c70,0x2071,0x1c70,0x1c70,0x1c70,0x1c70,0x0842,0x0842,0x0c14,0x145f,0x145f,0x0842,0x8000,0x8000},
+ {0x8000,0x8000,0x0842,0x145f,0x145f,0x145f,0x0c14,0x0c2b,0x2071,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x0c2b,0x0c14,0x145f,0x145f,0x145f,0x0842,0x8000,0x8000},
+ {0x8000,0x8000,0x8000,0x0842,0x145f,0x145f,0x0c14,0x0c2b,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x0c2b,0x0c14,0x145f,0x145f,0x0842,0x8000,0x8000,0x8000},
+ {0x8000,0x8000,0x8000,0x0842,0x145f,0x145f,0x145f,0x0c14,0x0c2b,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x0c2b,0x0c14,0x145f,0x145f,0x145f,0x0842,0x8000,0x8000,0x8000},
+ {0x8000,0x8000,0x0842,0x24b2,0x0c14,0x145f,0x145f,0x145f,0x145f,0x1c70,0x2071,0x1c70,0x1c70,0x1c70,0x1c70,0x145f,0x145f,0x145f,0x145f,0x0c14,0x24b2,0x0842,0x8000,0x8000},
+ {0x8000,0x0842,0x0842,0x24b2,0x24b2,0x145f,0x145f,0x145f,0x145f,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x145f,0x145f,0x145f,0x145f,0x24b2,0x24b2,0x0842,0x0842,0x8000},
+ {0x0842,0x77bd,0x0842,0x24b2,0x24b2,0x145f,0x145f,0x145f,0x145f,0x0c2b,0x24b2,0x2071,0x2071,0x2071,0x0c2b,0x145f,0x145f,0x145f,0x145f,0x24b2,0x24b2,0x0842,0x77bd,0x0842},
+ {0x0842,0x6f7a,0x39cf,0x24b2,0x24b2,0x145f,0x145f,0x145f,0x145f,0x6f7a,0x6f7a,0x7355,0x7355,0x7355,0x7355,0x145f,0x145f,0x145f,0x145f,0x24b2,0x24b2,0x39cf,0x6f7a,0x0842},
+ {0x8000,0x6f7a,0x6f7a,0x39cf,0x24b2,0x0c14,0x145f,0x145f,0x145f,0x145f,0x6f7a,0x6f7a,0x7355,0x7355,0x145f,0x145f,0x145f,0x145f,0x0c14,0x24b2,0x4e73,0x6f7a,0x6f7a,0x8000},
+ {0x8000,0x6f7a,0x6f7a,0x6f7a,0x24b2,0x0c45,0x0c14,0x145f,0x145f,0x145f,0x145f,0x6f7a,0x7355,0x145f,0x145f,0x145f,0x145f,0x0c14,0x0c45,0x24b2,0x77bd,0x6f7a,0x6f7a,0x8000},
+ {0x8000,0x0842,0x77bd,0x6f7a,0x0c14,0x0c45,0x0842,0x4f5f,0x0c14,0x145f,0x145f,0x145f,0x145f,0x145f,0x145f,0x0c14,0x4f5f,0x0842,0x0c45,0x0c14,0x6f7a,0x6f7a,0x0842,0x8000},
+ {0x8000,0x0842,0x6f7a,0x145f,0x145f,0x0c45,0x0842,0x4f5f,0x4f5f,0x0c14,0x145f,0x145f,0x145f,0x145f,0x0c14,0x4f5f,0x4f5f,0x0842,0x0c45,0x145f,0x145f,0x6f7a,0x0842,0x8000},
+ {0x8000,0x8000,0x4e73,0x145f,0x0c2b,0x6f7a,0x77bd,0x0842,0x4f5f,0x4f5f,0x4f5f,0x0c14,0x0c14,0x4f5f,0x4f5f,0x4f5f,0x0842,0x77bd,0x6f7a,0x0c2b,0x145f,0x4e73,0x8000,0x8000},
+ {0x8000,0x8000,0x0842,0x145f,0x0c2b,0x6f7a,0x77bd,0x77bd,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x533e,0x77bd,0x77bd,0x6f7a,0x0c2b,0x145f,0x0842,0x8000,0x8000},
+ {0x8000,0x8000,0x8000,0x0842,0x0c14,0x6f7a,0x77bd,0x77bd,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x77bd,0x77bd,0x6f7a,0x0c14,0x0842,0x8000,0x8000,0x8000},
+ {0x8000,0x8000,0x8000,0x8000,0x0842,0x4e73,0x6f7a,0x77bd,0x0842,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x0842,0x77bd,0x6f7a,0x4e73,0x0842,0x8000,0x8000,0x8000,0x8000},
+ {0x8000,0x8000,0x8000,0x8000,0x8000,0x0842,0x6f7a,0x77bd,0x8000,0x0842,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x0842,0x8000,0x77bd,0x6f7a,0x0842,0x8000,0x8000,0x8000,0x8000,0x8000},
+ {0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x0842,0x0842,0x8000,0x8000,0x8000,0x0842,0x0842,0x8000,0x8000,0x8000,0x0842,0x0842,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000},
+ {0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000},
+};
diff --git a/src/mods/mmx_zero_plugin.c b/src/mods/mmx_zero_plugin.c
index c78ce09..f519155 100644
--- a/src/mods/mmx_zero_plugin.c
+++ b/src/mods/mmx_zero_plugin.c
@@ -16,6 +16,13 @@ static void hook(CpuState *cpu, uint32_t pc) {
case 0x01815c: MmxZeroPlayerTick(g_ram); break;
case 0x048f07: MmxZeroAnimationStart(cpu->D, cpu->A & 255); break;
case 0x048eea: MmxZeroAnimationAdvance(cpu->D); break;
+ case 0x028403: case 0x03958f: case 0x039dcf: {
+ unsigned value = MmxZeroWeaponOrigin(g_ram,cpu->D,(pc & 0xffff) != 0x958f,cpu->A);
+ cpu->A = (uint16_t)value;
+ /* The intercepted STA does not set flags. Preserve the original LDA/
+ * ADC flags; only the coordinate being stored changes. */
+ break;
+ }
case 0x01a57d: case 0x038b6f: case 0x038d87: case 0x038ed7:
case 0x03951d: case 0x039841: case 0x039993: case 0x03a3cd:
case 0x01a589: case 0x038b7b: case 0x038d93: case 0x038ee3:
@@ -74,6 +81,7 @@ static void hook(CpuState *cpu, uint32_t pc) {
void MmxZeroRegisterHooks(void) {
const unsigned pcs[] = {0x00d6a7, 0x00d76a, 0x01971f, 0x019796, 0x0198ff,
0x01815c, 0x00d3e7, 0x049e76, 0x049c19, 0x048f07, 0x048eea,
+ 0x028403,0x03958f,0x039dcf,
0x01a57d,0x038b6f,0x038d87,0x038ed7,0x03951d,0x039841,0x039993,0x03a3cd,
0x01a589,0x038b7b,0x038d93,0x038ee3,0x039529,0x03984d,0x03999f,0x03a3d9};
for (unsigned i = 0; i < sizeof(pcs) / sizeof(pcs[0]); ++i)
diff --git a/tests/mmx_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c
index 4c45b46..ff9f729 100644
--- a/tests/mmx_adaptive_state_test.c
+++ b/tests/mmx_adaptive_state_test.c
@@ -84,7 +84,7 @@ static void zero_menu_pixels(void) {
const uint32_t *stock = MmxRendererStockFrame(); check(stock != NULL,"menu stock reference exists");
for (int y = 0; y < 224; ++y) for (int x = 0; x < 256; ++x) if (pixels[y * 256 + x] != stock[y * 256 + x]) {
if (x >= 104 && x < 152 && y >= 128 && y < 184) ++body;
- else if (x >= 192 && x < 216 && y >= 143 && y < 159) ++icon;
+ else if (x >= 192 && x < 216 && y >= 139 && y < 163) ++icon;
else { fprintf(stderr,"Unexpected Zero menu change at %d,%d\n",x,y); exit(1); }
}
check(body > 100 && icon > 50,"menu replaces body and life head while preserving other pixels");
@@ -108,9 +108,10 @@ static void zero_weapon_checks(const char *fixture, const char *capture) {
if (charged) {
g_ram[0x1f99] = 2;
for (int i = 0; i < 181; ++i) frame(SNES_PAD_Y);
+ if (!weapon) zero_capture(capture,".charge.cap");
}
unsigned seen = 0;
- for (int i = 0; i < 35; ++i) {
+ for (int i = 0; i < (charged && weapon == 3 ? 95 : 35); ++i) {
frame(!charged && i == 0 ? SNES_PAD_Y : 0);
for (unsigned d = 0x1228; d < 0x1428; d += 64) if (g_ram[d] && g_ram[d + 10] < 32)
seen |= 1u << g_ram[d + 10];
@@ -121,6 +122,20 @@ static void zero_weapon_checks(const char *fixture, const char *capture) {
"standing shot uses original Zero pose 31 muzzle, translated to X1 feet");
}
if (i == 2) { char suffix[64]; snprintf(suffix,sizeof(suffix),".weapon%u-%u.cap",weapon,charged); zero_capture(capture,suffix); }
+ if (i == 26 && charged && (weapon == 2 || weapon == 3)) {
+ char suffix[64]; snprintf(suffix,sizeof(suffix),".effect%u.cap",weapon); zero_capture(capture,suffix);
+ }
+ if (i == 32 && charged && weapon == 2) zero_capture(capture,".chameleon-next.cap");
+ if (i == 84 && charged && weapon == 3) zero_capture(capture,".shield.cap");
+ if (i == 0 && !charged && weapon == 5)
+ check((int)(g_ram[0x122d] | g_ram[0x122e]<<8) - (int)(g_ram[0xbad] | g_ram[0xbae]<<8) == 22,
+ "Tornado starts six pixels farther along Zero's facing direction");
+ if (i == 0 && !charged && weapon == 7)
+ check((int)(g_ram[0x1230] | g_ram[0x1231]<<8) - (int)(g_ram[0xbb0] | g_ram[0xbb1]<<8) == -8,
+ "Boomerang launches above X's original origin");
+ if (i == 20 && charged && weapon == 3)
+ check((int)(g_ram[0x1230] | g_ram[0x1231]<<8) - (int)(g_ram[0xbb0] | g_ram[0xbb1]<<8) == -6,
+ "Rolling Shield collision and art follow Zero's raised center");
}
unsigned expected_kind = weapon ? weapon + (charged ? 15 : 6) : charged ? 3 : 0;
check(seen & (1u << expected_kind),"X1 weapon produces its expected normal/charged projectile class");
diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c
index 7a9228b..976a1f4 100644
--- a/tests/mmx_renderer_test.c
+++ b/tests/mmx_renderer_test.c
@@ -1114,7 +1114,7 @@ static void zero_blink_submission(void) {
FILE *f = fopen("zero-render-test.bin", "wb"); assert(f);
const uint8_t header[] = {'M','M','X','Z','E','R','O','6',128,0,128,0,64,0,64,0,117,0,35,0};
uint8_t pixels[16384] = {0}, palette[256] = {0}, bounds[40] = {0};
- palette[2] = 31; pixels[64 * 128 + 64] = 1;
+ palette[46] = 31; pixels[64 * 128 + 64] = 23;
for (unsigned i = 0; i < 40; i += 4) bounds[i + 2] = bounds[i + 3] = 1;
uint8_t badge[160] = {0};
assert(fwrite(header,sizeof(header),1,f) == 1 && fwrite(palette,sizeof(palette),1,f) == 1 &&
@@ -1145,6 +1145,15 @@ static void zero_blink_submission(void) {
assert(output[40 * 256 + 40] == 0xff0000);
assert(output[40 * 256 + 41] == 0); /* Native X tile is fully suppressed. */
}
+ ram[0xc31] = 2; ppu.cgram[151] = 31 << 5;
+ capture(); assert(MmxRendererDraw(output,view,false));
+ assert(output[40 * 256 + 40] == 0x00ff00); /* Live charged Sting green. */
+ ppu.cgram[151] = 31 << 10;
+ capture(); assert(MmxRendererDraw(output,view,false));
+ assert(output[40 * 256 + 40] == 0x0000ff); /* Next native palette phase. */
+ ram[0xc31] = 0;
+ capture(); assert(MmxRendererDraw(output,view,false));
+ assert(output[40 * 256 + 40] == 0xff0000); /* Effect ends: original Zero red. */
/* A menu fade repeats OAM without rebuilding the object list. */
MmxRendererLatchSprites(); capture();
assert(MmxRendererDraw(output,view,false) && output[40 * 256 + 40] == 0xff0000);
diff --git a/tests/mmx_zero_test.c b/tests/mmx_zero_test.c
index 060a81a..b1c660a 100644
--- a/tests/mmx_zero_test.c
+++ b/tests/mmx_zero_test.c
@@ -47,6 +47,16 @@ int main(void) {
ram[0xbbf] = 100; /* A delayed shot uses its original firing sequence. */
assert(MmxZeroMuzzle(ram,0x1228,0,0,16) == 24);
assert(MmxZeroMuzzle(ram,0x1228,255,0,16) == 16);
+ ram[0x1232] = 0x0b; ram[0x1239] = 64;
+ assert(MmxZeroWeaponOrigin(ram,0x1228,0,144) == 150);
+ ram[0x1239] = 0;
+ assert(MmxZeroWeaponOrigin(ram,0x1228,0,112) == 106);
+ ram[0x1232] = 0x0d;
+ assert(MmxZeroWeaponOrigin(ram,0x1228,1,111) == 103);
+ ram[0x1232] = 0x12;
+ assert(MmxZeroWeaponOrigin(ram,0x1228,1,111) == 105);
+ assert(MmxZeroWeaponOrigin(ram,0xe68,1,111) == 111);
+ ram[0x1232] = 0;
const uint8_t normal[] = {0,255,6,14,0,0,255,7,17,8};
const uint8_t dash[] = {0,5,6,8,0,0,255,9,17,8};
memcpy(rom+0x32552,normal,10); memcpy(rom+0x33b38,dash,10);
diff --git a/tools/apply_zero_hooks.py b/tools/apply_zero_hooks.py
index 740a066..98963cd 100644
--- a/tools/apply_zero_hooks.py
+++ b/tools/apply_zero_hooks.py
@@ -8,7 +8,8 @@
PCS = {0x81971c, 0x819793, 0x8198fc}
MUZZLE_PCS = {0x81a566, 0x81a578, 0x838b6a, 0x838d82, 0x838eb4,
0x839518, 0x83983c, 0x839974, 0x83a3a9}
-REQUIRED = PCS | MUZZLE_PCS | {0x81815c, 0x00d3e5, 0x849e73, 0x849c16, 0x848f07, 0x848eea}
+ORIGIN_PCS = {0x8283ed: 1, 0x83958c: 0, 0x839dc5: 1}
+REQUIRED = PCS | MUZZLE_PCS | ORIGIN_PCS.keys() | {0x81815c, 0x00d3e5, 0x849e73, 0x849c16, 0x848f07, 0x848eea}
def apply(text):
@@ -18,6 +19,12 @@ def apply(text):
block = re.search(r'cpu_trace_block\(cpu, 0x([0-9A-Fa-f]+)\);', line)
if block:
pc = int(block[1], 16)
+ if pc in ORIGIN_PCS:
+ axis = ORIGIN_PCS[pc]
+ store = re.search(r'cpu_write16\(cpu, 0x00, \(uint16\)\(cpu->D \+ 0x000([58])\), (_v\d+)\);', line)
+ 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)
output.append(line)
if pc in MUZZLE_PCS:
load = re.search(r'uint8 (_v\d+) = cpu_read8.*0xbe3([9a])', line)
diff --git a/tools/compile_zero_life.py b/tools/compile_zero_life.py
new file mode 100644
index 0000000..19c162b
--- /dev/null
+++ b/tools/compile_zero_life.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+"""Pack the authored 24px RGBA life icon into native BGR555 renderer data."""
+from pathlib import Path
+from PIL import Image
+
+root = Path(__file__).resolve().parents[1]
+image = Image.open(root / 'assets/zero/life-head.png').convert('RGBA')
+if image.size != (24, 24):
+ raise SystemExit('Zero life icon must be 24x24')
+rows = []
+for y in range(24):
+ values = []
+ for x in range(24):
+ r, g, b, a = image.getpixel((x, y))
+ color = 0x8000 if a < 128 else (r >> 3) | ((g >> 3) << 5) | ((b >> 3) << 10)
+ values.append(f'0x{color:04x}')
+ rows.append(' {' + ','.join(values) + '},')
+(root / 'src/mmx_zero_life.h').write_text(
+ '/* Generated by tools/compile_zero_life.py from assets/zero/life-head.png. */\n'
+ 'static const uint16_t zero_life_pixels[24][24] = {\n' + '\n'.join(rows) + '\n};\n',
+ encoding='utf-8', newline='\n')
From 26deb2c2f7c7e9836928767afc2e6b34220f6605 Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Mon, 28 Sep 2026 22:03:55 -0700
Subject: [PATCH 09/16] Match Zero charge tiers and original X3 firing recovery
---
CMakeLists.txt | 1 +
docs/zero-spike.md | 22 ++-
src/mmx_renderer.c | 15 +-
src/mmx_rtl.c | 9 +-
src/mmx_zero.c | 187 +++++++++++++++++----
src/mmx_zero.h | 8 +-
src/mods/mmx_zero_plugin.c | 3 +-
tests/data/zero_x3_combat.csv | 280 ++++++++++++++++++++++++++++++++
tests/data/zero_x3_combat.md | 38 +++++
tests/mmx_adaptive_state_test.c | 77 ++++++++-
tests/mmx_zero_test.c | 58 +++++--
tools/apply_zero_hooks.py | 5 +-
12 files changed, 637 insertions(+), 66 deletions(-)
create mode 100644 tests/data/zero_x3_combat.csv
create mode 100644 tests/data/zero_x3_combat.md
diff --git a/CMakeLists.txt b/CMakeLists.txt
index e7d2143..2c564df 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -146,6 +146,7 @@ snesrecomp_target_mmx_state_tests(MegaManXSNESRecomp "${CMAKE_CURRENT_SOURCE_DIR
if(TARGET mmx_state_tests)
target_compile_definitions(mmx_state_tests PRIVATE
+ MMX_ZERO_COMBAT_REFERENCE_DEFAULT="${CMAKE_CURRENT_SOURCE_DIR}/tests/data/zero_x3_combat.csv"
MMX_ZERO_MOTION_REFERENCE_DEFAULT="${CMAKE_CURRENT_SOURCE_DIR}/tests/data/zero_x3_motion.csv")
get_target_property(mmx_state_sources mmx_state_tests SOURCES)
list(REMOVE_ITEM mmx_state_sources "${SNESRECOMP_ROOT}/runner/tests/mmx_state_runtime.c")
diff --git a/docs/zero-spike.md b/docs/zero-spike.md
index 5dfa92f..47305d1 100644
--- a/docs/zero-spike.md
+++ b/docs/zero-spike.md
@@ -50,9 +50,11 @@ is descriptive; the shared runtime does not enforce save isolation.
Controls use X1's button mappings:
- Dash is immediately available; no equipment is granted.
-- With the buster selected, hold fire for 180 gameplay updates, then release for
- the first full shot. After the short recovery, press fire for the second shot,
- then again for the saber. Ground and aerial swings are supported.
+- With the buster selected, charge tiers occur at 21, 81, 141 and 201 gameplay
+ updates. Hold for 201 (~3.35 seconds at 60 Hz), release for the first buster,
+ press again after its recovery for the second, then press again after both
+ beams/effects clear for the saber. Releasing at 141–200 gives two busters
+ without the saber. Ground and aerial swings are supported.
- Switching to a special weapon cancels the stored combo and uses X1's weapon
logic. Charged specials still require the actual arm upgrade.
@@ -173,8 +175,8 @@ Full-port progress on `feat/x3-zero-port`:
X1 still owns facing, spread patterns, trajectories and weapon effects.
All eight special weapons produce their expected normal/charged native
projectile classes and spend energy; both firing directions are checked.
-- Saves now use game chunk v5 for Zero's animation phase, retaining v4 combat
- saves and older formats. Captures use v4, with v2/v3 still readable. Disabled
+- Saves now use game chunk v6 for Zero's firing phase and stored charge tier,
+ retaining v4/v5 saves and older formats. Captures use v5, with v2/v3/v4 still readable. Disabled
saves remain v3. Complete snapshot, replay, rollback and rewind checks pass.
Owner playtest follow-up: projectile origins and the reported X tiles during
@@ -199,9 +201,13 @@ together. Swapping is not implemented by this HUD update.
branch maps all 81 native sequence entries to X3's original animations.
Extracting all 152 poses does not prove that every gameplay state selects
the correct pose or timing.
-2. Match X3 movement/charge timing and projectile presentation where required.
- Current movement physics, dash effects and the first two projectile graphics
- are X1's. Charge/hit palette effects and saber audio still need adaptation.
+2. Match remaining projectile presentation where required. Original charge
+ tiers, first/second body-animation events, air/ground phase transitions and
+ 44/45-frame saber recovery are implemented; see the measured
+ [combat reference](../tests/data/zero_x3_combat.md). The first two projectile
+ graphics and travel/effect lifetimes remain X1's. Saber readiness follows
+ their real retirement, including disappearance effects, without a fixed
+ delay. Charge/hit palette effects and saber audio still need adaptation.
3. Adapt and validate all eight X1 weapons in their normal and charged forms,
including body/arm poses, emission points and weapon palettes. All eight now
pass normal/charged activation and energy checks; visual effects need playtesting.
diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c
index 9319cc4..76d70af 100644
--- a/src/mmx_renderer.c
+++ b/src/mmx_renderer.c
@@ -324,7 +324,7 @@ bool MmxRendererSaveCapture(const char *path) {
if (!frame.valid || !path) return false;
FILE *f = fopen(path, "wb");
if (!f) return false;
- uint32_t header[] = {0x4d4d5843, 4, sizeof(frame) + sizeof(frame_zero)};
+ uint32_t header[] = {0x4d4d5843, 5, sizeof(frame) + sizeof(frame_zero)};
bool ok = fwrite(header, sizeof(header), 1, f) == 1 && fwrite(&frame, sizeof(frame), 1, f) == 1 &&
fwrite(&frame_zero, sizeof(frame_zero), 1, f) == 1;
return fclose(f) == 0 && ok;
@@ -333,16 +333,21 @@ bool MmxRendererLoadCapture(const char *path) {
if (!path) return false;
FILE *f = fopen(path, "rb");
if (!f) return false;
- uint32_t h[3];
+ uint32_t h[3] = {0};
frame.valid = false;
memset(&frame_zero, 0, sizeof(frame_zero));
bool ok = fread(h, sizeof(h), 1, f) == 1 && h[0] == 0x4d4d5843 &&
((h[1] == 2 && h[2] == sizeof(frame)) || (h[1] == 3 && h[2] == sizeof(frame) + MMX_ZERO_LEGACY_STATE_SIZE) ||
- (h[1] == 4 && h[2] == sizeof(frame) + sizeof(frame_zero))) &&
+ (h[1] == 4 && h[2] == sizeof(frame) + MMX_ZERO_ANIMATION_STATE_SIZE) ||
+ (h[1] == 5 && h[2] == sizeof(frame) + sizeof(frame_zero))) &&
fread(&frame, sizeof(frame), 1, f) == 1 &&
frame.captured == 224 && frame.piece_count <= MAX_PIECES && frame.expanded_count <= MAX_PIECES && frame.valid;
- if (ok && h[1] >= 3) ok = fread(&frame_zero, h[1] == 3 ? MMX_ZERO_LEGACY_STATE_SIZE : sizeof(frame_zero), 1, f) == 1 &&
- frame_zero.slash <= 46 && frame_zero.air <= 1 && frame_zero.anim_valid <= 1 && frame_zero.anim_pose < 117;
+ size_t zero_size = h[1] == 3 ? MMX_ZERO_LEGACY_STATE_SIZE :
+ h[1] == 4 ? MMX_ZERO_ANIMATION_STATE_SIZE : sizeof(frame_zero);
+ if (ok && h[1] >= 3) ok = fread(&frame_zero, zero_size, 1, f) == 1 &&
+ frame_zero.slash <= 46 && frame_zero.air <= 1 && frame_zero.anim_valid <= 1 && frame_zero.anim_pose < 117 &&
+ frame_zero.burst <= 2 && (!frame_zero.burst || (frame_zero.burst_offset >= 272 &&
+ frame_zero.burst_offset + 3 <= MMX_ZERO_ANIMATION_BYTES && frame_zero.burst_timer));
ok = ok && fgetc(f) == EOF;
fclose(f); frame.valid = ok; return ok;
}
diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c
index c0b0142..a4ea5a6 100644
--- a/src/mmx_rtl.c
+++ b/src/mmx_rtl.c
@@ -328,7 +328,7 @@ void mmx_host_yield(uint8_t countdown) {
#include "snes/saveload.h"
#define MMX_SAV_CHUNK_MAGIC 0x4D4D5854u /* "MMXT" */
-#define MMX_SAV_CHUNK_VERSION 5u /* Zero combat plus independent X3 animation. */
+#define MMX_SAV_CHUNK_VERSION 6u /* Original Zero charge/burst/recovery state. */
typedef struct MmxSavChunk {
uint32_t magic, version;
@@ -412,10 +412,15 @@ void MmxStateLoadExtra(struct SaveLoadInfo *sli, uint32_t version) {
MmxWideStateLoad(sli);
if (!RtlLoadExecutionState(sli)) g_load_chunk_ok = 0;
if (g_load_chunk.version >= 4) {
- size_t zero_size = g_load_chunk.version == 4 ? MMX_ZERO_LEGACY_STATE_SIZE : sizeof(g_load_zero);
+ size_t zero_size = g_load_chunk.version == 4 ? MMX_ZERO_LEGACY_STATE_SIZE :
+ g_load_chunk.version == 5 ? MMX_ZERO_ANIMATION_STATE_SIZE : sizeof(g_load_zero);
if (RtlStateBytesRemaining(sli) >= zero_size)
sli->func(sli, &g_load_zero, zero_size);
else g_load_chunk_ok = 0;
+ if (g_load_chunk.version < 6) {
+ g_load_zero.saber_ready = g_load_zero.combo != 0;
+ if (g_load_zero.slash > 44) g_load_zero.slash = 44;
+ }
}
}
if (!g_load_chunk_ok)
diff --git a/src/mmx_zero.c b/src/mmx_zero.c
index 8b1e588..448643e 100644
--- a/src/mmx_zero.c
+++ b/src/mmx_zero.c
@@ -14,18 +14,27 @@ static uint8_t muzzle[MMX_ZERO_MUZZLE_BYTES];
static MmxZeroState state;
_Static_assert(offsetof(MmxZeroState, anim_offset) == MMX_ZERO_LEGACY_STATE_SIZE,
"Keep the v4 combat-state prefix readable");
+_Static_assert(offsetof(MmxZeroState, burst_offset) == MMX_ZERO_ANIMATION_STATE_SIZE,
+ "Keep the v5 animation-state prefix readable");
static unsigned word(const uint8_t *p) { return p[0] | p[1] << 8; }
static void putword(uint8_t *p, unsigned v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); }
MmxZeroState MmxZeroGetState(void) { return state; }
void MmxZeroResetState(void) { memset(&state, 0, sizeof(state)); }
void MmxZeroSetState(MmxZeroState s) {
MmxZeroResetState();
- if (poses && s.combo <= 2 && s.slash <= 46 && s.charge <= 180 &&
+ if (poses && s.combo <= 2 && s.slash <= 44 && s.charge <= 201 &&
s.air <= 1 && (s.facing == 0 || s.facing == 64) && s.anim_valid <= 1 &&
+ s.burst <= 2 && s.burst_end <= 1 && s.saber_ready <= 1 && s.burst_transition <= 1 && s.burst_fired <= 1 &&
+ (!s.burst || (s.burst_offset >= 272 && s.burst_offset + 3 <= sizeof(animation) &&
+ s.burst_timer && animation[s.burst_offset + 2] < 117)) &&
(!s.anim_valid || (s.anim_offset >= 272 && s.anim_offset + 3 <= sizeof(animation) &&
s.anim_timer && s.anim_pose < 117)) &&
(!s.projectile || (s.projectile >= 0x1228 && s.projectile < 0x1428 && (s.projectile & 63) == 0x28))) state = s;
}
+unsigned MmxZeroChargeTier(const MmxZeroState *s) {
+ return !s || s->charge < 21 ? 0 : s->charge < 81 ? 4 :
+ s->charge < 141 ? 6 : s->charge < 201 ? 8 : 10;
+}
bool MmxZeroEnabled(void) { return poses != NULL; }
void MmxZeroDisable(void) {
@@ -116,7 +125,8 @@ unsigned MmxZeroMuzzle(const uint8_t r[0x20000], unsigned object,
unsigned native_index, unsigned axis, unsigned original) {
if (!poses || !r || object < 0x1228 || object >= 0x1428 ||
(object & 63) != 0x28 || axis > 1) return original;
- unsigned pose = state.anim_valid ? state.anim_pose : r[0xbbf] & 127;
+ unsigned pose = state.burst ? animation[state.burst_offset + 2] :
+ state.anim_valid ? state.anim_pose : r[0xbbf] & 127;
unsigned offset = pose < 117 ? muzzle[pose] : 0;
if (!offset) {
/* Delayed/formation projectiles can initialize after the firing overlay
@@ -157,7 +167,7 @@ static unsigned slash_pose(const MmxZeroState *s) {
/* Vanilla X3 group $4B actions $00/$0E: duration, frame. */
static const uint8_t duration[] = {3,3,1,2,3,3,6,16,3,3,3};
static const uint8_t pose[] = {0,1,2,2,3,4,5,6,4,1,0};
- unsigned age = s->slash ? s->slash - 1 : 0;
+ unsigned age = s->slash;
for (unsigned i = 0; i < sizeof(duration); ++i) {
if (age < duration[i]) return pose[i] + (s->air ? 7 : 0);
age -= duration[i];
@@ -172,6 +182,7 @@ const uint8_t *MmxZeroPose(const uint8_t ram[0x20000], const MmxZeroState *s) {
/* Old saves without mirrored animation state use the shared pose vocabulary
* until the next native animation start. New saves retain the exact phase. */
unsigned pose = s && s->anim_valid ? s->anim_pose : ram[0xbbf] & 127;
+ if (s && s->burst && s->burst_offset + 3 <= sizeof(animation)) pose = animation[s->burst_offset + 2];
if (pose >= 117) pose = 0;
if (s && s->slash) pose = 117 + slash_pose(s);
return poses + (size_t)pose * MMX_ZERO_WIDTH * MMX_ZERO_HEIGHT;
@@ -227,6 +238,8 @@ void MmxZeroCancel(uint8_t ram[0x20000]) {
/* Combat cancellation (hurt, weapon switch, menus) must not reset the
* independent body animation. Full reset/load uses MmxZeroResetState. */
memset(&state, 0, MMX_ZERO_LEGACY_STATE_SIZE);
+ memset((uint8_t *)&state + MMX_ZERO_ANIMATION_STATE_SIZE, 0,
+ sizeof(state) - MMX_ZERO_ANIMATION_STATE_SIZE);
}
static unsigned free_projectile(const uint8_t *r) {
for (unsigned d = 0x1228; d < 0x1428; d += 64)
@@ -236,59 +249,169 @@ static unsigned free_projectile(const uint8_t *r) {
static void clear_charge(uint8_t *r) {
memset(r + 0xbff, 0, 5);
}
+static unsigned burst_sequence(void) {
+ return state.burst == 1 ? (state.air ? 0x43 : 0x30) : (state.air ? 0x49 : 0x36);
+}
+static void burst_record(unsigned sequence) {
+ state.burst_offset = (uint16_t)word(animation + sequence * 2);
+ state.burst_timer = animation[state.burst_offset];
+}
+static void start_burst(uint8_t *r, unsigned which) {
+ state.burst = (uint8_t)which; state.burst_end = 0;
+ state.burst_transition = state.burst_fired = 0;
+ state.air = !(r[0xbd3] & 4); state.facing = r[0xc11] & 64;
+ burst_record(burst_sequence());
+ if (which == 2 && state.air) ++state.burst_timer; /* X3's separate setup frame. */
+ clear_charge(r);
+}
+static void emit_burst(uint8_t *r) {
+ unsigned d = free_projectile(r);
+ if (!d) return;
+ memset(r + d,0,64); r[d] = 1; r[d + 10] = 3;
+ ++r[0xbdd]; r[0x1f0d] = 4;
+ state.shot_mask |= (uint8_t)(1u << ((d - 0x1228) / 64));
+ state.cooldown = 0;
+}
+static void advance_burst(uint8_t *r) {
+ unsigned flags = animation[state.burst_offset + 1];
+ if (state.burst_transition) {
+ burst_record(burst_sequence() + (flags & 7));
+ state.burst_transition = 0;
+ flags = animation[state.burst_offset + 1];
+ }
+ if (flags & 128) { state.burst_end = 1; return; }
+ if ((flags & 64) && !state.burst_fired) {
+ emit_burst(r); state.burst_fired = 1;
+ }
+ bool air = !(r[0xbd3] & 4);
+ if (air != !!state.air) {
+ /* X3 changes action first, then resumes the equivalent numbered phase
+ * on the following update. The transition itself retains the old pose. */
+ state.air = air;
+ state.burst_transition = 1;
+ return;
+ }
+ if (!--state.burst_timer) {
+ unsigned next = state.burst_offset + 3;
+ if (next + 3 > sizeof(animation) || !animation[next] || animation[next + 2] >= 117) {
+ MmxZeroCancel(r); return;
+ }
+ state.burst_offset = (uint16_t)next;
+ state.burst_timer = animation[state.burst_offset];
+ }
+}
+static void track_burst_shots(const uint8_t *r) {
+ unsigned alive = 0;
+ for (unsigned i = 0; i < 8; ++i) {
+ unsigned d = 0x1228 + i * 64;
+ if ((state.shot_mask & (1u << i)) && r[d] && r[d + 10] == 3 &&
+ !own_projectile(r,d)) alive |= 1u << i;
+ }
+ /* X3 requires both the charged projectile and its effects to retire. The
+ * retained X1 beam executes its disappearance animation in the SAME slot
+ * ($81:A3CE..A40D); C25 is decremented only afterwards. Wait for that real
+ * lifetime, including impact/offscreen recovery, with no guessed delay. */
+ state.cooldown = 0;
+ state.shot_mask = (uint8_t)alive;
+}
+static bool burst_holds_air(void) {
+ return state.burst && state.air && (state.burst_end ||
+ (state.burst == 2 && state.burst_offset == word(animation + 0x49 * 2) && state.burst_timer == 2));
+}
void MmxZeroPlayerTick(uint8_t r[0x20000]) {
if (!poses || !r) return;
- /* This runs after X1 has latched mapped controls, before its player state.
- * Cutscenes, damage, death, ladders, ride armor and menus cancel the combo. */
unsigned action = r[0xbaa];
bool playable = r[0xd1] == 2 && r[0xd2] == 4 && r[0xba9] == 2 &&
(r[0xbcf] & 127) && !r[0x1f0c] && !r[0xbdb] &&
(action <= 8 || action == 0x10 || action == 0x12 || action == 0x14 || action == 0x20);
if (!playable) { MmxZeroCancel(r); return; }
- if (state.cooldown) --state.cooldown;
bool held = (r[0xbdf] & 64) != 0, pressed = (r[0xbe3] & 64) != 0;
+ track_burst_shots(r);
+ if (state.burst_end) {
+ state.burst = state.burst_end = 0;
+ if (r[0xbd3] & 4) r[0xbab] = 0; /* Restart the ordinary idle action. */
+ else if (r[0xbaa] == 8) MmxZeroAnimationStart(0xba8,5 + r[0xc17]);
+ }
if (state.slash) {
- if (++state.slash > 46) { MmxZeroCancel(r); return; }
- } else if (state.combo && pressed && !state.cooldown) {
+ if (++state.slash > 44) {
+ MmxZeroCancel(r);
+ if (r[0xbd3] & 4) r[0xbab] = 0;
+ return;
+ }
+ } else if (!state.burst && state.combo && pressed) {
if (state.combo == 1) {
- unsigned d = free_projectile(r);
- if (d && !r[0x1f0d]) {
- /* X1 rejects a second charged shot while $0C25 is nonzero.
- * Allocate the same native class directly; its initializer owns
- * position, velocity, graphics, lifetime and the charge-shot count. */
- clear_charge(r); memset(r + d, 0, 64);
- r[d] = 1; r[d + 10] = 3; ++r[0xbdd]; r[0x1f0d] = 4;
- state.combo = 2; state.cooldown = 18;
+ if (free_projectile(r) && !r[0x1f0d]) {
+ state.combo = state.saber_ready ? 2 : 0;
+ start_burst(r,2);
}
- } else {
+ } else if (state.saber_ready && !state.shot_mask && !state.cooldown && !r[0xc25]) {
unsigned d = free_projectile(r);
if (d) {
- clear_charge(r); state.combo = 0; state.slash = 1;
+ clear_charge(r); state.combo = state.saber_ready = 0; state.slash = 1;
state.air = !(r[0xbd3] & 4); state.facing = r[0xc11] & 64;
state.hit_slots = 0; state.projectile = (uint16_t)d;
memset(r + d, 0, 64); r[d] = 1; r[d + 1] = 2;
- r[d + 10] = 3; /* X1's unarmored full buster damage class. */
- r[d + 0x11] = r[0xbb9]; putword(r + d + 0x3e, 0x5a53);
- ++r[0xbdd];
+ r[d + 10] = 3; r[d + 0x11] = r[0xbb9];
+ putword(r + d + 0x3e, 0x5a53); ++r[0xbdd];
}
}
- } else if (!state.combo) {
- if (held) { if (state.charge < 180) ++state.charge; }
- else {
- if (state.charge == 180 && free_projectile(r) && !r[0x1f0d]) {
- clear_charge(r); r[0xc01] = 8; state.combo = 1; state.cooldown = 18;
+ } else if (!state.burst && !state.combo) {
+ if (held) {
+ if (state.charge < 201) ++state.charge;
+ /* Feed X1's ordinary charging visuals at Zero's measured thresholds.
+ * Its release command is selected explicitly below; X1 special weapons
+ * never enter this path and retain their own charge/upgrade rules. */
+ if (state.charge == 21) r[0xbff] = 0x97;
+ else if (state.charge > 21 && state.charge < 81) r[0xbff] = 0x90;
+ else if (state.charge == 81) r[0xbff] = 0x51;
+ else if (state.charge > 81) r[0xbff] = 0x40;
+ if (state.charge >= 141) { r[0xc03] = 1; r[0xc2a] = 4; }
+ } else {
+ unsigned tier = MmxZeroChargeTier(&state);
+ if (tier >= 8 && free_projectile(r) && !r[0x1f0d]) {
+ state.combo = 1; state.saber_ready = tier == 10;
+ start_burst(r,1);
+ } else if (tier >= 4) {
+ clear_charge(r); r[0xc01] = tier >= 6 ? 8 : 6;
}
state.charge = 0;
}
}
- if (state.combo) {
- /* Stored attacks must not start another native charge or pellet. */
+ if (state.burst) {
+ state.held_vy = (uint16_t)word(r + 0xbc4);
+ advance_burst(r);
+ clear_charge(r);
+ /* The ground firing sequence plants Zero's feet; jumping still transfers
+ * to its airborne counterpart. Native air control/gravity/collision run. */
+ if (!state.air) {
+ r[0xbde] = r[0xbe2] = 0;
+ if (action != 0) { r[0xbaa] = 0; r[0xbab] = 0; }
+ } else {
+ /* X3's firing action keeps integrating through the apex, instead of
+ * entering X1's ordinary fall initializer (which resets VY to zero). */
+ r[0xbdf] |= 128;
+ if (r[0xbaa] == 6 && (int16_t)word(r + 0xbc4) < 0) {
+ r[0xbaa] = 8; r[0xbab] = 2;
+ MmxZeroAnimationStart(0xba8,4 + r[0xc17]);
+ }
+ /* Second-shot initialization and the terminal action-return frame do
+ * not integrate motion in X3.
+ * Keep native terrain probes at the unchanged position, then restore
+ * the velocity after the native update for next frame's continuation. */
+ if (burst_holds_air()) {
+ state.held_vy = (uint16_t)word(r + 0xbc4); state.held_gravity = r[0xbc6];
+ putword(r + 0xbc4,0); r[0xbc6] = 0; r[0xbde] = r[0xbe2] = 0;
+ }
+ }
+ r[0xc11] = state.facing;
+ }
+ if (state.combo || state.burst) {
r[0xbdf] &= (uint8_t)~64; r[0xbe3] &= (uint8_t)~64;
}
if (state.slash) {
r[0xbde] = 0; r[0xbdf] &= 128; r[0xbe2] = r[0xbe3] = 0;
r[0xc11] = state.facing; clear_charge(r);
- r[0xbc2] = r[0xbc3] = 0; /* Stop horizontal velocity, preserve gravity. */
+ r[0xbc2] = r[0xbc3] = 0;
if (!state.air && action != 0) { r[0xbaa] = 0; r[0xbab] = 0; }
unsigned d = state.projectile;
if (own_projectile(r, d)) {
@@ -301,6 +424,12 @@ void MmxZeroPlayerTick(uint8_t r[0x20000]) {
}
}
}
+void MmxZeroPlayerEnd(uint8_t r[0x20000]) {
+ if (poses && r && state.burst) {
+ if (!state.air || burst_holds_air()) putword(r + 0xbc4,state.held_vy);
+ if (burst_holds_air()) r[0xbc6] = state.held_gravity;
+ }
+}
unsigned MmxZeroWeaponTick(uint8_t r[0x20000], unsigned d, unsigned active) {
if (!own_projectile(r, d)) return active;
if (!poses || d != state.projectile || !state.slash) {
diff --git a/src/mmx_zero.h b/src/mmx_zero.h
index e1776d5..afec478 100644
--- a/src/mmx_zero.h
+++ b/src/mmx_zero.h
@@ -5,13 +5,17 @@
enum { MMX_ZERO_WIDTH = 128, MMX_ZERO_HEIGHT = 128, MMX_ZERO_POSES = 152,
MMX_ZERO_ANIMATION_BYTES = 0x474, MMX_ZERO_MUZZLE_BYTES = 196,
- MMX_ZERO_LEGACY_STATE_SIZE = 12 };
+ MMX_ZERO_LEGACY_STATE_SIZE = 12, MMX_ZERO_ANIMATION_STATE_SIZE = 18 };
typedef struct MmxZeroState {
uint16_t charge, slash, projectile;
uint8_t combo, cooldown, air, facing;
uint16_t hit_slots;
uint16_t anim_offset;
uint8_t anim_timer, anim_pose, anim_flags, anim_valid;
+ uint16_t burst_offset;
+ uint8_t burst_timer, burst, burst_end, saber_ready, shot_mask, held_gravity;
+ uint16_t held_vy;
+ uint8_t burst_transition, burst_fired;
} MmxZeroState;
bool MmxZeroLoad(const char *path);
void MmxZeroDisable(void);
@@ -26,6 +30,8 @@ int MmxZeroHudColor(unsigned x, unsigned y);
void MmxZeroSetCollisionRom(uint8_t *rom, size_t size);
unsigned MmxZeroUpgradeBits(unsigned pc, unsigned original);
void MmxZeroPlayerTick(uint8_t ram[0x20000]);
+void MmxZeroPlayerEnd(uint8_t ram[0x20000]);
+unsigned MmxZeroChargeTier(const MmxZeroState *snapshot);
void MmxZeroAnimationStart(unsigned object, unsigned sequence);
void MmxZeroAnimationAdvance(unsigned object);
unsigned MmxZeroMuzzle(const uint8_t ram[0x20000], unsigned object,
diff --git a/src/mods/mmx_zero_plugin.c b/src/mods/mmx_zero_plugin.c
index f519155..56f8b86 100644
--- a/src/mods/mmx_zero_plugin.c
+++ b/src/mods/mmx_zero_plugin.c
@@ -14,6 +14,7 @@ static void hook(CpuState *cpu, uint32_t pc) {
case 0x00d76a: MmxRendererRecordPiece(g_ram, cpu->D); break;
case 0x01971f: case 0x019796: case 0x0198ff: cpu->A |= 8; break;
case 0x01815c: MmxZeroPlayerTick(g_ram); break;
+ case 0x018165: MmxZeroPlayerEnd(g_ram); break;
case 0x048f07: MmxZeroAnimationStart(cpu->D, cpu->A & 255); break;
case 0x048eea: MmxZeroAnimationAdvance(cpu->D); break;
case 0x028403: case 0x03958f: case 0x039dcf: {
@@ -80,7 +81,7 @@ static void hook(CpuState *cpu, uint32_t pc) {
}
void MmxZeroRegisterHooks(void) {
const unsigned pcs[] = {0x00d6a7, 0x00d76a, 0x01971f, 0x019796, 0x0198ff,
- 0x01815c, 0x00d3e7, 0x049e76, 0x049c19, 0x048f07, 0x048eea,
+ 0x01815c, 0x018165, 0x00d3e7, 0x049e76, 0x049c19, 0x048f07, 0x048eea,
0x028403,0x03958f,0x039dcf,
0x01a57d,0x038b6f,0x038d87,0x038ed7,0x03951d,0x039841,0x039993,0x03a3cd,
0x01a589,0x038b7b,0x038d93,0x038ee3,0x039529,0x03984d,0x03999f,0x03a3d9};
diff --git a/tests/data/zero_x3_combat.csv b/tests/data/zero_x3_combat.csv
new file mode 100644
index 0000000..72af20d
--- /dev/null
+++ b/tests/data/zero_x3_combat.csv
@@ -0,0 +1,280 @@
+mode,phase,tick,keys,pose,dy,vy,emitted
+ground,first,1,0,82,0,0,0
+ground,first,2,0,83,0,0,0
+ground,first,3,0,83,0,0,0
+ground,first,4,0,83,0,0,0
+ground,first,5,0,83,0,0,0
+ground,first,6,0,83,0,0,0
+ground,first,7,0,84,0,0,0
+ground,first,8,0,84,0,0,1
+ground,first,9,0,85,0,0,0
+ground,first,10,0,85,0,0,0
+ground,first,11,0,85,0,0,0
+ground,first,12,0,85,0,0,0
+ground,first,13,0,85,0,0,0
+ground,first,14,0,85,0,0,0
+ground,first,15,0,85,0,0,0
+ground,first,16,0,85,0,0,0
+ground,first,17,0,85,0,0,0
+ground,first,18,0,0,0,0,0
+ground,first,19,0,0,0,0,0
+ground,first,20,0,0,0,0,0
+ground,first,21,0,0,0,0,0
+ground,first,22,0,0,0,0,0
+ground,first,23,0,0,0,0,0
+ground,first,24,0,0,0,0,0
+ground,first,25,0,0,0,0,0
+ground,second,1,Y,86,0,0,0
+ground,second_rest,1,0,87,0,0,0
+ground,second_rest,2,0,87,0,0,0
+ground,second_rest,3,0,88,0,0,0
+ground,second_rest,4,0,88,0,0,0
+ground,second_rest,5,0,88,0,0,0
+ground,second_rest,6,0,88,0,0,0
+ground,second_rest,7,0,88,0,0,0
+ground,second_rest,8,0,89,0,0,0
+ground,second_rest,9,0,89,0,0,1
+ground,second_rest,10,0,90,0,0,0
+ground,second_rest,11,0,90,0,0,0
+ground,second_rest,12,0,90,0,0,0
+ground,second_rest,13,0,90,0,0,0
+ground,second_rest,14,0,90,0,0,0
+ground,second_rest,15,0,90,0,0,0
+ground,second_rest,16,0,90,0,0,0
+ground,second_rest,17,0,90,0,0,0
+ground,second_rest,18,0,90,0,0,0
+ground,second_rest,19,0,90,0,0,0
+ground,second_rest,20,0,90,0,0,0
+ground,second_rest,21,0,90,0,0,0
+ground,second_rest,22,0,90,0,0,0
+ground,second_rest,23,0,90,0,0,0
+ground,second_rest,24,0,90,0,0,0
+ground,second_rest,25,0,90,0,0,0
+ground,second_rest,26,0,90,0,0,0
+ground,second_rest,27,0,90,0,0,0
+ground,second_rest,28,0,0,0,0,0
+ground,second_rest,29,0,0,0,0,0
+ground,second_rest,30,0,0,0,0,0
+ground,second_rest,31,0,0,0,0,0
+ground,second_rest,32,0,0,0,0,0
+ground,second_rest,33,0,0,0,0,0
+ground,second_rest,34,0,0,0,0,0
+ground,second_rest,35,0,0,0,0,0
+ground,second_rest,36,0,0,0,0,0
+ground,second_rest,37,0,0,0,0,0
+ground,second_rest,38,0,0,0,0,0
+ground,second_rest,39,0,0,0,0,0
+ground,second_rest,40,0,0,0,0,0
+ground,second_rest,41,0,0,0,0,0
+ground,second_rest,42,0,0,0,0,0
+ground,second_rest,43,0,0,0,0,0
+ground,second_rest,44,0,0,0,0,0
+ground,second_rest,45,0,0,0,0,0
+ground,second_rest,46,0,0,0,0,0
+ground,second_rest,47,0,0,0,0,0
+ground,second_rest,48,0,0,0,0,0
+ground,second_rest,49,0,0,0,0,0
+ground,second_rest,50,0,0,0,0,0
+ground,second_rest,51,0,0,0,0,0
+ground,second_rest,52,0,0,0,0,0
+ground,second_rest,53,0,0,0,0,0
+ground,second_rest,54,0,0,0,0,0
+ground,second_rest,55,0,0,0,0,0
+ground,second_rest,56,0,0,0,0,0
+ground,second_rest,57,0,0,0,0,0
+ground,second_rest,58,0,0,0,0,0
+ground,second_rest,59,0,0,0,0,0
+ground,second_rest,60,0,0,0,0,0
+ground,third,1,Y,117,0,0,0
+ground,saber,1,0,117,0,0,0
+ground,saber,2,0,118,0,0,0
+ground,saber,3,0,118,0,0,0
+ground,saber,4,0,118,0,0,0
+ground,saber,5,0,119,0,0,0
+ground,saber,6,0,119,0,0,0
+ground,saber,7,0,119,0,0,0
+ground,saber,8,0,120,0,0,0
+ground,saber,9,0,120,0,0,0
+ground,saber,10,0,120,0,0,0
+ground,saber,11,0,121,0,0,0
+ground,saber,12,0,121,0,0,0
+ground,saber,13,0,121,0,0,0
+ground,saber,14,0,122,0,0,0
+ground,saber,15,0,122,0,0,0
+ground,saber,16,0,122,0,0,0
+ground,saber,17,0,122,0,0,0
+ground,saber,18,0,122,0,0,0
+ground,saber,19,0,122,0,0,0
+ground,saber,20,0,123,0,0,0
+ground,saber,21,0,123,0,0,0
+ground,saber,22,0,123,0,0,0
+ground,saber,23,0,123,0,0,0
+ground,saber,24,0,123,0,0,0
+ground,saber,25,0,123,0,0,0
+ground,saber,26,0,123,0,0,0
+ground,saber,27,0,123,0,0,0
+ground,saber,28,0,123,0,0,0
+ground,saber,29,0,123,0,0,0
+ground,saber,30,0,123,0,0,0
+ground,saber,31,0,123,0,0,0
+ground,saber,32,0,123,0,0,0
+ground,saber,33,0,123,0,0,0
+ground,saber,34,0,123,0,0,0
+ground,saber,35,0,123,0,0,0
+ground,saber,36,0,121,0,0,0
+ground,saber,37,0,121,0,0,0
+ground,saber,38,0,121,0,0,0
+ground,saber,39,0,118,0,0,0
+ground,saber,40,0,118,0,0,0
+ground,saber,41,0,118,0,0,0
+ground,saber,42,0,117,0,0,0
+ground,saber,43,0,117,0,0,0
+ground,saber,44,0,0,0,0,0
+ground,saber,45,0,0,0,0,0
+ground,saber,46,0,0,0,0,0
+ground,saber,47,0,0,0,0,0
+ground,saber,48,0,0,0,0,0
+ground,saber,49,0,0,0,0,0
+ground,saber,50,0,0,0,0,0
+air,jump,1,B+Y,0,0,1363,0
+air,jump,2,B+Y,23,-1299,1299,0
+air,jump,3,B+Y,23,-2534,1235,0
+air,jump,4,B+Y,23,-3705,1171,0
+air,jump,5,B+Y,24,-4812,1107,0
+air,first,1,B,91,-5855,1043,0
+air,first,2,B,92,-6834,979,0
+air,first,3,B,92,-7749,915,0
+air,first,4,B,92,-8600,851,0
+air,first,5,B,92,-9387,787,0
+air,first,6,B,92,-10110,723,0
+air,first,7,B,93,-10769,659,0
+air,first,8,B,93,-11364,595,1
+air,first,9,B,94,-11895,531,0
+air,first,10,B,94,-12362,467,0
+air,first,11,B,94,-12765,403,0
+air,first,12,B,94,-13104,339,0
+air,first,13,B,94,-13379,275,0
+air,first,14,B,94,-13590,211,0
+air,first,15,B,94,-13737,147,0
+air,first,16,B,94,-13820,83,0
+air,first,17,B,94,-13839,19,0
+air,first,18,B,94,-13794,-45,0
+air,first,19,B,94,-13685,-109,0
+air,first,20,B,94,-13512,-173,0
+air,first,21,B,94,-13275,-237,0
+air,first,22,B,94,-13275,-237,0
+air,first,23,B,26,-12974,-301,0
+air,first,24,B,26,-12609,-365,0
+air,first,25,B,26,-12180,-429,0
+air,second,1,Y+B,95,-12180,-429,0
+air,second_rest,1,B,95,-11687,-493,0
+air,second_rest,2,B,96,-11130,-557,0
+air,second_rest,3,B,96,-10509,-621,0
+air,second_rest,4,B,96,-9824,-685,0
+air,second_rest,5,B,96,-9075,-749,0
+air,second_rest,6,B,96,-8262,-813,0
+air,second_rest,7,B,97,-7385,-877,0
+air,second_rest,8,B,97,-6444,-941,1
+air,second_rest,9,B,98,-5439,-1005,0
+air,second_rest,10,B,98,-4370,-1069,0
+air,second_rest,11,B,98,-3237,-1133,0
+air,second_rest,12,B,98,-2040,-1197,0
+air,second_rest,13,B,98,-779,-1261,0
+air,second_rest,14,B,98,-222,-1325,0
+air,second_rest,15,B,98,-222,-1325,0
+air,second_rest,16,B,90,-222,-1325,0
+air,second_rest,17,B,90,-222,-1325,0
+air,second_rest,18,B,90,-222,-1325,0
+air,second_rest,19,B,90,-222,-1325,0
+air,second_rest,20,B,90,-222,-1325,0
+air,second_rest,21,B,90,-222,-1325,0
+air,second_rest,22,B,90,-222,-1325,0
+air,second_rest,23,B,90,-222,-1325,0
+air,second_rest,24,B,90,-222,-1325,0
+air,second_rest,25,B,90,-222,-1325,0
+air,second_rest,26,B,90,-222,-1325,0
+air,second_rest,27,B,90,-222,-1325,0
+air,second_rest,28,B,90,-222,-1325,0
+air,second_rest,29,B,90,-222,-1325,0
+air,second_rest,30,B,90,-222,-1325,0
+air,second_rest,31,B,90,-222,-1325,0
+air,second_rest,32,B,90,-222,-1325,0
+air,second_rest,33,B,0,-222,0,0
+air,second_rest,34,B,0,-222,0,0
+air,second_rest,35,B,0,-222,0,0
+air,second_rest,36,B,0,-222,0,0
+air,second_rest,37,B,0,-222,0,0
+air,second_rest,38,B,0,-222,0,0
+air,second_rest,39,B,0,-222,0,0
+air,second_rest,40,B,0,-222,0,0
+air,second_rest,41,B,0,-222,0,0
+air,second_rest,42,B,0,-222,0,0
+air,second_rest,43,B,0,-222,0,0
+air,second_rest,44,B,0,-222,0,0
+air,second_rest,45,B,0,-222,0,0
+air,second_rest,46,B,0,-222,0,0
+air,second_rest,47,B,0,-222,0,0
+air,second_rest,48,B,0,-222,0,0
+air,second_rest,49,B,0,-222,0,0
+air,second_rest,50,B,0,-222,0,0
+air,second_rest,51,B,0,-222,0,0
+air,second_rest,52,B,0,-222,0,0
+air,second_rest,53,B,0,-222,0,0
+air,second_rest,54,B,0,-222,0,0
+air,second_rest,55,B,0,-222,0,0
+air,second_rest,56,B,0,-222,0,0
+air,second_rest,57,B,0,-222,0,0
+air,second_rest,58,B,0,-222,0,0
+air,second_rest,59,B,0,-222,0,0
+air,second_rest,60,B,0,-222,0,0
+air,third,1,Y,117,-222,0,0
+air,saber,1,0,117,-222,0,0
+air,saber,2,0,118,-222,0,0
+air,saber,3,0,118,-222,0,0
+air,saber,4,0,118,-222,0,0
+air,saber,5,0,119,-222,0,0
+air,saber,6,0,119,-222,0,0
+air,saber,7,0,119,-222,0,0
+air,saber,8,0,120,-222,0,0
+air,saber,9,0,120,-222,0,0
+air,saber,10,0,120,-222,0,0
+air,saber,11,0,121,-222,0,0
+air,saber,12,0,121,-222,0,0
+air,saber,13,0,121,-222,0,0
+air,saber,14,0,122,-222,0,0
+air,saber,15,0,122,-222,0,0
+air,saber,16,0,122,-222,0,0
+air,saber,17,0,122,-222,0,0
+air,saber,18,0,122,-222,0,0
+air,saber,19,0,122,-222,0,0
+air,saber,20,0,123,-222,0,0
+air,saber,21,0,123,-222,0,0
+air,saber,22,0,123,-222,0,0
+air,saber,23,0,123,-222,0,0
+air,saber,24,0,123,-222,0,0
+air,saber,25,0,123,-222,0,0
+air,saber,26,0,123,-222,0,0
+air,saber,27,0,123,-222,0,0
+air,saber,28,0,123,-222,0,0
+air,saber,29,0,123,-222,0,0
+air,saber,30,0,123,-222,0,0
+air,saber,31,0,123,-222,0,0
+air,saber,32,0,123,-222,0,0
+air,saber,33,0,123,-222,0,0
+air,saber,34,0,123,-222,0,0
+air,saber,35,0,123,-222,0,0
+air,saber,36,0,121,-222,0,0
+air,saber,37,0,121,-222,0,0
+air,saber,38,0,121,-222,0,0
+air,saber,39,0,118,-222,0,0
+air,saber,40,0,118,-222,0,0
+air,saber,41,0,118,-222,0,0
+air,saber,42,0,117,-222,0,0
+air,saber,43,0,117,-222,0,0
+air,saber,44,0,0,-222,0,0
+air,saber,45,0,0,-222,0,0
+air,saber,46,0,0,-222,0,0
+air,saber,47,0,0,-222,0,0
+air,saber,48,0,0,-222,0,0
+air,saber,49,0,0,-222,0,0
+air,saber,50,0,0,-222,0,0
diff --git a/tests/data/zero_x3_combat.md b/tests/data/zero_x3_combat.md
new file mode 100644
index 0000000..8ef241a
--- /dev/null
+++ b/tests/data/zero_x3_combat.md
@@ -0,0 +1,38 @@
+# Original X3 Zero combat reference
+
+`zero_x3_combat.csv` contains measured frame results, not ROM bytes or graphics.
+Recorded using the original USA X3 ROM, SHA-256
+`65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7`,
+through the X3 project's interpreter with execution mode off. Zero was selected
+through the original game's character swap. The private fixture is on flat
+ground at the beginning of Neon Tiger.
+
+Each case restores the same stationary fixture, advances two neutral frames,
+and holds Y for 205 frames. The air case additionally holds B+Y for five frames.
+Then: release Y for 25 frames, press Y for one frame, release for 60 frames,
+press Y for one frame, release for 50 frames. B stays held through the airborne
+first and second busters. Each row is the state after that input frame.
+
+Pose indexes are group $4A indexes, or 117 plus the group $4B saber index.
+DY is relative to the starting position in 1/256 pixels. VY is signed 8.8.
+The fixture's fractional Y is $ED; a landing snaps it to zero. `emitted` marks
+an increase in the original charged-projectile count during a buster action.
+
+Additional charge measurements give tiers at held frames 21, 81, 141, and 201:
+half charge, full single buster, two busters, and two busters plus saber.
+The first grounded shot emits on frame 8 and returns to idle on frame 18.
+The second emits on frame 10 and returns to idle on frame 29. Airborne firing
+uses separate original animation records and resumes the equivalent phase on
+landing. Saber action returns on frame 44 and shows idle on frame 45.
+
+The backport reads the original body-animation records from the extracted
+asset cache. It retains X1's charged projectile and damage/collision machinery.
+Saber readiness follows X3's dependency rule: preceding charged attacks must
+finish. X1 includes its impact/disappearance animation in the projectile slot
+($81:A3CE..A40D), whereas X3 also counts separate effect actors ($0A82).
+Consequently, beam travel/effect lifetimes are X1's, not a claimed frame-exact
+port of X3's projectile engine. There is no fixed inter-attack cooldown.
+
+The ROM-backed test compares these stationary ground and air sequences with
+X1's Highway fixture. The existing movement reference covers ordinary motion;
+these checks do not claim exhaustive terrain, enemy, or campaign coverage.
diff --git a/tests/mmx_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c
index ff9f729..6ea1445 100644
--- a/tests/mmx_adaptive_state_test.c
+++ b/tests/mmx_adaptive_state_test.c
@@ -77,6 +77,39 @@ static void zero_motion_checks(const char *fixture) {
}
fclose(f); printf("ok: %u original-X3 movement/animation reference frames\n",checked);
}
+static void zero_combat_checks(const char *fixture) {
+ FILE *f=fopen(MMX_ZERO_COMBAT_REFERENCE_DEFAULT,"r"); check(f!=NULL,"X3 combat reference opens");
+ char line[160],previous[16]=""; unsigned count=0; int y0=0;
+ check(fgets(line,sizeof(line),f)!=NULL,"X3 combat reference header");
+ while(fgets(line,sizeof(line),f)) {
+ char mode[16],phase[24],keys[16]; int tick,pose,dy,vy,emitted;
+ check(sscanf(line,"%15[^,],%23[^,],%d,%15[^,],%d,%d,%d,%d",mode,phase,&tick,keys,&pose,&dy,&vy,&emitted)==8,"X3 combat reference row");
+ if(strcmp(mode,previous)) {
+ check(RtlLoadSnapshot(fixture),"restore combat fixture");
+ g_ram[0xbaf]=0xed; /* Original fixture's fractional Y, including landing snap. */
+ for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y);
+ y0=g_ram[0xbaf] | g_ram[0xbb0]<<8 | g_ram[0xbb1]<<16;
+ snprintf(previous,sizeof(previous),"%s",mode);
+ }
+ unsigned input=strchr(keys,'Y')?SNES_PAD_Y:0;
+ if(strchr(keys,'B')) input|=SNES_PAD_B;
+ unsigned before=zero_projectiles(3);
+ frame(input);
+ MmxZeroState z=MmxZeroGetState();
+ int actual_pose=(int)((MmxZeroPose(g_ram,&z)-MmxZeroMenuPose())/(128*128));
+ int actual_y=(int)(g_ram[0xbaf] | g_ram[0xbb0]<<8 | g_ram[0xbb1]<<16)-y0;
+ int actual_vy=(int16_t)(g_ram[0xbc4]|g_ram[0xbc5]<<8);
+ if(actual_pose!=pose || actual_y!=dy || actual_vy!=vy ||
+ (emitted && zero_projectiles(3)<=before)) {
+ fprintf(stderr,"combat mismatch %s/%s/%d pose %d/%d dy %d/%d vy %d/%d emitted %u/%d burst %u/%u\n",
+ mode,phase,tick,actual_pose,pose,actual_y,dy,actual_vy,vy,zero_projectiles(3)>before,emitted,z.burst,z.burst_end);
+ fprintf(stderr,"native action=%u sub=%u pose=%u seqbase=%u mirror=%u/%u/%u\n",g_ram[0xbaa],g_ram[0xbab],g_ram[0xbbf],g_ram[0xc17],z.anim_offset,z.anim_pose,z.anim_timer);
+ exit(1);
+ }
+ ++count;
+ }
+ fclose(f); printf("ok: %u original-X3 combat animation/physics reference frames\n",count);
+}
static void zero_menu_pixels(void) {
static uint32_t pixels[256 * 224]; unsigned body = 0, icon = 0;
static uint32_t baseline[48 * 56]; static bool have_baseline;
@@ -191,13 +224,45 @@ static void zero_state_checks(const char *assets, const char *fixture, uint8 *st
int w,h; MmxPrepareFrame(1280,720,&w,&h);
check(w == 256 && g_mmx_custom_renderer, "Zero activates native-width compositor");
zero_motion_checks(fixture);
+ zero_combat_checks(fixture);
+ check(RtlLoadSnapshot(fixture), "restore for burst state replay");
+ for(unsigned i=0;i<201;++i) frame(SNES_PAD_Y);
+ zero_replay(4);
+ size_t burst_n=RtlSaveSnapshotToMemory(start,cap);
+ zero_replay(8); size_t burst_en=RtlSaveSnapshotToMemory(expected,cap);
+ check(RtlLoadSnapshotFromMemory(start,burst_n),"load during first windup");
+ zero_replay(8); size_t burst_an=RtlSaveSnapshotToMemory(actual,cap);
+ same(expected,burst_en,actual,burst_an,"windup replay emits exactly the same shot");
+ size_t burst_rn=RtlRollbackSaveToMemory(start,cap);
+ zero_replay(14); size_t burst_ren=RtlRollbackSaveToMemory(expected,cap);
+ check(RtlRollbackLoadFromMemory(start,burst_rn),"load live beam and stored follow-ups");
+ zero_replay(14); burst_an=RtlRollbackSaveToMemory(actual,cap);
+ same(expected,burst_ren,actual,burst_an,"live beam and stored combo rollback replay");
+ /* v4/v5 had only the 12/18-byte prefix. Both must still load, with the
+ * former full-charge combo interpreted as saber-capable. */
+ burst_n=RtlSaveSnapshotToMemory(start,cap);
+ size_t chunk=0;
+ for(size_t i=burst_n-8;i>8;--i) {
+ uint32_t magic; memcpy(&magic,start+i,4);
+ if(magic==0x4d4d5854u) { chunk=i; break; }
+ }
+ check(chunk!=0,"Zero game chunk located");
+ for(uint32_t v=4;v<=5;++v) {
+ memcpy(start+chunk+4,&v,4);
+ size_t prefix=v==4?MMX_ZERO_LEGACY_STATE_SIZE:MMX_ZERO_ANIMATION_STATE_SIZE;
+ check(RtlLoadSnapshotFromMemory(start,burst_n-sizeof(MmxZeroState)+prefix),"legacy Zero state prefix loads");
+ check(MmxZeroGetState().combo==1 && MmxZeroGetState().saber_ready && !MmxZeroGetState().burst,"legacy stored combo migrates");
+ }
check(RtlLoadSnapshot(fixture), "restore for combo probe");
- for (int i=0;i<181;++i) frame(SNES_PAD_Y);
+ for (int i=0;i<201;++i) frame(SNES_PAD_Y);
frame(0);
- check(MmxZeroGetState().combo == 1, "full charge stores two follow-ups");
- zero_replay(19); frame(SNES_PAD_Y);
- check(zero_projectiles(3) == 2 && MmxZeroGetState().combo == 2, "two native charged shots coexist");
- zero_replay(20); frame(SNES_PAD_Y); zero_replay(12);
+ check(MmxZeroGetState().combo == 1 && MmxZeroGetState().saber_ready &&
+ !zero_projectiles(3), "full charge begins original windup and stores two follow-ups");
+ zero_replay(17); frame(SNES_PAD_Y); zero_replay(9);
+ check(zero_projectiles(3) == 2 && MmxZeroGetState().combo == 2, "two native charged shots coexist after second windup");
+ for (unsigned i=0;i<160 && (MmxZeroGetState().burst || MmxZeroGetState().shot_mask || g_ram[0xc25]);++i) frame(0);
+ check(!MmxZeroGetState().burst && !MmxZeroGetState().shot_mask && !g_ram[0xc25], "beams and their disappearance effects retire");
+ frame(SNES_PAD_Y); zero_replay(12);
MmxZeroState state = MmxZeroGetState();
check(state.slash > 7 && state.projectile && !state.combo, "saber reaches active frames");
check(g_ram[0x1f99] == 0, "combo does not grant X1 upgrades");
@@ -236,7 +301,7 @@ static void zero_state_checks(const char *assets, const char *fixture, uint8 *st
zero_capture(capture, ".dash.cap");
check(RtlLoadSnapshot(fixture), "restore for aerial saber probe");
unsigned floor = g_ram[0xbb0] | g_ram[0xbb1] << 8;
- MmxZeroSetState((MmxZeroState){.combo=2});
+ MmxZeroSetState((MmxZeroState){.combo=2,.saber_ready=1});
for (int i=0;i<6;++i) frame(SNES_PAD_B);
frame(SNES_PAD_B | SNES_PAD_Y); zero_replay(12);
check(MmxZeroGetState().air && MmxZeroGetState().slash > 7 &&
diff --git a/tests/mmx_zero_test.c b/tests/mmx_zero_test.c
index b1c660a..fda7677 100644
--- a/tests/mmx_zero_test.c
+++ b/tests/mmx_zero_test.c
@@ -18,6 +18,22 @@ static void asset(const char *path) {
uint8_t animation[MMX_ZERO_ANIMATION_BYTES] = {0};
for (unsigned i = 0; i < 136; ++i) { animation[i * 2] = 0x10; animation[i * 2 + 1] = 1; }
animation[272] = 2; animation[273] = 128; animation[275] = 253; animation[276] = 255;
+ /* Measured original X3 group $4A firing records, including phase aliases. */
+ const uint8_t bursts[][21] = {
+ {2,0,82, 5,1,83, 1,66,84, 1,3,84, 7,4,85, 7,149,85},
+ {2,0,86, 2,1,87, 5,2,88, 1,67,89, 1,4,89, 16,5,90, 255,134,90},
+ {2,0,91, 5,1,92, 1,66,93, 1,3,93, 12,4,94, 255,149,94},
+ {2,0,95, 3,1,96, 2,2,96, 1,67,97, 1,4,97, 12,5,98, 255,134,98}
+ };
+ const unsigned sequences[] = {0x30,0x36,0x43,0x49};
+ for (unsigned i=0;i<4;++i) {
+ unsigned base=300+i*24;
+ memcpy(animation+base,bursts[i],21);
+ for (unsigned j=0;j<(i%2?7:6);++j) {
+ animation[(sequences[i]+j)*2]=(uint8_t)(base+j*3);
+ animation[(sequences[i]+j)*2+1]=(uint8_t)((base+j*3)>>8);
+ }
+ }
assert(fwrite(animation,sizeof(animation),1,f) == 1);
uint8_t muzzle[MMX_ZERO_MUZZLE_BYTES] = {0};
muzzle[0] = 2; muzzle[122] = 255; muzzle[123] = 232;
@@ -68,21 +84,36 @@ int main(void) {
FILE *f = fopen("zero-test-bad.bin","wb"); assert(f); fputs("MMXZERO3",f); fclose(f);
assert(!MmxZeroLoad("zero-test-bad.bin") && MmxZeroEnabled());
player();
- for (int i=0;i<179;++i) tick(64,i==0?64:0);
- tick(0,0); assert(!MmxZeroGetState().combo); /* Almost charged cannot chain. */
- for (int i=0;i<180;++i) tick(64,i==0?64:0);
- tick(0,0); assert(MmxZeroGetState().combo == 1 && ram[0xc01] == 8);
+ const unsigned charges[]={20,21,80,81,140,141,200,201};
+ const unsigned tiers[]={0,4,4,6,6,8,8,10};
+ for(unsigned c=0;c<8;++c) {
+ player();
+ for(unsigned i=0;i=8) && z.saber_ready==(tiers[c]==10));
+ if(tiers[c] && tiers[c]<8) assert(ram[0xc01]==(tiers[c]==4?6:8));
+ }
+ assert(MmxZeroGetState().burst == 1 && !ram[0x1228]);
assert(ram[0x1f99] == 0); /* Dash/charge never grant equipment. */
- ram[0xc01] = 0;
- /* Native first shot is live. Zero must allocate another slot despite X1's lock. */
- ram[0x1228] = 1; ram[0x1229] = 2; ram[0x1232] = 3; ram[0xbdd] = ram[0xc25] = 1;
- for(int i=0;i<20;++i) tick(0,0);
+ for(int i=2;i<=7;++i) tick(0,0);
+ assert(!ram[0x1228]); tick(0,0); assert(ram[0x1228] && ram[0x1232]==3);
+ ram[0xc25]=1; ram[0x1f0d]=0;
+ for(int i=9;i<=18;++i) tick(0,0);
tick(64,64);
- assert(MmxZeroGetState().combo == 2 && ram[0x1268] == 1 && ram[0x1272] == 3 && ram[0xbdd] == 2);
+ assert(MmxZeroGetState().combo == 2 && MmxZeroGetState().burst==2 && !ram[0x1268]);
+ for(int i=2;i<=9;++i) tick(0,0);
+ assert(!ram[0x1268]); tick(0,0);
+ assert(ram[0x1268] == 1 && ram[0x1272] == 3 && ram[0xbdd] == 2);
assert(ram[0xc25] == 1); /* Native initializer, not us, increments that counter. */
- for(int i=0;i<20;++i) tick(0,0);
+ for(int i=11;i<=29;++i) tick(0,0);
+ tick(64,64); assert(!MmxZeroGetState().slash); /* Live beams block saber. */
+ ram[0x1229]=8; ram[0x1269]=8; /* Disappearance effects still own slots. */
+ tick(0,0); tick(64,64); assert(!MmxZeroGetState().slash);
+ memset(ram+0x1228,0,128); ram[0xbdd]=ram[0xc25]=ram[0x1f0d]=0;
+ tick(0,0);
tick(64,64); MmxZeroState s = MmxZeroGetState();
- assert(s.slash == 1 && s.projectile == 0x12a8 && !s.combo);
+ assert(s.slash == 1 && s.projectile == 0x1228 && !s.combo);
assert(!ram[s.projectile+0x20] && !ram[s.projectile+0x21]); /* Windup cannot hit. */
assert(!MmxZeroWeaponTick(ram,s.projectile,1));
for(int i=0;i<6;++i) tick(0,0);
@@ -99,9 +130,10 @@ int main(void) {
MmxZeroState invalid = s; invalid.air = 2; MmxZeroSetState(invalid);
assert(!MmxZeroGetState().slash); MmxZeroSetState(s);
/* Changing weapon cancels only our melee slot; native shots remain alive. */
+ ram[0x1268]=1; ram[0x1272]=3; ram[0xbdd]=2;
ram[0xbdb] = 2; tick(0,0);
- assert(!MmxZeroGetState().slash && !ram[s.projectile] && ram[0x1228] && ram[0x1268] && ram[0xbdd] == 2);
- player(); s = (MmxZeroState){.combo=2}; MmxZeroSetState(s);
+ assert(!MmxZeroGetState().slash && !ram[s.projectile] && ram[0x1268] && ram[0xbdd] == 1);
+ player(); s = (MmxZeroState){.combo=2,.saber_ready=1}; MmxZeroSetState(s);
for(unsigned d=0x1228;d<0x1428;d+=64) ram[d] = 1;
tick(64,64); assert(MmxZeroGetState().combo == 2 && !MmxZeroGetState().slash);
ram[0xbaa] = 0x0e; tick(0,0); assert(!MmxZeroGetState().combo);
diff --git a/tools/apply_zero_hooks.py b/tools/apply_zero_hooks.py
index 98963cd..9baaa8b 100644
--- a/tools/apply_zero_hooks.py
+++ b/tools/apply_zero_hooks.py
@@ -9,7 +9,7 @@
MUZZLE_PCS = {0x81a566, 0x81a578, 0x838b6a, 0x838d82, 0x838eb4,
0x839518, 0x83983c, 0x839974, 0x83a3a9}
ORIGIN_PCS = {0x8283ed: 1, 0x83958c: 0, 0x839dc5: 1}
-REQUIRED = PCS | MUZZLE_PCS | ORIGIN_PCS.keys() | {0x81815c, 0x00d3e5, 0x849e73, 0x849c16, 0x848f07, 0x848eea}
+REQUIRED = PCS | MUZZLE_PCS | ORIGIN_PCS.keys() | {0x81815c, 0x818165, 0x00d3e5, 0x849e73, 0x849c16, 0x848f07, 0x848eea}
def apply(text):
@@ -46,6 +46,9 @@ def apply(text):
if pc == 0x81815c and 'cpu->coprocessor_master_cycles = cpu->master_cycles;' in line:
output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern void MmxZeroPlayerTick(uint8_t *); MmxZeroPlayerTick(g_ram); }}\n')
found.add(pc)
+ if pc == 0x818165 and 'cpu->coprocessor_master_cycles = cpu->master_cycles;' in line:
+ output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern void MmxZeroPlayerEnd(uint8_t *); MmxZeroPlayerEnd(g_ram); }}\n')
+ found.add(pc)
if pc == 0x00d3e5:
load = re.search(r'uint8 (_v\d+) = cpu_read8\(cpu, 0x00, \(uint16\)\(cpu->D \+ 0x0000\)\);', line)
if load:
From dc141de758c16ed53bf15ac9510809a2edb7ab9a Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Mon, 28 Sep 2026 22:35:42 -0700
Subject: [PATCH 10/16] Polish Zero life icon and title cursor; stop looping
charge audio
---
assets/zero/README.md | 14 +++++------
assets/zero/life-head.png | Bin 647 -> 361 bytes
docs/zero-spike.md | 9 +++++--
src/mmx_renderer.c | 23 +++++++++++------
src/mmx_zero.c | 14 ++++++++++-
src/mmx_zero.h | 1 +
src/mmx_zero_life.h | 42 +++++++++++++-------------------
tests/mmx_adaptive_state_test.c | 22 +++++++++++++++++
tests/mmx_zero_test.c | 14 +++++++++++
tools/compile_zero_life.py | 12 ++++-----
10 files changed, 103 insertions(+), 48 deletions(-)
diff --git a/assets/zero/README.md b/assets/zero/README.md
index a81b412..59c8bb9 100644
--- a/assets/zero/README.md
+++ b/assets/zero/README.md
@@ -1,16 +1,16 @@
# Zero life head
-`life-head.png` is a transparent 24x24 front-facing Zero life icon, shared by the weapons menu and life pickups. It replaces the rejected 16x16 head. The extra room preserves the tall helmet fins, low V-shaped brow, cyan crystal, white side blades, gray cheek pieces and simple peach face from the owner's references. It is new mod art, not an extracted X3 life icon.
+`life-head.png` is a transparent 17x16 front-facing Zero life icon, shared by the weapons menu and life pickups. It is one pixel wider than X's 16px canvas. The red helmet fins, blue crystal, white eye highlights and dark pupils remain readable at native resolution. It is new mod art, not an extracted X3 life icon.
-The built-in image-generation tool produced the reference-guided raster. Asset import cropped its opaque bounds, sampled it onto the 24px grid without interpolation, and reduced it to 16 colors. The runtime uses native BGR555. No eyes or mouth were added. The original concept remains in the local ignored research directory as `zero-life-front-concept.png`.
+The built-in image-generation tool produced the reference-guided raster. Import cropped its opaque bounds, sampled onto the native grid without interpolation and reduced it to 16 colors. Final pixel hinting preserves the white pixels beside each pupil and the face between the eyes. The runtime uses native BGR555 colors. This replaces the oversized 24px version.
-Run `python tools/compile_zero_life.py` after editing the PNG. This deterministic asset compiler writes `src/mmx_zero_life.h`; no image library is needed at runtime. The larger pickup retains the original collection rules and hitbox.
+Run `python tools/compile_zero_life.py` after editing the PNG. This deterministic compiler writes `src/mmx_zero_life.h`; no image library is needed at runtime. Pickup collection rules and hitboxes remain native.
-References supplied by the owner:
+Owner references:
- `dacu4gt-2bc6f245-ea43-438a-90e7-3caaa5343b46.png`: frontal Zero helmet identity.
-- `512x512.png`: original X life icon's simple pixel-art style.
+- `512x512.png`: original X life icon's pixel-art style and eyes.
-Built-in generation prompt:
+Final built-in generation prompt brief: preserve Zero's angular red helmet and tall fins; make a compact front-facing SNES life head near X's 16x16 size, with a blue crystal, white eyes and dark pupils, a limited flat palette, hard pixel edges and transparent background. Do not turn the helmet into a recolored X helmet.
-> Use case: style-transfer. Asset: a production SNES Mega Man X 1-up life pickup sprite. Reference 1 provides Zero's front-facing helmet identity and silhouette. Reference 2 provides the VERY simple low-resolution pixel-art language of the original X life icon. Create ONE front-facing Zero head sprite that bridges these references. It must be a genuinely coarse 24 by 24 logical pixel sprite shown enlarged nearest-neighbor, centered, transparent background, no text or extras. Strict 24x24 pixel grid, each pixel a large uniform square, no smaller details. Recognizable long swept angular red helmet fins forming a low V-shaped brow, pale cyan central diamond, dark crimson crown, white side blades, gray cheek/ear blocks, simple peach face opening, dark outline. The forehead fins should point upward and outward, not cat ears. Use the original X icon's restrained blank face style: NO eyes, mouth, nose or facial expression. Only around 12 flat SNES-like colors. Height about 24 logical pixels, width 22. Preserve helmet silhouette from reference 1, but simplify ALL details to match reference 2. No smooth vector curves, no shaded illustration, no antialiasing, no drop shadow, no glow. Actual transparent background.
+Selected generated source: `exec-5c607f14-4679-4487-a6c9-357ce0a02e87.png`. Only the imported native-resolution asset is required by the project.
diff --git a/assets/zero/life-head.png b/assets/zero/life-head.png
index 23e22f68023d5278040b9fed9614a58ffdf3ea60..0c65073ce9e70a42a4b16610138dd71d47618bdd 100644
GIT binary patch
literal 361
zcmeAS@N?(olHy`uVBq!ia0vp^fNn{1`ISV`@iy0XB4ude`@%$AjK*4{WE{-7<{;d;jxehr9w0>6z^w_d>ectoQD9G$HDpY9X;QfO5Zm$q
z4#$iKj~(YU&0}dg^0LO;=X-ei_38Kg%=cQqR{c;dVD|N0!t75e1_MKa!PC{xWt~$(
F69A&SjmH20
literal 647
zcmV;20(kw2P)o^mu+W&8m}?1%vGJ!i6m~X3XJtcSLQSBd
z=jSehHdy%}9L8|rC(2v$%`waF`wJT7OWwV`o%z1mnc3Y(bW-p=PyY`sC4=v(c5=4z@KY+0*!0-%ns+xuv;R(FDYD4uCJ
zs`y%9v&F%MM(iBe6Kj{RCaxqrMyYJBXetaU)oLcOvbjoy)^nVNJXxha5(fQ^4G{3*>AC;pEKQ{*x{hld_w(P($7j$Lis}6#I@0j0<
z@6ChT5|2FxANYa;E;OJey1D2*{-nOeyGBapuYXEj5fSa~=c(R#$5h}=?*Jb@`?`qT
zML7D$MN89;^B>&Iy9fp}Y;JbOG^?ZkRcZjS!518GqaUF}@aaD610DXi>n^p2P3r&0
hpIiBm{LS2n;sE<$Rg0@rfNuZ*002ovPDHLkV1jhuIWGVJ
diff --git a/docs/zero-spike.md b/docs/zero-spike.md
index 47305d1..c1c1f7e 100644
--- a/docs/zero-spike.md
+++ b/docs/zero-spike.md
@@ -161,11 +161,16 @@ Full-port progress on `feat/x3-zero-port`:
mismatch that let X's tiles reappear. A renderer regression covers both the
visible transition and the actual hidden frame.
- The pause menu uses Zero's original standing body, including with all armor
- upgrades owned. Menu and pickup life heads use a new front-facing 16x16 sprite
- shaded with Zero's original palette; vanilla X3 itself retains X's life icon.
+ upgrades owned. Menu and pickup life heads use a new front-facing 17x16 sprite
+ with pupils and a blue crystal; vanilla X3 itself retains X's life icon.
See [the life-head asset](../assets/zero/life-head.png). Native 1-up
collection increments the life count normally. Menu pixel comparisons change
only the character and head areas, including the fully upgraded menu.
+- The title-screen cursor also uses Zero's original standing/firing poses.
+ Native selection handling and its confirmation projectile are preserved.
+- Combo release and cancellation stop X1's looping charge voice through the
+ native sound-command ring. Each buster queues one firing sound on its actual
+ release frame, with no repeated sound commands during recovery.
- Menu fades retain player attribution while X1 repeats OAM without submitting
a fresh sprite list. Exact matches to live OAM preserve real hidden blinks.
A 480-frame opening/closing check covers unupgraded and fully upgraded menus;
diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c
index 76d70af..43b81b8 100644
--- a/src/mmx_renderer.c
+++ b/src/mmx_renderer.c
@@ -77,6 +77,14 @@ static bool zero_weapons_menu(void) {
(frame.ram[0x1989] == 1 && word(frame.ram,0x198d) == 128 &&
word(frame.ram,0x1990) == 160 && (frame.ram[0x199e] == 0 || frame.ram[0x199e] == 0x18)));
}
+static bool zero_title_menu(void) {
+ /* Retail title-menu cursor is the player object, at one of three rows.
+ * Its native idle/firing animation and selection beam remain active. */
+ unsigned y = word(frame.ram,0xbb0);
+ return MmxZeroEnabled() && frame.ram[0xd1] == 0 && frame.ram[0xba9] == 2 &&
+ frame.ram[0xbbe] == 0 && word(frame.ram,0xbad) == 32 &&
+ y >= 166 && y <= 198;
+}
static bool zero_actor(unsigned object, unsigned animation) {
return object == 0xba8 || object == 0xc38 || object == 0xc58 || object == 0xc78 ||
(object == 0x1928 && animation == 0x5f) || (object == 0x1948 && animation == 0x5e) ||
@@ -556,8 +564,8 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x,
}
}
if (!stage && layer == 1 && size == 8 && zero_weapons_menu() &&
- px >= 192 && px < 216 && py >= 140 && py < 164)
- life_color = MmxZeroLifeColor(px - 192, py - 140);
+ px >= 195 && px < 195 + MMX_ZERO_LIFE_WIDTH && py >= 144 && py < 144 + MMX_ZERO_LIFE_HEIGHT)
+ life_color = MmxZeroLifeColor(px - 195, py - 144);
unsigned number = ((tile & 1023) + cx / 8 + cy / 8 * 16) & 1023;
unsigned address = (PPU_bgTileAdr(p, layer) + number * bpp * 4) & 0x7fff;
const uint8_t *bits = stage && g_mmx_render_asset_repairs && bpp == 4 && !(frame.ram[0x1f7a] == 1 && layer == 1) ?
@@ -712,6 +720,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
memset(door_cache, 0, sizeof(door_cache));
memset(out, 0, (size_t)view.width * 224 * sizeof(*out));
bool zero_menu = zero_weapons_menu();
+ bool zero_title = zero_title_menu();
bool stage = MmxWidePolicy_IsStageScene(frame.ram) && !zero_menu;
prepare_stage_planes();
LightBeam beams[2];
@@ -720,7 +729,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
MmxRenderAssetsDeathPaletteFade(frame.ram, frame.lines[0].palette) : 0;
const Piece *pieces = frame.expand && g_mmx_render_asset_repairs ? frame.expanded : frame.pieces;
unsigned piece_count = frame.expand && g_mmx_render_asset_repairs ? frame.expanded_count : frame.piece_count;
- const uint8_t *zero = stage || zero_menu ? MmxZeroPose(frame.ram, &frame_zero) : NULL;
+ const uint8_t *zero = stage || zero_menu || zero_title ? MmxZeroPose(frame.ram, &frame_zero) : NULL;
const uint8_t *blade = stage && zero ? MmxZeroBlade(&frame_zero) : NULL;
Piece waiting[128];
unsigned waiting_count = stage && g_mmx_render_asset_repairs ?
@@ -746,7 +755,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
const Raster *r = &frame.lines[y]; Ppu p;
memcpy(&p, r->registers, PPU_SAVESTATE_REGS_SIZE);
if (beam_count) p.cgwsel = (p.cgwsel & 0xcf) | 0x20;
- if ((p.bgmode & 7) != 1 || (!stage && !zero_menu)) {
+ if ((p.bgmode & 7) != 1 || (!stage && !zero_menu && !zero_title)) {
memcpy(out + y * view.width + view.extra, frame.stock + y * 256, 256 * sizeof(*out));
++stats.fallback_lines; continue;
}
@@ -813,9 +822,9 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
if (zero_life) {
if (!life_drawn[s.object >> 5]) {
life_drawn[s.object >> 5] = true;
- int lx = (int16_t)(word(frame.ram,s.object + 5) - word(frame.ram,0x1e4d)) - 12;
- int ly = (int16_t)(word(frame.ram,s.object + 8) - word(frame.ram,0x1e50)) - 12;
- sprite(&p,r,lx,ly,s.attr & 0x3fff,24,y,view,objects,false,NULL,0,object_colors,true,2);
+ int lx = (int16_t)(word(frame.ram,s.object + 5) - word(frame.ram,0x1e4d)) - MMX_ZERO_LIFE_WIDTH / 2;
+ int ly = (int16_t)(word(frame.ram,s.object + 8) - word(frame.ram,0x1e50)) - MMX_ZERO_LIFE_HEIGHT / 2;
+ sprite(&p,r,lx,ly,s.attr & 0x3fff,MMX_ZERO_LIFE_WIDTH,y,view,objects,false,NULL,0,object_colors,true,2);
}
continue;
}
diff --git a/src/mmx_zero.c b/src/mmx_zero.c
index 448643e..0c9ec13 100644
--- a/src/mmx_zero.c
+++ b/src/mmx_zero.c
@@ -81,7 +81,7 @@ bool MmxZeroLoad(const char *path) {
const uint16_t *MmxZeroColors(void) { return colors; }
const uint8_t *MmxZeroMenuPose(void) { return poses; }
int MmxZeroLifeColor(unsigned x, unsigned y) {
- if (!poses || x >= 24 || y >= 24) return -2;
+ if (!poses || x >= MMX_ZERO_LIFE_WIDTH || y >= MMX_ZERO_LIFE_HEIGHT) return -2;
unsigned color = zero_life_pixels[y][x];
return color & 0x8000 ? -2 : (int)color;
}
@@ -233,7 +233,9 @@ static void release_projectile(uint8_t *r) {
}
state.projectile = 0;
}
+static void clear_charge(uint8_t *r);
void MmxZeroCancel(uint8_t ram[0x20000]) {
+ if (ram && (state.charge || state.combo || state.burst || state.slash)) clear_charge(ram);
if (ram) release_projectile(ram);
/* Combat cancellation (hurt, weapon switch, menus) must not reset the
* independent body animation. Full reset/load uses MmxZeroResetState. */
@@ -246,7 +248,16 @@ static unsigned free_projectile(const uint8_t *r) {
if (!word(r + d)) return d;
return 0;
}
+static void sound(uint8_t *r, unsigned command) {
+ /* Same guest-owned ring as $80:88CD. The ordinary SPC driver consumes it. */
+ unsigned index = r[0xba3] & 0x1e;
+ r[0xb72 + index] = (uint8_t)command; r[0xb73 + index] = 0;
+ r[0xba3] = (uint8_t)((index + 2) & 0x1e);
+}
static void clear_charge(uint8_t *r) {
+ /* $81:9890 stops the looping charge voice before clearing charge state.
+ * Bypassing that release path without $17 leaves the SPC voice playing. */
+ if (r[0xc2f] & 64) { sound(r,0x17); r[0xc2f] &= (uint8_t)~64; }
memset(r + 0xbff, 0, 5);
}
static unsigned burst_sequence(void) {
@@ -269,6 +280,7 @@ static void emit_burst(uint8_t *r) {
if (!d) return;
memset(r + d,0,64); r[d] = 1; r[d + 10] = 3;
++r[0xbdd]; r[0x1f0d] = 4;
+ sound(r,2); /* Native full-buster release sound ($81:A015), once per shot. */
state.shot_mask |= (uint8_t)(1u << ((d - 0x1228) / 64));
state.cooldown = 0;
}
diff --git a/src/mmx_zero.h b/src/mmx_zero.h
index afec478..78643c6 100644
--- a/src/mmx_zero.h
+++ b/src/mmx_zero.h
@@ -4,6 +4,7 @@
#include
enum { MMX_ZERO_WIDTH = 128, MMX_ZERO_HEIGHT = 128, MMX_ZERO_POSES = 152,
+ MMX_ZERO_LIFE_WIDTH = 17, MMX_ZERO_LIFE_HEIGHT = 16,
MMX_ZERO_ANIMATION_BYTES = 0x474, MMX_ZERO_MUZZLE_BYTES = 196,
MMX_ZERO_LEGACY_STATE_SIZE = 12, MMX_ZERO_ANIMATION_STATE_SIZE = 18 };
typedef struct MmxZeroState {
diff --git a/src/mmx_zero_life.h b/src/mmx_zero_life.h
index 69bc7ae..c6f1070 100644
--- a/src/mmx_zero_life.h
+++ b/src/mmx_zero_life.h
@@ -1,27 +1,19 @@
/* Generated by tools/compile_zero_life.py from assets/zero/life-head.png. */
-static const uint16_t zero_life_pixels[24][24] = {
- {0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000},
- {0x8000,0x8000,0x0842,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x0842,0x8000,0x8000},
- {0x8000,0x8000,0x145f,0x0842,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x0842,0x145f,0x8000,0x8000},
- {0x8000,0x8000,0x145f,0x145f,0x0c14,0x8000,0x8000,0x8000,0x8000,0x0842,0x2071,0x2071,0x1c70,0x2071,0x0842,0x8000,0x8000,0x8000,0x8000,0x0c14,0x145f,0x145f,0x8000,0x8000},
- {0x8000,0x8000,0x145f,0x145f,0x145f,0x0842,0x8000,0x8000,0x0842,0x1c70,0x1c70,0x2071,0x1c70,0x1c70,0x1c70,0x0842,0x8000,0x8000,0x0842,0x145f,0x145f,0x145f,0x8000,0x8000},
- {0x8000,0x8000,0x0842,0x145f,0x145f,0x0c14,0x0842,0x0842,0x1c70,0x1c70,0x1c70,0x2071,0x1c70,0x1c70,0x1c70,0x1c70,0x0842,0x0842,0x0c14,0x145f,0x145f,0x0842,0x8000,0x8000},
- {0x8000,0x8000,0x0842,0x145f,0x145f,0x145f,0x0c14,0x0c2b,0x2071,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x0c2b,0x0c14,0x145f,0x145f,0x145f,0x0842,0x8000,0x8000},
- {0x8000,0x8000,0x8000,0x0842,0x145f,0x145f,0x0c14,0x0c2b,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x0c2b,0x0c14,0x145f,0x145f,0x0842,0x8000,0x8000,0x8000},
- {0x8000,0x8000,0x8000,0x0842,0x145f,0x145f,0x145f,0x0c14,0x0c2b,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x0c2b,0x0c14,0x145f,0x145f,0x145f,0x0842,0x8000,0x8000,0x8000},
- {0x8000,0x8000,0x0842,0x24b2,0x0c14,0x145f,0x145f,0x145f,0x145f,0x1c70,0x2071,0x1c70,0x1c70,0x1c70,0x1c70,0x145f,0x145f,0x145f,0x145f,0x0c14,0x24b2,0x0842,0x8000,0x8000},
- {0x8000,0x0842,0x0842,0x24b2,0x24b2,0x145f,0x145f,0x145f,0x145f,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x1c70,0x145f,0x145f,0x145f,0x145f,0x24b2,0x24b2,0x0842,0x0842,0x8000},
- {0x0842,0x77bd,0x0842,0x24b2,0x24b2,0x145f,0x145f,0x145f,0x145f,0x0c2b,0x24b2,0x2071,0x2071,0x2071,0x0c2b,0x145f,0x145f,0x145f,0x145f,0x24b2,0x24b2,0x0842,0x77bd,0x0842},
- {0x0842,0x6f7a,0x39cf,0x24b2,0x24b2,0x145f,0x145f,0x145f,0x145f,0x6f7a,0x6f7a,0x7355,0x7355,0x7355,0x7355,0x145f,0x145f,0x145f,0x145f,0x24b2,0x24b2,0x39cf,0x6f7a,0x0842},
- {0x8000,0x6f7a,0x6f7a,0x39cf,0x24b2,0x0c14,0x145f,0x145f,0x145f,0x145f,0x6f7a,0x6f7a,0x7355,0x7355,0x145f,0x145f,0x145f,0x145f,0x0c14,0x24b2,0x4e73,0x6f7a,0x6f7a,0x8000},
- {0x8000,0x6f7a,0x6f7a,0x6f7a,0x24b2,0x0c45,0x0c14,0x145f,0x145f,0x145f,0x145f,0x6f7a,0x7355,0x145f,0x145f,0x145f,0x145f,0x0c14,0x0c45,0x24b2,0x77bd,0x6f7a,0x6f7a,0x8000},
- {0x8000,0x0842,0x77bd,0x6f7a,0x0c14,0x0c45,0x0842,0x4f5f,0x0c14,0x145f,0x145f,0x145f,0x145f,0x145f,0x145f,0x0c14,0x4f5f,0x0842,0x0c45,0x0c14,0x6f7a,0x6f7a,0x0842,0x8000},
- {0x8000,0x0842,0x6f7a,0x145f,0x145f,0x0c45,0x0842,0x4f5f,0x4f5f,0x0c14,0x145f,0x145f,0x145f,0x145f,0x0c14,0x4f5f,0x4f5f,0x0842,0x0c45,0x145f,0x145f,0x6f7a,0x0842,0x8000},
- {0x8000,0x8000,0x4e73,0x145f,0x0c2b,0x6f7a,0x77bd,0x0842,0x4f5f,0x4f5f,0x4f5f,0x0c14,0x0c14,0x4f5f,0x4f5f,0x4f5f,0x0842,0x77bd,0x6f7a,0x0c2b,0x145f,0x4e73,0x8000,0x8000},
- {0x8000,0x8000,0x0842,0x145f,0x0c2b,0x6f7a,0x77bd,0x77bd,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x533e,0x77bd,0x77bd,0x6f7a,0x0c2b,0x145f,0x0842,0x8000,0x8000},
- {0x8000,0x8000,0x8000,0x0842,0x0c14,0x6f7a,0x77bd,0x77bd,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x77bd,0x77bd,0x6f7a,0x0c14,0x0842,0x8000,0x8000,0x8000},
- {0x8000,0x8000,0x8000,0x8000,0x0842,0x4e73,0x6f7a,0x77bd,0x0842,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x0842,0x77bd,0x6f7a,0x4e73,0x0842,0x8000,0x8000,0x8000,0x8000},
- {0x8000,0x8000,0x8000,0x8000,0x8000,0x0842,0x6f7a,0x77bd,0x8000,0x0842,0x4f5f,0x4f5f,0x4f5f,0x4f5f,0x0842,0x8000,0x77bd,0x6f7a,0x0842,0x8000,0x8000,0x8000,0x8000,0x8000},
- {0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x0842,0x0842,0x8000,0x8000,0x8000,0x0842,0x0842,0x8000,0x8000,0x8000,0x0842,0x0842,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000},
- {0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000},
+static const uint16_t zero_life_pixels[MMX_ZERO_LIFE_HEIGHT][MMX_ZERO_LIFE_WIDTH] = {
+ {0x8000,0x1084,0x1084,0x8000,0x8000,0x8000,0x1084,0x1084,0x1084,0x1084,0x1084,0x8000,0x8000,0x8000,0x1084,0x1084,0x8000},
+ {0x8000,0x1084,0x147f,0x1084,0x8000,0x1084,0x1015,0x2078,0x2078,0x2078,0x1015,0x1084,0x8000,0x1084,0x147f,0x1084,0x8000},
+ {0x8000,0x1084,0x147f,0x1015,0x1084,0x1015,0x1c7a,0x1c7a,0x1c7a,0x2078,0x2078,0x1015,0x1084,0x1015,0x147f,0x1084,0x8000},
+ {0x8000,0x8000,0x1083,0x1c7a,0x1015,0x2078,0x1c7a,0x1c7a,0x1c7a,0x2078,0x2078,0x1c7a,0x1015,0x1c7a,0x1084,0x8000,0x8000},
+ {0x8000,0x8000,0x1084,0x147f,0x1015,0x1c7a,0x2078,0x2078,0x1c7a,0x1c7a,0x1c7a,0x2078,0x1015,0x147f,0x1084,0x8000,0x8000},
+ {0x8000,0x8000,0x1084,0x147f,0x147f,0x1015,0x2078,0x1084,0x1084,0x1084,0x1c7a,0x1015,0x147f,0x147f,0x1084,0x8000,0x8000},
+ {0x8000,0x1084,0x5ed6,0x1015,0x147f,0x147f,0x1084,0x7f97,0x7ece,0x7de4,0x1084,0x147f,0x147f,0x1015,0x5ed6,0x1084,0x8000},
+ {0x1084,0x4a51,0x7bde,0x1015,0x147f,0x147f,0x1084,0x7e06,0x7de4,0x7de4,0x1084,0x147f,0x147f,0x1015,0x7bde,0x4a51,0x1084},
+ {0x1084,0x1015,0x7bde,0x5ed6,0x1084,0x147f,0x147f,0x1084,0x7de4,0x1084,0x147f,0x147f,0x1084,0x5ed6,0x7bde,0x1015,0x1084},
+ {0x1084,0x147f,0x1015,0x7bde,0x1084,0x7bde,0x1084,0x1c7a,0x1084,0x1c7a,0x1084,0x7bde,0x1084,0x7bde,0x1015,0x147f,0x1084},
+ {0x1084,0x147f,0x5ed6,0x4a51,0x1084,0x7bde,0x7bde,0x7bde,0x147f,0x7bde,0x7bde,0x7bde,0x1084,0x4a51,0x5ed6,0x147f,0x1084},
+ {0x8000,0x1084,0x5ed6,0x7bde,0x1084,0x7bde,0x1084,0x7bde,0x4adf,0x7bde,0x1084,0x7bde,0x1084,0x7bde,0x5ed6,0x1084,0x8000},
+ {0x8000,0x1084,0x5ed6,0x7bde,0x1084,0x4adf,0x4adf,0x323e,0x323e,0x323e,0x4adf,0x4adf,0x1084,0x7bde,0x5ed6,0x1084,0x8000},
+ {0x8000,0x8000,0x1084,0x5ed6,0x1084,0x323e,0x4adf,0x1084,0x1084,0x1084,0x4adf,0x323e,0x1084,0x5ed6,0x1084,0x8000,0x8000},
+ {0x8000,0x8000,0x8000,0x1084,0x1084,0x1084,0x323e,0x4adf,0x4adf,0x4adf,0x323e,0x1084,0x1084,0x1084,0x8000,0x8000,0x8000},
+ {0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x1084,0x1084,0x1084,0x1084,0x1084,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000},
};
diff --git a/tests/mmx_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c
index 6ea1445..9fb79ea 100644
--- a/tests/mmx_adaptive_state_test.c
+++ b/tests/mmx_adaptive_state_test.c
@@ -438,6 +438,28 @@ int main(int argc, char **argv) {
size_t cap = 2u * 1024u * 1024u;
uint8 *start = malloc(cap), *expected = malloc(cap), *actual = malloc(cap);
const char *zero_assets = getenv("MMX_ZERO_TEST_ASSETS");
+ const char *zero_title = getenv("MMX_ZERO_TITLE_FIXTURE");
+ if (zero_assets && zero_title) {
+ check(MmxZeroLoad(zero_assets),"title Zero assets load");
+ MmxZeroRegisterHooks();
+ check(RtlLoadSnapshot(zero_title),"private title fixture loads");
+ zero_replay(45);
+ const char *capture=getenv("MMX_ZERO_TEST_CAPTURE");
+ zero_capture(capture,".title-idle.cap");
+ check(g_ram[0xba9]==2 && g_ram[0xbbf]==0,"title cursor is standing player");
+ frame(SNES_PAD_START);
+ bool title_shot=false;
+ for(unsigned i=0;i<40;++i) {
+ frame(0);
+ title_shot |= g_ram[0x1229]==2 && g_ram[0x123e]==0x0e;
+ if(i==4 || i==9 || i==19) {
+ char suffix[64]; snprintf(suffix,sizeof(suffix),".title-shot%u.cap",i+2);
+ zero_capture(capture,suffix);
+ }
+ }
+ check(title_shot,"title confirmation preserves native full-buster projectile");
+ puts("MMX ZERO TITLE CHECKS PASSED"); return 0;
+ }
if (zero_assets) {
zero_state_checks(zero_assets, getenv("MMX_ZERO_TEST_FIXTURE"), start, expected, actual, cap);
return 0;
diff --git a/tests/mmx_zero_test.c b/tests/mmx_zero_test.c
index fda7677..40d79c8 100644
--- a/tests/mmx_zero_test.c
+++ b/tests/mmx_zero_test.c
@@ -95,9 +95,16 @@ int main(void) {
if(tiers[c] && tiers[c]<8) assert(ram[0xc01]==(tiers[c]==4?6:8));
}
assert(MmxZeroGetState().burst == 1 && !ram[0x1228]);
+ /* The native engine started a looping charge voice before host release. */
+ player();
+ for(unsigned i=0;i<201;++i) tick(64,i==0?64:0);
+ ram[0xc2f] |= 64;
+ tick(0,0);
+ assert(!(ram[0xc2f]&64) && ram[0xba3]==2 && ram[0xb72]==0x17);
assert(ram[0x1f99] == 0); /* Dash/charge never grant equipment. */
for(int i=2;i<=7;++i) tick(0,0);
assert(!ram[0x1228]); tick(0,0); assert(ram[0x1228] && ram[0x1232]==3);
+ assert(ram[0xba3]==4 && ram[0xb74]==2);
ram[0xc25]=1; ram[0x1f0d]=0;
for(int i=9;i<=18;++i) tick(0,0);
tick(64,64);
@@ -105,6 +112,7 @@ int main(void) {
for(int i=2;i<=9;++i) tick(0,0);
assert(!ram[0x1268]); tick(0,0);
assert(ram[0x1268] == 1 && ram[0x1272] == 3 && ram[0xbdd] == 2);
+ assert(ram[0xba3]==6 && ram[0xb76]==2);
assert(ram[0xc25] == 1); /* Native initializer, not us, increments that counter. */
for(int i=11;i<=29;++i) tick(0,0);
tick(64,64); assert(!MmxZeroGetState().slash); /* Live beams block saber. */
@@ -125,6 +133,7 @@ int main(void) {
assert(MmxZeroHitbox(ram,0xea8,s.projectile,0xffb0) == 0xffb0);
assert(MmxZeroDamage(ram,0xe68,s.projectile,3) == 0);
assert(MmxZeroDamage(ram,0xea8,s.projectile,3) == 16);
+ assert(ram[0xba3]==6); /* No repeated stop/release sounds through recovery. */
s = MmxZeroGetState(); MmxZeroResetState(); MmxZeroSetState(s);
assert(MmxZeroGetState().hit_slots == s.hit_slots && MmxZeroGetState().slash == s.slash);
MmxZeroState invalid = s; invalid.air = 2; MmxZeroSetState(invalid);
@@ -137,6 +146,11 @@ int main(void) {
for(unsigned d=0x1228;d<0x1428;d+=64) ram[d] = 1;
tick(64,64); assert(MmxZeroGetState().combo == 2 && !MmxZeroGetState().slash);
ram[0xbaa] = 0x0e; tick(0,0); assert(!MmxZeroGetState().combo);
+ player(); tick(64,64); ram[0xc2f]|=64;
+ MmxZeroCancel(ram); MmxZeroCancel(ram);
+ assert(ram[0xba3]==2 && ram[0xb72]==0x17 && !(ram[0xc2f]&64));
+ ram[0xc2f]|=64; MmxZeroCancel(ram);
+ assert(ram[0xba3]==2 && (ram[0xc2f]&64)); /* Native special charge is untouched. */
MmxZeroDisable(); MmxZeroSetCollisionRom(rom,sizeof(rom));
assert(!memcmp(rom,clean,sizeof(rom)));
remove("zero-test.bin"); remove("zero-test-bad.bin");
diff --git a/tools/compile_zero_life.py b/tools/compile_zero_life.py
index 19c162b..8169856 100644
--- a/tools/compile_zero_life.py
+++ b/tools/compile_zero_life.py
@@ -1,21 +1,21 @@
#!/usr/bin/env python3
-"""Pack the authored 24px RGBA life icon into native BGR555 renderer data."""
+"""Pack the authored 17x16 RGBA life icon into native BGR555 renderer data."""
from pathlib import Path
from PIL import Image
root = Path(__file__).resolve().parents[1]
image = Image.open(root / 'assets/zero/life-head.png').convert('RGBA')
-if image.size != (24, 24):
- raise SystemExit('Zero life icon must be 24x24')
+if image.size != (17, 16):
+ raise SystemExit('Zero life icon must be 17x16')
rows = []
-for y in range(24):
+for y in range(image.height):
values = []
- for x in range(24):
+ for x in range(image.width):
r, g, b, a = image.getpixel((x, y))
color = 0x8000 if a < 128 else (r >> 3) | ((g >> 3) << 5) | ((b >> 3) << 10)
values.append(f'0x{color:04x}')
rows.append(' {' + ','.join(values) + '},')
(root / 'src/mmx_zero_life.h').write_text(
'/* Generated by tools/compile_zero_life.py from assets/zero/life-head.png. */\n'
- 'static const uint16_t zero_life_pixels[24][24] = {\n' + '\n'.join(rows) + '\n};\n',
+ 'static const uint16_t zero_life_pixels[MMX_ZERO_LIFE_HEIGHT][MMX_ZERO_LIFE_WIDTH] = {\n' + '\n'.join(rows) + '\n};\n',
encoding='utf-8', newline='\n')
From 79418eca56fa090ff8239b0da69add7e20735546 Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Mon, 28 Sep 2026 22:50:38 -0700
Subject: [PATCH 11/16] Add grounded Select exchange with original X and Zero
teleport effects
---
README.md | 4 +-
assets/zero/README.md | 16 ---
assets/zero/life-head.png | Bin 361 -> 0 bytes
docs/zero-spike.md | 40 +++++--
.../0.1.0/manifest.toml | 2 +-
src/mmx_render_assets.c | 46 ++++++++
src/mmx_render_assets.h | 2 +
src/mmx_renderer.c | 108 +++++++++++-------
src/mmx_rtl.c | 6 +-
src/mmx_zero.c | 95 ++++++++++++---
src/mmx_zero.h | 14 ++-
src/mmx_zero_life.h | 19 ---
src/mods/mmx_zero_plugin.c | 2 +-
tests/mmx_adaptive_state_test.c | 59 +++++++++-
tools/compile_zero_life.py | 21 ----
15 files changed, 299 insertions(+), 135 deletions(-)
delete mode 100644 assets/zero/README.md
delete mode 100644 assets/zero/life-head.png
delete mode 100644 src/mmx_zero_life.h
delete mode 100644 tools/compile_zero_life.py
diff --git a/README.md b/README.md
index fcc6509..28209df 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,9 @@ availability and controls.
This branch also includes the disabled-by-default [X3 Zero experiment](docs/zero-spike.md).
It requires locally extracted X3 USA assets. The prototype supports the two-shot
-and saber combo; complete animation, weapon and campaign validation is unfinished.
+and saber combo, plus Select to exchange X and Zero while standing on the ground.
+The exchange uses original teleport sprites and pauses stage simulation.
+Complete campaign validation is unfinished.
diff --git a/assets/zero/README.md b/assets/zero/README.md
deleted file mode 100644
index 59c8bb9..0000000
--- a/assets/zero/README.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# Zero life head
-
-`life-head.png` is a transparent 17x16 front-facing Zero life icon, shared by the weapons menu and life pickups. It is one pixel wider than X's 16px canvas. The red helmet fins, blue crystal, white eye highlights and dark pupils remain readable at native resolution. It is new mod art, not an extracted X3 life icon.
-
-The built-in image-generation tool produced the reference-guided raster. Import cropped its opaque bounds, sampled onto the native grid without interpolation and reduced it to 16 colors. Final pixel hinting preserves the white pixels beside each pupil and the face between the eyes. The runtime uses native BGR555 colors. This replaces the oversized 24px version.
-
-Run `python tools/compile_zero_life.py` after editing the PNG. This deterministic compiler writes `src/mmx_zero_life.h`; no image library is needed at runtime. Pickup collection rules and hitboxes remain native.
-
-Owner references:
-
-- `dacu4gt-2bc6f245-ea43-438a-90e7-3caaa5343b46.png`: frontal Zero helmet identity.
-- `512x512.png`: original X life icon's pixel-art style and eyes.
-
-Final built-in generation prompt brief: preserve Zero's angular red helmet and tall fins; make a compact front-facing SNES life head near X's 16x16 size, with a blue crystal, white eyes and dark pupils, a limited flat palette, hard pixel edges and transparent background. Do not turn the helmet into a recolored X helmet.
-
-Selected generated source: `exec-5c607f14-4679-4487-a6c9-357ce0a02e87.png`. Only the imported native-resolution asset is required by the project.
diff --git a/assets/zero/life-head.png b/assets/zero/life-head.png
deleted file mode 100644
index 0c65073ce9e70a42a4b16610138dd71d47618bdd..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 361
zcmeAS@N?(olHy`uVBq!ia0vp^f
Nn{1`ISV`@iy0XB4ude`@%$AjK*4{WE{-7<{;d;jxehr9w0>6z^w_d>ectoQD9G$HDpY9X;QfO5Zm$q
z4#$iKj~(YU&0}dg^0LO;=X-ei_38Kg%=cQqR{c;dVD|N0!t75e1_MKa!PC{xWt~$(
F69A&SjmH20
diff --git a/docs/zero-spike.md b/docs/zero-spike.md
index c1c1f7e..aa54704 100644
--- a/docs/zero-spike.md
+++ b/docs/zero-spike.md
@@ -161,11 +161,10 @@ Full-port progress on `feat/x3-zero-port`:
mismatch that let X's tiles reappear. A renderer regression covers both the
visible transition and the actual hidden frame.
- The pause menu uses Zero's original standing body, including with all armor
- upgrades owned. Menu and pickup life heads use a new front-facing 17x16 sprite
- with pupils and a blue crystal; vanilla X3 itself retains X's life icon.
- See [the life-head asset](../assets/zero/life-head.png). Native 1-up
- collection increments the life count normally. Menu pixel comparisons change
- only the character and head areas, including the fully upgraded menu.
+ upgrades owned. Menu and pickup life heads retain X's original art, as
+ requested after the custom icon was rejected. Native 1-up collection increments
+ the life count normally. Menu pixel comparisons change only the character
+ area, including the fully upgraded menu; the life head matches stock exactly.
- The title-screen cursor also uses Zero's original standing/firing poses.
Native selection handling and its confirmation projectile are preserved.
- Combo release and cancellation stop X1's looping charge voice through the
@@ -180,8 +179,8 @@ Full-port progress on `feat/x3-zero-port`:
X1 still owns facing, spread patterns, trajectories and weapon effects.
All eight special weapons produce their expected normal/charged native
projectile classes and spend energy; both firing directions are checked.
-- Saves now use game chunk v6 for Zero's firing phase and stored charge tier,
- retaining v4/v5 saves and older formats. Captures use v5, with v2/v3/v4 still readable. Disabled
+- Saves now use game chunk v7 for character identity and teleport phase,
+ retaining v4/v5/v6 saves and older formats. Captures use v6, with v2-v5 still readable. Disabled
saves remain v3. Complete snapshot, replay, rollback and rewind checks pass.
Owner playtest follow-up: projectile origins and the reported X tiles during
@@ -196,10 +195,29 @@ visible while Zero blinks; X1's health amount and weapon icon are retained.
The current development executable is `build-zero/port-work/MegaManXSNESRecomp.exe`;
the owner's earlier screenshot session remains in `build-zero/hud-update`.
-An optional Select-button X/Zero swap is a longer-term direction, tracked in
-`beads-8wg.1.30`. That feature will need an explicit active-character state
-separate from loaded assets, with the body, abilities, collision and HUD changing
-together. Swapping is not implemented by this HUD update.
+Select now exchanges X and Zero while the player stands still on solid ground,
+outside menus, damage, ladders, ride armor and scripted transitions. The body,
+abilities, collision, menu and HUD follow the active character. Loaded assets
+remain independent of that identity, so X uses his native upgrade rules.
+
+The exchange uses original X3 Zero group `$4A` poses `$3C-$42`, and original
+X1 body layouts at `$8D:8000` with CHR DMA lists at `$85:A597` and the buster
+palette list `$0100`. Morph timing follows X1 sequences `$49/$48` and X3
+`$7F/$7E`; column travel follows X1's `$0AA6/$0800` departure/arrival speeds.
+The between-character delay is X3's `$1E` frames (`$84:8DBF`). X1's original
+teleport sounds `$0F/$0E` play through its SPC command ring. No replacement
+teleport art is drawn or generated.
+
+All cooperative game tasks pause during the exchange, including enemies,
+projectiles, items, animation and stage/camera scripts. NMI, input polling,
+rendering and audio continue. Only the presentation moves vertically: the
+player's real feet stay fixed. Held Select cannot retrigger; midair Select is
+ignored. Save/load preserves the full exchange and restores the frozen sprite
+queues. Both directions have focused runtime checks, including a live projectile
+and mid-exchange deterministic replay. Tracked in `beads-8wg.1.30`.
+
+This first swap build shares the existing X1 health and weapon pools. Separate
+character HP and the requested X2/X3 ability pages are subsequent work.
1. Audit the complete animation/state mapping: wall slide/jump, ladders, damage,
death, teleport, capsules, ride armor and scripted player poses. The full-port
diff --git a/mods/preloaded/packages/megaman-x.character.zero/0.1.0/manifest.toml b/mods/preloaded/packages/megaman-x.character.zero/0.1.0/manifest.toml
index d80a5dd..0553d0f 100644
--- a/mods/preloaded/packages/megaman-x.character.zero/0.1.0/manifest.toml
+++ b/mods/preloaded/packages/megaman-x.character.zero/0.1.0/manifest.toml
@@ -15,7 +15,7 @@ rom_sha256 = "b8f70a6e7fb93819f79693578887e2c11e196bdf1ac6ddc7cb924b1ad0be2d32"
[[feature]]
id = "zero"
name = "Play as Zero (experimental)"
-description = "Use locally extracted X3 Zero assets. Development prototype; campaign validation is incomplete."
+description = "Start as Zero and press Select while standing on the ground to exchange with X. Uses locally extracted X3 assets."
group = "Characters"
default_enabled = false
diff --git a/src/mmx_render_assets.c b/src/mmx_render_assets.c
index 56497de..fe6b4b0 100644
--- a/src/mmx_render_assets.c
+++ b/src/mmx_render_assets.c
@@ -9,6 +9,8 @@ static size_t rom_size;
static MmxSpriteAsset assets[256];
static MmxSpriteAsset captive_zero;
static unsigned captive_zero_ready;
+static MmxSpriteAsset teleport_x[8];
+static bool teleport_x_ready;
static uint8_t ready[256], sprite_resource[256];
static unsigned cached_stage = ~0u, cached_section = ~0u;
static unsigned bg_stage = ~0u;
@@ -26,6 +28,50 @@ void MmxRenderAssetsSetRom(const uint8_t *bytes, size_t size) {
cached_stage = cached_section = ~0u;
bg_stage = ~0u;
captive_zero_ready = 0;
+ teleport_x_ready = false;
+}
+const MmxSpriteAsset *MmxRenderAssetsTeleportX(unsigned pose) {
+ unsigned index = pose == 0 ? 7 : pose >= 0x3c && pose <= 0x42 ? pose - 0x3c : 8;
+ if (index >= 8 || !rom) return NULL;
+ if (!teleport_x_ready) {
+ uint16_t colors[16] = {0};
+ /* Buster palette list $0100, loaded by $81:9E8F / $82:8011. */
+ size_t list = 0x30000 + (word(0x30233) & 0x7fff);
+ bool found = false;
+ for (unsigned guard = 0; guard < 32; ++guard, list += 4) {
+ if (!range(list,4)) return NULL;
+ unsigned count = rom[list]; if (!count) break;
+ size_t source = 0x28000 + (word(list + 1) & 0x7fff);
+ if (!range(source,count * 2)) return NULL;
+ for (unsigned i = 0; i < count; ++i) {
+ int dest = rom[list + 3] + (int)i - 144;
+ if (dest >= 0 && dest < 16) { colors[dest] = (uint16_t)word(source + i * 2); found = true; }
+ }
+ }
+ if (!found) return NULL;
+ for (unsigned n = 0; n < 8; ++n) {
+ MmxSpriteAsset *a = &teleport_x[n]; memset(a,0,sizeof(*a));
+ unsigned f = n == 7 ? 0 : n + 0x3c;
+ /* Intermediate morph poses inherit $3E's CHR ($42 reloads it).
+ * The X1 five-byte DMA records contain literal ROM bank/source data. */
+ if (f >= 0x3f && f <= 0x41) f = 0x3e;
+ size_t dma = 0x2a597 + word(0x2a597 + f * 2);
+ bool complete = false;
+ for (unsigned guard = 0; guard < 32; ++guard, dma += 5) {
+ if (!range(dma,5) || !rom[dma]) return NULL;
+ unsigned count = rom[dma] * 16;
+ size_t source = lorom(word(dma + 1) | (rom[dma + 3] << 16));
+ int dest = (((int)rom[dma + 4] & 127) * 256 - 0x6000) * 2;
+ if (!range(source,count) || dest < 0 || dest + count > sizeof(a->tiles)) return NULL;
+ memcpy(a->tiles + dest,rom + source,count);
+ if (rom[dma + 4] & 128) { complete = true; break; }
+ }
+ if (!complete) return NULL;
+ memcpy(a->colors,colors,sizeof(colors));
+ }
+ teleport_x_ready = true;
+ }
+ return &teleport_x[index];
}
static size_t decode_resource(unsigned id, uint8_t decoded[65536]) {
size_t info = 0x376f7 + id * 5;
diff --git a/src/mmx_render_assets.h b/src/mmx_render_assets.h
index 9c71ba3..d098757 100644
--- a/src/mmx_render_assets.h
+++ b/src/mmx_render_assets.h
@@ -13,6 +13,8 @@ typedef struct MmxSpriteAsset {
void MmxRenderAssetsSetRom(const uint8_t *rom, size_t size);
const MmxSpriteAsset *MmxRenderAssetsSprite(unsigned stage, unsigned section, unsigned sprite);
const MmxSpriteAsset *MmxRenderAssetsCaptiveZero(void);
+/* Original X1 body/beam CHR and blue palette, decoded without touching VRAM. */
+const MmxSpriteAsset *MmxRenderAssetsTeleportX(unsigned pose);
const MmxSpriteAsset *MmxRenderAssetsObjectSprite(const uint8_t ram[0x20000],
unsigned object, unsigned animation);
bool MmxRenderAssetsRideArmorPalettePending(const uint8_t ram[0x20000], const uint16_t colors[16]);
diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c
index 43b81b8..989dbb5 100644
--- a/src/mmx_renderer.c
+++ b/src/mmx_renderer.c
@@ -72,7 +72,7 @@ MmxDisplayViewport MmxRendererDestination(MmxRenderView view, int width, int hei
}
static unsigned word(const uint8_t *p, unsigned a) { return p[a] | (p[a + 1] << 8); }
static bool zero_weapons_menu(void) {
- return MmxZeroEnabled() && frame.ram[0xd1] == 2 && frame.ram[0xd2] == 4 &&
+ return MmxZeroEnabled() && !frame_zero.active_x && frame.ram[0xd1] == 2 && frame.ram[0xd2] == 4 &&
(((frame.ram[0x1f10] == 6 || frame.ram[0x1f10] == 8) && (frame.ram[0xc3] & 128)) ||
(frame.ram[0x1989] == 1 && word(frame.ram,0x198d) == 128 &&
word(frame.ram,0x1990) == 160 && (frame.ram[0x199e] == 0 || frame.ram[0x199e] == 0x18)));
@@ -81,7 +81,7 @@ static bool zero_title_menu(void) {
/* Retail title-menu cursor is the player object, at one of three rows.
* Its native idle/firing animation and selection beam remain active. */
unsigned y = word(frame.ram,0xbb0);
- return MmxZeroEnabled() && frame.ram[0xd1] == 0 && frame.ram[0xba9] == 2 &&
+ return MmxZeroEnabled() && !frame_zero.active_x && frame.ram[0xd1] == 0 && frame.ram[0xba9] == 2 &&
frame.ram[0xbbe] == 0 && word(frame.ram,0xbad) == 32 &&
y >= 166 && y <= 198;
}
@@ -192,15 +192,7 @@ static unsigned fortress_waiting_pieces(Piece out[128], const Piece *pieces, uns
}
return count;
}
-void MmxRendererObserveObject(const uint8_t ram[0x20000], uint16_t object) {
- if (!g_mmx_custom_renderer || !ram) return;
- if (building_stage != ram[0x1f7a]) {
- building_count = expanded_building_count = 0; observed_lists = false;
- building_stage = ram[0x1f7a];
- }
- current_object = object;
- if (observed_lists || !g_mmx_expanded_sprites) return;
- observed_lists = true;
+static void expand_queues(const uint8_t *ram) {
/* D56F's actual six priority queues, captured before D6A7 can exhaust OAM.
* Keep its order: queues 0..2, weapon objects, X, queues 3..5. No arbitrary
* scan of dormant object slots and no additional guest objects or writes. */
@@ -215,6 +207,17 @@ void MmxRendererObserveObject(const uint8_t ram[0x20000], uint16_t object) {
for (unsigned i = 0; i < count; ++i) expand_object(ram, word(ram, 0x920 + group * 64 + i * 2));
}
}
+void MmxRendererObserveObject(const uint8_t ram[0x20000], uint16_t object) {
+ if (!g_mmx_custom_renderer || !ram) return;
+ if (building_stage != ram[0x1f7a]) {
+ building_count = expanded_building_count = 0; observed_lists = false;
+ building_stage = ram[0x1f7a];
+ }
+ current_object = object;
+ if (observed_lists || !g_mmx_expanded_sprites) return;
+ observed_lists = true;
+ expand_queues(ram);
+}
void MmxRendererRecordPiece(const uint8_t ram[0x20000], uint16_t d) {
if (!g_mmx_custom_renderer || !ram || d > 0xffe0 || building_count >= MAX_PIECES) return;
if (building_stage != ram[0x1f7a]) {
@@ -230,6 +233,7 @@ void MmxRendererRecordPiece(const uint8_t ram[0x20000], uint16_t d) {
ram[d + 0x10], animation, current_object);
}
void MmxRendererLatchSprites(void) {
+ if (MmxZeroSwapping() && !building_count) return;
/* Menu fades suspend OAM construction but the PPU keeps its previous
* sprites. Retain only Zero's attribution; drawing still requires an exact
* match in that raster's live OAM, so a real hidden blink stays hidden. */
@@ -280,6 +284,16 @@ void MmxRendererBeginFrame(const uint8_t ram[0x20000]) {
frame.valid = false; frame.captured = 0;
memcpy(frame.ram, ram, sizeof(frame.ram));
frame_zero = MmxZeroGetState();
+ if (MmxZeroSwapping() && !latched_count) {
+ /* A mid-swap load has no preceding submission. Rebuild the frozen
+ * native queues, including margin actors, before matching live OAM. */
+ expanded_building_count = 0; expand_queues(ram);
+ latched_stage = ram[0x1f7a];
+ latched_count = expanded_latched_count = expanded_building_count;
+ memcpy(latched,expanded_building,latched_count * sizeof(Piece));
+ memcpy(expanded_latched,expanded_building,latched_count * sizeof(Piece));
+ expanded_building_count = 0;
+ }
frame.piece_count = latched_stage == ram[0x1f7a] ? latched_count : 0;
memcpy(frame.pieces, latched, frame.piece_count * sizeof(*latched));
if (MmxZeroEnabled()) {
@@ -332,7 +346,7 @@ bool MmxRendererSaveCapture(const char *path) {
if (!frame.valid || !path) return false;
FILE *f = fopen(path, "wb");
if (!f) return false;
- uint32_t header[] = {0x4d4d5843, 5, sizeof(frame) + sizeof(frame_zero)};
+ uint32_t header[] = {0x4d4d5843, 6, sizeof(frame) + sizeof(frame_zero)};
bool ok = fwrite(header, sizeof(header), 1, f) == 1 && fwrite(&frame, sizeof(frame), 1, f) == 1 &&
fwrite(&frame_zero, sizeof(frame_zero), 1, f) == 1;
return fclose(f) == 0 && ok;
@@ -347,12 +361,16 @@ bool MmxRendererLoadCapture(const char *path) {
bool ok = fread(h, sizeof(h), 1, f) == 1 && h[0] == 0x4d4d5843 &&
((h[1] == 2 && h[2] == sizeof(frame)) || (h[1] == 3 && h[2] == sizeof(frame) + MMX_ZERO_LEGACY_STATE_SIZE) ||
(h[1] == 4 && h[2] == sizeof(frame) + MMX_ZERO_ANIMATION_STATE_SIZE) ||
- (h[1] == 5 && h[2] == sizeof(frame) + sizeof(frame_zero))) &&
+ (h[1] == 5 && h[2] == sizeof(frame) + MMX_ZERO_COMBAT_STATE_SIZE) ||
+ (h[1] == 6 && h[2] == sizeof(frame) + sizeof(frame_zero))) &&
fread(&frame, sizeof(frame), 1, f) == 1 &&
frame.captured == 224 && frame.piece_count <= MAX_PIECES && frame.expanded_count <= MAX_PIECES && frame.valid;
size_t zero_size = h[1] == 3 ? MMX_ZERO_LEGACY_STATE_SIZE :
- h[1] == 4 ? MMX_ZERO_ANIMATION_STATE_SIZE : sizeof(frame_zero);
+ h[1] == 4 ? MMX_ZERO_ANIMATION_STATE_SIZE :
+ h[1] == 5 ? MMX_ZERO_COMBAT_STATE_SIZE : sizeof(frame_zero);
if (ok && h[1] >= 3) ok = fread(&frame_zero, zero_size, 1, f) == 1 &&
+ frame_zero.active_x <= 1 && frame_zero.swap_phase <= 6 && frame_zero.swap_tick <= 30 &&
+ frame_zero.swap_y >= -320 && frame_zero.swap_y <= 0 &&
frame_zero.slash <= 46 && frame_zero.air <= 1 && frame_zero.anim_valid <= 1 && frame_zero.anim_pose < 117 &&
frame_zero.burst <= 2 && (!frame_zero.burst || (frame_zero.burst_offset >= 272 &&
frame_zero.burst_offset + 3 <= MMX_ZERO_ANIMATION_BYTES && frame_zero.burst_timer));
@@ -556,16 +574,6 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x,
int cx = px & (size - 1), cy = py & (size - 1);
if (tile & 0x4000) cx = size - 1 - cx;
if (tile & 0x8000) cy = size - 1 - cy;
- int life_color = -1;
- if (!stage && layer == 1 && size == 8 && zero_weapons_menu() && (tile & 0xfc00) == 0x0400) {
- unsigned number = tile & 1023;
- if ((number >= 0xea && number <= 0xec) || (number >= 0xfa && number <= 0xfc)) {
- tile = 0x080f; /* The menu's own empty panel tile supplies the backdrop. */
- }
- }
- if (!stage && layer == 1 && size == 8 && zero_weapons_menu() &&
- px >= 195 && px < 195 + MMX_ZERO_LIFE_WIDTH && py >= 144 && py < 144 + MMX_ZERO_LIFE_HEIGHT)
- life_color = MmxZeroLifeColor(px - 195, py - 144);
unsigned number = ((tile & 1023) + cx / 8 + cy / 8 * 16) & 1023;
unsigned address = (PPU_bgTileAdr(p, layer) + number * bpp * 4) & 0x7fff;
const uint8_t *bits = stage && g_mmx_render_asset_repairs && bpp == 4 && !(frame.ram[0x1f7a] == 1 && layer == 1) ?
@@ -577,7 +585,6 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x,
pixel = ((bits[0] >> shift) & 1) | (((bits[1] >> shift) & 1) << 1) |
(((bits[16] >> shift) & 1) << 2) | (((bits[17] >> shift) & 1) << 3);
} else pixel = tile_pixel(r->vram, address, cx & 7, cy & 7, bpp);
- if (life_color >= 0) { pixel = 1; *private_color = life_color; }
if (!pixel) return 0;
unsigned index = (((tile >> 10) & 7) << bpp) | pixel;
const MmxBackgroundPalette *palette = stage && g_mmx_render_asset_repairs ?
@@ -611,7 +618,7 @@ static void sprite(const Ppu *p, const Raster *r, int x, int sy, unsigned attr,
pixel = ((bits[0] >> shift) & 1) | (((bits[1] >> shift) & 1) << 1) |
(((bits[16] >> shift) & 1) << 2) | (((bits[17] >> shift) & 1) << 3);
} else pixel = tile_pixel(r->vram, base + tile * 16, cx & 7, row & 7, 4);
- int hud_color = zero_icon == 1 ? MmxZeroHudColor(cx, row) : zero_icon == 2 ? MmxZeroLifeColor(cx, row) : -1;
+ int hud_color = zero_icon ? MmxZeroHudColor(cx, row) : -1;
if (hud_color >= 0) pixel = 1;
else if (hud_color == -2) pixel = 0;
if (pixel) {
@@ -722,6 +729,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
bool zero_menu = zero_weapons_menu();
bool zero_title = zero_title_menu();
bool stage = MmxWidePolicy_IsStageScene(frame.ram) && !zero_menu;
+ bool swapping = stage && MmxZeroEnabled() && frame_zero.swap_phase;
prepare_stage_planes();
LightBeam beams[2];
unsigned beam_count = stage ? spark_lights(beams, view.extra) : 0;
@@ -780,7 +788,6 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
sprite(&p, r, s.x, s.y, s.attr, s.size, y, view, objects, true, asset, s.tile, object_colors, true, false);
}
bool replaced[128] = {false};
- bool life_drawn[256] = {false};
bool zero_drawn[2] = {false,false};
for (int i = (int)piece_count - 1; i >= 0; --i) {
Piece s = pieces[i]; const MmxSpriteAsset *asset = piece_assets[i];
@@ -792,16 +799,13 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
bool center = g_mmx_render_asset_repairs;
bool menu_body = s.object == 0x1988 && (s.animation == 0 || s.animation == 0x18);
bool zero_body = zero && (s.object == 0xba8 || menu_body);
+ bool swap_actor = swapping && (s.object == 0xba8 || s.object == 0xc38 ||
+ s.object == 0xc58 || s.object == 0xc78 || s.object == 0xc98);
bool zero_charge = zero && stage && s.object == 0xc98 && s.animation == 0x71;
- /* Native group $11 is the four-piece life pickup ($81:E33A). Keep its
- * real object, flashing submissions, terrain and collection behavior. */
- bool zero_life = stage && MmxZeroEnabled() && s.animation == 0x11 &&
- s.object >= 0x1628 && s.object < 0x1928 && (s.object - 0x1628) % 48 == 0 &&
- frame.ram[s.object + 10] == 4;
bool zero_armor = zero && (s.object == 0xc38 || s.object == 0xc58 || s.object == 0xc78 ||
(zero_menu && (s.object == 0x1928 || s.object == 0x1948 || s.object == 0x1968)));
bool oam_match = false;
- if (g_mmx_render_asset_repairs || zero_body || zero_armor || zero_life || zero_charge) for (int slot = 16; slot < 128; ++slot) {
+ if (g_mmx_render_asset_repairs || zero_body || zero_armor || zero_charge || swap_actor) for (int slot = 16; slot < 128; ++slot) {
unsigned pos = r->oam[slot * 2], hi = r->high_oam[slot / 4] >> (slot % 4 * 2);
int ox = (pos & 255) | ((hi & 1) << 8); if (ox >= 256) ox -= 512;
if (ox == s.x && (pos >> 8) == ((unsigned)s.y & 255) && r->oam[slot * 2 + 1] == s.attr) {
@@ -816,18 +820,9 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
unsigned attr = asset ? (s.attr & 0xf000) | ((asset->attributes & 15) << 8) |
(asset->live_tiles ? s.attr & 255 : 0) : s.attr;
if (asset && asset->live_colors) attr = (attr & ~0x0e00u) | (s.attr & 0x0e00u);
- if (zero_armor) continue;
+ if (zero_armor || swap_actor) continue;
if (zero_body && !oam_match && !(frame.expand && frame.ram[s.object + 14] &&
(s.x + s.size <= 0 || s.x >= 256))) continue;
- if (zero_life) {
- if (!life_drawn[s.object >> 5]) {
- life_drawn[s.object >> 5] = true;
- int lx = (int16_t)(word(frame.ram,s.object + 5) - word(frame.ram,0x1e4d)) - MMX_ZERO_LIFE_WIDTH / 2;
- int ly = (int16_t)(word(frame.ram,s.object + 8) - word(frame.ram,0x1e50)) - MMX_ZERO_LIFE_HEIGHT / 2;
- sprite(&p,r,lx,ly,s.attr & 0x3fff,MMX_ZERO_LIFE_WIDTH,y,view,objects,false,NULL,0,object_colors,true,2);
- }
- continue;
- }
if (zero_body) {
if (!zero_drawn[menu_body]) {
zero_drawn[menu_body] = true;
@@ -889,12 +884,37 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) {
if (x + size <= 0 || x >= 256) continue;
/* Only the player's HUD badge: not weapon icons, bosses or actors.
* This remains visible while the player blinks or has no body pieces. */
- bool zero_icon = (stage || zero_menu) && MmxZeroEnabled() && slot == 0 &&
+ bool zero_icon = (stage || zero_menu) && MmxZeroEnabled() && !frame_zero.active_x && slot == 0 &&
x == 8 && sy == 80 && attr == 0x3486 && size == 16;
bool anchored = stage && hud && sy < 96 && (slot < 16 || (bar_count >= 4 && slot >= bar_first && slot < bar_first + bar_count));
if (anchored) { if (x < 25) x -= view.extra; else if (x >= 216) x += view.extra; }
sprite(&p, r, x, sy, attr, size, y, view, objects, false, NULL, 0, object_colors, false, zero_icon);
}
+ if (swapping) {
+ unsigned pose = MmxZeroSwapPose(&frame_zero);
+ int x = (int16_t)(word(frame.ram,0xbad) - word(frame.ram,0x1e4d));
+ int sy = (int16_t)(word(frame.ram,0xbb0) - word(frame.ram,0x1e50)) + frame_zero.swap_y;
+ unsigned flip = frame.ram[0xbb9] & 64;
+ if (!frame_zero.active_x) {
+ const uint8_t *pixels = MmxZeroTeleportPose(pose);
+ int row = y - (sy - 8) + 64;
+ if (pixels && row >= 0 && row < 128) for (int col = 0; col < 128; ++col) {
+ unsigned pixel = pixels[row * 128 + col];
+ int dx = x + (flip ? 63 - col : col - 64) + view.extra;
+ if (pixel && dx >= 0 && dx < view.width) {
+ objects[dx] = (uint16_t)(0xa680 | (pixel & 15));
+ object_colors[dx] = MmxZeroColors()[pixel];
+ }
+ }
+ } else {
+ const MmxSpriteAsset *a = MmxRenderAssetsTeleportX(pose);
+ const uint8_t *layout = a ? sprite_arrangement(0,pose) : NULL;
+ if (layout) for (int i = layout[0] - 1; i >= 0; --i) {
+ Piece s = make_piece(layout + i * 4,x,sy,flip,0x22,0,0,0xba8);
+ sprite(&p,r,s.x,s.y,s.attr,s.size,y,view,objects,false,a,s.tile,object_colors,true,false);
+ }
+ }
+ }
for (int sx = 0; sx < view.width; ++sx) {
int x = sx - view.extra;
if (zero_menu && (x < 0 || x >= 256)) { out[y * view.width + sx] = 0; continue; }
diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c
index a4ea5a6..86b2fcf 100644
--- a/src/mmx_rtl.c
+++ b/src/mmx_rtl.c
@@ -328,7 +328,7 @@ void mmx_host_yield(uint8_t countdown) {
#include "snes/saveload.h"
#define MMX_SAV_CHUNK_MAGIC 0x4D4D5854u /* "MMXT" */
-#define MMX_SAV_CHUNK_VERSION 6u /* Original Zero charge/burst/recovery state. */
+#define MMX_SAV_CHUNK_VERSION 7u /* Active character and Select teleport phase. */
typedef struct MmxSavChunk {
uint32_t magic, version;
@@ -413,7 +413,8 @@ void MmxStateLoadExtra(struct SaveLoadInfo *sli, uint32_t version) {
if (!RtlLoadExecutionState(sli)) g_load_chunk_ok = 0;
if (g_load_chunk.version >= 4) {
size_t zero_size = g_load_chunk.version == 4 ? MMX_ZERO_LEGACY_STATE_SIZE :
- g_load_chunk.version == 5 ? MMX_ZERO_ANIMATION_STATE_SIZE : sizeof(g_load_zero);
+ g_load_chunk.version == 5 ? MMX_ZERO_ANIMATION_STATE_SIZE :
+ g_load_chunk.version == 6 ? MMX_ZERO_COMBAT_STATE_SIZE : sizeof(g_load_zero);
if (RtlStateBytesRemaining(sli) >= zero_size)
sli->func(sli, &g_load_zero, zero_size);
else g_load_chunk_ok = 0;
@@ -857,6 +858,7 @@ void RunOneFrameOfGame(void) {
}
}
cpu_trace_px_breadcrumb(&g_cpu, 0x2002, "before_Internal");
+ if (MmxZeroSwapTick(g_ram)) return;
if (s_ws_recover_armor) {
if (!g_mmx_custom_renderer || !MmxWidePolicy_PrematureRideArmor(g_ram) ||
MmxWidePolicy_RecoverRideArmor(g_ram, MmxWsMargin())) s_ws_recover_armor = false;
diff --git a/src/mmx_zero.c b/src/mmx_zero.c
index 0c9ec13..0af613a 100644
--- a/src/mmx_zero.c
+++ b/src/mmx_zero.c
@@ -1,5 +1,4 @@
#include "mmx_zero.h"
-#include "mmx_zero_life.h"
#include
#include
#include
@@ -16,6 +15,8 @@ _Static_assert(offsetof(MmxZeroState, anim_offset) == MMX_ZERO_LEGACY_STATE_SIZE
"Keep the v4 combat-state prefix readable");
_Static_assert(offsetof(MmxZeroState, burst_offset) == MMX_ZERO_ANIMATION_STATE_SIZE,
"Keep the v5 animation-state prefix readable");
+_Static_assert(offsetof(MmxZeroState, active_x) == MMX_ZERO_COMBAT_STATE_SIZE,
+ "Keep the v6 combat-state prefix readable");
static unsigned word(const uint8_t *p) { return p[0] | p[1] << 8; }
static void putword(uint8_t *p, unsigned v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); }
MmxZeroState MmxZeroGetState(void) { return state; }
@@ -23,6 +24,7 @@ void MmxZeroResetState(void) { memset(&state, 0, sizeof(state)); }
void MmxZeroSetState(MmxZeroState s) {
MmxZeroResetState();
if (poses && s.combo <= 2 && s.slash <= 44 && s.charge <= 201 &&
+ s.active_x <= 1 && s.swap_phase <= 6 && s.swap_tick <= 30 && s.swap_y <= 0 && s.swap_y >= -320 &&
s.air <= 1 && (s.facing == 0 || s.facing == 64) && s.anim_valid <= 1 &&
s.burst <= 2 && s.burst_end <= 1 && s.saber_ready <= 1 && s.burst_transition <= 1 && s.burst_fired <= 1 &&
(!s.burst || (s.burst_offset >= 272 && s.burst_offset + 3 <= sizeof(animation) &&
@@ -37,6 +39,8 @@ unsigned MmxZeroChargeTier(const MmxZeroState *s) {
}
bool MmxZeroEnabled(void) { return poses != NULL; }
+bool MmxZeroActive(void) { return poses && !state.active_x; }
+bool MmxZeroSwapping(void) { return poses && state.swap_phase; }
void MmxZeroDisable(void) {
MmxZeroResetState();
free(poses); poses = NULL;
@@ -80,11 +84,6 @@ bool MmxZeroLoad(const char *path) {
}
const uint16_t *MmxZeroColors(void) { return colors; }
const uint8_t *MmxZeroMenuPose(void) { return poses; }
-int MmxZeroLifeColor(unsigned x, unsigned y) {
- if (!poses || x >= MMX_ZERO_LIFE_WIDTH || y >= MMX_ZERO_LIFE_HEIGHT) return -2;
- unsigned color = zero_life_pixels[y][x];
- return color & 0x8000 ? -2 : (int)color;
-}
static void animation_record(unsigned offset) {
if (offset < 272 || offset + 3 > sizeof(animation) || !animation[offset] || animation[offset + 2] >= 117) {
state.anim_valid = 0; return;
@@ -123,7 +122,7 @@ void MmxZeroAnimationAdvance(unsigned object) {
}
unsigned MmxZeroMuzzle(const uint8_t r[0x20000], unsigned object,
unsigned native_index, unsigned axis, unsigned original) {
- if (!poses || !r || object < 0x1228 || object >= 0x1428 ||
+ if (!MmxZeroActive() || !r || object < 0x1228 || object >= 0x1428 ||
(object & 63) != 0x28 || axis > 1) return original;
unsigned pose = state.burst ? animation[state.burst_offset + 2] :
state.anim_valid ? state.anim_pose : r[0xbbf] & 127;
@@ -152,7 +151,7 @@ int MmxZeroHudColor(unsigned x, unsigned y) {
}
unsigned MmxZeroWeaponOrigin(const uint8_t r[0x20000], unsigned object,
unsigned axis, unsigned original) {
- if (!poses || !r || object < 0x1228 || object >= 0x1428 ||
+ if (!MmxZeroActive() || !r || object < 0x1228 || object >= 0x1428 ||
(object & 63) != 0x28) return original;
/* These retail routines bypass the shared muzzle table. Apply the offset
* where they assign the real object origin, so art and collision agree.
@@ -178,7 +177,7 @@ const uint8_t *MmxZeroPose(const uint8_t ram[0x20000], const MmxZeroState *s) {
/* Visibility belongs to the submitted sprite list, not this RAM snapshot.
* During invulnerability the next update can hide the player while OAM
* still contains the preceding visible frame. The compositor owns blinking. */
- if (!poses || !ram) return NULL;
+ if (!poses || !ram || (s && s->active_x)) return NULL;
/* Old saves without mirrored animation state use the shared pose vocabulary
* until the next native animation start. New saves retain the exact phase. */
unsigned pose = s && s->anim_valid ? s->anim_pose : ram[0xbbf] & 127;
@@ -208,13 +207,13 @@ void MmxZeroSetCollisionRom(uint8_t *rom, size_t size) {
if ((!memcmp(rom + 0x32552, old_normal, 10) || !memcmp(rom + 0x32552, normal, 10)) &&
(!memcmp(rom + 0x33b38, old_dash, 10) || !memcmp(rom + 0x33b38, dash, 10)) &&
(!memcmp(rom + 0x37fb0, empty, 40) || !memcmp(rom + 0x37fb0, saber_bounds, 40))) {
- memcpy(rom + 0x32552, poses ? normal : old_normal, 10);
- memcpy(rom + 0x33b38, poses ? dash : old_dash, 10);
- memcpy(rom + 0x37fb0, poses ? saber_bounds : empty, 40);
+ memcpy(rom + 0x32552, MmxZeroActive() ? normal : old_normal, 10);
+ memcpy(rom + 0x33b38, MmxZeroActive() ? dash : old_dash, 10);
+ memcpy(rom + 0x37fb0, MmxZeroActive() ? saber_bounds : empty, 40);
}
}
unsigned MmxZeroUpgradeBits(unsigned pc, unsigned original) {
- if (!poses) return original;
+ if (!MmxZeroActive()) return original;
switch (pc & 0x7fffff) {
case 0x01971c: case 0x019793: case 0x0198fc: return original | 8;
default: return original;
@@ -241,7 +240,7 @@ void MmxZeroCancel(uint8_t ram[0x20000]) {
* independent body animation. Full reset/load uses MmxZeroResetState. */
memset(&state, 0, MMX_ZERO_LEGACY_STATE_SIZE);
memset((uint8_t *)&state + MMX_ZERO_ANIMATION_STATE_SIZE, 0,
- sizeof(state) - MMX_ZERO_ANIMATION_STATE_SIZE);
+ MMX_ZERO_COMBAT_STATE_SIZE - MMX_ZERO_ANIMATION_STATE_SIZE);
}
static unsigned free_projectile(const uint8_t *r) {
for (unsigned d = 0x1228; d < 0x1428; d += 64)
@@ -260,6 +259,72 @@ static void clear_charge(uint8_t *r) {
if (r[0xc2f] & 64) { sound(r,0x17); r[0xc2f] &= (uint8_t)~64; }
memset(r + 0xbff, 0, 5);
}
+unsigned MmxZeroSwapPose(const MmxZeroState *s) {
+ if (!s || !s->swap_phase || s->swap_phase == 3) return 255;
+ if (s->swap_phase == 2 || s->swap_phase == 4) return 0x3c;
+ if (s->swap_phase == 6) return 0;
+ /* Retail X1 $49/$48 and X3 $7F/$7E: five one-frame body morphs
+ * and a two-frame energy ball. The traveling column is pose $3C. */
+ if (s->swap_phase == 1) return s->swap_tick < 5 ? 0x3e + s->swap_tick : 0x3d;
+ return s->swap_tick < 2 ? 0x3d : 0x44 - s->swap_tick;
+}
+const uint8_t *MmxZeroTeleportPose(unsigned pose) {
+ return poses && (pose == 0 || (pose >= 0x3c && pose <= 0x42)) ?
+ poses + (size_t)pose * MMX_ZERO_WIDTH * MMX_ZERO_HEIGHT : NULL;
+}
+bool MmxZeroSwapTick(uint8_t r[0x20000]) {
+ if (!poses || !r) return false;
+ if (!state.swap_phase) {
+ /* Native NMI edge latch: Select=$2000. A grounded idle player owns
+ * control; transitions, menus, hurt, ladders and ride armor do not. */
+ if (!(r[0xac] & 0x20) || r[0xd1] != 2 || r[0xd2] != 4 ||
+ r[0xba9] != 2 || r[0xbaa] != 0 || r[0xbab] != 2 ||
+ !(r[0xbd3] & 4) || !r[0xbb6] || !(r[0xbcf] & 127) ||
+ r[0x1f0c] || r[0x1f10] || r[0x1f23] || r[0x1f48] ||
+ word(r + 0xbc2) || state.burst || state.slash) return false;
+ MmxZeroCancel(r); clear_charge(r);
+ state.swap_phase = 1; state.swap_tick = state.swap_fraction = 0; state.swap_y = 0;
+ sound(r,0x0f); /* X1's original teleport-out sound ($81:8D05). */
+ } else switch (state.swap_phase) {
+ case 1:
+ if (++state.swap_tick == 7) { state.swap_phase = 2; state.swap_tick = 0; }
+ break;
+ case 2: {
+ /* Original X1 outgoing speed $0AA6, incoming speed $0800. */
+ int fixed = state.swap_y * 256 + state.swap_fraction - 0x0aa6;
+ state.swap_y = (int16_t)((fixed - 255) / 256);
+ state.swap_fraction = (uint8_t)(fixed - state.swap_y * 256);
+ int screen_y = (int16_t)(word(r + 0xbb0) - word(r + 0x1e50));
+ if (screen_y + state.swap_y < -40) {
+ state.active_x ^= 1; state.anim_valid = 0;
+ state.swap_phase = 3; state.swap_tick = 0;
+ }
+ break;
+ }
+ case 3:
+ if (++state.swap_tick == 30) { /* Original X3 exchange delay $1E. */
+ state.swap_phase = 4; state.swap_tick = state.swap_fraction = 0;
+ sound(r,0x0e); /* Native X1 arrival ($81:8A18). */
+ }
+ break;
+ case 4:
+ state.swap_y += 8;
+ if (state.swap_y >= 0) { state.swap_y = 0; state.swap_phase = 5; }
+ break;
+ case 5:
+ if (++state.swap_tick == 7) { state.swap_phase = 6; state.swap_tick = 0; }
+ break;
+ case 6:
+ state.swap_phase = state.swap_tick = state.swap_fraction = 0; state.swap_y = 0;
+ /* Resume idle through its native initializer, with no buffered presses. */
+ r[0xbab] = 0; r[0xab] = r[0xac] = r[0xbe2] = r[0xbe3] = 0;
+ return false;
+ }
+ /* The scheduler normally acknowledges NMI. Keep DMA/input/SPC alive while
+ * every task (including stage scripts and enemy animation) is suspended. */
+ r[0xb9d] = r[0xba0] = 0;
+ return true;
+}
static unsigned burst_sequence(void) {
return state.burst == 1 ? (state.air ? 0x43 : 0x30) : (state.air ? 0x49 : 0x36);
}
@@ -331,7 +396,7 @@ static bool burst_holds_air(void) {
(state.burst == 2 && state.burst_offset == word(animation + 0x49 * 2) && state.burst_timer == 2));
}
void MmxZeroPlayerTick(uint8_t r[0x20000]) {
- if (!poses || !r) return;
+ if (!MmxZeroActive() || !r) return;
unsigned action = r[0xbaa];
bool playable = r[0xd1] == 2 && r[0xd2] == 4 && r[0xba9] == 2 &&
(r[0xbcf] & 127) && !r[0x1f0c] && !r[0xbdb] &&
diff --git a/src/mmx_zero.h b/src/mmx_zero.h
index 78643c6..e42373a 100644
--- a/src/mmx_zero.h
+++ b/src/mmx_zero.h
@@ -4,9 +4,9 @@
#include
enum { MMX_ZERO_WIDTH = 128, MMX_ZERO_HEIGHT = 128, MMX_ZERO_POSES = 152,
- MMX_ZERO_LIFE_WIDTH = 17, MMX_ZERO_LIFE_HEIGHT = 16,
MMX_ZERO_ANIMATION_BYTES = 0x474, MMX_ZERO_MUZZLE_BYTES = 196,
- MMX_ZERO_LEGACY_STATE_SIZE = 12, MMX_ZERO_ANIMATION_STATE_SIZE = 18 };
+ MMX_ZERO_LEGACY_STATE_SIZE = 12, MMX_ZERO_ANIMATION_STATE_SIZE = 18,
+ MMX_ZERO_COMBAT_STATE_SIZE = 30 };
typedef struct MmxZeroState {
uint16_t charge, slash, projectile;
uint8_t combo, cooldown, air, facing;
@@ -17,15 +17,23 @@ typedef struct MmxZeroState {
uint8_t burst_timer, burst, burst_end, saber_ready, shot_mask, held_gravity;
uint16_t held_vy;
uint8_t burst_transition, burst_fired;
+ /* Zero remains the default for legacy saves and initial mod activation. */
+ uint8_t active_x, swap_phase, swap_tick, swap_fraction;
+ int16_t swap_y;
} MmxZeroState;
bool MmxZeroLoad(const char *path);
void MmxZeroDisable(void);
bool MmxZeroEnabled(void);
+bool MmxZeroActive(void);
+bool MmxZeroSwapping(void);
+/* Called after native NMI input polling; true suspends the game scheduler. */
+bool MmxZeroSwapTick(uint8_t ram[0x20000]);
+unsigned MmxZeroSwapPose(const MmxZeroState *snapshot);
+const uint8_t *MmxZeroTeleportPose(unsigned pose);
const uint8_t *MmxZeroPose(const uint8_t ram[0x20000], const MmxZeroState *snapshot);
const uint8_t *MmxZeroBlade(const MmxZeroState *snapshot);
const uint16_t *MmxZeroColors(void);
const uint8_t *MmxZeroMenuPose(void);
-int MmxZeroLifeColor(unsigned x, unsigned y);
/* Original X3 BGR555 badge pixel; -2 is transparent, -1 retains native art. */
int MmxZeroHudColor(unsigned x, unsigned y);
void MmxZeroSetCollisionRom(uint8_t *rom, size_t size);
diff --git a/src/mmx_zero_life.h b/src/mmx_zero_life.h
deleted file mode 100644
index c6f1070..0000000
--- a/src/mmx_zero_life.h
+++ /dev/null
@@ -1,19 +0,0 @@
-/* Generated by tools/compile_zero_life.py from assets/zero/life-head.png. */
-static const uint16_t zero_life_pixels[MMX_ZERO_LIFE_HEIGHT][MMX_ZERO_LIFE_WIDTH] = {
- {0x8000,0x1084,0x1084,0x8000,0x8000,0x8000,0x1084,0x1084,0x1084,0x1084,0x1084,0x8000,0x8000,0x8000,0x1084,0x1084,0x8000},
- {0x8000,0x1084,0x147f,0x1084,0x8000,0x1084,0x1015,0x2078,0x2078,0x2078,0x1015,0x1084,0x8000,0x1084,0x147f,0x1084,0x8000},
- {0x8000,0x1084,0x147f,0x1015,0x1084,0x1015,0x1c7a,0x1c7a,0x1c7a,0x2078,0x2078,0x1015,0x1084,0x1015,0x147f,0x1084,0x8000},
- {0x8000,0x8000,0x1083,0x1c7a,0x1015,0x2078,0x1c7a,0x1c7a,0x1c7a,0x2078,0x2078,0x1c7a,0x1015,0x1c7a,0x1084,0x8000,0x8000},
- {0x8000,0x8000,0x1084,0x147f,0x1015,0x1c7a,0x2078,0x2078,0x1c7a,0x1c7a,0x1c7a,0x2078,0x1015,0x147f,0x1084,0x8000,0x8000},
- {0x8000,0x8000,0x1084,0x147f,0x147f,0x1015,0x2078,0x1084,0x1084,0x1084,0x1c7a,0x1015,0x147f,0x147f,0x1084,0x8000,0x8000},
- {0x8000,0x1084,0x5ed6,0x1015,0x147f,0x147f,0x1084,0x7f97,0x7ece,0x7de4,0x1084,0x147f,0x147f,0x1015,0x5ed6,0x1084,0x8000},
- {0x1084,0x4a51,0x7bde,0x1015,0x147f,0x147f,0x1084,0x7e06,0x7de4,0x7de4,0x1084,0x147f,0x147f,0x1015,0x7bde,0x4a51,0x1084},
- {0x1084,0x1015,0x7bde,0x5ed6,0x1084,0x147f,0x147f,0x1084,0x7de4,0x1084,0x147f,0x147f,0x1084,0x5ed6,0x7bde,0x1015,0x1084},
- {0x1084,0x147f,0x1015,0x7bde,0x1084,0x7bde,0x1084,0x1c7a,0x1084,0x1c7a,0x1084,0x7bde,0x1084,0x7bde,0x1015,0x147f,0x1084},
- {0x1084,0x147f,0x5ed6,0x4a51,0x1084,0x7bde,0x7bde,0x7bde,0x147f,0x7bde,0x7bde,0x7bde,0x1084,0x4a51,0x5ed6,0x147f,0x1084},
- {0x8000,0x1084,0x5ed6,0x7bde,0x1084,0x7bde,0x1084,0x7bde,0x4adf,0x7bde,0x1084,0x7bde,0x1084,0x7bde,0x5ed6,0x1084,0x8000},
- {0x8000,0x1084,0x5ed6,0x7bde,0x1084,0x4adf,0x4adf,0x323e,0x323e,0x323e,0x4adf,0x4adf,0x1084,0x7bde,0x5ed6,0x1084,0x8000},
- {0x8000,0x8000,0x1084,0x5ed6,0x1084,0x323e,0x4adf,0x1084,0x1084,0x1084,0x4adf,0x323e,0x1084,0x5ed6,0x1084,0x8000,0x8000},
- {0x8000,0x8000,0x8000,0x1084,0x1084,0x1084,0x323e,0x4adf,0x4adf,0x4adf,0x323e,0x1084,0x1084,0x1084,0x8000,0x8000,0x8000},
- {0x8000,0x8000,0x8000,0x8000,0x8000,0x8000,0x1084,0x1084,0x1084,0x1084,0x1084,0x8000,0x8000,0x8000,0x8000,0x8000,0x8000},
-};
diff --git a/src/mods/mmx_zero_plugin.c b/src/mods/mmx_zero_plugin.c
index 56f8b86..5872fd3 100644
--- a/src/mods/mmx_zero_plugin.c
+++ b/src/mods/mmx_zero_plugin.c
@@ -12,7 +12,7 @@ static void hook(CpuState *cpu, uint32_t pc) {
switch (pc & 0x7fffff) {
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: cpu->A |= 8; break;
+ case 0x01971f: case 0x019796: case 0x0198ff: if (MmxZeroActive()) cpu->A |= 8; break;
case 0x01815c: MmxZeroPlayerTick(g_ram); break;
case 0x018165: MmxZeroPlayerEnd(g_ram); break;
case 0x048f07: MmxZeroAnimationStart(cpu->D, cpu->A & 255); break;
diff --git a/tests/mmx_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c
index 9fb79ea..8416bb1 100644
--- a/tests/mmx_adaptive_state_test.c
+++ b/tests/mmx_adaptive_state_test.c
@@ -120,7 +120,7 @@ static void zero_menu_pixels(void) {
else if (x >= 192 && x < 216 && y >= 139 && y < 163) ++icon;
else { fprintf(stderr,"Unexpected Zero menu change at %d,%d\n",x,y); exit(1); }
}
- check(body > 100 && icon > 50,"menu replaces body and life head while preserving other pixels");
+ check(body > 100 && icon == 0,"menu replaces body and preserves X's original life head exactly");
bool same_body = true;
for (int y = 128; y < 184; ++y) for (int x = 104; x < 152; ++x) {
unsigned at = (y - 128) * 48 + x - 104;
@@ -212,6 +212,57 @@ static void zero_menu_transition_checks(const char *fixture, const char *capture
}
g_mmx_render_asset_repairs = repairs;
}
+static void zero_swap_checks(const char *fixture, uint8 *start, uint8 *expected, uint8 *actual, size_t cap) {
+ check(RtlLoadSnapshot(fixture),"restore for grounded Select swap");
+ zero_replay(10);
+ frame(SNES_PAD_Y); frame(0);
+ check(zero_projectiles(0)!=0,"live native projectile participates in frozen-world check");
+ uint8_t objects[0x1d08-0xe18], camera[6], position[6];
+ memcpy(camera,g_ram+0x1e4d,6); memcpy(position,g_ram+0xbac,6);
+ unsigned hp=g_ram[0xbcf], weapon=g_ram[0xbdb], upgrades=g_ram[0x1f99];
+ const char *capture=getenv("MMX_ZERO_TEST_CAPTURE");
+ for(unsigned direction=0;direction<2;++direction) {
+ frame(SNES_PAD_SELECT);
+ if(!MmxZeroSwapping()) fprintf(stderr,"swap gates: %02x %02x %02x %02x %02x %02x; %02x %02x %02x %02x; vx %02x%02x\n",
+ g_ram[0xd1],g_ram[0xd2],g_ram[0xba9],g_ram[0xbaa],g_ram[0xbab],g_ram[0xbb6],
+ g_ram[0x1f0c],g_ram[0x1f10],g_ram[0x1f23],g_ram[0x1f48],g_ram[0xbc3],g_ram[0xbc2]);
+ check(MmxZeroSwapping(),"grounded Select starts exchange");
+ memcpy(objects,g_ram+0xe18,sizeof(objects));
+ unsigned phases=0, ticks=0;
+ while(MmxZeroSwapping() && ticks++<160) {
+ MmxZeroState s=MmxZeroGetState();
+ if(!(phases&(1u<cart->rom[0x32555]==(direction?18:14),"terrain/damage bounds follow active character");
+ if(direction) {
+ frame(SNES_PAD_SELECT); frame(SNES_PAD_SELECT);
+ check(!MmxZeroSwapping() && MmxZeroActive(),"holding Select cannot retrigger an exchange");
+ }
+ zero_replay(3);
+ char suffix[48]; snprintf(suffix,sizeof(suffix),".swap%u-done.cap",direction); zero_capture(capture,suffix);
+ }
+ frame(SNES_PAD_B); frame(SNES_PAD_SELECT);
+ check(!MmxZeroSwapping(),"Select cannot swap in midair");
+ check(RtlLoadSnapshot(fixture),"restore after Select checks");
+}
static void zero_state_checks(const char *assets, const char *fixture, uint8 *start,
uint8 *expected, uint8 *actual, size_t cap) {
check(fixture != NULL && MmxZeroLoad(assets), "Zero local assets load");
@@ -223,6 +274,8 @@ static void zero_state_checks(const char *assets, const char *fixture, uint8 *st
g_config.widescreen = false;
int w,h; MmxPrepareFrame(1280,720,&w,&h);
check(w == 256 && g_mmx_custom_renderer, "Zero activates native-width compositor");
+ zero_swap_checks(fixture,start,expected,actual,cap);
+ if(getenv("MMX_ZERO_SWAP_ONLY")) { puts("MMX SELECT SWAP CHECKS PASSED"); return; }
zero_motion_checks(fixture);
zero_combat_checks(fixture);
check(RtlLoadSnapshot(fixture), "restore for burst state replay");
@@ -253,6 +306,10 @@ static void zero_state_checks(const char *assets, const char *fixture, uint8 *st
check(RtlLoadSnapshotFromMemory(start,burst_n-sizeof(MmxZeroState)+prefix),"legacy Zero state prefix loads");
check(MmxZeroGetState().combo==1 && MmxZeroGetState().saber_ready && !MmxZeroGetState().burst,"legacy stored combo migrates");
}
+ uint32_t v6=6; memcpy(start+chunk+4,&v6,4);
+ check(RtlLoadSnapshotFromMemory(start,burst_n-sizeof(MmxZeroState)+MMX_ZERO_COMBAT_STATE_SIZE),
+ "pre-swap v6 save retains combat prefix");
+ check(MmxZeroActive() && !MmxZeroSwapping(),"pre-swap save defaults to Zero without an exchange");
check(RtlLoadSnapshot(fixture), "restore for combo probe");
for (int i=0;i<201;++i) frame(SNES_PAD_Y);
frame(0);
diff --git a/tools/compile_zero_life.py b/tools/compile_zero_life.py
deleted file mode 100644
index 8169856..0000000
--- a/tools/compile_zero_life.py
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env python3
-"""Pack the authored 17x16 RGBA life icon into native BGR555 renderer data."""
-from pathlib import Path
-from PIL import Image
-
-root = Path(__file__).resolve().parents[1]
-image = Image.open(root / 'assets/zero/life-head.png').convert('RGBA')
-if image.size != (17, 16):
- raise SystemExit('Zero life icon must be 17x16')
-rows = []
-for y in range(image.height):
- values = []
- for x in range(image.width):
- r, g, b, a = image.getpixel((x, y))
- color = 0x8000 if a < 128 else (r >> 3) | ((g >> 3) << 5) | ((b >> 3) << 10)
- values.append(f'0x{color:04x}')
- rows.append(' {' + ','.join(values) + '},')
-(root / 'src/mmx_zero_life.h').write_text(
- '/* Generated by tools/compile_zero_life.py from assets/zero/life-head.png. */\n'
- 'static const uint16_t zero_life_pixels[MMX_ZERO_LIFE_HEIGHT][MMX_ZERO_LIFE_WIDTH] = {\n' + '\n'.join(rows) + '\n};\n',
- encoding='utf-8', newline='\n')
From a3db0876205ba3feab8888f4df2edfa7c08be8a2 Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Mon, 28 Sep 2026 23:04:55 -0700
Subject: [PATCH 12/16] Document character HP, X2/X3 weapons and later co-op
requirements
---
docs/zero-weapons-coop-roadmap.md | 137 ++++++++++++++++++++++++++++++
1 file changed, 137 insertions(+)
create mode 100644 docs/zero-weapons-coop-roadmap.md
diff --git a/docs/zero-weapons-coop-roadmap.md b/docs/zero-weapons-coop-roadmap.md
new file mode 100644
index 0000000..2898f8e
--- /dev/null
+++ b/docs/zero-weapons-coop-roadmap.md
@@ -0,0 +1,137 @@
+# X1 character, weapon, and co-op roadmap
+
+Owner requirements recorded 2026-09-28. This is the durable scope document;
+implementation status and findings are tracked in the central Beads database
+at `F:\Software\beads\issues`, under `beads-8wg.1` (Mega Man X / SNES).
+Work branch: `feat/x3-zero-port`, worktree: `../_wt_mmx_zero`.
+
+## Order of work
+
+1. Preserve and finish the X3 Zero backport and Select exchange foundation.
+2. Finish separate character HP, then implement and validate all X2/X3 boss
+ weapons and charged attacks. Weapons are the next major feature.
+3. Only after the weapons are complete, build a separate simultaneous co-op mod.
+
+Use clean, bounded commits. Work solo unless the owner explicitly authorizes
+agents. Use focused tests for meaningful risks and the existing regression
+suite; do not turn this into an exhaustive testing campaign. User playtests
+start with a fresh boot: **never automatically load a save state into the
+owner's game**. Private test fixtures are separate.
+
+## Existing Zero backport and Select exchange
+
+Tracking: `beads-8wg.1.31` (full port), `beads-8wg.1.30` (exchange, closed).
+
+- Faithfully adapt X3 Zero's original animation, movement, collision bounds,
+ ground/air charged-buster combo and third-attack saber to X1. Keep original
+ dimensions unless an actual clearance problem calls for adjustment.
+- Keep all X1 weapons and charged attacks, with Zero-specific firing origins
+ and effects where needed. Charged specials require the actual X1 arm upgrade.
+- Use Zero's original X3 health-bar Z badge and original body poses in the
+ pause screen and title cursor. Preserve the title selection's actual shot.
+- Keep **X's original 1-up head** in both the pause screen and world pickups.
+ The custom Zero head was rejected; do not bring it back.
+- Select exchanges X and Zero while standing still on solid ground. Use the
+ original blue/red stage teleport art, with gameplay tasks frozen throughout.
+- Preserve X1 equipment/progression. X retains his native capabilities; Zero
+ retains his ported base abilities.
+
+Landed checkpoints:
+
+| Commit | Result |
+| --- | --- |
+| `9cef5fa` | Original X3 charge tiers, firing and combo recovery timing |
+| `f8843c6` | Title cursor/shot and looping charge-audio fix; its custom life head was subsequently removed |
+| `8a50ab0` | Grounded Select exchange, original teleport assets, restored original X life heads |
+
+The owner reports that switching looks flawless at first glance. The swap has
+focused freeze, state/replay, collision/capability and visual checks. This is
+not a claim that every stage and boss has been exhaustively playtested.
+
+## Separate HP in the exchange mod
+
+Tracking: `beads-8wg.1.33`.
+
+- X and Zero have separate **current HP**; changing characters restores that
+ character's pool. Damage affects only the active character.
+- **Health pickups heal only the active character.** This explicitly supersedes
+ the earlier request to heal both pools. Native overflow/subtank behavior
+ remains applicable; no inactive-character healing is added.
+- Heart tanks raise the shared maximum HP for both characters. This shared
+ maximum is separate from the two current-HP pools.
+- If the active character dies, lose a life normally. Refill both characters
+ on respawn; do not automatically switch to the surviving reserve character.
+- Saves, rollback and rewind preserve both pools and the active character.
+- Working HUD default: show the active character's health in the existing bar
+ and update it on exchange. The question of showing both bars in single-player
+ exchange mode is still open; the two-bar co-op layout below is explicit.
+
+## X2 and X3 boss weapons
+
+Tracking: `beads-8wg.1.32`.
+
+Add all sixteen boss weapons, each with normal and charged behavior, usable by
+both X and Zero in X1. Consult the local `../MegamanX2Recomp` and
+`../MegamanX3SNESRecomp` projects and original ROM assets/behavior. Zero's base
+X3 combat port does not itself supply these boss weapons.
+
+| Source | Weapons |
+| --- | --- |
+| X2 | Crystal Hunter, Bubble Splash, Silk Shot, Spin Wheel, Sonic Slicer, Strike Chain, Magnet Mine, Speed Burner |
+| X3 | Acid Burst, Parasitic Bomb, Triad Thunder, Spinning Blade, Ray Splasher, Gravity Well, Frost Shield, Tornado Fang |
+
+- Unlock the sixteen new weapons immediately. **Charged attacks still require
+ X1's arm upgrade**, for either character. Existing X1 weapon progression stays.
+- Port actual attack behavior and original visuals to the best practical
+ fidelity, including meaningful special effects such as Magnet Mine behavior.
+ Adapt X2/X3 X firing poses to Zero where needed.
+- Use ordinary enemy damage initially. No new boss-weakness tables or extra
+ damage/balance project is included.
+- Add X1/X2/X3 pages to the existing pause weapon screen. L/R bumpers cycle
+ pages. No instructional UI text; at most left/right caret symbols.
+- Include usable selection, charge/release, weapon energy, projectile lifetime,
+ terrain/enemy interaction, sound cleanup and persistent state. A menu entry
+ or a visual-only projectile does not count as a finished weapon.
+- Armor abilities, enhancement chips and secret attacks are outside this pass.
+- Commit menu/state infrastructure and completed weapon groups separately;
+ validate the important normal/charged behaviors and let the owner playtest.
+
+## Later: separate simultaneous co-op mod
+
+Tracking: `beads-8wg.1.34`, dependent on completing `beads-8wg.1.32`.
+
+Create a second mod that is **mutually exclusive with the Select-exchange
+mod**. Share the completed character/weapon implementation where appropriate,
+but do not allow both gameplay modes to activate together.
+
+- Add a second controller/player input assignment. Working roster: player 1 is
+ X, player 2 is Zero. Both spawn and act simultaneously in every stage.
+- Each player has independent movement, combat, collision and current HP.
+- HUD order from left to right: **X health, Zero health, then weapon energy
+ when applicable**. Move the existing weapon energy display to make room.
+- Keep both surviving players on the same screen. A player moving right cannot
+ scroll the other player off the left edge; apply the shared-screen constraint
+ consistently when movement would separate the pair.
+- During forced scene transitions/cutscenes, Zero teleports out using his
+ original red stage-exit effect, then returns with the original arrival effect
+ when normal gameplay resumes. Preserve native stage/script progression.
+- When one player dies, that player remains absent for the rest of the stage.
+ They return when the stage ends, or when the other player also dies and both
+ restart. Cutscene return must never resurrect an already fallen player.
+- Shared maximum-HP progression remains consistent with the character mode.
+ Normal health pickups should heal their collector, not the other player
+ (working extension of the latest single-player pickup rule).
+- Save/replay and mod activation must retain the mode, both players and their
+ alive/absent state without contaminating stock or Select-exchange play.
+
+Resolve these details before co-op implementation, after weapons are finished:
+
+- Independent weapon energy/selection and how to display two simultaneous
+ special-weapon energy meters within the requested HUD order.
+- Pause ownership, subtank use, controller disconnects and input assignment UI.
+- Vertical camera constraints and forced scrolling/platform sections.
+- Story scripts when X is dead and only Zero survives; how the surviving actor
+ drives triggers without reviving the dead partner.
+- Shared life-count consumption on a full-team wipe and checkpoint selection.
+
+These are implementation/design questions, not additions to the weapon scope.
From 7ea60a99253136a0ad4de138b53bff08a164af09 Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Mon, 28 Sep 2026 23:09:55 -0700
Subject: [PATCH 13/16] Keep separate X and Zero HP with active-only healing
---
docs/zero-spike.md | 11 ++++--
docs/zero-weapons-coop-roadmap.md | 5 +++
src/mmx_renderer.c | 8 +++--
src/mmx_rtl.c | 6 ++--
src/mmx_zero.c | 24 +++++++++++++
src/mmx_zero.h | 5 ++-
src/mods/mmx_zero_plugin.c | 3 +-
tests/mmx_adaptive_state_test.c | 60 +++++++++++++++++++++++++++++++
tools/apply_zero_hooks.py | 5 ++-
9 files changed, 117 insertions(+), 10 deletions(-)
diff --git a/docs/zero-spike.md b/docs/zero-spike.md
index aa54704..d5c5c16 100644
--- a/docs/zero-spike.md
+++ b/docs/zero-spike.md
@@ -216,8 +216,15 @@ ignored. Save/load preserves the full exchange and restores the frozen sprite
queues. Both directions have focused runtime checks, including a live projectile
and mid-exchange deterministic replay. Tracked in `beads-8wg.1.30`.
-This first swap build shares the existing X1 health and weapon pools. Separate
-character HP and the requested X2/X3 ability pages are subsequent work.
+X and Zero now retain separate current HP across exchanges. Native health
+pickups and subtanks heal only the active character. Heart tanks increase the
+shared maximum; native stage/checkpoint initialization refills both pools.
+Death still costs a life normally. Game save chunk v8 and capture v7 append
+the two HP pools, and prior character/swap saves remain readable. Focused
+runtime checks cover asymmetric HP, both sizes of native pickup, full-health
+pickup behavior, exchange replay, and native death/life loss/respawn.
+Weapon energy remains shared in this mode. The X2/X3 weapon and later co-op
+requirements are recorded in [the roadmap](zero-weapons-coop-roadmap.md).
1. Audit the complete animation/state mapping: wall slide/jump, ladders, damage,
death, teleport, capsules, ride armor and scripted player poses. The full-port
diff --git a/docs/zero-weapons-coop-roadmap.md b/docs/zero-weapons-coop-roadmap.md
index 2898f8e..d3cfbff 100644
--- a/docs/zero-weapons-coop-roadmap.md
+++ b/docs/zero-weapons-coop-roadmap.md
@@ -52,6 +52,11 @@ not a claim that every stage and boss has been exhaustively playtested.
Tracking: `beads-8wg.1.33`.
+Implemented and validated on `feat/x3-zero-port`: native pickups, independent
+HP on exchange, shared maximum, native life loss/refill, save/replay and legacy
+state migration. Five CTests and the existing ROM-backed Zero regression suite
+pass. The HUD uses the active character's existing bar.
+
- X and Zero have separate **current HP**; changing characters restores that
character's pool. Damage affects only the active character.
- **Health pickups heal only the active character.** This explicitly supersedes
diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c
index 989dbb5..cceab80 100644
--- a/src/mmx_renderer.c
+++ b/src/mmx_renderer.c
@@ -346,7 +346,7 @@ bool MmxRendererSaveCapture(const char *path) {
if (!frame.valid || !path) return false;
FILE *f = fopen(path, "wb");
if (!f) return false;
- uint32_t header[] = {0x4d4d5843, 6, sizeof(frame) + sizeof(frame_zero)};
+ uint32_t header[] = {0x4d4d5843, 7, sizeof(frame) + sizeof(frame_zero)};
bool ok = fwrite(header, sizeof(header), 1, f) == 1 && fwrite(&frame, sizeof(frame), 1, f) == 1 &&
fwrite(&frame_zero, sizeof(frame_zero), 1, f) == 1;
return fclose(f) == 0 && ok;
@@ -362,12 +362,14 @@ bool MmxRendererLoadCapture(const char *path) {
((h[1] == 2 && h[2] == sizeof(frame)) || (h[1] == 3 && h[2] == sizeof(frame) + MMX_ZERO_LEGACY_STATE_SIZE) ||
(h[1] == 4 && h[2] == sizeof(frame) + MMX_ZERO_ANIMATION_STATE_SIZE) ||
(h[1] == 5 && h[2] == sizeof(frame) + MMX_ZERO_COMBAT_STATE_SIZE) ||
- (h[1] == 6 && h[2] == sizeof(frame) + sizeof(frame_zero))) &&
+ (h[1] == 6 && h[2] == sizeof(frame) + MMX_ZERO_SWAP_STATE_SIZE) ||
+ (h[1] == 7 && h[2] == sizeof(frame) + sizeof(frame_zero))) &&
fread(&frame, sizeof(frame), 1, f) == 1 &&
frame.captured == 224 && frame.piece_count <= MAX_PIECES && frame.expanded_count <= MAX_PIECES && frame.valid;
size_t zero_size = h[1] == 3 ? MMX_ZERO_LEGACY_STATE_SIZE :
h[1] == 4 ? MMX_ZERO_ANIMATION_STATE_SIZE :
- h[1] == 5 ? MMX_ZERO_COMBAT_STATE_SIZE : sizeof(frame_zero);
+ h[1] == 5 ? MMX_ZERO_COMBAT_STATE_SIZE :
+ h[1] == 6 ? MMX_ZERO_SWAP_STATE_SIZE : sizeof(frame_zero);
if (ok && h[1] >= 3) ok = fread(&frame_zero, zero_size, 1, f) == 1 &&
frame_zero.active_x <= 1 && frame_zero.swap_phase <= 6 && frame_zero.swap_tick <= 30 &&
frame_zero.swap_y >= -320 && frame_zero.swap_y <= 0 &&
diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c
index 86b2fcf..da0cd39 100644
--- a/src/mmx_rtl.c
+++ b/src/mmx_rtl.c
@@ -328,7 +328,7 @@ void mmx_host_yield(uint8_t countdown) {
#include "snes/saveload.h"
#define MMX_SAV_CHUNK_MAGIC 0x4D4D5854u /* "MMXT" */
-#define MMX_SAV_CHUNK_VERSION 7u /* Active character and Select teleport phase. */
+#define MMX_SAV_CHUNK_VERSION 8u /* Separate X/Zero health pools. */
typedef struct MmxSavChunk {
uint32_t magic, version;
@@ -414,7 +414,8 @@ void MmxStateLoadExtra(struct SaveLoadInfo *sli, uint32_t version) {
if (g_load_chunk.version >= 4) {
size_t zero_size = g_load_chunk.version == 4 ? MMX_ZERO_LEGACY_STATE_SIZE :
g_load_chunk.version == 5 ? MMX_ZERO_ANIMATION_STATE_SIZE :
- g_load_chunk.version == 6 ? MMX_ZERO_COMBAT_STATE_SIZE : sizeof(g_load_zero);
+ g_load_chunk.version == 6 ? MMX_ZERO_COMBAT_STATE_SIZE :
+ g_load_chunk.version == 7 ? MMX_ZERO_SWAP_STATE_SIZE : sizeof(g_load_zero);
if (RtlStateBytesRemaining(sli) >= zero_size)
sli->func(sli, &g_load_zero, zero_size);
else g_load_chunk_ok = 0;
@@ -906,6 +907,7 @@ void RunOneFrameOfGame(void) {
MmxSchedulerTick();
}
cpu_trace_px_breadcrumb(&g_cpu, 0x2003, "after_Internal");
+ MmxZeroHealthSync(g_ram);
g_first_frame_done = true;
}
diff --git a/src/mmx_zero.c b/src/mmx_zero.c
index 0af613a..f494c98 100644
--- a/src/mmx_zero.c
+++ b/src/mmx_zero.c
@@ -17,6 +17,8 @@ _Static_assert(offsetof(MmxZeroState, burst_offset) == MMX_ZERO_ANIMATION_STATE_
"Keep the v5 animation-state prefix readable");
_Static_assert(offsetof(MmxZeroState, active_x) == MMX_ZERO_COMBAT_STATE_SIZE,
"Keep the v6 combat-state prefix readable");
+_Static_assert(offsetof(MmxZeroState, hp) == MMX_ZERO_SWAP_STATE_SIZE,
+ "Keep the v7 character/swap-state prefix readable");
static unsigned word(const uint8_t *p) { return p[0] | p[1] << 8; }
static void putword(uint8_t *p, unsigned v) { p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); }
MmxZeroState MmxZeroGetState(void) { return state; }
@@ -25,6 +27,8 @@ void MmxZeroSetState(MmxZeroState s) {
MmxZeroResetState();
if (poses && s.combo <= 2 && s.slash <= 44 && s.charge <= 201 &&
s.active_x <= 1 && s.swap_phase <= 6 && s.swap_tick <= 30 && s.swap_y <= 0 && s.swap_y >= -320 &&
+ s.hp_valid <= 1 && s.hp_max <= 32 && s.hp[0] <= 32 && s.hp[1] <= 32 &&
+ (!s.hp_valid || (s.hp_max >= 16 && s.hp[0] <= s.hp_max && s.hp[1] <= s.hp_max)) &&
s.air <= 1 && (s.facing == 0 || s.facing == 64) && s.anim_valid <= 1 &&
s.burst <= 2 && s.burst_end <= 1 && s.saber_ready <= 1 && s.burst_transition <= 1 && s.burst_fired <= 1 &&
(!s.burst || (s.burst_offset >= 272 && s.burst_offset + 3 <= sizeof(animation) &&
@@ -41,6 +45,24 @@ unsigned MmxZeroChargeTier(const MmxZeroState *s) {
bool MmxZeroEnabled(void) { return poses != NULL; }
bool MmxZeroActive(void) { return poses && !state.active_x; }
bool MmxZeroSwapping(void) { return poses && state.swap_phase; }
+void MmxZeroHealthSync(const uint8_t r[0x20000]) {
+ if (!poses || !r || r[0xd1] != 2 || r[0x1f9a] < 16 || r[0x1f9a] > 32) return;
+ unsigned max = r[0x1f9a], hp = r[0xbcf] & 127;
+ if (hp > max) hp = max;
+ /* Older saves have one pool. Seed both from it exactly once. All normal
+ * damage, pickups and subtanks continue to affect only native active HP. */
+ if (!state.hp_valid) state.hp[0] = state.hp[1] = (uint8_t)hp;
+ state.hp_valid = 1; state.hp_max = (uint8_t)max;
+ state.hp[state.active_x] = (uint8_t)hp;
+ if (state.hp[state.active_x ^ 1] > max) state.hp[state.active_x ^ 1] = (uint8_t)max;
+}
+void MmxZeroHealthRespawn(const uint8_t r[0x20000]) {
+ if (!poses || !r || r[0x1f9a] < 16 || r[0x1f9a] > 32) return;
+ /* Called at the original stage/checkpoint HP initialization, not whenever
+ * a pool happens to reach zero. Death and life loss remain native. */
+ state.hp_valid = 1; state.hp_max = r[0x1f9a];
+ state.hp[0] = state.hp[1] = state.hp_max;
+}
void MmxZeroDisable(void) {
MmxZeroResetState();
free(poses); poses = NULL;
@@ -274,6 +296,7 @@ const uint8_t *MmxZeroTeleportPose(unsigned pose) {
}
bool MmxZeroSwapTick(uint8_t r[0x20000]) {
if (!poses || !r) return false;
+ MmxZeroHealthSync(r);
if (!state.swap_phase) {
/* Native NMI edge latch: Select=$2000. A grounded idle player owns
* control; transitions, menus, hurt, ladders and ride armor do not. */
@@ -297,6 +320,7 @@ bool MmxZeroSwapTick(uint8_t r[0x20000]) {
int screen_y = (int16_t)(word(r + 0xbb0) - word(r + 0x1e50));
if (screen_y + state.swap_y < -40) {
state.active_x ^= 1; state.anim_valid = 0;
+ if (state.hp_valid) r[0xbcf] = state.hp[state.active_x] | 128;
state.swap_phase = 3; state.swap_tick = 0;
}
break;
diff --git a/src/mmx_zero.h b/src/mmx_zero.h
index e42373a..10d826c 100644
--- a/src/mmx_zero.h
+++ b/src/mmx_zero.h
@@ -6,7 +6,7 @@
enum { MMX_ZERO_WIDTH = 128, MMX_ZERO_HEIGHT = 128, MMX_ZERO_POSES = 152,
MMX_ZERO_ANIMATION_BYTES = 0x474, MMX_ZERO_MUZZLE_BYTES = 196,
MMX_ZERO_LEGACY_STATE_SIZE = 12, MMX_ZERO_ANIMATION_STATE_SIZE = 18,
- MMX_ZERO_COMBAT_STATE_SIZE = 30 };
+ MMX_ZERO_COMBAT_STATE_SIZE = 30, MMX_ZERO_SWAP_STATE_SIZE = 36 };
typedef struct MmxZeroState {
uint16_t charge, slash, projectile;
uint8_t combo, cooldown, air, facing;
@@ -20,12 +20,15 @@ typedef struct MmxZeroState {
/* Zero remains the default for legacy saves and initial mod activation. */
uint8_t active_x, swap_phase, swap_tick, swap_fraction;
int16_t swap_y;
+ uint8_t hp[2], hp_valid, hp_max; /* Index 0 = Zero, 1 = X; shared maximum. */
} MmxZeroState;
bool MmxZeroLoad(const char *path);
void MmxZeroDisable(void);
bool MmxZeroEnabled(void);
bool MmxZeroActive(void);
bool MmxZeroSwapping(void);
+void MmxZeroHealthSync(const uint8_t ram[0x20000]);
+void MmxZeroHealthRespawn(const uint8_t ram[0x20000]);
/* Called after native NMI input polling; true suspends the game scheduler. */
bool MmxZeroSwapTick(uint8_t ram[0x20000]);
unsigned MmxZeroSwapPose(const MmxZeroState *snapshot);
diff --git a/src/mods/mmx_zero_plugin.c b/src/mods/mmx_zero_plugin.c
index 5872fd3..b4f023c 100644
--- a/src/mods/mmx_zero_plugin.c
+++ b/src/mods/mmx_zero_plugin.c
@@ -10,6 +10,7 @@ extern uint8_t g_ram[0x20000];
static void hook(CpuState *cpu, uint32_t pc) {
if (!MmxZeroEnabled()) return;
switch (pc & 0x7fffff) {
+ 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;
@@ -80,7 +81,7 @@ static void hook(CpuState *cpu, uint32_t pc) {
}
}
void MmxZeroRegisterHooks(void) {
- const unsigned pcs[] = {0x00d6a7, 0x00d76a, 0x01971f, 0x019796, 0x0198ff,
+ const unsigned pcs[] = {0x009dca, 0x00d6a7, 0x00d76a, 0x01971f, 0x019796, 0x0198ff,
0x01815c, 0x018165, 0x00d3e7, 0x049e76, 0x049c19, 0x048f07, 0x048eea,
0x028403,0x03958f,0x039dcf,
0x01a57d,0x038b6f,0x038d87,0x038ed7,0x03951d,0x039841,0x039993,0x03a3cd,
diff --git a/tests/mmx_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c
index 8416bb1..de346e3 100644
--- a/tests/mmx_adaptive_state_test.c
+++ b/tests/mmx_adaptive_state_test.c
@@ -263,6 +263,60 @@ static void zero_swap_checks(const char *fixture, uint8 *start, uint8 *expected,
check(!MmxZeroSwapping(),"Select cannot swap in midair");
check(RtlLoadSnapshot(fixture),"restore after Select checks");
}
+static void zero_health_swap(void) {
+ frame(SNES_PAD_SELECT);
+ check(MmxZeroSwapping(),"HP exchange begins");
+ for(unsigned i=0;i<160 && MmxZeroSwapping();++i) frame(0);
+ check(!MmxZeroSwapping(),"HP exchange completes");
+ zero_replay(3);
+}
+static void zero_health_pickup(unsigned small) {
+ /* Retail health actor (item kind 2), collected through native collision. */
+ memset(g_ram+0x1628,0,48); g_ram[0x1628]=1;
+ g_ram[0x1632]=2; g_ram[0x1633]=(uint8_t)(128|small);
+ memcpy(g_ram+0x162d,g_ram+0xbad,2);
+ unsigned y=(g_ram[0xbb0] | g_ram[0xbb1]<<8)-16;
+ g_ram[0x1630]=(uint8_t)y; g_ram[0x1631]=(uint8_t)(y>>8);
+ zero_replay(55);
+ check(!g_ram[0x1628],"native health pickup finishes collection");
+}
+static void zero_health_checks(const char *fixture, uint8 *start, uint8 *expected, uint8 *actual, size_t cap) {
+ check(RtlLoadSnapshot(fixture),"restore for separate HP");
+ zero_replay(10);
+ g_ram[0xbcf]=7|128; frame(0);
+ check(MmxZeroGetState().hp[0]==7 && MmxZeroGetState().hp[1]==16,"damage affects only active Zero");
+ zero_health_swap();
+ check(!MmxZeroActive() && (g_ram[0xbcf]&127)==16,"X arrives with his independent HP");
+ g_ram[0xbcf]=9|128; frame(0);
+ zero_health_pickup(1);
+ check((g_ram[0xbcf]&127)==11 && MmxZeroGetState().hp[0]==7,"small health pickup heals X only");
+ zero_health_pickup(0);
+ check((g_ram[0xbcf]&127)==16 && MmxZeroGetState().hp[0]==7,"large health pickup clamps X without healing Zero");
+ zero_health_pickup(1);
+ check(MmxZeroGetState().hp[0]==7,"full active pool never redirects pickup healing to reserve");
+ zero_health_swap();
+ check(MmxZeroActive() && (g_ram[0xbcf]&127)==7,"Zero returns with his previous HP");
+ g_ram[0x1f9a]=18; g_ram[0xbcf]=9|128; frame(0);
+ check(MmxZeroGetState().hp_max==18 && MmxZeroGetState().hp[1]==16,"heart-tank maximum is shared without refilling reserve HP");
+ size_t n=RtlSaveSnapshotToMemory(start,cap);
+ zero_health_swap(); size_t en=RtlSaveSnapshotToMemory(expected,cap);
+ check(RtlLoadSnapshotFromMemory(start,n),"separate HP save loads");
+ zero_health_swap(); size_t an=RtlSaveSnapshotToMemory(actual,cap);
+ same(expected,en,actual,an,"separate HP and exchange replay deterministically");
+ unsigned lives=g_ram[0x1f80];
+ /* State produced by native lethal damage at $84:9D60. Let the real death
+ * animation, life decrement and checkpoint initializer perform the rest. */
+ g_ram[0xbcf]=128; g_ram[0xbaa]=0x0c; g_ram[0xbab]=0;
+ bool respawned=false;
+ for(unsigned i=0;i<1000;++i) {
+ frame(0); MmxZeroState s=MmxZeroGetState();
+ if(g_ram[0x1f80]+1==lives && s.hp_valid && s.hp[0]==18 && s.hp[1]==18 && g_ram[0xba9]==2) {
+ respawned=true; break;
+ }
+ }
+ check(respawned,"native death loses one life and respawn refills both HP pools");
+ check(RtlLoadSnapshot(fixture),"restore after separate HP checks");
+}
static void zero_state_checks(const char *assets, const char *fixture, uint8 *start,
uint8 *expected, uint8 *actual, size_t cap) {
check(fixture != NULL && MmxZeroLoad(assets), "Zero local assets load");
@@ -276,6 +330,8 @@ static void zero_state_checks(const char *assets, const char *fixture, uint8 *st
check(w == 256 && g_mmx_custom_renderer, "Zero activates native-width compositor");
zero_swap_checks(fixture,start,expected,actual,cap);
if(getenv("MMX_ZERO_SWAP_ONLY")) { puts("MMX SELECT SWAP CHECKS PASSED"); return; }
+ zero_health_checks(fixture,start,expected,actual,cap);
+ if(getenv("MMX_ZERO_HEALTH_ONLY")) { puts("MMX CHARACTER HP CHECKS PASSED"); return; }
zero_motion_checks(fixture);
zero_combat_checks(fixture);
check(RtlLoadSnapshot(fixture), "restore for burst state replay");
@@ -310,6 +366,10 @@ static void zero_state_checks(const char *assets, const char *fixture, uint8 *st
check(RtlLoadSnapshotFromMemory(start,burst_n-sizeof(MmxZeroState)+MMX_ZERO_COMBAT_STATE_SIZE),
"pre-swap v6 save retains combat prefix");
check(MmxZeroActive() && !MmxZeroSwapping(),"pre-swap save defaults to Zero without an exchange");
+ uint32_t v7=7; memcpy(start+chunk+4,&v7,4);
+ check(RtlLoadSnapshotFromMemory(start,burst_n-sizeof(MmxZeroState)+MMX_ZERO_SWAP_STATE_SIZE),
+ "pre-HP v7 save retains character/swap prefix");
+ check(!MmxZeroGetState().hp_valid,"pre-HP save seeds separate pools from native HP on first update");
check(RtlLoadSnapshot(fixture), "restore for combo probe");
for (int i=0;i<201;++i) frame(SNES_PAD_Y);
frame(0);
diff --git a/tools/apply_zero_hooks.py b/tools/apply_zero_hooks.py
index 9baaa8b..ecc8945 100644
--- a/tools/apply_zero_hooks.py
+++ b/tools/apply_zero_hooks.py
@@ -9,7 +9,7 @@
MUZZLE_PCS = {0x81a566, 0x81a578, 0x838b6a, 0x838d82, 0x838eb4,
0x839518, 0x83983c, 0x839974, 0x83a3a9}
ORIGIN_PCS = {0x8283ed: 1, 0x83958c: 0, 0x839dc5: 1}
-REQUIRED = PCS | MUZZLE_PCS | ORIGIN_PCS.keys() | {0x81815c, 0x818165, 0x00d3e5, 0x849e73, 0x849c16, 0x848f07, 0x848eea}
+REQUIRED = PCS | MUZZLE_PCS | ORIGIN_PCS.keys() | {0x009da6, 0x81815c, 0x818165, 0x00d3e5, 0x849e73, 0x849c16, 0x848f07, 0x848eea}
def apply(text):
@@ -26,6 +26,9 @@ def apply(text):
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)
output.append(line)
+ if pc == 0x009da6 and 'cpu_write8' in line and '0x0bcf' in line:
+ output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern void MmxZeroHealthRespawn(const uint8_t *); MmxZeroHealthRespawn(g_ram); }}\n')
+ found.add(pc)
if pc in MUZZLE_PCS:
load = re.search(r'uint8 (_v\d+) = cpu_read8.*0xbe3([9a])', line)
if load:
From 96e5c9ec5e1f188d7ff5b21487e41a794ac930b6 Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Tue, 29 Sep 2026 09:39:30 -0700
Subject: [PATCH 14/16] fix: fire the small green shot at Zero first charge
tier
---
snesrecomp | 2 +-
src/mmx_zero.c | 4 +++-
tests/mmx_adaptive_state_test.c | 16 ++++++++++++++++
tests/mmx_zero_test.c | 2 +-
4 files changed, 21 insertions(+), 3 deletions(-)
diff --git a/snesrecomp b/snesrecomp
index 8867499..98ce81b 160000
--- a/snesrecomp
+++ b/snesrecomp
@@ -1 +1 @@
-Subproject commit 8867499d28543e74cd31134abae9468a88675847
+Subproject commit 98ce81bcb35d102b4bf72e995c84bf583b15545d
diff --git a/src/mmx_zero.c b/src/mmx_zero.c
index f494c98..9f0ea6f 100644
--- a/src/mmx_zero.c
+++ b/src/mmx_zero.c
@@ -473,7 +473,9 @@ void MmxZeroPlayerTick(uint8_t r[0x20000]) {
state.combo = 1; state.saber_ready = tier == 10;
start_burst(r,1);
} else if (tier >= 4) {
- clear_charge(r); r[0xc01] = tier >= 6 ? 8 : 6;
+ /* X1 $81:94BF indexes $86:BA76 by command/2: command 2
+ * is class 1 (green half-shot); command 6 is class 2. */
+ clear_charge(r); r[0xc01] = tier >= 6 ? 8 : 2;
}
state.charge = 0;
}
diff --git a/tests/mmx_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c
index de346e3..24db2f6 100644
--- a/tests/mmx_adaptive_state_test.c
+++ b/tests/mmx_adaptive_state_test.c
@@ -317,6 +317,21 @@ static void zero_health_checks(const char *fixture, uint8 *start, uint8 *expecte
check(respawned,"native death loses one life and respawn refills both HP pools");
check(RtlLoadSnapshot(fixture),"restore after separate HP checks");
}
+static void zero_half_charge_checks(const char *fixture) {
+ for (unsigned arms=0;arms<2;++arms) {
+ check(RtlLoadSnapshot(fixture),"restore for native half-charge check");
+ g_ram[0x1f99]=(uint8_t)(arms?2:0);
+ for(unsigned i=0;i<30;++i) frame(SNES_PAD_Y);
+ frame(0);
+ check(zero_projectiles(1)==1 && !zero_projectiles(2) && !zero_projectiles(3),
+ "first Zero charge emits native green class 1 with or without X1 arms");
+ check(!MmxZeroGetState().combo && !MmxZeroGetState().burst && !(g_ram[0xc2f]&64),
+ "half-charge has no stored beam sequence or looping charge sound");
+ zero_replay(2);
+ zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),arms?".half-charge-arms.cap":".half-charge.cap");
+ }
+ check(RtlLoadSnapshot(fixture),"restore after native half-charge check");
+}
static void zero_state_checks(const char *assets, const char *fixture, uint8 *start,
uint8 *expected, uint8 *actual, size_t cap) {
check(fixture != NULL && MmxZeroLoad(assets), "Zero local assets load");
@@ -334,6 +349,7 @@ static void zero_state_checks(const char *assets, const char *fixture, uint8 *st
if(getenv("MMX_ZERO_HEALTH_ONLY")) { puts("MMX CHARACTER HP CHECKS PASSED"); return; }
zero_motion_checks(fixture);
zero_combat_checks(fixture);
+ zero_half_charge_checks(fixture);
check(RtlLoadSnapshot(fixture), "restore for burst state replay");
for(unsigned i=0;i<201;++i) frame(SNES_PAD_Y);
zero_replay(4);
diff --git a/tests/mmx_zero_test.c b/tests/mmx_zero_test.c
index 40d79c8..d497b3a 100644
--- a/tests/mmx_zero_test.c
+++ b/tests/mmx_zero_test.c
@@ -92,7 +92,7 @@ int main(void) {
MmxZeroState z=MmxZeroGetState(); assert(MmxZeroChargeTier(&z)==tiers[c]);
tick(0,0); z=MmxZeroGetState();
assert(z.combo==(tiers[c]>=8) && z.saber_ready==(tiers[c]==10));
- if(tiers[c] && tiers[c]<8) assert(ram[0xc01]==(tiers[c]==4?6:8));
+ if(tiers[c] && tiers[c]<8) assert(ram[0xc01]==(tiers[c]==4?2:8));
}
assert(MmxZeroGetState().burst == 1 && !ram[0x1228]);
/* The native engine started a looping charge voice before host release. */
From 3a2c4aff82086d3fa6f5adef957660474869e93f Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Tue, 29 Sep 2026 09:41:54 -0700
Subject: [PATCH 15/16] release: package Zero 0.0.1 with automatic source ROM
extraction
---
.github/workflows/build.yml | 2 +-
CMakeLists.txt | 7 +-
README.md | 11 +-
docs/mod-source-roms.md | 46 +++
docs/zero-0.0.1.md | 44 +++
docs/zero-port.md | 328 ++++++++++++++++++
docs/zero-spike.md | 2 +
docs/zero-weapons-coop-roadmap.md | 3 +-
.../0.0.1/manifest.toml | 36 ++
.../0.1.0/manifest.toml | 34 --
snesrecomp | 2 +-
src/mmx_source_assets.cpp | 149 ++++++++
src/mmx_source_assets.h | 12 +
src/mods/mmx_zero_plugin.c | 17 +-
tests/check_source_extraction.py | 27 ++
tests/mmx_source_assets_test.cpp | 11 +
tools/make_release.ps1 | 22 +-
17 files changed, 698 insertions(+), 55 deletions(-)
create mode 100644 docs/mod-source-roms.md
create mode 100644 docs/zero-0.0.1.md
create mode 100644 docs/zero-port.md
create mode 100644 mods/preloaded/packages/megaman-x.character.zero/0.0.1/manifest.toml
delete mode 100644 mods/preloaded/packages/megaman-x.character.zero/0.1.0/manifest.toml
create mode 100644 src/mmx_source_assets.cpp
create mode 100644 src/mmx_source_assets.h
create mode 100644 tests/check_source_extraction.py
create mode 100644 tests/mmx_source_assets_test.cpp
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2fcc98c..1b2ff51 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -20,4 +20,4 @@ jobs:
- name: Compile shared desktop host and game adapters
run: cmake --build build --target MegaManXSNESRecomp RockmanXSNESRecomp --parallel 4
- name: Check display, widescreen and password saves
- run: cmake --build build --target mmx_display_test mmx_wide_policy_test mmx_renderer_test mmx_password_save_test && ctest --test-dir build --output-on-failure
+ run: cmake --build build --target mmx_display_test mmx_wide_policy_test mmx_renderer_test mmx_password_save_test mmx_zero_test && ctest --test-dir build --output-on-failure
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 2c564df..2e0525d 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -36,7 +36,7 @@ function(add_mmx_variant TARGET GEN_DIR CFG_DIR)
src/mmx_spc_player.c src/mods/mmx_diagnostics_plugin.c
src/mods/mmx_widescreen_plugin.c src/mods/mmx_msu1_plugin.c
src/mods/mmx_password_plugin.c src/mmx_password_save.cpp
- src/mods/mmx_zero_plugin.c src/mmx_zero.c)
+ src/mods/mmx_zero_plugin.c src/mmx_zero.c src/mmx_source_assets.cpp)
target_include_directories(${TARGET} PRIVATE
${CFG_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_BINARY_DIR} ${SNESRECOMP_RUNNER_INCLUDE_DIRS})
@@ -101,6 +101,10 @@ add_mmx_variant(RockmanXSNESRecomp "${CMAKE_CURRENT_SOURCE_DIR}/variants/jp/gen"
include(CTest)
if(BUILD_TESTING)
+ add_executable(mmx_source_assets_test tests/mmx_source_assets_test.cpp src/mmx_source_assets.cpp
+ ${SNESRECOMP_ROOT}/runner/src/sha256.c)
+ target_include_directories(mmx_source_assets_test PRIVATE src ${SNESRECOMP_ROOT}/runner/src)
+ target_compile_features(mmx_source_assets_test PRIVATE cxx_std_17)
add_executable(mmx_zero_test tests/mmx_zero_test.c src/mmx_zero.c)
target_include_directories(mmx_zero_test PRIVATE src)
add_test(NAME mmx_zero_character COMMAND mmx_zero_test)
@@ -145,6 +149,7 @@ include(${SNESRECOMP_ROOT}/runner/cmake/mmx_state_tests.cmake)
snesrecomp_target_mmx_state_tests(MegaManXSNESRecomp "${CMAKE_CURRENT_SOURCE_DIR}/src/main.c")
if(TARGET mmx_state_tests)
+ add_dependencies(mmx_state_tests MegaManXSNESRecomp_widescreen_overrides)
target_compile_definitions(mmx_state_tests PRIVATE
MMX_ZERO_COMBAT_REFERENCE_DEFAULT="${CMAKE_CURRENT_SOURCE_DIR}/tests/data/zero_x3_combat.csv"
MMX_ZERO_MOTION_REFERENCE_DEFAULT="${CMAKE_CURRENT_SOURCE_DIR}/tests/data/zero_x3_motion.csv")
diff --git a/README.md b/README.md
index 28209df..9555ba5 100644
--- a/README.md
+++ b/README.md
@@ -60,11 +60,12 @@ image. It is disabled by default and enabled from the launcher's **Mods** page.
See [Adaptive widescreen support](#adaptive-widescreen-support) for
availability and controls.
-This branch also includes the disabled-by-default [X3 Zero experiment](docs/zero-spike.md).
-It requires locally extracted X3 USA assets. The prototype supports the two-shot
-and saber combo, plus Select to exchange X and Zero while standing on the ground.
-The exchange uses original teleport sprites and pauses stage simulation.
-Complete campaign validation is unfinished.
+The disabled-by-default **X3 Zero 0.0.1 mod** adds playable Zero, his
+buster/saber combo, and grounded Select exchange with X. Both characters have
+separate current health; pickups heal only the active character. Enable it in
+**Mods** and select your **Mega Man X3 USA ROM**. Assets are prepared locally
+automatically. See [setup and release notes](docs/zero-0.0.1.md) and the
+[technical handoff](docs/zero-port.md). X2/X3 boss weapons are a separate follow-up.
diff --git a/docs/mod-source-roms.md b/docs/mod-source-roms.md
new file mode 100644
index 0000000..ad29be4
--- /dev/null
+++ b/docs/mod-source-roms.md
@@ -0,0 +1,46 @@
+# Public mods: user-supplied source ROMs
+
+The user supplies the supported X1 ROM and an original USA X3 ROM for Zero.
+The later X2/X3 weapon expansion requires those games' respective ROMs. This
+contract covers all imported sprites, palettes, animation records, collision
+data, badges and future audio. No extracted content belongs in a public package.
+
+## Zero 0.0.1 setup
+
+The launcher asks for a **ROM**, never an extracted cache. Its X3 file resource
+uses shared key `megaman-x.source.x3`, reserved also for the weapon follow-up.
+The provider validates normalized SHA-256, accepts 512-byte copier headers,
+persists the path locally, and rejects unsupported ROMs before activation.
+
+Supported X3 USA normalized SHA-256:
+`65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7`.
+
+`src/mmx_source_assets.cpp` repeats validation and extracts Zero assets natively
+from the selected ROM, atomically publishing `cache/mmx-source/x3-zero-v6.bin`
+beside the executable. Failed validation leaves the previous cache untouched.
+Activation always requires the selected ROM; it never falls back to developer
+assets. Runtime extraction requires no Python or additional download.
+
+The native output is byte-for-byte equivalent to `tools/extract_zero.py`.
+The cache contains the original 117 body poses, 21 saber body poses, 14 saber
+blade poses, palettes, four HUD tiles, animation records, muzzle offsets and
+saber bounds. [Zero's source notebook](zero-port.md) records the ROM addresses.
+Code stores addresses and integration logic; imported bytes stay in the cache.
+
+## Release staging
+
+`tools/make_release.ps1` stages the executable, launcher assets, default config,
+README, Zero release notes and checked-in mod catalog. It does not copy the
+runtime's writable `mods` directory, key bindings or private caches. Catalog
+staging rejects untracked files, `state.toml`, ROMs, binary asset caches, saves
+and compositor captures. Runtime DLL dependencies are audited from PE imports.
+ROMs, extracted cache files and private fixtures are used only for local tests.
+
+## Follow-up weapons contract
+
+Both weapon packs live on the separate `feat/x2-x3-weapons` branch. X3 weapons
+reuse `megaman-x.source.x3`; changing it in either feature updates the other.
+X2 uses `megaman-x.source.x2` and normalized USA SHA-256
+`f3246755f608a1e1dc9c848b61da3b824c7853b29b3be40df6fc7f2793a887ed`.
+Both remain subject to the same private extraction and release audit rules.
+Co-op is a later mutually exclusive mod; see the [roadmap](zero-weapons-coop-roadmap.md).
diff --git a/docs/zero-0.0.1.md b/docs/zero-0.0.1.md
new file mode 100644
index 0000000..7f37520
--- /dev/null
+++ b/docs/zero-0.0.1.md
@@ -0,0 +1,44 @@
+# X3 Zero mod 0.0.1
+
+This optional mod adds X3 Zero to Mega Man X (USA Rev 1). Enable **X3 Zero**
+in the launcher's Mods page, select your **Mega Man X3 USA ROM** (`.sfc` or
+`.smc`), then launch. The game validates the ROM and prepares the required
+assets locally. No Python, manual extraction, or asset download is needed.
+The mod is disabled by default. It requires this release's host executable;
+copying its manifest into an older host does not install the native plugin.
+
+## Playing
+
+- Start as Zero, using X1's normal controls and earned special weapons.
+- Hold fire for progressively stronger charges: small green shot, full shot,
+ two-shot sequence, then two shots plus saber at maximum charge. Press fire
+ again for each stored follow-up. The saber waits for preceding beams to finish.
+- Press **Select** while standing idle on solid ground to exchange X and Zero.
+ Original blue/red teleport effects play while stage simulation pauses.
+- Each character has separate current health. Health pickups and subtanks heal
+ the active character only. Heart tanks increase the shared maximum.
+- Active-character death loses a life normally; respawn refills both pools.
+- X1 charged special weapons still require the arm upgrade. X keeps native X1
+ abilities/progression. Zero has his basic dash and buster/saber abilities.
+
+Zero uses original X3 body, saber, teleport and health badge artwork, including
+the pause portrait and title cursor. The title selection shot still works.
+The 1-up head deliberately remains X's original artwork.
+
+## Scope and known limits
+
+This is the first playable mod release, not a claim of exact X3 emulation or
+complete campaign validation. It retains X1's buster projectile engine and
+audio; original X3 projectile/effect/audio fidelity remains follow-up work.
+Some scripted poses, ride armor, capsules and unusual terrain still need
+broader playtesting. Separate mod save profiles are not yet enforced: keep
+your modded saves separate from stock and weapon-development saves.
+
+**X2/X3 boss weapons and simultaneous co-op are not included.** Weapons are
+developed on `feat/x2-x3-weapons`; co-op follows only after weapons finish.
+The source-ROM selection uses a shared X3 key so the future X3 weapons option
+can reuse it automatically. X2 weapons will require their own X2 ROM.
+
+The download includes no ROMs, extracted art, local ROM paths, saved games or
+asset caches. See [source provenance](mod-source-roms.md) and the complete
+[technical handoff](zero-port.md) for integration details and validation.
diff --git a/docs/zero-port.md b/docs/zero-port.md
new file mode 100644
index 0000000..b7e5101
--- /dev/null
+++ b/docs/zero-port.md
@@ -0,0 +1,328 @@
+# X3 Zero in X1: current implementation and handoff
+
+Release handoff for Zero **0.0.1**, 2026-09-29. Stable character work is
+integrated separately from `feat/x2-x3-weapons`. [zero-spike.md](zero-spike.md)
+is historical; this document records the current behavior and source findings.
+See [release setup](zero-0.0.1.md) and the [roadmap](zero-weapons-coop-roadmap.md).
+
+Central Beads: `.36` release/split, `.31` remaining Zero fidelity, `.30` Select
+exchange, `.33` separate HP, `.32` weapon expansion, `.34` later co-op, all under
+`beads-8wg.1`. Work solo, with bounded commits and private test fixtures.
+
+## Implemented behavior and limits
+
+| Area | Current implementation | Validation / remaining limit |
+| --- | --- | --- |
+| Body | Original-size X3 Zero, original body/saber art and animation records | No resizing needed in tested areas; complete campaign clearance is unverified |
+| Movement | X1 native movement with Zero's innate dash; original X3 base values retained | Five measured trajectories match X3; water/walls/ladders and special terrain need broader coverage |
+| Collision | X3 normal/dash body and terrain geometry, translated to X1's foot origin | Actual collision geometry changes, not only a sprite replacement; disabled/X identity restores X1 geometry |
+| Buster combo | Original charge tiers; ground/air first shot, second shot and final saber | Body timing measured against X3; first two beams retain X1 projectile art/travel/effect lifetimes |
+| Saber | Original ground/air body and blade frames; phase-dependent hitboxes | Native enemy/boss responses exercised; positive saber damage 16 is an adaptation, not universal X3 damage equivalence |
+| X1 specials | All eight native normal/charged attacks, Zero muzzle offsets and selected effect adjustments | Activation, energy, upgrade gate and both directions checked; remaining visual/state combinations need playtesting |
+| Hurt/fades | Replacement tied to submitted OAM, including hidden blink frames and pause fades | Renderer checks cover the reported X flashes and menu transitions |
+| HUD | Original X3 Z badge with its frame/palette | ROM-to-render comparison matched all 218 nontransparent badge pixels |
+| Pause/title | Original Zero body in pause and title cursor; title confirmation still shoots | Upgraded/unupgraded menus and native title shot checked |
+| Life heads | **Original X 1-up artwork**, both menu and pickups | Owner explicitly rejected the custom Zero head; do not restore any custom version |
+| Select exchange | Grounded idle X/Zero exchange using original blue/red teleport art; game tasks freeze | Both directions, held/midair Select, frozen live projectile and deterministic replay checked |
+| Health | Independent current HP, shared maximum; pickups/subtanks heal active character only | Native pickups, exchange, old-save migration, death/life loss and both-pool respawn refill checked |
+| Persistence | Host character state included in saves, rollback, rewind and captures | Legacy Zero states supported; public mod/save incompatibility UX remains unfinished |
+
+The original question was whether this required disassembling nearly all of
+both games. It did not: the implementation uses bounded source investigation,
+asset tables and integration hooks. This does not establish perfect X3 fidelity
+or a complete campaign playthrough.
+
+## Public distribution and asset provenance
+
+The [source-ROM release contract](mod-source-roms.md) is mandatory for **all**
+Zero content and the later weapon/co-op mods. Users provide the supported X1
+ROM and their own X3 ROM for Zero; the complete weapon set additionally needs
+X2. Extract assets locally. Public packages must not bundle ROMs, `zero-x3.bin`,
+`x-weapons.bin`, decoded art/audio, private fixtures or research captures.
+Source-ROM selection and automatic native extraction are implemented; Zero and
+the future X3 weapon option uses the same shared ROM key. Release packaging
+stages only the tracked catalog; no private ROM paths, caches or save states.
+
+`tools/extract_zero.py` validates original USA X3 SHA-256
+`65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7`
+after accepting an optional 512-byte copier header. Its current `MMXZERO6`
+cache contains:
+
+- 117 body poses (group `$4A`), 21 saber-body poses (`$4B`) and 14 blade poses
+ (`$50`), indexed at 128x128 with origin `(64,64)`. This canvas is transparent
+ padding around the original pixels, not a 128-pixel-tall character.
+- Original body/saber palettes; 40 bytes of ground/air saber collision bounds
+ translated eight pixels upward into X1 coordinates.
+- Four original 8x8 HUD badge tiles and their palette.
+- `$474` bytes containing 136 original body-animation directory entries and
+ records, plus 196 bytes of pose-specific firing data.
+
+The loader rejects a wrong header, dimensions/counts, truncated/trailing data,
+invalid pixels, invalid bounds and invalid animation/muzzle entries. Missing
+or invalid assets leave the mod inactive with a log message. Source addresses
+below and extraction code are durable; extracted assets remain local.
+
+## Mechanics and integration
+
+`src/mmx_zero.c` owns the asset cache and 40-byte `MmxZeroState`.
+`MmxZeroEnabled()` means assets are loaded; `MmxZeroActive()` additionally means
+the current player is Zero. Keep that distinction: choosing X must restore his
+native progression/capabilities while the shared mod remains loaded.
+
+X1 still owns stage scripts, collision resolution, inventory, boss progression
+and normal enemy behavior. Zero changes the playable actor; story NPC Zero is
+separate. No simultaneous second player exists in this mod.
+
+The 180-byte base movement tables are identical in the two original ROMs.
+Innate dash is granted at three capability reads by returning upgrade bit
+`$08`; the equipment byte is not changed. **Arms are bit `$02`**, so innate dash
+does not unlock charged special weapons for either character.
+
+The normal body damage half-size is `(6,18)`, center Y `-5`; dash is `(6,11)`,
+center Y `+2`. Both use terrain half-height 21 and center Y `-5`. Geometry
+comes from X3 with a `-8` Y translation to align feet. Expected-byte guards
+patch only the live cartridge copy and restore X1 bounds for X/disabled mode;
+source ROM files are never written.
+
+The animation adapter maps all 81 X1 sequence entries to X3 group `$4A`
+records, preserving interior sequence aliases. It mirrors body presentation
+without replacing X1's gameplay animation/event fields. X1's Hadouken entries
+use adapted Zero forward-firing/recovery poses. Complete mapping is not proof
+that every capsule, ride-armor, death or scripted state has been playtested.
+
+### Charge and saber sequence
+
+Held-gameplay-frame thresholds are 21 (half), 81 (single full), 141 (two
+full busters) and 201 (two busters plus saber). At 60 Hz, the highest tier takes
+about 3.35 seconds. Release starts the first shot; a later press after its
+body recovery starts the second. Another press starts the saber only when the
+preceding beams **and their native disappearance effects** have retired.
+There is no invented fixed delay between the second beam and saber readiness.
+
+The original grounded first shot emits on frame 8 and reaches idle on 18;
+the second emits on 10 and reaches idle on 29. Original body records drive
+emission, facing lock, ground/air phase changes and the small airborne motion
+holds. Native jump/gravity/terrain logic continues during airborne actions.
+Saber recovery returns on frame 44, with idle visible on 45. The blade is
+visible during slash ages 7-31; the damaging arc has five shorter phases and
+ends before the visible recovery finishes.
+
+The first two beams use native X1 full-charge class 3. A tracked slot mask
+allows both to coexist without replacing their native initialization or
+retirement. Saber occupies an ordinary projectile slot with ownership tag
+`$5A53` at `+$3E`; its native class update is suppressed. Original ground/air
+arc bounds feed X1's enemy collision routine. Positive eligible damage becomes
+16, while native zero/immunity/reflection paths remain. An enemy-slot mask
+prevents repeat hits against the same ordinary enemy slot during one swing.
+
+Hurt, incompatible player actions, weapon changes and menu entry cancel stored
+combat. Cancellation preserves body animation, active identity and HP pools;
+full reset/load is separate. Charge cleanup sends native command `$17` once
+when RAM `$0C2F` bit `$40` is set, then clears charge counters. Buster emissions
+send sound `$02` once each through X1's sound ring. This fixed the persistent
+charge noise; original X3 saber/audio-bank import is still pending.
+
+### X1 weapons and presentation
+
+Homing Torpedo, Chameleon Sting, Rolling Shield, Fire Wave, Storm Tornado,
+Electric Spark, Boomerang Cutter and Shotgun Ice keep X1's real weapon logic
+and progression. Normal/charged forms use original X3 muzzle coordinates;
+delayed projectiles recover the firing pose through their saved native index.
+X1 continues to apply facing, spread, trajectories and energy use.
+
+Additional origin corrections affect the actual projectile coordinate, so
+art and collision move together: Storm Tornado six pixels forward, Boomerang
+Cutter eight pixels up, charged Rolling Shield six pixels up. The shield was
+shifted rather than resized. Charging motes are spread 25% farther from the
+player center and six pixels upward, retaining their original pixel art and
+timing. Charged Sting remaps Zero's material/shade roles into the native live
+cycling palette while retaining readable skin/outline colors. Other charge,
+hit and weapon-palette fidelity remains part of visual acceptance.
+
+`src/mmx_renderer.c` replaces submitted player/menu/title pieces, suppresses
+X armor overlays and draws the original Zero pixels. Original OAM attribution
+is retained across repeated pause-fade frames. Live OAM matching prevents a
+visible old X frame from leaking through while also honoring truly hidden
+invulnerability frames. The compositor runs at native width whenever Zero is
+enabled; widescreen remains an independent setting.
+
+Pause body is centered at `(128,152)`; title selection retains X1's real player
+cursor animation and confirmation projectile. The HUD badge is independent of
+body blinking. Original X life-head pixels and native collection behavior are
+deliberate final requirements, superseding every earlier custom icon attempt.
+
+### Select and independent HP
+
+Select must be a new press during grounded, stationary, idle gameplay, outside
+menus, hurt, ladders, ride armor and scripted transitions. Burst/saber actions
+block exchange. The original player feet remain stationary; only presentation
+moves vertically. Five one-frame morphs plus a two-frame energy ball precede
+the outgoing column; departure speed is `$0AA6` in 8.8 units. After identity
+changes offscreen, wait X3's 30-frame exchange interval; arrival descends at
+eight pixels/frame and reverses the seven-frame morph. Native sounds are
+`$0F` out and `$0E` in. Held Select cannot repeatedly exchange.
+
+`src/mmx_rtl.c` calls `MmxZeroSwapTick` after NMI/input and returns before the
+cooperative game scheduler while exchange is active. Enemies, projectiles,
+items, camera and stage scripts stop; NMI, input, audio and rendering continue.
+Save/restore includes the frozen sprite queues and complete exchange state.
+Input edges are cleared before gameplay resumes.
+
+HP index 0 is Zero, 1 is X. Native RAM `$0BCF` always represents the active
+character. Health pickups **and subtanks heal only that character**. Heart
+tanks update the shared maximum at `$1F9A`; this does not mean ordinary pickups
+heal the reserve. Old saves seed both pools once from their single current HP.
+Active death follows native life loss; native stage/checkpoint HP initialization
+refills both pools to the shared maximum. It does not automatically switch to
+the living reserve. Single-player HUD shows the active character's bar.
+Weapon selection/energy are shared; checkpoint death preserves weapon energy.
+
+## Source and hook address map
+
+Hex addresses are SNES CPU LoROM addresses unless marked RAM or normalized
+ROM offset. `$04`/`$84`, for example, may name mirrored ROM banks. These findings
+come from the local original ROMs and measured recomp runs.
+
+| Source | Address / purpose |
+| --- | --- |
+| X3 sprite data | Layout root `$8D:8000`; group `$4A` DMA `$85:D6A8`, `$4B` `$85:DB47`, `$50` `$85:E6E0` |
+| X3 palettes | List root `$86:8180`, keys `$D0/$D2`; saber palette `$8C:B5A0` |
+| X3 animations | Group `$4A` directory/records `$3F:CC74`, length `$474`; normal body selection `$04:A63C`, transfers `$04:BCA2` |
+| X3 muzzle | Pose map `$39:9161`, signed Y/left-facing-X pairs `$39:91D9`; source helper `$81:8BA9` |
+| X3 geometry | Normal/dash `$86:B40E/B422`; ground/air saber `$86:B837/B84B` |
+| Shared movement | X1 `$86:B9B1`, X3 `$86:B272`: 30 six-byte velocity/acceleration records |
+| X3 HUD | Selector `$84:DCE6`, DMA list `$5C` at `$86:9AB3`; CHR `$2C:8D20/$2C:8DE0`, palette `$8C:B0E0` |
+| Teleport art | X3 group `$4A` poses `$3C-$42`; X1 layouts `$8D:8000`, DMA `$85:A597`, buster palette list `$0100` |
+| Teleport sequence | X1 `$49/$48`, X3 `$7F/$7E`; X3 exchange delay `$84:8DBF` |
+| X1 player RAM | `$0BA8`; X/Y `+$05/+$08`, facing `+$11`, action `+$02`; native active HP `$0BCF`, ground flag `$0BD3 & 4` |
+| X1 combat RAM | Projectile pool `$1228..1427`, stride `$40`; count `$0BDD`; selected weapon `$0BDB`; upgrade byte `$1F99` |
+| X1 sound | Ring `$0B72`, producer `$0BA3`; original queue routine `$80:88CD`; charge stop `$81:9890` |
+| Live collision patches | Normalized ROM `$32552` normal, `$33B38` dash, `$37FB0..37FD7` saber bounds |
+| X1 body animation hooks | Start `$84:8F07`, advance `$84:8EEA` |
+| X1 player hooks | Tick `$81:815C`, end `$81:8165`; dash capability reads `$81:971C/9793/98FC` |
+| X1 projectile hooks | Active read `$00:D3E5`; positive-damage path `$84:9E6E..9E76`; hitbox read `$84:9C16` |
+| X1 native origins | Buster `$81:A578..A5B5`; full special-weapon sites are `MUZZLE_PCS`/`ORIGIN_PCS` in `tools/apply_zero_hooks.py` |
+| X1 HP initialization | Compiled block `$00:9DA6`, interpreted post-store `$00:9DCA` |
+
+`tools/apply_zero_hooks.py` validates 23 generated-code integration sites.
+`src/mods/mmx_zero_plugin.c` supplies matching interpreter hooks, including
+correct instruction flags/timing. Generated block addresses and interpreted
+post-instruction addresses are intentionally different. Edit the patcher,
+not generated bank files. `src/main.c` reapplies live collision bounds before
+each frame; `src/mmx_rtl.c` owns scheduler/save integration.
+
+## Persistence and developer reproduction
+
+| Game chunk | Zero state prefix | Capture version introduced at that checkpoint |
+| --- | --- | --- |
+| v3 stock | None | v2 |
+| v4 | 12-byte combat | v3 |
+| v5 | 18-byte combat + body animation | v4 |
+| v6 | 30-byte extended combo timing | v5 |
+| v7 | 36-byte identity + exchange | v6 |
+| v8 current Zero-only | 40-byte state including HP | v7 |
+The Zero-only release writes game chunk v8 (v3 when disabled) and capture v7.
+Weapon inventory and combat state formats belong to the follow-up branch and
+are not readable by this release. Old Zero prefixes initialize new fields
+safely. Asset availability and mod identity matter when loading; the package's
+`requires-same-mods` declaration does not yet enforce public save isolation.
+
+Build/extract in the worktree, following the repository README and using the
+`build-zero-release` configuration. Example local asset command:
+
+```powershell
+python tools/extract_zero.py ../MegamanX3SNESRecomp/mmx3.sfc build-zero/port-work/zero-x3.bin
+```
+
+Package: `mods/preloaded/packages/megaman-x.character.zero/0.0.1/manifest.toml`.
+Activation plugin is `megaman-x.zero`; its picker selects the original X3 USA
+ROM. Native extraction produces the private cache under `cache/mmx-source/`.
+See [ROM setup](mod-source-roms.md). The release executable is
+`build-zero-release/MegaManXSNESRecomp.exe`; weapon development continues in
+`../_wt_mmx_zero/build-zero/port-work/MegaManXSNESRecomp.exe`.
+
+**Never automatically load a save state into the owner's game.** Launch owner
+playtests with a fresh boot and the ROM only. Private headless fixtures are
+separate and must not be copied into an owner session or release package.
+
+For focused ROM checks, build `mmx_state_tests` with `MMX_STATE_TESTS=ON`.
+Run from a scratch directory with absolute `MMX_ZERO_TEST_ASSETS` and
+`MMX_ZERO_TEST_FIXTURE` paths and X1 ROM argument. Fixture: complete standing,
+unupgraded Highway save with buster selected. Optional
+`MMX_ZERO_SWAP_ONLY` / `MMX_ZERO_HEALTH_ONLY` narrow scope. `MMX_ZERO_TEST_CAPTURE`
+sets a capture prefix. A separate `MMX_ZERO_TITLE_FIXTURE` enables title checks.
+Use complete `savestate`/`loadstate`, not the older machine-only debugger
+`save_state`/`load_state`, for host-state validation. None of this authorizes
+loading the owner's game.
+
+Compositor captures replay with `mmx_render_capture`, the X1 ROM, requested
+aspect and Zero cache. Native debug screenshots omit replacement art. Capture
+tool exit 1 means differences from stock PPU (expected); exit 2 is an error.
+Existing test sources: `tests/mmx_zero_test.c`, `tests/mmx_renderer_test.c`,
+`tests/mmx_adaptive_state_test.c`; measured references:
+[movement](../tests/data/zero_x3_motion.md) and
+[combat](../tests/data/zero_x3_combat.md). Their CSVs contain measurements,
+not sprites or ROM bytes.
+
+## Validation record, commits and outstanding work
+
+Prior checkpoints passed five CTests, the full ROM-backed Zero suite,
+248-frame movement reference and original ground/air combat traces; snapshot,
+rollback, rewind and old-state migrations are included. Dedicated probes
+exercised a highway wheel enemy and Armored Armadillo through native damage
+responses. All eight X1 normal/charged special classes and energy paths were
+checked in both firing directions. Menu fade coverage includes 480 opening/
+closing frames across unupgraded and upgraded cases. Separate HP and swap
+checks include native pickups, life loss/respawn and frozen-projectile replay.
+These are focused checks, not an exhaustive campaign certification. This
+documentation audit does not pretend to be a new gameplay test run.
+
+| Commit | Durable checkpoint |
+| --- | --- |
+| `0c5d415` | Playable bounded prototype |
+| `da73a88` | Original X3 HUD badge replaces approximate letter |
+| `bce2fd7` | Submitted-sprite visibility fixes hurt flashes |
+| `4e92e02` | Original animation mirroring and pause body; its life-head change later superseded |
+| `dacce26` | Original Zero muzzle offsets for X1 weapons |
+| `bdca72b` | Pause fade attribution; custom life-head art later superseded |
+| `dee8405` | Weapon effect placement/palette adjustments; custom life-head art later superseded |
+| `9cef5fa` | Measured charge tiers and ground/air firing recovery |
+| `f8843c6` | Title cursor/shot and looping-charge sound fix; its life icon later superseded |
+| `8a50ab0` | Grounded original-art exchange and restoration of X life heads |
+| `e26055a` | Independent current HP and active-only healing |
+
+Open Zero acceptance work after the weapon priority:
+
+1. Broader animation/state validation: wall slide/jump, ladders, hurt/death,
+ capsules, ride armor, stage teleports and scripted player poses. Do not
+ claim all states validated just because every sequence is mapped.
+2. Remaining presentation fidelity: X3 buster projectile/effect differences,
+ saber/source audio, charge/hit palettes and X1 special effects during
+ movement. Keep the owner's accepted X life head.
+3. Representative moving platforms, tight spaces, water, doors, bosses and
+ native/widescreen campaign playthrough. Alter dimensions only for an
+ observed clearance failure.
+4. Mod/save compatibility UX: warn before loading incompatible mod states.
+ Source-ROM setup and clean distribution staging are implemented for 0.0.1.
+
+The weapon expansion remains on `feat/x2-x3-weapons` and is excluded from
+Zero 0.0.1. Co-op remains a later mutually exclusive mod after weapons finish.
+
+### First charge correction for 0.0.1
+
+Owner playtesting exposed a wrong command conversion: X1 `$81:94BF..94C6`
+indexes `$86:BA76` by command/2. Command 2 spawns class 1 (the small green
+shot); command 6 spawns class 2. Zero's measured first tier (21..80 held
+frames) must request command 2. The thresholds and higher combo timing remain
+unchanged. The native integration test checks the actual spawned class after
+30 held frames with and without X1 arms, plus cleared audio/combination state.
+
+Release validation (2026-09-29): the five focused CTests and the complete private
+Zero runtime suite pass on the release build, including measured movement and
+ground/air combo traces, actual first-tier green projectile with/without arms,
+Select freeze/replay, separate HP/pickups/death, native X1 special weapons,
+legacy saves, rollback, rewind and menu fades. The first-tier capture was
+visually inspected. Native extraction matches the reference byte-for-byte;
+header normalization and invalid-ROM cache preservation pass. The ZIP is
+audited for absence of ROMs, extracted caches, save data, local mod state and
+weapon packages; its DLL dependency closure is validated.
diff --git a/docs/zero-spike.md b/docs/zero-spike.md
index d5c5c16..62182db 100644
--- a/docs/zero-spike.md
+++ b/docs/zero-spike.md
@@ -1,5 +1,7 @@
# X3 Zero in X1: bounded feasibility experiment
+Historical prototype. See [Zero 0.0.1](zero-0.0.1.md) and [current handoff](zero-port.md).
+
Implemented on `experiment/x3-zero-spike`, based on X1 commit `975b126`.
Tracking: central Beads `beads-110f`, under the X1 game epic and related to
the X3 game epic. This is an experimental playable prototype, not the finished
diff --git a/docs/zero-weapons-coop-roadmap.md b/docs/zero-weapons-coop-roadmap.md
index d3cfbff..d367d27 100644
--- a/docs/zero-weapons-coop-roadmap.md
+++ b/docs/zero-weapons-coop-roadmap.md
@@ -3,7 +3,8 @@
Owner requirements recorded 2026-09-28. This is the durable scope document;
implementation status and findings are tracked in the central Beads database
at `F:\Software\beads\issues`, under `beads-8wg.1` (Mega Man X / SNES).
-Work branch: `feat/x3-zero-port`, worktree: `../_wt_mmx_zero`.
+Zero 0.0.1 is released separately to main. Weapon branch: `feat/x2-x3-weapons`,
+worktree: `../_wt_mmx_zero`. Co-op starts only after that expansion is complete.
## Order of work
diff --git a/mods/preloaded/packages/megaman-x.character.zero/0.0.1/manifest.toml b/mods/preloaded/packages/megaman-x.character.zero/0.0.1/manifest.toml
new file mode 100644
index 0000000..f67811e
--- /dev/null
+++ b/mods/preloaded/packages/megaman-x.character.zero/0.0.1/manifest.toml
@@ -0,0 +1,36 @@
+format_version = 1
+id = "megaman-x.character.zero"
+version = "0.0.1"
+name = "X3 Zero"
+author = "mstan"
+description = "Play as X3 Zero or switch to X, with original teleport effects and separate health."
+license = "PolyForm-Noncommercial-1.0.0"
+resolver = "declarative"
+save_compatibility = "requires-same-mods"
+
+[[target]]
+game_id = "megaman-x-us"
+rom_sha256 = "b8f70a6e7fb93819f79693578887e2c11e196bdf1ac6ddc7cb924b1ad0be2d32"
+
+[[feature]]
+id = "zero"
+name = "X / Zero character switch"
+description = "Start as Zero and press Select while standing on the ground to exchange with X. Requires your Mega Man X3 USA ROM."
+group = "Characters"
+default_enabled = false
+
+[[resource]]
+feature = "zero"
+id = "x3-rom"
+label = "Mega Man X3 ROM"
+description = "Select your original Mega Man X3 USA ROM."
+format = "file"
+file_patterns = "*.sfc,*.smc"
+file_description = "Mega Man X3 USA ROM"
+shared_key = "megaman-x.source.x3"
+normalized_sha256 = "65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7"
+required = true
+
+[[plugin]]
+feature = "zero"
+id = "megaman-x.zero"
diff --git a/mods/preloaded/packages/megaman-x.character.zero/0.1.0/manifest.toml b/mods/preloaded/packages/megaman-x.character.zero/0.1.0/manifest.toml
deleted file mode 100644
index 0553d0f..0000000
--- a/mods/preloaded/packages/megaman-x.character.zero/0.1.0/manifest.toml
+++ /dev/null
@@ -1,34 +0,0 @@
-format_version = 1
-id = "megaman-x.character.zero"
-version = "0.1.0"
-name = "X3 Zero Experiment"
-author = "mstan"
-description = "Experimental playable-character port from Mega Man X3. Work in progress."
-license = "MIT"
-resolver = "declarative"
-save_compatibility = "requires-same-mods"
-
-[[target]]
-game_id = "megaman-x-us"
-rom_sha256 = "b8f70a6e7fb93819f79693578887e2c11e196bdf1ac6ddc7cb924b1ad0be2d32"
-
-[[feature]]
-id = "zero"
-name = "Play as Zero (experimental)"
-description = "Start as Zero and press Select while standing on the ground to exchange with X. Uses locally extracted X3 assets."
-group = "Characters"
-default_enabled = false
-
-[[resource]]
-feature = "zero"
-id = "assets"
-label = "Extracted X3 Zero assets"
-description = "Choose zero-x3.bin produced by tools/extract_zero.py from your X3 USA ROM. Defaults to zero-x3.bin beside the executable."
-format = "file"
-file_patterns = "*.bin"
-file_description = "Extracted Zero asset cache"
-required = false
-
-[[plugin]]
-feature = "zero"
-id = "megaman-x.zero"
diff --git a/snesrecomp b/snesrecomp
index 98ce81b..8566fdb 160000
--- a/snesrecomp
+++ b/snesrecomp
@@ -1 +1 @@
-Subproject commit 98ce81bcb35d102b4bf72e995c84bf583b15545d
+Subproject commit 8566fdb02b5e452d1700677c581ae83e3349e8d9
diff --git a/src/mmx_source_assets.cpp b/src/mmx_source_assets.cpp
new file mode 100644
index 0000000..2bc7ce7
--- /dev/null
+++ b/src/mmx_source_assets.cpp
@@ -0,0 +1,149 @@
+#include "mmx_source_assets.h"
+#include "sha256.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#ifdef _WIN32
+#define WIN32_LEAN_AND_MEAN
+#include
+#endif
+
+namespace {
+using Bytes = std::vector;
+namespace fs = std::filesystem;
+void require(bool ok, const char *why) { if (!ok) throw std::runtime_error(why); }
+void put(Bytes& b, unsigned v, unsigned n=2) { while (n--) { b.push_back(uint8_t(v)); v >>= 8; } }
+void append(Bytes& b, const Bytes& a) { b.insert(b.end(),a.begin(),a.end()); }
+struct Rom {
+ Bytes data;
+ Rom(const char *path, unsigned game) {
+ require(path && *path,"Select the original source ROM.");
+ std::ifstream f(fs::u8path(path),std::ios::binary|std::ios::ate);
+ require(bool(f),"Cannot open the source ROM.");
+ auto n=f.tellg(); require(n>0 && n<=8*1024*1024,"Unsupported source ROM size.");
+ data.resize(size_t(n)); f.seekg(0); f.read(reinterpret_cast(data.data()),n);
+ require(bool(f),"Cannot read the source ROM.");
+ if (data.size()%32768==512) data.erase(data.begin(),data.begin()+512);
+ uint8_t digest[32]; sha256_compute(data.data(),data.size(),digest);
+ const char *expected=
+ "65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7";
+ char actual[65]; for (unsigned i=0;i<32;++i) std::snprintf(actual+i*2,3,"%02x",digest[i]);
+ require(std::string(actual)==expected,
+ "Select the original Mega Man X3 USA ROM.");
+ }
+ size_t offset(unsigned a) const {
+ require((a&65535)>=32768,"Invalid source ROM address.");
+ return ((a>>16)&127)*32768+(a&32767);
+ }
+ Bytes raw(size_t p,size_t n) const {
+ require(p<=data.size() && n<=data.size()-p,"Source ROM read exceeds bounds.");
+ return Bytes(data.begin()+p,data.begin()+p+n);
+ }
+ Bytes at(unsigned a,size_t n) const { return raw(offset(a),n); }
+ unsigned integer(unsigned a,unsigned n=2) const {
+ Bytes b=at(a,n); unsigned v=0; for (unsigned i=0;i bytes{}, known{}; };
+void transfer(const Rom& r,unsigned table,unsigned pose,Tiles& t) {
+ unsigned a=(table&0xff0000)|((table+r.integer(table+pose*2))&65535);
+ for (unsigned i=0;i<64;++i,a+=6) {
+ unsigned count=r.integer(a,1); if (!count) return;
+ unsigned target=r.integer(a+4),start=((target&32767)-0x6000)*2,length=count*16;
+ require(start<=8192 && length<=8192-start,"Invalid source sprite transfer.");
+ Bytes b=r.at(r.integer(a+1,3),length);
+ std::copy(b.begin(),b.end(),t.bytes.begin()+start);
+ std::fill_n(t.known.begin()+start,length,1);
+ if (target&32768) return;
+ }
+ throw std::runtime_error("Unterminated source sprite transfer.");
+}
+struct Pose { int left=0,top=0,width=0,height=0; Bytes pixels; };
+Pose pose(const Rom& r,unsigned group,unsigned number,const Tiles& t,bool zero=false) {
+ unsigned table=r.integer(0x8d8000+group*3,3),a=r.integer(table+number*3,3),n=r.integer(a,1);
+ require(n<=64,"Invalid source sprite piece count.");
+ Bytes pieces=r.at(a+1,n*4); Pose p;
+ if (zero) { p.left=p.top=-64;p.width=p.height=128; }
+ else if (n) {
+ int right=-256,bottom=-256;p.left=p.top=256;
+ for (unsigned i=0;i=0;--i) {
+ unsigned flags=pieces[i*4],tile=pieces[i*4+3],size=flags&32?16:8;
+ int x=int8_t(pieces[i*4+1]),y=int8_t(pieces[i*4+2]);
+ require(zero || !(flags&14),"Unexpected source weapon palette.");
+ for (unsigned dy=0;dy>4)+ty/8)&15)*16+((tile+tx/8)&15),bits=number*32+(ty&7)*2,color=0;
+ for (unsigned plane=0;plane<4;++plane) {
+ unsigned index=bits+(plane/2)*16+plane%2;
+ require(zero || t.known[index],"Unresolved inherited source graphics.");
+ color|=((t.bytes[index]>>(7-(tx&7)))&1)<=0 && py>=0 && px>1)&7)|(group==0x50?3:1))*16;
+ p.pixels[py*p.width+px]=uint8_t(color);
+ }
+ }
+ }
+ return p;
+}
+Bytes zero_assets(const Rom& r) {
+ Bytes out{'M','M','X','Z','E','R','O','6'};
+ for (unsigned v : {128,128,64,64,117,35}) put(out,v);
+ std::array colors{};
+ for (unsigned key : {0xd0,0xd2}) {
+ unsigned a=0x860000|r.integer(0x868180+key);
+ for (unsigned i=0;;++i,a+=4) {
+ require(i<32,"Unterminated source palette.");
+ unsigned count=r.integer(a,1);if (!count) break;
+ unsigned source=0x8c0000|r.integer(a+1),dest=r.integer(a+3,1);
+ require(dest+count<=256,"Source palette exceeds CGRAM.");
+ for (unsigned j=0;j(bytes.data()),std::streamsize(bytes.size()));f.close();
+ require(bool(f),"Cannot write local mod cache.");
+#ifdef _WIN32
+ require(MoveFileExW(temp.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH)!=0,"Cannot publish local mod cache.");
+#else
+ fs::rename(temp,path);
+#endif
+ } catch (...) { std::error_code ec;fs::remove(temp,ec);throw; }
+}
+}
+int MmxSourceAssetsBuild(const char *rom,unsigned game,int zero,const char *output,char *error,size_t size) {
+ try {
+ require(game==3 && zero && output,"Invalid source asset request.");
+ Rom r(rom,game);publish(output,zero_assets(r));
+ if (error && size) error[0]=0;return 1;
+ } catch (const std::exception& e) { if (error && size) std::snprintf(error,size,"%s",e.what());return 0; }
+}
diff --git a/src/mmx_source_assets.h b/src/mmx_source_assets.h
new file mode 100644
index 0000000..e94abec
--- /dev/null
+++ b/src/mmx_source_assets.h
@@ -0,0 +1,12 @@
+#pragma once
+#include
+#ifdef __cplusplus
+extern "C" {
+#endif
+/* Validate original ROM identity (copier headers accepted), extract locally,
+ * and atomically publish a private runtime cache. No external interpreter. */
+int MmxSourceAssetsBuild(const char *rom, unsigned game, int zero,
+ const char *output, char *error, size_t error_size);
+#ifdef __cplusplus
+}
+#endif
diff --git a/src/mods/mmx_zero_plugin.c b/src/mods/mmx_zero_plugin.c
index b4f023c..64050fa 100644
--- a/src/mods/mmx_zero_plugin.c
+++ b/src/mods/mmx_zero_plugin.c
@@ -4,6 +4,8 @@
#include "snes/interp_bridge.h"
#include "mmx_renderer.h"
#include "mmx_zero.h"
+#include "mmx_source_assets.h"
+#include "sdl_compat.h"
#include
extern uint8_t g_ram[0x20000];
@@ -93,15 +95,20 @@ static void activate(void) {
char path[4096];
const RecompLauncherCModProvider *provider = snes_mod_runtime_launcher_provider_c();
RecompLauncherCModResource resource = {0};
- if (provider && provider->feature_resource_get &&
- provider->feature_resource_get(provider->ctx, "megaman-x.character.zero", "zero", 0, &resource) && resource.path[0])
- snprintf(path, sizeof(path), "%s", resource.path);
- else if (!snesrecomp_exe_dir_path("zero-x3.bin", path, sizeof(path))) return;
+ if (!provider || !provider->feature_resource_get ||
+ !provider->feature_resource_get(provider->ctx,"megaman-x.character.zero","zero",0,&resource) || !resource.path[0]) return;
+ if (!snesrecomp_exe_dir_path("cache/mmx-source/x3-zero-v6.bin",path,sizeof(path))) return;
+ char error[512];
+ if (!MmxSourceAssetsBuild(resource.path,3,1,path,error,sizeof(error))) {
+ fprintf(stderr,"[mmx-source] %s\n",error);
+ SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR,"Cannot prepare Zero mod",error,NULL);
+ return;
+ }
if (!MmxZeroLoad(path)) {
fprintf(stderr, "[mmx-zero] Cannot load extracted Zero assets: %s\n", path); return;
}
MmxZeroRegisterHooks();
- fprintf(stderr, "[mmx-zero] Experimental original-size Zero enabled\n");
+ fprintf(stderr, "[mmx-zero] Zero 0.0.1 enabled\n");
}
static void reset(void) { MmxZeroCancel(g_ram); MmxZeroDisable(); }
SNES_MOD_CONSTRUCTOR(mmx_register_zero_plugin) {
diff --git a/tests/check_source_extraction.py b/tests/check_source_extraction.py
new file mode 100644
index 0000000..97d1c4f
--- /dev/null
+++ b/tests/check_source_extraction.py
@@ -0,0 +1,27 @@
+"""Optional Zero native/reference parity check; provide extractor EXE and X3 ROM."""
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
+import extract_zero
+
+exe, rom = (str(Path(p).resolve()) for p in sys.argv[1:])
+with tempfile.TemporaryDirectory(prefix='mmx-zero-source-') as temporary:
+ root = Path(temporary)
+ output = root / 'zero.bin'
+ def run(source):
+ return subprocess.run([exe, str(source), '3', '1', str(output)])
+ reference = extract_zero.extract(rom)[0]
+ assert run(rom).returncode == 0 and output.read_bytes() == reference
+ source = Path(rom).read_bytes()
+ if len(source) % 32768 == 512:
+ source = source[512:]
+ headered = root / 'header.smc'
+ headered.write_bytes(bytes(512) + source)
+ assert run(headered).returncode == 0 and output.read_bytes() == reference
+ wrong = root / 'wrong.sfc'
+ wrong.write_bytes(bytes(len(source)))
+ assert run(wrong).returncode != 0 and output.read_bytes() == reference
+print('Zero extraction parity, copier header and invalid-ROM preservation passed')
diff --git a/tests/mmx_source_assets_test.cpp b/tests/mmx_source_assets_test.cpp
new file mode 100644
index 0000000..9f11af1
--- /dev/null
+++ b/tests/mmx_source_assets_test.cpp
@@ -0,0 +1,11 @@
+#include "mmx_source_assets.h"
+#include
+#include
+int main(int argc,char **argv) {
+ if (argc!=5) { std::fprintf(stderr,"Usage: extractor ROM GAME ZERO OUTPUT\n");return 2; }
+ char error[512];
+ if (!MmxSourceAssetsBuild(argv[1],unsigned(std::atoi(argv[2])),std::atoi(argv[3]),argv[4],error,sizeof(error))) {
+ std::fprintf(stderr,"%s\n",error);return 1;
+ }
+ return 0;
+}
diff --git a/tools/make_release.ps1 b/tools/make_release.ps1
index 0307402..253839c 100644
--- a/tools/make_release.ps1
+++ b/tools/make_release.ps1
@@ -26,6 +26,7 @@ param(
[Parameter(Mandatory = $true)][string]$Version,
[string]$BuildDir = 'build-recompui',
[string]$RuntimeBinDir = 'C:\msys64\mingw64\bin',
+ [string]$FrameworkDir = '',
[ValidateSet('SDL3', 'SDL2')][string]$SdlBackend = 'SDL3'
)
@@ -34,7 +35,9 @@ $root = Split-Path -Parent $PSScriptRoot
$build = Join-Path $root $BuildDir
$exe = Join-Path $build 'MegaManXSNESRecomp.exe'
$assets = Join-Path $build 'assets'
-$mods = Join-Path $build 'mods'
+# Stage the checked-in catalog, never a player's writable mods/state/cache.
+$mods = Join-Path $root 'mods'
+if (-not $FrameworkDir) { $FrameworkDir = Join-Path $root 'snesrecomp' }
if (-not (Test-Path -LiteralPath $exe)) {
throw "Release executable missing: $exe"
@@ -72,12 +75,17 @@ if (Test-Path -LiteralPath $mods) {
Copy-Item -LiteralPath $mods -Destination $stage -Recurse
}
-# keybinds.ini is user-generated (PSR-style rebind UI) and only exists next
-# to the exe once someone has actually rebound a key; ship it if present.
-$kb = Join-Path $build 'keybinds.ini'
-if (Test-Path -LiteralPath $kb) {
- Copy-Item -LiteralPath $kb -Destination $stage
+# Key bindings, ROM selections and private extraction caches are user data.
+# Only tracked catalog files are permitted in the mod release tree.
+$trackedMods = @(& git -C $root ls-files -- mods)
+foreach ($file in Get-ChildItem -LiteralPath (Join-Path $stage 'mods') -File -Recurse) {
+ $relative = 'mods/' + $file.FullName.Substring((Join-Path $stage 'mods').Length + 1).Replace('\','/')
+ if ($relative -notin $trackedMods -or $file.Name -eq 'state.toml' -or
+ $file.Extension -in '.sfc','.smc','.bin','.sav','.cap') {
+ throw "Private or untracked mod data in release: $relative"
+ }
}
+Copy-Item -LiteralPath (Join-Path $root 'docs/zero-0.0.1.md') -Destination $stage
# Runtime DLLs are NOT enumerated by hand. A hardcoded list is a snapshot of
# the import graph on the day it was written, and it silently omits anything a
@@ -92,7 +100,7 @@ if (Test-Path -LiteralPath $kb) {
# fails the release rather than shipping. Builds that link the MinGW runtime
# statically (SNESRECOMP_STATIC_RUNTIME, the default) correctly ship no
# libgcc/libstdc++/libwinpthread at all.
-. (Join-Path $root 'snesrecomp\tools\release\RuntimeDllClosure.ps1')
+. (Join-Path $FrameworkDir 'tools\release\RuntimeDllClosure.ps1')
$sdlDll = "$SdlBackend.dll"
$sdlSource = Join-Path $build $sdlDll
From a07dcb44fb3352b49c4068d2867095076609fa72 Mon Sep 17 00:00:00 2001
From: Matt Stanley <5354790+mstan@users.noreply.github.com>
Date: Tue, 29 Sep 2026 09:42:56 -0700
Subject: [PATCH 16/16] docs: record packaged Zero cold-boot validation
---
docs/zero-port.md | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/docs/zero-port.md b/docs/zero-port.md
index b7e5101..753d11b 100644
--- a/docs/zero-port.md
+++ b/docs/zero-port.md
@@ -325,4 +325,8 @@ legacy saves, rollback, rewind and menu fades. The first-tier capture was
visually inspected. Native extraction matches the reference byte-for-byte;
header normalization and invalid-ROM cache preservation pass. The ZIP is
audited for absence of ROMs, extracted caches, save data, local mod state and
-weapon packages; its DLL dependency closure is validated.
+weapon packages; its DLL dependency closure is validated.
+
+A fresh private install of the ZIP generated its 2,492,180-byte Zero cache
+from the selected X3 ROM and cold-booted for 180 frames (exit 0), with no save
+state or developer asset cache. This test used isolated local configuration.