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. +

Mega Man X experimental widescreen rendering in an ocean scene Mega Man X experimental widescreen rendering on the opening highway stage 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('