diff --git a/CMakeLists.txt b/CMakeLists.txt index c047646..b33ef09 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,6 +42,22 @@ if(BUILD_TESTING) 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) + 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) + 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) + if(MSVC) + target_compile_options(${mmx_test} PRIVATE /UNDEBUG) + else() + target_compile_options(${mmx_test} PRIVATE -UNDEBUG) + endif() + endforeach() + if(NOT WIN32) + target_link_libraries(mmx_renderer_test PRIVATE m) + target_link_libraries(mmx_render_capture PRIVATE m) + endif() endif() include(${SNESRECOMP_ROOT}/runner/runner.cmake) @@ -83,6 +99,8 @@ function(add_mmx_variant TARGET GEN_DIR CFG_INCLUDE_DIR) ${SNESRECOMP_RUNNER_SOURCES} # game-specific runtime (shared across both variants) src/mmx_display.c + src/mmx_renderer.c + src/mmx_render_assets.c src/main.c src/mmx_wide_preview.c src/mmx_wide_policy.c @@ -108,10 +126,23 @@ function(add_mmx_variant TARGET GEN_DIR CFG_INCLUDE_DIR) # pristine generation directory is reused. Rockman X intentionally ships # authentic 4:3 only, so its generated output remains untouched. if(NOT "${TARGET}" STREQUAL "RockmanXSNESRecomp") + # Ninja plans object rebuilds before running the injector. A script + # edit must invalidate the affected bank in this same build. + set_property(SOURCE ${GEN_DIR}/bank00_part0a_v2.c APPEND PROPERTY + OBJECT_DEPENDS ${CMAKE_SOURCE_DIR}/tools/apply_renderer_capture.py) + set_property(SOURCE ${GEN_DIR}/bank82_part07_v2.c APPEND PROPERTY + OBJECT_DEPENDS ${CMAKE_SOURCE_DIR}/tools/apply_overrides.py) + set_property(SOURCE ${GEN_DIR}/bank83_part01_v2.c ${GEN_DIR}/bank83_part0b_v2.c + ${GEN_DIR}/bank87_part04_v2.c ${GEN_DIR}/bank01_v2.c + ${GEN_DIR}/bank81_part0f_v2.c APPEND PROPERTY + OBJECT_DEPENDS ${CMAKE_SOURCE_DIR}/tools/apply_overrides.py) add_custom_target(${TARGET}_widescreen_overrides COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/apply_overrides.py --gen-dir ${GEN_DIR} + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/tools/apply_renderer_capture.py + --gen-dir ${GEN_DIR} WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} COMMENT "Applying ${TARGET} widescreen generated-code overrides" VERBATIM) diff --git a/README.md b/README.md index 8a7de17..efddfa6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # MegaManXSNESRecomp +This branch contains the [custom widescreen renderer spike](docs/custom-renderer-spike.md), +with 16:9, 21:9, 32:9, Adaptive, and HUD anchoring in the widescreen mod. +The linked notes include the isolated launcher, validation and remaining +acceptance work before replacing the legacy renderer. + Static recompilation of *Mega Man X* (SNES) into native C, using the [snesrecomp](https://github.com/mstan/snesrecomp) framework. This repo is the per-game side: the runtime, the recompiled C output, the diff --git a/docs/custom-renderer-spike.md b/docs/custom-renderer-spike.md new file mode 100644 index 0000000..e5b9f74 --- /dev/null +++ b/docs/custom-renderer-spike.md @@ -0,0 +1,835 @@ +# Mega Man X custom renderer spike + +## Current owner playtest checklist (2026-09-14, eleventh batch) + +- [x] F1: show the E-Tank behind the grinder on a fresh launch and save load. +- [x] F2: show the health pickups above X throughout the adaptive range. +- [x] F3: prefill scenery during the vertical drop instead of popping it in. +- [x] F3: keep the grinder visible as it moves away from X. +- [x] F5: preserve Armored Armadillo's damage flashing. +- [x] F6: pillarbox the weapons pause menu to hide garbled side margins. +- [x] New F2: render the complete Launch Octopus boat. +- [x] F9: prevent the next submerged enemy from poking through early. +- [x] F10: keep underwater scenery stable while walking left and right. + +Implemented and verified against isolated saves and targeted regressions below. +These checks are not owner acceptance or full-game qualification. + +## Previous owner playtest checklist (tenth batch) + +- [x] F1: preserve left terrain while the submarine waits underground. +- [x] F1: prevent the right-hand container from duplicating during that wait. +- [x] Verify both remain correct through the submarine's emergence. + +The transition captures and regression test pass; the owner confirmed this fix. + +## Previous owner playtest checklist (ninth batch) + +- [x] F3: fill the remaining missing area farther out in Storm Eagle. +- [x] F4: extend the water tint across the entire adaptive view. +- [x] F5: keep the buried enemy hidden until its original emergence. + +Implemented and verified with the current save fixtures; owner acceptance is +pending. Publication is limited to adaptive widescreen work in this game. + +## Draft integration status + +The spike is built and tested against its existing dependency pins. It has no +snesrecomp source changes; unrelated runtime, audio, and benchmark work in +other checkouts is outside this draft. + +Remote `main` advanced to `91be5ad` during the spike and adopted the shared +desktop host. A read-only merge check found conflicts in `CMakeLists.txt`, +`src/main.c`, `src/mmx_rtl.c`, and the now-removed local `src/opengl.c`. +Before merging this draft, port the custom frame capture, native PPU setup, +1024-pixel presentation capacity, and destination aspect handling into that +host architecture. The draft retains the tested host until that work is done. + +The two branches also independently assigned game save chunk version 2 to +different layouts: this spike marks the streaker migration in the existing +464-byte chunk, while main appends execution and CHR-binding state. Integration +must distinguish both formats and test migration with copies of existing saves. +Do not infer cross-branch save compatibility from the matching version number. + +## Previous owner playtest checklist (eighth batch) + +- [x] F1: replace parked streakers with moving entrances that retain room timing. +- [x] F3: prefill Storm Eagle's background so arrival has no blue gaps. +- [x] F3: show the chain platforms throughout the adaptive view. + +These three fixes have capture/test evidence below; owner playtest acceptance +is still pending. + +## Previous owner playtest checklist (seventh batch) + +- [x] F5: keep restored streaker/light visuals, but prevent attacks outside their room before entry. +- [x] F6: preserve Spark Mandrill's ice covering when Shotgun Ice freezes him. + +## Previous owner playtest checklist (sixth batch) + +- [x] Restore streaker visibility outside the native view; remove mid-screen spawn regression. +- [x] Keep the Heart Tank's colors correct near the wide-view edge. +- [x] F10: defer Spark Mandrill until arena entry, with shared handling for all bosses. + +## Previous owner playtest checklist (fifth batch) + +- [x] F2: Chill Penguin's ice breath and other abilities use the wrong colors. +- [x] F5: streaking enemies activate outside their designated room. +- [x] F6: incoming streaking enemies have malformed or missing light beams. +- [x] F7: the next room's enemy bubble is visible outside its room bounds. +- [x] F9: the heart pickup only appears at the native 4:3 edge. +- [x] F10: an enemy underneath X causes damage while invisible. + +## Previous owner playtest checklist (fourth batch) + +- [x] F1: outdoor pillar uses inverted colors before approach. +- [x] F1: Ride Armor briefly flickers to the wrong palette. +- [x] F2: preserve Adaptive width throughout death and respawn. +- [x] F3: show each boss door in the wide view without duplicate columns. +- [x] F4: Chill Penguin must wait until X enters the room through the second door. + +## Previous owner playtest checklist (third batch) + +- [x] F1: foreground building CHR is scrambled until X moves right. +- [x] F2: foreground building palettes change correct/wrong/correct on approach. +- [x] Reported F4 (save4): parked traffic/foreground palettes change behind X. +- [x] F8 (save7): Vile dialogue repeats into a black area in the left margin. +- [x] F9 (save8): pillarbox the password screen instead of expanding stale maps. +- [x] F7: flying enemy completes its approach/exit before X can engage it. +- [x] New F3: Ride Armor is absent on approach, but appears after death/respawn. + +Track each fix and its validation here. Keep source saves unchanged, sprite +capacity opt-in, and all code on `codex/mmx-custom-renderer-spike`. +Checked means implemented and validated against the captured/simulated cases +below; the next owner playtest remains the acceptance check. + +Branch: `codex/mmx-custom-renderer-spike`, based on `bbaf743`. +Tracking: central Beads `beads-8wg.1.21`. + +The Super Metroid approach works for MMX's Mode-1 stage rendering. This branch +contains a playable prototype, with the old renderer still selectable for +comparison. It is not yet qualified to replace the released renderer. + +## Run the prototype + +From this worktree on Matthew's Windows machine: + +```powershell +.\tools\run_custom_renderer.ps1 +``` + +The launcher opens with a separate `build-custom/playtest` data directory. +On first use, the script enables the widescreen mod with the custom renderer, +Adaptive aspect, and edge-anchored HUD. Choose a ROM in the launcher, or pass +`-DirectRomPath ` to boot directly. Settings, ROM selection and saves in +the original checkout are not used. Existing playtest selections are preserved. +The script supplies the MinGW runtime DLL search path; launching the bare EXE +without those DLLs on PATH may fail. + +The widescreen mod offers Custom/Legacy renderer, Adaptive/16:9/21:9/32:9, +and edge/native HUD anchors. Disable the mod for stock rendering. The custom +aspect option controls presentation independently of the legacy Display aspect +setting. Fixed ratios letterbox as needed; Adaptive follows the window. + +`Expanded sprite capacity` is a separate, experimental option, **off by +default**. It draws up to 2,048 pieces from the game's submitted draw queues +when the retail OAM writer runs out of slots. It applies only to the custom +renderer. Enemy allocation and behavior still run through the guest game. + +Logical widths are 342, 448 and 682 pixels for 16:9, 21:9 and 32:9. Adaptive +is bounded from 4:3 to a 1024-pixel host surface (approximately 5.33:1), with +even-pixel rounding. This is a host allocation limit, not the shared PPU limit. +Gameplay simulation and presentation remain at the existing cadence; this +spike does not add Super Metroid's frame interpolation feature. + +## Architecture + +`src/mmx_renderer.c` adapts the Mode-1 pixel decoder/compositor design from +SuperMetroidRecomp's `src/sm_renderer.c`. The shared PPU stays at 256 pixels, +with `g_ws_active=false` and zero extra space. No shadow tilemap, periodic-fold +cache, widened PPU buffer, or margin line enhancer participates in custom +composition. The common presentation helper only copies pixels and preserves +frame-dump diagnostics. + +The existing raster pass still executes once per simulated frame, retaining +MMX's IRQ/HDMA ordering. Each visible line captures registers, palette, VRAM +and OAM. The host compositor reads those snapshots without running guest code +or writing guest memory. Native terrain samples use captured VRAM; wider BG1 +and BG2 samples use MMX's retained level maps and ROM metatile definitions. +BG1 uses the full camera plus raster scroll phase. BG2 uses the streamer's +retained coordinates. Stage edges reflect terrain. Storm Eagle's airport sky +also uses the retained map, reflecting the airport panorama's painted edge +where a wider view exceeds that band. Launch Octopus's buried submarine mask +follows the live entrance controller instead of a fixed camera interval. + +The generated D76A hook records actual metasprite drawing data before native +clipping. It neither widens the native OAM emission gate nor alters CPU state. +Signed host coordinates distinguish a sprite at +300 from one at -212; the +512-pixel OAM wrap ambiguity therefore does not constrain the view. Pieces +are latched before the next NMI publishes the corresponding OAM. Stage changes, +resets and state loads invalidate host observations. Recorded pieces now draw +their complete footprints, including the column at native X=255 rejected by +the retail writer. Native OAM supplies HUD and other uncaptured submissions. +Full host Y coordinates also avoid the native eight-bit wrap ambiguity. + +`src/mmx_render_assets.c` resolves live animation sets to the ROM's compressed +graphics and section resource tables. For an unambiguous enemy resource whose +live tile or palette binding is stale, the compositor uses private decoded +CHR and the resource's palette. Current bindings retain live VRAM and palette +effects. This covers child animation pieces as well as the parent; it does +not draw a fixed preview icon or advance guest DMA. Unknown or ambiguous +resources keep the existing live-art path. + +The optional capacity extension observes D6A7's first object handoff before +OAM exhaustion. It reads the same six priority queues, the three weapon +objects, and X, in D56F's order. The default still uses only actual D76A +observations. Both paths share the existing relational crusher-child tile +repair. Neither path adds guest objects or writes guest OAM. + +HUD selection preserves MMX's first-16-slot reserve and signature-checked +boss-health runs. The custom compositor relocates those sprites before layer +composition, leaving their native positions free of duplicates. Door handling +uses the existing structural three-metatile signature, requires an adjacent +mate, and retains the column facing the current room while replacing only +its duplicate in host margins. Native scripted doors retain live PPU output. + +Gameplay widening remains separate from drawing. Existing enemy, projectile, +traffic and helicopter hooks use the custom view's rounded margin. Spawn +anchors retain 32 pixels of lead beyond that margin. Boss encounter families +and kinds 0–2 (except Heart Tanks and Highway traffic) keep their native scan, with independent +cursors. Vile's protected interval starts earlier when necessary to stop the +larger custom lookahead from reaching the allocation-sensitive room first. +Early guest graphics/stage streaming remains disabled in custom mode. +The Highway bee controller now belongs to the native scan because its +initialization starts the arena camera push. Once that boundary is reached, +its vertical descent receives widescreen lead independently of the lock. +The extra background view projects ROM-authored palette transitions separately +from guest CGRAM, including Highway's half-speed BG2 parallax. + +## F1–F4 playtest repairs, 2026-09-14 + +The owner's four compatible saves are reproduced in the ignored validation +directories; source saves are unchanged. F1's turtle and F3's bee were alive +with stale graphics bindings. F2's turtle retained a palette selection from +an earlier resource allocation. F4 exposed buildings before the camera's +`$0850` palette transition. These were resource and timing problems; the +captured frames did not exhaust the retail submission budget. + +- F1 and F3 now draw their live animated enemies using ROM-backed resources. + F2's turtle uses the correct palette. F4's newly visible buildings use the + projected palette in both 21:9 and 32:9 captures. +- All four saves pass live/offline agreement at 16:9, 21:9, 32:9 and Adaptive. + Final runs: `mmx-render-flg1e69q`, `mmx-render-0ppkaq4_`, + `mmx-render-zckr30vq`, and `mmx-render-p4u4iklh`, respectively. +- `mmx-render-5wytay_g` repeats all four at 32:9 with expanded capacity on. + Its guest RAM and output images equal the capacity-off run, and all expanded + queue entries match the observed submissions in order. A synthetic test + with 200 submitted pieces confirms that the additional 88 pieces appear + only when the option is enabled; the available 112 gameplay slots remain + the cutoff when it is disabled. +- The first controlled bee-boundary test (`mmx-render-ks0qm66s`) uses copies of F3 + with the bee put in waiting state and X placed on either side of the native + distance threshold. At a distance of 160 it stays in state 2; at 96 it enters + descent state 4, even with a 32:9 view. This is a boundary test, not a claim + that a complete F4-to-bee route was played through. The simple walk script + fell into the intervening gap before reaching the encounter. + This distance-only fix is superseded by the encounter correction below. +- Expanded-capacity regression samples cover Chill Penguin, Highway, the + Chill door, and the previously healthy Vile fight (`mmx-render-87xxty6j`, + `mmx-render-c67u9alu`, `mmx-render-l7pb3jrn`). Mod-off and Legacy still produce + stock and legacy surface widths even if the new option is stored as on + (`mmx-render-1sjvrm3w`, `mmx-render-9b3kwxet`). + +Capture format 2 records animation/resource identity and optional complete +submissions. The replay tool checks the unmodified compositor against native +PPU pixels first, then reports `repaired_native_pixels` separately. Correcting +an invisible native-area enemy intentionally changes those pixels. Raw oracle +agreement by itself never establishes that a guest graphics binding is valid. +`MMX_RENDER_OBJECT_TRACE=` optionally records Highway bee, Chill +Penguin boss/flyer, and Ride Armor states with player/enemy positions. It logs +transitions and periodic samples while those objects are alive. + +## Second playtest: arena push, descent and propellers + +The owner's new F1/F2 saves exposed two separate gates. `82:B8E6` saves the +camera limits at object offsets `$3B`/`$31`, then locks both limits to +`objectX-$F0` immediately. Allocating this controller in the margin therefore +started a two-pixel-per-frame camera push early. `82:B964` separately waits +for the player-distance threshold before descending. Restoring only that +second threshold made the bee late without fixing the premature camera push. + +Highway kind-3/id-`$22` now initializes in the native scan, like the existing +Thunder Slimer exception. For the first bee at `$0AF4`, the native scan reaches +its 32-pixel column when the camera reaches `$09E0`. After that boundary, the +descent uses the visible margin plus the existing 32-pixel lead. Older spike +saves with a waiting bee and its exact premature lock recover the saved +camera limits until the same native boundary. This recovery does not write +player/camera positions or unwind an encounter that has already started. + +The rotor is effect id `$1F`, animation `$36`, with a Bee Blader parent. Its +`82:F486` initialization clears the tile base and OBJ page bit: it uses +permanent page-zero graphics and borrows only resource `$2D`'s palette. +Renderer repair now recognizes that relationship, retains the live rotor +animation/CHR, and supplies the missing palette. Substituting the body's +graphics would be incorrect. Correct current bindings retain live colors. +This repair works with expanded sprite capacity off; that option stays off +by default. + +Validation artifacts under `build-custom/validation`: + +- `mmx-render-iknia7ot` confirms new F1's saved arena lock is released and F2 + begins descent on the first loaded frame at 32:9. Final rotor verification + uses `mmx-render-z4kmde74` (capacity off) and `mmx-render-9a6ex5ri` (on), + with all three F1/F2/old-F3 samples passing the + raw native pixel oracle and live/replay agreement; expanded queues match + the observed submissions in order. +- A copied, controlled F2 fixture removes only the already allocated bee, + its rotor and its own event flag, restores the saved camera limits, and + places X/camera just before the spawn column. `mmx-render-3fnlazqz` shows + no bee before entry. `mmx-render-qvv_l50q` and `mmx-render-99u5_xvh` show + allocation at camera `$09E0` in both 32:9 and 16:9, then lock and descent. + These are boundary checks, not a complete Highway playthrough. +- F2 walking samples pass at 16:9, 21:9 and Adaptive in + `mmx-render-6x4patdu`, `mmx-render-pl7kv8ec`, `mmx-render-ul6vxykl`. + At 16:9 descent starts after 26 pixels of movement from this manually + positioned save, instead of the previous 105-pixel wait. +- All three CTests pass. Added checks cover the exact camera-lock boundary, + recovery without moving X, preserving active encounters, and drawing a + rotor from live CHR with the borrowed palette. Strict C warnings pass. +- The final executable also passes the Chill door snapshots + (`mmx-render-fbbwthcq`) and mod-off isolation (`mmx-render-qq6l14m5`). + +The existing checkpoint is `384a5fe` on `codex/mmx-custom-renderer-spike`. +All follow-up work remains on that branch; no main/master merge is intended. + +## Third playtest: seven-item burndown + +Background resources now belong to individual world columns, replacing the +earlier per-side palette substitution. Highway kind-2 events `$16`/`$17` +select the private CHR/palette phase. BG1 uses its world position; Highway's +half-speed BG2 projects that position back into the event coordinate system. +Only the groups/tiles owned by those transfers are replaced. The native +256-pixel background continues to use captured PPU data. Other stages retained +their live resources in this batch; Chill's foreground palette ownership is +added in the fourth batch below. + +The private resources remain authoritative in the margins even when RAM says +the requested phase is current: RAM changes before DMA finishes. A moving F1 +capture exposed that one-frame gap and now stays correct across it. + +Stage BG3 is screen-space dialogue and is clipped to the native view. In the +Highway end arena, BG2 columns before `$A00` are authored as empty at the new +vertical scroll. Extending the arena sky edge fills that exposed region. +The password screen is identified by game scene `$D3=$0A`; `$D1/$D2` alone +incorrectly identified it as gameplay. Non-stage scenes use the centered +stock frame with black side bars, including native HUD/menu placement. + +The pink flyer (enemy `$36`, `$83:DF71`) previously abandoned its approach +after traveling 160 pixels from spawn. Its custom-renderer leash now adds the +visible margin, allowing it to enter its original attack states. Ride Armor's +dedicated `$83:8948` horizontal lifetime check and `$82:808F` presentation check +now include that margin. The vertical lifetime limit is unchanged. These new +hooks retain their original limits with no custom margin or in Legacy mode. + +The provided F3 already contained an armor initialized and erased before its +first animation/physics update. A state-load compatibility fix recognizes +only the untouched Chill Penguin spawn signature (position `$1220,$0390`, +full health, initial frame/collision data, empty slot, X before the spawn). +It resumes the guest initialization once the armor is within both lifetime +axes. Used, moved, animated, or damaged armor does not qualify. Its animation +`$4A` is separately mapped to resource `$49`; the enemy table only maps the +pilot, so the usable armor also needed private art before that resource loads. + +Evidence in `build-custom/validation`: + +- `mmx-render-ja_um420`: F1/F2/traffic/F8/F9 moving samples at maximum Adaptive + width. F1 reaches player `$12C3` with the new CHR phase selected; its distant + towers stay intact. Traffic remains blue behind X. F8 has one dialogue box + with continuous sky in the left margin; F9 is pillarboxed. +- `mmx-render-gpgc8yab`: F7 normal approach, then waiting. The first flyer + reaches attack state 4 at frame 269 with a 60-pixel X separation, progresses + through states 6/8/10, and the second reaches state 4 at frame 400. Neither + prematurely takes the retreat state 12 before engagement. +- `mmx-render-aku6oz5o`: original new F3, with scripted movement/jumps. Armor + revives at the expanded lifetime boundary, remains alive through approach, + and is visible with its green/yellow art after 620 frames. This validates + appearance and persistence; the script did not mount the armor. +- `mmx-render-2e7_20hc`: F3/F7 with expanded capacity enabled. The option is + recorded as on, submission prefixes match, and sprites render correctly. + The other samples use the default off setting. +- `mmx-render-aobkpvx9`: existing Chill door fixtures still pass at maximum + width. `mmx-render-04v2ovzj` and `mmx-render-m_ssjbe8` confirm Mod Off and + Legacy retain their respective renderers and surface widths. +- Each custom capture is replayed at 4:3, 16:9, 21:9, 32:9, and maximum + Adaptive width. All raw native pixel oracles pass; live output equals replay + at the configured ratio. Visual inspection is separate from that oracle. +- All three CTests and strict C warnings pass. Regression cases cover world + resource ownership through pending DMA, dialogue/arena sky, password bars, + both armor cull edges, untouched-save recovery exclusions, and dedicated + armor art. The injector verifies one flyer hook and one armor lifetime hook. + +Source saves are unchanged. All seven reports have fixes and evidence; broader +full-game qualification and owner acceptance remain open before retiring Legacy. + +## Fourth playtest: palettes, death/arrival and Chill doors + +The five current reports are fixed on the same branch. The owner's updated +F1-F4 saves were copied to `build-custom/fourth-saves` for reproducible runs. + +Chill's kind-2 `$17` event at world X `$1021` assigns foreground palettes to +the cave/outdoor sides. The first event's high nibble establishes phase 1 on +the cave side, rather than assuming phase 0. The compositor projects the +owned BG1 palette groups into the margins. BG2 keeps its live palette because +the sky also changes with elevation; Chill's CHR remains live for the same +reason. The distant outdoor pillar now stays cyan before and after approach. + +The usable armor's section binding advances before CGRAM receives its new +palette. Its exact old cave palette (resource `$4A`) is recognized in the +captured OBJ colors and replaced with armor resource `$49` during that gap. +Arbitrary live damage/flash palettes are not treated as a pending transfer. + +Stage dispatch `$80:997B` keeps the world active in `$D3` states `$00`, `$02`, +`$04`, `$06` and `$08`. Those states now retain the selected width through +level arrival, READY, death and stage clear. Password state `$0A` remains +pillarboxed. During death, an exact saturating RGB transform inferred from +captured CGRAM also applies the white fade to private margin resources. + +Door deduplication previously removed both columns of a distant closed pair. +It now keeps the column facing the camera's room, suppressing only the mate. +Opening/closing still follows the guest's live tilemap edits. Chill Penguin's +kind-3/id-`$02` allocation belongs to the native scan so the wider lookahead +cannot start the encounter from the hallway. + +Evidence under `build-custom/validation`: + +- `mmx-render-gl5p7yv5` captures the corrected distant F1 pillar; + `mmx-render-foii88zg` captures F1 at frame 170 during the actual pending + armor palette transfer. The armor remains green/yellow. The input harness + now accepts button chords such as `right+b` to traverse the cave exit. +- `mmx-render-bsrahrce` captures F3/F4 with both distant doors present as + single columns and a continuous, correctly colored sky. +- `mmx-render-883h206u` reaches frame 550 of F4's rightward traversal: X is + still in the hallway and no Penguin object exists. The full 1,000-frame + run `mmx-render-qhrk63h_` first allocates Penguin at frame 605, player X + `$1E0C`, after crossing the second-door boundary `$1E00`; its introduction + advances to state 2 at frame 781. This verifies entry, not boss defeat. +- `mmx-render-upregn_g` verifies the full-width death flash at frame 220. + `mmx-render-mfll_eq3` and `mmx-render-brf3c59i` sample level setup/black + transition and the full-width READY scene at frames 330 and 450. +- `mmx-render-j_s_x1p9` confirms F9's password screen stays pillarboxed with + expanded sprite capacity enabled. The current F1-F4 checks use capacity + off; that remains the default. +- All custom captures replay at 4:3, 16:9, 21:9, 32:9 and maximum Adaptive + width with zero raw native pixel differences. Live maximum-width output + matches replay. All three CTests and strict C warnings pass, including + new tests for scene ownership, both door-facing directions, palette phases, + exact pending armor colors, death fades and Penguin event ownership. + +## Fifth playtest: six-item burndown + +The owner's updated F2/F5/F6/F7/F9/F10 saves are copied under +`build-custom/fifth-saves`. These fixes remain on the isolated spike branch. + +- Penguin's `$81:BCAA` breath setup combines body resource `$61` CHR with + ice resource `$62` colors. Animation `$67` alone therefore cannot identify + its palette. Projectile id `$1A` now resolves that mixed binding; its + statue animation `$68` resolves resource `$62`. Current bindings retain + their live colors. The body and other effect identities keep their own art. +- Enemy `$0D` explicitly chooses tile offsets 0/8 at `$88:8F42` and changes + palette bits during its states. When resource `$07` is current, those live + variants are preserved. Substituting raw tiles from the generic resource + had erased F10's submitted, collidable enemy. +- This batch moved Spark's streaker/controller `$37` to the native scan; + the sixth batch below reverses that change after the owner's pop-in report. + Its color window is composed independently in + signed host coordinates using the rounded ROM profile at `$86:D136`. + This removes the native generator's 0/255 clamp and offscreen entrance + delay. Two lights, opposite directions, and shrinking exit beams retain + their separate state. The guest's HDMA tables and PPU remain unchanged. +- Spark BG2 mode `$0C` is the Thunder Slimer actor surface. It keeps its + native arena rendering; retained actor staging tiles are no longer decoded + as scrolling terrain in the margins. F7's two stray bubbles were these + background tiles, not active enemy/projectile objects. +- Heart Tanks (kind 0/id `$0B`) are allowed in the wide scan. A collectible-only + pass catches up columns already exposed by a loaded save at the next + camera-column scan, using the original guest allocator and pickup flags. + Its existing widened lifetime/presentation path then shows the live heart. + This scan excludes enemies and scripted room events. It does not implement + general enemy activation after a sudden resize. + +Evidence under `build-custom/validation`: + +| Run | Verification | +| --- | --- | +| `mmx-render-r4gmm3dl` | F2 blue breath/statues, F7 no stray bubbles, F10 visible enemy; expanded capacity on | +| `mmx-render-r2bkvrsc` | F5 held right to frame 550, camera `$0416`: no streaker allocated | +| `mmx-render-2wy0kwwj` | Same F5 route to frame 700, camera `$04F3`: first streaker active near its room | +| `mmx-render-0xwxjpqs` | F6 moved/jumped right to frame 260: a newly spawned streaker and its continuous wide light; expanded capacity on | +| `mmx-render-fj2vzq0_` | F9 frame 64: heart at world `$15D6` appears around screen X 438, beyond the native edge | +| `mmx-render-atipiy5l` | F9 repeated scans through frame 200 retain exactly one heart | +| `mmx-render-h8o6lw26` | Copied F9 with only collected flag `$1F9C` bit `$40` set: zero hearts after the same route | +| `mmx-render-c52jjbzj`, `mmx-render-n697lhi4` | Mod Off and Legacy still select their stock/legacy output paths | + +All custom captures pass the raw native oracle and live/replay agreement, +with replays at 4:3, 16:9, 21:9, 32:9 and maximum Adaptive width. F2/F10 now +have zero repaired-native pixels in their supplied frames: preserving the +correct native binding removes the bad substitution. Signed light projection +can intentionally differ from the clamped/delayed native color window. + +All three CTests and strict C warnings pass. Added tests cover mixed Penguin +resources, preserving enemy `$0D` variants, room/collectible record ownership, +offscreen rounded lights in both directions and together, and actor-layer +clipping without changing native BG2. These checks do not claim a complete +stage playthrough or mid-boss defeat. Source saves remain unchanged; expanded +sprite capacity remains off by default. + +## Sixth playtest: shared boss timing and edge visibility + +The current F5/F6/F9/F10 saves are copied under `build-custom/sixth-saves`. +The new F10 is Spark Mandrill's double-door approach, not the older invisible +enemy fixture. All three reported items are implemented and replay-validated. + +- Boss ownership now uses one stage-independent encounter classification. + The eight Mavericks and Bospider share initialization guard `$84:AADD`; + Rangda Bangda, D-Rex and Sigma/Velguarder use dedicated intro controllers. + Those families, plus Bee Blader and Thunder Slimer, belong to the + original native event scan. Independent scan cursors preserve each authored + entry boundary without a door-coordinate or per-stage boss exception. + Fortress rematches use the same IDs and therefore the same rule. Child + effects and ordinary enemies retain their existing allocation paths. + Vile's existing protected cutscene interval remains in place. +- Streaker `$37` is an ordinary wide-spawned enemy again. At maximum width, + F5's first streaker allocates at frame 314, camera 832, enemy X 1526: + 694 pixels from the native left edge, beyond the visible right edge of 640. + This restores early visibility instead of popping into the middle. The + existing host light composition remains in place. +- `$81:E99D` binds Heart Tank collectible `$0B` directly to resource `$36`. + Its animation `$38` is absent from the enemy resource table. The explicit + collectible lookup now supplies its own art/colors when its old palette + slot is reused by a neighboring section, and retains current live bindings. + +Evidence under `build-custom/validation`: + +| Run | Verification | +| --- | --- | +| `mmx-render-8r8za99a` | F10 held right through frame 980: Spark allocates at frame 596 with X `$1D0C`, past the second door at `$1D00`; intro state 2 begins at frame 772 | +| `mmx-render-dr2swwfd` | Mod-off run of the same F10 input: frame-980 RGB output exactly matches the fixed capture's native 256-pixel replay | +| `mmx-render-_n4r81u1` | Chill Penguin still allocates at frame 605 with X `$1E0C`, past its second door at `$1E00` | +| `mmx-render-0a1z4crt` | F5 early streaker allocation and movement through frame 550; capacity off | +| `mmx-render-iuhr8rxk` | F6 widened allocation/render replay with expanded capacity on | +| `mmx-render-bj16ivma`, `mmx-render-_rm0sfm_` | F9 backs away from resource section 5 into section 4; heart remains pink near X 600 while its old live palette slot contains unrelated blue/green colors | + +All custom snapshots pass the raw native oracle, all five aspect replays and +live/replay agreement. All three CTests and strict C warnings pass. Tests cover +every classified encounter in every stage, streaker ownership and the Heart +Tank's dedicated collectible pool/resource lookup. Expanded capacity remains +opt-in and off by default. The boss family coverage is verified against ROM +initializers and policy tests; actual arena-entry replays cover Spark Mandrill +and Chill Penguin. The owner's third-boss playtest and remaining full-stage +qualification are still needed before replacing the released renderer. + +## Seventh playtest: streaker activation and Spark's ice coat + +The eighth batch below supersedes this batch's parked-streaker movement rule. + +The owner accepted the restored streaker/light rendering but reported that +F5 still attacks outside the room. The updated F6 is now the Spark Mandrill +fight with Shotgun Ice equipped. Immutable copies are in `seventh-saves`. + +- Streakers still allocate beyond the wide edge and render their live art. + Only their `$87:A597` horizontal movement waits: until the native scan + reaches the authored 32-pixel spawn column, they stay at the room position. + Animation, drawing, collisions and light composition continue. Launched, + fading or killed actors are never held, and no host-only latch is needed + for saves. The hook is custom-renderer-only, injected at one verified site, + with an explicit CMake dependency so the first build recompiles that bank. +- Spark's `$88:A25E` freeze state selects palette bits `$0A`; thawing returns + them to `$08`. His ice chips share animation `$91` and its alternate palette. + When resource `$8A` is resident, those authored bindings now retain live + colors instead of being replaced with the default orange body palette. + Nonresident resources still use the private asset repair path. + +Evidence under `build-custom/validation`: + +| Run | Verification | +| --- | --- | +| `mmx-render-oz5fhi4i` | F5 frame 550: first streaker remains visible at authored X `$05F6`; second waits at `$065D`, neither has crossed into the preceding corridor | +| `mmx-render-b1germvu` | F5 through frame 740: first stays at X 1526 through frame 672/camera 1226, then moves at frame 688/camera 1249 after native column threshold `$04E0`; second still waits | +| `mmx-render-1v5jrj1d` | F5 32:9 with expanded capacity on: launched actor and wide light remain visible at room entry | +| `mmx-render-7q625nmn` | F6 Shotgun Ice frame 90: frozen state `04/0A/04`, blue ice coat, live/replay and native RGB match; capacity off | +| `mmx-render-58i8ods7` | F6 frame 145: thaw state `04/0A/08`, normal body colors plus pale ice chips, native RGB match; capacity on | + +All three CTests, strict C warnings and injector drift checks pass. Replays +cover 4:3, 16:9, 21:9, 32:9 and maximum Adaptive width. The raw native oracle +passes for every sample; Spark's corrected freeze/thaw frames also match the +native image after asset repair. The existing wide light can intentionally +alter native color-window pixels near entry. Capacity remains opt-in/off by +default, saves are preserved, and all work remains on the spike branch. + +## Eighth playtest: moving entrances and Storm Eagle prefill + +Current F1 contains parked streakers saved by the seventh build. Current F3 +is Storm Eagle's arrival. Immutable source copies are in `eighth-saves`. + +- Streaker `$37` uses the original event column to start its encounter. The + initialization hook at `$87:A590`, after orientation and before drawing, + moves its starting point one wide margin plus 32 pixels toward its entrance + side. Its original six-pixel flight runs continuously; the accepted wide + light composition is unchanged. The old movement-skip hook is removed, + including when applying overrides to an existing generated tree. The legacy + renderer retains its earlier event ownership. +- Old parked saves release only untouched, healthy, unstarted streakers whose + position matches their original event and remains ahead of the native scan. + The original event flag and light-window ownership are released together. + Game chunk version 2 distinguishes newly saved moving entrances; version 1 + still loads, with the same 464-byte layout. Moving, damaged and fading actors + are excluded from the repair. +- Storm's BG2 exception was wrapping a partially filled native tilemap. The + complete retained screen/metatile map already contains the mountain and road + art, so margins now use it during arrival as well as subsequent scrolling. +- The chain's ten platforms come from `$81:FAC5`, rather than ordinary enemy + records. Only Storm's kind-2/id-4 parameter-3/4 create/remove switches widen + their horizontal interval. Initial loads inside that interval catch up via + the original idempotent allocator. Movement and ride collisions remain the + original routines; camera, resource and other mechanism switches stay native. + +Evidence under `build-custom/validation` (all runs use capacity off unless +explicitly noted): + +| Run | Verification | +| --- | --- | +| `mmx-render-iqjeja5z` | F3 frame 300, camera 0: mountains/road prefilled across maximum Adaptive; ten live chain platforms, including visible pieces beyond 4:3 | +| `mmx-render-oqsgfycs` | F1 held right to frame 190, camera 1219: no parked actors or premature streaker | +| `mmx-render-2gj4hio1` | F1 frame 225: first entrance visible at the wide edge; allocation at frame 210/camera 1248, initialized world X 1942 at frame 211 | +| `mmx-render-sqyydwhe` | F1 frame 245: X 1864 to 1768 over 16 frames confirms uninterrupted original flight speed; light visible | +| `mmx-render-agvtge_m` | Same F1 entrance at live 32:9 | +| `mmx-render-d1xatpnq` | Live 16:9 F1 remains free of parked enemies while stationary; F3 background prefilled | +| `mmx-render-1ker7w9r` | F3 approach with capacity on: still ten moving platforms, no duplicate allocation; scripted jump misses the platform and reaches death with full wide output | +| `mmx-render-de8pulv_` | Spark's Shotgun Ice freeze regression: blue ice coat retained | +| `mmx-render-38invqko` / `mmx-render-2wyyw8g1` | Final executable: maximum Adaptive Storm prefill/platforms and live 21:9 streaker entrance | +| `mmx-render-c5nfcolc` / `mmx-render-fual82y9` | Final executable: mod disabled gives native 256 output; legacy gives its existing 342 output, with no custom capture | + +The three CTests pass, including background prefill from empty native margin +tiles, both entrance directions across margins, parked-save exclusions and +isolated platform switches. Injector counts and strict renderer/policy warnings +pass. Each capture is replayed at all five aspect settings with zero raw native +oracle differences; live presentation matches its corresponding replay. +These are targeted captures, not a completed airport or full-game playthrough. + +## Ninth playtest: distant panorama, water and buried entrance + +Current F3/F4/F5 were copied to `ninth-saves` without modifying the originals. +The fixes are presentation-only; event allocation, physics and triggers keep +the eighth build's behavior. + +- Storm's retained panorama is only 640 pixels wide, ending halfway through + its third screen. The farther cells contain intentional holes and other + mechanism data. During the airport's existing sky phase, the renderer finds + the painted band's end from its top row and reflects that edge into farther + margins. This continues the mountains and road without reading empty cells; + native pixels and other background phases retain their original sources. +- Launch Octopus uses BG3 for a water filter, blended over the world on the + subscreen. The dialogue clipping rule had removed this layer in the margins. + That stage's active water blend now extends BG3 across the view with its + existing vertical scroll, waterline and half-color math. Other stage overlays + remain bounded, including Vile's dialogue. +- The large buried submarine is baked into BG1, not an ordinary visible sprite. + Its live `$21` entrance controller identifies buried variants and the start + of the rise. Before emergence, only its source-art rectangle is replaced + with the preceding empty water screen; the vertical bounds come from the + ROM's `$86:CBEC`/`CBF2` tables. This replaces the old `$0A70..$0AC0` camera + exception, which missed larger margins. The original raster-IRQ rise and + active enemy rendering take over at their original states. + +Evidence under `build-custom/validation`: + +| Run | Verification | +| --- | --- | +| `mmx-render-0saxk8o_` | Baseline: all three reports reproduced from the updated F3/F4/F5 saves | +| `mmx-render-9fifz_tn` | Fixed live maximum Adaptive: F3 far-right gap filled with panorama; F4 full-width waterline/tint; F5 buried body hidden | +| `mmx-render-qxdco6rh` | F5 held right to frame 140: submarine beginning to rise from the ground at the native trigger | +| `mmx-render-tvrkx6yy` | Same route through frame 230: emerged enemy and its parts visible, without changing guest entrance timing | + +All three CTests and strict C warnings pass. Added renderer tests cover a gap +beyond the panorama's 640-pixel end, its reflection and phase exclusion, water +color blending across both seams and above the waterline, bounded non-water +overlays, and hidden/rising/active/surface submarine states. The capture harness +replays every sample at 4:3, 16:9, 21:9, 32:9 and maximum Adaptive with zero raw +native differences and matching live/replay output. Capacity is still opt-in. + +## Tenth playtest: terrain during the submarine entrance + +Current F1 was copied to `tenth-saves/save0.sav`. Holding right reproduced a +complete slope at frame 60, a missing slope and repeated container at frame +100, then recovering terrain as the submarine emerged. The preceding body +mask prevented early visibility but did not confine its raster scroll. + +`$82:B414` stores the camera Y minus the burial offset in `$C4`; `$80:84CB` +applies that value to BG1 between the actor's vertical bounds. The original +view contains empty water around the body in that band. Adaptive exposes +unrelated scenery on the same scanlines, and applying the offset to its +retained map moved the slope away and repeated the container's upper section. + +The compositor now identifies that active entrance's IRQ value and restores +the terrain scroll outside the body's columns. This remains active during +the rise as the offset decreases. Body columns, native pixels, other raster +bands, and guest execution retain their existing behavior. + +The new synthetic regression failed on the previous code, then passed with +the fix. It checks a slope, two differently colored container sections, +native pixels, and the displaced body in both waiting and rising states. +All three CTests and strict C warnings pass. Live maximum-width captures at +frames 60, 100, 160 and 240 cover approach, buried wait, emergence and the +active enemy; each replays at all five aspects with zero raw native pixel +differences and matching live output. Sprite capacity remains off. + +Evidence under `build-custom/validation`: + +| Run | Verification | +| --- | --- | +| `mmx-render-16wjmv9b` | Previous build, frame 100: both reported defects reproduced | +| `mmx-render-1dorsktl` | Fixed approach, frame 60: intact terrain and hidden body | +| `mmx-render-thkqlrn3` | Fixed buried wait, frame 100: intact slope and one complete container | +| `mmx-render-6wzgiyak` | Fixed emergence, frame 160: terrain intact while the body rises | +| `mmx-render-n6hdrs92` | Fixed active enemy, frame 240: terrain and container remain complete | + +## Eleventh playtest: cold pickups, Armadillo and Launch Octopus + +The original Armadillo fixtures are frozen in `eleventh-saves`; the later F2, +F9 and F10 Launch fixtures are in `eleventh-extra-saves`. Each capture starts +a fresh process with a copied save, so earlier visits cannot prime its assets. + +- The collectible catch-up now runs at `$00:DC92` even without camera movement. + It admits only health, weapon energy, Sub Tanks and Heart Tanks to the native + allocator; the game's collected/live flags still prevent duplicates. + Sub Tanks bind resource `$8C` directly at `$81:E4D3`, outside the enemy + animation table. Private CHR plus their permanent live OBJ palette 2 makes + the blue tank available on cold loads without borrowing the resource palette. +- The Armadillo shaft relocates camera `$1F00,$0600` to `$0100,$0800` at + `$81:F9B7`. Margins now project the destination below the join before that + relocation, including its palette. Grinder `$2C` also needs the `$82:808F` + presentation range extended independently of its lifetime check. +- Resident bosses retain live art/colors, preserving damage flashes and weapon + effects. Pause detection combines the suspended HUD task and menu HDMA bit; + either condition alone also occurs during gameplay. The weapons menu uses + the stock centered frame and black margins. +- Signed BG2 source scroll fixes the boat's negative entrance Y. Its body is + restricted to the authored source screen; Launch's foreground uses private + CHR where the boat has replaced resident art. Submarine bounds now include + the leading nose while preserving the previous terrain-scroll fix. +- Walking right in F10 exposes an otherwise occluded BG2 band containing + foreground scraps. In this Launch background mode, the margins continue + adjacent seabed artwork across source `$0500..06FF,$0370..038F`. Rows outside + that band and native pixels retain their original sources. + +Release capture evidence under `build-custom/validation`: + +| Run | Verification | +| --- | --- | +| `mmx-render-ilf7oabi` | Cold F1 blue Sub Tank, both F2 overhead health pickups, F5 boss and F6 pillarboxed menu | +| `mmx-render-p1pwvdiy`, `mmx-render-etfjxwll` | F3 frames 90/120: matching lower shaft scenery before/after relocation | +| `mmx-render-rqulcyjt` | F3 frame 450: grinder still submits 49 pieces beyond the native range | +| `mmx-render-re5y61w8`, `mmx-render-264fsc0o` | F5 frames 45/90: live boss colors, including the bright hit effect | +| `mmx-render-b8y85_r8` | New F2 complete boat and F9 hidden submarine nose | +| `mmx-render-46td4nr9` | F10 held right, frame 140: previously malformed seabed is complete | +| `mmx-render-zcxkaixj` | F10 held left then right: seabed remains complete on return | +| `mmx-render-17r6cpcc`, `mmx-render-ns5781sm` | Earlier Storm panorama, water, buried enemy, Spark lights, Heart Tank and boss hallway fixtures | +| `mmx-render-ylm4k9h8`, `mmx-render-xjm0scgz` | Previous submarine slope/container fix remains intact during wait and rise | +| `mmx-render-ohc1pjqc` | Spark Mandrill still renders his blue ice coat after Shotgun Ice contact | + +All three CTests, strict C warnings and injector checks pass. Added tests cover blank-VRAM cold +Sub Tank art with its live blue palette, collectible record ownership, boss +palette ownership, pause isolation, signed boat scroll, submarine nose bounds, +and both terrain continuations. Every release capture replays at 4:3, 16:9, +21:9, 32:9 and maximum Adaptive with zero raw native differences and matching +live/replay output. Spark's saved ice-coat sequence also passes. Expanded +capacity remains off; ROMs, saves and capture artifacts remain untracked. + +## Validation recorded on 2026-09-13/14 + +- Windows Release CMake build and all three CTests pass. Assertions remain + enabled in Release tests. The custom renderer, replay tool and renderer test + compile with `-Wall -Wextra -Werror`. +- Tests cover geometry through the host capacity, fixed-aspect destination + fitting, immutable raster input, HUD relocation, opposite-side sprites + beyond the OAM wrap boundary, reset invalidation, independent spawn cursors, + record ownership, Vile lookahead protection and door signatures. +- Real snapshots from Highway, Chill Penguin, Spark Mandrill's late door area, + and Sigma 1/Vile were replayed at 4:3, 16:9, 21:9 and 32:9. Sampled native + pixels match the PPU oracle exactly, with 224 custom lines and no fallback. + Wider images were inspected separately. This is sampled-frame evidence, + not a full-game visual certification. +- Final Highway runs using each mod aspect selection, including Adaptive, + produced the expected live surface size. The live image equals offline + replay byte for byte. The 41 visible recorded metasprite pieces match native + OAM geometry, size and attributes with zero unmatched pieces. +- Mod-off and Legacy launches produce 256- and 342-pixel surfaces respectively, + and do not produce a custom raster capture. +- A 3,000-frame replay from the clean Vile door checkpoint entered the encounter + and reached active cutscene/combat rendering at camera `$0A80`. Its final + capture has zero native differences at all four ratios, including 3,518 + sprite pixels in the 32:9 margins. This does **not** establish successful + completion of Vile's entire dialogue/recharge/final-fight sequence. + +Artifacts are ignored under `build-custom/validation`. Useful runs: + +| Run directory | Evidence | +| --- | --- | +| `mmx-render-fanzbzhv` | Final 32:9 Highway capture, live/replay agreement | +| `mmx-render-ewybrv6s` | Final 16:9 mod selection | +| `mmx-render-wz0gszkc` | Final 21:9 mod selection | +| `mmx-render-4mtnt6eh` | Final Adaptive mod selection | +| `mmx-render-612qanel` | Chill Penguin and Highway fixtures | +| `mmx-render-m5_rjzns` | Chill Penguin approach and boss-door fixtures | +| `mmx-render-ndg2vntg` | Spark Mandrill late door area | +| `mmx-render-udm7payt` | Vile door-to-encounter script | +| `mmx-render-a0szjd8s` | Previously healthy Vile final-fight snapshot | +| `mmx-render-43h_loc5` / `mmx-render-7_vkv17q` | Mod-off / Legacy smoke | + +The original checkout's 266,643-byte v4 saves were rejected by the current +runtime. Tests use compatible 297,754-byte archived v7 saves, copied into each +test directory. No source save was converted or overwritten. + +## Required before removing the legacy renderer + +1. Replay Thunder Slimer's actual approach, the later Spark light streaks, and + the complete Vile sequence at each aspect. The archived Spark save named in + the old issue now contains the late boss-door area, so it is not evidence + for Thunder Slimer activation. +2. Add and validate activation coverage for newly exposed columns when the + window suddenly expands or a narrow-view save is loaded. Current hooks + scale the normal moving-camera spawn window, but the retail scanner still + runs at camera-column crossings. This spike does not guarantee that a + resize cannot reveal a dormant enemy before the next scan. +3. Cover animated backgrounds, all stage-specific resource transitions, + overlapping sprites and doors through complete opening/closing sequences. + Background CHR and unresolved sprite resources still come from live VRAM; + further art transitions may need additional resource ownership handling. + Validate damage flashes and alternate palettes on repaired enemy families. + Non-stage screens and non-Mode-1 lines + intentionally use centered stock output. Exact native pixel agreement does + not validate unseen margin art or encounter progression. + +## Rebuild and reproduce + +Use CMake with the checked-in dependency pins and a generated USA `src/gen`. +This worktree was configured with the original checkout's read-only dependency +directories (`snesrecomp` at `8d12911`, `recomp-ui` at `a7a4f30`) and copied +generated sources. CMake reapplies and verifies both the existing gameplay +overrides and the pre-clipping capture hook before building. ROM and generated +banks remain ignored. The experimental sources are wired into CMake; the old +MSVC solution has not been updated for this spike. + +```powershell +& 'C:\Program Files\CMake\bin\cmake.exe' --build build-custom --parallel 6 +& 'C:\Program Files\CMake\bin\ctest.exe' --test-dir build-custom --output-on-failure +``` + +`tools/test_renderer_captures.py --help` documents isolated fixture runs and +aspect/renderer selection. `mmx_render_capture` replays a capture using the +caller-supplied ROM, compares unanchored native pixels, then writes the selected +HUD layout. Both headered and unheadered ROMs are accepted for replay. + +The central Beads issue is saved locally. Its Dolt push currently fails because +the configured remote references a missing `refs/dolt/remotes/origin/dolt/data` +ref; this spike does not repair the issue database's synchronization setup. diff --git a/mods/preloaded/packages/megaman-x.enhancement.widescreen/1.0.0/manifest.toml b/mods/preloaded/packages/megaman-x.enhancement.widescreen/1.0.0/manifest.toml index 7e2ee56..fcbaab0 100644 --- a/mods/preloaded/packages/megaman-x.enhancement.widescreen/1.0.0/manifest.toml +++ b/mods/preloaded/packages/megaman-x.enhancement.widescreen/1.0.0/manifest.toml @@ -3,7 +3,7 @@ id = "megaman-x.enhancement.widescreen" version = "1.0.0" name = "Mega Man X Widescreen" author = "mstan" -description = "Extends Mega Man X's horizontal field of view by one third using its game-specific HUD, background-margin, stage-overlay, spawn, cull, and draw-window work." +description = "Custom widescreen renderer spike with adaptive stage rendering and HUD anchoring." license = "MIT" resolver = "declarative" save_compatibility = "shared" @@ -15,10 +15,70 @@ rom_sha256 = "b8f70a6e7fb93819f79693578887e2c11e196bdf1ac6ddc7cb924b1ad0be2d32" [[feature]] id = "widescreen" name = "Widescreen (Extended view)" -description = "Add one-third more horizontal game area. Output follows Display aspect: 16:9 (CRT), 32:21 (square pixels), or 4:3 (square frame)." +description = "Extend the stage view with live enemies, preserved encounter timing, and anchored health bars." group = "Display" default_enabled = false +[[option]] +feature = "widescreen" +id = "renderer" +label = "Renderer" +type = "choice" +default = "custom" +[[option.choice]] +value = "custom" +label = "Custom renderer (spike)" +[[option.choice]] +value = "legacy" +label = "Legacy widescreen" + +[[option]] +feature = "widescreen" +id = "aspect" +label = "Aspect ratio" +description = "Custom renderer: adapt to the window, or select a fixed aspect ratio." +type = "choice" +default = "adaptive" +[[option.choice]] +value = "adaptive" +label = "Adaptive" +[[option.choice]] +value = "16:9" +label = "16:9" +[[option.choice]] +value = "21:9" +label = "21:9" +[[option.choice]] +value = "32:9" +label = "32:9" + +[[option]] +feature = "widescreen" +id = "hud" +label = "HUD anchoring" +type = "choice" +default = "edges" +[[option.choice]] +value = "edges" +label = "Screen edges" +[[option.choice]] +value = "center" +label = "Native positions" + +[[option]] +feature = "widescreen" +id = "expanded_sprites" +label = "Expanded sprite capacity" +description = "Custom renderer: draw submitted sprites beyond the original frame limit. Experimental; disabled by default." +type = "choice" +default = "off" +[[option.choice]] +value = "off" +label = "Off" +[[option.choice]] +value = "on" +label = "On (experimental)" + [[plugin]] feature = "widescreen" id = "megaman-x.widescreen" diff --git a/src/main.c b/src/main.c index 6c03514..b2d76cd 100644 --- a/src/main.c +++ b/src/main.c @@ -19,7 +19,8 @@ #include "snes/msu1.h" #include "snes/ws_shadow.h" #include "widescreen.h" -#include "mmx_wide_preview.h" +#include "mmx_wide_preview.h" +#include "mmx_renderer.h" #include "types.h" #include "mmx_rtl.h" @@ -160,6 +161,22 @@ static GamepadInfo g_gamepad[2]; extern Snes *g_snes; static void MmxDisplay_PreparePpuFrame(void) { + if (g_mmx_custom_renderer) { + int w = 16, h = 9; + if (g_window && g_renderer_funcs.GetOutputSize) g_renderer_funcs.GetOutputSize(&w, &h); + g_mmx_custom_view = MmxRendererViewport(g_mmx_custom_aspect, w, h); + g_snes_width = g_mmx_custom_view.width; + g_ws_active = false; + g_ws_extra = 0; + PpuBeginDrawing(g_ppu, g_my_pixels, 256 * 4, g_ppu_render_flags); + PpuSetExtraSpace(g_ppu, 0); + PpuSetWsHudOamShift(g_ppu, 0); + PpuSetWsHudOamShiftRange2(g_ppu, 0, 0); + PpuSetWidescreenBg3Widen(g_ppu, 0); + PpuSetWidescreenLineEnhancer(g_ppu, NULL, NULL); + if (g_snes && g_snes->cart) MmxRendererSetRom(g_rom, g_snes->cart->romSize); + return; + } int width = MmxDisplay_ComputeFrameWidth(g_config.widescreen); /* Probe/CI determinism: SNESRECOMP_WS_EXTRA pins the margin so measured * widescreen geometry never silently follows the window aspect (a probe @@ -255,7 +272,7 @@ void MmxDisplay_SetWidescreenEnabled(bool enabled) { } bool MmxDisplay_IsWidescreenEnabled(void) { return g_config.widescreen; } -bool MmxDisplay_IsWidescreenActive(void) { return g_ws_active; } +bool MmxDisplay_IsWidescreenActive(void) { return g_ws_active || (g_mmx_custom_renderer && g_mmx_custom_view.extra); } int MmxDisplay_GetCurrentFrameWidth(void) { return g_snes_width > 0 ? g_snes_width : 256; } /* Resolve one BG2 8x8 tile directly from MMX's decompressed level map. @@ -443,7 +460,15 @@ static int g_script_index; // current entry static int g_script_phase; // 0=holding, 1=waiting static int g_script_counter; // frames left in current phase -static uint32 ParseButtonMask(const char *name) { +static uint32 ParseButtonMask(const char *name) { + /* Scripted movement must be able to jump while holding a direction. */ + const char *plus = strchr(name, '+'); + if (plus) { + char first[64]; size_t length = (size_t)(plus - name); + if (!length || length >= sizeof(first)) return 0; + memcpy(first, name, length); first[length] = 0; + return ParseButtonMask(first) | ParseButtonMask(plus + 1); + } if (strcmp(name, "start") == 0) return 0x0008; if (strcmp(name, "select") == 0) return 0x0004; if (strcmp(name, "up") == 0) return 0x0010; @@ -641,7 +666,31 @@ static SDL_HitTestResult HitTestCallback(SDL_Window *win, const SDL_Point *pt, v return SDL_HITTEST_NORMAL; } -void RtlDrawPpuFrame(uint8 *pixel_buffer, size_t pitch, uint32 render_flags) { +void RtlDrawPpuFrame(uint8 *pixel_buffer, size_t pitch, uint32 render_flags) { + if (g_mmx_custom_renderer) { + static uint32_t output[MMX_RENDER_MAX_WIDTH * 224]; + extern void MmxWsChrRebindSweep(void); + MmxWsChrRebindSweep(); + MmxRendererBeginFrame(g_ram); + g_rtl_game_info->draw_ppu_frame(); + bool valid = MmxRendererEndFrame((const uint32_t *)g_my_pixels) && + MmxRendererDraw(output, g_mmx_custom_view, g_mmx_custom_hud); + if (!valid) { + memset(output, 0, g_snes_width * 224 * sizeof(*output)); + for (int y = 0; y < 224; ++y) + memcpy(output + y * g_snes_width + g_mmx_custom_view.extra, + g_my_pixels + y * 256 * 4, 256 * 4); + } + /* Shared pitch-aware blit and capture diagnostics; no PPU composition. */ + RtlWidescreenPresent(pixel_buffer, pitch, (const uint8_t *)output, g_snes_width, 224); + const char *capture = getenv("MMX_RENDER_CAPTURE"); + if (capture) { + static unsigned count; + const char *after = getenv("MMX_RENDER_CAPTURE_FRAME"); + if (++count == (unsigned)(after ? atoi(after) : 1)) MmxRendererSaveCapture(capture); + } + return; + } MmxDisplay_PrepareBg2Shadow(); /* WS-CHRBIND heal sweep: re-run bank_82_827D_M1X1's OAM tile-base/ * palette bind for any object that latched it before its VRAM-CHR slot @@ -877,6 +926,7 @@ static void SdlRenderer_BeginDraw(int width, int height, uint8 **pixels, int *pi MmxDisplay_ComputeViewport(width, height, output_width, output_height, SnesDisplayAspect_Clamp(g_config.display_aspect), g_config.ignore_aspect_ratio, false, &viewport); + if (g_mmx_custom_renderer) viewport = MmxRendererDestination(g_mmx_custom_view, output_width, output_height); g_sdl_present_rect.x = viewport.x; g_sdl_present_rect.y = viewport.y; g_sdl_present_rect.w = viewport.width; @@ -1148,14 +1198,12 @@ int main(int argc, char** argv) { argv[0] = (char *)AbsolutizePathArg(argv[0], rom_abs, sizeof(rom_abs)); } - /* The config is config.ini next to the executable — nothing else, - * no directory walking. Anchoring cwd to the exe dir also pins - * keybinds.ini, rom.cfg and saves/ there, however the process was - * launched. (On read-only installs the anchor declines and cwd - * stays authoritative; see launcher.h.) */ + /* By default anchor config, keybinds, ROM selection and saves to the exe. + * Explicit --config keeps the caller's cwd for isolated playtest data. + * Read-only installs also keep cwd authoritative; see launcher.h. */ { extern int snesrecomp_anchor_to_exe_dir(void); - int anchored = snesrecomp_anchor_to_exe_dir(); + int anchored = config_file ? 0 : snesrecomp_anchor_to_exe_dir(); host_report_breadcrumb("exe-dir anchor: %s", anchored ? "ok" : "declined (cwd stays authoritative)"); } @@ -1368,9 +1416,8 @@ int main(int argc, char** argv) { #endif #if defined(RECOMP_LAUNCHER) - /* cwd is anchored to the exe dir (snesrecomp_anchor_to_exe_dir above), - * and recomp_ui.cmake stages assets to /assets, so "." resolves - * assets correctly. */ + /* Assets are relative to cwd. The default anchor uses the staged + * executable assets; an isolated --config runner supplies its own. */ int act = recomp_launcher_run_window( MMX_LAUNCHER_TITLE, &ls, &gi, ".", init_rom, rom_path_buf, sizeof(rom_path_buf)); @@ -1941,7 +1988,11 @@ error_reading:; uint32 inputs = g_input_state | g_pad_buttons | g_gamepad[0].axis_buttons | g_gamepad[1].axis_buttons << 12; inputs |= TickScript(); inputs |= debug_server_get_controller_inputs(); - RtlRunFrame(inputs | GetActiveControllers() | debug_server_get_controller_active_mask()); + if (g_mmx_custom_renderer) { + MmxDisplay_PreparePpuFrame(); + MmxRendererLatchSprites(); + } + RtlRunFrame(inputs | GetActiveControllers() | debug_server_get_controller_active_mask()); #ifdef ENABLE_ORACLE_BACKEND // Step the oracle emulator with the same input. The runner's per-player @@ -2188,8 +2239,9 @@ static void HandleCommand(uint32 j, bool pressed) { g_cursor = !g_cursor; snesrecomp_sdl_show_cursor(g_cursor != 0); break; - case kKeys_Reset: - RtlReset(1); + case kKeys_Reset: + MmxRendererReset(); + RtlReset(1); break; case kKeys_Pause: g_paused = !g_paused; break; case kKeys_PauseDimmed: diff --git a/src/mmx_render_assets.c b/src/mmx_render_assets.c new file mode 100644 index 0000000..758243e --- /dev/null +++ b/src/mmx_render_assets.c @@ -0,0 +1,332 @@ +#include "mmx_render_assets.h" +#include "mmx_wide_policy.h" +#include + +/* The same ROM compression/transfer format used by mmx_wide_preview.c, + * applied to live animation pieces, without allocating guest VRAM/CGRAM. */ +static const uint8_t *rom; +static size_t rom_size; +static MmxSpriteAsset assets[256]; +static uint8_t ready[256], sprite_resource[256]; +static unsigned cached_stage = ~0u, cached_section = ~0u; +static unsigned bg_stage = ~0u; +static uint8_t bg_phase[2][8192], bg_chr[16][65536]; +static bool bg_chr_valid[16][2048], bg_chr_ready[16], bg_palette_ready[16]; +static MmxBackgroundPalette bg_palette[16]; +static bool range(size_t a, size_t n) { return a <= rom_size && n <= rom_size - a; } +static unsigned word(size_t a) { return range(a, 2) ? rom[a] | (rom[a + 1] << 8) : 0; } +static size_t lorom(unsigned a) { return ((a >> 16) & 127) * 0x8000u + (a & 0x7fff); } +static unsigned ram_word(const uint8_t *r, unsigned a) { return r[a] | (r[a + 1] << 8); } + +void MmxRenderAssetsSetRom(const uint8_t *bytes, size_t size) { + if (rom == bytes && rom_size == size) return; + rom = bytes; rom_size = size; + cached_stage = cached_section = ~0u; + bg_stage = ~0u; +} +static bool tiles(unsigned id, uint8_t out[8192]) { + uint8_t decoded[65536]; + size_t info = 0x376f7 + id * 5; + if (!range(info, 5)) return false; + size_t count = word(info), pos = lorom(word(info + 2) | (rom[info + 4] << 16)); + if (!count || count > sizeof(decoded)) return false; + for (size_t n = 0; n < count;) { + if (!range(pos, 2)) return false; + unsigned control = rom[pos++], repeat = rom[pos++]; + for (unsigned bit = 128; bit && n < count; bit >>= 1) { + if ((control & bit) && !range(pos, 1)) return false; + decoded[n++] = (uint8_t)((control & bit) ? rom[pos++] : repeat); + } + } + size_t spec = 0x371b7 + word(0x371b7 + id * 2), source = 0; + memset(out, 0, 8192); + for (unsigned guard = 0; guard < 256; ++guard) { + if (!range(spec, 2)) return false; + unsigned length = rom[spec], destination = rom[spec + 1]; + if (!length) return true; + if (length == 255) { ++spec; continue; } + length *= 16; + if (source + length > count) return false; + int address = ((int)(destination & 127) - 0x60) * 512; + if (address >= 0 && address + (int)length <= 8192) + memcpy(out + address, decoded + source, length); + source += length; spec += 2; + if (destination & 128) return true; + } + return false; +} +static bool palette(unsigned id, uint16_t out[16]) { + size_t p = 0x30000 + (word(0x30133 + id) & 0x7fff); + bool found = false; + memset(out, 0, 32); + for (unsigned guard = 0; guard < 32; ++guard, p += 4) { + if (!range(p, 4)) return false; + unsigned count = rom[p]; + if (!count) return found; + size_t source = 0x28000 + (word(p + 1) & 0x7fff); + int first = (int)rom[p + 3] - 128; + if (!range(source, count * 2)) return false; + for (unsigned i = 0; i < count; ++i) if ((unsigned)(first + (int)i) < 16) { + out[first + i] = (uint16_t)word(source + i * 2); found = true; + } + } + return false; +} +static void stage_assets(unsigned stage, unsigned section) { + if (stage == cached_stage && section == cached_section) return; + cached_stage = stage; cached_section = section; + memset(ready, 0, sizeof(ready)); memset(sprite_resource, 255, sizeof(sprite_resource)); + if (stage >= 13 || !range(0x376f7, 1280)) return; + /* The section table includes boss/cutscene resource sets as well as the + * normal route. Prefer the active set; otherwise require a unique palette. */ + size_t base = 0x32cee; + unsigned start = word(base + stage * 2), end = word(base + stage * 2 + 2); + if (end < start || end - start > 128) return; + unsigned palettes[256]; + for (unsigned i = 0; i < 256; ++i) palettes[i] = ~0u; + for (unsigned pass = 0; pass < 2; ++pass) for (unsigned s = 0; s < (end - start) / 2; ++s) { + if ((s == section) != (pass == 1)) continue; + size_t p = base + word(base + start + s * 2); + for (unsigned guard = 0; guard < 64 && range(p, 6) && rom[p] != 255; ++guard, p += 6) { + unsigned id = rom[p], pal = word(p + 3); + if (!pass && palettes[id] != ~0u && palettes[id] != pal) { ready[id] = 3; continue; } + if (!pass && ready[id] == 3) continue; + palettes[id] = pal; + MmxSpriteAsset *a = &assets[id]; + a->id = (uint8_t)id; a->tile_base = (uint8_t)(word(p + 1) >> 4); + a->attributes = (uint8_t)(0x20 | ((word(p + 1) >> 12) & 1) | (rom[p + 5] >> 3)); + a->current = pass == 1; + a->live_tiles = false; + a->live_colors = false; + ready[id] = tiles(id, a->tiles) && palette(pal, a->colors) ? 1 : 2; + } + } + /* The ROM pairs each enemy's animation set with its compressed resource. + * Child pieces retain the parent's animation set, including Highway's + * crusher and bee children. Ambiguous animation sets stay on live VRAM. */ + for (unsigned id = 1; id <= 0x68; ++id) { + size_t p = 0x325e4 + (id - 1) * 2; + unsigned animation = rom[p], resource = rom[p + 1]; + if (!animation || ready[resource] != 1) continue; + if (sprite_resource[animation] == 255) sprite_resource[animation] = (uint8_t)resource; + else if (sprite_resource[animation] != resource) sprite_resource[animation] = 254; + } +} +const MmxSpriteAsset *MmxRenderAssetsSprite(unsigned stage, unsigned section, unsigned sprite) { + stage_assets(stage, section); + unsigned id = sprite < 256 ? sprite_resource[sprite] : 255; + return id < 254 && ready[id] == 1 ? &assets[id] : NULL; +} +const MmxSpriteAsset *MmxRenderAssetsObjectSprite(const uint8_t ram[0x20000], + unsigned object, unsigned animation) { + if (!ram) return NULL; + /* Sub Tanks bind resource $8C directly at $81:E4D3, outside the enemy + * animation table. Cold loads must not depend on previously resident CHR. */ + if (object >= 0x1628 && object < 0x1928 && (object - 0x1628) % 0x30 == 0 && + ram[object + 10] == 5 && animation == 0x96) { + stage_assets(ram[0x1f7a], ram[0x1f08]); + if (ready[0x8c] != 1) return NULL; + static MmxSpriteAsset tank; + tank = assets[0x8c]; tank.current = false; tank.live_colors = true; + /* E4E9 explicitly borrows permanent OBJ palette 2. Resource $8C's + * section palette belongs to its other art and would turn the tank green. */ + return &tank; + } + /* Spark's freeze state ($88:A25E) deliberately selects palette $0A, + * and thawing returns it to $08. Its ice chips share animation $91 and + * the live ice palette. Native-timed bosses have current art; replacing + * these bindings with resource $8A's default colors erases the ice coat. */ + if (animation == 0x91 && + ((object >= 0xe68 && object <= 0x1228 && (object & 63) == 0x28 && ram[object + 10] == 0x31) || + (object >= 0x1928 && object < 0x1d08 && (object & 31) == 8 && ram[object + 10] == 6))) { + stage_assets(ram[0x1f7a], ram[0x1f08]); + if (ready[0x8a] == 1 && assets[0x8a].current) return NULL; + } + /* $81:E99D binds collectible $0B directly to resource $36; animation + * $38 has no entry in the enemy resource table. Its old palette slot can + * be reused while the tank is still visible in the extended view. */ + if (object >= 0x1628 && object < 0x1928 && (object - 0x1628) % 0x30 == 0 && + ram[object + 10] == 0x0b && animation == 0x38) { + stage_assets(ram[0x1f7a], ram[0x1f08]); + return ready[0x36] == 1 ? &assets[0x36] : NULL; + } + /* Penguin breath shares the body's CHR ($61) but deliberately borrows + * the ice-statue palette ($62), $81:BCAA..BCBA. Animation identity alone + * must not turn that valid mixed binding back into the boss palette. */ + if (ram[0x1f7a] == 8 && object >= 0x1268 && object <= 0x18e8 && + (object & 63) == 0x28 && ram[object + 10] == 0x1a && + (animation == 0x67 || animation == 0x68)) { + stage_assets(8, ram[0x1f08]); + if (ready[0x62] != 1) return NULL; + if (animation == 0x68) return &assets[0x62]; + if (ready[0x61] != 1) return NULL; + static MmxSpriteAsset breath; + breath = assets[0x61]; + memcpy(breath.colors, assets[0x62].colors, sizeof(breath.colors)); + breath.attributes = (assets[0x62].attributes & 0xfe) | (assets[0x61].attributes & 1); + breath.current = assets[0x61].current && assets[0x62].current; + return &breath; + } + /* Enemy $0D's $88:8F42 setup chooses tile offsets 0/8, and its damage + * states change palette bits explicitly. Its active resource uses live + * page-zero art; treating these authored variants as stale erases it. */ + if (object >= 0xe68 && object <= 0x1228 && (object & 63) == 0x28 && + ram[object + 10] == 0x0d && animation == 1) { + stage_assets(ram[0x1f7a], ram[0x1f08]); + if (ready[7] == 1 && assets[7].current) return NULL; + } + /* The usable Ride Armor has a dedicated slot/animation; the enemy table + * maps its pilot ($4F), not the armor's own $4A animation, to resource $49. + * Early visibility must not borrow CHR/palettes from the current cave set. */ + if (object == 0xe18 && animation == 0x4a) { + stage_assets(ram[0x1f7a], ram[0x1f08]); + return ready[0x49] == 1 ? &assets[0x49] : NULL; + } + /* 82:F486's rotor effect uses animation $36, but binds resource $2D + * directly through $7F832D. It is absent from the enemy animation table. + * Verify its Bee Blader parent; unrelated users of animation $36 must + * retain their own art. The parent need not remain live after detachment. */ + if (ram[0x1f7a] == 0 && animation == 0x36 && object >= 0x1928 && object <= 0x1be8 && + (object & 31) == 8 && ram[object + 10] == 0x1f) { + unsigned parent = ram_word(ram, object + 12); + if (parent >= 0xe68 && parent <= 0x1228 && (parent & 63) == 0x28 && ram[parent + 10] == 0x22) { + stage_assets(ram[0x1f7a], ram[0x1f08]); + if (ready[0x2d] != 1) return NULL; + /* F48C clears the tile base and F492 clears the OBJ page bit. The + * rotor uses permanent page-zero CHR, borrowing only the bee palette. + * Substituting the body's CHR would turn its blades into body tiles. */ + static MmxSpriteAsset rotor; + memcpy(rotor.colors, assets[0x2d].colors, sizeof(rotor.colors)); + rotor.id = 0x2d; rotor.tile_base = 0; + rotor.attributes = assets[0x2d].attributes & 0xfe; + rotor.current = assets[0x2d].current; rotor.live_tiles = true; + return &rotor; + } + } + const MmxSpriteAsset *asset = MmxRenderAssetsSprite(ram[0x1f7a], ram[0x1f08], animation); + /* Native-timed bosses own current resources. Their palette changes are + * intentional damage/weapon effects, including Armadillo's hit flash. */ + if (asset && asset->current && object >= 0xe68 && object < 0x1228 && + (object & 63) == 0x28 && MmxWidePolicy_IsBossEncounter(ram[object + 10])) return NULL; + return asset; +} +bool MmxRenderAssetsRideArmorPalettePending(const uint8_t ram[0x20000], const uint16_t colors[16]) { + if (!ram || !colors || ram[0x1f7a] != 8) return false; + stage_assets(ram[0x1f7a], ram[0x1f08]); + /* Section 4 replaces resource $4A's cave palette in OBJ slot 5 with + * armor resource $49. Binding metadata advances before the palette DMA. + * Recognize the exact previous palette, leaving flashes/other colors alone. */ + return ready[0x49] == 1 && ready[0x4a] == 1 && assets[0x49].current && + memcmp(colors + 1, assets[0x4a].colors + 1, 15 * sizeof(*colors)) == 0 && + memcmp(colors + 1, assets[0x49].colors + 1, 15 * sizeof(*colors)) != 0; +} +/* Kind-2 $17 records select background resources. Highway/Launch use + * horizontal CHR projection, Highway/Chill use horizontal palettes, and + * Armadillo's shaft continuation requests its destination palette explicitly. */ +static bool prepare_background(const uint8_t *ram) { + unsigned stage = ram[0x1f7a]; + if ((stage != 0 && stage != 1 && stage != 3 && stage != 8) || !range(0x32280, 2)) return false; + if (bg_stage == stage) return true; + bg_stage = stage; + memset(bg_phase, 0, sizeof(bg_phase)); + memset(bg_chr_ready, 0, sizeof(bg_chr_ready)); + memset(bg_palette_ready, 0, sizeof(bg_palette_ready)); + size_t pos = 0x28000 + (word(0x282c2 + stage * 2) & 0x7fff); + if (!range(pos, 1)) return false; + unsigned column = rom[pos++]; + bool first[2] = {true, true}; + for (unsigned guard = 0; guard < 512 && range(pos, 8); ++guard) { + unsigned x = word(pos + 5), event = rom[pos + 3]; + if ((rom[pos] & 15) == 2 && (event == 0x16 || event == 0x17)) { + unsigned line = x & 0x7fff; + /* The high nibble names the phase on the left of the first boundary. + * Chill's cave starts in palette phase 1, not phase 0. */ + if (first[event - 0x16]) { + memset(bg_phase[event - 0x16], rom[pos + 4] >> 4, 8192); + first[event - 0x16] = false; + } + if (line < 8192) memset(bg_phase[event - 0x16] + line, rom[pos + 4] & 15, 8192 - line); + } + pos += 7; + if (x & 0x8000) { if (rom[pos] == column) break; column = rom[pos++]; } + } + return true; +} +static size_t background_list(size_t base, unsigned phase) { + unsigned start = word(base + bg_stage * 2), end = word(base + bg_stage * 2 + 2); + if (end < start || phase >= (end - start) / 2) return rom_size; + return base + word(base + start + phase * 2); +} +const uint8_t *MmxRenderAssetsBackgroundTile(const uint8_t ram[0x20000], + int world_x, unsigned vram_word) { + if (!ram || (ram[0x1f7a] != 0 && ram[0x1f7a] != 1) || world_x < 0 || world_x >= 8192 || vram_word >= 0x8000 || !prepare_background(ram)) return NULL; + unsigned phase = bg_phase[0][world_x]; + /* RAM records the requested phase before DMA completes. Use private + * resources even when it matches, so margin art cannot briefly regress. */ + if (!bg_chr_ready[phase]) { + bg_chr_ready[phase] = true; + memset(bg_chr_valid[phase], 0, sizeof(bg_chr_valid[phase])); + size_t p = background_list(0x321d5, phase); + /* B436's nine-byte DMA records: byte count, VRAM word destination, + * ROM long source, palette descriptor. BG data is uncompressed. */ + for (unsigned guard = 0; guard < 32 && range(p, 9) && word(p); ++guard, p += 9) { + unsigned count = word(p), dest = word(p + 2) * 2; + size_t source = lorom(word(p + 4) | (rom[p + 6] << 16)); + if ((dest & 31) || (count & 31) || dest + count > 65536 || !range(source, count)) continue; + memcpy(bg_chr[phase] + dest, rom + source, count); + memset(bg_chr_valid[phase] + dest / 32, 1, count / 32); + } + } + return bg_chr_valid[phase][vram_word / 16] ? bg_chr[phase] + vram_word * 2 : NULL; +} +static const MmxBackgroundPalette *background_palette(unsigned phase) { + if (!bg_palette_ready[phase]) { + bg_palette_ready[phase] = true; + memset(&bg_palette[phase], 0, sizeof(bg_palette[phase])); + size_t p = background_list(0x32260, phase); + for (unsigned guard = 0; guard < 32 && range(p, 3) && word(p) != 0xffff; ++guard, p += 3) { + size_t source = 0x28000 + (word(p) & 0x7fff); + unsigned first = rom[p + 2]; + /* Chill's cave/outdoor phases leave the $20 group unchanged. */ + if (bg_stage == 8 && first == 0x20) continue; + if (first + 16 > 128 || !range(source, 32)) continue; + for (unsigned i = 0; i < 16; ++i) { + bg_palette[phase].colors[first + i] = (uint16_t)word(source + i * 2); + bg_palette[phase].valid[first + i] = true; + } + } + } + return &bg_palette[phase]; +} +const MmxBackgroundPalette *MmxRenderAssetsBackgroundPalette(const uint8_t ram[0x20000], + int world_x) { + if (!ram || (ram[0x1f7a] != 0 && ram[0x1f7a] != 8) || world_x < 0 || world_x >= 8192 || !prepare_background(ram)) return NULL; + return background_palette(bg_phase[1][world_x]); +} +const MmxBackgroundPalette *MmxRenderAssetsBackgroundPalettePhase(const uint8_t ram[0x20000], unsigned phase) { + return ram && phase < 16 && prepare_background(ram) ? background_palette(phase) : NULL; +} +uint16_t MmxRenderAssetsFadeColor(uint16_t color, unsigned amount) { + unsigned result = 0; + for (unsigned shift = 0; shift < 15; shift += 5) { + unsigned c = ((color >> shift) & 31) + amount; + result |= (c > 31 ? 31 : c) << shift; + } + return (uint16_t)result; +} +unsigned MmxRenderAssetsDeathPaletteFade(const uint8_t ram[0x20000], const uint16_t colors[256]) { + if (!ram || !colors || ram[0xd3] != 6 || !prepare_background(ram)) return 0; + const MmxBackgroundPalette *base = background_palette(ram[0x1f0a] & 15); + /* Death adds the same saturating RGB amount to the live palettes. Infer + * only an exact transform of every owned opaque color, from captured CGRAM + * (RAM can be a DMA ahead), then apply it to private margin resources too. */ + for (unsigned fade = 0; fade <= 31; ++fade) { + unsigned checked = 0; bool match = true; + for (unsigned i = 0; i < 128 && match; ++i) if ((i & 15) && base->valid[i]) { + ++checked; + match = MmxRenderAssetsFadeColor(base->colors[i], fade) == colors[i]; + } + if (checked && match) return fade; + } + return 0; +} diff --git a/src/mmx_render_assets.h b/src/mmx_render_assets.h new file mode 100644 index 0000000..dc25bce --- /dev/null +++ b/src/mmx_render_assets.h @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#include + +typedef struct MmxSpriteAsset { + uint8_t tiles[8192]; + uint16_t colors[16]; + uint8_t id, tile_base, attributes; + bool current, live_tiles, live_colors; +} MmxSpriteAsset; + +void MmxRenderAssetsSetRom(const uint8_t *rom, size_t size); +const MmxSpriteAsset *MmxRenderAssetsSprite(unsigned stage, unsigned section, unsigned sprite); +const MmxSpriteAsset *MmxRenderAssetsObjectSprite(const uint8_t ram[0x20000], + unsigned object, unsigned animation); +bool MmxRenderAssetsRideArmorPalettePending(const uint8_t ram[0x20000], const uint16_t colors[16]); +unsigned MmxRenderAssetsDeathPaletteFade(const uint8_t ram[0x20000], const uint16_t colors[256]); +uint16_t MmxRenderAssetsFadeColor(uint16_t color, unsigned amount); +typedef struct MmxBackgroundPalette { + uint16_t colors[128]; + bool valid[128]; +} MmxBackgroundPalette; +/* Resolve the resource belonging to a world column, independently of which + * side of the viewport it occupies. NULL retains the current live resource. */ +const uint8_t *MmxRenderAssetsBackgroundTile(const uint8_t ram[0x20000], + int world_x, unsigned vram_word); +const MmxBackgroundPalette *MmxRenderAssetsBackgroundPalette(const uint8_t ram[0x20000], + int world_x); +const MmxBackgroundPalette *MmxRenderAssetsBackgroundPalettePhase(const uint8_t ram[0x20000], unsigned phase); diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c new file mode 100644 index 0000000..48f97bf --- /dev/null +++ b/src/mmx_renderer.c @@ -0,0 +1,682 @@ +#include "mmx_renderer.h" +#include "mmx_display.h" +#include "mmx_wide_policy.h" +#include "mmx_render_assets.h" +#include + +/* Mode-1 decode/composition follows SuperMetroidRecomp's sm_renderer.c. + * The PPU remains native. Immutable raster snapshots are the only inputs to + * presentation; this module never calls guest code or the PPU renderer. */ +typedef struct Raster { + uint8_t registers[PPU_SAVESTATE_REGS_SIZE]; + uint16_t palette[256], oam[256], vram[0x8000]; + uint8_t high_oam[32]; +} Raster; +typedef struct Piece { + int16_t x, y; uint16_t attr; uint8_t size, animation; + uint8_t tile, palette_bits; uint16_t object; +} Piece; +enum { MAX_PIECES = 2048 }; +typedef struct Frame { + Raster lines[224]; + uint8_t ram[0x20000]; + uint32_t stock[256 * 224]; + Piece pieces[MAX_PIECES]; + unsigned piece_count, captured; + bool valid; + Piece expanded[MAX_PIECES]; + unsigned expanded_count; + bool expand; +} Frame; +static Frame frame; +static Piece building[MAX_PIECES], latched[MAX_PIECES]; +static unsigned building_count, latched_count; +static uint8_t building_stage, latched_stage; +static Piece expanded_building[MAX_PIECES], expanded_latched[MAX_PIECES]; +static unsigned expanded_building_count, expanded_latched_count; +static uint16_t current_object; +static bool observed_lists; +static const uint8_t *rom; +static size_t rom_size; +static MmxRenderStats stats; +static uint8_t door_cache[512 * 512]; +static int airport_sky_width; +typedef struct SubmarineBody { + int left, top, bottom; + unsigned shift; + bool hidden, scrolling; +} SubmarineBody; +static SubmarineBody submarine_bodies[16]; +static unsigned submarine_count; +bool g_mmx_custom_renderer; +bool g_mmx_custom_hud = true; +bool g_mmx_expanded_sprites; +bool g_mmx_render_asset_repairs = true; +MmxRenderAspect g_mmx_custom_aspect = MMX_ASPECT_ADAPTIVE; +MmxRenderView g_mmx_custom_view = {342, 43, 16.0 / 9.0}; + +MmxRenderView MmxRendererViewport(MmxRenderAspect mode, int w, int h) { + double aspect = mode == MMX_ASPECT_16_9 ? 16.0 / 9.0 : + mode == MMX_ASPECT_21_9 ? 21.0 / 9.0 : + mode == MMX_ASPECT_32_9 ? 32.0 / 9.0 : + w > 0 && h > 0 ? (double)w / h : 16.0 / 9.0; + /* Preserve CRT pixel proportions; adaptive is bounded by host capacity, + * independently of the shared PPU's 96-pixel margin capacity. */ + aspect = fmax(4.0 / 3.0, fmin(MMX_RENDER_MAX_WIDTH / 192.0, aspect)); + int width = 2 * (int)floor(aspect * 96.0 + 0.5); + return (MmxRenderView){width, (width - 256) / 2, aspect}; +} +MmxDisplayViewport MmxRendererDestination(MmxRenderView view, int width, int height) { + if (width <= 0 || height <= 0) return (MmxDisplayViewport){0}; + int w = width, h = (int)floor(width / view.aspect + 0.5); + if (h > height) { h = height; w = (int)floor(height * view.aspect + 0.5); } + return (MmxDisplayViewport){(width - w) / 2, (height - h) / 2, w, h}; +} +static unsigned word(const uint8_t *p, unsigned a) { return p[a] | (p[a + 1] << 8); } +static const uint8_t *rom_at(unsigned address, size_t length) { + if ((address & 0xffff) < 0x8000) return NULL; + size_t offset = ((address >> 16) & 0x7f) * 0x8000 + (address & 0x7fff); + return rom && offset <= rom_size && length <= rom_size - offset ? rom + offset : NULL; +} +void MmxRendererSetRom(const uint8_t *bytes, size_t length) { + rom = bytes; rom_size = length; MmxRenderAssetsSetRom(bytes, length); +} +void MmxRendererReset(void) { + frame.valid = false; frame.captured = 0; + building_count = latched_count = 0; + building_stage = latched_stage = 0xff; + expanded_building_count = expanded_latched_count = 0; + current_object = 0; observed_lists = false; +} +static Piece make_piece(const uint8_t *p, int x, int y, unsigned flip, + unsigned attributes, unsigned base, unsigned animation, unsigned object) { + int size = p[4] & 0x20 ? 16 : 8; + x += flip & 0x40 ? -(int8_t)p[1] - size : (int8_t)p[1]; + y += flip & 0x80 ? -(int8_t)p[2] - size : (int8_t)p[2]; + unsigned attr = (((p[4] & 0xce) | attributes) ^ flip) << 8; + attr |= (p[3] + base) & 255; + return (Piece){(int16_t)x, (int16_t)y, (uint16_t)attr, (uint8_t)size, + (uint8_t)animation, p[3], (uint8_t)(p[4] & 14), (uint16_t)object}; +} +static void expand_object(const uint8_t *ram, unsigned object) { + if (object < 0x20 || object > 0x1fe0) return; + unsigned animation = ram[object + 0x16], f = ram[object + 0x17] & 127; + const uint8_t *pointer = rom_at(0x8d8000 + animation * 3, 3); + if (!pointer) return; + unsigned address = word(pointer, 0) | (pointer[2] << 16); + pointer = rom_at(address + f * 3, 3); + if (!pointer) return; + address = word(pointer, 0) | (pointer[2] << 16); + const uint8_t *arrangement = rom_at(address, 1); + if (!arrangement || !rom_at(address, 1 + arrangement[0] * 4)) return; + int x = (int16_t)(word(ram, object + 5) - word(ram, 0x1e4d)); + int y = (int16_t)(word(ram, object + 8) + (int8_t)ram[object + 0x19] - word(ram, 0x1e50)); + unsigned base = MmxWidePolicy_CrusherTileBase(ram, (uint16_t)object, ram[object + 0x18]); + for (unsigned i = 0; i < arrangement[0] && expanded_building_count < MAX_PIECES; ++i) + expanded_building[expanded_building_count++] = make_piece(arrangement + i * 4, x, y, + ram[object + 0x11] & 0x40, ram[object + 0x11] & 0x3f, base, animation, object); +} +void MmxRendererObserveObject(const uint8_t ram[0x20000], uint16_t object) { + if (!g_mmx_custom_renderer || !ram) return; + if (building_stage != ram[0x1f7a]) { + building_count = expanded_building_count = 0; observed_lists = false; + building_stage = ram[0x1f7a]; + } + current_object = object; + if (observed_lists || !g_mmx_expanded_sprites) return; + observed_lists = true; + /* D56F's actual six priority queues, captured before D6A7 can exhaust OAM. + * Keep its order: queues 0..2, weapon objects, X, queues 3..5. No arbitrary + * scan of dormant object slots and no additional guest objects or writes. */ + for (unsigned group = 0; group < 6; ++group) { + if (group == 3) { + for (unsigned d = 0xc38; d <= 0xc78; d += 0x20) + if (ram[d] && ram[d + 14]) expand_object(ram, d); + if (ram[0xbb6]) expand_object(ram, 0xba8); + } + unsigned count = ram[0xe7 + group]; + if (count > 32) count = 32; + for (unsigned i = 0; i < count; ++i) expand_object(ram, word(ram, 0x920 + group * 64 + i * 2)); + } +} +void MmxRendererRecordPiece(const uint8_t ram[0x20000], uint16_t d) { + if (!g_mmx_custom_renderer || !ram || d > 0xffe0 || building_count >= MAX_PIECES) return; + if (building_stage != ram[0x1f7a]) { + building_count = 0; + building_stage = ram[0x1f7a]; + } + unsigned pointer = word(ram, d + 0x18) | (ram[d + 0x1a] << 16); + const uint8_t *p = rom_at(pointer, 5); + if (!p) return; + int x = (int16_t)word(ram, d), y = (int16_t)word(ram, d + 2); + unsigned animation = current_object && current_object < 0x1fe0 ? ram[current_object + 0x16] : 255; + building[building_count++] = make_piece(p, x, y, ram[d + 0xb], ram[d + 0xf], + ram[d + 0x10], animation, current_object); +} +void MmxRendererLatchSprites(void) { + latched_count = building_count; + latched_stage = building_stage; + memcpy(latched, building, building_count * sizeof(*building)); + building_count = 0; + expanded_latched_count = expanded_building_count; + memcpy(expanded_latched, expanded_building, expanded_building_count * sizeof(Piece)); + expanded_building_count = 0; observed_lists = false; current_object = 0; +} +static void trace_objects(const uint8_t *ram) { + static FILE *log; + static bool checked; + static unsigned tick, previous[16]; + ++tick; + if (!checked) { + checked = true; + const char *path = getenv("MMX_RENDER_OBJECT_TRACE"); + if (path && *path) log = fopen(path, "w"); + if (log) fputs("frame,object,state,camera,player_x,enemy_x,enemy_y,pieces,stage,id,health,player_y\n", log); + } + unsigned stage = ram[0x1f7a]; + if (!log) return; + for (unsigned i = 0; i < 16; ++i) { + unsigned d = i == 15 ? 0xe18 : 0xe68 + i * 64; + bool selected = i == 15 ? stage == 8 : + MmxWidePolicy_IsBossEncounter(ram[d + 10]) || + (stage == 8 && ram[d + 10] == 0x36) || (stage == 6 && ram[d + 10] == 0x37); + unsigned state = ram[d] && selected ? ram[d + 1] + 1u : 0; + if (state == previous[i] && (!state || (tick & 15))) continue; + previous[i] = state; + fprintf(log, "%u,%04x,%d,%u,%u,%u,%u,%u,%u,%u,%u,%u\n", tick, d, (int)state - 1, + word(ram, 0x1e4d), word(ram, 0xbad), word(ram, d + 5), word(ram, d + 8), latched_count, + stage, ram[d + 10], ram[d + 0x27], word(ram, 0xbb0)); + fflush(log); + } +} +void MmxRendererBeginFrame(const uint8_t ram[0x20000]) { + trace_objects(ram); + frame.valid = false; frame.captured = 0; + memcpy(frame.ram, ram, sizeof(frame.ram)); + frame.piece_count = latched_stage == ram[0x1f7a] ? latched_count : 0; + memcpy(frame.pieces, latched, frame.piece_count * sizeof(*latched)); + frame.expanded_count = latched_stage == ram[0x1f7a] ? expanded_latched_count : 0; + memcpy(frame.expanded, expanded_latched, frame.expanded_count * sizeof(Piece)); + frame.expand = g_mmx_expanded_sprites; +} +void MmxRendererCaptureLine(const Ppu *p, unsigned line) { + if (!p || line < 1 || line > 224 || line != frame.captured + 1) return; + Raster *r = &frame.lines[line - 1]; + memcpy(r->registers, p, sizeof(r->registers)); + memcpy(r->palette, p->cgram, sizeof(r->palette)); + memcpy(r->oam, p->oam, sizeof(r->oam)); + memcpy(r->high_oam, p->highOam, sizeof(r->high_oam)); + memcpy(r->vram, p->vram, sizeof(r->vram)); + ++frame.captured; +} +bool MmxRendererEndFrame(const uint32_t stock[256 * 224]) { + if (!stock || frame.captured != 224) return false; + memcpy(frame.stock, stock, sizeof(frame.stock)); + return frame.valid = true; +} +MmxRenderStats MmxRendererGetStats(void) { return stats; } +const uint32_t *MmxRendererStockFrame(void) { return frame.valid ? frame.stock : NULL; } +bool MmxRendererSaveCapture(const char *path) { + if (!frame.valid || !path) return false; + FILE *f = fopen(path, "wb"); + if (!f) return false; + uint32_t header[] = {0x4d4d5843, 2, sizeof(frame)}; + bool ok = fwrite(header, sizeof(header), 1, f) == 1 && fwrite(&frame, sizeof(frame), 1, f) == 1; + return fclose(f) == 0 && ok; +} +bool MmxRendererLoadCapture(const char *path) { + if (!path) return false; + FILE *f = fopen(path, "rb"); + if (!f) return false; + uint32_t h[3]; + frame.valid = false; + bool ok = fread(h, sizeof(h), 1, f) == 1 && h[0] == 0x4d4d5843 && + h[1] == 2 && h[2] == sizeof(frame) && fread(&frame, sizeof(frame), 1, f) == 1 && + frame.captured == 224 && frame.piece_count <= MAX_PIECES && frame.expanded_count <= MAX_PIECES && frame.valid && fgetc(f) == EOF; + fclose(f); frame.valid = ok; return ok; +} + +bool MmxRendererStageTile(const uint8_t ram[0x20000], unsigned layer, + int x, int y, uint16_t *entry) { + if (!ram || !entry || layer > 1 || x < 0 || y < 0 || x >= 8192 || y >= 8192) return false; + unsigned layout = layer ? 0xec00 : 0xe800; + unsigned screens = layer ? 0xa600 : 0x2000; + unsigned screen = ram[layout + (y >> 8) * 32 + (x >> 8)]; + unsigned address = (screens + screen * 512 + ((y & 0xf0) << 1) + ((x & 0xf0) >> 3)) & 0xffff; + unsigned metatile = word(ram, address); + unsigned pointer = 0xb95 + layer * 3; + unsigned table = word(ram, pointer) | (ram[pointer + 2] << 16); + address = (table & 0xff0000) | ((table + metatile * 8 + ((y & 8) ? 4 : 0) + ((x & 8) ? 2 : 0)) & 0xffff); + const uint8_t *tile = rom_at(address, 2); + if (!tile) return false; + *entry = (uint16_t)word(tile, 0); return true; +} +static bool door_body(int x, int y) { + if (x < 0 || y < 0 || x >= 8192 || y >= 8192) return false; + unsigned key = (y >> 4) * 512 + (x >> 4); + if (door_cache[key]) return door_cache[key] == 2; + door_cache[key] = 1; + x &= ~15; y &= ~15; + for (int row = 0; row < 3; ++row) { + uint16_t entries[3][4]; + bool valid = true; + for (int r = 0; r < 3; ++r) for (int q = 0; q < 4; ++q) + valid &= MmxRendererStageTile(frame.ram, 0, x + (q & 1) * 8, + y + (r - row) * 16 + (q >> 1) * 8, &entries[r][q]); + if (valid && MmxWidePolicy_IsBossDoorBody(entries, row)) { door_cache[key] = 2; return true; } + } + return false; +} +static unsigned tile_pixel(const uint16_t *vram, unsigned address, int x, int y, unsigned bpp) { + unsigned a = (address + y) & 0x7fff, shift = 7 - x; + unsigned bits = vram[a] >> shift; + unsigned pixel = (bits & 1) | ((bits >> 7) & 2); + if (bpp == 4) { bits = vram[(a + 8) & 0x7fff] >> shift; + pixel |= ((bits & 1) << 2) | ((bits >> 5) & 8); } + return pixel; +} +static void prepare_stage_planes(void) { + airport_sky_width = 0; submarine_count = 0; + if (frame.ram[0x1f7a] == 5 && frame.ram[0x1e89] == 0x0e && + word(frame.ram, 0x1e90) == 0 && word(frame.ram, 0x1e50) >= 0x300) { + /* The airport panorama ends partway through screen 2 (640 pixels in + * the retail map). Later cells belong to other mechanisms and contain + * intentional holes. Discover the continuous sky band from its top row + * and reflect that edge, instead of exposing those unpainted cells. */ + unsigned x; uint16_t tile; + for (x = 0; x < 1024; x += 8) + if (!MmxRendererStageTile(frame.ram, 1, x, 0, &tile) || !(tile & 1023)) break; + if (x >= 256 && x < 1024) airport_sky_width = (int)x; + } + if (frame.ram[0x1f7a] != 1) return; + for (unsigned d = 0xe68; d <= 0x1228; d += 64) { + const uint8_t *r = frame.ram; + if (!r[d] || r[d + 10] != 0x21 || !(r[d + 11] & 0x80) || r[d + 1] != 0) continue; + unsigned variant = r[d + 11] & 0x7f; + if (variant >= 3) continue; + /* $82:AE81 / $86:CBEC describe the buried submarine's BG1 body. + * State 4 starts its real rise. Before that, the source-art rectangle + * must stay empty even when an adaptive margin can already see it. */ + const uint8_t *top = rom_at(0x86cbec + variant * 2, 2); + const uint8_t *bottom = rom_at(0x86cbf2 + variant * 2, 2); + if (!top || !bottom || word(bottom, 0) < word(top, 0)) continue; + unsigned shift = word(r, d + 0x34); + bool scrolling = (r[d + 2] == 2 || r[d + 2] == 4) && r[0xba1] == 2 && + word(r, 0x1f28) == word(top, 0) && word(r, 0x1f2a) == word(bottom, 0) && + shift > 0 && shift <= 0x50; + submarine_bodies[submarine_count++] = (SubmarineBody){ + ((int)word(r, d + 5)) & ~31, (int)word(top, 0), (int)word(bottom, 0) + 1, + shift, r[d + 2] != 4, scrolling}; + } +} +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; + /* Launch's BG3 water plane is blended over the world on the subscreen. + * Repeat that plane while retaining the vertical waterline/scroll. Other + * BG3 uses, including dialogue, stay within their native screen bounds. */ + bool water = frame.ram[0x1f7a] == 1 && (p->screenEnabled[0] & 4) && + !(p->screenEnabled[1] & 4) && (p->cgwsel & 2) && (p->cgadsub & 0x44) == 0x44; + if (stage && layer == 2 && margin && !water) return 0; + /* Spark's BG2 mode $0C is the Thunder Slimer actor surface, not the + * scrolling level map. The retained map contains its staging tiles; + * extending those outside the native arena duplicates dormant bubbles. */ + if (stage && layer == 1 && margin && frame.ram[0x1f7a] == 6 && frame.ram[0x1e89] == 0x0c) return 0; + int asset_x = -1; + bool armadillo_lower_shaft = false; + unsigned bpp = layer == 2 ? 2 : 4, size = PPU_bigTiles(p, layer) ? 16 : 8; + int px = (x + p->hScroll[layer]) & 1023, py = (y + p->vScroll[layer]) & 1023; + unsigned sc = p->bgXsc[layer], tx = px / size, ty = py / size; + unsigned a = (sc & 0xfc) * 256 + (tx & 31) + (ty & 31) * 32; + 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 (stage && size == 8 && layer < 2 && (x < 0 || x >= 256)) { + int wx, wy; + if (layer == 0) { + wx = MmxDisplay_ExpandStageScroll((uint16_t)word(frame.ram, 0x1e4d), p->hScroll[0]) + x; + wy = MmxDisplay_ExpandStageScroll((uint16_t)word(frame.ram, 0x1e50), p->vScroll[0]) + y; + /* $81:F9B7 joins the shaft at camera $1F00,$0600 to $0100,$0800. + * Project its continuation before that native-coordinate relocation, + * so the wide sides already show the lower room while X falls. */ + if (frame.ram[0x1f7a] == 3 && word(frame.ram, 0x1e4d) == 0x1f00 && + word(frame.ram, 0x1e50) >= 0x500 && word(frame.ram, 0x1e50) <= 0x600 && wy >= 0x600) { + wx -= 0x1e00; wy += 0x200; armadillo_lower_shaft = true; + } + for (unsigned i = 0; i < submarine_count; ++i) { + const SubmarineBody *body = &submarine_bodies[i]; + /* $82:B414 subtracts the burial offset into $C4; the $80:84CB + * raster IRQ applies it to the whole BG1 scanline. Only the body + * occupies that band in 4:3. Restore the terrain's original scroll + * outside its columns in the wider view, including during the rise. + * Match the captured IRQ value so other raster bands stay intact. */ + if (body->scrolling && (p->vScroll[0] & 1023) == (word(frame.ram, 0xc4) & 1023)) { + if (wx < body->left || wx >= body->left + 160) wy += body->shift; + break; + } + } + } else { + int stream_x = (int16_t)word(frame.ram, 0x1e8d), stream_y = (int16_t)word(frame.ram, 0x1e90); + wx = stream_x + (((p->hScroll[1] - stream_x + 512) & 1023) - 512) + x; + wy = stream_y + (((p->vScroll[1] - stream_y + 512) & 1023) - 512) + y; + /* The boat's BG2 body occupies one source screen; surrounding map + * cells are staging art. The controller scrolls that screen into view. */ + if (frame.ram[0x1f7a] == 1 && frame.ram[0x1e89] == 0x0c && + (wx < 0xb00 || wx >= 0xc00 || wy < 0 || wy >= 256)) return 0; + /* The sea floor behind the raised bank was never exposed by the + * native camera: its $0370..038F band includes foreground scraps. + * Continue the adjacent authored seabed across that occluded band. */ + if (frame.ram[0x1f7a] == 1 && frame.ram[0x1e89] == 0x0e && + wx >= 0x500 && wx < 0x700 && wy >= 0x370 && wy < 0x390) + wx = 0x700 | (wx & 255); + /* Highway's final arena switches to the sky plane at BG2 x=$A00. + * Earlier columns are intentionally empty at this vertical scroll; + * extend the arena's sky edge when a wide view reaches behind it. */ + if (frame.ram[0x1f7a] == 0 && stream_x >= 0xa00 && wx < 0xa00) wx = 0xa00; + } + /* Reconstruct prepared map data independently of circular VRAM history. + * Outside authored terrain, reflect only the background edge. */ + if (wx < 0) wx = -wx - 1; + if (layer == 1 && airport_sky_width && wx >= airport_sky_width) { + wx %= 2 * airport_sky_width; + if (wx >= airport_sky_width) wx = 2 * airport_sky_width - wx - 1; + } + if (layer == 0) { + unsigned stage_id = frame.ram[0x1f7a]; + if (stage_id < 13 && rom_size > 0x30d24 + stage_id * 3 + 2) { + const uint8_t *t = rom + 0x30d24 + stage_id * 3; + const uint8_t *bounds = rom_at(word(t, 0) | (t[2] << 16), 2); + if (bounds && bounds[0] && bounds[0] <= 32) { + int width = bounds[0] * 256; + if (wx >= width) wx = width * 2 - wx - 1; + if (wx < 0) wx = 0; + } + } + for (unsigned i = 0; i < submarine_count; ++i) { + const SubmarineBody *body = &submarine_bodies[i]; + if (body->hidden && wx >= body->left && wx < body->left + 160 && wy >= body->top && wy < body->bottom) { + wx -= 256; break; /* The preceding water screen has no source body. */ + } + } + if (door_body(wx, wy)) { + bool left = door_body(wx - 16, wy), right = door_body(wx + 16, wy); + if (left || right) { + int boundary = (wx & ~15) + (right ? 16 : 0); + bool view_left = word(frame.ram, 0x1e4d) + 128 < (unsigned)boundary; + /* Retain the column facing the current room. Only its duplicate + * samples the neighboring wall; a distant closed door stays visible. */ + if (view_left && left) wx += 16; + if (!view_left && right) wx -= 16; + } + } + } + uint16_t mapped; + if (MmxRendererStageTile(frame.ram, layer, wx, wy, &mapped)) { + tile = mapped; px = wx; py = wy; + /* The city moves at half speed. Express its map column as the player + * X at which it crosses the native view's center (camera+128). */ + asset_x = layer == 1 && frame.ram[0x1f7a] == 0 ? wx * 2 - 128 : wx; + /* Chill's BG2 sky palette also changes with elevation. Its cave-exit + * X transition owns foreground art only; keep the live sky colors. */ + if (layer == 1 && frame.ram[0x1f7a] == 8) asset_x = -1; + } + } + int cx = px & (size - 1), cy = py & (size - 1); + if (tile & 0x4000) cx = size - 1 - cx; + if (tile & 0x8000) cy = size - 1 - cy; + unsigned number = ((tile & 1023) + cx / 8 + cy / 8 * 16) & 1023; + unsigned address = (PPU_bgTileAdr(p, layer) + number * bpp * 4) & 0x7fff; + const uint8_t *bits = g_mmx_render_asset_repairs && bpp == 4 && !(frame.ram[0x1f7a] == 1 && layer == 1) ? + MmxRenderAssetsBackgroundTile(frame.ram, asset_x, address) : NULL; + unsigned pixel; + if (bits) { + bits += (cy & 7) * 2; + unsigned shift = 7 - (cx & 7); + pixel = ((bits[0] >> shift) & 1) | (((bits[1] >> shift) & 1) << 1) | + (((bits[16] >> shift) & 1) << 2) | (((bits[17] >> shift) & 1) << 3); + } else pixel = tile_pixel(r->vram, address, cx & 7, cy & 7, bpp); + if (!pixel) return 0; + unsigned index = (((tile >> 10) & 7) << bpp) | pixel; + const MmxBackgroundPalette *palette = g_mmx_render_asset_repairs ? + (armadillo_lower_shaft ? MmxRenderAssetsBackgroundPalettePhase(frame.ram, 1) : + MmxRenderAssetsBackgroundPalette(frame.ram, asset_x)) : NULL; + if (palette && palette->valid[index]) *private_color = palette->colors[index]; + unsigned priority = tile & 0x2000 ? (layer == 2 && (p->bgmode & 8) ? 15 : high[layer]) : low[layer]; + return (uint16_t)((priority << 12) | (layer << 8) | index); +} +static void sprite(const Ppu *p, const Raster *r, int x, int sy, unsigned attr, int size, + int y, MmxRenderView view, uint16_t *out, bool margins_only, + const MmxSpriteAsset *asset, unsigned raw_tile, int *object_color, + bool full_coordinates) { + int row = full_coordinates ? y - sy : (y - sy) & 255; + if (row < 0 || row >= size) return; + if (attr & 0x8000) row = size - 1 - row; + unsigned base = (p->obsel & 7) * 8192; + if (attr & 256) base += (((p->obsel >> 3) & 3) + 1) * 4096; + unsigned z = ((((attr >> 12) & 3) * 4 + 2) << 12) | + ((attr & 0x800 ? 4 : 6) << 8) | (128 + ((attr >> 9) & 7) * 16); + for (int c = 0; c < size; ++c) { + int dx = x + c, dest = dx + view.extra; + if (dest < 0 || dest >= view.width || (margins_only && dx >= 0 && dx < 256)) continue; + int cx = attr & 0x4000 ? size - 1 - c : c; + unsigned number = asset && !asset->live_tiles ? raw_tile : attr & 255; + unsigned tile = ((((number >> 4) + row / 8) & 15) << 4) | (((number & 15) + cx / 8) & 15); + unsigned pixel; + if (asset && !asset->live_tiles) { + const uint8_t *bits = asset->tiles + tile * 32 + (row & 7) * 2; + unsigned shift = 7 - (cx & 7); + pixel = ((bits[0] >> shift) & 1) | (((bits[1] >> shift) & 1) << 1) | + (((bits[16] >> shift) & 1) << 2) | (((bits[17] >> shift) & 1) << 3); + } else pixel = tile_pixel(r->vram, base + tile * 16, cx & 7, row & 7, 4); + if (pixel) { + out[dest] = (uint16_t)(z | pixel); + object_color[dest] = asset && !asset->live_colors ? asset->colors[pixel] : -1; + if (x + c < 0 || x + c >= 256) ++stats.margin_sprite_pixels; + } + } +} +static bool window(const Ppu *p, int layer, int x, int extra) { + unsigned flags = (p->windowsel >> (layer * 4)) & 15; + int l1 = p->window1left == 0 ? -extra : p->window1left; + int r1 = p->window1right == 255 ? 255 + extra : p->window1right; + int l2 = p->window2left == 0 ? -extra : p->window2left; + int r2 = p->window2right == 255 ? 255 + extra : p->window2right; + bool a = (x >= l1 && x <= r1) != ((flags & 1) != 0); + bool b = (x >= l2 && x <= r2) != ((flags & 4) != 0); + if (!(flags & 2)) return (flags & 8) && b; + if (!(flags & 8)) return a; + switch ((p->wbgobjlog >> (layer * 2)) & 3) { + case 0: return a || b; case 1: return a && b; case 2: return a != b; default: return a == b; + } +} +typedef struct LightBeam { int left[224], right[224]; } LightBeam; +static unsigned spark_lights(LightBeam beams[2], int extra) { + /* $87:A7F6 builds 8-bit HDMA windows from the ROM's rounded beam profile. + * Rebuild only that color window in signed host coordinates. The native + * generator clamps a left-moving light at zero and waits for a right-side + * arrival to enter 256 pixels; neither limitation describes a wide view. */ + if (!g_mmx_render_asset_repairs || frame.ram[0x1f7a] != 6 || + frame.ram[0x1f0a] != 4 || frame.ram[0x1e89] != 2) return 0; + const uint8_t *curve = rom_at(0x86d136, 25); + if (!curve) return 0; + /* Sprite/HDMA submission precedes the next camera update in frame.ram. + * Use the captured raster scroll, as terrain reconstruction does. */ + int camera_x = MmxDisplay_ExpandStageScroll((uint16_t)word(frame.ram, 0x1e4d), + (uint16_t)word(frame.lines[0].registers, 14)); + int camera_y = MmxDisplay_ExpandStageScroll((uint16_t)word(frame.ram, 0x1e50), + (uint16_t)word(frame.lines[0].registers, 22)); + unsigned count = 0; + for (unsigned d = 0xe68; d <= 0x1228 && count < 2; d += 64) { + const uint8_t *r = frame.ram; + if (!r[d] || r[d + 10] != 0x37 || r[d + 1] != 2 || r[d + 0x1c] || + (r[d + 0x2d] != 0x40 && r[d + 0x2d] != 0x80)) continue; + bool right = r[d + 0x0b] == 1, fading = r[d + 3] != 0; + int tip = (int16_t)(word(r, d + 0x22) - camera_x) + (right ? -24 : 24); + int top = fading ? (int16_t)word(r, d + 0x36) : + (int16_t)(word(r, d + 0x24) - camera_y) - 24; + unsigned height = fading ? r[d + 0x3b] : 49; + int inset = fading ? r[d + 0x1f] : 0; + LightBeam *beam = &beams[count++]; + for (int y = 0; y < 224; ++y) { beam->left[y] = 1; beam->right[y] = 0; } + unsigned index = 0, remaining = curve[0]; int edge = fading ? 0 : 12; + for (unsigned row = 0; row < height && row < 49; ++row) { + int y = top + (int)row; + if (y >= 0 && y < 224) { + beam->left[y] = right ? -extra + inset : tip + edge; + beam->right[y] = right ? tip - edge : 255 + extra - inset; + } + if (!fading) { + if (remaining) --remaining; + else if (index < 25) { + remaining = curve[index++]; + if (remaining >= 5) { remaining -= 5; ++edge; } else --edge; + } + } + } + } + return count; +} +static bool condition(unsigned mode, bool inside) { return mode == 3 || (mode == 1 && !inside) || (mode == 2 && inside); } +static uint32_t colour(const Ppu *p, const uint16_t *palette, const uint8_t brightness[32], uint16_t main, uint16_t sub, bool inside, int object_color, const int bg_colors[3]) { + unsigned rgb = palette[main & 255], layer = (main >> 8) & 15; + if (object_color >= 0 && (layer == 4 || layer == 6)) rgb = (unsigned)object_color; + if (layer < 3 && bg_colors[layer] >= 0) rgb = (unsigned)bg_colors[layer]; + bool clipped = condition(p->cgwsel >> 6, inside); + bool math = !condition((p->cgwsel >> 4) & 3, inside) && ((p->cgadsub & 63) & (1u << layer)); + unsigned other = p->fixedColor; + bool half = math && (p->cgadsub & 64) && !clipped; + if (math && (p->cgwsel & 2)) { + if (sub & 255) { + unsigned sub_layer = (sub >> 8) & 15; + other = object_color >= 0 && (sub_layer == 4 || sub_layer == 6) ? (unsigned)object_color : palette[sub & 255]; + if (sub_layer < 3 && bg_colors[sub_layer] >= 0) other = (unsigned)bg_colors[sub_layer]; + } else half = false; + } + uint32_t result = 0; + for (int component = 0; component < 3; ++component) { + int c = clipped ? 0 : (rgb >> (component * 5)) & 31; + if (math) { int second = (other >> (component * 5)) & 31; + c += p->cgadsub & 128 ? -second : second; + if (c < 0) c = 0; + if (half) c /= 2; + if (c > 31) c = 31; + } + result |= (uint32_t)brightness[c] << (16 - component * 8); + } + return result; +} +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; + memset(&stats, 0, sizeof(stats)); stats.pieces = frame.piece_count; + memset(door_cache, 0, sizeof(door_cache)); + memset(out, 0, (size_t)view.width * 224 * sizeof(*out)); + bool stage = MmxWidePolicy_IsStageScene(frame.ram); + prepare_stage_planes(); + LightBeam beams[2]; + unsigned beam_count = stage ? spark_lights(beams, view.extra) : 0; + unsigned palette_fade = stage && g_mmx_render_asset_repairs ? + MmxRenderAssetsDeathPaletteFade(frame.ram, frame.lines[0].palette) : 0; + const Piece *pieces = frame.expand && g_mmx_render_asset_repairs ? frame.expanded : frame.pieces; + unsigned piece_count = frame.expand && g_mmx_render_asset_repairs ? frame.expanded_count : frame.piece_count; + const MmxSpriteAsset *piece_assets[MAX_PIECES] = {0}; + if (stage && g_mmx_render_asset_repairs) for (unsigned i = 0; i < piece_count; ++i) { + const Piece *s = &pieces[i]; + const MmxSpriteAsset *a = MmxRenderAssetsObjectSprite(frame.ram, s->object, s->animation); + /* Keep current allocations and their live flashes/animation. Repair + * missing or stale bindings using the ROM resource's own palette. */ + if (a && (!a->current || (s->attr & 255) != ((s->tile + a->tile_base) & 255) || + ((s->attr >> 8) & 0x2f) != (unsigned)(a->attributes | s->palette_bits) || + (s->object == 0xe18 && MmxRenderAssetsRideArmorPalettePending(frame.ram, + frame.lines[0].palette + 128 + ((s->attr >> 9) & 7) * 16)))) piece_assets[i] = a; + } + 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) { + memcpy(out + y * view.width + view.extra, frame.stock + y * 256, 256 * sizeof(*out)); + ++stats.fallback_lines; continue; + } + ++stats.custom_lines; + if (p.inidisp & 128) continue; + uint8_t brightness[32]; + for (int c = 0; c < 32; ++c) brightness[c] = (uint8_t)(((c << 3) | (c >> 2)) * (p.inidisp & 15) / 15); + uint16_t objects[MMX_RENDER_MAX_WIDTH] = {0}; + int object_colors[MMX_RENDER_MAX_WIDTH]; + for (int x = 0; x < view.width; ++x) object_colors[x] = -1; + bool replaced[128] = {false}; + for (int i = (int)piece_count - 1; i >= 0; --i) { + Piece s = pieces[i]; const MmxSpriteAsset *asset = piece_assets[i]; + /* Recorded pieces already obey the retail submission budget. Draw + * their entire footprint, including x=255 which native D76A clips. + * Only the explicit expanded list can add pieces beyond that budget. */ + bool center = g_mmx_render_asset_repairs; + if (g_mmx_render_asset_repairs) for (int slot = 16; slot < 128; ++slot) { + 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) { + replaced[slot] = true; center = true; + } + } + unsigned attr = asset ? (s.attr & 0xd000) | 0x2000 | ((asset->attributes & 15) << 8) | + (asset->live_tiles ? s.attr & 255 : 0) : s.attr; + if (asset && asset->live_colors) attr = (attr & ~0x0e00u) | (s.attr & 0x0e00u); + sprite(&p, r, s.x, s.y, attr, s.size, y, view, objects, !center, asset, s.tile, object_colors, true); + } + int bar_first = -1, bar_count = 0; + if (hud) for (int slot = 16; slot <= 48; ++slot) { + unsigned pos = r->oam[slot * 2], attr = r->oam[slot * 2 + 1]; + unsigned tile = attr & 255, hi = (r->high_oam[slot / 4] >> (slot % 4 * 2)) & 1; + bool bar = !hi && (pos & 255) >= 216 && (pos >> 8) < 96 && ((attr >> 9) & 7) == 2 && + (tile == 128 || tile == 130 || tile == 132 || tile == 134 || tile == 170); + if (bar) { if (bar_first < 0) bar_first = slot; ++bar_count; } + else if (bar_first >= 0) break; + } + static const int sizes[8][2] = {{8,16},{8,32},{8,64},{16,32},{16,64},{32,64},{16,32},{16,32}}; + for (int slot = 127; slot >= 0; --slot) { + if (replaced[slot]) continue; + unsigned pos = r->oam[slot * 2], attr = r->oam[slot * 2 + 1]; + unsigned hi = r->high_oam[slot / 4] >> (slot % 4 * 2); + int x = (pos & 255) | ((hi & 1) << 8), sy = pos >> 8; + if (x >= 256) x -= 512; + int size = sizes[p.obsel >> 5][(hi >> 1) & 1]; + if (x + size <= 0 || x >= 256) continue; + bool anchored = hud && sy < 96 && (slot < 16 || (bar_count >= 4 && slot >= bar_first && slot < bar_first + bar_count)); + if (anchored) { if (x < 25) x -= view.extra; else if (x >= 216) x += view.extra; } + sprite(&p, r, x, sy, attr, size, y, view, objects, false, NULL, 0, object_colors, false); + } + for (int sx = 0; sx < view.width; ++sx) { + int x = sx - view.extra; + 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 (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]); + } + if (palette_fade) { + for (int layer = 0; layer < 3; ++layer) if (bg_colors[layer] >= 0) + bg_colors[layer] = MmxRenderAssetsFadeColor((uint16_t)bg_colors[layer], palette_fade); + if (object_colors[sx] >= 0) + object_colors[sx] = MmxRenderAssetsFadeColor((uint16_t)object_colors[sx], palette_fade); + } + for (int sub = 0; sub < 2; ++sub) { + for (int layer = 0; layer < 3; ++layer) + if ((p.screenEnabled[sub] & (1 << layer)) && + (!(p.screenWindowed[sub] & (1 << layer)) || !window(&p, layer, x, view.extra)) && bg[layer] > screens[sub]) screens[sub] = bg[layer]; + if ((p.screenEnabled[sub] & 16) && (!(p.screenWindowed[sub] & 16) || !window(&p, 4, x, view.extra)) && + objects[sx] > screens[sub]) screens[sub] = objects[sx]; + } + bool color_window = window(&p, 5, x, view.extra); + if (beam_count) { + color_window = false; + for (unsigned i = 0; i < beam_count; ++i) + color_window |= x >= beams[i].left[y] && x <= beams[i].right[y]; + } + out[y * view.width + sx] = colour(&p, r->palette, brightness, screens[0], screens[1], color_window, object_colors[sx], bg_colors); + } + } + return true; +} diff --git a/src/mmx_renderer.h b/src/mmx_renderer.h new file mode 100644 index 0000000..00c84eb --- /dev/null +++ b/src/mmx_renderer.h @@ -0,0 +1,38 @@ +#pragma once +#include "snes/ppu.h" +#include "mmx_display.h" + +enum { MMX_RENDER_HEIGHT = 224, MMX_RENDER_MAX_WIDTH = 1024 }; +typedef enum MmxRenderAspect { + MMX_ASPECT_ADAPTIVE, MMX_ASPECT_16_9, MMX_ASPECT_21_9, MMX_ASPECT_32_9 +} MmxRenderAspect; +typedef struct MmxRenderView { int width, extra; double aspect; } MmxRenderView; +typedef struct MmxRenderStats { + unsigned custom_lines, fallback_lines, margin_sprite_pixels, pieces; +} MmxRenderStats; + +extern bool g_mmx_custom_renderer; +extern bool g_mmx_custom_hud; +extern bool g_mmx_expanded_sprites; +/* Diagnostic oracle switch: compare the compositor before art repairs. */ +extern bool g_mmx_render_asset_repairs; +extern MmxRenderAspect g_mmx_custom_aspect; +extern MmxRenderView g_mmx_custom_view; +MmxRenderView MmxRendererViewport(MmxRenderAspect aspect, int width, int height); +MmxDisplayViewport MmxRendererDestination(MmxRenderView view, int width, int height); +void MmxRendererReset(void); +void MmxRendererSetRom(const uint8_t *rom, size_t size); +/* Capture drawing data before native clipping; never modify guest state. */ +void MmxRendererRecordPiece(const uint8_t ram[0x20000], uint16_t scratch); +void MmxRendererObserveObject(const uint8_t ram[0x20000], uint16_t object); +void MmxRendererLatchSprites(void); +void MmxRendererBeginFrame(const uint8_t ram[0x20000]); +void MmxRendererCaptureLine(const Ppu *ppu, unsigned line); +bool MmxRendererEndFrame(const uint32_t stock[256 * 224]); +bool MmxRendererDraw(uint32_t *output, MmxRenderView view, bool anchor_hud); +MmxRenderStats MmxRendererGetStats(void); +bool MmxRendererStageTile(const uint8_t ram[0x20000], unsigned layer, + int x, int y, uint16_t *word); +bool MmxRendererSaveCapture(const char *path); +bool MmxRendererLoadCapture(const char *path); +const uint32_t *MmxRendererStockFrame(void); diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c index ad533cc..753947d 100644 --- a/src/mmx_rtl.c +++ b/src/mmx_rtl.c @@ -1,5 +1,6 @@ #include "mmx_rtl.h" #include "mmx_wide_policy.h" +#include "mmx_renderer.h" #include "variables.h" #include "common_cpu_infra.h" #include "snes/snes.h" @@ -326,7 +327,7 @@ void mmx_host_yield(uint8_t countdown) { #include "snes/saveload.h" #define MMX_SAV_CHUNK_MAGIC 0x4D4D5854u /* "MMXT" */ -#define MMX_SAV_CHUNK_VERSION 1u +#define MMX_SAV_CHUNK_VERSION 2u /* Native-timed moving streaker entrances. */ typedef struct MmxSavChunk { uint32_t magic, version; @@ -378,14 +379,29 @@ void MmxStateLoadExtra(struct SaveLoadInfo *sli, uint32_t version) { 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 && - g_load_chunk.version == MMX_SAV_CHUNK_VERSION) + g_load_chunk.version >= 1 && g_load_chunk.version <= MMX_SAV_CHUNK_VERSION) g_load_chunk_ok = 1; else fprintf(stderr, "[mmx_state] load: bad game chunk (magic=%08x ver=%u)\n", g_load_chunk.magic, g_load_chunk.version); } +static bool s_ws_recover_armor; +static int MmxWsMargin(void); void MmxOnStateLoaded(uint32_t version) { + MmxRendererReset(); + s_ws_recover_armor = g_mmx_custom_renderer && MmxWidePolicy_PrematureRideArmor(g_ram); + if (g_mmx_custom_renderer && (!g_load_chunk_ok || g_load_chunk.version < 2)) { + for (uint16 object = 0xe68; object <= 0x1228; object += 64) { + uint16 flag = g_ram[object + 12] | (g_ram[object + 13] << 8); + if (flag < 0xfa00 || flag > 0xfffb) continue; + uint16 record = g_ram[flag + 3] | (g_ram[flag + 4] << 8); + if (record < 0x8000 || record > 0xfff9) continue; + const uint8 *event = RomPtr(0x850000u | record); + if ((event[0] & 15) == 3 && event[3] == 0x37) + MmxWidePolicy_RecoverParkedStreaker(g_ram, object, (event[5] | (event[6] << 8)) & 0x1fff); + } + } 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 @@ -652,6 +668,7 @@ void MmxDrawPpuFrame(void) { int trigger = g_snes->vIrqEnabled ? g_snes->vTimer + 1 : -1; for (int i = 0; i <= 224; i++) { + if (g_mmx_custom_renderer) MmxRendererCaptureLine(g_ppu, i); ppu_runLine(g_ppu, i); SimpleHdma_DoLine(&hdma_chans[0]); SimpleHdma_DoLine(&hdma_chans[1]); @@ -793,6 +810,10 @@ void RunOneFrameOfGame(void) { } } cpu_trace_px_breadcrumb(&g_cpu, 0x2002, "before_Internal"); + if (s_ws_recover_armor) { + if (!g_mmx_custom_renderer || !MmxWidePolicy_PrematureRideArmor(g_ram) || + MmxWidePolicy_RecoverRideArmor(g_ram, MmxWsMargin())) s_ws_recover_armor = false; + } /* Rearm the P.X tripwire here so the first x=1→0 transition INSIDE * Internal() (the main game loop) is captured fresh. The earlier * boot-time REP #$38 in I_RESET is expected and intentional; we only @@ -853,8 +874,9 @@ static int MmxWsMargin(void) { extern bool g_ws_active; extern int g_ws_extra; extern uint8_t g_ram[0x20000]; - if (!g_ws_active || g_ram[0xD1] != 0x02 || g_ram[0xD2] != 0x04) + if ((!g_ws_active && !g_mmx_custom_renderer) || g_ram[0xD1] != 0x02 || g_ram[0xD2] != 0x04) return 0; + if (g_mmx_custom_renderer) return MmxWidePolicy_IsStageScene(g_ram) ? (g_mmx_custom_view.extra + 7) & ~7 : 0; return (g_ws_extra + 7) & ~7; } @@ -876,6 +898,13 @@ uint16 MmxWsShotCullVerdictX(uint16 v) { return ((uint16)(v + m) >= (uint16)(0x140 + 2 * m)) ? 1 : 0; } +uint16 MmxWsFlyerLeashLimit(void) { + return MmxWidePolicy_FlyerLeash(g_mmx_custom_renderer ? MmxWsMargin() : 0); +} +uint16 MmxWsRideArmorCullVerdictX(uint16 v) { + return MmxWidePolicy_RideArmorCull(v, g_mmx_custom_renderer ? MmxWsMargin() : 0); +} + /* bank_00_DC36 spawn-scan anchors (one 32px column scanned per camera * column crossing; bank_00_DCDB walks the column's spawn records). * Right anchor: vanilla $1E4D + 0x100 -> +margin so enemies enter the @@ -898,13 +927,16 @@ static int MmxWsSpawnWide(void) { * carry = (objX - camX + 0x60) >= 0x1c0. Ordinary enemy lifetime already * uses the widened bank_02_806E path, but these kind-1 cars never visit it: * at the early spawn anchor 808F marks them offscreen and their F554 updater - * immediately clears the object. Widen only Highway traffic ID $21 here; - * every other presentation object retains the exact vanilla verdict. */ + * immediately clears the object. The dedicated Ride Armor slot also draws + * through this routine, so its presentation must match its wider lifetime. */ uint16 MmxWsPresentationCullVerdictX(uint16 dpage, uint16 v) { extern uint8_t g_ram[0x20000]; int m = MmxWsSpawnWide() ? MmxWsMargin() : 0; - if (!m || g_ram[0x1f7a] != 0x00 || - g_ram[(uint16)(dpage + 0x0a)] != 0x21) + bool traffic = g_ram[0x1f7a] == 0 && g_ram[(uint16)(dpage + 0x0a)] == 0x21; + bool armor = g_mmx_custom_renderer && dpage == 0xe18; + bool grinder = g_mmx_custom_renderer && dpage >= 0xe68 && dpage < 0x1228 && + (dpage & 63) == 0x28 && g_ram[dpage + 10] == 0x2c; + if (!m || (!traffic && !armor && !grinder)) return v >= 0x1c0 ? 1 : 0; return ((uint16)(v + m) >= (uint16)(0x1c0 + 2 * m)) ? 1 : 0; } @@ -915,6 +947,7 @@ uint16 MmxWsPresentationCullVerdictX(uint16 dpage, uint16 v) { * D6A7 already packs bit 8 of D76A's 16-bit screen X into the SNES OAM high * table, and the widened PPU preserves those positive 256+ coordinates. */ uint16 MmxWsOamRightLimit(uint16 vanilla_limit) { + if (g_mmx_custom_renderer) return vanilla_limit; int m = MmxWsSpawnWide() ? MmxWsMargin() : 0; return (uint16)(vanilla_limit + m); } @@ -927,6 +960,7 @@ uint16 MmxWsOamRightLimit(uint16 vanilla_limit) { * widened limit) OR the left-margin window x+16 in [-margin, 0). The * PPU's 9-bit OAM X path already renders the negative coordinates. */ uint16 MmxWsOamXReject(uint16 x_plus_16, uint16 widened_limit) { + if (g_mmx_custom_renderer) return x_plus_16 >= widened_limit; if (x_plus_16 < widened_limit) return 0; int m = MmxWsSpawnWide() ? MmxWsMargin() : 0; @@ -948,8 +982,9 @@ int MmxWsRealSpawnActive(void) { * The normal DCDB call uses the host-owned wide cursor/anchor and admits * ordinary type-3 enemies plus narrowly identified Highway traffic. A second * host-paired DCDB call restores the guest's native cursor and unmodified 4:3 - * anchor for kinds 0-2. Spark Mandrill's kind-3/id-$03 mid-boss controller is - * also native-owned. The guest cursor remains save-state-authoritative, so a + * anchor for kinds 0-2. Spark's kind-3/id-$03 and Highway's kind-3/id-$22 + * encounter controllers are also native-owned. The guest cursor remains + * save-state-authoritative, so a * rejected wide record is still present when native timing reaches it. * Type-3 ownership otherwise stays strictly disjoint: an early enemy can be * killed before its native anchor without respawning. */ @@ -959,6 +994,7 @@ static struct { uint16 native_cursor_before; uint16 dpage; int active; + int collectibles; } s_ws_spawn_pass; static uint16 MmxWsSpawnReadCursor(uint16 dpage) { @@ -1010,7 +1046,8 @@ static int MmxWsForceNativeSpawnTiming(void) { if (g_ram[0x1f7a] != 0x09) return 0; uint16 column = (uint16)(g_ram[0x1e4d] | ((uint16)g_ram[0x1e4e] << 8)); - return column >= 0x0900 && column <= 0x0a80; + unsigned lookahead = g_mmx_custom_renderer ? (unsigned)MmxWsMargin() + 32 : 0; + return MmxWidePolicy_ForceNativeSpawnTiming(g_ram[0x1f7a], column, lookahead); } /* +32px slack past the visible margin: an anchor of exactly the margin @@ -1045,6 +1082,9 @@ int MmxWsSpawnRecordAllowed(uint16 dpage, uint8 type) { uint16 rec = MmxWsSpawnReadCursor(dpage); uint8 *descriptor = RomPtr(0x850000u | rec); const uint8 object_id = descriptor[3]; + if (s_ws_spawn_pass.collectibles) return kind == 0 && MmxWidePolicy_IsCollectible(object_id); + if (!g_mmx_custom_renderer && kind == 3 && object_id == 0x37) + return anchor == s_ws_spawn_pass.wide_anchor; int allowed = 1; if (anchor == s_ws_spawn_pass.wide_anchor) { allowed = MmxWidePolicy_SpawnRecordAllowed( @@ -1075,25 +1115,88 @@ void MmxWsSpawnRunNativePass(CpuState *cpu) { MmxWsSpawnWriteCursor(dpage, s_ws_spawn_pass.native_cursor_before); g_ram[dpage] = (uint8_t)s_ws_spawn_pass.native_anchor; g_ram[(uint16)(dpage + 1)] = (uint8_t)(s_ws_spawn_pass.native_anchor >> 8); - (void)cpu_dispatch_call_pc(cpu, 0x00DCDBu, 0x00DC8Du); + (void)cpu_dispatch_call_pc(cpu, 0x00DCDBu, 0x00DC8Fu); *cpu = saved; g_ram[dpage] = (uint8_t)s_ws_spawn_pass.wide_anchor; g_ram[(uint16)(dpage + 1)] = (uint8_t)(s_ws_spawn_pass.wide_anchor >> 8); s_ws_spawn_pass.active = 0; } +void MmxWsCollectiblePass(CpuState *cpu) { + extern uint8_t g_ram[0x20000]; + int margin = g_mmx_custom_renderer && MmxWidePolicy_IsStageScene(g_ram) ? MmxWsMargin() : 0; + if (!margin) return; + /* DC92 runs even when no camera column changed. This matters on a cold + * state load: visible pickups must not wait for X to move a full column. + * DCDB remains the allocator, with its collected/live flags untouched. */ + CpuState saved = *cpu; + uint16 dpage = cpu->D; + uint8 scratch[32]; memcpy(scratch, g_ram + dpage, sizeof(scratch)); + int camera = g_ram[0x1e4d] | (g_ram[0x1e4e] << 8); + int y = (g_ram[0x1e50] | (g_ram[0x1e51] << 8)) - 32; + g_ram[dpage + 2] = (uint8)y; g_ram[dpage + 3] = (uint8)(y >> 8); + y += 0x120; + g_ram[dpage + 4] = (uint8)y; g_ram[dpage + 5] = (uint8)(y >> 8); + s_ws_spawn_pass.active = s_ws_spawn_pass.collectibles = 1; + for (int column = (camera - margin - 32) & ~31; column <= camera + 256 + margin + 32; column += 32) { + if (column < 0 || column >= 8192) continue; + g_ram[dpage] = (uint8)column; g_ram[dpage + 1] = (uint8)(column >> 8); + *cpu = saved; + (void)cpu_dispatch_call_pc(cpu, 0x00DCDBu, 0x00DC8Fu); + } + s_ws_spawn_pass.active = s_ws_spawn_pass.collectibles = 0; + memcpy(g_ram + dpage, scratch, sizeof(scratch)); + *cpu = saved; +} + /* bank_82_B964 controls the intro-stage helicopter's entrance. Vanilla * starts its descent when (objX - 0x80) < X's world X, which places its * center at the native right edge. Add the visible widescreen margin plus * 32px of sprite-footprint lead so the large helicopter's outer tiles enter * naturally instead of its controller waking only when the center reaches * the widened edge. */ -uint16 MmxWsEnemyActivationDistance(uint16 v) { +uint16 MmxWsEnemyActivationDistance(uint16 v, uint16 object) { int m = MmxWsSpawnWide() ? MmxWsMargin() : 0; if (m) m += 32; + /* The arena push stays native; only the visible descent gets widescreen + * lead. Also release prematurely latched locks in older spike saves. */ + if (g_mmx_custom_renderer) { + extern uint8_t g_ram[0x20000]; + return MmxWidePolicy_BeeEntrance(g_ram, object, (uint16)(v + m)); + } return (uint16)(v + m); } +void MmxWsStreakerEntrance(uint16 object) { + extern uint8_t g_ram[0x20000]; + if (g_mmx_custom_renderer && MmxWsSpawnWide()) + MmxWidePolicy_StreakerEntrance(g_ram, object, MmxWsMargin()); +} + +uint16 MmxWsChainPlatformLine(CpuState *cpu, uint16 line) { + if (!g_mmx_custom_renderer || !MmxWsSpawnWide() || cpu->X != 5) return line; + unsigned margin = MmxWsMargin(); + uint16 adjusted = MmxWidePolicy_ChainPlatformLine(g_ram, cpu->D, line, margin); + if (adjusted == line) return line; + uint16 player = g_ram[0xbad] | (g_ram[0xbae] << 8); + bool right = (int16)(player - adjusted) >= 0; + bool inside = g_ram[cpu->D + 0xb] == 3 ? right : !right; + for (unsigned object = 0x1628; inside && object < 0x1928; object += 0x30) + if (g_ram[object] && g_ram[object + 10] == 0x0e) inside = false; + if (inside) { + /* F944 initializes the switch state without firing an edge. Catch up + * initial loads already inside the widened interval with the original + * idempotent allocator. Its later native call cannot double platforms. */ + CpuState saved = *cpu; + uint8 scratch[0x20]; memcpy(scratch, g_ram, sizeof(scratch)); + uint32 bank = (uint32)cpu->PB << 16; + (void)cpu_dispatch_call_pc(cpu, bank | 0xFAC5u, bank | 0xF97Au); + memcpy(g_ram, scratch, sizeof(scratch)); + *cpu = saved; + } + return adjusted; +} + /* bank_03_FDD3 camera-line trigger compare. Tilemap screen staging * (Task_B091 via FE05/FE0C) is fired by level-placed camera-line * trigger objects: FDD3 compares live camera X ($0BAD, via $0BA8+X @@ -1110,6 +1213,7 @@ uint16 MmxWsEnemyActivationDistance(uint16 v) { * vertical margins; codes 0x16/0x17 are other trigger classes * (camera locks etc.) and must fire at authentic positions. */ static int MmxWsStageWide(void) { + if (g_mmx_custom_renderer) return 0; static int s_on = -1; if (s_on < 0) { const char *e = getenv("SNESRECOMP_WS_STAGE"); @@ -1282,7 +1386,7 @@ static uint32_t s_ws_chrbind_copy_latches_created; static int MmxWsChrBindActive(void) { extern bool g_ws_active; extern uint8_t g_ram[0x20000]; - return g_ws_active && g_ram[0x00D1] == 0x02 && g_ram[0x00D2] == 0x04; + return (g_ws_active || g_mmx_custom_renderer) && g_ram[0x00D1] == 0x02 && g_ram[0x00D2] == 0x04; } /* Residual Highway crusher repair. The crusher body owns the CHR binding; @@ -1309,18 +1413,8 @@ uint8 MmxWsChrBindResolveParent(uint16 scratchD, uint16 objectX, uint8 childBase) { if (!MmxWsChrBindActive()) return childBase; extern uint8_t g_ram[0x20000]; - if (g_ram[0x1f7a] != 0x00) return childBase; uint16 child = (uint16)(scratchD + objectX); - if ((child & 0x003f) != 0x0028 || - g_ram[(uint16)(child + 0x0a)] != 0x09) - return childBase; - uint16 parent = (uint16)(g_ram[(uint16)(child + 0x0c)] | - ((uint16)g_ram[(uint16)(child + 0x0d)] << 8)); - if ((parent & 0x003f) != 0x0028 || - g_ram[(uint16)(parent + 0x0a)] != 0x0f) - return childBase; - uint8 parentBase = g_ram[(uint16)(parent + 0x18)]; - return parentBase; + return MmxWidePolicy_CrusherTileBase(g_ram, child, childBase); } /* Called via the WS-CHRBIND injection right after each inlined bind site's diff --git a/src/mmx_wide_policy.c b/src/mmx_wide_policy.c index 2fc6c98..596b35a 100644 --- a/src/mmx_wide_policy.c +++ b/src/mmx_wide_policy.c @@ -1,5 +1,92 @@ #include "mmx_wide_policy.h" +bool MmxWidePolicy_IsStageScene(const uint8_t ram[0x20000]) { + /* $80:997B: level setup, arrival, play, death and stage-clear all retain + * the stage view. The next state ($0A) changes to the password/menu flow. + * The weapon menu suspends the HUD task ($1F10=8) and owns HDMA channel 7. + * Neither condition alone identifies it: cutscenes hide the HUD, while + * Spark's moving lights also use that HDMA channel during gameplay. */ + return ram && ram[0xd1] == 2 && ram[0xd2] == 4 && ram[0xd3] <= 8 && !(ram[0xd3] & 1) && + !(ram[0x1f10] == 8 && (ram[0xc3] & 0x80)); +} + +bool MmxWidePolicy_IsCollectible(uint8_t object_id) { + /* Health/weapon-energy pickups, Sub Tanks, and Heart Tanks. Other kind-0 + * records include vehicles and mechanisms and retain native timing. */ + return (object_id >= 1 && object_id <= 5) || object_id == 0x0b; +} + +static uint16_t read_word(const uint8_t *ram, unsigned a) { + return (uint16_t)(ram[a] | (ram[a + 1] << 8)); +} +static void write_word(uint8_t *ram, unsigned a, uint16_t value) { + ram[a] = (uint8_t)value; ram[a + 1] = (uint8_t)(value >> 8); +} +uint16_t MmxWidePolicy_FlyerLeash(unsigned margin) { + /* $83:DF71 limits id-$36's travel from its spawn, even while homing. + * Give an early-spawned flyer enough travel to reach the native arena. */ + return (uint16_t)(0xa0 + margin); +} +bool MmxWidePolicy_RideArmorCull(uint16_t distance, unsigned margin) { + /* $83:8948 has its own cam-128..cam+383 horizontal lifetime window. */ + return (uint16_t)(distance + margin) >= 0x200 + 2 * margin; +} +bool MmxWidePolicy_PrematureRideArmor(const uint8_t ram[0x20000]) { + /* Compatibility with early spike saves: the empty Chill Penguin armor + * was initialized, then culled before its first animation/physics update. + * Require that exact untouched spawn signature; used/damaged/destroyed + * armor must never be recreated. This is checked only after a state load. */ + return MmxWidePolicy_IsStageScene(ram) && ram[0x1f7a] == 8 && + read_word(ram, 0xe18) == 0 && read_word(ram, 0xe1a) == 0 && + read_word(ram, 0xe1d) == 0x1220 && read_word(ram, 0xe20) == 0x390 && + ram[0xe2e] == 0x4a && ram[0xe2f] == 0 && ram[0xe3f] == 0x10 && + read_word(ram, 0xe38) == 0xbb4c && read_word(ram, 0xbad) < 0x1200; +} +bool MmxWidePolicy_RecoverRideArmor(uint8_t ram[0x20000], unsigned margin) { + if (!MmxWidePolicy_PrematureRideArmor(ram)) return false; + uint16_t dx = (uint16_t)(read_word(ram, 0xe1d) - read_word(ram, 0x1e4d) + 0x80); + uint16_t dy = (uint16_t)(read_word(ram, 0xe20) - read_word(ram, 0x1e50) + 0x80); + if (MmxWidePolicy_RideArmorCull(dx, margin) || dy >= 0x1e0) return false; + ram[0xe18] = 1; /* Let the original initialization/physics resume. */ + return true; +} +uint16_t MmxWidePolicy_BeeEntrance(uint8_t ram[0x20000], uint16_t object, + uint16_t distance) { + if (!ram || ram[0x1f7a] != 0 || object < 0xe68 || object > 0x1228 || + (object & 63) != 0x28 || !ram[object] || ram[object + 10] != 0x22 || ram[object + 1] != 2) + return distance; + unsigned x = read_word(ram, object + 5); + unsigned camera = read_word(ram, 0x1e4d); + uint16_t lock = (uint16_t)(x - 0xf0); + uint16_t old_left = read_word(ram, object + 0x3b), old_right = read_word(ram, object + 0x31); + /* DCDB indexes 32-pixel columns at camera+$100. B8E6 saves the old + * limits, then sets both to objectX-$F0. Existing saves can already have + * that lock latched hundreds of pixels early; do not move X or the camera. */ + if (camera + 0x100 < (x & ~31u)) { + if (read_word(ram, 0x1e5e) == lock && read_word(ram, 0x1e60) == lock) { + write_word(ram, 0x1e5e, old_left); write_word(ram, 0x1e60, old_right); + } + return 0; + } + if (read_word(ram, 0x1e5e) == old_left && read_word(ram, 0x1e60) == old_right) { + write_word(ram, 0x1e5e, lock); write_word(ram, 0x1e60, lock); + } + return distance; +} + +uint8_t MmxWidePolicy_CrusherTileBase(const uint8_t ram[0x20000], uint16_t object, uint8_t base) { + if (!ram || ram[0x1f7a] != 0 || (object & 63) != 0x28 || ram[(uint16_t)(object + 10)] != 9) + return base; + uint16_t parent = (uint16_t)(ram[(uint16_t)(object + 12)] | (ram[(uint16_t)(object + 13)] << 8)); + return (parent & 63) == 0x28 && ram[(uint16_t)(parent + 10)] == 15 ? ram[(uint16_t)(parent + 24)] : base; +} + +bool MmxWidePolicy_ForceNativeSpawnTiming(uint8_t stage, uint16_t camera, + unsigned lookahead) { + unsigned start = lookahead < 0x900 ? 0x900 - lookahead : 0; + return stage == 9 && camera >= start && camera <= 0xa80; +} + bool MmxWidePolicy_IsBossDoorBody(const uint16_t words[3][4], int row_index) { if (!words || (unsigned)row_index >= 3) return false; @@ -42,6 +129,84 @@ void MmxWidePolicy_EndWideSpawnPass(MmxWideSpawnCursor *cursor, cursor->valid = true; } +static bool unstarted_streaker(const uint8_t ram[0x20000], uint16_t object) { + if (!ram || ram[0x1f7a] != 6 || object < 0xe68 || object > 0x1228 || + (object & 63) != 0x28 || !ram[object] || ram[object + 10] != 0x37 || + ram[object + 1] != 2 || ram[object + 2] || ram[object + 3] || !ram[object + 0x27]) + return false; + return true; +} + +void MmxWidePolicy_StreakerEntrance(uint8_t ram[0x20000], uint16_t object, unsigned margin) { + if (!margin || !unstarted_streaker(ram, object)) return; + /* Run once after $87:A527 has chosen the original flight direction. + * The native event starts the encounter, then the moving actor enters + * from beyond the wide edge instead of appearing in its middle. */ + int lead = (int)margin + 32; + int x = read_word(ram, object + 5) + (ram[object + 0xb] ? -lead : lead); + write_word(ram, object + 5, (uint16_t)x); + write_word(ram, object + 0x22, (uint16_t)x); +} + +bool MmxWidePolicy_RecoverParkedStreaker(uint8_t ram[0x20000], uint16_t object, uint16_t authored_x) { + if (!MmxWidePolicy_IsStageScene(ram) || !unstarted_streaker(ram, object)) return false; + if (ram[object + 0x27] != 2 || read_word(ram, object + 5) != authored_x) return false; + unsigned column = read_word(ram, object + 5) & ~31u; + unsigned camera_column = read_word(ram, 0x1e4d) & ~31u; + unsigned flag = read_word(ram, object + 0xc); + unsigned owner = ram[object + 0x2d]; + if (column <= camera_column + 256 || flag < 0xfa00 || flag > 0xfffb || + ram[flag] != 1 || (owner != 0x40 && owner != 0x80)) return false; + /* Older spike saves contain early, parked actors ahead of the untouched + * native event cursor. Mirror $87:A94F / $82:8387 so that cursor can + * allocate them at the proper time. Never resurrect an attacked actor. */ + ram[owner == 0x40 ? 0xaa1 : 0xaaf] = 0; + ram[owner == 0x40 ? 0xaa8 : 0xab6] = 0; + ram[0x1f2c] &= (uint8_t)~owner; + if (!ram[0x1f2c]) ram[0xc9] = 0; + ram[flag] = 0; + write_word(ram, object, 0); write_word(ram, object + 2, 0); write_word(ram, object + 0xe, 0); + return true; +} + +uint16_t MmxWidePolicy_ChainPlatformLine(const uint8_t ram[0x20000], uint16_t object, + uint16_t line, unsigned margin) { + /* $81:F97A parameters 3/4 only create/remove the airport chain platforms; + * the other parameters move the camera, palette or unrelated mechanisms. */ + if (!ram || ram[0x1f7a] != 5 || object < 0x1d08 || object >= 0x1e08 || + (object & 15) != 8 || ram[object + 0xa] != 4) return line; + if (ram[object + 0xb] == 3) return (uint16_t)(line - margin); + if (ram[object + 0xb] == 4) return (uint16_t)(line + margin); + return line; +} + +bool MmxWidePolicy_IsBossEncounter(uint8_t object_id) { + /* The eight Mavericks and Bospider call the shared defeated-boss guard + * $84:AADD during initialization. The other fortress encounters have + * dedicated intro controllers. Classify the family once, independent of + * stage, so rematches obey the same native event scan as first encounters. + * These initializers can seize the camera/player before drawing a sprite. */ + switch (object_id) { + case 0x02: /* Chill Penguin */ + case 0x05: /* Boomer Kuwanger */ + case 0x07: /* Launch Octopus */ + case 0x0a: /* Sting Chameleon */ + case 0x0c: /* Flame Mammoth */ + case 0x14: /* Armored Armadillo */ + case 0x31: /* Spark Mandrill */ + case 0x52: /* Storm Eagle */ + case 0x5d: /* Rangda Bangda controller */ + case 0x62: /* D-Rex controller */ + case 0x63: /* Bospider */ + case 0x65: /* Sigma / Velguarder encounter controller */ + case 0x03: /* Thunder Slimer */ + case 0x22: /* Bee Blader */ + return true; + default: + return false; + } +} + bool MmxWidePolicy_SpawnRecordAllowed(uint8_t stage, uint8_t kind, uint8_t object_id, bool native_pass) { kind &= 0x0f; @@ -52,11 +217,12 @@ bool MmxWidePolicy_SpawnRecordAllowed(uint8_t stage, uint8_t kind, if (stage == 0x00 && kind == 1 && object_id == 0x21) return true; - /* Spark Mandrill's Thunder Slimer mid-boss controller is authored as kind - * 3 even though it is an encounter trigger, not an ordinary margin enemy. - * Spawning it early lets it tear itself down before the arena boundary and - * the native pass then refuses it, leaving the barrier permanently closed. */ - if (stage == 0x06 && kind == 3 && object_id == 0x03) + if (kind == 0 && MmxWidePolicy_IsCollectible(object_id)) return true; + + /* Boss records belong to the native scan, just like camera/door events. + * Its independent cursor reaches them at the authored arena boundary. + * Streakers also retain their room timing, with a moving offscreen entry. */ + if (kind == 3 && (MmxWidePolicy_IsBossEncounter(object_id) || object_id == 0x37)) return native_pass; return native_pass ? kind != 3 : kind == 3; diff --git a/src/mmx_wide_policy.h b/src/mmx_wide_policy.h index 71844a8..7df8840 100644 --- a/src/mmx_wide_policy.h +++ b/src/mmx_wide_policy.h @@ -4,12 +4,31 @@ #include #include +uint8_t MmxWidePolicy_CrusherTileBase(const uint8_t ram[0x20000], uint16_t object, uint8_t base); +bool MmxWidePolicy_IsStageScene(const uint8_t ram[0x20000]); +uint16_t MmxWidePolicy_FlyerLeash(unsigned margin); +bool MmxWidePolicy_RideArmorCull(uint16_t distance, unsigned margin); +bool MmxWidePolicy_PrematureRideArmor(const uint8_t ram[0x20000]); +bool MmxWidePolicy_RecoverRideArmor(uint8_t ram[0x20000], unsigned margin); + +/* Recover a waiting bee's premature camera lock from older spike saves and + * keep its entrance behind the native encounter boundary. Custom mode only. */ +uint16_t MmxWidePolicy_BeeEntrance(uint8_t ram[0x20000], uint16_t object, + uint16_t distance); + /* MMX authors each boss-room boundary as two back-to-back 16-pixel door * columns. The native camera shows only the column belonging to the current * room. Door metatile IDs vary by stage, but their three-row 8x8 construction * is stable: mirrored top, four-way mirrored center, reversed bottom. Return * true when row_index belongs to a stack with that structural signature. */ bool MmxWidePolicy_IsBossDoorBody(const uint16_t words[3][4], int row_index); +/* Enemy-family identity, shared by original stages and fortress rematches. */ +bool MmxWidePolicy_IsBossEncounter(uint8_t object_id); +bool MmxWidePolicy_IsCollectible(uint8_t object_id); +void MmxWidePolicy_StreakerEntrance(uint8_t ram[0x20000], uint16_t object, unsigned margin); +bool MmxWidePolicy_RecoverParkedStreaker(uint8_t ram[0x20000], uint16_t object, uint16_t authored_x); +uint16_t MmxWidePolicy_ChainPlatformLine(const uint8_t ram[0x20000], uint16_t object, + uint16_t line, unsigned margin); typedef struct MmxWideSpawnCursor { uint16_t wide; @@ -30,5 +49,9 @@ void MmxWidePolicy_EndWideSpawnPass(MmxWideSpawnCursor *cursor, * broad kind classification. */ bool MmxWidePolicy_SpawnRecordAllowed(uint8_t stage, uint8_t kind, uint8_t object_id, bool native_pass); +/* Protect allocation-sensitive Vile scripts before a larger lookahead can + * reach their room. A zero lookahead retains the shipped legacy interval. */ +bool MmxWidePolicy_ForceNativeSpawnTiming(uint8_t stage, uint16_t camera, + unsigned lookahead); #endif diff --git a/src/mods/mmx_widescreen_plugin.c b/src/mods/mmx_widescreen_plugin.c index c66ee38..5d6dcad 100644 --- a/src/mods/mmx_widescreen_plugin.c +++ b/src/mods/mmx_widescreen_plugin.c @@ -1,16 +1,42 @@ #include "mod_runtime.h" #include "mmx_display.h" +#include "mmx_renderer.h" +#include +#if defined(RECOMP_LAUNCHER) +#include "recomp_launcher.h" +#endif /* - * The surveyed renderer/HUD/BG/object-window implementation remains in the - * game and engine. This plugin moves only its player-facing activation into - * the package catalog. + * Keep renderer selection, aspect and HUD anchoring in the mod catalog. + * Gameplay widening stays in the game hooks for both renderer choices. */ static void mmx_widescreen_reset(void) { + g_mmx_custom_renderer = false; + g_mmx_expanded_sprites = false; + MmxRendererReset(); MmxDisplay_SetWidescreenEnabled(false); } static void mmx_widescreen_activate(void) { + g_mmx_custom_renderer = true; + g_mmx_custom_aspect = MMX_ASPECT_ADAPTIVE; + g_mmx_custom_hud = true; + g_mmx_expanded_sprites = false; +#if defined(RECOMP_LAUNCHER) + const RecompLauncherCModProvider *provider = snes_mod_runtime_launcher_provider_c(); + RecompLauncherCModOption option; + for (int i = 0; provider && provider->feature_option_get && + provider->feature_option_get(provider->ctx, "megaman-x.enhancement.widescreen", "widescreen", i, &option); ++i) { + if (!strcmp(option.id, "renderer")) g_mmx_custom_renderer = strcmp(option.value, "legacy") != 0; + if (!strcmp(option.id, "hud")) g_mmx_custom_hud = strcmp(option.value, "center") != 0; + if (!strcmp(option.id, "expanded_sprites")) g_mmx_expanded_sprites = !strcmp(option.value, "on"); + if (!strcmp(option.id, "aspect")) { + if (!strcmp(option.value, "16:9")) g_mmx_custom_aspect = MMX_ASPECT_16_9; + if (!strcmp(option.value, "21:9")) g_mmx_custom_aspect = MMX_ASPECT_21_9; + if (!strcmp(option.value, "32:9")) g_mmx_custom_aspect = MMX_ASPECT_32_9; + } + } +#endif MmxDisplay_SetWidescreenEnabled(true); } diff --git a/src/opengl.c b/src/opengl.c index fe158ea..a40a7c0 100644 --- a/src/opengl.c +++ b/src/opengl.c @@ -6,7 +6,8 @@ #include "util.h" #include "glsl_shader.h" #include "config.h" -#include "mmx_display.h" +#include "mmx_display.h" +#include "mmx_renderer.h" #define CODE(...) #__VA_ARGS__ @@ -176,6 +177,7 @@ static void OpenGLRenderer_EndDraw(void) { SnesDisplayAspect_Clamp(g_config.display_aspect), g_config.ignore_aspect_ratio, false, &viewport); + if (g_mmx_custom_renderer) viewport = MmxRendererDestination(g_mmx_custom_view, drawable_width, drawable_height); int viewport_width = viewport.width, viewport_height = viewport.height; int viewport_x = viewport.x; int viewport_y = viewport.y; diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c new file mode 100644 index 0000000..9d0e82e --- /dev/null +++ b/tests/mmx_renderer_test.c @@ -0,0 +1,616 @@ +#include "mmx_renderer.h" +#include "mmx_render_assets.h" +#include + +static uint8_t ram[0x20000], rom_bytes[0x100000]; +static Ppu ppu; +static uint32_t stock[256 * 224], output[MMX_RENDER_MAX_WIDTH * 224]; +static void capture(void) { + MmxRendererBeginFrame(ram); + for (unsigned y = 1; y <= 224; ++y) MmxRendererCaptureLine(&ppu, y); + assert(MmxRendererEndFrame(stock)); +} +static void geometry(void) { + assert(MmxRendererViewport(MMX_ASPECT_16_9, 0, 0).width == 342); + assert(MmxRendererViewport(MMX_ASPECT_21_9, 0, 0).width == 448); + assert(MmxRendererViewport(MMX_ASPECT_32_9, 0, 0).width == 682); + assert(MmxRendererViewport(MMX_ASPECT_ADAPTIVE, 3840, 1080).width == 682); + assert(MmxRendererViewport(MMX_ASPECT_ADAPTIVE, 1, 100).width == 256); + assert(MmxRendererViewport(MMX_ASPECT_ADAPTIVE, 10000, 1).width == MMX_RENDER_MAX_WIDTH); + MmxDisplayViewport dst = MmxRendererDestination(MmxRendererViewport(MMX_ASPECT_21_9, 0, 0), 1920, 1080); + assert(dst.width == 1920 && dst.height == 823 && dst.y == 128); +} +static void raster_and_hud(void) { + memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); + ram[0xd1] = 2; ram[0xd2] = 4; ram[0xd3] = 4; + ppu.inidisp = 15; ppu.bgmode = 1; ppu.screenEnabled[0] = 16; + ppu.cgram[129] = 31; ppu.cgram[0] = 31 << 10; + for (int i = 0; i < 128; ++i) ppu.oam[i * 2] = 0xe000; + ppu.oam[0] = 0x1010; ppu.oam[1] = 0; + for (int y = 0; y < 8; ++y) ppu.vram[y] = 255; + capture(); + /* Draw uses snapshots even if every live input changes after capture. */ + memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); + MmxRenderView v = MmxRendererViewport(MMX_ASPECT_32_9, 0, 0); + assert(MmxRendererDraw(output, v, true)); + assert(output[16 * v.width + 16] == 0xff0000); + assert(output[16 * v.width + 16 + v.extra] == 0x0000ff); + assert(MmxRendererDraw(output, v, false)); + assert(output[16 * v.width + 16 + v.extra] == 0xff0000); + assert(MmxRendererGetStats().custom_lines == 224); + assert(!MmxRendererDraw(output, (MmxRenderView){2048,896,8}, true)); + MmxRendererReset(); assert(!MmxRendererDraw(output, v, true)); +} +static void sprite_coordinates(void) { + memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); + ram[0xd1] = 2; ram[0xd2] = 4; ram[0xd3] = 4; + ppu.inidisp = 15; ppu.bgmode = 1; ppu.screenEnabled[0] = 16; ppu.cgram[129] = 31; + for (int i = 0; i < 128; ++i) ppu.oam[i * 2] = 0xe000; + for (int y = 0; y < 8; ++y) ppu.vram[y] = 255; + MmxRendererSetRom(rom_bytes, sizeof(rom_bytes)); + g_mmx_custom_renderer = true; + ram[0x18] = 0; ram[0x19] = 0x80; ram[0x1a] = 0x80; + ram[0] = 44; ram[1] = 1; ram[2] = 40; + MmxRendererRecordPiece(ram, 0); /* +300 must never wrap to -212. */ + ram[0] = 0x38; ram[1] = 0xff; + MmxRendererRecordPiece(ram, 0); /* -200 must never wrap to +312. */ + ram[0] = 255; ram[1] = 0; ram[2] = 60; + MmxRendererRecordPiece(ram, 0); /* Native x=255 clip must not cut a seam. */ + ram[0] = 54; ram[1] = 1; ram[2] = 44; ram[3] = 1; + MmxRendererRecordPiece(ram, 0); /* Host y=300 must not wrap into row 44. */ + MmxRendererLatchSprites(); capture(); + MmxRenderView v = MmxRendererViewport(MMX_ASPECT_32_9, 0, 0); + assert(MmxRendererDraw(output, v, false)); + assert(output[40 * v.width + v.extra + 300] == 0xff0000); + assert(output[40 * v.width + v.extra - 200] == 0xff0000); + assert(output[40 * v.width + v.extra - 212] == 0); + assert(output[60 * v.width + v.extra + 255] == 0xff0000); + assert(output[44 * v.width + v.extra + 310] == 0); + assert(MmxRendererGetStats().margin_sprite_pixels == 184); + g_mmx_custom_renderer = false; +} +static void put_word(unsigned a, unsigned v) { ram[a] = (uint8_t)v; ram[a + 1] = (uint8_t)(v >> 8); } +static void rom_word(unsigned a, unsigned v) { rom_bytes[a] = (uint8_t)v; rom_bytes[a + 1] = (uint8_t)(v >> 8); } +static void rom_long(unsigned a, unsigned v) { rom_word(a, v); rom_bytes[a + 2] = (uint8_t)(v >> 16); } +static void expanded_capacity(void) { + memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); memset(rom_bytes, 0, sizeof(rom_bytes)); + ram[0xd1] = 2; ram[0xd2] = 4; ram[0xd3] = 4; + ppu.inidisp = 15; ppu.bgmode = 1; ppu.screenEnabled[0] = 16; ppu.cgram[129] = 31; + for (int i = 0; i < 128; ++i) ppu.oam[i * 2] = 0xe000; + for (int i = 16; i < 128; ++i) { ppu.oam[i * 2] = 0x2800; ppu.oam[i * 2 + 1] = 0x2000; } + for (int y = 0; y < 8; ++y) ppu.vram[y] = 255; + rom_long(0x68003, 0x8d9000); rom_long(0x69000, 0x8d9100); + rom_bytes[0x69100] = 200; + for (unsigned i = 112; i < 200; ++i) rom_bytes[0x69101 + i * 4] = 50; + ram[0xe7] = 1; put_word(0x920, 0xe68); + ram[0xe68] = 1; ram[0xe7e] = 1; ram[0xe79] = 0x20; + put_word(0xe70, 40); + MmxRendererSetRom(NULL, 0); MmxRendererSetRom(rom_bytes, sizeof(rom_bytes)); + g_mmx_custom_renderer = true; + MmxRenderView v = MmxRendererViewport(MMX_ASPECT_16_9, 0, 0); + for (int enabled = 0; enabled < 2; ++enabled) { + MmxRendererReset(); g_mmx_expanded_sprites = enabled != 0; + uint8_t before[0x20000]; memcpy(before, ram, sizeof(ram)); + MmxRendererObserveObject(ram, 0xe68); + assert(!memcmp(before, ram, sizeof(ram))); + /* Model the retail writer stopping after the 112 available gameplay + * slots. The submitted list still contains the remaining 88 pieces. */ + ram[0xf] = 0x20; put_word(2, 40); ram[0x1a] = 0x8d; + for (unsigned i = 0; i < 112; ++i) { + put_word(0x18, 0x9100 + i * 4); MmxRendererRecordPiece(ram, 0); + } + MmxRendererLatchSprites(); capture(); + assert(MmxRendererDraw(output, v, false)); + assert(output[40 * v.width + v.extra + 50] == (enabled ? 0xff0000u : 0)); + assert(MmxRendererGetStats().pieces == 112); + /* An invalidated frame must never resurrect expanded submissions. */ + MmxRendererReset(); assert(!MmxRendererDraw(output, v, false)); + } + g_mmx_custom_renderer = g_mmx_expanded_sprites = false; +} +static void background_resources(void) { + memset(ram, 0, sizeof(ram)); memset(rom_bytes, 0, sizeof(rom_bytes)); + rom_word(0x282c2, 0x9000); + rom_bytes[0x29000] = 0x42; rom_bytes[0x29001] = 2; + rom_bytes[0x29004] = 0x17; rom_bytes[0x29005] = 1; + rom_word(0x29006, 0x0850); + rom_bytes[0x29008] = 2; rom_bytes[0x2900b] = 0x16; rom_bytes[0x2900c] = 1; + rom_word(0x2900d, 0x8850); rom_bytes[0x2900f] = 0x42; + rom_word(0x32260, 0x20); rom_word(0x32262, 0x24); + rom_word(0x32280, 0x30); rom_word(0x32282, 0x40); + rom_word(0x32290, 0xa000); rom_bytes[0x32292] = 0x70; rom_word(0x32293, 0xffff); + rom_word(0x322a0, 0xa020); rom_bytes[0x322a2] = 0x70; rom_word(0x322a3, 0xffff); + for (unsigned i = 0; i < 16; ++i) { rom_word(0x2a000 + i * 2, i); rom_word(0x2a020 + i * 2, i + 16); } + rom_word(0x321d5, 0x20); rom_word(0x321d7, 0x24); + rom_word(0x321f5, 0x30); rom_word(0x321f7, 0x40); + rom_word(0x32205, 32); rom_word(0x32207, 0x3500); rom_long(0x32209, 0x808100); + rom_word(0x32215, 32); rom_word(0x32217, 0x3500); rom_long(0x32219, 0x808120); + memset(rom_bytes + 0x100, 0x55, 32); memset(rom_bytes + 0x120, 0xaa, 32); + MmxRenderAssetsSetRom(NULL, 0); MmxRenderAssetsSetRom(rom_bytes, sizeof(rom_bytes)); + assert(MmxRenderAssetsBackgroundPalette(ram, 0x84f)->colors[0x71] == 1); + const MmxBackgroundPalette *pal = MmxRenderAssetsBackgroundPalette(ram, 0x850); + for (int i = 0; i < 128; ++i) { + assert(pal->valid[i] == (i >= 0x70)); + if (i >= 0x70) assert(pal->colors[i] == i - 0x70 + 16); + } + const uint8_t *tile = MmxRenderAssetsBackgroundTile(ram, 0x850, 0x3500); + assert(tile && tile[0] == 0xaa && tile[31] == 0xaa); + assert(!MmxRenderAssetsBackgroundTile(ram, 0x850, 0x3510)); + ram[0x1f09] = ram[0x1f0a] = 1; + pal = MmxRenderAssetsBackgroundPalette(ram, 0x84f); + assert(pal && pal->colors[0x71] == 1); + tile = MmxRenderAssetsBackgroundTile(ram, 0x84f, 0x3500); + assert(tile && tile[0] == 0x55); + /* Selecting the new phase in RAM precedes its DMA; margin resources + * remain stable through that transition instead of borrowing stale VRAM. */ + assert(MmxRenderAssetsBackgroundPalette(ram, 0x850)->colors[0x71] == 17); + assert(MmxRenderAssetsBackgroundTile(ram, 0x850, 0x3500)[0] == 0xaa); + /* Camera travel cannot recolor the same authored column. */ + put_word(0x1e4d, 0x900); + assert(MmxRenderAssetsBackgroundPalette(ram, 0x84f)->colors[0x71] == 1); + uint16_t faded[256] = {0}; + for (unsigned i = 0; i < 16; ++i) { + unsigned red = i + 26; + faded[0x70 + i] = (uint16_t)((red > 31 ? 31 : red) | (10 << 5) | (10 << 10)); + } + ram[0xd3] = 4; assert(MmxRenderAssetsDeathPaletteFade(ram, faded) == 0); + ram[0xd3] = 6; assert(MmxRenderAssetsDeathPaletteFade(ram, faded) == 10); + assert(MmxRenderAssetsFadeColor(0, 10) == ((10 << 10) | (10 << 5) | 10)); + assert(MmxRenderAssetsFadeColor(0x1234, 31) == 0x7fff); + faded[0x71] = 0; assert(MmxRenderAssetsDeathPaletteFade(ram, faded) == 0); + ram[0x1f7a] = 7; + assert(!MmxRenderAssetsBackgroundPalette(ram, 0x84f)); + /* Chill's cave palette is phase 1; its first X boundary changes to 2. + * No X projection is allowed for its vertically switched CHR. */ + rom_word(0x282c2 + 16, 0x9000); rom_bytes[0x29005] = 0x12; + rom_word(0x32260 + 16, 0x20); rom_word(0x32260 + 18, 0x26); + rom_word(0x32284, 0x50); rom_word(0x322b0, 0xa000); rom_bytes[0x322b2] = 0x70; rom_word(0x322b3, 0xffff); + MmxRenderAssetsSetRom(NULL, 0); MmxRenderAssetsSetRom(rom_bytes, sizeof(rom_bytes)); + ram[0x1f7a] = 8; + assert(MmxRenderAssetsBackgroundPalette(ram, 0x84f)->colors[0x71] == 17); + assert(MmxRenderAssetsBackgroundPalette(ram, 0x850)->colors[0x71] == 1); + assert(!MmxRenderAssetsBackgroundTile(ram, 0x850, 0x3500)); +} +static void dialogue_and_password(void) { + memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); + MmxRendererReset(); MmxRendererSetRom(NULL, 0); + ram[0xd1] = 2; ram[0xd2] = 4; ram[0xd3] = 4; + ppu.inidisp = 15; ppu.bgmode = 9; ppu.screenEnabled[0] = 4; + ppu.bgXsc[2] = 4; ppu.cgram[1] = 31; + for (int i = 0; i < 8; ++i) ppu.vram[i] = 255; + capture(); + MmxRenderView v = MmxRendererViewport(MMX_ASPECT_32_9, 0, 0); + assert(MmxRendererDraw(output, v, true)); + assert(output[40 * v.width + v.extra + 40] == 0xff0000); + assert(output[40 * v.width + 40] == 0); + ram[0xd3] = 0x0a; + for (unsigned i = 0; i < 256 * 224; ++i) stock[i] = 0x123456; + capture(); assert(MmxRendererDraw(output, v, true)); + assert(MmxRendererGetStats().fallback_lines == 224); + for (int y = 0; y < 224; ++y) for (int x = 0; x < v.width; ++x) + assert(output[y * v.width + x] == (x >= v.extra && x < v.extra + 256 ? 0x123456u : 0)); + ram[0xd3] = 4; ram[0x1f10] = 8; ram[0xc3] = 0x80; /* Weapons menu keeps the gameplay scene number. */ + capture(); assert(MmxRendererDraw(output, v, true)); + assert(MmxRendererGetStats().fallback_lines == 224); + for (int y = 0; y < 224; ++y) for (int x = 0; x < v.width; ++x) + assert(output[y * v.width + x] == (x >= v.extra && x < v.extra + 256 ? 0x123456u : 0)); + memset(stock, 0, sizeof(stock)); +} +static void highway_arena_sky(void) { + memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); memset(rom_bytes, 0, sizeof(rom_bytes)); + MmxRendererSetRom(NULL, 0); MmxRendererSetRom(rom_bytes, sizeof(rom_bytes)); + ram[0xd1] = 2; ram[0xd2] = ram[0xd3] = 4; + put_word(0x1e8d, 0xa78); put_word(0x1e90, 0x16b); + put_word(0xb98, 0x8000); ram[0xb9a] = 0x80; + for (int y = 0; y < 4; ++y) for (int x = 10; x < 16; ++x) ram[0xec00 + y * 32 + x] = 1; + for (int i = 0; i < 256; ++i) put_word(0xa800 + i * 2, 1); + for (int q = 0; q < 4; ++q) rom_word(8 + q * 2, 1); + ppu.inidisp = 15; ppu.bgmode = 1; ppu.screenEnabled[0] = 2; + ppu.bgXsc[1] = 8; ppu.hScroll[1] = 0x278; ppu.vScroll[1] = 0x16a; ppu.cgram[1] = 31; + for (int i = 0; i < 1024; ++i) ppu.vram[0x800 + i] = 1; + for (int y = 0; y < 8; ++y) ppu.vram[16 + y] = 255; + capture(); MmxRenderView v = MmxRendererViewport(MMX_ASPECT_ADAPTIVE, 2048, 300); + assert(MmxRendererDraw(output, v, false)); + for (int x = 0; x < v.width; ++x) assert(output[80 * v.width + x] == 0xff0000); +} +static void distant_doors(void) { + memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); memset(rom_bytes, 0, sizeof(rom_bytes)); + MmxRendererReset(); MmxRendererSetRom(NULL, 0); MmxRendererSetRom(rom_bytes, sizeof(rom_bytes)); + ram[0xd1] = 2; ram[0xd2] = ram[0xd3] = 4; ram[0x1f7a] = 8; + put_word(0xb95, 0x8000); ram[0xb97] = 0x80; + const unsigned tiles[3][4] = {{0x6df,0x46df,0x6ef,0x46ef}, + {0x6ff,0x46ff,0x86ff,0xc6ff}, {0x86ef,0xc6ef,0x86df,0xc6df}}; + /* Two back-to-back door columns at world 304/320, outside the native view. */ + ram[0xe801] = 1; + for (int y = 0; y < 3; ++y) { + for (int x = 3; x <= 4; ++x) put_word(0x2200 + ((y + 4) * 16 + x) * 2, y + 1); + for (int q = 0; q < 4; ++q) { + rom_word((y + 1) * 8 + q * 2, tiles[y][q]); + for (int row = 0; row < 8; ++row) ppu.vram[(tiles[y][q] & 1023) * 16 + row] = 255; + } + } + ppu.inidisp = 15; ppu.bgmode = 1; ppu.screenEnabled[0] = 1; + ppu.bgXsc[0] = 0x50; ppu.cgram[17] = 31; + MmxRenderView v = MmxRendererViewport(MMX_ASPECT_ADAPTIVE, 2048, 300); + capture(); assert(MmxRendererDraw(output, v, false)); + for (int x = 304; x < 336; ++x) assert(output[80 * v.width + v.extra + x] == (x < 320 ? 0xff0000u : 0)); + put_word(0x1e4d, 500); ppu.hScroll[0] = 500; + capture(); assert(MmxRendererDraw(output, v, false)); + for (int x = 304; x < 336; ++x) assert(output[80 * v.width + v.extra + x - 500] == (x >= 320 ? 0xff0000u : 0)); +} +static void storm_background_prefill(void) { + memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); memset(rom_bytes, 0, sizeof(rom_bytes)); + MmxRendererReset(); MmxRendererSetRom(NULL, 0); MmxRendererSetRom(rom_bytes, sizeof(rom_bytes)); + ram[0xd1] = 2; ram[0xd2] = ram[0xd3] = 4; ram[0x1f7a] = 5; + put_word(0x1e50, 0x600); ram[0x1e89] = 0x0e; + put_word(0xb98, 0x8000); ram[0xb9a] = 0x80; + ram[0xec01] = 1; + for (int i = 0; i < 256; ++i) put_word(0xa800 + i * 2, 1); + for (int q = 0; q < 4; ++q) rom_word(8 + q * 2, 1); + ppu.inidisp = 15; ppu.bgmode = 1; ppu.screenEnabled[0] = 2; + ppu.bgXsc[1] = 8; ppu.cgram[1] = 31; + for (int y = 0; y < 8; ++y) ppu.vram[16 + y] = 255; + /* The native VRAM tilemap remains blank, while the retained next screen + * contains the complete mountain/road map during Storm's arrival. */ + capture(); MmxRenderView v = MmxRendererViewport(MMX_ASPECT_ADAPTIVE, 2048, 300); + assert(MmxRendererDraw(output, v, false)); + assert(output[80 * v.width + v.extra + 128] == 0); + assert(output[80 * v.width + v.extra + 320] == 0xff0000); + assert(output[80 * v.width + v.extra - 320] == 0xff0000); +} +static void resource_decode(void) { + memset(rom_bytes, 0, sizeof(rom_bytes)); + /* Two section lists: resource 1 is available in the future section only, + * with a legitimate tile base of zero. Decode a repeated-byte CHR stream. */ + rom_word(0x32cee, 0x20); rom_word(0x32cf0, 0x24); + rom_word(0x32d0e, 0x30); rom_word(0x32d10, 0x40); + rom_bytes[0x32d1e] = 255; + rom_bytes[0x32d2e] = 1; rom_word(0x32d2f, 0); rom_word(0x32d31, 2); + rom_bytes[0x32d33] = 0x40; rom_bytes[0x32d34] = 255; + rom_bytes[0x325e4] = 7; rom_bytes[0x325e5] = 1; + rom_word(0x376fc, 32); rom_long(0x376fe, 0x808000); + for (unsigned i = 0; i < 8; i += 2) rom_bytes[i + 1] = 0x55; + rom_word(0x371b9, 0x200); rom_bytes[0x373b7] = 2; rom_bytes[0x373b8] = 0xe0; + rom_word(0x30135, 0x9000); rom_bytes[0x31000] = 16; rom_word(0x31001, 0x9000); rom_bytes[0x31003] = 128; + for (unsigned i = 0; i < 16; ++i) rom_word(0x29000 + i * 2, i); + MmxRenderAssetsSetRom(NULL, 0); MmxRenderAssetsSetRom(rom_bytes, sizeof(rom_bytes)); + const MmxSpriteAsset *asset = MmxRenderAssetsSprite(0, 0, 7); + assert(asset && !asset->current && asset->tile_base == 0 && asset->attributes == 0x28); + for (unsigned i = 0; i < 32; ++i) assert(asset->tiles[i] == 0x55); + for (unsigned i = 0; i < 16; ++i) assert(asset->colors[i] == i); + asset = MmxRenderAssetsSprite(0, 1, 7); assert(asset && asset->current); + + /* Heart Tanks bind resource $36 without an enemy animation-table entry. + * Keep that identity both before and after its section's VRAM allocation. */ + rom_bytes[0x32d2e] = 0x36; + memcpy(rom_bytes + 0x376f7 + 0x36 * 5, rom_bytes + 0x376fc, 5); + rom_word(0x371b7 + 0x36 * 2, 0x200); + MmxRenderAssetsSetRom(NULL, 0); MmxRenderAssetsSetRom(rom_bytes, sizeof(rom_bytes)); + memset(ram, 0, sizeof(ram)); ram[0x1632] = ram[0x1902] = 0x0b; + assert(!MmxRenderAssetsSprite(0, 0, 0x38)); + asset = MmxRenderAssetsObjectSprite(ram, 0x1628, 0x38); + assert(asset && asset->id == 0x36 && !asset->current && asset->colors[5] == 5); + assert(MmxRenderAssetsObjectSprite(ram, 0x18f8, 0x38) == asset); + ram[0x1f08] = 1; + asset = MmxRenderAssetsObjectSprite(ram, 0x1628, 0x38); + assert(asset && asset->current); + ram[0x1632] = 7; assert(!MmxRenderAssetsObjectSprite(ram, 0x1628, 0x38)); + assert(!MmxRenderAssetsObjectSprite(ram, 0x18f8, 0x37)); + + /* Spark and his ice chips deliberately switch away from the resource's + * default palette. Retain live colors when their section is resident. */ + rom_bytes[0x32d2e] = 0x8a; rom_bytes[0x325e4] = 0x91; rom_bytes[0x325e5] = 0x8a; + memcpy(rom_bytes + 0x376f7 + 0x8a * 5, rom_bytes + 0x376fc, 5); + rom_word(0x371b7 + 0x8a * 2, 0x200); + MmxRenderAssetsSetRom(NULL, 0); MmxRenderAssetsSetRom(rom_bytes, sizeof(rom_bytes)); + memset(ram, 0, sizeof(ram)); ram[0xe72] = 0x31; ram[0x1932] = 6; + asset = MmxRenderAssetsObjectSprite(ram, 0xe68, 0x91); + assert(asset && !asset->current); /* Unloaded art still gets private repair. */ + ram[0x1f08] = 1; + assert(!MmxRenderAssetsObjectSprite(ram, 0xe68, 0x91)); + assert(!MmxRenderAssetsObjectSprite(ram, 0x1928, 0x91)); + ram[0x1932] = 7; assert(MmxRenderAssetsObjectSprite(ram, 0x1928, 0x91)); + rom_bytes[0x325e4] = 7; + + /* The rotor borrows resource $2D's palette, but uses permanent page-zero + * CHR. Its animation is deliberately absent from the enemy asset table. */ + rom_bytes[0x32d2e] = 0x2d; rom_bytes[0x325e5] = 0x2d; + memcpy(rom_bytes + 0x376f7 + 0x2d * 5, rom_bytes + 0x376fc, 5); + rom_word(0x371b7 + 0x2d * 2, 0x200); + MmxRenderAssetsSetRom(NULL, 0); MmxRenderAssetsSetRom(rom_bytes, sizeof(rom_bytes)); + memset(ram, 0, sizeof(ram)); + ram[0x1932] = 0x1f; ram[0x193e] = 0x36; put_word(0x1934, 0xe68); ram[0xe72] = 0x22; + asset = MmxRenderAssetsObjectSprite(ram, 0x1928, 0x36); + assert(asset && asset->live_tiles && asset->attributes == 0x28); + ram[0xe72] = 0x29; assert(!MmxRenderAssetsObjectSprite(ram, 0x1928, 0x36)); + ram[0xe72] = 0x22; /* A dead parent's retained identity is sufficient. */ + memset(&ppu, 0, sizeof(ppu)); MmxRendererReset(); + ram[0xd1] = 2; ram[0xd2] = 4; ram[0xd3] = 4; + ppu.inidisp = 15; ppu.bgmode = 1; ppu.screenEnabled[0] = 16; + for (int i = 0; i < 128; ++i) ppu.oam[i * 2] = 0xe000; + for (int y = 0; y < 8; ++y) ppu.vram[0x40 * 16 + y] = 255; + rom_bytes[0x103] = 0x40; put_word(0x18, 0x8100); ram[0x1a] = 0x80; + put_word(0, 40); put_word(2, 40); + MmxRendererSetRom(rom_bytes, sizeof(rom_bytes)); g_mmx_custom_renderer = true; + MmxRendererObserveObject(ram, 0x1928); MmxRendererRecordPiece(ram, 0); + MmxRendererLatchSprites(); capture(); + MmxRenderView v = MmxRendererViewport(MMX_ASPECT_32_9, 0, 0); + assert(MmxRendererDraw(output, v, false)); + assert(output[40 * v.width + v.extra + 40] == 0x080000); + g_mmx_custom_renderer = false; + /* Dedicated usable armor is not the pilot animation in the enemy table. */ + rom_bytes[0x32d2e] = 0x49; + memcpy(rom_bytes + 0x376f7 + 0x49 * 5, rom_bytes + 0x376fc, 5); + rom_word(0x371b7 + 0x49 * 2, 0x200); + MmxRenderAssetsSetRom(NULL, 0); MmxRenderAssetsSetRom(rom_bytes, sizeof(rom_bytes)); + asset = MmxRenderAssetsObjectSprite(ram, 0xe18, 0x4a); + assert(asset && asset->id == 0x49 && !asset->current && !asset->live_tiles); + assert(!MmxRenderAssetsObjectSprite(ram, 0xe68, 0x4a)); + /* The cave palette can survive after the section/armor bind advances. + * Repair only that known stale palette, preserving arbitrary live flashes. */ + rom_bytes[0x32d1e] = 0x4a; rom_word(0x32d1f, 0); rom_word(0x32d21, 4); + rom_bytes[0x32d23] = 0x40; rom_bytes[0x32d24] = 255; + memcpy(rom_bytes + 0x376f7 + 0x4a * 5, rom_bytes + 0x376fc, 5); + rom_word(0x371b7 + 0x4a * 2, 0x200); + rom_word(0x30137, 0x9100); rom_bytes[0x31100] = 16; rom_word(0x31101, 0x9100); rom_bytes[0x31103] = 128; + uint16_t old_colors[16]; + for (unsigned i = 0; i < 16; ++i) { old_colors[i] = (uint16_t)(i + 16); rom_word(0x29100 + i * 2, old_colors[i]); } + rom_word(0x32cee + 16, 0x20); rom_word(0x32cee + 18, 0x24); + ram[0x1f7a] = 8; ram[0x1f08] = 1; + MmxRenderAssetsSetRom(NULL, 0); MmxRenderAssetsSetRom(rom_bytes, sizeof(rom_bytes)); + assert(MmxRenderAssetsRideArmorPalettePending(ram, old_colors)); + old_colors[1] = 0x7fff; assert(!MmxRenderAssetsRideArmorPalettePending(ram, old_colors)); + /* Penguin's projectile combines body CHR with the distinct ice palette. + * The body and unrelated effect users must keep the original mapping. */ + const unsigned ids[] = {0x61, 0x62, 7}; + for (unsigned i = 0; i < 3; ++i) { + unsigned p = 0x32d1e + i * 6; + rom_bytes[p] = (uint8_t)ids[i]; rom_word(p + 1, i == 1 ? 0x400 : 0x1000); + rom_word(p + 3, i == 1 ? 4 : 2); rom_bytes[p + 5] = i == 1 ? 0x50 : 0x40; + memcpy(rom_bytes + 0x376f7 + ids[i] * 5, rom_bytes + 0x376fc, 5); + rom_word(0x371b7 + ids[i] * 2, 0x200); + } + rom_bytes[0x32d30] = 255; rom_word(0x32cee + 18, 0x22); + rom_bytes[0x325e6] = 0x67; rom_bytes[0x325e7] = 0x61; + rom_bytes[0x325e4 + 12 * 2] = 1; rom_bytes[0x325e5 + 12 * 2] = 7; + ram[0x1f08] = 0; ram[0x1472] = 0x1a; + MmxRenderAssetsSetRom(NULL, 0); MmxRenderAssetsSetRom(rom_bytes, sizeof(rom_bytes)); + asset = MmxRenderAssetsObjectSprite(ram, 0x1468, 0x67); + assert(asset && asset->id == 0x61 && asset->current && asset->attributes == 0x2b && asset->colors[1] == 17); + assert(asset->tiles[0] == 0x55); + assert(MmxRenderAssetsObjectSprite(ram, 0x1468, 0x68)->id == 0x62); + assert(!MmxRenderAssetsObjectSprite(ram, 0xe68, 0x67)); /* Current boss owns its palette. */ + ram[0xe72] = 0x14; + assert(!MmxRenderAssetsObjectSprite(ram, 0xe68, 0x67)); /* Armadillo damage colors, too. */ + ram[0xe72] = 4; + assert(MmxRenderAssetsObjectSprite(ram, 0xe68, 0x67)); /* Unrelated enemies still get repair. */ + ram[0x1472] = 0x12; + assert(MmxRenderAssetsObjectSprite(ram, 0x1468, 0x67)->colors[1] == 1); + ram[0xe72] = 0x0d; ram[0xe80] = 8; + assert(MmxRenderAssetsSprite(8, 0, 1)->id == 7); + assert(!MmxRenderAssetsObjectSprite(ram, 0xe68, 1)); + /* A cold Sub Tank binds resource $8C without an enemy-table animation. */ + rom_bytes[0x32d1e] = 0x8c; + memcpy(rom_bytes + 0x376f7 + 0x8c * 5, rom_bytes + 0x376fc, 5); + rom_word(0x371b7 + 0x8c * 2, 0x200); + MmxRenderAssetsSetRom(NULL, 0); MmxRenderAssetsSetRom(rom_bytes, sizeof(rom_bytes)); + ram[0x1632] = 5; + assert(!MmxRenderAssetsSprite(8, 0, 0x96)); + asset = MmxRenderAssetsObjectSprite(ram, 0x1628, 0x96); + assert(asset && asset->id == 0x8c && asset->tiles[0] == 0x55 && asset->live_colors && !asset->current); + memset(&ppu, 0, sizeof(ppu)); MmxRendererReset(); + ppu.inidisp = 15; ppu.bgmode = 1; ppu.screenEnabled[0] = 16; + ppu.cgram[175] = 31 << 10; /* Permanent blue pickup palette; CHR stays blank. */ + for (int i = 0; i < 128; ++i) ppu.oam[i * 2] = 0xe000; + rom_bytes[0x100] = 1; memset(rom_bytes + 0x101, 0, 4); + put_word(0x18, 0x8100); ram[0x1a] = 0x80; put_word(0, 400); put_word(2, 40); + ram[0xf] = 0x25; ram[0xb] = ram[0x10] = 0; ram[0x163e] = 0x96; + g_mmx_custom_renderer = true; + MmxRendererObserveObject(ram, 0x1628); MmxRendererRecordPiece(ram, 0); + MmxRendererLatchSprites(); capture(); + v = MmxRendererViewport(MMX_ASPECT_ADAPTIVE, 2048, 300); + assert(MmxRendererDraw(output, v, false)); + assert(output[40 * v.width + v.extra + 401] == 0x0000ff); + g_mmx_custom_renderer = false; + ram[0x1632] = 7; assert(!MmxRenderAssetsObjectSprite(ram, 0x1628, 0x96)); + MmxRenderAssetsSetRom(NULL, 0); + assert(!MmxRenderAssetsSprite(0, 0, 7)); +} +static void spark_effects(void) { + memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); memset(rom_bytes, 0, sizeof(rom_bytes)); + MmxRendererReset(); MmxRendererSetRom(NULL, 0); MmxRendererSetRom(rom_bytes, sizeof(rom_bytes)); + ram[0xd1] = 2; ram[0xd2] = ram[0xd3] = 4; ram[0x1f7a] = 6; + ram[0x1f0a] = 4; ram[0x1e89] = 2; + ppu.inidisp = 15; ppu.bgmode = 1; ppu.cgram[0] = ppu.fixedColor = 0x7fff; + ppu.cgadsub = 0xa0; /* Backdrop is dark until a light disables subtraction. */ + static const uint8_t profile[] = {0,0,0,0,0,0,1,1,2,3,3,4,8,8,7,6,6,5,5,5,5,5,5,3,0}; + memcpy(rom_bytes + 0x35136, profile, sizeof(profile)); + ram[0xe68] = 1; ram[0xe69] = 2; ram[0xe72] = 0x37; ram[0xe95] = 0x40; + put_word(0xe8a, 400); put_word(0xe8c, 100); /* Outside native range, light state still zero. */ + MmxRenderView v = MmxRendererViewport(MMX_ASPECT_ADAPTIVE, 2048, 300); + capture(); assert(MmxRendererDraw(output, v, false)); + assert(output[76 * v.width + v.extra + 436] == 0xffffff); + assert(output[76 * v.width + v.extra + 435] == 0); + assert(output[100 * v.width + v.extra + 424] == 0xffffff); + assert(output[75 * v.width + v.extra + 500] == 0); + /* A second, mirrored light can occupy the other margin simultaneously. */ + memcpy(ram + 0xea8, ram + 0xe68, 64); ram[0xeb3] = 1; ram[0xed5] = 0x80; + put_word(0xeca, (uint16_t)-100); + capture(); assert(MmxRendererDraw(output, v, false)); + assert(output[76 * v.width + v.extra - 136] == 0xffffff); + assert(output[76 * v.width + v.extra - 135] == 0); + assert(output[100 * v.width + v.extra + 500] == 0xffffff); + g_mmx_render_asset_repairs = false; + assert(MmxRendererDraw(output, v, false)); + assert(output[100 * v.width + v.extra + 500] == 0); + g_mmx_render_asset_repairs = true; + ram[0xea8] = 0; ram[0xe6b] = 1; ram[0xea3] = 13; ram[0xe87] = 24; + put_word(0xe9e, 90); + capture(); assert(MmxRendererDraw(output, v, false)); + assert(output[90 * v.width + v.extra + 500] == 0xffffff); + assert(output[103 * v.width + v.extra + 500] == 0); + assert(output[90 * v.width + v.extra + 616] == 0); + /* Thunder Slimer's BG2 actor tiles must never repeat into the margins. */ + memset(ram + 0xe68, 0, 128); ram[0x1f0a] = 1; + ppu.cgadsub = 0; ppu.cgram[0] = 0; ppu.cgram[1] = 31; + ppu.screenEnabled[0] = 2; ppu.bgXsc[1] = 0x50; + for (int i = 0; i < 1024; ++i) ppu.vram[0x5000 + i] = 1; + for (int y = 0; y < 8; ++y) ppu.vram[16 + y] = 255; + put_word(0xb98, 0x8000); ram[0xb9a] = 0x80; + for (int q = 0; q < 4; ++q) rom_word(q * 2, 1); + capture(); assert(MmxRendererDraw(output, v, false)); + assert(output[50 * v.width + v.extra - 100] == 0xff0000); + ram[0x1e89] = 0x0c; + capture(); assert(MmxRendererDraw(output, v, false)); + assert(output[50 * v.width + v.extra - 100] == 0); + assert(output[50 * v.width + v.extra + 100] == 0xff0000); +} +static void airport_panorama_edge(void) { + memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); memset(rom_bytes, 0, sizeof(rom_bytes)); + MmxRendererReset(); MmxRendererSetRom(NULL, 0); MmxRendererSetRom(rom_bytes, sizeof(rom_bytes)); + ram[0xd1] = 2; ram[0xd2] = ram[0xd3] = 4; ram[0x1f7a] = 5; ram[0x1e89] = 0x0e; + put_word(0x1e50, 0x600); put_word(0x1e8d, 118); + put_word(0xb98, 0x8000); ram[0xb9a] = 0x80; + ram[0xec01] = 1; ram[0xec02] = 2; ram[0xec03] = 3; + for (int y = 0; y < 16; ++y) for (int x = 0; x < 40; ++x) + put_word(0xa600 + (x / 16) * 512 + y * 32 + (x % 16) * 2, x == 39 ? 2 : 1); + for (int q = 0; q < 4; ++q) { rom_word(8 + q * 2, 1); rom_word(16 + q * 2, 0x402); } + ppu.inidisp = 15; ppu.bgmode = 1; ppu.screenEnabled[0] = 2; ppu.hScroll[1] = 118; + ppu.bgXsc[1] = 8; ppu.cgram[1] = 31; ppu.cgram[17] = 31 << 10; + for (int y = 0; y < 8; ++y) ppu.vram[16 + y] = ppu.vram[32 + y] = 255; + capture(); MmxRenderView v = MmxRendererViewport(MMX_ASPECT_ADAPTIVE, 2048, 300); + assert(MmxRendererDraw(output, v, false)); + assert(output[80 * v.width + v.extra + 128] == 0); /* Native still uses its own tilemap. */ + assert(output[80 * v.width + v.extra + 530] == 0x0000ff); /* Reflect the painted edge. */ + assert(output[80 * v.width + v.extra + 560] == 0xff0000); /* No blue backdrop hole. */ + put_word(0x1e90, 256); /* Other airport/roof planes are not the panorama. */ + capture(); assert(MmxRendererDraw(output, v, false)); + assert(output[80 * v.width + v.extra + 560] == 0); +} + +static void wide_water_plane(void) { + memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); memset(rom_bytes, 0, sizeof(rom_bytes)); + MmxRendererReset(); MmxRendererSetRom(NULL, 0); MmxRendererSetRom(rom_bytes, sizeof(rom_bytes)); + ram[0xd1] = 2; ram[0xd2] = ram[0xd3] = 4; ram[0x1f7a] = 1; + put_word(0xb95, 0x8000); ram[0xb97] = 0x80; + for (int i = 0; i < 256; ++i) put_word(0x2000 + i * 2, 1); + for (int q = 0; q < 4; ++q) rom_word(8 + q * 2, 1); + ppu.inidisp = 15; ppu.bgmode = 9; ppu.screenEnabled[0] = 5; ppu.screenEnabled[1] = 1; + ppu.cgwsel = 2; ppu.cgadsub = 0x44; ppu.bgTileAdr = 0x400; + ppu.bgXsc[0] = 0x10; ppu.bgXsc[2] = 8; ppu.cgram[1] = 31; ppu.cgram[5] = 31 << 10; + for (int i = 0; i < 1024; ++i) ppu.vram[0x1000 + i] = 1; + for (int i = 4 * 32; i < 1024; ++i) ppu.vram[0x800 + i] = 0x2402; + for (int y = 0; y < 8; ++y) ppu.vram[16 + y] = ppu.vram[0x4010 + y] = 255; + capture(); MmxRenderView v = MmxRendererViewport(MMX_ASPECT_ADAPTIVE, 2048, 300); + assert(MmxRendererDraw(output, v, false)); + for (int x = 0; x < v.width; ++x) { + assert(output[16 * v.width + x] == 0xff0000); /* Above the waterline. */ + assert(output[40 * v.width + x] == 0x7b007b); /* Same half blend across both seams. */ + } + ram[0x1f7a] = 0; /* Dialogue overlays in other stages must remain bounded. */ + capture(); assert(MmxRendererDraw(output, v, false)); + assert(output[40 * v.width + v.extra + 128] == 0x7b007b); + assert(output[40 * v.width + 128] == 0xff0000); +} + +static void buried_submarine(void) { + memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); memset(rom_bytes, 0, sizeof(rom_bytes)); + MmxRendererReset(); MmxRendererSetRom(NULL, 0); MmxRendererSetRom(rom_bytes, sizeof(rom_bytes)); + ram[0xd1] = 2; ram[0xd2] = ram[0xd3] = 4; ram[0x1f7a] = 1; + put_word(0x1e4d, 0xa53); put_word(0x1e50, 0x20f); + put_word(0xb95, 0x8000); ram[0xb97] = 0x80; + ram[0xe800 + 2 * 32 + 11] = 1; ram[0xe800 + 2 * 32 + 12] = 2; + for (int y = 5; y < 10; ++y) for (int x = 12; x < 20; ++x) + put_word(0x2200 + (x / 16) * 512 + y * 32 + (x % 16) * 2, 1); + for (int q = 0; q < 4; ++q) rom_word(8 + q * 2, 1); + rom_word(0x34bec, 0x258); rom_word(0x34bf2, 0x29f); + ram[0xe68] = 1; ram[0xe72] = 0x21; ram[0xe73] = 0x80; ram[0xe6a] = 6; put_word(0xe6d, 0xbce); + ppu.inidisp = 15; ppu.bgmode = 1; ppu.screenEnabled[0] = 1; + ppu.hScroll[0] = 0x253; ppu.vScroll[0] = 0x20f; ppu.bgXsc[0] = 8; ppu.cgram[1] = 31; + for (int y = 0; y < 8; ++y) ppu.vram[16 + y] = 255; + capture(); MmxRenderView v = MmxRendererViewport(MMX_ASPECT_ADAPTIVE, 2048, 300); + assert(MmxRendererDraw(output, v, false)); + assert(output[96 * v.width + v.extra + 389] == 0); /* Hidden at the owner's earlier camera. */ + assert(output[96 * v.width + v.extra + 365] == 0); /* Nose before the next 32-pixel boundary. */ + ram[0xe6a] = 4; /* The native emergence state owns presentation now. */ + capture(); assert(MmxRendererDraw(output, v, false)); + assert(output[96 * v.width + v.extra + 389] == 0xff0000); + assert(output[96 * v.width + v.extra + 365] == 0xff0000); + ram[0xe69] = 2; ram[0xe6a] = 0; + capture(); assert(MmxRendererDraw(output, v, false)); + assert(output[96 * v.width + v.extra + 389] == 0xff0000); + ram[0xe69] = 0; ram[0xe73] = 0; /* Ordinary surface variant is never hidden. */ + capture(); assert(MmxRendererDraw(output, v, false)); + assert(output[96 * v.width + v.extra + 389] == 0xff0000); + + /* The entrance IRQ moves one horizontal BG1 band down by 80 pixels. + * Distant terrain on either side of the actor must retain its own rows: + * the left green slope cannot vanish, nor may the red container top be + * repeated over its blue base. The native field still uses the real IRQ. */ + ram[0xe800 + 2 * 32 + 9] = 3; + put_word(0x2600 + 7 * 32 + 6 * 2, 2); /* World $0960,$0270: slope. */ + put_word(0x2400 + 2 * 32 + 10 * 2, 1); /* $0CA0,$0220: container top. */ + put_word(0x2400 + 7 * 32 + 10 * 2, 3); /* $0CA0,$0270: container base. */ + for (int q = 0; q < 4; ++q) { + rom_word(16 + q * 2, 0x0401); rom_word(24 + q * 2, 0x0801); + } + ppu.cgram[17] = 31 << 5; ppu.cgram[33] = 31 << 10; + ram[0xe73] = 0x80; ram[0xe6a] = 2; ram[0xba1] = 2; + put_word(0xe9c, 0x50); put_word(0xc4, 0x1bf); + put_word(0x1f28, 0x258); put_word(0x1f2a, 0x29f); + for (int phase = 2; phase <= 4; phase += 2) { + ram[0xe6a] = (uint8_t)phase; + MmxRendererBeginFrame(ram); + for (int line = 1; line <= 224; ++line) { + ppu.vScroll[0] = line >= 75 && line <= 145 ? 0x1bf : 0x20f; + MmxRendererCaptureLine(&ppu, line); + } + assert(MmxRendererEndFrame(stock)); + assert(MmxRendererDraw(output, v, false)); + assert(output[96 * v.width + v.extra + 0x960 - 0xa53] == 0x00ff00); + assert(output[96 * v.width + v.extra + 0xca0 - 0xa53] == 0x0000ff); + assert(output[16 * v.width + v.extra + 0xca0 - 0xa53] == 0xff0000); + assert(output[96 * v.width + v.extra + 128] == 0); /* Native unchanged. */ + assert(output[96 * v.width + v.extra + 389] == 0); /* Body still displaced. */ + } +} + +static void background_continuations(void) { + memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); memset(rom_bytes, 0, sizeof(rom_bytes)); + MmxRendererReset(); MmxRendererSetRom(NULL, 0); MmxRendererSetRom(rom_bytes, sizeof(rom_bytes)); + ram[0xd1] = 2; ram[0xd2] = ram[0xd3] = 4; ram[0x1f7a] = 1; ram[0x1e89] = 0x0c; + put_word(0xb98, 0x8000); ram[0xb9a] = 0x80; + ram[0xec0b] = 1; + for (int i = 0; i < 256; ++i) { put_word(0xa800 + i * 2, 1); put_word(0xaa00 + i * 2, 2); } + for (int q = 0; q < 4; ++q) { rom_word(8 + q * 2, 1); rom_word(16 + q * 2, 0x401); } + ppu.inidisp = 15; ppu.bgmode = 1; ppu.screenEnabled[0] = 2; ppu.bgXsc[1] = 8; + ppu.cgram[1] = 31; ppu.cgram[17] = 31 << 10; + for (int y = 0; y < 8; ++y) ppu.vram[16 + y] = 255; + put_word(0x1e8d, 0xa40); put_word(0x1e90, 0xffe0); ppu.hScroll[1] = 0x240; ppu.vScroll[1] = 0x3e0; + MmxRenderView v = MmxRendererViewport(MMX_ASPECT_ADAPTIVE, 2048, 300); + capture(); assert(MmxRendererDraw(output, v, false)); + assert(output[80 * v.width + v.extra + 320] == 0xff0000); /* Full boat past native edge, cold VRAM blank. */ + assert(output[8 * v.width + v.extra + 320] == 0); /* Above its negative entrance scroll. */ + assert(output[80 * v.width + v.extra + 500] == 0); /* No repeated ship. */ + assert(output[80 * v.width + v.extra + 128] == 0); /* Native field untouched. */ + + ram[0x1e89] = 0x0e; put_word(0x1e8d, 0x740); put_word(0x1e90, 0x300); + ppu.hScroll[1] = 0x340; ppu.vScroll[1] = 0x300; + ram[0xec00 + 3 * 32 + 6] = 2; ram[0xec00 + 3 * 32 + 7] = 1; + capture(); assert(MmxRendererDraw(output, v, false)); + assert(output[120 * v.width + v.extra - 288] == 0xff0000); /* Fill the otherwise occluded scraps. */ + assert(output[96 * v.width + v.extra - 288] == 0x0000ff); /* Preserve rows outside that band. */ + + ram[0x1f7a] = 3; put_word(0xb95, 0x8000); ram[0xb97] = 0x80; + ram[0xe800 + 8 * 32 + 2] = 1; + for (int i = 0; i < 256; ++i) put_word(0x2200 + i * 2, 1); + ppu.screenEnabled[0] = 1; ppu.bgXsc[0] = 8; + put_word(0x1e4d, 0x1f00); put_word(0x1e50, 0x600); ppu.hScroll[0] = 0x100; ppu.vScroll[0] = 0x200; + capture(); assert(MmxRendererDraw(output, v, false)); + uint32_t before = output[80 * v.width + v.extra + 320]; + put_word(0x1e4d, 0x100); put_word(0x1e50, 0x800); ppu.vScroll[0] = 0; + capture(); assert(MmxRendererDraw(output, v, false)); + assert(before == 0xff0000 && output[80 * v.width + v.extra + 320] == before); +} + +int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); expanded_capacity(); background_resources(); dialogue_and_password(); highway_arena_sky(); distant_doors(); storm_background_prefill(); resource_decode(); spark_effects(); airport_panorama_edge(); wide_water_plane(); buried_submarine(); background_continuations(); return 0; } diff --git a/tests/mmx_wide_policy_test.c b/tests/mmx_wide_policy_test.c index c37dafc..b0fafb3 100644 --- a/tests/mmx_wide_policy_test.c +++ b/tests/mmx_wide_policy_test.c @@ -1,6 +1,64 @@ #include "mmx_wide_policy.h" #include +#include + +static uint8_t ram[0x20000]; +static void put(unsigned a, unsigned v) { ram[a] = (uint8_t)v; ram[a + 1] = (uint8_t)(v >> 8); } +static unsigned get(unsigned a) { return ram[a] | (ram[a + 1] << 8); } +static void test_flyer_and_armor_range(void) { + for (unsigned margin = 0; margin <= 384; margin += 8) { + /* A flyer at the spawn lead can approach within its 64px attack range + * before it exhausts the leash, including the extra spawn column. */ + assert(MmxWidePolicy_FlyerLeash(margin) > 128 + margin + 32 - 64); + assert(!MmxWidePolicy_RideArmorCull((uint16_t)(-128 - (int)margin + 128), margin)); + assert(MmxWidePolicy_RideArmorCull((uint16_t)(-129 - (int)margin + 128), margin)); + assert(!MmxWidePolicy_RideArmorCull((uint16_t)(383 + margin + 128), margin)); + assert(MmxWidePolicy_RideArmorCull((uint16_t)(384 + margin + 128), margin)); + } + assert(MmxWidePolicy_FlyerLeash(0) == 160); + memset(ram, 0, sizeof(ram)); + ram[0xd1] = 2; ram[0xd2] = ram[0xd3] = 4; ram[0x1f7a] = 8; + put(0xe1d, 0x1220); put(0xe20, 0x390); ram[0xe2e] = 0x4a; + ram[0xe3f] = 0x10; put(0xe38, 0xbb4c); put(0xbad, 0xeca); + put(0x1e4d, 0xe4a); put(0x1e50, 0x300); + assert(MmxWidePolicy_PrematureRideArmor(ram)); + assert(!MmxWidePolicy_RecoverRideArmor(ram, 384)); /* Still beyond even max view. */ + put(0x1e4d, 0x1000); + assert(!MmxWidePolicy_RecoverRideArmor(ram, 0)); + ram[0xe3f] = 0; + assert(!MmxWidePolicy_PrematureRideArmor(ram)); /* Destroyed. */ + ram[0xe3f] = 0x10; ram[0xe2f] = 1; + assert(!MmxWidePolicy_PrematureRideArmor(ram)); /* Already animated. */ + ram[0xe2f] = 0; put(0xe20, 0x391); + assert(!MmxWidePolicy_PrematureRideArmor(ram)); /* Already moved. */ + put(0xe20, 0x390); put(0x1e50, 0x100); + assert(!MmxWidePolicy_RecoverRideArmor(ram, 384)); /* Preserve vertical cull. */ + put(0x1e50, 0x300); + assert(MmxWidePolicy_RecoverRideArmor(ram, 384)); + assert(ram[0xe18] == 1 && get(0xe1d) == 0x1220 && get(0xbad) == 0xeca); + assert(!MmxWidePolicy_RecoverRideArmor(ram, 384)); /* No duplication. */ +} +static void test_bee_camera_and_descent(void) { + memset(ram, 0, sizeof(ram)); + ram[0xe68] = 1; ram[0xe69] = 2; ram[0xe72] = 0x22; + put(0xe6d, 0xaf4); put(0xe99, 0x1b00); /* Saved right limit. */ + put(0x1e4d, 0x885); put(0xbad, 0x8cb); + put(0x1e5e, 0xa04); put(0x1e60, 0xa04); + assert(MmxWidePolicy_BeeEntrance(ram, 0xe68, 376) == 0); + assert(get(0x1e5e) == 0 && get(0x1e60) == 0x1b00); + assert(get(0x1e4d) == 0x885 && get(0xbad) == 0x8cb); + put(0x1e4d, 0x9df); /* Last pixel before the native spawn column. */ + assert(MmxWidePolicy_BeeEntrance(ram, 0xe68, 376) == 0); + assert(get(0x1e5e) == 0); + put(0x1e4d, 0x9e0); + assert(MmxWidePolicy_BeeEntrance(ram, 0xe68, 376) == 376); + assert(get(0x1e5e) == 0xa04 && get(0x1e60) == 0xa04); + assert(MmxWidePolicy_BeeEntrance(ram, 0xe68, 128) == 128); + ram[0xe69] = 4; put(0x1e4d, 0x885); + assert(MmxWidePolicy_BeeEntrance(ram, 0xe68, 376) == 376); + assert(get(0x1e5e) == 0xa04); /* Never unwind a running encounter. */ +} static void test_boss_door_stack(void) { const uint16_t chill_penguin[][4] = { @@ -51,6 +109,53 @@ static void test_spawn_cursors_are_independent(void) { assert(cursor.valid); } +static void test_streaker_entry_and_recovery(void) { + memset(ram, 0, sizeof(ram)); + ram[0xd1] = 2; ram[0xd2] = ram[0xd3] = 4; + ram[0x1f7a] = 6; ram[0xe68] = 1; ram[0xe69] = 2; + ram[0xe72] = 0x37; ram[0xe8f] = 2; + put(0xe6d, 0x5f6); put(0x1e4d, 0x340); + put(0xe74, 0xfa7a); ram[0xfa7a] = 1; ram[0xe95] = 0x40; ram[0x1f2c] = 0xc0; + for (unsigned m = 0; m <= 384; m += 8) { + put(0xe6d, 0x5f6); ram[0xe73] = 0; + MmxWidePolicy_StreakerEntrance(ram, 0xe68, m); + assert(get(0xe6d) == 0x5f6 + (m ? m + 32 : 0)); + if (m) assert(!MmxWidePolicy_RecoverParkedStreaker(ram, 0xe68, 0x5f6)); + put(0xe6d, 0x5f6); ram[0xe73] = 1; + MmxWidePolicy_StreakerEntrance(ram, 0xe68, m); + assert(get(0xe6d) == 0x5f6 - (m ? m + 32 : 0)); + } + put(0xe6d, 0x5f6); ram[0xe6a] = 2; + assert(!MmxWidePolicy_RecoverParkedStreaker(ram, 0xe68, 0x5f6)); + ram[0xe6a] = 0; ram[0xe6b] = 2; + assert(!MmxWidePolicy_RecoverParkedStreaker(ram, 0xe68, 0x5f6)); + ram[0xe6b] = 0; ram[0xe8f] = 1; + assert(!MmxWidePolicy_RecoverParkedStreaker(ram, 0xe68, 0x5f6)); + ram[0xe8f] = 2; put(0x1e4d, 0x4e0); + assert(!MmxWidePolicy_RecoverParkedStreaker(ram, 0xe68, 0x5f6)); + put(0x1e4d, 0x340); + assert(MmxWidePolicy_RecoverParkedStreaker(ram, 0xe68, 0x5f6)); + assert(!ram[0xe68] && !ram[0xfa7a] && ram[0x1f2c] == 0x80); + assert(!MmxWidePolicy_RecoverParkedStreaker(ram, 0xe68, 0x5f6)); + assert(!MmxWidePolicy_RecoverParkedStreaker(NULL, 0xe68, 0x5f6)); +} + +static void test_chain_platform_switches(void) { + memset(ram, 0, sizeof(ram)); ram[0x1f7a] = 5; + ram[0x1d12] = 4; ram[0x1d13] = 3; + assert(MmxWidePolicy_ChainPlatformLine(ram, 0x1d08, 256, 0) == 256); + uint16_t entry = MmxWidePolicy_ChainPlatformLine(ram, 0x1d08, 256, 384); + assert((int16_t)(128 - entry) >= 0); /* READY arrival is already inside. */ + ram[0x1d13] = 4; + assert(MmxWidePolicy_ChainPlatformLine(ram, 0x1d08, 0x3b0, 384) == 0x530); + for (unsigned param = 0; param < 12; ++param) if (param != 3 && param != 4) { + ram[0x1d13] = (uint8_t)param; + assert(MmxWidePolicy_ChainPlatformLine(ram, 0x1d08, 256, 384) == 256); + } + ram[0x1d13] = 3; ram[0x1f7a] = 6; + assert(MmxWidePolicy_ChainPlatformLine(ram, 0x1d08, 256, 384) == 256); +} + static void test_spawn_record_ownership(void) { /* Ordinary enemies are early/wide only; controllers are native only. */ assert(MmxWidePolicy_SpawnRecordAllowed(0x06, 3, 0x20, false)); @@ -61,6 +166,35 @@ static void test_spawn_record_ownership(void) { /* Spark's kind-3 mid-boss controller is deliberately native-timed. */ assert(!MmxWidePolicy_SpawnRecordAllowed(0x06, 3, 0x03, false)); assert(MmxWidePolicy_SpawnRecordAllowed(0x06, 3, 0x03, true)); + assert(!MmxWidePolicy_SpawnRecordAllowed(0x00, 3, 0x22, false)); + assert(MmxWidePolicy_SpawnRecordAllowed(0x00, 3, 0x22, true)); + assert(!MmxWidePolicy_SpawnRecordAllowed(0x08, 3, 0x02, false)); + assert(MmxWidePolicy_SpawnRecordAllowed(0x08, 3, 0x02, true)); + assert(!MmxWidePolicy_SpawnRecordAllowed(6, 3, 0x37, false)); + assert(MmxWidePolicy_SpawnRecordAllowed(6, 3, 0x37, true)); + /* The same boss IDs recur in fortress stages. No per-stage exception + * may put any encounter back into the widened ordinary-enemy scan. */ + const uint8_t bosses[] = {2,5,7,0x0a,0x0c,0x14,0x31,0x52,0x5d,0x62,0x63,0x65,3,0x22}; + for (unsigned stage = 0; stage < 13; ++stage) for (unsigned i = 0; i < sizeof(bosses); ++i) { + assert(MmxWidePolicy_IsBossEncounter(bosses[i])); + assert(!MmxWidePolicy_SpawnRecordAllowed(stage, 3, bosses[i], false)); + assert(MmxWidePolicy_SpawnRecordAllowed(stage, 3, bosses[i], true)); + } + assert(!MmxWidePolicy_IsBossEncounter(0x37)); /* A streaker is not an encounter. */ + assert(!MmxWidePolicy_IsBossEncounter(0x0b)); /* Nor is a Heart Tank. */ + assert(MmxWidePolicy_SpawnRecordAllowed(0, 3, 0x11, false)); /* Highway traffic enemy. */ + assert(MmxWidePolicy_SpawnRecordAllowed(6, 0, 0x0b, false)); + assert(MmxWidePolicy_SpawnRecordAllowed(6, 0, 0x0b, true)); + assert(!MmxWidePolicy_SpawnRecordAllowed(6, 2, 0x0b, false)); + for (unsigned id = 1; id <= 5; ++id) { + assert(MmxWidePolicy_IsCollectible(id)); + assert(MmxWidePolicy_SpawnRecordAllowed(3, 0, id, false)); + assert(MmxWidePolicy_SpawnRecordAllowed(3, 0, id, true)); + assert(!MmxWidePolicy_SpawnRecordAllowed(3, 2, id, false)); + } + assert(!MmxWidePolicy_IsCollectible(7)); /* Minecart. */ + assert(!MmxWidePolicy_IsCollectible(10)); /* Stage mechanism. */ + assert(!MmxWidePolicy_SpawnRecordAllowed(3, 0, 7, false)); /* Highway traffic remains eligible in both passes. */ assert(MmxWidePolicy_SpawnRecordAllowed(0x00, 1, 0x21, false)); @@ -68,9 +202,34 @@ static void test_spawn_record_ownership(void) { } int main(void) { + memset(ram, 0, sizeof(ram)); ram[0xd1] = 2; ram[0xd2] = 4; + for (int scene = 0; scene <= 8; scene += 2) { + ram[0xd3] = (uint8_t)scene; assert(MmxWidePolicy_IsStageScene(ram)); + } + ram[0xd3] = 10; assert(!MmxWidePolicy_IsStageScene(ram)); + ram[0xd3] = 4; ram[0xc3] = 0xc0; ram[0x1f10] = 2; + assert(MmxWidePolicy_IsStageScene(ram)); /* Spark's light HDMA remains gameplay. */ + ram[0x1f10] = 8; + assert(!MmxWidePolicy_IsStageScene(ram)); /* Weapons menu, same outer game mode. */ + ram[0xc3] = 0; + assert(MmxWidePolicy_IsStageScene(ram)); /* A hidden HUD alone is not a menu. */ + ram[0xd2] = 2; ram[0xd3] = 2; assert(!MmxWidePolicy_IsStageScene(ram)); + assert(!MmxWidePolicy_ForceNativeSpawnTiming(9, 0x8ff, 0)); + assert(MmxWidePolicy_ForceNativeSpawnTiming(9, 0x900, 0)); + assert(MmxWidePolicy_ForceNativeSpawnTiming(9, 0xa80, 0)); + assert(!MmxWidePolicy_ForceNativeSpawnTiming(9, 0xa81, 0)); + /* 32:9: rounded margin 216 plus 32-pixel spawn slack. */ + assert(!MmxWidePolicy_ForceNativeSpawnTiming(9, 0x807, 248)); + assert(MmxWidePolicy_ForceNativeSpawnTiming(9, 0x808, 248)); + assert(MmxWidePolicy_ForceNativeSpawnTiming(9, 0x880, 248)); + assert(!MmxWidePolicy_ForceNativeSpawnTiming(0, 0x880, 248)); test_boss_door_stack(); test_non_door_stack(); test_spawn_cursors_are_independent(); test_spawn_record_ownership(); + test_streaker_entry_and_recovery(); + test_chain_platform_switches(); + test_bee_camera_and_descent(); + test_flyer_and_armor_range(); return 0; } diff --git a/tools/apply_overrides.py b/tools/apply_overrides.py index 711fae2..76ffeeb 100644 --- a/tools/apply_overrides.py +++ b/tools/apply_overrides.py @@ -265,6 +265,8 @@ import sys MARKERS = ("/*WS-CULL*/", "/*WS-PRESENTATION-CULL*/", "/*WS-SHOT-CULL*/", "/*WS-SPAWN*/", "/*WS-SPAWN-PASS*/", "/*WS-ACTIVATE*/", + "/*WS-FLYER-LEASH*/", "/*WS-ARMOR-CULL*/", "/*WS-STREAKER-WAIT*/", + "/*WS-STREAKER-ENTRY*/", "/*WS-CHAIN-PLATFORM*/", "/*WS-OAM*/", "/*WS-OAM-L*/", "/*WS-LOOKAHEAD*/", "/*WS-STAGE*/", "/*WS-SHADOW*/", "/*WS-CHRBIND*/", "/*WS-CHRBIND-COPY*/", "/*WS-CHRBIND-PARENT*/", "/*MSU1-MUSIC*/", "/*MSU1-STAGE*/") @@ -488,6 +490,11 @@ def apply_bank00_spawn_pass(lines, verbose): out.append(line) n += 1 continue + if cur_fn == "bank_00_DC36" and cur_block == 0x00DC92 and line.strip() == "cpu->coprocessor_master_cycles = cpu->master_cycles;": + out.append(line) + out.append(" /*WS-SPAWN-PASS*/ { extern void MmxWsCollectiblePass(CpuState *); MmxWsCollectiblePass(cpu); }\n") + n += 1 + continue out.append(line) if verbose and n: print(f" WS-SPAWN-PASS injected {n} site(s)") @@ -632,8 +639,8 @@ def apply_bank02(lines, verbose): def activation_snippet(indent, var): return (f"{indent}/*WS-ACTIVATE*/ {{ extern uint16 " - f"MmxWsEnemyActivationDistance(uint16); {var} = " - f"MmxWsEnemyActivationDistance({var}); }}\n") + f"MmxWsEnemyActivationDistance(uint16, uint16); {var} = " + f"MmxWsEnemyActivationDistance({var}, cpu->D); }}\n") def apply_bank82_shot_cull(lines, verbose): @@ -713,7 +720,7 @@ def apply_bank82_presentation_cull(lines, verbose): def apply_bank82_activation(lines, verbose): - """Widen the Chill Penguin intro helicopter's player-distance gate. + """Widen the Highway helicopter's descent gate after native arena entry. bank_82_B964 holds the helicopter just above the viewport until its controller is 0x80 pixels ahead of X. That is exactly the native @@ -721,6 +728,7 @@ def apply_bank82_activation(lines, verbose): large sprite's lead distance while preserving 0x80 when widescreen spawning is disabled. """ + lines = [line for line in lines if '/*WS-ACTIVATE*/' not in line] out = [] cur_block = None n = 0 @@ -741,6 +749,78 @@ def apply_bank82_activation(lines, verbose): return out, n +def apply_flyer_leash(lines, verbose): + """The pink flyer's spawn leash must include the custom view's margin.""" + out, block, count = [], None, 0 + for line in lines: + out.append(line) + trace = RE_TRACE.search(line) + if trace: + block = canon_pc24(int(trace.group(1), 16)) + match = re.match(r"^(\s*)uint16 (_v\d+) = 0xa0;\s*$", line) + if block == 0x03DF71 and match: + out.append(f"{match[1]}/*WS-FLYER-LEASH*/ {{ extern uint16 MmxWsFlyerLeashLimit(void); {match[2]} = MmxWsFlyerLeashLimit(); }}\n") + count += 1 + return out, count + + +def apply_streaker_entry(lines, verbose): + """Keep native timing, but start the moving actor beyond the wide edge.""" + # Migrate the previous spike's parked-actor hook in existing generations. + lines = [line for line in lines if '/*WS-STREAKER-WAIT*/' not in line] + out, count, block = [], 0, None + for line in lines: + out.append(line) + trace = RE_TRACE.search(line) + if trace: + block = canon_pc24(int(trace[1], 16)) + # After the deadline guard so a yield cannot apply the offset twice. + if block == 0x07A590 and 'cpu->coprocessor_master_cycles = cpu->master_cycles;' in line: + indent = line[:len(line) - len(line.lstrip())] + out.append(f"{indent}/*WS-STREAKER-ENTRY*/ {{ extern void MmxWsStreakerEntrance(uint16); MmxWsStreakerEntrance(cpu->D); }}\n") + count += 1 + return out, count + + +def apply_chain_platform(lines, verbose): + """Widen only the chain-platform create/remove switches at $81:F97A.""" + out, block, count = [], None, 0 + for line in lines: + out.append(line) + trace = RE_TRACE.search(line) + if trace: + block = canon_pc24(int(trace[1], 16)) + match = re.match(r"^(\s*)uint16 (_v\d+) = cpu_read16\(cpu, 0x00, \(uint16\)\(cpu->D \+ 0x0000 \+ cpu->X\)\);", line) + if block == 0x01F97A and match: + out.append(f"{match[1]}/*WS-CHAIN-PLATFORM*/ {{ extern uint16 MmxWsChainPlatformLine(CpuState *, uint16); {match[2]} = MmxWsChainPlatformLine(cpu, {match[2]}); }}\n") + count += 1 + return out, count + + +def apply_ride_armor_cull(lines, verbose): + """Widen only the first (horizontal) compare in $83:8948.""" + out, block, compared, pending, count = [], None, None, False, 0 + for line in lines: + trace = RE_TRACE.search(line) + if trace: + block = canon_pc24(int(trace.group(1), 16)) + compared, pending = None, False + if block == 0x038948: + if re.match(r"^\s*uint16 _v\d+ = 0x200;\s*$", line): + pending = True + match = RE_READ_A.match(line) + if pending and match: + compared = match[1] + pending = False + branch = RE_BRANCH_C.match(line) + if compared and branch: + out.append(f"{branch[1]}/*WS-ARMOR-CULL*/ {{ extern uint16 MmxWsRideArmorCullVerdictX(uint16); cpu->_flag_C = MmxWsRideArmorCullVerdictX({compared}); }}\n") + count += 1 + compared = None + out.append(line) + return out, count + + RE_CHRBIND_ANCHOR = re.compile( r"^\s*uint8 (_v\d+) = cpu_read8\(cpu, \(uint8\)\(\(\(\(uint32\)0x7f8200 " r"\+ \(uint32\)cpu->X\)\) >> 16\), \(uint16\)\(\(\(uint32\)0x7f8200 \+ " @@ -1014,6 +1094,10 @@ def main(): (apply_bank82_presentation_cull, "/*WS-PRESENTATION-CULL*/"), (apply_bank82_shot_cull, "/*WS-SHOT-CULL*/"), (apply_bank82_activation, "/*WS-ACTIVATE*/"), + (apply_flyer_leash, "/*WS-FLYER-LEASH*/"), + (apply_streaker_entry, "/*WS-STREAKER-ENTRY*/"), + (apply_chain_platform, "/*WS-CHAIN-PLATFORM*/"), + (apply_ride_armor_cull, "/*WS-ARMOR-CULL*/"), (apply_bank03, "/*WS-STAGE*/"), (apply_chrbind_generic, "/*WS-CHRBIND*/"), (apply_chrbind_copy_generic, "/*WS-CHRBIND-COPY*/"), @@ -1043,7 +1127,16 @@ def main(): for fn, marker in appliers: with open(path, "r", encoding="utf-8") as f: contents = f.read() - if marker in contents: + stale_activation = (fn is apply_bank82_activation and + 'MmxWsEnemyActivationDistance(uint16);' in contents) + stale_collectibles = (fn is apply_bank00_spawn_pass and 'RecompReturn bank_00_DC36_' in contents + and 'MmxWsCollectiblePass(cpu)' not in contents) + if stale_collectibles: + lines = [line for line in contents.splitlines(keepends=True) if marker not in line] + if not args.check: + with open(path, 'w', encoding='utf-8', newline='') as f: + f.writelines(lines) + if marker in contents and not stale_activation and not stale_collectibles: effective_counts[marker] = ( effective_counts.get(marker, 0) + contents.count(marker)) already += 1 @@ -1072,6 +1165,13 @@ def main(): file=sys.stderr) return 1 chrbind_found = effective_counts.get("/*WS-CHRBIND*/", 0) + if not args.restore: + for marker, expected in (("/*WS-FLYER-LEASH*/", 1), ("/*WS-ARMOR-CULL*/", 1), + ("/*WS-STREAKER-ENTRY*/", 1), ("/*WS-CHAIN-PLATFORM*/", 2), + ("/*WS-SPAWN-PASS*/", 4)): + if effective_counts.get(marker, 0) != expected: + print(f"ERROR: expected exactly {expected} {marker} hook(s), found {effective_counts.get(marker, 0)}", file=sys.stderr) + return 1 if not args.restore and chrbind_found != EXPECTED_CHRBIND_SITES: print( f"ERROR: expected exactly {EXPECTED_CHRBIND_SITES} WS-CHRBIND " diff --git a/tools/apply_renderer_capture.py b/tools/apply_renderer_capture.py new file mode 100644 index 0000000..4168010 --- /dev/null +++ b/tools/apply_renderer_capture.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Read-only pre-clipping sprite observation for the custom renderer. + +Run after apply_overrides.py. Idempotent, and fail closed if D76A coverage +is missing: an executable without this hook silently loses margin sprites. +""" +import argparse +from pathlib import Path +import re + +MARKER = '/*MMX-RENDER-PIECE*/' + +def apply(text): + text = ''.join(line for line in text.splitlines(keepends=True) if MARKER not in line) + count = 0 + def inject(match): + nonlocal count + count += 1 + return match[0] + (' /*MMX-RENDER-PIECE*/ { extern uint8_t g_ram[0x20000]; extern void MmxRendererRecordPiece(const uint8_t *, uint16_t); ' + 'MmxRendererRecordPiece(g_ram, cpu->D); }\n') + # Inject after the deadline check, so a yielded/resumed block is observed + # once. Do not affect registers, flags, OAM, CPU cycles or dispatch. + text = re.sub(r' cpu_trace_block\(cpu, 0x00D76A\);\n(?:(?!cpu_trace_block)[\s\S])*?' + r' cpu->coprocessor_master_cycles = cpu->master_cycles;\n', inject, text) + def observe(match): + return match[0] + (' /*MMX-RENDER-PIECE*/ { extern uint8_t g_ram[0x20000]; extern void MmxRendererObserveObject(const uint8_t *, uint16_t); ' + 'MmxRendererObserveObject(g_ram, (uint16_t)(cpu->D + cpu->X)); }\n') + text, objects = re.subn(r' cpu_trace_block\(cpu, 0x00D6A7\);\n(?:(?!cpu_trace_block)[\s\S])*?' + r' cpu->coprocessor_master_cycles = cpu->master_cycles;\n', observe, text) + if count and objects != count: + raise ValueError(f'D6A7/D76A mode coverage differs: {objects}/{count}') + return text, count + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--gen-dir', type=Path, required=True) + args = parser.parse_args() + count = 0 + for path in args.gen_dir.glob('*.c'): + original = path.read_text(encoding='utf-8') + if 'cpu_trace_block(cpu, 0x00D76A);' not in original: + continue + updated, sites = apply(original) + count += sites + if updated != original: + path.write_text(updated, encoding='utf-8', newline='\n') + if count < 2: + raise SystemExit(f'Expected both D76A entry modes, found {count} capture sites') + print(f'MMX custom renderer: {count} pre-clipping capture sites verified') + +if __name__ == '__main__': + main() diff --git a/tools/mmx_render_capture.c b/tools/mmx_render_capture.c new file mode 100644 index 0000000..55e6577 --- /dev/null +++ b/tools/mmx_render_capture.c @@ -0,0 +1,52 @@ +#include "mmx_renderer.h" +#include + +static bool bmp(const char *path, const uint32_t *pixels, int width) { + FILE *f = fopen(path, "wb"); + if (!f) return false; + uint32_t size = (uint32_t)width * 224 * 4; + const uint8_t signature[2] = {'B', 'M'}; + uint32_t header[] = {54 + size, 0, 54, 40, (uint32_t)width, 224, 0x200001, 0, size, 0, 0, 0, 0}; + bool ok = fwrite(signature, 2, 1, f) == 1 && fwrite(header, sizeof(header), 1, f) == 1; + for (int y = 223; y >= 0; --y) ok &= fwrite(pixels + y * width, width * 4, 1, f) == 1; + return fclose(f) == 0 && ok; +} +int main(int argc, char **argv) { + if (argc != 6) { + fprintf(stderr, "usage: mmx_render_capture capture rom aspect hud(0|1) output.bmp\n"); return 2; + } + FILE *f = fopen(argv[2], "rb"); + if (!f || fseek(f, 0, SEEK_END)) return 2; + long size = ftell(f); rewind(f); + if (size <= 0 || size > 0x1000000) return 2; + uint8_t *bytes = malloc(size); + if (!bytes || fread(bytes, size, 1, f) != 1) return 2; + fclose(f); + size_t copier = (size % 0x8000) == 512 ? 512 : 0; + MmxRendererSetRom(bytes + copier, size - copier); + if (!MmxRendererLoadCapture(argv[1])) return 2; + MmxRenderAspect aspect = !strcmp(argv[3], "16:9") ? MMX_ASPECT_16_9 : + !strcmp(argv[3], "21:9") ? MMX_ASPECT_21_9 : MMX_ASPECT_32_9; + MmxRenderView view = !strcmp(argv[3], "4:3") ? (MmxRenderView){256,0,4.0/3.0} : + !strcmp(argv[3], "max") ? MmxRendererViewport(MMX_ASPECT_ADAPTIVE, 2048, 300) : MmxRendererViewport(aspect, 16, 9); + uint32_t *pixels = calloc((size_t)view.width * 224, 4); + g_mmx_render_asset_repairs = false; + if (!pixels || !MmxRendererDraw(pixels, view, false)) return 2; + unsigned differences = 0; + const uint32_t *stock = MmxRendererStockFrame(); + for (int y = 0; y < 224; ++y) for (int x = 0; x < 256; ++x) + differences += ((stock[y * 256 + x] ^ pixels[y * view.width + x + view.extra]) & 0xffffff) != 0; + MmxRenderStats stats = MmxRendererGetStats(); + g_mmx_render_asset_repairs = true; + if (!MmxRendererDraw(pixels, view, false)) return 2; + unsigned repaired = 0; + for (int y = 0; y < 224; ++y) for (int x = 0; x < 256; ++x) + repaired += ((stock[y * 256 + x] ^ pixels[y * view.width + x + view.extra]) & 0xffffff) != 0; + unsigned margin = MmxRendererGetStats().margin_sprite_pixels; + printf("{\"width\":%d,\"native_differences\":%u,\"custom_lines\":%u,\"fallback_lines\":%u,\"pieces\":%u,\"margin_sprite_pixels\":%u,\"repaired_native_pixels\":%u}\n", + view.width, differences, stats.custom_lines, stats.fallback_lines, stats.pieces, margin, repaired); + if (atoi(argv[4]) && !MmxRendererDraw(pixels, view, true)) return 2; + bool ok = bmp(argv[5], pixels, view.width); + free(pixels); free(bytes); + return !ok ? 2 : differences ? 1 : 0; +} diff --git a/tools/run_custom_renderer.ps1 b/tools/run_custom_renderer.ps1 new file mode 100644 index 0000000..4f74cc3 --- /dev/null +++ b/tools/run_custom_renderer.ps1 @@ -0,0 +1,64 @@ +[CmdletBinding()] +param( + [string]$DirectRomPath, + [string]$RuntimeBin = 'C:\msys64\mingw64\bin', + [switch]$CheckOnly +) +$ErrorActionPreference = 'Stop' +$rendererRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path +$rendererExe = Join-Path $rendererRoot 'build-custom\MegaManXSNESRecomp.exe' +$rendererData = Join-Path $rendererRoot 'build-custom\playtest' +if (-not (Test-Path -LiteralPath $rendererExe -PathType Leaf)) { + throw "Build the custom renderer first: $rendererExe" +} +if (-not (Test-Path -LiteralPath $RuntimeBin -PathType Container)) { + throw "Runtime DLL directory is missing: $RuntimeBin" +} +$rendererArguments = @('--config', 'config.ini') +if ($DirectRomPath) { + $rendererArguments += (Resolve-Path -LiteralPath $DirectRomPath).Path +} +if ($CheckOnly) { + Write-Output "Executable: $rendererExe" + Write-Output "Settings and saves: $rendererData" + Write-Output "Runtime DLLs: $RuntimeBin" + return +} +[void](New-Item -ItemType Directory -Path $rendererData -Force) +Copy-Item -LiteralPath (Join-Path $rendererRoot 'build-custom\assets') ` + -Destination $rendererData -Recurse -Force +# Refresh package definitions while keeping this playtest's selections/saves. +Copy-Item -LiteralPath (Join-Path $rendererRoot 'mods\preloaded\packages') ` + -Destination (New-Item -ItemType Directory -Path (Join-Path $rendererData 'mods') -Force).FullName ` + -Recurse -Force +$rendererState = Join-Path $rendererData 'mods\state.toml' +if (-not (Test-Path -LiteralPath $rendererState)) { + @' +format_version = 1 +[[package]] +id = "megaman-x.enhancement.widescreen" +version = "1.0.0" +[[feature]] +package_id = "megaman-x.enhancement.widescreen" +id = "widescreen" +enabled = true +[feature.values] +renderer = "custom" +aspect = "adaptive" +hud = "edges" +'@ | Set-Content -LiteralPath $rendererState -Encoding ASCII +} +$rendererConfig = Join-Path $rendererData 'config.ini' +if (-not (Test-Path -LiteralPath $rendererConfig)) { + Copy-Item -LiteralPath (Join-Path $rendererRoot 'config.ini') -Destination $rendererConfig +} +$rendererSavedPath = $env:PATH +Push-Location -LiteralPath $rendererData +try { + $env:PATH = "$RuntimeBin;$rendererSavedPath" + & $rendererExe @rendererArguments + if ($LASTEXITCODE -ne 0) { throw "Renderer exited with $LASTEXITCODE; see $rendererData" } +} finally { + $env:PATH = $rendererSavedPath + Pop-Location +} diff --git a/tools/test_renderer_captures.py b/tools/test_renderer_captures.py new file mode 100644 index 0000000..b9e830d --- /dev/null +++ b/tools/test_renderer_captures.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Capture isolated MMX save fixtures and replay each at five aspect settings. + +Copies caller-owned saves; never runs in the source checkout or writes back to +the originals. The pixel oracle compares unanchored native pixels, then saves +anchored wide images for visual review. It does not certify a full playthrough. +""" +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import struct +import tempfile + +def sprite_matches(data): + """Independent native OAM check of pre-clipping geometry and art identity.""" + ram_offset = 12 + 224 * 66656 + pieces_offset = ram_offset + 0x20000 + 256 * 224 * 4 + version = struct.unpack_from('> (slot % 4 * 2) + x = (pos & 255) | ((flags & 1) << 8) + if x >= 256: x -= 512 + expected.add((x, pos >> 8, attr, 16 if flags & 2 else 8)) + matched = missing = 0 + for i in range(count): + x, y, attr, size = struct.unpack_from('= count and data[pieces_offset:pieces_offset + count * stride] == data[expanded:expanded + count * stride]) + return result + +def bmp_pixels(data): + offset = struct.unpack_from('