diff --git a/CMakeLists.txt b/CMakeLists.txt index 2e0525d..a9af453 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,6 +2,11 @@ cmake_minimum_required(VERSION 3.20) project(MegaManXSNESRecomp VERSION 1.6.6 LANGUAGES C CXX) set(CMAKE_C_STANDARD 11) find_package(Python3 COMPONENTS Interpreter REQUIRED) +add_custom_command(OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/mmx_weapon_sources.h" + COMMAND ${Python3_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/tools/generate_x_weapon_tables.py" + "${CMAKE_CURRENT_SOURCE_DIR}/tools/data/x_weapon_assets.json" + "${CMAKE_CURRENT_BINARY_DIR}/mmx_weapon_sources.h" + DEPENDS tools/generate_x_weapon_tables.py tools/data/x_weapon_assets.json VERBATIM) set(SNESRECOMP_BUILD_VERSION "${PROJECT_VERSION}" CACHE STRING "Release stamp for crash reports") set(SNESRECOMP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/snesrecomp" CACHE PATH "snesrecomp checkout") @@ -36,7 +41,9 @@ function(add_mmx_variant TARGET GEN_DIR CFG_DIR) src/mmx_spc_player.c src/mods/mmx_diagnostics_plugin.c src/mods/mmx_widescreen_plugin.c src/mods/mmx_msu1_plugin.c src/mods/mmx_password_plugin.c src/mmx_password_save.cpp - src/mods/mmx_zero_plugin.c src/mmx_zero.c src/mmx_source_assets.cpp) + src/mods/mmx_zero_plugin.c src/mmx_zero.c src/mmx_weapons.c src/mmx_weapon_combat.c + src/mmx_source_assets.cpp "${CMAKE_CURRENT_BINARY_DIR}/mmx_weapon_sources.h") + target_compile_features(${TARGET} PRIVATE cxx_std_17) target_include_directories(${TARGET} PRIVATE ${CFG_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/src ${CMAKE_CURRENT_BINARY_DIR} ${SNESRECOMP_RUNNER_INCLUDE_DIRS}) @@ -71,7 +78,8 @@ function(add_mmx_variant TARGET GEN_DIR CFG_DIR) ${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_zero_hooks.py) + ${CMAKE_CURRENT_SOURCE_DIR}/tools/apply_zero_hooks.py + ${CMAKE_CURRENT_SOURCE_DIR}/tools/apply_weapon_hooks.py) add_custom_target(${TARGET}_widescreen_overrides COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/apply_overrides.py --gen-dir ${GEN_DIR} @@ -81,6 +89,8 @@ function(add_mmx_variant TARGET GEN_DIR CFG_DIR) --gen-dir ${GEN_DIR} COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/apply_zero_hooks.py --gen-dir ${GEN_DIR} + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/apply_weapon_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) @@ -102,8 +112,8 @@ 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) + ${SNESRECOMP_ROOT}/runner/src/sha256.c "${CMAKE_CURRENT_BINARY_DIR}/mmx_weapon_sources.h") + target_include_directories(mmx_source_assets_test PRIVATE src ${CMAKE_CURRENT_BINARY_DIR} ${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) @@ -118,10 +128,10 @@ 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 src/mmx_zero.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 src/mmx_weapons.c src/mmx_weapon_combat.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 src/mmx_zero.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 src/mmx_weapons.c src/mmx_weapon_combat.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 mmx_zero_test) if(MSVC) diff --git a/README.md b/README.md index 9555ba5..fda85d3 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,8 @@ 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. +[technical handoff](docs/zero-port.md). This branch contains the [X2/X3 weapon follow-up](docs/x-weapons-port.md), +with thirteen weapons implemented and three still using the development fallback.

Mega Man X experimental widescreen rendering in an ocean scene diff --git a/docs/mod-source-roms.md b/docs/mod-source-roms.md index ad29be4..bb5b962 100644 --- a/docs/mod-source-roms.md +++ b/docs/mod-source-roms.md @@ -1,46 +1,105 @@ -# 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). +# Public mod: user-supplied source ROMs + +Owner requirement, recorded 2026-09-29. Applies to the Zero/Select mod, the +X2/X3 weapon expansion, and the later mutually exclusive co-op mod. Scope: +[roadmap](zero-weapons-coop-roadmap.md). Technical findings: +[source notebook](x-weapons-source-notes.md). +Zero implementation/provenance: [Zero port handoff](zero-port.md). + +## Distribution contract + +The user supplies their own X1, X2 and X3 ROMs for the complete mod. A public +download must not contain ROMs or extracted game assets. This covers **all** +imported content, including Zero's body animations, saber, teleport effects, +HUD badge, menu/title poses, weapon projectiles, icons, palettes, animation +records, and any future imported audio or effects. These are not exceptions +because they are small or embedded in another file. + +| Enabled content | Required user-supplied source | +| --- | --- | +| Base X1 game and its native assets | Supported X1 ROM | +| X3 Zero, character exchange and X3-derived visuals | Supported X3 ROM, in addition to X1 | +| X2 weapons alone | Supported X2 ROM, in addition to X1 | +| X3 weapons alone | Supported X3 ROM, in addition to X1 | +| Complete X2/X3 boss-weapon expansion | Supported X2 and X3 ROMs, in addition to X1 | +| Later X/Zero co-op with the completed weapon set | The same X1/X2/X3 sources | + +Extract needed data locally from the supplied ROMs. Ship the mod's code, +extractors, address descriptors and documentation. Keep generated caches on +the user's machine; do not bundle a developer's caches or download them as a +substitute for supplying the source ROMs. No ROM-finding/downloading service +is part of setup. + +## Launcher setup and automatic extraction + +The launcher exposes three independent options: **X3 Zero Experiment**, +**X2 Weapons (experimental)** and **X3 Weapons (experimental)**. Users select +original USA `.sfc`/`.smc` ROMs, never extracted `.bin` files. X2/X3 weapon packs +also work with X alone when Zero is disabled. Pause L/R skips disabled packs. + +Zero and X3 weapons both bind `shared_key = "megaman-x.source.x3"`; choosing, +replacing or clearing either picker changes the same canonical X3 path and +immediately appears on the other. X2 uses `megaman-x.source.x2`. The shared +runtime persists these values as `[[shared_resource]]` entries in mod state. +An enabled feature with a missing or incorrect ROM cannot launch. Original +ROM SHA-256 is checked after stripping an optional 512-byte copier header: + +- X2: `f3246755f608a1e1dc9c848b61da3b824c7853b29b3be40df6fc7f2793a887ed` +- X3: `65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7` + +`src/mmx_source_assets.cpp` performs native extraction at activation. There is +no Python/runtime tool installation or user extraction step. It validates the +ROM again and atomically writes private caches in `cache/mmx-source/` beside +the executable: `x3-zero-v7.bin`, `x2-weapons-v5.bin`, `x3-weapons-v5.bin`. +Each launch regenerates from the selected ROM, so stale/developer caches are +never an implicit fallback. Native extraction matches the reference tools +byte for byte. Copier headers produce identical output; wrong ROMs are rejected. + +The weapon descriptor compiles into address-only C++ tables during the normal +developer build. Zero uses the same verified source addresses as the reference +extractor. Separate eight-entry weapon caches use `MMXWEAP5`; the loader also +accepts the combined sixteen-entry development cache in that format. Version 4 +added each source game's dedicated 16x16 gameplay HUD footer, separate from its +pause-menu icon. Version 5 adds a palette per sprite group and up to three +groups, allowing original shared explosion art with its own source palette. +Compressed static graphics are decoded from the selected ROM using address-only +descriptors. Old weapon caches are regenerated from the selected ROMs. +Source pack selection and this artwork change do not alter gameplay saves. + +Shared framework support originated in `795fc99` and is integrated at +`8566fdb` (framework PR #131), the same tested pin used by Zero 0.0.1 +(central issue `beads-8wg.2.90`); game integration is tracked in `beads-8wg.1.35`. +The framework provider test covers both sharing directions, independent keys, +persistence, clearing, copier headers and incorrect-ROM launch rejection. +ROM-backed game checks cover independent packs without Zero, pause page +skipping, native-width composition, firing, exact save/replay and loading both +packs without discarding the first pack's art. +The desktop fresh-boot check activated Zero and both weapon packs using only +the selected source paths and generated all three private caches. No save state +was loaded. `tests/check_source_extraction.py` reproduces byte parity, header +normalization and wrong-ROM rejection without overwriting a valid cache. + +## Developer reference tools + +- `tools/extract_zero.py` produces the local `zero-x3.bin` cache from X3. +- `tools/extract_x_weapons.py` produces the local `x-weapons.bin` cache from X2 + and X3 using `tools/data/x_weapon_assets.json`. It checks normalized source + hashes and accepts a 512-byte copier header. +- These remain reference/debugger tools. Launcher activation requires source + ROMs and generates its own caches; it does not use those developer files. +- Addresses, extraction logic and behavioral findings belong in source + control; ROMs, decoded graphics, asset caches, private save states and + research captures do not belong in public release packages. + +## Release boundary and remaining weapon work + +Zero 0.0.1 has shipped separately in PR #52 with the native ROM picker and +private extraction. Its package audit stages only the tracked mod catalog, +rejects private state/cache/ROM files, validates DLL closure, and passed a fresh +cold boot from a ZIP install. The weapons branch retains that packaging path. + +The X2/X3 weapons remain development content: four combat implementations and +twelve fallbacks. Finish their behavior/fidelity and audit every added resource +before publishing a weapon release. Future sound imports must also come from +the user's ROMs. Recheck the final weapon distributable from a clean install, +including both independent packs and their shared X3 selection with Zero. diff --git a/docs/weapons/crystal-hunter.md b/docs/weapons/crystal-hunter.md new file mode 100644 index 0000000..9e6ac77 --- /dev/null +++ b/docs/weapons/crystal-hunter.md @@ -0,0 +1,100 @@ +# Crystal Hunter port and source notes + +Implemented under central Beads `beads-8wg.1.45` / weapon epic `.32`. +Original USA X2 ROM is required, with no game assets committed. X is the +original reference; Zero uses adapted X1 firing poses and muzzle placement. + +## Normal projectile and crystallization + +Class `$07` dispatch `$81:8D1C`, initializer `$8D2D`; group `$10`, sequence 1, +DMA `$85:9D28`, box `$86:B694` = (0,0,8,8). Speeds `$86:B6A6` give horizontal +`$0340` (3.25 pixels/frame), zero initial vertical velocity, gravity `$10`. +Water uses the second record: horizontal `$0300`, vertical `$0140` upward. +`$8DBC..8DC6` clamps downward velocity by its high byte to `$FB`. + +Native collision `$88:DAA9` writes the target enemy address to projectile +`+$38`. A surviving target enters projectile state 8 (`$81:8DD4`), which +copies the target position, places its enemy actor in source freeze state 6, +and forms the crystal using sequence 2. Sequence 3 is the completed block; +gravity becomes `$40`, with a half-speed upward bounce on first floor contact. +`$81:8FB1` shatters it when the player's horizontal speed reaches 3 pixels +and their boxes overlap. `$81:8F14` creates eight original crystal fragments +and invokes the source enemy/item cleanup helpers. Offscreen cleanup also +disposes of the frozen target. Boss/armored rows generally reflect Crystal +Hunter; ordinary susceptible row `$86:F40A` gives damage 1 versus buster 3. +The generic neutral row `$86:F4C8` is immune to this special-response weapon +and must not be mistaken for an ordinary numeric damage amount. + +## Charged time distortion + +Charged class `$10` dispatches at `$81:9CCF`. It is an invisible controller: +the stale group/pose in a RAM trace does **not** represent a frozen enemy or +a projectile to draw. This corrects the ambiguity in the early reference log. + +`$9CFD..9D4D` locks the player and object groups, initializes vertical-scroll +HDMA buffers `$7F:C640/$7F:C840`, and sets a 180-frame distortion timer. +`$9D4F..9E9D` alternates those buffers, propagating paired scanline offsets +outward from the middle and changing the amplitude every sixteen frames. +The source displacement directory is `$86:B7ED`. + +At distortion end `$9E9F..9ECE`, normal player/object processing resumes and +`$1F32=$80`, `$1F33=1` enable half-speed game simulation. `$80:98D6..98EB` +(and `$80:8F75..8F8F`) skip every other stage update, including the player. +`$81:9ED1` runs this for 240 simulation updates: approximately eight real +seconds. `$9EEB` clears the effect and releases its firing lock. + +Costs from `$86:AF28/$AF3E` are one unit normal plus one extra for charged +release, i.e. two units for the charged effect. A private paused X2 runtime +confirmed the 180-frame onset and the subsequent timer decrementing once +per two displayed frames. It uses an isolated copied reference fixture; +the owner's game/save state is never loaded by these checks. + +## X1 implementation and validation + +`src/mmx_weapon_crystal.inc` implements the source projectile, one flying +shot/three frozen targets, formation, fall/bounce, solid top, dash shatter, +and charged time control. Eligible ordinary X1 enemies use damage categories +0..5; bosses and protected actors keep their native response. Their AI is +suspended through the enemy-loop hook while the crystal owns their position. +The player can jump onto a crystal or dash through it at three pixels/tick. +Shattering re-enters X1's native lethal-hit path for death effects and drops. +The dying host's contact damage is cleared, including after the crystal +controller retires; otherwise X1 can hurt the player from its death actor. +Offscreen/cancellation cleanup consumes the frozen host instead of restoring AI. + +Original fragment class `$08`, subtypes `$A5/$A6`, dispatches at `$81:F068`. +Descriptors `$86:EBCC/$EBD0` select group `$10`, sequences 4/5, poses 14/15. +Eight fragments use velocity tables `$86:EE38/$EE48`, gravity `$30`, alternating +slot/frame visibility, and offscreen retirement. They occupy the existing +saved cosmetic pool, leaving native attack slots available. Source RNG +arithmetic is seeded from saved port state because X1 consumes RNG differently. + +Charged onset freezes the game for 180 displayed frames, then runs the whole +simulation, including the player, every other frame for 480 displayed frames. +Pause does not consume the effect. Charging requires X1's arm upgrade. A +179-frame original-runtime HDMA capture matched the translated ripple on rows +0..222 throughout; the port uses the continuous displacement on the last row +instead of X2's byte-copy wraparound artifact there. It affects the original +BG1 vertical scroll, leaving sprite/HUD positions alone. + +Focused native-runtime checks passed for both characters: source speed/arc, +energy and arm gating, frozen onset, half-speed motion, expiration, and +save/replay across onset/end. A real enemy encounter checked suspended AI, +formation, standing, native shatter death without player damage, eight original +shards, and debris/death replay. Crystal, debris and distortion captures were +visually inspected. Original SPC sound effects remain part of the shared +audio follow-up; no substitute sound is presented as X2 audio. + +### Playtest correction: frozen target visibility + +The first implementation suppressed the frozen actor's normal submission but +only drew the crystal shell. The renderer now reconstructs the held enemy pose +at the crystal's position, using its original X1 sprite resource and palette, +before drawing the X2 shell. This keeps AI/animation frozen while the visible +enemy falls and bounces with its prison. Native collision/shatter checks and +save/replay passed again; the rendered highway target is visible inside the +crystal. Tracked in `beads-8wg.1.48`. + +The charged effect intentionally slows X/Zero too: the original X2 frame-skip +sites above skip player processing as well as enemies. It is not an accidental +side effect of the port. diff --git a/docs/weapons/gravity-well.md b/docs/weapons/gravity-well.md new file mode 100644 index 0000000..48ec6e1 --- /dev/null +++ b/docs/weapons/gravity-well.md @@ -0,0 +1,89 @@ +# Gravity Well port and source notes + +Implemented on `feat/x2-x3-weapons`, tracking `beads-8wg.1.43`. Initial source +research is also preserved privately in `_wt_mmx_weapon_silk`; implementation +continued solo in `_wt_mmx_zero`. + +Reference attacks are X's original X3 weapon ID 6. Zero will use adapted +original Zero body poses, with source projectile art and mechanics. User ROM +extraction remains mandatory. + +Verified source findings: + +- Normal projectile class `$0C` / group `$0F` starts at 4 pixels per update, + decelerates by `$20`, forms a stationary well, then retracts toward X. + Original well animation and phase traces are saved privately. +- Charged class `$15` uses X body group `$33`, sequence 35 (decimal), with an + upward cast. The field lasts 300 simulation updates after the cast/orb + ascent; the source keeps X in his casting action during this effect. +- These weapons' damage-table entries are **utility response IDs**, not HP + damage: 0 immune, normal 1 pull/dissolve, 2 shake/dissolve, 3 shake-only; + charged 4 lift, 3 shake-only, 5 special. `$82:DB3F..DDD2` implements the + responses. Do not feed these bytes into the ordinary numeric damage adapter. + +Private artifacts in that worktree's `_research` include `gravity_probe.py`, +`gravity-probe.json`, `gravity-probe-summary.txt`, and bounded player, +normal/charged, pull and victim/lift assembly notes. The private X3 source +process uses port 4397 and an isolated copy of fixture 6. + +## Implemented behavior + +Normal flight decelerates from four pixels/tick by `$20` to one pixel/tick, +forms after travelling about 64 pixels, holds for 180 ticks, collapses and +homes back at four pixels/tick. Group `$0F` sequences 0..3 supply every phase. +The original return/pull direction vectors at `$86:E18E/$E10E` are retained; +the adapter selects the closest of the 32 directions with integer arithmetic. + +The charged orb waits for the original cast's `$40` flag, rises with `$C0` +upward acceleration, then runs a 300-tick field. X remains in the `$20`-flag +hold pose until the field ends, then completes recovery. Original group `$33` +sequences 35/36 supply ground/air casting; the cache aliases the air sequence +as group 52. The original upward particles use group `$0F`, sequences 5..8, +16 pixels/tick and one spawn per four ticks, alternating screen halves. + +X3 `$84:A55A` and tables `$86:B352/$B368` charge **one energy for normal and +one for the charged effect** (the initial normal shot during the hold is a +separate one-unit debit). A private original-game run measured `$5C00` to +`$5A00` for that complete hold/release. X1 arms remain required; X3 armor's +energy discounts are outside this boss-weapon port. + +## X1 enemy and Zero adaptations + +Only visible, live ordinary actors in damage categories 0..5 with nonzero +native body bounds are eligible. Actors with half-width at most 16 and +half-height at most 24 are movable: normal pulls them slowly for 30 ticks, +then accelerates/flashes for 30 ticks before dissolving; charged accelerates +them upward until they leave the screen. Larger actors up to 32x32 half-bounds +shake without damage. Larger/protected/boss actors are excluded. This is an +explicit conservative X1 mapping, not a claim that X3 has response data for +X1's enemies. Enemy projectile conversion and X3-specific stage triggers are +not applied to unrelated X1 actors. + +Victims retain their original native sprite arrangement/resource/palette; +the renderer rebuilds those pieces while the shared enemy hook suspends AI. +Only their saved position changes. Dissolve uses X1's ordinary state-4 death +handler and clears contact damage. Death is tested as native state 4 or slot +retirement, because death animations can reuse the HP byte. Cancellation +releases surviving victims to their own AI. Boss weakness tables are unchanged. + +Zero uses his original X3 group `$4A` raised-arm body frames `$43..47` timed +to X's cast, including the held pose. This is an adaptation: X3 Zero never +performed this weapon. X uses original base-X art; X1 armor overlays are +hidden during the cast, as with the Triad punch. Original source sound import +remains shared fidelity work. The intermediate breakaway pieces created by +`$82:DC4D/$D96D` use X3 enemy-specific part indices from `$86:A69F`; those +indices do not describe X1 enemies. The adapter retains X1's native death +debris and drops rather than assigning unrelated X3 enemy parts. + +The existing combat snapshot holds well/body actors, particles and enemy +controllers; its size/ABI is unchanged. ROM-derived art remains local. The +address-only manifest now extracts 658 combined weapon poses (483,877 bytes). +Native and Python extraction match byte for byte, including headered ROM and +wrong-ROM cache-preservation checks. + +Focused checks pass for both characters: normal deceleration/formation/return, +costs, arms, original ground/air cast, upward particles, locked field and +movement recovery, and save/replay. Real enemy tests cover pull with AI held, +native dissolve, charged acceleration/offscreen death, protected-category +exclusion, and replay of both enemy paths. Original X3 and port orb/field/body +captures were visually reviewed. diff --git a/docs/weapons/parasitic-bomb.md b/docs/weapons/parasitic-bomb.md new file mode 100644 index 0000000..4722014 --- /dev/null +++ b/docs/weapons/parasitic-bomb.md @@ -0,0 +1,89 @@ +# Parasitic Bomb + +Implemented for X and adapted Zero on `feat/x2-x3-weapons`. +Tracking: central Beads `beads-8wg.1.42`, parent `beads-8wg.1.32`. +Reference: X using native X3 weapon ID 2 in the original USA ROM. +Zero using boss weapons here is an adaptation, not native X3 functionality. + +## Source record + +| Source address | Finding | +| --- | --- | +| `$81:96F0..97A7` | Normal class `$08`, 3 px/update; reflection reverses X and gives upward Y velocity -3 | +| `$81:97AB..99FA` | Captured-enemy class `$1E`, original host art, formation and homing/stationary/rolling responses | +| `$81:982A / 98C2 / 98E7` | Homing life 180 ticks; stationary/rolling life 240; rolling gravity 32/256 | +| `$84:D00E` | Special enemy response captures instead of subtracting ordinary HP | +| `$81:AC82..AF37` | Charged class `$11`: four cursors, lock, miniunit launch, homing, impact and deflection | +| `$86:BA62` | Cursor offsets (-16,-20), (-16,20), (16,-20), (16,20) | +| `$81:AD5B / ADB5 / ADC6` | Rotating cursor search, 4 px travel, snap when either axis is within 5 px | +| `$81:ADFE / AE1C / AE33` | 16-tick lock; one unit of energy spent at miniunit launch | +| `$81:AE42..AE86` | Wait for unit completion, then 30-tick cooldown; release ends the cursor cycle | +| `$81:AD07 / AE9A..AEFC` | Unit starts at 3 px, decelerates 16/256 for 10 ticks, then homes at 4 px every fourth tick | +| `$81:AEB5..AEE5` | Unit attack box and 120-tick lifetime activate near its locked cursor | +| `$81:AF0D` | Deflected unit uses pose 39, vertical flip, zero X velocity and gravity 32/256 | +| `$84:D757` | Unit contact effect: original group `$17`, sequence 1, poses 0..2 | +| `$84:D6E0 / D6AF` | Ordinary impact/timeout effect: group `$08`, sequence 5 | +| `$86:B925 / B92F / BA5E` | Shot (0,0,13,12), payload (0,0,16,16), unit (1,-2,8,9) attack boxes | +| `$86:E4A5` | Enemy-response matrix; ordinary buster 3, shot 2, unit 5, payload 15 | + +Normal formation uses group `$06`, sequence 1 (30 ticks), then sequence 2. +Cursors use sequences 8/10, miniunits sequence 12, normal projectiles sequence 0. +All art and animation records come from the user's X3 ROM. Shared effect +graphics come from compressed resource `$0A` loaded at OBJ `$6800`. Runtime +palette comparison identifies the small unit impact at ROM `$630E0`; the +larger puff uses the reference runtime's gray palette at `$66948`. + +Private research includes `_research/parasitic-source-check.json`, +`parasitic-normal-impact.json`, source screenshots and the earlier bounded +assembly/traces in `_wt_mmx_weapon_fang/_research`. Source runtime port 4385 +used private fixture 6 with `$0A8E=0` (X), never an owner playtest save. +Do not distribute these captures or extracted assets with the public mod. + +## X1 behavior and deliberate adaptations + +Normal shots cost one energy. Eligible ordinary-enemy hits retain the host's +native sprite/pose and suspend its AI and body damage. After formation it +becomes a moving or stationary payload. Timeout, collision or cancellation +enters X1's native death handling and clears contact damage. Saved host slot +and type are checked before moving or retiring an enemy. + +X1 has no X3 per-enemy response table. Ordinary categories 0..5 with nonzero +boxes up to 24x32 half-extents can be captured. Larger actors (over 16x24) stay +stationary; small grounded actors roll; airborne actors home. Bosses/protected +categories remain immune to capture/targeting. Source angular motion tables +are retained, with integer nearest-direction and nearest eligible X1 target +selection. These are explicit cross-game adapters, not native X3 response types. + +Full charge requires X1's arm upgrade. Four cursors appear while fire is held; +searching is free and each launched unit costs one energy. Cursors reserve +separate targets. Releasing fire retires idle cursors; active locks/units finish +their cycle. Searching follows the player, eight pixels higher for Zero, and +units launch at the active character's muzzle. The port shares X1's eight +attack slots, permits up to three ordinary shots/payloads, and waits for a +free slot before spending energy on another unit. + +Ordinary damage preserves source-to-buster ratios: noncapturing shot 2/3, +unit 5/3, payload 15/3. Fractional carry uses the shared saved per-enemy +accumulator. Capture itself removes no HP. Protected encounter rows keep +neutral X1 buster damage and native immunity, without imported boss weaknesses. + +## State, validation and remaining fidelity work + +No save/capture ABI growth: existing 40-byte attacks store host/target, +quadrant, phase, timers and animation. Unit contact bit 15 gates collision and +lifetime; bits 0..14 retain native enemy contact history. The shared moved-enemy +renderer reconstructs host art and suppresses its duplicate native draw. + +Focused ROM-runtime checks cover X/Zero motion/cost, arm gating, automatic +held-charge activation, four free cursors, movement/release and exact snapshot +replay. A real X1 enemy is captured without HP loss, keeps its AI suspended, +completes formation and enters native death on cleanup. A charged encounter +checks protected-target exclusion, one-energy launch, actual homing damage, +original shared impact, cursor recovery and replay. Captured host, payload, +search and impact renders were visually reviewed. Native/Python extraction +agrees byte-for-byte, including copier headers, wrong-ROM rejection and good +cache preservation. + +Original SPC sound effects remain a shared follow-up. X3's enemy-specific +responses and stage-dependent palettes cannot map identically onto X1; the +rules and reference palette above are intentional adaptations. diff --git a/docs/weapons/silk-shot.md b/docs/weapons/silk-shot.md new file mode 100644 index 0000000..3d35221 --- /dev/null +++ b/docs/weapons/silk-shot.md @@ -0,0 +1,149 @@ +# X2 Silk Shot backport + +Tracking: central Beads `beads-8wg.1.38`, under weapon-port issue +`beads-8wg.1.32`. Implementation branch: `feat/x2-silk-shot-port`. + +This implements native X2 weapon ID 3 for X and Zero: all five original material +forms, normal bounces and four-way fragments, leaf trails, and the eight-piece +charged gathering/held/release attack. It uses X1's arm-upgrade requirement, +player movement, muzzle placement, native enemy collision and damage immunity. +The implementation is in `src/mmx_weapon_silk.inc`; its small registration points +are in `mmx_weapon_combat.c`. + +## Source addresses and observed behavior + +Addresses below are SNES CPU addresses in the original USA X2 ROM. Instruction +decoding and private reference-runtime traces were used; the owner's playtest +was not touched or loaded from a state. + +| Source | Meaning | +| --- | --- | +| `$81:91C0..9267` | Normal initialization, material selection, velocity and palette | +| `$81:9289..92D0` | Normal bounce count, rebound loss, gravity and wall contact | +| `$81:92D1..9313` | Four normal fragments | +| `$81:931F..9382` | Leaf bundle launch, deceleration, upward curve and breakup | +| `$81:93DE..951F` | Normal fragment initialization and movement | +| `$81:952C..9564`, `$81:84E7..8538` | Leaf trail allocation, original animation and 60-tick life | +| `$88:C475..C4E7` | Start gathering while fully charged fire is still held | +| `$81:A136..A335` | Charged gathering, held formation, release and impact | +| `$81:A413..A501` | Release conditions, formation offsets and gather positions | +| `$86:B6DB`, `$86:B6F7` | Normal and fragment animation IDs per material | +| `$86:B6E9`, `$86:B705`, `$86:B735`, `$86:B73C` | Gravity, fragment velocities, bounce counts and rebound loss | +| `$86:B910`, `$86:B938`, `$86:B958`, `$86:B978`, `$86:B988` | Charged piece animations, gather offsets, radial velocities, formation offsets and flips | +| `$86:AF2E`, `$86:AF44` | Normal cost 1, charged cost 2 (8.8 values) | +| `$86:B74E`, `$86:B990` | Material-specific damage classes | +| `$86:F4C8` | Ordinary-enemy damage row, basic buster = 3 | + +Normal shots start at horizontal speed 2 pixels/tick and upward speed 3. They +use source material-specific gravity and bounce loss. A solid side or final +bounce scatters four fragments; enemy contact also scatters the main shot. +Fragments retain their original graphics and source velocities, with native +enemy collision until they leave the visible projectile bounds. + +| Material | Normal class | Charged class | Normal / charged raw damage | Gravity (8.8) | Ground contacts before breakup | Rebound loss (8.8) | +| --- | --- | --- | --- | --- | --- | --- | +| Stone | `$09` | `$12` | 15 / 30 | 96 | 2 | 384 | +| Metal balls | `$18` | `$1F` | 15 / 30 | 64 | 3 | 256 | +| Robot scrap | `$1B` | `$20` | 15 / 30 | 64 | 1 | 512 | +| Leaves | `$1C` | `$21` | 5 / 10 | 32 | Curved launch, then falling leaves | Special | +| Crystal | `$1E` | `$22` | 15 / 30 | 64 | 127 | 16 | + +Stone, metal and scrap fragments move at 4 pixels/tick on each axis; crystal +fragments move at 5. Leaves launch at 5 horizontally, decelerate by 64/256 each +tick and curve upward for 20 ticks. They then slow and scatter into four +original animated leaves falling at 1–2 pixels/tick for 60 ticks. The launch +sheds the original cosmetic leaf actor every four simulation ticks, also with +a 60-tick lifetime and alternating-frame visibility. RNG uses the existing +saved-tick deterministic source-arithmetic helper; it does not claim to share +X2's entire global RNG stream. + +Once X1 reaches full charge with the arm upgrade, eight pieces appear at the +original 256-pixel gather offsets. They converge at 8 pixels/tick for 32 ticks, +then follow the character's muzzle in the source formation. The player may move +while holding the cluster. Releasing fire launches all pieces together at +4 pixels/tick, upward speed 1, gravity 32/256 and downward cap 5. Each piece +spends 1/4 energy on release, totaling 2 for eight pieces. Gathering itself is +free; the initial press still fires the normal one-energy shot. Releasing early +or entering a source-equivalent hurt/death/teleport condition releases gathered +pieces. Native charge sound is stopped and the ordinary X1 release allocation +is suppressed so it cannot add an extra shot. + +Charged terrain/enemy impact scatters each piece along its original eight-way +velocity; leaves instead drift downward. A subsequent enemy contact removes +the dispersed piece. Already-hit enemies remain excluded for that piece during +its transition, preventing double counting one continuous contact. Ordinary +damage is the raw value divided by 3 and scaled by the target's X1 buster damage: +5 / 10 buster hits for solid materials, 5/3 / 10/3 for leaves. Existing shared +fractional carry preserves ratios. Boss/special-category neutral damage and +X1 immunity stay unchanged; no imported weaknesses are added. + +## Original assets and X1 material choice + +All gameplay poses 5–29 have explicit original DMA records at `$85:9FEF`. +The previous foundation note saying other forms required stage graphics was +incorrect. Only tiny unused poses 1–4 inherit stage tiles. All gameplay artwork +can be extracted directly from the user-provided X2 ROM. + +Original group `$48` contains: + +- Stone: poses 5–8; metal: 9–12; robot scrap: 13–18. +- Leaves: 19–25, using palette `$05:BAC0`. +- Crystal: 26–29, using palette `$05:BB00`. + +Stone, metal and scrap use `$05:BAE0`. Palette selection comes from +`$86:B6F0` through the palette records rooted at `$86:817A`; resources `$46`, +`$56`, `$C4` select those three palettes. The earlier cache mislabeled poses +19–29 as scrap and colored leaves/crystal with the stone palette. + +The descriptor now has three cached groups (72, 73, 74), with an optional +`source_group: 72` selecting the original layout and animation tables. Each +cached group keeps the correct palette and native pose numbers. Python and +native extraction agree byte-for-byte. The on-disk format remains `MMXWEAP5`; +with Tornado Fang integrated, the combined cache has 638 nonempty poses and +454,607 bytes. No ROM, +extracted pixels, palettes, capture, or reference screenshot is committed. +The launcher regenerates the local cache from the user's ROM automatically. + +X2 chooses material through authored stage events (`$1F1B`). X1 has no such +events, so this port deliberately maps environments: + +| X1 stage ID (`$1F7A`) | Environment | Material | +| --- | --- | --- | +| 0 | Highway | Metal | +| 1 | Launch Octopus | Stone | +| 2 | Sting Chameleon | Leaves | +| 3 | Armored Armadillo | Stone | +| 4 | Flame Mammoth | Robot scrap | +| 5 | Storm Eagle | Metal | +| 6 | Spark Mandrill | Robot scrap | +| 7 | Boomer Kuwanger | Metal | +| 8 | Chill Penguin | Crystal | +| 9 / 10 / 11 / 12 | Fortress stages | Stone / scrap / metal / crystal | + +This table is an X1 adaptation, not an original X2 stage mapping. X2's special +material values 5/6 produce health/energy in specifically authored hidden +rooms. X1 has no equivalent rooms; this port does not add arbitrary pickup +generators. Static X1 terrain uses the existing sweep adapter. Moving stage +actors do not automatically become new Silk collision surfaces. Original X2 +sound sample import remains outside this weapon-specific change; native X1 +firing/charge sound cleanup is retained. + +## State and validation + +The implementation uses the existing eight native projectile slots and sixteen +saved cosmetic slots. Charged pieces compete for the original finite slot pool; +unavailable slots are skipped. Cosmetic leaves do not consume damage slots. +No combat/save/capture ABI version changes are required. State validation +accepts only the material's correct group and bounded phase/piece identity. + +`MMX_WEAPON_SILK_TEST=1` adds focused ROM-backed checks to `mmx_state_tests`: +both characters' normal launch/cost/fire limit, real terrain bounces and +fragments, no-arm gating, held gathering, release speed/cost/audio cleanup, +terrain dispersion, deterministic normal and moving-held save/replay, original +leaf curves/trails/fall lifetime, first-full-tick release, and real X1 enemy +collision/damage. Releasing exactly as full charge becomes available also +launches the complete attack without requiring a prior gathering tick. Captures +of held and released clusters and leaf particles are available locally for +visual review. Original material sheets were rendered directly from ROM and +inspected. Native/Python extraction parity, copier-header support, wrong-ROM +rejection and cache preservation also pass. diff --git a/docs/weapons/spinning-blade.md b/docs/weapons/spinning-blade.md new file mode 100644 index 0000000..583485b --- /dev/null +++ b/docs/weapons/spinning-blade.md @@ -0,0 +1,62 @@ +# Spinning Blade: contact and charged timing + +X3 USA ROM, native X reference. Zero uses adapted X1 firing origins; vanilla +X3 does not give him these boss weapons. See `../mod-source-roms.md` for the +user-ROM extraction requirement. No ROM, extracted art or private save fixture +is distributed with this source. + +## Source evidence + +- Charged dispatch `$81:B423`, active dispatch `$81:B521`: the main actor + owns two children for the muzzle and tether. Children retire if the parent + leaves active state 2 or sets contact flag `+$30`. +- `$81:B55C`: local tick `+$3B` is incremented once per update. On odd ticks, + radius `+$38` increases by one eight-pixel unit, to ten. The next odd update + starts the 120-tick hold (`+$3C=$78`). +- `$81:B5B8`: decrement the hold timer, retract when zero; otherwise only held + Up/Down (`$0A0F & $0C`) initiates an orbit. Fire is not a rotation command. +- `$81:B5FE`: 32 angle changes, one every other local tick. The original signed + coordinate pairs are at `$86:BA86`, not bank `$81`. Their horizontal maximum + is 77 pixels and vertical maximum 80; interpolating a circular radius-80 path + subtly changes the original motion. `$81:B671` retracts eight pixels on odd + ticks. In the local timing sequence, radius is still 80 at tick 140 and is + 48 at tick 150. +- Charged collision bounds `$86:BA82` are `(0,0,13,10)`. +- `$81:B753` handles a killed target by clearing `+$30` and resuming active + state 2. `$81:B74C` retires a reflected actor. +- `$81:B75C` handles a surviving hit: enter detached phase 8, set vertical + velocity `$0800`, and use `$81:8CA4` to sign the horizontal `$0800` against + facing. This handler runs each tick while state 8 persists. `$82:D6F8` + subtracts the `$E0` horizontal acceleration initialized at `$81:B48C`, so the + observed velocities are -2272 facing right and +1824 facing left, with + screen-space vertical velocity -2048 (all 8.8 fixed point). + +A private controlled original-X3 probe placed a surviving ordinary enemy at +the fully extended blade: 120 HP became 90 once, then remained 90 over the +remaining 45-tick trace. The blade entered state 8/phase 8 and flew off while +the tether disappeared. This disproves the early adapter's assumption that +the attached charged blade could repeatedly damage the same target every +16 ticks. + +## Port behavior and validation + +`src/mmx_weapon_blade.inc` owns the normal pair and charged controller. Charged +variants 0/5 extend/hold, 1/2 orbit, 3 retract, and 4 recoil. The recoil has no +damage box or tether/muzzle rendering and retires outside the viewport. Killing +a target permits continued flight; surviving contact ends the attached attack. +The player retains the native firing overlay while the blade is attached, +matching the source controller's firing-timer updates. The generic 16-tick +hit-ledger reset has been removed. Saved combat structure +size is unchanged; the held countdown and orbit index use existing velocity +fields. Previous four-pixel extension states can finish extending without +exceeding the radius limit. + +Damage retains the original ratios of 9/3 for normal and 30/3 for charged on +X1's ordinary-enemy HP scale; native boss immunity and neutral damage remain. + +The focused ROM-runtime suite exercises both X and Zero: arm-upgrade gating, +one-/three-energy costs, normal twin launch, native normal contact, timed hold +and retraction, fire suppression, Up rotation, surviving charged contact, +recoil velocity, damage cessation, and snapshot replay while rotating or +recoiling. Render captures cover the held blade, orbit and detached blade. +Original SPC sound and the broader weapon contact audit remain separate work. diff --git a/docs/weapons/strike-chain.md b/docs/weapons/strike-chain.md new file mode 100644 index 0000000..7346d03 --- /dev/null +++ b/docs/weapons/strike-chain.md @@ -0,0 +1,113 @@ +# X2 Strike Chain backport + +Tracking: central Beads `beads-8wg.1.40`, under weapon-port issue +`beads-8wg.1.32`. Implementation branch: `feat/x2-strike-chain-port`. + +X2 weapon ID 6 works for X and Zero using the original normal and charged hook +and chain graphics. The charged attack requires X1's arm upgrade. This port uses +X1's live terrain, enemy collision, damage immunities, and collectible objects. +The implementation is isolated in `src/mmx_weapon_chain.inc` with small combat +and renderer registrations. No save-state ABI or asset-cache change is needed. + +## Original USA ROM findings + +These are CPU addresses, established by decoding the source instructions and +tracing a private X2 reference runtime. The owner's game and save states were +not used for the investigation. + +| Address | Behavior | +| --- | --- | +| `$81:9816..98B0` | Normal dispatch, initialization and rejection/refund of duplicate proxy shots | +| `$81:98DF..9917` | Normal startup: original sequence 0, then 16-tick extension timer | +| `$81:9918..9977` | Extend 4 pixels/tick; release fire, floor/ceiling, or timer starts retraction; wall contact starts pull | +| `$81:9978..99F8` | Retraction, wall pull, and carried pickup phases | +| `$81:99F9..9A31` | Kill continues normal chain; surviving hit retires it; either successful contact spends another half energy | +| `$81:9A4D..9A9C` | Follow player displacement, bounded to 12 pixels, and current arm height | +| `$81:9A9D..9B1C` | Nine cosmetic links; positions at eighths between arm and hook | +| `$81:9B29..9B90` | Current muzzle and return/anchor crossing checks | +| `$81:9B91..9BCE` | Pull allowed at body-to-hook distance 12..<64 normal / 12..<128 charged; opposite input cancels | +| `$81:9BCF..9C00` | Facing change, hurt/death, teleport and special body-state cancellation | +| `$81:9C3A..9C79` | Grab ordinary weapon energy, health and lives; hold with item `+$28` and release on cleanup | +| `$81:A6A4..A732` | Charged dispatch and 6-pixel/tick initialization | +| `$81:A765..A7FC` | Charged startup, 18-tick automatic extension and retraction; pickup scan during retraction | +| `$81:A7FD..A865` | Charged wall pull and pickup retrieval | +| `$81:A866..A899` | Kill reward, restore phase after enemy contact, clear contact counter, spend 1/16 energy | +| `$81:849D..84E3` | Normal link sequence 4 and charged link sequence 10 | +| `$88:C43E..C451` | Keep the firing pose while the tether is active | +| `$86:AF34` | Half-energy launch cost; charged duplicate initialization also refunds this entry | +| `$86:B790`, `$86:BA17` | Centered hook collision boxes, half extents 5x5 normal / 8x8 charged | +| `$86:F4C8` | Neutral damage row: buster 3, normal chain class `$0C` = 5, charged class `$15` = 1 | +| `$88:DA9B..DB78` | Native hit result dispatch: surviving hit state 8, lethal hit state 6 | +| `$88:DFF5..E035`, `$86:B0CE` | Kill reward row 7: equal small-health/small-weapon-energy probabilities | + +The normal startup reaches its terminal animation marker after two ticks. A +held hook extends for fifteen movement ticks at 4 pixels/tick (60 pixels beyond +the original 16-pixel muzzle). A tap can retract before any extension. Charged +startup reaches its terminal marker after eleven ticks (3+4+4); it extends for +seventeen movement ticks at 6 pixels/tick (102 pixels beyond the muzzle), even +after fire is released. Retraction uses the same speed toward the current arm. + +Both launch costs are **0.5 energy**. A reference trace starting at 28 produced +27.5 after a normal shot and 27 after holding a full charge and releasing (the +initial normal shot plus the charged launch). `$86:AF4A` is not the charged +launch-cost entry; interpreting that zero as a free charged launch was rejected +against the actual runtime trace. + +The source keeps the shooting arm out for the entire tether. Runtime samples +showed body firing timer `+$50=15` throughout both normal and charged chains, +then counted down after cleanup. The port refreshes X1's native firing timer, +preserving standing, running, jumping and wall firing animations. + +## Art and damage + +The existing user-ROM extractor supplies X2 group **71 (`$47`)**, all 24 poses, +its original animation records, and pose DMA table `$85:A068`. Sequences 0/3/2 +are normal startup/flying/anchored hook; 6/7/9 are charged equivalents. Sequence +4 cycles link poses 5..10 at three ticks each; sequence 10 cycles charged link +poses 20..23 at three ticks each. The renderer reconstructs the nine source +cosmetic actors without spending native projectile slots or inventing art. + +Original data is read from the owner's supplied X2 ROM through the shared ROM +picker/cache workflow. No ROM, extracted sprites, or generated asset pack is +checked in. See `docs/x-weapons-port.md` and `docs/x-weapons-source-notes.md` +for the distribution and source-ROM requirements. + +The normal hook deals **5/3 of neutral X1 buster damage**, plus the shared +cumulative-third rounding. It continues after a kill and ends after striking a +surviving enemy. Charged contacts deal **1/3 of neutral buster damage each**, +with the source's per-tick contact reset and 1/16 energy drain. A charged kill +allocates a genuine X1 small health or weapon-energy pickup; collection stays +in the native item handler. Boss/armored responses retain X1's neutral damage +and immunity handling; X2 boss weaknesses are not imported. + +## Host adaptations and limits + +- The pull moves X/Zero 4 or 6 pixels toward the anchored hook after ordinary + player movement, preserving source arm height. It sweeps against X1 terrain + so a hook cannot force the character through a solid wall. X1 moving objects + and stage scripts retain their own collision handling. +- Ordinary X1 pickup pool `$1628..1927`, stride `$30`, maps the same kinds + 1/2/4. Held pickups retain native lifetime, collection and refill behavior. + Zero's arm reaches farther than the native pickup collection box, so an item + retracts the final few pixels toward his body before release. Ordinary HP + collection still heals only the active character. +- The 50/50 charged kill reward uses source RNG arithmetic seeded from saved tick state; + it does not reproduce X2's global RNG sequence or alter X1 enemy drop tables. +- Enemy shields and invulnerability remain native. The charged hook continues + after a blocked hit, while the normal hook ends, matching the source phases. +- X2's original SPC sound bank is not imported by this handler. X1's shared + weapon firing/charge audio remains in use. + +## Focused verification + +`MMX_WEAPON_CHAIN_TEST=1` selects the focused checks in +`tests/mmx_weapon_chain_test.inc`, using a private headless X1 fixture. They +cover both characters' tap/held/charged attacks, launch energy, X1 arms gating, +original art availability, held firing pose, and exact replay; actual terrain +wall attachment and both pull speeds; opposite-direction cancellation; native +pickup retrieval and collection; normal enemy collision/damage/contact cost; +and charged contact rounding, repeated-hit drain, and guaranteed kill reward. + +Private renderer captures check the normal and charged original hook/link art. +The source-reference evidence and test fixtures remain ignored research output; +the reproducible source addresses and assertions above are the durable record. diff --git a/docs/weapons/tornado-fang.md b/docs/weapons/tornado-fang.md new file mode 100644 index 0000000..485c9f6 --- /dev/null +++ b/docs/weapons/tornado-fang.md @@ -0,0 +1,126 @@ +# Tornado Fang source and X1 adaptation + +Tracked by central Beads `beads-8wg.1.37`. X3 weapon ID 8, original sprite +group `$13`, normal class `$0E`, charged class `$17`. Source is the user-supplied +USA X3 ROM; no extracted graphics are committed. This work is independent of +the released Zero mod and belongs on the X2/X3 weapon follow-up branch. + +## Normal drills + +`$81:A9F6` dispatches through `$A9FB`: AA07/AAB6/AC4A/AC1D/AC1D/AC79. +Initializer `$AA07` sets seven-contact durability, 60-tick staging timers, +group `$13`, animation 13, box `$86:BA32` = (-5,0,9,7), and DMA `$85:E5A5`. +Water classes `$0D/$0E` choose the stored acceleration cap: 4 px/tick in +water, 3 otherwise. This cap is chosen at creation, not every frame. + +Routes `$86:BA36`, selected by `$81:AB9B`, contain duration and signed 8.8 +velocities. A single drill moves forward at 2 px/tick for two four-tick +records, then pauses. A second press while the first drill's 60-tick flag +remains active allocates two drills (`$AB54/$AA7F`): four ticks forward, +six ticks vertically at +/-3 px/tick, eight ticks forward, then pause. +Their final separation is 36 pixels. The source limit is three drills. +Each press costs one energy; the extra drill does not charge another unit. + +At the 60-tick boundary, `$AAF5` clears velocity and starts animation 1. +`$AB19` accelerates forward by $20/256 per tick to the stored cap. Original +animations 13, 0, 1 retain their frame durations and loops. +The drill passes through ordinary static terrain, as the source routine does; +native X1 actor collision still handles enemies and breakable actor objects. + +Enemy contact `$AC1D` decrements the seven-contact durability, starts animation +2 and a 16-tick contact pause, moving forward by $10/256 for its first two +ticks. Afterwards it accelerates again. Native deflection state 4 runs +`$AC4A`: half horizontal velocity, upward 3 px/tick, gravity $10/256 and +animation 3. Normal per-target hit suppression resets after the impact pause. + +## Held charged drill + +`$81:BE58`, initializer `$BE69`, active `$BEB4`: the fully charged drill is +deployed while fire remains held. `$81:AF4A/$AF5D` retires it when the held +fire bit clears, energy is empty or the player's action is incompatible. +Every 64 active ticks `$BEC2` calls `$84:A556` with mode 4, subtracting the +normal one-unit cost plus two-unit charged supplement. Initial deployment +also costs three. X1's arms bit `$1F99 & 2` gates this form for either character. + +The Fang-only input adapter invokes X1's existing native full-release path +when its full tier is reached while held; the unmodified physical held value +continues to own the drill. Native charge audio/visual state is stopped after +deployment. The player's firing overlay remains active until release. No +shared charge algorithm or save ABI changes are made. + +`$BF62` maps source body poses through `$39:90A0/$39:9104`, placing the drill +at the arm and selecting layout poses 12..17. The X1 adaptation follows the +existing X/Zero muzzle position and adds seven pixels beyond the barrel. +Normal running/jumping/dashing retain the character's native firing animation. +Wall slide uses source vertical layouts 14/15. `$BFC4` changes the drill to +pose 15 on wall contact and increments source player +$77; `$84:8A19` +consumes this to suppress wall-slide velocity. The port holds X1's wall-slide +height while retaining native jump/action transitions. Full-stage wall-grip +and ladder pose coverage remains a playtest item; it is not claimed proven +by the standing/moving fixture. + +Charged hitboxes `$86:BAEE`: horizontal (0,-6,4,6), vertical +(-4,-8,4,16), and zero box when embedded in a wall. Charged drill contacts +remain available every simulation tick, subject to the enemy's native +invulnerability logic. + +## Original rotating graphics + +Charged drill rotation is **CHR animation**, not pose animation. `$81:BF00` +requests directory indices `$29..$2C` every two ticks through `$80:872F`. +The `$86:97AD` directory pointers at `$86:97FF` lead to bulk DMA lists +`$86:9A61/$9A70/$9A7F/$9A8E`. These replace the drill tiles without changing +the arm-orientation layout. + +Both extractors now support an address-only animated-layout descriptor. +Normal group 19 poses 0..35 remain unchanged. Four CHR phases times six +layouts are appended as cached poses 42..65; 36..41 remain empty because +those layouts belong to a separate source sequence. MMXWEAP5's binary format +is unchanged. Native extraction automatically regenerates the cache from the +user's ROM. The combined reference cache contains 624 populated poses and +451,225 bytes. Source runtime VRAM comparison matched all six orientations +for the captured phase. Native/Python extraction parity also passes. + +## Damage, persistence and validation + +Neutral ordinary-enemy row `$86:E55D` gives raw damage **2 normal / 6 charged**, +versus buster **3**. Existing damage normalization therefore uses 2/3 and 2 +X1 buster damage respectively, retaining fractional thirds between hits. +The normal drill's individual contacts are intentionally small; seven hits +add up to 14/3 buster damage. Boss weaknesses are unchanged and native immunity +is preserved. This is neither an arbitrary one-HP projectile nor raw X3 damage +applied directly to X1's lower HP scale. + +All route timers, durability, selected art phase, positions and contact state +fit the existing 40-byte shot. No game-save/capture size or version changes. +Fields are documented in `src/mmx_weapon_fang.inc`. + +Focused checks cover both X and Zero: normal 2 px/tick launch, pause, +second-press paired drills, 60-tick transition, energy, arms gating, automatic +held charged activation, moving arm attachment, 64-tick upkeep, release/audio +cleanup, and deterministic snapshot replay. A real native enemy contact checks +damage and impact transition; controlled continuation checks seven-contact +retirement. Source extraction checks cover copier headers, wrong ROM rejection +and cache preservation on failure. Original charged art was visually inspected. + +Remaining fidelity work: source drill motor/impact audio, +broader wall-grip/ladder pose playtests and compatibility +with stage-specific moving/destructible objects. No source armor/chip bonus is +imported in this boss-weapon scope. + +## Contact particles + +Normal `$81:ABDC` and charged `$81:C046` spawn source class `$10`, group `$13`, +sequence 11: poses 20..27, two ticks each. These stationary impact fragments +now use independent saved visual slots. Normal contact uses the target center; +charged contact uses the drill origin shifted four pixels forward and upward, +and both reverse horizontal facing as in X3. Normalized zero-HP contacts still +produce the effect: the source hit occurred even when fractional damage rounds +to zero. The seventh normal contact uses `$84:D6E0`'s group `$08`, sequence 5 puff. + +The extractor aliases the debris group as 20 to keep its reference gray +palette `$66948` separate from the drill's own palette. All eight extracted +debris poses compare byte-for-byte with the original X3 runtime's live VRAM. +The shared puff uses original resource `$0A` graphics. No new game/capture ABI +or damaging actor slot is required. Native contact, particle save/replay and +visual captures were checked along with the existing Fang gameplay suite. diff --git a/docs/weapons/triad-thunder.md b/docs/weapons/triad-thunder.md new file mode 100644 index 0000000..5800624 --- /dev/null +++ b/docs/weapons/triad-thunder.md @@ -0,0 +1,116 @@ +# Triad Thunder source and X1 adaptation + +Central Beads `beads-8wg.1.39`. X3 weapon ID 3, projectile group `$0B`. +All graphics and animation records are extracted from the owner's original +USA X3 ROM. No ROM data, sprite sheets, or generated asset caches belong in +the release repository. The shared X3 ROM setting supplies this weapon and +Zero; extraction happens behind the scenes. +The host retains its existing X1 firing/charge audio integration; this change +does not import the X3 sound driver or sample bank. + +## Source map + +| Function/data | USA X3 address | Meaning | +| --- | --- | --- | +| Normal orb dispatch/init | `$81:9A0B / 9A1C` | Native class `$09` | +| Orb companion allocation | `$81:9AA1` | Three positions, one energy cost | +| Orb hold/link/release | `$81:9AFD / 9B2C / 9B80` | 30 ticks, 38 ticks, then bolts | +| Repeat input/renewal | `$81:9BE4 / 9C17 / 9C53` | Four fire edges, at most three renewals | +| Player displacement | `$81:9CA0` | Triangle follows player motion | +| Link/bolt initialization | `$81:9CE4` | Native class `$1B` | +| Dynamic bolt boxes | `$81:9E8A` | Animation flags select collision segments | +| Orb/link/bolt boxes | `$86:B93C..B97F` | Original centers and radii | +| Angle indices, links, velocities | `$86:B980..B9BB` | Source Y is upward-positive | +| Charged controller | `$81:B167 / B178` | Native class `$12`, creates two waves | +| Ground wave | `$81:B230 / B241` | Native class `$0F`, contour following | +| Charged boxes/speed | `$86:BA72 / BA76 / BA80` | Screen quake, wave, 4 px/tick | +| Charged player action | `$84:A2B3` | Source action `$72`, landing and punch | +| Base-X body animation | `$3F:D09E`, sequence `$22` | Original punch timing and poses | +| Base-X body DMA | `$85:D334` via `$86:B26C` | No imported armor overlay | +| Ordinary damage row | `$86:E55D` | Buster base 3 | + +The private X3 reference fixture was stepped with normal and 205-tick charged +releases. RAM traces established the projectile classes, source coordinates, +animation timing, body group, energy costs, and ground-wave release point. +The reference process was separate from the owner's running game. + +## Normal attack + +One energy creates three orbs. They travel outward for eight ticks at four +pixels per tick, arriving at player offsets `(0,-40)`, `(-32,24)`, `(32,24)`. +They charge for 30 ticks, then retain a triangle of connecting electrical +arcs for 38 ticks. The connecting actors grow, loop, and shrink through the +original sequences 3/4/5/6. Orb sequences 0/1/2 preserve their source frame +durations and loops. Each component remains attached to player displacement. + +At the final boundary the three orbs become falling debris and emit the +three original bolts. Bolt sequences 7/8 grow for 18 ticks while attached, +then move at `(0,-2048)`, `(-1636,1228)`, `(1636,1228)` in downward-positive +8.8 host coordinates. The full sprite footprint controls offscreen retirement, +so the long original artwork does not disappear when only its origin exits. + +Four new fire presses during the active formation request another cycle. +At its boundary, one more energy rotates the triangle vertically through a +16-tick route. Up to three additional cycles are allowed. Original horizontal +and vertical sprite flips apply to the orbs, links, and released bolts. +Holding fire still builds charge through X1's native charge system. + +## Charged attack + +X1's arm upgrade is required, for either playable character. A full release +costs three energy; the initial uncharged shot while beginning the hold costs +its usual one. The body waits for solid ground, then plays the original X3 +punch sequence: 24 ticks of windup, 60 ticks of earthquake, 120 ticks with the +ground waves, and nine ticks of recovery. Inputs cannot cancel the committed +punch. Damage/hurt and stage transitions still interrupt it. +An airborne release falls through native X1 terrain handling, capped at the +source 5.5-pixel fall speed, and begins the punch only after landing. + +X uses eight original base-body poses from group `$33`. The descriptor's +`animation_sequence: 34` restricts source animation traversal to that action, +because the larger player directory includes unrelated animation commands. +Offsets, timing records, and loop bytes remain original. This extension is +address-only metadata and does not change the `MMXWEAP5` cache layout. +X1 armor overlays are suppressed during this action: there are no equivalent +X1 armor pieces for the X3 punch, so the base X body is shown temporarily. +Zero has no source punch sequence; existing X3 Zero windup/crouched strike +poses follow the same timing. No replacement sprite art was fabricated. + +The controller starts screen-wide damage on the source strike flag. Every +eight ticks it invokes the state of X1's native camera quake (`$84:A341`), +equivalent to X3 `$84:D5A2`: duration 6, magnitude 3, X/Y increments 1. +The two ground waves start 60 ticks after the strike and use the original +group `$0B` sequence 10. Their terrain adapter uses live X1 metatiles and +slopes, follows floor contours, climbs walls, and descends exposed edges. +Four one-pixel probes implement the source four-pixel speed without skipping +X1 corners. This is a geometry adapter, rather than transplanting the X3 +terrain engine or its stage-specific breakable scenery. + +## Damage and state + +Ordinary-enemy source damage is 15 for orbs and the earthquake, 5 for links +and bolts, 9 for ground waves, against source buster damage 3. Existing shared +fractional carry preserves these ratios on X1's HP scale: respectively 5, +5/3, and 3 basic shots per contact. Native X1 invulnerability/collision remains +in charge; boss responses keep neutral buster damage and existing immunity. +No X3 boss weaknesses or X3-only stage destruction rules are imported. +The later owner-approved [X1 electric compatibility](x1-stage-reactions.md) +does reuse Electric Spark's damage and Armadillo armor-break reaction. + +The source diagonal bolt collision table has unusual flag-dependent entries +past the initial six boxes. Private runtime RAM confirmed those addresses; +the adapter preserves them rather than replacing them with sprite rectangles. +All formation, renewal, animation, body, quake, and wave state fits existing +saved projectile fields. The combat state remains 1,028 bytes. + +## Validation + +`MMX_WEAPON_TRIAD_TEST=1` selects focused native-runtime checks for both X +and Zero: triangle/link/bolt phases, renewal input and energy, arm gating, +original punch timing, frozen movement, native camera shake, delayed waves, +snapshot replay, return of controls, airborne landing, hurt cancellation, and +real native enemy damage. The final focused check exits successfully for +both characters. An ordinary enemy takes five X1 HP on one orb contact. +Native/Python extraction parity covers the added body poses and restricted +animation extraction. Captures compare original source artwork with the +port's normal triangle, emitted rays, inverted formation, punch, and waves. diff --git a/docs/weapons/x1-stage-reactions.md b/docs/weapons/x1-stage-reactions.md new file mode 100644 index 0000000..720e639 --- /dev/null +++ b/docs/weapons/x1-stage-reactions.md @@ -0,0 +1,61 @@ +# Imported weapons and X1 elemental reactions + +Owner-approved exceptions to the original neutral-boss-damage scope, tracked +in `beads-8wg.1.48`. No X2/X3 boss weakness tables are imported. + +| Imported attack | X1 collision class | X1 reaction | +| --- | --- | --- | +| Speed Burner fireball / ground flames | `$0A` | Normal Fire Wave | +| Charged Speed Burner, outside water | `$13` | Charged Fire Wave | +| Triad Thunder normal orbs / links / released bolts | `$0C` | Normal Electric Spark | +| Charged Triad Thunder damaging quake / waves | `$15` | Charged Electric Spark | + +The projectile keeps its imported graphics, timing, collision box and behavior. +Only its native collision class changes. `$84:9E15..9E45` uses this class to +select the X1 damage/immunity response and publishes it in `$1F1D`. This is +early enough for native enemy scripts to recognize fire/electricity after +the shared collision call. Changing only the final damage subtraction would +leave reflected/immune stage objects and scripted reactions broken. + +Ordinary enemies still use the source weapon-to-buster ratio on X1's HP scale: +the adapter reads the neutral buster entry rather than multiplying the mapped +element's damage again. Speed Burner preserves a stronger native Fire Wave +hit on susceptible enemies. Boss/armored rows use the mapped X1 damage value +and their existing invulnerability/reaction logic. Speed Burner's underwater +bubble form is explicitly excluded from fire mapping. + +## Verified X1 source paths + +- Penguin's bunker actor `$57`, `$87:E354`: damage row `$11`, 16 HP. + Its normal Fire Wave entry is 3, versus a reflected buster. `$87:E367..E371` + enters state 8 at zero HP; `$87:E462` invokes native debris, map modification + and cleanup. The building is not deleted by a host-side tile approximation. +- Chill Penguin fire reaction: `$81:B683..B69C` recognizes `$0A/$13` and enters + the original burning response. Other native Fire Wave reactions are retained. +- Armored Armadillo actor `$14`, `$83:B387..B398`: an electric hit (`$0C/$15`) + with armor still present (`+$33==0`) selects state 8/substate 2 and the native + extended reaction timer. The ordinary Electric Spark weakness hit is 3 HP. + Native AI owns the subsequent armor-break sequence. + +## Focused validation + +`MMX_WEAPON_STAGE_TEST=1` enables optional private stage-fixture checks in +`mmx_state_tests`. `MMX_IGLOO_FIXTURE` takes a copied save from the earlier +bunker report (`save5.sav`): walk right and fire Speed Burner until native bunker destruction, +for both X and Zero. Player invulnerability is held only in this test to stop +the fixture's flyers from interrupting the repeated firing sequence. + +`MMX_ARMADILLO_FIXTURE` takes the existing private boss fixture. Restore its +armor flag, overlap an actual imported Triad bolt with the boss, and observe +the native collision: HP 32 to 29, state 8/substate 2, then native break motion. +These checks passed. The tests also accept `MMX_RIDE_FIXTURE` for copied UI +earlier Ride Armor report (`save4.sav`), capturing Zero boarding, riding, walking, punching and +exiting. The owner game is never controlled or loaded by this harness. + +The old boss fixture can emit APU guest-clock synchronization warnings during +headless replay; these checks validate gameplay/renderer behavior, not audio. +Original imported SPC effects remain the separate `.46` follow-up. + +The shared state menu and its OSD label slots from zero: `Slot 6 loaded` refers +to `save6.sav`. The earlier test fixture descriptions used one-based numbers; +use the actual filenames above to reproduce those older encounters. diff --git a/docs/x-weapons-port.md b/docs/x-weapons-port.md new file mode 100644 index 0000000..3fb88f9 --- /dev/null +++ b/docs/x-weapons-port.md @@ -0,0 +1,463 @@ +# X2/X3 boss-weapon port + +Scope and sequencing: [character/weapons/co-op roadmap](zero-weapons-coop-roadmap.md). +Tracking: central Beads `beads-8wg.1.32`, branch `feat/x2-x3-weapons`. +Addressed source findings and remaining uncertainties: +[X1/X2/X3 source notebook](x-weapons-source-notes.md). +Public release contract: [user-supplied source ROMs](mod-source-roms.md). + +Reference attacks are X's original X2/X3 attacks. Zero's use of these weapons +in X1 is deliberately adapted: original weapon artwork and mechanics with +Zero-appropriate poses, firing origins and body actions. It is not presented as +a reproduction of native X3 Zero boss-weapon behavior. + +Latest playtest exceptions: Speed Burner now uses X1 Fire Wave's stage/enemy +reactions, and Triad Thunder uses Electric Spark's reactions, including +Armadillo's armor break. This supersedes the earlier blanket neutral-damage +rule for these two elements; see [source paths and tests](weapons/x1-stage-reactions.md). + +## Asset extraction foundation + +`tools/extract_x_weapons.py` builds a local `MMXWEAP5` cache from both original +USA ROMs. It validates normalized ROM hashes, accepts copier headers, reads +the original sprite layouts and DMA lists, and preserves original palettes. +The source-controlled descriptor contains addresses only; ROMs and extracted +graphics remain local. + +```powershell +python tools/extract_x_weapons.py ../MegamanX2Recomp/mmx2.sfc ../MegamanX3SNESRecomp/mmx3.sfc build-zero/port-work/x-weapons.bin +``` + +The current cache contains 646 projectile/effect poses, sixteen original pause-menu +icons, sixteen gameplay HUD footers and the original animation sequences +(464,830 bytes). This is an asset foundation, not a +claim that the weapons are already playable in X1. + +Sources were checked in the local recomp projects using private, paused +reference runs. The owner's playtest was never loaded from these fixtures. +Normal and charged releases were recorded for each native weapon ID. Artwork +samples were rendered directly from the extracted indexed pixels and inspected. + +| Native ID | X2 weapon / sprite group | X3 weapon / sprite group | +| --- | --- | --- | +| 1 | Crystal Hunter / `$10` | Acid Burst / `$05` | +| 2 | Bubble Splash / `$44` | Parasitic Bomb / `$06` | +| 3 | Silk Shot / `$48` | Triad Thunder / `$0B` | +| 4 | Spin Wheel / `$46` | Spinning Blade / `$0C` | +| 5 | Sonic Slicer / `$41`, charged `$87` | Ray Splasher / `$0D` | +| 6 | Strike Chain / `$47` | Gravity Well / `$0F` | +| 7 | Magnet Mine / `$0F`, charged `$13` | Frost Shield / `$10` | +| 8 | Speed Burner / `$25`, charged `$26` | Tornado Fang / `$13` | + +Both games use the sprite layout root at `$8D:8000`. Weapon-selection graphics +are the original seven-byte bulk DMA records: X2's pointer table is +`$86:9664`, X3's is `$86:97AD`, indexed by `$3E + weapon * 2`. Per-pose graphics +use the six-byte DMA records in bank `$85`. Palette addresses and group DMA +roots are in `tools/data/x_weapon_assets.json`. + +Triad Thunder also loads its normal lightning graphics from `$86:9976` when +fired; the ground-wave frames replace that region through their own pose DMA. +Crystal Hunter's inherited frames use its original setup CHR transfer. +Silk Shot extracts all five original material forms and their palettes from +its explicit source DMA table; the earlier assumption that it required borrowed +stage CHR was incorrect. Triad also extracts X3's original base-X ground-punch +poses and animation, with existing Zero strike poses used for Zero's adaptation. +Tornado Fang includes the 36 frames covered by its player weapon +DMA table plus 24 charged drill frames combining six arm layouts with four +original CHR rotation phases; see [Tornado Fang source notes](weapons/tornado-fang.md). + +The binary stores sixteen weapon entries, each with game/weapon IDs, original +X body and weapon palettes, the original 16x16 menu icon and its palette, then +groups of cropped indexed sprite frames. +Every frame retains its signed position relative to the actor origin. Empty +entries retain native pose numbering for poses not used by an extracted group. + +The menu icons come from each game's compressed graphics resource `$4C`. +Its five-byte record is in X2 `$86:FA01` / X3 `$86:F732`; the extractor decodes +the original literal/backreference format and checks output bounds. It does +not use projectile pose zero as a substitute for menu art. Both menus follow +native weapon IDs: X3 Parasitic Bomb is ID 2 and Frost Shield is ID 7. + +## Pause selection and persistence + +`mmx_weapons.c` validates and owns local caches, separate selection, +and sixteen energy pools. The launcher exposes independent X2/X3 weapon options +with source ROM pickers and native automatic extraction; see [setup](mod-source-roms.md). +The X3 ROM path is shared with Zero. Either weapon pack also works with X alone. +Disabled packs are skipped when +L/R cycles X1/X2/X3 pages within native pause navigation. The compositor uses +X1's font and energy-bar tiles with the source games' actual menu icons. +No instructional UI text is added. Both X and Zero can select the new entries. + +Outside pause, L/R cycles the eight weapons and buster within the selected +game's set. Switching games requires changing the pause page. X1 retains its +native unlock filtering; both shoulders return to the current set's buster. +Selection respects native projectile/cutscene locks and clears charge state. +The buster now retains page 1/2 with weapon 0 in the existing 40-byte state; +new builds accept old saves, but older builds reject this newly valid pairing. +Focused runtime checks cover both characters and both extended pages, all +entries in both directions, held buttons, buster save/load and menu reopening, +and X1 ownership filtering after returning to its page. + +Bounded generated/interpreter hooks virtualize the pause inventory reads and +selection. X1 progression/energy stays untouched. An extended selection uses +native buster resources as a safe underlying actor. The current +implementation includes normal and charged gameplay for all sixteen weapons. + +The game save chunk is version 13 when extended weapons are enabled; legacy +saves initialize full energy without changing X1 inventory. Zero-only saves +remain version 8 and stock saves version 3. Renderer capture version 12 also +stores the displayed weapon page and the active projectile simulation. Existing +older captures still load. Version 9 game states initialize an empty projectile +simulation; their weapon selection and energy remain intact. +Game versions 9/10 and capture versions 8/9 retain their original 24-byte +inventory prefix; new loads initialize the sixteen appended fraction bytes +to zero. New inventory state is 40 bytes and preserves source 8.8 precision. + +Damaging attacks use source ordinary-enemy damage divided by +their source buster value (3), multiplied by the X1 target's buster damage. +Fractions carry between contacts to preserve ratios, including weak individual +pellets and strong sustained weapons. Special encounter/armored profiles keep +neutral buster damage; native immunity remains unchanged. No boss weaknesses +are added. See the source notebook for the exact per-phase values. Combat state +is 1,028 bytes, including sixteen independent visual-particle slots; v12 game +saves and v11 captures migrate the old 388-byte prefix with empty effects. +V10/v11 game saves and v9/v10 captures migrate their old 328-byte +projectile prefix with empty damage carry and effects. Earlier checkpoint references to +one-HP placeholder damage below describe the original implementation only. + +Focused ROM-backed checks exercise all sixteen menu choices while X1 weapons +are locked, forward/backward page cycling, X/Zero selection, native cleanup, +partial-energy save/load and deterministic menu replay. Original icon renders +were inspected for both pages; the five existing CTests pass. + +## First combat checkpoint: Spinning Blade + +`mmx_weapon_combat.c` integrates host-side attack movement and original source +animation records with X1's existing projectile allocation, collision, damage, +firing poses, charge effects and sound cleanup. The projectile state is saved +alongside the guest RAM, including mid-flight animation and charged rotation. +Generated-code and interpreter hooks apply the same behavior. + +The first supported attack is X3 Spinning Blade. Its twin normal blades follow +the measured deceleration/vertical separation and original animation. Normal +enemy contact uses X1's positive buster damage and its existing immunity rules, +then plays the original blade impact. The charged blade extends to 80 pixels +from the muzzle, rotates when commanded, and retracts. Its tether and muzzle +use the original X3 sprite poses. One normal pair costs one energy; a charged +release costs three, as measured in the original X3 runtime. X1's actual arm +upgrade bit ($1F99 & $02) is required. The charging press can also emit an +ordinary pair, as it does in the source game. + +The cache additionally stores original animation directories and records from +X2 root $2F:A000 and X3 root $3F:8000: duration, flags, pose and relative loops. +MMXWEAP4 is 433,323 bytes for the current descriptor. Only addresses and the +extractor are committed, never the ROM or extracted art. + +Focused ROM-backed checks cover both characters, twin-shot creation, normal +and charged energy use, arm gating, charge-audio cleanup, exact save/replay +mid-flight and mid-turn, and a real highway enemy losing one HP through the +native collision routine. Original normal/charged art is captured for review; +extended and rotating tether renders were inspected. The full existing Zero +ROM regression, all sixteen menu-choice checks and five CTests pass after the +combat integration. The desktop game and capture tools build successfully. + +This is a combat foundation checkpoint, not completion of the weapon set. +Spinning Blade's integration still needs final attention to transitions, +special terrain/reflection cases and fidelity during movement. Source X1 +charge/firing sounds are reused; X2/X3 sound-bank import is not implemented. + +## Energy integration + +Extended selections use X1's vertical energy segments and the selected +weapon's **original X2/X3 gameplay footer**, extracted independently of the +pause icon. Native X2 tile $28 and X3 tile $AC are 16x16 sprites loaded by each +weapon's bulk DMA list. Their source pixels, frame, transparency and weapon +palette are used unchanged, with no crop, scaling or borrowed X1 frame. +The earlier cropped-menu approximation was rejected and removed. +All sixteen rendered footers were compared pixel for pixel against private +original-game screenshots; the corresponding DMA bytes and palettes also match +live source VRAM/CGRAM. Pause icons remain unchanged. Native/Python extraction +parity and focused menu/energy runtime checks pass. MMXWEAP4 adds these 256 +indexed pixels to each entry; automatic ROM extraction regenerates old caches. +Gameplay save/capture layouts are unchanged. HUD inventory reads are virtualized; +they never change the X1 inventory. + +The native weapon-energy pickup actor (item kind 1) retains its collection +collision, gameplay freeze, incremental refill and sounds. Small pickups add +two energy and large pickups add eight to the selected extended weapon. After +the selected weapon fills, the original auto-refill scan gets first choice of +owned X1 weapons; its remainder can fill X2/X3 weapons in inventory order. +Picking up energy while using the buster can also refill new inventory. + +Checkpoint death preserves weapon energy, matching measured X1 behavior. The +native full-inventory refill entry $00:9EF9 also refills all sixteen additions. +This is separate from the two character HP pools, which refill on respawn. +Focused runtime checks cover native small/large pickups, overflow, buster +auto-refill, unchanged X1 unlocks/inventory, save/replay during animated refill, +and matching native/extended energy retention through actual death/respawn. + +Fractional costs are preserved internally to 1/256 energy, matching the source +games' representation; native HUD/menu bars display whole units. Native pickup +reads/writes preserve the low byte. X1's selected-weapon refill increments one +whole unit at a time and clamps the final increment, discarding any fractional +excess from that increment; only remaining pickup ticks enter auto-refill. +The port preserves that behavior and other weapons' existing fractions. +Focused checks cover half-unit refill, reserve quarter-unit preservation, +native clamping/overflow, exact replay during refill, fractions across pause +selection, v10 save migration and an original v9 renderer capture. + +## Second combat checkpoint: Acid Burst + +Normal Acid Burst now lobs from either character's muzzle, accepts up/down +aiming, splashes on X1's live tile terrain, and emits the original four +droplets. Charged release creates the two original growing blobs, which bounce +and leave splashes for up to five terrain contacts. Original animation records +and art come from the user's X3 cache. Costs are one normal / two charged; +real X1 arms still gate charging. Enemy hits retain ordinary X1 damage. + +Shared combat dispatch now supports both Blade and Acid. Collision boxes vary +by weapon/phase. The new terrain helper reads X1's live metatile properties, +including half/quarter slopes and one-way ladder tops. It sweeps movement to +avoid skipping thin tiles. No player-only hazard side effects are invoked. +The existing save/capture layouts are unchanged. + +Focused checks pass for both characters: normal/charged creation and energy, +native highway floor contact, four droplets, charged bounces/splashes, slot +cleanup, charge-audio cleanup, exact save/replay through terrain interaction, +and ordinary damage/impact retirement against an actual highway enemy. Normal +and charged source-art renders were inspected; the existing Blade combat +regression passes and the desktop/capture tools build. + +Remaining Acid fidelity work: underwater dissolution, moving-platform contact, +source sound import, and representative non-flat terrain playtests. Addresses +and measured behaviors are in the [source notebook](x-weapons-source-notes.md). + +## Third combat checkpoint: Ray Splasher + +Ray Splasher now emits seven spread rays over its normal burst, using the +original muzzle animation and ray/trail poses. The muzzle follows the active +character, and the firing body pose now stays active for the complete 60-frame +burst, matching the original X3 reference. Zero retains his original extended +arm pose instead of returning to idle while the muzzle keeps emitting. +The charged attack deploys the original floating turret, launches upward and +cycles the source sixteen-direction pattern for twenty-two shots over 180 +active frames. Both characters pay one energy normally and exactly 2.5 for +charged release; child rays do not consume additional energy. + +Ray projectiles retain ordinary native damage; their original trails drain +after impact. Focused checks cover both characters, shot counts/directions, +fractional cost, charge sound cleanup, native enemy damage/retirement, complete +slot cleanup and exact save/replay from deployment through radial firing. +Normal and turret renders were inspected. Blade's regression passes, including +a fix for an old adapter mistake: RAM `$0C25` is the native charged-beam count, +not a firing-pose timer. The adapter no longer pins it to two while the charged +blade is active, avoiding a leak into later attacks. Desktop/tools build. + +Remaining Ray fidelity work: full character deployment/firing body poses and +their movement/air transitions, turret destruction effects/interactions, +source audio, and visual comparison of trails at contact/offscreen edges. +The current deployment uses a 36-frame wait measured from the reference; +the original waits on its body-animation event. This is a practical adapter, +not a claim that the source body-action state machine has been fully ported. + +## Fourth combat checkpoint: Sonic Slicer + +The normal attack forms at the muzzle, launches two accelerating arcs and +ricochets from live X1 tile terrain. The third vertical contact retires a blade. +It costs exactly half an energy unit. Charged release uses X2's separate `$87` +sprite group and creates five blades, spreading upward, stopping horizontal +travel at their apex, then falling through terrain. It costs two energy and +requires X1's arms. Native ordinary enemy damage and original impact art apply. + +Focused checks pass for both characters: split timing, fractional costs and +insufficient energy, arm gating, native floor bounce/retirement, five charged +arcs and their apex, charge sound/slot cleanup, ordinary native enemy contact, +and exact save/replay. Original normal and charged captures were inspected; +desktop and capture tools build. Original normal trails, cosmetic wall sparks, +source sounds and broader movement/terrain comparison remain fidelity work. + +## Fifth combat checkpoint: Spin Wheel + +The normal wheel uses X2's original forming/spinning/shrinking animations, +falls onto live X1 terrain, waits 30 frames and rolls with source momentum +decay. Its speed responds to slope direction; airborne wall reflection loses +momentum, and a ground wall starts the original delay/hop behavior. Enemy +contact deals ordinary X1 damage, briefly stops the wheel and reduces momentum +before it resumes. Source ground-effect poses follow the wheel on the floor. +Normal fire costs one energy; charged release costs three and requires arms. + +Charged formation emits the original eight radial projectiles with source +positions, velocities and directional art. X1 has eight projectile slots, so +the center's slot becomes one projectile while saved state renders its brief +original center flash. All eight directions fit when the pool is free. These +charged projectiles pass through terrain, as in X2. No cache or state size +change is required; all 27 poses were already extracted from the user's ROM. + +Focused checks cover both characters, forming/falling/ground delay/rolling, +costs and insufficient energy, arms gate, eight charged directions, charge +audio and slot cleanup, deterministic replay, and native enemy damage/pause. +Normal and charged original-art captures were inspected. Broader slope/wall +and moving-platform comparisons, source sounds and X2-specific destructible +terrain remain fidelity work; X1 has no equivalent source-only breakable blocks. + +## Sixth combat checkpoint: Frost Shield + +Normal fire uses the original X3 ice formation, accelerating rocket, falling +core and planted spike. Air/water variants use source animation records, +acceleration, steering, gravity and hitboxes. Charged fire forms the original +arm shield, holds for 360 ticks, blocks destructible enemy shots, then releases +the moving ice chunk. Killing contact preserves the charged shield; contact +with a surviving enemy breaks it, as in X3. Normal/charged costs are 1/3. +Damage is 15/3 buster strength, with 9/3 for the released charged chunk. + +Underwater charging creates the original rising platform. A bounded native +terrain hook lets either character stand on it and jump off; it floats for +240 ticks at the waterline. Ice shards use the original particle art and +velocity tables. All 63 poses were already in the source-ROM cache, so no +asset format or save size changes were needed. Landing is X1 action `$0A`, +not hurt (`$0E`); both Frost Shield and Ray Splasher now preserve their firing +poses across ordinary landings. + +Focused checks cover both characters, normal terrain phases, arms gating, +energy, charged contact responses and projectile blocking, shield expiry, +counter/audio cleanup, and deterministic replay. A private controlled water +fixture exercises native landing, riding, jump-off and save/replay. Full water +stage/camera traversal still needs playtesting. Source sounds, source RNG +ordering for cosmetic shards and interactions with moving stage actors remain +fidelity work. Cosmetic shards share X1's finite projectile slots. + +## Seventh combat checkpoint: Bubble Splash and full-charge detection + +Normal fire reproduces X2's two-tick held-fire cadence, seven-bubble limit, +one-eighth energy cost, varied growth/pop sequences, initial speed range and +upward acceleration. Water increases acceleration. Original bubbles pass +through terrain; enemy contact plays the original popping animation. Normal +damage is 2/3 of a buster hit, with fractional carry across contacts. + +Charged release creates the renewing seven-bubble cloud. Each bubble follows +X2's 29-record movement path, with source delays, growth art and parent motion. +Activation and each replacement cost one-eighth energy; charged contact is +5/3 buster damage. The source three-pixel underwater lift is included with +an X1 ceiling check. Shoulder cycling cancels the persistent cloud. Original +32-pose art is already extracted; no new asset or save format is needed. + +This exposed a shared charge bug: buster class 3 represents the intermediate +arm-upgraded beam, not the full special-weapon release. Imported attacks now +use X1's own full-release marker `$C01 == 4`, gated by arms. Holding beyond +full charge therefore retains the charged attack. Earlier 150-tick test +releases exercised the wrong tier; charged checks now hold for 205+ ticks, +and Bubble Splash also checks that an intermediate release stays normal. + +Focused checks exercise both characters, continuous fire/energy, growth/pop, +full/intermediate charge and arm gate, renewing cloud/drain, native enemy +contact, private water lift, shoulder cancellation, empty-energy cleanup and +exact save/replay. Original-art captures were inspected. RNG is deterministic +from port state using the source arithmetic, rather than the source game's +whole RNG call stream. Source audio and full water-stage traversal remain +fidelity work. + +## Eighth combat checkpoint: Magnet Mine + +Normal mines travel at two pixels per tick, steer with source acceleration +and retain vertical momentum after releasing up/down. Terrain or another mine +starts the original arming animation and 60-tick planted wait. Native enemy +contact or timeout starts X2's original shared explosion; blast completion +detonates nearby mines. One flying/arming mine is allowed; planting frees +the firing limit while retaining its physical slot. Normal cost is one energy. + +Charged mines travel at half a pixel per tick and steer the same way. They +pull destructible enemy projectiles five pixels horizontally and three +vertically each tick, absorb contacting shots on alternating ticks, and grow +at 16/32 absorptions through the three original animations and 8/16/24-radius +hitboxes. Native immunity is respected. Charged cost is three energy; both +forms deal 5/3 buster damage with source-style repeated explosion/charged +contacts. X2's separate armor energy-conversion bonus is outside this pass. + +Both-character checks cover speed/steering, source firing limit, native terrain +planting, timed explosion, chain reaction, arms/costs, projectile attraction, +immunity/absorption, both growth thresholds, native enemy damage, save/replay +and cleanup. Normal blast and charged art were inspected. Original blast CHR +and its separate palette are extracted from the user's X2 ROM (MMXWEAP5), +not recolored weapon sprites. Moving-platform attachment and source audio +remain fidelity work. + +## Ninth combat checkpoint: Speed Burner + +Normal fire uses X2's original 5px/tick projectile, growth/flight/impact +animations, phase hitboxes, ground-flame emission every four ticks, and +60-tick rolling flames. Original sparkles persist independently of native +attack slots. Entering water changes the projectile to the original small +central bubble with two orbiting visual bubbles. Normal cost is one energy; +fire and ground flames deal 5/3 buster damage, the water form 1/3. + +Charged release starts a native X1 dash with original X2 speed $0475, +ground/air countdown $30/$18, body-attached group $26, alternating collision +and 1/3 buster damage. It costs three energy. X1 still handles walls, ground, +camera, jumping out of the ground dash and opposite-direction cancellation. +The airborne form holds height and then returns to gravity. Underwater it +retains movement but loses its flames, attack box and damage guard. + +The airborne test also corrected a shared allocation-hook bug: X1 clears +the charge command before allocating a moving/air shot. The port now captures +that command at the common $81:94AF selection entry, before the clear. +Both-character checks cover normal/ground fire, particles, arms/costs, +ground/air dash motion and cleanup, private water behavior, and save/replay. +The main flame and dash art were inspected. Underwater dash bubbles now use +original group `$17` art, a 26-tick lifetime and source upward acceleration; +their emission and save/replay were checked. Source audio and moving-platform +ground-fire behavior remain fidelity work. +Native X1 still limits damaging actors to eight, compared with X2's nine. + +## Further integrated weapons + +| Weapon | Implemented behavior and source notes | +| --- | --- | +| [Tornado Fang](weapons/tornado-fang.md) | Staged normal drills, repeat-fire pair, enemy contact pauses, rotating charged drill, held drain and wall grip | +| [Silk Shot](weapons/silk-shot.md) | All five materials, normal bounce/fragments, original leaf trails, eight-piece charged gathering and release | +| [Triad Thunder](weapons/triad-thunder.md) | Three-orb formation, connecting arcs and bolts, repeat-input inversion, charged punch/quake and terrain-following waves | +| [Strike Chain](weapons/strike-chain.md) | Original hook/links, normal and charged extension/retraction, wall pull, pickup retrieval and charged kill rewards | +| [Crystal Hunter](weapons/crystal-hunter.md) | Falling projectile, enemy crystallization, solid platform, dash shatter/original debris, charged distortion and half-speed simulation | +| [Gravity Well](weapons/gravity-well.md) | Normal pull/dissolve/return, original ground/air cast, charged enemy lift and rising particles, protected-enemy exclusion | +| [Parasitic Bomb](weapons/parasitic-bomb.md) | Original captured-enemy art and formation, homing/stationary/rolling payloads, four charged targeting cursors, homing units and original impact effects | + +Spin Wheel's contact correction accepts hits during formation as well as +falling/rolling, clears falling velocity, and restores X2's pause entry plus +ten-tick countdown. A native enemy test now covers repeated hits at a fixed +position and resumed travel after the target's death, rather than only one hit. + +These implementations retain the existing combat/save ABI. Original SPC sound +effects are not yet imported; each weapon's notes identify remaining audio and +stage-specific adaptations. X's Triad punch currently uses original base-X art, +so X1 armor overlays are hidden for that action. + +## Completion checkpoint and follow-ups + +All sixteen weapons have normal and charged gameplay implemented. Crystal +Hunter, Gravity Well and Parasitic Bomb complete the earlier thirteen ports. +Charging retains X1's arm-upgrade gate; utility attacks have explicit X1 enemy +adapters instead of treating special response bytes as damage. The extractor +reads 691 source poses (501,828 bytes in the combined local cache). + +This is a playable implementation checkpoint, not a claim of identical game +engines or finished audio. Tornado Fang impact debris and Speed Burner's +underwater ambient bubbles are also implemented with original assets. +Follow-ups include original SPC effects and stage-specific object interactions +documented per weapon. Ordinary +damage keeps source-to-buster ratios without imported boss weaknesses. +Co-op remains a later, separate mod. Private fixtures must never be loaded into +the owner's running playtest. + + +## Branch boundary after Zero 0.0.1 + +Zero 0.0.1 is released independently (PR #52). This branch contains the entire +X2/X3 weapon follow-up; do not merge unfinished weapons with the Zero release. +The shared source-ROM provider uses the same framework pin as Zero. The Zero +release notes describe only its v8 save / v7 capture layout. With weapons here, +game chunks use v13 (40-byte inventory plus 1,028-byte combat state), captures +use v12. Game v12 and capture v11 load their 388-byte prefix with empty visual +particles. Older v9/v10 game and v8/v9 capture inventories migrate their 24-byte +prefix; game v10/v11 and capture v9/v10 combat prefixes initialize empty damage carry. +The full 40-byte Zero state is unchanged. Do not load weapon-branch saves in +the Zero-only release. No co-op implementation belongs in this branch. diff --git a/docs/x-weapons-source-notes.md b/docs/x-weapons-source-notes.md new file mode 100644 index 0000000..a90ccbc --- /dev/null +++ b/docs/x-weapons-source-notes.md @@ -0,0 +1,803 @@ +# X1/X2/X3 weapon source reference + +This is the durable investigation notebook for the weapon backport. Update it +when a source address, runtime observation or mapping is established. Scope: +[roadmap](zero-weapons-coop-roadmap.md); implementation/checkpoints: +[port notes](x-weapons-port.md). Work is tracked in central `beads-8wg.1.32`. + +Addresses below are SNES CPU LoROM addresses unless explicitly marked RAM or +normalized ROM offset. Sources are the local original USA ROMs in the X1, X2 +and X3 recomp projects. ROM hashes are checked by +[`x_weapon_assets.json`](../tools/data/x_weapon_assets.json). Do not commit ROMs, +extracted art or Ghidra databases. Reference sessions use private fixtures, +never the owner's running game. + +## X1 integration map + +| Address / RAM | Established purpose and port use | +| --- | --- | +| RAM `$0BA8` | Player object; X position `+$05`, Y `+$08`, facing `+$11` bit `$40` means right | +| RAM `$0BDB` | Native selected weapon, even ID; extended weapons retain native buster resources | +| RAM `$0BDD` | Active player projectile count; every owned allocation/retirement must balance it | +| RAM `$1228..$1427`, stride `$40` | Eight native projectile slots; port ownership tag `$5758` at `+3E` | +| RAM `$0E68..$1227`, stride `$40` | Ordinary enemy slots; enemy current HP `+27`, collision box pointer `+20` | +| RAM `$1F99` bit `$02` | Actual arms upgrade; required for charged X2/X3 weapons | +| RAM `$1F99` bit `$08` | Dash upgrade, NOT arms; Zero's innate dash must not enable charged specials | +| RAM `$1F88 + weapon_index*2` | Native X1 unlock/energy byte; extended inventory must not modify it | +| RAM `$1F12` | Native weapon-energy HUD state; zero rebuilds after extended selection changes | +| `$81:815C`, `$81:8165` | Player tick entry/end; Zero and extended combat input handling | +| `$81:9D47` | Newly allocated buster object available in X; mark extended shots before native effect dispatch | +| `$00:D3E5` / interpreted `$00:D3E7` | Projectile-active read; owned host simulation replaces native class update | +| `$84:9C16` / interpreted `$84:9C19` | Enemy/projectile hitbox read; suppress duplicate contact as appropriate | +| `$84:9E6E..9E76` | Native positive-damage subtraction; use original buster damage and existing immunity rules | +| `$81:A578..A5B5` | Native buster muzzle coordinates, facing and player-position addition | +| `$82:8174..8235` | Original fixed-point acceleration/movement; source vertical velocity is positive UP | +| `$00:C67F` | Pause menu input: L/R page cycling | +| RAM `$1EC8` | Native pause direct-page base; cursor `+0A` = `$1ED2`, candidate `+0B` | +| `$00:D907..D9F9` | Native weapon HUD setup/draw, using virtual selection/energy reads | +| `$81:E042..E103` | Collected weapon-energy item refill: freeze, four-frame increments, native sounds/cleanup | +| `$81:E11A..E169` | Auto-refill scan of owned X1 weapons; X reaches `$12` after exhausting the scan; RAM `$0000` holds remaining 8.8 energy | +| `$00:9EF9..9F0E` | Native full-inventory refill; extend to all 16 added weapons | +| Normalized ROM `$37F80..37F9F` | Verified unused bytes used for eight owned projectile collision boxes; separate from Zero's `$37FB0` saber boxes | + +Item pool fixture: RAM `$1628`, stride `$30`. Kind `+0A=1` is weapon energy; +kind 2 is health. `+0B & 127` distinguishes small (1) from large (0), and bit +7 prevents fixture expiration. Kind 3 is NOT weapon energy. A wrong item kind +is an invalid test fixture, not evidence about the energy hooks. + +Checkpoint death preserves native weapon energy. This was checked against an +owned X1 weapon at five energy while an extended weapon retained twelve. +Health refill is deliberately separate: both character HP pools refill on +respawn; ordinary HP pickups heal only the active character. + +## X2/X3 source assets and runtime mapping + +| Source fact | X2 | X3 | +| --- | --- | --- | +| Player object in measured source fixtures | RAM `$09D8` | RAM `$09D8` | +| Source projectile pool | RAM `$10D8..$1317`, stride `$40` | Same | +| First weapon energy pair | RAM `$1FBA` | RAM `$1FBB` | +| Arms upgrade byte | RAM `$1FD0`, bit 2 | RAM `$1FD1`, bit 2 | +| Sprite-layout root | `$8D:8000` | `$8D:8000` | +| Animation-group directory root | `$2F:A000` | `$3F:8000` | +| Static weapon-selection DMA root | `$86:9664` | `$86:97AD` | +| Menu compressed-resource table | `$86:FA01` | `$86:F732` | +| Resource `$4C` source | `$22:8FB9` | `$21:E7E4` | +| Menu icon palette, normalized ROM offset | `$2CEE0` | `$62DA0` | + +Weapon-selection DMA is indexed by `$3E + weapon_id*2`. The descriptor records +each per-pose DMA table, group and palette. Animation directories contain +16-bit offsets into records `(duration, flags, pose)`; flag `$80` adds a signed +16-bit relative loop displacement from the following offset. The extractor +walks all sequences, validates bounds, and stores these source records. + +Native IDs X2: 1 Crystal Hunter, 2 Bubble Splash, 3 Silk Shot, 4 Spin Wheel, +5 Sonic Slicer, 6 Strike Chain, 7 Magnet Mine, 8 Speed Burner. +Native IDs X3: 1 Acid Burst, 2 Parasitic Bomb, 3 Triad Thunder, 4 Spinning Blade, +5 Ray Splasher, 6 Gravity Well, 7 Frost Shield, 8 Tornado Fang. Pause menu +order follows these IDs. Earlier notes incorrectly swapped names 2/7 and +compensated by swapping their menu icons, masking the mismatch with the source +projectile data. Original menu capture and handlers `$81:970C` / `$81:AC82` +confirm ID 2/group `$06` is the parasite bomb and its charged seeking bits; +ID 7/group `$10` is the ice missile and charged shield. Names and icon selection +are corrected in both native and Python extractors. Data groups/palettes did +not need remapping. The first five implemented combat weapons used other IDs; +Frost Shield subsequently implements the corrected ID 7. + +## X3 Spinning Blade observations + +Normal actor class `$0A`, group `$0C`: initial horizontal speed `$0400`, +deceleration `$20` per frame toward the return direction. Twin vertical speeds +separate by `$10` per frame and clamp at magnitude `$C0`. Normal damage bounds +are `(0,0,13,10)`. A normal pair costs one energy; repeated presses while the +pair is active do not create more pairs in the source runtime. + +Charged actor class `$13`, same group: extends 80 pixels from the muzzle in +eight-pixel steps every other tick, holds for 120 ticks, then retracts with the +same cadence. Up/Down starts a full orbit of 32 original positions held for two +ticks each; fire does not start the orbit. Normal stationary source muzzle +measured at player `(14,-4)`, blade fully extended at `(94,-4)`. A charged release costs three energy. +The initial press used to begin charging can separately emit a one-energy pair. + +The contact/timing follow-up is recorded in [weapons/spinning-blade.md](weapons/spinning-blade.md). +A surviving-enemy hit dislodges the charged blade and removes its tether; +the earlier port's periodic 16-tick hit reset was incorrect and is removed. + +Animation sequences: 0 normal spin; 1 impact; 4 charged spin; 5 tether extension; +6 tether retraction; 7 muzzle; 8/9 tether rotations. Tether pose 66 points left +in unflipped source art, 42 up, 50 right, 58 down. Actor facing flips matter: +using a world-angle pose and then flipping it again reverses the tether. + +## Limits of the current reference traces + +The original 100-frame normal/charged recordings are discovery material, not +proof of every charged form. The X2 fixture is near a wall. Some charged forms +have eligibility constraints or activate while held. In particular, Sonic +Slicer's charged discovery trace was superseded by the verified trace below; +Frost Shield is now covered by the follow-up below. Gravity Well and Tornado Fang require source +checks before implementing from those initial traces. Keep verified facts +separate from inferred behavior; add new findings below as weapons are ported. + +## X1 terrain mapping for ported projectiles + +`$84:90B3..9110` samples the object's terrain offsets and resolves the collision +class. `$84:916A..91AC` reads the live screen map at RAM `$E800`, indexed by +`(y >> 8)*32 + (x >> 8)`. The screen selects a 512-byte metatile page; the +within-screen byte offset is `((y & $F0) << 1) + ((x & $F0) >> 3)`. That index +wraps to 16 bits before the long read at RAM `$7E2000 + index`. + +The metatile word indexes the collision property table pointed to by RAM +`$0B92..0B94`. Its low six bits select terrain behavior. This is **not** the +graphics definition table at `$0B95`. Read current RAM, not an initial ROM +layout, so broken/changed tiles immediately affect projectiles. + +Floor dispatch `$84:961C`: classes 1-4 are half slopes and 5-12 quarter slopes +(`$96D3..9815`); `$13` is solid (`$96B5`). Classes `$33..3F` contain solid, +conveyor, ladder-top and spike behaviors. `$39/$3A` tops are one-way. Water and +ordinary ladder classes do not block these attacks. The port reads these +properties without invoking player-only hazard or conveyor side effects. +The swept projectile helper currently covers static tile terrain and slopes; +dynamic moving-platform actor contact still needs a separate integration. + +## X3 Acid Burst + +Verified against original ROM and private runtime traces: + +- Normal actor class `$07`, group `$05`; initial source velocity `($0120,$0280)` + with gravity `$20`. Source Y is positive up. Up/down modify the lob to + `(0,+$0500)` / `(0,-$0500)`; table `$06:B893..B8A2`, selection `$81:919B..91DE`. + Normal flight uses animation sequence 2, poses 10-14, three frames each. +- Native terrain contact `$81:91FC..9267` selects sequence 7 for floor, + 10 for ceiling, 11 for wall; wall contact flips facing. A floor splash lasts + 40 frames. The animation flag on pose 37 triggers four droplets; tables at + `$06:B8AD..B90C` specify their offsets/velocities. Droplet actor `$18` uses + gravity `$30`, ten initial terrain-grace frames, sequence 4, then sequence 5 + on terrain. Source routines `$81:92D3..933F` and `$81:93BD..945F`. +- Charged actor `$10`, same group: `$81:947E..9545` creates two blobs, initially + `($00C0,$0500)` and `($0200,$0300)`, source gravity `$38`. Sequence 12 grows + from pose 20 through 15,10,5 to 0; then sequence 0 animates full size. +- Charged collision `$81:9555..95F9` permits five terrain contacts, emits a + splash each time, reverses X on walls and uses source vertical speed + `+$0500` on floor / `-$0500` on ceiling. Floor/ceiling contact sets X speed + magnitude `$0100`. Splash clones are created at `$81:962B..965A`. +- Original collision radii: normal 6x6, charged main blob and droplets 4x4, + charged splash 8x8. Visual growth does not enlarge the main blob's source + damage box. Enemy impact uses sequence 6; normal terrain splashes are visual + parents for the damaging droplets. +- Measured energy: normal **1**, charged **2**. Private original runtime went + from 28 to 27 on the initial charging press, then to 25 on charged release. + Do not reuse Spinning Blade's three-energy charged cost. + +Port checkpoint covers those airborne, tile-contact and enemy-contact forms +for both characters. Source underwater dissolution (`$02:DF16` called from +`$81:9183/$94C5`) and source audio are still pending; current sounds come from +X1. Revisit moving-platform contact and broader slopes/ceiling/wall playtests +alongside the other terrain-sensitive weapons. Those are open fidelity items, +not facts already established by the highway floor test. + +## X3 Ray Splasher + +Normal class `$0B`, sprite group `$0D`: `$81:A19C..A20F` attaches its muzzle +effect to the player for 60 frames, emitting a ray every eight frames (seven +rays total). The muzzle uses animation sequence 10. Child class `$1C` setup +`$81:A254..A2CB` uses sequence 3 (pose 13), radius 8x8 and a speed lookup: +normal directions `$06:B9C8..B9CF`, charged directions `$06:B9D0..B9DF`, +vectors `$06:E18E` multiplied by eight. The normal first directions produce +`(3968,+992)`, `(3968,-992)`, `(4096,0)` in source coordinates when facing right. +The ray trail is real source art: effect class `$0F`, `$81:8578..85FB`, uses +sequences 4/5 (poses 14/15), delayed along the parent's position history. + +Charged turret class `$14`: `$81:B7C7..B83B` waits for the character's deployment +animation, then `$81:B83C..B856` launches upward at `$0300` with deceleration +`$20`. Once stationary, `$81:B858..B878` fires every eight frames for 180 frames. +`$81:B8E0..B914` cycles sixteen radial directions; this is not an enemy-seeking +turret. Animation sequences 0/1 supply launch/active poses. Original deployment +trace starts at player `(-2,-44)` and launches about 36 frames after release; +that delay comes from the source body-animation flag, not a proven universal +timer. Adapt the deployment pose/timing carefully for X1 X and X3 Zero. + +**Inventory prerequisite:** measured normal cost is 1; charged cost is **2.5**. +The private source RAM pair `$1FC3` changed from `$5B00` (27) to `$5880` (24.5) +on charged release after the one-energy initial shot. Fractional inventory is +now implemented with sixteen appended low bytes, retaining full source 8.8 +precision. Game state v11/capture v10 migrate older inventory prefixes without +altering their whole units. The unused `charge` field remains separate because +menu selection clears it; it must not hold inventory fractions. + +X1 pickup `$81:E0A9..E0CE` reads the full 8.8 pair and adds `$0100`. At `$1C00` +it clamps, discarding fractional excess from the last increment; `$E0B7..C1` +passes only the remaining whole pickup ticks to auto-refill. Verified port +example: selected 27.5 + small pickup becomes 28; reserve 10.25 becomes 11.25. +The selected weapon's discarded half is original native behavior, not a +fractional-state bug. HUD/menu energy still uses the pair's high byte. + +Normal/charged Ray combat is now implemented. Trail history offsets are +`$06:B7DA` values `-8/-12` bytes; four bytes represent a position and the head +index advances after writing, giving the first/second prior recorded positions. +The port reconstructs these constant-velocity positions from saved ray state, +using original poses 14/15. It does not allocate cosmetic native projectiles. +Normal source angles are 7,9,8,7,9,6,10; left-facing adds 16 modulo 32. Charged +direction indexing increments before lookup. Turret launch uses Y `-44` for +X; Zero adapts that to `-52` to preserve the shared eight-pixel foot-origin +translation. Exact source body-event deployment, destruction effects, source +audio and contact-edge trail comparison remain open fidelity work. + +## X1 charged-beam counter correction + +### Ray burst body comparison (owner playtest follow-up) + +The original normal muzzle actor lasts **60 ticks**, emits at each eighth +tick, and produces **seven rays**. The reference player +$50 timer remains +active through the burst: 58 at trace tick 0, 38 at tick 20, 8 at tick 50, +0 at tick 58, then $FF and idle on tick 59. X3 body poses 49/50 retain the +extended arm. This confirms the earlier projectile lifetime; the port's +character overlay was the part ending early. + +X1's equivalent timer is `$BF8` (player `$BA8 + $50`), consumed by +`$81:9540..9569` / `$956A..957F`. While the normal Ray muzzle exists, set it +to the remaining burst duration before native player animation advances. +This preserves the native movement/firing overlay and Zero's mirrored source +animation, and naturally returns to idle when the burst ends. It is unrelated +to the charged-beam counter `$C25`. Focused checks now inspect the late-burst +body timer and Zero's original pose 50 as well as all seven ray emissions. + +### Damage investigation started with the stats goal + +The existing adapter only substitutes an attack's geometry and uses X1's +ordinary buster damage. That explains the owner's weak-weapon report; these +were never original damage numbers. X3's positive subtraction is +`$84:CF38..CF3D`, using table `$86:E4A5` indexed by enemy +$28 and projectile +class +$0A. X2's equivalent is `$88:DA9B..DB02`, table `$86:F3A4`. +The tables contain per-enemy special responses, immunity and weaknesses; +they are not a single universal number per weapon. + +Private original X3 encounter: actor $1F at $0D18 has 18 HP, damage category +9, table row `$86:E725`. Buster class 0 deals 3; Ray child class $1C deals 5. +Do not confuse the normal muzzle class $0B or turret class $14 with the rays +that actually hit. Source ordinary/boss damage distinctions and multi-hit +cooldowns must be adapted deliberately to X1's enemy HP scale. The stats goal +requests source-based weapon strength; new boss-weakness tables remain out of +scope. The owner was offered normalized buster ratios versus raw source HP +numbers; the owner explicitly selected normalized buster ratios. + +### Normalized source damage implementation + +The ordinary profile uses X2 row `$86:F4C8` (categories 3/4/17/18/19, +also selected by ordinary object initializers, e.g. `$82:B968` and `$84:94E2`) +and X3 row `$86:E55D` (category 2; categories 3..6 agree for the attacks +below). Both use basic buster damage 3. This is a neutral cross-game +adaptation, not a transplant of every enemy-specific response. + +| Attack | Source class | Raw ordinary damage | Buster ratio | +| --- | --- | --- | --- | +| Bubble Splash normal / charged | $08 / $11 | 2 / 5 | 2/3 / 5/3 | +| Magnet Mine normal / charged | $0D / $16 | 5 / 5 | 5/3 / 5/3 | +| Spin Wheel normal / charged | $0A / $13 | 25 / 50 | 25/3 / 50/3 | +| Sonic Slicer normal / charged | $0B / $14 | 4 / 1 | 4/3 / 1/3 | +| Acid Burst blob / charged blob | $07 / $10 | 9 / 9 | 3 / 3 | +| Acid droplet | $18 | 5 | 5/3 | +| Spinning Blade normal / charged | $0A / $13 | 9 / 30 | 3 / 10 | +| Ray Splasher ray / turret contact | $1C / $14 | 5 / 9 | 5/3 / 3 | +| Frost Shield normal / charged / released chunk | $0D / $16 / $21 | 15 / 15 / 9 | 5 / 5 / 3 | + +Multiply by the positive X1 basic-buster value for that enemy. Keep a +remainder in thirds per enemy slot, initially 1, so the cumulative result +rounds to nearest HP without inflating rapid/sub-buster attacks. Rays on a +one-damage-buster target deal 2,1,2 over three contacts; three charged Sonic +contacts total one HP. A fractional contact can flash without removing an +integer HP. Fractions reset on a recycled/healed enemy, stage/reset or +explicit projectile cancellation, and are serialized. + +X1's directory `$86:EF37` has ordinary categories 0..5 and special +encounter/armored categories 6..19. Keep the latter's native positive buster +damage as a neutral 1:1 ratio: the usual nonweak source boss profile is one +for these attacks and one for buster. Native zero/special immunity responses +remain authoritative. No new weakness, armor-breaking or special-response +matrix is imported by this scalar damage change. + +Combat state appends fifteen four-byte damage records after the old 328-byte +prefix (388 bytes total). Game chunk v12 / renderer capture v11 store them; +older game v10/v11 and capture v9/v10 read the original prefix and initialize +empty carry. Original graphics cache stays MMXWEAP4. The source table's first +pointer is **not** its directory length: X2 has 32 entries and X3 has 35. + +### Prior charged-beam counter finding + +RAM `$0C25` increments at `$81:A2E9` and decrements at `$81:A407` when a native +class-3 full-charge beam finishes its disappearance. It is **not** a general +firing-pose timer. The earlier Blade adapter pinned it to two as an attempted +pose hold, leaving a stale count after the host-owned blade retired. That +write is removed. Ray/Blade runtime checks now require zero native charged-beam +count after the extended attacks finish. Own projectile count remains `$0BDD`; +do not confuse these counters. + +## X2 Sonic Slicer + +Normal class `$0B`, group `$41`, DMA `$85:9F83`, animation `$2F:EC62`: +`$81:9622..9666` waits for sequence 0's final flag, then starts sequence 1 +and creates a second blade. Table `$86:B784` gives horizontal speeds 768/896 +and source upward accelerations 4/8 in 8.8 units; initial source Y speed is +-128. Bounds `$86:B77A` are `(0,0,12,8)`. `$81:9667..96A9` reflects off walls; +`$81:96AB..96D5` reflects and halves vertical speed, retiring on the third +vertical contact. Normal impact sequence 4 uses poses 16-21. Normal cost is +**0.5**, verified from the native energy pair and preserved by host fractions. + +Charged class `$14` uses **group `$87`, not `$41`**: DMA `$85:9FBE`, animation +`$2F:D54D`. The initial 205-frame source recording still showed the normal +pair and was not evidence for the charged form. A private 400-frame charge +allowed that pair to retire and produced the real five-blade attack. The +charged group adds 19 locally extracted poses. Pose 18 is the inherited blank +8x8 tile `$45` (all pixels zero); there are only 18 entries in its DMA table. +The descriptor marks that pose as inherited instead of reading past the table. + +`$81:A570..A5BF` sets up the charged form. `$81:A5D2..A613` waits on forming +sequence 0's last flag, starts sequence 1 and spawns four siblings. Source +velocity rows `$86:B9F9` are `(0,2304)`, `(400,2269)`, `(-400,2269)`, +`(787,2165)`, `(-787,2165)`; Y is positive up. Gravity bytes at `$86:BA0D` +are 80,78,78,75,75. `$81:A614..A632` stops horizontal travel at the apex, +switches to sequence 2 (pose 11) and downward acceleration 96. `$A647..A653` +clamps the source Y high byte to `$F8` after movement, preserving its low byte. +Charged blades pass through tiles. Bounds `$86:B9F1` are `(0,-2,10,11)`. +Impact uses sequence 3; charged cost is **2**, separate from the initial +half-unit normal shot that can begin a charge. + +Port checks cover X and Zero, fractional debit and insufficient energy, +charging locked without arms, native floor reflection/third-contact retirement, +five charged arcs/apex transitions, native enemy damage/impact retirement, +slot and charge-audio cleanup, and exact save/replay before splitting and +across the apex. Normal and charged original-art captures were inspected. +The existing combat/save ABI is unchanged. Open fidelity items: normal trail +history (`$81:97B3..97FF`), wall-contact cosmetic effects, source sound import, +and broader terrain/movement comparisons. The shared swept tile solver resolves +contacts in the current tick; the source consumes its preceding contact flags. + + +## X1 gameplay weapon HUD footer (2026-09-29) + +**Superseded by original source HUD extraction below.** The initial repair +described here cropped menu art into X1's border and was rejected by the owner. +It is retained only to explain the mistaken approach; it is no longer used. + +The extended weapon proxy submits slot 7 at (24,80), attribute $3620. Its +buster-loaded tile $20 contains unrelated graphics, so simply drawing the +pause icon lost the gameplay meter enclosure. Reuse the live X1 health footer +tile $86 with palette 2: side columns 1/14, white lower rim and rounded black +outer edge. The X glyph reaches columns 2/13, so the first 10x10 clear left +colored corner remnants. Clear columns 2..13 and rows 2..12 to black index 1; +preserve row 13's lower bevel and the original segment connection. Menu art +has its own border in the outer two pixels. Find the nonblack symbol bounds +only within source x/y 2..13, center them in the 12x11 inset, and reduce only +oversize symbols with centered nearest samples. Derived HUD pixels are built +after asset loading; cache format and original 16x16 pause art are unchanged. +The corrected native-width replay capture was inspected visually. + +### Original X2/X3 gameplay footer correction + +Native OAM slot 7 is (24,80), attribute **$3628 in X2 / $36AC in X3**. +Both are full 16x16 gameplay-specific sprites using sprite palette 3 (CGRAM +entries 176..191), which matches each descriptor's weapon palette exactly. +These are different artwork from the pause icons. Their four tiles already +appear in the weapon-selection bulk DMA lists ($86:9664 / $86:97AD, indexed +by $3E + native weapon ID * 2): + +| Game | Top-row VRAM words | Bottom-row VRAM words | ROM source rows | +| --- | --- | --- | --- | +| X2 | $6280..629F | $6380..639F | Per-weapon seven-byte bulk records | +| X3 | $6AC0..6ADF | $6BC0..6BDF | $2C:87A0 + (ID-1)*$40 / $2C:89A0 + (ID-1)*$40 | + +Decode all four tiles directly from the validated bulk transfer buffer before +per-pose DMA modifies it. Preserve every indexed pixel, including its frame +and transparent corners; render with the source weapon palette, no resizing. +MMXWEAP4 appends 256 pixels after each 356-byte entry prefix (612 bytes before +the groups). It replaces the inferred 12x11 glyph and borrowed X1 border. +User ROM extraction remains automatic and no extracted graphics are committed. + +Validation: all sixteen pairs of top/bottom source DMA rows match live VRAM +from separate paused X2/X3 reference runs; all sixteen palettes match their +source CGRAM. Rendered port footers match every nontransparent pixel of the +original source screenshots exactly. Native/reference extraction parity, +copier-header normalization and wrong-ROM rejection pass, as do the focused +menu and energy/pickup/save checks. X/Zero health emblems remain independent. + +### X1 shoulder cycle reference + +`$81:99D3..9A6F` checks `$1F23`, projectile count `$BDD`, `$1F31` and actions +`$18/$42` before changing weapons. Pressed `$BE2` bits `$10/$20` advance or +reverse; both held `$BDE & $30` return to buster. Ownership bit `$40` in +`$1F86 + native_weapon` filters X1 choices, independent of remaining energy. +HUD `$1F12=0` rebuilds the special meter, `4` removes it for the buster. +Extended selection handles these buttons before the normal player action and +consumes only pressed bits. Clearing held bits would break next-frame edge +detection and repeatedly cycle a held shoulder. Page 0 still uses original code; +pages 1/2 wrap weapon IDs 0..8 without changing page, including fallback entries. + + +## Spin Wheel (X2) + +The source handlers are in **bank $87**, not $81. Normal class $0A uses group +$46, DMA $85:9F2E, animations $2F:EDD2. Entry $87:848D / init $84A8 sets +momentum +$37 to $0400 and starts formation sequence 0. After its end flag, +$8565 starts sequence 1 with VX/VY zero and downward acceleration 64; $8937 +caps falling VY to source -$0400 after movement. Ground contact starts a +30-frame wait ($85BD..8624), then rolling ($863C..86A9). Ground traction +$88A1 adds a signed value from $86:B75E: 16,-20,0,-16,-4,-4 depending on the +source slope orientation; the flat-ground index is verified below. +Airborne momentum loses 4/frame, wall reversals lose 16. A wall stall waits +30 frames while losing 8/frame, then hops with current momentum as upward VY +($872D..87D2). Exhausted momentum enters shrink sequence 10. Enemy contact +has a 10-frame pause with momentum loss ($87D3..8822), not immediate retirement. +Normal collision bounds $86:B754 are (0,0,9,9). Spin sequences 1..4 use poses +6/8/7 at durations 1..4; 5..8 are ground-effect poses 9..13. + +Charged class $13 starts at $87:894E. Sequence 11's flag-1 pose 15 triggers +eight children ($8A13..8A75). Table $86:B99D has signed dx,dy,VX,source VY: +(0,8,0,-1024), (6,6,724,-724), (8,0,1024,0), (6,-6,724,724), +(0,-8,0,1024), (-6,-6,-724,724), (-8,0,-1024,0), (-6,6,-724,-724). +Children use static directional poses 19..26 (sequences 12..19), constant +velocity and terrain-passing movement ($8A95..8A9F). Parent bounds $86:B9DD +are 7x7; children $86:B9E7 are 6x6. Formation emits around release frame 13/14. +The source uses nine actor slots including the visual parent; X1 has eight +projectile slots, so retain all eight directions and render the center burst +cosmetically in the port. Energy costs are verified below. All 27 poses are +already in the extracted cache. Private trace: x2-weapons-reference.json. + +Follow-up verification: private paused X2 PID 56056 / debug port 4394, fixture +0 (the owner's X1 playtest is separate), recorded in wheel-probe.json. Energy +at `$1FC0` changes `$5C00 -> $5B00` on the normal press, then `$5800` on the +charged release after that press: **normal 1, charged 3**. Original charge +formation splits at about release tick 13/14, with all eight original vectors. +Normal formation ends after 29 source animation ticks; gravity 64 reaches a +1024 downward cap. Ground center is eight pixels above the surface (damage +radius is nine), followed by the 30-frame wait before rolling. + +`$88:D6FA..D751` resolves the traction index. Collision classes 1/2, 3/4, +5..8 and 9..12 select slope groups 0..3; flat/ordinary solid selects 4 (right) +or 5 (left), both **-4 momentum per frame**. For positive horizontal velocity, +table `$86:B08F` swaps 0/1 and 2/3. Thus rising half-slopes are -20 uphill, ++16 downhill; rising quarter-slopes are -16 uphill, 0 downhill. `$87:88B1` +caps positive momentum at 1024. Ground wall contact enters a 30-frame stall, +losing 8/frame, then hops with the remaining momentum as upward velocity. +Airborne wall reflection loses 16; ordinary airborne travel loses 4/frame. +`$87:87FB..8822` pauses movement for ten frames after enemy contact, losing +8 momentum each frame, then resumes the preceding movement phase. Exhaustion +plays source sequence 10 before retiring. Spin sequences 1..4 and ground +effect sequences 5..8 are selected by the momentum high byte. + +Port state mapping: normal `muzzle_pose` is form/fall/ground delay/roll/ +airborne roll/wall delay/shrink (0..6), `origin_x` is momentum, `radius` is +the remaining delay, `tether_pose` is the enemy-contact pause, `origin_y` is +ground-effect time. Charged `muzzle_pose` is formation/flight (0/1), `variant` +is direction 0..7, `origin_x/y` retain the center, and direction 0's `radius` +drives the six-frame original flash. Charged `tether_pose` retains the original +center facing. These fields are saved already, preserving the 40/328-byte ABI. + +Enemy-contact correction (owner playtest, 2026-09-29): `$87:849E` dispatches +contact states 4 and 6 to `$87:87D3`, and state 8 to `$87:8823` (which also +enters `$87:87D3` while momentum remains). This overrides **every** movement +phase, including formation. `$87:87EA..87FA` clears vertical velocity and +initializes the ten-tick pause; the following ten updates consume eight +momentum each and restore collision at `$87:8818..8820`. Thus the contact +entry plus countdown occupies eleven updates, and another overlapping hit +can immediately restart it without falling or rolling in between. The old +port excluded formation, never cleared vertical velocity, and consumed the +first countdown tick on entry. Its first-hit-only test did not cover the +reported repeated-hit failure. Private original-runtime trace +`wheel-contact-source.json` injects the original surviving-hit state from +`$88:DB11..DB23` in the empty reference room, verifying both phase entry and +the eleven-update timing; native X1 enemy regression covers real collisions. + +## X3 Frost Shield: verified phases and X1 platform adaptation + +Normal dispatch `$81:A672`, table `$A677`: handlers A683/A702/A80E/A80E/ +A869/A933 for object states 0/2/4/6/8/10. Charged dispatch table `$81:BACD`: +BAD9/BB5B/BD19/BCFE/BD19/BD2A. Source damage code `$84:CF4C..CF60` sets +state 8 after nonlethal contact and 6 after lethal contact. These must not +be conflated: normal state 6 produces the falling core and ice shards; +normal 8 retires through generic impact. Charged 6 retains the arm shield +(except released class $21); charged 8 breaks it. Private X3 contact capture +confirmed 15 raw ordinary damage and retirement on a surviving target. + +Normal `$81:A6C6..A78D` starts horizontal magnitude $10, plays group $10 +sequence 0/2 (air/water), then 1/3. Formation lasts about 70 ticks. Rocket +acceleration is $10/$08, capped at $400/$300. `$A7BF` clears vertical speed +between single-pixel up/down pulses every eight ticks. Normal source `$30` +initial collision grace depends on the first graphics upload; the port uses +the two-tick first-upload grace. Contact core starts with reversed quarter +horizontal speed and upward $300/$200; gravity $20/$18, fall cap $600/$400. +Sequence 5 holds until floor contact, then advances into spike growth 6/8 +and planted loop 7/9 for 240 ticks. Failed formation uses 18/19 for 120 ticks. +Source costs measured at `$1FC7`: one normal, three charged energy units. + +Charged `$81:BAFC..BC29` uses formation 10, dry growth 12, held pose 53. +Collision stays disabled until the full shield. `$BC07` holds for 360 ticks, +then `$BD43` releases class $21, sequence 15 (pose 23), for 180 ticks. Its +initial horizontal/upward speeds are $100/$300, gravity $20, fall cap $800; +ground rolling reaches $300. Bounds are shield (-3,0,10,17), chunk (0,0,11,7). +X3 `$84:CC0E` erases destructible enemy projectiles. The port uses X1's pool +`$1428..1627` and native `$84:9BC8..9C09` eligibility (active, not flag $40, +nonzero damage category), with the actual source shield and projectile boxes. + +Charged water branch `$81:BB8F..BBD3` uses sequence 13/pose 57. `$BC2F..BC99` +rises at acceleration $F0, capped at $100; `$BE34` probes center and center-16 +against the waterline, then holds for 240 ticks. Source bob table `$86:BAE6` +maps to world velocities 0,$20,0,-$20 in 32-tick intervals. X1 water classes +$0D/$0E (`$84:987E`) replace X3's stage-specific `$82:DF16` waterline lookup. +The port extends native terrain return `$84:91DB` (interpreter PC $0491DC), +only for player $BA8 and active water-platform phases. Previous Y is player ++$24 ($BCC); ground contact is +$2B ($BD3), not $BCB. Both translated player +boxes have feet at origin+16. Descending across platform top (center-24) +grounds the player; upward jumps pass through. Rider and platform motion +serialize and replay exactly; ceiling contact prevents pushing into solid tiles. + +Normal shard creator `$81:A93A` selects four class-$37 particles, sequence 4 +(pose 19). Charged `$BD9E` selects 14/17 (air pose 12/water pose 62). +Shared initializer `$82:FD23..FD94` uses $86:DAE6/$DAF6 velocity rows and +gravity $30. Original particles use 32-byte slots `$1818..1D17`. The port +uses those art/velocity rows, deterministic saved-frame selection, and the +available X1 projectile slots; it does not reproduce the source RNG stream. + +Saved Frost fields: `muzzle_pose` normal form/rocket/core/land/grow/spike/ +failed/cleanup (0..7), charged form/grow/shield/chunk/cleanup/rise/float +(0..6); `variant` air/water/cosmetic shard (0/1/2); `origin_x` phase lifetime, +`radius` steering clock, `tether_pose` rider flag. No new save fields required. +Cleanup slots have no hitbox and are hidden while source shards render. + +X1 action table `$81:82A6`: `$0A` is the four-frame landing state +(`$81:8600..8658`); `$0E` is hurt (`$84:9F2F..9F3F`), `$0C` death. +Frost and Ray firing holds now test the actual hurt state, preserving landing. +Focused ROM checks cover both characters, terrain phases, charge gate/costs, +shield blocking/contact responses, expiry/cleanup, and a private controlled +water fixture for native platform landing, riding, jump-off and exact replay. +Full water-stage traversal and original audio remain follow-up fidelity work. + +## X2 Bubble Splash: continuous fire and charged movement records + +Normal class $08, group $44, DMA `$85:9D7A`, animation `$2F:ECE0`, 32 poses. +Entry `$81:8FDB`, init `$8FEC..9087`, update `$908B..90E4`, pop `$90E5..9123`. +Body helper `$88:C452..C474` creates a fire edge every two ticks while held, +unless full-charge palette state +$5B is 1. `$1F64` bits 1..7 enforce seven +bubbles; `$81:9155` allocates the lowest free identity. Source private capture +confirmed seven normal bubbles and 8.8 energy decrements of $20 per bubble. + +Initial horizontal magnitude is $100 + RNG byte + half the player's actual +horizontal displacement ($81:9026..904D); initial VY is zero. Sequence 1's +end flag starts a varied sequence selected by `$86:B6B2` (2..10). Upward +acceleration is $0C in air, $40 in water ($81:9170..9180); formation initially +moves horizontally only. After growth's end flag, hold for RNG($0F)+6 ticks, +then pop using `$86:B6C2` (sequences 11/12/13). Damage bounds `$86:B6AE` +are (0,0,7,7). Source normal update has no solid-terrain clipping. Enemy +contact states 4/6 go to pop, as does timeout. Cosmetic class-$31 spray on +early contact is not yet ported; actual pop art is. + +Charged class $11 manager `$81:9F1D..A019`, child `$81:A01A..A121`. +Manager emits at most one child per tick, replenishing the seven source +identity bits. `$9FC5..9FDF` subtracts $20 for each child. Private capture: +release consumes $20 activation + $20 first child; six subsequent child +creations subtract $20 each, then replacements continue to drain energy. +Manager persists until depletion, death or weapon cancellation. + +Children start at parent (8,16), hidden for RNG($3F) ticks, then follow the +shared `$86:B85D..B90B` path. Twenty-nine six-byte records each specify two +ticks, display flags, signed VX and upward-positive VY. All seven pointers at +`$86:B84F` select the same route. Source growth sequences `$86:B841` are +2/3/5/6/8/9/8; impact sequences `$86:B848` are 13/11/12/13/11/12/13. Child +movement includes parent displacement (`$81:A06E..A088`), permitting running +and jumping with the cloud. Completion retires the child and frees its identity. + +`$81:9FF3..A00D` lifts an airborne underwater player three pixels per tick. +The port uses X1's water class lookup and actual airborne action 6, with a +ceiling probe for the translated X/Zero terrain box. Persistent cloud can +be canceled through the selected page's shoulder cycle. Hidden manager and +delayed/popped bubbles have no damage hitbox. Actual source bounds are 7x7. + +Saved fields: normal `variant` growth sequence, `radius` final hold, +`tether_pose` identity; charged `variant` manager 0/child 1..7, `radius` +initial delay or route index, `tether_pose` two-tick route timer, +`origin_x/y` previous parent position. `muzzle_pose` tracks formation/wait, +growth/route, normal final hold and pop. Inventory `cooldown` holds repeat-fire +cadence. Existing 40-byte shot / 388-byte combat layout remains unchanged. +RNG uses `$88:CB42` arithmetic seeded from saved port tick/identity; identical +source RNG consumption is not claimed. Original art/palettes remain ROM-derived. + +### Full special-weapon charge is a player marker, not a buster class + +X1 `$81:98A7..98AC` writes player +$59 from `$86:BA71`; `$81:94FD..951F` +explicitly compares that field to 4 to choose a charged special weapon. +At player $BA8 this is `$C01`. The previous port checked buster class 3; +that class occurs at the intermediate arm-upgraded tier (about 100..179 +held ticks). A full 180+ hold instead produces buster class 2, so the prior +check incorrectly reverted extended weapons to normal on a longer hold. +Use `$C01 == 4 && ($1F99 & 2)` at `$81:94AF`, the common shot-selection entry. +The moving/air firing routine clears +$59 at `$81:93EA` before calling the +later allocation hook `$81:9D47` at `$81:93F8`. Thus checking the marker only +at $9D47 works while standing but silently loses charged moving/air releases. +Capture the boolean in the allocated slot's host state at $94AF, then retain +it when $9D47 marks the completed native projectile. Native +pose, charge effects and release cleanup continue to run. Charged checks for +all implemented weapons now use 205+ ticks, plus a 120-tick normal-release +check. This change applies only to extended weapons, not Zero's base combo. + +## Speed Burner: projectile, ground flames, water and charged body dash + +X2 weapon ID 8 uses normal group $25 (17 poses) and charged group $26 +(3 poses), DMA roots $85:A139/$A1AA. Normal initializer $82:ADCA..AE51 +sets class $0E, VX +/-$0500, VY 0 and sequence 6. Sequence 6 grows through +poses 6/7/8; its final flag transitions to looping sequence 7 (poses 9..11). +Hitbox phase flags index $86:B7B4: (0,0,6,7), (0,0,9,8), (0,0,10,12), +(0,0,12,16). Native contact uses sequence 8, poses 12..16 ($82:AF16). +The fireball itself travels through terrain; $82:AF7B probes 24px below it +every four global ticks to spawn class $0E subtype 1 ground fire. + +Ground fire starts sequence 1, downward velocity $0400, 60-tick timeout, +and no damage until landing. $82:AE9A..AF15 rolls it at +/-$0200 once grounded, +with a new 60-tick timer and box (3,-1,9,7) at $86:B7C4. Missing ground, +side contact or timeout starts sequence 2 burnout; it continues horizontal +motion during burnout. The port uses live X1 collision classes/slopes. + +Sparkles are cosmetic class 6, emitted every four ticks by $82:AFB9. +$82:AAA4..AB36 selects group $25 sequences 3/4 (poses 4/5), 32-tick life, +random signed subpixel VX, vertical acceleration 4..11, starting Y +/-16, +and alternate-global-tick visibility. They use sixteen independent host +effect slots and source RNG arithmetic seeded by serialized simulation state. +This extends combat state after its previous 388-byte prefix; game v13 and +capture v12 preserve particles, while older formats initialize empty effects. + +Water check $82:B018..B04B recognizes classes $0D/$0E or the source waterline; +it changes the normal actor to class $23, pose 6 (sequence 5). Two visual +children orbit using the eight two-tick records at $86:B5A7, starting offsets +0/24 from $86:B5D8. The port uses X1's live water classes. Raw ordinary damage +in $86:F4C8 is 5 for class $0E and 1 for class $23, versus buster 3. + +Charged projectile entry $82:B04C initializes class $17 and follows the +player's actual X/Y ($82:B10C), with hitbox (0,4,20,20) at $86:BA39. +It remains active only during X2 actions $5C/$5E ($82:B11B), alternates +collision each tick, and uses group $26 sequence 0. In water $82:B0F9 +disables collision and clears the player's damage guard; it emits ambient +bubbles through $82:B148. Those ambient bubbles remain an explicit TODO. + +Body handlers $88:AC7E/$ACFD use timers $30 ground and $18 air, decrementing +through zero; shared $88:AD8F sets speed $0475 (4.45703125px/tick). +Ground jump, loss of ground, walls and opposite input end the source dash. +Air keeps height. The private original-runtime trace confirms speed -1141 +in 8.8 units while facing left, source pose/action $5C, and one/three energy +cost (initial press plus full release takes $5C00 to $5800). +Raw class $17 ordinary damage is 1, so the port preserves 1/3 buster per +eligible contact, not a guessed large dash hit. + +X1 adaptation keeps action $14, original body/armor animation and native +collision/camera. The bounded $82:823E pre-movement hook supplies the X2 +speed/countdown even on native dash initialization. Air temporarily supplies +the native dash's ground prerequisite, retains the source height, then clears +that prerequisite. Native flags remain responsible for walls and transitions. +The dry damage guard is removed on every retirement/cancellation path. +Both X and Zero use their existing dash art under the original flame effect. + +## Original shared effects and group palettes (MMXWEAP5) + +Magnet Mine's blast switches from group $0F to the shared X2 group $08, +sequence 5 (`$82:AD19..AD1F`). The weapon sheet alone cannot draw that art. +Its nine used poses 5..13 live in the persistent object CHR at VRAM +$D000..DFFF, decoded from compressed resource $0A in the `$86:FA01` directory. +The resource expands to 4096 bytes and exactly matches the original runtime's +corresponding CHR. All nine decoded poses were compared against that runtime +capture. Palette 2 is ROM file offset $2B220, rather than Magnet Mine's +palette 3 ($2B3A0). No hand-drawn explosion or menu-image substitution is used. + +Address-only descriptors now support `static_resource`/`static_offset` and +an optional group palette address. Both extractors decode the common LZ +resource before weapon transfers. The mine gains a third group; unused poses +0..4 remain empty, while animation sequence 5's nine original poses are kept. + +MMXWEAP5 retains the 612-byte entry prefix (body/weapon/menu palettes and +menu/HUD pixels). Each group has its existing six-byte header, then 32 bytes +of source palette, followed by the animation bytes and pose records. The +loader supports three groups per weapon. This is an asset-cache revision, +with no change to game saves or renderer captures. Automatic extraction +writes `x2-weapons-v5.bin` / `x3-weapons-v5.bin` from the user's original ROMs. +The combined cache is 440,617 bytes with 600 nonempty extracted poses. +Native/reference extraction parity, copier headers, wrong-ROM rejection and +preservation of an existing cache on extraction failure pass. + +## X2 Magnet Mine: steering, planting, chains and absorption + +Playtest correction: normal group `$0F` uses the static weapon-selection +graphics for all sixteen layouts. `$82:AC19..AD36` changes animation without +calling `$88:C9B8` (per-pose DMA). The actor's inherited `$31=$9BFC` belongs +to common-effect data and must not be treated as this group's DMA directory. +Doing so overwrote the small red/blue mine with unrelated purple fragments. +All normal poses now retain the original selection CHR; charged group `$13` +still uses its explicit `$85:A0B1` transfers, and explosions retain resource +`$0A`. This correction applies to automatic native ROM extraction as well as +the developer Python extractor. + +Normal class $0D dispatch `$82:AC08`, table `$AC0D` contains +AC19/AC5E/ACDC/ACDC/ACDC/AD36 for states 0/2/4/6/8/10. All hit outcomes +therefore enter the explosion routine. Initial horizontal speed is $200, +vertical zero, group $0F sequence 1. `$88:E6C4..E6F8` steers by $80 per tick +to vertical caps +/-$400 and does not damp after release. `$82:AC81..AC9E` +tests other projectiles (`$AD86`) and static terrain (`$88:CD5E`); contact +stops translation and starts sequence 2. At its end flag, +$37 becomes $3C +and source player +$35 decrements, freeing the one-flying-mine limit while +the planted slot stays occupied. Private source body +$67 confirms limit 1. + +The 60-tick planted delay enters `$ACDC`; `$AD00..AD23` switches to group +$08/sequence 5 and damage box `$86:B7B0` = (0,-8,17,18). Flying box `$B7A6` +is (0,0,4,4). Explosion phase survives repeated contacts via source +$3A. +`$AD44..AD85` checks overlapping player projectiles at blast completion and +starts their impact states, creating a delayed chain reaction. The port chains +adjacent mines and permits further normal fire after planting, while keeping +X1's native slot count balanced. Damage contact is enabled on alternating +ticks, with hit deduplication reset between eligible contacts. + +Charged class $16, group $13, entry `$88:E57E`, init `$E590..E5DC` uses +horizontal $80 and zero VY. It reuses the same steering helper, passes through +terrain and survives enemy contact. `$E67A..E6C3` pulls each destructible enemy +projectile by signed 5/3 pixels toward the mine. `$88:D81F..D864` consumes +at most one colliding eligible projectile on alternating active-contact ticks. +`$E623..E64D` grows at 16 and 32 absorptions, selecting sequences 1/2 and +boxes `$86:BA2D/$BA31/$BA35` = radii 8/16/24. Counter/size, animation and +position serialize. Source armor bit `$1FD0 & 4` can convert absorptions to +another weapon's energy; that armor feature is outside this boss-weapon pass. + +Private X2 left-facing capture confirmed VX -512 normal, -128 charged; +ten UP ticks reached upward 1024 and retained it. Energy `$1FC6` went +28 -> 27 normal and 28 -> 24 for normal initial press plus charged release, +confirming costs 1/3. Both damage classes read 5 against ordinary buster 3. +Source display bounds `$80:D859` remove origin outside camera X [-32,288) +or Y [-16,240); the port uses those same bounds for this slow attack. + +Port saved fields: normal `muzzle_pose` flight/arming/planted/blast (0..3), +`radius` planted timer; charged `variant` size tier, `radius` absorbed count. +Normal group changes $0F->$08 for explosion; charged uses $13 throughout. +No gameplay save-size change. Focused checks validate X/Zero steering/inertia, +native terrain planting, additional fire after planting, chain timing, arms +gate/costs, 5/3 attraction, immune projectile exclusion, 16/32 growth, native +enemy damage, deterministic replay and cleanup. Moving stage actors and source +audio remain fidelity follow-up work. + +## X1 buster limit after cycling imported weapon sets + +X1 `$81:9A49..9A50` restores player `+$67` (`$0C0F`) from `$86:BAB8` +when changing weapons. The buster entry is 3. Firing at `$81:93B5..93B9` +compares the active projectile count to that limit and rejects all shots if +the limit is zero. The imported L/R adapter previously cleared `$0C0F`; +returning to buster therefore displayed the correct selection but prevented +both X and Zero from firing. It now restores the native buster limit. A ROM +regression reproduces the failure before the fix and verifies actual shots +after pause selection, forward/backward cycles, both shoulders, and the X1 +cycle, with both characters and both imported sets. + +## Utility weapon completion records + +The final three normal/charged ports have dedicated source records: + +- [Crystal Hunter](weapons/crystal-hunter.md): freeze response, original shard + velocities, native collision/platform/death adapter and source HDMA time effect. +- [Gravity Well](weapons/gravity-well.md): normal capture/dissolve and return, + original ground/air casting poses, charged lift, particles and protected actors. +- [Parasitic Bomb](weapons/parasitic-bomb.md): preserved enemy art, three payload + responses, four-cursor search/lock, per-unit energy, normalized damage and + original shared hit effects. + +All sixteen weapons now have normal and charged gameplay paths. These records +separate observed source behavior from X1 enemy/stage and Zero-body adaptations. +The shared combat state stays 1,028 bytes; no new snapshot ABI is required by +these three ports. Remaining audio/stage compatibility work is listed in the port document. + +## Final source particle pass + +Speed Burner underwater dash emitter X2 `$82:B148..B191` spawns class `$31` +every second frame. X is offset by source RNG &15; Y by (RNG &31)-16. A private +native-runtime trace confirms group `$17` sequence 0, poses 3..7/31, 26 display +ticks and upward acceleration `$10/256` from rest. Source resource `$0A` supplies +the inherited tiles at OBJ `$6800`; the original runtime palette matches ROM +`$2B220`. These bubbles now render in independent saved visual slots, without +the normal fire sparkle's alternating-frame flicker. Deterministic saved tick/ +slot sampling replaces the source RNG stream, consistent with other particles. + +Fang's original contact debris and final puff are described in +[its source record](weapons/tornado-fang.md#contact-particles). All eight debris +poses match source live VRAM. Both particle families have focused gameplay and +snapshot-replay checks and inspected original/port renders. Native/Python +extraction parity passes with 691 populated source poses and a 501,828-byte +combined local cache. No ROM or generated graphics are committed. diff --git a/docs/zero-port.md b/docs/zero-port.md index 50396c8..b217951 100644 --- a/docs/zero-port.md +++ b/docs/zero-port.md @@ -59,7 +59,7 @@ 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` +after accepting an optional 512-byte copier header. Its current `MMXZERO7` cache contains: - 117 body poses (group `$4A`), 21 saber-body poses (`$4B`) and 14 blade poses @@ -70,6 +70,9 @@ cache contains: - 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. +- Three original charge-flash palettes and 66 charge-particle poses (three + groups of 22 frames). Legacy v6 caches remain readable; the launcher + regenerates v7 from the user's X3 ROM to supply the new presentation. The loader rejects a wrong header, dimensions/counts, truncated/trailing data, invalid pixels, invalid bounds and invalid animation/muzzle entries. Missing @@ -165,6 +168,17 @@ 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. +Ride Armor uses X1 player sprite group `$6B`, not the regular movement group. +Its pose indices previously drove unrelated Zero run/jump frames and drew a +full body over the cockpit. The compositor now uses that group's original +pilot head placement for each boarding, walking and punching frame. Zero's +original standing helmet/shoulder pixels occupy the exposed pilot area; the +cockpit conceals the body. This is an adaptation: vanilla X3 prevents Zero +from entering Ride Armor (`$83:9639`) and does not provide a dedicated Zero +pilot sprite set. No fan-mod art is used. A private copy of the owner's slot +05 checked boarding, idle, movement, punch and exit; rendered captures were +reviewed. Normal body animation resumes when X1 leaves group `$6B`. + ### Select and independent HP Select must be a new press during grounded, stationary, idle gameplay, outside @@ -311,7 +325,7 @@ Open Zero acceptance work after the weapon priority: 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 + saber/source audio, 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 @@ -319,6 +333,26 @@ Open Zero acceptance work after the weapon priority: 4. Mod/save compatibility UX: warn before loading incompatible mod states. Source-ROM setup and clean distribution staging are implemented for 0.0.1. +### Penguin canister foreground overlap (2026-09-29) + +Tracked in `beads-8wg.1.49`. The owner's `Slot 6 loaded` screenshot corresponds +to `save6.sav` (the shared menu/OSD uses zero-based slots). At this position, +native foreground tiles mask part of the player at the snowy canister. Zero's +wider firing pose makes the partial overlap more visible than X's. + +A private copy was captured as Zero and as X. Rendering the exact same Zero +capture with original X art and comparing against the captured native PPU +frame produced **zero differing pixels around the player and canister**. All +68 differences in that comparison were confined to the imported weapon HUD +footer at `(27..39,80..95)`. This is authored background priority, not the Zero +compositor dropping body pieces. Preserve native layer order; no global +foreground-priority override was added. A more coherent canister mask remains +optional visual polish, separate from the accepted weapon integration. + +`MMX_WEAPON_STAGE_TEST=1` with `MMX_FOREGROUND_FIXTURE` set to a private copy +and `MMX_ZERO_TEST_CAPTURE` set to an output prefix captures both characters +for repeatable review without touching the owner's session. + 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. @@ -344,3 +378,54 @@ 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. + +### Charge presentation and cancellation follow-up (#55 / #56) + +GitHub [55](https://github.com/mstan/MegaManXSNESRecomp/issues/55) reports +overlapping charge effects after hurt or character exchange; +[56](https://github.com/mstan/MegaManXSNESRecomp/issues/56) reports orange-only +charging without X1 arms. Central tracking: `beads-8wg.1.44`. + +Original X3 Zero was visually reviewed in a private original-game fixture, +including held charge, both beam releases, prolonged stored-saber idle and +saber use. His body flashes blue at the earlier charge stages, purple at the +double-shot stage, and green at full saber charge. Green continues to alternate +with the normal body palette after the beams have fired, until the saber is +used or the charge is cancelled. The orbiting particles disappear on release; +stored readiness is a body flash, not another particle cloud. + +Bounded original-USA source references: + +| Source | Finding | +| --- | --- | +| X3 `$84:ABC3..AD75` | Charge counter `player+$57`; thresholds `$14/$50/$8C/$C8`, saturated `$C9`; Zero's saber flag `+$B7` | +| X3 `$84:AF13..AF40` | Palette flash clock; toggles pair index `+$82` with XOR 2, reloading every two ticks | +| X3 `$86:B3B4` | Palette key pairs; **data bank is `$86`**, not the calling code bank `$84` | +| X3 `$86:8180`, `$81:804A` | Key `$136` = blue `$8C:AF60`, `$138` = purple `$8C:AF80`, `$13A` = green `$8C:A5E0`; 16-color body destination `$90` | +| X3 `$81:84C1..8577` | Charge particle actor class 1; groups `$6F/$70/$71`; follows the player and retires for stored-saber state `$0C` | +| X3 `$3F:DA87` | Shared particle animation: 22 one-frame poses and loop | +| X3 `$86:F732`, resource `$0A` | Compressed common CHR; 4096 decoded bytes at OBJ tile-bank offset `$1000` (VRAM word `$6800`) | +| X3 `$8C:B100`, `$8C:B0E0` | Particle base palette (key `$14`) and palette-2 variant; `$70/$71` use palette 2 before full saber charge. Key `$D2` / `$8C:B5A0` belongs at palette 3 for Zero's saber, not charge palette 0 | +| X1 `$82:82ED` | Native charge allocation searches **all** 32-byte slots `$0C98..0E17` | +| X1 `$81:9890` | Charge-loop stop command `$17`; cancellation must also retire live particle actors | + +The old port fed X1's visual charge state, whose final stage depends on X1 +arms, while Zero's actual combo used independent X3 thresholds. The renderer +also recognized only slot `$0C98`, and cancellation left small charge actors +alive. The fix extracts the original X3 presentation, gives it one animation +clock, and removes class-1 charge effects across the full native small-actor +pool on release/cancellation. Unrelated small effects and special-weapon +charging retain their native handling. The previously unused `cooldown` byte +is now `charge_phase`; the 40-byte save/capture layout and old-state prefixes +are unchanged. Existing charge thresholds, beam timing and X1 arm gating for +special weapons are unchanged. + +The native extractor and Python reference produce identical v7 caches; +ROMs, decoded images and private visual captures remain excluded from Git. +Focused runtime checks pass without/with arms, stored saber after both beam +lifetimes, exact save/replay of a held full charge, hurt while holding fire, +and both exchange directions followed by held-fire charging. The complete +existing Zero runtime suite and Zero/renderer CTests also pass. Native X3 +captures and corresponding port captures were visually inspected; this caught +and corrected the distinction between common charge palette 0 and Zero's +saber palette 3 before completion. diff --git a/docs/zero-weapons-coop-roadmap.md b/docs/zero-weapons-coop-roadmap.md index d367d27..fe9c265 100644 --- a/docs/zero-weapons-coop-roadmap.md +++ b/docs/zero-weapons-coop-roadmap.md @@ -1,10 +1,12 @@ # X1 character, weapon, and co-op roadmap -Owner requirements recorded 2026-09-28. This is the durable scope document; +Owner requirements recorded 2026-09-28 and updated 2026-09-29. 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. +worktree: `../_wt_mmx_zero`. The owner approved merging the current weapon mods +after investigating the reported Penguin canister clipping, then starting couch co-op. +Remaining source-audio and contact-audit refinements are separate follow-ups. ## Order of work @@ -81,6 +83,13 @@ 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. +The fidelity reference is **X using each weapon in its original game**. Zero +using X1/X2/X3 boss weapons here is an intentional adaptation, not a claim of +native X3 Zero functionality. Preserve source projectile art, attack timing, +relative damage and effects; adapt Zero's poses, muzzle positions and body-driven +actions to fit this mod. Do not use Zero firing boss weapons in X3 as a reference +requirement (owner clarification, 2026-09-29). + | Source | Weapons | | --- | --- | | X2 | Crystal Hunter, Bubble Splash, Silk Shot, Spin Wheel, Sonic Slicer, Strike Chain, Magnet Mine, Speed Burner | @@ -91,8 +100,14 @@ X3 combat port does not itself supply these boss weapons. - 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. +- The first combat pass used X1 buster damage as a placeholder. The owner's + subsequent explicit goal includes all remaining weapon animations and stats: + compare source damage and restore each weapon's relative strength, with + normalization to X1's HP scale as explicitly selected by the owner. The + subsequent explicit exceptions map Speed Burner to X1 Fire Wave reactions + and Triad Thunder to Electric Spark reactions, including Armadillo's armor + break. Other new boss weaknesses remain out of scope. Resolve per-hit, repeated-hit and effect-only behavior + from source code/runtime; do not count generic buster damage as completed stats. - 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, @@ -102,7 +117,14 @@ X3 combat port does not itself supply these boss weapons. - 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 +Current checkpoint: all sixteen weapons have normal and charged gameplay +implemented, including source-relative damage and the three utility weapons. +See [port status and remaining fidelity work](x-weapons-port.md) for remaining +refinements. On 2026-09-29 the owner accepted this checkpoint for integration +and authorized co-op next; original SPC sounds and the broader timing audit +remain tracked separately in `beads-8wg.1.46` and `.47`. + +## Next: separate simultaneous couch co-op mod Tracking: `beads-8wg.1.34`, dependent on completing `beads-8wg.1.32`. @@ -110,34 +132,63 @@ 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. +- Add a second controller/player input assignment in recomp-ui. The existing + SNES profile already supports two ports; first verify whether MMX only needs + to advertise `num_players=2` before changing shared UI code. +- Player 1 starts alone. A connected/mapped player 2 presses Start to join; + the character not currently on screen joins (X or Zero). This supersedes the + earlier rule that both players always spawn together at initial stage entry. +- Briefly freeze gameplay during joining and use the character's original + stage-teleport assets. Find clear ground to the left or right of player 1 + within the current screen. If no safe space fits the incoming body, reject + the join and play the existing wrong-save/password sound. Do not force the + character into terrain or relocate player 1. - 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. +- Independent weapon selections and weapon energy are confirmed. Each player's + L/R gameplay cycling stays within that player's selected X1/X2/X3 set. +- HUD order from left to right is **P1 health, P1 weapon, P2 health, P2 weapon**. + Reserve the weapon-bar column as an empty gap when that player uses the + buster; do not shift the following bars. This supersedes the earlier HUD order. +- Either present player may pause using Start and operate their own equipment + menu. P2 Start while absent is a join request, not P1's pause command. +- Players can overlap each other; there is no player collision or friendly fire. - 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. +- For a boss door, the character who touches the trigger owns the original + scripted walk. The other living player teleports out and back near the + triggering player before the fight. Use the correct blue/red original + character teleport assets. Apply the same ownership principle to forced + cutscenes; keep native stage/script progression authoritative. - 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). + Health and weapon-energy pickups affect only their collector. No inactive + or partner healing. Fallen players cannot be revived by a scene transition. - 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. +Confirmed in the owner's up-front replies: co-op remains mutually exclusive +with Select exchange, SELECT switching is disabled, fallen players cannot +rejoin mid-stage, weapon energy/selection are independent, and either player +can pause for their own equipment. + +Implementation defaults to document and validate: + +- Preserve shared unlocks/progression and a shared team life count; spend one + life on a full team wipe and restart at the native checkpoint. +- Keep pause input owned by its opener until that player resumes. Subtank use + heals its user. The owner confirmed separate subtank reserves, with shared + tank unlocks and other upgrades. Pause safely if an active controller disconnects. +- The owner confirmed a configured P1 character, with the other character + reserved for P2. The brief fixed-X/Zero answer was explicitly withdrawn; + either X or Zero must be selectable for P1. - 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. +Scope is couch co-op first. Consume the engine's two-port simulation inputs and +serialize all player/weapon/join/camera state so future snesrecomp netplay can +reuse the same deterministic simulation. Network compatibility must be tested +later; it is not guaranteed merely by using the existing transport. diff --git a/mods/preloaded/packages/megaman-x.weapons.x2/0.1.0/manifest.toml b/mods/preloaded/packages/megaman-x.weapons.x2/0.1.0/manifest.toml new file mode 100644 index 0000000..ae505ae --- /dev/null +++ b/mods/preloaded/packages/megaman-x.weapons.x2/0.1.0/manifest.toml @@ -0,0 +1,36 @@ +format_version = 1 +id = "megaman-x.weapons.x2" +version = "0.1.0" +name = "X2 Weapons (experimental)" +author = "mstan" +description = "Adds Mega Man X2 boss weapons to X1. Weapon implementation is in progress." +license = "MIT" +resolver = "declarative" +save_compatibility = "requires-same-mods" + +[[target]] +game_id = "megaman-x-us" +rom_sha256 = "b8f70a6e7fb93819f79693578887e2c11e196bdf1ac6ddc7cb924b1ad0be2d32" + +[[feature]] +id = "weapons" +name = "X2 boss weapons" +description = "Unlock X2 weapons for X and Zero. Cycle pause pages with L/R. Charged attacks require X1's arm upgrade." +group = "Weapons" +default_enabled = false + +[[resource]] +feature = "weapons" +id = "x2-rom" +label = "Mega Man X2 ROM" +description = "Select your original Mega Man X2 USA ROM." +format = "file" +file_patterns = "*.sfc,*.smc" +file_description = "Mega Man X2 USA ROM" +shared_key = "megaman-x.source.x2" +normalized_sha256 = "f3246755f608a1e1dc9c848b61da3b824c7853b29b3be40df6fc7f2793a887ed" +required = true + +[[plugin]] +feature = "weapons" +id = "megaman-x.weapons.x2" diff --git a/mods/preloaded/packages/megaman-x.weapons.x3/0.1.0/manifest.toml b/mods/preloaded/packages/megaman-x.weapons.x3/0.1.0/manifest.toml new file mode 100644 index 0000000..bebf083 --- /dev/null +++ b/mods/preloaded/packages/megaman-x.weapons.x3/0.1.0/manifest.toml @@ -0,0 +1,36 @@ +format_version = 1 +id = "megaman-x.weapons.x3" +version = "0.1.0" +name = "X3 Weapons (experimental)" +author = "mstan" +description = "Adds Mega Man X3 boss weapons to X1. Weapon implementation is in progress." +license = "MIT" +resolver = "declarative" +save_compatibility = "requires-same-mods" + +[[target]] +game_id = "megaman-x-us" +rom_sha256 = "b8f70a6e7fb93819f79693578887e2c11e196bdf1ac6ddc7cb924b1ad0be2d32" + +[[feature]] +id = "weapons" +name = "X3 boss weapons" +description = "Unlock X3 weapons for X and Zero. Cycle pause pages with L/R. Charged attacks require X1's arm upgrade." +group = "Weapons" +default_enabled = false + +[[resource]] +feature = "weapons" +id = "x3-rom" +label = "Mega Man X3 ROM" +description = "Select your original Mega Man X3 USA ROM. This selection is shared with Zero." +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 = "weapons" +id = "megaman-x.weapons.x3" diff --git a/src/main.c b/src/main.c index abcaf8d..4c9d993 100644 --- a/src/main.c +++ b/src/main.c @@ -12,6 +12,8 @@ #include "mmx_display.h" #include "mmx_renderer.h" #include "mmx_zero.h" +#include "mmx_weapon_combat.h" +#include "mmx_weapons.h" #include "snes/cart.h" #include "mmx_spc_player.h" #include "mmx_default_config.h" @@ -35,7 +37,7 @@ 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 || MmxZeroEnabled()); + g_mmx_custom_renderer = !MMX_VARIANT_JP && (g_config.widescreen || MmxZeroEnabled() || MmxWeaponsEnabled()); g_mmx_custom_view = MmxRendererViewport(g_mmx_custom_aspect, dw, dh, SnesDisplayAspect_Clamp(g_config.display_aspect)); if (!g_config.widescreen) { @@ -59,8 +61,10 @@ 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) + if (g_snes->cart) { MmxZeroSetCollisionRom(g_snes->cart->rom, g_snes->cart->romSize); + MmxWeaponsCollisionRom(g_snes->cart->rom, g_snes->cart->romSize); + } if (g_mmx_custom_renderer) MmxRendererLatchSprites(); } static void MmxResetRenderer(void) { diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index 68d4546..ddf7fb5 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -3,6 +3,8 @@ #include "mmx_wide_policy.h" #include "mmx_render_assets.h" #include "mmx_zero.h" +#include "mmx_weapons.h" +#include "mmx_weapon_combat.h" #include /* Mode-1 decode/composition follows SuperMetroidRecomp's sm_renderer.c. @@ -31,6 +33,8 @@ typedef struct Frame { } Frame; static Frame frame; static MmxZeroState frame_zero; +static MmxWeaponsState frame_weapons; +static MmxWeaponCombatState frame_weapon_combat; static Piece building[MAX_PIECES], latched[MAX_PIECES]; static unsigned building_count, latched_count; static uint8_t building_stage, latched_stage; @@ -233,7 +237,7 @@ void MmxRendererRecordPiece(const uint8_t ram[0x20000], uint16_t d) { ram[d + 0x10], animation, current_object); } void MmxRendererLatchSprites(void) { - if (MmxZeroSwapping() && !building_count) return; + if ((MmxZeroSwapping() || MmxWeaponsTimeActive()) && !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. */ @@ -284,7 +288,9 @@ void MmxRendererBeginFrame(const uint8_t ram[0x20000]) { frame.valid = false; frame.captured = 0; memcpy(frame.ram, ram, sizeof(frame.ram)); frame_zero = MmxZeroGetState(); - if (MmxZeroSwapping() && !latched_count) { + frame_weapons = MmxWeaponsGetState(); + frame_weapon_combat = MmxWeaponsGetCombatState(); + if ((MmxZeroSwapping() || MmxWeaponsTimeActive()) && !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); @@ -346,9 +352,11 @@ bool MmxRendererSaveCapture(const char *path) { if (!frame.valid || !path) return false; FILE *f = fopen(path, "wb"); if (!f) return false; - uint32_t header[] = {0x4d4d5843, 7, sizeof(frame) + sizeof(frame_zero)}; + uint32_t header[] = {0x4d4d5843, 12, sizeof(frame) + sizeof(frame_zero) + sizeof(frame_weapons) + sizeof(frame_weapon_combat)}; 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; + fwrite(&frame_zero, sizeof(frame_zero), 1, f) == 1 && + fwrite(&frame_weapons, sizeof(frame_weapons), 1, f) == 1 && + fwrite(&frame_weapon_combat, sizeof(frame_weapon_combat), 1, f) == 1; return fclose(f) == 0 && ok; } bool MmxRendererLoadCapture(const char *path) { @@ -358,12 +366,19 @@ bool MmxRendererLoadCapture(const char *path) { uint32_t h[3] = {0}; frame.valid = false; memset(&frame_zero, 0, sizeof(frame_zero)); + memset(&frame_weapons, 0, sizeof(frame_weapons)); + memset(&frame_weapon_combat, 0, sizeof(frame_weapon_combat)); bool ok = fread(h, sizeof(h), 1, f) == 1 && h[0] == 0x4d4d5843 && ((h[1] == 2 && h[2] == sizeof(frame)) || (h[1] == 3 && h[2] == sizeof(frame) + MMX_ZERO_LEGACY_STATE_SIZE) || (h[1] == 4 && h[2] == sizeof(frame) + MMX_ZERO_ANIMATION_STATE_SIZE) || (h[1] == 5 && h[2] == sizeof(frame) + 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))) && + (h[1] == 7 && h[2] == sizeof(frame) + sizeof(frame_zero)) || + (h[1] == 8 && h[2] == sizeof(frame) + sizeof(frame_zero) + MMX_WEAPONS_LEGACY_STATE_SIZE) || + (h[1] == 9 && h[2] == sizeof(frame) + sizeof(frame_zero) + MMX_WEAPONS_LEGACY_STATE_SIZE + MMX_WEAPON_COMBAT_LEGACY_SIZE) || + (h[1] == 10 && h[2] == sizeof(frame) + sizeof(frame_zero) + sizeof(frame_weapons) + MMX_WEAPON_COMBAT_LEGACY_SIZE) || + (h[1] == 11 && h[2] == sizeof(frame) + sizeof(frame_zero) + sizeof(frame_weapons) + MMX_WEAPON_COMBAT_DAMAGE_SIZE) || + (h[1] == 12 && h[2] == sizeof(frame) + sizeof(frame_zero) + sizeof(frame_weapons) + sizeof(frame_weapon_combat))) && 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 : @@ -376,6 +391,11 @@ bool MmxRendererLoadCapture(const char *path) { 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)); + if (ok && h[1] >= 8) ok = fread(&frame_weapons, h[1] >= 10 ? sizeof(frame_weapons) : MMX_WEAPONS_LEGACY_STATE_SIZE, 1, f) == 1 && + MmxWeaponsValidState(&frame_weapons); + if (ok && h[1] >= 9) ok = fread(&frame_weapon_combat, + h[1]>=12 ? sizeof(frame_weapon_combat) : h[1]==11 ? MMX_WEAPON_COMBAT_DAMAGE_SIZE : MMX_WEAPON_COMBAT_LEGACY_SIZE, 1, f) == 1 && + MmxWeaponsValidCombatState(&frame_weapon_combat); ok = ok && fgetc(f) == EOF; fclose(f); frame.valid = ok; return ok; } @@ -453,6 +473,49 @@ static void prepare_stage_planes(void) { shift, r[d + 2] != 4, scrolling}; } } +static unsigned menu_glyph(unsigned c) { + if (c >= 'A' && c <= 'L') return 0xc4 + c - 'A'; + if (c >= 'M' && c <= 'P') return 0xd4 + c - 'M'; + if (c >= 'R' && c <= 'W') return 0xd8 + c - 'R'; + return c == '.' ? 0xdf : c == 'X' ? 0x9d : 0; +} +static uint16_t weapon_menu_tile(unsigned tx, unsigned ty, uint16_t original) { + if (!frame_weapons.menu_page || !MmxWeaponsMenuVisible(frame.ram) || ty < 5 || ty > 14) return original; + unsigned column = tx >= 17, local = tx - (column ? 13 : 0), item = (ty - 5) / 2 + column * 5; + if (!item || item > 8) return original; + if (local == 4 || local == 5) return 0; /* Icons use their original source-game palette below. */ + if (!(ty & 1) && local >= 6 && local <= 14) { + unsigned flags = frame.ram[0x1ed2] == item ? 0x0800 : 0x2800; + if (local == 6) return (uint16_t)(flags | 0x82); + if (local == 14) return (uint16_t)(flags | 0x4082); + int filled = frame_weapons.energy[(frame_weapons.menu_page - 1) * 8 + item - 1] - (int)(local - 7) * 4; + if (filled < 0) filled = 0; + if (filled > 4) filled = 4; + return (uint16_t)(flags | (0x83 + filled)); + } + if ((ty & 1) && local >= 7 && local <= 14) { + const char *label = MmxWeaponsLabel(frame_weapons.menu_page, item); + unsigned i = local - 7; + return (uint16_t)(i < strlen(label) ? 0x1c00 | menu_glyph((unsigned char)label[i]) : 0); + } + return original; +} +static int weapon_menu_icon(int x, int y) { + if (!frame_weapons.menu_page || !MmxWeaponsMenuVisible(frame.ram) || y < 40 || y >= 120) return -1; + int column = x >= 136, left = column ? 136 : 32; + if (x < left || x >= left + 16) return -1; + unsigned row = (y - 40) / 16, item = row + column * 5; + if (!item || item > 8) return -1; + const MmxWeaponPose *pose = MmxWeaponsIcon(frame_weapons.menu_page, item); + const uint16_t *colors = MmxWeaponsIconPalette(frame_weapons.menu_page, item); + if (!pose || !colors) return -1; + /* Icons occupy the original 16x16 cells, centered without resampling. */ + int px = x - left - (16 - (int)pose->width) / 2; + int py = (y - 40) % 16 - (16 - (int)pose->height) / 2; + if (px < 0 || py < 0 || px >= pose->width || py >= pose->height) return -1; + unsigned pixel = pose->pixels[py * pose->width + px]; + return pixel ? colors[pixel] : -1; +} static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, int y, bool stage, int *private_color) { static const unsigned low[] = {8, 7, 1}, high[] = {12, 11, 3}; bool margin = x < 0 || x >= 256; @@ -475,6 +538,11 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, if ((sc & 1) && (tx & 32)) a += 1024; if ((sc & 2) && (ty & 32)) a += (sc & 1) ? 2048 : 1024; uint16_t tile = r->vram[a & 0x7fff]; + if (layer == 0 && !stage) { + int icon = weapon_menu_icon(x, y); + if (icon >= 0) { *private_color = icon; return 0x8001; } + tile = weapon_menu_tile(tx, ty, tile); + } if (stage && size == 8 && layer < 2 && (x < 0 || x >= 256)) { int wx, wy; if (layer == 0) { @@ -730,6 +798,45 @@ static uint32_t colour(const Ppu *p, const uint16_t *palette, const uint8_t brig } return result; } +static void weapon_sprite_row(const MmxWeaponShot *s, unsigned pose, int x, int sy, + int y, MmxRenderView view, uint16_t *objects, int *colors) { + const MmxWeaponPose *p = MmxWeaponsPose(s->page,s->weapon,s->group,pose); + const uint16_t *palette = MmxWeaponsGroupPalette(s->page,s->weapon,s->group); + if (!p || !palette) return; + int row = (s->facing&128 ? -(y-sy)-1 : y-sy) - p->top; + if (row < 0 || row >= p->height) return; + for (int col=0;colwidth;++col) { + unsigned pixel=p->pixels[row*p->width+col]; + int dx=x+((s->facing&64) ? -1-p->left-col : p->left+col)+view.extra; + if (pixel && dx>=0 && dxorigin_x; + if(d<0xe68 || d>=0x1228 || !frame.ram[d] || frame.ram[d+10]!=s->origin_y) return; + unsigned group=frame.ram[d+22]; + const uint8_t *a=sprite_arrangement(group,frame.ram[d+23]&127);if(!a) return; + const MmxSpriteAsset *asset=MmxRenderAssetsObjectSprite(frame.ram,d,group); + int x=(s->x>>8)-(int)word(frame.ram,0x1e4d); + int sy=(s->y>>8)+(int8_t)frame.ram[d+25]-(int)word(frame.ram,0x1e50); + for(int i=(int)a[0]-1;i>=0;--i) { + Piece piece=make_piece(a+i*4,x,sy,frame.ram[d+17]&64, + frame.ram[d+17]&63,frame.ram[d+24],group,d); + unsigned attr=asset ? (piece.attr&0xf000)|((asset->attributes&15)<<8)| + (asset->live_tiles?piece.attr&255:0) : piece.attr; + if(y=piece.y+piece.size) continue; + if(s->weapon==6 && !s->charged && s->muzzle_pose!=3 && s->age>30 && (s->age&1)) { + uint16_t flash[MMX_RENDER_MAX_WIDTH]={0};int flash_colors[MMX_RENDER_MAX_WIDTH]; + sprite(p,r,piece.x,piece.y,attr,piece.size,y,view,flash,false,asset,piece.tile,flash_colors,true,false,false); + for(int x=0;xpalette[128+(flash[x]&15)]; + } + } else sprite(p,r,piece.x,piece.y,attr,piece.size,y,view,objects,false,asset,piece.tile,colors,true,false,false); + } +} bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { if (!out || !frame.valid || view.width < 256 || view.width > MMX_RENDER_MAX_WIDTH || view.extra != (view.width - 256) / 2 || (view.width & 1)) return false; @@ -737,9 +844,16 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { memset(door_cache, 0, sizeof(door_cache)); memset(out, 0, (size_t)view.width * 224 * sizeof(*out)); bool zero_menu = zero_weapons_menu(); + bool weapon_menu = MmxWeaponsMenuVisible(frame.ram); + bool menu = zero_menu || weapon_menu; bool zero_title = zero_title_menu(); - bool stage = MmxWidePolicy_IsStageScene(frame.ram) && !zero_menu; + bool stage = MmxWidePolicy_IsStageScene(frame.ram) && !menu; bool swapping = stage && MmxZeroEnabled() && frame_zero.swap_phase; + unsigned weapon_page = menu ? frame_weapons.menu_page : frame_weapons.page; + unsigned weapon_item = menu ? frame.ram[0x1ed2] : frame_weapons.weapon; + const uint16_t *weapon_colors = MmxWeaponsPalette(weapon_page, weapon_item, true); + MmxSpriteAsset x_weapon_palette = {0}; + if (weapon_colors) { memcpy(x_weapon_palette.colors, weapon_colors, 32); x_weapon_palette.live_tiles = true; } prepare_stage_planes(); LightBeam beams[2]; unsigned beam_count = stage ? spark_lights(beams, view.extra) : 0; @@ -749,6 +863,31 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { 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; + const uint8_t *charge = stage && zero ? MmxZeroChargePose(&frame_zero) : NULL; + const MmxWeaponShot *triad=NULL,*gravity=NULL; + if (stage && !swapping) for (unsigned i=0;i<8;++i) { + const MmxWeaponShot *s=frame_weapon_combat.shots+i; + if (s->active && s->page==2 && s->weapon==3 && s->charged && !s->variant && s->muzzle_pose) triad=s; + if (s->active && s->page==2 && s->weapon==6 && s->charged && s->variant==1) gravity=s; + } + if (triad && zero) { + /* X3 Zero has no ground-punch action. Reuse his original windup and + * crouched strike body poses, with the X3 Triad action's own timing. */ + MmxZeroState strike=frame_zero;strike.burst=0;strike.air=0; + strike.slash=triad->pose==39?1:triad->pose==41?3:triad->pose==29?6: + triad->pose==30?9:triad->pose==31?12:triad->pose==32?15:21; + zero=MmxZeroPose(frame.ram,&strike);blade=NULL; + } + if(gravity && zero) { + /* Zero never used Gravity Well in X3. Adapt original group-4A raised-arm + * body poses to the source cast timing, including its held pose. */ + MmxZeroState cast=frame_zero;cast.burst=cast.slash=0;cast.anim_valid=1; + cast.anim_pose=gravity->pose==39 || gravity->pose==40?0: + gravity->pose==41 || gravity->pose==44?0x43:gravity->pose==29 || gravity->pose==45?0x44: + gravity->pose==35?0x45:gravity->pose==36?0x46:0x47; + zero=MmxZeroPose(frame.ram,&cast);blade=NULL; + } + const MmxWeaponShot *cast_body=triad?triad:gravity; Piece waiting[128]; unsigned waiting_count = stage && g_mmx_render_asset_repairs ? fortress_waiting_pieces(waiting, pieces, piece_count) : 0; @@ -769,11 +908,12 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { (s->object == 0xe18 && MmxRenderAssetsRideArmorPalettePending(frame.ram, frame.lines[0].palette + 128 + ((s->attr >> 9) & 7) * 16)))) piece_assets[i] = a; } + int16_t crystal_ripple[224];MmxWeaponsTimeRipple(&frame_weapon_combat,crystal_ripple); for (int y = 0; y < 224; ++y) { const Raster *r = &frame.lines[y]; Ppu p; memcpy(&p, r->registers, PPU_SAVESTATE_REGS_SIZE); if (beam_count) p.cgwsel = (p.cgwsel & 0xcf) | 0x20; - if ((p.bgmode & 7) != 1 || (!stage && !zero_menu && !zero_title)) { + if ((p.bgmode & 7) != 1 || (!stage && !menu && !zero_title)) { memcpy(out + y * view.width + view.extra, frame.stock + y * 256, 256 * sizeof(*out)); ++stats.fallback_lines; continue; } @@ -801,6 +941,8 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { 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]; + bool frozen_enemy=stage && (MmxWeaponsFrozenEnemy(&frame_weapon_combat,s.object) || + MmxWeaponsMovedEnemy(&frame_weapon_combat,s.object)); if (g_mmx_render_asset_repairs && fortress_sound_actor(s.object) && (s.x >= 256 || s.x + s.size <= 0)) continue; /* Recorded pieces already obey the retail submission budget. Draw @@ -813,13 +955,15 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { frame.ram[0xd3]==2 && s.object==0x1ce8 && s.animation==0x19; bool menu_body = s.object == 0x1988 && (s.animation == 0 || s.animation == 0x18); bool zero_body = zero && (s.object == 0xba8 || menu_body); + bool triad_x_body=cast_body && !zero && s.object==0xba8; + bool triad_armor=cast_body && (s.object==0xc38 || s.object==0xc58 || s.object==0xc78); 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_charge = zero && stage && MmxZeroNativeChargeObject(s.object,frame.ram[s.object+10]); 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 || red_ready) for (int slot = 16; slot < 128; ++slot) { + if (g_mmx_render_asset_repairs || zero_body || zero_armor || zero_charge || swap_actor || red_ready || triad_x_body || triad_armor || frozen_enemy) 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) { @@ -834,7 +978,17 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { unsigned attr = asset ? (s.attr & 0xf000) | ((asset->attributes & 15) << 8) | (asset->live_tiles ? s.attr & 255 : 0) : s.attr; if (asset && asset->live_colors) attr = (attr & ~0x0e00u) | (s.attr & 0x0e00u); - if (zero_armor || swap_actor) continue; + if (zero_armor || swap_actor || triad_armor || frozen_enemy) continue; + if (zero_charge && MmxZeroHasChargeArt() && !frame.ram[0xbdb] && !weapon_item) continue; + if (triad_x_body) { + if (oam_match && !zero_drawn[0]) { + zero_drawn[0]=true; + weapon_sprite_row(cast_body,cast_body->pose, + (int16_t)(word(frame.ram,0xbad)-word(frame.ram,0x1e4d)), + (int16_t)(word(frame.ram,0xbb0)-word(frame.ram,0x1e50)),y,view,objects,object_colors); + } + 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) { @@ -843,8 +997,22 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { 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; + bool pilot = !menu_body && s.animation==0x6b; + if(pilot) { + /* X1 switches to a separate pilot group on boarding. Its pose + * numbers are not movement poses. Keep its authored entry/walk/ + * punch offsets and expose Zero's original helmet/shoulders over + * the cockpit. Vanilla X3 Zero has no Ride Armor pilot artwork. */ + const uint8_t *layout=sprite_arrangement(0x6b,frame.ram[0xbbf]&127); + if(layout && layout[0]) { + int dx=(int8_t)layout[1]+5; + zx+=(frame.ram[0xbb9]&64)?-dx:dx; + zy+=8+(int8_t)layout[2]+20; + } + body=MmxZeroMenuPose(); + } int row = y - zy + 64; - if (row >= 0 && row < MMX_ZERO_HEIGHT) { + if (row >= 0 && row < MMX_ZERO_HEIGHT && (!pilot || (row>=44 && row<64))) { const uint16_t *colors = MmxZeroColors(); unsigned z = ((((s.attr >> 12) & 3) * 4 + 2) << 12) | 0x680; for (int col = 0; col < MMX_ZERO_WIDTH; ++col) { @@ -861,7 +1029,8 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { 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]; + } else object_colors[dx] = !menu_body && pixel >= 16 && pixel < 32 ? + MmxZeroBodyColors(&frame_zero)[pixel - 16] : colors[pixel]; } } } @@ -876,6 +1045,11 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { s.x += (s.x + s.size / 2 - cx) / 4; s.y += (s.y + s.size / 2 - cy) / 4 - 6; } + /* Extended choices have their own original X palette. Their native + * buster proxy must not show the palette of an unrelated locked X1 + * weapon while navigating the pause screen. Preserve hit flashes. */ + if (frame_zero.active_x && weapon_colors && zero_actor(s.object, s.animation) && + (menu || (attr & 0x0e00))) asset = &x_weapon_palette; sprite(&p, r, s.x, s.y, attr, s.size, y, view, objects, !center, asset, s.tile, object_colors, true, false, red_ready); } int bar_first = -1, bar_count = 0; @@ -902,8 +1076,99 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { 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; } + if (stage && frame_weapons.page && frame_weapons.weapon && slot == 7 && (pos & 255) == 24 && sy == 80 && attr == 0x3620) { + /* Dedicated source gameplay footer, with its original frame, + * pixels and weapon palette. Menu icons are a different asset. */ + const MmxWeaponPose *icon=MmxWeaponsHudIcon(frame_weapons.page,frame_weapons.weapon); + const uint16_t *colors=MmxWeaponsPalette(frame_weapons.page,frame_weapons.weapon,false); + int row=y-sy; + if (icon && colors && row>=0 && row<16) for(int col=0;col<16;++col) { + unsigned pixel=icon->pixels[row*16+col]; + int dx=x+col+view.extra; + if(pixel && dx>=0 && dx=81 && frame_zero.charge<201 ? 32 : 0; + if(row>=0 && row=0 && dxactive || !s->age) continue; + if(i<8 && s->page==1 && s->weapon==1 && !s->charged && + s->muzzle_pose>=1 && s->muzzle_pose<=4) + moved_enemy_row(s,&p,r,y,view,objects,object_colors); + if(s->page==2 && s->weapon==2) { + if(s->variant==1) moved_enemy_row(s,&p,r,y,view,objects,object_colors); + if(s->variant==2 && s->muzzle_pose==4) continue; + } + if(s->page==2 && s->weapon==6) { + if(i>=8 && s->variant==2) {moved_enemy_row(s,&p,r,y,view,objects,object_colors);continue;} + if(i<8 && (s->variant==1 || (s->charged && s->muzzle_pose!=1))) continue; + } + if (s->page==2 && s->weapon==3 && s->charged && (!s->variant || !s->muzzle_pose)) continue; + if (s->page==1 && s->weapon==1 && s->charged) continue; + if (i>=8 && s->page==1 && !(s->weapon==8 && s->variant==5) && (s->weapon==1 ? s->age==1 || !((s->age-1+s->tether_pose)&1) : + !(frame_weapon_combat.tick&1))) continue; /* Original debris/sparkle flicker. */ + if (i<8 && s->page==1 && s->weapon==1 && s->muzzle_pose==5) continue; + if (s->page==1 && s->weapon==8 && s->charged && s->tether_pose) continue; + if (s->page==1 && s->weapon==2 && s->charged && (!s->variant || !s->muzzle_pose)) continue; + if (s->page==2 && s->weapon==7 && s->variant!=2 && s->muzzle_pose==(s->charged ? 4 : 7)) continue; + if (s->page==1 && s->weapon==4) { + if (s->charged && s->muzzle_pose && s->radius) { + static const uint8_t flash[6]={15,16,16,17,17,18}; + MmxWeaponShot center=*s;center.facing=s->tether_pose ? 64 : 0; + weapon_sprite_row(¢er,flash[6-s->radius],s->origin_x-word(frame.ram,0x1e4d), + s->origin_y-word(frame.ram,0x1e50),y,view,objects,object_colors); + } else if (!s->charged && (s->muzzle_pose==2 || s->muzzle_pose==3 || s->muzzle_pose==5)) { + unsigned duration=s->origin_x>=1024 ? 1 : s->origin_x>=512 ? 2 : 3; + weapon_sprite_row(s,9+(unsigned)s->origin_y/duration%5,(s->x>>8)-word(frame.ram,0x1e4d), + (s->y>>8)+8-word(frame.ram,0x1e50),y,view,objects,object_colors); + } + } + if (s->page==1 && s->weapon==6 && s->variant) { + /* X2 $81:849D and $9ABF: nine links, every eighth of the span. */ + unsigned pose=(s->charged ? 20 : 5)+s->tether_pose/3; + for (int link=8;link>=0;--link) { + int x=(s->origin_x*(8-link)+(s->x>>8)*link)/8; + weapon_sprite_row(s,pose,x-word(frame.ram,0x1e4d), + (s->y>>8)-word(frame.ram,0x1e50),y,view,objects,object_colors); + } + } + if (s->charged && s->page == 2 && s->weapon == 4 && s->variant!=4) { + int ox=s->origin_x-word(frame.ram,0x1e4d), oy=s->origin_y-word(frame.ram,0x1e50); + weapon_sprite_row(s,s->tether_pose,ox,oy,y,view,objects,object_colors); + weapon_sprite_row(s,s->muzzle_pose,ox,oy,y,view,objects,object_colors); + } + if (s->page == 2 && s->weapon == 5 && s->variant >= 2) { + /* Original X3 trail actors use poses 14/15 and delayed positions. + * Rays have constant velocity, so their history is reconstructed + * exactly without extra transient slots or unsaved renderer state. */ + for (int delay=2;delay>=1;--delay) { + int remaining = delay - (s->variant == 3 ? s->radius : 0); + if (remaining > 0 && s->age > (unsigned)delay+1) + weapon_sprite_row(s,13+delay,((s->x-s->vx*remaining)>>8)-word(frame.ram,0x1e4d), + ((s->y-s->vy*remaining)>>8)-word(frame.ram,0x1e50),y,view,objects,object_colors); + } + if (s->variant == 3) continue; + } + weapon_sprite_row(s,s->pose,(s->x>>8)-word(frame.ram,0x1e4d), + (s->y>>8)-word(frame.ram,0x1e50),y,view,objects,object_colors); + } if (swapping) { unsigned pose = MmxZeroSwapPose(&frame_zero); int x = (int16_t)(word(frame.ram,0xbad) - word(frame.ram,0x1e4d)); @@ -931,11 +1196,12 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { } 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; } + if (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)) { int bx = x, by = y + 1; + if (stage && layer==0) by+=crystal_ripple[y]; if (p.mosaic & (1 << layer)) { int size = (p.mosaic >> 4) + 1; bx -= ((bx % size) + size) % size; by -= by % size; } bg[layer] = background(&p, r, layer, bx, by, stage, &bg_colors[layer]); diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c index da0cd39..f84119c 100644 --- a/src/mmx_rtl.c +++ b/src/mmx_rtl.c @@ -2,6 +2,8 @@ #include "mmx_wide_policy.h" #include "mmx_renderer.h" #include "mmx_zero.h" +#include "mmx_weapons.h" +#include "mmx_weapon_combat.h" #include "variables.h" #include "common_cpu_infra.h" #include "snes/snes.h" @@ -328,7 +330,7 @@ void mmx_host_yield(uint8_t countdown) { #include "snes/saveload.h" #define MMX_SAV_CHUNK_MAGIC 0x4D4D5854u /* "MMXT" */ -#define MMX_SAV_CHUNK_VERSION 8u /* Separate X/Zero health pools. */ +#define MMX_SAV_CHUNK_VERSION 13u /* Independent source-weapon visual particles. */ typedef struct MmxSavChunk { uint32_t magic, version; @@ -359,12 +361,14 @@ static void MmxWideStateLoad(struct SaveLoadInfo *sli); static void MmxWideStateApply(bool loaded); static uint8_t g_load_frame_flags[4]; static MmxZeroState g_load_zero; +static MmxWeaponsState g_load_weapons; +static MmxWeaponCombatState g_load_weapon_combat; void MmxStateSaveExtra(struct SaveLoadInfo *sli) { MmxSavChunk c; memset(&c, 0, sizeof(c)); c.magic = MMX_SAV_CHUNK_MAGIC; - c.version = MmxZeroEnabled() ? MMX_SAV_CHUNK_VERSION : 3; + c.version = MmxWeaponsEnabled() ? MMX_SAV_CHUNK_VERSION : MmxZeroEnabled() ? 8 : 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); @@ -387,6 +391,14 @@ void MmxStateSaveExtra(struct SaveLoadInfo *sli) { MmxZeroState zero = MmxZeroGetState(); sli->func(sli, &zero, sizeof(zero)); } + if (c.version >= 9) { + MmxWeaponsState weapons = MmxWeaponsGetState(); + sli->func(sli, &weapons, sizeof(weapons)); + } + if (c.version >= 10) { + MmxWeaponCombatState combat = MmxWeaponsGetCombatState(); + sli->func(sli, &combat, sizeof(combat)); + } } void MmxStateLoadExtra(struct SaveLoadInfo *sli, uint32_t version) { @@ -394,6 +406,8 @@ void MmxStateLoadExtra(struct SaveLoadInfo *sli, uint32_t 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_weapons, 0, sizeof(g_load_weapons)); + memset(&g_load_weapon_combat, 0, sizeof(g_load_weapon_combat)); 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 && @@ -424,6 +438,21 @@ void MmxStateLoadExtra(struct SaveLoadInfo *sli, uint32_t version) { if (g_load_zero.slash > 44) g_load_zero.slash = 44; } } + if (g_load_chunk.version >= 9) { + size_t weapons_size = g_load_chunk.version >= 11 ? sizeof(g_load_weapons) : MMX_WEAPONS_LEGACY_STATE_SIZE; + if (RtlStateBytesRemaining(sli) >= weapons_size) { + sli->func(sli, &g_load_weapons, weapons_size); + if (!MmxWeaponsValidState(&g_load_weapons)) g_load_chunk_ok = 0; + } else g_load_chunk_ok = 0; + } + if (g_load_chunk.version >= 10) { + size_t combat_size=g_load_chunk.version>=13 ? sizeof(g_load_weapon_combat) : + g_load_chunk.version==12 ? MMX_WEAPON_COMBAT_DAMAGE_SIZE : MMX_WEAPON_COMBAT_LEGACY_SIZE; + if (RtlStateBytesRemaining(sli) >= combat_size) { + sli->func(sli, &g_load_weapon_combat, combat_size); + if (!MmxWeaponsValidCombatState(&g_load_weapon_combat)) g_load_chunk_ok = 0; + } else g_load_chunk_ok = 0; + } } if (!g_load_chunk_ok) fprintf(stderr, "[mmx_state] load: bad game chunk (magic=%08x ver=%u)\n", @@ -452,6 +481,8 @@ void MmxOnStateLoaded(uint32_t version) { g_first_frame_done = complete ? g_load_frame_flags[1] != 0 : true; MmxWideStateApply(complete); MmxZeroSetState(g_load_zero); + MmxWeaponsSetState(g_load_weapons); + MmxWeaponsSetCombatState(g_load_weapon_combat); 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 @@ -860,6 +891,7 @@ void RunOneFrameOfGame(void) { } cpu_trace_px_breadcrumb(&g_cpu, 0x2002, "before_Internal"); if (MmxZeroSwapTick(g_ram)) return; + if (MmxWeaponsFrameTick(g_ram)) return; if (s_ws_recover_armor) { if (!g_mmx_custom_renderer || !MmxWidePolicy_PrematureRideArmor(g_ram) || MmxWidePolicy_RecoverRideArmor(g_ram, MmxWsMargin())) s_ws_recover_armor = false; diff --git a/src/mmx_source_assets.cpp b/src/mmx_source_assets.cpp index 2bc7ce7..168bc4a 100644 --- a/src/mmx_source_assets.cpp +++ b/src/mmx_source_assets.cpp @@ -31,10 +31,10 @@ struct Rom { 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= + const char *expected=game==2 ? "f3246755f608a1e1dc9c848b61da3b824c7853b29b3be40df6fc7f2793a887ed" : "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, + require(std::string(actual)==expected,game==2 ? "Select the original Mega Man X2 USA ROM." : "Select the original Mega Man X3 USA ROM."); } size_t offset(unsigned a) const { @@ -64,6 +64,16 @@ void transfer(const Rom& r,unsigned table,unsigned pose,Tiles& t) { } throw std::runtime_error("Unterminated source sprite transfer."); } +void bulk(const Rom& r,unsigned a,Tiles& t) { + for (unsigned i=0;i<64;++i,a+=7) { + unsigned length=r.integer(a); if (length&1) return; + unsigned start=(r.integer(a+2)-0x6000)*2; + require(start<=8192 && length<=8192-start,"Invalid source selection transfer."); + Bytes b=r.at(r.integer(a+4,3),length); + std::copy(b.begin(),b.end(),t.bytes.begin()+start); std::fill_n(t.known.begin()+start,length,1); + } + throw std::runtime_error("Unterminated source selection 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); @@ -95,15 +105,16 @@ Pose pose(const Rom& r,unsigned group,unsigned number,const Tiles& t,bool zero=f if (color) { int px=x+int(dx)-p.left,py=y+int(dy)-p.top; require(px>=0 && py>=0 && px>1)&7)|(group==0x50?3:1))*16; + if (zero) color+=(((flags>>1)&7)|(group>=0x6f?0:group==0x50?3:1))*16; p.pixels[py*p.width+px]=uint8_t(color); } } } return p; } +Bytes menu(const Rom& r,unsigned game,unsigned resource); Bytes zero_assets(const Rom& r) { - Bytes out{'M','M','X','Z','E','R','O','6'}; + Bytes out{'M','M','X','Z','E','R','O','7'}; for (unsigned v : {128,128,64,64,117,35}) put(out,v); std::array colors{}; for (unsigned key : {0xd0,0xd2}) { @@ -117,12 +128,106 @@ Bytes zero_assets(const Rom& r) { } } for (unsigned i=0;i<16;++i) colors[176+i]=r.integer(0x8cb5a0+i*2); + for (unsigned i=0;i<16;++i) colors[128+i]=r.integer(0x8cb100+i*2); + for (unsigned i=0;i<16;++i) colors[160+i]=r.integer(0x8cb0e0+i*2); for (unsigned i=128;i<256;++i) put(out,colors[i]); Bytes bounds=r.at(0x86b837,40);for (unsigned i=1;i<40;i+=4) bounds[i]-=8; append(out,bounds);append(out,r.at(0x2c8d20,64));append(out,r.at(0x2c8de0,64));append(out,r.at(0x8cb0e0,32)); append(out,r.at(0x3fcc74,0x474));append(out,r.at(0x399161,120));append(out,r.at(0x3991d9,76)); const unsigned groups[][3]={{0x4a,117,0x85d6a8},{0x4b,21,0x85db47},{0x50,14,0x85e6e0}}; for (auto& g : groups) { Tiles t;for (unsigned i=0;i pending;std::set visited; + require(sequence<0 || unsigned(sequence)*2=0) pending.push_back(r.integer(base+unsigned(sequence)*2)); + else for (unsigned i=0;i=header && a<=8192,"Source animation exceeds group."); + Bytes rec=r.at(base+a,3); require(rec[0] && rec[2]<128,"Invalid source animation."); + unsigned end=a+3; + if (rec[1]&128) { pending.push_back(end+int16_t(r.integer(base+end)));extent=std::max(extent,end+2); } + else { pending.push_back(end);extent=std::max(extent,end); } + } + return r.at(base,extent); +} +Bytes menu(const Rom& r,unsigned game,unsigned resource=0x4c) { + unsigned rec=(game==2?0x86fa01:0x86f732)+resource*5,length=r.integer(rec+3); + size_t p=r.offset(r.integer(rec,3));Bytes b; + while (b.size()>=1) { + if (control&bit) { + Bytes pair=r.raw(p,2);p+=2;unsigned count=pair[0]>>2,distance=((pair[0]&3)<<8)|pair[1]; + require(count && distance && distance<=b.size() && b.size()+count<=length,"Invalid source menu backreference."); + while (count--) b.push_back(b[b.size()-distance]); + } else b.push_back(r.raw(p++,1)[0]); + } + } + return b; +} +Bytes weapon_assets(const Rom& r,unsigned game) { + Bytes out{'M','M','X','W','E','A','P','5'};put(out,8,4);Bytes graphics=menu(r,game); + for (const auto& w : weapon_sources) if (w.game==game) { + put(out,game,1);put(out,w.id,1);put(out,w.groups,1);put(out,0,1); + append(out,r.raw(w.body,32));append(out,r.raw(w.palette,32));append(out,r.raw(game==2?0x2cee0:0x62da0,32)); + unsigned tile=w.id<8?0x30+w.id*2:0x50; + for (unsigned y=0;y<16;++y) for (unsigned x=0;x<16;++x) { + unsigned start=(game==2?-0x200:0)+(tile+x/8+y/8*16)*32+(y&7)*2,color=0; + require(start+17>(7-(x&7)))&1)<=0) { + Bytes common=menu(r,game,unsigned(w.resource)); + require(w.offset+common.size()<=base.bytes.size(),"Static source graphics exceed tile bank."); + for (unsigned i=0;i>(7-(x&7)))&1)<=0) transfer(r,g.dma,unsigned(g.setup),t); + Bytes a=animations(r,game,g.source,g.animation_sequence);put(out,g.id);put(out,g.frames);put(out,unsigned(a.size())); + append(out,r.raw(g.palette,32));append(out,a); + for (unsigned i=0;i=g.animated_start;unsigned layout=i; + if (!animated && g.restricted && (i>=64 || !(g.poses&(uint64_t(1)<=64 || !(g.inherited&(uint64_t(1)<facing?1:-1; + if(s->charged && s->age && s->variant!=4) { + if(r[d+1]==4) {retire(r,d);return;} /* Source reflected actor is discarded. */ + if(r[d+1]>=8) { + s->variant=4;s->muzzle_pose=s->tether_pose=0; + /* $B75C resets +/-$0800 each contact-state tick; $82:D6F8 then + * subtracts the initial $E0 horizontal acceleration ($B48C). */ + s->vx=(int16_t)(-direction*0x800-0xe0);s->vy=-0x800; + } else if(r[d+1]==6) s->hit_slots=0; /* A killed target does not dislodge it. */ + } + if(!s->charged && s->age && (r[d+1]>=8 || s->variant==128)) { + if(s->variant!=128) {s->variant=128;s->vy=26;animation_start(s,1);} + if(!s->vy || !s->active) {retire(r,d);return;} + --s->vy;animation_step(s);native_object(r,d,s);putword(r+d+0x20,0);return; + } + if(!s->age) { + if(!MmxWeaponsSpend(s->page,s->weapon,(s->charged?3:1)*256)) {retire(r,d);return;} + muzzle_origin(r,d,s);s->vx=(int16_t)(direction*1024);s->vy=0; + animation_start(s,s->charged?4:0);s->born=combat.tick; + if(!s->charged) { + unsigned other=free_slot(r); + if(other) { + MmxWeaponShot *t=combat.shots+slot_index(other);*t=*s;t->variant=1;t->age=1; + memset(r+other,0,64);native_object(r,other,t);++r[0xbdd]; + } + } else {s->vx=0;s->tether_pose=30;s->muzzle_pose=39;} + s->age=1;native_object(r,d,s);return; + } + if(s->age==1 && s->born==combat.tick) return; + if(s->charged && s->variant!=4) { + /* Original orbit offsets at $86:BA86. There are 32 positions, each + * held for two ticks, rather than 64 interpolated positions. */ + static const int8_t orbit[32][2]={ + {0,-80},{15,-78},{29,-74},{44,-66},{56,-56},{65,-43},{71,-28},{75,-15}, + {77,0},{75,15},{71,28},{65,43},{56,56},{44,66},{29,74},{15,78}, + {0,80},{-15,78},{-29,74},{-44,66},{-56,56},{-65,43},{-71,28},{-75,15}, + {-77,0},{-75,-15},{-71,-28},{-65,-43},{-56,-56},{-44,-66},{-29,-74},{-15,-78}}; + muzzle_origin(r,d,s); + static const uint8_t muzzle[4]={39,40,41,40}; + s->muzzle_pose=muzzle[(s->age/2)&3]; + int dx=s->radius,dy=0; + if(s->variant==5) { + if(!--s->vy) s->variant=3; + else if(combat.direction&12) { + s->variant=(combat.direction&8)?1:2;s->vx=0; + } + } else if(s->variant==1 || s->variant==2) { + bool up=s->variant==1; + if(s->age&1) s->vx+=2; + unsigned angle=(unsigned)s->vx&63; + dx=orbit[((16-angle)&63)/2][0];dy=orbit[((16-angle)&63)/2][1]*(up?1:-1); + s->tether_pose=(uint8_t)(42+((24+(up?(int)(angle/2):-(int)(angle/2)))&31)); + if(s->vx>=64) s->variant=3; + } else { + if(s->age&1) { + if(s->variant==3) { + if(!s->radius) {retire(r,d);return;} + s->radius=(uint8_t)(s->radius>8?s->radius-8:0); + } else if(s->radius>=80) {s->variant=5;s->vy=120;} + else s->radius=(uint8_t)(s->radius>=72?80:s->radius+8); + } + dx=s->radius; + s->tether_pose=(uint8_t)(30+(s->radius>=72?8:s->radius?s->radius/8-1:0)); + } + s->x+=direction*dx*256;s->y+=dy*256; + } else if(s->charged) { + s->x+=s->vx;s->y+=s->vy; + if(s->x/256<(int)word(r+0x1e4d)-32 || s->x/256>(int)word(r+0x1e4d)+288 || + s->y/256<(int)word(r+0x1e50)-32 || s->y/256>(int)word(r+0x1e50)+256) {retire(r,d);return;} + } else { + s->vx-=(int16_t)(direction*32);s->vy+=s->variant?-16:16; + if(s->vy>192) s->vy=192; + if(s->vy<-192) s->vy=-192; + s->x+=s->vx;s->y+=s->vy; + } + if(++s->age>240 || s->x/256<(int)word(r+0x1e4d)-256 || + s->x/256>(int)word(r+0x1e4d)+512 || s->y/256<(int)word(r+0x1e50)-256 || + s->y/256>(int)word(r+0x1e50)+480) {retire(r,d);return;} + animation_step(s); + if(!s->active) {retire(r,d);return;} + native_object(r,d,s); + if(s->charged && s->variant==4) {putword(r+d+0x20,0);r[d+0x30]=1;} +} diff --git a/src/mmx_weapon_chain.inc b/src/mmx_weapon_chain.inc new file mode 100644 index 0000000..49ab7ef --- /dev/null +++ b/src/mmx_weapon_chain.inc @@ -0,0 +1,130 @@ +/* X2 $81:9816..9C79 / $81:A6A4..A8D9. Nine original link actors are + * reconstructed by the renderer; only the hook participates in collision. + * variant: startup, extending, retracting, wall anchor, carried pickup. + * vy: held X1 item address; radius: extension timer or held item kind; + * tether_pose: original link animation clock; muzzle_pose: pending contact. */ +static bool chain_item(unsigned d) { + return d>=0x1628 && d<0x1928 && (d-0x1628)%0x30==0; +} +static void chain_release(uint8_t *r,const MmxWeaponShot *s) { + unsigned item=(uint16_t)s->vy; + if (chain_item(item) && r[item] && r[item+10]==s->radius) r[item+0x28]=0; + if (s->variant==3) putword(r+0xbc4,0); +} +static void chain_spend(unsigned amount) { + unsigned energy=MmxWeaponsEnergyAmount(1,6); + MmxWeaponsSpend(1,6,energy=(r[enemy+0x27]&127); + chain_spend(s->charged ? 16 : 128); + if (!s->charged) { s->muzzle_pose=killed ? 1 : 2;return; } + if (!killed || r[enemy+0x28]>=6) return; + /* X2 $86:B0CE, drop row 7: 50% small health / 50% small energy. + * Allocate a real X1 item, so its collection, refill and lifetime remain + * native. Source RNG arithmetic is seeded from saved simulation state. */ + for (unsigned item=0x1628;item<0x1928;item+=0x30) if (!word(r+item)) { + memset(r+item,0,0x30);r[item]=1; + r[item+10]=bubble_random(enemy+s->born)<128 ? 1 : 2;r[item+11]=1; + putword(r+item+5,s->x>>8);putword(r+item+8,s->y>>8);break; + } +} +static bool chain_grab(uint8_t *r,MmxWeaponShot *s) { + int reach=s->charged ? 8 : 5; + for (unsigned item=0x1628;item<0x1928;item+=0x30) { + unsigned kind=r[item+10]; + if (!r[item] || r[item+1]!=2 || r[item+2]>=6 || r[item+0x28] || + (kind!=1 && kind!=2 && kind!=4)) continue; + int dx=(int)word(r+item+5)-(s->x>>8),dy=(int)word(r+item+8)-(s->y>>8); + /* Original small/large item collision radii are 6/8. */ + int half=(r[item+11]&127) ? 6 : 8; + if (dx < -reach-half || dx > reach+half || dy < -reach-half || dy > reach+half) continue; + s->vy=(int16_t)item;s->radius=(uint8_t)kind;s->variant=4;r[item+0x28]=1; + return true; + } + return false; +} +static bool chain_cancelled(const uint8_t *r,const MmxWeaponShot *s) { + unsigned action=r[0xbaa]; + return r[0x1f0c] || !(r[0xbcf]&127) || (r[0xbb9]&64)!=s->facing || + action==12 || action==14 || action==0x18 || action==0x1a || + action==0x1c || action==0x24 || action==0x2c || action==0x30 || action==0x42; +} +static void chain_tick(uint8_t *r,unsigned d,MmxWeaponShot *s) { + if (!s->age) { + for (unsigned i=0;i<8;++i) if (combat.shots+i!=s && combat.shots[i].active && + combat.shots[i].page==1 && combat.shots[i].weapon==6) { retire(r,d);return; } + if (!MmxWeaponsSpend(1,6,128)) { retire(r,d);return; } + muzzle_origin(r,d,s);s->vx=(int16_t)((s->facing ? 1 : -1)*(s->charged ? 1536 : 1024)); + s->born=combat.tick;s->age=1;animation_start(s,s->charged ? 6 : 0); + animation_step(s); /* The source advances animation on its allocation tick. */ + native_object(r,d,s);return; + } + if (s->age==1 && s->born==combat.tick) return; + if (chain_cancelled(r,s) || s->muzzle_pose==2 || (!s->charged && r[d+1]==4) || ++s->age>160) { retire(r,d);return; } + if (s->charged && r[d+1]==4) chain_spend(16); + s->muzzle_pose=0; + if (s->charged) s->hit_slots=0; /* Source resets the contact counter every tick. */ + MmxWeaponShot muzzle=*s;muzzle_origin(r,d,&muzzle); + int direction=s->facing ? 1 : -1; + if (s->variant!=3) { + /* Source $9A4D follows body displacement, bounded to twelve pixels. + * Re-evaluating the arm anchor also accommodates Zero and wall poses. */ + int shift=muzzle.origin_x-s->origin_x; + if (shift>12) shift=12;if (shift< -12) shift=-12; + s->x+=shift*256;s->y=muzzle.y; + } + s->origin_x=muzzle.origin_x;s->origin_y=muzzle.origin_y; + if (!s->variant) { + if (s->flags&128) { + if (terrain_solid(r,s->x>>8,s->y>>8,false,NULL)) { retire(r,d);return; } + s->variant=1;s->radius=s->charged ? 18 : 16;s->tether_pose=1; + s->x=muzzle.x;s->y=muzzle.y;animation_start(s,s->charged ? 7 : 3);animation_step(s); + } else animation_step(s); + } else { + s->tether_pose=(uint8_t)((s->tether_pose+1)%(s->charged ? 12 : 18)); + if (s->variant==1) { + if ((!s->charged && chain_grab(r,s))) { /* Retract with the item on the next tick. */ } + else if ((!s->charged && !combat.held) || !--s->radius || + terrain_solid(r,s->x>>8,(s->y>>8)+(s->charged ? 8 : 5),true,NULL) || + terrain_solid(r,s->x>>8,(s->y>>8)-(s->charged ? 8 : 5),false,NULL)) s->variant=2; + else { + /* Chain movement is horizontal; vy stores a held item, not velocity. */ + unsigned hit=terrain_move(r,s,s->charged ? 8 : 5,0); + if (hit&1) { s->variant=3;animation_start(s,s->charged ? 9 : 2); } + else animation_step(s); + } + } else if (s->variant==2 || s->variant==4) { + if (s->variant==2) chain_grab(r,s); + s->x-=s->vx; + if (s->variant==4) { + unsigned item=(uint16_t)s->vy; + if (!chain_item(item) || !r[item] || r[item+10]!=s->radius || r[item+2]>=6) { + chain_release(r,s);s->vy=0;s->variant=2; + } else { putword(r+item+5,s->x>>8);putword(r+item+8,s->y>>8); } + } + /* Zero's arm reaches beyond the native pickup box. Carry an item + * through the last few pixels to the body, then let X1 collect it. */ + int destination=s->variant==4 ? (int)word(r+0xbad) : s->origin_x; + if (direction*((s->x>>8)-destination)<=0) { retire(r,d);return; } + /* Source return phases keep the current hook pose; links still animate. */ + } else if (s->variant==3) { + int distance=direction*((s->x>>8)-(int)word(r+0xbad)); + if (distance<12 || distance>=(s->charged ? 128 : 64) || + direction*((s->x>>8)-s->origin_x)<=0 || (r[0xbdf]&(s->facing ? 2 : 1))) { + retire(r,d);return; + } + /* Apply the source's 4/6 px pull after ordinary player movement. + * Sweep the host body so a hook cannot pull X/Zero through a wall. */ + MmxWeaponShot body={0};body.x=(int)word(r+0xbad)*256;body.y=(int)word(r+0xbb0)*256; + body.vx=s->vx;body.vy=(int16_t)((s->y-muzzle.y)); + int oldx=body.x;terrain_move(r,&body,MmxZeroActive() ? 10 : 6,15); + putword(r+0xbad,body.x>>8);putword(r+0xbb0,body.y>>8);putword(r+0xbc4,0); + if (oldx==body.x) { retire(r,d);return; } + muzzle_origin(r,d,&muzzle);s->origin_x=muzzle.origin_x;s->origin_y=muzzle.origin_y; + animation_step(s); + } + } + if (!s->active) { retire(r,d);return; } + native_object(r,d,s); +} diff --git a/src/mmx_weapon_combat.c b/src/mmx_weapon_combat.c new file mode 100644 index 0000000..f996a5e --- /dev/null +++ b/src/mmx_weapon_combat.c @@ -0,0 +1,1462 @@ +#include "mmx_weapon_combat.h" +#include "mmx_weapons.h" +#include "mmx_zero.h" +#include + +static MmxWeaponCombatState combat; +static uint8_t *collision_rom; +static size_t collision_rom_size; +static bool collision_patch; +static uint8_t previous_boxes[32]; +static void fang_player(uint8_t *r); +static void triad_player(uint8_t *r); +static void triad_box(const MmxWeaponShot *s,uint8_t *box); +static void chain_release(uint8_t *r,const MmxWeaponShot *s); +static void crystal_terrain(uint8_t *r); +static void crystal_contact(uint8_t *r,unsigned enemy,MmxWeaponShot *s); +static void crystal_effect_tick(uint8_t *r,MmxWeaponShot *s); +static void gravity_player(uint8_t *r); +static void gravity_effect_tick(uint8_t *r,MmxWeaponShot *s); +static void parasitic_player(uint8_t *r); +static bool parasitic_contact(uint8_t *r,unsigned enemy,MmxWeaponShot *s); +static void parasitic_release(uint8_t *r,const MmxWeaponShot *s); +_Static_assert(sizeof(MmxWeaponShot) == 40 && offsetof(MmxWeaponCombatState,enemies) == MMX_WEAPON_COMBAT_LEGACY_SIZE && + offsetof(MmxWeaponCombatState,effects)==MMX_WEAPON_COMBAT_DAMAGE_SIZE && + sizeof(MmxWeaponCombatState) == 1028, "Weapon combat save ABI"); +static unsigned word(const uint8_t *p) { return p[0] | (p[1] << 8); } +static void putword(uint8_t *p, unsigned value) { p[0] = (uint8_t)value; p[1] = (uint8_t)(value >> 8); } +static bool slot_valid(unsigned d) { return d >= 0x1228 && d < 0x1428 && (d & 63) == 0x28; } +static void silk_player_tick(uint8_t *r); +static void silk_effect_tick(MmxWeaponShot *s); +static bool owned(const uint8_t *r, unsigned d) { return slot_valid(d) && r[d] && word(r + d + 0x3e) == 0x5758; } +static unsigned slot_index(unsigned d) { return (d - 0x1228) / 64; } +static unsigned weapon_group(unsigned page, unsigned weapon) { + return page == 1 ? (weapon == 1 ? 16 : weapon == 2 ? 68 : weapon == 3 ? 72 : weapon == 4 ? 70 : weapon == 5 ? 65 : weapon == 6 ? 71 : weapon == 7 ? 15 : weapon == 8 ? 37 : 0) : + page == 2 ? (weapon == 1 ? 5 : weapon == 2 ? 6 : weapon == 3 ? 11 : weapon == 4 ? 12 : weapon == 5 ? 13 : weapon == 6 ? 15 : weapon == 7 ? 16 : weapon == 8 ? 19 : 0) : 0; +} +static void sound(uint8_t *r, unsigned command) { + unsigned i = r[0xba3] & 30; r[0xb72 + i] = (uint8_t)command; r[0xb73 + i] = 0; r[0xba3] = (uint8_t)((i + 2) & 30); +} +static void stop_charge(uint8_t *r) { + if (r[0xc2f] & 64) { sound(r, 0x17); r[0xc2f] &= (uint8_t)~64; } + memset(r + 0xbff, 0, 5); +} +MmxWeaponCombatState MmxWeaponsGetCombatState(void) { return combat; } +bool MmxWeaponsValidCombatState(const MmxWeaponCombatState *s) { + if (!s || s->valid > 1 || s->held > 1 || s->pressed > 1 || s->reserved) return false; + for (unsigned i=0;i<15;++i) if (s->enemies[i].remainder>2 || + s->enemies[i].hp>127 || s->enemies[i].active>1) return false; + for (unsigned i = 0; i < 24; ++i) { + const MmxWeaponShot *p = i<8 ? s->shots+i : s->effects+i-8; + if (i>=8 && p->active && !(p->page==2 && p->weapon==6 && p->variant>=2 && p->variant<=3) && + !(p->page==2 && p->weapon==8 && !p->charged && p->variant>=3 && p->variant<=4) && (p->page!=1 || p->charged || + !((p->weapon==8 && (p->variant==4 || p->variant==5)) || (p->weapon==3 && p->variant==3) || + (p->weapon==1 && p->variant==2)))) return false; + if (p->active > 1 || p->reserved || p->charged > 1 || p->pose >= 128 || p->animation > 8192 || + p->x < -0x1000000 || p->x > 0x1000000 || p->y < -0x1000000 || p->y > 0x1000000 || + (p->active && (!weapon_group(p->page,p->weapon) || p->group != + (p->page == 1 && p->weapon == 3 ? (p->variant==3 ? 73 : p->variant==4 ? 74 : 72) : + p->page == 1 && p->weapon == 5 && p->charged ? 135 : + p->page == 1 && p->weapon == 7 ? (p->charged ? 19 : p->muzzle_pose==3 ? 8 : 15) : + p->page == 1 && p->weapon == 8 ? (p->variant==5?23:p->charged?38:37) : + p->page == 2 && p->weapon == 3 && p->charged && !p->variant ? 51 : + p->page == 2 && p->weapon == 6 && p->charged && p->variant==1 ? (p->group==52?52:51) : + p->page == 2 && p->weapon == 2 && p->variant==4 ? (p->muzzle_pose?23:8) : + p->page == 2 && p->weapon == 8 && i>=8 ? (p->variant==3?20:8) : + weapon_group(p->page,p->weapon))))) return false; + if (p->active && p->page == 1 && p->weapon == 5 && + (p->variant > 4 || p->radius > 2 || p->muzzle_pose > 3)) return false; + if (p->active && p->page==1 && p->weapon==2 && + (p->variant>(p->charged ? 7 : 10) || p->muzzle_pose>3 || p->radius>63 || + p->tether_pose>(p->charged ? 2 : 7))) return false; + if (p->active && p->page==1 && p->weapon==6 && + (p->variant>4 || p->radius>18 || p->muzzle_pose>2 || p->tether_pose>17 || + (p->vy && (p->vy<0x1628 || p->vy>=0x1928 || (p->vy-0x1628)%0x30)))) return false; + if (p->active && p->page==1 && p->weapon==7 && + (p->variant>2 || p->muzzle_pose>3 || p->radius>(p->charged ? 32 : 60))) return false; + if (p->active && p->page==1 && p->weapon==3 && + (p->variant>4 || p->muzzle_pose>3 || p->radius>127 || p->tether_pose>8 || + (p->charged && p->age && !p->tether_pose))) return false; + if (p->active && p->page==1 && p->weapon==1 && + (p->variant>2 || p->muzzle_pose>(p->charged ? 1 : 6) || p->radius>32)) return false; + if (p->active && p->page==1 && p->weapon==8 && + (p->variant>(i<8 ? 3 : 5) || p->muzzle_pose>3 || p->radius>60 || p->tether_pose>1)) return false; + if (p->active && p->page == 1 && p->weapon == 4 && + (p->variant > (p->charged ? 7 : 0) || p->radius > (p->charged ? 6 : 30) || + p->muzzle_pose > (p->charged ? 1 : 6) || p->tether_pose > (p->charged ? 1 : 10))) return false; + if (p->active && p->page == 2 && p->weapon == 1 && + (p->muzzle_pose > 2 || p->radius > 10 || + (p->variant > 2 && p->variant != 128 && p->variant != 129 && p->variant != 130))) return false; + if (p->active && p->page==2 && p->weapon==4 && + ((p->charged ? p->variant>5 : p->variant>1 && p->variant!=128) || p->radius>80 || + (p->charged && p->variant==5 && (p->vy<1 || p->vy>120)))) return false; + if (p->active && p->page == 2 && p->weapon == 5 && (p->variant > 3 || p->radius > 16)) return false; + if (p->active && p->page == 2 && p->weapon == 7 && + (p->variant > 2 || p->muzzle_pose > 7 || + p->radius > 8 || p->tether_pose > 1 || p->origin_x < 0 || p->origin_x > 360)) return false; + if (p->active && p->page==2 && p->weapon==8 && + (p->variant>(i>=8?4:p->charged?5:2) || p->muzzle_pose>4 || p->radius>64 || p->tether_pose>7)) return false; + if (p->active && p->page==2 && p->weapon==3 && + (p->variant>(p->charged?2:8) || p->muzzle_pose>3 || p->tether_pose>19)) return false; + if (p->active && p->page==2 && p->weapon==6 && + (p->variant>(i<8?1:3) || p->muzzle_pose>4 || p->radius>180 || + (p->variant==2 && (p->origin_x<0xe68 || p->origin_x>=0x1228 || (p->origin_x&63)!=0x28)))) return false; + if (p->active && p->page==2 && p->weapon==2 && + (p->variant>4 || p->muzzle_pose>4 || p->tether_pose>3 || p->radius>120 || + (p->variant==1 && (p->origin_x<0xe68 || p->origin_x>=0x1228 || (p->origin_x&63)!=0x28)))) return false; + } + return true; +} +void MmxWeaponsSetCombatState(MmxWeaponCombatState s) { + memset(&combat, 0, sizeof(combat)); + if (MmxWeaponsValidCombatState(&s)) combat = s; +} +bool MmxWeaponsCombatActive(void) { + MmxWeaponsState s = MmxWeaponsGetState(); + return MmxWeaponsEnabled() && weapon_group(s.page,s.weapon) != 0; +} +static void retire(uint8_t *r, unsigned d) { + const MmxWeaponShot *s=combat.shots+slot_index(d); + if(s->active && s->page==2 && s->weapon==2 && s->variant==1) parasitic_release(r,s); + /* A crystallized source enemy is consumed when its crystal is discarded, + * including weapon cancellation/offscreen cleanup ($81:8DDF). */ + if (s->active && s->page==1 && s->weapon==1 && !s->charged && + s->muzzle_pose>=1 && s->muzzle_pose<=4 && s->origin_x>=0xe68 && s->origin_x<0x1228 && + r[s->origin_x+10]==s->origin_y) memset(r+s->origin_x,0,4); + if (s->active && s->page==1 && s->weapon==8 && s->charged && s->age) { + r[0xbd8]=0; + r[0xc26]&=(uint8_t)~64; + if (r[0xbaa]==0x14) { r[0xbaa]=(r[0xbd3]&4) ? 0 : 8;r[0xbab]=0; } + if (s->variant) r[0xc06]&=(uint8_t)~4; + } + if (s->active && s->page==1 && s->weapon==6) chain_release(r,s); + if (owned(r,d)) { memset(r+d,0,64); if (r[0xbdd]) --r[0xbdd]; } + memset(combat.shots + slot_index(d),0,sizeof(MmxWeaponShot)); +} +void MmxWeaponsCancelShots(uint8_t r[0x20000]) { + for (unsigned d = 0x1228; d < 0x1428; d += 64) if (r) retire(r,d); + memset(&combat,0,sizeof(combat)); +} +static void animation_record(MmxWeaponShot *s, unsigned offset) { + unsigned size = 0; + const uint8_t *data = MmxWeaponsAnimation(s->page,s->weapon,s->group,&size); + if (!data || offset + 3 > size || !data[offset] || data[offset+2] >= 128) { s->active = 0; return; } + s->animation = (uint16_t)offset; s->timer = data[offset]; s->flags = data[offset+1]; s->pose = data[offset+2]; +} +static void animation_start(MmxWeaponShot *s, unsigned sequence) { + unsigned size = 0; + const uint8_t *data = MmxWeaponsAnimation(s->page,s->weapon,s->group,&size); + if (!data || size < 2 || sequence * 2 + 2 > word(data)) { s->active = 0; return; } + animation_record(s,word(data + sequence * 2)); +} +static void animation_step(MmxWeaponShot *s) { + if (!s->timer || --s->timer) return; + unsigned next = s->animation + 3, size = 0; + const uint8_t *data = MmxWeaponsAnimation(s->page,s->weapon,s->group,&size); + if (s->flags & 128) { + if (!data || next + 2 > size) { s->active = 0; return; } + next = (unsigned)((int)next + (int16_t)word(data + next)); + } + animation_record(s,next); +} +static void cycle_weapon(uint8_t *r) { + MmxWeaponsState s=MmxWeaponsGetState(); + if (!s.page) return; /* X1 keeps its native ownership-aware cycle. */ + unsigned held=r[0xbde]&0x30,pressed=r[0xbe2]&0x30; + /* Consume shoulders even for the buster and unfinished weapons. Native + * $81:99D3 must never select an X1 weapon behind an extended selection. */ + /* Leave held input intact: native edge detection uses it next frame. + * Both held buttons may still run native buster cleanup, which is correct. */ + r[0xbe2]&=(uint8_t)~0x30; + bool bubble_shield=false; + for (unsigned i=0;i<8;++i) if (combat.shots[i].active && combat.shots[i].page==1 && + combat.shots[i].weapon==2 && combat.shots[i].charged) bubble_shield=true; + if (!pressed || r[0xd1]!=2 || r[0xd2]!=4 || r[0xd3]!=4 || r[0xba9]!=2 || + r[0x1f23] || (r[0xbdd] && !bubble_shield) || r[0x1f31] || + r[0xbaa]==0x18 || r[0xbaa]==0x42) return; + unsigned weapon=held==0x30 ? 0 : (s.weapon+(pressed&0x20 ? 8 : 1))%9; + if (weapon==s.weapon) return; + MmxZeroCancel(r); stop_charge(r); MmxWeaponsCancelShots(r); + s.weapon=(uint8_t)weapon; s.charge=s.cooldown=0; MmxWeaponsSetState(s); + /* Native $81:9A49 restores +$67 from $86:BAB8 on a weapon change. + * Buster entry is three; leaving zero makes $81:93B5 reject all fire. */ + r[0xbdb]=0; r[0xc0f]=3; r[0x1f12]=weapon ? 0 : 4; +} +static MmxWeaponShot *speed_dash(void) { + for (unsigned i=0;i<8;++i) if (combat.shots[i].active && combat.shots[i].age && + combat.shots[i].page==1 && combat.shots[i].weapon==8 && combat.shots[i].charged) + return combat.shots+i; + return NULL; +} +/* Keep X1's native dash, wall/ground collision, animation and camera. X2's + * charged dash changes its speed to $0475 and timer to $30/$18. This hook + * runs before native horizontal integration, including the first tick. */ +void MmxWeaponsPlayerMotion(uint8_t r[0x20000],unsigned d) { + if (!MmxWeaponsEnabled() || d!=0xba8) return; + MmxWeaponShot *s=speed_dash();if (!s || r[0xbaa]!=0x14) return; + putword(r+0xc04,1141);putword(r+0xbc2,s->facing ? 1141 : -1141); + r[0xbfa]=s->radius; +} +void MmxWeaponsPlayerTick(uint8_t r[0x20000]) { + if (!MmxWeaponsEnabled()) return; + cycle_weapon(r); + if (!combat.valid || combat.stage != r[0x1f7a] || r[0xd1] != 2 || !(r[0xbcf] & 127)) { + MmxWeaponsCancelShots(r); combat.valid = 1; combat.stage = r[0x1f7a]; + } + ++combat.tick; + for (unsigned i=0;i<16;++i) { + MmxWeaponShot *p=combat.effects+i;if (!p->active) continue; + if (p->page==2 && p->weapon==6) { gravity_effect_tick(r,p);continue; } + if ((p->page==2 && p->weapon==8) || (p->page==1 && p->weapon==8 && p->variant==5)) { + if((p->flags&128) && p->timer==1) {memset(p,0,sizeof(*p));continue;} + ++p->age; + if(p->page==1) {p->vy-=16;p->y+=p->vy;} + animation_step(p);continue; + } + if (p->weapon==1) { crystal_effect_tick(r,p);continue; } + if (p->weapon==3) { silk_effect_tick(p);continue; } + if (++p->age>=32) { memset(p,0,sizeof(*p));continue; } + p->vy+=(int16_t)(p->tether_pose ? -(int)p->radius : (int)p->radius); + p->x+=p->vx;p->y+=p->vy; + } + for (unsigned i=0;i<15;++i) { + unsigned d=0xe68+i*64,hp=r[d+0x27]&127; + MmxWeaponDamageState *e=combat.enemies+i; + if (!r[d] || !r[d+1] || !hp || e->kind!=r[d+10] || hp>e->hp) + memset(e,0,sizeof(*e)); + if (e->active) e->hp=(uint8_t)hp; + } + combat.held = (r[0xbdf] & 64) != 0; combat.pressed = (r[0xbe3] & 64) != 0; + combat.direction = r[0xbe3] & 12; + if (!MmxWeaponsCombatActive()) return; + /* Use X1's established firing poses, charge effects and release sound + * cleanup. The actual arm upgrade is bit $02; Zero's innate dash is $08. */ + MmxZeroCancel(r); r[0xc0f] = 2; + if (!(r[0x1f99] & 2)) stop_charge(r); + MmxWeaponsState s = MmxWeaponsGetState(); + if (s.page==1 && s.weapon==3) silk_player_tick(r); + if (s.page==1 && s.weapon==6) for (unsigned i=0;i<8;++i) { + const MmxWeaponShot *p=combat.shots+i; + if (p->active && p->page==1 && p->weapon==6 && r[0xbaa]!=12 && + r[0xbaa]!=14 && !r[0x1f0c]) r[0xbf8]=16; /* X2 $88:C43E keeps its arm out. */ + } + if (s.page==1 && s.weapon==1) r[0xc0f]=4; + if (s.page==1 && s.weapon==8) { + r[0xc0f]=1; + MmxWeaponShot *p=speed_dash(); + if (p) { + stop_charge(r);r[0xbdf]&=(uint8_t)~64;r[0xbe3]&=(uint8_t)~64; + r[0xc11]=p->facing;r[0xbb9]=(r[0xbb9]&~64)|p->facing; + if (p->variant) { r[0xc06]|=4;r[0xbe3]&=(uint8_t)~128;putword(r+0xbc4,0); } + /* Source dash can be cancelled by the opposite direction. Keep + * actual direction input; synthesize only the native dash button. */ + r[0xbde]|=128; + r[0xc26]|=64; /* Native dash-button mode ($81:9D24). */ + } + } + if (s.page==1 && s.weapon==7) r[0xc0f]=8; /* Planted mines release the source firing limit. */ + if (s.page==2 && s.weapon==8) fang_player(r); + if (s.page==2 && s.weapon==3) triad_player(r); + if (s.page==2 && s.weapon==6) gravity_player(r); + if (s.page==2 && s.weapon==2) parasitic_player(r); + if (s.page==1 && s.weapon==2) { + bool shield=false; + for (unsigned i=0;i<8;++i) if (combat.shots[i].active && combat.shots[i].page==1 && + combat.shots[i].weapon==2 && combat.shots[i].charged) shield=true; + r[0xc0f]=8; /* Seven bubbles, with room for the charged controller. */ + if (shield) { stop_charge(r);r[0xbdf]&=(uint8_t)~64;r[0xbe3]&=(uint8_t)~64; } + else if (combat.held && r[0xc03]!=1) { + /* X2 $88:C452: synthesize another fire edge every two ticks until + * fully charged. Native X1 still owns pose, charge and allocation. */ + if (combat.pressed || !s.cooldown || !--s.cooldown) { r[0xbe3]|=64;s.cooldown=2; } + } else s.cooldown=0; + MmxWeaponsSetState(s); + } + if (!MmxWeaponsEnergyAmount(s.page,s.weapon)) { stop_charge(r); r[0xbdf] &= (uint8_t)~64; r[0xbe3] &= (uint8_t)~64; } + for (unsigned i=0;i<8;++i) if (combat.shots[i].active && combat.shots[i].charged && + combat.shots[i].page == 2 && (combat.shots[i].weapon == 4 || + (combat.shots[i].weapon == 7 && combat.shots[i].muzzle_pose < 3))) { + /* Attached Blade and Frost Shield suppress another buster/charge. */ + stop_charge(r); r[0xbdf] &= (uint8_t)~64; r[0xbe3] &= (uint8_t)~64; + /* X3 $81:B574/$B5B8 retains the firing overlay while Blade is attached. */ + if ((combat.shots[i].weapon==7 || combat.shots[i].variant!=4) && + r[0xbaa]!=14 && r[0xbaa]!=12 && !r[0x1f0c]) r[0xbf8]=2; + } + for (unsigned i=0;i<8;++i) { + const MmxWeaponShot *p = combat.shots+i; + if (p->active && p->page == 2 && p->weapon == 5 && p->variant < 2) { + /* The normal burst owns one muzzle; holding can still build a charge. */ + if (!p->charged) { + r[0xbe3] &= (uint8_t)~64; + /* X3 keeps body +$50 active for the whole 60-frame burst. X1's + * ordinary shot overlay expires much sooner; retain its movement + * animation and use the actual firing timer, never beam count C25. */ + if (p->age && p->age<60 && r[0xbaa]!=14 && r[0xbaa]!=12 && !r[0x1f0c]) + r[0xbf8]=(uint8_t)(60-p->age); + } + } + } +} +void MmxWeaponsSelectShot(const uint8_t r[0x20000], unsigned d) { + if (!MmxWeaponsCombatActive() || !slot_valid(d)) return; + /* Moving/air firing ($81:93EA) clears +$59 before it calls $9D47. + * Capture the command at $94AF, shared by every firing pose. */ + combat.shots[slot_index(d)].charged=r[0xc01]==4 && (r[0x1f99]&2); +} +void MmxWeaponsMarkShot(uint8_t r[0x20000], unsigned d) { + if (!MmxWeaponsCombatActive() || !slot_valid(d) || !r[d]) return; + MmxWeaponsState w = MmxWeaponsGetState(); + MmxWeaponShot *s = combat.shots + slot_index(d); + bool charged=s->charged!=0; + memset(s,0,sizeof(*s)); s->active = 1; s->page = w.page; s->weapon = w.weapon; + s->group = (uint8_t)weapon_group(w.page,w.weapon); + /* X1 $81:94FD..951F identifies full special-weapon release by player + * +$59 == 4. Buster class 3 is the intermediate arm-upgraded beam; + * using that class inverted medium/full releases after a longer hold. */ + s->charged = charged; + if (s->page == 1 && s->weapon == 5 && s->charged) s->group = 135; + if (s->page == 1 && s->weapon == 7 && s->charged) s->group = 19; + if (s->page == 1 && s->weapon == 8 && s->charged) s->group = 38; + if (s->page == 2 && s->weapon == 3 && s->charged) s->group = 51; + s->facing = r[0xbb9] & 64; + r[d + 10] = 0; putword(r+d+0x3e,0x5758); +} +static void shot_bounds(const MmxWeaponShot *s, unsigned *rx, unsigned *ry) { + *rx = 13; *ry = 10; + if (s->page==1 && s->weapon==1) *rx=*ry=s->muzzle_pose==5 ? 24 : 8; + if (s->page==2 && s->weapon==2) {*rx=s->variant==1?16:s->charged?8:13;*ry=s->variant==1?16:s->charged?9:12;} + if (s->page==1 && s->weapon==2) *rx=*ry=7; + if (s->page==1 && s->weapon==3) *rx=*ry=(!s->charged && s->muzzle_pose==3) ? 5 : 8; + if (s->page==1 && s->weapon==8) { + static const uint8_t boxes[4][2]={{6,7},{9,8},{10,12},{12,16}}; + unsigned phase=s->flags&3;*rx=boxes[phase][0];*ry=boxes[phase][1]; + if (s->variant==1) { *rx=9;*ry=7; } + if (s->charged) *rx=*ry=20; + } + if (s->page==1 && s->weapon==7) { + *rx=*ry=s->charged ? 8*(s->variant+1) : 4; + if (!s->charged && s->muzzle_pose==3) { *rx=17;*ry=18; } + } + if (s->page==1 && s->weapon==6) *rx=*ry=s->charged ? 8 : 5; + if (s->page == 1 && s->weapon == 4) *rx = *ry = s->charged ? (s->muzzle_pose ? 6 : 7) : 9; + if (s->page == 1 && s->weapon == 5) { + *rx = s->charged ? 10 : 12; *ry = s->charged ? 11 : 8; + } + if (s->page == 2 && s->weapon == 1) { + *rx = *ry = s->charged ? 4 : s->variant == 2 ? 4 : 6; + if (s->variant >= 128) *rx = *ry = s->charged ? 8 : 4; + } + if (s->page == 2 && s->weapon == 5) { + *rx = s->charged && s->variant < 2 ? 16 : 8; *ry = 8; + } + if (s->page == 2 && s->weapon == 7) { + *rx=5;*ry=7; + if (s->charged) { + *rx=s->muzzle_pose>=5 ? 16 : s->muzzle_pose==3 ? 11 : 10; + *ry=s->muzzle_pose>=5 ? 24 : s->muzzle_pose==3 ? 7 : 17; + } + else if (s->muzzle_pose==1) { *rx=s->variant ? 17 : 14;*ry=s->variant ? 9 : 7; } + else if (s->muzzle_pose==5) { *rx=s->variant ? 23 : 15;*ry=s->variant ? 14 : 9; } + } + if (s->page==2 && s->weapon==8) { + *rx=s->charged?4:9;*ry=s->charged?(s->variant==2?16:6):7; + if (s->charged && s->variant==3) *rx=*ry=0; + } +} +static void collision_box(unsigned index, const MmxWeaponShot *s) { + if (!collision_patch) return; + unsigned rx,ry; shot_bounds(s,&rx,&ry); + uint8_t *box = previous_boxes + index*4; + box[0] = box[1] = 0; box[2] = (uint8_t)rx; box[3] = (uint8_t)ry; + if (s->page == 1 && s->weapon == 5 && s->charged) box[1] = 254; + if (s->page==1 && s->weapon==7 && !s->charged && s->muzzle_pose==3) box[1]=248; + if (s->page==1 && s->weapon==8) { + if (s->charged) box[1]=4; + else if (s->variant==1) { box[0]=3;box[1]=255; } + } + if (s->page == 2 && s->weapon == 7) { + if (s->charged && s->muzzle_pose<3) box[0]=(uint8_t)-3; + else if (!s->charged && s->muzzle_pose==1) box[0]=(uint8_t)(s->variant ? -13 : -7); + else if (!s->charged && s->muzzle_pose==5) box[1]=(uint8_t)(s->variant ? -12 : -7); + } + if (s->page==2 && s->weapon==8) { + if (!s->charged) box[0]=251; + else if (s->variant==2) { box[0]=252;box[1]=248; } + else if (s->variant!=3) box[1]=250; + } + if (s->page==2 && s->weapon==3) triad_box(s,box); + if (s->page==2 && s->weapon==2 && s->variant==3) {box[0]=1;box[1]=254;} + memcpy(collision_rom + 0x37f80 + index*4,box,4); +} +static unsigned native_damage_class(const MmxWeaponShot *s) { + /* Owner-authorized X1 elemental compatibility. Reuse the actual native + * reaction dispatch, including Penguin's igloos and Armadillo's armor. + * Speed Burner's underwater bubbles are not fire. */ + if(s->page==1 && s->weapon==8 && s->variant<2 && !(s->charged && s->tether_pose)) + return s->charged ? 0x13 : 0x0a; + if(s->page==2 && s->weapon==3) return s->charged ? 0x15 : 0x0c; + return 0; +} +static void native_object(uint8_t *r, unsigned d, const MmxWeaponShot *s) { + r[d] = 1; r[d+1] = 2; r[d+10] = (uint8_t)native_damage_class(s); r[d+14] = 0; + r[d+17] = (uint8_t)(0x22 | s->facing); r[d+0x28] = 1; + putword(r+d+5,(unsigned)(s->x >> 8)); putword(r+d+8,(unsigned)(s->y >> 8)); + putword(r+d+0x20,0xff80 + slot_index(d) * 4); putword(r+d+0x3e,0x5758); + r[d+0x30] = 0; + if (s->page==2 && s->weapon==7 && s->variant!=2) + r[d+0x30]=(uint8_t)(s->charged ? s->muzzle_pose<2 : s->age<3); + if (s->page==1 && s->weapon==7 && (s->charged || s->muzzle_pose==3)) r[d+0x30]=s->age&1; + if (s->page==1 && s->weapon==8 && s->charged) r[d+0x30]=s->age&1; + if (s->page==2 && s->weapon==8 && !s->charged && s->muzzle_pose==3) r[d+0x30]=s->radius; + collision_box(slot_index(d),s); +} +static unsigned free_slot(const uint8_t *r) { + for (unsigned d=0x1228;d<0x1428;d+=64) if (!word(r+d)) return d; + return 0; +} +static void muzzle_origin(const uint8_t *r, unsigned d, MmxWeaponShot *s) { + int dx = (int8_t)MmxZeroMuzzle(r,d,r[d+0x3c],0,16); + int dy = (int8_t)MmxZeroMuzzle(r,d,r[d+0x3c],1,253); + s->x = ((int)word(r+0xbad) + (s->facing ? dx : -dx)) * 256; + s->y = ((int)word(r+0xbb0) + dy) * 256; + s->origin_x = (int16_t)(s->x >> 8); s->origin_y = (int16_t)(s->y >> 8); +} +/* X1 $84:90B3/$916A: live screen layout -> metatile -> collision class. + * Read the current RAM map so destroyed terrain is immediately reflected. */ +static unsigned terrain_type(const uint8_t *r, int x, int y) { + if (!collision_rom || x < 0 || x >= 8192 || y < 0 || y >= 8192) return 0; + unsigned screen = r[0xe800 + (y >> 8)*32 + (x >> 8)]; + unsigned cell = 0x2000 + ((screen*512 + ((y & 240) << 1) + ((x & 240) >> 3)) & 65535); + unsigned address = word(r+0xb92) | (r[0xb94] << 16); + address += word(r+cell); + if ((address & 65535) < 0x8000) return 0; + size_t offset = ((address >> 16) & 127)*0x8000 + (address & 0x7fff); + return offset < collision_rom_size ? collision_rom[offset] & 63 : 0; +} +static int terrain_surface(unsigned type, int x) { + unsigned u = (x & 15) + 1; + /* X1's original half/quarter slopes, $84:96D3..9815. */ + if (type == 1) return 16 - (int)(u/2); + if (type == 2) return 8 - (int)(u/2); + if (type == 3) return 8 + (int)(u/2); + if (type == 4) return (int)(u/2); + if (type >= 5 && type <= 8) return 16 - (int)(type-5)*4 - (int)(u/4); + if (type >= 9 && type <= 12) return 12 - (int)(type-9)*4 + (int)(u/4); + if (type == 0x13 || type >= 0x33) return (type == 0x33 || type >= 0x3e) ? 2 : 0; + return 17; /* Empty, water, ladders and decorative classes. */ +} +static bool terrain_solid(const uint8_t *r, int x, int y, bool floor, int *surface) { + unsigned type = terrain_type(r,x,y); + if (!floor && ((type >= 1 && type <= 12) || type == 0x39 || type == 0x3a)) return false; + int height = terrain_surface(type,x); + if (height > (y & 15)) return false; + if (surface) *surface = (y & ~15) + height; + return true; +} +static bool terrain_water(const uint8_t *r,int x,int y) { + unsigned type=terrain_type(r,x,y); + return type==13 || type==14; /* X1 $84:987E water probes. */ +} +/* Extend the native terrain result, before the player consumes it. Both X + * and Zero's translated terrain boxes put their feet 16px below the origin. + * Native +$24 is the preceding Y position. Upward jumps pass through. */ +void MmxWeaponsTerrainEnd(uint8_t r[0x20000],unsigned object) { + if (!MmxWeaponsEnabled() || object!=0xba8) return; + crystal_terrain(r); + for (unsigned i=0;i<8;++i) { + MmxWeaponShot *s=combat.shots+i; + if (!s->active || s->page!=2 || s->weapon!=7 || !s->charged || s->variant!=1 || + s->muzzle_pose<5 || s->muzzle_pose>6) continue; + s->tether_pose=0; + int top=(s->y>>8)-24,x=(int)word(r+0xbad)-(s->x>>8); + int feet=(int)word(r+0xbb0)+16,previous=(int)word(r+0xbcc)+16; + if ((int16_t)word(r+0xbc4)>0 || x < -22 || x > 22 || previous>top+2 || feettether_pose=1; + } +} +/* Sweep at one-pixel intervals, preserving the subpixel endpoint. Return + * native-style contact bits: side 1, floor 4, ceiling 8. */ +static unsigned terrain_move(const uint8_t *r, MmxWeaponShot *s, unsigned rx, unsigned ry) { + unsigned hit = 0; + int32_t target = s->x + s->vx; + int direction = s->vx >= 0 ? 1 : -1; + while (s->x != target) { + int32_t next = s->x + direction*256; + if ((direction > 0 && next > target) || (direction < 0 && next < target)) next = target; + if (terrain_solid(r,(next >> 8)+direction*(int)rx,s->y >> 8,false,NULL)) { hit |= 1; break; } + s->x = next; + } + target = s->y + s->vy; direction = s->vy >= 0 ? 1 : -1; + while (s->y != target) { + int32_t next = s->y + direction*256; + if ((direction > 0 && next > target) || (direction < 0 && next < target)) next = target; + int surface = 0; + if (terrain_solid(r,s->x >> 8,(next >> 8)+direction*(int)ry,direction > 0,&surface)) { + if (direction > 0) { s->y = (surface-1-(int)ry)*256; hit |= 4; } + else hit |= 8; + break; + } + s->y = next; + } + return hit; +} +static MmxWeaponShot *spawn_child(uint8_t *r, const MmxWeaponShot *parent) { + unsigned d = free_slot(r); if (!d) return NULL; + MmxWeaponShot *s = combat.shots + slot_index(d); *s = *parent; + s->age = 1; s->born = combat.tick; s->hit_slots = 0; + memset(r+d,0,64); native_object(r,d,s); ++r[0xbdd]; return s; +} +/* X2 $86:B85D..B90B: each charged-bubble movement record lasts two + * ticks. Velocities use the source upward-positive Y convention. */ +static const int16_t bubble_route[29][2]={ + {1664,384},{1024,640},{-256,512},{-1152,384},{-1536,128}, + {-1408,-256},{-1024,-512},{128,-256},{1024,-128},{1536,128}, + {1536,384},{1408,768},{-384,512},{-1408,768},{-1408,128}, + {-1280,-128},{-1152,-512},{128,-512},{1152,-128},{1408,256}, + {1280,640},{768,640},{-256,384},{-640,256},{-1408,128}, + {-896,-256},{-896,-128},{256,-256},{1280,128} +}; +static unsigned bubble_random(unsigned salt) { + /* Source RNG arithmetic ($88:CB42), seeded from saved simulation state. + * Independent of host frame rate; source and X1 consume RNG differently. */ + unsigned seed=(combat.tick*109u+salt*977u+0x731du)&65535; + unsigned mixed=(seed*3u)&65535; + return ((seed&255)+(mixed>>8))&255; +} +static unsigned bubble_manager(void) { + for (unsigned i=0;i<8;++i) if (combat.shots[i].active && combat.shots[i].page==1 && + combat.shots[i].weapon==2 && combat.shots[i].charged && !combat.shots[i].variant) return i; + return 8; +} +static void bubble_pop(MmxWeaponShot *s) { + static const uint8_t normal[11]={0,0,13,11,12,13,11,12,13,11,12}; + static const uint8_t charged[7]={13,11,12,13,11,12,13}; + s->muzzle_pose=3;s->vx=s->vy=0; + animation_start(s,s->charged ? charged[s->variant-1] : normal[s->variant]); +} +static void bubble_emit(uint8_t *r,MmxWeaponShot *s) { + static const uint8_t sequence[7]={2,3,5,6,8,9,8}; + unsigned used=0; + for (unsigned i=0;i<8;++i) if (combat.shots[i].active && combat.shots[i].page==1 && + combat.shots[i].weapon==2) { + /* Normal bubbles occupy the same source identity bits until retired. */ + unsigned identity=combat.shots[i].charged ? combat.shots[i].variant : combat.shots[i].tether_pose; + if (identity && identity<8) used|=1u<muzzle_pose=3;return; } + MmxWeaponShot *t=spawn_child(r,s);if (!t) return; + t->variant=(uint8_t)id;t->muzzle_pose=0;t->radius=0;t->tether_pose=2; + t->origin_x=(int16_t)word(r+0xbad);t->origin_y=(int16_t)word(r+0xbb0); + t->x=(t->origin_x+8)*256;t->y=(t->origin_y+16)*256; + t->vx=bubble_route[0][0];t->vy=-bubble_route[0][1]; + t->muzzle_pose=0;t->radius=(uint8_t)(bubble_random(id)&63); + animation_start(t,sequence[id-1]); + unsigned d=0x1228+(unsigned)(t-combat.shots)*64;putword(r+d+0x20,0); +} +static void bubble_tick(uint8_t *r,unsigned d,MmxWeaponShot *s) { + if (!s->age) { + unsigned count=0,used=0; + for (unsigned i=0;i<8;++i) if (combat.shots+i!=s && combat.shots[i].active && + combat.shots[i].page==1 && combat.shots[i].weapon==2) { + if (!combat.shots[i].charged) { ++count;used|=1u<charged && count>=7) || !MmxWeaponsSpend(1,2,32)) { retire(r,d);return; } + s->age=1;s->born=combat.tick;muzzle_origin(r,d,s); + if (s->charged) { + s->variant=0;s->x=word(r+0xbad)*256;s->y=word(r+0xbb0)*256; + animation_start(s,0);bubble_emit(r,s); + } else { + static const uint8_t sequences[16]={2,3,4,5,6,7,8,9,10,2,3,4,5,6,7,8}; + unsigned motion=word(r+0xbad)>word(r+0xbca) ? word(r+0xbad)-word(r+0xbca) : word(r+0xbca)-word(r+0xbad); + if (motion>16) motion=0; + unsigned speed=256+bubble_random(d)+motion*128; + s->vx=(int16_t)(s->facing ? (int)speed : -(int)speed);s->vy=0; + s->variant=sequences[bubble_random(d+1)&15];s->radius=(uint8_t)(6+(bubble_random(d+2)&15)); + s->tether_pose=1;while (s->tether_pose<7 && (used&(1u<tether_pose))) ++s->tether_pose; + animation_start(s,1); + } + native_object(r,d,s);if (s->charged) putword(r+d+0x20,0);return; + } + if (s->age==1 && s->born==combat.tick) return; + if (s->charged && !s->variant) { + if (s->muzzle_pose==3 || !(r[0xbcf]&127) || r[0xbaa]==12) { retire(r,d);return; } + s->x=word(r+0xbad)*256;s->y=word(r+0xbb0)*256; + if (terrain_water(r,s->x>>8,s->y>>8) && r[0xbaa]==6 && + !terrain_solid(r,s->x>>8,(s->y>>8)-(MmxZeroActive()?29:21),false,NULL)) + putword(r+0xbb0,word(r+0xbb0)-3); + bubble_emit(r,s);native_object(r,d,s);putword(r+d+0x20,0); + if (++s->age==0) s->age=2;return; + } + if (s->muzzle_pose==3) { + if (s->flags&128) { retire(r,d);return; } + animation_step(s); + } else if (r[d+1]>=6 || (s->charged && bubble_manager()==8)) bubble_pop(s); + else if (s->charged) { + int px=word(r+0xbad),py=word(r+0xbb0); + s->x+=(px-s->origin_x)*256;s->y+=(py-s->origin_y)*256; + s->origin_x=(int16_t)px;s->origin_y=(int16_t)py; + if (!s->muzzle_pose) { + if (s->radius) --s->radius; + else { s->muzzle_pose=1;s->radius=0; } + } else { + if (!--s->tether_pose) { + if (++s->radius==29) { retire(r,d);return; } + s->tether_pose=2;s->vx=bubble_route[s->radius][0];s->vy=-bubble_route[s->radius][1]; + } + s->x+=s->vx;s->y+=s->vy;animation_step(s); + } + } else { + if (!s->muzzle_pose) { + if (s->flags&128) { s->muzzle_pose=1;animation_start(s,s->variant); } + else animation_step(s); + } else { + s->vy-=terrain_water(r,s->x>>8,s->y>>8) ? 64 : 12; + if (s->muzzle_pose==1) { + if (s->flags&128) s->muzzle_pose=2;else animation_step(s); + } else if (!--s->radius) bubble_pop(s); + } + s->x+=s->vx;s->y+=s->vy; + } + if (++s->age>400 || !s->active || (s->x>>8)<(int)word(r+0x1e4d)-96 || + (s->x>>8)>(int)word(r+0x1e4d)+352 || (s->y>>8)<(int)word(r+0x1e50)-128 || + (s->y>>8)>(int)word(r+0x1e50)+320) { retire(r,d);return; } + native_object(r,d,s); + if (s->muzzle_pose==3 || (s->charged && !s->muzzle_pose)) putword(r+d+0x20,0); +} +static void magnet_explode(MmxWeaponShot *s) { + s->muzzle_pose=3;s->group=8;s->vx=s->vy=0;s->hit_slots=0;animation_start(s,5); +} +static bool magnet_projectiles(uint8_t *r,const MmxWeaponShot *s) { + /* X2 $88:E67A pulls destructible enemy shots by 5/3 pixels, then + * $88:D81F absorbs at most one overlapping shot on alternating ticks. */ + for (unsigned d=0x1428;d<0x1628;d+=64) if (r[d] && !(r[d]&64) && r[d+0x28]) { + int x=word(r+d+5),y=word(r+d+8); + putword(r+d+5,x+((x<(s->x>>8)) ? 5 : -5)); + putword(r+d+8,y+((y<(s->y>>8)) ? 3 : -3)); + } + if (s->age&1) return false; + int radius=8*(s->variant+1); + for (unsigned d=0x1428;d<0x1628;d+=64) { + unsigned box=word(r+d+0x20); + if (!r[d] || (r[d]&64) || !r[d+0x28] || box<0x8000 || !collision_rom) continue; + size_t offset=0x30000+(box&32767);if (offset+4>collision_rom_size) continue; + const uint8_t *b=collision_rom+offset; + int x=(int)word(r+d+5)+(int8_t)b[0]*((r[d+17]&64) ? -1 : 1)-(s->x>>8); + int y=(int)word(r+d+8)+(int8_t)b[1]-(s->y>>8); + if (x < -radius-b[2] || x > radius+b[2] || y < -radius-b[3] || y > radius+b[3]) continue; + memset(r+d,0,4);putword(r+d+14,0);putword(r+d+0x2c,0);return true; + } + return false; +} +static void magnet_tick(uint8_t *r,unsigned d,MmxWeaponShot *s) { + if (!s->age) { + /* Source normal limit is one flying/arming mine. Once planted, +$35 + * is released while its native slot stays live until the explosion. */ + for (unsigned i=0;i<8;++i) if (combat.shots+i!=s && combat.shots[i].active && + combat.shots[i].page==1 && combat.shots[i].weapon==7 && + (combat.shots[i].charged || combat.shots[i].muzzle_pose<2)) { retire(r,d);return; } + if (!MmxWeaponsSpend(1,7,s->charged ? 768 : 256)) { retire(r,d);return; } + muzzle_origin(r,d,s);s->age=1;s->born=combat.tick;s->vy=0; + s->vx=(int16_t)((s->facing ? 1 : -1)*(s->charged ? 128 : 512)); + animation_start(s,s->charged ? 0 : 1);native_object(r,d,s);return; + } + if (s->charged) { + if (magnet_projectiles(r,s) && s->radius<32) { + ++s->radius; + if (!(s->radius&15)) { ++s->variant;animation_start(s,s->variant); } + } + } else if (s->muzzle_pose!=3 && r[d+1]>=6) magnet_explode(s); + if (s->charged || !s->muzzle_pose) { + /* $88:E6C4 steering has inertia: releasing up/down retains VY. */ + if (r[0xbdf]&8) { s->vy-=128;if (s->vy < -1024) s->vy=-1024; } + if (r[0xbdf]&4) { s->vy+=128;if (s->vy > 1024) s->vy=1024; } + unsigned hit=0; + if (s->charged) { s->x+=s->vx;s->y+=s->vy; } + else { + hit=terrain_move(r,s,4,4); + for (unsigned i=0;i<8 && !hit;++i) { + const MmxWeaponShot *t=combat.shots+i; + if (t==s || !t->active || t->page!=1 || t->weapon!=7 || t->charged) continue; + int dx=(t->x-s->x)>>8,dy=(t->y-s->y)>>8; + if (dx>=-8 && dx<=8 && dy>=-8 && dy<=8) hit=1; + } + } + if (hit) { s->muzzle_pose=1;s->vx=s->vy=0;animation_start(s,2); } + else animation_step(s); + } else if (s->muzzle_pose==1) { + if (s->flags&128) { s->muzzle_pose=2;s->radius=60;animation_start(s,3); } + else animation_step(s); + } else if (s->muzzle_pose==2) { + if (!--s->radius) magnet_explode(s); + } else { + if (s->flags&128) { + /* Source $82:AD44 detonates intersecting mines at the end of the + * blast, preserving the visible delay in a chain reaction. */ + for (unsigned i=0;i<8;++i) { + MmxWeaponShot *t=combat.shots+i; + if (t==s || !t->active || t->page!=1 || t->weapon!=7 || t->charged || t->muzzle_pose==3) continue; + int dx=(t->x-s->x)>>8,dy=((t->y-s->y)>>8)+8; + if (dx>=-21 && dx<=21 && dy>=-22 && dy<=22) magnet_explode(t); + } + retire(r,d);return; + } + animation_step(s); + } + if (++s->age>1600 || !s->active || (s->x>>8)<(int)word(r+0x1e4d)-32 || + (s->x>>8)>=(int)word(r+0x1e4d)+288 || (s->y>>8)<(int)word(r+0x1e50)-16 || + (s->y>>8)>=(int)word(r+0x1e50)+240) { retire(r,d);return; } + if (!(s->age&1) && (s->charged || s->muzzle_pose==3)) s->hit_slots=0; + native_object(r,d,s); +} +static void speed_spark(const MmxWeaponShot *parent) { + for (unsigned i=0;i<16;++i) if (!combat.effects[i].active) { + MmxWeaponShot *t=combat.effects+i;*t=*parent;t->variant=4;t->age=1;t->charged=0; + t->group=37;t->muzzle_pose=0;t->tether_pose=bubble_random(i+2)&1;t->born=combat.tick; + t->radius=4+(bubble_random(i+3)&7);t->vx=(int16_t)bubble_random(i+4); + if (parent->facing) t->vx=-t->vx; + t->vy=0;t->y+=((int)(bubble_random(i+5)&31)-16)*256; + animation_start(t,3+(bubble_random(i+6)&1));return; + } +} +static void speed_dash_bubble(const MmxWeaponShot *parent) { + /* X2 $82:B148 emits class $31 on alternating frames. Original group + * $17 sequence 0 lasts 26 ticks; velocity accelerates upward by $10. */ + for(unsigned i=0;i<16;++i) if(!combat.effects[i].active) { + MmxWeaponShot *t=combat.effects+i;*t=*parent; + t->variant=5;t->group=23;t->charged=0;t->age=1;t->born=combat.tick; + t->vx=t->vy=0;t->muzzle_pose=0;t->radius=t->tether_pose=0; + t->x+=(bubble_random(i+2)&15)*256;t->y+=((int)(bubble_random(i+3)&31)-16)*256; + animation_start(t,0);return; + } +} +static void speed_water(uint8_t *r,MmxWeaponShot *s) { + s->variant=2;s->muzzle_pose=0;animation_start(s,5); + for (unsigned i=0;i<2;++i) { + MmxWeaponShot *t=spawn_child(r,s);if (!t) break; + t->variant=3;t->radius=(uint8_t)(i*4);t->tether_pose=(uint8_t)i; + t->origin_x=(int16_t)(s-combat.shots);t->origin_y=(int16_t)(s->x>>8); + t->y+=(i ? -3 : 3)*256;t->muzzle_pose=0;animation_start(t,5); + putword(r+0x1228+(unsigned)(t-combat.shots)*64+0x20,0); + } +} +static void speed_tick(uint8_t *r,unsigned d,MmxWeaponShot *s) { + if (!s->age) { + if (!MmxWeaponsSpend(1,8,(s->charged ? 3 : 1)*256)) { retire(r,d);return; } + muzzle_origin(r,d,s);s->born=combat.tick;s->age=1; + s->vx=s->facing ? 1280 : -1280;s->vy=0; + if (s->charged) { + s->variant=!(r[0xbd3]&4);s->radius=s->variant ? 25 : 49; + s->x=word(r+0xbad)*256;s->y=word(r+0xbb0)*256;s->origin_y=(int16_t)word(r+0xbb0); + r[0xbaa]=0x14;r[0xbab]=0;r[0xbf8]=255;r[0xc17]=0; + s->tether_pose=terrain_water(r,s->x>>8,s->y>>8); + r[0xbd8]=s->tether_pose ? 0 : 128;stop_charge(r); + animation_start(s,0); + } else { + animation_start(s,6); + if (terrain_water(r,s->x>>8,s->y>>8)) speed_water(r,s); + } + native_object(r,d,s);if (s->charged && s->tether_pose) putword(r+d+0x20,0);return; + } + if (s->age==1 && s->born==combat.tick) return; + if (s->charged) { + if (r[0xbaa]!=0x14 || r[0x1f0c] || !--s->radius || !(r[0xbcf]&127)) { retire(r,d);return; } + if (s->variant) { putword(r+0xbb0,s->origin_y);r[0xbaf]=0;r[0xc06]&=(uint8_t)~4; } + s->x=word(r+0xbad)*256;s->y=word(r+0xbb0)*256; + s->tether_pose=terrain_water(r,s->x>>8,s->y>>8); + r[0xbd8]=s->tether_pose ? 0 : 128;animation_step(s); + if(s->tether_pose && !(combat.tick&1)) speed_dash_bubble(s); + if (!(s->age&1)) s->hit_slots=0; + } else if (s->variant==3) { + /* X2 $86:B5A7: two opposite eight-record orbits, two ticks each. + * These water bubbles are visual children; class $23 parent hits. */ + static const int16_t orbit[8][2]={{128,-128},{128,-512},{-128,-512},{-128,-128}, + {-128,128},{-128,512},{128,512},{128,128}}; + unsigned parent=(unsigned)s->origin_x; + if (parent>=8 || !combat.shots[parent].active || combat.shots[parent].variant!=2) { retire(r,d);return; } + const MmxWeaponShot *p=combat.shots+parent; + if (!(s->age&1)) s->radius=(s->radius+1)&7; + s->x+=p->x-s->origin_y*256+orbit[s->radius][0];s->y-=orbit[s->radius][1]; + s->origin_y=(int16_t)(p->x>>8); + } else if (s->muzzle_pose==3) { + if (s->flags&128) { retire(r,d);return; }animation_step(s); + if (s->variant==1) s->x+=s->vx; + } else if (r[d+1]>=6) { + if (s->variant) { retire(r,d);return; } + s->muzzle_pose=3;s->vx=s->vy=0;animation_start(s,8); + } else if (s->variant==1) { + if (s->muzzle_pose==0) { + unsigned hit=terrain_move(r,s,0,0); + if (hit&4) { s->muzzle_pose=1;s->radius=60;s->vx=s->facing ? 512 : -512; } + else if (!--s->radius) { retire(r,d);return; } + } else { + unsigned hit=terrain_move(r,s,3,0); + if (!--s->radius || !(hit&4) || (hit&1)) { s->muzzle_pose=3;animation_start(s,2); } + else animation_step(s); + } + } else { + if (!s->variant) { + if (terrain_water(r,s->x>>8,s->y>>8)) speed_water(r,s); + else { + if (!s->muzzle_pose && (s->flags&128)) { s->muzzle_pose=1;animation_start(s,7); } + else animation_step(s); + if (s->muzzle_pose && !(combat.tick&3)) speed_spark(s); + if (!(combat.tick&3) && terrain_solid(r,s->x>>8,(s->y>>8)+24,true,NULL)) { + MmxWeaponShot *t=spawn_child(r,s); + if (t) { + t->variant=1;t->muzzle_pose=0;t->radius=60;t->vx=0;t->vy=1024; + animation_start(t,1);putword(r+0x1228+(unsigned)(t-combat.shots)*64+0x20,0); + } + } + } + } + s->x+=s->vx; /* Source fire and water shot pass through solid terrain. */ + } + if (++s->age>240 || !s->active || (!s->charged && ((s->x>>8)<(int)word(r+0x1e4d)-32 || + (s->x>>8)>=(int)word(r+0x1e4d)+288 || (s->y>>8)<(int)word(r+0x1e50)-16 || + (s->y>>8)>=(int)word(r+0x1e50)+240))) { retire(r,d);return; } + native_object(r,d,s); + if (s->charged ? s->tether_pose : s->variant==3 || s->muzzle_pose==3 || (s->variant==1 && !s->muzzle_pose)) + putword(r+d+0x20,0); +} +static void frost_shards(uint8_t *r,const MmxWeaponShot *parent) { + /* X3 $81:A93A/$BD9E selects four class-$37 particles. Its shared + * initializer $82:FD66 uses these $86:DAE6/$DAF6 velocity rows and gravity + * 48. Sample the source rows deterministically from the saved frame/slot. + * Cosmetic particles share X1's finite projectile pool. */ + static const int16_t vx[8]={-512,-256,128,384,-640,-384,256,512}; + static const int16_t vy[8]={768,1024,1408,640,896,1152,1280,512}; + for (unsigned i=0;i<4;++i) { + MmxWeaponShot *t=spawn_child(r,parent);if (!t) break; + unsigned index=(combat.tick+i*3+(unsigned)(parent->x>>8))&7; + t->variant=2;t->muzzle_pose=7;t->origin_x=0;t->radius=0;t->tether_pose=0; + t->vx=vx[index];t->vy=-vy[(index+i*2)&7]; + animation_start(t,parent->charged ? (parent->variant ? 17 : 14) : 4); + putword(r+0x1228+(unsigned)(t-combat.shots)*64+0x20,0); + } +} +static void frost_break(uint8_t *r,MmxWeaponShot *s,bool shards) { + if (shards) frost_shards(r,s); + s->muzzle_pose=s->charged ? 4 : 7;s->vx=s->vy=0;s->origin_x=2; +} +static void frost_impact(MmxWeaponShot *s) { + s->muzzle_pose=2;s->vx=(int16_t)((-s->vx)>>2); + s->vy=s->variant ? -512 : -768;s->hit_slots=0; + animation_start(s,5); +} +static void frost_block(uint8_t *r,const MmxWeaponShot *s) { + /* X3 $84:CC0E clears a colliding, destructible enemy projectile. X1's + * pool is $1428..1627; its native damage category and hitbox remain the + * authority. Zero/immune categories are not made destructible. */ + for (unsigned d=0x1428;d<0x1628;d+=64) { + unsigned box=word(r+d+0x20),type=r[d+0x28]; + if (!r[d] || (r[d]&64) || !type || box<0x8000 || !collision_rom) continue; + size_t offset=0x30000+(box&0x7fff); + if (offset+4>collision_rom_size) continue; + const uint8_t *b=collision_rom+offset; + int x=(int)word(r+d+5)+(int8_t)b[0]*((r[d+17]&64) ? -1 : 1)- + ((s->x>>8)+(s->facing ? 3 : -3)); + int y=(int)word(r+d+8)+(int8_t)b[1]-(s->y>>8); + if (x < -10-(int)b[2] || x > 10+(int)b[2] || y < -17-(int)b[3] || y > 17+(int)b[3]) continue; + memset(r+d,0,4);putword(r+d+14,0);putword(r+d+0x2c,0);return; + } +} +static void frost_tick(uint8_t *r,unsigned d,MmxWeaponShot *s) { + /* X3 $81:A6DF / $81:BAFC, group $10. Normal phases: form, rocket, + * falling core, landed core, spike growth, planted spike, failed form, + * shatter. Charged phases: form, shield growth, shield, released chunk, + * shatter, rising water platform, floating platform. origin_x holds phase + * lifetime; radius is the steering clock. */ + if (!s->age) { + for (unsigned i=0;i<8;++i) if (combat.shots+i!=s && combat.shots[i].active && + combat.shots[i].page==2 && combat.shots[i].weapon==7 && + combat.shots[i].charged==s->charged) { retire(r,d);return; } + if (!MmxWeaponsSpend(2,7,s->charged ? 0x300 : 0x100)) { retire(r,d);return; } + muzzle_origin(r,d,s);s->born=combat.tick;s->age=1; + s->variant=(uint8_t)terrain_water(r,s->x>>8,s->y>>8); + if (s->charged) { + s->vx=(int16_t)(((s->x>>8)-(int)word(r+0xbad))*(s->facing ? 1 : -1)); + s->origin_y=(int16_t)((s->y>>8)-(int)word(r+0xbb0)); + } else s->vx=s->facing ? 16 : -16; + s->vy=0;s->origin_x=s->charged ? 360 : 0; + animation_start(s,s->charged ? 10 : s->variant*2); + native_object(r,d,s);return; + } + if (s->age==1 && s->born==combat.tick) return; + unsigned phase=s->muzzle_pose; + if (s->variant==2) { + s->vy+=48;s->x+=s->vx;s->y+=s->vy; + if (++s->age>180 || (s->y>>8)>(int)word(r+0x1e50)+256 || + (s->x>>8)<(int)word(r+0x1e4d)-64 || (s->x>>8)>(int)word(r+0x1e4d)+320) { retire(r,d);return; } + native_object(r,d,s);putword(r+d+0x20,0);return; + } else if (phase==(s->charged ? 4u : 7u)) { + if (!s->origin_x || !--s->origin_x) { retire(r,d);return; } + } else if (s->charged && r[d+1]>=8) { + /* Source state 8 shatters on a surviving enemy; state 6 retains the + * shield after killing one. These are deliberately different. */ + frost_break(r,s,false); + } else if (s->charged && phase>=5) { + int32_t previous_y=s->y; + if (!terrain_water(r,s->x>>8,s->y>>8)) frost_break(r,s,true); + else if (phase==5) { + if (!terrain_water(r,s->x>>8,(s->y>>8)-16)) { + s->muzzle_pose=6;s->origin_x=240;s->radius=0;s->vy=0; + } else { + s->vy-=240;if (s->vy < -256) s->vy=-256; + unsigned hit=terrain_move(r,s,16,24); + if (hit&8) frost_break(r,s,true); + } + if (!(s->flags&128)) animation_step(s); + } else if (!--s->origin_x) frost_break(r,s,true); + else { + static const int16_t bob[4]={0,32,0,-32}; + s->vy=bob[((s->age>>5)&3)];s->y+=s->vy; + } + if (s->tether_pose && s->muzzle_pose>=5) { + int32_t target=(int32_t)word(r+0xbb0)*256+s->y-previous_y; + if (!terrain_solid(r,word(r+0xbad),(target>>8)-(MmxZeroActive()?26:18),false,NULL)) + putword(r+0xbb0,(unsigned)(target>>8)); + else frost_break(r,s,true); + } + } else if (s->charged) { + if (phase<3) { + if (r[0xbaa]==14 || r[0xbaa]==12 || r[0x1f0c] || r[0xbaa]>=0x18) frost_break(r,s,phase!=0); + else { + s->facing=r[0xbb9]&64; + int dx=(int8_t)MmxZeroMuzzle(r,d,r[d+0x3c],0,(uint8_t)s->vx); + int dy=(int8_t)MmxZeroMuzzle(r,d,r[d+0x3c],1,(uint8_t)s->origin_y); + s->x=((int)word(r+0xbad)+(s->facing ? dx : -dx))*256; + s->y=((int)word(r+0xbb0)+dy)*256; + if (phase<2) { + if (s->flags&128) { + ++s->muzzle_pose; + if (!phase) { + s->variant=(uint8_t)terrain_water(r,s->x>>8,s->y>>8); + if (s->variant) { s->muzzle_pose=5;s->vx=s->vy=0;s->tether_pose=0;animation_start(s,13); } + else animation_start(s,12); + } + } else animation_step(s); + } else if (!--s->origin_x) { + /* $81:BC07..BC24 releases class $21 after 360 shield ticks. */ + frost_shards(r,s); + s->muzzle_pose=3;s->origin_x=180;s->vx=s->facing ? 256 : -256; + s->vy=-768;s->hit_slots=0;animation_start(s,15); + } + if (s->muzzle_pose==2) frost_block(r,s); + } + } else { + if (r[d+1]>=6 || !--s->origin_x) frost_break(r,s,true); + else { + s->vy+=32;if (s->vy>2048) s->vy=2048; + unsigned hit=terrain_move(r,s,11,7); + if (hit&1) frost_break(r,s,true); + else if (hit&4) { s->vy=0;if (!(s->age&7)) s->vx=s->facing ? 768 : -768; } + else if (hit&8) s->vy=0; + } + } + } else if (r[d+1]>=6 && phase<6) { + if (r[d+1]==6 && phase<2) { frost_shards(r,s);frost_impact(s); } + else frost_break(r,s,r[d+1]==6); + } else if (!phase) { + unsigned hit=terrain_move(r,s,5,7); + if ((hit&1) || terrain_solid(r,s->x>>8,s->y>>8,false,NULL)) { + s->muzzle_pose=6;s->origin_x=120;s->vx=s->facing ? 512 : -512; + animation_start(s,18+s->variant); + } else if (s->flags&128) { s->muzzle_pose=1;animation_start(s,1+s->variant*2); } + else animation_step(s); + } else if (phase==1) { + unsigned accel=s->variant ? 8 : 16,maximum=s->variant ? 768 : 1024; + int speed=(s->facing ? s->vx : -s->vx)+(int)accel; + if (speed>(int)maximum) speed=(int)maximum; + s->vx=(int16_t)(s->facing ? speed : -speed);s->vy=0; + /* Source $A7BF clears VY between single-pixel steering pulses. */ + if (++s->radius==8) { + s->radius=0;s->vy=(r[0xbdf]&8) ? -256 : (r[0xbdf]&4) ? 256 : 0; + } + unsigned hit=terrain_move(r,s,5,7); + if (hit&1) { frost_shards(r,s);frost_impact(s); } else animation_step(s); + } else if (phase==2) { + s->vy+=s->variant ? 24 : 32; + if (s->vy>(s->variant ? 1024 : 1536)) s->vy=s->variant ? 1024 : 1536; + unsigned hit=terrain_move(r,s,5,7); + if (hit&4) { s->muzzle_pose=3;s->vx=s->vy=0; } + else if (hit&8) s->vy=0; + } else if (phase<6) { + if (!terrain_solid(r,s->x>>8,(s->y>>8)+8,true,NULL)) frost_break(r,s,true); + else if (phase==5) { if (!--s->origin_x) frost_break(r,s,true); } + else if (s->flags&128) { + ++s->muzzle_pose; + if (phase==3) animation_start(s,6+s->variant*2); + else { s->origin_x=240;animation_start(s,7+s->variant*2); } + } else animation_step(s); + } else { + if (!--s->origin_x) { retire(r,d);return; } + s->vx=-s->vx;s->x+=s->vx;animation_step(s); + } + if (++s->age>720 || !s->active || s->x/256<(int)word(r+0x1e4d)-96 || + s->x/256>(int)word(r+0x1e4d)+352 || s->y/256<(int)word(r+0x1e50)-160 || + s->y/256>(int)word(r+0x1e50)+320) { retire(r,d);return; } + native_object(r,d,s); + if ((s->charged && s->muzzle_pose==4) || (!s->charged && s->muzzle_pose>=6)) putword(r+d+0x20,0); +} +static unsigned wheel_spin_sequence(const MmxWeaponShot *s) { + unsigned speed=s->origin_x>0 ? (unsigned)s->origin_x>>8 : 0; + return 4-(speed>7 ? 7 : speed)/2; /* X2 $86:B76A. */ +} +static void wheel_split(uint8_t *r,unsigned d,MmxWeaponShot *s) { + /* X2 uses a visual center plus eight projectiles. Reuse the center's + * native slot for direction 0; keep its brief flash as saved render state. */ + static const int8_t dx[8]={0,6,8,6,0,-6,-8,-6},dy[8]={8,6,0,-6,-8,-6,0,6}; + static const int16_t vx[8]={0,724,1024,724,0,-724,-1024,-724}; + static const int16_t vy[8]={1024,724,0,-724,-1024,-724,0,724}; + MmxWeaponShot center=*s; + for (unsigned i=0;i<8;++i) { + MmxWeaponShot *t=i ? spawn_child(r,¢er) : s; + if (!t) break; + t->variant=(uint8_t)i;t->muzzle_pose=1;t->radius=i ? 0 : 6; + t->tether_pose=(uint8_t)(center.facing!=0); + t->origin_x=(int16_t)(center.x>>8);t->origin_y=(int16_t)(center.y>>8); + t->x=center.x+dx[i]*256;t->y=center.y+dy[i]*256; + t->vx=vx[i];t->vy=vy[i];t->facing=0; + t->age=1;t->born=combat.tick;t->hit_slots=0; + animation_start(t,12+i); + native_object(r,i ? 0x1228+(unsigned)(t-combat.shots)*64 : d,t); + } +} +static int wheel_traction(const uint8_t *r,const MmxWeaponShot *s) { + unsigned type=terrain_type(r,s->x>>8,(s->y>>8)+9); + bool right=s->facing!=0; + if (type==1 || type==2) return right ? -20 : 16; + if (type==3 || type==4) return right ? 16 : -20; + if (type>=5 && type<=8) return right ? -16 : 0; + if (type>=9 && type<=12) return right ? 0 : -16; + return -4; +} +static void wheel_tick(uint8_t *r,unsigned d,MmxWeaponShot *s) { + /* Normal phases: form, fall, ground delay, roll, airborne roll, wall + * delay, shrink. origin_x is momentum; origin_y advances the ground FX; + * tether_pose is the native ten-frame enemy-contact pause. */ + if (!s->age) { + for (unsigned i=0;i<8;++i) if (combat.shots+i!=s && combat.shots[i].active && + combat.shots[i].page==1 && combat.shots[i].weapon==4 && + combat.shots[i].charged==s->charged) { retire(r,d); return; } + if (!MmxWeaponsSpend(1,4,s->charged ? 0x300 : 0x100)) { retire(r,d); return; } + muzzle_origin(r,d,s);s->age=1;s->born=combat.tick; + s->vx=s->vy=0; + if (!s->charged) { s->origin_x=1024;s->origin_y=0; } + animation_start(s,s->charged ? 11 : 0);native_object(r,d,s);return; + } + if (s->age==1 && s->born==combat.tick) return; + if (s->charged) { + if (!s->muzzle_pose) { + if (s->flags&1) { wheel_split(r,d,s); return; } + animation_step(s); + } else { + if (r[d+1]>=8) { retire(r,d); return; } + s->x+=s->vx;s->y+=s->vy; + if (s->radius) --s->radius; + } + } else { + unsigned previous_speed=wheel_spin_sequence(s); + /* X2 $87:87EA applies even during formation. The entry update clears + * vertical speed, then ten following updates consume momentum. Retain + * the movement phase so contact can retrigger before it resumes. */ + bool contact=s->muzzle_pose!=6 && !s->tether_pose && r[d+1]>=4; + if (contact) { s->tether_pose=10;s->vy=0; } + if (s->tether_pose) { + if (!contact) { + s->origin_x-=8; + if (!--s->tether_pose) s->hit_slots=0; + } + } else if (!s->muzzle_pose) { + if (s->flags&128) { s->muzzle_pose=1;animation_start(s,1); } + } else if (s->muzzle_pose==2 || s->muzzle_pose==5) { + ++s->origin_y; + if (s->muzzle_pose==5) s->origin_x-=8; + if (s->radius && !--s->radius) { + if (s->muzzle_pose==5) { s->vy=-s->origin_x;s->muzzle_pose=4; } + else s->muzzle_pose=3; + } + } else { + unsigned phase=s->muzzle_pose; + if (phase==6 && (s->flags&128)) { retire(r,d); return; } + if (phase==3) { + s->origin_x=(int16_t)(s->origin_x+wheel_traction(r,s)); + if (s->origin_x>1024) s->origin_x=1024; + ++s->origin_y; + } + s->vx=(phase==3 || phase==4) ? (int16_t)(s->origin_x*(s->facing ? 1 : -1)) : 0; + s->vy+=64;if (s->vy>1024) s->vy=1024; + unsigned hit=terrain_move(r,s,8,8); + if (hit&4) { + s->vy=0; + if (phase==1) { s->muzzle_pose=2;s->radius=30; } + else if (phase==4) s->muzzle_pose=3; + } else if (phase==3) s->muzzle_pose=4; + if (hit&8) s->vy=0; + if (hit&1) { + if (phase==3) { s->muzzle_pose=5;s->radius=30;s->vx=s->vy=0; } + else if (phase==4) { s->facing^=64;s->origin_x-=16; } + } else if (phase==4 && !(hit&4)) s->origin_x-=4; + } + if (s->origin_x<0 && s->muzzle_pose!=6) { + s->muzzle_pose=6;s->vx=0;s->tether_pose=0;animation_start(s,10); + } else if (s->muzzle_pose>=2 && s->muzzle_pose!=6 && previous_speed!=wheel_spin_sequence(s)) + animation_start(s,wheel_spin_sequence(s)); + animation_step(s); + } + if (++s->age>600 || !s->active || s->x/256<(int)word(r+0x1e4d)-96 || + s->x/256>(int)word(r+0x1e4d)+352 || s->y/256<(int)word(r+0x1e50)-160 || + s->y/256>(int)word(r+0x1e50)+288) { retire(r,d); return; } + native_object(r,d,s); + if ((!s->charged && (s->muzzle_pose==6 || s->tether_pose)) || + (s->charged && !s->muzzle_pose)) putword(r+d+0x20,0); +} +static void sonic_tick(uint8_t *r, unsigned d, MmxWeaponShot *s) { + /* X2 $81:9622/$A5D2 launch from the final forming-animation flag. + * Variant selects the source velocity row; muzzle_pose is the phase: + * 0 forming, 1 flight, 2 charged descent, 3 enemy impact. */ + static const int16_t vx[5] = {0,400,-400,787,-787}; + static const int16_t vy[5] = {-2304,-2269,-2269,-2165,-2165}; + static const uint8_t gravity[5] = {80,78,78,75,75}; + if (!s->age) { + for (unsigned i=0;i<8;++i) if (combat.shots+i != s && combat.shots[i].active && + combat.shots[i].page == 1 && combat.shots[i].weapon == 5) { retire(r,d); return; } + if (!MmxWeaponsSpend(1,5,s->charged ? 0x200 : 0x80)) { retire(r,d); return; } + muzzle_origin(r,d,s); s->age = 1; s->born = combat.tick; + s->vx = s->charged ? 0 : (s->facing ? 768 : -768); + s->vy = s->charged ? -2304 : 128; + animation_start(s,0); native_object(r,d,s); return; + } + if (s->age == 1 && s->born == combat.tick) return; + if (s->muzzle_pose != 3 && r[d+1] >= 8) { + s->muzzle_pose = 3; s->vx = s->vy = 0; + animation_start(s,s->charged ? 3 : 4); + } + if (s->muzzle_pose == 3) { + if (s->flags & 128) { retire(r,d); return; } + animation_step(s); + } else if (!s->muzzle_pose) { + if (s->flags & 128) { + s->muzzle_pose = 1; animation_start(s,1); + for (unsigned i=1;i<(s->charged ? 5u : 2u);++i) { + MmxWeaponShot *t = spawn_child(r,s); if (!t) break; + t->variant = (uint8_t)i; + t->vx = s->charged ? vx[i] : (s->facing ? 896 : -896); + t->vy = s->charged ? vy[i] : 128; + } + } else animation_step(s); + } else if (s->charged) { + s->vy += s->muzzle_pose == 2 ? 96 : gravity[s->variant]; + s->x += s->vx; s->y += s->vy; + if (s->muzzle_pose == 1) { + animation_step(s); + if (s->vy > 0) { s->muzzle_pose = 2; s->vx = 0; animation_start(s,2); } + } else { + /* Native $A647 clamps the high byte after movement. */ + int original = -s->vy; + if (original < -2048) s->vy = (int16_t)(2048 - (original & 255)); + } + } else { + s->vy -= s->variant ? 8 : 4; + unsigned hit = terrain_move(r,s,12,8); + if (hit & 1) { s->vx = -s->vx; s->facing ^= 64; } + if (hit & 12) { + if (++s->radius >= 3) { retire(r,d); return; } + s->vy = (int16_t)((-s->vy) >> 1); + } + int original = -s->vy; + if (original >= 1024) s->vy = (int16_t)-(768 + (original & 255)); + animation_step(s); + } + if (++s->age > 360 || s->x/256 < (int)word(r+0x1e4d)-96 || + s->x/256 > (int)word(r+0x1e4d)+352 || s->y/256 < (int)word(r+0x1e50)-160 || + s->y/256 > (int)word(r+0x1e50)+288 || !s->active) { retire(r,d); return; } + native_object(r,d,s); + if (s->muzzle_pose == 3) putword(r+d+0x20,0); +} +static void acid_splash(MmxWeaponShot *s, unsigned contact) { + s->variant = 128; s->radius = 0; s->hit_slots = 0; + unsigned sequence = (contact & 1) ? 11 : (contact & 8) ? 10 : 7; + s->vx = sequence == 11 ? 24 : sequence == 10 ? 42 : 40; s->vy = 0; + if (contact & 1) s->facing ^= 64; + animation_start(s,sequence); +} +static void acid_fragments(uint8_t *r, const MmxWeaponShot *parent) { + /* X3 $06:B8AD..B90B: four droplets, oriented for the struck surface. */ + static const int16_t dx[3][4] = {{0,-6,-10,-12},{0,0,0,0},{0,0,0,0}}; + static const int16_t dy[3][4] = {{-8,-6,-4,0},{0,0,0,0},{0,0,0,0}}; + static const int16_t vx[3][4] = {{64,128,256,448},{512,192,-192,-512},{576,256,-256,-576}}; + static const int16_t vy[3][4] = {{-768,-512,-384,-256},{64,64,64,64},{-512,-768,-576,-384}}; + unsigned kind = parent->muzzle_pose; + for (unsigned i=0;i<4;++i) { + MmxWeaponShot *s = spawn_child(r,parent); if (!s) break; + s->charged = 0; s->variant = 2; s->radius = 10; + s->x += dx[kind][i]*256; s->y += dy[kind][i]*256; + s->vx = vx[kind][i]*(parent->facing ? 1 : -1); s->vy = vy[kind][i]; + s->facing &= 64; s->muzzle_pose = 0; animation_start(s,4); + } +} +static void acid_tick(uint8_t *r, unsigned d, MmxWeaponShot *s) { + /* Acid variants: 0/1 primary blobs, 2 droplets, 128 surface splash, + * 129 droplet splash, 130 enemy impact. Radius holds bounces/grace frames; + * muzzle_pose holds the surface orientation for the four-droplet emission. */ + if (!s->age) { + if (!MmxWeaponsSpend(s->page,s->weapon,(s->charged ? 2 : 1)*256)) { retire(r,d); return; } + muzzle_origin(r,d,s); s->born = combat.tick; s->age = 1; + s->radius = s->charged ? 5 : 0; + s->vx = (s->facing ? 1 : -1) * (s->charged ? 192 : 288); + s->vy = s->charged ? -1280 : -640; + if (!s->charged && (r[0xbdf] & 12)) { + s->vx = 0; s->vy = (r[0xbdf] & 8) ? -1280 : 1280; + } + animation_start(s,s->charged ? 12 : 2); + if (s->charged) { + MmxWeaponShot *t = spawn_child(r,s); + if (t) { t->variant = 1; t->vx = (t->facing ? 1 : -1)*512; t->vy = -768; } + } + native_object(r,d,s); return; + } + if (s->age == 1 && s->born == combat.tick) return; + if (s->variant < 128 && r[d+1] >= 8) { + s->variant = 130; s->vx = 30; s->vy = 0; animation_start(s,6); + } + if (s->variant >= 128) { + if (!s->vx) { retire(r,d); return; } + --s->vx; + if (s->variant == 128 && (s->flags & 1) && !s->radius) { + s->radius = 1; + if (!s->charged) acid_fragments(r,s); + } + } else { + s->vy += s->charged ? 56 : s->variant == 2 ? 48 : 32; + if (s->vy > (s->charged ? 1280 : 1536)) s->vy = s->charged ? 1280 : 1536; + unsigned rx = s->charged || s->variant == 2 ? 4 : 6, hit; + if (s->variant == 2 && s->radius) { --s->radius; s->x += s->vx; s->y += s->vy; hit = 0; } + else hit = terrain_move(r,s,rx,rx); + if (hit) { + if (s->charged && s->radius) { + MmxWeaponShot *p = spawn_child(r,s); + if (p) acid_splash(p,hit); + if (!--s->radius) { retire(r,d); return; } + if (hit & 1) { s->vx = -s->vx; s->facing ^= 64; } + else { s->vx = (s->facing ? 1 : -1)*256; s->vy = (hit & 8) ? 1280 : -1280; } + } else if (s->variant == 2) { + s->variant = 129; s->vx = 24; s->vy = 0; animation_start(s,5); + } else { + s->muzzle_pose = (hit & 1) ? 0 : (hit & 8) ? 1 : 2; + acid_splash(s,hit); + } + } + } + if (++s->age > 360 || s->x/256 < (int)word(r+0x1e4d)-128 || + s->x/256 > (int)word(r+0x1e4d)+384 || s->y/256 < (int)word(r+0x1e50)-192 || + s->y/256 > (int)word(r+0x1e50)+416) { retire(r,d); return; } + /* The growth sequence marks its final size with bit 1. */ + if (s->charged && s->variant < 128 && (s->flags & 2)) animation_start(s,0); + else animation_step(s); + if (!s->active) { retire(r,d); return; } + native_object(r,d,s); + if (s->variant >= 129 || (!s->charged && s->variant == 128)) putword(r+d+0x20,0); +} +static int ray_vector(unsigned angle) { + /* Original X3 $06:E18E 32-direction vectors, quarter-wave symmetry. */ + static const int16_t quarter[9] = {0,124,228,307,361,409,457,496,512}; + angle &= 31; int sign = angle >= 16 ? -1 : 1; angle &= 15; + return sign * quarter[angle > 8 ? 16-angle : angle] * 8; +} +static void ray_emit(uint8_t *r, MmxWeaponShot *s) { + static const uint8_t forward[8] = {7,9,8,7,9,6,10,0}; + static const uint8_t radial[16] = {2,20,12,24,8,10,18,30,28,14,4,16,0,26,6,22}; + MmxWeaponShot *t = spawn_child(r,s); if (!t) return; + unsigned angle; + if (s->charged) { s->radius = (s->radius+1)&15; angle = radial[s->radius]; } + else angle = forward[(s->radius++)&7] + (s->facing ? 0 : 16); + t->variant = 2; t->vx = (int16_t)ray_vector(angle); t->vy = (int16_t)-ray_vector(angle+8); + t->radius = 0; t->origin_x = (int16_t)(t->x>>8); t->origin_y = (int16_t)(t->y>>8); + animation_start(t,3); +} +static void ray_tick(uint8_t *r, unsigned d, MmxWeaponShot *s) { + /* Variants 0 muzzle/deploy/rise, 1 deployed turret, 2 ray, 3 draining trail. + * Turret phase age is stored in origin_x; radius is the emission direction. */ + if (!s->age) { + for (unsigned i=0;i<8;++i) if (combat.shots+i != s && combat.shots[i].active && + combat.shots[i].page == 2 && combat.shots[i].weapon == 5 && + combat.shots[i].variant < 2 && combat.shots[i].charged == s->charged) { retire(r,d); return; } + if (!MmxWeaponsSpend(2,5,s->charged ? 0x280 : 0x100)) { retire(r,d); return; } + s->age = 1; s->born = combat.tick; + muzzle_origin(r,d,s); animation_start(s,s->charged ? 0 : 10); + if (s->charged) { + s->x = ((int)word(r+0xbad) + (s->facing ? -2 : 2))*256; + s->y = ((int)word(r+0xbb0) - (MmxZeroActive() ? 52 : 44))*256; + s->vy = -768; s->origin_x = 0; + } else { + s->origin_x = (int16_t)(((s->x>>8)-(int)word(r+0xbad))*(s->facing ? 1 : -1)); + s->origin_y = (int16_t)((s->y>>8)-(int)word(r+0xbb0)); + } + native_object(r,d,s); putword(r+d+0x20,0); return; + } + if (s->age == 1 && s->born == combat.tick) return; + if (s->variant == 3) { + if (++s->radius >= 3) { retire(r,d); return; } + } else if (s->variant == 2) { + if (r[d+1] >= 8) { s->variant = 3; s->radius = 0; } + else { s->x += s->vx; s->y += s->vy; } + } else if (!s->charged) { + if (s->age >= 60 || r[0xbaa] == 14 || r[0xbaa] == 12 || r[0x1f0c]) { retire(r,d); return; } + s->facing = r[0xbb9] & 64; + /* Keep the firing origin after the brief native firing overlay finishes; + * movement-specific Zero poses can still supply updated arm coordinates. */ + int dx = (int8_t)MmxZeroMuzzle(r,d,r[d+0x3c],0,(uint8_t)s->origin_x); + int dy = (int8_t)MmxZeroMuzzle(r,d,r[d+0x3c],1,(uint8_t)s->origin_y); + s->x = ((int)word(r+0xbad)+(s->facing ? dx : -dx))*256; + s->y = ((int)word(r+0xbb0)+dy)*256; + if (!(s->age & 7)) ray_emit(r,s); + animation_step(s); + } else if (!s->variant) { + if (s->age >= 36) { + s->vy += 32; + if (s->vy >= 0) { + s->vy = 0; s->variant = 1; s->origin_x = 0; animation_start(s,1); + } else { s->y += s->vy; animation_step(s); } + } + } else { + if (++s->origin_x >= 180 || r[d+1] >= 8) { retire(r,d); return; } + if (!(s->origin_x & 7)) ray_emit(r,s); + animation_step(s); + } + if (++s->age > 300 || s->x/256 < (int)word(r+0x1e4d)-96 || + s->x/256 > (int)word(r+0x1e4d)+352 || s->y/256 < (int)word(r+0x1e50)-160 || + s->y/256 > (int)word(r+0x1e50)+320 || !s->active) { retire(r,d); return; } + native_object(r,d,s); + if (s->variant == 3 || (s->variant < 2 && (!s->charged || !s->variant))) putword(r+d+0x20,0); +} +#include "mmx_weapon_fang.inc" +#include "mmx_weapon_silk.inc" +#include "mmx_weapon_triad.inc" +#include "mmx_weapon_chain.inc" +#include "mmx_weapon_crystal.inc" +#include "mmx_weapon_gravity.inc" +#include "mmx_weapon_parasitic.inc" +#include "mmx_weapon_blade.inc" +unsigned MmxWeaponsProjectileTick(uint8_t r[0x20000], unsigned d, unsigned active) { + if (!owned(r,d)) { + if (slot_valid(d)) memset(combat.shots+slot_index(d),0,sizeof(MmxWeaponShot)); + return active; + } + MmxWeaponShot *s = combat.shots + slot_index(d); + if (!MmxWeaponsEnabled() || !s->active) { retire(r,d); return 0; } + if (s->page == 2 && s->weapon == 2) { parasitic_tick(r,d,s); return 0; } + if (s->page == 2 && s->weapon == 6) { gravity_tick(r,d,s); return 0; } + if (s->page == 1 && s->weapon == 1) { crystal_tick(r,d,s); return 0; } + if (s->page == 1 && s->weapon == 2) { bubble_tick(r,d,s); return 0; } + if (s->page == 1 && s->weapon == 3) { silk_tick(r,d,s); return 0; } + if (s->page == 1 && s->weapon == 6) { chain_tick(r,d,s); return 0; } + if (s->page == 1 && s->weapon == 7) { magnet_tick(r,d,s); return 0; } + if (s->page == 1 && s->weapon == 8) { speed_tick(r,d,s); return 0; } + if (s->page == 1 && s->weapon == 4) { wheel_tick(r,d,s); return 0; } + if (s->page == 1 && s->weapon == 5) { sonic_tick(r,d,s); return 0; } + if (s->page == 2 && s->weapon == 1) { acid_tick(r,d,s); return 0; } + if (s->page == 2 && s->weapon == 5) { ray_tick(r,d,s); return 0; } + if (s->page == 2 && s->weapon == 7) { frost_tick(r,d,s); return 0; } + if (s->page == 2 && s->weapon == 8) { fang_tick(r,d,s); return 0; } + if (s->page == 2 && s->weapon == 3) { triad_tick(r,d,s); return 0; } + blade_tick(r,d,s); + return 0; +} + +void MmxWeaponsCollisionRom(uint8_t *rom, size_t size) { + collision_rom = rom; collision_rom_size = size; collision_patch = false; + uint8_t empty[32]; memset(empty,255,32); + if (!rom || size < 0x37fa0 || (memcmp(rom+0x37f80,empty,32) && memcmp(rom+0x37f80,previous_boxes,32))) return; + collision_patch = MmxWeaponsEnabled(); + memcpy(previous_boxes,empty,32); memcpy(rom+0x37f80,empty,32); + if (collision_patch) for (unsigned i=0;i<8;++i) collision_box(i,combat.shots+i); +} +static unsigned enemy_bit(unsigned enemy) { + return enemy >= 0xe68 && enemy < 0x1228 && (enemy & 63) == 0x28 ? 1u << ((enemy-0xe68)/64) : 0; +} +static unsigned source_damage(const MmxWeaponShot *s) { + /* Neutral ordinary-enemy rows: X2 $86:F4C8, X3 $86:E55D. + * Both use buster=3. Classes are determined by the projectile phase, + * not by the menu ID (Ray children are $1C, Acid droplets are $18). + * Special-response weapons are added with their own behavior handlers. */ + if (s->page==1) { + if (s->weapon==1) return 1; + if (s->weapon==2) return s->charged ? 5 : 2; + if (s->weapon==3) return s->variant==3 ? (s->charged ? 10 : 5) : (s->charged ? 30 : 15); + if (s->weapon==6) return s->charged ? 1 : 5; + if (s->weapon==7) return 5; + if (s->weapon==8) return s->charged || s->variant>=2 ? 1 : 5; + if (s->weapon==4) return s->charged ? 50 : 25; + if (s->weapon==5) return s->charged ? 1 : 4; + } else if (s->page==2) { + if (s->weapon==1) return s->variant==2 ? 5 : 9; + if (s->weapon==2) return s->variant==1?15:s->charged?5:2; + if (s->weapon==3) return s->charged ? (s->variant?9:15) : (s->variant<3?15:5); + if (s->weapon==4) return s->charged ? 30 : 9; + if (s->weapon==5) return s->variant>=2 ? 5 : 9; + if (s->weapon==7) return s->charged && s->muzzle_pose==3 ? 9 : 15; + if (s->weapon==8) return s->charged ? 6 : 2; + } + return 3; +} +unsigned MmxWeaponsDamage(uint8_t r[0x20000], unsigned enemy, unsigned d, unsigned original) { + if (!owned(r,d) || !original || (original & 128)) return original; + MmxWeaponShot *s = combat.shots + slot_index(d); + unsigned bit = enemy_bit(enemy); + if (!bit || !s->active) return original; + if (s->hit_slots & bit) return 0; + s->hit_slots |= (uint16_t)bit; + if (s->page==1 && s->weapon==1 && !s->charged && s->muzzle_pose==5) + return enemy==(unsigned)s->origin_x ? 127 : 0; + /* X1 categories 0..5 are ordinary enemies; 6..19 contain the eight + * Maverick and special encounter/armored response rows at $86:EF37. + * Source bosses mostly take one from these attacks and one from buster. + * Preserve that neutral ratio, with the owner's explicit Fire Wave / + * Electric Spark compatibility using X1's native response instead. */ + if (r[enemy+0x28]>=6) { + if (s->page==1 && s->weapon==6) chain_contact(r,s,enemy,original); + if (s->page==2 && s->weapon==8) fang_contact(r,s,enemy); + return original; + } + if(s->page==2 && s->weapon==2 && !s->charged && !s->variant && parasitic_contact(r,enemy,s)) return 0; + if (s->page==1 && s->weapon==1 && !s->charged && !s->muzzle_pose) + crystal_contact(r,enemy,s); + MmxWeaponDamageState *e=combat.enemies+(enemy-0xe68)/64; + unsigned hp=r[enemy+0x27]&127; + if (!e->active || e->kind!=r[enemy+10] || hp>e->hp) { + e->remainder=1; /* Round the cumulative total to nearest whole HP. */ + e->kind=r[enemy+10];e->active=1; + } + e->hp=(uint8_t)hp; + unsigned basis=original; + if(native_damage_class(s) && collision_rom && collision_rom_size>0x37013) { + unsigned row=0x36f37+word(collision_rom+0x36f37+r[enemy+0x28]*2); + basis=collision_rom[row]; /* Keep source ratios on the buster HP scale. */ + } + unsigned scaled=basis*source_damage(s)+e->remainder; + e->remainder=(uint8_t)(scaled%3); + unsigned damage=scaled/3; + if(s->page==1 && s->weapon==8 && native_damage_class(s) && original>basis && damagepage==1 && s->weapon==6) chain_contact(r,s,enemy,damage); + if (s->page==2 && s->weapon==8) fang_contact(r,s,enemy); + return damage>127 ? 127 : damage; +} +unsigned MmxWeaponsHitbox(const uint8_t r[0x20000], unsigned enemy, unsigned d, unsigned original) { + if (MmxWeaponsMovedEnemy(&combat,enemy) || MmxWeaponsMovedEnemy(&combat,d)) return 0; + if (MmxWeaponsFrozenEnemy(&combat,enemy) || MmxWeaponsFrozenEnemy(&combat,d)) return 0; + /* A shattering crystal resumes its host's native collision to deliver + * the lethal hit. That must not also let the host hurt the dashing player. */ + if (enemy==0xba8 || d==0xba8) for(unsigned i=0;i<8;++i) { + const MmxWeaponShot *s=combat.shots+i; + if(s->active && s->page==1 && s->weapon==1 && !s->charged && s->muzzle_pose==5 && + s->origin_x==(int)(enemy==0xba8 ? d : enemy)) return 0; + } + if (owned(r,d) && (combat.shots[slot_index(d)].hit_slots & enemy_bit(enemy))) return 0; + return original; +} diff --git a/src/mmx_weapon_combat.h b/src/mmx_weapon_combat.h new file mode 100644 index 0000000..0373a52 --- /dev/null +++ b/src/mmx_weapon_combat.h @@ -0,0 +1,48 @@ +#pragma once +#include +#include +#include + +typedef struct MmxWeaponShot { + int32_t x, y; /* 16.8 world coordinates. */ + int16_t vx, vy; + uint16_t age, animation, hit_slots, born; + int16_t origin_x, origin_y; + uint8_t page, weapon, group, pose, timer, flags, facing, variant; + uint8_t charged, radius, active, reserved; + uint8_t tether_pose, muzzle_pose; +} MmxWeaponShot; +/* X1 has integer HP; retain thirds so source ratios do not round every ray + * up or turn every sub-buster contact into a full buster hit. */ +typedef struct MmxWeaponDamageState { + uint8_t remainder, kind, hp, active; +} MmxWeaponDamageState; +#define MMX_WEAPON_COMBAT_LEGACY_SIZE 328u +#define MMX_WEAPON_COMBAT_DAMAGE_SIZE 388u +typedef struct MmxWeaponCombatState { + MmxWeaponShot shots[8]; + uint16_t tick; + uint8_t stage, valid, held, pressed, direction, reserved; + MmxWeaponDamageState enemies[15]; + MmxWeaponShot effects[16]; /* Particles/utility victims do not consume native hit slots. */ +} MmxWeaponCombatState; +MmxWeaponCombatState MmxWeaponsGetCombatState(void); +bool MmxWeaponsValidCombatState(const MmxWeaponCombatState *state); +void MmxWeaponsSetCombatState(MmxWeaponCombatState state); +bool MmxWeaponsCombatActive(void); +void MmxWeaponsPlayerTick(uint8_t ram[0x20000]); +void MmxWeaponsPlayerMotion(uint8_t ram[0x20000],unsigned object); +bool MmxWeaponsFrameTick(uint8_t ram[0x20000]); +bool MmxWeaponsTimeActive(void); +bool MmxWeaponsFrozenEnemy(const MmxWeaponCombatState *state,unsigned object); +const MmxWeaponShot *MmxWeaponsMovedEnemy(const MmxWeaponCombatState *state,unsigned object); +unsigned MmxWeaponsEnemyActive(const uint8_t ram[0x20000],unsigned object,unsigned active); +void MmxWeaponsTimeRipple(const MmxWeaponCombatState *state,int16_t lines[224]); +void MmxWeaponsTerrainEnd(uint8_t ram[0x20000],unsigned object); +void MmxWeaponsMarkShot(uint8_t ram[0x20000], unsigned slot); +void MmxWeaponsSelectShot(const uint8_t ram[0x20000], unsigned slot); +unsigned MmxWeaponsProjectileTick(uint8_t ram[0x20000], unsigned slot, unsigned active); +void MmxWeaponsCancelShots(uint8_t ram[0x20000]); +void MmxWeaponsCollisionRom(uint8_t *rom, size_t size); +unsigned MmxWeaponsDamage(uint8_t ram[0x20000], unsigned enemy, unsigned projectile, unsigned original); +unsigned MmxWeaponsHitbox(const uint8_t ram[0x20000], unsigned enemy, unsigned projectile, unsigned original); diff --git a/src/mmx_weapon_crystal.inc b/src/mmx_weapon_crystal.inc new file mode 100644 index 0000000..125f938 --- /dev/null +++ b/src/mmx_weapon_crystal.inc @@ -0,0 +1,162 @@ +/* X2 Crystal Hunter: $81:8D1C and $81:9CCF. Source notes in + * docs/weapons/crystal-hunter.md. Normal phases: 0 projectile, 1 forming, + * 2 falling, 3 bouncing, 4 grounded, 5 shatter contact. origin_x/y store + * the frozen enemy slot/type. Charged age counts display frames, including + * the original 180-frame onset and 480-frame half-speed period. */ +static const MmxWeaponShot *crystal_time(const MmxWeaponCombatState *c) { + for(unsigned i=0;i<8;++i) { + const MmxWeaponShot *s=c->shots+i; + if(s->active && s->age && s->page==1 && s->weapon==1 && s->charged) return s; + } + return NULL; +} +bool MmxWeaponsTimeActive(void) { return MmxWeaponsEnabled() && crystal_time(&combat); } +bool MmxWeaponsFrameTick(uint8_t r[0x20000]) { + if(!MmxWeaponsEnabled()) return false; + const MmxWeaponShot *p=crystal_time(&combat);if(!p) return false; + unsigned slot=(unsigned)(p-combat.shots);MmxWeaponShot *s=combat.shots+slot; + if(r[0xd1]!=2 || r[0xd2]!=4 || r[0xd3]!=4 || !(r[0xbcf]&127) || combat.stage!=r[0x1f7a]) { + retire(r,0x1228+slot*64);return false; + } + if(r[0x1f10]) return false; /* Pause does not consume the effect. */ + if(s->age<=180) {++s->age;return true;} + s->muzzle_pose=1; + if(++s->age>660) {retire(r,0x1228+slot*64);return false;} + return (s->age&1)==0; +} +void MmxWeaponsTimeRipple(const MmxWeaponCombatState *c,int16_t lines[224]) { + memset(lines,0,224*sizeof(*lines)); + const MmxWeaponShot *s=crystal_time(c);if(!s || s->age>180) return; + /* Replay the source HDMA-buffer updates, not a synthesized sine wave. + * $86:B7ED supplies these four expansion records; $81:9D4F shifts two + * scanlines outward, with opposite signs above/below the center. */ + static const uint8_t wave[4][13]={{1,2,3,0},{2,2,3,3,4,4,0}, + {3,3,4,4,5,5,6,6,7,7,0},{4,4,5,5,6,6,7,7,8,8,9,9,0}}; + for(unsigned frame=1;frameage;++frame) { + memmove(lines,lines+2,110*sizeof(*lines)); + memmove(lines+114,lines+112,110*sizeof(*lines)); + memset(lines+110,0,4*sizeof(*lines)); + if(frame>=32 && !(frame&15)) for(unsigned half=0;half<2;++half) { + int i=half?112:111,step=half?1:-1; + while(i>=0 && i<224) { + unsigned n=(unsigned)(half?-lines[i]:lines[i]); + if(!n) {i+=step;continue;} + unsigned row=n<4?n:3; + for(unsigned k=0;k<13 && wave[row][k] && i>=0 && i<224;++k,i+=step) + lines[i]=(int16_t)(half?-(int)wave[row][k]:wave[row][k]); + } + } + if(!(frame&15)) {lines[111]=1;lines[112]=-1;} + } +} +bool MmxWeaponsFrozenEnemy(const MmxWeaponCombatState *c,unsigned enemy) { + for(unsigned i=0;i<8;++i) { + const MmxWeaponShot *s=c->shots+i; + if(s->active && s->page==1 && s->weapon==1 && !s->charged && + s->muzzle_pose>=1 && s->muzzle_pose<=4 && s->origin_x==(int)enemy) return true; + } + return false; +} +unsigned MmxWeaponsEnemyActive(const uint8_t r[0x20000],unsigned enemy,unsigned active) { + (void)r; + return MmxWeaponsEnabled() && (MmxWeaponsFrozenEnemy(&combat,enemy) || + MmxWeaponsMovedEnemy(&combat,enemy)) ? 0 : active; +} +static void crystal_contact(uint8_t *r,unsigned enemy,MmxWeaponShot *s) { + s->origin_x=(int16_t)enemy;s->origin_y=r[enemy+10];s->muzzle_pose=1; + s->x=(int32_t)word(r+enemy+5)*256;s->y=(int32_t)word(r+enemy+8)*256; + s->vx=s->vy=0;s->radius=0;animation_start(s,2); +} +static void crystal_shatter(uint8_t *r,MmxWeaponShot *s) { + s->muzzle_pose=5;s->radius=8;s->hit_slots=0; + /* Re-enter the target's native lethal-hit path, including normal drops + * and stage cleanup. The crystal itself supplies the final contact. */ + r[s->origin_x+0x27]&=127; + r[s->origin_x+0x26]=0; /* Native death actors must not retain contact damage. */ + /* $81:8F14 creates eight class-08 debris actors, subtypes A5/A6. + * $81:F068 uses the two velocity tables at $86:EE38/$86:EE48. */ + static const int16_t vx[8]={-512,-256,128,384,-640,-384,256,512}; + static const int16_t vy[8]={768,1024,1408,640,896,1152,1280,512}; + unsigned count=0; + for(unsigned i=0;i<16 && count<8;++i) if(!combat.effects[i].active) { + MmxWeaponShot *p=combat.effects+i;memset(p,0,sizeof(*p)); + p->active=1;p->age=1;p->page=1;p->weapon=1;p->group=16;p->variant=2; + p->x=s->x;p->y=s->y;p->born=combat.tick;p->tether_pose=(uint8_t)(count++&1); + p->facing=(uint8_t)(bubble_random(i+1)&64); + p->vx=vx[bubble_random(i+2)&7];p->vy=-vy[bubble_random(i+3)&7]; + animation_start(p,4+(bubble_random(i+4)&1)); + } +} +static void crystal_effect_tick(uint8_t *r,MmxWeaponShot *s) { + if(!fang_visible(r,s)) {memset(s,0,sizeof(*s));return;} + ++s->age;s->vy+=48;s->x+=s->vx;s->y+=s->vy;animation_step(s); +} +static void crystal_terrain(uint8_t *r) { + for(unsigned i=0;i<8;++i) { + MmxWeaponShot *s=combat.shots+i; + if(!s->active || s->page!=1 || s->weapon!=1 || s->charged || + s->muzzle_pose<2 || s->muzzle_pose>4) continue; + int top=(s->y>>8)-21,dx=(int)word(r+0xbad)-(s->x>>8); + int feet=(int)word(r+0xbb0)+16,previous=(int)word(r+0xbcc)+16; + if((int16_t)word(r+0xbc4)>0 || dx < -22 || dx>22 || previous>top+2 || feetage) { + unsigned frozen=0;bool flying=false,charged=false; + for(unsigned i=0;i<8;++i) { + const MmxWeaponShot *p=combat.shots+i; + if(p==s || !p->active || p->page!=1 || p->weapon!=1) continue; + if(p->charged) charged=true; + else if(!p->muzzle_pose) flying=true; + else ++frozen; + } + if(charged || flying || (!s->charged && frozen>=3) || + !MmxWeaponsSpend(1,1,(s->charged?2:1)*256)) {retire(r,d);return;} + muzzle_origin(r,d,s);s->age=1;s->born=combat.tick; + if(s->charged) {stop_charge(r);r[0xbf8]=0;} + else { + bool water=terrain_water(r,s->x>>8,s->y>>8); + s->vx=(int16_t)((s->facing?1:-1)*(water?768:832));s->vy=water?-320:0; + animation_start(s,1); + } + native_object(r,d,s); + if(s->charged) putword(r+d+0x20,0); + return; + } + if(s->charged) {native_object(r,d,s);putword(r+d+0x20,0);return;} + if(!s->muzzle_pose) { + if(r[d+1]==4 && !s->variant) {s->variant=1;s->vx=-s->vx;} + if(r[d+1]>=6) {retire(r,d);return;} + s->vy+=16;if(s->vy>1280) s->vy=(int16_t)(1280+(s->vy&255)); + s->x+=s->vx;s->y+=s->vy;animation_step(s); + } else { + unsigned enemy=(unsigned)s->origin_x; + if(enemy<0xe68 || enemy>=0x1228 || !r[enemy] || r[enemy+10]!=s->origin_y) {retire(r,d);return;} + if(s->muzzle_pose==1) { + if(s->flags&128) {s->muzzle_pose=2;s->vy=0;animation_start(s,3);} + else animation_step(s); + } else if(s->muzzle_pose<5) { + if(s->muzzle_pose!=4) { + s->vy+=64;if(s->vy>1280) s->vy=(int16_t)(1280+(s->vy&255)); + unsigned hit=terrain_move(r,s,16,21); + if(hit&4) { + if(s->muzzle_pose==2) {s->muzzle_pose=3;s->vy=(int16_t)(-s->vy/2);} + else {s->muzzle_pose=4;s->vy=0;} + } + } + putword(r+enemy+5,(unsigned)(s->x>>8));putword(r+enemy+8,(unsigned)(s->y>>8)); + animation_step(s); + int dx=(int)word(r+0xbad)-(s->x>>8),dy=(int)word(r+0xbb0)-(s->y>>8); + int speed=(int16_t)word(r+0xbc2);if(speed<0) speed=-speed; + if(speed>=768 && dx>=-24 && dx<=24 && dy>=-31 && dy<=31) crystal_shatter(r,s); + } else if(!--s->radius) {retire(r,d);return;} + } + ++s->age; + if(!s->active || !fang_visible(r,s)) {retire(r,d);return;} + native_object(r,d,s); + if(s->muzzle_pose!=0 && s->muzzle_pose!=5) putword(r+d+0x20,0); + if(s->variant) putword(r+d+0x20,0); +} diff --git a/src/mmx_weapon_fang.inc b/src/mmx_weapon_fang.inc new file mode 100644 index 0000000..de58a88 --- /dev/null +++ b/src/mmx_weapon_fang.inc @@ -0,0 +1,133 @@ +/* X3 Tornado Fang, $81:A9F6/$BE58. See docs/weapons/tornado-fang.md. + * Saved fields: variant=normal single/top/bottom or charged orientation; + * muzzle_pose=route/wait/accelerate/contact/deflected; radius=phase timer; + * tether_pose=remaining drill contacts; origin_x=route cursor, + * origin_y=normal source speed cap / charged previous player height. + * No unsaved simulation state. */ +static void fang_player(uint8_t *r) { + r[0xc0f]=3; + for (unsigned i=0;i<8;++i) { + const MmxWeaponShot *s=combat.shots+i; + if (!s->active || s->page!=2 || s->weapon!=8 || !s->charged) continue; + stop_charge(r);r[0xbdf]&=(uint8_t)~64;r[0xbe3]&=(uint8_t)~64; + if (combat.held && r[0xbaa]!=14 && r[0xbaa]!=12 && !r[0x1f0c]) r[0xbf8]=16; + return; + } + /* Unlike the release weapons, X3 deploys its drill at full charge while + * Y is still held. Let X1's native full-release path allocate it. */ + if (combat.held && (r[0x1f99]&2) && r[0xc03]==1) r[0xbdf]&=(uint8_t)~64; +} +static void fang_contact(const uint8_t *r,const MmxWeaponShot *s,unsigned enemy) { + /* X3 $81:ABDC/$C046: class $10, group $13 sequence 11. The last + * normal contact uses $84:D6E0's shared puff instead. No attack slot. */ + for(unsigned i=0;i<16;++i) if(!combat.effects[i].active) { + MmxWeaponShot *t=combat.effects+i;*t=*s; + bool exhausted=!s->charged && s->tether_pose==1; + t->variant=exhausted?4:3;t->group=exhausted?8:20;t->charged=0; + t->age=1;t->born=combat.tick;t->vx=t->vy=0;t->radius=t->tether_pose=t->muzzle_pose=0; + t->facing=s->facing^64; + t->x=(s->charged?(s->x>>8)+(s->facing?4:-4):(int)word(r+enemy+5))*256; + t->y=(s->charged?(s->y>>8)-4:(int)word(r+enemy+8))*256; + animation_start(t,exhausted?5:11);return; + } +} +static void fang_route(MmxWeaponShot *s) { + /* $86:BA36 records are duration/VX/VY, upward-positive source Y. */ + static const uint8_t duration[3][3]={{4,4,0},{4,6,8},{2,0,0}}; + unsigned route=s->muzzle_pose==3?2:s->variant?1:0,index=(unsigned)s->origin_x; + if (index>=3 || !duration[route][index]) { + if (s->muzzle_pose!=3) {s->muzzle_pose=1;animation_start(s,0);} + s->vx=s->vy=0;return; + } + s->radius=duration[route][index]; + int speed=route==2?16:route==1 && index==1?0:512; + s->vx=(int16_t)(s->facing?speed:-speed); + s->vy=(route==1 && index==1)?(s->variant==1?-768:768):0; +} +static bool fang_visible(const uint8_t *r,const MmxWeaponShot *s) { + int x=(s->x>>8)-(int)word(r+0x1e4d),y=(s->y>>8)-(int)word(r+0x1e50); + return x>=-32 && x<288 && y>=-16 && y<240; +} +static void fang_attach(uint8_t *r,unsigned d,MmxWeaponShot *s) { + int previous_y=s->origin_y; + s->facing=r[0xbb9]&64;muzzle_origin(r,d,s); + /* Source standing arm offsets are X +/-23, Y -5. Keep Zero's actual + * muzzle offsets and add the seven-pixel drill mount beyond the barrel. */ + s->x+=(s->facing?7:-7)*256;s->y-=2*256;s->variant=0; + if (r[0xbaa]==0x12) { + s->variant=2;s->x=((int)word(r+0xbad)+(s->facing?8:-8))*256; + s->y=((int)word(r+0xbb0)-16)*256; + if (terrain_solid(r,(s->x>>8)+(s->facing?1:-1)*8,s->y>>8,false,NULL)) { + s->variant=3; + /* X3 $84:8A19 stops wall sliding while +$77 is nonzero. Preserve + * X1's wall/jump transitions, only suppress the current slide step. */ + if (s->age>1) {putword(r+0xbb0,(unsigned)previous_y);s->y=(previous_y-16)*256;} + putword(r+0xbc4,0); + } + } + s->origin_y=(int16_t)word(r+0xbb0); + s->pose=(uint8_t)(42+((s->age/2)&3)*6+s->variant); +} +static void fang_tick(uint8_t *r,unsigned d,MmxWeaponShot *s) { + if (!s->age) { + unsigned count=0;bool recent=false; + for (unsigned i=0;i<8;++i) { + const MmxWeaponShot *p=combat.shots+i; + if (p==s || !p->active || p->page!=2 || p->weapon!=8) continue; + ++count;recent|=!p->charged && !p->variant && p->age<=60; + if (p->charged) {retire(r,d);return;} + } + if ((!s->charged && count>=3) || !MmxWeaponsSpend(2,8,(s->charged?3:1)*256)) {retire(r,d);return;} + s->age=1;s->born=combat.tick;s->tether_pose=7;muzzle_origin(r,d,s); + if (s->charged) {s->radius=64;fang_attach(r,d,s);stop_charge(r);r[0xbf8]=16;} + else { + s->variant=recent?1:0;s->origin_x=0; + s->origin_y=terrain_water(r,s->x>>8,s->y>>8)?1024:768; + animation_start(s,13);fang_route(s); + if (recent && count<2) { + MmxWeaponShot *child=spawn_child(r,s);if(child) child->variant=2; + } + } + native_object(r,d,s);return; + } + if (s->age==1 && s->born==combat.tick) return; + if (s->charged) { + if (!combat.held || r[0xbaa]==14 || r[0xbaa]==12 || r[0x1f0c] || + !(r[0xbcf]&127) || !MmxWeaponsEnergyAmount(2,8)) {retire(r,d);return;} + if (!--s->radius) { + if (!MmxWeaponsSpend(2,8,3*256)) {retire(r,d);return;}s->radius=64; + } + ++s->age;if (!s->age) s->age=2; + fang_attach(r,d,s);s->hit_slots=0;native_object(r,d,s); + if (s->variant==3) putword(r+d+0x20,0); + return; + } + if (r[d+1]>=6 && s->muzzle_pose!=3 && s->muzzle_pose!=4) { + if (!--s->tether_pose) {retire(r,d);return;} + s->muzzle_pose=3;s->radius=16;s->vx=(int16_t)(s->facing?16:-16);s->vy=0; + s->origin_x=0;animation_start(s,2); + } else if (r[d+1]==4 && s->muzzle_pose!=4) { + s->muzzle_pose=4;s->vx/=2;s->vy=-768;animation_start(s,3); + } + if (s->muzzle_pose==3) { + if (s->radius>14) s->x+=s->vx; + if (!--s->radius) { + s->muzzle_pose=2;s->vx=s->vy=0;s->hit_slots=0;animation_start(s,1); + } + } else if (s->muzzle_pose==4) {s->vy+=16;s->x+=s->vx;s->y+=s->vy;} + else if (s->muzzle_pose==0) { + s->x+=s->vx;s->y+=s->vy; + if (s->radius && !--s->radius) {++s->origin_x;fang_route(s);} + } else if (s->muzzle_pose==1) { + if (s->age>=60) {s->muzzle_pose=2;s->vx=s->vy=0;animation_start(s,1);} + } else { + int cap=s->origin_y; + s->vx+=(int16_t)(s->facing?32:-32); + if (s->vx>cap) s->vx=(int16_t)cap;if (s->vx < -cap) s->vx=(int16_t)-cap; + s->x+=s->vx; + } + animation_step(s);++s->age; + if (!s->active || !fang_visible(r,s) || s->age>900) {retire(r,d);return;} + native_object(r,d,s); + if (s->muzzle_pose==4) putword(r+d+0x20,0); +} diff --git a/src/mmx_weapon_gravity.inc b/src/mmx_weapon_gravity.inc new file mode 100644 index 0000000..b5f67a3 --- /dev/null +++ b/src/mmx_weapon_gravity.inc @@ -0,0 +1,175 @@ +/* X3 $81:A3xx/$B915 and $82:DB3F..DDD2. Utility response codes are + * deliberately not damage. See docs/weapons/gravity-well.md. + * Shot variants 0 well/orb, 1 body; effect variants 2 enemy, 3 particles. + * Victim origin_x/y retain native slot/type, vx retains anchored X. */ +const MmxWeaponShot *MmxWeaponsMovedEnemy(const MmxWeaponCombatState *c,unsigned enemy) { + for(unsigned i=0;i<8;++i) { + const MmxWeaponShot *s=c->shots+i; + if(s->active && s->page==2 && s->weapon==2 && s->variant==1 && s->origin_x==(int)enemy) return s; + } + for(unsigned i=0;i<16;++i) { + const MmxWeaponShot *s=c->effects+i; + if(s->active && s->page==2 && s->weapon==6 && s->variant==2 && s->origin_x==(int)enemy) return s; + } + return NULL; +} +static MmxWeaponShot *gravity_actor(unsigned variant) { + for(unsigned i=0;i<8;++i) { + MmxWeaponShot *s=combat.shots+i; + if(s->active && s->page==2 && s->weapon==6 && s->variant==variant) return s; + } + return NULL; +} +static void gravity_player(uint8_t *r) { + r[0xc0f]=1; + MmxWeaponShot *s=gravity_actor(1);if(!s) return; + stop_charge(r);r[0xbde]=r[0xbe2]=r[0xbdf]=r[0xbe3]=0;r[0xbf8]=0; + putword(r+0xbc2,0);putword(r+0xbc4,0); + if(r[0xbaa]!=14 && r[0xbaa]!=12) {r[0xbaa]=0;r[0xbab]=2;} +} +static unsigned gravity_angle(int dx,int dy) { + /* Original 32-way $86:E18E directions; choose by cross-product against + * the requested vector, using integer arithmetic in saved simulation. */ + int best=0;int64_t score=-1; + for(unsigned a=0;a<32;++a) { + int vx=ray_vector(a),vy=-ray_vector(a+8); + int64_t dot=(int64_t)dx*vx+(int64_t)dy*vy; + if(dot<=0) continue; + int64_t candidate=dot*dot/(vx*vx+vy*vy); + if(candidate>score) {score=candidate;best=(int)a;} + } + return (unsigned)best; +} +static int gravity_slow_vector(unsigned angle) { + static const int16_t q[9]={0,93,171,230,271,307,343,372,384}; + unsigned a=angle&31,index=(a&8)?8-(a&7):(a&7); + return (a&16)?-q[index]:q[index]; +} +static bool gravity_host(const uint8_t *r,const MmxWeaponShot *s) { + unsigned d=(unsigned)s->origin_x; + return d>=0xe68 && d<0x1228 && (d&63)==0x28 && r[d] && r[d+1]==2 && + r[d+10]==s->origin_y && (r[d+0x27]&127); +} +static void gravity_destroy(uint8_t *r,MmxWeaponShot *s) { + unsigned d=(unsigned)s->origin_x; + /* X1's ordinary-enemy death state owns its explosion/drop/retirement. + * Gravity is a scripted dissolve/lift kill, not a numeric weapon hit. */ + if(gravity_host(r,s)) {r[d+1]=4;r[d+2]=r[d+3]=r[d+0x27]=r[d+0x26]=0;} + memset(s,0,sizeof(*s)); +} +static void gravity_effect_tick(uint8_t *r,MmxWeaponShot *s) { + if(s->variant==3) { + s->y+=s->vy;++s->age;animation_step(s); + if(!fang_visible(r,s)) memset(s,0,sizeof(*s)); + return; + } + if(!gravity_host(r,s)) {memset(s,0,sizeof(*s));return;} + MmxWeaponShot *well=gravity_actor(0); + if(!well || well->muzzle_pose!=2) { + if(s->muzzle_pose==3) putword(r+s->origin_x+5,(unsigned)s->vx); + memset(s,0,sizeof(*s));return; + } + ++s->age; + if(s->muzzle_pose==3) { /* Large anchored enemies shake without damage. */ + static const int8_t shake[4]={0,1,0,-1};s->x=(s->vx+shake[s->age&3])*256; + } else if(s->charged) { + s->vy-=192;if(s->vy<-4096) s->vy=-4096;s->y+=s->vy; + if((s->y>>8)<(int)word(r+0x1e50)-32) {gravity_destroy(r,s);return;} + } else { + unsigned a=gravity_angle((well->x-s->x)>>8,(well->y-s->y)>>8); + int vx,vy; + if(s->age<=30) {vx=gravity_slow_vector(a)>>2;vy=-(gravity_slow_vector(a+8)>>2);} + else if(s->age<=38) {vx=gravity_slow_vector(a);vy=-gravity_slow_vector(a+8);} + else {int scale=s->age<=46?1:2;vx=ray_vector(a)/8*scale;vy=-ray_vector(a+8)/8*scale;} + s->x+=vx;s->y+=vy; + if(s->age>=60) {gravity_destroy(r,s);return;} + } + putword(r+s->origin_x+5,s->x>>8);putword(r+s->origin_x+8,s->y>>8); +} +static void gravity_capture(uint8_t *r,MmxWeaponShot *well) { + for(unsigned d=0xe68;d<0x1228;d+=64) { + if(!r[d] || r[d+1]!=2 || !(r[d+0x27]&127) || r[d+0x28]>=6 || + !word(r+d+0x20) || MmxWeaponsFrozenEnemy(&combat,d) || MmxWeaponsMovedEnemy(&combat,d)) continue; + int x=(int)word(r+d+5)-(int)word(r+0x1e4d),y=(int)word(r+d+8)-(int)word(r+0x1e50); + if(x<0 || x>=256 || y<0 || y>=224) continue; + unsigned box=word(r+d+0x20); + size_t off=0x30000+(box&0x7fff); + if(!collision_rom || off+4>collision_rom_size) continue; + unsigned rx=collision_rom[off+2],ry=collision_rom[off+3]; + if(!rx || !ry || rx>32 || ry>32) continue; + for(unsigned i=0;i<16;++i) if(!combat.effects[i].active) { + MmxWeaponShot *s=combat.effects+i;memset(s,0,sizeof(*s)); + s->active=1;s->age=1;s->page=2;s->weapon=6;s->group=15;s->variant=2; + s->charged=well->charged;s->origin_x=(int16_t)d;s->origin_y=r[d+10]; + s->x=(int)word(r+d+5)*256;s->y=(int)word(r+d+8)*256;s->vx=(int16_t)word(r+d+5); + s->muzzle_pose=(rx>16 || ry>24)?3:0;break; + } + } +} +static void gravity_particle(uint8_t *r,unsigned age) { + for(unsigned i=0;i<16;++i) if(!combat.effects[i].active) { + MmxWeaponShot *s=combat.effects+i;memset(s,0,sizeof(*s)); + s->active=1;s->age=1;s->page=2;s->weapon=6;s->group=15;s->variant=3; + s->x=((int)word(r+0x1e4d)+(int)(bubble_random(i)&127)+(age&4?128:0))*256; + s->y=((int)word(r+0x1e50)+224)*256;s->vy=-4096; + animation_start(s,5+(bubble_random(i+1)&3));return; + } +} +static void gravity_tick(uint8_t *r,unsigned d,MmxWeaponShot *s) { + if(!s->age) { + for(unsigned i=0;i<8;++i) { + MmxWeaponShot *p=combat.shots+i; + if(p!=s && p->active && p->page==2 && p->weapon==6) {retire(r,d);return;} + } + if(!MmxWeaponsSpend(2,6,256)) {retire(r,d);return;} + s->age=1;s->born=combat.tick;muzzle_origin(r,d,s); + if(s->charged) { + stop_charge(r);animation_start(s,4); + MmxWeaponShot *body=spawn_child(r,s); + if(!body) {retire(r,d);return;} + body->variant=1;body->group=(r[0xbd3]&4)?51:52;animation_start(body,body->group==51?35:36); + native_object(r,0x1228+(unsigned)(body-combat.shots)*64,body); + putword(r+0x1228+(unsigned)(body-combat.shots)*64+0x20,0); + } else {s->vx=(int16_t)(s->facing?1024:-1024);animation_start(s,0);} + } else if(s->born!=combat.tick) { + ++s->age; + if(s->charged && s->variant==1) { + if(r[0xbaa]==14 || r[0xbaa]==12 || r[0x1f0c]) {retire(r,d);return;} + MmxWeaponShot *well=gravity_actor(0); + if(s->flags&64) {if(well && !well->muzzle_pose) {well->muzzle_pose=1;well->x=(int)word(r+0xbad)*256;well->y=((int)word(r+0xbb0)-27)*256;}} + if(!(s->flags&32) || !well) { + if((s->flags&128) && s->timer==1) {retire(r,d);return;} + animation_step(s); + } + } else if(s->charged) { + if(!gravity_actor(1)) {retire(r,d);return;} + if(s->muzzle_pose==1) { + s->vy-=192;s->y+=s->vy;animation_step(s); + if((s->y>>8)<(int)word(r+0x1e50)-16) {s->muzzle_pose=2;s->origin_x=300;} + } else if(s->muzzle_pose==2) { + if(!--s->origin_x) {retire(r,d);return;} + gravity_capture(r,s);if(!(s->age&3)) gravity_particle(r,s->age); + } + } else if(!s->muzzle_pose) { + s->vx+=(s->facing?-32:32); + if(s->facing && s->vx<256) s->vx=256; + if(!s->facing && s->vx>-256) s->vx=-256; + s->x+=s->vx;animation_step(s); + int travelled=(s->x>>8)-s->origin_x;if(travelled<0) travelled=-travelled; + if(travelled>=64) {s->muzzle_pose=1;animation_start(s,1);} + } else if(s->muzzle_pose==1 || s->muzzle_pose==3) { + if((s->flags&128) && s->timer==1) { + ++s->muzzle_pose;animation_start(s,s->muzzle_pose==2?2:0);s->radius=180; + } else animation_step(s); + } else if(s->muzzle_pose==2) { + gravity_capture(r,s);animation_step(s); + if(!--s->radius || !fang_visible(r,s)) {s->muzzle_pose=3;animation_start(s,3);} + } else { + int dx=(int)word(r+0xbad)-(s->x>>8),dy=(int)word(r+0xbb0)-(s->y>>8); + if(dx>-12 && dx<12 && dy>-18 && dy<18) {retire(r,d);return;} + unsigned a=gravity_angle(dx,dy);s->x+=ray_vector(a)/4;s->y-=ray_vector(a+8)/4;animation_step(s); + } + } + if(s->age>700 || (!s->charged && s->muzzle_pose!=2 && !fang_visible(r,s))) {retire(r,d);return;} + native_object(r,d,s);putword(r+d+0x20,0); +} diff --git a/src/mmx_weapon_parasitic.inc b/src/mmx_weapon_parasitic.inc new file mode 100644 index 0000000..4dfb01a --- /dev/null +++ b/src/mmx_weapon_parasitic.inc @@ -0,0 +1,211 @@ +/* X3 $81:96F0/$97AB/$AC82; docs/weapons/parasitic-bomb.md. + * Variants: normal shot, captured enemy, charged cursor, homing bee, impact. + * Payload origin_x/y = native host/type; cursor/bee origin_x/y = target/type. + * Cursor tether_pose = quadrant, hit_slots = next-search quadrant (cursor 0). + * Payload tether_pose = homing/stationary/rolling, radius = target index+1. */ +static MmxWeaponShot *parasitic_cursor(unsigned id) { + for(unsigned i=0;i<8;++i) { + MmxWeaponShot *s=combat.shots+i; + if(s->active && s->page==2 && s->weapon==2 && s->variant==2 && (id==4 || s->tether_pose==id)) return s; + } + return NULL; +} +static void parasitic_player(uint8_t *r) { + r[0xc0f]=8; + if(parasitic_cursor(4)) { + stop_charge(r);r[0xbdf]&=(uint8_t)~64;r[0xbe3]&=(uint8_t)~64;return; + } + /* Source cursors appear at full charge while fire remains held. */ + if(combat.held && (r[0x1f99]&2) && r[0xc03]==1) r[0xbdf]&=(uint8_t)~64; +} +static void parasitic_release(uint8_t *r,const MmxWeaponShot *s) { + MmxWeaponShot host=*s;gravity_destroy(r,&host); +} +static bool parasitic_target(const uint8_t *r,unsigned enemy,int kind) { + if(enemy<0xe68 || enemy>=0x1228 || (enemy&63)!=0x28 || !r[enemy] || + r[enemy+1]!=2 || !(r[enemy+0x27]&127) || r[enemy+0x28]>=6 || + (kind>=0 && r[enemy+10]!=kind) || !word(r+enemy+0x20) || + MmxWeaponsFrozenEnemy(&combat,enemy) || MmxWeaponsMovedEnemy(&combat,enemy)) return false; + int x=(int)word(r+enemy+5)-(int)word(r+0x1e4d),y=(int)word(r+enemy+8)-(int)word(r+0x1e50); + return x>=0 && x<256 && y>=0 && y<224; +} +static unsigned parasitic_search(const uint8_t *r,const MmxWeaponShot *s) { + unsigned found=0;int distance=0x7fffffff; + for(unsigned d=0xe68;d<0x1228;d+=64) { + if(!parasitic_target(r,d,-1)) continue; + bool reserved=false; + if(s->variant==2) for(unsigned i=0;i<8;++i) { + const MmxWeaponShot *p=combat.shots+i; + if(p!=s && p->active && p->page==2 && p->weapon==2 && p->variant==2 && + p->muzzle_pose>0 && p->muzzle_pose<4 && p->origin_x==(int)d) reserved=true; + } + if(reserved) continue; + int dx=(int)word(r+d+5)-(s->x>>8),dy=(int)word(r+d+8)-(s->y>>8); + int n=dx*dx+dy*dy;if(ncollision_rom_size) return false; + unsigned rx=collision_rom[off+2],ry=collision_rom[off+3]; + if(!rx || !ry || rx>24 || ry>32) return false; + int x=(int)word(r+enemy+5),y=(int)word(r+enemy+8); + s->variant=1;s->muzzle_pose=0;s->origin_x=(int16_t)enemy;s->origin_y=r[enemy+10]; + s->x=x*256;s->y=y*256;s->vy=0;s->radius=0;s->age=1; + /* X1 has no X3 per-enemy response bytes. Keep large anchored actors + * stationary; use rolling for small grounded actors, homing for airborne. */ + s->tether_pose=(rx>16 || ry>24)?2:terrain_solid(r,x,y+(int)ry+2,true,NULL)?3:1; + if(s->tether_pose!=3) s->vx=0; + animation_start(s,1);return true; +} +static void parasitic_aim(const uint8_t *r,MmxWeaponShot *s,unsigned target) { + unsigned a=gravity_angle((int)word(r+target+5)-(s->x>>8),(int)word(r+target+8)-(s->y>>8)); + s->vx=(int16_t)(ray_vector(a)/4);s->vy=(int16_t)(-ray_vector(a+8)/4); +} +static void parasitic_home_cursor(const uint8_t *r,MmxWeaponShot *s) { + static const int8_t offsets[4][2]={{-16,-20},{-16,20},{16,-20},{16,20}}; + s->x=((int)word(r+0xbad)+offsets[s->tether_pose][0])*256; + s->y=((int)word(r+0xbb0)+offsets[s->tether_pose][1]-(MmxZeroActive()?8:0))*256; +} +static void parasitic_native(uint8_t *r,unsigned d,MmxWeaponShot *s) { + native_object(r,d,s); + if(s->variant==2 || s->variant==4 || (s->variant==1 && !s->muzzle_pose) || + (s->variant==3 && (s->muzzle_pose!=1 || !(s->hit_slots&0x8000)))) putword(r+d+0x20,0); +} +static void parasitic_impact(MmxWeaponShot *s,bool unit_contact) { + s->variant=4;s->group=unit_contact?23:8;s->muzzle_pose=unit_contact;s->facing=0; + animation_start(s,unit_contact?1:5); +} +static void parasitic_cursor_tick(uint8_t *r,unsigned d,MmxWeaponShot *s) { + bool held=combat.held && MmxWeaponsEnergyAmount(2,2)>=256 && r[0xbaa]!=14 && r[0xbaa]!=12 && !r[0x1f0c]; + unsigned target=(unsigned)s->origin_x; + MmxWeaponShot *leader=parasitic_cursor(0); + if(!leader) {leader=parasitic_cursor(4);if(leader) leader->hit_slots=leader->tether_pose;} + if(s->muzzle_pose && s->muzzle_pose<3 && !parasitic_target(r,target,s->origin_y)) { + if(leader && s->muzzle_pose==1 && s->radius) leader->hit_slots=(s->tether_pose+1)&3; + s->muzzle_pose=4;s->radius=10; + } + if(!s->muzzle_pose) { + if(!held) {retire(r,d);return;} + parasitic_home_cursor(r,s); + if(leader && leader->hit_slots==s->tether_pose) { + target=parasitic_search(r,s); + if(target) {s->origin_x=(int16_t)target;s->origin_y=r[target+10];s->muzzle_pose=1;s->radius=4;parasitic_aim(r,s,target);} + } + } else if(s->muzzle_pose==1) { + if(s->radius && !--s->radius && leader) leader->hit_slots=(s->tether_pose+1)&3; + int dx=(int)word(r+target+5)-(s->x>>8),dy=(int)word(r+target+8)-(s->y>>8); + /* Original $81:ADB5/$ADC6 snaps when either axis comes within 5px. */ + if((dx>-5 && dx<5) || (dy>-5 && dy<5)) { + if(leader && s->radius) leader->hit_slots=(s->tether_pose+1)&3; + s->muzzle_pose=2;s->radius=16;animation_start(s,10); + } else { + if(!(s->age&3)) parasitic_aim(r,s,target);s->x+=s->vx;s->y+=s->vy; + } + } else if(s->muzzle_pose==2) { + s->x=(int)word(r+target+5)*256;s->y=(int)word(r+target+8)*256; + if(s->radius) --s->radius; + else if(free_slot(r) && MmxWeaponsSpend(2,2,256)) { + MmxWeaponShot *bee=spawn_child(r,s); + if(bee) { + bee->variant=3;bee->muzzle_pose=0;bee->radius=10;bee->hit_slots=0; + bee->facing=r[0xbb9]&64;muzzle_origin(r,d,bee); + bee->origin_x=s->origin_x;bee->origin_y=s->origin_y; + bee->vx=(int16_t)(bee->facing?768:-768);bee->vy=0;animation_start(bee,12); + parasitic_native(r,0x1228+(unsigned)(bee-combat.shots)*64,bee); + s->muzzle_pose=3; + } + } else if(MmxWeaponsEnergyAmount(2,2)<256) {s->muzzle_pose=4;s->radius=10;} + } else if(s->muzzle_pose==3) { + bool bee=false; + for(unsigned i=0;i<8;++i) { + const MmxWeaponShot *p=combat.shots+i; + if(p->active && p->page==2 && p->weapon==2 && p->variant==3 && p->tether_pose==s->tether_pose) bee=true; + } + if(parasitic_target(r,target,s->origin_y)) {s->x=(int)word(r+target+5)*256;s->y=(int)word(r+target+8)*256;} + if(!bee) {s->muzzle_pose=4;s->radius=30;} + } else if(!s->radius || !--s->radius) { + if(!held) {retire(r,d);return;} + s->muzzle_pose=0;animation_start(s,8); + } + animation_step(s); +} +static void parasitic_tick(uint8_t *r,unsigned d,MmxWeaponShot *s) { + if(!s->age) { + if(s->charged) { + if(parasitic_cursor(4)) {retire(r,d);return;} + s->variant=2;s->age=1;s->born=combat.tick;animation_start(s,8);stop_charge(r); + parasitic_home_cursor(r,s); + for(unsigned i=1;i<4;++i) { + MmxWeaponShot *p=spawn_child(r,s);if(!p) break; + p->tether_pose=(uint8_t)i;parasitic_home_cursor(r,p); + parasitic_native(r,0x1228+(unsigned)(p-combat.shots)*64,p); + } + } else { + unsigned count=0;for(unsigned i=0;i<8;++i) if(combat.shots[i].active && + combat.shots[i].page==2 && combat.shots[i].weapon==2 && !combat.shots[i].charged) ++count; + if(count>3 || !MmxWeaponsSpend(2,2,256)) {retire(r,d);return;} + muzzle_origin(r,d,s);s->age=1;s->born=combat.tick;s->vx=(int16_t)(s->facing?768:-768);animation_start(s,0); + } + parasitic_native(r,d,s);return; + } + if(s->born==combat.tick) return; + if(s->variant==2) parasitic_cursor_tick(r,d,s); + else if(s->variant==1) { + if(!gravity_host(r,s) || (s->muzzle_pose && r[d+1]>=6)) {retire(r,d);return;} + if(!s->muzzle_pose) { + if((s->flags&128) && s->timer==1) {s->muzzle_pose=s->tether_pose;s->age=1;s->hit_slots=0;animation_start(s,2);} + else animation_step(s); + } else { + if(s->age>=(s->muzzle_pose==1?180:240)) {retire(r,d);return;} + if(s->muzzle_pose==1) { + unsigned target=s->radius?0xe68+(s->radius-1)*64:0; + if(!parasitic_target(r,target,-1)) {target=parasitic_search(r,s);s->radius=(uint8_t)(target?(target-0xe68)/64+1:0);} + if(!(s->age&3)) { + if(target) parasitic_aim(r,s,target); + else {unsigned a=(s->age/4)&31;s->vx=(int16_t)(gravity_slow_vector(a)>>1);s->vy=(int16_t)-(gravity_slow_vector(a+8)>>1);} + } + s->x+=s->vx;s->y+=s->vy; + } else if(s->muzzle_pose==3) { + s->vy+=32;if(s->vy>1280) s->vy=1280; + unsigned hit=terrain_move(r,s,13,12);if(hit&3) {retire(r,d);return;}if(hit&4) s->vy=0; + } + putword(r+s->origin_x+5,s->x>>8);putword(r+s->origin_x+8,s->y>>8);animation_step(s); + } + } else if(s->variant==3) { + if(r[d+1]>=6) parasitic_impact(s,true); + else if(r[d+1]==4) {s->muzzle_pose=2;s->facing|=128;s->pose=39;s->vx=0;s->vy=32;s->y+=s->vy;} + else if(!s->muzzle_pose) { + s->vx+=(s->facing?-16:16);s->x+=s->vx; + if(!--s->radius) {s->muzzle_pose=1;s->radius=120;} + animation_step(s); + } else if(s->muzzle_pose==1) { + unsigned target=(unsigned)s->origin_x; + /* Source $81:AEB5: collision and its 120-tick life become active + * when the unit reaches its locked cursor. Bit 15 is outside the + * fifteen native-enemy contact bits and survives snapshots. */ + MmxWeaponShot *cursor=parasitic_cursor(s->tether_pose); + if(cursor && cursor->muzzle_pose==3) { + int dx=(s->x-cursor->x)>>8,dy=(s->y-cursor->y)>>8; + if(dx>-16 && dx<16 && dy>-18 && dy<18) s->hit_slots|=0x8000; + } + if((s->hit_slots&0x8000) && !--s->radius) {parasitic_impact(s,false);parasitic_native(r,d,s);return;} + if(parasitic_target(r,target,s->origin_y) && !(s->age&3)) parasitic_aim(r,s,target); + s->facing=s->vx>=0?64:0;s->x+=s->vx;s->y+=s->vy;animation_step(s); + } else {s->vy+=32;s->y+=s->vy;} + } else if(s->variant==4) { + if((s->flags&128) && s->timer==1) {retire(r,d);return;} + animation_step(s); + } else { + if(r[d+1]>=6) parasitic_impact(s,false); + else { + if(r[d+1]==4 && !s->muzzle_pose) {s->muzzle_pose=1;s->vx=-s->vx;s->vy=-768;} + s->x+=s->vx;s->y+=s->vy;animation_step(s); + } + } + if(!s->active) return; + ++s->age; + if(s->variant!=2 && !fang_visible(r,s)) {retire(r,d);return;} + parasitic_native(r,d,s); +} diff --git a/src/mmx_weapon_silk.inc b/src/mmx_weapon_silk.inc new file mode 100644 index 0000000..e327c31 --- /dev/null +++ b/src/mmx_weapon_silk.inc @@ -0,0 +1,198 @@ +/* X2 Silk Shot: $81:91C0 normal, $81:A136 charged, $88:C475 gathering. + * variant = source material (stone, metal, scrap, leaves, crystal). + * muzzle_pose = 0 gathering/normal flight, 1 held/leaf launch, + * 2 charged flight/leaf fall, 3 dispersed pieces. + * tether_pose = original piece identity; radius = bounce/gather/life timer. + * All coordinates, animation and RNG choices are stored in the combat state. */ +static const uint8_t silk_normal_sequence[5][2]={{2,3},{6,7},{10,11},{16,16},{19,20}}; +static const uint8_t silk_fragment_sequence[5][2]={{4,5},{8,8},{14,15},{17,17},{21,22}}; +static const uint8_t silk_charged_sequence[5][8]={ + {2,3,2,3,4,5,4,5},{6,7,6,7,8,9,8,9}, + {10,11,12,13,14,15,14,15},{16,16,16,16,18,18,18,18}, + {19,20,19,20,21,22,21,22}}; +static const int16_t silk_radial[8][2]={ + {0,2048},{-2048,2048},{-2048,0},{-2048,-2048}, + {0,-2048},{2048,2048},{2048,0},{2048,-2048}}; +static const int8_t silk_offsets[8][2]={ + {-8,0},{-4,-10},{4,4},{8,-8},{-4,-12},{-4,8},{-2,-2},{10,0}}; +static const uint8_t silk_flips[8]={0,0,64,64,0,0,64,64}; + +static unsigned silk_material(const uint8_t *r) { + /* X1 has no Silk material events. Keep its environment choice explicit; + * the attack physics and original assets remain those of the chosen form. */ + static const uint8_t material[13]={1,0,3,0,2,1,2,1,4,0,2,1,4}; + return r[0x1f7a]<13 ? material[r[0x1f7a]] : 0; +} +static unsigned silk_group(unsigned material) { return material==3 ? 73 : material==4 ? 74 : 72; } +static bool silk_blocked_action(const uint8_t *r) { + return r[0xbaa]==12 || r[0xbaa]==14 || r[0xbaa]==0x18 || r[0xbaa]==0x42 || r[0x1f0c]; +} +static void silk_held_origin(const uint8_t *r,unsigned d,MmxWeaponShot *s) { + unsigned id=s->tether_pose-1;s->facing=r[0xbb9]&64; + muzzle_origin(r,d,s); + s->x+=(s->facing ? 1 : -1)*silk_offsets[id][0]*256; + s->y+=silk_offsets[id][1]*256;s->facing^=silk_flips[id]; + s->origin_x=(int16_t)word(r+0xbad);s->origin_y=(int16_t)word(r+0xbb0); +} +static bool silk_create_pieces(uint8_t *r,bool released) { + bool created=false; + for (unsigned id=8;id;--id) { + unsigned d=free_slot(r);if (!d) break; + MmxWeaponShot *s=combat.shots+slot_index(d);memset(s,0,sizeof(*s)); + s->active=s->charged=s->age=1;s->page=1;s->weapon=3; + s->variant=(uint8_t)silk_material(r);s->group=(uint8_t)silk_group(s->variant); + s->tether_pose=(uint8_t)id;s->radius=32;s->born=combat.tick; + silk_held_origin(r,d,s); + if (released) { + unsigned energy=MmxWeaponsEnergyAmount(1,3); + MmxWeaponsSpend(1,3,energy<64 ? energy : 64); + s->muzzle_pose=2;s->vx=(r[0xbb9]&64) ? 1024 : -1024;s->vy=-256; + } else { + s->vx=silk_radial[id-1][0];s->vy=silk_radial[id-1][1]; + s->x-=s->vx*32;s->y-=s->vy*32; + } + animation_start(s,silk_charged_sequence[s->variant][id-1]); + memset(r+d,0,64);native_object(r,d,s); + if (!released) putword(r+d+0x20,0); + ++r[0xbdd];created=true; + } + return created; +} +static void silk_player_tick(uint8_t *r) { + bool held=false,charged=false; + for (unsigned i=0;i<8;++i) { + MmxWeaponShot *s=combat.shots+i; + if (!s->active || s->page!=1 || s->weapon!=3) continue; + charged|=s->charged!=0;held|=s->charged && s->muzzle_pose<2; + } + r[0xc0f]=1; + if (!charged && combat.held && r[0xc03]==1 && (r[0x1f99]&2) && + MmxWeaponsEnergyAmount(1,3) && !silk_blocked_action(r)) { + /* Original $88:C49E allocates eight pieces at full charge while held. + * Use free native slots; gathering itself spends no energy. */ + held=silk_create_pieces(r,false); + } + if (held) { + /* Preserve actual held input in combat.held for the gathered pieces, + * but prevent X1 from creating a second release projectile. */ + stop_charge(r);r[0xbe3]&=(uint8_t)~64;r[0xbdf]&=(uint8_t)~64; + if (!silk_blocked_action(r)) r[0xbf8]=2; + } +} +static void silk_effect_tick(MmxWeaponShot *s) { + if (++s->age>=60) { memset(s,0,sizeof(*s));return; } + s->y+=s->vy;animation_step(s); +} +static void silk_leaf_trail(const MmxWeaponShot *s) { + if (combat.tick&3) return; + for (unsigned i=0;i<16;++i) if (!combat.effects[i].active) { + MmxWeaponShot *t=combat.effects+i;*t=*s;t->charged=0;t->age=1;t->tether_pose=0; + t->x+=(bubble_random(i)&7)*256;t->y+=(bubble_random(i+19)&7)*256; + t->vx=0;t->vy=(int16_t)(256+bubble_random(i+33));animation_start(t,17);return; + } +} +static void silk_leaf_fall(MmxWeaponShot *s,unsigned id) { + static const uint8_t sequence[4]={17,23,24,25}; + s->muzzle_pose=3;s->radius=60;s->vx=0;s->vy=(int16_t)(256+bubble_random(id)); + animation_start(s,sequence[bubble_random(id+7)&3]); +} +static void silk_split(uint8_t *r,unsigned d,MmxWeaponShot *s) { + if (s->charged) { + if (s->muzzle_pose==3) { retire(r,d);return; } + s->muzzle_pose=3; + if (s->variant==3) silk_leaf_fall(s,s->tether_pose); + else { s->vx=silk_radial[s->tether_pose-1][0];s->vy=silk_radial[s->tether_pose-1][1]; } + /* Retain each piece's first-target mask, so impact and its outgoing + * shard cannot damage that same enemy twice during a single contact. */ + return; + } + MmxWeaponShot parent=*s;retire(r,d); + for (unsigned id=4;id;--id) { + MmxWeaponShot *t=spawn_child(r,&parent);if (!t) break; + t->muzzle_pose=3;t->tether_pose=(uint8_t)id; + animation_start(t,silk_fragment_sequence[t->variant][id&1]); + if (t->variant==3) { + static const int8_t offsets[4][2]={{0,8},{4,-8},{8,4},{-8,-5}}; + t->x+=(offsets[id-1][0]+(int)(bubble_random(id)&7))*256; + t->y+=(offsets[id-1][1]+(int)(bubble_random(id+8)&7))*256; + silk_leaf_fall(t,id); + } else { + int speed=t->variant==4 ? 1280 : 1024; + t->vx=(int16_t)(id&1 ? speed : -speed); + t->vy=(int16_t)(id>=3 ? speed : -speed); + } + } +} +static void silk_tick(uint8_t *r,unsigned d,MmxWeaponShot *s) { + if (!s->age) { + /* The normal shot is single-fire; source fragments keep that limit + * occupied until all have left. A release after charge cancellation + * can still enter here as a charged native command. */ + if (s->charged) { retire(r,d);silk_create_pieces(r,true);return; } + if (!MmxWeaponsSpend(1,3,256)) { retire(r,d);return; } + s->variant=(uint8_t)silk_material(r);s->group=(uint8_t)silk_group(s->variant); + s->age=1;s->born=combat.tick;muzzle_origin(r,d,s); + s->vx=s->facing ? 512 : -512;s->vy=-768; + static const uint8_t bounces[5]={2,3,1,1,127};s->radius=bounces[s->variant]; + animation_start(s,silk_normal_sequence[s->variant][bubble_random(d)&1]); + if (s->variant==3) { s->muzzle_pose=1;s->radius=20;s->vx=s->facing ? 1280 : -1280;s->vy=0; } + native_object(r,d,s);return; + } + if (s->born==combat.tick) return; + bool contact=r[d+1]>=4; + if (s->charged && s->muzzle_pose<2) { + if (!combat.held || silk_blocked_action(r)) { + unsigned energy=MmxWeaponsEnergyAmount(1,3); + MmxWeaponsSpend(1,3,energy<64 ? energy : 64); /* $86:AF44 / eight. */ + silk_held_origin(r,d,s);s->vx=(r[0xbb9]&64) ? 1024 : -1024;s->vy=-256; + s->muzzle_pose=2;s->hit_slots=0; + } else if (!s->muzzle_pose && s->radius) { + s->x+=((int)word(r+0xbad)-s->origin_x)*256+s->vx; + s->y+=((int)word(r+0xbb0)-s->origin_y)*256+s->vy; + s->origin_x=(int16_t)word(r+0xbad);s->origin_y=(int16_t)word(r+0xbb0); + if (!--s->radius) { s->muzzle_pose=1;silk_held_origin(r,d,s); } + } else silk_held_origin(r,d,s); + } else if (contact) { + silk_split(r,d,s); + if (!s->active || !s->charged) return; + } else if (s->muzzle_pose==3) { + s->x+=s->vx;s->y+=s->vy; + if (s->variant==3 && (!s->radius || !--s->radius)) { retire(r,d);return; } + animation_step(s); + } else if (s->charged) { + /* Source checks the leading piece centers, then scatters the eight + * pieces on terrain. Native damage gives the same break on an enemy. */ + s->vy+=32;if (s->vy>1280) s->vy=1280; + if (terrain_move(r,s,0,0)) silk_split(r,d,s); + } else if (s->variant==3) { + unsigned hit; + if (s->muzzle_pose==1) { + s->vx+=(int16_t)(s->facing ? -64 : 64);s->vy-=32;if (s->vy< -768) s->vy=-768; + hit=terrain_move(r,s,0,0);silk_leaf_trail(s); + if (hit || !--s->radius) { s->muzzle_pose=2;s->vx=0;s->vy=-256; } + } else { + s->vy+=32;hit=terrain_move(r,s,0,0); + if (hit || s->vy>0) { silk_split(r,d,s);return; } + } + } else { + static const uint8_t gravity[5]={96,64,64,32,64}; + static const uint16_t rebound[5]={384,256,512,1280,16}; + s->vy+=gravity[s->variant];if (s->vy>2048) s->vy=2048; + unsigned hit=terrain_move(r,s,8,8); + if (hit&1) { silk_split(r,d,s);return; } + if (hit&8) s->vy=0; + if (hit&4) { + if (!--s->radius) { silk_split(r,d,s);return; } + s->vy=(int16_t)(-s->vy+(int)rebound[s->variant]);if (s->vy>=0) s->vy=-256; + } + } + if (!s->active) { retire(r,d);return; } + if (s->age<65535) ++s->age; /* A held cluster has no source time limit. */ + if ((!s->charged || s->muzzle_pose>=2) && + (s->x/256 < (int)word(r+0x1e4d)-32 || s->x/256 >= (int)word(r+0x1e4d)+288 || + s->y/256 < (int)word(r+0x1e50)-16 || s->y/256 >= (int)word(r+0x1e50)+240)) { + retire(r,d);return; + } + native_object(r,d,s); + if (s->charged && s->muzzle_pose<2) putword(r+d+0x20,0); +} diff --git a/src/mmx_weapon_triad.inc b/src/mmx_weapon_triad.inc new file mode 100644 index 0000000..2b4cd62 --- /dev/null +++ b/src/mmx_weapon_triad.inc @@ -0,0 +1,239 @@ +/* X3 $81:9A0B/$9CD8/$B167/$B230. See docs/weapons/triad-thunder.md. + * variant: normal 0..2 orbs, 3..5 links, 6..8 rays; charged 0 body, + * 1/2 ground waves. muzzle_pose: phase; radius: phase ticks; + * tether_pose: renew count | fire-edge count<<2 (or damage enabled). + * Every simulation field is included in the existing combat snapshot. */ +static MmxWeaponShot *triad_body(void) { + for (unsigned i=0;i<8;++i) { + MmxWeaponShot *p=combat.shots+i; + if (p->active && p->page==2 && p->weapon==3 && p->charged && !p->variant) return p; + } + return NULL; +} +static void triad_player(uint8_t *r) { + r[0xc0f]=8; + MmxWeaponShot *body=triad_body(); + if (body) { + stop_charge(r); + r[0xbde]=r[0xbe2]=r[0xbdf]=r[0xbe3]=0; + putword(r+0xbc2,0);r[0xbf8]=0; + if (body->muzzle_pose && r[0xbaa]!=14 && r[0xbaa]!=12) { + r[0xbaa]=0;r[0xbab]=2;putword(r+0xbc4,0); + } else if (!body->muzzle_pose && (int16_t)word(r+0xbc4)<-1408) putword(r+0xbc4,-1408); + return; + } + for (unsigned i=0;i<8;++i) { + MmxWeaponShot *p=combat.shots+i; + if (!p->active || p->page!=2 || p->weapon!=3 || p->charged || p->variant>=3 || p->muzzle_pose>=3) continue; + if (combat.pressed && p->muzzle_pose && (p->tether_pose>>2)<4) p->tether_pose+=4; + r[0xbe3]&=(uint8_t)~64; /* Four repeat edges rotate the existing triangle. */ + } +} +static void triad_box(const MmxWeaponShot *s,uint8_t *box) { + static const int8_t up[6][4]={{0,-8,9,10},{0,-16,9,16},{0,-24,9,20}, + {0,-32,9,27},{0,-39,9,35},{0,-48,9,42}}; + static const int8_t diagonal[9][4]={{-88,-88,8,8},{-80,-82,11,12},{-74,-74,18,13}, + {-1,0,0,24},{8,10,18,30},{0,5,5,5},{-52,-52,11,12},{-44,-44,16,13},{-32,-31,16,17}}; + box[0]=box[1]=0;box[2]=13;box[3]=15; + if (s->charged) {if (!s->variant) {box[2]=127;box[3]=95;}return;} + if (s->variant>=3 && s->variant<6) { + if (s->variant==3) {box[2]=18;box[3]=9;} + else {box[0]=254;box[2]=11;box[3]=20;} + } else if (s->variant>=6) { + unsigned f=s->flags&127; + if (s->variant==6) memcpy(box,up[f>5?5:f],4); + else { + unsigned k=f<3?f:f==0x11?3:f==0x23?4:5; + if (!(s->timer&1) && (f&0x30)) k=6+((f&0x30)>>4)-1; + memcpy(box,diagonal[k],4); + } + } +} +static void triad_follow(const uint8_t *r,MmxWeaponShot *s) { + int x=(int)word(r+0xbad),y=(int)word(r+0xbb0); + s->x+=(x-s->origin_x)*256;s->y+=(y-s->origin_y)*256; + s->origin_x=(int16_t)x;s->origin_y=(int16_t)y; +} +static void triad_native(uint8_t *r,unsigned d,MmxWeaponShot *s) { + native_object(r,d,s); + bool disabled=s->charged ? (s->variant ? !s->muzzle_pose : s->muzzle_pose<2) : + s->variant<3 ? (!s->muzzle_pose || s->muzzle_pose==3) : + s->variant<6 ? !s->tether_pose : false; + if (disabled) putword(r+d+0x20,0); +} +static void triad_links(uint8_t *r,MmxWeaponShot *s) { + static const int8_t xy[3][2]={{-16,-8},{0,24},{16,-8}}; + for (unsigned i=0;i<3;++i) { + MmxWeaponShot *p=spawn_child(r,s);if (!p) break; + p->variant=(uint8_t)(3+i);p->muzzle_pose=0;p->radius=30;p->tether_pose=0; + int sign=(s->facing&128)?-1:1; + p->x=(s->origin_x+xy[i][0])*256;p->y=(s->origin_y+xy[i][1]*sign)*256; + p->facing=(uint8_t)((s->facing&128)|(i==0?64:0)); + animation_start(p,i==1?5:3); + triad_native(r,0x1228+(unsigned)(p-combat.shots)*64,p); + } +} +static bool triad_renew(uint8_t *r,MmxWeaponShot *s) { + unsigned count=s->tether_pose&3; + if ((s->tether_pose>>2)<4 || count>=3 || !MmxWeaponsSpend(2,3,256)) return false; + static const int16_t vx[3]={0,-1024,1024},vy[3]={-1280,768,768}; + for (unsigned i=0;i<8;++i) { + MmxWeaponShot *p=combat.shots+i; + if (!p->active || p->page!=2 || p->weapon!=3 || p->charged || p->variant>=3 || p->muzzle_pose>=3) continue; + p->facing^=128;p->muzzle_pose=0;p->radius=16;p->tether_pose=(uint8_t)(count+1); + p->born=combat.tick; + int sign=(p->facing&128)?-1:1; + p->vx=(int16_t)(vx[p->variant]*sign);p->vy=(int16_t)(vy[p->variant]*sign); + p->hit_slots=0;animation_start(p,0); + } + return true; +} +static void triad_release(uint8_t *r,MmxWeaponShot *s) { + static const int16_t vx[3]={0,-1636,1636},vy[3]={-2048,1228,1228}; + static const int8_t xy[3][2]={{0,-40},{-32,24},{32,24}}; + MmxWeaponShot *p=spawn_child(r,s); + if (p) { + p->variant=(uint8_t)(6+s->variant);p->muzzle_pose=0;p->radius=18;p->tether_pose=0; + int sign=(s->facing&128)?-1:1; + p->facing=(uint8_t)((s->facing&128)|(s->variant==1?64:0)); + p->x=(s->origin_x+xy[s->variant][0])*256;p->y=(s->origin_y+xy[s->variant][1]*sign)*256; + p->vx=vx[s->variant];p->vy=(int16_t)(vy[s->variant]*sign); + animation_start(p,s->variant?8:7); + triad_native(r,0x1228+(unsigned)(p-combat.shots)*64,p); + } + s->muzzle_pose=3;s->radius=(uint8_t)(10+((combat.tick+s->variant*3)&7));s->vx=s->vy=0; + animation_start(s,2); +} +static bool triad_visible(const uint8_t *r,const MmxWeaponShot *s) { + const MmxWeaponPose *p=MmxWeaponsPose(2,3,11,s->pose); + int x=(s->x>>8)-(int)word(r+0x1e4d),y=(s->y>>8)-(int)word(r+0x1e50); + if (!p) return false; + int left=(s->facing&64)?-p->left-p->width:p->left; + int top=(s->facing&128)?-p->top-p->height:p->top; + return x+left<288 && x+left+p->width>=-32 && y+top<240 && y+top+p->height>=-16; +} +/* The source's two-pixel probes follow floor slopes, climb wall faces, + * and descend exposed edges. Integrate four one-pixel steps so X1's + * narrower tiles/slopes cannot be skipped. Wave origin is the contact. */ +static bool triad_ground(const uint8_t *r,MmxWeaponShot *s) { + int x=s->x>>8,y=s->y>>8,dx=s->variant==1?-1:1; + for (unsigned n=0;n<4;++n) { + if (s->muzzle_pose==1) { + if (terrain_solid(r,x+dx*2,y-2,false,NULL)) {s->muzzle_pose=2;s->vy=-1024;} + else { + int floor=0;bool found=false; + for (int step=-2;step<=4;++step) if (terrain_solid(r,x+dx,y+step,true,&floor)) {found=true;break;} + if (found) {x+=dx;y=floor;} + else {x+=dx;s->muzzle_pose=2;s->vy=1024;} + } + } + if (s->muzzle_pose==2) { + int dy=s->vy<0?-1:1;y+=dy; + if (dy<0) { + if (terrain_solid(r,x,y-2,false,NULL)) return false; + if (!terrain_solid(r,x+dx*2,y-1,false,NULL)) {x+=dx*2;s->muzzle_pose=1;} + } else { + int floor=0; + if (terrain_solid(r,x,y+2,true,&floor)) {y=floor;s->muzzle_pose=1;} + else if (!terrain_solid(r,x-dx*2,y,false,NULL)) return false; + } + } + } + s->x=x*256;s->y=y*256;return true; +} +static void triad_tick(uint8_t *r,unsigned d,MmxWeaponShot *s) { + if (!s->age) { + for (unsigned i=0;i<8;++i) { + MmxWeaponShot *p=combat.shots+i; + if (p==s || !p->active || p->page!=2 || p->weapon!=3) continue; + if (!s->charged || p->charged) {retire(r,d);return;} + retire(r,0x1228+i*64); + } + if (!MmxWeaponsSpend(2,3,(s->charged?3:1)*256)) {retire(r,d);return;} + s->age=1;s->born=combat.tick;s->origin_x=(int16_t)word(r+0xbad);s->origin_y=(int16_t)word(r+0xbb0); + s->x=s->origin_x*256;s->y=(s->origin_y-8)*256; + if (s->charged) { + s->group=51;s->facing=r[0xbb9]&64;animation_start(s,34);stop_charge(r); + if (!(r[0xbd3]&4)) {r[0xbaa]=8;r[0xbab]=0;} + for (unsigned i=1;i<=2;++i) { + MmxWeaponShot *p=spawn_child(r,s);if (!p) break; + p->group=11;p->variant=(uint8_t)i;p->facing=0;p->vx=(int16_t)(i==1?-1024:1024);p->vy=1024; + animation_start(p,10);triad_native(r,0x1228+(unsigned)(p-combat.shots)*64,p); + } + } else { + s->facing=0;s->radius=8;s->vx=0;s->vy=-1024;animation_start(s,0); + for (unsigned i=1;i<=2;++i) { + MmxWeaponShot *p=spawn_child(r,s);if (!p) break; + p->variant=(uint8_t)i;p->vx=(int16_t)(i==1?-1024:1024);p->vy=1024; + p->x+=p->vx;p->y+=p->vy;--p->radius;animation_step(p); + triad_native(r,0x1228+(unsigned)(p-combat.shots)*64,p); + } + s->x+=s->vx;s->y+=s->vy;--s->radius;animation_step(s); + } + triad_native(r,d,s);return; + } + if (s->born==combat.tick) return; + if (s->charged) { + if (!s->variant) { + if (r[0xbaa]==14 || r[0xbaa]==12 || r[0x1f0c]) {retire(r,d);return;} + s->x=((int)word(r+0x1e4d)+128)*256;s->y=((int)word(r+0x1e50)+112)*256; + if (!s->muzzle_pose) { + if (!(r[0xbd3]&4)) {triad_native(r,d,s);return;} + s->muzzle_pose=1;r[0xbaa]=0;r[0xbab]=2;putword(r+0xbc2,0);putword(r+0xbc4,0); + } + animation_step(s); + if ((s->flags&128) && s->timer==1) {retire(r,d);return;} + if (s->flags&32) s->muzzle_pose=2; + if (s->flags&64) s->muzzle_pose=3; + if (s->muzzle_pose>=2) { + s->hit_slots=0; + if (!(s->radius++&7)) { + /* Original X1 quake ($84:A341), X3 calls equivalent $84:D5A2. */ + r[0x1e7a]=6;r[0x1e83]=3;r[0x1e7f]=r[0x1e81]=1;r[0x1e85]=0;r[0x1e78]|=2; + } + } + } else { + if (!s->muzzle_pose) { + MmxWeaponShot *body=triad_body();if (!body) {retire(r,d);return;} + s->x=(int)word(r+0xbad)*256;s->y=((int)word(r+0xbb0)+16)*256; + if (body->muzzle_pose>=3) s->muzzle_pose=1; + } else { + if (r[d+1]>=4 || !triad_ground(r,s) || !triad_visible(r,s)) {retire(r,d);return;} + animation_step(s); + } + } + } else if (s->variant<3) { + if (s->muzzle_pose<3 || s->radius) triad_follow(r,s); + if (!s->muzzle_pose) { + s->x+=s->vx;s->y+=s->vy; + if (!--s->radius) {s->muzzle_pose=1;s->radius=30;animation_start(s,1);} + } else if (s->muzzle_pose==1) { + if (!--s->radius) {s->muzzle_pose=2;s->radius=38;if (!s->variant) triad_links(r,s);} + } else if (s->muzzle_pose==2) { + if (!--s->radius) { + if (s->variant || !triad_renew(r,s)) triad_release(r,s); + } + } else if (s->radius) --s->radius; + else {s->vy+=32;if(s->vy>1024)s->vy=1024;s->y+=s->vy;} + if (s->muzzle_pose<3) s->hit_slots=0; + animation_step(s); + } else if (s->variant<6) { + triad_follow(r,s); + if (!s->muzzle_pose) { + if (s->flags&1) s->tether_pose=1; + if (!--s->radius) {s->muzzle_pose=1;animation_start(s,s->variant==4?6:4);} + } else { + if ((s->flags&1) || s->pose==16) s->tether_pose=0; + if ((s->flags&128) && s->timer==1) {retire(r,d);return;} + } + s->hit_slots=0;animation_step(s); + } else { + if (!s->muzzle_pose) { + triad_follow(r,s);if (!--s->radius) s->muzzle_pose=1; + } else {s->x+=s->vx;s->y+=s->vy;} + s->hit_slots=0;animation_step(s); + } + ++s->age; + if (!s->active || s->age>900 || (!s->charged && !triad_visible(r,s))) {retire(r,d);return;} + triad_native(r,d,s); +} diff --git a/src/mmx_weapons.c b/src/mmx_weapons.c new file mode 100644 index 0000000..d070f69 --- /dev/null +++ b/src/mmx_weapons.c @@ -0,0 +1,246 @@ +#include "mmx_weapons.h" +#include "mmx_weapon_combat.h" +#include +#include +#include + +enum { WEAPONS = 16, GROUPS = 3, POSES = 128 }; +typedef struct WeaponGroup { + unsigned id, count, animation_size; + const uint8_t *animation; + uint16_t colors[16]; + MmxWeaponPose pose[POSES]; +} WeaponGroup; +typedef struct WeaponArt { + uint16_t body[16], colors[16]; + uint16_t icon_colors[16]; + MmxWeaponPose icon; + MmxWeaponPose hud_icon; + unsigned groups; + WeaponGroup group[GROUPS]; +} WeaponArt; +static WeaponArt art[WEAPONS]; +static uint8_t *asset; +static uint8_t *page_assets[2]; +static MmxWeaponsState state; +_Static_assert(sizeof(MmxWeaponsState) == 40 && offsetof(MmxWeaponsState,fraction) == MMX_WEAPONS_LEGACY_STATE_SIZE, "Weapon save ABI"); +static unsigned word(const uint8_t *p) { return p[0] | (p[1] << 8); } +static unsigned weapon_index(unsigned page, unsigned weapon) { return (page - 1) * 8 + weapon - 1; } +static bool valid_weapon(unsigned page, unsigned weapon) { return page >= 1 && page <= 2 && weapon >= 1 && weapon <= 8; } +static void initialize(void) { + if (!state.initialized) { memset(&state, 0, sizeof(state)); memset(state.energy, 28, 16); state.initialized = 1; } +} +bool MmxWeaponsEnabled(void) { return asset || page_assets[0] || page_assets[1]; } +bool MmxWeaponsPageEnabled(unsigned page) { return page>=1 && page<=2 && art[(page-1)*8].groups; } +bool MmxWeaponsActive(void) { return MmxWeaponsPageEnabled(state.page) && valid_weapon(state.page, state.weapon); } +MmxWeaponsState MmxWeaponsGetState(void) { return state; } +bool MmxWeaponsValidState(const MmxWeaponsState *s) { + if (!s || s->page > 2 || s->weapon > 8 || s->menu_page > 2 || s->initialized > 1 || + s->charge > 201 || s->reserved) return false; + for (unsigned i = 0; i < 16; ++i) if (s->energy[i] > 28 || (s->energy[i] == 28 && s->fraction[i])) return false; + return true; +} +void MmxWeaponsSetState(MmxWeaponsState s) { + memset(&state, 0, sizeof(state)); + if (MmxWeaponsValidState(&s)) state = s; + if (MmxWeaponsEnabled()) { + initialize(); + if (state.page && !MmxWeaponsPageEnabled(state.page)) state.page=state.weapon=0; + if (state.menu_page && !MmxWeaponsPageEnabled(state.menu_page)) state.menu_page=0; + } +} +void MmxWeaponsDisable(void) { + MmxWeaponsCancelShots(NULL); + free(asset); asset = NULL; memset(art, 0, sizeof(art)); memset(&state, 0, sizeof(state)); + for (unsigned i=0;i<2;++i) { free(page_assets[i]);page_assets[i]=NULL; } +} +static bool load(const char *path, unsigned page) { + FILE *f = path ? fopen(path, "rb") : NULL; + if (!f) return false; + if (fseek(f, 0, SEEK_END)) { fclose(f); return false; } + long size = ftell(f); rewind(f); + if (size < 12 || size > 2 * 1024 * 1024) { fclose(f); return false; } + uint8_t *data = malloc((size_t)size); + WeaponArt *candidate = calloc(WEAPONS, sizeof(*candidate)); + bool ok = data && candidate && fread(data, (size_t)size, 1, f) == 1; + fclose(f); + if (!ok) { free(data); free(candidate); return false; } + unsigned count=page ? 8 : 16,first=page ? (page-1)*8 : 0; + ok = !memcmp(data, "MMXWEAP5", 8) && word(data+8)==count && !word(data+10); + size_t pos = 12; + for (unsigned i = first; i < first+count && ok; ++i) { + if (pos + 612 > (size_t)size) { ok = false; break; } + const uint8_t *p = data + pos; + WeaponArt *w = candidate + i; + ok = p[0] == 2 + i / 8 && p[1] == 1 + i % 8 && p[2] >= 1 && p[2] <= GROUPS && !p[3]; + w->groups = p[2]; + for (unsigned c = 0; c < 16; ++c) { w->body[c] = (uint16_t)word(p + 4 + c * 2); w->colors[c] = (uint16_t)word(p + 36 + c * 2); } + for (unsigned c = 0; c < 16; ++c) w->icon_colors[c] = (uint16_t)word(p + 68 + c * 2); + w->icon = (MmxWeaponPose){-8,-8,16,16,p + 100}; + w->hud_icon = (MmxWeaponPose){0,0,16,16,p + 356}; + for (unsigned n = 0; n < 512; ++n) if (p[100 + n] > 15) ok = false; + pos += 612; + for (unsigned g = 0; g < w->groups && ok; ++g) { + if (pos + 38 > (size_t)size) { ok = false; break; } + WeaponGroup *group = w->group + g; + group->id = word(data + pos); group->count = word(data + pos + 2); + group->animation_size = word(data + pos + 4); pos += 6; + for (unsigned c=0;c<16;++c) group->colors[c]=(uint16_t)word(data+pos+c*2); + pos+=32; + if (group->animation_size < 5 || group->animation_size > 8192 || pos + group->animation_size > (size_t)size) { ok = false; break; } + group->animation = data + pos; pos += group->animation_size; + if (group->count > POSES || !group->count) { ok = false; break; } + for (unsigned j = 0; j < group->count && ok; ++j) { + if (pos + 8 > (size_t)size) { ok = false; break; } + MmxWeaponPose *pose = group->pose + j; + pose->left = (int16_t)word(data + pos); pose->top = (int16_t)word(data + pos + 2); + pose->width = (uint16_t)word(data + pos + 4); pose->height = (uint16_t)word(data + pos + 6); pos += 8; + size_t length = (size_t)pose->width * pose->height; + if (pose->width > 256 || pose->height > 256 || pos + length > (size_t)size || + pose->left < -256 || pose->left > 256 || pose->top < -256 || pose->top > 256) { ok = false; break; } + pose->pixels = data + pos; + for (size_t n = 0; n < length; ++n) if (data[pos + n] > 15) { ok = false; break; } + pos += length; + } + } + } + ok = ok && pos == (size_t)size; + if (ok) { + if (!page) { MmxWeaponsDisable(); asset = data; } + else { free(page_assets[page-1]);page_assets[page-1]=data; } + memcpy(art+first,candidate+first,count*sizeof(*art));initialize(); + } + else free(data); + free(candidate); return ok; +} +bool MmxWeaponsLoad(const char *path) { return load(path,0); } +bool MmxWeaponsLoadPage(const char *path,unsigned page) { return page>=1 && page<=2 && load(path,page); } +const MmxWeaponPose *MmxWeaponsPose(unsigned page, unsigned weapon, unsigned group, unsigned pose) { + if (!MmxWeaponsPageEnabled(page) || !valid_weapon(page, weapon)) return NULL; + const WeaponArt *w = art + weapon_index(page, weapon); + /* UINT_MAX asks for the weapon's primary group (including its native icon). */ + for (unsigned i = 0; i < w->groups; ++i) if ((group == UINT32_MAX || w->group[i].id == group) && pose < w->group[i].count) + return w->group[i].pose + pose; + return NULL; +} +const uint16_t *MmxWeaponsPalette(unsigned page, unsigned weapon, bool body) { + if (!MmxWeaponsPageEnabled(page) || !valid_weapon(page, weapon)) return NULL; + const WeaponArt *w = art + weapon_index(page, weapon); return body ? w->body : w->colors; +} +const MmxWeaponPose *MmxWeaponsIcon(unsigned page, unsigned weapon) { + return MmxWeaponsPageEnabled(page) && valid_weapon(page, weapon) ? &art[weapon_index(page, weapon)].icon : NULL; +} +const uint16_t *MmxWeaponsGroupPalette(unsigned page,unsigned weapon,unsigned group) { + if (!MmxWeaponsPageEnabled(page) || !valid_weapon(page,weapon)) return NULL; + const WeaponArt *w=art+weapon_index(page,weapon); + for (unsigned i=0;igroups;++i) if(w->group[i].id==group) return w->group[i].colors; + return NULL; +} +const MmxWeaponPose *MmxWeaponsHudIcon(unsigned page, unsigned weapon) { + return MmxWeaponsPageEnabled(page) && valid_weapon(page, weapon) ? &art[weapon_index(page, weapon)].hud_icon : NULL; +} +const uint8_t *MmxWeaponsAnimation(unsigned page, unsigned weapon, unsigned group, unsigned *size) { + if (!MmxWeaponsPageEnabled(page) || !size || !valid_weapon(page, weapon)) return NULL; + const WeaponArt *w = art + weapon_index(page, weapon); + for (unsigned i = 0; i < w->groups; ++i) if (w->group[i].id == group) { + *size = w->group[i].animation_size; return w->group[i].animation; + } + return NULL; +} +const uint16_t *MmxWeaponsIconPalette(unsigned page, unsigned weapon) { + return MmxWeaponsPageEnabled(page) && valid_weapon(page, weapon) ? art[weapon_index(page, weapon)].icon_colors : NULL; +} +const char *MmxWeaponsLabel(unsigned page, unsigned weapon) { + static const char *labels[16] = {"C.HUNTER", "B.SPLASH", "S.SHOT", "S.WHEEL", "S.SLICER", "S.CHAIN", "M.MINE", "S.BURNER", + "ACID.B", "P.BOMB", "T.THUNDR", "S.BLADE", "R.SPLASH", "G.WELL", "F.SHIELD", "T.FANG"}; + return valid_weapon(page, weapon) ? labels[weapon_index(page, weapon)] : ""; +} +unsigned MmxWeaponsEnergyRead(unsigned address, unsigned original) { + if (!MmxWeaponsActive()) return original; + if (address == 0xbdb) return 2; /* HUD/pickup routines only: virtual inventory index. */ + unsigned i = weapon_index(state.page,state.weapon), value = 0xc0 | state.energy[i]; + return address == 0x1f85 ? (value << 8) | state.fraction[i] : value; +} +static unsigned energy_amount(unsigned i) { return state.energy[i]*256 + state.fraction[i]; } +static void set_energy(unsigned i, unsigned value) { + if (value > 28*256) value = 28*256; + state.energy[i] = (uint8_t)(value >> 8); state.fraction[i] = (uint8_t)value; +} +unsigned MmxWeaponsEnergyAmount(unsigned page, unsigned weapon) { + return MmxWeaponsPageEnabled(page) && valid_weapon(page,weapon) ? energy_amount(weapon_index(page,weapon)) : 0; +} +bool MmxWeaponsSpend(unsigned page, unsigned weapon, unsigned cost) { + if (!MmxWeaponsPageEnabled(page) || !valid_weapon(page,weapon)) return false; + unsigned i = weapon_index(page,weapon), value = energy_amount(i); + if (cost > value) return false; + set_energy(i,value-cost); return true; +} +bool MmxWeaponsEnergyStore(unsigned value, bool pickup) { + if (!MmxWeaponsActive()) return false; + if (pickup) { + set_energy(weapon_index(state.page,state.weapon),value & 0x3fff); + } + return true; /* Guest inventory stays untouched, including its dirty flags. */ +} +void MmxWeaponsEnergyOverflow(uint8_t r[0x20000], unsigned index) { + /* Native auto-refill has already scanned X1's owned weapons. Only the + * unconsumed remainder after that complete scan can fill new inventory. */ + if (!MmxWeaponsEnabled() || index < 18) return; + unsigned amount = word(r), initial = amount; + for (unsigned i=0;i<16 && amount;++i) { + if (!MmxWeaponsPageEnabled(i/8+1)) continue; + unsigned value = energy_amount(i), add = 28*256-value; if (add > amount) add = amount; + set_energy(i,value+add); amount -= add; + } + if (amount != initial) { + unsigned i = r[0xba3] & 30; + r[0xb72+i] = 0x0d; r[0xb73+i] = 0; r[0xba3] = (uint8_t)((i+2)&30); + } +} +void MmxWeaponsRefill(void) { + if (MmxWeaponsEnabled()) { memset(state.energy,28,sizeof(state.energy)); memset(state.fraction,0,sizeof(state.fraction)); } +} +bool MmxWeaponsMenuVisible(const uint8_t r[0x20000]) { + return MmxWeaponsEnabled() && r && r[0xd1] == 2 && r[0xd2] == 4 && + (((r[0x1f10] == 6 || r[0x1f10] == 8) && (r[0xc3] & 128)) || + (r[0x1989] == 1 && word(r + 0x198d) == 128 && word(r + 0x1990) == 160 && + (r[0x199e] == 0 || r[0x199e] == 0x18))); +} +void MmxWeaponsMenuTick(uint8_t r[0x20000], unsigned dp) { + if (!MmxWeaponsEnabled() || !r || dp > 0x1ff00) return; + unsigned button = r[0xbe2] & 0x30; + if (button != 0x10 && button != 0x20) return; + do { state.menu_page = (uint8_t)((state.menu_page + (button == 0x10 ? 1 : 2)) % 3); } + while (state.menu_page && !MmxWeaponsPageEnabled(state.menu_page)); + unsigned cursor = r[dp + 10]; + if (!state.menu_page && cursor >= 1 && cursor <= 8 && !(r[0x1f86 + cursor * 2] & 64)) r[dp + 10] = 0; + /* Use the native menu movement sound and its existing SPC command ring. */ + unsigned index = r[0xba3] & 0x1e; + r[0xb72 + index] = 0x2c; r[0xb73 + index] = 0; r[0xba3] = (uint8_t)((index + 2) & 0x1e); + r[0xbe2] &= (uint8_t)~0x30; +} +unsigned MmxWeaponsMenuRead(uint8_t r[0x20000], unsigned pc, unsigned dp, unsigned index, unsigned original) { + if (!MmxWeaponsEnabled() || !r || dp > 0x1ff00) return original; + switch (pc & 0xffff) { + case 0xc484: + state.menu_page = state.page; + return state.page ? state.weapon * 2 : original; + case 0xc767: + return state.menu_page ? (state.page == state.menu_page ? state.weapon * 2 : 0) : original; + case 0xce28: { + unsigned cursor = r[dp + 10], old_page = state.page; + if (cursor < 9) { + MmxWeaponsCancelShots(r); + if (old_page || state.menu_page) r[0x1f12] = 0; /* Rebuild the native energy HUD. */ + state.page = state.menu_page; /* Buster remains part of the chosen set. */ + state.weapon = (uint8_t)cursor; state.charge = state.cooldown = 0; + if (old_page || state.menu_page) return 254; /* Run native weapon cleanup even between two extended weapons. */ + } + return original; + } + case 0xce33: + return state.menu_page && original < 9 ? 0 : original; /* Native actor retains safe buster resources. */ + default: + return state.menu_page && index < 16 ? 0xc0 | state.energy[(state.menu_page - 1) * 8 + index / 2] : original; + } +} diff --git a/src/mmx_weapons.h b/src/mmx_weapons.h new file mode 100644 index 0000000..9ce3850 --- /dev/null +++ b/src/mmx_weapons.h @@ -0,0 +1,45 @@ +#pragma once +#include +#include + +/* Page 0 retains the native X1 inventory. Pages 1/2 are X2/X3. */ +enum { MMX_WEAPONS_LEGACY_STATE_SIZE = 24 }; +typedef struct MmxWeaponsState { + uint8_t page, weapon, menu_page, initialized; + uint8_t energy[16]; + uint16_t charge; + uint8_t cooldown, reserved; + uint8_t fraction[16]; /* Low bytes of source-format 8.8 weapon energy. */ +} MmxWeaponsState; +typedef struct MmxWeaponPose { + int16_t left, top; + uint16_t width, height; + const uint8_t *pixels; +} MmxWeaponPose; +bool MmxWeaponsLoad(const char *path); +bool MmxWeaponsLoadPage(const char *path, unsigned page); +bool MmxWeaponsPageEnabled(unsigned page); +void MmxWeaponsDisable(void); +bool MmxWeaponsEnabled(void); +bool MmxWeaponsActive(void); +MmxWeaponsState MmxWeaponsGetState(void); +bool MmxWeaponsValidState(const MmxWeaponsState *state); +void MmxWeaponsSetState(MmxWeaponsState state); +const MmxWeaponPose *MmxWeaponsPose(unsigned page, unsigned weapon, unsigned group, unsigned pose); +const MmxWeaponPose *MmxWeaponsIcon(unsigned page, unsigned weapon); +const MmxWeaponPose *MmxWeaponsHudIcon(unsigned page, unsigned weapon); +const uint16_t *MmxWeaponsIconPalette(unsigned page, unsigned weapon); +const uint8_t *MmxWeaponsAnimation(unsigned page, unsigned weapon, unsigned group, unsigned *size); +const uint16_t *MmxWeaponsPalette(unsigned page, unsigned weapon, bool body); +const uint16_t *MmxWeaponsGroupPalette(unsigned page,unsigned weapon,unsigned group); +const char *MmxWeaponsLabel(unsigned page, unsigned weapon); +bool MmxWeaponsMenuVisible(const uint8_t ram[0x20000]); +void MmxWeaponsMenuTick(uint8_t ram[0x20000], unsigned direct_page); +unsigned MmxWeaponsMenuRead(uint8_t ram[0x20000], unsigned pc, unsigned direct_page, + unsigned index, unsigned original); +unsigned MmxWeaponsEnergyRead(unsigned address, unsigned original); +bool MmxWeaponsEnergyStore(unsigned value, bool pickup); +void MmxWeaponsEnergyOverflow(uint8_t ram[0x20000], unsigned index); +void MmxWeaponsRefill(void); +unsigned MmxWeaponsEnergyAmount(unsigned page, unsigned weapon); +bool MmxWeaponsSpend(unsigned page, unsigned weapon, unsigned cost); /* 8.8 units. */ diff --git a/src/mmx_zero.c b/src/mmx_zero.c index 9f0ea6f..bc1e57a 100644 --- a/src/mmx_zero.c +++ b/src/mmx_zero.c @@ -4,6 +4,8 @@ #include static uint8_t *poses; +static uint8_t *charge_poses; +static uint16_t charge_colors[3][16]; static uint16_t colors[128]; static uint8_t saber_bounds[40]; static uint8_t hud_tiles[128]; @@ -66,6 +68,7 @@ void MmxZeroHealthRespawn(const uint8_t r[0x20000]) { void MmxZeroDisable(void) { MmxZeroResetState(); free(poses); poses = NULL; + free(charge_poses); charge_poses = NULL; } bool MmxZeroLoad(const char *path) { FILE *f = path ? fopen(path, "rb") : NULL; @@ -73,9 +76,10 @@ bool MmxZeroLoad(const char *path) { 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; + uint8_t *data = NULL, *particles = NULL, flash[96]; + bool modern = false; bool ok = fread(header, 1, sizeof(header), f) == sizeof(header) && - !memcmp(header, "MMXZERO6", 8) && word(header + 8) == MMX_ZERO_WIDTH && + ((modern = !memcmp(header, "MMXZERO7", 8)) || !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) && @@ -83,7 +87,15 @@ bool MmxZeroLoad(const char *path) { 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; } + if (ok) { data = malloc(size); ok = data && fread(data, 1, size, f) == size; } + size_t charge_size = (size_t)MMX_ZERO_CHARGE_POSES * MMX_ZERO_WIDTH * MMX_ZERO_HEIGHT; + if (ok && modern) { + particles = malloc(charge_size); + ok = particles && fread(flash, 1, sizeof(flash), f) == sizeof(flash) && + fread(particles, 1, charge_size, f) == charge_size; + if (ok) for (size_t i=0;i=16) { ok=false; break; } + } + if (ok) ok = 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; } @@ -94,8 +106,9 @@ bool MmxZeroLoad(const char *path) { } 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; + if (!ok) { free(data); free(particles); return false; } + MmxZeroDisable(); poses = data; charge_poses = particles; + if (modern) for (unsigned i=0;i<48;++i) charge_colors[i/16][i%16]=(uint16_t)(word(flash+i*2)&0x7fff); memcpy(saber_bounds, bounds, sizeof(bounds)); memcpy(animation, anim, sizeof(animation)); memcpy(muzzle, emission, sizeof(muzzle)); @@ -105,6 +118,21 @@ bool MmxZeroLoad(const char *path) { return true; } const uint16_t *MmxZeroColors(void) { return colors; } +bool MmxZeroHasChargeArt(void) { return charge_poses != NULL; } +const uint16_t *MmxZeroBodyColors(const MmxZeroState *s) { + if (!charge_poses || !s || s->active_x || s->swap_phase || s->slash || + (s->charge < 25 && !(s->combo && s->saber_ready)) || (s->charge_phase & 2)) return colors + 16; + return charge_colors[s->saber_ready || s->charge >= 201 ? 2 : s->charge >= 141 ? 1 : 0]; +} +const uint8_t *MmxZeroChargePose(const MmxZeroState *s) { + if (!charge_poses || !s || s->active_x || s->swap_phase || s->slash || s->burst || s->combo || s->charge < 21) return NULL; + unsigned group = s->charge < 81 ? 0 : s->charge < 141 ? 1 : 2; + return charge_poses + (size_t)(group * 22 + s->charge_phase % 22) * MMX_ZERO_WIDTH * MMX_ZERO_HEIGHT; +} +bool MmxZeroNativeChargeObject(unsigned object, unsigned kind) { + /* $82:82ED allocates any of twelve small actors, not just $0C98. */ + return object >= 0xc98 && object < 0xe18 && ((object - 0xc98) % 32) == 0 && kind == 1; +} 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) { @@ -280,6 +308,9 @@ static void clear_charge(uint8_t *r) { * 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); + r[0xc2a] = 0; + for (unsigned d=0xc98;d<0xe18;d+=32) + if (MmxZeroNativeChargeObject(d,r[d+10])) memset(r+d,0,32); } unsigned MmxZeroSwapPose(const MmxZeroState *s) { if (!s || !s->swap_phase || s->swap_phase == 3) return 255; @@ -371,7 +402,6 @@ static void emit_burst(uint8_t *r) { ++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]; @@ -412,7 +442,6 @@ static void track_burst_shots(const uint8_t *r) { * 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) { @@ -427,6 +456,9 @@ void MmxZeroPlayerTick(uint8_t r[0x20000]) { (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; + if (state.charge >= 21 || (state.combo && state.saber_ready)) + state.charge_phase = (uint8_t)((state.charge_phase + 1) % 88); + else state.charge_phase = 0; track_burst_shots(r); if (state.burst_end) { state.burst = state.burst_end = 0; @@ -445,7 +477,7 @@ void MmxZeroPlayerTick(uint8_t r[0x20000]) { state.combo = state.saber_ready ? 2 : 0; start_burst(r,2); } - } else if (state.saber_ready && !state.shot_mask && !state.cooldown && !r[0xc25]) { + } else if (state.saber_ready && !state.shot_mask && !r[0xc25]) { unsigned d = free_projectile(r); if (d) { clear_charge(r); state.combo = state.saber_ready = 0; state.slash = 1; diff --git a/src/mmx_zero.h b/src/mmx_zero.h index 10d826c..b20265d 100644 --- a/src/mmx_zero.h +++ b/src/mmx_zero.h @@ -3,13 +3,14 @@ #include #include -enum { MMX_ZERO_WIDTH = 128, MMX_ZERO_HEIGHT = 128, MMX_ZERO_POSES = 152, +enum { MMX_ZERO_WIDTH = 128, MMX_ZERO_HEIGHT = 128, MMX_ZERO_POSES = 152, MMX_ZERO_CHARGE_POSES = 66, 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; + /* Reuses the formerly unused cooldown byte without changing save layout. */ + uint8_t combo, charge_phase, air, facing; uint16_t hit_slots; uint16_t anim_offset; uint8_t anim_timer, anim_pose, anim_flags, anim_valid; @@ -36,6 +37,10 @@ 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 uint16_t *MmxZeroBodyColors(const MmxZeroState *snapshot); +const uint8_t *MmxZeroChargePose(const MmxZeroState *snapshot); +bool MmxZeroHasChargeArt(void); +bool MmxZeroNativeChargeObject(unsigned object, unsigned kind); const uint8_t *MmxZeroMenuPose(void); /* Original X3 BGR555 badge pixel; -2 is transparent, -1 retains native art. */ int MmxZeroHudColor(unsigned x, unsigned y); diff --git a/src/mods/mmx_zero_plugin.c b/src/mods/mmx_zero_plugin.c index 64050fa..5b8d035 100644 --- a/src/mods/mmx_zero_plugin.c +++ b/src/mods/mmx_zero_plugin.c @@ -4,20 +4,72 @@ #include "snes/interp_bridge.h" #include "mmx_renderer.h" #include "mmx_zero.h" +#include "mmx_weapons.h" +#include "mmx_weapon_combat.h" #include "mmx_source_assets.h" #include "sdl_compat.h" #include extern uint8_t g_ram[0x20000]; +static void weapon_energy_hook(CpuState *cpu, uint32_t pc) { + if (!MmxWeaponsEnabled()) return; + pc &= 0x7fffff; + if (pc == 0x9ef9) { MmxWeaponsRefill(); return; } + if (pc == 0x01e169) { MmxWeaponsEnergyOverflow(g_ram,cpu->X); return; } + bool store = pc == 0xd91d || pc == 0xd95e || pc == 0xd9ab || pc == 0x01e0ce; + if (store) { + bool wide = pc == 0x01e0ce; + if (MmxWeaponsEnergyStore(cpu->A,wide)) { + /* Skip only this inventory STA. Preserve its accumulator and flags, + * and retain the generated tier's ordinary absolute,Y write timing. */ + extern uint8_t g_memsel; + cpu->cycles += wide ? 6 : 5; + cpu->master_cycles += (wide ? 6 : 5) * (g_memsel ? 6 : 8); + interp_bridge_pre_opcode_redirect((cpu->PB << 16) | ((pc + 3) & 65535)); + } + return; + } + bool index = pc == 0xd90f || pc == 0xd94d || pc == 0x01e058 || pc == 0x01e0a7; + bool y = pc == 0x01e058 || pc == 0x01e0a7, wide = pc == 0x01e0ac; + unsigned mask = wide ? 65535 : 255; + unsigned original = y ? cpu->Y : cpu->A & mask; + unsigned value = MmxWeaponsEnergyRead(index ? 0xbdb : wide ? 0x1f85 : 0x1f86, original); + if (y) cpu->Y = (uint16_t)value; + else cpu->A = (uint16_t)((cpu->A & ~mask) | value); + cpu->_flag_Z = !value; cpu->_flag_N = (value & (wide ? 32768 : 128)) != 0; + cpu->P = (cpu->P & ~0x82) | (cpu->_flag_Z ? 2 : 0) | (cpu->_flag_N ? 128 : 0); +} +static void weapon_menu_hook(CpuState *cpu, uint32_t pc) { + if (!MmxWeaponsEnabled()) return; + pc &= 0xffff; + if (pc == 0xc67f) { MmxWeaponsMenuTick(g_ram, cpu->D); return; } + /* Interpreted hooks run just after the original LDA. */ + unsigned source = pc == 0xc487 ? 0xc484 : pc == 0xc76c ? 0xc767 : + pc == 0xce2b ? 0xce28 : pc == 0xce35 ? 0xce33 : + pc == 0xc79a ? 0xc792 : pc == 0xc7bd ? 0xc7b5 : + pc == 0xc80d ? 0xc805 : pc == 0xc82c ? 0xc826 : + pc == 0xc86d ? 0xc865 : 0xc886; + unsigned value = MmxWeaponsMenuRead(g_ram, source, cpu->D, cpu->X, cpu->A & 255); + cpu->A = (cpu->A & 0xff00) | value; + cpu->_flag_Z = value == 0; cpu->_flag_N = (value & 128) != 0; + cpu->P = (cpu->P & ~0x82) | (cpu->_flag_Z ? 2 : 0) | (cpu->_flag_N ? 128 : 0); +} static void hook(CpuState *cpu, uint32_t pc) { - if (!MmxZeroEnabled()) return; + if (!MmxZeroEnabled() && !MmxWeaponsEnabled()) 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 0x01815c: + MmxWeaponsPlayerTick(g_ram); + if (!MmxWeaponsCombatActive()) MmxZeroPlayerTick(g_ram); + break; + case 0x019d47: MmxWeaponsMarkShot(g_ram, cpu->X); break; + case 0x0194af: MmxWeaponsSelectShot(g_ram, cpu->X); break; case 0x018165: MmxZeroPlayerEnd(g_ram); break; + case 0x0491dc: MmxWeaponsTerrainEnd(g_ram,cpu->D); break; + case 0x02823e: MmxWeaponsPlayerMotion(g_ram,cpu->D); break; case 0x048f07: MmxZeroAnimationStart(cpu->D, cpu->A & 255); break; case 0x048eea: MmxZeroAnimationAdvance(cpu->D); break; case 0x028403: case 0x03958f: case 0x039dcf: { @@ -41,8 +93,16 @@ static void hook(CpuState *cpu, uint32_t pc) { cpu->P = (cpu->P & ~0x82) | (cpu->_flag_Z ? 2 : 0) | (cpu->_flag_N ? 128 : 0); break; } + case 0x00d4f4: case 0x00d511: { + unsigned value=MmxWeaponsEnemyActive(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 0x00d3e7: { - unsigned value = MmxZeroWeaponTick(g_ram, cpu->D, cpu->A & 255); + unsigned value = MmxWeaponsProjectileTick(g_ram, cpu->D, + 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); @@ -50,7 +110,8 @@ static void hook(CpuState *cpu, uint32_t pc) { } case 0x049e76: { unsigned original = cpu_read8(cpu, cpu->DB, (uint16_t)(0xef37 + cpu->Y)); - unsigned damage = MmxZeroDamage(g_ram, cpu->D, cpu->X, original); + unsigned damage = MmxWeaponsDamage(g_ram, cpu->D, cpu->X, + 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 @@ -76,7 +137,8 @@ static void hook(CpuState *cpu, uint32_t pc) { break; } case 0x049c19: - cpu->Y = (uint16_t)MmxZeroHitbox(g_ram, cpu->D, cpu->X, cpu->Y); + cpu->Y = (uint16_t)MmxWeaponsHitbox(g_ram, cpu->D, cpu->X, + 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; @@ -84,34 +146,63 @@ static void hook(CpuState *cpu, uint32_t pc) { } void MmxZeroRegisterHooks(void) { const unsigned pcs[] = {0x009dca, 0x00d6a7, 0x00d76a, 0x01971f, 0x019796, 0x0198ff, - 0x01815c, 0x018165, 0x00d3e7, 0x049e76, 0x049c19, 0x048f07, 0x048eea, + 0x01815c, 0x018165, 0x019d47, 0x0194af, 0x00d3e7, 0x00d4f4, 0x00d511, 0x049e76, 0x049c19, 0x048f07, 0x048eea, 0x0491dc, 0x02823e, 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); + const unsigned menu_pcs[] = {0xc67f,0xc487,0xc76c,0xce2b,0xce35, + 0xc79a,0xc7bd,0xc80d,0xc82c,0xc86d,0xc88c}; + for (unsigned i = 0; i < sizeof(menu_pcs) / sizeof(menu_pcs[0]); ++i) + interp_bridge_set_pre_opcode_hook(menu_pcs[i], weapon_menu_hook); + const unsigned energy_pcs[] = {0x9ef9,0xd90f,0xd91b,0xd91d,0xd94d,0xd951,0xd95e,0xd9a7,0xd9ab, + 0x01e058,0x01e065,0x01e0a7,0x01e0ac,0x01e0ce,0x01e169}; + for (unsigned i=0;ifeature_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; - } + !provider->feature_resource_get(provider->ctx,package,feature,0,&resource) || !resource.path[0]) return 0; + snprintf(leaf,sizeof(leaf),"cache/mmx-source/x%u-%s.bin",game,zero?"zero-v7":"weapons-v5"); + if (!snesrecomp_exe_dir_path(leaf,path,4096)) return 0; + if (MmxSourceAssetsBuild(resource.path,game,zero,path,error,sizeof(error))) return 1; + fprintf(stderr,"[mmx-source] %s\n",error); + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR,"Cannot prepare Mega Man mod",error,NULL); + return 0; +} +static void activate(void) { + char path[4096]; + if (!prepare("megaman-x.character.zero","zero",3,1,path)) 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(); } +static void activate_weapons(unsigned game) { + char path[4096]; + const char *package=game==2?"megaman-x.weapons.x2":"megaman-x.weapons.x3"; + if (!prepare(package,"weapons",game,0,path)) return; + if (!MmxWeaponsLoadPage(path,game-1)) { + SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR,"Cannot prepare Mega Man mod","Cannot load the generated weapon data.",NULL); + return; + } + MmxZeroRegisterHooks(); + fprintf(stderr,"[mmx-weapons] X%u weapons loaded from the selected source ROM\n",game); +} +static void activate_x2(void) { activate_weapons(2); } +static void activate_x3(void) { activate_weapons(3); } +static void reset(void) { + if (MmxWeaponsEnabled()) g_ram[0x1f12] = 0; + MmxWeaponsCancelShots(g_ram); MmxZeroCancel(g_ram); MmxZeroDisable(); MmxWeaponsDisable(); +} SNES_MOD_CONSTRUCTOR(mmx_register_zero_plugin) { (void)snes_mod_register_reset_callback(reset); (void)snes_mod_register_activation_plugin("megaman-x.zero", activate); + (void)snes_mod_register_activation_plugin("megaman-x.weapons.x2",activate_x2); + (void)snes_mod_register_activation_plugin("megaman-x.weapons.x3",activate_x3); } diff --git a/tests/check_source_extraction.py b/tests/check_source_extraction.py index 97d1c4f..d9caf38 100644 --- a/tests/check_source_extraction.py +++ b/tests/check_source_extraction.py @@ -1,27 +1,34 @@ -"""Optional Zero native/reference parity check; provide extractor EXE and X3 ROM.""" +"""Optional native/reference parity check; supply executable and your X2/X3 ROMs.""" from pathlib import Path +import struct import subprocess import sys import tempfile sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools')) import extract_zero +import extract_x_weapons -exe, rom = (str(Path(p).resolve()) for p in sys.argv[1:]) -with tempfile.TemporaryDirectory(prefix='mmx-zero-source-') as temporary: +exe, x2, x3 = (str(Path(p).resolve()) for p in sys.argv[1:]) +with tempfile.TemporaryDirectory(prefix='mmx-source-check-') 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() + def run(rom, game, zero, name): + path = root / name + subprocess.run([exe, str(rom), str(game), str(zero), str(path)], check=True) + return path.read_bytes() + + reference_zero = extract_zero.extract(x3)[0] + assert run(x3, 3, 1, 'zero.bin') == reference_zero + a, b = run(x2, 2, 0, 'x2.bin'), run(x3, 3, 0, 'x3.bin') + combined = a[:8] + struct.pack('=5;--i) { + frame(SNES_PAD_UP); zero_replay(3); + check(g_ram[0x1ed2]==i,"right-column extended weapon selectable while X1 is locked"); + } + char suffix[40]; snprintf(suffix,sizeof(suffix),".x%u-menu.cap",page+1); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),suffix); + frame(SNES_PAD_START); zero_replay(75); + MmxWeaponsState s=MmxWeaponsGetState(); + check(s.page==page && s.weapon==5 && g_ram[0xbdb]==0,"pause confirms extended selection with safe native buster resources"); + check(!memcmp(inventory,g_ram+0x1f88,16),"extended selection preserves native X1 progression and energy"); + if (page==1) { + frame(SNES_PAD_START); zero_replay(75); + check(MmxWeaponsGetState().menu_page==1 && g_ram[0x1ed2]==5,"pause reopens at extended selection"); + frame(SNES_PAD_LEFT); zero_replay(3); check(!g_ram[0x1ed2],"native cursor returns to buster"); + } + } + MmxWeaponsState s=MmxWeaponsGetState(); s.energy[0]=7; s.energy[15]=13; + s.fraction[0]=128;s.fraction[15]=64;MmxWeaponsSetState(s); + size_t n=RtlSaveSnapshotToMemory(start,cap); + frame(SNES_PAD_START); zero_replay(75); frame(SNES_PAD_R); zero_replay(3); + check(!MmxWeaponsGetState().menu_page,"R wraps to original X1 page"); + check(!g_ram[0x1ed2],"return to locked X1 page selects available buster"); + size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"weapon selection and partial energy restore"); + s=MmxWeaponsGetState(); check(s.page==2 && s.weapon==5 && s.energy[0]==7 && s.energy[15]==13 && + s.fraction[0]==128 && s.fraction[15]==64,"independent fractional weapon state survives save/load"); + frame(SNES_PAD_START); zero_replay(75); frame(SNES_PAD_R); zero_replay(3); + size_t an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"weapon-menu deterministic replay"); + frame(SNES_PAD_START); zero_replay(75); + check(!MmxWeaponsActive() && !g_ram[0xbdb],"X1 buster selection exits extended weapon mode"); + check(MmxWeaponsGetState().fraction[0]==128 && MmxWeaponsGetState().fraction[15]==64,"menu selection preserves reserve energy fractions"); + check(!memcmp(inventory,g_ram+0x1f88,16),"page cycle and save/load leave original inventory intact"); + zero_health_swap(); check(MmxZeroGetState().active_x,"weapon-page test switches to X"); + frame(SNES_PAD_START); zero_replay(75); frame(SNES_PAD_L); zero_replay(3); + check(MmxWeaponsGetState().menu_page==2,"L wraps backward from X1 to X3 for X"); + frame(SNES_PAD_DOWN); zero_replay(3); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".x-menu.cap"); + frame(SNES_PAD_START); zero_replay(75); + check(MmxZeroGetState().active_x && MmxWeaponsGetState().page==2 && + MmxWeaponsGetState().weapon==1 && !g_ram[0xbdb],"X selects an extended weapon through native pause"); + puts("MMX X2/X3 WEAPON MENU CHECKS PASSED"); +} +static void weapon_cycle_buster_check(void) { + frame(SNES_PAD_Y);frame(0); + bool fired=false; + for(unsigned d=0x1228;d<0x1428;d+=64) if(g_ram[d] && g_ram[d+10]==0) fired=true; + check(fired,"buster fires after shoulder cycling back from an imported weapon"); + zero_replay(90); + check(!g_ram[0xbdd],"cycled buster shot retires without blocking subsequent weapon switches"); +} +static void weapon_cycle_checks(const char *assets,const char *fixture,uint8 *start,size_t cap) { + check(MmxWeaponsLoad(assets),"load weapons for shoulder cycling"); + for (unsigned character=0;character<2;++character) for (unsigned page=1;page<=2;++page) { + check(RtlLoadSnapshot(fixture),"restore shoulder cycling fixture"); + zero_replay(10); + if (character) zero_health_swap(); + uint8 inventory[16]; memcpy(inventory,g_ram+0x1f88,16); + frame(SNES_PAD_START); zero_replay(75); + for(unsigned i=0;i>8); +} +static void weapon_energy_checks(const char *assets, const char *fixture, uint8 *start, + uint8 *expected, uint8 *actual, size_t cap) { + check(MmxWeaponsLoad(assets) && RtlLoadSnapshot(fixture),"load energy pickup fixture"); + uint8 inventory[16]; memcpy(inventory,g_ram+0x1f88,16); + MmxWeaponsState w=MmxWeaponsGetState(); w.page=2;w.weapon=4;w.energy[11]=10;MmxWeaponsSetState(w); + g_ram[0x1f12]=0; zero_replay(6); + check(g_ram[0x71c]==24 && g_ram[0x71d]==80,"native weapon energy HUD becomes visible for extended selection"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".energy-hud.cap"); + weapon_energy_pickup(1); zero_replay(55); + check(!g_ram[0x1628] && MmxWeaponsGetState().energy[11]==12,"small native pickup refills two selected extended energy"); + weapon_energy_pickup(0); zero_replay(8); + size_t n=RtlSaveSnapshotToMemory(start,cap); + zero_replay(45);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(!g_ram[0x1628] && MmxWeaponsGetState().energy[11]==20,"large native pickup refills eight selected extended energy"); + check(RtlLoadSnapshotFromMemory(start,n),"restore animated energy refill"); + zero_replay(45);size_t an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"native energy refill resumes deterministically"); + check(!memcmp(inventory,g_ram+0x1f88,16),"HUD and extended pickups preserve X1 inventory"); + w=MmxWeaponsGetState();w.energy[11]=27;w.energy[0]=3;MmxWeaponsSetState(w); + weapon_energy_pickup(0);zero_replay(55); + check(MmxWeaponsGetState().energy[11]==28 && MmxWeaponsGetState().energy[0]==10,"overflow fills other extended weapons after native inventory scan"); + w=MmxWeaponsGetState();w.page=w.weapon=0;MmxWeaponsSetState(w);g_ram[0x1f12]=0; + weapon_energy_pickup(1);zero_replay(55); + check(MmxWeaponsGetState().energy[0]==12,"buster-mode pickup can auto-refill extended inventory"); + check(!memcmp(inventory,g_ram+0x1f88,16),"extended auto-refill does not unlock X1 weapons"); + g_ram[0x1f88]=0x45; /* Native owned weapon with five energy for comparison. */ + unsigned lives=g_ram[0x1f80];g_ram[0xbcf]=128;g_ram[0xbaa]=12;g_ram[0xbab]=0; + bool respawned=false; + for(unsigned i=0;i<1000;++i) { + frame(0); + if(g_ram[0x1f80]+1==lives && (g_ram[0xbcf]&127)==16 && g_ram[0xba9]==2) {respawned=true;break;} + } + check(respawned,"native respawn completes during energy check"); + check((g_ram[0x1f88]&63)==5 && MmxWeaponsGetState().energy[0]==12,"checkpoint death preserves energy like native X1 weapons"); + check(RtlLoadSnapshot(fixture),"restore fractional pickup fixture"); + w=MmxWeaponsGetState();w.page=2;w.weapon=5;w.energy[12]=20;w.fraction[12]=128;MmxWeaponsSetState(w); + weapon_energy_pickup(1);zero_replay(55); + check(MmxWeaponsEnergyAmount(2,5)==22*256+128,"native small refill preserves half-unit energy"); + w=MmxWeaponsGetState();w.energy[12]=27;w.energy[0]=10;w.fraction[0]=64;MmxWeaponsSetState(w); + weapon_energy_pickup(1);zero_replay(8);n=RtlSaveSnapshotToMemory(start,cap); + zero_replay(50);en=RtlSaveSnapshotToMemory(expected,cap); + /* X1 $81:E0AF..C1 adds/clamps one unit, then forwards only the remaining + * whole pickup ticks. Preserve that behavior, including the last half-unit + * discarded by native clamping, and retain the reserve's existing fraction. */ + check(MmxWeaponsEnergyAmount(2,5)==28*256 && MmxWeaponsEnergyAmount(1,1)==11*256+64, + "fractional pickup caps like native X1 and preserves reserve fractions"); + check(RtlLoadSnapshotFromMemory(start,n),"restore fractional native refill"); + zero_replay(50);an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"fractional refill and overflow replay exactly"); + n=RtlSaveSnapshotToMemory(start,cap);size_t chunk=0; + for(size_t i=n-8;i>8;--i) {uint32_t magic;memcpy(&magic,start+i,4);if(magic==0x4d4d5854u) {chunk=i;break;}} + check(chunk!=0,"fractional inventory game chunk located"); + size_t end_inventory=n-sizeof(MmxWeaponCombatState); + memmove(start+end_inventory-16,start+end_inventory,MMX_WEAPON_COMBAT_LEGACY_SIZE); + uint32_t legacy=10;memcpy(start+chunk+4,&legacy,4); + check(RtlLoadSnapshotFromMemory(start,n-16-(sizeof(MmxWeaponCombatState)-MMX_WEAPON_COMBAT_LEGACY_SIZE)), + "legacy v10 whole-unit weapon save loads"); + check(MmxWeaponsEnergyAmount(1,1)==11*256 && MmxWeaponsEnergyAmount(2,5)==28*256, + "legacy weapon save retains whole units and initializes zero fractions"); + puts("MMX EXTENDED ENERGY CHECKS PASSED"); +} +static unsigned extended_shots(bool charged) { + MmxWeaponCombatState s=MmxWeaponsGetCombatState(); unsigned count=0; + for (unsigned i=0;i<8;++i) count += s.shots[i].active && s.shots[i].charged==charged; + return count; +} +static unsigned acid_variants(unsigned variant, bool charged) { + MmxWeaponCombatState c=MmxWeaponsGetCombatState(); unsigned n=0; + for(unsigned i=0;i<8;++i) n+=c.shots[i].active && c.shots[i].weapon==1 && + c.shots[i].variant==variant && c.shots[i].charged==charged; + return n; +} +static void weapon_source_pack_checks(const char *fixture,uint8 *start,uint8 *expected,uint8 *actual,size_t cap) { + const char *x2=getenv("MMX_SOURCE_X2_CACHE"),*x3=getenv("MMX_SOURCE_X3_CACHE"); + check(x2 && x3,"native source cache paths supplied"); + MmxZeroDisable();MmxWeaponsDisable(); + for(unsigned page=1;page<=2;++page) { + check(MmxWeaponsLoadPage(page==1?x2:x3,page),"independent source weapon pack loads"); + check(RtlLoadSnapshot(fixture),"restore source pack fixture"); + check(!MmxZeroEnabled() && !MmxZeroActive(),"source weapon pack works with Zero disabled"); + int width,height;MmxPrepareFrame(1280,720,&width,&height); + check(width==256 && g_mmx_custom_renderer,"weapon pack alone enables native-width compositor"); + check(MmxWeaponsPageEnabled(page) && !MmxWeaponsPageEnabled(3-page),"only selected source pack is available"); + frame(SNES_PAD_START);zero_replay(75);frame(SNES_PAD_R);zero_replay(3); + check(MmxWeaponsGetState().menu_page==page,"pause skips source pack that is not enabled"); + frame(SNES_PAD_R);zero_replay(3);check(!MmxWeaponsGetState().menu_page,"pause wraps to X1 with one source pack"); + frame(SNES_PAD_L);zero_replay(3);check(MmxWeaponsGetState().menu_page==page,"backward pause selects enabled source pack"); + frame(SNES_PAD_DOWN);zero_replay(3); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),page==1?".x2-only-menu.cap":".x3-only-menu.cap"); + frame(SNES_PAD_START);zero_replay(75); + MmxWeaponsState w=MmxWeaponsGetState();w.page=page;w.weapon=page==1?5:1;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);zero_replay(35);check(extended_shots(false)>0,"X fires ported weapon without Zero mod"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(10);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"source-pack-only save loads"); + zero_replay(10);size_t an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"source-pack-only projectiles replay exactly"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),page==1?".x2-only-shot.cap":".x3-only-shot.cap"); + MmxWeaponsCancelShots(g_ram);MmxWeaponsDisable(); + } + check(MmxWeaponsLoadPage(x2,1) && MmxWeaponsLoadPage(x3,2),"both independently extracted packs load together"); + check(MmxWeaponsPageEnabled(1) && MmxWeaponsPageEnabled(2) && MmxWeaponsPose(1,5,135,11),"second pack preserves first pack art"); + puts("MMX SOURCE PACK CHECKS PASSED"); +} +static unsigned frost_slot(bool charged) { + MmxWeaponCombatState c=MmxWeaponsGetCombatState(); + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].weapon==7 && c.shots[i].charged==charged) return i; + return 8; +} +static unsigned triad_count(bool charged,unsigned first,unsigned last) { + MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned n=0; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].page==2 && c.shots[i].weapon==3 && + c.shots[i].charged==charged && c.shots[i].variant>=first && c.shots[i].variant<=last) ++n; + return n; +} +static void weapon_triad_checks(const char *assets,const char *fixture,uint8 *start, + uint8 *expected,uint8 *actual,size_t cap) { + check(MmxWeaponsLoad(assets),"Triad Thunder source assets load"); + check(MmxWeaponsPose(2,3,51,34)->width>0,"original base-X punch art is extracted"); + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore Triad fixture");if(character) zero_health_swap(); + MmxWeaponsState w=MmxWeaponsGetState();w.page=2;w.weapon=3;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);frame(0); + check(triad_count(false,0,2)==3 && MmxWeaponsEnergyAmount(2,3)==27*256, + "three Triad orbs launch for one energy"); + zero_replay(45); + check(triad_count(false,0,2)==3 && triad_count(false,3,5)==3,"three original connecting arcs appear after orb formation"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-triad.cap":".zero-triad.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(40);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore Triad triangle");zero_replay(40); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"Triad links and beam emission replay exactly"); + check(triad_count(false,6,8)==3,"formation releases three original directional bolts"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-triad-rays.cap":".zero-triad-rays.cap"); + zero_replay(160);check(!extended_shots(false) && !g_ram[0xbdd],"Triad debris and bolts retire without slot leaks"); + frame(SNES_PAD_Y);frame(0);zero_replay(12); + for(unsigned press=0;press<4;++press) {frame(SNES_PAD_Y);frame(0);} + zero_replay(68);MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned inverted=0; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].variant<3 && (c.shots[i].facing&128)) ++inverted; + check(inverted==3 && MmxWeaponsEnergyAmount(2,3)==25*256,"four fire edges invert the existing triangle for one more energy"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-triad-inverted.cap":".zero-triad-inverted.cap"); + MmxWeaponsCancelShots(g_ram);zero_replay(2); + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y);frame(0); + check(!extended_shots(true),"charged Triad requires X1 arm upgrade"); + check(RtlLoadSnapshot(fixture),"restore charged Triad fixture");if(character) zero_health_swap(); + w=MmxWeaponsGetState();w.page=2;w.weapon=3;MmxWeaponsSetState(w);g_ram[0x1f99]|=2; + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y);frame(0);zero_replay(2); + check(triad_count(true,0,2)==3 && MmxWeaponsEnergyAmount(2,3)==24*256, + "charged release creates punch and two delayed waves for three energy"); + int x=g_ram[0xbad]|g_ram[0xbae]<<8; + for(unsigned i=0;i<35;++i) frame(SNES_PAD_RIGHT|SNES_PAD_B|SNES_PAD_Y); + c=MmxWeaponsGetCombatState();unsigned body=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].charged && !c.shots[i].variant) body=i; + check(body<8 && c.shots[body].muzzle_pose==2 && c.shots[body].pose==34 && + (g_ram[0xbad]|g_ram[0xbae]<<8)==x && g_ram[0x1e83]==3 && !(g_ram[0xc2f]&64), + "source punch pose locks movement, begins native quake, and stops charge audio"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-triad-punch.cap":".zero-triad-punch.cap"); + n=RtlSaveSnapshotToMemory(start,cap);zero_replay(58);en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore charged Triad quake");zero_replay(58); + an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"native quake, body poses and wave release replay exactly"); + c=MmxWeaponsGetCombatState();unsigned moving=0; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].charged && c.shots[i].variant && c.shots[i].muzzle_pose) ++moving; + check(moving>0 && triad_count(true,0,0)==1,"waves launch after the sixty-tick quake while body recovery continues"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-triad-waves.cap":".zero-triad-waves.cap"); + zero_replay(180);check(!extended_shots(true) && !g_ram[0xbdd],"charged attack restores input and releases all slots"); + frame(SNES_PAD_RIGHT);frame(SNES_PAD_RIGHT); + check((g_ram[0xbad]|g_ram[0xbae]<<8)>x,"player can move again after original recovery"); + frame(0);for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y); + for(unsigned i=0;i<10;++i) frame(SNES_PAD_Y|SNES_PAD_B);frame(0);frame(0); + c=MmxWeaponsGetCombatState();body=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].charged && !c.shots[i].variant) body=i; + check(body<8 && !c.shots[body].muzzle_pose,"air release waits for solid ground before punching"); + zero_replay(60);c=MmxWeaponsGetCombatState(); + check(c.shots[body].active && c.shots[body].muzzle_pose>=1,"air release lands and starts the committed punch"); + g_ram[0xbaa]=14;frame(0); + check(!triad_count(true,0,2),"hurt cancels the punch and unreleased ground waves"); + } + check(RtlLoadSnapshot(fixture),"restore Triad enemy encounter");unsigned victim=0; + for(unsigned i=0;i<400 && !victim;++i) { + frame(SNES_PAD_RIGHT); + for(unsigned d=0xe68;d<0x1228;d+=64) + if(g_ram[d] && (g_ram[d+0x27]&127) && (g_ram[d+0x20]|g_ram[d+0x21])) {victim=d;break;} + } + check(victim!=0,"native enemy available for Triad damage"); + MmxWeaponsState w=MmxWeaponsGetState();w.page=2;w.weapon=3;MmxWeaponsSetState(w); + frame(0);frame(SNES_PAD_Y);frame(0);MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && !c.shots[i].variant) { + c.shots[i].x=(g_ram[victim+5]|g_ram[victim+6]<<8)*256; + c.shots[i].y=(g_ram[victim+8]|g_ram[victim+9]<<8)*256; + c.shots[i].muzzle_pose=1;c.shots[i].radius=30;c.shots[i].vx=c.shots[i].vy=0;slot=i;break; + } + check(slot<8,"Triad orb is available for native collision");g_ram[victim+0x27]=32;MmxWeaponsSetCombatState(c); + zero_replay(1); + check((g_ram[victim+0x27]&127)==27,"native orb collision preserves five-buster damage ratio"); + puts("MMX TRIAD THUNDER CHECKS PASSED"); +} +static void weapon_fang_checks(const char *assets,const char *fixture,uint8 *start, + uint8 *expected,uint8 *actual,size_t cap) { + check(MmxWeaponsLoad(assets),"Tornado Fang original assets load"); + check(MmxWeaponsPose(2,8,19,65)!=NULL,"charged Fang includes all four original CHR phases"); + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore Fang fixture");if(character) zero_health_swap(); + MmxWeaponsState w=MmxWeaponsGetState();w.page=2;w.weapon=8;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);frame(0);MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) {slot=i;break;} + check(slot<8 && c.shots[slot].vx==512 && MmxWeaponsEnergyAmount(2,8)==27*256, + "normal drill starts at two pixels per tick and costs one energy"); + zero_replay(20);c=MmxWeaponsGetCombatState(); + check(c.shots[slot].active && c.shots[slot].muzzle_pose==1 && !c.shots[slot].vx, + "normal drill pauses after eight-tick launch route"); + frame(SNES_PAD_Y);frame(0);zero_replay(15);c=MmxWeaponsGetCombatState(); + unsigned top=8,bottom=8;for(unsigned i=0;i<8;++i) if(c.shots[i].active) { + if(c.shots[i].variant==1) top=i;if(c.shots[i].variant==2) bottom=i; + } + check(top<8 && bottom<8 && c.shots[bottom].y-c.shots[top].y==36*256 && + MmxWeaponsEnergyAmount(2,8)==26*256,"second press launches two separated drills for one energy"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(40);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore staged drill salvo");zero_replay(40); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"drill staging and acceleration replay exactly"); + c=MmxWeaponsGetCombatState();check(c.shots[slot].muzzle_pose==2 && c.shots[slot].vx>0, + "drill accelerates after its original sixty-tick delay"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-fang.cap":".zero-fang.cap"); + zero_replay(180);check(!extended_shots(false) && !g_ram[0xbdd],"normal drills leave the screen and release slots"); + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y);frame(0); + check(!extended_shots(true),"Fang charge requires X1 arms"); + check(RtlLoadSnapshot(fixture),"restore charged Fang fixture");if(character) zero_health_swap(); + w=MmxWeaponsGetState();w.page=2;w.weapon=8;MmxWeaponsSetState(w);g_ram[0x1f99]|=2; + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y); + c=MmxWeaponsGetCombatState();slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].charged) slot=i; + check(slot<8 && c.shots[slot].pose>=42 && c.shots[slot].pose<66 && + MmxWeaponsEnergyAmount(2,8)==24*256,"full held charge automatically deploys the rotating drill for three energy"); + check(g_ram[0xbf8] && !(g_ram[0xc2f]&64),"held drill retains firing pose without lingering charge audio"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-fang-held.cap":".zero-fang-held.cap"); + n=RtlSaveSnapshotToMemory(start,cap);for(unsigned i=0;i<20;++i) frame(SNES_PAD_Y|SNES_PAD_RIGHT); + en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore held Fang");for(unsigned i=0;i<20;++i) frame(SNES_PAD_Y|SNES_PAD_RIGHT); + an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"held drill follows moving player and replays exactly"); + for(unsigned i=0;i<44;++i) frame(SNES_PAD_Y); + check(MmxWeaponsEnergyAmount(2,8)==21*256,"sustained drill costs three energy each sixty-four ticks"); + frame(0);zero_replay(2);check(!extended_shots(true) && !(g_ram[0xc2f]&64),"release removes held drill and its charge sound"); + } + check(RtlLoadSnapshot(fixture),"restore Fang enemy encounter");unsigned victim=0; + for(unsigned i=0;i<400 && !victim;++i) { + frame(SNES_PAD_RIGHT); + for(unsigned d=0xe68;d<0x1228;d+=64) + if(g_ram[d] && (g_ram[d+0x27]&127) && (g_ram[d+0x20]|g_ram[d+0x21])) {victim=d;break;} + } + check(victim!=0,"native enemy available for drill contact"); + unsigned fx=(g_ram[0xbad]|g_ram[0xbae]<<8)+64,fy=(g_ram[0xbb0]|g_ram[0xbb1]<<8)-32; + g_ram[victim+5]=(uint8_t)fx;g_ram[victim+6]=(uint8_t)(fx>>8); + g_ram[victim+8]=(uint8_t)fy;g_ram[victim+9]=(uint8_t)(fy>>8); + MmxWeaponsState w=MmxWeaponsGetState();w.page=2;w.weapon=8;MmxWeaponsSetState(w); + frame(0);frame(SNES_PAD_Y);frame(0);MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) { + c.shots[i].x=(g_ram[victim+5]|g_ram[victim+6]<<8)*256; + c.shots[i].y=(g_ram[victim+8]|g_ram[victim+9]<<8)*256; + c.shots[i].muzzle_pose=1;c.shots[i].vx=c.shots[i].vy=0;slot=i;break; + } + check(slot<8,"drill is available for native contact");g_ram[victim+0x27]=32;MmxWeaponsSetCombatState(c); + zero_replay(3);c=MmxWeaponsGetCombatState(); + check((g_ram[victim+0x27]&127)==31 && c.shots[slot].muzzle_pose==3 && c.shots[slot].tether_pose==6, + "real enemy contact applies scaled damage and original impact pause"); + bool debris=false;for(unsigned i=0;i<16;++i) debris|=c.effects[i].active && c.effects[i].group==20; + check(debris,"native drill contact emits original sixteen-tick impact debris"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".fang-debris.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(8);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore drill impact debris");zero_replay(8); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"drill contact debris replays exactly"); + unsigned d=0x1228+slot*64; + for(unsigned contact=0;contact<6;++contact) { + c=MmxWeaponsGetCombatState();c.shots[slot].muzzle_pose=2;c.shots[slot].radius=0; + MmxWeaponsSetCombatState(c);g_ram[d+1]=6;MmxWeaponsProjectileTick(g_ram,d,1); + } + check(!MmxWeaponsGetCombatState().shots[slot].active,"seventh damage contact consumes normal drill durability"); + puts("MMX TORNADO FANG CHECKS PASSED"); +} +static void weapon_speed_checks(const char *assets,const char *fixture,uint8 *start, + uint8 *expected,uint8 *actual,size_t cap) { + check(MmxWeaponsLoad(assets),"Speed Burner original assets load"); + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore Speed Burner fixture");if(character) zero_health_swap(); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=8;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);frame(0);MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) {slot=i;break;} + check(slot<8 && c.shots[slot].vx==1280 && MmxWeaponsEnergyAmount(1,8)==27*256, + "normal flame uses original five-pixel speed and one-energy cost"); + zero_replay(15);c=MmxWeaponsGetCombatState();unsigned fire=0; + for(unsigned i=0;i<8;++i) fire+=c.shots[i].active && c.shots[i].variant==1; + check(fire>0 && c.shots[slot].muzzle_pose==1,"fireball grows and creates ground flames"); + unsigned sparks=0;for(unsigned i=0;i<16;++i) sparks+=c.effects[i].active; + check(sparks>0,"original flame sparkles use separate visual slots"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-speed.cap":".zero-speed.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(20);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore growing flame trail");zero_replay(20); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"fireball and ground flames replay exactly"); + zero_replay(180);check(!extended_shots(false) && !g_ram[0xbdd],"normal fire and ground flames expire cleanly"); + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y);frame(0);zero_replay(5); + check(!extended_shots(true),"charged Speed Burner requires X1 arms"); + check(RtlLoadSnapshot(fixture),"restore charged dash fixture");if(character) zero_health_swap(); + w=MmxWeaponsGetState();w.page=1;w.weapon=8;MmxWeaponsSetState(w);g_ram[0x1f99]|=2; + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y);frame(0);zero_replay(5); + c=MmxWeaponsGetCombatState();slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].charged) {slot=i;break;} + check(slot<8 && c.shots[slot].group==38 && !c.shots[slot].variant && + (int16_t)(g_ram[0xbc2]|g_ram[0xbc3]<<8)==1141 && MmxWeaponsEnergyAmount(1,8)==24*256, + "charged ground dash uses original speed, effect, and three-energy cost"); + check(g_ram[0xbaa]==0x14 && g_ram[0xbd8]==128 && !(g_ram[0xc2f]&64),"charged dash owns native dash pose and damage guard without charge sound"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-speed-dash.cap":".zero-speed-dash.cap"); + n=RtlSaveSnapshotToMemory(start,cap);zero_replay(12);en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore charged dash");zero_replay(12); + an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"charged body dash replays exactly"); + zero_replay(50);check(!extended_shots(true) && !g_ram[0xbd8] && g_ram[0xbaa]!=0x14,"ground dash ends and clears its damage guard"); + check(RtlLoadSnapshot(fixture),"restore airborne dash fixture");if(character) zero_health_swap(); + w=MmxWeaponsGetState();w.page=1;w.weapon=8;MmxWeaponsSetState(w);g_ram[0x1f99]|=2; + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y); + for(unsigned i=0;i<10;++i) frame(SNES_PAD_Y|SNES_PAD_B); + frame(0);zero_replay(3); + c=MmxWeaponsGetCombatState();slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].charged) {slot=i;break;} + check(slot<8 && c.shots[slot].variant==1,"air release starts source short dash"); + unsigned y=g_ram[0xbb0]|g_ram[0xbb1]<<8;zero_replay(8); + check((g_ram[0xbb0]|g_ram[0xbb1]<<8)==y,"air dash holds height while moving horizontally"); + zero_replay(25);check(!extended_shots(true) && !g_ram[0xbd8],"air dash expires and gravity resumes"); + } + check(RtlLoadSnapshot(fixture),"restore private water-fire fixture"); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=8;MmxWeaponsSetState(w);g_ram[0x1f99]|=2; + unsigned px=g_ram[0xbad]|g_ram[0xbae]<<8,py=g_ram[0xbb0]|g_ram[0xbb1]<<8; + size_t offsets[256];uint8_t properties[256];unsigned changed=0; + for(int y=(int)py-160;y<=(int)py+16;y+=16) for(int x=(int)px-64;x<=(int)px+300;x+=16) { + if(x<0 || y<0) continue; + unsigned screen=g_ram[0xe800+(y>>8)*32+(x>>8)]; + unsigned cell=0x2000+((screen*512+((y&240)<<1)+((x&240)>>3))&65535); + unsigned address=(g_ram[0xb92]|g_ram[0xb93]<<8|g_ram[0xb94]<<16)+(g_ram[cell]|g_ram[cell+1]<<8); + size_t offset=((address>>16)&127)*32768+(address&32767); + if((g_snes->cart->rom[offset]&63)!=0) continue; + check(changed<256,"private water properties fit fixture storage"); + offsets[changed]=offset;properties[changed++]=g_snes->cart->rom[offset];g_snes->cart->rom[offset]=13; + } + frame(SNES_PAD_Y);zero_replay(5);MmxWeaponCombatState c=MmxWeaponsGetCombatState(); + unsigned parent=8,children=0; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) { if(c.shots[i].variant==2) parent=i;children+=c.shots[i].variant==3; } + check(parent<8 && children==2 && c.shots[parent].pose==6,"water replaces normal fire with original three-bubble formation"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".speed-water.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(12);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore water-bubble orbit");zero_replay(12); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"source water orbits replay exactly"); + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y);frame(0);zero_replay(5); + c=MmxWeaponsGetCombatState();unsigned dash=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].charged) dash=i; + check(dash<8 && c.shots[dash].tether_pose && !g_ram[0xbd8] && + !(g_ram[0x1228+dash*64+0x20]|g_ram[0x1228+dash*64+0x21]),"water dash retains movement but loses flames, attack box and damage guard"); + bool bubbles=false;for(unsigned i=0;i<16;++i) bubbles|=c.effects[i].active && c.effects[i].variant==5 && + c.effects[i].group==23 && c.effects[i].vy<0; + check(bubbles,"underwater charged dash emits original accelerating bubble particles"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".speed-dash-bubbles.cap"); + n=RtlSaveSnapshotToMemory(start,cap);zero_replay(10);en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore underwater dash bubbles");zero_replay(10); + an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"underwater dash bubbles replay exactly"); + for(unsigned i=0;icart->rom[offsets[i]]=properties[i]; + MmxWeaponsCancelShots(g_ram); + puts("MMX SPEED BURNER CHECKS PASSED"); +} +#include "mmx_weapon_silk_test.inc" +#include "mmx_weapon_chain_test.inc" +static void weapon_magnet_checks(const char *assets,const char *fixture,uint8 *start, + uint8 *expected,uint8 *actual,size_t cap) { + check(MmxWeaponsLoad(assets),"Magnet Mine and original explosion assets load"); + check(MmxWeaponsPose(1,7,8,8) && memcmp(MmxWeaponsGroupPalette(1,7,8),MmxWeaponsPalette(1,7,false),32), + "mine blast has its original separate effect palette"); + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore Magnet Mine fixture");if(character) zero_health_swap(); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=7;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);frame(0);MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) {slot=i;break;} + check(slot<8 && c.shots[slot].vx==512 && MmxWeaponsEnergyAmount(1,7)==27*256, + "normal mine uses source speed and one-energy cost"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-magnet-normal.cap":".zero-magnet-normal.cap"); + for(unsigned i=0;i<8;++i) frame(SNES_PAD_UP); + c=MmxWeaponsGetCombatState();check(c.shots[slot].vy==-1024,"up steers mine to source vertical speed cap"); + frame(0);check(MmxWeaponsGetCombatState().shots[slot].vy==-1024,"mine retains vertical momentum after steering release"); + frame(SNES_PAD_Y);frame(0);check(extended_shots(false)==1,"one flying mine at a time matches source limit"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(12);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore steering mine");zero_replay(12); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"steered mine replay is exact"); + check(RtlLoadSnapshot(fixture),"restore planted-mine fixture");if(character) zero_health_swap(); + w=MmxWeaponsGetState();w.page=1;w.weapon=7;MmxWeaponsSetState(w);frame(SNES_PAD_Y);frame(0); + c=MmxWeaponsGetCombatState();for(unsigned i=0;i<8;++i) if(c.shots[i].active) {slot=i;break;} + unsigned phases=0,linked=8;bool blast=false,chain=false; + for(unsigned i=0;i<165;++i) { + frame(i<20 ? SNES_PAD_DOWN : 0);c=MmxWeaponsGetCombatState(); + if(linked<8 && !c.shots[slot].active && c.shots[linked].active && c.shots[linked].muzzle_pose==3) chain=true; + if(c.shots[slot].active) { + phases|=1u<>8),y=(unsigned)(c.shots[slot].y>>8); + memset(g_ram+bullet,0,64);g_ram[bullet]=1;g_ram[bullet+0x28]=1; + g_ram[bullet+5]=(uint8_t)(x+50);g_ram[bullet+6]=(uint8_t)((x+50)>>8); + g_ram[bullet+8]=(uint8_t)(y+40);g_ram[bullet+9]=(uint8_t)((y+40)>>8); + MmxWeaponsProjectileTick(g_ram,d,1); + check((unsigned)(g_ram[bullet+5]|g_ram[bullet+6]<<8)==x+45 && + (unsigned)(g_ram[bullet+8]|g_ram[bullet+9]<<8)==y+37,"charged mine pulls enemy shots by source 5/3-pixel steps"); + g_ram[bullet+0x28]=0;MmxWeaponsProjectileTick(g_ram,d,1); + check((unsigned)(g_ram[bullet+5]|g_ram[bullet+6]<<8)==x+45,"immune enemy shots are not made magnetic"); + for(unsigned absorbed=0;absorbed<32;++absorbed) { + memset(g_ram+bullet,0,64);g_ram[bullet]=1;g_ram[bullet+0x28]=1; + memcpy(g_ram+bullet+5,g_ram+d+5,2);memcpy(g_ram+bullet+8,g_ram+d+8,2); + memcpy(g_ram+bullet+0x20,g_ram+d+0x20,2); + MmxWeaponsProjectileTick(g_ram,d,1);if(g_ram[bullet]) MmxWeaponsProjectileTick(g_ram,d,1); + check(!g_ram[bullet],"charged mine absorbs contacting enemy shot"); + if(absorbed==15) check(MmxWeaponsGetCombatState().shots[slot].variant==1,"sixteen absorbed shots grow the middle mine"); + } + c=MmxWeaponsGetCombatState();check(c.shots[slot].variant==2 && c.shots[slot].radius==32, + "thirty-two absorbed shots grow original largest mine and hitbox"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-magnet-charged.cap":".zero-magnet-charged.cap"); + n=RtlSaveSnapshotToMemory(start,cap);zero_replay(20);en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore grown charged mine");zero_replay(20); + an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"charged mine size, animation and motion replay exactly"); + for(unsigned i=0;i<80;++i) frame(SNES_PAD_UP); + check(!extended_shots(true) && !g_ram[0xbdd] && !(g_ram[0xc2f]&64),"charged mine leaves the screen without audio or slot leaks"); + } + check(RtlLoadSnapshot(fixture),"restore mine enemy encounter");unsigned victim=0; + for(unsigned i=0;i<400 && !victim;++i) { + frame(SNES_PAD_RIGHT); + for(unsigned d=0xe68;d<0x1228;d+=64) + if(g_ram[d] && (g_ram[d+0x27]&127) && (g_ram[d+0x20]|g_ram[d+0x21])) {victim=d;break;} + } + check(victim!=0,"native enemy available for mine collision"); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=7;MmxWeaponsSetState(w); + frame(0);frame(SNES_PAD_Y);frame(0);MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) { + c.shots[i].x=(g_ram[victim+5]|g_ram[victim+6]<<8)*256; + c.shots[i].y=(g_ram[victim+8]|g_ram[victim+9]<<8)*256; + c.shots[i].vx=c.shots[i].vy=0;slot=i;break; + } + check(slot<8,"mine is available for native contact");g_ram[victim+0x27]=32;MmxWeaponsSetCombatState(c); + zero_replay(3);check((g_ram[victim+0x27]&127)<=30,"native mine impact deals source-scaled damage"); + check(MmxWeaponsGetCombatState().shots[slot].muzzle_pose==3,"native enemy contact starts mine explosion"); + puts("MMX MAGNET MINE CHECKS PASSED"); +} +static void weapon_bubble_checks(const char *assets,const char *fixture,uint8 *start, + uint8 *expected,uint8 *actual,size_t cap) { + check(MmxWeaponsLoad(assets),"Bubble Splash original assets load"); + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore Bubble Splash fixture"); + if(character) zero_health_swap(); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=2;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);frame(0); + check(extended_shots(false)==1 && MmxWeaponsEnergyAmount(1,2)==28*256-32, + "tap creates one original bubble for one-eighth energy"); + for(unsigned i=0;i<18;++i) frame(SNES_PAD_Y); + check(extended_shots(false)==7 && MmxWeaponsEnergyAmount(1,2)==28*256-7*32, + "held fire fills source seven-bubble limit with fractional costs"); + frame(0);zero_replay(15);MmxWeaponCombatState c=MmxWeaponsGetCombatState(); + unsigned rising=0; + for(unsigned i=0;i<8;++i) rising+=c.shots[i].active && c.shots[i].vy<0; + check(rising>0,"normal bubbles grow and accelerate upward"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-bubble.cap":".zero-bubble.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(40);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore rising bubbles");zero_replay(40); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"bubble motion and popping replay exactly"); + zero_replay(220);check(!g_ram[0xbdd] && !extended_shots(false),"released bubbles pop and free every slot"); + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y); + frame(0);zero_replay(20);check(!extended_shots(true),"Bubble Splash charged form requires X1 arms"); + MmxWeaponsCancelShots(g_ram);zero_replay(10);g_ram[0x1f99]|=2; + for(unsigned i=0;i<120;++i) frame(SNES_PAD_Y); + frame(0);zero_replay(5);check(!extended_shots(true),"intermediate buster tier remains a normal special-weapon release"); + MmxWeaponsCancelShots(g_ram);zero_replay(10); + for(unsigned i=0;i<250;++i) frame(SNES_PAD_Y); + frame(0);zero_replay(80);c=MmxWeaponsGetCombatState(); + unsigned controller=0,children=0,moving=0; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].charged) { + controller+=!c.shots[i].variant;children+=c.shots[i].variant!=0; + moving+=c.shots[i].variant!=0 && c.shots[i].muzzle_pose==1; + } + check(controller==1 && children==7 && moving,"charged bubbles form the original renewing seven-bubble cloud"); + check(!(g_ram[0xc2f]&64),"charged cloud stops charge audio"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-bubble-charged.cap":".zero-bubble-charged.cap"); + n=RtlSaveSnapshotToMemory(start,cap);unsigned energy=MmxWeaponsEnergyAmount(1,2); + zero_replay(110);en=RtlSaveSnapshotToMemory(expected,cap); + check(MmxWeaponsEnergyAmount(1,2)>8)*32+(px>>8)]; + unsigned cell=0x2000+((screen*512+((py&240)<<1)+((px&240)>>3))&65535); + unsigned address=(g_ram[0xb92]|g_ram[0xb93]<<8|g_ram[0xb94]<<16)+(g_ram[cell]|g_ram[cell+1]<<8); + size_t offset=((address>>16)&127)*32768+(address&32767); + uint8_t property=g_snes->cart->rom[offset];g_snes->cart->rom[offset]=13;g_ram[0xbaa]=6; + c=MmxWeaponsGetCombatState(); + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].charged && !c.shots[i].variant) + MmxWeaponsProjectileTick(g_ram,0x1228+i*64,1); + check((unsigned)(g_ram[0xbb0]|g_ram[0xbb1]<<8)==py-3,"charged bubbles supply the source underwater lift"); + g_snes->cart->rom[offset]=property; + check(RtlLoadSnapshotFromMemory(start,n),"restore after private water check"); + frame(SNES_PAD_R);zero_replay(3); + check(MmxWeaponsGetState().weapon==3 && !g_ram[0xbdd],"shoulder cycle cancels the persistent bubble cloud cleanly"); + check(RtlLoadSnapshotFromMemory(start,n),"restore for empty-energy cleanup"); + w=MmxWeaponsGetState();w.energy[1]=0;w.fraction[1]=31;MmxWeaponsSetState(w); + zero_replay(220);check(!g_ram[0xbdd] && !extended_shots(true),"insufficient energy ends cloud and all bubbles"); + } + check(RtlLoadSnapshot(fixture),"restore bubble enemy encounter");unsigned victim=0; + for(unsigned i=0;i<400 && !victim;++i) { + frame(SNES_PAD_RIGHT); + for(unsigned d=0xe68;d<0x1228;d+=64) + if(g_ram[d] && (g_ram[d+0x27]&127) && (g_ram[d+0x20]|g_ram[d+0x21])) {victim=d;break;} + } + check(victim!=0,"native enemy available for bubble contact"); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=2;MmxWeaponsSetState(w); + frame(0);frame(SNES_PAD_Y);frame(0); + MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) { + c.shots[i].x=(g_ram[victim+5]|g_ram[victim+6]<<8)*256; + c.shots[i].y=(g_ram[victim+8]|g_ram[victim+9]<<8)*256; + c.shots[i].vx=c.shots[i].vy=0;slot=i;break; + } + check(slot<8,"bubble available for native collision");g_ram[victim+0x27]=32;MmxWeaponsSetCombatState(c); + zero_replay(3);check((g_ram[victim+0x27]&127)==31,"native bubble hit applies rounded source two-thirds damage"); + check(MmxWeaponsGetCombatState().shots[slot].muzzle_pose==3,"enemy contact starts original bubble pop"); + puts("MMX BUBBLE SPLASH CHECKS PASSED"); +} +static void weapon_frost_checks(const char *assets,const char *fixture,uint8 *start, + uint8 *expected,uint8 *actual,size_t cap) { + check(MmxWeaponsLoad(assets),"Frost Shield source assets load"); + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore Frost Shield fixture"); + if(character) zero_health_swap(); + MmxWeaponsState w=MmxWeaponsGetState();w.page=2;w.weapon=7;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);zero_replay(80);unsigned slot=frost_slot(false); + check(slot<8 && MmxWeaponsEnergyAmount(2,7)==27*256,"normal Frost Shield forms and costs one energy"); + MmxWeaponCombatState c=MmxWeaponsGetCombatState(); + check(c.shots[slot].muzzle_pose==1 && c.shots[slot].vx>16,"source formation becomes an accelerating ice missile"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-frost.cap":".zero-frost.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(25);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore ice missile");zero_replay(25); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"ice flight and original animation replay exactly"); + check(RtlLoadSnapshotFromMemory(start,n),"restore for ice impact"); + g_ram[0x1228+slot*64+1]=6; /* Native lethal-contact result, X3 $84:CF60. */ + unsigned phases=0; + for(unsigned i=0;i<150;++i) { + frame(0);c=MmxWeaponsGetCombatState(); + if(c.shots[slot].active) phases|=1u<>8)*32+(x>>8)]; + unsigned cell=0x2000+((screen*512+((y&240)<<1)+((x&240)>>3))&65535); + unsigned address=(g_ram[0xb92]|g_ram[0xb93]<<8|g_ram[0xb94]<<16)+(g_ram[cell]|g_ram[cell+1]<<8); + size_t offset=((address>>16)&127)*32768+(address&32767); + if((g_snes->cart->rom[offset]&63)!=0) continue; + check(changed<256,"private water properties fit fixture storage"); + offsets[changed]=offset;properties[changed++]=g_snes->cart->rom[offset];g_snes->cart->rom[offset]=13; + } + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y); + frame(0);zero_replay(24);unsigned slot=frost_slot(true); + check(slot<8,"underwater charged ice is present");MmxWeaponCombatState c=MmxWeaponsGetCombatState(); + check(c.shots[slot].muzzle_pose==5 && c.shots[slot].variant==1,"water produces the rising original ice platform"); + int platform_y=c.shots[slot].y;zero_replay(8);c=MmxWeaponsGetCombatState(); + check(c.shots[slot].y>8);py=(unsigned)((c.shots[slot].y>>8)-43); + g_ram[0xbad]=(uint8_t)px;g_ram[0xbae]=(uint8_t)(px>>8); + g_ram[0xbb0]=g_ram[0xbcc]=(uint8_t)py;g_ram[0xbb1]=g_ram[0xbcd]=(uint8_t)(py>>8); + g_ram[0xbc4]=g_ram[0xbc5]=0;g_ram[0xbaa]=6;g_ram[0xbab]=0; + zero_replay(12);c=MmxWeaponsGetCombatState(); + check(c.shots[slot].active && c.shots[slot].tether_pose,"native terrain hook lands player on moving ice"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".frost-platform.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(8);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore moving ice rider");zero_replay(8); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"moving platform and rider replay exactly"); + frame(SNES_PAD_B);c=MmxWeaponsGetCombatState(); + check(!c.shots[slot].tether_pose && (int16_t)(g_ram[0xbc4]|g_ram[0xbc5]<<8)>0,"player jumps freely off the ice platform"); + for(unsigned i=0;icart->rom[offsets[i]]=properties[i]; + puts("MMX FROST SHIELD CHECKS PASSED"); +} +static void weapon_wheel_checks(const char *assets,const char *fixture,uint8 *start, + uint8 *expected,uint8 *actual,size_t cap) { + check(MmxWeaponsLoad(assets),"Spin Wheel original assets load"); + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore Spin Wheel fixture"); + if(character) zero_health_swap(); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=4;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);zero_replay(5); + check(extended_shots(false)==1 && MmxWeaponsEnergyAmount(1,4)==27*256, + "normal Spin Wheel forms for one energy unit"); + size_t n=RtlSaveSnapshotToMemory(start,cap); + unsigned phases=0; + for(unsigned i=0;i<90;++i) { + frame(0);MmxWeaponCombatState c=MmxWeaponsGetCombatState(); + for(unsigned j=0;j<8;++j) if(c.shots[j].active) phases|=1u<>8);g_ram[victim+6]=(uint8)(contact_x>>16); + g_ram[victim+8]=(uint8)(contact_y>>8);g_ram[victim+9]=(uint8)(contact_y>>16); + frame(0);c=MmxWeaponsGetCombatState(); + unsigned now=g_ram[victim+0x27]&127; + if(now0; + check(falling==5,"all five charged blades stop spreading at their apex and fall"); + en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore charged Sonic ascent"); + zero_replay(29);an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"five charged Sonic trajectories replay exactly across apex"); + zero_replay(120); + check(!extended_shots(true) && !g_ram[0xbdd] && !g_ram[0xc25],"charged Sonic blades retire without leaking native counts"); + w=MmxWeaponsGetState();w.energy[4]=0;w.fraction[4]=127;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);zero_replay(30); + check(!extended_shots(false) && MmxWeaponsEnergyAmount(1,5)==127, + "insufficient half-unit energy prevents Sonic fire without underflow"); + } + check(RtlLoadSnapshot(fixture),"restore Sonic native-enemy encounter");unsigned victim=0; + for(unsigned i=0;i<400 && !victim;++i) { + frame(SNES_PAD_RIGHT); + for(unsigned d=0xe68;d<0x1228;d+=64) + if(g_ram[d] && (g_ram[d+0x27]&127) && (g_ram[d+0x20]|g_ram[d+0x21])) {victim=d;break;} + } + check(victim!=0,"native enemy available for Sonic collision"); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=5;MmxWeaponsSetState(w); + frame(0);frame(SNES_PAD_Y);zero_replay(30); + MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned shot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) { + c.shots[i].x=(g_ram[victim+5]|g_ram[victim+6]<<8)*256-c.shots[i].vx; + c.shots[i].y=(g_ram[victim+8]|g_ram[victim+9]<<8)*256-c.shots[i].vy; + shot=i;break; + } + check(shot<8,"Sonic blade available for native enemy contact"); + MmxWeaponsSetCombatState(c);unsigned hp=g_ram[victim+0x27]&127;zero_replay(3); + check((g_ram[victim+0x27]&127)+1==hp,"Sonic Slicer deals ordinary native enemy damage"); + zero_replay(30);check(!MmxWeaponsGetCombatState().shots[shot].active,"Sonic impact retires without repeated hits"); + puts("MMX SONIC SLICER CHECKS PASSED"); +} +static unsigned ray_births(void) { + MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned count=0; + for(unsigned i=0;i<8;++i) count+=c.shots[i].active && c.shots[i].weapon==5 && + c.shots[i].variant==2 && c.shots[i].born==c.tick; + return count; +} +static void weapon_ray_checks(const char *assets, const char *fixture, uint8 *start, + uint8 *expected, uint8 *actual, size_t cap) { + check(MmxWeaponsLoad(assets),"Ray Splasher original assets load"); + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore Ray Splasher fixture"); + if(character) zero_health_swap(); + MmxWeaponsState w=MmxWeaponsGetState();w.page=2;w.weapon=5;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);unsigned rays=0; + for(unsigned i=0;i<65;++i) { + frame(0);rays+=ray_births(); + if(i==19 || i==45) { + check(g_ram[0xbf8]<128 && (g_ram[0xbbf]&127)!=0, + "Ray firing pose remains active through the complete source burst"); + if(!character) check(MmxZeroGetState().anim_pose==50, + "Zero retains his original extended-arm firing pose during the late burst"); + if(i==45) zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-ray.cap":".zero-ray.cap"); + } + } + check(rays==7 && MmxWeaponsEnergyAmount(2,5)==27*256,"normal Ray Splasher emits seven spread rays for one energy"); + check(g_ram[0xbf8]==255 && !(g_ram[0xbbf]&127),"Ray returns to idle after the source burst ends"); + zero_replay(40);check(!extended_shots(false) && !g_ram[0xbdd] && !g_ram[0xc25],"ray burst releases slots without leaking native charged-beam count"); + g_ram[0x1f99]|=2; + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y); + frame(0);zero_replay(10); + check(extended_shots(true)==1 && MmxWeaponsEnergyAmount(2,5)==23*256+128, + "charged Ray Splasher deploys its turret for exactly two and a half energy"); + check(!(g_ram[0xc2f]&64),"charged Ray Splasher stops charge audio"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(95);size_t en=RtlSaveSnapshotToMemory(expected,cap); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-ray-turret.cap":".zero-ray-turret.cap"); + check(RtlLoadSnapshotFromMemory(start,n),"restore Ray turret deployment"); + zero_replay(95);size_t an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"turret rise and radial firing replay exactly"); + check(RtlLoadSnapshotFromMemory(start,n),"restore for turret lifetime check"); + rays=0;bool left=false,right=false,up=false,down=false; + for(unsigned i=0;i<300;++i) { + frame(0);rays+=ray_births();MmxWeaponCombatState c=MmxWeaponsGetCombatState(); + for(unsigned j=0;j<8;++j) if(c.shots[j].active && c.shots[j].variant==2) { + left|=c.shots[j].vx<0;right|=c.shots[j].vx>0;up|=c.shots[j].vy<0;down|=c.shots[j].vy>0; + } + } + check(rays==22 && left && right && up && down,"original Ray turret cycles radial directions for twenty-two shots"); + check(!extended_shots(true) && !g_ram[0xbdd] && !g_ram[0xc25],"turret and ray trails retire cleanly"); + check(MmxWeaponsEnergyAmount(2,5)==23*256+128,"turret child rays do not consume extra energy"); + } + check(RtlLoadSnapshot(fixture),"restore Ray native-enemy encounter");unsigned victim=0; + for(unsigned i=0;i<400 && !victim;++i) { + frame(SNES_PAD_RIGHT); + for(unsigned d=0xe68;d<0x1228;d+=64) + if(g_ram[d] && (g_ram[d+0x27]&127) && (g_ram[d+0x20]|g_ram[d+0x21])) {victim=d;break;} + } + check(victim!=0,"native enemy available for Ray Splasher collision"); + MmxWeaponsState w=MmxWeaponsGetState();w.page=2;w.weapon=5;MmxWeaponsSetState(w); + frame(0);frame(SNES_PAD_Y);zero_replay(9); + MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned projectile=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].variant==2) { + c.shots[i].x=(g_ram[victim+5]|g_ram[victim+6]<<8)*256-c.shots[i].vx; + c.shots[i].y=(g_ram[victim+8]|g_ram[victim+9]<<8)*256-c.shots[i].vy; + projectile=i;break; + } + check(projectile<8,"Ray projectile available for native collision"); + g_ram[victim+0x27]=32; + MmxWeaponsSetCombatState(c);unsigned hp=g_ram[victim+0x27]&127;zero_replay(3); + check((g_ram[victim+0x27]&127)+2==hp,"Ray Splasher deals normalized source damage to a native enemy"); + zero_replay(3);check(!MmxWeaponsGetCombatState().shots[projectile].active,"Ray hit drains original trail and releases its slot"); + puts("MMX RAY SPLASHER CHECKS PASSED"); +} +static void weapon_acid_checks(const char *assets, const char *fixture, uint8 *start, + uint8 *expected, uint8 *actual, size_t cap) { + check(MmxWeaponsLoad(assets),"Acid Burst original assets load"); + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore Acid Burst fixture"); + if(character) zero_health_swap(); + MmxWeaponsState w=MmxWeaponsGetState();w.page=2;w.weapon=1;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);zero_replay(8); + check(extended_shots(false)==1 && MmxWeaponsGetState().energy[8]==27, + "normal Acid Burst fires and spends one energy"); + MmxWeaponCombatState c=MmxWeaponsGetCombatState();int shot_y=0; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) shot_y=c.shots[i].y>>8; + check(shot_y < (g_ram[0xbb0]|g_ram[0xbb1]<<8),"acid lobs upward from character muzzle"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-acid.cap":".zero-acid.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(65); + size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore acid before terrain contact"); + zero_replay(65);size_t an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"acid terrain contact and droplets replay exactly"); + check(acid_variants(128,false)==1 && acid_variants(2,false)==4, + "acid splashes against native highway floor and throws four droplets"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-acid-splash.cap":".zero-acid-splash.cap"); + zero_replay(170); + check(!extended_shots(false) && !g_ram[0xbdd],"acid and droplets release all native slots"); + g_ram[0x1f99]|=2; + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y); + frame(0);zero_replay(25); + check(extended_shots(true)==2 && MmxWeaponsGetState().energy[8]==24, + "charged Acid Burst releases two growing blobs and costs two energy"); + check(!(g_ram[0xc2f]&64),"charged acid release stops charging audio"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-acid-charged.cap":".zero-acid-charged.cap"); + n=RtlSaveSnapshotToMemory(start,cap);zero_replay(35);en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore charged acid before bounce"); + zero_replay(35);an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"charged acid bounce and splashes replay exactly"); + check(acid_variants(128,true)>=1 && extended_shots(true)>=3, + "charged acid bounces from native floor and leaves damaging splashes"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-acid-bounce.cap":".zero-acid-bounce.cap"); + zero_replay(330); + check(!extended_shots(true) && !g_ram[0xbdd],"charged acid bounce budget retires the attack"); + } + check(RtlLoadSnapshot(fixture),"restore acid native-enemy encounter"); + unsigned victim=0; + for(unsigned i=0;i<400 && !victim;++i) { + frame(SNES_PAD_RIGHT); + for(unsigned d=0xe68;d<0x1228;d+=64) + if(g_ram[d] && (g_ram[d+0x27]&127) && (g_ram[d+0x20]|g_ram[d+0x21])) {victim=d;break;} + } + check(victim!=0,"native enemy available for Acid Burst collision"); + MmxWeaponsState w=MmxWeaponsGetState();w.page=2;w.weapon=1;MmxWeaponsSetState(w); + frame(0);frame(SNES_PAD_Y);frame(0); + MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned projectile=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) { + c.shots[i].x=(g_ram[victim+5]|g_ram[victim+6]<<8)*256; + c.shots[i].y=(g_ram[victim+8]|g_ram[victim+9]<<8)*256; + c.shots[i].vx=c.shots[i].vy=0;projectile=i;break; + } + check(projectile<8,"acid actor available for native collision"); + g_ram[victim+0x27]=32; + MmxWeaponsSetCombatState(c);unsigned hp=g_ram[victim+0x27]&127;zero_replay(3); + check((g_ram[victim+0x27]&127)+3==hp,"Acid Burst deals three buster hits of damage to a real enemy"); + zero_replay(35); + check(!MmxWeaponsGetCombatState().shots[projectile].active,"acid enemy impact retires without creating floor droplets"); + puts("MMX ACID BURST CHECKS PASSED"); +} +static void weapon_combat_checks(const char *assets, const char *fixture, uint8 *start, + uint8 *expected, uint8 *actual, size_t cap) { + check(MmxWeaponsLoad(assets),"original weapon animations load"); + for (unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore blade fixture"); + if (character) zero_health_swap(); + MmxWeaponsState w=MmxWeaponsGetState(); w.page=2; w.weapon=4; MmxWeaponsSetState(w); + frame(SNES_PAD_Y); zero_replay(6); + check(extended_shots(false)==2 && MmxWeaponsGetState().energy[11]==27,"normal blade creates twin projectiles and spends one energy"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-blade.cap":".zero-blade.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap); + zero_replay(12); size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore active twin blades"); + zero_replay(12); size_t an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"active blade save and replay"); + MmxWeaponsCancelShots(g_ram); zero_replay(30); + for (unsigned i=0;i<205;++i) frame(SNES_PAD_Y); + frame(0); zero_replay(6); + check(!extended_shots(true),"extended charging requires actual X1 arm upgrade"); + MmxWeaponsCancelShots(g_ram); zero_replay(30); g_ram[0x1f99]|=2; + for (unsigned i=0;i<205;++i) frame(SNES_PAD_Y); + frame(0); zero_replay(25); + check(extended_shots(true)==1,"arms-upgraded release creates charged blade"); + check(g_ram[0xbf8]!=0,"attached Blade retains the native firing overlay"); + check(MmxWeaponsGetState().energy[11]==22 && !(g_ram[0xc2f]&64),"charged blade costs three energy and stops charge audio"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-blade-charged.cap":".zero-blade-charged.cap"); + MmxWeaponCombatState blade=MmxWeaponsGetCombatState();unsigned charged_slot=8; + for(unsigned i=0;i<8;++i) if(blade.shots[i].active && blade.shots[i].charged) {charged_slot=i;break;} + check(charged_slot<8,"charged Blade controller is present"); + n=RtlSaveSnapshotToMemory(start,cap); + zero_replay(140-blade.shots[charged_slot].age); + blade=MmxWeaponsGetCombatState(); + check(blade.shots[charged_slot].radius==80,"source charged Blade remains extended through local tick 140"); + zero_replay(10);blade=MmxWeaponsGetCombatState(); + check(blade.shots[charged_slot].radius==48,"source timeout retracts to 48 pixels by local tick 150"); + zero_replay(16);check(!extended_shots(true),"source timed retraction releases the charged Blade"); + check(RtlLoadSnapshotFromMemory(start,n),"restore held Blade for control checks"); + frame(SNES_PAD_Y);zero_replay(4);blade=MmxWeaponsGetCombatState(); + check(blade.shots[charged_slot].variant==5 && extended_shots(true)==1, + "fire does not rotate the original charged Blade or create another shot"); + frame(SNES_PAD_UP); zero_replay(10); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-blade-turn.cap":".zero-blade-turn.cap"); + n=RtlSaveSnapshotToMemory(start,cap); zero_replay(30); en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore rotating charged blade"); + zero_replay(30); an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"rotating blade save and replay"); + zero_replay(80); check(!extended_shots(true) && !g_ram[0xc25],"charged blade retracts without leaking native charged-beam count"); + } + check(RtlLoadSnapshot(fixture),"restore native enemy encounter"); + unsigned victim=0; + for (unsigned i=0;i<400 && !victim;++i) { + frame(SNES_PAD_RIGHT); + for (unsigned d=0xe68;d<0x1228;d+=64) + if (g_ram[d] && (g_ram[d+0x27]&127) && (g_ram[d+0x20]|g_ram[d+0x21])) { victim=d; break; } + } + check(victim!=0,"native highway enemy supplies collision target"); + uint8_t blade_enemy[64];memcpy(blade_enemy,g_ram+victim,64); + MmxWeaponsState w=MmxWeaponsGetState(); w.page=2;w.weapon=4;MmxWeaponsSetState(w); + frame(0); frame(SNES_PAD_Y); frame(0); + MmxWeaponCombatState c=MmxWeaponsGetCombatState(); unsigned projectile=0; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) { + c.shots[i].x=(g_ram[victim+5]|g_ram[victim+6]<<8)*256; + c.shots[i].y=(g_ram[victim+8]|g_ram[victim+9]<<8)*256; + projectile=0x1228+i*64; break; + } + check(projectile!=0,"blade actor available for native collision"); + g_ram[victim+0x27]=32; + MmxWeaponsSetCombatState(c); unsigned hp=g_ram[victim+0x27]&127; + zero_replay(3); + check((g_ram[victim+0x27]&127)+3==hp,"blade uses three buster hits of damage against a real enemy"); + zero_replay(30); + check(!MmxWeaponsGetCombatState().shots[(projectile-0x1228)/64].active,"native enemy hit plays original impact animation and retires blade"); + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore charged Blade contact fixture"); + if(character) zero_health_swap(); + w=MmxWeaponsGetState();w.page=2;w.weapon=4;MmxWeaponsSetState(w);g_ram[0x1f99]|=2; + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y); + frame(0);zero_replay(30);c=MmxWeaponsGetCombatState();unsigned slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].charged) {slot=i;break;} + check(slot<8,"charged Blade available for surviving-enemy contact"); + memcpy(g_ram+victim,blade_enemy,64);g_ram[victim+0x27]=64;g_ram[victim+0x30]=0; + unsigned x=(unsigned)(c.shots[slot].x>>8),y=(unsigned)(c.shots[slot].y>>8); + g_ram[victim+5]=(uint8_t)x;g_ram[victim+6]=(uint8_t)(x>>8); + g_ram[victim+8]=(uint8_t)y;g_ram[victim+9]=(uint8_t)(y>>8); + zero_replay(3);c=MmxWeaponsGetCombatState(); + check((g_ram[victim+0x27]&127)==54,"charged Blade deals ten buster hits once to a surviving enemy"); + check(c.shots[slot].active && c.shots[slot].variant==4 && !c.shots[slot].muzzle_pose && !c.shots[slot].tether_pose, + "source surviving hit throws charged Blade away and removes the tether"); + check(c.shots[slot].vy==-2048 && c.shots[slot].vx==-(c.shots[slot].facing?1:-1)*2048-224, + "charged Blade recoil matches the original contact-state velocities"); + check(!(g_ram[0x1228+slot*64+0x20]|g_ram[0x1228+slot*64+0x21]),"recoiling Blade no longer has a damage box"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-blade-recoil.cap":".zero-blade-recoil.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap); + zero_replay(20);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check((g_ram[victim+0x27]&127)==54,"charged Blade does not apply the old periodic repeat hit"); + check(RtlLoadSnapshotFromMemory(start,n),"restore charged Blade recoil"); + zero_replay(20);size_t an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"charged Blade contact recoil replays exactly"); + } + puts("MMX EXTENDED WEAPON COMBAT CHECKS PASSED"); +} +static void weapon_damage_checks(const char *assets,const char *fixture,uint8 *start,size_t cap) { + check(MmxWeaponsLoad(assets) && RtlLoadSnapshot(fixture),"restore source damage fixture"); + MmxWeaponsState w=MmxWeaponsGetState();w.page=2;w.weapon=5;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);zero_replay(9); + MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].variant==2) {slot=i;break;} + check(slot<8,"original Ray projectile available for damage scaling"); + unsigned d=0x1228+slot*64,enemy=0xe68; + g_ram[enemy]=1;g_ram[enemy+1]=2;g_ram[enemy+10]=0x1f; + g_ram[enemy+0x27]=32;g_ram[enemy+0x28]=3; + check(MmxWeaponsDamage(g_ram,enemy,d,0)==0 && MmxWeaponsDamage(g_ram,enemy,d,128)==128, + "native immunity and reflection remain authoritative"); + check(MmxWeaponsDamage(g_ram,enemy,d,1)==2,"first 5/3 ray contact rounds cumulative damage"); + check(MmxWeaponsDamage(g_ram,enemy,d,1)==0,"same ray cannot hit the same enemy twice"); + size_t n=RtlSaveSnapshotToMemory(start,cap); + unsigned total=2; + for(unsigned i=0;i<2;++i) { + c=MmxWeaponsGetCombatState();c.shots[slot].hit_slots=0;MmxWeaponsSetCombatState(c); + total+=MmxWeaponsDamage(g_ram,enemy,d,1); + } + check(total==5,"three rays equal exactly five X1 buster hits, without per-ray rounding inflation"); + check(RtlLoadSnapshotFromMemory(start,n),"damage fraction survives save/load"); + c=MmxWeaponsGetCombatState();c.shots[slot].hit_slots=0;MmxWeaponsSetCombatState(c); + check(MmxWeaponsDamage(g_ram,enemy,d,1)==1,"restored fractional carry gives the same next contact"); + c=MmxWeaponsGetCombatState();c.shots[slot].hit_slots=0;MmxWeaponsSetCombatState(c); + g_ram[enemy+0x28]=6; + check(MmxWeaponsDamage(g_ram,enemy,d,1)==1,"boss receives neutral source damage without imported weakness"); + /* A v11 snapshot ends at the old combat prefix. */ + size_t chunk=0; + for(size_t i=n-8;i>8;--i) {uint32_t magic;memcpy(&magic,start+i,4);if(magic==0x4d4d5854u) {chunk=i;break;}} + check(chunk!=0,"damage snapshot chunk found"); + uint32_t particles_legacy=12;memcpy(start+chunk+4,&particles_legacy,4); + check(RtlLoadSnapshotFromMemory(start,n-(sizeof(c)-MMX_WEAPON_COMBAT_DAMAGE_SIZE)), + "v12 damage snapshot migrates without visual particles"); + c=MmxWeaponsGetCombatState();unsigned particles=0;for(unsigned i=0;i<16;++i) particles+=c.effects[i].active; + check(!particles,"legacy particle slots initialize empty"); + uint32_t legacy=11;memcpy(start+chunk+4,&legacy,4); + check(RtlLoadSnapshotFromMemory(start,n-(sizeof(c)-MMX_WEAPON_COMBAT_LEGACY_SIZE)), + "v11 projectile snapshot migrates without damage carry"); + c=MmxWeaponsGetCombatState();check(!c.enemies[0].active,"legacy damage carry initializes empty"); + puts("MMX SOURCE DAMAGE CHECKS PASSED"); +} 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"); @@ -364,6 +1587,75 @@ static void zero_half_charge_checks(const char *fixture) { } check(RtlLoadSnapshot(fixture),"restore after native half-charge check"); } +static unsigned zero_charge_actors(void) { + unsigned n=0; + for(unsigned d=0xc98;d<0xe18;d+=32) n+=g_ram[d] && MmxZeroNativeChargeObject(d,g_ram[d+10]); + return n; +} +static void zero_charge_visual_checks(const char *fixture,uint8 *start,uint8 *expected,uint8 *actual,size_t cap) { + const char *capture=getenv("MMX_ZERO_TEST_CAPTURE"); + for(unsigned arms=0;arms<=2;arms+=2) { + check(RtlLoadSnapshot(fixture),"restore charge-color fixture"); + g_ram[0x1f99]=(uint8_t)arms; + const uint16_t *palettes[3]={0}; + for(unsigned tick=1;tick<=228;++tick) { + frame(SNES_PAD_Y); MmxZeroState z=MmxZeroGetState(); + check(zero_charge_actors()<=1,"one native charge effect while holding fire"); + if(z.charge>=25 && !(z.charge_phase&2)) { + unsigned tier=z.charge>=201?2:z.charge>=141?1:0; + palettes[tier]=MmxZeroBodyColors(&z); + check(palettes[tier]!=MmxZeroColors()+16,"body flashes before obtaining X1 arms"); + if(tick==29 || tick==145 || tick==205) { + char suffix[64];snprintf(suffix,sizeof(suffix),".charge-%u-%u.cap",arms,tier); + zero_capture(capture,suffix); + } + } + if(tick>=21) check(MmxZeroChargePose(&z)!=NULL,"original X3 charge particle poses available"); + } + check(palettes[0] && palettes[1] && palettes[2] && memcmp(palettes[0],palettes[1],32) && + memcmp(palettes[1],palettes[2],32),"blue, purple and green charge palettes are distinct"); + size_t n=RtlSaveSnapshotToMemory(start,cap); + for(unsigned i=0;i<16;++i) frame(SNES_PAD_Y); + size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore full-charge animation"); + for(unsigned i=0;i<16;++i) frame(SNES_PAD_Y); + size_t an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"full-charge particles and palette replay exactly"); + frame(0);zero_replay(17);frame(SNES_PAD_Y);zero_replay(90); + MmxZeroState z=MmxZeroGetState(); + check(z.combo==2 && z.saber_ready && !z.burst && !MmxZeroChargePose(&z) && !zero_charge_actors(), + "stored saber retains readiness after beams without orbiting charge effects"); + unsigned green=0,base=0; + for(unsigned i=0;i<8;++i) { + frame(0);z=MmxZeroGetState();const uint16_t *p=MmxZeroBodyColors(&z); + green+=p==palettes[2];base+=p==MmxZeroColors()+16; + if(p==palettes[2]) zero_capture(capture,".stored-saber.cap"); + } + check(green==4 && base==4,"stored saber alternates original green and normal palette every two frames"); + frame(SNES_PAD_Y);z=MmxZeroGetState(); + check(z.slash && !z.saber_ready && MmxZeroBodyColors(&z)==MmxZeroColors()+16,"saber use consumes the green readiness flash"); + } + check(RtlLoadSnapshot(fixture),"restore interrupted charge fixture"); + for(unsigned i=0;i<160;++i) frame(SNES_PAD_Y); + /* Put the live charge actor in a later legal allocation, then take hurt. */ + check(g_ram[0xc98] && g_ram[0xca2]==1,"native charge actor allocated"); + memcpy(g_ram+0xd58,g_ram+0xc98,32);memset(g_ram+0xc98,0,32); + g_ram[0xbaa]=0x0e;g_ram[0xbab]=0;frame(SNES_PAD_Y); + check(!MmxZeroGetState().charge && !zero_charge_actors(),"hurt cancels charge including an effect outside the first pool slot"); + for(unsigned i=0;i<230;++i) {frame(SNES_PAD_Y);check(zero_charge_actors()<=1,"held fire after hurt never doubles the effect");} + zero_health_swap(); + check(MmxZeroGetState().active_x && !zero_charge_actors(),"swap cancels Zero charge effects before X arrives"); + for(unsigned i=0;i<150;++i) frame(SNES_PAD_Y); + zero_health_swap(); + check(!MmxZeroGetState().active_x && !zero_charge_actors(),"swap cancels X charge effects before Zero arrives"); + for(unsigned i=0;i<210;++i) {frame(SNES_PAD_Y);check(zero_charge_actors()<=1,"held fire after swap never doubles the effect");} + frame(0);check(MmxZeroGetState().saber_ready,"recharged Zero releases the full combo normally"); + puts("MMX ZERO CHARGE COLOR AND CANCELLATION CHECKS PASSED"); +} +#include "mmx_weapon_crystal_test.inc" +#include "mmx_weapon_gravity_test.inc" +#include "mmx_weapon_parasitic_test.inc" +#include "mmx_weapon_stage_test.inc" 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"); @@ -375,6 +1667,33 @@ static void zero_state_checks(const char *assets, const char *fixture, uint8 *st g_config.widescreen = false; int w,h; MmxPrepareFrame(1280,720,&w,&h); check(w == 256 && g_mmx_custom_renderer, "Zero activates native-width compositor"); + const char *weapons=getenv("MMX_WEAPONS_TEST_ASSETS"); + if (getenv("MMX_SOURCE_PACK_TEST")) { weapon_source_pack_checks(fixture,start,expected,actual,cap);return; } + if (weapons) { + if (getenv("MMX_WEAPON_STAGE_TEST")) weapon_stage_checks(weapons); + else if (getenv("MMX_WEAPON_DAMAGE_TEST")) weapon_damage_checks(weapons,fixture,start,cap); + else if (getenv("MMX_WEAPON_CYCLE_TEST")) weapon_cycle_checks(weapons,fixture,start,cap); + else if (getenv("MMX_WEAPON_FROST_TEST")) weapon_frost_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_BUBBLE_TEST")) weapon_bubble_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_MAGNET_TEST")) weapon_magnet_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_TRIAD_TEST")) weapon_triad_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_FANG_TEST")) weapon_fang_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_CHAIN_TEST")) weapon_chain_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_SILK_TEST")) weapon_silk_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_CRYSTAL_TEST")) weapon_crystal_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_GRAVITY_TEST")) weapon_gravity_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_PARASITIC_TEST")) weapon_parasitic_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_SPEED_TEST")) weapon_speed_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_WHEEL_TEST")) weapon_wheel_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_SONIC_TEST")) weapon_sonic_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_RAY_TEST")) weapon_ray_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_ACID_TEST")) weapon_acid_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_ENERGY_TEST")) weapon_energy_checks(weapons,fixture,start,expected,actual,cap); + else if (getenv("MMX_WEAPON_COMBAT_TEST")) weapon_combat_checks(weapons,fixture,start,expected,actual,cap); + else weapon_menu_checks(weapons,fixture,start,expected,actual,cap); + return; + } + if(getenv("MMX_ZERO_CHARGE_ONLY")) { zero_charge_visual_checks(fixture,start,expected,actual,cap); return; } if(getenv("MMX_ZERO_READY_ONLY")) { zero_ready_checks(fixture); return; } zero_swap_checks(fixture,start,expected,actual,cap); if(getenv("MMX_ZERO_SWAP_ONLY")) { puts("MMX SELECT SWAP CHECKS PASSED"); return; } diff --git a/tests/mmx_weapon_chain_test.inc b/tests/mmx_weapon_chain_test.inc new file mode 100644 index 0000000..ee9dad0 --- /dev/null +++ b/tests/mmx_weapon_chain_test.inc @@ -0,0 +1,121 @@ +static unsigned chain_test_slot(void) { + MmxWeaponCombatState c=MmxWeaponsGetCombatState(); + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].page==1 && c.shots[i].weapon==6) return i; + return 8; +} +static void weapon_chain_checks(const char *assets,const char *fixture,uint8 *start, + uint8 *expected,uint8 *actual,size_t cap) { + check(MmxWeaponsLoad(assets),"Strike Chain original assets load"); + check(MmxWeaponsPose(1,6,71,0) && MmxWeaponsPose(1,6,71,23),"normal and charged hook/link art exists"); + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore Strike Chain fixture");if(character) zero_health_swap(); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=6;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);frame(0);zero_replay(5); + check(chain_test_slot()==8 && MmxWeaponsEnergyAmount(1,6)==27*256+128, + "tap retracts before extending and costs half an energy"); + frame(SNES_PAD_Y);unsigned slot=chain_test_slot();check(slot<8,"held chain allocates a native hook"); + check(MmxWeaponsGetCombatState().shots[slot].pose==1,"normal startup consumes the original first one-tick pose on allocation"); + for(unsigned i=0;i<12;++i) frame(SNES_PAD_Y); + MmxWeaponCombatState c=MmxWeaponsGetCombatState(); + check(c.shots[slot].active && c.shots[slot].variant==1 && c.shots[slot].vx==1024 && + c.shots[slot].x/256-c.shots[slot].origin_x>=32,"holding extends normal hook at four pixels per tick"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-chain-normal.cap":".zero-chain-normal.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap);for(unsigned i=0;i<25;++i) frame(SNES_PAD_Y); + size_t en=RtlSaveSnapshotToMemory(expected,cap);check(RtlLoadSnapshotFromMemory(start,n),"restore extended chain"); + for(unsigned i=0;i<25;++i) frame(SNES_PAD_Y);size_t an=RtlSaveSnapshotToMemory(actual,cap); + same(expected,en,actual,an,"normal chain extension, retraction and original animations replay exactly"); + check(chain_test_slot()==8 && !g_ram[0xbdd],"normal chain retracts after source timer and releases native slot"); + frame(0);for(unsigned i=0;i<220;++i) frame(SNES_PAD_Y);frame(0);zero_replay(6); + check(!extended_shots(true),"charged chain requires X1 arms"); + g_ram[0x1f99]|=2;for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y);frame(0);frame(0); + slot=chain_test_slot();c=MmxWeaponsGetCombatState(); + check(slot<8 && c.shots[slot].charged && c.shots[slot].vx==1536 && !c.shots[slot].variant, + "full charge starts the original charged hook startup at six pixels per tick"); + zero_replay(21);c=MmxWeaponsGetCombatState(); + check(g_ram[0xbf8]>=14,"source shooting pose remains active through the charged tether"); + check(c.shots[slot].active && c.shots[slot].variant==1 && c.shots[slot].x/256-c.shots[slot].origin_x>=54, + "charged chain extends automatically after its longer startup"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-chain-charged.cap":".zero-chain-charged.cap"); + n=RtlSaveSnapshotToMemory(start,cap);zero_replay(35);en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore charged chain");zero_replay(35); + an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"charged chain and contact state replay exactly"); + check(chain_test_slot()==8 && !g_ram[0xbdd],"charged chain retracts and cleans up"); + /* A real X1 pickup is pulled to the player and collected by its native + * handler, including the extended weapon refill and active-only HP rule. */ + frame(SNES_PAD_Y);for(unsigned i=0;i<10;++i) frame(SNES_PAD_Y); + slot=chain_test_slot();check(slot<8,"chain available for pickup retrieval");c=MmxWeaponsGetCombatState(); + unsigned item=0x1628;memset(g_ram+item,0,48);g_ram[item]=1;g_ram[item+10]=2;g_ram[item+11]=129; + unsigned x=c.shots[slot].x/256+4,y=c.shots[slot].y/256; + g_ram[item+5]=(uint8)x;g_ram[item+6]=(uint8)(x>>8);g_ram[item+8]=(uint8)y;g_ram[item+9]=(uint8)(y>>8); + g_ram[0xbcf]=8;bool grabbed=false; + for(unsigned i=0;i<40;++i) {frame(SNES_PAD_Y);c=MmxWeaponsGetCombatState();grabbed|=c.shots[slot].active && c.shots[slot].variant==4;} + check(grabbed && !g_ram[item] && (g_ram[0xbcf]&127)==10,"normal hook retrieves and natively collects a small health pickup"); + MmxWeaponsCancelShots(g_ram); + } + /* Use one private metatile to test actual wall contact without depending + * on stage progress or modifying the owner's running game. */ + for(unsigned charged=0;charged<2;++charged) { + check(RtlLoadSnapshot(fixture),"restore wall-pull fixture"); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=6;MmxWeaponsSetState(w);g_ram[0x1f99]|=2; + if(charged) {for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y);frame(0);} else frame(SNES_PAD_Y); + unsigned slot=chain_test_slot();check(slot<8,"wall test has a hook"); + MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned px=g_ram[0xbad]|g_ram[0xbae]<<8; + int x=(px+48)&~15,y=c.shots[slot].y>>8; + unsigned screen=g_ram[0xe800+(y>>8)*32+(x>>8)]; + unsigned cell=0x2000+((screen*512+((y&240)<<1)+((x&240)>>3))&65535); + unsigned base=g_ram[0xb92]|g_ram[0xb93]<<8|g_ram[0xb94]<<16; + unsigned delta=(0xffd0-(base&65535))&65535; + unsigned offset=((base>>16)&127)*32768+0x7fd0; + uint8 low=g_ram[cell],high=g_ram[cell+1],property=g_snes->cart->rom[offset]; + g_ram[cell]=(uint8)delta;g_ram[cell+1]=(uint8)(delta>>8);g_snes->cart->rom[offset]=0x13; + bool anchor=false; + for(unsigned i=0;i<30 && !anchor;++i) {frame(charged?0:SNES_PAD_Y);c=MmxWeaponsGetCombatState();anchor=c.shots[slot].active && c.shots[slot].variant==3;} + g_ram[cell]=low;g_ram[cell+1]=high;g_snes->cart->rom[offset]=property; + check(anchor,"hook attaches to actual X1 wall collision"); + unsigned before=g_ram[0xbad]|g_ram[0xbae]<<8;frame(charged?0:SNES_PAD_Y); + check((unsigned)(g_ram[0xbad]|g_ram[0xbae]<<8)==before+(charged?6:4),"wall anchor pulls at original normal/charged speed"); + frame(SNES_PAD_LEFT);zero_replay(2);check(chain_test_slot()==8,"opposite input detaches chain and restores movement"); + } + /* Exercise the actual native enemy loop, including fractional charged + * damage. Test placement brings an existing enemy into the hook path. */ + check(RtlLoadSnapshot(fixture),"restore native chain combat fixture");unsigned enemy=0; + for(unsigned i=0;i<400 && !enemy;++i) { + frame(SNES_PAD_RIGHT); + for(unsigned d=0xe68;d<0x1228;d+=64) if(g_ram[d] && (g_ram[d+0x27]&127) && + (g_ram[d+0x20]|g_ram[d+0x21])) {enemy=d;break;} + } + check(enemy!=0,"native enemy available for Strike Chain"); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=6;MmxWeaponsSetState(w); + frame(0);frame(SNES_PAD_Y);for(unsigned i=0;i<5;++i) frame(SNES_PAD_Y); + unsigned slot=chain_test_slot();check(slot<8,"normal hook available for native hit"); + MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned d=0x1228+slot*64; + c.shots[slot].x=(g_ram[enemy+5]|g_ram[enemy+6]<<8)*256;c.shots[slot].y=(g_ram[enemy+8]|g_ram[enemy+9]<<8)*256; + MmxWeaponsSetCombatState(c);g_ram[enemy+0x27]=32; + g_ram[d+5]=g_ram[enemy+5];g_ram[d+6]=g_ram[enemy+6];g_ram[d+8]=g_ram[enemy+8];g_ram[d+9]=g_ram[enemy+9]; + /* Damage dispatch runs in the native collision pass before projectile tick. */ + frame(SNES_PAD_Y);frame(SNES_PAD_Y); + check((g_ram[enemy+0x27]&127)<=30 && chain_test_slot()==8 && MmxWeaponsEnergyAmount(1,6)==27*256, + "normal hook hits through native collision at 5/3 buster, costs contact energy and retires on a survivor"); + check(RtlLoadSnapshot(fixture),"restore charged chain contact fixture"); + w=MmxWeaponsGetState();w.page=1;w.weapon=6;MmxWeaponsSetState(w);g_ram[0x1f99]|=2; + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y);frame(0);frame(0); + slot=chain_test_slot();check(slot<8,"native firing allocates charged contact test hook");d=0x1228+slot*64; + check(MmxWeaponsEnergyAmount(1,6)==27*256,"charged launch and initial normal shot each cost half an energy"); + enemy=0xe68;g_ram[enemy]=1;g_ram[enemy+1]=2;g_ram[enemy+10]=1;g_ram[enemy+0x28]=0;g_ram[enemy+0x27]=32; + unsigned damage=0,energy=MmxWeaponsEnergyAmount(1,6); + for(unsigned i=0;i<3;++i) { + MmxWeaponsProjectileTick(g_ram,d,1); + damage+=MmxWeaponsDamage(g_ram,enemy,d,1); + check(!MmxWeaponsDamage(g_ram,enemy,d,1),"charged chain deduplicates an enemy inside one collision tick"); + } + check(damage==1 && MmxWeaponsEnergyAmount(1,6)==energy-48 && chain_test_slot()<8, + "three charged contacts preserve one-third buster damage, drain three sixteenths and keep the hook alive"); + memset(g_ram+0x1628,0,0x300);g_ram[enemy+0x27]=1;bool lethal=false; + for(unsigned i=0;i<3 && !lethal;++i) { + MmxWeaponsProjectileTick(g_ram,d,1);lethal=MmxWeaponsDamage(g_ram,enemy,d,1)!=0; + } + check(lethal && g_ram[0x1628] && (g_ram[0x1632]==1 || g_ram[0x1632]==2) && g_ram[0x1633]==1, + "charged kill supplies the original guaranteed small health/energy reward as a native X1 pickup"); + MmxWeaponsCancelShots(g_ram); + puts("MMX STRIKE CHAIN CHECKS PASSED"); +} diff --git a/tests/mmx_weapon_crystal_test.inc b/tests/mmx_weapon_crystal_test.inc new file mode 100644 index 0000000..23e5366 --- /dev/null +++ b/tests/mmx_weapon_crystal_test.inc @@ -0,0 +1,86 @@ +static void weapon_crystal_checks(const char *assets,const char *fixture,uint8 *start, + uint8 *expected,uint8 *actual,size_t cap) { + check(MmxWeaponsLoad(assets),"Crystal Hunter source graphics load"); + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore Crystal Hunter fixture");if(character) zero_health_swap(); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=1;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);frame(0);MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) {slot=i;break;} + check(slot<8 && c.shots[slot].vx==832 && MmxWeaponsEnergyAmount(1,1)==27*256, + "Crystal Hunter normal uses source speed and one-energy cost"); + int y=c.shots[slot].y;zero_replay(8);c=MmxWeaponsGetCombatState(); + check(c.shots[slot].vy>0 && c.shots[slot].y>y,"crystal projectile follows its original falling arc"); + zero_replay(110);for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y);frame(0);zero_replay(3); + check(!MmxWeaponsTimeActive(),"Crystal Hunter charged effect requires X1 arms"); + MmxWeaponsCancelShots(g_ram);zero_replay(90);MmxWeaponsRefill();g_ram[0x1f99]|=2; + for(unsigned i=0;i<205;++i) frame(SNES_PAD_Y);frame(0);zero_replay(3); + check(MmxWeaponsTimeActive() && MmxWeaponsEnergyAmount(1,1)==25*256, + "charged crystal effect costs two units after the initial normal shot"); + unsigned x=g_ram[0xbad]|g_ram[0xbae]<<8; + for(unsigned i=0;i<70;++i) frame(SNES_PAD_RIGHT); + check((unsigned)(g_ram[0xbad]|g_ram[0xbae]<<8)==x,"original distortion onset suspends player motion"); + c=MmxWeaponsGetCombatState();int16_t ripple[224];MmxWeaponsTimeRipple(&c,ripple);bool distorted=false; + for(unsigned i=0;i<224;++i) if(ripple[i]) distorted=true; + check(distorted,"charged onset reproduces original scanline distortion"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-crystal-time.cap":".zero-crystal-time.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(140);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore charged time distortion");zero_replay(140); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"time distortion and half-speed transition replay exactly"); + unsigned moved=0,stayed=0; + for(unsigned i=0;i<30;++i) { + x=g_ram[0xbad]|g_ram[0xbae]<<8;frame(SNES_PAD_RIGHT); + if((unsigned)(g_ram[0xbad]|g_ram[0xbae]<<8)==x) ++stayed;else ++moved; + } + check(moved>=12 && moved<=16 && stayed>=14,"charged Crystal Hunter runs the game on alternate frames"); + zero_replay(460);check(!MmxWeaponsTimeActive() && !g_ram[0xbdd],"time effect expires and releases its native slot"); + } + check(RtlLoadSnapshot(fixture),"restore Crystal Hunter real-enemy encounter");unsigned victim=0; + for(unsigned i=0;i<400 && !victim;++i) { + frame(SNES_PAD_RIGHT); + for(unsigned d=0xe68;d<0x1228;d+=64) + if(g_ram[d] && (g_ram[d+0x27]&127) && g_ram[d+0x28]<6 && + (g_ram[d+0x20]|g_ram[d+0x21])) {victim=d;break;} + } + check(victim!=0,"native susceptible enemy is available for crystallization"); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=1;MmxWeaponsSetState(w); + frame(0);frame(SNES_PAD_Y);frame(0);MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) { + c.shots[i].x=(g_ram[victim+5]|g_ram[victim+6]<<8)*256; + c.shots[i].y=(g_ram[victim+8]|g_ram[victim+9]<<8)*256; + c.shots[i].vx=c.shots[i].vy=0;slot=i;break; + } + check(slot<8,"crystal projectile available for native collision");g_ram[victim+0x27]=16;MmxWeaponsSetCombatState(c); + zero_replay(3);c=MmxWeaponsGetCombatState(); + check(MmxWeaponsFrozenEnemy(&c,victim),"native enemy contact begins crystallization"); + uint8 enemy_action[3];memcpy(enemy_action,g_ram+victim+1,3); + zero_replay(25);check(!memcmp(enemy_action,g_ram+victim+1,3),"crystallized enemy AI remains suspended"); + c=MmxWeaponsGetCombatState();check(c.shots[slot].muzzle_pose>=2,"source formation animation produces a solid crystal"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".crystal-enemy.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(15);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore frozen enemy");zero_replay(15); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"frozen enemy and crystal physics replay exactly"); + c=MmxWeaponsGetCombatState();unsigned x=(unsigned)(c.shots[slot].x>>8),y=(unsigned)(c.shots[slot].y>>8); + g_ram[0xbad]=(uint8_t)x;g_ram[0xbae]=(uint8_t)(x>>8); + g_ram[0xbb0]=(uint8_t)(y-36);g_ram[0xbb1]=(uint8_t)((y-36)>>8); + g_ram[0xbcc]=(uint8_t)(y-40);g_ram[0xbcd]=(uint8_t)((y-40)>>8); + g_ram[0xbc4]=0;g_ram[0xbc5]=255;g_ram[0xbaa]=8;g_ram[0xbab]=2;g_ram[0xbd3]=0; + MmxWeaponsTerrainEnd(g_ram,0xba8); + check((g_ram[0xbd3]&4) && (unsigned)(g_ram[0xbb0]|g_ram[0xbb1]<<8)==y-37, + "player can stand on the source crystal block"); + g_ram[0xbad]=(uint8_t)(x-20);g_ram[0xbae]=(uint8_t)((x-20)>>8); + g_ram[0xbb0]=(uint8_t)y;g_ram[0xbb1]=(uint8_t)(y>>8);g_ram[0xbab]=0;g_ram[0xbaa]=0; + g_ram[0xbd3]|=4;g_ram[0x1f99]|=8; + unsigned health=g_ram[0xbcf]&127; + frame(SNES_PAD_RIGHT|SNES_PAD_A); + c=MmxWeaponsGetCombatState();unsigned fragments=0; + for(unsigned i=0;i<16;++i) if(c.effects[i].active && c.effects[i].weapon==1 && + (c.effects[i].pose==14 || c.effects[i].pose==15)) ++fragments; + check(fragments==8,"dash shatter creates eight original Crystal Hunter shards"); + n=RtlSaveSnapshotToMemory(start,cap);zero_replay(8);en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore shatter debris");zero_replay(8); + an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"shatter debris and native enemy death replay exactly"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".crystal-shatter.cap"); + check(!g_ram[victim] || g_ram[victim+1]==4,"dashing through crystal invokes native enemy destruction"); + check((g_ram[0xbcf]&127)==health && g_ram[0xbaa]!=14,"shattering enemy cannot hurt the dashing player"); + puts("MMX CRYSTAL HUNTER CHECKS PASSED"); +} diff --git a/tests/mmx_weapon_gravity_test.inc b/tests/mmx_weapon_gravity_test.inc new file mode 100644 index 0000000..45ad6e2 --- /dev/null +++ b/tests/mmx_weapon_gravity_test.inc @@ -0,0 +1,92 @@ +static unsigned gravity_test_slot(MmxWeaponCombatState c,unsigned variant) { + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].page==2 && + c.shots[i].weapon==6 && c.shots[i].variant==variant) return i; + return 8; +} +static void weapon_gravity_checks(const char *assets,const char *fixture,uint8 *start, + uint8 *expected,uint8 *actual,size_t cap) { + check(MmxWeaponsLoad(assets),"Gravity Well original graphics load"); + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore Gravity Well fixture");if(character) zero_health_swap(); + MmxWeaponsState w=MmxWeaponsGetState();w.page=2;w.weapon=6;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);frame(0);MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned slot=gravity_test_slot(c,0); + check(slot<8 && c.shots[slot].vx==992 && MmxWeaponsEnergyAmount(2,6)==27*256, + "Gravity Well follows source 4-to-3.875 speed and costs one energy"); + zero_replay(55);c=MmxWeaponsGetCombatState(); + check(c.shots[slot].active && c.shots[slot].muzzle_pose==2 && c.shots[slot].radius>150, + "normal orb decelerates, forms and sustains the source well"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-gravity-well.cap":".zero-gravity-well.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(220);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore normal Gravity Well");zero_replay(220); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"well duration and homing return replay exactly"); + c=MmxWeaponsGetCombatState();check(gravity_test_slot(c,0)==8 && !g_ram[0xbdd],"well returns and releases its firing slot"); + for(unsigned i=0;i<330;++i) frame(SNES_PAD_Y);frame(0);zero_replay(3); + check(gravity_test_slot(MmxWeaponsGetCombatState(),1)==8,"Gravity Well charge requires X1 arms"); + MmxWeaponsCancelShots(g_ram);zero_replay(90);MmxWeaponsRefill();g_ram[0x1f99]|=2; + for(unsigned i=0;i<330;++i) frame(SNES_PAD_Y);frame(0);zero_replay(12); + c=MmxWeaponsGetCombatState();unsigned body=gravity_test_slot(c,1);slot=gravity_test_slot(c,0); + check(body<8 && slot<8 && c.shots[body].group==51 && MmxWeaponsEnergyAmount(2,6)==26*256, + "charged Gravity Well uses original ground cast and one additional energy"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-gravity-cast.cap":".zero-gravity-cast.cap"); + unsigned x=g_ram[0xbad]|g_ram[0xbae]<<8; + for(unsigned i=0;i<100;++i) frame(SNES_PAD_RIGHT); + c=MmxWeaponsGetCombatState();check(c.shots[slot].muzzle_pose==2 && + (unsigned)(g_ram[0xbad]|g_ram[0xbae]<<8)==x,"source charged field locks the cast and launches its orb upward"); + bool particles=false;for(unsigned i=0;i<16;++i) particles|=c.effects[i].active && c.effects[i].variant==3; + check(particles,"charged field emits original upward particles"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-gravity-field.cap":".zero-gravity-field.cap"); + n=RtlSaveSnapshotToMemory(start,cap);zero_replay(310);en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore charged Gravity Well");zero_replay(310); + an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"charged field and recovery replay exactly"); + check(gravity_test_slot(MmxWeaponsGetCombatState(),1)==8 && !g_ram[0xbdd],"charged field expires and ends the casting lock"); + x=g_ram[0xbad]|g_ram[0xbae]<<8;for(unsigned i=0;i<12;++i) frame(SNES_PAD_RIGHT); + check((unsigned)(g_ram[0xbad]|g_ram[0xbae]<<8)>x,"player movement resumes after the field"); + MmxWeaponsCancelShots(g_ram);MmxWeaponsRefill(); + for(unsigned i=0;i<330;++i) frame(SNES_PAD_Y); + unsigned py=(g_ram[0xbb0]|g_ram[0xbb1]<<8)-64; + g_ram[0xbb0]=(uint8_t)py;g_ram[0xbb1]=(uint8_t)(py>>8);g_ram[0xbd3]&=(uint8_t)~4; + g_ram[0xbaa]=8;g_ram[0xbab]=2;g_ram[0xbc4]=g_ram[0xbc5]=0; + frame(0);zero_replay(4);c=MmxWeaponsGetCombatState();body=gravity_test_slot(c,1); + check(body<8 && c.shots[body].group==52,"airborne charge selects the original air cast"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-gravity-air.cap":".zero-gravity-air.cap"); + } + check(RtlLoadSnapshot(fixture),"restore real enemy Gravity Well encounter");unsigned victim=0; + for(unsigned i=0;i<400 && !victim;++i) { + frame(SNES_PAD_RIGHT); + for(unsigned d=0xe68;d<0x1228;d+=64) if(g_ram[d] && g_ram[d+1]==2 && + (g_ram[d+0x27]&127) && g_ram[d+0x28]<6 && (g_ram[d+0x20]|g_ram[d+0x21])) {victim=d;break;} + } + check(victim!=0,"real native enemy found for gravity interaction"); + uint8_t enemy[64];memcpy(enemy,g_ram+victim,64); + MmxWeaponsState w=MmxWeaponsGetState();w.page=2;w.weapon=6;MmxWeaponsSetState(w); + frame(0);frame(SNES_PAD_Y);frame(0);zero_replay(60); + MmxWeaponCombatState c=MmxWeaponsGetCombatState();const MmxWeaponShot *p=MmxWeaponsMovedEnemy(&c,victim); + check(p!=NULL,"formed well captures ordinary native enemy"); + unsigned px=g_ram[victim+5]|g_ram[victim+6]<<8,py=g_ram[victim+8]|g_ram[victim+9]<<8; + uint8_t action[3];memcpy(action,g_ram+victim+1,3);zero_replay(10); + check(!memcmp(action,g_ram+victim+1,3) && ((unsigned)(g_ram[victim+5]|g_ram[victim+6]<<8)!=px || + (unsigned)(g_ram[victim+8]|g_ram[victim+9]<<8)!=py),"gravity moves enemy while suspending its own AI"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".gravity-victim.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(60);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore pulled enemy");zero_replay(60); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"enemy pull and native death replay exactly"); + check(!g_ram[victim] || g_ram[victim+1]==4,"gravity dissolve enters native enemy death"); + check(RtlLoadSnapshot(fixture),"restore charged enemy lift encounter"); + w=MmxWeaponsGetState();w.page=2;w.weapon=6;MmxWeaponsSetState(w);g_ram[0x1f99]|=2; + for(unsigned i=0;i<330;++i) frame(SNES_PAD_Y);frame(0);zero_replay(90); + memcpy(g_ram+victim,enemy,64); + unsigned ex=(g_ram[0x1e4d]|g_ram[0x1e4e]<<8)+170,ey=(g_ram[0x1e50]|g_ram[0x1e51]<<8)+130; + g_ram[victim+5]=(uint8_t)ex;g_ram[victim+6]=(uint8_t)(ex>>8); + g_ram[victim+8]=(uint8_t)ey;g_ram[victim+9]=(uint8_t)(ey>>8);g_ram[victim+0x28]=6; + frame(0);c=MmxWeaponsGetCombatState();check(!MmxWeaponsMovedEnemy(&c,victim),"gravity excludes protected damage categories"); + g_ram[victim+0x28]=enemy[0x28];frame(0);c=MmxWeaponsGetCombatState();p=MmxWeaponsMovedEnemy(&c,victim); + check(p && p->charged,"charged field captures an eligible native enemy"); + ey=g_ram[victim+8]|g_ram[victim+9]<<8;zero_replay(8); + check((unsigned)(g_ram[victim+8]|g_ram[victim+9]<<8)>8); + g_ram[victim+8]=(uint8_t)capture_y;g_ram[victim+9]=(uint8_t)(capture_y>>8); + c.shots[slot].x=(g_ram[victim+5]|g_ram[victim+6]<<8)*256; + c.shots[slot].y=(g_ram[victim+8]|g_ram[victim+9]<<8)*256;MmxWeaponsSetCombatState(c); + g_ram[victim+0x27]=16;zero_replay(3);c=MmxWeaponsGetCombatState(); + check(MmxWeaponsMovedEnemy(&c,victim) && c.shots[slot].variant==1,"native collision captures an ordinary enemy"); + uint8_t action[3];memcpy(action,g_ram+victim+1,3);zero_replay(15); + check(!memcmp(action,g_ram+victim+1,3) && (g_ram[victim+0x27]&127)==16, + "captured host keeps its HP and suspends its native AI during formation"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".parasitic-capture.cap"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(30);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore captured enemy");zero_replay(30); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"captured enemy formation and motion replay exactly"); + c=MmxWeaponsGetCombatState();check(c.shots[slot].active && c.shots[slot].muzzle_pose, + "original formation animation finishes and activates the payload"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".parasitic-payload.cap"); + n=RtlSaveSnapshotToMemory(start,cap); + unsigned second=victim==0xe68?0xea8:0xe68; + memcpy(g_ram+second,enemy,64);g_ram[second+0x27]=64; + unsigned sx=(unsigned)(c.shots[slot].x>>8)+4,sy=(unsigned)(c.shots[slot].y>>8); + g_ram[second+5]=(uint8_t)sx;g_ram[second+6]=(uint8_t)(sx>>8); + g_ram[second+8]=(uint8_t)sy;g_ram[second+9]=(uint8_t)(sy>>8); + zero_replay(3);check((g_ram[second+0x27]&127)<64 && g_ram[victim+1]==4, + "captured payload damages another native enemy and consumes its host"); + check(RtlLoadSnapshotFromMemory(start,n),"restore payload before cancellation"); + MmxWeaponsCancelShots(g_ram);check(g_ram[victim+1]==4 && !g_ram[victim+0x26], + "payload cleanup uses native death and clears contact damage"); + check(RtlLoadSnapshot(fixture),"restore charged bomb target encounter"); + w=MmxWeaponsGetState();w.page=2;w.weapon=2;MmxWeaponsSetState(w);g_ram[0x1f99]|=2; + for(unsigned i=0;i<330;++i) frame(SNES_PAD_Y); + memcpy(g_ram+victim,enemy,64);g_ram[victim+0x27]=16; + unsigned ex=(g_ram[0x1e4d]|g_ram[0x1e4e]<<8)+170,ey=(g_ram[0x1e50]|g_ram[0x1e51]<<8)+120; + g_ram[victim+5]=(uint8_t)ex;g_ram[victim+6]=(uint8_t)(ex>>8); + g_ram[victim+8]=(uint8_t)ey;g_ram[victim+9]=(uint8_t)(ey>>8);g_ram[victim+0x28]=6; + for(unsigned i=0;i<6;++i) frame(SNES_PAD_Y); + c=MmxWeaponsGetCombatState();bool idle=true; + for(unsigned i=0;i<8;++i) if(c.shots[i].active && c.shots[i].variant==2 && c.shots[i].muzzle_pose) idle=false; + check(idle,"charged cursors exclude protected enemy categories");g_ram[victim+0x28]=enemy[0x28]; + unsigned bee=8; + for(unsigned i=0;i<120 && bee==8;++i) {frame(SNES_PAD_Y);bee=parasitic_test_slot(MmxWeaponsGetCombatState(),3);} + check(bee<8 && MmxWeaponsEnergyAmount(2,2)==26*256,"locked cursor launches a homing unit for one energy"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".parasitic-launch.cap"); + bool impact=false; + for(unsigned i=0;i<100 && !impact;++i) { + frame(0);c=MmxWeaponsGetCombatState();unsigned burst=parasitic_test_slot(c,4); + if(burst<8) {impact=true;check(c.shots[burst].group==23,"homing unit uses X3's shared impact animation");} + } + check(impact && (g_ram[victim+0x27]&127)<16,"homing unit reaches and damages a real native target"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".parasitic-impact.cap"); + n=RtlSaveSnapshotToMemory(start,cap);zero_replay(160);en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore homing unit");zero_replay(160); + an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"homing unit, collision and cursor recovery replay exactly"); + check(!parasitic_test_count(MmxWeaponsGetCombatState(),2) && !g_ram[0xbdd], + "busy cursors finish and clean up after fire is released"); + check(MmxWeaponsEnergyAmount(2,2)==26*256,"released cursors cannot launch additional units"); + puts("MMX PARASITIC BOMB CHECKS PASSED"); +} diff --git a/tests/mmx_weapon_silk_test.inc b/tests/mmx_weapon_silk_test.inc new file mode 100644 index 0000000..b20459a --- /dev/null +++ b/tests/mmx_weapon_silk_test.inc @@ -0,0 +1,105 @@ +static void weapon_silk_checks(const char *assets,const char *fixture,uint8 *start, + uint8 *expected,uint8 *actual,size_t cap) { + check(MmxWeaponsLoad(assets),"Silk Shot original material assets load"); + check(MmxWeaponsPose(1,3,72,5) && MmxWeaponsPose(1,3,72,13) && + MmxWeaponsPose(1,3,73,19) && MmxWeaponsPose(1,3,74,26),"all five original Silk materials are extracted"); + check(memcmp(MmxWeaponsGroupPalette(1,3,72),MmxWeaponsGroupPalette(1,3,73),32) && + memcmp(MmxWeaponsGroupPalette(1,3,72),MmxWeaponsGroupPalette(1,3,74),32), + "leaves and crystal use their original distinct palettes"); + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(fixture),"restore Silk Shot fixture");if(character) zero_health_swap(); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=3;MmxWeaponsSetState(w); + frame(SNES_PAD_Y);frame(0);MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) {slot=i;break;} + check(slot<8 && c.shots[slot].vx==512 && c.shots[slot].vy<0 && c.shots[slot].variant==1 && + MmxWeaponsEnergyAmount(1,3)==27*256,"normal Silk launches the metal form on the highway for one energy"); + frame(SNES_PAD_Y);frame(0);check(extended_shots(false)==1,"source normal Silk firing limit is one"); + size_t n=RtlSaveSnapshotToMemory(start,cap);zero_replay(18);size_t en=RtlSaveSnapshotToMemory(expected,cap); + check(RtlLoadSnapshotFromMemory(start,n),"restore normal Silk arc");zero_replay(18); + size_t an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"normal Silk arc and animation replay exactly"); + unsigned bounces=0;bool fragments=false;int previous_vy=MmxWeaponsGetCombatState().shots[slot].vy; + for(unsigned i=0;i<170;++i) { + frame(SNES_PAD_RIGHT);c=MmxWeaponsGetCombatState(); + if(c.shots[slot].active && c.shots[slot].muzzle_pose!=3) { + bounces+=previous_vy>0 && c.shots[slot].vy<0;previous_vy=c.shots[slot].vy; + } + unsigned scattered=0;for(unsigned j=0;j<8;++j) scattered+=c.shots[j].active && c.shots[j].muzzle_pose==3; + if(scattered && !fragments) { + fragments=true;zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-silk-fragments.cap":".zero-silk-fragments.cap"); + } + } + check(bounces>=1 && fragments && !g_ram[0xbdd],"normal Silk bounces on X1 ground, scatters four pieces and cleans up"); + for(unsigned i=0;i<220;++i) frame(SNES_PAD_Y);frame(0); + check(!extended_shots(true),"Silk gathering requires X1 arms"); + check(RtlLoadSnapshot(fixture),"restore charged Silk fixture");if(character) zero_health_swap(); + w=MmxWeaponsGetState();w.page=1;w.weapon=3;MmxWeaponsSetState(w);g_ram[0x1f99]|=2; + bool gathering=false; + for(unsigned i=0;i<220;++i) { + frame(SNES_PAD_Y);c=MmxWeaponsGetCombatState(); + for(unsigned j=0;j<8;++j) if(c.shots[j].active && c.shots[j].charged && !c.shots[j].muzzle_pose) gathering=true; + } + c=MmxWeaponsGetCombatState();unsigned held=0; + for(unsigned i=0;i<8;++i) held+=c.shots[i].active && c.shots[i].charged && c.shots[i].muzzle_pose==1; + check(gathering && held==8 && MmxWeaponsEnergyAmount(1,3)==27*256, + "full charge draws eight original pieces into a held cluster without spending charged energy yet"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-silk-held.cap":".zero-silk-held.cap"); + n=RtlSaveSnapshotToMemory(start,cap);for(unsigned i=0;i<12;++i) frame(SNES_PAD_Y|SNES_PAD_RIGHT); + en=RtlSaveSnapshotToMemory(expected,cap);check(RtlLoadSnapshotFromMemory(start,n),"restore held Silk cluster"); + for(unsigned i=0;i<12;++i) frame(SNES_PAD_Y|SNES_PAD_RIGHT); + an=RtlSaveSnapshotToMemory(actual,cap);same(expected,en,actual,an,"moving held Silk formation replays exactly"); + frame(0);frame(0);c=MmxWeaponsGetCombatState();unsigned flying=0; + for(unsigned i=0;i<8;++i) flying+=c.shots[i].active && c.shots[i].charged && c.shots[i].muzzle_pose==2 && c.shots[i].vx==1024; + check(flying==8 && MmxWeaponsEnergyAmount(1,3)==25*256 && !(g_ram[0xc2f]&64), + "release launches eight pieces at source speed for two energy and stops charge audio"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".x-silk-charged.cap":".zero-silk-charged.cap"); + bool burst=false; + for(unsigned i=0;i<105;++i) {frame(0);c=MmxWeaponsGetCombatState();for(unsigned j=0;j<8;++j) burst|=c.shots[j].active && c.shots[j].muzzle_pose==3;} + check(burst && !g_ram[0xbdd] && !extended_shots(true),"charged Silk disperses on X1 terrain and releases native slots"); + } + check(RtlLoadSnapshot(fixture),"restore first-full-tick Silk release"); + MmxWeaponsState edge=MmxWeaponsGetState();edge.page=1;edge.weapon=3;MmxWeaponsSetState(edge);g_ram[0x1f99]|=2; + for(unsigned i=0;i<250 && g_ram[0xc03]!=1;++i) frame(SNES_PAD_Y); + check(g_ram[0xc03]==1 && !extended_shots(true),"native full charge is reached before the gathering hook"); + frame(0);frame(0); + check(extended_shots(true)==8 && MmxWeaponsEnergyAmount(1,3)==25*256, + "releasing on the first full-charge tick launches all pieces without losing the charged shot"); + MmxWeaponsCancelShots(g_ram); + check(RtlLoadSnapshot(fixture),"restore leaf-material fixture"); + MmxWeaponsState leaves=MmxWeaponsGetState();leaves.page=1;leaves.weapon=3;MmxWeaponsSetState(leaves); + frame(SNES_PAD_Y);frame(0);MmxWeaponCombatState leaf=MmxWeaponsGetCombatState();unsigned leafslot=8; + for(unsigned i=0;i<8;++i) if(leaf.shots[i].active) {leafslot=i;leaf.shots[i].age=0;break;} + check(leafslot<8,"leaf-material source actor available");MmxWeaponsSetCombatState(leaf); + /* Choose Chameleon's material at initialization only. Keep the actual + * highway terrain fixture and native stage scripts intact. */ + unsigned stage=g_ram[0x1f7a];g_ram[0x1f7a]=2;MmxWeaponsProjectileTick(g_ram,0x1228+leafslot*64,1);g_ram[0x1f7a]=(uint8_t)stage; + leaf=MmxWeaponsGetCombatState();check(leaf.shots[leafslot].variant==3 && leaf.shots[leafslot].group==73 && + leaf.shots[leafslot].pose==19,"Chameleon environment selects original green leaves"); + bool trail=false,fall=false; + for(unsigned i=0;i<95;++i) { + frame(0);leaf=MmxWeaponsGetCombatState(); + for(unsigned j=0;j<16;++j) trail|=leaf.effects[j].active && leaf.effects[j].weapon==3; + for(unsigned j=0;j<8;++j) fall|=leaf.shots[j].active && leaf.shots[j].muzzle_pose==3; + if(i==24) zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".silk-leaves.cap"); + } + leaf=MmxWeaponsGetCombatState();unsigned particles=0;for(unsigned i=0;i<16;++i) particles+=leaf.effects[i].active; + check(trail && fall && !particles && !g_ram[0xbdd],"leaf burst curves, sheds original animated leaves and expires without slot leaks"); + /* Native collision, rather than a direct HP edit, must invoke the port. */ + check(RtlLoadSnapshot(fixture),"restore Silk enemy encounter");unsigned victim=0; + for(unsigned i=0;i<400 && !victim;++i) { + frame(SNES_PAD_RIGHT); + for(unsigned d=0xe68;d<0x1228;d+=64) + if(g_ram[d] && (g_ram[d+0x27]&127) && (g_ram[d+0x20]|g_ram[d+0x21])) {victim=d;break;} + } + check(victim!=0,"native enemy available for Silk collision"); + MmxWeaponsState w=MmxWeaponsGetState();w.page=1;w.weapon=3;MmxWeaponsSetState(w); + frame(0);frame(SNES_PAD_Y);frame(0);MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) { + c.shots[i].x=(g_ram[victim+5]|g_ram[victim+6]<<8)*256;c.shots[i].y=(g_ram[victim+8]|g_ram[victim+9]<<8)*256; + c.shots[i].vx=c.shots[i].vy=0;slot=i;break; + } + check(slot<8,"Silk is available for native contact");g_ram[victim+0x27]=32;MmxWeaponsSetCombatState(c); + zero_replay(3);check((g_ram[victim+0x27]&127)<=27,"normal Silk deals five source-scaled buster hits through native collision"); + check(extended_shots(false)>1,"native enemy contact disperses normal Silk"); + MmxWeaponsCancelShots(g_ram); + puts("MMX SILK SHOT CHECKS PASSED"); +} diff --git a/tests/mmx_weapon_stage_test.inc b/tests/mmx_weapon_stage_test.inc new file mode 100644 index 0000000..37b90b7 --- /dev/null +++ b/tests/mmx_weapon_stage_test.inc @@ -0,0 +1,78 @@ +/* Optional owner-provided stage fixtures, copied into a private test folder. + * The shared state menu/OSD uses zero-based labels: Slot 6 is save6.sav. */ +static void weapon_stage_checks(const char *assets) { + check(MmxWeaponsLoad(assets),"stage interaction assets load"); + const char *ride=getenv("MMX_RIDE_FIXTURE"),*igloo=getenv("MMX_IGLOO_FIXTURE"); + const char *foreground=getenv("MMX_FOREGROUND_FIXTURE"); + if(foreground) for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(foreground),"private canister foreground fixture loads"); + if(character) zero_health_swap(); + zero_replay(2); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".canister-x.cap":".canister-zero.cap"); + } + if(ride) { + check(RtlLoadSnapshot(ride),"private Ride Armor fixture loads"); + for(unsigned i=0;i<100;++i) { + frame(i<60?SNES_PAD_LEFT:0); + if(i==40) zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".ride-enter.cap"); + } + check(g_ram[0xbbe]==0x6b && g_ram[0xbaa]==0x2c,"native boarding reaches Ride Armor pilot group"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".ride-idle.cap"); + for(unsigned i=0;i<25;++i) frame(SNES_PAD_RIGHT); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".ride-walk.cap"); + frame(SNES_PAD_Y);zero_replay(8); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".ride-punch.cap"); + frame(SNES_PAD_UP|SNES_PAD_B);zero_replay(25); + check(g_ram[0xbbe]!=0x6b,"jumping out restores the normal player sprite group"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".ride-exit.cap"); + } + if(igloo) { + for(unsigned character=0;character<2;++character) { + check(RtlLoadSnapshot(igloo),"private Penguin igloo fixture loads"); + if(character) zero_health_swap(); + MmxWeaponsCancelShots(g_ram);MmxWeaponsState w=MmxWeaponsGetState(); + w.page=1;w.weapon=8;MmxWeaponsSetState(w);MmxWeaponsRefill(); + for(unsigned i=0;i<60;++i) frame(SNES_PAD_RIGHT); + bool destroyed=false;unsigned target=0; + for(unsigned d=0xe68;d<0x1228;d+=64) if(g_ram[d] && g_ram[d+10]==0x57 && + (g_ram[d+5]|g_ram[d+6]<<8)>(g_ram[0xbad]|g_ram[0xbae]<<8)) {target=d;break;} + check(target!=0,"original Penguin bunker is in front of the player"); + for(unsigned i=0;i<240 && !destroyed;++i) { + g_ram[0xbd8]=60; /* Player +$30: isolate contact from the fixture's flyers. */ + frame((i%25==0)?SNES_PAD_Y:0); + destroyed=g_ram[target+1]==8 || g_ram[target+0x3a]!=0 || !g_ram[target]; + } + check(destroyed,"Speed Burner invokes native bunker destruction for both characters"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),character?".igloo-x.cap":".igloo-zero.cap"); + } + } + const char *boss=getenv("MMX_ARMADILLO_FIXTURE"); + if(boss) { + check(RtlLoadSnapshot(boss),"private Armadillo fixture loads"); + const unsigned enemy=0xe68; + check(g_ram[enemy+10]==0x14,"original Armadillo actor is present"); + MmxWeaponsCancelShots(g_ram);MmxWeaponsState w=MmxWeaponsGetState(); + w.page=2;w.weapon=3;MmxWeaponsSetState(w);MmxWeaponsRefill(); + /* Restore armor on this old damaged-boss fixture, then let native AI + * and collision own the actual hit and break sequence. */ + g_ram[enemy+0x33]=g_ram[enemy+0x38]=g_ram[enemy+0x37]=0; + g_ram[enemy+0x27]=g_ram[enemy+0x39]=32;g_ram[enemy+2]=4;g_ram[enemy+3]=0; + frame(0);frame(SNES_PAD_Y);zero_replay(2); + MmxWeaponCombatState c=MmxWeaponsGetCombatState();unsigned slot=8; + for(unsigned i=0;i<8;++i) if(c.shots[i].active) {slot=i;break;} + check(slot<8,"Triad orb available for armor contact"); + for(unsigned i=0;i<8;++i) if(i!=slot) {memset(&c.shots[i],0,sizeof(c.shots[i]));memset(g_ram+0x1228+i*64,0,64);} + c.shots[slot].variant=6;c.shots[slot].muzzle_pose=1;c.shots[slot].radius=0; + c.shots[slot].x=(g_ram[enemy+5]|g_ram[enemy+6]<<8)*256; + c.shots[slot].y=(g_ram[enemy+8]|g_ram[enemy+9]<<8)*256; + c.shots[slot].vx=c.shots[slot].vy=0;MmxWeaponsSetCombatState(c); + bool breaking=false; + for(unsigned i=0;i<8;++i) { + frame(0);if(g_ram[enemy+2]==8 && g_ram[enemy+3]==2) breaking=true; + } + check(breaking,"Triad contact invokes Armadillo's native electric armor-break action"); + check((g_ram[enemy+39]&127)==29,"Triad uses Electric Spark's three-damage weakness hit"); + zero_capture(getenv("MMX_ZERO_TEST_CAPTURE"),".armadillo-break.cap"); + } + puts("MMX STAGE INTERACTION CHECKS PASSED"); +} diff --git a/tests/mmx_zero_test.c b/tests/mmx_zero_test.c index d497b3a..9ed9c5a 100644 --- a/tests/mmx_zero_test.c +++ b/tests/mmx_zero_test.c @@ -147,8 +147,12 @@ int main(void) { 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; + /* The allocator can put stale charge effects anywhere in this pool. */ + ram[0xc98]=ram[0xd58]=1; ram[0xca2]=ram[0xd62]=1; + ram[0xd78]=1; ram[0xd82]=2; /* An unrelated small effect must survive. */ MmxZeroCancel(ram); MmxZeroCancel(ram); assert(ram[0xba3]==2 && ram[0xb72]==0x17 && !(ram[0xc2f]&64)); + assert(!ram[0xc98] && !ram[0xd58] && ram[0xd78]); ram[0xc2f]|=64; MmxZeroCancel(ram); assert(ram[0xba3]==2 && (ram[0xc2f]&64)); /* Native special charge is untouched. */ MmxZeroDisable(); MmxZeroSetCollisionRom(rom,sizeof(rom)); diff --git a/tools/apply_weapon_hooks.py b/tools/apply_weapon_hooks.py new file mode 100644 index 0000000..0a54676 --- /dev/null +++ b/tools/apply_weapon_hooks.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Keep extended pause-page choices separate from the native X1 inventory.""" +import argparse +from pathlib import Path +import re + +MARKER = '/*MMX-WEAPONS*/' +INVENTORY = {0xc792, 0xc7b5, 0xc805, 0xc826, 0xc865, 0xc886} +SELECTION = {0xc484, 0xc767, 0xce28, 0xce33} +ENERGY_READ = {0xd907,0xd917,0xd940,0xd94a,0xd9a4,0x81e053,0x81e062,0x81e0a0} +ENERGY_STORE = {0xd917,0xd95c,0xd9a4,0x81e0cb} +REQUIRED = INVENTORY | SELECTION | ENERGY_READ | ENERGY_STORE | {0xc67f,0x819d47,0x9ef9,0x81e169} + + +def apply(text): + output, found, pc = [], set(), 0 + for line in text.splitlines(keepends=True): + if MARKER in line: + continue + block = re.search(r'cpu_trace_block\(cpu, 0x([0-9A-Fa-f]+)\);', line) + if block: + pc = int(block[1], 16) + energy_store = pc in ENERGY_STORE and 'cpu_write' in line and ('0x1f86' in line or '0x1f85' in line) + if energy_store: + value = re.search(r', (_v\d+)\);',line)[1] + output.append(f' {MARKER} {{ extern bool MmxWeaponsEnergyStore(unsigned, bool); if (!MmxWeaponsEnergyStore({value}, {str(pc == 0x81e0cb).lower()}))\n') + output.append(line) + if energy_store: + output.append(f' {MARKER} }}\n') + found.add(pc) + if pc in (0x9ef9,0x81e169) and 'cpu->coprocessor_master_cycles = cpu->master_cycles;' in line: + call = 'extern void MmxWeaponsRefill(void); MmxWeaponsRefill();' if pc == 0x9ef9 else 'extern uint8_t g_ram[0x20000]; extern void MmxWeaponsEnergyOverflow(uint8_t *, unsigned); MmxWeaponsEnergyOverflow(g_ram, cpu->X);' + output.append(f' {MARKER} {{ {call} }}\n') + found.add(pc) + energy_load = re.search(r'uint(8|16) (_v\d+) = cpu_read',line) if pc in ENERGY_READ else None + if energy_load: + address = next((a for a in ('0x0bdb','0x1f85','0x1f86') if a in line),None) + if address: + output.append(f' {MARKER} {{ extern unsigned MmxWeaponsEnergyRead(unsigned, unsigned); {energy_load[2]} = (uint{energy_load[1]})MmxWeaponsEnergyRead({address}, {energy_load[2]}); }}\n') + found.add(pc) + if pc == 0x819d47 and 'cpu->coprocessor_master_cycles = cpu->master_cycles;' in line: + output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern void MmxWeaponsMarkShot(uint8_t *, unsigned); MmxWeaponsMarkShot(g_ram, cpu->X); }}\n') + found.add(pc) + if pc == 0xc67f and 'cpu->coprocessor_master_cycles = cpu->master_cycles;' in line: + output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern void MmxWeaponsMenuTick(uint8_t *, unsigned); MmxWeaponsMenuTick(g_ram, cpu->D); }}\n') + found.add(pc) + load = re.search(r'uint8 (_v\d+) = cpu_read8\(', line) + if not load: + continue + selected = (pc in INVENTORY and '0x1f88' in line) or ( + pc in SELECTION and ('0x0bdb' in line or (pc == 0xce33 and '0x000a' in line))) + if selected: + output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern unsigned MmxWeaponsMenuRead(uint8_t *, unsigned, unsigned, unsigned, unsigned); {load[1]} = (uint8)MmxWeaponsMenuRead(g_ram, 0x{pc:06x}, cpu->D, cpu->X, {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('bank*.c'): + text = path.read_text(encoding='utf-8') + if not any(f'0x{pc:06X}' in text for pc in REQUIRED): + 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 extended weapon hooks: {REQUIRED - found}') + print(f'MMX weapons: {len(found)} menu/combat sites verified') + + +if __name__ == '__main__': + main() diff --git a/tools/apply_zero_hooks.py b/tools/apply_zero_hooks.py index ecc8945..122eb85 100644 --- a/tools/apply_zero_hooks.py +++ b/tools/apply_zero_hooks.py @@ -9,7 +9,8 @@ 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} +REQUIRED = PCS | MUZZLE_PCS | ORIGIN_PCS.keys() | {0x009da6, 0x81815c, 0x818165, 0x00d3e5, 0x849e73, 0x849c16, 0x848f07, 0x848eea, 0x8491db, 0x82823e, 0x8194af} +OPTIONAL = {0x00d4f2, 0x00d50f} # Current enemy loops run through the interpreter. def apply(text): @@ -26,6 +27,20 @@ def apply(text): output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern unsigned MmxZeroWeaponOrigin(const uint8_t *, unsigned, unsigned, unsigned); {store[2]} = (uint16)MmxZeroWeaponOrigin(g_ram, cpu->D, {axis}, {store[2]}); cpu_write_a_m(cpu, {store[2]}); }}\n') found.add(pc) output.append(line) + if pc in (0x00d4f2,0x00d50f): + 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 MmxWeaponsEnemyActive(const uint8_t *, unsigned, unsigned); {load[1]} = (uint8)MmxWeaponsEnemyActive(g_ram,cpu->D,{load[1]}); }}\n') + found.add(pc) + if pc == 0x8194af and 'cpu->coprocessor_master_cycles = cpu->master_cycles;' in line: + output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern void MmxWeaponsSelectShot(const uint8_t *, unsigned); MmxWeaponsSelectShot(g_ram,cpu->X); }}\n') + found.add(pc) + if pc == 0x82823e and 'cpu->coprocessor_master_cycles = cpu->master_cycles;' in line: + output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern void MmxWeaponsPlayerMotion(uint8_t *, unsigned); MmxWeaponsPlayerMotion(g_ram,cpu->D); }}\n') + found.add(pc) + if pc == 0x8491db and 'cpu->D = cpu_read16(cpu, 0x00, cpu->S);' in line: + output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern void MmxWeaponsTerrainEnd(uint8_t *, unsigned); MmxWeaponsTerrainEnd(g_ram,cpu->D); }}\n') + found.add(pc) 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) @@ -44,10 +59,10 @@ def apply(text): 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') + output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern unsigned MmxZeroHitbox(const uint8_t *, unsigned, unsigned, unsigned); extern unsigned MmxWeaponsHitbox(const uint8_t *, unsigned, unsigned, unsigned); {load[1]} = (uint16)MmxWeaponsHitbox(g_ram, cpu->D, cpu->X, 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') + output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern void MmxZeroPlayerTick(uint8_t *); extern void MmxWeaponsPlayerTick(uint8_t *); extern bool MmxWeaponsCombatActive(void); MmxWeaponsPlayerTick(g_ram); if (!MmxWeaponsCombatActive()) 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') @@ -55,13 +70,13 @@ def apply(text): 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') + output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern unsigned MmxZeroWeaponTick(uint8_t *, unsigned, unsigned); extern unsigned MmxWeaponsProjectileTick(uint8_t *, unsigned, unsigned); {load[1]} = (uint8)MmxWeaponsProjectileTick(g_ram, cpu->D, 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') + output.append(f' {MARKER} {{ extern uint8_t g_ram[0x20000]; extern unsigned MmxZeroDamage(uint8_t *, unsigned, unsigned, unsigned); extern unsigned MmxWeaponsDamage(uint8_t *, unsigned, unsigned, unsigned); {variable} = (uint8)MmxWeaponsDamage(g_ram, cpu->D, cpu->X, 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) @@ -78,13 +93,13 @@ def main(): 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}): + if not any(f'0x{pc:06X}' in text for pc in REQUIRED | OPTIONAL | {0x849e6e}): continue updated, sites = apply(text) found |= sites if updated != text: path.write_text(updated, encoding='utf-8', newline='\n') - if found != REQUIRED: + if REQUIRED - found: raise SystemExit(f'Missing Zero hooks: {REQUIRED - found}') print(f'MMX Zero: {len(found)} capability/combat sites verified') diff --git a/tools/data/x_weapon_assets.json b/tools/data/x_weapon_assets.json new file mode 100644 index 0000000..2120dec --- /dev/null +++ b/tools/data/x_weapon_assets.json @@ -0,0 +1,409 @@ +[ + { + "game": 2, + "weapon": 1, + "name": "Crystal Hunter", + "sha256": "f3246755f608a1e1dc9c848b61da3b824c7853b29b3be40df6fc7f2793a887ed", + "groups": [ + { + "group": 16, + "frames": 16, + "dma": "859d28", + "setup_poses": [ + 3 + ] + } + ], + "body_palette": "2cca0", + "weapon_palette": "2b380" + }, + { + "game": 2, + "weapon": 2, + "name": "Bubble Splash", + "sha256": "f3246755f608a1e1dc9c848b61da3b824c7853b29b3be40df6fc7f2793a887ed", + "groups": [ + { + "group": 68, + "frames": 32, + "dma": "859d7a" + } + ], + "body_palette": "2cc60", + "weapon_palette": "2ba60" + }, + { + "game": 2, + "weapon": 3, + "name": "Silk Shot", + "sha256": "f3246755f608a1e1dc9c848b61da3b824c7853b29b3be40df6fc7f2793a887ed", + "groups": [ + { + "group": 72, + "source_group": 72, + "frames": 30, + "dma": "859fef", + "poses": [ + 0, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18 + ], + "palette": "2bae0", + "note": "Original stone, metal and scrap forms." + }, + { + "group": 73, + "source_group": 72, + "frames": 30, + "dma": "859fef", + "poses": [ + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "palette": "2bac0", + "note": "Original leaf form and palette." + }, + { + "group": 74, + "source_group": 72, + "frames": 30, + "dma": "859fef", + "poses": [ + 26, + 27, + 28, + 29 + ], + "palette": "2bb00", + "note": "Original crystal form and palette." + } + ], + "body_palette": "2ccc0", + "weapon_palette": "2bae0" + }, + { + "game": 2, + "weapon": 4, + "name": "Spin Wheel", + "sha256": "f3246755f608a1e1dc9c848b61da3b824c7853b29b3be40df6fc7f2793a887ed", + "groups": [ + { + "group": 70, + "frames": 27, + "dma": "859f2e" + } + ], + "body_palette": "2cd00", + "weapon_palette": "2ba80" + }, + { + "game": 2, + "weapon": 5, + "name": "Sonic Slicer", + "sha256": "f3246755f608a1e1dc9c848b61da3b824c7853b29b3be40df6fc7f2793a887ed", + "groups": [ + { + "group": 65, + "frames": 23, + "dma": "859f83" + }, + { + "group": 135, + "frames": 19, + "dma": "859fbe", + "inherited_poses": [18], + "note": "Impact pose 18 is the inherited blank tile $45; the native DMA directory has only 18 entries." + } + ], + "body_palette": "2cc40", + "weapon_palette": "2ba00" + }, + { + "game": 2, + "weapon": 6, + "name": "Strike Chain", + "sha256": "f3246755f608a1e1dc9c848b61da3b824c7853b29b3be40df6fc7f2793a887ed", + "groups": [ + { + "group": 71, + "frames": 24, + "dma": "85a068" + } + ], + "body_palette": "2cce0", + "weapon_palette": "2baa0" + }, + { + "game": 2, + "weapon": 7, + "name": "Magnet Mine", + "sha256": "f3246755f608a1e1dc9c848b61da3b824c7853b29b3be40df6fc7f2793a887ed", + "static_resource": 10, + "static_offset": 4096, + "groups": [ + { + "group": 15, + "frames": 16, + "dma": "859bfc", + "inherited_poses": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + "note": "Normal mine uses weapon-selection CHR throughout. $82:AC19..AD36 never invokes per-pose DMA; its inherited $9BFC pointer is unrelated common-effect data." + }, + { + "group": 19, + "frames": 12, + "dma": "85a0b1" + }, + { + "group": 8, + "frames": 14, + "dma": "859bfc", + "palette": "2b220", + "poses": [5, 6, 7, 8, 9, 10, 11, 12, 13], + "inherited_poses": [5, 6, 7, 8, 9, 10, 11, 12, 13] + } + ], + "body_palette": "2cc20", + "weapon_palette": "2b3a0" + }, + { + "game": 2, + "weapon": 8, + "name": "Speed Burner", + "sha256": "f3246755f608a1e1dc9c848b61da3b824c7853b29b3be40df6fc7f2793a887ed", + "static_resource": 10, + "static_offset": 4096, + "groups": [ + { + "group": 37, + "frames": 17, + "dma": "85a139" + }, + { + "group": 38, + "frames": 3, + "dma": "85a1aa" + }, + { + "group": 23, + "frames": 32, + "dma": "85a139", + "poses": [3, 4, 5, 6, 7, 31], + "inherited_poses": [3, 4, 5, 6, 7, 31], + "animation_sequence": 0, + "palette": "2b220" + } + ], + "body_palette": "2cc80", + "weapon_palette": "2b360" + }, + { + "game": 3, + "weapon": 1, + "name": "Acid Burst", + "sha256": "65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7", + "groups": [ + { + "group": 5, + "frames": 57, + "dma": "85de72" + } + ], + "body_palette": "62420", + "weapon_palette": "62fa0" + }, + { + "game": 3, + "weapon": 2, + "name": "Parasitic Bomb", + "sha256": "65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7", + "static_resource": 10, + "static_offset": 4096, + "groups": [ + { + "group": 6, + "frames": 49, + "dma": "85dfdc" + }, + { + "group": 23, + "frames": 3, + "dma": "85dfdc", + "inherited_poses": [0, 1, 2], + "animation_sequence": 1, + "palette": "630e0" + }, + { + "group": 8, + "frames": 14, + "dma": "85dfdc", + "poses": [5, 6, 7, 8, 9, 10, 11, 13], + "inherited_poses": [5, 6, 7, 8, 9, 10, 11, 13], + "animation_sequence": 5, + "palette": "66948" + } + ], + "body_palette": "62440", + "weapon_palette": "62fc0" + }, + { + "game": 3, + "weapon": 3, + "name": "Triad Thunder", + "sha256": "65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7", + "groups": [ + { + "group": 11, + "frames": 48, + "dma": "85e076" + }, + { + "group": 51, + "frames": 42, + "dma": "85d334", + "poses": [29,30,31,32,33,34,39,41], + "animation_sequence": 34, + "palette": "624c0" + } + ], + "body_palette": "624c0", + "weapon_palette": "62fe0", + "additional_dma": [ + "869976" + ] + }, + { + "game": 3, + "weapon": 4, + "name": "Spinning Blade", + "sha256": "65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7", + "groups": [ + { + "group": 12, + "frames": 74, + "dma": "85e0e4" + } + ], + "body_palette": "624e0", + "weapon_palette": "63000" + }, + { + "game": 3, + "weapon": 5, + "name": "Ray Splasher", + "sha256": "65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7", + "groups": [ + { + "group": 13, + "frames": 32, + "dma": "85e2dc" + } + ], + "body_palette": "62500", + "weapon_palette": "63020" + }, + { + "game": 3, + "weapon": 6, + "name": "Gravity Well", + "sha256": "65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7", + "groups": [ + { + "group": 15, + "frames": 31, + "dma": "85e31d" + }, + { + "group": 51, + "frames": 42, + "dma": "85d334", + "poses": [29,35,36,37,38,39,41], + "animation_sequence": 35, + "palette": "62520" + }, + { + "group": 52, + "source_group": 51, + "frames": 48, + "dma": "85d334", + "poses": [40,44,45,46,47], + "animation_sequence": 36, + "palette": "62520" + } + ], + "body_palette": "62520", + "weapon_palette": "63040", + "additional_dma": ["869976"] + }, + { + "game": 3, + "weapon": 7, + "name": "Frost Shield", + "sha256": "65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7", + "groups": [ + { + "group": 16, + "frames": 63, + "dma": "85e485" + } + ], + "body_palette": "62540", + "weapon_palette": "63060" + }, + { + "game": 3, + "weapon": 8, + "name": "Tornado Fang", + "sha256": "65b03268afac296330e8ff8d60dd0825879e13ed658b37713c034a3bd074f1d7", + "static_resource": 10, + "static_offset": 4096, + "groups": [ + { + "group": 19, + "frames": 66, + "dma": "85e5a5", + "poses": [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35], + "animated_frame_start": 42, + "animated_layout_start": 12, + "animated_layout_count": 6, + "animated_dma_table": "8697ff" + }, + { + "group": 20, + "source_group": 19, + "frames": 28, + "poses": [20, 21, 22, 23, 24, 25, 26, 27], + "dma": "85e5a5", + "animation_sequence": 11, + "palette": "66948" + }, + { + "group": 8, + "frames": 14, + "dma": "85e5a5", + "poses": [5, 6, 7, 8, 9, 10, 11, 13], + "inherited_poses": [5, 6, 7, 8, 9, 10, 11, 13], + "animation_sequence": 5, + "palette": "66948" + } + ], + "body_palette": "62560", + "weapon_palette": "63080" + } +] diff --git a/tools/extract_x_weapons.py b/tools/extract_x_weapons.py new file mode 100644 index 0000000..6ede023 --- /dev/null +++ b/tools/extract_x_weapons.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Extract original X2/X3 weapon art from locally supplied USA ROMs. + +The descriptor contains ROM addresses, never distributed game art. Static CHR +bindings and palette addresses were measured at native weapon selection; pose +layouts and per-pose DMA lists are decoded directly from each original ROM. +""" +import argparse +import hashlib +import json +from pathlib import Path +import struct + + +class Rom: + def __init__(self, path, digest): + self.data = Path(path).read_bytes() + if len(self.data) % 32768 == 512: + self.data = self.data[512:] + if hashlib.sha256(self.data).hexdigest() != digest: + raise ValueError(f'Incorrect original USA ROM: {path}') + + def at(self, address, size): + if address & 65535 < 32768: + raise ValueError(f'Invalid ROM address {address:06x}') + return self.raw(((address >> 16) & 127) * 32768 + (address & 32767), size) + + def raw(self, offset, size): + result = self.data[offset:offset + size] + if len(result) != size: + raise ValueError('ROM read exceeds file') + return result + + def integer(self, address, size=2): + return int.from_bytes(self.at(address, size), 'little') + + +def transfers(rom, table, pose, tiles, known): + address = (table & 0xff0000) | ((table + rom.integer(table + pose * 2)) & 65535) + for _ in range(64): + count = rom.integer(address, 1) + if not count: + return + source = rom.integer(address + 1, 3) + target = rom.integer(address + 4) + start, length = ((target & 32767) - 0x6000) * 2, count * 16 + if start < 0 or start + length > len(tiles): + raise ValueError(f'Invalid DMA {table:06x}/{pose}: {start}/{length}') + tiles[start:start + length] = rom.at(source, length) + known[start:start + length] = b'\1' * length + if target & 32768: + return + address += 6 + raise ValueError('Unterminated DMA list') + + +def bulk_transfers(rom, address, tiles, known): + for _ in range(64): + length = rom.integer(address) + if length & 1: + return + start = (rom.integer(address + 2) - 0x6000) * 2 + source = rom.integer(address + 4, 3) + if start < 0 or start + length > len(tiles): + raise ValueError('Invalid weapon-selection DMA') + tiles[start:start + length] = rom.at(source, length) + known[start:start + length] = b'\1' * length + address += 7 + raise ValueError('Unterminated weapon-selection DMA list') + + +def pose_art(rom, group, pose, tiles, known): + table = rom.integer(0x8d8000 + group * 3, 3) + address = rom.integer(table + pose * 3, 3) + count = rom.integer(address, 1) + if count > 64: + raise ValueError('Invalid sprite layout') + pieces = list(struct.iter_unpack(' 256 or height > 256: + raise ValueError('Sprite exceeds extraction bounds') + pixels = bytearray(width * height) + for flags, x, y, tile in reversed(pieces): + if flags & 14: + raise ValueError(f'Unexpected extra palette in {group:02x}/{pose}: {flags:02x}') + size = 16 if flags & 32 else 8 + for dy in range(size): + for dx in range(size): + tx = size - 1 - dx if flags & 64 else dx + ty = size - 1 - dy if flags & 128 else dy + number = (((tile >> 4) + ty // 8) & 15) * 16 + ((tile + tx // 8) & 15) + bits, shift = number * 32 + (ty & 7) * 2, 7 - (tx & 7) + offsets = [bits, bits + 1, bits + 16, bits + 17] + if not all(known[o] for o in offsets): + raise ValueError(f'Unresolved inherited CHR {group:02x}/{pose:02x} tile {number:02x}') + color = sum(((tiles[o] >> shift) & 1) << p for p, o in enumerate(offsets)) + if color: + pixels[(y - top + dy) * width + x - left + dx] = color + return left, top, width, height, bytes(pixels) + + +def menu_graphics(rom, game, resource=0x4c): + # Original resource $4C; X2 $80:B25E and X3 $80:B730 decode these LZ records. + table = 0x86fa01 if game == 2 else 0x86f732 + record = table + resource * 5 + source, length = rom.integer(record, 3), rom.integer(record + 3) + pos = ((source >> 16) & 127) * 32768 + (source & 32767) + result = bytearray() + while len(result) < length: + control = rom.raw(pos, 1)[0] + pos += 1 + for bit in (128, 64, 32, 16, 8, 4, 2, 1): + if len(result) == length: + break + if control & bit: + a, b = rom.raw(pos, 2) + pos += 2 + count, distance = a >> 2, ((a & 3) << 8) | b + if not count or not distance or distance > len(result) or len(result) + count > length: + raise ValueError('Invalid menu graphics backreference') + for _ in range(count): + result.append(result[-distance]) + else: + result.extend(rom.raw(pos, 1)) + pos += 1 + return result + + +def menu_icon(rom, game, weapon, graphics): + # Both source menus follow native weapon IDs; X3 ID 2 is Parasitic Bomb + # and ID 7 is Frost Shield, matching their projectile groups/palettes. + tile = 0x30 + weapon * 2 if weapon < 8 else 0x50 + offset = -0x200 if game == 2 else 0 + pixels = bytearray() + for y in range(16): + for x in range(16): + number = tile + x // 8 + (y // 8) * 16 + start, shift = offset + number * 32 + (y & 7) * 2, 7 - (x & 7) + pixels.append(sum(((graphics[start + q] >> shift) & 1) << p for p, q in enumerate((0, 1, 16, 17)))) + colors = rom.raw(0x2cee0 if game == 2 else 0x62da0, 32) + return colors + pixels + + +def hud_icon(game, tiles, known): + # Native OAM slot 7: X2 $3628, X3 $36AC. These are dedicated 16x16 + # gameplay footers uploaded by the weapon-selection bulk DMA list. + tile = 0x28 if game == 2 else 0xac + pixels = bytearray() + for y in range(16): + for x in range(16): + start = (tile + x // 8 + y // 8 * 16) * 32 + (y & 7) * 2 + offsets = (start, start + 1, start + 16, start + 17) + if not all(known[o] for o in offsets): + raise ValueError('Unresolved original gameplay HUD graphics') + pixels.append(sum(((tiles[o] >> (7 - (x & 7))) & 1) << p + for p, o in enumerate(offsets))) + return pixels + + +def animation_group(rom, game, group, sequence=None): + root = 0x2fa000 if game == 2 else 0x3f8000 + base = rom.integer(root + group * 3, 3) + header = rom.integer(base) + if not header or header & 1 or header > 1024: + raise ValueError('Invalid animation sequence directory') + extent, visited = header, set() + if sequence is not None and not 0 <= sequence * 2 < header: + raise ValueError('Invalid selected animation sequence') + pending = [rom.integer(base + i) for i in (range(0, header, 2) if sequence is None else [sequence * 2])] + while pending: + offset = pending.pop() + if offset in visited: + continue + if offset < header or offset > 8192: + raise ValueError('Animation sequence exceeds group bounds') + visited.add(offset) + duration, flags, pose = rom.at(base + offset, 3) + if not duration or pose >= 128: + raise ValueError('Invalid animation record') + end = offset + 3 + if flags & 128: + displacement = int.from_bytes(rom.at(base + end, 2), 'little', signed=True) + pending.append(end + displacement) + extent = max(extent, end + 2) + else: + pending.append(end) + extent = max(extent, end) + return rom.at(base, extent) + + +def extract(x2, x3): + entries = json.loads((Path(__file__).parent / 'data/x_weapon_assets.json').read_text()) + roms = {game: Rom(path, next(e['sha256'] for e in entries if e['game'] == game)) + for game, path in ((2, x2), (3, x3))} + menus = {game: menu_graphics(rom, game) for game, rom in roms.items()} + result = bytearray(struct.pack('<8sI', b'MMXWEAP5', len(entries))) + sheets = [] + for entry in entries: + rom = roms[entry['game']] + colors = rom.raw(int(entry['weapon_palette'], 16), 32) + result.extend(struct.pack('<4B', entry['game'], entry['weapon'], len(entry['groups']), 0)) + result.extend(rom.raw(int(entry['body_palette'], 16), 32) + colors) + result.extend(menu_icon(rom, entry['game'], entry['weapon'], menus[entry['game']])) + base, base_known = bytearray(8192), bytearray(8192) + if 'static_resource' in entry: + common = menu_graphics(rom, entry['game'], entry['static_resource']) + offset = entry['static_offset'] + if offset + len(common) > len(base): + raise ValueError('Static source graphics exceed tile bank') + base[offset:offset+len(common)] = common + base_known[offset:offset+len(common)] = b'\1' * len(common) + bulk_root = 0x869664 if entry['game'] == 2 else 0x8697ad + bulk = 0x860000 | rom.integer(bulk_root + 0x3e + entry['weapon'] * 2) + bulk_transfers(rom, bulk, base, base_known) + for address in entry.get('additional_dma', []): + bulk_transfers(rom, int(address, 16), base, base_known) + result.extend(hud_icon(entry['game'], base, base_known)) + for group in entry['groups']: + tiles, known = bytearray(base), bytearray(base_known) + for pose in group.get('setup_poses', []): + transfers(rom, int(group['dma'], 16), pose, tiles, known) + source_group = group.get('source_group', group['group']) + animation = animation_group(rom, entry['game'], source_group, group.get('animation_sequence')) + result.extend(struct.pack('= group['animated_frame_start'] + if not animated and 'poses' in group and pose not in group['poses']: + result.extend(bytes(8)) + continue + try: + layout = pose + if animated: + phase, orientation = divmod(pose-group['animated_frame_start'], group['animated_layout_count']) + tiles, known = bytearray(base), bytearray(base_known) + bulk_transfers(rom, 0x860000 | rom.integer(int(group['animated_dma_table'],16)+phase*2),tiles,known) + layout = group['animated_layout_start']+orientation + elif pose not in group.get('inherited_poses', []): + transfers(rom, int(group['dma'], 16), pose, tiles, known) + left, top, width, height, pixels = pose_art(rom, source_group, layout, tiles, known) + except ValueError as error: + raise ValueError(f"X{entry['game']} {entry['name']} {group['group']:02x}/{pose:02x}: {error}") from error + result.extend(struct.pack('> 16) & 127) * 32768 + (address & 32767) + decoded = bytearray() + while len(decoded) < length: + control = rom.data[offset] + offset += 1 + for bit in (128, 64, 32, 16, 8, 4, 2, 1): + if len(decoded) == length: + break + if control & bit: + a, b = rom.data[offset:offset + 2] + offset += 2 + count, distance = a >> 2, ((a & 3) << 8) | b + if not count or not 0 < distance <= len(decoded) or len(decoded) + count > length: + raise ValueError('Invalid common graphics backreference') + for _ in range(count): + decoded.append(decoded[-distance]) + else: + decoded.append(rom.data[offset]) + offset += 1 + if length != 4096: + raise ValueError('Unexpected common graphics size') + tiles = bytearray(8192) + tiles[0x1000:0x2000] = decoded + return tiles + + 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): + address = (dma & 0xff0000) | ((dma + rom.integer(dma + number * 2)) & 0xffff) if dma else 0 + for _ in range(32) if dma else (): count = rom.integer(address, 1) if not count: break @@ -74,7 +104,8 @@ def frame(rom, group, number, dma, tiles=None): break address += 6 else: - raise ValueError('Unterminated tile transfer list') + if dma: + 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) @@ -97,7 +128,7 @@ def frame(rom, group, number, dma, tiles=None): 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 + base = 0 if group >= 0x6f else 3 if group == 0x50 else 1 pixels[py * WIDTH + px] = color + (((flags >> 1) & 7) | base) * 16 return pixels @@ -105,6 +136,11 @@ def frame(rom, group, number, dma, tiles=None): def extract(path): rom = Rom(path) colors = palette(rom) + # The stage's common OBJ palette (key $14). Zero's key $D2 is loaded at + # palette 3 by $84:819F, not at palette 0 used by charge motes. + colors[:16] = struct.unpack('<16H', rom.read(0x8cb100, 32)) + # Charge groups $70/$71 OR palette 2 until the stored saber becomes ready. + colors[32:48] = struct.unpack('<16H', rom.read(0x8cb0e0, 32)) poses = [] for group, count, dma in GROUPS: tiles = bytearray(8192) @@ -121,8 +157,14 @@ def extract(path): 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 + # Body palette pairs $86:B3B4: blue, purple, then green; each alternates + # with the base palette every two frames. Green persists with stored saber. + flash = b''.join(rom.read(a, 32) for a in (0x8caf60, 0x8caf80, 0x8ca5e0)) + # All three charge groups share the 22 one-frame poses at $3F:DA87. + tiles = common_tiles(rom) + particles = b''.join(frame(rom, group, n, 0, tiles) for group in (0x6f, 0x70, 0x71) for n in range(22)) + header = struct.pack('<8s6H', b'MMXZERO7', WIDTH, HEIGHT, ORIGIN_X, ORIGIN_Y, 117, 35) + return header + struct.pack('<128H', *colors) + bounds + hud + animation + muzzle + b''.join(poses) + flash + particles, colors, poses def main(): diff --git a/tools/generate_x_weapon_tables.py b/tools/generate_x_weapon_tables.py new file mode 100644 index 0000000..aeda263 --- /dev/null +++ b/tools/generate_x_weapon_tables.py @@ -0,0 +1,26 @@ +"""Compile address-only weapon descriptors into the native ROM extractor.""" +import json +from pathlib import Path +import sys + +entries = json.loads(Path(sys.argv[1]).read_text()) +lines = ['// Generated from address-only metadata; contains no game assets.', + 'static const WeaponSource weapon_sources[] = {'] +for e in entries: + groups = [] + for g in e['groups']: + allowed = sum(1 << p for p in g.get('poses', range(g['frames']))) + inherited = sum(1 << p for p in g.get('inherited_poses', [])) + setup = g.get('setup_poses', []) + groups.append('{%d,%d,0x%s,%d,0x%xULL,0x%xULL,%d,0x%s,%d,%d,%d,%d,0x%s,%d}' % + (g['group'], g['frames'], g['dma'], bool('poses' in g), + allowed if 'poses' in g else 0, inherited, setup[0] if setup else -1, + g.get('palette', e['weapon_palette']),g.get('source_group',g['group']),g.get('animated_frame_start',0), + g.get('animated_layout_start',0),g.get('animated_layout_count',0),g.get('animated_dma_table','0'), + g.get('animation_sequence',-1))) + extra = e.get('additional_dma', ['0']) + lines.append(' {%d,%d,0x%s,0x%s,0x%s,%d,%d,%d,{%s}},' % + (e['game'], e['weapon'], e['body_palette'], e['weapon_palette'], + extra[0], len(groups), e.get('static_resource', -1), e.get('static_offset', 0), ','.join(groups))) +lines.append('};') +Path(sys.argv[2]).write_text('\n'.join(lines) + '\n') diff --git a/tools/mmx_render_capture.c b/tools/mmx_render_capture.c index 257ef41..174965b 100644 --- a/tools/mmx_render_capture.c +++ b/tools/mmx_render_capture.c @@ -1,5 +1,6 @@ #include "mmx_renderer.h" #include "mmx_zero.h" +#include "mmx_weapons.h" #include static bool bmp(const char *path, const uint32_t *pixels, int width) { @@ -13,10 +14,11 @@ 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 && argc != 7) { - fprintf(stderr, "usage: mmx_render_capture capture rom aspect hud(0|1) output.bmp [zero-assets.bin]\n"); return 2; + if (argc < 6 || argc > 8) { + fprintf(stderr, "usage: mmx_render_capture capture rom aspect hud(0|1) output.bmp [zero-assets.bin] [x-weapons.bin]\n"); return 2; } - if (argc == 7 && !MmxZeroLoad(argv[6])) return 2; + if (argc >= 7 && !MmxZeroLoad(argv[6])) return 2; + if (argc == 8 && !MmxWeaponsLoad(argv[7])) return 2; FILE *f = fopen(argv[2], "rb"); if (!f || fseek(f, 0, SEEK_END)) return 2; long size = ftell(f); rewind(f);