From 7d813ea5491ee0b7a9979178196245bbb59e55e2 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:21:40 -0700 Subject: [PATCH 01/13] Prototype MMX custom compositor with mod aspect and HUD options --- CMakeLists.txt | 20 + README.md | 5 + docs/custom-renderer-spike.md | 163 +++++++++ .../1.0.0/manifest.toml | 50 ++- src/main.c | 74 +++- src/mmx_renderer.c | 342 ++++++++++++++++++ src/mmx_renderer.h | 34 ++ src/mmx_rtl.c | 14 +- src/mmx_wide_policy.c | 6 + src/mmx_wide_policy.h | 4 + src/mods/mmx_widescreen_plugin.c | 29 +- src/opengl.c | 4 +- tests/mmx_renderer_test.c | 65 ++++ tests/mmx_wide_policy_test.c | 9 + tools/apply_renderer_capture.py | 45 +++ tools/mmx_render_capture.c | 44 +++ tools/run_custom_renderer.ps1 | 64 ++++ tools/test_renderer_captures.py | 147 ++++++++ 18 files changed, 1095 insertions(+), 24 deletions(-) create mode 100644 docs/custom-renderer-spike.md create mode 100644 src/mmx_renderer.c create mode 100644 src/mmx_renderer.h create mode 100644 tests/mmx_renderer_test.c create mode 100644 tools/apply_renderer_capture.py create mode 100644 tools/mmx_render_capture.c create mode 100644 tools/run_custom_renderer.ps1 create mode 100644 tools/test_renderer_captures.py diff --git a/CMakeLists.txt b/CMakeLists.txt index c047646..8850778 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_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_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,7 @@ 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/main.c src/mmx_wide_preview.c src/mmx_wide_policy.c @@ -112,6 +129,9 @@ function(add_mmx_variant TARGET GEN_DIR CFG_INCLUDE_DIR) 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..0e82128 --- /dev/null +++ b/docs/custom-renderer-spike.md @@ -0,0 +1,163 @@ +# Mega Man X custom renderer spike + +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. + +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. The existing Storm Eagle +roof exception and Launch Octopus submarine entrance mask are retained. + +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. Native OAM remains the +source for the native footprint; recorded pieces supply margin pixels. + +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 skips the authored pair in host margins while retaining native +scripted doors. + +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. Spark Mandrill's kind-3 +mid-boss controller and kinds 0–2 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. + +## 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. + Margin CHR currently comes from captured live VRAM; art not loaded by the + game may need a host graphics cache. 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..b3ff549 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,56 @@ 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" + [[plugin]] feature = "widescreen" id = "megaman-x.widescreen" diff --git a/src/main.c b/src/main.c index 6c03514..eb85500 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. @@ -641,7 +658,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 +918,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 +1190,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 +1408,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 +1980,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 +2231,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_renderer.c b/src/mmx_renderer.c new file mode 100644 index 0000000..c0eec81 --- /dev/null +++ b/src/mmx_renderer.c @@ -0,0 +1,342 @@ +#include "mmx_renderer.h" +#include "mmx_display.h" +#include "mmx_wide_policy.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, reserved; } 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; +} 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 const uint8_t *rom; +static size_t rom_size; +static MmxRenderStats stats; +static uint8_t door_cache[512 * 512]; +bool g_mmx_custom_renderer; +bool g_mmx_custom_hud = 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; } +void MmxRendererReset(void) { + frame.valid = false; frame.captured = 0; + building_count = latched_count = 0; + building_stage = latched_stage = 0xff; +} +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 size = p[4] & 0x20 ? 16 : 8; + int x = (int16_t)word(ram, d), y = (int16_t)word(ram, d + 2); + x += ram[d + 0xb] & 0x40 ? -(int8_t)p[1] - size : (int8_t)p[1]; + y += ram[d + 0xb] & 0x80 ? -(int8_t)p[2] - size : (int8_t)p[2]; + unsigned attr = (((p[4] & 0xce) | ram[d + 0xf]) ^ ram[d + 0xb]) << 8; + attr |= (p[3] + ram[d + 0x10]) & 255; + building[building_count++] = (Piece){(int16_t)x, (int16_t)y, (uint16_t)attr, (uint8_t)size, 0}; +} +void MmxRendererLatchSprites(void) { + latched_count = building_count; + latched_stage = building_stage; + memcpy(latched, building, building_count * sizeof(*building)); + building_count = 0; +} +void MmxRendererBeginFrame(const uint8_t ram[0x20000]) { + 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)); +} +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, 1, 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] == 1 && h[2] == sizeof(frame) && fread(&frame, sizeof(frame), 1, f) == 1 && + frame.captured == 224 && frame.piece_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 uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, int y, bool stage) { + static const unsigned low[] = {8, 7, 1}, high[] = {12, 11, 3}; + 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]; + bool roof_sky = layer == 1 && frame.ram[0x1f7a] == 5 && word(frame.ram, 0x1e50) >= 0x300; + if (stage && size == 8 && layer < 2 && !roof_sky && (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; + } else { + int stream_x = word(frame.ram, 0x1e8d), stream_y = 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; + } + /* 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 == 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; + } + } + if (stage_id == 1 && word(frame.ram, 0x1e4d) >= 0xa70 && word(frame.ram, 0x1e4d) < 0xac0 && + y >= 0x50 && y < 0xb0 && wx >= 0xbc0 && wx < 0xc40) wx -= 256; + if (door_body(wx, wy) && (door_body(wx - 16, wy) || door_body(wx + 16, wy))) { + int step = x < 0 ? -16 : 16; + /* Skip the whole authored pair, retaining the native scripted door. */ + for (int i = 0; i < 2 && door_body(wx, wy); ++i) wx += step; + } + } + uint16_t mapped; + if (MmxRendererStageTile(frame.ram, layer, wx, wy, &mapped)) { tile = mapped; px = wx; py = wy; } + } + 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 pixel = tile_pixel(r->vram, PPU_bgTileAdr(p, layer) + number * bpp * 4, cx & 7, cy & 7, bpp); + if (!pixel) return 0; + unsigned priority = tile & 0x2000 ? (layer == 2 && (p->bgmode & 8) ? 15 : high[layer]) : low[layer]; + return (uint16_t)((priority << 12) | (layer << 8) | (((tile >> 10) & 7) << bpp) | pixel); +} +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) { + int row = (y - sy) & 255; + if (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 tile = (((((attr & 255) >> 4) + row / 8) & 15) << 4) | (((attr & 15) + cx / 8) & 15); + unsigned pixel = tile_pixel(r->vram, base + tile * 16, cx & 7, row & 7, 4); + if (pixel) { out[dest] = (uint16_t)(z | pixel); if (margins_only) ++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; + } +} +static bool condition(unsigned mode, bool inside) { return mode == 3 || (mode == 1 && !inside) || (mode == 2 && inside); } +static uint32_t colour(const Ppu *p, const Raster *r, const uint8_t brightness[32], uint16_t main, uint16_t sub, bool inside) { + unsigned rgb = r->palette[main & 255], layer = (main >> 8) & 15; + 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) other = r->palette[sub & 255]; 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 = frame.ram[0xd1] == 2 && frame.ram[0xd2] == 4; + for (int y = 0; y < 224; ++y) { + const Raster *r = &frame.lines[y]; Ppu p; + memcpy(&p, r->registers, PPU_SAVESTATE_REGS_SIZE); + 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}; + for (int i = (int)frame.piece_count - 1; i >= 0; --i) { + Piece s = frame.pieces[i]; + sprite(&p, r, s.x, s.y, s.attr, s.size, y, view, objects, 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) { + 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); + } + for (int sx = 0; sx < view.width; ++sx) { + int x = sx - view.extra; + uint16_t screens[2] = {0x500, 0x500}, bg[3] = {0}; + 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); + } + 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]; + } + out[y * view.width + sx] = colour(&p, r, brightness, screens[0], screens[1], window(&p, 5, x, view.extra)); + } + } + return true; +} diff --git a/src/mmx_renderer.h b/src/mmx_renderer.h new file mode 100644 index 0000000..dc646d8 --- /dev/null +++ b/src/mmx_renderer.h @@ -0,0 +1,34 @@ +#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 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 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..045fde6 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" @@ -386,6 +387,7 @@ void MmxStateLoadExtra(struct SaveLoadInfo *sli, uint32_t version) { } void MmxOnStateLoaded(uint32_t version) { + MmxRendererReset(); 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 +654,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]); @@ -853,8 +856,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 (g_mmx_custom_view.extra + 7) & ~7; return (g_ws_extra + 7) & ~7; } @@ -915,6 +919,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 +932,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; @@ -1010,7 +1016,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 @@ -1110,6 +1117,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 +1290,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; diff --git a/src/mmx_wide_policy.c b/src/mmx_wide_policy.c index 2fc6c98..c5feec1 100644 --- a/src/mmx_wide_policy.c +++ b/src/mmx_wide_policy.c @@ -1,5 +1,11 @@ #include "mmx_wide_policy.h" +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; diff --git a/src/mmx_wide_policy.h b/src/mmx_wide_policy.h index 71844a8..bb2ca3e 100644 --- a/src/mmx_wide_policy.h +++ b/src/mmx_wide_policy.h @@ -30,5 +30,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..2f6b2c9 100644 --- a/src/mods/mmx_widescreen_plugin.c +++ b/src/mods/mmx_widescreen_plugin.c @@ -1,16 +1,39 @@ #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; + 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; +#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, "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..9423f0b --- /dev/null +++ b/tests/mmx_renderer_test.c @@ -0,0 +1,65 @@ +#include "mmx_renderer.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; + 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; + 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. */ + 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(MmxRendererGetStats().margin_sprite_pixels == 128); + g_mmx_custom_renderer = false; +} +int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); return 0; } diff --git a/tests/mmx_wide_policy_test.c b/tests/mmx_wide_policy_test.c index c37dafc..2bf4a6b 100644 --- a/tests/mmx_wide_policy_test.c +++ b/tests/mmx_wide_policy_test.c @@ -68,6 +68,15 @@ static void test_spawn_record_ownership(void) { } int main(void) { + 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(); diff --git a/tools/apply_renderer_capture.py b/tools/apply_renderer_capture.py new file mode 100644 index 0000000..4071479 --- /dev/null +++ b/tools/apply_renderer_capture.py @@ -0,0 +1,45 @@ +#!/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) + 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..0241377 --- /dev/null +++ b/tools/mmx_render_capture.c @@ -0,0 +1,44 @@ +#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} : MmxRendererViewport(aspect, 16, 9); + uint32_t *pixels = calloc((size_t)view.width * 224, 4); + 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(); + printf("{\"width\":%d,\"native_differences\":%u,\"custom_lines\":%u,\"fallback_lines\":%u,\"pieces\":%u,\"margin_sprite_pixels\":%u}\n", + view.width, differences, stats.custom_lines, stats.fallback_lines, stats.pieces, stats.margin_sprite_pixels); + 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..7e7b55b --- /dev/null +++ b/tools/test_renderer_captures.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Capture isolated MMX save fixtures and replay each at four aspect ratios. + +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 + count = 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(' Date: Mon, 14 Sep 2026 01:12:50 -0700 Subject: [PATCH 02/13] Repair early MMX graphics and palettes; add opt-in sprite capacity --- CMakeLists.txt | 9 +- docs/custom-renderer-spike.md | 76 ++++++- .../1.0.0/manifest.toml | 14 ++ src/mmx_render_assets.c | 146 +++++++++++++ src/mmx_render_assets.h | 17 ++ src/mmx_renderer.c | 199 +++++++++++++++--- src/mmx_renderer.h | 4 + src/mmx_rtl.c | 16 +- src/mmx_wide_policy.c | 7 + src/mmx_wide_policy.h | 2 + src/mods/mmx_widescreen_plugin.c | 3 + tests/mmx_renderer_test.c | 100 ++++++++- tools/apply_renderer_capture.py | 7 + tools/mmx_render_capture.c | 11 +- tools/test_renderer_captures.py | 27 ++- 15 files changed, 589 insertions(+), 49 deletions(-) create mode 100644 src/mmx_render_assets.c create mode 100644 src/mmx_render_assets.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 8850778..64274c5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,10 +42,10 @@ 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_display.c src/mmx_wide_policy.c) + add_executable(mmx_renderer_test tests/mmx_renderer_test.c src/mmx_renderer.c src/mmx_render_assets.c src/mmx_display.c src/mmx_wide_policy.c) 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_display.c src/mmx_wide_policy.c) + add_executable(mmx_render_capture tools/mmx_render_capture.c src/mmx_renderer.c src/mmx_render_assets.c src/mmx_display.c src/mmx_wide_policy.c) 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) @@ -100,6 +100,7 @@ function(add_mmx_variant TARGET GEN_DIR CFG_INCLUDE_DIR) # 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 @@ -125,6 +126,10 @@ 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) add_custom_target(${TARGET}_widescreen_overrides COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/apply_overrides.py diff --git a/docs/custom-renderer-spike.md b/docs/custom-renderer-spike.md index 0e82128..4f74f38 100644 --- a/docs/custom-renderer-spike.md +++ b/docs/custom-renderer-spike.md @@ -28,6 +28,11 @@ 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. @@ -57,8 +62,24 @@ 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. Native OAM remains the -source for the native footprint; recorded pieces supply margin pixels. +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 @@ -74,6 +95,51 @@ mid-boss controller and kinds 0–2 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's vertical descent keeps its native player-distance trigger; +allocating its controller in the wider scan no longer advances that entrance. +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. +- A 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. +- 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 state +transitions with player/enemy positions for timing investigations. ## Validation recorded on 2026-09-13/14 @@ -133,8 +199,10 @@ test directory. No source save was converted or overwritten. 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. - Margin CHR currently comes from captured live VRAM; art not loaded by the - game may need a host graphics cache. Non-stage screens and non-Mode-1 lines + 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. 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 b3ff549..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 @@ -65,6 +65,20 @@ label = "Screen edges" 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/mmx_render_assets.c b/src/mmx_render_assets.c new file mode 100644 index 0000000..24ef571 --- /dev/null +++ b/src/mmx_render_assets.c @@ -0,0 +1,146 @@ +#include "mmx_render_assets.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 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; +} +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; + 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; +} +void MmxRenderAssetsMarginPalette(const uint8_t ram[0x20000], int extra, + uint16_t colors[128], bool changed[128]) { + memset(changed, 0, 128 * sizeof(*changed)); + unsigned stage = ram[0x1f7a], phase = ram[0x1f0a]; + if (!extra || stage >= 13 || !range(0x32260, 32)) return; + /* Highway's city layer scrolls at half the camera speed. An extra BG2 + * column becomes native after twice that camera travel, so its palette + * lookahead must use the same parallax scale as the retained map. */ + int camera = (int)ram_word(ram, 0x1e4d), projected = camera + extra * (stage == 0 ? 2 : 1); + size_t pos = 0x28000 + (word(0x282c2 + stage * 2) & 0x7fff); + if (!range(pos, 1)) return; + unsigned column = rom[pos++]; + int nearest = extra > 0 ? camera : projected - 1; + for (unsigned guard = 0; guard < 512 && range(pos, 8); ++guard) { + unsigned x = word(pos + 5), kind = rom[pos] & 15, event = rom[pos + 3]; + int line = (int)(x & 0x7fff); + if (kind == 2 && event == 0x17 && + (extra > 0 ? line > camera && line <= projected && line >= nearest : + line > projected && line <= camera && line > nearest)) { + phase = extra > 0 ? rom[pos + 4] & 15 : rom[pos + 4] >> 4; + nearest = line; + if (extra < 0) break; + } + pos += 7; + if (x & 0x8000) { if (rom[pos] == column) break; column = rom[pos++]; } + } + if (phase == ram[0x1f0a]) return; + size_t base = 0x32260, p = base + word(base + word(base + stage * 2) + phase * 2); + 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]; + if (first + 16 > 128 || !range(source, 32)) continue; + for (unsigned i = 0; i < 16; ++i) { colors[first + i] = (uint16_t)word(source + i * 2); changed[first + i] = true; } + } +} diff --git a/src/mmx_render_assets.h b/src/mmx_render_assets.h new file mode 100644 index 0000000..a155891 --- /dev/null +++ b/src/mmx_render_assets.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include +#include + +typedef struct MmxSpriteAsset { + uint8_t tiles[8192]; + uint16_t colors[16]; + uint8_t id, tile_base, attributes; + bool current; +} MmxSpriteAsset; + +void MmxRenderAssetsSetRom(const uint8_t *rom, size_t size); +const MmxSpriteAsset *MmxRenderAssetsSprite(unsigned stage, unsigned section, unsigned sprite); +/* ROM-authored palette transitions for the extra view; never touch CGRAM. */ +void MmxRenderAssetsMarginPalette(const uint8_t ram[0x20000], int extra, + uint16_t colors[128], bool changed[128]); diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index c0eec81..b87606b 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -1,6 +1,7 @@ #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. @@ -11,7 +12,10 @@ typedef struct Raster { 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, reserved; } Piece; +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]; @@ -20,17 +24,26 @@ typedef struct Frame { 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]; 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}; @@ -57,11 +70,66 @@ static const uint8_t *rom_at(unsigned address, size_t length) { 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; } +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; @@ -72,25 +140,51 @@ void MmxRendererRecordPiece(const uint8_t ram[0x20000], uint16_t d) { unsigned pointer = word(ram, d + 0x18) | (ram[d + 0x1a] << 16); const uint8_t *p = rom_at(pointer, 5); if (!p) return; - int size = p[4] & 0x20 ? 16 : 8; int x = (int16_t)word(ram, d), y = (int16_t)word(ram, d + 2); - x += ram[d + 0xb] & 0x40 ? -(int8_t)p[1] - size : (int8_t)p[1]; - y += ram[d + 0xb] & 0x80 ? -(int8_t)p[2] - size : (int8_t)p[2]; - unsigned attr = (((p[4] & 0xce) | ram[d + 0xf]) ^ ram[d + 0xb]) << 8; - attr |= (p[3] + ram[d + 0x10]) & 255; - building[building_count++] = (Piece){(int16_t)x, (int16_t)y, (uint16_t)attr, (uint8_t)size, 0}; + 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\n", log); + } + if (!log || ram[0x1f7a] != 0) return; + for (unsigned i = 0; i < 15; ++i) { + unsigned d = 0xe68 + i * 64; + unsigned state = ram[d] && ram[d + 10] == 0x22 ? ram[d + 1] + 1u : 0; + if (state == previous[i]) continue; + previous[i] = state; + fprintf(log, "%u,%04x,%d,%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); + 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; @@ -113,7 +207,7 @@ bool MmxRendererSaveCapture(const char *path) { if (!frame.valid || !path) return false; FILE *f = fopen(path, "wb"); if (!f) return false; - uint32_t header[] = {0x4d4d5843, 1, sizeof(frame)}; + 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; } @@ -124,8 +218,8 @@ bool MmxRendererLoadCapture(const char *path) { uint32_t h[3]; frame.valid = false; bool ok = fread(h, sizeof(h), 1, f) == 1 && h[0] == 0x4d4d5843 && - h[1] == 1 && h[2] == sizeof(frame) && fread(&frame, sizeof(frame), 1, f) == 1 && - frame.captured == 224 && frame.piece_count <= MAX_PIECES && frame.valid && fgetc(f) == EOF; + 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; } @@ -223,9 +317,11 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, return (uint16_t)((priority << 12) | (layer << 8) | (((tile >> 10) & 7) << bpp) | pixel); } 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) { - int row = (y - sy) & 255; - if (row >= size) return; + 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; @@ -235,9 +331,20 @@ static void sprite(const Ppu *p, const Raster *r, int x, int sy, unsigned attr, 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 tile = (((((attr & 255) >> 4) + row / 8) & 15) << 4) | (((attr & 15) + cx / 8) & 15); - unsigned pixel = tile_pixel(r->vram, base + tile * 16, cx & 7, row & 7, 4); - if (pixel) { out[dest] = (uint16_t)(z | pixel); if (margins_only) ++stats.margin_sprite_pixels; } + unsigned number = asset ? raw_tile : attr & 255; + unsigned tile = ((((number >> 4) + row / 8) & 15) << 4) | (((number & 15) + cx / 8) & 15); + unsigned pixel; + if (asset) { + 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->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) { @@ -255,13 +362,19 @@ static bool window(const Ppu *p, int layer, int x, int extra) { } } static bool condition(unsigned mode, bool inside) { return mode == 3 || (mode == 1 && !inside) || (mode == 2 && inside); } -static uint32_t colour(const Ppu *p, const Raster *r, const uint8_t brightness[32], uint16_t main, uint16_t sub, bool inside) { - unsigned rgb = r->palette[main & 255], layer = (main >> 8) & 15; +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) { + unsigned rgb = palette[main & 255], layer = (main >> 8) & 15; + if (object_color >= 0 && (layer == 4 || layer == 6)) rgb = (unsigned)object_color; 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) other = r->palette[sub & 255]; else half = false; } + 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]; + } else half = false; + } uint32_t result = 0; for (int component = 0; component < 3; ++component) { int c = clipped ? 0 : (rgb >> (component * 5)) & 31; @@ -282,6 +395,20 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { memset(door_cache, 0, sizeof(door_cache)); memset(out, 0, (size_t)view.width * 224 * sizeof(*out)); bool stage = frame.ram[0xd1] == 2 && frame.ram[0xd2] == 4; + 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 = MmxRenderAssetsSprite(frame.ram[0x1f7a], frame.ram[0x1f08], 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))) piece_assets[i] = a; + } + uint16_t margin_colors[2][128]; bool margin_changed[2][128] = {{false}}; + if (stage && g_mmx_render_asset_repairs) for (int side = 0; side < 2; ++side) + MmxRenderAssetsMarginPalette(frame.ram, side ? view.extra : -view.extra, margin_colors[side], margin_changed[side]); for (int y = 0; y < 224; ++y) { const Raster *r = &frame.lines[y]; Ppu p; memcpy(&p, r->registers, PPU_SAVESTATE_REGS_SIZE); @@ -294,9 +421,24 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { 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}; - for (int i = (int)frame.piece_count - 1; i >= 0; --i) { - Piece s = frame.pieces[i]; - sprite(&p, r, s.x, s.y, s.attr, s.size, y, view, objects, true); + 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) : s.attr; + 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) { @@ -309,6 +451,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { } 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; @@ -317,7 +460,12 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { 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); + sprite(&p, r, x, sy, attr, size, y, view, objects, false, NULL, 0, object_colors, false); + } + uint16_t palettes[2][256]; + for (int side = 0; side < 2; ++side) { + memcpy(palettes[side], r->palette, sizeof(r->palette)); + for (int i = 0; i < 128; ++i) if (margin_changed[side][i]) palettes[side][i] = margin_colors[side][i]; } for (int sx = 0; sx < view.width; ++sx) { int x = sx - view.extra; @@ -335,7 +483,8 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { if ((p.screenEnabled[sub] & 16) && (!(p.screenWindowed[sub] & 16) || !window(&p, 4, x, view.extra)) && objects[sx] > screens[sub]) screens[sub] = objects[sx]; } - out[y * view.width + sx] = colour(&p, r, brightness, screens[0], screens[1], window(&p, 5, x, view.extra)); + const uint16_t *pal = x < 0 ? palettes[0] : x >= 256 ? palettes[1] : r->palette; + out[y * view.width + sx] = colour(&p, pal, brightness, screens[0], screens[1], window(&p, 5, x, view.extra), object_colors[sx]); } } return true; diff --git a/src/mmx_renderer.h b/src/mmx_renderer.h index dc646d8..00c84eb 100644 --- a/src/mmx_renderer.h +++ b/src/mmx_renderer.h @@ -13,6 +13,9 @@ typedef struct 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); @@ -21,6 +24,7 @@ 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); diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c index 045fde6..47e33af 100644 --- a/src/mmx_rtl.c +++ b/src/mmx_rtl.c @@ -1096,6 +1096,10 @@ void MmxWsSpawnRunNativePass(CpuState *cpu) { * naturally instead of its controller waking only when the center reaches * the widened edge. */ uint16 MmxWsEnemyActivationDistance(uint16 v) { + /* Custom mode can prepare the helicopter's art independently. Its entrance + * is vertical, from above the screen, so retain the native descent trigger + * even when the ordinary enemy scan has already allocated its controller. */ + if (g_mmx_custom_renderer) return v; int m = MmxWsSpawnWide() ? MmxWsMargin() : 0; if (m) m += 32; return (uint16)(v + m); @@ -1317,18 +1321,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 c5feec1..ab64756 100644 --- a/src/mmx_wide_policy.c +++ b/src/mmx_wide_policy.c @@ -1,5 +1,12 @@ #include "mmx_wide_policy.h" +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; diff --git a/src/mmx_wide_policy.h b/src/mmx_wide_policy.h index bb2ca3e..489b7e9 100644 --- a/src/mmx_wide_policy.h +++ b/src/mmx_wide_policy.h @@ -4,6 +4,8 @@ #include #include +uint8_t MmxWidePolicy_CrusherTileBase(const uint8_t ram[0x20000], uint16_t object, uint8_t base); + /* 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 diff --git a/src/mods/mmx_widescreen_plugin.c b/src/mods/mmx_widescreen_plugin.c index 2f6b2c9..5d6dcad 100644 --- a/src/mods/mmx_widescreen_plugin.c +++ b/src/mods/mmx_widescreen_plugin.c @@ -12,6 +12,7 @@ */ static void mmx_widescreen_reset(void) { g_mmx_custom_renderer = false; + g_mmx_expanded_sprites = false; MmxRendererReset(); MmxDisplay_SetWidescreenEnabled(false); } @@ -20,6 +21,7 @@ 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; @@ -27,6 +29,7 @@ static void mmx_widescreen_activate(void) { 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; diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c index 9423f0b..5570bc5 100644 --- a/tests/mmx_renderer_test.c +++ b/tests/mmx_renderer_test.c @@ -1,4 +1,5 @@ #include "mmx_renderer.h" +#include "mmx_render_assets.h" #include static uint8_t ram[0x20000], rom_bytes[0x100000]; @@ -53,13 +54,108 @@ static void sprite_coordinates(void) { 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(MmxRendererGetStats().margin_sprite_pixels == 128); + 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; } -int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); return 0; } +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; + 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 margin_palette_transition(void) { + memset(ram, 0, sizeof(ram)); memset(rom_bytes, 0, sizeof(rom_bytes)); + put_word(0x1e4d, 0x7c2); + rom_word(0x282c2, 0x9000); + rom_bytes[0x29000] = 0x42; rom_bytes[0x29001] = 2; + rom_bytes[0x29004] = 0x17; rom_bytes[0x29005] = 1; + rom_word(0x29006, 0x8850); rom_bytes[0x29008] = 0x42; + rom_word(0x32260, 0x20); 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); } + MmxRenderAssetsSetRom(NULL, 0); MmxRenderAssetsSetRom(rom_bytes, sizeof(rom_bytes)); + uint16_t colors[128]; bool changed[128]; + MmxRenderAssetsMarginPalette(ram, 0, colors, changed); + for (int i = 0; i < 128; ++i) assert(!changed[i]); + MmxRenderAssetsMarginPalette(ram, 96, colors, changed); + for (int i = 0; i < 128; ++i) { + assert(changed[i] == (i >= 0x70)); + if (i >= 0x70) assert(colors[i] == i - 0x70 + 16); + } + /* Backtracking projects the previous palette; the live phase stays put. */ + put_word(0x1e4d, 0x900); ram[0x1f0a] = 1; + MmxRenderAssetsMarginPalette(ram, -96, colors, changed); + for (int i = 0x70; i < 128; ++i) assert(changed[i] && colors[i] == i - 0x70); + assert(ram[0x1f0a] == 1 && ram[0x1e4e] == 9); +} +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); + MmxRenderAssetsSetRom(NULL, 0); + assert(!MmxRenderAssetsSprite(0, 0, 7)); +} +int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); expanded_capacity(); margin_palette_transition(); resource_decode(); return 0; } diff --git a/tools/apply_renderer_capture.py b/tools/apply_renderer_capture.py index 4071479..4168010 100644 --- a/tools/apply_renderer_capture.py +++ b/tools/apply_renderer_capture.py @@ -22,6 +22,13 @@ def inject(match): # 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(): diff --git a/tools/mmx_render_capture.c b/tools/mmx_render_capture.c index 0241377..a047ec8 100644 --- a/tools/mmx_render_capture.c +++ b/tools/mmx_render_capture.c @@ -29,14 +29,21 @@ int main(int argc, char **argv) { !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} : 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(); - printf("{\"width\":%d,\"native_differences\":%u,\"custom_lines\":%u,\"fallback_lines\":%u,\"pieces\":%u,\"margin_sprite_pixels\":%u}\n", - view.width, differences, stats.custom_lines, stats.fallback_lines, stats.pieces, stats.margin_sprite_pixels); + 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); diff --git a/tools/test_renderer_captures.py b/tools/test_renderer_captures.py index 7e7b55b..de9ee9c 100644 --- a/tools/test_renderer_captures.py +++ b/tools/test_renderer_captures.py @@ -18,7 +18,9 @@ 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 - count = struct.unpack_from('> 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(' Date: Mon, 14 Sep 2026 01:37:07 -0700 Subject: [PATCH 03/13] Keep Bee Blader arena timing native and repair its rotor palette --- CMakeLists.txt | 2 ++ docs/custom-renderer-spike.md | 61 +++++++++++++++++++++++++++++++++-- src/mmx_render_assets.c | 27 ++++++++++++++++ src/mmx_render_assets.h | 4 ++- src/mmx_renderer.c | 9 +++--- src/mmx_rtl.c | 19 ++++++----- src/mmx_wide_policy.c | 35 +++++++++++++++++++- src/mmx_wide_policy.h | 5 +++ tests/mmx_renderer_test.c | 27 ++++++++++++++++ tests/mmx_wide_policy_test.c | 28 ++++++++++++++++ tools/apply_overrides.py | 11 ++++--- 11 files changed, 207 insertions(+), 21 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 64274c5..64769fa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -130,6 +130,8 @@ function(add_mmx_variant TARGET GEN_DIR CFG_INCLUDE_DIR) # 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) add_custom_target(${TARGET}_widescreen_overrides COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/apply_overrides.py diff --git a/docs/custom-renderer-spike.md b/docs/custom-renderer-spike.md index 4f74f38..0a00ab2 100644 --- a/docs/custom-renderer-spike.md +++ b/docs/custom-renderer-spike.md @@ -95,8 +95,9 @@ mid-boss controller and kinds 0–2 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's vertical descent keeps its native player-distance trigger; -allocating its controller in the wider scan no longer advances that entrance. +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. @@ -121,12 +122,13 @@ captured frames did not exhaust the retail submission budget. 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. -- A controlled bee-boundary test (`mmx-render-ks0qm66s`) uses copies of F3 +- 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 @@ -141,6 +143,59 @@ agreement by itself never establishes that a guest graphics binding is valid. `MMX_RENDER_OBJECT_TRACE=` optionally records Highway bee state transitions with player/enemy positions for timing investigations. +## 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. + ## Validation recorded on 2026-09-13/14 - Windows Release CMake build and all three CTests pass. Assertions remain diff --git a/src/mmx_render_assets.c b/src/mmx_render_assets.c index 24ef571..c18353f 100644 --- a/src/mmx_render_assets.c +++ b/src/mmx_render_assets.c @@ -90,6 +90,7 @@ static void stage_assets(unsigned stage, unsigned section) { 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; ready[id] = tiles(id, a->tiles) && palette(pal, a->colors) ? 1 : 2; } } @@ -109,6 +110,32 @@ const MmxSpriteAsset *MmxRenderAssetsSprite(unsigned stage, unsigned section, un 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; + /* 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; + } + } + return MmxRenderAssetsSprite(ram[0x1f7a], ram[0x1f08], animation); +} void MmxRenderAssetsMarginPalette(const uint8_t ram[0x20000], int extra, uint16_t colors[128], bool changed[128]) { memset(changed, 0, 128 * sizeof(*changed)); diff --git a/src/mmx_render_assets.h b/src/mmx_render_assets.h index a155891..1347e02 100644 --- a/src/mmx_render_assets.h +++ b/src/mmx_render_assets.h @@ -7,11 +7,13 @@ typedef struct MmxSpriteAsset { uint8_t tiles[8192]; uint16_t colors[16]; uint8_t id, tile_base, attributes; - bool current; + bool current, live_tiles; } 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); /* ROM-authored palette transitions for the extra view; never touch CGRAM. */ void MmxRenderAssetsMarginPalette(const uint8_t ram[0x20000], int extra, uint16_t colors[128], bool changed[128]); diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index b87606b..d14d291 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -331,10 +331,10 @@ static void sprite(const Ppu *p, const Raster *r, int x, int sy, unsigned attr, 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 ? raw_tile : attr & 255; + 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) { + 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) | @@ -400,7 +400,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { 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 = MmxRenderAssetsSprite(frame.ram[0x1f7a], frame.ram[0x1f08], s->animation); + 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) || @@ -437,7 +437,8 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { replaced[slot] = true; center = true; } } - unsigned attr = asset ? (s.attr & 0xd000) | 0x2000 | ((asset->attributes & 15) << 8) : s.attr; + unsigned attr = asset ? (s.attr & 0xd000) | 0x2000 | ((asset->attributes & 15) << 8) | + (asset->live_tiles ? s.attr & 255 : 0) : s.attr; sprite(&p, r, s.x, s.y, attr, s.size, y, view, objects, !center, asset, s.tile, object_colors, true); } int bar_first = -1, bar_count = 0; diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c index 47e33af..f195152 100644 --- a/src/mmx_rtl.c +++ b/src/mmx_rtl.c @@ -954,8 +954,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. */ @@ -1082,7 +1083,7 @@ 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); @@ -1095,13 +1096,15 @@ void MmxWsSpawnRunNativePass(CpuState *cpu) { * 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) { - /* Custom mode can prepare the helicopter's art independently. Its entrance - * is vertical, from above the screen, so retain the native descent trigger - * even when the ordinary enemy scan has already allocated its controller. */ - if (g_mmx_custom_renderer) return 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); } diff --git a/src/mmx_wide_policy.c b/src/mmx_wide_policy.c index ab64756..8af4aaa 100644 --- a/src/mmx_wide_policy.c +++ b/src/mmx_wide_policy.c @@ -1,5 +1,35 @@ #include "mmx_wide_policy.h" +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_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; @@ -69,7 +99,10 @@ bool MmxWidePolicy_SpawnRecordAllowed(uint8_t stage, uint8_t 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) + /* Highway's Bee Blader also starts an arena camera push in its init, + * before its separate descent state. It must not initialize in a margin. */ + if ((stage == 0x06 && kind == 3 && object_id == 0x03) || + (stage == 0x00 && kind == 3 && object_id == 0x22)) 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 489b7e9..a5737dd 100644 --- a/src/mmx_wide_policy.h +++ b/src/mmx_wide_policy.h @@ -6,6 +6,11 @@ uint8_t MmxWidePolicy_CrusherTileBase(const uint8_t ram[0x20000], uint16_t object, uint8_t base); +/* 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 diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c index 5570bc5..7a5777c 100644 --- a/tests/mmx_renderer_test.c +++ b/tests/mmx_renderer_test.c @@ -155,6 +155,33 @@ static void resource_decode(void) { 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); + + /* 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; + 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; MmxRenderAssetsSetRom(NULL, 0); assert(!MmxRenderAssetsSprite(0, 0, 7)); } diff --git a/tests/mmx_wide_policy_test.c b/tests/mmx_wide_policy_test.c index 2bf4a6b..b4297b9 100644 --- a/tests/mmx_wide_policy_test.c +++ b/tests/mmx_wide_policy_test.c @@ -1,6 +1,31 @@ #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_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] = { @@ -61,6 +86,8 @@ 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)); /* Highway traffic remains eligible in both passes. */ assert(MmxWidePolicy_SpawnRecordAllowed(0x00, 1, 0x21, false)); @@ -81,5 +108,6 @@ int main(void) { test_non_door_stack(); test_spawn_cursors_are_independent(); test_spawn_record_ownership(); + test_bee_camera_and_descent(); return 0; } diff --git a/tools/apply_overrides.py b/tools/apply_overrides.py index 711fae2..493ab6d 100644 --- a/tools/apply_overrides.py +++ b/tools/apply_overrides.py @@ -632,8 +632,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 +713,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 +721,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 @@ -1043,7 +1044,9 @@ 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) + if marker in contents and not stale_activation: effective_counts[marker] = ( effective_counts.get(marker, 0) + contents.count(marker)) already += 1 From fb2f329647131fd73c877494c8e9fba3926d4069 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:59:33 -0700 Subject: [PATCH 04/13] Fix wide background ownership, overlays, flyer approach and Ride Armor --- CMakeLists.txt | 2 + docs/custom-renderer-spike.md | 88 ++++++++++++++++++++++++++- src/mmx_render_assets.c | 103 +++++++++++++++++++++++--------- src/mmx_render_assets.h | 13 +++- src/mmx_renderer.c | 74 +++++++++++++++-------- src/mmx_rtl.c | 25 ++++++-- src/mmx_wide_policy.c | 34 +++++++++++ src/mmx_wide_policy.h | 5 ++ tests/mmx_renderer_test.c | 102 ++++++++++++++++++++++++------- tests/mmx_wide_policy_test.c | 34 +++++++++++ tools/apply_overrides.py | 47 +++++++++++++++ tools/mmx_render_capture.c | 3 +- tools/test_renderer_captures.py | 18 +++--- 13 files changed, 457 insertions(+), 91 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 64769fa..74842af 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -132,6 +132,8 @@ function(add_mmx_variant TARGET GEN_DIR CFG_INCLUDE_DIR) 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 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 diff --git a/docs/custom-renderer-spike.md b/docs/custom-renderer-spike.md index 0a00ab2..b4b4a6b 100644 --- a/docs/custom-renderer-spike.md +++ b/docs/custom-renderer-spike.md @@ -1,5 +1,20 @@ # Mega Man X custom renderer spike +## Current owner playtest checklist (2026-09-14 morning) + +- [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`. @@ -140,8 +155,9 @@ 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 state -transitions with player/enemy positions for timing investigations. +`MMX_RENDER_OBJECT_TRACE=` optionally records Highway bee, Chill +Penguin 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 @@ -196,6 +212,74 @@ Validation artifacts under `build-custom/validation`: 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 retain +their live resources until their vertical/encounter phase rules are verified. + +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. + ## Validation recorded on 2026-09-13/14 - Windows Release CMake build and all three CTests pass. Assertions remain diff --git a/src/mmx_render_assets.c b/src/mmx_render_assets.c index c18353f..b227932 100644 --- a/src/mmx_render_assets.c +++ b/src/mmx_render_assets.c @@ -8,6 +8,10 @@ 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); } @@ -17,6 +21,7 @@ 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]; @@ -113,6 +118,13 @@ const MmxSpriteAsset *MmxRenderAssetsSprite(unsigned stage, unsigned section, un const MmxSpriteAsset *MmxRenderAssetsObjectSprite(const uint8_t ram[0x20000], unsigned object, unsigned animation) { if (!ram) 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 @@ -136,38 +148,75 @@ const MmxSpriteAsset *MmxRenderAssetsObjectSprite(const uint8_t ram[0x20000], } return MmxRenderAssetsSprite(ram[0x1f7a], ram[0x1f08], animation); } -void MmxRenderAssetsMarginPalette(const uint8_t ram[0x20000], int extra, - uint16_t colors[128], bool changed[128]) { - memset(changed, 0, 128 * sizeof(*changed)); - unsigned stage = ram[0x1f7a], phase = ram[0x1f0a]; - if (!extra || stage >= 13 || !range(0x32260, 32)) return; - /* Highway's city layer scrolls at half the camera speed. An extra BG2 - * column becomes native after twice that camera travel, so its palette - * lookahead must use the same parallax scale as the retained map. */ - int camera = (int)ram_word(ram, 0x1e4d), projected = camera + extra * (stage == 0 ? 2 : 1); - size_t pos = 0x28000 + (word(0x282c2 + stage * 2) & 0x7fff); - if (!range(pos, 1)) return; +/* Highway's kind-2 $16/$17 records select BG CHR/palette phases at X + * boundaries. Other stages can use vertical switches or boss-state offsets; + * keep their live resources until their ownership has been established. */ +static bool prepare_background(const uint8_t *ram) { + unsigned stage = ram[0x1f7a]; + if (stage != 0 || !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) & 0x7fff); + if (!range(pos, 1)) return false; unsigned column = rom[pos++]; - int nearest = extra > 0 ? camera : projected - 1; for (unsigned guard = 0; guard < 512 && range(pos, 8); ++guard) { - unsigned x = word(pos + 5), kind = rom[pos] & 15, event = rom[pos + 3]; - int line = (int)(x & 0x7fff); - if (kind == 2 && event == 0x17 && - (extra > 0 ? line > camera && line <= projected && line >= nearest : - line > projected && line <= camera && line > nearest)) { - phase = extra > 0 ? rom[pos + 4] & 15 : rom[pos + 4] >> 4; - nearest = line; - if (extra < 0) break; + unsigned x = word(pos + 5), event = rom[pos + 3]; + if ((rom[pos] & 15) == 2 && (event == 0x16 || event == 0x17)) { + unsigned line = x & 0x7fff; + 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++]; } } - if (phase == ram[0x1f0a]) return; - size_t base = 0x32260, p = base + word(base + word(base + stage * 2) + phase * 2); - 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]; - if (first + 16 > 128 || !range(source, 32)) continue; - for (unsigned i = 0; i < 16; ++i) { colors[first + i] = (uint16_t)word(source + i * 2); changed[first + i] = true; } + return true; +} +static size_t background_list(size_t base, unsigned phase) { + unsigned start = word(base), end = word(base + 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 || 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; +} +const MmxBackgroundPalette *MmxRenderAssetsBackgroundPalette(const uint8_t ram[0x20000], + int world_x) { + if (!ram || world_x < 0 || world_x >= 8192 || !prepare_background(ram)) return NULL; + unsigned phase = bg_phase[1][world_x]; + 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]; + 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]; } diff --git a/src/mmx_render_assets.h b/src/mmx_render_assets.h index 1347e02..e4dba4e 100644 --- a/src/mmx_render_assets.h +++ b/src/mmx_render_assets.h @@ -14,6 +14,13 @@ 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); -/* ROM-authored palette transitions for the extra view; never touch CGRAM. */ -void MmxRenderAssetsMarginPalette(const uint8_t ram[0x20000], int extra, - uint16_t colors[128], bool changed[128]); +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); diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index d14d291..54c478e 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -163,16 +163,19 @@ static void trace_objects(const uint8_t *ram) { 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\n", log); + if (log) fputs("frame,object,state,camera,player_x,enemy_x,enemy_y,pieces,stage,id,health,player_y\n", log); } - if (!log || ram[0x1f7a] != 0) return; - for (unsigned i = 0; i < 15; ++i) { - unsigned d = 0xe68 + i * 64; - unsigned state = ram[d] && ram[d + 10] == 0x22 ? ram[d + 1] + 1u : 0; - if (state == previous[i]) continue; + unsigned stage = ram[0x1f7a]; + if (!log || (stage != 0 && stage != 8)) return; + for (unsigned i = 0; i < 16; ++i) { + unsigned d = i == 15 ? 0xe18 : 0xe68 + i * 64; + bool selected = i == 15 ? stage == 8 : ram[d + 10] == (stage == 0 ? 0x22 : 0x36); + 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\n", tick, d, (int)state - 1, - word(ram, 0x1e4d), word(ram, 0xbad), word(ram, d + 5), word(ram, d + 8), latched_count); + 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); } } @@ -262,8 +265,13 @@ static unsigned tile_pixel(const uint16_t *vram, unsigned address, int x, int y, pixel |= ((bits & 1) << 2) | ((bits >> 5) & 8); } return pixel; } -static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, int y, bool stage) { +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; + /* Stage BG3 is a screen-space overlay (dialogue), not a repeating world + * map. Keep its original scroll/window processing inside the native view. */ + if (stage && layer == 2 && margin) return 0; + int asset_x = -1; 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; @@ -281,6 +289,10 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, int stream_x = word(frame.ram, 0x1e8d), stream_y = 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; + /* 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. */ @@ -305,16 +317,34 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, } } uint16_t mapped; - if (MmxRendererStageTile(frame.ram, layer, wx, wy, &mapped)) { tile = mapped; px = wx; py = wy; } + 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; + } } 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 pixel = tile_pixel(r->vram, PPU_bgTileAdr(p, layer) + number * bpp * 4, cx & 7, cy & 7, bpp); + unsigned address = (PPU_bgTileAdr(p, layer) + number * bpp * 4) & 0x7fff; + const uint8_t *bits = g_mmx_render_asset_repairs && bpp == 4 ? + 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 ? + 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) | (((tile >> 10) & 7) << bpp) | pixel); + 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, @@ -362,9 +392,10 @@ static bool window(const Ppu *p, int layer, int x, int extra) { } } 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) { +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; @@ -373,6 +404,7 @@ static uint32_t colour(const Ppu *p, const uint16_t *palette, const uint8_t brig 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; @@ -394,7 +426,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { memset(&stats, 0, sizeof(stats)); stats.pieces = frame.piece_count; memset(door_cache, 0, sizeof(door_cache)); memset(out, 0, (size_t)view.width * 224 * sizeof(*out)); - bool stage = frame.ram[0xd1] == 2 && frame.ram[0xd2] == 4; + bool stage = MmxWidePolicy_IsStageScene(frame.ram); 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}; @@ -406,9 +438,6 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { if (a && (!a->current || (s->attr & 255) != ((s->tile + a->tile_base) & 255) || ((s->attr >> 8) & 0x2f) != (unsigned)(a->attributes | s->palette_bits))) piece_assets[i] = a; } - uint16_t margin_colors[2][128]; bool margin_changed[2][128] = {{false}}; - if (stage && g_mmx_render_asset_repairs) for (int side = 0; side < 2; ++side) - MmxRenderAssetsMarginPalette(frame.ram, side ? view.extra : -view.extra, margin_colors[side], margin_changed[side]); for (int y = 0; y < 224; ++y) { const Raster *r = &frame.lines[y]; Ppu p; memcpy(&p, r->registers, PPU_SAVESTATE_REGS_SIZE); @@ -463,19 +492,15 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { 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); } - uint16_t palettes[2][256]; - for (int side = 0; side < 2; ++side) { - memcpy(palettes[side], r->palette, sizeof(r->palette)); - for (int i = 0; i < 128; ++i) if (margin_changed[side][i]) palettes[side][i] = margin_colors[side][i]; - } 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[layer] = background(&p, r, layer, bx, by, stage, &bg_colors[layer]); } for (int sub = 0; sub < 2; ++sub) { for (int layer = 0; layer < 3; ++layer) @@ -484,8 +509,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { if ((p.screenEnabled[sub] & 16) && (!(p.screenWindowed[sub] & 16) || !window(&p, 4, x, view.extra)) && objects[sx] > screens[sub]) screens[sub] = objects[sx]; } - const uint16_t *pal = x < 0 ? palettes[0] : x >= 256 ? palettes[1] : r->palette; - out[y * view.width + sx] = colour(&p, pal, brightness, screens[0], screens[1], window(&p, 5, x, view.extra), object_colors[sx]); + out[y * view.width + sx] = colour(&p, r->palette, brightness, screens[0], screens[1], window(&p, 5, x, view.extra), object_colors[sx], bg_colors); } } return true; diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c index f195152..f2bb554 100644 --- a/src/mmx_rtl.c +++ b/src/mmx_rtl.c @@ -386,8 +386,11 @@ void MmxStateLoadExtra(struct SaveLoadInfo *sli, uint32_t version) { 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 (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 @@ -796,6 +799,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 @@ -858,7 +865,7 @@ static int MmxWsMargin(void) { extern uint8_t g_ram[0x20000]; if ((!g_ws_active && !g_mmx_custom_renderer) || g_ram[0xD1] != 0x02 || g_ram[0xD2] != 0x04) return 0; - if (g_mmx_custom_renderer) return (g_mmx_custom_view.extra + 7) & ~7; + if (g_mmx_custom_renderer) return MmxWidePolicy_IsStageScene(g_ram) ? (g_mmx_custom_view.extra + 7) & ~7 : 0; return (g_ws_extra + 7) & ~7; } @@ -880,6 +887,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 @@ -902,13 +916,14 @@ 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; + if (!m || (!traffic && !armor)) return v >= 0x1c0 ? 1 : 0; return ((uint16)(v + m) >= (uint16)(0x1c0 + 2 * m)) ? 1 : 0; } diff --git a/src/mmx_wide_policy.c b/src/mmx_wide_policy.c index 8af4aaa..57847ab 100644 --- a/src/mmx_wide_policy.c +++ b/src/mmx_wide_policy.c @@ -1,11 +1,45 @@ #include "mmx_wide_policy.h" +bool MmxWidePolicy_IsStageScene(const uint8_t ram[0x20000]) { + /* D1/D2 survive the transition to the password screen; D3 selects the + * actual stage loop ($04), password ($0A), and other menu substates. */ + return ram && ram[0xd1] == 2 && ram[0xd2] == 4 && ram[0xd3] == 4; +} + 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 || diff --git a/src/mmx_wide_policy.h b/src/mmx_wide_policy.h index a5737dd..44c73b2 100644 --- a/src/mmx_wide_policy.h +++ b/src/mmx_wide_policy.h @@ -5,6 +5,11 @@ #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. */ diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c index 7a5777c..15f6006 100644 --- a/tests/mmx_renderer_test.c +++ b/tests/mmx_renderer_test.c @@ -22,7 +22,7 @@ static void geometry(void) { } static void raster_and_hud(void) { memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); - ram[0xd1] = 2; ram[0xd2] = 4; + 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; @@ -43,7 +43,7 @@ static void raster_and_hud(void) { } static void sprite_coordinates(void) { memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); - ram[0xd1] = 2; ram[0xd2] = 4; + 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; @@ -74,7 +74,7 @@ static void rom_word(unsigned a, unsigned v) { rom_bytes[a] = (uint8_t)v; rom_by 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[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; } @@ -108,31 +108,85 @@ static void expanded_capacity(void) { } g_mmx_custom_renderer = g_mmx_expanded_sprites = false; } -static void margin_palette_transition(void) { +static void background_resources(void) { memset(ram, 0, sizeof(ram)); memset(rom_bytes, 0, sizeof(rom_bytes)); - put_word(0x1e4d, 0x7c2); rom_word(0x282c2, 0x9000); rom_bytes[0x29000] = 0x42; rom_bytes[0x29001] = 2; rom_bytes[0x29004] = 0x17; rom_bytes[0x29005] = 1; - rom_word(0x29006, 0x8850); rom_bytes[0x29008] = 0x42; - rom_word(0x32260, 0x20); rom_word(0x32280, 0x30); rom_word(0x32282, 0x40); + 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)); - uint16_t colors[128]; bool changed[128]; - MmxRenderAssetsMarginPalette(ram, 0, colors, changed); - for (int i = 0; i < 128; ++i) assert(!changed[i]); - MmxRenderAssetsMarginPalette(ram, 96, colors, changed); + assert(MmxRenderAssetsBackgroundPalette(ram, 0x84f)->colors[0x71] == 1); + const MmxBackgroundPalette *pal = MmxRenderAssetsBackgroundPalette(ram, 0x850); for (int i = 0; i < 128; ++i) { - assert(changed[i] == (i >= 0x70)); - if (i >= 0x70) assert(colors[i] == i - 0x70 + 16); + assert(pal->valid[i] == (i >= 0x70)); + if (i >= 0x70) assert(pal->colors[i] == i - 0x70 + 16); } - /* Backtracking projects the previous palette; the live phase stays put. */ - put_word(0x1e4d, 0x900); ram[0x1f0a] = 1; - MmxRenderAssetsMarginPalette(ram, -96, colors, changed); - for (int i = 0x70; i < 128; ++i) assert(changed[i] && colors[i] == i - 0x70); - assert(ram[0x1f0a] == 1 && ram[0x1e4e] == 9); + 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); + ram[0x1f7a] = 8; + assert(!MmxRenderAssetsBackgroundPalette(ram, 0x84f)); +} +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)); + 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 resource_decode(void) { memset(rom_bytes, 0, sizeof(rom_bytes)); @@ -169,7 +223,7 @@ static void resource_decode(void) { 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[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; @@ -182,7 +236,15 @@ static void resource_decode(void) { 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)); MmxRenderAssetsSetRom(NULL, 0); assert(!MmxRenderAssetsSprite(0, 0, 7)); } -int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); expanded_capacity(); margin_palette_transition(); resource_decode(); return 0; } +int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); expanded_capacity(); background_resources(); dialogue_and_password(); highway_arena_sky(); resource_decode(); return 0; } diff --git a/tests/mmx_wide_policy_test.c b/tests/mmx_wide_policy_test.c index b4297b9..923b471 100644 --- a/tests/mmx_wide_policy_test.c +++ b/tests/mmx_wide_policy_test.c @@ -6,6 +6,39 @@ 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; @@ -109,5 +142,6 @@ int main(void) { test_spawn_cursors_are_independent(); test_spawn_record_ownership(); 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 493ab6d..c9ecd26 100644 --- a/tools/apply_overrides.py +++ b/tools/apply_overrides.py @@ -265,6 +265,7 @@ 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-OAM*/", "/*WS-OAM-L*/", "/*WS-LOOKAHEAD*/", "/*WS-STAGE*/", "/*WS-SHADOW*/", "/*WS-CHRBIND*/", "/*WS-CHRBIND-COPY*/", "/*WS-CHRBIND-PARENT*/", "/*MSU1-MUSIC*/", "/*MSU1-STAGE*/") @@ -742,6 +743,45 @@ 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_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 \+ " @@ -1015,6 +1055,8 @@ 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_ride_armor_cull, "/*WS-ARMOR-CULL*/"), (apply_bank03, "/*WS-STAGE*/"), (apply_chrbind_generic, "/*WS-CHRBIND*/"), (apply_chrbind_copy_generic, "/*WS-CHRBIND-COPY*/"), @@ -1075,6 +1117,11 @@ def main(): file=sys.stderr) return 1 chrbind_found = effective_counts.get("/*WS-CHRBIND*/", 0) + if not args.restore: + for marker in ("/*WS-FLYER-LEASH*/", "/*WS-ARMOR-CULL*/"): + if effective_counts.get(marker, 0) != 1: + print(f"ERROR: expected exactly 1 {marker} hook, 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/mmx_render_capture.c b/tools/mmx_render_capture.c index a047ec8..55e6577 100644 --- a/tools/mmx_render_capture.c +++ b/tools/mmx_render_capture.c @@ -27,7 +27,8 @@ int main(int argc, char **argv) { 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} : MmxRendererViewport(aspect, 16, 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; diff --git a/tools/test_renderer_captures.py b/tools/test_renderer_captures.py index de9ee9c..df9998e 100644 --- a/tools/test_renderer_captures.py +++ b/tools/test_renderer_captures.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Capture isolated MMX save fixtures and replay each at four aspect ratios. +"""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 @@ -64,7 +64,7 @@ def main(): p.add_argument('--artifacts', type=Path, required=True) p.add_argument('--frames', type=int, default=120) p.add_argument('--script', type=Path) - p.add_argument('--aspect', choices=['adaptive', '16:9', '21:9', '32:9'], default='32:9') + p.add_argument('--aspect', choices=['adaptive', '16:9', '21:9', '32:9', 'max'], default='32:9') p.add_argument('--renderer', choices=['custom', 'legacy', 'off'], default='custom') p.add_argument('--expanded-sprites', action='store_true') args = p.parse_args() @@ -102,7 +102,7 @@ def main(): aspect = "32:9" hud = "edges" expanded_sprites = "off" -'''.replace('aspect = "32:9"', f'aspect = "{args.aspect}"') +'''.replace('aspect = "32:9"', f'aspect = "{"adaptive" if args.aspect == "max" else args.aspect}"') .replace('expanded_sprites = "off"', f'expanded_sprites = "{"on" if args.expanded_sprites else "off"}"') .replace('renderer = "custom"', f'renderer = "{args.renderer if args.renderer != "off" else "custom"}"') .replace('enabled = true', f'enabled = {"false" if args.renderer == "off" else "true"}')) @@ -118,7 +118,7 @@ def main(): DisplayAspect = 4:3 [Sound] EnableAudio = 0 -''') +'''.replace('WindowScale = 1', 'WindowScale = 1\nWindowSize = 2048x300' if args.aspect == 'max' else 'WindowScale = 1')) (folder / 'input.txt').write_text(args.script.read_text() if args.script else 'wait 30\nloadstate 0\n') with (folder / 'stdout.log').open('wb') as out, (folder / 'stderr.log').open('wb') as err: result = subprocess.run([str(exe), '--config', 'config.ini', '--script', 'input.txt', @@ -132,7 +132,7 @@ def main(): bitmap = (folder / 'final.bmp').read_bytes() width, height = struct.unpack_from(' Date: Mon, 14 Sep 2026 10:55:47 -0700 Subject: [PATCH 05/13] Fix Chill palette transitions, distant doors and stage lifecycle width --- docs/custom-renderer-spike.md | 75 ++++++++++++++++++++++++++++++--- src/main.c | 10 ++++- src/mmx_render_assets.c | 67 ++++++++++++++++++++++++----- src/mmx_render_assets.h | 3 ++ src/mmx_renderer.c | 32 +++++++++++--- src/mmx_wide_policy.c | 12 +++--- tests/mmx_renderer_test.c | 63 ++++++++++++++++++++++++++- tests/mmx_wide_policy_test.c | 8 ++++ tools/test_renderer_captures.py | 2 +- 9 files changed, 240 insertions(+), 32 deletions(-) diff --git a/docs/custom-renderer-spike.md b/docs/custom-renderer-spike.md index b4b4a6b..da3948e 100644 --- a/docs/custom-renderer-spike.md +++ b/docs/custom-renderer-spike.md @@ -1,6 +1,14 @@ # Mega Man X custom renderer spike -## Current owner playtest checklist (2026-09-14 morning) +## Current owner playtest checklist (2026-09-14, 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. @@ -100,8 +108,8 @@ 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 skips the authored pair in host margins while retaining native -scripted doors. +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 @@ -156,7 +164,7 @@ 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 flyer, and Ride Armor states with player/enemy positions. It logs +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 @@ -219,8 +227,9 @@ 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 retain -their live resources until their vertical/encounter phase rules are verified. +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 @@ -280,6 +289,60 @@ Evidence in `build-custom/validation`: 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. + ## Validation recorded on 2026-09-13/14 - Windows Release CMake build and all three CTests pass. Assertions remain diff --git a/src/main.c b/src/main.c index eb85500..b2d76cd 100644 --- a/src/main.c +++ b/src/main.c @@ -460,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; diff --git a/src/mmx_render_assets.c b/src/mmx_render_assets.c index b227932..fc71b0c 100644 --- a/src/mmx_render_assets.c +++ b/src/mmx_render_assets.c @@ -148,24 +148,41 @@ const MmxSpriteAsset *MmxRenderAssetsObjectSprite(const uint8_t ram[0x20000], } return MmxRenderAssetsSprite(ram[0x1f7a], ram[0x1f08], animation); } -/* Highway's kind-2 $16/$17 records select BG CHR/palette phases at X - * boundaries. Other stages can use vertical switches or boss-state offsets; - * keep their live resources until their ownership has been established. */ +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; +} +/* Highway and Chill Penguin's kind-2 $17 records select palettes at X + * boundaries. Chill's CHR also switches vertically, so only Highway gets + * the horizontal CHR projection below. */ static bool prepare_background(const uint8_t *ram) { unsigned stage = ram[0x1f7a]; - if (stage != 0 || !range(0x32280, 2)) return false; + if ((stage != 0 && 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) & 0x7fff); + 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; @@ -174,13 +191,13 @@ static bool prepare_background(const uint8_t *ram) { return true; } static size_t background_list(size_t base, unsigned phase) { - unsigned start = word(base), end = word(base + 2); + 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 || world_x < 0 || world_x >= 8192 || vram_word >= 0x8000 || !prepare_background(ram)) return NULL; + if (!ram || ram[0x1f7a] != 0 || 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. */ @@ -200,10 +217,7 @@ const uint8_t *MmxRenderAssetsBackgroundTile(const uint8_t ram[0x20000], } return bg_chr_valid[phase][vram_word / 16] ? bg_chr[phase] + vram_word * 2 : NULL; } -const MmxBackgroundPalette *MmxRenderAssetsBackgroundPalette(const uint8_t ram[0x20000], - int world_x) { - if (!ram || world_x < 0 || world_x >= 8192 || !prepare_background(ram)) return NULL; - unsigned phase = bg_phase[1][world_x]; +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])); @@ -211,6 +225,8 @@ const MmxBackgroundPalette *MmxRenderAssetsBackgroundPalette(const uint8_t ram[0 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); @@ -220,3 +236,32 @@ const MmxBackgroundPalette *MmxRenderAssetsBackgroundPalette(const uint8_t ram[0 } return &bg_palette[phase]; } +const MmxBackgroundPalette *MmxRenderAssetsBackgroundPalette(const uint8_t ram[0x20000], + int world_x) { + if (!ram || world_x < 0 || world_x >= 8192 || !prepare_background(ram)) return NULL; + return background_palette(bg_phase[1][world_x]); +} +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 index e4dba4e..4c323c8 100644 --- a/src/mmx_render_assets.h +++ b/src/mmx_render_assets.h @@ -14,6 +14,9 @@ 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]; diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index 54c478e..6866ead 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -169,7 +169,8 @@ static void trace_objects(const uint8_t *ram) { if (!log || (stage != 0 && stage != 8)) return; for (unsigned i = 0; i < 16; ++i) { unsigned d = i == 15 ? 0xe18 : 0xe68 + i * 64; - bool selected = i == 15 ? stage == 8 : ram[d + 10] == (stage == 0 ? 0x22 : 0x36); + bool selected = i == 15 ? stage == 8 : + stage == 0 ? ram[d + 10] == 0x22 : ram[d + 10] == 0x36 || ram[d + 10] == 2; unsigned state = ram[d] && selected ? ram[d + 1] + 1u : 0; if (state == previous[i] && (!state || (tick & 15))) continue; previous[i] = state; @@ -310,10 +311,16 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, } if (stage_id == 1 && word(frame.ram, 0x1e4d) >= 0xa70 && word(frame.ram, 0x1e4d) < 0xac0 && y >= 0x50 && y < 0xb0 && wx >= 0xbc0 && wx < 0xc40) wx -= 256; - if (door_body(wx, wy) && (door_body(wx - 16, wy) || door_body(wx + 16, wy))) { - int step = x < 0 ? -16 : 16; - /* Skip the whole authored pair, retaining the native scripted door. */ - for (int i = 0; i < 2 && door_body(wx, wy); ++i) wx += step; + 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; @@ -322,6 +329,9 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, /* 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); @@ -427,6 +437,8 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { memset(door_cache, 0, sizeof(door_cache)); memset(out, 0, (size_t)view.width * 224 * sizeof(*out)); bool stage = MmxWidePolicy_IsStageScene(frame.ram); + 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}; @@ -436,7 +448,9 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { /* 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))) piece_assets[i] = a; + ((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; @@ -502,6 +516,12 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { 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)) && diff --git a/src/mmx_wide_policy.c b/src/mmx_wide_policy.c index 57847ab..75f776c 100644 --- a/src/mmx_wide_policy.c +++ b/src/mmx_wide_policy.c @@ -1,9 +1,9 @@ #include "mmx_wide_policy.h" bool MmxWidePolicy_IsStageScene(const uint8_t ram[0x20000]) { - /* D1/D2 survive the transition to the password screen; D3 selects the - * actual stage loop ($04), password ($0A), and other menu substates. */ - return ram && ram[0xd1] == 2 && ram[0xd2] == 4 && ram[0xd3] == 4; + /* $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. */ + return ram && ram[0xd1] == 2 && ram[0xd2] == 4 && ram[0xd3] <= 8 && !(ram[0xd3] & 1); } static uint16_t read_word(const uint8_t *ram, unsigned a) { @@ -134,9 +134,11 @@ bool MmxWidePolicy_SpawnRecordAllowed(uint8_t stage, uint8_t kind, * Spawning it early lets it tear itself down before the arena boundary and * the native pass then refuses it, leaving the barrier permanently closed. */ /* Highway's Bee Blader also starts an arena camera push in its init, - * before its separate descent state. It must not initialize in a margin. */ + * before its separate descent state. It must not initialize in a margin. + * Chill Penguin must wait until X has crossed the second boss door. */ if ((stage == 0x06 && kind == 3 && object_id == 0x03) || - (stage == 0x00 && kind == 3 && object_id == 0x22)) + (stage == 0x00 && kind == 3 && object_id == 0x22) || + (stage == 0x08 && kind == 3 && object_id == 0x02)) return native_pass; return native_pass ? kind != 3 : kind == 3; diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c index 15f6006..8ac3d0e 100644 --- a/tests/mmx_renderer_test.c +++ b/tests/mmx_renderer_test.c @@ -148,8 +148,28 @@ static void background_resources(void) { /* Camera travel cannot recolor the same authored column. */ put_word(0x1e4d, 0x900); assert(MmxRenderAssetsBackgroundPalette(ram, 0x84f)->colors[0x71] == 1); - ram[0x1f7a] = 8; + 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)); @@ -188,6 +208,31 @@ static void highway_arena_sky(void) { 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 resource_decode(void) { memset(rom_bytes, 0, sizeof(rom_bytes)); /* Two section lists: resource 1 is available in the future section only, @@ -244,7 +289,21 @@ static void resource_decode(void) { 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)); MmxRenderAssetsSetRom(NULL, 0); assert(!MmxRenderAssetsSprite(0, 0, 7)); } -int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); expanded_capacity(); background_resources(); dialogue_and_password(); highway_arena_sky(); resource_decode(); return 0; } +int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); expanded_capacity(); background_resources(); dialogue_and_password(); highway_arena_sky(); distant_doors(); resource_decode(); return 0; } diff --git a/tests/mmx_wide_policy_test.c b/tests/mmx_wide_policy_test.c index 923b471..f0655d0 100644 --- a/tests/mmx_wide_policy_test.c +++ b/tests/mmx_wide_policy_test.c @@ -121,6 +121,8 @@ static void test_spawn_record_ownership(void) { 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)); /* Highway traffic remains eligible in both passes. */ assert(MmxWidePolicy_SpawnRecordAllowed(0x00, 1, 0x21, false)); @@ -128,6 +130,12 @@ 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[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)); diff --git a/tools/test_renderer_captures.py b/tools/test_renderer_captures.py index df9998e..2344d3b 100644 --- a/tools/test_renderer_captures.py +++ b/tools/test_renderer_captures.py @@ -145,7 +145,7 @@ def main(): ram = memoryview(data)[12 + 224 * 66656:][:0x20000] def word(address): return struct.unpack_from(' Date: Mon, 14 Sep 2026 11:33:12 -0700 Subject: [PATCH 06/13] Fix boss effects, wide light beams and collectible visibility --- docs/custom-renderer-spike.md | 68 ++++++++++++++++++++++++++++++- src/mmx_render_assets.c | 25 ++++++++++++ src/mmx_renderer.c | 62 +++++++++++++++++++++++++++- src/mmx_rtl.c | 21 ++++++++++ src/mmx_wide_policy.c | 8 +++- tests/mmx_renderer_test.c | 76 ++++++++++++++++++++++++++++++++++- tests/mmx_wide_policy_test.c | 6 +++ 7 files changed, 261 insertions(+), 5 deletions(-) diff --git a/docs/custom-renderer-spike.md b/docs/custom-renderer-spike.md index da3948e..f7ffda2 100644 --- a/docs/custom-renderer-spike.md +++ b/docs/custom-renderer-spike.md @@ -1,6 +1,15 @@ # Mega Man X custom renderer spike -## Current owner playtest checklist (2026-09-14, fourth batch) +## Current owner playtest checklist (2026-09-14, 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. @@ -343,6 +352,63 @@ Evidence under `build-custom/validation`: 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. +- Spark's streaker/controller `$37` now belongs to the native event scan, + preserving its room entry. 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. + ## Validation recorded on 2026-09-13/14 - Windows Release CMake build and all three CTests pass. Assertions remain diff --git a/src/mmx_render_assets.c b/src/mmx_render_assets.c index fc71b0c..8b58176 100644 --- a/src/mmx_render_assets.c +++ b/src/mmx_render_assets.c @@ -118,6 +118,31 @@ const MmxSpriteAsset *MmxRenderAssetsSprite(unsigned stage, unsigned section, un const MmxSpriteAsset *MmxRenderAssetsObjectSprite(const uint8_t ram[0x20000], unsigned object, unsigned animation) { if (!ram) return 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. */ diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index 6866ead..3841c56 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -272,6 +272,10 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, /* Stage BG3 is a screen-space overlay (dialogue), not a repeating world * map. Keep its original scroll/window processing inside the native view. */ if (stage && layer == 2 && margin) 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; 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; @@ -401,6 +405,53 @@ static bool window(const Ppu *p, int layer, int x, int extra) { 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; @@ -437,6 +488,8 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { memset(door_cache, 0, sizeof(door_cache)); memset(out, 0, (size_t)view.width * 224 * sizeof(*out)); bool stage = MmxWidePolicy_IsStageScene(frame.ram); + 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; @@ -455,6 +508,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { 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; @@ -529,7 +583,13 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { if ((p.screenEnabled[sub] & 16) && (!(p.screenWindowed[sub] & 16) || !window(&p, 4, x, view.extra)) && objects[sx] > screens[sub]) screens[sub] = objects[sx]; } - out[y * view.width + sx] = colour(&p, r->palette, brightness, screens[0], screens[1], window(&p, 5, x, view.extra), object_colors[sx], bg_colors); + 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_rtl.c b/src/mmx_rtl.c index f2bb554..686d760 100644 --- a/src/mmx_rtl.c +++ b/src/mmx_rtl.c @@ -981,6 +981,7 @@ static struct { uint16 native_cursor_before; uint16 dpage; int active; + int collectibles; } s_ws_spawn_pass; static uint16 MmxWsSpawnReadCursor(uint16 dpage) { @@ -1068,6 +1069,7 @@ 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 && object_id == 0x0b; int allowed = 1; if (anchor == s_ws_spawn_pass.wide_anchor) { allowed = MmxWidePolicy_SpawnRecordAllowed( @@ -1099,6 +1101,25 @@ void MmxWsSpawnRunNativePass(CpuState *cpu) { 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, 0x00DC8Fu); + if (g_mmx_custom_renderer) { + /* A loaded narrow-view save can already expose a Heart Tank column + * behind the new leading anchor. Let the game's allocator catch up + * collectible records across the visible range; its persistent pickup + * flags remain authoritative. No enemy or scripted event is admitted. */ + int margin = MmxWsMargin(); + int camera = g_ram[0x1e4d] | (g_ram[0x1e4e] << 8); + uint8 scratch[16]; memcpy(scratch, g_ram + dpage + 0x10, sizeof(scratch)); + 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.collectibles = 0; + memcpy(g_ram + dpage + 0x10, scratch, sizeof(scratch)); + } *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); diff --git a/src/mmx_wide_policy.c b/src/mmx_wide_policy.c index 75f776c..fe442d8 100644 --- a/src/mmx_wide_policy.c +++ b/src/mmx_wide_policy.c @@ -129,14 +129,18 @@ bool MmxWidePolicy_SpawnRecordAllowed(uint8_t stage, uint8_t kind, if (stage == 0x00 && kind == 1 && object_id == 0x21) return true; + /* Heart Tanks are persistent collectibles, not camera/encounter events. */ + if (kind == 0 && object_id == 0x0b) 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. */ /* Highway's Bee Blader also starts an arena camera push in its init, * before its separate descent state. It must not initialize in a margin. - * Chill Penguin must wait until X has crossed the second boss door. */ - if ((stage == 0x06 && kind == 3 && object_id == 0x03) || + * Chill Penguin must wait until X has crossed the second boss door. + * Spark's id-$37 light controllers also retain their authored room entry. */ + if ((stage == 0x06 && kind == 3 && (object_id == 0x03 || object_id == 0x37)) || (stage == 0x00 && kind == 3 && object_id == 0x22) || (stage == 0x08 && kind == 3 && object_id == 0x02)) return native_pass; diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c index 8ac3d0e..ef37742 100644 --- a/tests/mmx_renderer_test.c +++ b/tests/mmx_renderer_test.c @@ -303,7 +303,81 @@ static void resource_decode(void) { 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)->colors[1] == 1); + 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)); MmxRenderAssetsSetRom(NULL, 0); assert(!MmxRenderAssetsSprite(0, 0, 7)); } -int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); expanded_capacity(); background_resources(); dialogue_and_password(); highway_arena_sky(); distant_doors(); resource_decode(); return 0; } +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); +} +int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); expanded_capacity(); background_resources(); dialogue_and_password(); highway_arena_sky(); distant_doors(); resource_decode(); spark_effects(); return 0; } diff --git a/tests/mmx_wide_policy_test.c b/tests/mmx_wide_policy_test.c index f0655d0..f648fb8 100644 --- a/tests/mmx_wide_policy_test.c +++ b/tests/mmx_wide_policy_test.c @@ -123,6 +123,12 @@ static void test_spawn_record_ownership(void) { 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)); + assert(MmxWidePolicy_SpawnRecordAllowed(6, 0, 0x0b, false)); + assert(MmxWidePolicy_SpawnRecordAllowed(6, 0, 0x0b, true)); + assert(!MmxWidePolicy_SpawnRecordAllowed(6, 2, 0x0b, false)); + assert(!MmxWidePolicy_SpawnRecordAllowed(6, 0, 0x05, false)); /* Highway traffic remains eligible in both passes. */ assert(MmxWidePolicy_SpawnRecordAllowed(0x00, 1, 0x21, false)); From 8511324d8fac9456be6f98051880c0d600e0c8b4 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:53:56 -0700 Subject: [PATCH 07/13] Use shared boss spawn timing and restore wide edge visibility --- docs/custom-renderer-spike.md | 62 ++++++++++++++++++++++++++++++++--- src/mmx_render_assets.c | 8 +++++ src/mmx_renderer.c | 5 +-- src/mmx_wide_policy.c | 42 +++++++++++++++++------- src/mmx_wide_policy.h | 2 ++ tests/mmx_renderer_test.c | 17 ++++++++++ tests/mmx_wide_policy_test.c | 15 +++++++-- 7 files changed, 131 insertions(+), 20 deletions(-) diff --git a/docs/custom-renderer-spike.md b/docs/custom-renderer-spike.md index f7ffda2..9f7e2b5 100644 --- a/docs/custom-renderer-spike.md +++ b/docs/custom-renderer-spike.md @@ -1,6 +1,12 @@ # Mega Man X custom renderer spike -## Current owner playtest checklist (2026-09-14, fifth batch) +## Current owner playtest checklist (2026-09-14, 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. @@ -122,8 +128,8 @@ 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. Spark Mandrill's kind-3 -mid-boss controller and kinds 0–2 keep their native scan, with independent +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. @@ -366,8 +372,9 @@ The owner's updated F2/F5/F6/F7/F9/F10 saves are copied under 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. -- Spark's streaker/controller `$37` now belongs to the native event scan, - preserving its room entry. Its color window is composed independently in +- 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 @@ -409,6 +416,51 @@ 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. + ## Validation recorded on 2026-09-13/14 - Windows Release CMake build and all three CTests pass. Assertions remain diff --git a/src/mmx_render_assets.c b/src/mmx_render_assets.c index 8b58176..a99b5f9 100644 --- a/src/mmx_render_assets.c +++ b/src/mmx_render_assets.c @@ -118,6 +118,14 @@ const MmxSpriteAsset *MmxRenderAssetsSprite(unsigned stage, unsigned section, un const MmxSpriteAsset *MmxRenderAssetsObjectSprite(const uint8_t ram[0x20000], unsigned object, unsigned animation) { if (!ram) 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. */ diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index 3841c56..ed7f415 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -166,11 +166,12 @@ static void trace_objects(const uint8_t *ram) { 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 || (stage != 0 && stage != 8)) return; + if (!log) return; for (unsigned i = 0; i < 16; ++i) { unsigned d = i == 15 ? 0xe18 : 0xe68 + i * 64; bool selected = i == 15 ? stage == 8 : - stage == 0 ? ram[d + 10] == 0x22 : ram[d + 10] == 0x36 || ram[d + 10] == 2; + 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; diff --git a/src/mmx_wide_policy.c b/src/mmx_wide_policy.c index fe442d8..19e9e81 100644 --- a/src/mmx_wide_policy.c +++ b/src/mmx_wide_policy.c @@ -119,6 +119,33 @@ void MmxWidePolicy_EndWideSpawnPass(MmxWideSpawnCursor *cursor, cursor->valid = true; } +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: /* Armored Armadillo */ + case 0x14: /* Flame Mammoth */ + 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; @@ -132,17 +159,10 @@ bool MmxWidePolicy_SpawnRecordAllowed(uint8_t stage, uint8_t kind, /* Heart Tanks are persistent collectibles, not camera/encounter events. */ if (kind == 0 && object_id == 0x0b) 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. */ - /* Highway's Bee Blader also starts an arena camera push in its init, - * before its separate descent state. It must not initialize in a margin. - * Chill Penguin must wait until X has crossed the second boss door. - * Spark's id-$37 light controllers also retain their authored room entry. */ - if ((stage == 0x06 && kind == 3 && (object_id == 0x03 || object_id == 0x37)) || - (stage == 0x00 && kind == 3 && object_id == 0x22) || - (stage == 0x08 && kind == 3 && object_id == 0x02)) + /* Boss records belong to the native scan, just like camera/door events. + * Its independent cursor reaches them at the authored arena boundary. + * Ordinary enemies, including the light streakers, remain visible early. */ + if (kind == 3 && MmxWidePolicy_IsBossEncounter(object_id)) 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 44c73b2..7a8118f 100644 --- a/src/mmx_wide_policy.h +++ b/src/mmx_wide_policy.h @@ -22,6 +22,8 @@ uint16_t MmxWidePolicy_BeeEntrance(uint8_t ram[0x20000], uint16_t object, * 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); typedef struct MmxWideSpawnCursor { uint16_t wide; diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c index ef37742..a69a71b 100644 --- a/tests/mmx_renderer_test.c +++ b/tests/mmx_renderer_test.c @@ -255,6 +255,23 @@ static void resource_decode(void) { 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)); + /* 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; diff --git a/tests/mmx_wide_policy_test.c b/tests/mmx_wide_policy_test.c index f648fb8..15d5518 100644 --- a/tests/mmx_wide_policy_test.c +++ b/tests/mmx_wide_policy_test.c @@ -123,8 +123,19 @@ static void test_spawn_record_ownership(void) { 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)); + 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)); From 0d923be71647cd6e04d164eeafea8fbf4a577d9d Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:08:03 -0700 Subject: [PATCH 08/13] Delay streaker attacks and preserve Spark Mandrill ice colors --- CMakeLists.txt | 3 ++- docs/custom-renderer-spike.md | 43 ++++++++++++++++++++++++++++++++++- src/mmx_render_assets.c | 10 ++++++++ src/mmx_rtl.c | 6 +++++ src/mmx_wide_policy.c | 14 ++++++++++++ src/mmx_wide_policy.h | 1 + tests/mmx_renderer_test.c | 15 ++++++++++++ tests/mmx_wide_policy_test.c | 23 +++++++++++++++++++ tools/apply_overrides.py | 22 ++++++++++++++++-- 9 files changed, 133 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 74842af..f9c8a80 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -132,7 +132,8 @@ function(add_mmx_variant TARGET GEN_DIR CFG_INCLUDE_DIR) 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 APPEND PROPERTY + set_property(SOURCE ${GEN_DIR}/bank83_part01_v2.c ${GEN_DIR}/bank83_part0b_v2.c + ${GEN_DIR}/bank87_part04_v2.c APPEND PROPERTY OBJECT_DEPENDS ${CMAKE_SOURCE_DIR}/tools/apply_overrides.py) add_custom_target(${TARGET}_widescreen_overrides COMMAND ${Python3_EXECUTABLE} diff --git a/docs/custom-renderer-spike.md b/docs/custom-renderer-spike.md index 9f7e2b5..de39267 100644 --- a/docs/custom-renderer-spike.md +++ b/docs/custom-renderer-spike.md @@ -1,6 +1,11 @@ # Mega Man X custom renderer spike -## Current owner playtest checklist (2026-09-14, sixth batch) +## Current owner playtest checklist (2026-09-14, 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. @@ -461,6 +466,42 @@ 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 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. + ## Validation recorded on 2026-09-13/14 - Windows Release CMake build and all three CTests pass. Assertions remain diff --git a/src/mmx_render_assets.c b/src/mmx_render_assets.c index a99b5f9..ed4f57f 100644 --- a/src/mmx_render_assets.c +++ b/src/mmx_render_assets.c @@ -118,6 +118,16 @@ const MmxSpriteAsset *MmxRenderAssetsSprite(unsigned stage, unsigned section, un const MmxSpriteAsset *MmxRenderAssetsObjectSprite(const uint8_t ram[0x20000], unsigned object, unsigned animation) { if (!ram) return NULL; + /* 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. */ diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c index 686d760..45ded02 100644 --- a/src/mmx_rtl.c +++ b/src/mmx_rtl.c @@ -1144,6 +1144,12 @@ uint16 MmxWsEnemyActivationDistance(uint16 v, uint16 object) { return (uint16)(v + m); } +int MmxWsStreakerWaiting(uint16 object) { + extern uint8_t g_ram[0x20000]; + return g_mmx_custom_renderer && MmxWsSpawnWide() && MmxWsMargin() && + MmxWidePolicy_StreakerWaiting(g_ram, object); +} + /* 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 diff --git a/src/mmx_wide_policy.c b/src/mmx_wide_policy.c index 19e9e81..16d5ff6 100644 --- a/src/mmx_wide_policy.c +++ b/src/mmx_wide_policy.c @@ -119,6 +119,20 @@ void MmxWidePolicy_EndWideSpawnPass(MmxWideSpawnCursor *cursor, cursor->valid = true; } +bool MmxWidePolicy_StreakerWaiting(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; + /* Keep the early-visible actor at its authored room position until its + * 32-pixel event column reaches the native scan. $87:A6BA then advances + * its beam state; a launched/fading actor must never be parked again. + * This needs no host latch, so saves retain the same waiting behavior. */ + unsigned column = read_word(ram, object + 5) & ~31u; + unsigned camera_column = read_word(ram, 0x1e4d) & ~31u; + return column < camera_column || column > camera_column + 256; +} + 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 diff --git a/src/mmx_wide_policy.h b/src/mmx_wide_policy.h index 7a8118f..6498657 100644 --- a/src/mmx_wide_policy.h +++ b/src/mmx_wide_policy.h @@ -24,6 +24,7 @@ uint16_t MmxWidePolicy_BeeEntrance(uint8_t ram[0x20000], uint16_t object, 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_StreakerWaiting(const uint8_t ram[0x20000], uint16_t object); typedef struct MmxWideSpawnCursor { uint16_t wide; diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c index a69a71b..6ba2a32 100644 --- a/tests/mmx_renderer_test.c +++ b/tests/mmx_renderer_test.c @@ -272,6 +272,21 @@ static void resource_decode(void) { 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; diff --git a/tests/mmx_wide_policy_test.c b/tests/mmx_wide_policy_test.c index 15d5518..1990732 100644 --- a/tests/mmx_wide_policy_test.c +++ b/tests/mmx_wide_policy_test.c @@ -109,6 +109,28 @@ static void test_spawn_cursors_are_independent(void) { assert(cursor.valid); } +static void test_streaker_wait(void) { + memset(ram, 0, sizeof(ram)); + ram[0x1f7a] = 6; ram[0xe68] = 1; ram[0xe69] = 2; + ram[0xe72] = 0x37; ram[0xe8f] = 2; + put(0xe6d, 0x5f6); put(0x1e4d, 0x340); + assert(MmxWidePolicy_StreakerWaiting(ram, 0xe68)); + put(0x1e4d, 0x4df); assert(MmxWidePolicy_StreakerWaiting(ram, 0xe68)); + put(0x1e4d, 0x4e0); assert(!MmxWidePolicy_StreakerWaiting(ram, 0xe68)); + put(0x1e4d, 0x640); assert(MmxWidePolicy_StreakerWaiting(ram, 0xe68)); + put(0x1e4d, 0x5ff); assert(!MmxWidePolicy_StreakerWaiting(ram, 0xe68)); + put(0x1e4d, 0x340); ram[0xe6a] = 2; + assert(!MmxWidePolicy_StreakerWaiting(ram, 0xe68)); /* Already launched. */ + ram[0xe6a] = 0; ram[0xe6b] = 2; + assert(!MmxWidePolicy_StreakerWaiting(ram, 0xe68)); /* Fading. */ + ram[0xe6b] = 0; ram[0xe8f] = 0; + assert(!MmxWidePolicy_StreakerWaiting(ram, 0xe68)); /* Killed before launch. */ + ram[0xe8f] = 2; ram[0xe72] = 0x31; + assert(!MmxWidePolicy_StreakerWaiting(ram, 0xe68)); /* Other actor. */ + assert(!MmxWidePolicy_StreakerWaiting(ram, 0)); + assert(!MmxWidePolicy_StreakerWaiting(NULL, 0xe68)); +} + static void test_spawn_record_ownership(void) { /* Ordinary enemies are early/wide only; controllers are native only. */ assert(MmxWidePolicy_SpawnRecordAllowed(0x06, 3, 0x20, false)); @@ -166,6 +188,7 @@ int main(void) { test_non_door_stack(); test_spawn_cursors_are_independent(); test_spawn_record_ownership(); + test_streaker_wait(); 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 c9ecd26..cab0b74 100644 --- a/tools/apply_overrides.py +++ b/tools/apply_overrides.py @@ -265,7 +265,7 @@ 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-FLYER-LEASH*/", "/*WS-ARMOR-CULL*/", "/*WS-STREAKER-WAIT*/", "/*WS-OAM*/", "/*WS-OAM-L*/", "/*WS-LOOKAHEAD*/", "/*WS-STAGE*/", "/*WS-SHADOW*/", "/*WS-CHRBIND*/", "/*WS-CHRBIND-COPY*/", "/*WS-CHRBIND-PARENT*/", "/*MSU1-MUSIC*/", "/*MSU1-STAGE*/") @@ -758,6 +758,23 @@ def apply_flyer_leash(lines, verbose): return out, count +def apply_streaker_wait(lines, verbose): + """Keep early-visible streakers in place until their native event column. + + Skip only horizontal movement, retaining animation, drawing, collision + and the existing light composition in the rest of the actor update. + """ + out, count = [], 0 + for line in lines: + out.append(line) + trace = RE_TRACE.search(line) + if trace and canon_pc24(int(trace[1], 16)) == 0x07A597: + indent = line[:len(line) - len(line.lstrip())] + out.append(f"{indent}/*WS-STREAKER-WAIT*/ {{ extern int MmxWsStreakerWaiting(uint16); if (MmxWsStreakerWaiting(cpu->D)) goto L_A59B_M1X1; }}\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 @@ -1056,6 +1073,7 @@ def main(): (apply_bank82_shot_cull, "/*WS-SHOT-CULL*/"), (apply_bank82_activation, "/*WS-ACTIVATE*/"), (apply_flyer_leash, "/*WS-FLYER-LEASH*/"), + (apply_streaker_wait, "/*WS-STREAKER-WAIT*/"), (apply_ride_armor_cull, "/*WS-ARMOR-CULL*/"), (apply_bank03, "/*WS-STAGE*/"), (apply_chrbind_generic, "/*WS-CHRBIND*/"), @@ -1118,7 +1136,7 @@ def main(): return 1 chrbind_found = effective_counts.get("/*WS-CHRBIND*/", 0) if not args.restore: - for marker in ("/*WS-FLYER-LEASH*/", "/*WS-ARMOR-CULL*/"): + for marker in ("/*WS-FLYER-LEASH*/", "/*WS-ARMOR-CULL*/", "/*WS-STREAKER-WAIT*/"): if effective_counts.get(marker, 0) != 1: print(f"ERROR: expected exactly 1 {marker} hook, found {effective_counts.get(marker, 0)}", file=sys.stderr) return 1 From c129efe4257ecbea36e8a442e795a2e016d44613 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:37:19 -0700 Subject: [PATCH 09/13] Replace parked streakers and prefill Storm Eagle airport --- CMakeLists.txt | 3 +- docs/custom-renderer-spike.md | 68 +++++++++++++++++++++++++++++++++-- src/mmx_renderer.c | 3 +- src/mmx_rtl.c | 47 +++++++++++++++++++++--- src/mmx_wide_policy.c | 53 ++++++++++++++++++++++----- src/mmx_wide_policy.h | 5 ++- tests/mmx_renderer_test.c | 22 +++++++++++- tests/mmx_wide_policy_test.c | 62 ++++++++++++++++++++++---------- tools/apply_overrides.py | 45 ++++++++++++++++------- 9 files changed, 256 insertions(+), 52 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f9c8a80..b33ef09 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -133,7 +133,8 @@ function(add_mmx_variant TARGET GEN_DIR CFG_INCLUDE_DIR) 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 APPEND PROPERTY + ${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} diff --git a/docs/custom-renderer-spike.md b/docs/custom-renderer-spike.md index de39267..f2c70a8 100644 --- a/docs/custom-renderer-spike.md +++ b/docs/custom-renderer-spike.md @@ -1,6 +1,15 @@ # Mega Man X custom renderer spike -## Current owner playtest checklist (2026-09-14, seventh batch) +## Current owner playtest checklist (2026-09-14, 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. @@ -97,8 +106,9 @@ 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. The existing Storm Eagle -roof exception and Launch Octopus submarine entrance mask are retained. +retained coordinates. Stage edges reflect terrain. Storm Eagle's airport sky +also uses the complete retained map. The Launch Octopus submarine entrance +mask is retained. The generated D76A hook records actual metasprite drawing data before native clipping. It neither widens the native OAM emission gate nor alters CPU state. @@ -468,6 +478,8 @@ 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`. @@ -502,6 +514,56 @@ 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. + ## Validation recorded on 2026-09-13/14 - Windows Release CMake build and all three CTests pass. Assertions remain diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index ed7f415..49cde6e 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -285,8 +285,7 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, if ((sc & 1) && (tx & 32)) a += 1024; if ((sc & 2) && (ty & 32)) a += (sc & 1) ? 2048 : 1024; uint16_t tile = r->vram[a & 0x7fff]; - bool roof_sky = layer == 1 && frame.ram[0x1f7a] == 5 && word(frame.ram, 0x1e50) >= 0x300; - if (stage && size == 8 && layer < 2 && !roof_sky && (x < 0 || x >= 256)) { + 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; diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c index 45ded02..6968caa 100644 --- a/src/mmx_rtl.c +++ b/src/mmx_rtl.c @@ -327,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; @@ -379,7 +379,7 @@ 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", @@ -391,6 +391,17 @@ 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 @@ -1070,6 +1081,8 @@ int MmxWsSpawnRecordAllowed(uint16 dpage, uint8 type) { uint8 *descriptor = RomPtr(0x850000u | rec); const uint8 object_id = descriptor[3]; if (s_ws_spawn_pass.collectibles) return kind == 0 && object_id == 0x0b; + 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( @@ -1144,10 +1157,34 @@ uint16 MmxWsEnemyActivationDistance(uint16 v, uint16 object) { return (uint16)(v + m); } -int MmxWsStreakerWaiting(uint16 object) { +void MmxWsStreakerEntrance(uint16 object) { extern uint8_t g_ram[0x20000]; - return g_mmx_custom_renderer && MmxWsSpawnWide() && MmxWsMargin() && - MmxWidePolicy_StreakerWaiting(g_ram, object); + 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 diff --git a/src/mmx_wide_policy.c b/src/mmx_wide_policy.c index 16d5ff6..d7c3c07 100644 --- a/src/mmx_wide_policy.c +++ b/src/mmx_wide_policy.c @@ -119,18 +119,55 @@ void MmxWidePolicy_EndWideSpawnPass(MmxWideSpawnCursor *cursor, cursor->valid = true; } -bool MmxWidePolicy_StreakerWaiting(const uint8_t ram[0x20000], uint16_t object) { +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; - /* Keep the early-visible actor at its authored room position until its - * 32-pixel event column reaches the native scan. $87:A6BA then advances - * its beam state; a launched/fading actor must never be parked again. - * This needs no host latch, so saves retain the same waiting behavior. */ + 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; - return column < camera_column || column > camera_column + 256; + 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) { @@ -175,8 +212,8 @@ bool MmxWidePolicy_SpawnRecordAllowed(uint8_t stage, uint8_t kind, /* Boss records belong to the native scan, just like camera/door events. * Its independent cursor reaches them at the authored arena boundary. - * Ordinary enemies, including the light streakers, remain visible early. */ - if (kind == 3 && MmxWidePolicy_IsBossEncounter(object_id)) + * 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 6498657..1f338de 100644 --- a/src/mmx_wide_policy.h +++ b/src/mmx_wide_policy.h @@ -24,7 +24,10 @@ uint16_t MmxWidePolicy_BeeEntrance(uint8_t ram[0x20000], uint16_t object, 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_StreakerWaiting(const uint8_t ram[0x20000], uint16_t object); +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; diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c index 6ba2a32..b0348d1 100644 --- a/tests/mmx_renderer_test.c +++ b/tests/mmx_renderer_test.c @@ -233,6 +233,26 @@ static void distant_doors(void) { 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, @@ -412,4 +432,4 @@ static void spark_effects(void) { assert(output[50 * v.width + v.extra - 100] == 0); assert(output[50 * v.width + v.extra + 100] == 0xff0000); } -int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); expanded_capacity(); background_resources(); dialogue_and_password(); highway_arena_sky(); distant_doors(); resource_decode(); spark_effects(); return 0; } +int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); expanded_capacity(); background_resources(); dialogue_and_password(); highway_arena_sky(); distant_doors(); storm_background_prefill(); resource_decode(); spark_effects(); return 0; } diff --git a/tests/mmx_wide_policy_test.c b/tests/mmx_wide_policy_test.c index 1990732..5ccf35e 100644 --- a/tests/mmx_wide_policy_test.c +++ b/tests/mmx_wide_policy_test.c @@ -109,26 +109,51 @@ static void test_spawn_cursors_are_independent(void) { assert(cursor.valid); } -static void test_streaker_wait(void) { +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); - assert(MmxWidePolicy_StreakerWaiting(ram, 0xe68)); - put(0x1e4d, 0x4df); assert(MmxWidePolicy_StreakerWaiting(ram, 0xe68)); - put(0x1e4d, 0x4e0); assert(!MmxWidePolicy_StreakerWaiting(ram, 0xe68)); - put(0x1e4d, 0x640); assert(MmxWidePolicy_StreakerWaiting(ram, 0xe68)); - put(0x1e4d, 0x5ff); assert(!MmxWidePolicy_StreakerWaiting(ram, 0xe68)); - put(0x1e4d, 0x340); ram[0xe6a] = 2; - assert(!MmxWidePolicy_StreakerWaiting(ram, 0xe68)); /* Already launched. */ + 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_StreakerWaiting(ram, 0xe68)); /* Fading. */ - ram[0xe6b] = 0; ram[0xe8f] = 0; - assert(!MmxWidePolicy_StreakerWaiting(ram, 0xe68)); /* Killed before launch. */ - ram[0xe8f] = 2; ram[0xe72] = 0x31; - assert(!MmxWidePolicy_StreakerWaiting(ram, 0xe68)); /* Other actor. */ - assert(!MmxWidePolicy_StreakerWaiting(ram, 0)); - assert(!MmxWidePolicy_StreakerWaiting(NULL, 0xe68)); + 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) { @@ -145,8 +170,8 @@ static void test_spawn_record_ownership(void) { 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)); + 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}; @@ -188,7 +213,8 @@ int main(void) { test_non_door_stack(); test_spawn_cursors_are_independent(); test_spawn_record_ownership(); - test_streaker_wait(); + 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 cab0b74..78fd67b 100644 --- a/tools/apply_overrides.py +++ b/tools/apply_overrides.py @@ -266,6 +266,7 @@ 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*/") @@ -758,19 +759,35 @@ def apply_flyer_leash(lines, verbose): return out, count -def apply_streaker_wait(lines, verbose): - """Keep early-visible streakers in place until their native event column. - - Skip only horizontal movement, retaining animation, drawing, collision - and the existing light composition in the rest of the actor update. - """ - out, count = [], 0 +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 and canon_pc24(int(trace[1], 16)) == 0x07A597: + 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-WAIT*/ {{ extern int MmxWsStreakerWaiting(uint16); if (MmxWsStreakerWaiting(cpu->D)) goto L_A59B_M1X1; }}\n") + 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 @@ -1073,7 +1090,8 @@ def main(): (apply_bank82_shot_cull, "/*WS-SHOT-CULL*/"), (apply_bank82_activation, "/*WS-ACTIVATE*/"), (apply_flyer_leash, "/*WS-FLYER-LEASH*/"), - (apply_streaker_wait, "/*WS-STREAKER-WAIT*/"), + (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*/"), @@ -1136,9 +1154,10 @@ def main(): return 1 chrbind_found = effective_counts.get("/*WS-CHRBIND*/", 0) if not args.restore: - for marker in ("/*WS-FLYER-LEASH*/", "/*WS-ARMOR-CULL*/", "/*WS-STREAKER-WAIT*/"): - if effective_counts.get(marker, 0) != 1: - print(f"ERROR: expected exactly 1 {marker} hook, found {effective_counts.get(marker, 0)}", file=sys.stderr) + for marker, expected in (("/*WS-FLYER-LEASH*/", 1), ("/*WS-ARMOR-CULL*/", 1), + ("/*WS-STREAKER-ENTRY*/", 1), ("/*WS-CHAIN-PLATFORM*/", 2)): + 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( From 45fa4e2f2de216c94cc5977a2006649f2f5b57f9 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:54:41 -0700 Subject: [PATCH 10/13] Fill distant airport scenery and preserve water and buried entrances --- docs/custom-renderer-spike.md | 57 +++++++++++++++++++++++-- src/mmx_renderer.c | 56 ++++++++++++++++++++++--- tests/mmx_renderer_test.c | 79 ++++++++++++++++++++++++++++++++++- 3 files changed, 183 insertions(+), 9 deletions(-) diff --git a/docs/custom-renderer-spike.md b/docs/custom-renderer-spike.md index f2c70a8..ca9a585 100644 --- a/docs/custom-renderer-spike.md +++ b/docs/custom-renderer-spike.md @@ -1,6 +1,15 @@ # Mega Man X custom renderer spike -## Current owner playtest checklist (2026-09-14, eighth batch) +## Current owner playtest checklist (2026-09-14, 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. + +## 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. @@ -107,8 +116,9 @@ 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 complete retained map. The Launch Octopus submarine entrance -mask is retained. +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. @@ -564,6 +574,47 @@ 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 HDMA-driven 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. + ## Validation recorded on 2026-09-13/14 - Windows Release CMake build and all three CTests pass. Assertions remain diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index 49cde6e..997be71 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -40,6 +40,10 @@ 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 BuriedBody { int left, top, bottom; } BuriedBody; +static BuriedBody buried_bodies[16]; +static unsigned buried_count; bool g_mmx_custom_renderer; bool g_mmx_custom_hud = true; bool g_mmx_expanded_sprites; @@ -267,12 +271,45 @@ static unsigned tile_pixel(const uint16_t *vram, unsigned address, int x, int y, pixel |= ((bits & 1) << 2) | ((bits >> 5) & 8); } return pixel; } +static void prepare_stage_planes(void) { + airport_sky_width = 0; buried_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 || r[d + 2] == 4) 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; + buried_bodies[buried_count++] = (BuriedBody){ + ((int)word(r, d + 5) + 16) & ~31, (int)word(top, 0), (int)word(bottom, 0) + 1}; + } +} static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, int y, bool stage, int *private_color) { static const unsigned low[] = {8, 7, 1}, high[] = {12, 11, 3}; bool margin = x < 0 || x >= 256; - /* Stage BG3 is a screen-space overlay (dialogue), not a repeating world - * map. Keep its original scroll/window processing inside the native view. */ - if (stage && layer == 2 && margin) return 0; + /* 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. */ @@ -302,6 +339,10 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, /* 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) { @@ -313,8 +354,12 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, if (wx < 0) wx = 0; } } - if (stage_id == 1 && word(frame.ram, 0x1e4d) >= 0xa70 && word(frame.ram, 0x1e4d) < 0xac0 && - y >= 0x50 && y < 0xb0 && wx >= 0xbc0 && wx < 0xc40) wx -= 256; + for (unsigned i = 0; i < buried_count; ++i) { + const BuriedBody *body = &buried_bodies[i]; + if (wx >= body->left && wx < body->left + 128 && 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) { @@ -488,6 +533,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { memset(door_cache, 0, sizeof(door_cache)); memset(out, 0, (size_t)view.width * 224 * sizeof(*out)); bool stage = MmxWidePolicy_IsStageScene(frame.ram); + 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 ? diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c index b0348d1..56c9580 100644 --- a/tests/mmx_renderer_test.c +++ b/tests/mmx_renderer_test.c @@ -432,4 +432,81 @@ static void spark_effects(void) { assert(output[50 * v.width + v.extra - 100] == 0); assert(output[50 * v.width + v.extra + 100] == 0xff0000); } -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(); return 0; } +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. */ + 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); + 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); +} + +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(); return 0; } From 4f21e203c5e2377b2a551f0fc6a045501062c0a8 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:00:13 -0700 Subject: [PATCH 11/13] Document adaptive renderer draft integration blockers --- docs/custom-renderer-spike.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/custom-renderer-spike.md b/docs/custom-renderer-spike.md index ca9a585..08e488e 100644 --- a/docs/custom-renderer-spike.md +++ b/docs/custom-renderer-spike.md @@ -9,6 +9,25 @@ 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. From 5e529df8c8968bfa079d7036c7d41b0cad2e3ba7 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:10:00 -0700 Subject: [PATCH 12/13] Confine submarine entrance scroll to its body in adaptive margins --- docs/custom-renderer-spike.md | 48 +++++++++++++++++++++++++++++++++-- src/mmx_renderer.c | 42 ++++++++++++++++++++++-------- tests/mmx_renderer_test.c | 31 ++++++++++++++++++++++ 3 files changed, 108 insertions(+), 13 deletions(-) diff --git a/docs/custom-renderer-spike.md b/docs/custom-renderer-spike.md index 08e488e..cfbd73e 100644 --- a/docs/custom-renderer-spike.md +++ b/docs/custom-renderer-spike.md @@ -1,6 +1,14 @@ # Mega Man X custom renderer spike -## Current owner playtest checklist (2026-09-14, ninth batch) +## Current owner playtest checklist (2026-09-14, 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; owner acceptance is pending. + +## 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. @@ -615,7 +623,7 @@ the eighth build's behavior. 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 HDMA-driven rise and + 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`: @@ -634,6 +642,42 @@ 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 | + ## Validation recorded on 2026-09-13/14 - Windows Release CMake build and all three CTests pass. Assertions remain diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index 997be71..c97fe49 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -41,9 +41,13 @@ static size_t rom_size; static MmxRenderStats stats; static uint8_t door_cache[512 * 512]; static int airport_sky_width; -typedef struct BuriedBody { int left, top, bottom; } BuriedBody; -static BuriedBody buried_bodies[16]; -static unsigned buried_count; +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; @@ -272,7 +276,7 @@ static unsigned tile_pixel(const uint16_t *vram, unsigned address, int x, int y, return pixel; } static void prepare_stage_planes(void) { - airport_sky_width = 0; buried_count = 0; + 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 @@ -287,8 +291,7 @@ static void prepare_stage_planes(void) { 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 || r[d + 2] == 4) continue; + 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. @@ -297,8 +300,13 @@ static void prepare_stage_planes(void) { 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; - buried_bodies[buried_count++] = (BuriedBody){ - ((int)word(r, d + 5) + 16) & ~31, (int)word(top, 0), (int)word(bottom, 0) + 1}; + 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) + 16) & ~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) { @@ -327,6 +335,18 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, 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; + 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 + 128) wy += body->shift; + break; + } + } } else { int stream_x = word(frame.ram, 0x1e8d), stream_y = word(frame.ram, 0x1e90); wx = stream_x + (((p->hScroll[1] - stream_x + 512) & 1023) - 512) + x; @@ -354,9 +374,9 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, if (wx < 0) wx = 0; } } - for (unsigned i = 0; i < buried_count; ++i) { - const BuriedBody *body = &buried_bodies[i]; - if (wx >= body->left && wx < body->left + 128 && wy >= body->top && wy < body->bottom) { + for (unsigned i = 0; i < submarine_count; ++i) { + const SubmarineBody *body = &submarine_bodies[i]; + if (body->hidden && wx >= body->left && wx < body->left + 128 && wy >= body->top && wy < body->bottom) { wx -= 256; break; /* The preceding water screen has no source body. */ } } diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c index 56c9580..b744b59 100644 --- a/tests/mmx_renderer_test.c +++ b/tests/mmx_renderer_test.c @@ -507,6 +507,37 @@ static void buried_submarine(void) { 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. */ + } } 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(); return 0; } From 461fd0380ba0c10b9533e9160b80a5b24edf6f33 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:32:22 -0700 Subject: [PATCH 13/13] Fix adaptive pickups, shaft transitions, and background actors --- docs/custom-renderer-spike.md | 71 +++++++++++++++++++++++++++++- src/mmx_render_assets.c | 36 ++++++++++++--- src/mmx_render_assets.h | 3 +- src/mmx_renderer.c | 34 ++++++++++++--- src/mmx_rtl.c | 52 +++++++++++++--------- src/mmx_wide_policy.c | 21 ++++++--- src/mmx_wide_policy.h | 1 + tests/mmx_renderer_test.c | 77 ++++++++++++++++++++++++++++++++- tests/mmx_wide_policy_test.c | 16 ++++++- tools/apply_overrides.py | 17 +++++++- tools/test_renderer_captures.py | 3 +- 11 files changed, 281 insertions(+), 50 deletions(-) diff --git a/docs/custom-renderer-spike.md b/docs/custom-renderer-spike.md index cfbd73e..e5b9f74 100644 --- a/docs/custom-renderer-spike.md +++ b/docs/custom-renderer-spike.md @@ -1,12 +1,27 @@ # Mega Man X custom renderer spike -## Current owner playtest checklist (2026-09-14, tenth batch) +## 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; owner acceptance is pending. +The transition captures and regression test pass; the owner confirmed this fix. ## Previous owner playtest checklist (ninth batch) @@ -678,6 +693,58 @@ Evidence under `build-custom/validation`: | `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 diff --git a/src/mmx_render_assets.c b/src/mmx_render_assets.c index ed4f57f..758243e 100644 --- a/src/mmx_render_assets.c +++ b/src/mmx_render_assets.c @@ -1,4 +1,5 @@ #include "mmx_render_assets.h" +#include "mmx_wide_policy.h" #include /* The same ROM compression/transfer format used by mmx_wide_preview.c, @@ -96,6 +97,7 @@ static void stage_assets(unsigned stage, unsigned section) { 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; } } @@ -118,6 +120,18 @@ const MmxSpriteAsset *MmxRenderAssetsSprite(unsigned stage, unsigned section, un 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 @@ -189,7 +203,12 @@ const MmxSpriteAsset *MmxRenderAssetsObjectSprite(const uint8_t ram[0x20000], return &rotor; } } - return MmxRenderAssetsSprite(ram[0x1f7a], ram[0x1f08], animation); + 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; @@ -201,12 +220,12 @@ bool MmxRenderAssetsRideArmorPalettePending(const uint8_t ram[0x20000], const ui memcmp(colors + 1, assets[0x4a].colors + 1, 15 * sizeof(*colors)) == 0 && memcmp(colors + 1, assets[0x49].colors + 1, 15 * sizeof(*colors)) != 0; } -/* Highway and Chill Penguin's kind-2 $17 records select palettes at X - * boundaries. Chill's CHR also switches vertically, so only Highway gets - * the horizontal CHR projection below. */ +/* 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 != 8) || !range(0x32280, 2)) return false; + 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)); @@ -240,7 +259,7 @@ static size_t background_list(size_t base, unsigned phase) { } const uint8_t *MmxRenderAssetsBackgroundTile(const uint8_t ram[0x20000], int world_x, unsigned vram_word) { - if (!ram || ram[0x1f7a] != 0 || world_x < 0 || world_x >= 8192 || vram_word >= 0x8000 || !prepare_background(ram)) return NULL; + 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. */ @@ -281,9 +300,12 @@ static const MmxBackgroundPalette *background_palette(unsigned phase) { } const MmxBackgroundPalette *MmxRenderAssetsBackgroundPalette(const uint8_t ram[0x20000], int world_x) { - if (!ram || world_x < 0 || world_x >= 8192 || !prepare_background(ram)) return NULL; + 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) { diff --git a/src/mmx_render_assets.h b/src/mmx_render_assets.h index 4c323c8..dc25bce 100644 --- a/src/mmx_render_assets.h +++ b/src/mmx_render_assets.h @@ -7,7 +7,7 @@ typedef struct MmxSpriteAsset { uint8_t tiles[8192]; uint16_t colors[16]; uint8_t id, tile_base, attributes; - bool current, live_tiles; + bool current, live_tiles, live_colors; } MmxSpriteAsset; void MmxRenderAssetsSetRom(const uint8_t *rom, size_t size); @@ -27,3 +27,4 @@ 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 index c97fe49..48f97bf 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -305,7 +305,7 @@ static void prepare_stage_planes(void) { 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) + 16) & ~31, (int)word(top, 0), (int)word(bottom, 0) + 1, + ((int)word(r, d + 5)) & ~31, (int)word(top, 0), (int)word(bottom, 0) + 1, shift, r[d + 2] != 4, scrolling}; } } @@ -323,6 +323,7 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, * 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; @@ -335,6 +336,13 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, 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 @@ -343,14 +351,24 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, * 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 + 128) wy += body->shift; + if (wx < body->left || wx >= body->left + 160) wy += body->shift; break; } } } else { - int stream_x = word(frame.ram, 0x1e8d), stream_y = word(frame.ram, 0x1e90); + 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. */ @@ -376,7 +394,7 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, } for (unsigned i = 0; i < submarine_count; ++i) { const SubmarineBody *body = &submarine_bodies[i]; - if (body->hidden && wx >= body->left && wx < body->left + 128 && wy >= body->top && wy < body->bottom) { + 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. */ } } @@ -408,7 +426,7 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, if (tile & 0x8000) cy = size - 1 - cy; unsigned number = ((tile & 1023) + cx / 8 + cy / 8 * 16) & 1023; unsigned address = (PPU_bgTileAdr(p, layer) + number * bpp * 4) & 0x7fff; - const uint8_t *bits = g_mmx_render_asset_repairs && bpp == 4 ? + 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) { @@ -420,7 +438,8 @@ static uint16_t background(const Ppu *p, const Raster *r, unsigned layer, int x, if (!pixel) return 0; unsigned index = (((tile >> 10) & 7) << bpp) | pixel; const MmxBackgroundPalette *palette = g_mmx_render_asset_repairs ? - MmxRenderAssetsBackgroundPalette(frame.ram, asset_x) : NULL; + (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); @@ -451,7 +470,7 @@ static void sprite(const Ppu *p, const Raster *r, int x, int sy, unsigned attr, } 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->colors[pixel] : -1; + object_color[dest] = asset && !asset->live_colors ? asset->colors[pixel] : -1; if (x + c < 0 || x + c >= 256) ++stats.margin_sprite_pixels; } } @@ -602,6 +621,7 @@ bool MmxRendererDraw(uint32_t *out, MmxRenderView view, bool hud) { } 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; diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c index 6968caa..753947d 100644 --- a/src/mmx_rtl.c +++ b/src/mmx_rtl.c @@ -934,7 +934,9 @@ uint16 MmxWsPresentationCullVerdictX(uint16 dpage, uint16 v) { int m = MmxWsSpawnWide() ? MmxWsMargin() : 0; bool traffic = g_ram[0x1f7a] == 0 && g_ram[(uint16)(dpage + 0x0a)] == 0x21; bool armor = g_mmx_custom_renderer && dpage == 0xe18; - if (!m || (!traffic && !armor)) + 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; } @@ -1080,7 +1082,7 @@ 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 && object_id == 0x0b; + 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; @@ -1114,31 +1116,39 @@ void MmxWsSpawnRunNativePass(CpuState *cpu) { 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, 0x00DC8Fu); - if (g_mmx_custom_renderer) { - /* A loaded narrow-view save can already expose a Heart Tank column - * behind the new leading anchor. Let the game's allocator catch up - * collectible records across the visible range; its persistent pickup - * flags remain authoritative. No enemy or scripted event is admitted. */ - int margin = MmxWsMargin(); - int camera = g_ram[0x1e4d] | (g_ram[0x1e4e] << 8); - uint8 scratch[16]; memcpy(scratch, g_ram + dpage + 0x10, sizeof(scratch)); - 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.collectibles = 0; - memcpy(g_ram + dpage + 0x10, scratch, sizeof(scratch)); - } *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 diff --git a/src/mmx_wide_policy.c b/src/mmx_wide_policy.c index d7c3c07..596b35a 100644 --- a/src/mmx_wide_policy.c +++ b/src/mmx_wide_policy.c @@ -2,8 +2,18 @@ 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. */ - return ram && ram[0xd1] == 2 && ram[0xd2] == 4 && ram[0xd3] <= 8 && !(ram[0xd3] & 1); + * 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) { @@ -181,8 +191,8 @@ bool MmxWidePolicy_IsBossEncounter(uint8_t object_id) { case 0x05: /* Boomer Kuwanger */ case 0x07: /* Launch Octopus */ case 0x0a: /* Sting Chameleon */ - case 0x0c: /* Armored Armadillo */ - case 0x14: /* Flame Mammoth */ + case 0x0c: /* Flame Mammoth */ + case 0x14: /* Armored Armadillo */ case 0x31: /* Spark Mandrill */ case 0x52: /* Storm Eagle */ case 0x5d: /* Rangda Bangda controller */ @@ -207,8 +217,7 @@ bool MmxWidePolicy_SpawnRecordAllowed(uint8_t stage, uint8_t kind, if (stage == 0x00 && kind == 1 && object_id == 0x21) return true; - /* Heart Tanks are persistent collectibles, not camera/encounter events. */ - if (kind == 0 && object_id == 0x0b) return true; + 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. diff --git a/src/mmx_wide_policy.h b/src/mmx_wide_policy.h index 1f338de..7df8840 100644 --- a/src/mmx_wide_policy.h +++ b/src/mmx_wide_policy.h @@ -24,6 +24,7 @@ uint16_t MmxWidePolicy_BeeEntrance(uint8_t ram[0x20000], uint16_t object, 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, diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c index b744b59..9d0e82e 100644 --- a/tests/mmx_renderer_test.c +++ b/tests/mmx_renderer_test.c @@ -187,6 +187,11 @@ static void dialogue_and_password(void) { 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)); @@ -374,12 +379,40 @@ static void resource_decode(void) { 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)->colors[1] == 1); + 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)); } @@ -498,9 +531,11 @@ static void buried_submarine(void) { 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); @@ -540,4 +575,42 @@ static void buried_submarine(void) { } } -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(); return 0; } +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 5ccf35e..b0fafb3 100644 --- a/tests/mmx_wide_policy_test.c +++ b/tests/mmx_wide_policy_test.c @@ -186,7 +186,15 @@ static void test_spawn_record_ownership(void) { assert(MmxWidePolicy_SpawnRecordAllowed(6, 0, 0x0b, false)); assert(MmxWidePolicy_SpawnRecordAllowed(6, 0, 0x0b, true)); assert(!MmxWidePolicy_SpawnRecordAllowed(6, 2, 0x0b, false)); - assert(!MmxWidePolicy_SpawnRecordAllowed(6, 0, 0x05, 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)); @@ -199,6 +207,12 @@ int main(void) { 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)); diff --git a/tools/apply_overrides.py b/tools/apply_overrides.py index 78fd67b..76ffeeb 100644 --- a/tools/apply_overrides.py +++ b/tools/apply_overrides.py @@ -490,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)") @@ -1124,7 +1129,14 @@ def main(): contents = f.read() stale_activation = (fn is apply_bank82_activation and 'MmxWsEnemyActivationDistance(uint16);' in contents) - if marker in contents and not stale_activation: + 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 @@ -1155,7 +1167,8 @@ def main(): 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-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 diff --git a/tools/test_renderer_captures.py b/tools/test_renderer_captures.py index 2344d3b..b9e830d 100644 --- a/tools/test_renderer_captures.py +++ b/tools/test_renderer_captures.py @@ -145,7 +145,8 @@ def main(): ram = memoryview(data)[12 + 224 * 66656:][:0x20000] def word(address): return struct.unpack_from('