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/.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..2e0525d 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 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})
@@ -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,13 @@ 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)
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 +118,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()
@@ -138,6 +149,10 @@ 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")
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/README.md b/README.md
index d88cb92..9555ba5 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,13 @@ 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.
+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..753d11b
--- /dev/null
+++ b/docs/zero-port.md
@@ -0,0 +1,332 @@
+# 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.
+
+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.
diff --git a/docs/zero-spike.md b/docs/zero-spike.md
new file mode 100644
index 0000000..62182db
--- /dev/null
+++ b/docs/zero-spike.md
@@ -0,0 +1,268 @@
+# 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
+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, the ground/air
+saber bounds, and Zero's original X3 health-bar badge and palette. The current
+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, 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.
+
+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, 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.
+
+## 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
+
+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 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
+ 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;
+ 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.
+ 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 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
+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
+`$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`.
+
+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`.
+
+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
+ 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 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.
+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/docs/zero-weapons-coop-roadmap.md b/docs/zero-weapons-coop-roadmap.md
new file mode 100644
index 0000000..d367d27
--- /dev/null
+++ b/docs/zero-weapons-coop-roadmap.md
@@ -0,0 +1,143 @@
+# 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).
+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
+
+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`.
+
+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
+ 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.
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/snesrecomp b/snesrecomp
index 8867499..8566fdb 160000
--- a/snesrecomp
+++ b/snesrecomp
@@ -1 +1 @@
-Subproject commit 8867499d28543e74cd31134abae9468a88675847
+Subproject commit 8566fdb02b5e452d1700677c581ae83e3349e8d9
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_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 0fd2ffd..cceab80 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;
@@ -69,6 +71,26 @@ 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_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)));
+}
+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_zero.active_x && 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) ||
+ (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;
size_t offset = ((address >> 16) & 0x7f) * 0x8000 + (address & 0x7fff);
@@ -78,6 +100,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;
@@ -169,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. */
@@ -192,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]) {
@@ -207,9 +233,20 @@ 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));
+ 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. */
+ 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));
@@ -246,11 +283,47 @@ 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();
+ 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()) {
+ /* 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;
@@ -273,19 +346,37 @@ 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, 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;
}
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) && 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) + 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) + 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 :
+ 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 &&
+ 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;
}
@@ -487,7 +578,7 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x,
if (tile & 0x8000) cy = size - 1 - cy;
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) {
@@ -498,7 +589,7 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x,
} else pixel = tile_pixel(r->vram, address, cx & 7, cy & 7, bpp);
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];
@@ -508,7 +599,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, 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;
@@ -529,9 +620,13 @@ 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;
+ if (hud_color >= 0) pixel = 1;
+ else if (hud_color == -2) pixel = 0;
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;
}
}
@@ -633,7 +728,10 @@ 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 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;
@@ -641,6 +739,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 || 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 ?
fortress_waiting_pieces(waiting, pieces, piece_count) : 0;
@@ -665,7 +765,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 && !zero_title)) {
memcpy(out + y * view.width + view.extra, frame.stock + y * 256, 256 * sizeof(*out));
++stats.fallback_lines; continue;
}
@@ -687,9 +787,10 @@ 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[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) &&
@@ -698,10 +799,19 @@ 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 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;
+ 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_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) {
+ oam_match = true;
replaced[slot] = true; center = true;
}
}
@@ -712,7 +822,49 @@ 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);
- sprite(&p, r, s.x, s.y, attr, s.size, y, view, objects, !center, asset, s.tile, object_colors, true);
+ 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_body) {
+ 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 = 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));
+ /* 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;
if (hud) for (int slot = 16; slot <= 48; ++slot) {
@@ -732,12 +884,42 @@ 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;
- bool anchored = hud && sy < 96 && (slot < 16 || (bar_count >= 4 && slot >= bar_first && slot < bar_first + bar_count));
+ /* 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() && !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);
+ 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; }
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 cff2892..da0cd39 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 8u /* Separate X/Zero health pools. */
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,19 @@ 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) {
+ 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 :
+ 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;
+ 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)
fprintf(stderr, "[mmx_state] load: bad game chunk (magic=%08x ver=%u)\n",
@@ -431,6 +451,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
@@ -838,6 +859,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;
@@ -885,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_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/mmx_zero.c b/src/mmx_zero.c
new file mode 100644
index 0000000..9f0ea6f
--- /dev/null
+++ b/src/mmx_zero.c
@@ -0,0 +1,556 @@
+#include "mmx_zero.h"
+#include
+#include
+#include
+
+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 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");
+_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_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; }
+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.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) &&
+ 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; }
+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;
+}
+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, "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(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)
+ 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) 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);
+ return true;
+}
+const uint16_t *MmxZeroColors(void) { return colors; }
+const uint8_t *MmxZeroMenuPose(void) { return poses; }
+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;
+}
+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. */
+ 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
+ };
+ 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;
+ 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);
+}
+unsigned MmxZeroMuzzle(const uint8_t r[0x20000], unsigned object,
+ unsigned native_index, unsigned axis, unsigned original) {
+ 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;
+ 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;
+ 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;
+}
+unsigned MmxZeroWeaponOrigin(const uint8_t r[0x20000], unsigned object,
+ unsigned axis, unsigned original) {
+ 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.
+ * 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};
+ static const uint8_t pose[] = {0,1,2,2,3,4,5,6,4,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];
+ }
+ return 0;
+}
+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 || (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;
+ 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;
+}
+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, 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 (!MmxZeroActive()) 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;
+}
+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. */
+ memset(&state, 0, MMX_ZERO_LEGACY_STATE_SIZE);
+ memset((uint8_t *)&state + MMX_ZERO_ANIMATION_STATE_SIZE, 0,
+ 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)
+ 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);
+}
+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;
+ 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. */
+ 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;
+ if (state.hp_valid) r[0xbcf] = state.hp[state.active_x] | 128;
+ 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);
+}
+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;
+ 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;
+}
+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 (!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] &&
+ (action <= 8 || action == 0x10 || action == 0x12 || action == 0x14 || action == 0x20);
+ if (!playable) { MmxZeroCancel(r); return; }
+ 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 > 44) {
+ MmxZeroCancel(r);
+ if (r[0xbd3] & 4) r[0xbab] = 0;
+ return;
+ }
+ } else if (!state.burst && state.combo && pressed) {
+ if (state.combo == 1) {
+ if (free_projectile(r) && !r[0x1f0d]) {
+ state.combo = state.saber_ready ? 2 : 0;
+ start_burst(r,2);
+ }
+ } else if (state.saber_ready && !state.shot_mask && !state.cooldown && !r[0xc25]) {
+ unsigned d = free_projectile(r);
+ if (d) {
+ 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; r[d + 0x11] = r[0xbb9];
+ putword(r + d + 0x3e, 0x5a53); ++r[0xbdd];
+ }
+ }
+ } 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) {
+ /* 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;
+ }
+ }
+ 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;
+ 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;
+ }
+ }
+}
+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) {
+ 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..10d826c
--- /dev/null
+++ b/src/mmx_zero.h
@@ -0,0 +1,60 @@
+#pragma once
+#include
+#include
+#include
+
+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_SWAP_STATE_SIZE = 36 };
+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;
+ /* 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);
+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);
+/* 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 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,
+ 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);
+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..64050fa
--- /dev/null
+++ b/src/mods/mmx_zero_plugin.c
@@ -0,0 +1,117 @@
+#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 "mmx_source_assets.h"
+#include "sdl_compat.h"
+#include
+
+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;
+ 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: {
+ 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:
+ 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;
+ 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[] = {0x009dca, 0x00d6a7, 0x00d76a, 0x01971f, 0x019796, 0x0198ff,
+ 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};
+ 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]) 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] Zero 0.0.1 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/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/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/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 98bb0ba..24db2f6 100644
--- a/tests/mmx_adaptive_state_test.c
+++ b/tests/mmx_adaptive_state_test.c
@@ -26,6 +26,465 @@ 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 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_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;
+ 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 >= 139 && y < 163) ++icon;
+ else { fprintf(stderr,"Unexpected Zero menu change at %d,%d\n",x,y); exit(1); }
+ }
+ 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;
+ 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) {
+ 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);
+ if (!weapon) zero_capture(capture,".charge.cap");
+ }
+ unsigned seen = 0;
+ 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];
+ 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); }
+ 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");
+ 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_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_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_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_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");
+ MmxZeroRegisterHooks();
+ check(RtlLoadSnapshot(fixture), "Zero gameplay fixture loads");
+ MmxZeroCancel(g_ram);
+ check(g_ram[0xba9] == 2 && g_ram[0xbaa] == 0 && g_ram[0xbdb] == 0 &&
+ !g_ram[0x1f99] && (g_ram[0xbd3] & 4), "fixture is standing, unupgraded buster");
+ 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_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);
+ 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);
+ 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");
+ }
+ 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");
+ 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);
+ 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");
+ 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,.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 &&
+ (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");
+ 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();
+ 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");
+ puts("MMX ZERO RUNTIME CHECKS PASSED");
+}
int main(int argc, char **argv) {
check(argc == 2 || argc == 3, "ROM supplied");
SDL_SetMainReady();
@@ -111,6 +570,33 @@ 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");
+ 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;
+ }
if (argc == 3) {
check(RtlLoadSnapshot("saves/save11.sav"), "file loads in new process");
replay(10);
@@ -158,7 +644,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_renderer_test.c b/tests/mmx_renderer_test.c
index 98b844a..976a1f4 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,61 @@ 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','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[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 &&
+ 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);
+ 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");
+ 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. */
+ }
+ 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);
+ /* 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;
+ MmxRendererReset();
+}
static void weapons_menu_margins(void) {
memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram));
MmxRendererReset(); MmxRendererSetRom(NULL, 0);
@@ -1126,4 +1183,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; }
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/tests/mmx_zero_test.c b/tests/mmx_zero_test.c
new file mode 100644
index 0000000..d497b3a
--- /dev/null
+++ b/tests/mmx_zero_test.c
@@ -0,0 +1,159 @@
+#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','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);
+ 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);
+ 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;
+ 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));
+}
+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. */
+ 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);
+ 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);
+ 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();
+ 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?2: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);
+ 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[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. */
+ 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 == 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);
+ 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);
+ 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);
+ 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[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);
+ 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");
+ 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..ecc8945
--- /dev/null
+++ b/tools/apply_zero_hooks.py
@@ -0,0 +1,93 @@
+#!/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}
+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() | {0x009da6, 0x81815c, 0x818165, 0x00d3e5, 0x849e73, 0x849c16, 0x848f07, 0x848eea}
+
+
+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)
+ 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 == 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:
+ 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')
+ 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:
+ 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 == 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:
+ 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..17a7bb2
--- /dev/null
+++ b/tools/extract_zero.py
@@ -0,0 +1,154 @@
+#!/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
+ # 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)
+ # 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)
+ # 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():
+ 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/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
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('