From 494deeb6d833601599a9fedff22f0cbc9c6553b2 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:14:16 -0700 Subject: [PATCH 1/3] Reduce raster copies and reuse draw-scoped stage lookups --- CMakeLists.txt | 33 ++ docs/PERFORMANCE_BURNDOWN.md | 596 ++++++++++++++++++++++++++++++++ snesrecomp | 2 +- src/mmx_renderer.c | 266 +++++++++----- src/mmx_renderer.h | 13 + src/mmx_rtl.c | 5 + tests/mmx_adaptive_state_test.c | 20 ++ tests/mmx_frame_cost_test.inc | 92 +++++ tests/mmx_magic_carpet_test.inc | 17 + tests/mmx_renderer_test.c | 119 ++++++- tests/mmx_renderer_variant.c | 11 + tools/attribute_frame_costs.py | 83 +++++ tools/benchmark_raster_route.py | 134 +++++++ 13 files changed, 1303 insertions(+), 88 deletions(-) create mode 100644 docs/PERFORMANCE_BURNDOWN.md create mode 100644 tests/mmx_frame_cost_test.inc create mode 100644 tests/mmx_renderer_variant.c create mode 100644 tools/attribute_frame_costs.py create mode 100644 tools/benchmark_raster_route.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 28dff5e5..f9ec3855 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,11 @@ add_custom_command(OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/mmx_weapon_sources.h" set(SNESRECOMP_BUILD_VERSION "${PROJECT_VERSION}" CACHE STRING "Release stamp for crash reports") set(SNESRECOMP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/snesrecomp" CACHE PATH "snesrecomp checkout") set(RECOMP_UI_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/recomp-ui" CACHE PATH "recomp-ui checkout") +option(MMX_RASTER_FULL_COPY "Use full-copy raster storage as the correctness/performance reference" OFF) +option(MMX_STAGE_TILE_REUSE "Reuse exact stage-map lookups within one captured draw" ON) +set_property(SOURCE src/mmx_renderer.c APPEND PROPERTY COMPILE_DEFINITIONS + MMX_RASTER_FULL_COPY=$ + MMX_STAGE_TILE_REUSE=$) set(SNESRECOMP_ENABLE_MODS ON CACHE BOOL "Shared mod package runtime" FORCE) # Match Gundam Endless Duel's internet-capable lobby build. Per-target netplay # keeps the unmodified Japanese edition and small unit tests independent. @@ -149,6 +154,7 @@ add_mmx_variant(RockmanXSNESRecomp "${CMAKE_CURRENT_SOURCE_DIR}/variants/jp/gen" include(CTest) if(BUILD_TESTING) + add_subdirectory(${SNESRECOMP_ROOT}/runner/tests/raster_snapshot raster-snapshot-tests) add_subdirectory(${SNESRECOMP_ROOT}/runner/tests/diagnostics diagnostic-writer-tests) add_executable(mmx_boss_rush_test tests/mmx_boss_rush_test.c src/mmx_boss_rush.c src/mmx_boss_rush_draw.c) target_include_directories(mmx_boss_rush_test PRIVATE src) @@ -183,6 +189,32 @@ if(BUILD_TESTING) add_executable(mmx_renderer_test tests/mmx_renderer_test.c src/mmx_renderer.c src/mmx_render_assets.c src/mmx_display.c src/mmx_wide_policy.c src/mmx_zero.c src/mmx_weapons.c src/mmx_weapon_combat.c src/mmx_coop_view.c src/mmx_boss_rush.c src/mmx_boss_rush_draw.c) target_include_directories(mmx_renderer_test PRIVATE src ${SNESRECOMP_ROOT}/runner/src) add_test(NAME mmx_custom_renderer COMMAND mmx_renderer_test) + get_target_property(mmx_renderer_reference_sources mmx_renderer_test SOURCES) + list(REMOVE_ITEM mmx_renderer_reference_sources src/mmx_renderer.c) + foreach(reference FULLCOPY TRACE) + string(TOLOWER "${reference}" reference_name) + add_executable(mmx_renderer_${reference_name}_test + ${mmx_renderer_reference_sources} tests/mmx_renderer_variant.c + src/mmx_boss_rush_score.cpp) + target_include_directories(mmx_renderer_${reference_name}_test PRIVATE + src ${SNESRECOMP_ROOT}/runner/src) + target_compile_definitions(mmx_renderer_${reference_name}_test PRIVATE + MMX_RENDERER_TEST_${reference}=1) + target_compile_features(mmx_renderer_${reference_name}_test PRIVATE cxx_std_17) + if(MSVC) + target_compile_options(mmx_renderer_${reference_name}_test PRIVATE /UNDEBUG) + else() + target_compile_options(mmx_renderer_${reference_name}_test PRIVATE -UNDEBUG) + endif() + if(NOT WIN32) + target_link_libraries(mmx_renderer_${reference_name}_test PRIVATE m) + endif() + add_test(NAME mmx_custom_renderer_${reference_name} + COMMAND mmx_renderer_${reference_name}_test) + file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/renderer-${reference_name}") + set_tests_properties(mmx_custom_renderer_${reference_name} PROPERTIES + WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/renderer-${reference_name}") + endforeach() add_executable(mmx_render_capture tools/mmx_render_capture.c src/mmx_renderer.c src/mmx_render_assets.c src/mmx_display.c src/mmx_wide_policy.c src/mmx_zero.c src/mmx_weapons.c src/mmx_weapon_combat.c src/mmx_coop_view.c src/mmx_boss_rush.c src/mmx_boss_rush_draw.c) target_include_directories(mmx_render_capture PRIVATE src ${SNESRECOMP_ROOT}/runner/src) foreach(mmx_render mmx_renderer_test mmx_render_capture) @@ -247,6 +279,7 @@ if(TARGET mmx_state_tests) COMMAND ${CMAKE_COMMAND} -E env --unset=MMX_MAGIC_CARPET_WIDE --unset=MMX_MAGIC_CARPET_NO_SHOT SDL_AUDIODRIVER=dummy MMX_REPLAY_RECORD=0 + MMX_RASTER_INVARIANTS=1 "MMX_MAGIC_CARPET_TEST=${CMAKE_CURRENT_SOURCE_DIR}/tests/data/magic_carpet_inputs.csv" ${carpet_env} $ "${CMAKE_CURRENT_SOURCE_DIR}/mmx.sfc") diff --git a/docs/PERFORMANCE_BURNDOWN.md b/docs/PERFORMANCE_BURNDOWN.md new file mode 100644 index 00000000..3862d6e9 --- /dev/null +++ b/docs/PERFORMANCE_BURNDOWN.md @@ -0,0 +1,596 @@ +# Accuracy-preserving performance burndown + +Campaign: 2026-10-22. Tracker: `beads-9nma`. Title branch: +`perf/raster-snapshot-burndown`; framework branch: +`perf/raster-snapshot-storage`. + +Keep the general SNES implementation as the correctness reference. A title may +specialize work when it preserves the applicable game and hardware contract. +Promote reusable mechanisms into snesrecomp without moving title-specific +spawning, object allocation, artwork, cameras, or scene rules into the framework. + +AOT CPU code and modeled devices can coexist in a native host application. +MMX also deliberately interprets its small original coroutine scheduler while +dispatching task bodies into AOT code; the interpreter is not exclusively a +cold fallback here. Removing modeled state is not itself a performance result. +The useful test is whether a replacement preserves observable behavior while +reducing measured work. Universal compatibility and title-specific fast paths +fit that same framework. + +## Baseline and scope + +- MMX source: `8a1806c1fa2ce53a9a82f22e3ff0ef1c9825579c`. +- Framework: the title's recorded pin, + `7a67b85724a8b3be5b592c5a7578e35f739c81fb`. +- Use isolated worktrees; preserve unrelated owner changes. +- Keep generated ROM code, ROMs, captures and measurements private and ignored. +- Record compiler, dependency identities, configuration, exact commands, route, + output hashes, elapsed times and memory/copy counts with each experiment. +- Desktop results do not qualify lower-end hardware or another OS/architecture. +- The owner's ideal target is the original Xbox. Prioritize its memory/cache + budget and portable scalar code; a Windows i386 compile is not Xbox runtime QA. +- Old save/capture compatibility is not an acceptance requirement. Current-build + save/load, rewind and rollback remain requirements; native game progress is + never discarded. + +## Sequential experiments + +Owner's validation sequence (2026-10-22): keep MMX as the proving ground for +the remaining candidates. Attribute, implement and validate each there first, +including correctness, current-build restore/replay, rollback/netplay and +headroom. Retain only supported wins. Do not switch titles after every trial. +When the MMX candidate set is settled, combine all applicable retained changes +into one integration build for Super Mario World and one for Super Metroid. +Run automated validation on each combined build before handing it to the owner +for one general gameplay/feel playtest per game. Final approval belongs to the +owner. Rejected probes are excluded; title-specific code is not forced into +other games. Follow-up ecosystem discovery may now begin in separate windows +under the owner's updated instruction below. +The owner will do the final combined gameplay/correctness run after the changes +are together, rather than receiving a new handoff after each small trial. +If local contention prevents useful measurement, `F:/NAS` is the owner's offered +entry point for SSH access to an older fourth-generation system. Inspect its +connection instructions before use; no remote run has been attempted here. + +| ID | Tracker | Experiment | Status | +|---|---|---|---| +| P0 | `beads-9wlm` | Build a coherent reference; capture correctness and performance baselines | Complete | +| P1 | `beads-4lwp` | Share exact immutable VRAM snapshots across unchanged scanlines | Retained; local qualification passes | +| P2 | `beads-3w9m` | Attribute and evaluate palette/OAM reuse and tile-row decoding | Screened; these implementations rejected | +| P3 | `beads-mija` | Establish pixel-only bypass eligibility and shared-renderer boundaries | Audit complete; bypass deferred | +| P4 | `beads-kneo` | Qualify retained changes for rollback/netplay and portable consumers | Local checks pass; external targets unverified | +| P5 | `beads-wwgh` | Attribute remaining MMX frame costs and trial substantial exact optimizations | Complete; draw-scoped stage-map reuse retained | +| P6 | `beads-akjp` | Integrate applicable retained changes into combined SMW/SM builds, automate qualification, then owner approval | Automated qualification/NAS timings pass; owner approved all three; publishing qualified revisions | + +An experiment ends as accepted, rejected, or explicitly unverified. A checklist +entry is not a performance promise. Do not retain neutral/noisy work merely to +complete an item. After two sound neutral/negative probes, stop that candidate. + +## Acceptance gates + +1. Exact full-copy reference versus candidate raster bytes and rendered pixels: + constant memory; first/last-word writes; changes on consecutive lines; ABA + changes; every line changing; palette/OAM/register changes; frame restart; + capture round-trip; invalid/truncated input. +2. Render original captured frames repeatedly at native, wide and ultrawide + widths, changing view order and warming caches differently. Rendering must + never change guest state or captured source data. +3. ROM-backed Magic Carpet in native, 16:9 and 32:9, plus the no-shot negative + control. Preserve actual heart collection, slot reuse, ride/altitude and + deterministic restore; do not set success flags. The existing console-lag + difference is a documented limitation, not a newly passing timing test. +4. Current-build state save/load, rewind and corrected-input resimulation. + Present zero, once or repeatedly between identical simulation steps and + compare simulation snapshots/digest partitions. Discard derived renderer + history after discontinuities and reconstruct from restored authoritative + state. Never serialize host pointers or cache warmth as simulation state. +5. Existing custom-renderer, co-op, independent-camera, menu, color/window, + sprite-priority and capture checks. Keep the native framebuffer fallback. +6. Audio-enabled deterministic routes with muted host output and matching + simulation/audio results. Audio-disabled throughput is reported separately. +7. Interleaved/order-balanced A/B runs with warmups, repeated samples and an + A/A noise control. Include capture/draw attribution, copied bytes, resident + memory or storage footprint and executable/code-size deltas. +8. Build the portable implementation and rollback-linked title. Two local + processes exercise actual delay/rollback episodes if available; report + missing network, hardware or cross-OS coverage explicitly. Preserve strict + game/build/config identity checks. + +## Ownership and abstraction + +The snapshot store, exact tile/row decode, generic PPU composition and safe bus +or dispatch primitives can belong to snesrecomp. MMX's source-frame orchestration, +Storm Eagle previews, allocator/spawn policy, private sprite assets and camera +projection stay in MMX. Super Metroid has the same 64 KiB-per-line capture pattern +and is a second consumer for the storage contract. F-Zero currently takes one +VRAM image per source frame and uses Mode 7-specific presentation; it should not +be forced through MMX's compositor. + +Derived presentation optimizations need no rbengine/recomp-net wire or scheduler +change. Faster simulation can help catch-up, but that is measured separately +from drawing. Guest-affecting CPU/device optimizations need deterministic replay +and qualified simulation profiles, with actual capabilities agreed before frame +0 under the existing host-optimization policy. No live backend renegotiation. + +## Evidence and results + +Raw private evidence is under ignored `build/performance/` and +`F:/Temp/Codex/snesrecomp/native-performance-20261022`. ROMs, generated sources, +save files and images are deliberately absent from Git. + +### P0: reference + +Regenerated USA code with the Rust analyzer: 242 roots, 4,707 AOT variants, +78 LLE variants, 16 banks. GCC 15.2, Release, SDL3; cached host-frame staging, +small audio history, production diagnostics, rollback linked. Dependencies: + +- recomp-net: `8c76fb643339144a3a84d5ba8f5defe6250dcd26`. +- retcomm-rbengine: `2a03e73693acee0fb78076ea058642c931bef12e`. +- recomp-ui: `7c635c6b366a6e0d5d3c913a7fce6ec04dd9f256`. + +Archived the original app, state-test and capture-renderer executables before +editing. Initial custom-renderer and four Magic Carpet checks passed. + +### P1: immutable raster storage + +The shared header owns stable immutable byte images, uses exact comparison for +consecutive equality, and reuses its allocation high-water across frames. MMX +owns scanline-to-image references. Its hardware loop can also reuse an image +when the existing VRAM port-write count proves no intervening writes. Every +frame starts uncertain; every IRQ forces comparison; a line callback disables +counter reuse for the remaining raster. Trace builds and direct/mock captures +always compare bytes. No PPU state layout or device write path changed. + +The partner's writable CHR preview has separate backing. Capture files use +build-specific versions 24/25 with image indices; file pointers never become +usable host pointers. Failure rejects the custom frame and retains the stock +presentation path. No guest work is skipped. + +In the final Magic Carpet frame, VRAM capture fell from 224 copies (14,680,064 +bytes) to one copy (65,536 bytes) plus one conservative comparison. Frame +metadata is 662,296 bytes. Static BSS fell by 14,678,176 bytes; executable text +grew by 664 bytes. Heap images are allocated as needed, up to 224 versions for +a fully changing raster. This is a workload-dependent memory saving, not a +fixed 14 MiB reduction in every scene; allocations retain their high-water. + +The comparator-only prototype had noisy/negative route results. A custom word +comparison was slower than libc `memcmp` and was removed. Only the scoped +counter implementation below is retained. + +Five balanced pairs per aspect, warmups, and two same-executable pairs produced: + +| Headless Magic Carpet route | Median CPU throughput gain | Equivalent CPU-time reduction | Median wall throughput gain | +|---|---:|---:|---:| +| Native | 14.9% | 13.0% | 8.4% | +| 16:9 | 27.5% | 21.6% | 41.7% | +| 32:9 | 48.0% | 32.4% | 61.4% | + +Same-executable CPU differences were -4.9% and -3.5%; wall differences -9.8% +and -6.2%. Other host jobs were active. Native included one slower CPU pair; +all wide/ultrawide CPU pairs improved. Treat these as route-specific desktop +evidence, not whole-game FPS, presentation/audio-delivery timing or Xbox speed. +Every run had matching guest CSV and setup/active save SHA-256 hashes. Details: +`build/performance/route-ab-p1-final/results.json`. + +Nine original-versus-candidate BMP comparisons also matched byte-for-byte: +Storm Eagle samples 60, 900 and 1500, each at native, 16:9 and 32:9. Each +executable loaded its own build's capture format; no old-capture migration was +added. Evidence: `build/performance/route-pixels-p1/results.json`. + +### P2: rejected screening candidates + +A 64-entry tile-row cache used 2 KiB including its expansion table. Exhaustive +low-plane values and 2/4bpp checks matched scalar decoding. Balanced spatial +and random-access probes were slower with the cache, including repeated runs +with hit/miss instrumentation removed. Reject this cache design; do not infer +that every possible decoder or a different host would behave the same way. + +Sharing a combined 1,056-byte CGRAM/OAM image could save at most 235,488 bytes +in a constant frame. Two balanced probes were slower than copying, both for +constant and changing images. Retain simple copies for now. Revisit this as a +memory tradeoff only if Xbox measurements justify it. Synthetic evidence is +in scratch `p2-probes.json` and `p2-probes-uninstrumented.json`. + +### P3: bypass and abstraction decision + +Do not disable `MmxDrawPpuFrame`: HDMA and IRQs execute within its loop. +`ppu_runLine` also advances frame/mosaic/OAM history and sprite evaluation; +the latter sets the overflow flags read through $213E. MMX consumes stock +pixels for non-Mode-1 lines and scenes outside its supported custom paths. +The stock-frame accessor, capture utility and native-pixel checks are additional +consumers. A pixel-only bypass is therefore a separate implementation, not a +safe switch in this change. + +Before attempting it, specify per-line custom-pixel eligibility, retain +device/status behavior, and supply valid stock pixels to fallback, debugging +and comparison consumers, either eagerly or by correct materialization. Then +qualify actual scene/mode transitions and rollback. This bypass remains +unimplemented and unqualified. Do not describe the SNES optimization work as +exhausted or start the next system on that assumption. + +Keep MMX's renderer title-local. The snapshot mechanism is shared now; Super +Metroid's matching per-line capture is a concrete second-consumer candidate, +not yet an integrated/tested consumer. F-Zero's one-image Mode 7 path does not +justify a common MMX scene compositor. + +### P4: correctness and runtime qualification + +Optimized, forced-full-copy and trace renderer suites pass, including live +memory edits, consecutive changes, ABA, counter wrap, raw IRQ edits, sticky +callback fallback, immutable views, capture round-trip and invalid/truncated +input. Generic store tests include allocation failures and an independent +full-copy oracle. A Windows i386 object also compiles; no Xbox SDK/runtime or +other-OS build has been qualified. + +All four Magic Carpet cases pass, including the no-shot negative control. +Repeated native/wide/ultrawide rendering leaves the full rollback snapshot +byte-identical and advances no guest clocks. Current-build memory/file saves, +rewind and fresh-process replay pass. Original native save data is untouched. + +Full CTest initially passed 27 checks, skipped two asset-dependent saber checks, +and failed the existing trace-writer queue-saturation assertion. That unchanged +writer test passed twice on rerun. Keep this initial failure in the record. +Reset in both LLE/HLE modes and Boss Rush reports also passed. +After adding the fresh-fixture test mode, all eight focused renderer, Magic +Carpet and shared-store gates pass again (19.53 seconds). No production change +was made after the qualified snapshot implementation; the fixture switch is +test-executable initialization only. Timing JSON records the exact measured +executable hashes; scratch `final-manifest.json` records the final sources and +app/test executable hashes. + +The general state-test slot was unsuitable as a grounded gameplay fixture; +it failed partner placement before simulation/network qualification. The new +test-only `MMX_NETPLAY_FIXTURE_OUT` route instead boots normally and waits for +real Highway gameplay, a living player and standing ground contact. No guest +success flags or owner saves are edited to construct this fixture. + +Using that fresh fixture, both real lobby/runtime peer tests pass 480 gameplay +frames with a rollback episode and replay tick on each peer, no digest fork, +both players alive, matching final positions/health, and imported X2/X3 weapon +contexts. Independent views end at X=128/478 with different actual rendered +views; unified views end at X=128/352 within the shared separation limit. + +Two actual desktop hosts pass 180-frame rollback and delay sessions with audio +enabled and the dummy output device. A further 480-frame audio-enabled rollback +test passes save/load/cancel, deliberate guest-state and saved-slot mismatch +repair, new epoch agreement, and identical 326,777-byte peer slots. No offline +autosave pollution. These cold-boot/menu checks supplement the actual gameplay +pair; they do not constitute an audio-enabled whole-campaign audit. + +No rbengine/recomp-net code or protocol changed. Local tests used the same +candidate on both peers and retained existing compatibility checks. This does +not claim cross-build, cross-OS or Xbox interoperability. Capture allocation +history and host pointers remain outside authoritative snapshots/digests. + +The scalar helper compiles with `-m32 -march=i686 -mno-sse -mno-sse2 -mno-mmx +-mno-avx` to a PE-i386 object. That is not an Xbox executable. No nxdk tools +were found on this process's PATH and no original-Xbox build target was found +in the inspected project build files. Xbox needs a separate target/dependency +qualification: [nxdk](https://github.com/XboxDev/nxdk) documents original-Xbox +support and SDL2, while [SDL3's GDK backend](https://wiki.libsdl.org/SDL3/README-gdk) +documents Xbox One/Series. Do not mistake that SDL3 platform entry for a +qualified original-Xbox backend. No SDK was installed or hardware run attempted. + +### Reproduction and remaining gates + +Use a private working/output directory and non-C: TEMP/TMP, application data, +cache and log overrides for every command. Keep background output muted. Build +with `MMX_STATE_TESTS=ON`; the full-copy build option is +`MMX_RASTER_FULL_COPY=ON`. The small shared test directory is also standalone +CMake, independent of ROM generation and desktop dependencies. + +```text +ctest --test-dir build/performance --parallel 1 --output-on-failure +python tools/benchmark_raster_route.py --reference --candidate --rom --inputs tests/data/magic_carpet_inputs.csv --output --pairs 5 +``` + +Run `mmx_state_tests ` from an empty directory with +`MMX_NETPLAY_FIXTURE_OUT=` to create the current-build +fixture. Then run `tools/test_netplay_pair.py` with both owned source ROMs, +that fixture, and `--independent-views` or `--unified-views`; each test needs its +own output directory. `tools/test_netplay_desktop.py --audio` covers real desktop +hosts; add `--delay-sync` or `--savestate-menu --force-mismatch` for the other +checked modes. The tools' `--help` lists their required file arguments. + +The first tranche is locally qualified. Remaining work before claiming broad +SNES/target qualification: + +1. Publish/review the shared helper, then update the title's gitlink to a + reachable framework commit. Current changes remain isolated and unmerged. +2. Integrate/qualify a second consumer, such as Super Metroid, under that + consumer's actual capture/save/view contracts. +3. Establish an original-Xbox executable and host dependency path, then measure + the full code, heap, rollback/rewind, graphics and audio memory budget and + frame costs there. Desktop x64 results do not establish feasibility or speed. +4. If pursuing pixel-only bypass, implement the explicit eligibility and stock + framebuffer contract above and audit mode/scene transitions. No bypass has + passed that gate yet. +5. Cross-OS/CPU peer qualification needs actual target builds and matched + simulation results; local two-process sessions cannot substitute for it. + +### P5: compositor attribution and stage-map reuse + +The new `MMX_FRAME_COST_TEST` harness brackets guest execution, raster capture, +composition and rollback saves. Each clocked sequence starts from the same +state as ordinary `frame()` execution and must finish with identical full +rollback bytes and sampled pixels. `tools/attribute_frame_costs.py` runs Highway +idle, Storm setup and active Magic Carpet at native, 16:9 and 32:9 in private +process profiles. It records process CPU time separately: the four stage +measurements are wall time and include preemption. + +Two initial attribution runs used 22.25 and 22.09 CPU seconds for the complete +matrix. In the wide cases, composition accounted for about 83–91% of measured +stage time. This makes exact compositor work removal a stronger next candidate +than skipping stock pixels or changing CPU bus bookkeeping. These results do +not make the latter opportunities impossible or establish full-game FPS. + +The stage-map trial reuses the last 8-by-8 map-cell lookup separately for each +background layer. That lookup resolves captured RAM tables and ROM metatiles; +it ignores the low three coordinate bits. It is more expensive than the small +bit-row decoder tested and rejected in P2. Only its result is reused: pixel +decoding, palette changes, priority, windows and composition still run normally. +Keys reset at every draw, so a changed view, restored capture or an edit to the +same external ROM pointer cannot carry a prior result into another draw. +Failed lookups retain their reference behavior. No live guest memory is cached. + +This is MMX-specific map policy and stays in MMX. It needs no new shared PPU, +rbengine, capability profile or wire format. `MMX_STAGE_TILE_REUSE=OFF` retains +the portable reference; trace builds and reference renderer suites disable it. +The state-test binary adds 384 text bytes and 32 BSS bytes on this x64 build, +with no heap allocation. A four-quadrant map test checks every row at changing +widths and checks edits to the same ROM pointer between draws. All eight focused +renderer/reference/trace, Magic Carpet and shared-snapshot checks pass. + +Retained after five balanced A/B pairs over the complete three-scene, +three-aspect matrix. Median process CPU-time reduction is about 4.9% +(about 5.1% more throughput), relative to P1 with stage-map reuse disabled. +Two quieter confirmation pairs agree at about 4.8–4.9% time reduction; +the third is a large scheduling/frequency outlier and is not the estimate. +Reference A/A throughput variation was about 2.1%. All nine sampled pixel +hashes match across binaries and every run passes its control-state comparison. +These percentages describe this headless rendering workload; do not add them +to P1's differently weighted raster-route percentages or promise full-game FPS. + +Current-build memory/file save/load, rewind and fresh-process replay pass. +Actual gameplay peers pass independent and unified view tests, with real +rollback/replay. Audio-enabled desktop rollback, delay sync and forced +state/slot repair pass with muted output and no offline autosave pollution. +Nine ROM-backed captured-frame BMPs (three Storm route times by three aspects) +match the original pre-P1 renderer byte-for-byte. The cache stays outside +authoritative state and no rbengine/recomp-net changes were necessary. + +Evidence lives under +`G:/Temp/Codex/snesrecomp/mmx-major-costs-20261022/`; the `attribution-p1`, +`map-cache-ab`, `map-cache-confirm`, `map-cache-regression` and `map-cache-pixels` +directories preserve separate reports. +The MMX candidate set is settled. Applicable snapshot changes have now been +integrated into isolated SMW and Super Metroid worktrees on G:. + +### P6: combined SMW and Super Metroid candidates + +The owner's original checkouts remain untouched. SMW is based on +`55628231b1fe683fe96062ce5a7f322ab05ed8b3` with engine `8dc869c`; Super +Metroid is based on `fe37c5f9b01ac87c4d44b08fd46d40638639bc54` with engine +`bb37c87`. Isolated title, engine and UI worktrees and their builds live under +`G:/Temp/Codex/snesrecomp/snes-bundle-20261022/`. All three consumers use the +same exact snapshot helper. MMX's map lookup specialization remains MMX-only. + +The newer engine pins no longer have MMX's existing VRAM write counter. Their +initial exact-compare implementation passed correctness checks and saved +static storage, but its CPU measurements were mixed. It was not accepted as +a general CPU win. Both newer pins now have an optional, default-off +`SNESRECOMP_PPU_CAPTURE_REVISION` signal enabled by each adopting title's build. +The derived revision is outside guest snapshots. Actual VRAM port writes, +native word writes and native text updates advance it. The title-owned raster +compares its first line and after IRQ/native uncertainty; callbacks and direct +callers retain exact comparison. Palette, OAM and register values remain +independent per line. No rbengine or recomp-net implementation/wire changes +were needed. This does not qualify different builds or external CPU/OS peers. + +The real PPU device-port revision test passes enabled and disabled, including +read-only operations, reset and counter wrap. SMW renderer tests pass tracked, +byte-compare, full-copy and trace-on configurations. SM passes all 11 CTests, +including the full-copy renderer and real-PPU revision test; its separate +byte-compare and trace-on renderer checks pass too. The final test-only oracle +edit was rebuilt and both affected SM renderer suites passed again. + +SMW's final original/candidate routes run 4000 audio-enabled simulations at +4:3, 16:9 and 32:9. All 21 BMPs, draw lists, sprite telemetry and every common +frame telemetry row match. Each tested raster uses one 64KiB VRAM image, +replacing 224 copies (14MiB); IRQ fences require at most one exact comparison +per frame on this route. Ordinary file load checks compare the documented +functional partitions because file loads deliberately re-anchor chip clocks; +they are not a full-chip rollback equality claim. The invalid full-byte +file-replay expectation was closed as `beads-3s7n`, not treated as an engine +regression or used to weaken rollback checks. + +SM stock/wide/120Hz runs preserve their per-simulation trace at 3200 frames; +the final 120Hz run produced 6424 presentations. Nine fixed-source artifacts +match the original across binaries. Independent live interpolated final BMPs +have uncontrolled phase and are excluded from that comparison. The Ceres +diagnostic positions escape prerequisites and reaches adjacent guest-produced +changing Mode-7 matrices; it does not validate natural escape completion. +Its 11000-frame stock/wide traces match. Original/new capture utilities render +that previous/current pair identically at alpha 0, 0.5 and 1 for 4:3, 16:9 and +32:9 (nine exact BMP pairs with active custom Mode-7). Sampled Landing Site and +Ceres captures each contain one consecutive VRAM version. Both runtime frame +stores retain independent ownership for interpolation. + +The copied owner packages pass audio-emulating, speaker-muted startup checks. +SMW's actual Mods-selected adaptive Fit/screen-based-spawn package reproduces +the qualified 16:9 gameplay BMP exactly. SM's packaged Fit settings, normal +sprite configuration and enabled rewind also match the original for the +gameplay BMP, full source capture and 3200 simulation trace rows. The latter +route logs the same pre-existing unresolved-abandon warning at `$88D865`, +frame 2659, in original and candidate. This campaign establishes optimization +invariance there, not correctness of that unresolved dispatch. + +Static BSS reductions versus originals are 14,678,176 bytes for SMW and +29,356,416 bytes for SM; text grows 1216 and 2856 bytes respectively. The +snapshot heap retains each store's version highwater, bounded by 224 images +per frame store. These are static storage savings, not fixed process RSS +reductions; rewind, SDL and other existing allocations remain separate. + +The local Ryzen host became heavily contended, so balanced final CPU probes +use the owner's offered NAS via its existing SSH configuration: Windows 10, +Intel i5-4670, unchanged x64 GCC 15.2 Release binaries, audio emulation on, +dummy presentation/output devices. All remote bulk data/profile/cache/temp +paths are under `G:/Temp/Codex/snesrecomp/snes-perf-20261022/`; no services or +machine-wide settings are changed. The remote driver checks exact state +artifacts, warms both binaries, runs an A/A control and three balanced A/B +pairs per aspect. SMW's completed median CPU-time savings are 39.35% at 4:3, +21.14% at 16:9 and 15.93% at 32:9; its A/A difference is -0.047%. This is the +whole headless natural-input route including menu, YI2, death and map return. +SM's completed boot/attract-to-Landing-Site matrix saves a median 23.01% CPU +time at 16:9 and 22.96% at 32:9. Stock 4:3 has no qualified gain: its median +uses 0.22% more CPU, with paired time-savings samples +0.22%, -2.26%, -0.22%. +The SM A/A control has identical measured process CPU time. SMW's 4:3 case +keeps its adaptive renderer enabled; SM's 4:3 case uses the stock renderer. +All 44 runs pass their exact artifact checks, and their recorded binary +identities match local originals/candidates. Results and host metadata are +mirrored locally under `final-performance-nas`; full run artifacts remain +on the NAS. Neither route establishes whole-campaign FPS or original-Xbox +runtime performance. These percentages are time reductions; they must not +be added to MMX's differently weighted earlier workload percentages. + +Detailed evidence: `smw-tracked-qualified`, `sm-tracked-qualified`, +`sm-ceres-tracked`, `sm-final-exact`, `snapshot-stats.json`, `playtest-smoke` +and `sm-fit-package-qualified` under the G: bundle root. Final packages are +under `playtests/`, with executable/ROM/DLL identities in `manifest.json`. +They have private progress, normal window/audio launchers, adaptive Fit and +SMW screen-based spawning. The earlier owner MMX process/folder is preserved. +Owner final feedback after launching all three combined candidates: +"all 3 pass." This approves the tested candidate set for general gameplay/ +correctness integration; it supplements the automated evidence without +establishing full-campaign or Xbox qualification. The launcher template's +APP marker initially also replaced APPDATA variable names. Replacement now +matches the quoted marker only; all three corrected launchers were executed +and their actual window handles verified before this owner approval. + +The exact qualified engine revisions are published as `bb156e1` (MMX's `7a67b85` base), +`4c9d7b0` (SMW's `8dc869c` base), and `53624eff` (SM's `bb37c87` base). +The game pins may now reference these reachable revisions. Current upstream +history has advanced since the candidate bases: MMX main still pins engine +`7a67b85`, SMW main pins `fa018f3` and has native co-op renderer changes, and +SM main pins `3cbd954` with newer runtime/release changes. Preserve those +upstream improvements during any later main-branch integration; the owner's +choice of landing target was requested while qualified commits are prepared. +Launch links: `playtests/SuperMarioWorld/Play.cmd`, +`playtests/SuperMetroid/Play.cmd`, and optional updated +`playtests/MegaManX/Play.cmd`. `playtests/RESULTS.txt` records scope and limits. +Central Beads updates are recorded locally; defer the next expensive sync +until competing ecosystem builds are quiet, then sync before integration. + +## Reusable discovery checklist + +Carry this method into the later ecosystems; do not assume their bottlenecks +or safe substitutions match SNES. Accurate reference code can contain costly +host representations without requiring those costs for correctness. + +1. Inventory work per guest frame: copied bytes, allocations, clears, repeated + decoding, dispatch, device execution, synchronization and presentation. + Check multiplication by scanlines, views and rollback replay count. +2. Separate required guest behavior from host representation. Preserve timing, + ordering, side effects and readbacks; look first for redundant host work. + Here, per-line VRAM values were required, but 224 independent copies were not. +3. Prove ownership and invalidation. Account for DMA, interrupts, direct mod + writes, debugger edits, restore, reset and reused addresses. Use scoped + proofs or exact comparisons where a global dirty counter is insufficient. +4. Keep shared mechanisms separate from title policy. Share storage/decoding + contracts where there are real consumers; retain each game's scene, asset, + allocator and camera rules. Keep a portable reference path. +5. Measure headroom with balanced real-workload comparisons and a noise control. + Capped FPS hides savings. Report CPU/frame work separately from wall time, + presentation and audio delivery. Record code, heap and allocation high-water; + memory removed from static storage may reappear on the heap. +6. Verify identical guest state, pixels and current-build restore/replay behavior + as applicable. Vary presentation count, view width and cache warmth; exercise + real rollback and audio paths. Keep external hardware/OS coverage explicit. +7. Reject attractive ideas when they lose. The small tile-row cache and + palette/OAM compare/reuse probes were slower here. Cache lookup, comparison + and invalidation can cost more than the work they replace. + +This result does not establish why previous agents did not pursue it. The +current optimization guidance explicitly permits exact host-cost removal; +correctness and determinism constrain the proof, rather than banning the work. +Use the findings to improve future discovery, without treating an unprofiled +subsystem as inherently necessary overhead or promising a gain in advance. + +## Follow-up systems + +Owner gameplay/feel handoff: ignored `build/playtest-raster-20261022/Play.cmd`. +Normal fresh boot, 1280x720 window, normal audio, adaptive widescreen enabled, +separate settings/progress, no Magic Carpet fixture required. The copied app +matches the qualified app SHA-256 and completed a muted 180-frame startup smoke +test with audio synthesis enabled. The launcher restores normal video/audio +drivers and keeps temporary data, profiles and caches within the playtest folder. + +Owner feedback: "mmx seems fine." Record this as no observed regression in +the owner's general gameplay/feel playtest. This supplements the automated +evidence; it does not establish full-campaign or original-Xbox qualification. + +Latest owner instruction (2026-10-22): the method is sufficiently settled to +provide self-contained prompts now for NDS, GBA, NES and Sega Genesis, plus a +tentative Virtual Boy prompt. The owner will paste them into separate windows. +This supersedes both the serial plan and the requirement to wait for final +SNES approval before issuing prompts. Work in this SNES window is explicitly +paused after delivering the prompts, until the owner acknowledges and asks +to resume. Carry forward the measured method, invariance/restore/netplay +gates, rejected probes and scope limits; each ecosystem must find its own +bottlenecks. + +### Pause checkpoint + +Historical checkpoint: the owner subsequently said "You may continue". +Work has resumed; see P6 above for the newer qualification/package results. + +All work remains local and uncommitted; game gitlinks have not been changed +to unreachable engine commits. Preserve the owner's existing MMX playtest. + +- Optional PPU capture revision is implemented in both newer pinned engines, + after the guest snapshot range, default off unless a title enables it. + Actual port writes/read/reset/wrap tests pass with tracking on and off. + IRQ/native/callback fences retain exact comparison; direct captures compare. +- SMW write-tracked/full-copy/byte-compare renderer checks pass. Both full apps + build. Updated SMW matches its original at 4:3, 16:9 and 32:9: all 21 BMPs, + draw lists, sprite telemetry and 4000 common frame rows per aspect match. + Functional file-load partitions pass under the documented clock-reanchoring + contract; this is not a full-chip rollback-byte claim. +- SM passes all 11 CTests, including full-copy and real-PPU revision tests. + The byte-compare renderer suite also passes. Updated stock/wide/120Hz runs + match their simulation traces; 3200 simulations produced 6424 FPS-mode + presentations. The Ceres fixture also passes stock/wide traces for 11000 + frames and reaches adjacent guest-produced changing Mode-7 captures. + Escape prerequisites are positioned by the diagnostic script, not validated + as a natural escape completion. +- Final BSS reductions versus originals: SMW 14,678,176 bytes; SM 29,356,416 + bytes. Text growth: SMW 1216 bytes; SM 2856 bytes. Heap highwater depends on + distinct VRAM versions, so these are static storage differences, not fixed + RSS reductions. + +Resume from `G:/Temp/Codex/snesrecomp/snes-bundle-20261022/`: + +1. Compile/run trace-on renderer variants after the test-oracle conditional + edit, and rerun the affected SM renderer tests (only test sources changed + since the successful app builds). +2. Compare updated SM fixed-source artifacts to the original and render the + Ceres previous/current pair with original/new utilities at fixed alpha + and multiple aspects. Source: `sm-ceres-tracked/sm-renderer-m_8de132/wide/`. + The live FPS final BMP has uncontrolled interpolation phase and must not + be asserted identical across independent runs. +3. Run `benchmark-final.py` only with a quiet host and no competing builds or + Beads sync. It has been written but NOT run. It balances originals against + final candidates with warmups/A-A checks and checks state artifacts. SMW + scope includes menus, YI2, death and map return; SM scope is boot/attract to + Landing Site. Earlier compare-only timings were mixed; no final gain claim. +4. Package separate updated MMX/SMW/SM playtests, normal window/audio, private + F:/G: profiles and native saves, adaptive Fit, SMW adaptive spawning. Verify + copied executables, muted startup smokes, and preserve old owner sessions. +5. Record exact findings in Beads/docs, sync only when heavy work is quiet, + then hand off for the owner's combined gameplay/correctness approval. + +Current completed evidence: `smw-tracked-qualified`, +`sm-tracked-qualified/sm-renderer-dedl6nz5`, and +`sm-ceres-tracked/sm-renderer-m_8de132`. No agent-started build or game test is +left running at this pause. Final speed estimates, Xbox runtime qualification, +the combined owner handoff and owner approval remain outstanding. diff --git a/snesrecomp b/snesrecomp index 7a67b857..bb156e13 160000 --- a/snesrecomp +++ b/snesrecomp @@ -1 +1 @@ -Subproject commit 7a67b85724a8b3be5b592c5a7578e35f739c81fb +Subproject commit bb156e13398971f4b7d149b17a500f5b6aa28c91 diff --git a/src/mmx_renderer.c b/src/mmx_renderer.c index 59dcfdf2..5edb9cdc 100644 --- a/src/mmx_renderer.c +++ b/src/mmx_renderer.c @@ -1,4 +1,5 @@ #include "mmx_renderer.h" +#include "raster_snapshot.h" #include "mmx_display.h" #include "mmx_wide_policy.h" #include "mmx_render_assets.h" @@ -16,9 +17,32 @@ * 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]; + uint16_t palette[256], oam[256]; uint8_t high_oam[32]; + const uint16_t *vram; } Raster; +/* Storage is derived presentation data. Generic captures compare whole images; + * the title-owned hardware raster can additionally prove unchanged intervals + * using the existing port-write count. Every frame and IRQ starts uncertain. + * Never serialize these host pointers as simulation state. */ +static SnesRasterSnapshotStore vram_snapshots; +static bool capture_failed; +static const Ppu *hardware_raster_ppu; +static uint32_t hardware_vram_writes; +static bool hardware_vram_tracking, hardware_vram_uncertain; +#ifndef MMX_RASTER_FULL_COPY +#define MMX_RASTER_FULL_COPY 0 +#endif +#ifndef MMX_STAGE_TILE_REUSE +#define MMX_STAGE_TILE_REUSE 1 +#endif +#if SNESRECOMP_TRACE +#undef MMX_STAGE_TILE_REUSE +#define MMX_STAGE_TILE_REUSE 0 +#endif +#if MMX_STAGE_TILE_REUSE +static struct { unsigned key; uint16_t entry; bool found; } stage_tile_cache[2]; +#endif static MmxBossRushState frame_rush; static MmxBossRushVisualState frame_rush_visual; typedef struct Piece { @@ -72,6 +96,7 @@ static int view_camera(const uint8_t *r,unsigned address) { void MmxRendererHoldFrame(bool held) {frame_held=held;} static uint8_t partner_ram[0x20000]; static Raster partner_raster; +static uint16_t partner_vram[0x8000]; static Piece building[MAX_PIECES], latched[MAX_PIECES]; static unsigned building_count, latched_count; static uint8_t building_stage, latched_stage; @@ -217,6 +242,8 @@ void MmxRendererReset(void) { frame_world_sprite_count = 0; frame_debug_rect_count = 0; frame.valid = false; frame.captured = 0; + capture_failed = false; + hardware_vram_tracking = false; building_count = latched_count = 0; building_stage = latched_stage = 0xff; expanded_building_count = expanded_latched_count = 0; @@ -534,6 +561,8 @@ void MmxRendererBeginFrame(const uint8_t ram[0x20000]) { debug_rect_count * sizeof(*frame_debug_rects)); trace_objects(ram); frame.valid = false; frame.captured = 0; + hardware_vram_tracking = false; + capture_failed = !SnesRasterSnapshotBegin(&vram_snapshots, 0x10000, 224); memcpy(frame.ram, ram, sizeof(frame.ram)); frame_zero = MmxZeroGetState(); frame_bounds=MmxCoopViewsGetBoundsState(); @@ -760,22 +789,55 @@ bool MmxRendererRidePilotFacingForTest(bool *native_hflip, *drawn_mirror = frame_player_overlay.facing_left; return true; } +void MmxRendererBeginHardwareRaster(const Ppu *p) { + hardware_raster_ppu = p; + hardware_vram_writes = p ? p->vramWriteCount : 0; + hardware_vram_uncertain = true; + /* Trace commands can inject/restore arbitrary memory. Byte comparisons + * remain the reference there. Callback-owned VRAM is also never assumed. */ +#if SNESRECOMP_TRACE + hardware_vram_tracking = false; +#else + hardware_vram_tracking = p && !p->widescreenLineEnhancer; +#endif +} +void MmxRendererEndHardwareRaster(void) { hardware_vram_tracking = false; } +void MmxRendererHardwareVramChanged(void) { hardware_vram_uncertain = true; } void MmxRendererCaptureLine(const Ppu *p, unsigned line) { - if (!p || line < 1 || line > 224 || line != frame.captured + 1) return; + if (!p || capture_failed || 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)); + /* Only the title-owned scanline loop enables counter reuse. CPU execution + * (including native hooks) happens at IRQs, which explicitly invalidate it. + * HDMA stores go through the PPU ports and bump vramWriteCount. There cannot + * be 2^32 VRAM stores between adjacent hardware scanlines; wrapping once is + * fine. Direct/mock captures use exact byte comparisons instead. */ + if (p != hardware_raster_ppu || p->widescreenLineEnhancer) + hardware_vram_tracking = false; + if (!MMX_RASTER_FULL_COPY && hardware_vram_tracking && !hardware_vram_uncertain && + frame.captured && hardware_vram_writes == p->vramWriteCount) + r->vram = vram_snapshots.images[vram_snapshots.count - 1]; + else + r->vram = SnesRasterSnapshotCapture(&vram_snapshots, p->vram, !MMX_RASTER_FULL_COPY); + hardware_vram_writes = p->vramWriteCount; + hardware_vram_uncertain = false; + if (!r->vram) { capture_failed = true; return; } ++frame.captured; } bool MmxRendererEndFrame(const uint32_t stock[256 * 224]) { - if (!stock || frame.captured != 224) return false; + if (!stock || capture_failed || frame.captured != 224) return false; memcpy(frame.stock, stock, sizeof(frame.stock)); return frame.valid = true; } MmxRenderStats MmxRendererGetStats(void) { return stats; } +MmxRasterStats MmxRendererGetRasterStats(void) { + return (MmxRasterStats){vram_snapshots.copied_bytes, + vram_snapshots.allocated * vram_snapshots.image_bytes, + sizeof(frame), vram_snapshots.count, vram_snapshots.compared_bytes}; +} const uint32_t *MmxRendererStockFrame(void) { return frame.valid ? frame.stock : NULL; } void MmxRendererCoopFrame(const MmxCoopState *s) { if (!s || !s->initialized) { memset(&frame_coop,0,sizeof(frame_coop)); return; } @@ -807,103 +869,110 @@ void MmxRendererCoopFrame(const MmxCoopState *s) { frame_partner_overlay=overlay; } } +/* Capture files are build-specific. Version 24/25 records pointer-free frame + * metadata, image indices and only the distinct immutable VRAM images. */ bool MmxRendererSaveCapture(const char *path) { - if (!frame.valid || !path) return false; + if (!frame.valid || !path || !vram_snapshots.count || + vram_snapshots.image_bytes != 0x10000) return false; + uint16_t indices[224]; + for (unsigned line = 0; line < 224; ++line) { + unsigned image = 0; + while (image < vram_snapshots.count && + frame.lines[line].vram != vram_snapshots.images[image]) ++image; + if (image == vram_snapshots.count) return false; + indices[line] = (uint16_t)image; + } + Frame *saved = malloc(sizeof(*saved)); + if (!saved) return false; + memcpy(saved, &frame, sizeof(*saved)); + for (unsigned line = 0; line < 224; ++line) saved->lines[line].vram = NULL; FILE *f = fopen(path, "wb"); - if (!f) return false; - uint32_t header[] = {0x4d4d5843, frame_coop.initialized ? 23 : 22, - sizeof(frame) + sizeof(frame_zero) + sizeof(frame_weapons) + sizeof(frame_weapon_combat) + - (frame_coop.initialized ? sizeof(frame_coop) : 0) + sizeof(frame_rush) + sizeof(frame_rush_visual)+sizeof(frame_bounds)}; - bool ok = fwrite(header, sizeof(header), 1, f) == 1 && fwrite(&frame, sizeof(frame), 1, f) == 1 && - fwrite(&frame_zero, sizeof(frame_zero), 1, f) == 1 && + if (!f) { free(saved); return false; } + uint32_t count = vram_snapshots.count; + uint32_t header[] = {0x4d4d5843, frame_coop.initialized ? 25 : 24, + (uint32_t)(sizeof(frame) + sizeof(count) + sizeof(indices) + count * 0x10000u + + sizeof(frame_zero) + sizeof(frame_weapons) + sizeof(frame_weapon_combat) + + (frame_coop.initialized ? sizeof(frame_coop) : 0) + sizeof(frame_rush) + + sizeof(frame_rush_visual) + sizeof(frame_bounds))}; + bool ok = fwrite(header, sizeof(header), 1, f) == 1 && + fwrite(saved, sizeof(*saved), 1, f) == 1 && + fwrite(&count, sizeof(count), 1, f) == 1 && + fwrite(indices, sizeof(indices), 1, f) == 1; + free(saved); + for (unsigned image = 0; ok && image < count; ++image) + ok = fwrite(vram_snapshots.images[image], 0x10000, 1, f) == 1; + if (ok) ok = fwrite(&frame_zero, sizeof(frame_zero), 1, f) == 1 && fwrite(&frame_weapons, sizeof(frame_weapons), 1, f) == 1 && fwrite(&frame_weapon_combat, sizeof(frame_weapon_combat), 1, f) == 1; - if (ok && frame_coop.initialized) ok = fwrite(&frame_coop,sizeof(frame_coop),1,f) == 1; - if(ok) ok=fwrite(&frame_rush,sizeof(frame_rush),1,f)==1; - if(ok) ok=fwrite(&frame_rush_visual,sizeof(frame_rush_visual),1,f)==1; - if(ok) ok=fwrite(&frame_bounds,sizeof(frame_bounds),1,f)==1; + if (ok && frame_coop.initialized) ok = fwrite(&frame_coop, sizeof(frame_coop), 1, f) == 1; + if (ok) ok = fwrite(&frame_rush, sizeof(frame_rush), 1, f) == 1 && + fwrite(&frame_rush_visual, sizeof(frame_rush_visual), 1, f) == 1 && + fwrite(&frame_bounds, sizeof(frame_bounds), 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] = {0}; + uint32_t h[3] = {0}, count = 0; + uint16_t indices[224]; frame.valid = false; reset_pilot_overlay_alignment(); memset(&frame_zero, 0, sizeof(frame_zero)); memset(&frame_weapons, 0, sizeof(frame_weapons)); memset(&frame_weapon_combat, 0, sizeof(frame_weapon_combat)); - memset(&frame_coop,0,sizeof(frame_coop)); - memset(&frame_bounds,0,sizeof(frame_bounds)); - memset(&frame_rush,0,sizeof(frame_rush)); - memset(&frame_rush_visual,0,sizeof(frame_rush_visual)); + memset(&frame_coop, 0, sizeof(frame_coop)); + memset(&frame_bounds, 0, sizeof(frame_bounds)); + memset(&frame_rush, 0, sizeof(frame_rush)); + memset(&frame_rush_visual, 0, sizeof(frame_rush_visual)); bool ok = fread(h, sizeof(h), 1, f) == 1 && h[0] == 0x4d4d5843 && - ((h[1] == 2 && h[2] == sizeof(frame)) || (h[1] == 3 && h[2] == sizeof(frame) + MMX_ZERO_LEGACY_STATE_SIZE) || - (h[1] == 4 && h[2] == sizeof(frame) + MMX_ZERO_ANIMATION_STATE_SIZE) || - (h[1] == 5 && h[2] == sizeof(frame) + MMX_ZERO_COMBAT_STATE_SIZE) || - (h[1] == 6 && h[2] == sizeof(frame) + MMX_ZERO_SWAP_STATE_SIZE) || - (h[1] == 7 && h[2] == sizeof(frame) + MMX_ZERO_HEALTH_STATE_SIZE) || - (h[1] == 8 && h[2] == sizeof(frame) + MMX_ZERO_HEALTH_STATE_SIZE + MMX_WEAPONS_LEGACY_STATE_SIZE) || - (h[1] == 9 && h[2] == sizeof(frame) + MMX_ZERO_HEALTH_STATE_SIZE + MMX_WEAPONS_LEGACY_STATE_SIZE + MMX_WEAPON_COMBAT_LEGACY_SIZE) || - (h[1] == 10 && h[2] == sizeof(frame) + MMX_ZERO_HEALTH_STATE_SIZE + sizeof(frame_weapons) + MMX_WEAPON_COMBAT_LEGACY_SIZE) || - (h[1] == 11 && h[2] == sizeof(frame) + MMX_ZERO_HEALTH_STATE_SIZE + sizeof(frame_weapons) + MMX_WEAPON_COMBAT_DAMAGE_SIZE) || - (h[1] == 12 && h[2] == sizeof(frame) + MMX_ZERO_HEALTH_STATE_SIZE + sizeof(frame_weapons) + sizeof(frame_weapon_combat)) || - (h[1] == 13 && h[2] == sizeof(frame) + MMX_ZERO_HEALTH_STATE_SIZE + sizeof(frame_weapons) + sizeof(frame_weapon_combat) + MMX_COOP_LEGACY_STATE_SIZE) || - (h[1] == 14 && h[2] == sizeof(frame) + sizeof(frame_zero) + sizeof(frame_weapons) + sizeof(frame_weapon_combat)) || - ((h[1] == 15 || h[1] == 17) && h[2] == sizeof(frame) + sizeof(frame_zero) + sizeof(frame_weapons) + sizeof(frame_weapon_combat) + sizeof(frame_coop)) || - (h[1] == 16 && h[2] == sizeof(frame) + sizeof(frame_zero) + sizeof(frame_weapons) + sizeof(frame_weapon_combat)) || - ((h[1]>=18 && h[1]<=23) && h[2]==sizeof(frame)+sizeof(frame_zero)+sizeof(frame_weapons)+sizeof(frame_weapon_combat)+sizeof(frame_rush)+((h[1]&1)?sizeof(frame_coop):0)+(h[1]>=20?sizeof(frame_rush_visual):0)+(h[1]>=22?sizeof(frame_bounds):0))) && - fread(&frame, sizeof(frame), 1, f) == 1 && - frame.captured == 224 && frame.piece_count <= MAX_PIECES && frame.expanded_count <= MAX_PIECES && frame.valid; - size_t zero_size = h[1] == 3 ? MMX_ZERO_LEGACY_STATE_SIZE : - h[1] == 4 ? MMX_ZERO_ANIMATION_STATE_SIZE : - h[1] == 5 ? MMX_ZERO_COMBAT_STATE_SIZE : - h[1] == 6 ? MMX_ZERO_SWAP_STATE_SIZE : h[1] < 14 ? MMX_ZERO_HEALTH_STATE_SIZE : sizeof(frame_zero); - if (ok && h[1] >= 3) ok = fread(&frame_zero, zero_size, 1, f) == 1 && - frame_zero.active_x <= 1 && frame_zero.swap_phase <= 6 && frame_zero.swap_tick <= 30 && - frame_zero.swap_y >= -320 && frame_zero.swap_y <= 0 && - frame_zero.slash <= 46 && frame_zero.air <= 1 && frame_zero.anim_valid <= 1 && frame_zero.anim_pose < 117 && - frame_zero.burst <= 2 && (!frame_zero.burst || (frame_zero.burst_offset >= 272 && - frame_zero.burst_offset + 3 <= MMX_ZERO_ANIMATION_BYTES && frame_zero.burst_timer)); - if (ok && h[1] >= 8) ok = fread(&frame_weapons, h[1] >= 10 ? sizeof(frame_weapons) : MMX_WEAPONS_LEGACY_STATE_SIZE, 1, f) == 1 && - MmxWeaponsValidState(&frame_weapons); - if (ok && h[1] >= 9) ok = fread(&frame_weapon_combat, - h[1]>=12 ? sizeof(frame_weapon_combat) : h[1]==11 ? MMX_WEAPON_COMBAT_DAMAGE_SIZE : MMX_WEAPON_COMBAT_LEGACY_SIZE, 1, f) == 1 && + (h[1] == 24 || h[1] == 25) && fread(&frame, sizeof(frame), 1, f) == 1; + const bool valid = true; + ok = ok && !memcmp(&frame.valid, &valid, sizeof(valid)) && + frame.captured == 224 && frame.piece_count <= MAX_PIECES && frame.expanded_count <= MAX_PIECES; + frame.valid = false; + /* File bytes never become usable host pointers, including on failure. */ + for (unsigned line = 0; line < 224; ++line) frame.lines[line].vram = NULL; + ok = ok && fread(&count, sizeof(count), 1, f) == 1 && count && count <= 224 && + h[2] == sizeof(frame) + sizeof(count) + sizeof(indices) + count * 0x10000u + + sizeof(frame_zero) + sizeof(frame_weapons) + sizeof(frame_weapon_combat) + + (h[1] == 25 ? sizeof(frame_coop) : 0) + sizeof(frame_rush) + + sizeof(frame_rush_visual) + sizeof(frame_bounds) && + fread(indices, sizeof(indices), 1, f) == 1; + for (unsigned line = 0; ok && line < 224; ++line) ok = indices[line] < count; + uint16_t *source = ok ? malloc(0x10000) : NULL; + ok = ok && source && SnesRasterSnapshotBegin(&vram_snapshots, 0x10000, 224); + for (unsigned image = 0; ok && image < count; ++image) + ok = fread(source, 0x10000, 1, f) == 1 && + SnesRasterSnapshotCapture(&vram_snapshots, source, false) != NULL; + free(source); + if (ok) ok = fread(&frame_zero, sizeof(frame_zero), 1, f) == 1 && MmxZeroValidState(&frame_zero) && + fread(&frame_weapons, sizeof(frame_weapons), 1, f) == 1 && MmxWeaponsValidState(&frame_weapons) && + fread(&frame_weapon_combat, sizeof(frame_weapon_combat), 1, f) == 1 && MmxWeaponsValidCombatState(&frame_weapon_combat); - if (ok && h[1]<16) { - /* Older captures only kept palette bits. Use their saved pose as the - * best available approximation; all new captures retain submission. */ - for (unsigned list=0;list<2;++list) { - Piece *pieces=list ? frame.expanded : frame.pieces; - unsigned count=list ? frame.expanded_count : frame.piece_count; - for (unsigned i=0;ianimation==0x0e || p->animation==0x9e) && p->object<0x1fe0) { - unsigned pose=frame.ram[p->object+23]&31; - p->palette_pose=(p->palette_pose&14)|(pose&1)|((pose&30)<<3); - } - } - } - } - if (ok && (h[1] == 13 || h[1] == 15 || h[1] == 17 || h[1]==19 || h[1]==21 || h[1]==23)) { - MmxCoopState coop; - if (h[1] == 13) { - uint8_t legacy[MMX_COOP_LEGACY_STATE_SIZE]; - ok = fread(legacy,sizeof(legacy),1,f) == 1; - if (ok) MmxCoopImportLegacy(&coop,legacy); - } else ok = fread(&coop,sizeof(coop),1,f) == 1; - ok = ok && coop.initialized == 1; - for (unsigned i=0;ok && i<2;++i) ok = coop.players[i].character <= MMX_COOP_ZERO && - coop.players[i].status <= MMX_COOP_FALLEN && MmxZeroValidState(&coop.players[i].zero) && - MmxWeaponsValidState(&coop.players[i].weapons) && MmxWeaponsValidCombatState(&coop.players[i].combat); - if (ok) MmxRendererCoopFrame(&coop); - } - if(ok && h[1]>=18) ok=fread(&frame_rush,sizeof(frame_rush),1,f)==1 && MmxBossRushValidState(&frame_rush); - if(ok && h[1]>=20) ok=fread(&frame_rush_visual,sizeof(frame_rush_visual),1,f)==1 && MmxBossRushVisualValidState(&frame_rush_visual); - if(ok && h[1]>=22) ok=fread(&frame_bounds,sizeof(frame_bounds),1,f)==1 && MmxCoopViewsValidBoundsState(&frame_bounds); - ok = ok && fgetc(f) == EOF; - fclose(f); frame.valid = ok; return ok; + if (ok && h[1] == 25) { + ok = fread(&frame_coop, sizeof(frame_coop), 1, f) == 1 && + frame_coop.initialized == 1 && frame_coop.current < 2; + for (unsigned i = 0; ok && i < 2; ++i) ok = + frame_coop.players[i].character <= MMX_COOP_ZERO && + frame_coop.players[i].status <= MMX_COOP_FALLEN && + MmxZeroValidState(&frame_coop.players[i].zero) && + MmxWeaponsValidState(&frame_coop.players[i].weapons) && + MmxWeaponsValidCombatState(&frame_coop.players[i].combat); + } + if (ok) ok = fread(&frame_rush, sizeof(frame_rush), 1, f) == 1 && MmxBossRushValidState(&frame_rush) && + fread(&frame_rush_visual, sizeof(frame_rush_visual), 1, f) == 1 && MmxBossRushVisualValidState(&frame_rush_visual) && + fread(&frame_bounds, sizeof(frame_bounds), 1, f) == 1 && MmxCoopViewsValidBoundsState(&frame_bounds) && + fgetc(f) == EOF; + fclose(f); + if (ok) { + for (unsigned line = 0; line < 224; ++line) + frame.lines[line].vram = vram_snapshots.images[indices[line]]; + if (frame_coop.initialized) { MmxCoopState coop = frame_coop; MmxRendererCoopFrame(&coop); } + } + frame.valid = ok; + if (!ok) frame.captured = 0; + return ok; } bool MmxRendererStageTile(const uint8_t ram[0x20000], unsigned layer, @@ -921,6 +990,24 @@ bool MmxRendererStageTile(const uint8_t ram[0x20000], unsigned layer, if (!tile) return false; *entry = (uint16_t)word(tile, 0); return true; } +static bool stage_tile_for_draw(unsigned layer, int x, int y, uint16_t *entry) { +#if MMX_STAGE_TILE_REUSE + if (layer > 1 || x < 0 || y < 0 || x >= 8192 || y >= 8192) return false; + /* The reference ignores the low three coordinate bits. frame.ram is + * immutable during a draw; reset the keys at every Draw, including after + * load, a view change or an edit to the external ROM. No live RAM cache. */ + unsigned key = ((unsigned)y >> 3) * 1024 + ((unsigned)x >> 3); + if (stage_tile_cache[layer].key != key) { + stage_tile_cache[layer].key = key; + stage_tile_cache[layer].found = MmxRendererStageTile(frame.ram, layer, x, y, + &stage_tile_cache[layer].entry); + } + if (stage_tile_cache[layer].found) *entry = stage_tile_cache[layer].entry; + return stage_tile_cache[layer].found; +#else + return MmxRendererStageTile(frame.ram, layer, x, y, entry); +#endif +} 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); @@ -1178,7 +1265,7 @@ 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)) { + if (stage_tile_for_draw(layer, wx, wy, &mapped)) { tile = mapped; px = wx; py = wy; /* These backdrops move at half speed. Express the map column as the player * X at which it crosses the native view's center (camera+128). */ @@ -1538,6 +1625,10 @@ static void prepare_partner_graphics(void) { * captured state, including WRAM-backed armor/effects, and changes no guest * memory, transfer timing, or save/rollback layout. */ partner_raster = frame.lines[0]; + /* Partner pose previews may patch CHR. Keep their writable image separate + * from the shared immutable scanline images. */ + memcpy(partner_vram, partner_raster.vram, sizeof(partner_vram)); + partner_raster.vram = partner_vram; unsigned end = frame.ram[0xa3] & ~7u; for (unsigned offset=0;offset 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; +#if MMX_STAGE_TILE_REUSE + stage_tile_cache[0].key = stage_tile_cache[1].key = ~0u; +#endif memset(door_cache, 0, sizeof(door_cache)); memset(out, 0, (size_t)view.width * 224 * sizeof(*out)); if(frame_rush.mode==MMX_RUSH_FINISHED) { diff --git a/src/mmx_renderer.h b/src/mmx_renderer.h index 5668c188..5be07197 100644 --- a/src/mmx_renderer.h +++ b/src/mmx_renderer.h @@ -16,6 +16,12 @@ typedef struct MmxRenderView { int width, extra; double aspect; } MmxRenderView; typedef struct MmxRenderStats { unsigned custom_lines, fallback_lines, margin_sprite_pixels, pieces; } MmxRenderStats; +typedef struct MmxRasterStats { + uint64_t vram_copied_bytes; + size_t vram_allocated_bytes, frame_metadata_bytes; + unsigned vram_images; + uint64_t vram_compared_bytes; +} MmxRasterStats; /* A presentation-only player plane. Origins are signed coordinates on the * player's 128x128 canvas; the renderer owns projection and composition. */ @@ -125,9 +131,16 @@ void MmxRendererBeginFrame(const uint8_t ram[0x20000]); void MmxRendererCoopFrame(const MmxCoopState *state); void MmxRendererHoldFrame(bool held); void MmxRendererCaptureLine(const Ppu *ppu, unsigned line); +/* Scoped to MMX's raster loop: byte comparisons remain the generic fallback. + * Call Changed after arbitrary guest/host execution; end before leaving the + * loop. Never use this around unobserved direct VRAM writers. */ +void MmxRendererBeginHardwareRaster(const Ppu *ppu); +void MmxRendererHardwareVramChanged(void); +void MmxRendererEndHardwareRaster(void); bool MmxRendererEndFrame(const uint32_t stock[256 * 224]); bool MmxRendererDraw(uint32_t *output, MmxRenderView view, bool anchor_hud); MmxRenderStats MmxRendererGetStats(void); +MmxRasterStats MmxRendererGetRasterStats(void); bool MmxRendererStageTile(const uint8_t ram[0x20000], unsigned layer, int x, int y, uint16_t *word); bool MmxRendererSaveCapture(const char *path); diff --git a/src/mmx_rtl.c b/src/mmx_rtl.c index 0a3fbfe9..3062fa1c 100644 --- a/src/mmx_rtl.c +++ b/src/mmx_rtl.c @@ -898,6 +898,7 @@ void MmxDrawPpuFrame(void) { int trigger = g_snes->vIrqEnabled ? g_snes->vTimer + 1 : -1; + if (g_mmx_custom_renderer) MmxRendererBeginHardwareRaster(g_ppu); for (int i = 0; i <= 224; i++) { if (g_mmx_custom_renderer) MmxRendererCaptureLine(g_ppu, i); ppu_runLine(g_ppu, i); @@ -917,9 +918,13 @@ void MmxDrawPpuFrame(void) { * pop). Without it cpu->S drifts on every interrupt. */ cpu_push_interrupt_frame(&g_cpu); I_IRQ(&g_cpu); + /* IRQs may run native mod hooks as well as ordinary PPU port writes. + * Recompare VRAM even when no observed port write incremented its count. */ + MmxRendererHardwareVramChanged(); trigger = g_snes->vIrqEnabled ? g_snes->vTimer + 1 : -1; } } + MmxRendererEndHardwareRaster(); } void RunOneFrameOfGame(void) { diff --git a/tests/mmx_adaptive_state_test.c b/tests/mmx_adaptive_state_test.c index 26b553e0..7c1781e5 100644 --- a/tests/mmx_adaptive_state_test.c +++ b/tests/mmx_adaptive_state_test.c @@ -1862,6 +1862,7 @@ static void zero_state_checks(const char *assets, const char *fixture, uint8 *st check(!g_mmx_custom_renderer && !MmxZeroEnabled(), "disabling returns to stock presentation"); puts("MMX ZERO RUNTIME CHECKS PASSED"); } +#include "mmx_frame_cost_test.inc" int main(int argc, char **argv) { check(argc == 2 || argc == 3, "ROM supplied"); SDL_SetMainReady(); @@ -1955,6 +1956,25 @@ int main(int argc, char **argv) { size_t cap = 2u * 1024u * 1024u; uint8 *start = malloc(cap), *expected = malloc(cap), *actual = malloc(cap); if(getenv("MMX_ZERO_BLOCKS_TEST")) {zero_blocks_checks(getenv("MMX_ZERO_TEST_ASSETS"));return 0;} + if (getenv("MMX_FRAME_COST_TEST")) { + frame_cost_checks(start, expected, actual, cap); + return 0; + } + if (getenv("MMX_NETPLAY_FIXTURE_OUT")) { + /* Create a current-build fixture through normal boot and landing. The + * general save/rewind test's slot can be a menu or transition snapshot. */ + hitbox_boot(); + unsigned waited; + for (waited = 0; waited < 600; ++waited) { + if (g_ram[0xba9] == 2 && !g_ram[0xbaa] && (g_ram[0xbd3] & 4) && + (g_ram[0xbcf] & 127)) break; + frame(0); + } + check(waited < 600, "fresh Highway fixture stands on real ground"); + check(RtlSaveSnapshot(getenv("MMX_NETPLAY_FIXTURE_OUT")), "fresh netplay fixture saves"); + puts("MMX NETPLAY FIXTURE CREATED"); + return 0; + } if(getenv("MMX_MAGIC_CARPET_TEST")) {magic_carpet_checks(getenv("MMX_MAGIC_CARPET_TEST"),start,expected,actual,cap);return 0;} if(getenv("MMX_BOSS_RUSH_TEST")) {rush_runtime_checks(start,expected,actual,cap);return 0;} if(getenv("MMX_ZERO_SABER_PERF_TEST")) { diff --git a/tests/mmx_frame_cost_test.inc b/tests/mmx_frame_cost_test.inc new file mode 100644 index 00000000..24840388 --- /dev/null +++ b/tests/mmx_frame_cost_test.inc @@ -0,0 +1,92 @@ +/* Test-only attribution. Repeat the normal frame sequence with clocks around + * its stages, then compare against frame() from the same authoritative state. + * These are wall-time stages (preemption included), not CPU-time speedups. */ +static uint64_t frame_cost_pixels(const uint32_t *pixels, size_t count) { + uint64_t hash = UINT64_C(14695981039346656037); + for (size_t i = 0; i < count; ++i) { + hash ^= pixels[i]; hash *= UINT64_C(1099511628211); + } + return hash; +} +static void frame_cost_draw(uint32_t *pixels) { + if (g_mmx_custom_renderer) { + if (!MmxRendererDraw(pixels, g_mmx_custom_view, true)) + check(0, "attributed frame renders"); + } + else memcpy(pixels, g_my_pixels, 256 * 224 * sizeof(*pixels)); +} +static void frame_cost_checks(uint8_t *start, uint8_t *expected, + uint8_t *actual, size_t cap) { + enum { N = 160 }; + static uint32_t pixels[MMX_RENDER_MAX_WIDTH * 224]; + const char *names[] = {"highway-idle", "storm-setup", "magic-carpet"}; + const char *paths[] = {getenv("MMX_FRAME_COST_HIGHWAY"), + getenv("MMX_FRAME_COST_STORM"), getenv("MMX_FRAME_COST_CARPET")}; + const char *aspects[] = {"native", "16:9", "32:9"}; + const double scale = 1000.0 / (double)SDL_GetPerformanceFrequency(); + FILE *csv = fopen("frame-costs.csv", "w"); + check(csv != NULL, "frame attribution output opens"); + fputs("scene,aspect,custom,frames,guest_ms,raster_ms,compose_ms,rollback_save_ms," + "vram_images,vram_copied_bytes,rollback_bytes,pixel_hash\n", csv); + for (unsigned scene = 0; scene < 3; ++scene) { + check(paths[scene] != NULL, "attribution has a current-build scene fixture"); + for (unsigned view = 0; view < 3; ++view) { + check(RtlLoadSnapshot(paths[scene]), "attribution fixture loads"); + g_config.display_aspect = kSnesDisplayAspect_Crt4x3; + g_config.widescreen = view != 0; + g_mmx_custom_aspect = view == 2 ? MMX_ASPECT_32_9 : MMX_ASPECT_16_9; + int width, height; + MmxPrepareFrame(1280, 720, &width, &height); + for (unsigned i = 0; i < 24; ++i) { frame(0); frame_cost_draw(pixels); } + size_t n = RtlSaveSnapshotToMemory(start, cap); + check(n != 0, "attribution start saves"); + uint64_t reference_pixels = 0; + size_t en = 0; + for (unsigned i = 0; i < N; ++i) { + frame(0); frame_cost_draw(pixels); + en = RtlRollbackSaveToMemory(expected, cap); + if (!en) check(0, "reference rollback snapshot saves"); + if (!(i % 16)) reference_pixels = (reference_pixels ^ frame_cost_pixels(pixels, + (size_t)(g_mmx_custom_renderer ? g_mmx_custom_view.width : 256) * 224)) * + UINT64_C(1099511628211) + i; + } + check(RtlLoadSnapshotFromMemory(start, n), "attribution start restores"); + double elapsed[4] = {0}; + uint64_t measured_pixels = 0; + size_t an = 0; + for (unsigned i = 0; i < N; ++i) { + uint64_t t0 = SDL_GetPerformanceCounter(); + MmxBeforeFrame(); + RtlRunFrame(1u << 30); + MmxBossRushAudioPresent(); + uint64_t t1 = SDL_GetPerformanceCounter(); + CaptureSimulationFrame(1); + uint64_t t2 = SDL_GetPerformanceCounter(); + frame_cost_draw(pixels); + uint64_t t3 = SDL_GetPerformanceCounter(); + an = RtlRollbackSaveToMemory(actual, cap); + uint64_t t4 = SDL_GetPerformanceCounter(); + if (!an) check(0, "attributed rollback snapshot saves"); + elapsed[0] += (t1 - t0) * scale; + elapsed[1] += (t2 - t1) * scale; + elapsed[2] += (t3 - t2) * scale; + elapsed[3] += (t4 - t3) * scale; + if (!(i % 16)) measured_pixels = (measured_pixels ^ frame_cost_pixels(pixels, + (size_t)(g_mmx_custom_renderer ? g_mmx_custom_view.width : 256) * 224)) * + UINT64_C(1099511628211) + i; + } + same(expected, en, actual, an, "attributed sequence preserves full rollback state"); + check(reference_pixels == measured_pixels, "attributed sequence preserves sampled pixels"); + MmxRasterStats storage = g_mmx_custom_renderer ? + MmxRendererGetRasterStats() : (MmxRasterStats){0}; + fprintf(csv, "%s,%s,%u,%u,%.6f,%.6f,%.6f,%.6f,%u,%llu,%zu,%016llx\n", + names[scene], aspects[view], g_mmx_custom_renderer, N, + elapsed[0] / N, elapsed[1] / N, elapsed[2] / N, elapsed[3] / N, + storage.vram_images, (unsigned long long)storage.vram_copied_bytes, + an, (unsigned long long)measured_pixels); + fflush(csv); + } + } + check(fclose(csv) == 0, "frame attribution output writes"); + puts("MMX FRAME COST CHECKS PASSED"); +} diff --git a/tests/mmx_magic_carpet_test.inc b/tests/mmx_magic_carpet_test.inc index 19bc1a1f..255b2e49 100644 --- a/tests/mmx_magic_carpet_test.inc +++ b/tests/mmx_magic_carpet_test.inc @@ -115,5 +115,22 @@ static void magic_carpet_checks(const char *path,uint8 *start,uint8 *expected,ui check(RtlLoadSnapshotFromMemory(start,active_size),"active carpet snapshot restores again"); carpet_frames(30,SNES_PAD_RIGHT);size_t an=RtlSaveSnapshotToMemory(actual,cap); same(expected,en,actual,an,"active carpet replays deterministically"); + if(getenv("MMX_RASTER_INVARIANTS")) { + check(RtlRollbackSnapshotBound()<=cap,"rollback invariant buffers fit"); + en=RtlRollbackSaveToMemory(expected,cap); + uint64_t clocks=g_cpu.master_cycles; + static uint32_t pixels[MMX_RENDER_MAX_WIDTH*224]; + const MmxRenderView views[]={{256,0,4.0/3.0},{682,213,32.0/9.0},{342,43,16.0/9.0}}; + for(unsigned repeat=0;repeat<2;++repeat) for(unsigned v=0;v<3;++v) + check(MmxRendererDraw(pixels,views[(v+repeat)%3],repeat!=0),"repeated render at another width succeeds"); + an=RtlRollbackSaveToMemory(actual,cap); + same(expected,en,actual,an,"render count and view width leave complete rollback state unchanged"); + check(g_cpu.master_cycles==clocks,"rendering consumes no guest clocks"); + } + MmxRasterStats raster=MmxRendererGetRasterStats(); + printf("MMX_RASTER_STATS {\"images\":%u,\"copied_bytes\":%llu,\"allocated_bytes\":%zu,\"metadata_bytes\":%zu,\"compared_bytes\":%llu}\n", + raster.vram_images,(unsigned long long)raster.vram_copied_bytes, + raster.vram_allocated_bytes,raster.frame_metadata_bytes, + (unsigned long long)raster.vram_compared_bytes); puts("MMX MAGIC CARPET CHECKS PASSED"); } diff --git a/tests/mmx_renderer_test.c b/tests/mmx_renderer_test.c index aca2e706..26585aed 100644 --- a/tests/mmx_renderer_test.c +++ b/tests/mmx_renderer_test.c @@ -93,6 +93,123 @@ static void raster_and_hud(void) { assert(!MmxRendererDraw(output, (MmxRenderView){2048,896,8}, true)); MmxRendererReset(); assert(!MmxRendererDraw(output, v, true)); } +static void raster_test_enhancer(Ppu *p, uint y, bool sub, void *context) { + (void)p;(void)y;(void)sub;(void)context; +} +static void immutable_vram_raster(void) { + MmxRenderView views[] = {{256,0,4.0/3.0}, + MmxRendererViewport(MMX_ASPECT_32_9,0,0,kSnesDisplayAspect_Crt4x3), + MmxRendererViewport(MMX_ASPECT_16_9,0,0,kSnesDisplayAspect_Crt4x3)}; + const char *path = "raster-storage-test.cap"; + for (unsigned test_case = 0; test_case < 6; ++test_case) { + unsigned pattern=test_case%3;bool hardware=test_case>=3; + memset(&ppu,0,sizeof(ppu));memset(ram,0,sizeof(ram)); + MmxRendererReset();MmxRendererSetRom(NULL,0);MmxZeroDisable();MmxBossRushReset(); + ram[0xd1]=2;ram[0xd2]=ram[0xd3]=4; + ppu.inidisp=15;ppu.bgmode=1;ppu.screenEnabled[0]=16; + ppu.cgram[129]=31;ppu.cgram[130]=31<<5; + for(unsigned slot=0;slot<128;++slot) ppu.oam[slot*2]=0xe000; + for(unsigned slot=0;slot<28;++slot) ppu.oam[slot*2]=(uint16_t)((slot*8)<<8|16); + MmxRendererBeginFrame(ram); + ppu.vramWriteCount=UINT32_MAX-3;unsigned previous_phase=2; + if(hardware) MmxRendererBeginHardwareRaster(&ppu); + for(unsigned line=1;line<=224;++line) { + unsigned phase=pattern ? ((line-1)/(pattern==1?1:3))&1 : 0; + for(unsigned row=0;row<8;++row) ppu.vram[row]=phase?0xff00:0xff; + if(hardware && phase!=previous_phase && line<80) { + if(line==21) MmxRendererHardwareVramChanged(); /* Raw IRQ/native edit. */ + else ++ppu.vramWriteCount; /* Port writes, including counter wrap. */ + } + if(hardware && line==80) ppu.widescreenLineEnhancer=raster_test_enhancer; + if(hardware && line==81) ppu.widescreenLineEnhancer=NULL; + /* After the callback appeared, direct uncounted writes must remain on + * the byte-comparison path for the rest of this raster. */ + MmxRendererCaptureLine(&ppu,line); + previous_phase=phase; + } + MmxRendererEndHardwareRaster(); + assert(MmxRendererEndFrame(stock)); + MmxRasterStats storage=MmxRendererGetRasterStats(); + assert(storage.vram_images>=1 && storage.vram_images<=224); + assert(storage.vram_copied_bytes==(uint64_t)storage.vram_images*65536); + assert(storage.frame_metadata_bytes<1024*1024); +#if defined(MMX_RENDERER_TEST_FULLCOPY) + assert(storage.vram_images==224 && !storage.vram_compared_bytes); +#elif defined(MMX_RENDERER_TEST_TRACE) + assert(storage.vram_compared_bytes==223u*65536); +#endif + /* Live memory changes, width changes and repeated draws cannot edit the + * immutable images. Alternation also catches accidental ABA aliasing. */ + memset(&ppu,0,sizeof(ppu));memset(ram,0,sizeof(ram)); + for(unsigned pass=0;pass<2;++pass) { + for(unsigned view=0;view<3;++view) { + MmxRenderView v=views[(view+pass)%3]; + assert(MmxRendererDraw(output,v,false)); + for(unsigned y=0;y<224;++y) { + unsigned phase=pattern ? (y/(pattern==1?1:3))&1 : 0; + assert(output[y*v.width+v.extra+16]==(phase?0x00ff00u:0xff0000u)); + } + } + if(!pass) { + assert(MmxRendererSaveCapture(path));MmxRendererReset(); + assert(MmxRendererLoadCapture(path)); + } + } + FILE *f=fopen(path,"r+b");assert(f); + assert(!fseek(f,(long)(12+storage.frame_metadata_bytes+sizeof(uint32_t)),SEEK_SET)); + const uint16_t invalid=65535;assert(fwrite(&invalid,sizeof(invalid),1,f)==1);fclose(f); + assert(!MmxRendererLoadCapture(path)); + assert(!MmxRendererDraw(output,views[0],false)); + } + /* A truncated file must not make a partially read frame drawable. */ + FILE *f=fopen(path,"wb");assert(f); + uint32_t header[]={0x4d4d5843,24,0};assert(fwrite(header,sizeof(header),1,f)==1);fclose(f); + assert(!MmxRendererLoadCapture(path)); + assert(!MmxRendererDraw(output,views[0],false)); + remove(path);MmxRendererReset(); +} +static void stage_map_draw_lifetime(void) { + memset(&ppu,0,sizeof(ppu));memset(ram,0,sizeof(ram));memset(rom_bytes,0,sizeof(rom_bytes)); + MmxRendererReset();MmxRendererSetRom(NULL,0);MmxZeroDisable();MmxBossRushReset(); + g_mmx_render_asset_repairs=false; + ram[0xd1]=2;ram[0xd2]=ram[0xd3]=4; + ram[0xb96]=ram[0xb97]=0x80; /* BG1 metatile table at $80:8000. */ + rom_bytes[2]=1;rom_bytes[4]=16;rom_bytes[6]=17; + ppu.inidisp=15;ppu.bgmode=1;ppu.screenEnabled[0]=1; + ppu.bgTileAdr=2;ppu.bgXsc[0]=0x40; + ppu.cgram[1]=31;ppu.cgram[2]=31<<5;ppu.cgram[3]=31<<10;ppu.cgram[4]=0x7fff; + for(unsigned slot=0;slot<128;++slot) ppu.oam[slot*2]=0xe000; + for(unsigned row=0;row<8;++row) { + ppu.vram[0x2000+row]=0xff; + ppu.vram[0x2010+row]=0xff00; + ppu.vram[0x2100+row]=0xffff; + ppu.vram[0x2118+row]=0xff; + } + MmxRendererSetRom(rom_bytes,sizeof(rom_bytes));capture(); + const uint32_t colors[]={0xff0000,0x00ff00,0x0000ff,0xffffff}; + const MmxRenderView views[]={{342,43,16.0/9.0},{682,213,32.0/9.0},{342,43,16.0/9.0}}; + for(unsigned edit=0;edit<2;++edit) { + for(unsigned v=0;v<3;++v) { + MmxRenderView view=views[v];assert(MmxRendererDraw(output,view,false)); + for(unsigned y=0;y<224;++y)for(int x=-view.extra;x<256+view.extra;++x) { + unsigned expected=0xff0000; + if(x<0 || x>=256) { + unsigned wx=x<0?(unsigned)(-x-1):(unsigned)x; + expected=edit?0x00ff00:colors[((wx>>3)&1)|((((y+1)>>3)&1)<<1)]; + } + if(output[y*view.width+x+view.extra]!=expected) + fprintf(stderr,"stage-map edit=%u view=%u x=%d y=%u got=%08x expected=%08x\n", + edit,v,x,y,output[y*view.width+x+view.extra],expected); + assert(output[y*view.width+x+view.extra]==expected); + } + } + /* Editing the external ROM between draws must not reuse an earlier + * result, even with the same captured RAM and ROM pointer. */ + for(unsigned q=0;q<4;++q)rom_bytes[q*2]=1; + } + memset(rom_bytes,0,sizeof(rom_bytes)); + g_mmx_render_asset_repairs=true;MmxRendererReset();MmxRendererSetRom(NULL,0); +} static void sprite_coordinates(void) { memset(&ppu, 0, sizeof(ppu)); memset(ram, 0, sizeof(ram)); ram[0xd1] = 2; ram[0xd2] = 4; ram[0xd3] = 4; @@ -1661,4 +1778,4 @@ static void rush_results_page(void) { assert((output[48*256+92]&0xffffff)==0x00ffff); MmxBossRushReset(); } -int main(void) { geometry(); raster_and_hud(); sprite_coordinates(); independent_oam_visibility(); expanded_capacity(); background_resources(); dialogue_and_password(); highway_arena_sky(); highway_airship_binding(); distant_doors(); storm_background_prefill(); resource_decode(); spark_effects(); airport_panorama_edge(); airport_upper_sky_repair(); wide_water_plane(); buried_submarine(); background_continuations(); launch_background_palettes(); sting_background_palettes(); mammoth_background_palettes(); dialogue_backdrop(); fortress_actor_presentation(); storm_dormant_presentation(); sprite_priority_and_cutscene_binding(); weapons_menu_margins(); zero_blink_submission(); rush_private_pose_graphics(); rush_static_and_streamed_graphics(); rush_results_page(); return 0; } +int main(void) { geometry(); raster_and_hud(); immutable_vram_raster(); stage_map_draw_lifetime(); sprite_coordinates(); independent_oam_visibility(); expanded_capacity(); background_resources(); dialogue_and_password(); highway_arena_sky(); highway_airship_binding(); distant_doors(); storm_background_prefill(); resource_decode(); spark_effects(); airport_panorama_edge(); airport_upper_sky_repair(); wide_water_plane(); buried_submarine(); background_continuations(); launch_background_palettes(); sting_background_palettes(); mammoth_background_palettes(); dialogue_backdrop(); fortress_actor_presentation(); storm_dormant_presentation(); sprite_priority_and_cutscene_binding(); weapons_menu_margins(); zero_blink_submission(); rush_private_pose_graphics(); rush_static_and_streamed_graphics(); rush_results_page(); return 0; } diff --git a/tests/mmx_renderer_variant.c b/tests/mmx_renderer_variant.c new file mode 100644 index 00000000..5e821d64 --- /dev/null +++ b/tests/mmx_renderer_variant.c @@ -0,0 +1,11 @@ +/* Build the same renderer suite against its maintained reference paths. */ +#define MMX_STAGE_TILE_REUSE 0 +#if defined(MMX_RENDERER_TEST_FULLCOPY) +#undef MMX_RASTER_FULL_COPY +#define MMX_RASTER_FULL_COPY 1 +#endif +#if defined(MMX_RENDERER_TEST_TRACE) +#undef SNESRECOMP_TRACE +#define SNESRECOMP_TRACE 1 +#endif +#include "../src/mmx_renderer.c" diff --git a/tools/attribute_frame_costs.py b/tools/attribute_frame_costs.py new file mode 100644 index 00000000..b7cd8d65 --- /dev/null +++ b/tools/attribute_frame_costs.py @@ -0,0 +1,83 @@ +"""MMX stage attribution with exact state/pixel checks; no speedup assertions. + +The C harness reports wall-time stages, including preemption. Process CPU time +is recorded separately. This is a headless source-frame/compositor probe, not +desktop presentation or audio-delivery qualification. +""" +import argparse +import csv +import hashlib +import json +import os +from pathlib import Path +import subprocess + +from benchmark_raster_route import process_cpu_seconds + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("exe", "rom", "highway", "storm", "carpet", "output"): + parser.add_argument("--" + name, type=Path, required=True) + parser.add_argument("--runs", type=int, choices=range(1, 5), default=2) + args = parser.parse_args() + for name in ("exe", "rom", "highway", "storm", "carpet"): + setattr(args, name, getattr(args, name).resolve(strict=True)) + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=True) + if any(root.iterdir()): + parser.error("output must be empty; preserve earlier evidence") + report = {"kind": "headless MMX stage attribution", + "inputs": {name: {"path": str(getattr(args, name)), + "sha256": sha(getattr(args, name))} + for name in ("exe", "rom", "highway", "storm", "carpet")}, + "runs": []} + expected_pixels = None + for run in range(args.runs): + work = root / f"run{run}" + work.mkdir() + (work / "temp").mkdir() + env = {k: v for k, v in os.environ.items() if not k.startswith( + ("MMX_", "SNESRECOMP_", "SNES_NET", "SNES_RB_", "RNET_"))} + env.update(TEMP=str(work / "temp"), TMP=str(work / "temp"), + LOCALAPPDATA=str(work / "profile"), APPDATA=str(work / "profile"), + XDG_CACHE_HOME=str(work / "cache"), XDG_DATA_HOME=str(work / "data"), + SDL_AUDIODRIVER="dummy", MMX_REPLAY_RECORD="0", + SNESRECOMP_LLE_BOUNCE="1", MMX_FRAME_COST_TEST="1", + MMX_FRAME_COST_HIGHWAY=str(args.highway), + MMX_FRAME_COST_STORM=str(args.storm), + MMX_FRAME_COST_CARPET=str(args.carpet)) + process = subprocess.Popen([str(args.exe), str(args.rom)], cwd=work, + env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + try: + stdout, _ = process.communicate(timeout=180) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + raise + (work / "run.log").write_bytes(stdout) + if process.returncode or b"MMX FRAME COST CHECKS PASSED" not in stdout: + raise RuntimeError(f"{work}: failed ({process.returncode})\n" + + stdout.decode(errors="replace")[-4000:]) + with (work / "frame-costs.csv").open(newline="") as stream: + rows = list(csv.DictReader(stream)) + if len(rows) != 9: + raise RuntimeError("Expected three scenes at three aspects") + pixels = {(row["scene"], row["aspect"]): row["pixel_hash"] for row in rows} + if expected_pixels is not None and pixels != expected_pixels: + raise RuntimeError("Repeat process produced different sampled pixels") + expected_pixels = pixels + report["runs"].append({"cpu_seconds": process_cpu_seconds(process), "rows": rows}) + (root / "results.json").write_text(json.dumps(report, indent=2)) + print(f"Run {run}: state/pixel checks passed; CPU=" + f"{report['runs'][-1]['cpu_seconds']}", flush=True) + for row in rows: + print(json.dumps(row), flush=True) + + +if __name__ == "__main__": + main() diff --git a/tools/benchmark_raster_route.py b/tools/benchmark_raster_route.py new file mode 100644 index 00000000..db350034 --- /dev/null +++ b/tools/benchmark_raster_route.py @@ -0,0 +1,134 @@ +"""Balanced ROM-backed Magic Carpet A/B; headless simulation and raster capture. + +This does not measure desktop presentation/audio delivery or certify Xbox speed. +All generated files, configuration, caches and temporary data stay under output. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import statistics +import subprocess +import time + + +def process_cpu_seconds(process): + if os.name == "nt": + import ctypes + from ctypes import wintypes + times = [wintypes.FILETIME() for _ in range(4)] + get_times = ctypes.WinDLL("kernel32", use_last_error=True).GetProcessTimes + if not get_times(ctypes.c_void_p(int(process._handle)), + *(ctypes.byref(t) for t in times)): + raise ctypes.WinError(ctypes.get_last_error()) + return sum((t.dwHighDateTime << 32) | t.dwLowDateTime for t in times[2:]) / 1e7 + return None + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + for key in ("reference", "candidate", "rom", "inputs", "output"): + p.add_argument("--" + key, type=Path, required=True) + p.add_argument("--pairs", type=int, default=5) + p.add_argument("--cases", nargs="+", choices=("native", "widescreen", "ultrawide"), + default=["native", "widescreen", "ultrawide"]) + args = p.parse_args() + if not 2 <= args.pairs <= 20: + p.error("pairs must be 2..20") + for key in ("reference", "candidate", "rom", "inputs"): + setattr(args, key, getattr(args, key).resolve(strict=True)) + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=True) + if any(root.iterdir()): + p.error("output must be empty to preserve earlier evidence") + report = {"kind": "headless ROM simulation plus raster capture", + "reference_sha256": sha(args.reference), "candidate_sha256": sha(args.candidate), + "rom_sha256": sha(args.rom), "inputs_sha256": sha(args.inputs), + "records": [], "summary": {}} + expectations = {} + + def run(case, label, exe, tag): + work = root / f"{case}-{tag}-{label}" + work.mkdir() + temp = work / "temp" + temp.mkdir() + env = {k: v for k, v in os.environ.items() + if not k.startswith(("MMX_", "SNES_NET", "SNES_RB_", "RNET_", "SNESRECOMP_"))} + env.update(TEMP=str(temp), TMP=str(temp), LOCALAPPDATA=str(work / "profile"), + APPDATA=str(work / "profile"), XDG_CACHE_HOME=str(work / "cache"), + XDG_DATA_HOME=str(work / "data"), SDL_AUDIODRIVER="dummy", + MMX_REPLAY_RECORD="0", SNESRECOMP_EXECUTION_MODE="lle", + SNESRECOMP_LLE_BOUNCE="1", MMX_MAGIC_CARPET_TEST=str(args.inputs)) + if case != "native": + env["MMX_MAGIC_CARPET_WIDE"] = "32:9" if case == "ultrawide" else "1" + start = time.perf_counter() + process = subprocess.Popen([str(exe), str(args.rom)], cwd=work, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + try: + stdout, _ = process.communicate(timeout=90) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + raise + seconds = time.perf_counter() - start + cpu_seconds = process_cpu_seconds(process) + (work / "run.log").write_bytes(stdout) + text = stdout.decode(errors="replace") + if process.returncode or "MMX MAGIC CARPET CHECKS PASSED" not in text: + raise RuntimeError(f"{work}: failed ({process.returncode})\n{text[-3000:]}") + files = {name: sha(work / name) for name in + ("magic-carpet.csv", "magic-carpet-setup.sav", "magic-carpet-active.sav")} + if case in expectations and files != expectations[case]: + raise RuntimeError(f"{work}: guest trace/snapshot differs from reference") + expectations.setdefault(case, files) + stat = re.findall(r"MMX_RASTER_STATS (\{[^\n]+\})", text) + row = {"case": case, "label": label, "tag": tag, "seconds": seconds, + "cpu_seconds": cpu_seconds, + "files": files, "raster": json.loads(stat[-1]) if stat else None} + report["records"].append(row) + (root / "results.json").write_text(json.dumps(report, indent=2)) + print(f"{case} {tag} {label}: {seconds:.4f}s, CPU={cpu_seconds}", flush=True) + return row + + for case in args.cases: + run(case, "reference", args.reference, "warmup") + run(case, "candidate", args.candidate, "warmup") + gains, cpu_gains = [], [] + for pair in range(args.pairs): + samples = {} + order = (("reference", args.reference), ("candidate", args.candidate)) + if pair & 1: + order = tuple(reversed(order)) + for label, exe in order: + samples[label] = run(case, label, exe, f"pair{pair}") + gains.append(100 * (samples["reference"]["seconds"] / samples["candidate"]["seconds"] - 1)) + if samples["reference"]["cpu_seconds"] and samples["candidate"]["cpu_seconds"]: + cpu_gains.append(100 * (samples["reference"]["cpu_seconds"] / samples["candidate"]["cpu_seconds"] - 1)) + report["summary"][case] = {"paired_throughput_gain_percent": gains, + "median_gain_percent": statistics.median(gains), + "min_gain_percent": min(gains), "max_gain_percent": max(gains), + "paired_cpu_gain_percent": cpu_gains, + "median_cpu_gain_percent": statistics.median(cpu_gains) if cpu_gains else None} + # Same-executable noise control, using the same route and process boundaries. + aa, aa_cpu = [], [] + case = args.cases[0] + for pair in range(2): + a = run(case, "reference0", args.reference, f"aa{pair}") + b = run(case, "reference1", args.reference, f"aa{pair}") + aa.append(100 * (a["seconds"] / b["seconds"] - 1)) + if a["cpu_seconds"] and b["cpu_seconds"]: + aa_cpu.append(100 * (a["cpu_seconds"] / b["cpu_seconds"] - 1)) + report["aa_gain_percent"] = aa + report["aa_cpu_gain_percent"] = aa_cpu + (root / "results.json").write_text(json.dumps(report, indent=2)) + print(json.dumps({"summary": report["summary"], "aa_gain_percent": aa}, indent=2)) + + +if __name__ == "__main__": + main() From 8e0524d4834d960fd7ed173cd3afaa4302ec2438 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:52:43 -0700 Subject: [PATCH 2/3] Keep Boss Rush replacements out of the weapon-menu camera --- CMakeLists.txt | 8 +++++ docs/boss-rush.md | 19 ++++++++++ src/mmx_boss_rush_runtime.c | 19 +++++++--- tests/mmx_boss_rush_pause_spawn_test.inc | 44 ++++++++++++++++++++++++ tests/mmx_boss_rush_runtime_test.inc | 2 ++ 5 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 tests/mmx_boss_rush_pause_spawn_test.inc diff --git a/CMakeLists.txt b/CMakeLists.txt index f9ec3855..2fbdf2f1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -264,6 +264,14 @@ if(TARGET mmx_state_tests) $ "${CMAKE_CURRENT_SOURCE_DIR}/mmx.sfc") set_tests_properties(mmx_boss_rush_reports PROPERTIES WORKING_DIRECTORY "${rush_report_dir}" LABELS "rom;boss-rush") + set(rush_pause_spawn_dir "${CMAKE_CURRENT_BINARY_DIR}/boss-rush-pause-spawn") + file(MAKE_DIRECTORY "${rush_pause_spawn_dir}") + add_test(NAME mmx_boss_rush_pause_spawn + COMMAND ${CMAKE_COMMAND} -E env SDL_AUDIODRIVER=dummy MMX_REPLAY_RECORD=0 + MMX_BOSS_RUSH_TEST=1 MMX_BOSS_RUSH_PAUSE_SPAWN_TEST=1 + $ "${CMAKE_CURRENT_SOURCE_DIR}/mmx.sfc") + set_tests_properties(mmx_boss_rush_pause_spawn PROPERTIES + WORKING_DIRECTORY "${rush_pause_spawn_dir}" LABELS "rom;boss-rush") foreach(carpet_case native widescreen ultrawide no_shot) set(carpet_env) if(carpet_case STREQUAL "widescreen") diff --git a/docs/boss-rush.md b/docs/boss-rush.md index 4e68c8c0..a9a1dc1a 100644 --- a/docs/boss-rush.md +++ b/docs/boss-rush.md @@ -465,3 +465,22 @@ distinguishes these layouts by their bounded tail lengths. The ROM-backed v19/v20, combined v21, file/memory imports and byte-exact immediate restoration. This format compatibility does not establish multiplayer compatibility between builds; the existing build identity checks remain in place. + +## Replacement spawns during weapon-menu entry (2026-10-22) + +A boss defeated as the native weapon menu opens could be replaced at X `$00C0` +instead of inside the `$1E00` arena. The menu coroutine clears the live camera +before publishing its menu flags; the replacement controller previously used +that temporary camera as its spawn origin. The owner's slot 01 identified +Spark Mandrill, already fighting at `$1093,$017A` after spawning outside. + +Replacement timers now wait for a stage scene and an arena camera. Entrance +placement also bounds the camera to the encounter's saved origin/range. This +works with expanded weapons disabled and preserves normal scrolling entrances. +It does not clamp native boss attacks or change the renderer. + +The real-ROM `mmx_boss_rush_pause_spawn` test covers 90 pause offsets in each +replacement slot. It drives native zero-HP death, checks the queued boss's first +coordinates, closes the menu, and requires the native entrance to reach combat. +The original failing timing and untouched owner save are retained privately; +no native in-game save data is changed. Tracking: `beads-8wg.1.145`. diff --git a/src/mmx_boss_rush_runtime.c b/src/mmx_boss_rush_runtime.c index 1c12f8f1..2cf6ef5e 100644 --- a/src/mmx_boss_rush_runtime.c +++ b/src/mmx_boss_rush_runtime.c @@ -21,7 +21,14 @@ extern uint8_t g_ram[0x20000]; static unsigned word(const uint8_t *p) {return p[0]|p[1]<<8;} static void put(uint8_t *p,unsigned v) {p[0]=(uint8_t)v;p[1]=(uint8_t)(v>>8);} -static unsigned entry_x(const uint8_t *r,unsigned seat) {return word(r+0x1e4d)+64+seat*128;} +static bool arena_camera(const uint8_t *r,const MmxBossRushState *s) { + unsigned x=word(r+0x1e4d),scroll=r[0x1f7a]==4?MMX_RUSH_MAMMOTH_SCROLL:0; + return x>=s->camera_x && x<=s->camera_x+scroll; +} +static unsigned entry_x(const uint8_t *r,const MmxBossRushState *s,unsigned seat) { + unsigned x=arena_camera(r,s)?word(r+0x1e4d):s->camera_x; + return x+64+seat*128; +} static void stack_push(CpuState *cpu,unsigned v,unsigned bytes) { while(bytes) {cpu_write8(cpu,0,cpu->S,(uint8_t)(v>>(--bytes*8)));--cpu->S;} } @@ -289,7 +296,7 @@ static void boss_hook(CpuState *cpu,uint32_t pc) { } if(at==0x07925e) { /* Keep his assigned side after the stock arrival adds $40 to X. */ - cpu->A=(uint16_t)entry_x(g_ram,(unsigned)owner); + cpu->A=(uint16_t)entry_x(g_ram,&s,(unsigned)owner); cpu->_flag_Z=!cpu->A;cpu->_flag_N=cpu->A>>15; cpu->P=(cpu->P&~0x82u)|(cpu->_flag_Z?2:0)|(cpu->_flag_N?128:0); } @@ -525,7 +532,11 @@ void MmxBossRushFrame(uint8_t *r,uint16_t input) { } if(s.mode==MMX_RUSH_PREPARING) {camera(r);return;} camera(r); - if(r[0xd3]==4 && !r[0x1f19] && !MmxWeaponsMenuVisible(r) && !MmxCoopGetState().menu_owner) { + /* The weapon-menu coroutine clears the camera before publishing its menu + * flags. Do not advance replacement timers or create an actor in that + * transient coordinate space, including when extra weapons are disabled. */ + if(r[0xd3]==4 && !r[0x1f19] && MmxWidePolicy_IsStageScene(r) && arena_camera(r,&s) && + !MmxWeaponsMenuVisible(r) && !MmxCoopGetState().menu_owner) { for(unsigned i=0;i<2;++i) { s=MmxBossRushGetState();MmxBossRushBoss *b=&s.bosses[i]; if(b->phase==MMX_RUSH_DYING) { @@ -557,7 +568,7 @@ void MmxBossRushFrame(uint8_t *r,uint16_t input) { s.bosses[i].globals[n]=r[globals[n]]; MmxBossRushSetState(&s); memset(r+object,0,64);r[object]=0x81;r[object+10]=kMmxBossRushBosses[id].kind; - unsigned x=entry_x(r,i),y=s.camera_y+(id==4?43:96); + unsigned x=entry_x(r,&s,i),y=s.camera_y+(id==4?43:96); put(r+object+5,x);put(r+object+8,y);put(r+object+0x22,x);put(r+object+0x24,y); } } diff --git a/tests/mmx_boss_rush_pause_spawn_test.inc b/tests/mmx_boss_rush_pause_spawn_test.inc new file mode 100644 index 00000000..7d286db6 --- /dev/null +++ b/tests/mmx_boss_rush_pause_spawn_test.inc @@ -0,0 +1,44 @@ +/* A native boss defeat can overlap the weapon-menu coroutine's early camera + * clear, before its HUD/menu flags become visible. Never spawn at menu X=0. + * HP positioning triggers the real death controller; no defeat flags are set. */ +static void rush_pause_spawn_checks(uint8_t *arena,uint8_t *combat,size_t cap) { + size_t arena_size=RtlSaveSnapshotToMemory(arena,cap); + check(arena_size,"pause-spawn arena saves"); + rush_report_pair(2,7); + size_t combat_size=RtlSaveSnapshotToMemory(combat,cap); + check(combat_size,"pause-spawn native combat saves"); + unsigned cases=0; + for(unsigned seat=0;seat<2;++seat)for(unsigned delay=0;delay<90;++delay) { + check(RtlLoadSnapshotFromMemory(combat,combat_size),"pause-spawn native combat restores"); + MmxBossRushState s=MmxBossRushGetState();unsigned generation=s.bosses[seat].generation; + uint32_t defeated=s.defeated; + s.queue[0]=1;unsigned q=1; + for(unsigned i=0;i<8;++i)if(i!=1)s.queue[q++]=(uint8_t)i; + s.queued=8;check(MmxBossRushSetState(&s),"pause-spawn queues a Mandrill replacement"); + g_ram[s.bosses[seat].object+0x27]=0; + bool spawned=false,fighting=false; + for(unsigned t=0;ts.camera_x+336) { + fprintf(stderr,"pause-spawn seat=%u delay=%u tick=%u x=%04x camera=%04x flags=%u/%u\n", + seat,delay,t,x,eagle_word(g_ram+0x1e4d),g_ram[0xd3],g_ram[0x1f19]); + check(false,"replacement enters inside the gameplay arena"); + } + check(s.defeated==defeated+1 && s.bosses[seat].id==1, + "native death awards one defeat and starts the queued Mandrill"); + spawned=true; + } + if(t>delay+120 && s.bosses[seat].phase==MMX_RUSH_FIGHTING) {fighting=true;break;} + } + check(spawned && fighting,"replacement completes its native entrance after pause resumes"); + ++cases; + } + check(RtlLoadSnapshotFromMemory(arena,arena_size),"pause-spawn restores its caller arena"); + printf("MMX BOSS RUSH PAUSE SPAWN PASSED: %u native timing/seat cases\n",cases); +} diff --git a/tests/mmx_boss_rush_runtime_test.inc b/tests/mmx_boss_rush_runtime_test.inc index 61e1f7cd..9632ad44 100644 --- a/tests/mmx_boss_rush_runtime_test.inc +++ b/tests/mmx_boss_rush_runtime_test.inc @@ -929,6 +929,7 @@ static void rush_projectile_defeat(unsigned seat,unsigned weapon) { #include "mmx_boss_rush_coop_combat_test.inc" #include "mmx_boss_rush_reports_test.inc" #include "mmx_gravity_rush_test.inc" +#include "mmx_boss_rush_pause_spawn_test.inc" static void rush_runtime_checks(uint8_t *start,uint8_t *expected,uint8_t *actual,size_t cap) { const char *coop_assets=getenv("MMX_BOSS_RUSH_COOP"); const char *zero_assets=getenv("MMX_BOSS_RUSH_ZERO"); @@ -1112,6 +1113,7 @@ static void rush_runtime_checks(uint8_t *start,uint8_t *expected,uint8_t *actual puts("MMX BOSS RUSH ARENA CHECKS PASSED"); return; } + if(getenv("MMX_BOSS_RUSH_PAUSE_SPAWN_TEST")) {rush_pause_spawn_checks(start,expected,cap);return;} if(getenv("MMX_BOSS_RUSH_REPORT_TEST")) {rush_report_checks(start,expected,cap);return;} if(getenv("MMX_BOSS_RUSH_GRAVITY_TEST")) {rush_gravity_checks(start,expected,actual,cap);return;} if(getenv("MMX_ZERO_SABER_PERF_TEST")) { From db693211aef6aff1e9109eaeb78e450acfd383e0 Mon Sep 17 00:00:00 2001 From: Matt Stanley <5354790+mstan@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:50:13 -0700 Subject: [PATCH 3/3] Record current-main bundle and Boss Rush qualification --- docs/PERFORMANCE_BURNDOWN.md | 67 ++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/docs/PERFORMANCE_BURNDOWN.md b/docs/PERFORMANCE_BURNDOWN.md index 3862d6e9..d5f8404d 100644 --- a/docs/PERFORMANCE_BURNDOWN.md +++ b/docs/PERFORMANCE_BURNDOWN.md @@ -474,13 +474,76 @@ history has advanced since the candidate bases: MMX main still pins engine `7a67b85`, SMW main pins `fa018f3` and has native co-op renderer changes, and SM main pins `3cbd954` with newer runtime/release changes. Preserve those upstream improvements during any later main-branch integration; the owner's -choice of landing target was requested while qualified commits are prepared. +choice of landing target was requested while qualified commits were prepared. Launch links: `playtests/SuperMarioWorld/Play.cmd`, `playtests/SuperMetroid/Play.cmd`, and optional updated `playtests/MegaManX/Play.cmd`. `playtests/RESULTS.txt` records scope and limits. Central Beads updates are recorded locally; defer the next expensive sync until competing ecosystem builds are quiet, then sync before integration. +### Current-main integration and Boss Rush follow-up + +The owner selected current-main integration and automated requalification. +This supersedes the pending target choice above. The original approved +packages, progress and exact revisions remain preserved; current-main +integration uses separate worktrees in the private bundle's `current-main`. +Framework PR #209 landed the shared helper as merge `2c311b0`; all seven +active Windows/Linux/macOS/Lua CI checks passed. The helper alone is also +backported atop the current title pins: MMX `bb156e1`, SMW `4c5c7dc`, and +SM `7b8cc25`. Modern SMW/SM already provide `Ppu.vramWriteCount`, so their +new integration reuses it rather than adding a duplicate PPU counter. + +Current-main qualification results (2026-10-22): + +- MMX starts at main `99450ef`, preserving its newer Zero and camera fixes. + GCC Release with recomp-net/rbengine and ICE builds successfully. All 31 + registered CTests have no failures (29 pass, two optional donor-asset + checks skip). Magic Carpet passes at all three aspects and the no-shot + negative control. Nine current-main full-copy/optimized Magic Carpet BMP + pairs match exactly. Independent and unified gameplay peers agree after + real rollback/replay with imported weapon contexts. Audio-enabled desktop + rollback, delay sync and forced host-state/slot repair pass, without offline + autosave pollution. Automated sound output is muted through SDL dummy audio. +- SMW starts at main `6c773d6`, preserving its co-op renderer. Two complete + stock generations are byte-identical; the Release app and all three + standalone renderer variants pass. At 4:3, 16:9 and 32:9, full-copy versus + optimized routes match 21 BMPs, draw lists, sprite traces and 4000 semantic + frame rows per aspect. Actual Mods Fit/adaptive spawning matches another + seven captures and 4000 rows. Current-build file-load functional partitions + pass under the documented clock-reanchoring contract, without claiming + full-chip rollback equivalence. Title PR: mstan/SuperMarioWorldRecomp#24. +- SM starts at main `b4bedbd`, preserving runtime, diagnostic replay and + release changes. Strict full regeneration is idempotent; Release and all + 14 CTests pass. Stock/wide/120 Hz simulation traces match over 3200 frames, + with nine matching fixed source/trace/BMP artifacts. Actual Mods Fit and + rewind match. Audio-enabled Ceres stock/wide routes match over 11000 frames + and reach changing guest-generated Mode 7 matrices; nine fixed-alpha/aspect + image pairs match. The fixture bypasses prerequisites/position, not proof + of natural completion. Title PR: mstan/SuperMetroidRecomp#31. + +The owner's slot 01 report was Spark Mandrill already outside the Boss Rush +arena. A separate correctness bug was reproduced: Start on the native boss +death frame enters the weapon menu, whose coroutine temporarily clears the +live camera before publishing its menu flags. The replacement timer could +then create the next boss at X=$00C0. Saved arena ownership now gates the +replacement timer/spawn; normal combat and Mammoth scrolling remain intact. +The regression exercises 90 pause offsets for each replacement seat (180 +cases), confirms in-arena creation and actual entrance into combat. It passes +on both the approved base and current main. Baseline fix `0b8be10` is applied +to current main as `8e0524d`. The owner's original snapshot is untouched; +this prevents future bad creation and does not relocate its already escaped +actor. Details: `docs/boss-rush.md`, tracker `beads-8wg.1.145`. + +Current-main evidence is in `mmx-ctest.log`, `mmx-pixels-qualified`, +`mmx-net-independent`, `mmx-net-unified`, `mmx-desktop-*`, `smw-qualified2`, +`smw-fit-qualified-current`, `sm-qualified`, `sm-ceres-qualified` and +`sm-fit-qualified-current`. Full-copy references use the documented build +options with otherwise identical current-main objects. These are correctness +reruns, not new performance estimates. Earlier balanced NAS timings remain +the headroom estimate for the approved older baseline. Xbox runtime and full +campaign coverage remain unverified. At this checkpoint the helper is merged; +the three current-main title branches await publication/CI/merge completion. + ## Reusable discovery checklist Carry this method into the later ecosystems; do not assume their bottlenecks @@ -539,7 +602,7 @@ to resume. Carry forward the measured method, invariance/restore/netplay gates, rejected probes and scope limits; each ecosystem must find its own bottlenecks. -### Pause checkpoint +### Historical pause checkpoint (superseded by P6 above) Historical checkpoint: the owner subsequently said "You may continue". Work has resumed; see P6 above for the newer qualification/package results.