diff --git a/.gitignore b/.gitignore index dfdc66e..db3096a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ # Build outputs +/.research/ +/build-mario-assets/ /build/ /build-*/ /out/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 886664b..0ac1f2f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,6 +29,9 @@ set(GAME_GENERATED_DISPATCH file(GLOB TOMBA2_MOD_PLUGIN_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/mods/*.c" "${CMAKE_CURRENT_SOURCE_DIR}/src/mods/*.cpp") +list(APPEND TOMBA2_MOD_PLUGIN_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/src/mods/mario/smash_assets.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/mods/mario/smash_audio.cpp") # The resident implementation is a build choice. Leave the original generated # loader available in REFERENCE without linking/preparing the resident service. @@ -85,13 +88,11 @@ endif() set(TOMBA2_PRELOADED_MODS "${CMAKE_CURRENT_SOURCE_DIR}/mods/preloaded") set(TOMBA2_PRELOADED_MODS_ITA "${CMAKE_CURRENT_SOURCE_DIR}/mods/preloaded_ita") -# USA Seamless Loading replaces the generic CD-speed and host-turbo mods. -# Restore the caller's selection afterward: Italian has no resident adapter. -set(_tomba2_previous_builtin_allowlist "${PSX_BUILTIN_MOD_ALLOWLIST}") -set(PSX_BUILTIN_MOD_ALLOWLIST - psx.enhancement.pgxp - psx.presentation.bezel) +# Keep framework presentation controls available as new ones are added. +# Generic loading/pacing controls are excluded from every release. psxrecomp_add_runtime_target(psx-runtime + EXCLUDE_BUILTIN_MODS psx.enhancement.cd-speed psx.enhancement.fast-loading + psx.enhancement.8mb-ram psx.enhancement.hd-textures PGXP GAME_GENERATED_FULL_C "${GAME_GENERATED_FULL}" GAME_GENERATED_DISPATCH_C "${GAME_GENERATED_DISPATCH}" @@ -114,8 +115,6 @@ psxrecomp_add_runtime_target(psx-runtime DEFAULT_GAME_CONFIG_PATH "game.toml" LAUNCHER_BOXART "${CMAKE_SOURCE_DIR}/recomp/launcher/boxart.tga" ) -set(PSX_BUILTIN_MOD_ALLOWLIST "${_tomba2_previous_builtin_allowlist}") -unset(_tomba2_previous_builtin_allowlist) # Distinct process name (see comment above). set_target_properties(psx-runtime PROPERTIES OUTPUT_NAME "${_tomba2_exe_name}") @@ -132,13 +131,18 @@ if(BUILD_TESTING) add_executable(tomba2_preloaded_mods_test tests/test_preloaded_mods.cpp "${PSXRECOMP_ROOT}/runtime/src/mod_packages.cpp" + "${PSXRECOMP_ROOT}/runtime/src/mod_media.cpp" + "${PSXRECOMP_ROOT}/runtime/src/iso_reader.cpp" + "${PSXRECOMP_ROOT}/runtime/src/cue_sheet.cpp" "${PSXRECOMP_ROOT}/runtime/src/crc32.c" "${PSXRECOMP_ROOT}/runtime/src/psx_sha256.c" ) target_include_directories(tomba2_preloaded_mods_test PRIVATE "${PSXRECOMP_ROOT}/runtime/include" + "${PSXRECOMP_ROOT}/recompiler/include" "${PSXRECOMP_ROOT}/recompiler/lib/toml11" ) + target_link_libraries(tomba2_preloaded_mods_test PRIVATE chdr-static) add_test( NAME tomba2_preloaded_mods_test COMMAND "$" @@ -171,6 +175,7 @@ if(PSXRECOMP_GAME_ITA_DEBUG_PORT_OVERRIDE) endif() psxrecomp_add_runtime_target(psx-runtime-ita + EXCLUDE_BUILTIN_MODS psx.enhancement.cd-speed psx.enhancement.fast-loading GAME_GENERATED_FULL_C "${GAME_ITA_GENERATED_FULL}" GAME_GENERATED_DISPATCH_C "${GAME_ITA_GENERATED_DISPATCH}" EXTRAS_SOURCES ${TOMBA2_MOD_PLUGIN_SOURCES} diff --git a/README.md b/README.md index 1537b32..1f0044c 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ retail dump; clear it to return to bundled OpenBIOS. Tomba 2's mods live on the launcher's **Mods** page. Seamless Loading is enabled by default for the US disc, replacing the generic CD Speed and Fast Loading options. Adaptive widescreen and 1080p internal rendering are also enabled by default. -Interpolation is disabled and hidden; Skip FMVs remains optional. Italian retains its existing loading options. +Interpolation is disabled and hidden; Skip FMVs remains optional. Generic CD Speed and host-paced Fast Loading are excluded from both regional release catalogs. ## Layout @@ -70,6 +70,13 @@ overlays at runtime (same architecture as Tomba! 1). ## Built-in mods +**Mario's Hat (EXPERIMENTAL 0.0.1, US only)** is optional and disabled by default. +Select your original Super Smash Bros. 64 US 1.0 ROM in Mods, then equip the +costume in game. The verified ROM is required before imported assets can be used, +including on later launches with a warm cache. No Nintendo ROM or imported assets +are bundled. Mario has double jump, extended punches and native grab/throw moves; +full progression and all-stage behavior remain experimental. + Enable **Tomba 2 Widescreen** on the Mods page and select 16:9, 21:9, or Adaptive. Adaptive follows the live window or fullscreen aspect from 4:3 up to 21:9. Resizing wider reveals more of the world instead of stretching a fixed diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2892eb3..0384c41 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,25 @@ +# Tomba! 2 v0.3.0 - Experimental Mario's Hat + +The US Windows preview adds optional **Mario's Hat, EXPERIMENTAL 0.0.1**. +Enable it in Mods, select your original Super Smash Bros. 64 US 1.0 ROM, +and equip the costume in game. A verified ROM remains required even when +imported assets are already cached. No Nintendo ROM or imported asset is shipped. + +- Imported Mario appearance, motions and voice cues, double jump, extended punch + reach and native grab/throw interactions. +- Corrected facing, chest perch, post/ledge grab, hanging and ladder presentation. +- Diagnostics disabled in production, complete launcher fonts/artwork and + current framework presentation controls retained. +- Preserves the accepted wall-clock presentation default instead of adding + synchronized-swap stalls on a non-matching high-refresh monitor. +- Current-build save/load and native memory-card progression were tested on + representative village routes. Full progression, all-stage performance and + complete native coverage remain unproven; the mod defaults off. +- CD Speed and host-paced Fast Loading packages are excluded from releases. + +This release targets the US disc on Windows x64. Existing Italian and Linux +release artifacts remain available separately. + # Tomba! 2 v0.2.1 - Startup loading fix Fixes a startup hang immediately after the Whoopee Camp logo movie that could diff --git a/docs/AOT_RELEASE.md b/docs/AOT_RELEASE.md index b89ad2e..acb2ed0 100644 --- a/docs/AOT_RELEASE.md +++ b/docs/AOT_RELEASE.md @@ -19,6 +19,20 @@ filename table. Addresses and hashes are never borrowed from another region. ## Build and package +The US release uses the shared channel-enforcing packager and the title's +`packaging/release.toml` configuration: + +```powershell +& 'C:/msys64/mingw64/bin/python.exe' psxrecomp-v4/tools/package_title_release.py --channel public --platform windows --jobs 2 +``` + +It verifies the actual public/diagnostics-off build flags, records source and +artifact identities in `RELEASE_MANIFEST.json`, and reopens the finished archive +for verification. The US post-stage gate also checks Experimental Mario 0.0.1, +the required verified Smash ROM, and absence of imported Nintendo assets or +prohibited loading controls. Other regional/platform artifacts remain separate +until their matching shared packaging configuration is qualified. + Provide original discs at the paths in `game.toml` / `game_ita.toml` and use the pinned framework emitter. Both packagers always run fresh extraction and compile every recipe in isolation, then audit every native pair against original bytes. diff --git a/docs/MARIOS_HAT.md b/docs/MARIOS_HAT.md new file mode 100644 index 0000000..b7520d7 --- /dev/null +++ b/docs/MARIOS_HAT.md @@ -0,0 +1,485 @@ +# Mario's Hat development + +Status: substantial implementation in progress, **not release ready**. +Tracking: `beads-hal6` (title) and `beads-qbbc` (framework PCM callback). +The current target is US SCUS-94454, Windows, OpenGL. Other renderers and +regions are not qualified. No Nintendo ROM, extracted asset, sampled animation +or PCM data belongs in source control or a distributable. + +## Design and controls + +The optional mod adds a permanent virtual inventory item, Mario's Hat. Equip +or remove it through the ordinary inventory using Tomba's native costume +transaction and transformation animation. The item remains available without +setting an ownership byte in the native save structure. Other costumes are +exclusive with Mario and must restore Tomba. + +The approved v1 uses Smash Bros. 64 Mario: movement, one extra airborne jump, +fist-range attacks retaining the equipped weapon's damage/target rules, +overhead capture, and native hold/aim/throw. Native paths, collision, +attachments, shadow and progression rules remain authoritative. Explicit +suit/event gates remain. Fireball, Super Jump Punch and tornado are deferred. +Controls remain Triangle for inventory, Cross for jump/confirmation, Circle +for attack/talk and native controls for carrying/aiming/throwing. These design +statements are not a claim that every interaction below has passed QA. + +## Local import + +The bundled US manifest is +`mods/preloaded/packages/tomba2.character.mario-hat/0.0.1/manifest.toml`. +Version 0.0.1 is Experimental and default off. The existing format-9 +native resource picker/import pipeline uses `prepare = "tomba2.mario.import"`. +The ROM resource is input-only, shared as `n64.smash64.us`; the prepared asset +resource is hidden. The US runtime stages this manifest into its normal Mods +catalog, including release builds. The Italian catalog does not include it. + +The importer accepts canonical US 1.0 data in `.z64`, `.v64` or `.n64` byte +orders, normalizes it and verifies size and SHA-256 before extraction: + +The ROM is mandatory whenever the mod runs, including when imported assets +already exist. The trusted preparer validates the selected ROM before checking +the asset cache. Keep that ROM accessible at the selected path; a cache alone +does not satisfy the requirement. Missing selection, an unavailable file and +a correctly sized N64 file with the wrong SHA-256 were each rejected by the +production executable (exit 1), with the valid imported cache still available. +The isolated rejection evidence is `rom-requirement-r28/report.json` under the +retained feedback task directory. No owner gameplay was relaunched. + +| Identity | Value | +| --- | --- | +| Canonical ROM bytes | 16,777,216 | +| Canonical ROM SHA-256 | `15592e79d3c5295cef4371d4992f0bd25bec2102fc29644c93e682f7ea99ef3d` | +| Cache magic / revision | `MARIOR02` / `r2` | +| Cache bytes | 678,804 | +| Cache SHA-256 | `1cbe9ce74e63464da8582d14dea5f8ba94e30a8c0258f03fee80f9abb3ace588` | + +The cache contains 25 joints, 320 triangles, 18 textures, 34 sampled motions +(1,424 frames) and 13 locally decoded Mario voices. It is published under +`%LOCALAPPDATA%/psxrecomp/imports/tomba2-mario/r2/`. It is private owner-derived +data, not a redistributable build input. Decoder metadata is derived from +`ssb-decomp-re` commit `572a747d8171137718fad472c5d7473dc7309ffd`; +the ignored research clone is not a vendored asset dependency. + +For standalone importer/decoder checks, configure with the native MinGW +compiler and Ninja, then build the small test project: + +```powershell +& 'C:/msys64/mingw64/bin/cmake.exe' -S tests/mario -B build-mario-assets -G Ninja ` + -DCMAKE_C_COMPILER=C:/msys64/mingw64/bin/gcc.exe ` + -DCMAKE_CXX_COMPILER=C:/msys64/mingw64/bin/g++.exe ` + -DCMAKE_MAKE_PROGRAM=C:/msys64/mingw64/bin/ninja.exe ` + -DMARIO_TEST_CACHE="" +& 'C:/msys64/mingw64/bin/cmake.exe' --build build-mario-assets --parallel 2 +& 'C:/msys64/mingw64/bin/ctest.exe' --test-dir build-mario-assets --output-on-failure +& './build-mario-assets/mario-import.exe' '' '' +``` + +Without an owner cache, the asset test runs malformed-input checks then reports +skipped (77); the separate deterministic PCM test requires no ROM. The native +picker/import UI has not been validated; importer success alone does not +establish that UI workflow. The owner explicitly waived import UI work for +the current gameplay handoff on 2026-10-24. + +## Runtime implementation + +`src/mods/tomba2_mario_plugin.cpp` redirects guarded item metadata/icon table +accesses into guest allocations and appends item ID 256. Inventory lookup +returns a full-width virtual ID; it never indexes the 256 native ownership +bytes with that ID. The native costume byte remains ordinary costume zero. +The mod's requested/equipped state lives in guest memory. + +The imported body uses sampled source poses, mod texture banks and GT3 packets. +Its 18 materials share a 256x64 immutable atlas with replicated borders; +material origins change packet UVs without changing imported texels or OT order. +Original actor logic, attachments and shadow continue to run. Weapon-family +actors provide short-lived fist collision windows; their original family +handlers perform retirement/pool accounting. Real damage and weapon-specific +target rules still require meaningful controlled interaction fixtures. + +Framework `psx_mod_register_pcm_plugin` callbacks mix after SPU/shadow synthesis +and before audio capture or host muting. The integer voice resampler has an +explicit source cursor and fractional phase in guest memory. Loading a state +rebinds textures without clearing valid restored metadata or voice state. +Current-build save/load, rewind and rollback must remain correct. States from +other builds are disposable; native memory-card progress is a separate contract. + +## Build and live smoke tooling + +Use the isolated title worktree and its nested framework. Preserve the asset +junction `tomba2`; do not recursively remove or replace it. Check drive space +and I/O before substantial generation/builds. Use native executables directly +from PowerShell with profile/login behavior disabled. + +Regenerate after hook-list changes; never edit generated C: + +```powershell +& './psxrecomp-v4/recompiler/build/psxrecomp-game.exe' --config game.toml +& 'C:/msys64/mingw64/bin/cmake.exe' --build build-mario --target psx-runtime --parallel 2 +``` + +Build only `psx-runtime` in this worktree: the default all target also requests +Italian generated code that is absent. Development overrides name the binary +`Tomba2Mario.exe`, with debug tools on and port 4597. Private launch arguments: + +```text +--hidden-window --no-launcher --game .research/game-test.toml +--bios build-mario/bios/openbios.bin --debug-port 4597 +--memcard-dir .research/test-saves +``` + +The private cards/states resolve under `build-mario/.research/test-saves`. +Use process-local `SDL_AUDIO_DRIVER=dummy` for unattended work and prepend the +native MinGW bin directory to process-local PATH. TCP screenshots, RAM reads +and input routes preserve desktop focus. Owner QA is a separate explicit +handoff in a normal window with normal audio. + +`tools/mario_probe.py` exposes bounded live RAM observations, input routes, +state completion receipts and screenshots. `Probe.actors()` walks the native +active lists, not a list of proven enemies. RAM reads are live observations, +not atomic snapshots. Byte writes can race native physics; a position write +that is immediately corrected by the native path resolver is not a valid +interaction fixture. The attempted debug placement hook was discarded because +the native player path context still corrected the requested location. + +```powershell +python tools/mario_smoke.py --output build-mario-assets/smoke-current +``` + +Run only against an isolated private-card runtime. Slot 7 must be quiet, +grounded Tomba gameplay near the Fisherman's house, owning exactly two native +equipment entries. The script fails if this layout differs. It writes a Mario +fixture to slot 8 and a mid-voice fixture to slot 9; slots are configurable. +Button durations use guest VBlanks through `input_route_*`, never pause/step. +The report distinguishes passed assertions from unverified contracts. +The helper waits for actual game entry and the US MAIN.EXE function identity +before the first restore. Restoring too early during boot left host title/boot +context active in a test attempt. Inventory checks also verify that the native +menu opened and that the virtual item is selected before confirming it. + +## Validation and remaining gates + +The r11 message-queue build passed 21 live assertions: native inventory transaction, +equip/unequip, first jump, one extra airborne jump, rejected third press, +landing rearm and a new extra jump on the next cycle, current-build load with +an imported voice active, cursor completion, and unchanged native ownership +bytes. The longer native extra-jump voice provides the mid-voice save fixture; +the short first-jump voice can finish before asynchronous observations return. +The separate asset and PCM tests passed, including preservation of every atlas +texel and edge borders. Five framework tests passed again: +`mod_runtime_test`, `mod_packages_test`, +`mod_texture_banks_test`, `mod_memory_snapshot_test`, +`spu_end_without_repeat_test`. + +Visual evidence `build-mario-assets/ui-r6-equip-inventory.png` and +`ui-r6-equip-toast.png` verifies the cap is inside its native icon slot, the +ordinary Blackjack row retains its name, and the confirmation names Mario's +Hat. `smoke-r11b/` retains current screenshots and the assertion report. +`atlas-r10-front.png` verifies face/cap texture mapping after atlas packing. + +`tools/mario_suit_smoke.py --item 15 --output ` +checks exclusivity through real native menu transactions in both directions. +Squirrel Clothes (15), Bird Clothes (14), and the first Evil Pig robe (18) +passed eight assertions each in r11, including confirmation consumption and +restoration of the original ownership bytes. Acquisition is deliberately +bypassed by granting only the chosen native item and its inventory sort byte +in private RAM; these checks do not establish acquisition or event gates. +Reports and screenshots are in `suit-r11-squirrel/`, `suit-r11-bird/`, and +`suit-r11-robe/` under `build-mario-assets`. + +`tools/mario_interaction_smoke.py --output ` walks +from the Mario slot-8 fixture to the first post pig. It compares the same +approach with and without an attack, checks a distant miss, observes an actual +native capture link, and saves/loads that capture. Actor observations come +from the recorded frame ring. It writes disposable slots 10/11 by default and +restores the input fixture and previous snapshot-region configuration. No +actor position, health, capture or success flag is changed. `--weapon 3` and +`--weapon 6` grant Boomerang and Hammer ownership/order in private RAM and use +the native inventory to equip them; acquisition is bypassed and the original +ownership is restored. These are ordinary-pig interaction checks, not proof +of damage amounts or weapon-specific target gates. + +The r12 test exposed a real Hammer defect: its fist was retired after four +total handler ticks, while the native swing was still winding up in phase 2. +The r13 implementation counts four active ticks after the Hammer reaches its +damaging phase, bounds interrupted lifetimes, and clears its native terrain +impact latch on forced retirement. The Hammer now passes the real hit/miss, +balanced retirement, capture and capture-state restore checks (27 assertions, +`interactions-r13-hammer/`). +Blackjack and Boomerang passed the same rebuilt-runtime comparison (23 and +27 assertions respectively), including out-of-range misses and restored +ownership. Evidence is in `interactions-r13-blackjack/` and +`interactions-r13-boomerang/`. The broader r13 smoke rerun exposed a pending +save that completed after its short jump voice ended; that snapshot-timing +investigation is tracked separately as `beads-qrwo`. Do not claim a fresh +mid-voice save/load pass for r13 from the earlier r11 evidence. + +The r14 framework fix (`9e4cf574`, `beads-qrwo`) polls staged user snapshots +before the amortized interrupt fast returns, retaining the exception, native +resume-PC, host-adapter and mod-callback safety gates. Save-status receipts +now report poll counts and deferral reasons. The revised smoke tool submits +the save when the real extra-jump voice starts, then waits for actual restored +frames before inspecting history. All 21 checks passed in `smoke-r14-final/`: +the save completed on its first poll and recorded frames restored voice 436 +with its cursor and fractional phase. Six focused framework tests passed, +including the extended host-snapshot boundary test. + +A matched quiet-scene check sustained approximately 60 VBlanks/s before and +after this framework fix (16.681/16.689 ms mean host frames). Long-frame +outliers also occurred with the unchanged framework in the exact same saved +scene (187.727 ms baseline maximum versus 172.216 ms candidate); they are not +established as an introduced regression. These capped measurements qualify +this narrow comparison, not the full mod performance gate. Binary hashes, +source diff and measurements remain under `build-mario-assets/snapshot-poll-*`. + +`tools/mario_voice_smoke.py` (NumPy required) stimulates guest voice 437 and +correlates the real post-SPU WAV with independently resampled owner PCM. The +r10 capture in `voice-r10/` passed at normalized correlation 0.814 and the +voice cursor completed. This is an audio integration check, not proof of the +gameplay event that normally triggers the voice. Use only private output and +cache paths; the WAV contains owner-derived sound and must not ship. + +`tools/mario_perf.py` compares quiet Tomba and Mario fixtures in the same +binary/configuration/scene. The mod is loaded in both cases, so this does not +qualify a separate pre-framework or mod-disabled baseline. In the 5x hidden GL +measurements, the original material banks produced about 101 batches/present +and 2.13 ms flush time as Mario. The atlas reduced these to 12.3 batches and +0.375 ms, close to the paired Tomba's 10.5 batches / 0.373 ms. Throughput was +noisy: the earlier pair measured 47.16/37.05 guest VBlanks/s (Tomba/Mario), the +reverse-order atlas pair 33.36/32.23. Do not claim that the final frame-time +budget or cross-build performance gate has passed from these variable runs. + +The r15 native-card check loaded the file saved while Mario was equipped, +first in the same process and then from a fresh process with no savestate +transactions. The fresh load restored ordinary Tomba at the saved sign with +the expected native ownership bytes and responsive movement. Mario selection +is session state and is not embedded in the native save. The unchanged card 2 +and all card-1 progress sectors were verified. The only first-load card change +was system write-test frame 63: native SIO writes 38 and 91 reconstruct the +before/after hashes exactly. Initial whole-card equality was too strict; +`native-reload-r15-fresh/report-audited.json` retains that correction and its +proof, while preserving the initial failed report. The revised +`tools/mario_native_reload_smoke.py` passed 15 assertions in +`native-reload-r15-final/`. It uses only native menus, with no RAM writes or +snapshot loads. `mario_probe.py` now accepts complete multiline JSON responses; +the card transaction diagnostic pretty-prints its arrays across lines. + +An r15 recorded capture route held the post pig, turned from native heading +0 to 2048 with Left, and used Cross to execute phases 3/4/6 and release the +pig through its native handler. This is still the special first-post pig; +its visible placement was below Mario, so that build did not implement +overhead presentation correctly. Evidence: `capture-route-r15.json` and +`capture-{hold,aim-left,throw}-r15.png` under `build-mario-assets`. + +The r16 fix raises only the imported arm render matrices and translates the +captured actor's native model matrices during its draw call, then restores +every matrix. Native actor coordinates, links, collision and release remain +unchanged. The source model faces +Z, not the previously assumed +X; corrected +yaw now follows native headings. Asset checks cover all four quarter turns, +unchanged non-arm bones and rigid arm lengths. `interactions-r16b-blackjack/` +passes 30 assertions, including hold, both aim directions, unchanged native +captured coordinates, active overhead drawing and actual throw/release. The +broader 21-assertion smoke also passes in `smoke-r16/`. Screenshots +`facing-{left,right}-r16.png` and the interaction captures show the correction. + +Probe framing now stops at a complete JSON object instead of requiring EOF: +the latter raced Windows socket reset during a history read. Two socket tests +in `tests/mario/test_probe.py` exercise fragmented multiline replies (including +quoted braces/escapes) and failure responses while the server keeps its socket +open. Both pass; live interaction/history and 2 MiB RAM reads also pass. + +The r16 matched quiet-scene runs remain below the desired runtime target: +forward order Tomba/Mario 59.49/55.65 VBlanks/s, reverse order Mario/Tomba +47.81/57.29. Other compiler processes heavily used the host (one `cl` process +reported 645% CPU), and long-frame outliers varied, so these are not a clean +final performance qualification. Mario emitted 320 body triangles without +culling; the original Smash fighter display state uses `G_CULL_BACK`. Check +source geometry modes and built behavior before changing this policy. + +The r17 decoder now retains F3DEX2 geometry-mode culling per triangle. The +normal fighter draw in the referenced Smash source explicitly enables backface +culling; the imported model's geometry-mode commands can retain two-sided, +front-cull or both-cull behavior. The native renderer applies that policy after +near clipping and projection. In the quiet scene this reduces emitted body +triangles from 320 to about 157 without losing the visible face/cap/gloves. +The front/back/two-sided mode checks and owner asset/PCM checks pass. + +Both matched r17 orders now show quiet-scene parity: forward Tomba/Mario +57.88/58.18 VBlanks/s and reverse Mario/Tomba 59.42/59.57. Mean measured host +frames were 16.680–16.683 ms in all four cases, with 20.235–22.375 ms maximums. +Reports are `perf-r17{,-reverse}.json`; source hashes, executable identity and +the source patch are retained alongside them. This qualifies this quiet 5x GL +costume comparison, not all gameplay or a separate mod-disabled engine baseline. + +The r18 draw adjustment mirrors the native capture attachment's full +`80 + target height` offset above the imported hands. This clears the pig from +Mario's cap while turning. Both directions were visually inspected in +`interactions-r18-blackjack/`. All 30 interaction assertions and all 21 broader +smoke assertions pass in r18, including current-build captured-state restore, +actual native hold/aim/throw and the active voice restore. No dispatch misses +were recorded, and both private card files still match the post-save hashes. +`render-r18-identity.json` records the tested binary/source hashes. The hidden, +muted runtime was stopped after testing; its build, fixtures and evidence remain. + +The r20 basic checks survived the failed session and were recovered from +`build-mario-assets/basic-r20/`. Native contact damage reached zero health, +the death/Continue screen appeared, and Cross returned to responsive gameplay +with positive native health, Mario still equipped, a rearmed extra jump and +unchanged inventory. All six Continue assertions passed, with no RAM writes +or dispatch misses. Both private card hashes match the validated r18 values. + +The enabled adaptive widescreen profile followed 960x720, 1280x720, +1680x720 and 960x720 windows (eight assertions). Composed `present_shot` +captures show real additional terrain/scenery at 16:9 and 21:9 and the return +to 4:3. These are introductory-village checks, not an all-stage widescreen +audit. The r21 follow-up passes seven checks: wide inventory opens/closes, +Mario remains equipped, current-build save/load restores health, inventory +and rearmed extra jump, both cards are unchanged, and dispatch misses remain +zero. Evidence: `build-mario-assets/handoff-r21/preflight.json` and its two +composed screenshots. An initial probe requested invalid slot 12 and aborted; +the corrected report uses slot 9 (valid slots are 0 through 11). + +Slot 9 is now the post-Continue Mario gameplay handoff fixture outside the +Fisherman's house, replacing the disposable r20 widescreen fixture. The +earlier voice-restore reports remain evidence; rerunning the voice smoke +regenerates its fixture. The owner will assess actual Mario gameplay and give +feedback. No new build or engine change was needed for these checks. + +Still unverified: damage amounts and weapon-specific target requirements, +broader capture/aim/throw target coverage, rewind/audio replay, broader runtime +performance and widescreen coverage, release defaults and packaging. Import UI +is waived for this handoff. A post approach +caused native Tomba and Mario to enter the same blocked/hurt states; it is not +proof of a working hit or capture. The later controlled interaction fixture +above provides separate hit/stun and capture evidence. Relocated actor `0x800fc8d8` was visually +identified as an NPC, so its counters/memory changes are not combat evidence. + +The expensive original-disc AOT pipeline completed 29 images / 54 recipes and +staged 81 audited native pairs in `build-mario-aot/staged-audited`, with +`AOT_CACHE_AUDIT.json`. Preserve this evidence and the successful extraction +`build-mario-aot/disc-aot-5gv1i6ou`. The toast fixes changed the configured +function hook from `0x80078ca8` through `0x8004ed0c` to `0x8004fa38`; the resulting native cache +namespace differs from that earlier audit. Final staging must use an audit +matching the final hook/codegen configuration. Do not blindly copy or rename +the old cache namespace, or restart every recipe before checking which inputs +and artifacts can be reused by the supported pipeline. + +These tests qualify the basic development-build handoff, not feature +completion or a release archive. A future archive must exclude ROMs/imported caches and both +`psx.enhancement.cd-speed` and `psx.enhancement.fast-loading` packages. Keep the +optional character feature default off. Keep both Beads issues in progress +until their requested outcomes are validated. + +Cleanup retained the successful AOT extraction/audit and private r2 cache; +two failed extraction directories containing only their failure logs and the +obsolete local-app-data r1 cache were audited and removed. Builds, current +states, useful screenshots/WAVs and performance reports remain private task +evidence and need cleanup after the implementation/release handoff. +The temporary r15–r18 offline pose-probe source, executable, raw exported mesh/ +pose JSON and contact images were audited and removed after the presentation +checks; no new bulk scratch remains outside the retained build/evidence trees. + +## Owner feedback and gated r23 playtests + +R23 corrects costume-transformation facing, extends the fist's forward native +collision extent from 122 to 152 units, and restores the imported voices' source +pitch and duration. Smash FGM 429..441 selects tables 296..308; their first pitch +commands lower the raw waveforms by approximately an octave. The previous mixer +ignored that articulation. Common Tomba vocal events now choose Mario cues at +the native sound wrapper instead of adding a second voice from action changes. +See `MARIOS_HAT_CHECKPOINT.md` for exact fields, rates and current evidence paths. + +R23 passes 22 broad gameplay checks, 30 real interaction checks, native asset/PCM +tests and Python gate tests. A matched input route demonstrates a newly reachable +pig hit while distant punches still miss. Resolve that target by native handler +and authored coordinates: actor-pool addresses change after Continue/reloads. + +The new playtest tool prevents a repeat of a stale/incomplete shard handoff: + +1. `prepare` rebuilds the verified original-disc recipes with the supported + framework pipeline and stages its audit with the matching cache namespace. +2. `qualify` verifies every audited artifact, proves runtime page/orphan additions + against bounded original-disc bytes, binds the debug port to the actual hidden + executable, and measures standing and movement with private fixtures. It + requires >=57 VBlanks/s, >99% native dispatch, no misses, live Mario gameplay + and no artifact changes during the measurement. +3. `launch` accepts only a passing report for the exact executable, configuration, + tool and cache. It opens a normal window with normal audio and private cards. + +Use Python 3.11+ and native Windows GCC. Example qualification/handoff after a +hidden, muted QA process is running on 4598 with current-build slots 7/8/10: + +```powershell +$py = 'C:\Users\Matthew\AppData\Local\Programs\Python\Python312\python.exe' +$qa = 'G:\Temp\Codex\psxrecomp\tomba2-mario-feedback-20261024\qa' +& $py tools/mario_playtest_gate.py qualify --exe "$qa\Tomba2MarioCandidate.exe" ` + --inventory build-mario-aot/disc-aot-5gv1i6ou/runtime-input-inventory.json ` + --qualification "$qa\PLAYTEST_QUALIFICATION.json" --port 4598 +# Stop that automated process before the owner-facing launch: +& $py tools/mario_playtest_gate.py launch --exe "$qa\Tomba2MarioCandidate.exe" ` + --qualification "$qa\PLAYTEST_QUALIFICATION.json" --cards "$qa\cards" --port 4598 +``` + +The first cold movement qualification correctly failed (54.92 Hz plus new cache +files). With the additions retained and independently checked, a fresh process +passed at 59.57/59.50 Hz standing as Tomba/Mario and 58.79 Hz moving. The cache +remained stable and native dispatch exceeded 99.9%. These are village-route +measurements at the current profile; they do not establish every area's +performance or release readiness. Never rename an old namespace to satisfy the +gate. A new binary/configuration/cache requires new qualification. + +The final timing code queues debug inputs before measuring and uses actual +guest-frame progress between midpoint-timed counter reads. Earlier movement +rates included the setup time for 24 input RPCs and understated throughput. +The corrected final gate passes at **59.99/60.03 Hz** standing as Tomba/Mario +and **58.63 Hz** moving; its exact-identity report is +`qa/PLAYTEST_QUALIFICATION.json`. The 54 compile logs are retained, while roughly +1 GB of disposable compiler jobs and duplicate caches has been removed. + +## R24: bucket dialogue and production comparison + +Owner feedback exposed that r23's costume-only facing exception was too narrow. +The native yaw matrix faces (cos h, **-sin h**) in X/Z; the imported conversion +used +sin h. R24 corrects this globally, removes the special-case half-turn, +and corrects the fist's Z direction. The exact bucket-on-fishing-wire dialogue +was reproduced through real native pickup contact and visually compared with +Tomba. Tests retain its independently observed native yaw/matrix values. + +Busy-area slowdown also survives r23's narrow village gate. R24 with GPU timer +queries disabled measured 59.65 Hz in the busy fixture; the owner requested the +stronger experiment of compiling diagnostics out entirely. That Release build +uses `-DPSX_DEBUG_TOOLS=OFF`, retains the same 1080p/5x/adaptive-wide profile, and +stages 123 native pairs whose guards match original recipe bytes. One unproven +runtime-only pair was excluded. This is an owner-requested production trial, +with its hashes/flags in `production-playtest/PRODUCTION_BUILD.json`; no TCP +performance qualification is claimed for it. Native cards are separately copied, +and debug save states are not carried across. See the checkpoint for the open +owner process, exact repro and retained evidence. Production build scratch is +retained for ongoing feedback and must be cleaned when no longer needed. + +## US reverse-engineering notes + +Addresses below were checked against live US SCUS-94454 instructions/data. +Overlay address identity must be requalified for another stage/revision. + +| Address / field | Meaning | +| --- | --- | +| `0x800e7e80` | Player actor; `+4` mode, `+5/+6` action/phase, `+0x2c/+0x30/+0x34` Q16 world position | +| Player `+0x145` | Native air phase; zero after landing. `+0x29` is not a reliable standing/grounded flag | +| `0x800bfab4`, `0x800bfbb4` | Native ownership bytes and inventory ordering | +| `0x800a2be8`, `0x800a24f4` | Native item metadata and icon-index tables | +| `0x800376ec` | Item-name renderer; menu/item/column/y in a0..a3 | +| `0x8007e938` | Sprite wrapper: a0 is a sprite bank, a1/a2 anchor x/y, a3 UI layer | +| `rw(0x800ed8c8) + layer*4` | Actual UI ordering-table link | +| `0x8004ed0c`, caller `0x8004d6e4` | Ordinary equipped-message producer and costume wrapper return | +| `0x8004eddc`, caller `0x800343fc` | Two-item remove/equip message when replacing a native suit | +| `0x8004fa38` | Native message queue; a1 = -1 accepts already encoded text | +| `0x8004ead0` | Encodes an item name: equipment colour `0xf4`, character minus 32, space/newline `0xfb`, ordinary colour `0xf0`, terminator `0xff` | +| `0x800fb168`, `0x800f2624`, `0x800f2738` | Three active-list heads. Node `+0x20` is previous, `+0x24` next, `+0x1c` handler. The third list can be empty during gameplay | + +The inventory cap is an imported 8x4 sprite drawn at 2x, centred around the +native icon anchor. The old broad text-render replacement could rename an +ordinary Blackjack row while a timeout was active. The replacement handles +only a pending hat transaction at the message queue, checking the encoded +item/status tokens and saved costume caller addresses on the native stack. +Both ordinary equip/remove and replacement of a native suit use the hat name; +weapon and shoe confirmations and ordinary inventory labels remain native. diff --git a/docs/MARIOS_HAT_CHECKPOINT.md b/docs/MARIOS_HAT_CHECKPOINT.md new file mode 100644 index 0000000..d9e4c53 --- /dev/null +++ b/docs/MARIOS_HAT_CHECKPOINT.md @@ -0,0 +1,372 @@ +# Mario's Hat continuation checkpoint + +## Release continuation, 2026-10-25 + +Recovered failed session `01a1705c-2e8e-7eb2-93c4-308f861fa7aa` and the owner's +explicit approval to merge, with Mario labeled **EXPERIMENTAL 0.0.1**. Title +PR: ; framework API dependency: +. Do not repeat the +completed owner visual handoff. + +The US Windows preview version is `v0.3.0`. `packaging/release.toml` uses the +shared public-channel packager, OpenBIOS only and diagnostics off. The mod is +in the ordinary US catalog, default off, and still requires the verified +original Smash US1.0 ROM before cached assets can be reused. Runtime production +refusal checks cover missing selection, missing file and wrong SHA-256. + +Release and development native-hook arrays now agree; the earlier release +config omitted Mario hooks. Current original-disc cache namespace is +`cg18_67fb3a9e_gcacf1fd28_f2`: all54 recipes/29 images audit, yielding73 pairs +and14328 manifest rows. Counts establish guarded candidates, not full coverage. +The current integration passes the22 native gameplay/voice/current-build +save-load assertions and loads the original private native card with the +expected position/inventory. Both regional release catalogs exclude CD Speed +and Fast Loading; Italian copies are retained developer-only/default-off. + +An initial public package was withheld after pacing measurements. The newer +framework selected synchronized swaps on the165Hz panel, unlike the approved +build's driver-off wall-clock pacing. The title now explicitly preserves that +presentation default through `[video] vsync = "off"`; user preferences still +override it. Keep the existing57Hz development qualification threshold. CPU +contention and early-startup snapshot loads affected earlier samples; retain +the reports and distinguish them from the final qualified profile. + +Current work/evidence: `G:/Temp/Codex/psxrecomp/tomba2-mario-feedback-20261024/merge-r29`. +Requested artifacts go to title `dist/`. The original accepted r26/r27 build +and owner cards remain untouched. Final merge/release status and hashes are +recorded in Beads `beads-hal6` and the GitHub PR/release records. + +Last updated: 2026-10-24. Work solo. Main tracking: `beads-hal6`. + +Worktree: `F:/Projects/psxrecomp/_wt-tomba2-marios-hat`, branch +`mod/marios-hat`; native-save commit `edaef10`, overhead/facing commit `54054f6`. +See the latest title commit for the final culling/clearance fix. Nested framework branch +`mod/tomba2-marios-hat`, snapshot fix `9e4cf574`. Both were clean on resume. +Read `MARIOS_HAT.md` for design, build commands, completed checks and open gates. + +## Current owner-feedback build: r26 (production), r27 launcher repair + +The repaired owner handoff opened recomp-ui as PID **26928**, caption +`Tomba! 2 - The Evil Swine Return - Launcher`, normal window and normal audio. +The command explicitly uses `--launcher`. All hidden QA processes are stopped; +no automated input goes to the owner process. Source is committed on +`mod/marios-hat`. Passing evidence and private native cards are retained. + +R27 repairs the handoff folder, which omitted `assets/` and `bios/` during +manual staging. This caused the fallback pixel font and absent artwork. +Assets resolve beside the executable, independent of working directory. +The gate now stages both build-generated folders automatically and checks +every support file by SHA-256, including the required fonts, box art and BIOS. +Verification refuses missing/changed files; launch restores them before checking. +Owner settings, mods and native cards are outside that copy operation. The +four gate tests pass, including missing font/cover/BIOS, tampering, incomplete +build inventory and preservation of user settings/native saves. Owner launch +also clears hidden/script/smoke-test environment overrides. + +Actual production Play was clicked through the Windows computer-use skill in +a muted private clone. The launcher exited into the production runtime; +OpenGL/BIOS initialization and executed guest PCs `0x80017D74`/`0x80017DAC` +were observed. Hidden LNG_SCRIPT clicks/keys did not activate Play and are +not counted as launch evidence. No game/engine rebuild was needed. The private +clone was stopped and removed; compact screenshots, startup logs and receipts +remain in `launcher-r27`. Owner PID24184 was already closed before this retry. +The repaired owner handoff retains normal audio, normal window and DualSense. + +The owner accepted the repaired build ("this looks good") and authorized +committing and pushing all work to a branch. Publish the title branch +`mod/marios-hat` and its pinned framework dependency as +`mod/tomba2-marios-hat`; preserve the tested submodule pins. The Smash 64 ROM +requirement already exists: required input-only original US1.0 ROM, normalized +size/SHA-256 checked before cached-asset reuse. Three isolated production +checks reject missing selection, missing file and wrong SHA-256 even with the +valid asset cache available; see `rom-requirement-r28/report.json`. These reject +before renderer/game startup and do not repeat the owner's cleared playtest. + +The owner clarified the missing climbing pose was on **posts/ledges**. R26 +additionally plays Smash CliffCatch572 through the fresh native post grab, +then CliffWait573, and uses jump509 during the native4/2 upward transition +before fall513. Actual frame history records both native and mod guest state: +`post-r26/post-grab-r26.json`, `post-upward-r26.json`, and the composed ascent +image. `traversal-r26-post-final` passes24 checks, including fresh grab/catch, +hanging rest, upward movement with the jump pose, and the earlier chest/ladder +checks. An initial live end-observation saw native reattachment after reading +the trace; release assertions now use its actual recorded frames. The prior30 +interaction checks and matched reach evidence remain valid for their unchanged +code. R25 launcher33532 was closed normally and replaced with this build. + +The owner confirms the diagnostics-free build is **WAY more performant**. +Keep future owner handoffs Release / `PSX_DEBUG_TOOLS=OFF`. The latest request +is chest perching, 2-3x punch reach, climbing/hanging animations, then reopening +at recomp-ui. The previous production process was already closed by the owner. + +R25 maps native capture phases8/9 to Smash Teeter/balance520, rather than the +throw's final pose. Phases10..12 are the spring away from a chest and now use +jump/fall. Native hang4/1 uses looping Smash CliffWait573. The house ladder is +traversal0xA/11 catch, /12 climb, /13 exit: native motion0x40 -> CliffCatch572, +0x41 -> CliffWait573 with alternating rigid hand/knee movement, then +CliffClimbQuick2 576 for exit. Smash64 has no repeating ladder motion, so that +cycle adapts the imported hang skeleton without stretching limbs or moving +native roots. Approach jumps/falls/landings and sign turns have separate poses. +All assets were already in the r2 import; no reimport or ROM redistribution. + +Punch center202 + half-width178 gives forward extent380, **2.5x** the previous +152, preserving near edge24. Native vertical bounds, handlers and four active +ticks remain. Actual matched post-pig inputs now hit at pretravel0/2/4/6, +which R23 missed; all0..14 hit and the distant miss still passes. + +Validation: asset/PCM2/2 (36 real motions, including balance and exit), gate3/3 +with diagnostic/stale staging refusal, 30 live hit/grab/hold/turn/throw checks, +and 20 traversal checks including native chest spring, hang/release, continuing +ladder motion, current-build state restore and actual roof exit. Composed +images reviewed in `traversal-r25-balance`. An initial test incorrectly assumed +the chest spring must leave action3 immediately; observed native3/11 air1 +corrected both the test and the spring animation. A deep crouch experiment was +replaced by the source balance pose after visual inspection. + +Evidence root remains `G:/Temp/Codex/psxrecomp/tomba2-mario-feedback-20261024`. +Private QA fixtures: slot1 chest perch, slot4 post hang, slot5 mid-ladder, +slot11 disposable current-build ladder save. Slot2 remains pre-bucket house. +To reproduce the ladder, load slot2, teleport X only to11100, hold Up47 frames; +native collision enters0xA/12 and ascends. Only travel is bypassed; no action, +inventory, collision link or success flag is written. Slot4 came from actual +Right125, Right+Cross24, Right40 input from quiet Mario slot8. + +Current production executable SHA256: +`82f964afe80aba5bcc43eda0d5d9250f6fb93248c9c1aac34d3cdbe0c90270f1`. +Debug QA SHA256: +`0c7d2a4c633df85aa25705d13bd2e9a71fc363feb62c336289fdd064da48d2e1`. +Production has **158** original-byte-verified pairs (81 offline +77 runtime +additions), same namespace `cg18_05391a69_gcacf1fd28_f2`; hooks/config unchanged. +Owner play generated another unprovable orphan pair0009C000_AB864F9F, moved out +of the executable cache into `unverified-runtime-cache-r25` (106KB), alongside +the previously excluded0009A000 case. All other new pairs pass original guards. +No full-static-coverage or production TCP-FPS claim. Native production cards +were retained in place and their hashes are unchanged across staging. + +`mario_playtest_gate.py launch-production --build --exe +--inventory --qualification +--cards ` checks Release, diagnostics OFF, actual compile flag, +built/staged executable equality and original-byte-audited shards before +launching **--launcher**, normal audio, without automated gameplay. This is the +owner path; the old `launch` command remains for explicit diagnostic tests. +Preserve production-playtest/native cards, QA fixtures, prod-build for quick +iteration, and compact passing evidence until owner feedback; clean those +task outputs when no longer needed. The broader feature/release issue remains +open; do not redo the cleared earlier death/Continue/widescreen/native-save gates. + +## Earlier r24 production experiment / r23 fixes + +**Historical r24 experiment, subsequently cleared for performance.** R23 was +not cleared: the owner still saw slowdown and supplied +`Tomba2MarioCandidate_yuyoKmNbvm.png`, then identified the trigger as grabbing +the bucket on the fishing wire. The exact "carrying the Water" dialogue is now +reproduced and visually corrected in `cutscene-repro/bucket-fixed-r24-verified.png`. + +The root cause was the shared heading conversion: native RotMatrix yaw maps +forward to **(cos h, -sin h)**, whereas Mario used (cos h, +sin h). Horizontal +0/2048 headings hid the error. R24 fixes the global Z sign, removes the ad hoc +costume-only half-turn and matches the fist's Z direction. The bucket cutscene +is mode 4/action 30/heading 1027; its captured native Q12 matrix +X column is +{-19,0,-4096}. Asset regression tests now use those independent native values. +Additional scripted yaw at +0x14e was investigated, but is zero in this actual +failure. No extra mode exception or native-matrix replacement was needed. + +Repro: private slot 2 is a pre-bucket house approach at X=10500. Teleport the +player's X only to 10320, wait 10 frames, hold Cross 30 frames then release 70. +The actual native bucket contact grants item 40 and starts its dialogue; no +inventory or success flags are written. After settling, slot 0 holds the exact +dialogue. Native Tomba and pre-fix Mario references are retained. Slot 6 is a +busy gameplay fixture near X=11700; damage moves the player left, so assert +positive health/live gameplay during timing. Earlier `perf-busy-r23.json` and +`perf-busy-live-r23.json` reached death and are invalid performance comparisons. + +Live owner observation in the busier area was 20.648 ms (~48 Hz), 1111 primitives, +190.6 batches. Native dispatch exceeds 99.9%; interpreted work is small. GPU timer +instrumentation issues hundreds of timestamp queries per busy frame. The R24 +diagnostic build with `PSX_GL_PERF=0` measured **59.65 Hz** on the busy fixture +(`perf-no-queries-r24.json`), with health 2/live mode 1. An earlier cold R23 attempt +timed out and is inconclusive. Other owner game processes were also using the GPU; +they were left untouched. Do not claim the production runtime's FPS from this +diagnostic measurement. + +The owner explicitly requested a production build with no diagnostics. It was +built Release with **PSX_DEBUG_TOOLS=OFF**, and build.ninja confirms +PSX_NO_DEBUG_TOOLS=1. `production-playtest/Tomba2MarioProd.exe` SHA-256 is +`ff9c897ca5fcf4eefae5fa92ae2524c0477e82398f31948257b65806230ac38b`. +The production directory has **123** original-byte-verified native pairs (81 +offline + 42 additions). Runtime-only `0009A000_AB864F9F.dll` lacks an original +recipe guard match and was excluded from this staging, not relabelled as audited. +`PRODUCTION_BUILD.json` records flags, hashes, audit and experiment scope. The +existing TCP qualifier does not qualify a diagnostics-free binary. + +The earlier production owner PID **61636** ran in a normal window with normal audio, +same 1080p/5x/adaptive-wide profile and separately copied native cards. It has +no TCP listener. The production copy deliberately contains no cross-build debug +save states; use native Continue. Startup reached game main(). All hidden QA +processes have stopped. Do not automate against or close the owner production +window until finished. Retain prod-build for quick iteration during feedback; +it and task temp need cleanup when this handoff is finished. Preserve all cards. + +The owner finished the r21 playtest; its window is closed. The active follow-up +addresses lag, backwards costume transformation, short punches, high/fast Mario +voices and surviving Tomba vocals. Work remains solo, with no import UI requested. + +`build-mario/Tomba2MarioCandidate.exe` is the new build (SHA-256 +`6eeb99f20e136399230f3473fabc2de75b2b8c7dd7f755ddc1bba3967d3310bd`). +The old `Tomba2Mario.exe` is preserved. The private playable runtime and evidence +are under `G:/Temp/Codex/psxrecomp/tomba2-mario-feedback-20261024/qa` and its +parent. Its copied cards are separate from the owner's earlier private cards; +never copy these fixtures back over the earlier cards. + +Changes and completed checks: + +- Apply a half-turn only to native mode 4/action 0x11 costume presentation. + The unequip screenshot clearly shows Mario's face; equip has the same native + state, but the captured frame is obscured by the transformation flare. +- Punch horizontal half/full widths are **actor +0x80/+0x82 = 64/128**; + forward offset is 88. Old extent was 72+50=122; new extent is 88+64=152. + **+0x76 is weapon-private state, not a hitbox radius.** The matched native pig + route now hits at pretravel 8 where the old build misses; distant misses and + ordinary native target handling remain intact. +- Apply Smash's actual FGM source pitch commands to the raw imported PCM: + mostly -1199 cents, yielding 16009 Hz from the 32000 Hz synthesizer. + Voice 430 uses 16280 Hz; 435 uses 16374 Hz. Audio capture correlation is 0.869 + against independently decoded/resampled source audio. Keep WAVs private. +- Replace the common Tomba vocal band (group 1, sound 0x1d..0x37), including + death/hurt/effort cues; ordinary effects and area/NPC banks pass through. + Remove duplicate action-byte voice triggers. A real native jump proves the + replacement hook is exercised. +- R23 broad gameplay: **22/22**; real interaction suite: **30/30**. Asset/PCM + tests and Python gate refusal tests pass. No unknown dispatches were recorded. + +All 54 verified original-disc recipes (29 images) were rebuilt for the final +`cg18_05391a69_gcacf1fd28_f2` namespace: 81 audited pairs, 14619 manifest rows. +The offline inventory remains `build-mario-aot/disc-aot-5gv1i6ou/` and must be +preserved. Fourteen runtime page/orphan additions also match the original recipe +bytes; the offline auditor alone rejects their different page bases, so the +playtest tool separately checks their bounded function guards. + +`tools/mario_playtest_gate.py` has prepare/qualify/launch actions. Qualification +requires the full audited shard set, a hidden runtime with isolated cards, native +dispatch coverage, no dispatch misses, >=57 VBlanks/s standing and moving, and +unchanged executable/config/tool/cache identities. Launch refuses missing, stale +or failed qualification. Do not weaken its threshold or rename cache namespaces. + +The first cold movement run failed at 54.92 Hz and generated extra shards; +its failure is retained. After those original-byte-verified additions were +staged, a **fresh process restart** passed: Tomba/Mario standing 59.57/59.50 Hz, +movement 58.79 Hz, stable cache and no misses. This qualifies the measured village +route only; broader stages, rewind/audio and release packaging remain open. +The final gate also fixes a measurement defect: queueing 24 debug input steps +was previously included in movement wall time. It now queues first and measures +actual guest-frame progress between midpoint-timed frame-counter reads. Final +qualification passes at **59.99/60.03 Hz** standing and **58.63 Hz** moving. +Use `qa/PLAYTEST_QUALIFICATION.json` for this final gated handoff. + +Retain the playable `qa` directory, useful reports/captures, original extraction +and private asset cache for feedback. The 54 compile logs are retained compactly; +compile-job scratch and duplicate staged caches were audited and removed +(about 1 GB). Preserve all native cards. The earlier r21 handoff below is historical. + +## Latest continuation: basic checks and owner handoff + +Recovered session `01a16ff8-ba29-74e3-b816-76eb9a46503d`, including the final +direction: **no import UI work; test basics such as death/continue and +widescreen, then hand off Mario gameplay for owner feedback**. + +The previous session's r20 process (PID 34768) was still running hidden/muted. +Its on-disk death/Continue report passes six assertions without RAM writes; +the adaptive 4:3 -> 16:9 -> 21:9 -> 4:3 report passes eight assertions. +Composed captures were visually reviewed for real margin scenery. The r21 +follow-up passes seven wide-inventory/current-build-load/card/dispatch checks +in `build-mario-assets/handoff-r21/preflight.json`. Both private cards still +match the hashes below. Binary SHA-256 remains +`e7b0af2dfeca4f77a5f6756a9e11513782f68198c1955bd3c03fe532eb5e3e8e`. + +**Slot 9 is now the owner gameplay fixture**, Mario equipped outside the +Fisherman's house after Continue (native health 4). Slots 7/8 and 10/11 remain +the earlier private fixtures. The old voice test regenerates slot 9 when run; +do not run it against an owner playtest. Save-state slot IDs are 0 through 11. +The r20 hidden process has been stopped. The owner playtest was launched as +PID 60228 in a normal 1280x720 window with normal audio, the same private +cards/profile, and slot 9 loaded only after US MAIN.EXE was actually ready. +`handoff-r21/owner-launch.json` confirms equipped Mario, health 4, active host +audio and zero dispatch misses. Automated input has ended. Leave that process +for owner feedback; do not run additional gameplay checks against it. + +No release archive is requested. Broader combat, rewind/audio, performance, +all-stage widescreen and final AOT/package qualification remain open; they do +not require redoing the completed basic checks before owner gameplay feedback. +The retained r21 reports, two composed screenshots, startup logs and slot 9 +are useful handoff evidence. Clean them with the other private build/evidence +trees after feedback and implementation/release delivery; preserve native cards. + +## Earlier r15-r18 checkpoint + +Recovered session: `01a16fa4-c12b-7a33-b63d-2acf27dfa8b1`. Its later tool +history completed a native save, but never completed the native reload check. +The final `continue` failed during remote context compaction. + +The final r18 private runtime (PID 49820) was stopped after validation. +Build: `build-mario/Tomba2Mario.exe`; TCP port 4597. Use the documented hidden, +muted launch in `MARIOS_HAT.md` for further unattended QA and preserve focus. +Private card/state directory is `build-mario/.research/test-saves`. Never +substitute owner cards. Slots 7/8 are quiet Tomba/Mario outside Fisherman's +house, 10/11 are disposable interaction fixtures. See above for current slot 9. + +On recovery, card 1 contains one real native file `BASCUSP94454T2-00` in block +1 (state `0x51`). SHA-256: +`8b6be4e684ab36c7bf22331c3e770c1ae1340796d4b1aab021a6473b7c1c91c2`. +Card 2 remains empty, SHA-256: +`7706c7d43edaf8cb7618e574f03457105153e3bdc196db803a600ad96a8f58e8`. +The pre-save observation is `build-mario-assets/native-card-before.json`; +save-screen evidence is `native-card-saved.png`. Saved player position was +`[280312448, -68881920, 260046848]`, Mario requested/equipped, native costume 0, +only native items 0 and 10 owned. Native reload is now validated below. + +The previous session tried Triangle to exit Save, which does not exit this +menu. Circle is Return; three bounded Circle taps returned to gameplay. +Native reload now passes, including a fresh process with generation 0 and +Mario not requested/equipped. See `native-reload-r15-fresh/report-audited.json` +and `native-reload-r15-final/report.json`. The initial fresh check's whole-card +equality failed because the native BIOS test writes system frame 63. SIO +transactions 38 and 91 exactly reconstruct both hashes; all other sectors +match. The initial failure is preserved and the audited report explains the +correction. `mario_probe.py` was fixed to read complete multiline responses. + +The post-pig capture route now demonstrates holding, a Left turn (heading +0 -> 2048), and a native Cross throw/release through phases 3/4/6. It does +not establish every target's throw behavior. `capture-route-r15.json` records +the observations. The r16 build fixes overhead presentation using temporary +render-matrix translation and rigidly raised imported arms; gameplay coordinates +remain native. It also fixes the imported +Z forward-axis mapping to native +headings. Thirty interaction assertions and the broader 21-check smoke pass. +See `interactions-r16b-blackjack/`, `smoke-r16/`, and facing screenshots. +A history-read socket reset exposed EOF framing in the probe; complete-object +framing and two socket tests now pass. + +The final r18 build retains F3DEX2 per-triangle cull modes and mirrors the +native capture attachment's full height above Mario's hands. Cap clearance was +visually checked in both directions. Both complete live suites pass in r18: +`smoke-r18/` (21) and `interactions-r18-blackjack/` (30). Asset/PCM tests and +the two probe socket tests pass. Final binary/source hashes and a source patch +are in `render-r18-identity.json` and `culling-clearance-r18.patch`. + +Current quiet performance evidence is `perf-r17.json` and its reverse-order +pair: Tomba/Mario 57.88/58.18 VBlanks/s; reversed Mario/Tomba 59.42/59.57. +Mean host frames are 16.680–16.683 ms for all four cases. This is quiet 5x GL +costume parity; broader gameplay and a separate mod-disabled engine baseline +remain unqualified. Culling reduces emitted body triangles from 320 to ~157. +The r18-only change adjusts captured-model height and does not affect quiet +rendering. Earlier r16 slow/noisy results remain recorded in `MARIOS_HAT.md`. +Temporary offline pose probes/exports/contact images were audited and removed. + +Remaining release gates include broader hold/aim/throw target coverage, +rewind/audio, broader built performance, wider stage coverage and final +packaging. Import UI was waived by the owner. Existing passing r14 and +weapon-family checks need not be repeated without a relevant change. + +Preserve the successful AOT extraction/audit and private asset cache described +in `MARIOS_HAT.md`. No release has occurred. The owner gameplay handoff is +recorded above. Unattended work stays hidden/muted; owner playtests are +windowed with normal audio. diff --git a/game.toml b/game.toml index 25522ef..46883d5 100644 --- a/game.toml +++ b/game.toml @@ -13,15 +13,52 @@ seeds = "seeds/ghidra_funcs.txt" # bios_thunks = "seeds/tomba2_bios_thunks.txt" # optional; add once inventoried out_dir = "generated" strict = true -mod_function_entry_funcs = ["0x8003F9A8", "0x8003FA44", "0x8008179C", "0x8003D0BC"] +mod_function_entry_funcs = [ + "0x8003F9A8", "0x8003FA44", "0x8008179C", "0x8003D0BC", "0x80036240", "0x800368D0", "0x80035FAC", "0x800349A4", "0x80057FD4", "0x80067EF4", "0x8003CDD8", "0x8005950C", "0x800574E0", "0x80069858", "0x80058648", "0x8006AE28", "0x8006BB6C", "0x8006C478", "0x800376EC", "0x8007E938", "0x80074590", "0x8004FA38" +] mod_instruction_sites = [ - "0x8003FA14", "0x8003FA9C", - "0x8007724C", "0x800772E8", "0x80077394", - "0x80077428", "0x800774BC", "0x80077550", + "0x8003FA14", + "0x8003FA9C", + "0x8007724C", + "0x800772E8", + "0x80077394", + "0x80077428", + "0x800774BC", + "0x80077550", "0x80050CB4", "0x8002B314", - "0x8003BB90", "0x80079BB4", - "0x8007708C", "0x80077640", "0x80077E88", + "0x8003BB90", + "0x80079BB4", + "0x8007708C", + "0x80077640", + "0x80077E88", + "0x80033D2C", + "0x80034278", + "0x800349C4", + "0x80034A18", + "0x80034A70", + "0x80034AA8", + "0x80035FC4", + "0x80036358", + "0x80036960", + "0x800369BC", + "0x80036A2C", + "0x80036BDC", + "0x80036CB4", + "0x800371D0", + "0x800376B4", + "0x8003774C", + "0x800377B8", + "0x80037FDC", + "0x80038128", + "0x80038198", + "0x80038338", + "0x800383AC", + "0x800383DC", + "0x80039584", + "0x800396CC", + "0x800396FC", + "0x800534DC", ] [timing] @@ -118,6 +155,10 @@ lock_mode = true # Antialiasing: linear present filtering for the sampled frame. The launcher # exposes both controls on the Video page and persists overrides to settings.toml. [video] +# Preserve the accepted wall-clock-paced Windows profile. On a non-matching +# high-refresh panel, synchronized swaps add stalls on top of the guest pacer. +# The launcher still allows an explicit user VSync preference. +vsync = "off" renderer = "opengl" internal_resolution = "1080p" supersampling = 2 diff --git a/mods/preloaded_ita/packages/psx.enhancement.cd-speed/1.0.0/manifest.toml b/mods/development_ita/packages/psx.enhancement.cd-speed/1.0.0/manifest.toml similarity index 98% rename from mods/preloaded_ita/packages/psx.enhancement.cd-speed/1.0.0/manifest.toml rename to mods/development_ita/packages/psx.enhancement.cd-speed/1.0.0/manifest.toml index 25b5cae..d27839b 100644 --- a/mods/preloaded_ita/packages/psx.enhancement.cd-speed/1.0.0/manifest.toml +++ b/mods/development_ita/packages/psx.enhancement.cd-speed/1.0.0/manifest.toml @@ -4,6 +4,7 @@ version = "1.0.0" name = "Velocita CD" author = "psxrecomp" description = "Velocita del lettore CD emulato. Riduce i caricamenti senza accelerare il gioco, quindi non altera movimento o route basate sul timing." +channel = "developer" resolver = "declarative" save_compatibility = "shared" diff --git a/mods/preloaded_ita/packages/psx.enhancement.fast-loading/1.0.0/manifest.toml b/mods/development_ita/packages/psx.enhancement.fast-loading/1.0.0/manifest.toml similarity index 98% rename from mods/preloaded_ita/packages/psx.enhancement.fast-loading/1.0.0/manifest.toml rename to mods/development_ita/packages/psx.enhancement.fast-loading/1.0.0/manifest.toml index 0fe4531..f039f23 100644 --- a/mods/preloaded_ita/packages/psx.enhancement.fast-loading/1.0.0/manifest.toml +++ b/mods/development_ita/packages/psx.enhancement.fast-loading/1.0.0/manifest.toml @@ -4,6 +4,7 @@ version = "1.0.0" name = "Caricamento rapido (pacing host)" author = "psxrecomp" description = "Accelera il tempo reale dei caricamenti senza cambiare nulla che il gioco possa osservare. Ogni frame guest, interrupt CD e callback resta schedulato normalmente." +channel = "developer" resolver = "declarative" save_compatibility = "shared" diff --git a/mods/preloaded/README.md b/mods/preloaded/README.md index 1713289..08c8146 100644 --- a/mods/preloaded/README.md +++ b/mods/preloaded/README.md @@ -5,8 +5,9 @@ page. They contain metadata only; trusted native activation callbacks are compiled into Tomba2Recomp. Seamless Loading is enabled by default and replaces generic CD Speed and -host-paced Fast Loading in the USA catalog. Widescreen, Frame Blending, and Skip -FMVs are disabled by default. Debug Menu is withheld pending native coverage; +host-paced Fast Loading in the USA catalog. Widescreen, Skip FMVs and Mario's +Hat are disabled by default. Mario's Hat is Experimental version 0.0.1 and +requires a verified original Super Smash Bros. 64 US 1.0 ROM. Debug Menu is withheld pending native coverage; its development package lives outside this catalog in `mods/development`. The target hash is the SHA-256 of the SCUS-94454 MODE2/2352 data-track BIN used by the mod loader for identity. diff --git a/mods/preloaded/packages/tomba2.character.mario-hat/0.0.1/manifest.toml b/mods/preloaded/packages/tomba2.character.mario-hat/0.0.1/manifest.toml new file mode 100644 index 0000000..b7364f0 --- /dev/null +++ b/mods/preloaded/packages/tomba2.character.mario-hat/0.0.1/manifest.toml @@ -0,0 +1,49 @@ +format_version = 9 +id = "tomba2.character.mario-hat" +version = "0.0.1" +name = "Mario's Hat" +author = "mstan" +description = "Equip Mario's Hat to play as Smash 64 Mario in Tomba's world." +resolver = "declarative" +prepare = "tomba2.mario.import" +save_compatibility = "shared" +channel = "experimental" + +[[target]] +game_id = "SCUS-94454" +disc_sha256 = "8e9568388155787384c3166a5934a495fbff3fa57154ab0489b0f214fe05a8ab" + +[[feature]] +id = "mario-hat" +name = "Mario's Hat" +description = "Adds a permanent costume item. Mario can double jump, punch, and grab and throw enemies. Requires your Smash 64 US ROM." +group = "Characters" +default_enabled = false + +[[resource]] +feature = "mario-hat" +id = "smash-rom" +label = "Super Smash Bros. 64 ROM" +description = "Select the original US 1.0 ROM. Mario's assets are imported locally." +format = "n64-rom" +file_patterns = "*.z64,*.v64,*.n64" +file_description = "Nintendo 64 ROM" +required = true +input_only = true +shared_source = "n64.smash64.us" +size = 16777216 +sha256 = "15592e79d3c5295cef4371d4992f0bd25bec2102fc29644c93e682f7ea99ef3d" + +[[resource]] +feature = "mario-hat" +id = "mario-assets" +label = "Mario assets" +format = "file" +required = true +hidden = true +size = 678804 +sha256 = "1cbe9ce74e63464da8582d14dea5f8ba94e30a8c0258f03fee80f9abb3ace588" + +[[plugin]] +feature = "mario-hat" +id = "tomba2.mario.hat" diff --git a/packaging/release.toml b/packaging/release.toml new file mode 100644 index 0000000..6b2479a --- /dev/null +++ b/packaging/release.toml @@ -0,0 +1,37 @@ +schema = 1 + +[product] +name = "Tomba2Recomp" +display_name = "Tomba! 2 - The Evil Swine Return" +exe_name = "Tomba2Recomp" +version_file = "packaging/release/VERSION" +game_toml = "game.toml" +player_game_toml = "packaging/release/game.toml" +runtime_target = "psx-runtime" +zip_folder = "Tomba2Recomp-windows-x64" +launcher_boxart = "recomp/launcher/boxart.tga" + +[build] +regenerate = true +cmake_args = [ + "-DPSX_EXECUTION_PROFILE=ENHANCED", "-DPSX_PGXP_VARIANT=OFF", + "-DPSX_SETUP_WIZARD=OFF", "-DPSX_NETPLAY=OFF", + "-DPSX_MOD_DEVELOPER_CHANNEL=OFF" +] +windows_cmake_args = ["-DCMAKE_EXE_LINKER_FLAGS=-Wl,--no-insert-timestamp"] + +[aot] +mode = "cache" +profile = "aot/usa.json" +cps = false + +[overlay_toolchain] +ship = true + +[payload] +start_here = "packaging/release/START_HERE.txt" +files = ["README.md", "LICENSE", "RELEASE_NOTES.md", + {from = "packaging/release/input.ini", to = "input.ini"}] + +[[hooks.post_stage]] +command = ["{python}", "tools/verify_mario_release.py", "{stage}"] diff --git a/packaging/release/START_HERE.txt b/packaging/release/START_HERE.txt index 2be66bb..00c7960 100644 --- a/packaging/release/START_HERE.txt +++ b/packaging/release/START_HERE.txt @@ -1,4 +1,4 @@ -Tomba2Recomp - Tomba! 2: The Evil Swine Return, recompiled +Tomba2Recomp @VERSION@ - Tomba! 2: The Evil Swine Return, recompiled ========================================================== WHAT YOU NEED @@ -20,6 +20,13 @@ FIRST RUN Interpolation is disabled and hidden. Skip FMVs remains optional. 4. Press Launch. Your choices are remembered for next time. +EXPERIMENTAL MARIO'S HAT (USA ONLY) + Optional mod version 0.0.1, disabled by default. Enable it in Mods and select + your original Super Smash Bros. 64 US 1.0 ROM. The ROM is verified before + cached imported assets can be used. Equip Mario's Hat from the costume menu. + Mario can double jump, punch, and grab/throw enemies. Broader progression + coverage remains experimental. No Nintendo ROM or imported asset is bundled. + WHERE THINGS LIVE saves/ memory cards cache/ converted native code, grows as you play diff --git a/packaging/release/VERSION b/packaging/release/VERSION index 22c08f7..268b033 100644 --- a/packaging/release/VERSION +++ b/packaging/release/VERSION @@ -1 +1 @@ -v0.2.1 +v0.3.0 diff --git a/packaging/release/game.toml b/packaging/release/game.toml index f80061e..6d817f0 100644 --- a/packaging/release/game.toml +++ b/packaging/release/game.toml @@ -13,15 +13,52 @@ seeds = "seeds/ghidra_funcs.txt" # bios_thunks = "seeds/tomba2_bios_thunks.txt" # optional; add once inventoried out_dir = "generated" strict = true -mod_function_entry_funcs = ["0x8003F9A8", "0x8003FA44", "0x8008179C", "0x8003D0BC"] +mod_function_entry_funcs = [ + "0x8003F9A8", "0x8003FA44", "0x8008179C", "0x8003D0BC", "0x80036240", "0x800368D0", "0x80035FAC", "0x800349A4", "0x80057FD4", "0x80067EF4", "0x8003CDD8", "0x8005950C", "0x800574E0", "0x80069858", "0x80058648", "0x8006AE28", "0x8006BB6C", "0x8006C478", "0x800376EC", "0x8007E938", "0x80074590", "0x8004FA38" +] mod_instruction_sites = [ - "0x8003FA14", "0x8003FA9C", - "0x8007724C", "0x800772E8", "0x80077394", - "0x80077428", "0x800774BC", "0x80077550", + "0x8003FA14", + "0x8003FA9C", + "0x8007724C", + "0x800772E8", + "0x80077394", + "0x80077428", + "0x800774BC", + "0x80077550", "0x80050CB4", "0x8002B314", - "0x8003BB90", "0x80079BB4", - "0x8007708C", "0x80077640", "0x80077E88", + "0x8003BB90", + "0x80079BB4", + "0x8007708C", + "0x80077640", + "0x80077E88", + "0x80033D2C", + "0x80034278", + "0x800349C4", + "0x80034A18", + "0x80034A70", + "0x80034AA8", + "0x80035FC4", + "0x80036358", + "0x80036960", + "0x800369BC", + "0x80036A2C", + "0x80036BDC", + "0x80036CB4", + "0x800371D0", + "0x800376B4", + "0x8003774C", + "0x800377B8", + "0x80037FDC", + "0x80038128", + "0x80038198", + "0x80038338", + "0x800383AC", + "0x800383DC", + "0x80039584", + "0x800396CC", + "0x800396FC", + "0x800534DC", ] [timing] @@ -116,6 +153,10 @@ lock_mode = true # Antialiasing: linear present filtering for the sampled frame. The launcher # exposes both controls on the Video page and persists overrides to settings.toml. [video] +# Preserve the accepted wall-clock-paced Windows profile. On a non-matching +# high-refresh panel, synchronized swaps add stalls on top of the guest pacer. +# The launcher still allows an explicit user VSync preference. +vsync = "off" renderer = "opengl" internal_resolution = "1080p" supersampling = 2 diff --git a/psxrecomp-v4 b/psxrecomp-v4 index 61c2217..97c9aa4 160000 --- a/psxrecomp-v4 +++ b/psxrecomp-v4 @@ -1 +1 @@ -Subproject commit 61c2217346b0cf214a97a955062dfeec70e6949b +Subproject commit 97c9aa4018e57b014cfcb5f0976d0624598cae6a diff --git a/recomp-ui b/recomp-ui index 3afef67..3f1d6f1 160000 --- a/recomp-ui +++ b/recomp-ui @@ -1 +1 @@ -Subproject commit 3afef670bdea1f981f485e961480014544c2cc7b +Subproject commit 3f1d6f122a58823614abce14f5b86f4d165a629c diff --git a/src/mods/mario/action_animation.h b/src/mods/mario/action_animation.h new file mode 100644 index 0000000..839f442 --- /dev/null +++ b/src/mods/mario/action_animation.h @@ -0,0 +1,34 @@ +#pragma once + +namespace tomba2::mario { +// Observed US native states: chest 3/8..12, hanging 4/1, and the burning +// house ladder 0xA/11 (catch), /12 (climb), /13 (exit). Motion 0x41 is +// the native sustained climb; its root movement remains entirely native. +inline bool ladder_animation(unsigned action,unsigned motion) { + return action>=0xa && action<=0xd && motion==0x41; +} +inline unsigned traversal_animation(unsigned phase,unsigned motion) { + switch(motion) { + case 0x40:return 572; // ledge catch + case 0x41:return phase==13?576:573; // climb cycle / climb out + case 0x63:case 0x66:return 509; // approach jump + case 0x64:case 0x67:return 513; // approach fall + case 0x65:case 0x16:case 0x17:return 518; // landing + default:return 499; // turning/reading a sign is not a climb + } +} +inline unsigned capture_animation(unsigned phase,unsigned air=0) { + // Phases8/9 perch on a chest or other supported object; 10..12 spring + // away from it. These share the capture action but never carry/throw it. + return phase>=10?(air==2?513:509):phase>=8?520:phase>=5?563:phase>=2?562:561; +} +inline unsigned post_animation(unsigned phase,unsigned air,unsigned previous_clip,unsigned clock) { + // Finish the catch after native attachment phase0, then hold the ledge. + // Jumping up from a post is an ascent before it becomes a fall. + if(!phase || (phase==1 && previous_clip==572 && clock<10))return 572; + return phase==1?573:air==2?513:509; +} +constexpr unsigned punch_offset=202,punch_half_width=178; +static_assert(punch_offset-punch_half_width==24); +static_assert(punch_offset+punch_half_width==380); // 2.5 * previous extent152 +} // namespace tomba2::mario diff --git a/src/mods/mario/motion_recipe.h b/src/mods/mario/motion_recipe.h new file mode 100644 index 0000000..ece1f71 --- /dev/null +++ b/src/mods/mario/motion_recipe.h @@ -0,0 +1,134 @@ +// Format metadata from ssb-decomp-re 572a747d8171137718fad472c5d7473dc7309ffd. +// No ROM data. {file id, Figatree slots, auxiliary root, 32-bit animation}. +static constexpr MotionRecipe motion_recipes[] = { + {499, 24, false, false}, // Wait + {500, 24, false, false}, // Walk1 + {501, 24, false, false}, // Walk2 + {502, 24, false, false}, // Walk3 + {503, 24, false, false}, // WalkEnd + {504, 24, false, false}, // Dash + {505, 24, false, false}, // Run + {506, 24, false, false}, // RunBrake + {507, 24, false, false}, // Turn + {508, 25, true, false}, // TurnRun + {509, 24, false, false}, // JumpF + {510, 24, false, false}, // JumpB + {511, 24, false, false}, // JumpAerialF + {512, 24, false, false}, // JumpAerialB + {513, 24, false, false}, // Fall + {514, 24, false, false}, // FallAerial + {515, 24, false, false}, // Crouch + {516, 24, false, false}, // CrouchIdle + {517, 24, false, false}, // CrouchEnd + {518, 24, false, false}, // LandingAirX + {519, 24, false, false}, // ShieldDrop + {520, 24, false, false}, // Teeter + {521, 24, false, false}, // Teeterstart + {522, 24, false, false}, // Sleep + {523, 24, false, false}, // Damaged1 + {524, 24, false, false}, // Damaged2 + {525, 24, false, false}, // FalconDivePulled + {526, 24, false, false}, // DamageX1 + {527, 24, false, false}, // DamageX2 + {528, 24, false, false}, // DamageX3 + {529, 24, false, false}, // Damaged3 + {530, 24, false, false}, // Damaged4 + {531, 24, false, false}, // Damaged5 + {532, 24, false, false}, // Damaged6 + {533, 28, false, false}, // DamageAir + {534, 24, false, false}, // Damaged7 + {535, 24, false, false}, // DamageFlyX1 + {536, 24, false, false}, // DamageFlyX2 + {537, 24, false, false}, // Damage2 + {538, 24, false, false}, // ShieldBreak + {539, 24, false, false}, // DamageFlyTop + {540, 24, false, false}, // Damaged + {541, 24, false, false}, // FallSpecial + {542, 24, false, false}, // CeilingBonk + {543, 24, false, false}, // DownBounceD + {544, 24, false, false}, // StunLandU + {545, 24, false, false}, // DownStandD + {546, 24, false, false}, // StunStartU + {547, 25, true, false}, // TechF + {548, 25, true, false}, // TechB + {549, 25, true, false}, // DownForwardD + {550, 25, true, false}, // DownForwardU + {551, 25, true, false}, // DownBackD + {552, 25, true, false}, // DownBackU + {553, 24, false, false}, // DownAttackD + {554, 24, false, false}, // DownAttackU + {555, 24, false, false}, // Tech + {556, 24, false, false}, // ClangRecoil + {557, 25, true, false}, // ShieldOn + {558, 25, true, false}, // ShieldOff + {559, 25, true, false}, // RollF + {560, 25, true, false}, // RollB + {561, 25, false, false}, // Catch + {562, 25, false, false}, // CatchPull + {563, 25, false, false}, // ThrowF + {564, 25, false, false}, // ThrowB + {565, 25, true, false}, // EggLayPulled + {566, 25, true, false}, // ThrownDKPulled + {567, 25, true, false}, // ThrownMarioBros + {568, 25, true, false}, // ThrownDK + {569, 25, true, false}, // Thrown2 + {570, 25, true, false}, // Thrown1 + {571, 24, false, false}, // Taunt + {572, 25, true, false}, // CliffCatch + {573, 25, true, false}, // CliffWait + {574, 25, true, false}, // CliffQuick + {575, 25, true, false}, // CliffClimbQuick1 + {576, 25, true, false}, // CliffClimbQuick2 + {577, 25, true, false}, // CliffSlow + {578, 25, true, false}, // CliffClimbSlow1 + {579, 25, true, false}, // CliffClimbSlow2 + {580, 25, true, false}, // CliffAttackQuick1 + {581, 25, true, false}, // CliffAttackQuick2 + {582, 25, true, false}, // CliffAttackSlow1 + {583, 25, true, false}, // CliffAttackSlow2 + {584, 25, true, false}, // CliffEscapeQuick1 + {585, 25, true, false}, // CliffEscapeQuick2 + {586, 25, true, false}, // CliffEscapeSlow1 + {587, 25, true, false}, // CliffEscapeSlow2 + {588, 24, false, false}, // LightItemPickup + {589, 24, false, false}, // ItemThrowSmashB + {590, 24, false, false}, // ItemThrowSmashU + {591, 24, false, false}, // ItemThrowSmashD + {592, 25, true, false}, // ItemThrowDash + {593, 24, false, false}, // ItemDrop + {594, 24, false, false}, // ItemThrowAirSmashB + {595, 24, false, false}, // ItemThrowAirSmashU + {596, 24, false, false}, // ItemThrowAirSmashF + {597, 25, false, false}, // HeavyItemPickup + {598, 25, false, false}, // HeavyItemThrowSmashB + {606, 24, false, false}, // Jab1 + {607, 24, false, false}, // Jab2 + {608, 24, false, false}, // Jab3 + {609, 25, true, false}, // DashAttack + {610, 24, false, false}, // FTiltHigh + {611, 24, false, false}, // FTilt + {612, 24, false, false}, // FTiltLow + {613, 25, true, false}, // UTilt + {614, 25, true, false}, // DTilt + {615, 24, false, false}, // FSmashHigh + {616, 24, false, false}, // FSmashMidHigh + {617, 24, false, false}, // FSmash + {618, 24, false, false}, // FSmashMidLow + {619, 24, false, false}, // FSmashLow + {620, 24, false, false}, // USmash + {621, 24, false, false}, // DSmash + {622, 24, false, false}, // AttackAirN + {623, 24, false, false}, // AttackAirF + {624, 24, false, false}, // AttackAirB + {625, 24, false, false}, // AttackAirU + {626, 25, true, false}, // AttackAirD + {627, 24, false, false}, // LandingAirF + {628, 24, false, false}, // LandingAirB + {629, 24, false, false}, // LandingAirU + {630, 24, false, false}, // EnterPipe + {631, 24, false, false}, // ExitPipe + {632, 24, false, false}, // ExitPipeWalk + {633, 25, true, true}, // Appear1 + {634, 25, true, true}, // Appear2 + {640, 24, false, false}, // Damage +}; diff --git a/src/mods/mario/pcm_voice.h b/src/mods/mario/pcm_voice.h new file mode 100644 index 0000000..d03e060 --- /dev/null +++ b/src/mods/mario/pcm_voice.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include +#include + +namespace tomba2::mario { +struct VoicePosition {uint32_t cursor=0,fraction=0;}; +struct VoiceMix {uint32_t frames=0;bool finished=false;}; + +// Source-rate signed little-endian mono -> 44.1 kHz stereo. All mutable state is +// explicit so guest-memory save/load, rewind and rollback resume identically. +inline VoiceMix mix_pcm_voice(const uint8_t* pcm,size_t bytes,int16_t* stereo, + uint32_t frames,VoicePosition& position,uint32_t source_rate=32000) { + if(!pcm || bytes%2 || bytes/2>std::numeric_limits::max() || + !source_rate || source_rate>192000 || position.fraction>=44100 || + position.cursor>=bytes/2)return {0,true}; + if(!stereo || !frames)return {}; + uint32_t count=uint32_t(bytes/2),mixed=0; + auto sample=[&](uint32_t i) { + size_t offset=size_t(i)*2; + return int32_t(int16_t(uint16_t(pcm[offset])|uint16_t(pcm[offset+1])<<8)); + }; + for(uint32_t f=0;f=count}; +} +} diff --git a/src/mods/mario/presentation.h b/src/mods/mario/presentation.h new file mode 100644 index 0000000..a3904f2 --- /dev/null +++ b/src/mods/mario/presentation.h @@ -0,0 +1,107 @@ +#pragma once + +#include "smash_assets.h" +#include +#include + +namespace tomba2::mario { +struct Matrix {float m[3][4]{};}; +inline Matrix local_matrix(const Transform& t) { + const float a=std::cos(t.rotation.x),b=std::sin(t.rotation.x); + const float c=std::cos(t.rotation.y),d=std::sin(t.rotation.y); + const float e=std::cos(t.rotation.z),f=std::sin(t.rotation.z); + return {{{c*e*t.scale.x,(b*d*e-a*f)*t.scale.y,(a*d*e+b*f)*t.scale.z,t.translation.x}, + {c*f*t.scale.x,(b*d*f+a*e)*t.scale.y,(a*d*f-b*e)*t.scale.z,t.translation.y}, + {-d*t.scale.x,b*c*t.scale.y,a*c*t.scale.z,t.translation.z}}}; +} +inline Matrix multiply(const Matrix& a,const Matrix& b) { + Matrix c; + for(unsigned r=0;r<3;++r) { + for(unsigned j=0;j<3;++j)for(unsigned k=0;k<3;++k)c.m[r][j]+=a.m[r][k]*b.m[k][j]; + c.m[r][3]=a.m[r][3]; + for(unsigned k=0;k<3;++k)c.m[r][3]+=a.m[r][k]*b.m[k][3]; + } + return c; +} +inline Vec3 transform(const Matrix& m,const Vec3& v) { + return {m.m[0][0]*v.x+m.m[0][1]*v.y+m.m[0][2]*v.z+m.m[0][3], + m.m[1][0]*v.x+m.m[1][1]*v.y+m.m[1][2]*v.z+m.m[1][3], + m.m[2][0]*v.x+m.m[2][1]*v.y+m.m[2][2]*v.z+m.m[2][3]}; +} +using PoseBones=std::array; +inline bool visible_face(uint8_t cull,const float (&x)[3],const float (&y)[3]) { + float area=(x[1]-x[0])*(y[2]-y[0])-(y[1]-y[0])*(x[2]-x[0]); + // The source-to-world Y reflection makes source front faces positive in + // PS1 screen coordinates. Preserve explicit two-sided/front-cull modes. + return area!=0 && !(cull&1 && area>0) && !(cull&2 && area<0); +} +inline PoseBones pose_bones(const Model& model,const std::array& pose) { + PoseBones bones; + for(size_t i=0;i{3,6,6}, {9,12,13}}) { + unsigned root=chain[0],hand=chain[1],last=chain[2]; + Vec3 p=transform(bones[root],{}),q=transform(bones[hand],{}); + Vec3 v{q.x-p.x,q.y-p.y,q.z-p.z}; + float length=std::sqrt(v.x*v.x+v.y*v.y+v.z*v.z); + if(length<0.001f)continue; + v={v.x/length,v.y/length,v.z/length}; + float x=-v.z,z=v.x,s2=x*x+z*z; + Matrix r{{{1,0,0,0},{0,1,0,0},{0,0,1,0}}}; + if(s2<0.000001f) { + if(v.y<0) {r.m[0][0]=-1;r.m[1][1]=-1;} + } else { + // Rodrigues: I + [v cross up] + [v cross up]^2/(1+v.y). + float k=(1-v.y)/s2; + r.m[0][0]=1-z*z*k;r.m[0][1]=-z;r.m[0][2]=x*z*k; + r.m[1][0]=z;r.m[1][1]=v.y;r.m[1][2]=-x; + r.m[2][0]=x*z*k;r.m[2][1]=x;r.m[2][2]=1-x*x*k; + } + Vec3 rotated=transform(r,p); + r.m[0][3]=p.x-rotated.x;r.m[1][3]=p.y-rotated.y;r.m[2][3]=p.z-rotated.z; + for(unsigned i=root;i<=last;++i)bones[i]=multiply(r,bones[i]); + } +} +inline Vec3 capture_hands(const PoseBones& bones,const Matrix& world) { + Vec3 a=transform(bones[6],{}),b=transform(bones[12],{}); + return transform(world,{(a.x+b.x)*0.5f,(a.y+b.y)*0.5f,(a.z+b.z)*0.5f}); +} +inline void rotate_limb_x(PoseBones& bones,unsigned first,unsigned last,float angle) { + Vec3 pivot=transform(bones[first],{}); + float c=std::cos(angle),s=std::sin(angle); + Matrix r{{{1,0,0,0},{0,c,-s,0},{0,s,c,0}}}; + Vec3 q=transform(r,pivot); + r.m[0][3]=pivot.x-q.x;r.m[1][3]=pivot.y-q.y;r.m[2][3]=pivot.z-q.z; + for(unsigned i=first;i<=last;++i)bones[i]=multiply(r,bones[i]); +} +inline void climb_ladder(PoseBones& bones,float phase) { + // Smash64 supplies ledge hanging and pull-up, but no repeating ladder + // motion. Articulate those imported limbs around their existing joints; + // alternating reaches and knee lifts never stretch a source segment. + float step=std::sin(phase); + rotate_limb_x(bones,3,6,0.35f+0.4f*step); + rotate_limb_x(bones,9,13,0.35f-0.4f*step); + rotate_limb_x(bones,14,18,-0.4f-0.45f*step); + rotate_limb_x(bones,16,18,0.6f+0.6f*step); + rotate_limb_x(bones,19,23,-0.4f+0.45f*step); + rotate_limb_x(bones,21,23,0.6f-0.6f*step); +} +} // namespace tomba2::mario diff --git a/src/mods/mario/smash_assets.cpp b/src/mods/mario/smash_assets.cpp new file mode 100644 index 0000000..4615c07 --- /dev/null +++ b/src/mods/mario/smash_assets.cpp @@ -0,0 +1,510 @@ +#include "smash_assets.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace tomba2::mario { +namespace { +[[noreturn]] void invalid(const char* what) { throw std::runtime_error(what); } +void span(const Bytes& b, size_t p, size_t n) { + if (p > b.size() || n > b.size() - p) invalid("Truncated Smash asset"); +} +uint16_t be16(const Bytes& b, size_t p) { + span(b,p,2); return uint16_t(b[p] << 8 | b[p+1]); +} +uint32_t be32(const Bytes& b, size_t p) { + return uint32_t(be16(b,p)) << 16 | be16(b,p+2); +} +float bef(const Bytes& b, size_t p) { + uint32_t bits = be32(b,p); float f; std::memcpy(&f,&bits,4); + if (!std::isfinite(f)) invalid("Non-finite Smash transform"); + return f; +} +size_t pointer(const Bytes& b, size_t p) { + uint32_t v = be32(b,p); + if (!v) return 0; + size_t result = (v & 0xffffu) * 4u; + span(b,result,1); return result; +} +void le32(Bytes& b, uint32_t v) { + for (unsigned s = 0; s < 32; s += 8) b.push_back(uint8_t(v >> s)); +} +uint32_t read_le32(const Bytes& b, size_t p) { + span(b,p,4); return uint32_t(b[p]) | uint32_t(b[p+1])<<8 | + uint32_t(b[p+2])<<16 | uint32_t(b[p+3])<<24; +} +struct Bits { + const Bytes& b; size_t bit = 0; + uint32_t get(unsigned n) { + if (n > 32 || bit > b.size()*8 || n > b.size()*8-bit) + invalid("Truncated VPK bitstream"); + uint32_t v = 0; + while(n--) { v = (v<<1) | ((b[bit/8] >> (7-bit%8))&1); ++bit; } + return v; + } +}; +struct Tree { + struct Node { int left=-1,right=-1; unsigned value=0; }; + std::vector nodes; int root=-1; + explicit Tree(Bits& bits) { + std::vector stack; + for (unsigned count=0; count<511; ++count) { + bool branch=bits.get(1)!=0; + if (branch && stack.size()==1) { root=stack.back(); return; } + Node node; + if (branch) { + if (stack.size()<2) invalid("Invalid VPK Huffman tree"); + node.right=stack.back(); stack.pop_back(); + node.left=stack.back(); stack.pop_back(); + } else { + node.value=bits.get(8); + if(node.value>32) invalid("Invalid VPK code width"); + } + stack.push_back(int(nodes.size())); nodes.push_back(node); + } + invalid("VPK Huffman tree exceeds limit"); + } + unsigned get(Bits& bits) const { + int index=root; + for (unsigned depth=0; depth<256; ++depth) { + const Node& n=nodes.at(size_t(index)); + if(n.left<0) return n.value; + index=bits.get(1)?n.right:n.left; + } + invalid("VPK Huffman tree exceeds depth limit"); + } +}; +uint16_t rgba5551(uint16_t n64) { + // N64 is R5 G5 B5 A1; PS1 is STP B5 G5 R5. + if (!(n64&1)) return 0; + return uint16_t(0x8000 | (n64>>11) | ((n64>>6)&31)<<5 | ((n64>>1)&31)<<10); +} +uint16_t color(uint32_t rgba) { + return uint16_t(0x8000 | ((rgba>>27)&31) | + ((rgba>>19)&31)<<5 | ((rgba>>11)&31)<<10); +} +} + +Bytes decompress_vpk(const Bytes& input, uint32_t expected_size) { + if(input.size()<9 || std::memcmp(input.data(),"vpk0",4) || + be32(input,4)!=expected_size || expected_size>4*1024*1024) + invalid("Invalid VPK header/size"); + Bits bits{input,64}; unsigned method=bits.get(8); + if(method>1) invalid("Unsupported VPK method"); + Tree offsets(bits), lengths(bits); + Bytes result; result.reserve(expected_size); + while(result.size()result.size() || !length || + length>expected_size-result.size()) invalid("Invalid VPK back-reference"); + while(length--) result.push_back(result[result.size()-size_t(distance)]); + } + return result; +} + +Bytes extract_reloc(const Bytes& rom, uint32_t id) { + constexpr size_t table=0x1ac870, count=2132, start=table+(count+1)*12; + if(rom.size()!=rom_size || be32(rom,0)!=0x80371240 || id>=count) + invalid("Expected canonical Smash 64 US ROM"); + size_t e=table+id*12, p=start+(be32(rom,e)&0x7fffffff); + size_t packed=be16(rom,e+6)*4u, unpacked=be16(rom,e+10)*4u; + bool compressed=(be32(rom,e)&0x80000000u)!=0; + span(rom,p,compressed?packed:unpacked); + Bytes data(rom.begin()+p,rom.begin()+p+(compressed?packed:unpacked)); + return compressed?decompress_vpk(data,uint32_t(unpacked)):data; +} + +std::vector required_files() { + std::vector ids{296,299}; + // Common actions, item carry/throw, jab/kick, aerials and entry poses. + for(uint32_t i=499;i<=598;++i) ids.push_back(i); + for(uint32_t i=606;i<=634;++i) ids.push_back(i); + ids.push_back(640); + for(uint32_t voice=429;voice<=441;++voice)ids.push_back(voice_cache_base+voice); + return ids; +} +Bytes build_cache(const Bytes& rom) { + Bytes result{'M','A','R','I','O','R','0','2'}; + auto voices=extract_voices(rom); + auto ids=required_files(); le32(result,uint32_t(ids.size())); + for(uint32_t id:ids) { + Bytes file=id>=voice_cache_base?voices.at(id-voice_cache_base):extract_reloc(rom,id); + le32(result,id); le32(result,uint32_t(file.size())); + result.insert(result.end(),file.begin(),file.end()); + } + return result; +} +std::map read_cache(const Bytes& cache) { + span(cache,0,12); + if(std::memcmp(cache.data(),"MARIOR02",8)) invalid("Wrong Mario cache version"); + auto ids=required_files(); + if(read_le32(cache,8)!=ids.size()) invalid("Wrong Mario cache file count"); + std::map files; size_t p=12; + for(uint32_t expected:ids) { + uint32_t id=read_le32(cache,p),size=read_le32(cache,p+4);p+=8; + if(id!=expected || size>4*1024*1024 || !size) invalid("Invalid Mario cache entry"); + span(cache,p,size); files.emplace(id,Bytes(cache.begin()+p,cache.begin()+p+size)); p+=size; + } + if(p!=cache.size()) invalid("Trailing Mario cache data"); + return files; +} + +namespace { +// Material animation: first costume's primary colour is a packed RGBA track. +uint32_t material_color(const Bytes& b,size_t script,uint32_t initial,unsigned wanted) { + if(!script) return initial; + size_t p=script; + for(unsigned n=0;n<256;++n) { + uint32_t cmd=be32(b,p);p+=4; + unsigned op=cmd>>25, flags=(cmd>>15)&0x3ff; + if(!op) return initial; + if(op==1) continue; + if(op==13) {p+=4;continue;} + unsigned words=0; + switch(op) { + case 2:case 3:case 7:case 8:case 9:case 10:case 11: words=1;break; + case 4:case 5: words=2;break; + case 6:words=1;break; + case 18:case 19:case 20:case 21: + for(unsigned track=0;track<5;++track) if(flags&(1u<,uint16_t> textures; + std::array vertices{}; + std::array loaded{}; + std::vector materials; + size_t image=0,loaded_palette=0; + unsigned format=0,size=0,width=1,height=1,material=0; + uint32_t shade=0xffffffff; + // Smash's normal fighter display state starts with G_CULL_BACK enabled + // (ftdisplaymain.c). A model DL can change it with F3DEX2 GeometryMode. + uint32_t geometry=0x400; + float scale_s=1,scale_t=1,tile_s=0,tile_t=0; + unsigned tile_width=1,tile_height=1; + uint8_t mode_s=2,mode_t=2; + bool texture_enabled=false; + + uint16_t texture() { + const Material& m=materials.at(material); + size_t palette=m.palette?m.palette:loaded_palette; + auto key=std::make_tuple(m.offset,image,palette,shade); + auto known=textures.find(key); if(known!=textures.end()) return known->second; + Texture t; + if(texture_enabled && m.offset && (be16(b,m.offset+0x30)&5) && image && palette) { + t.mode_s=mode_s;t.mode_t=mode_t; + // MObjSub's image dimensions, not the UV tile window. + t.width=be16(b,m.offset+0x34); t.height=be16(b,m.offset+0x36); + if(!t.width || !t.height || t.width>256 || t.height>256) + invalid("Unsupported Mario texture dimensions"); + unsigned pixel_size=b.at(m.offset+0x33); + unsigned pixel_format=b.at(m.offset+0x32); + if(pixel_format!=2 || pixel_size!=0) invalid("Expected Mario CI4 texture"); + size_t count=size_t(t.width)*t.height;span(b,image,(count+1)/2); + for(size_t i=0;i>(i%2?0:4))&15); + t.pixels.push_back(rgba5551(be16(b,palette+index*2))); + } + // RDP clamps to the tile window, then applies its mask/mirror. + // Mario's 64-texel windows deliberately mirror 32-texel images. + // Bake that small window so PS1 texture packets retain the seam. + unsigned w=std::min(tile_width,256u),h=std::min(tile_height,256u); + if(w && h) { + auto address=[](unsigned v,unsigned extent,uint8_t mode) { + if(mode&1) {v%=extent*2;return v>=extent?extent*2-1-v:v;} + return (mode&2)?std::min(v,extent-1):v%extent; + }; + std::vector expanded(w*h); + for(unsigned y=0;y=65535) invalid("Too many Mario textures"); + uint16_t index=uint16_t(result.textures.size()); + result.textures.push_back(std::move(t));textures[key]=index;return index; + } + void triangle(unsigned joint,uint32_t word) { + Triangle t; t.joint=uint16_t(joint); t.texture=texture(); + t.cull=uint8_t((geometry>>9)&3); + for(unsigned i=0;i<3;++i) { + unsigned index=((word>>(16-i*8))&255)/2; + if(index>=32 || !loaded[index]) invalid("Mario triangle references unloaded vertex"); + t.vertices[i]=vertices[index]; + t.vertices[i].u-=tile_s;t.vertices[i].v-=tile_t; + } + result.triangles.push_back(t); + if(result.triangles.size()>4096) invalid("Mario model triangle limit exceeded"); + } + void display_list(unsigned joint,size_t p,unsigned depth=0) { + if(depth>8) invalid("Mario display-list recursion limit"); + for(unsigned commands=0;commands<4096;++commands,p+=8) { + uint32_t a=be32(b,p),c=be32(b,p+4);unsigned op=a>>24; + switch(op) { + case 0xdf:return; + case 0xde: + if((c>>24)==0x0e) { + material=(c&0xffffff)/8; + if(material>=materials.size()) invalid("Invalid Mario material reference"); + shade=materials[material].primary; + if(materials[material].offset) { + size_t sprites=pointer(b,materials[material].offset+4); + if(sprites)image=pointer(b,sprites); + } + } else display_list(joint,pointer(b,p+4),depth+1); + if(a&0x10000) return; + break; + case 0x01: { + unsigned n=(a>>12)&255,end=(a>>1)&127; + if(!n || end32) invalid("Invalid Mario vertex run"); + size_t v=pointer(b,p+4);span(b,v,n*16); + for(unsigned i=0;i>16)&255; + if(index>=32 || !loaded[index]) invalid("Mario modify-vertex references unloaded vertex"); + if(field==0x14) { + vertices[index].u=float(int16_t(c>>16))/32; + vertices[index].v=float(int16_t(c))/32; + } else invalid("Unsupported Mario modify-vertex field"); + break; + } + case 0x05:triangle(joint,a);break; + case 0x06:triangle(joint,a);triangle(joint,c);break; + case 0xfd:image=pointer(b,p+4);format=(a>>21)&7;size=(a>>19)&3;break; + case 0xfa:shade=c;break; + case 0xf0:loaded_palette=image;break; + case 0xd7: + texture_enabled=(a&2)!=0; + scale_s=float(c>>16)/65536;scale_t=float(c&65535)/65536;break; + case 0xf2: + if(((c>>24)&7)==0) { + tile_s=float((a>>12)&4095)/4;tile_t=float(a&4095)/4; + tile_width=unsigned(float((c>>12)&4095)/4-tile_s+1); + tile_height=unsigned(float(c&4095)/4-tile_t+1); + } + break; + case 0xf5: + if(((c>>24)&7)==0) {mode_s=uint8_t((c>>8)&3);mode_t=uint8_t((c>>18)&3);} + break; + case 0xd9:geometry=(geometry&(a&0xffffffu))|c;break; + // Remaining RSP/RDP state changes affect stock lighting/tile filtering; + // geometry, material identity and UVs are preserved above. + case 0xdb:case 0xe2:case 0xe3:case 0xe6: + case 0xe7:case 0xe8:case 0xf3: + case 0xf9:case 0xfc:break; + default:throw std::runtime_error("Unsupported Mario display-list opcode " + + std::to_string(op) + " at " + std::to_string(p)); + } + } + invalid("Unterminated Mario display list"); + } +public: + explicit ModelDecoder(const Bytes& model):b(model) {} + Model run() { + std::array parents;parents.fill(-1); + for(unsigned i=0;i<25;++i) { + size_t p=0x2200+i*44;unsigned depth=be32(b,p)&0xfff; + if(depth>=32 || (depth && parents[depth-1]<0)) invalid("Invalid Mario joint tree"); + Joint j;j.parent=depth?parents[depth-1]:-1; + j.bind.translation={bef(b,p+8),bef(b,p+12),bef(b,p+16)}; + j.bind.rotation={bef(b,p+20),bef(b,p+24),bef(b,p+28)}; + j.bind.scale={bef(b,p+32),bef(b,p+36),bef(b,p+40)}; + parents[depth]=int(i); result.joints.push_back(j); + } + for(unsigned i=0;i<25;++i) { + size_t dl=pointer(b,0x2200+i*44+4); if(!dl) continue; + size_t list=pointer(b,i*4),anims=pointer(b,0x2680+i*4); + materials.clear(); + // Glove display lists use the RSP's white lights directly and have + // no MObjSub; the fighter's model-parts table selects their mesh. + if(!list) materials.push_back({0,0,0xffffffff}); + for(unsigned n=0;list && n<8;++n) { + size_t m=pointer(b,list+n*4);if(!m)break; + size_t pal_table=pointer(b,m+0x2c); + size_t pal=pal_table?pointer(b,pal_table):0; + uint32_t rgba=be32(b,m+0x50); + size_t script=anims?pointer(b,anims+n*4):0; + rgba=material_color(b,script,rgba,0); + if(be16(b,m+0x30)&0x1000) + rgba=material_color(b,script,be32(b,m+0x60),3)|255u; + materials.push_back({m,pal,rgba}); + } + if(materials.empty()) invalid("Empty Mario material list"); + image=0;material=0;shade=materials[0].primary;display_list(i,dl); + } + if(result.triangles.empty()) invalid("Empty Mario mesh"); + // Cap portion of Mario's original 8x10 stock icon. The source bitmap + // has a 16-texel pitch and RDP's odd-row word swap (SP_TEXSHUF). + result.hat_icon.width=8;result.hat_icon.height=4; + for(unsigned y=0;y<4;++y)for(unsigned x=0;x<8;++x) { + size_t p=0x71a8+y*8+((x/2)^((y&1)?4:0));span(b,p,1); + unsigned index=(b[p]>>(x&1?0:4))&15; + result.hat_icon.pixels.push_back(rgba5551(be16(b,0x7200+index*2))); + } + return std::move(result); + } +}; +} +Model decode_model(const std::map& files) { + return ModelDecoder(files.at(296)).run(); +} + +namespace { +struct MotionRecipe { uint32_t id,slots; bool auxiliary,wide; }; +#include "motion_recipe.h" +struct Segment { float at,duration,base,target,rate0,rate1; unsigned kind; }; +using Tracks=std::array,10>; +float units(unsigned track,int16_t value,bool rate=false) { + float divisor=track<3?512.0f:track==3?16384.0f: + track<7?(rate?32.0f:4.0f):(rate?8192.0f:4096.0f); + return value/divisor; +} +Tracks figatree(const Bytes& b,size_t p,float& length) { + Tracks tracks; + std::array target{},rate{}; + std::array initialized{}; + float clock=0; + for(unsigned commands=0;commands<4096;++commands) { + unsigned c=be16(b,p);p+=2; + unsigned op=c>>11,flags=(c>>1)&1023; + if(op==0 || op==13) { + for(unsigned t=0;t<10;++t) if(initialized[t] && tracks[t].empty()) + tracks[t].push_back({0,0,target[t],target[t],0,0,0}); + length=std::max(length,clock);return tracks; + } + float duration=(c&1)?float(be16(b,p)):0; + if(c&1)p+=2; + if(op==1 || op==14) {clock+=duration;continue;} + if(op==11) { + for(unsigned t=0;t<10;++t) if((flags&(1u<10) throw std::runtime_error("Unsupported Mario Figatree command " + + std::to_string(op) + " at " + std::to_string(p-2)); + for(unsigned t=0;t<10;++t) if(flags&(1u<4096) invalid("Mario animation exceeds duration limit"); + } + invalid("Unterminated Mario Figatree script"); +} +float sample(const std::vector& track,float frame) { + const Segment* selected=&track.front(); + for(const auto& s:track) {if(s.at>frame)break;selected=&s;} + const Segment& s=*selected; + if(s.duration<=0) return s.target; + float u=std::clamp((frame-s.at)/s.duration,0.0f,1.0f); + if(s.kind==3)return u<1?s.base:s.target; + if(s.kind!=2)return s.base+(s.target-s.base)*u; + float u2=u*u,u3=u2*u; + return (2*u3-3*u2+1)*s.base+(-2*u3+3*u2)*s.target+ + (u3-2*u2+u)*s.duration*s.rate0+(u3-u2)*s.duration*s.rate1; +} +void assign(Transform& transform,unsigned track,float v) { + switch(track) { + case 0:transform.rotation.x=v;break;case 1:transform.rotation.y=v;break; + case 2:transform.rotation.z=v;break; + case 3:invalid("Mario path interpolation needs a source path descriptor"); + case 4:transform.translation.x=v;break;case 5:transform.translation.y=v;break; + case 6:transform.translation.z=v;break; + case 7:transform.scale.x=v;break;case 8:transform.scale.y=v;break; + case 9:transform.scale.z=v;break; + } +} +} +Animation decode_animation(uint32_t id,const Bytes& raw,const Model& model) { + auto found=std::find_if(std::begin(motion_recipes),std::end(motion_recipes), + [id](const MotionRecipe& r){return r.id==id;}); + if(found==std::end(motion_recipes) || !found->slots || found->slots>26 || found->wide) + invalid("Unsupported Mario animation format"); + if(model.joints.size()!=25)invalid("Mario animation requires the Mario rig"); + std::array joint_tracks;Animation result;result.id=id; + for(unsigned slot=0;slotslots;++slot) { + size_t p=pointer(raw,slot*4);if(!p)continue; + if(found->auxiliary && slot==0)continue; // Tomba owns all root motion. + unsigned joint=slot-(found->auxiliary?1:0); + if(joint>=25)invalid("Mario animation joint outside rig"); + try {joint_tracks[joint]=figatree(raw,p,result.duration);} + catch(const std::exception& e) {throw std::runtime_error("Motion " + + std::to_string(id) + " joint " + std::to_string(joint) + ": " + e.what());} + } + unsigned count=unsigned(std::ceil(result.duration))+1; + if(count>4097)invalid("Mario animation frame limit"); + result.frames.resize(count); + for(unsigned f=0;f256 || h+2>64) + invalid("Mario texture cannot fit native-UV atlas"); + if(x+w+2>256) {x=0;y+=row_height;row_height=0;} + if(y+h+2>64)invalid("Mario textures exceed native-UV atlas"); + atlas.origins.push_back({uint16_t(x+1),uint16_t(y+1)}); + for(unsigned row=0;row +#include +#include +#include +#include + +// Smash 64 US 1.0 format metadata, derived from ssb-decomp-re commit +// 572a747d8171137718fad472c5d7473dc7309ffd. No game bytes live here. +namespace tomba2::mario { +using Bytes = std::vector; +inline constexpr uint32_t rom_size = 16u * 1024u * 1024u; +inline constexpr const char* rom_sha256 = + "15592e79d3c5295cef4371d4992f0bd25bec2102fc29644c93e682f7ea99ef3d"; + +Bytes decompress_vpk(const Bytes& input, uint32_t expected_size); +Bytes extract_reloc(const Bytes& rom, uint32_t id); +std::vector required_files(); +// An immutable, versioned asset cache. Does not contain the ROM or its path. +Bytes build_cache(const Bytes& canonical_rom); +std::map read_cache(const Bytes& cache); +// Mono 32000 Hz signed PCM, keyed by the original Mario voice ID 429..441. +std::map extract_voices(const Bytes& canonical_rom); +inline constexpr uint32_t voice_cache_base = 10000; + +struct Vec3 { float x = 0, y = 0, z = 0; }; +struct Transform { + Vec3 translation, rotation; + Vec3 scale{1, 1, 1}; +}; +struct Joint { int parent = -1; Transform bind; }; +struct Vertex { + Vec3 position, normal; + float u = 0, v = 0; + uint16_t joint = 0; // cached seam vertices can belong to the preceding bone +}; +struct Triangle { + uint16_t joint = 0, texture = 0; + uint8_t cull = 2; // F3DEX2 front/back geometry-mode bits, normalized to 1/2 + std::array vertices; +}; +struct Texture { + uint16_t width = 1, height = 1; + uint8_t mode_s = 2, mode_t = 2; // N64 wrap/mirror/clamp flags + std::vector pixels; // PS1 RGBA5551; bit 15 marks opaque texels. +}; +struct Model { + std::vector joints; + std::vector triangles; + std::vector textures; + Texture hat_icon; +}; +Model decode_model(const std::map& files); +struct TextureAtlas { + Texture texture; + std::vector> origins; +}; +// Immutable owner-derived pixels, with replicated borders for each material. +// One bank avoids a GL batch break at every interleaved material in the OT. +TextureAtlas make_texture_atlas(const Model& model); + +struct Animation { + uint32_t id = 0; + float duration = 1; + // Frames sampled from the original Figatree curves at the source 60 Hz. + std::vector> frames; +}; +Animation decode_animation(uint32_t id, const Bytes& raw, const Model& model); +} // namespace tomba2::mario diff --git a/src/mods/mario/smash_audio.cpp b/src/mods/mario/smash_audio.cpp new file mode 100644 index 0000000..ec8e63e --- /dev/null +++ b/src/mods/mario/smash_audio.cpp @@ -0,0 +1,80 @@ +#include "smash_assets.h" +#include +#include +#include + +namespace tomba2::mario { +namespace { +void check(bool ok) { if(!ok)throw std::runtime_error("Invalid Smash voice bank"); } +uint16_t u16(const Bytes& b,size_t p) { + check(p<=b.size() && b.size()-p>=2); + return uint16_t(b[p]<<8|b[p+1]); +} +uint32_t u32(const Bytes& b,size_t p) { return uint32_t(u16(b,p))<<16|u16(b,p+2); } +struct Wave {uint32_t base,length,book;}; +} + +std::map extract_voices(const Bytes& rom) { + // US B1_sounds2 DMA ranges and FGM articulation IDs from ssb-decomp-re. + check(rom.size()==rom_size); + Bytes ctl(rom.begin()+0xc6b650,rom.begin()+0xc7b1f0); + constexpr size_t table_base=0xc7b1f0,table_end=0xf573d0; + std::map waves; + std::set instruments,sounds; + auto sound=[&](uint32_t p) { + if(!p || !sounds.insert(p).second)return; + uint32_t w=u32(ctl,p+8),base=u32(ctl,w),length=u32(ctl,w+4),book=u32(ctl,w+16); + check(w+20<=ctl.size() && ctl[w+8]==0 && length && length<=table_end-table_base && + base<=table_end-table_base-length); + waves.emplace(base,Wave{base,length,book}); + }; + auto instrument=[&](uint32_t p) { + if(!p || !instruments.insert(p).second)return; + unsigned count=u16(ctl,p+14);check(count<1024); + for(unsigned i=0;i ordered; + for(const auto& item:waves)ordered.push_back(item.second); + std::map result; + for(unsigned voice=429;voice<=441;++voice) { + const Wave& w=ordered.at(173+voice-429); + // Mario uses order-two/four-predictor Nintendo VADPCM books. + check(u32(ctl,w.book)==2 && u32(ctl,w.book+4)==4); + int16_t book[64]; + for(unsigned i=0;i<64;++i)book[i]=int16_t(u16(ctl,w.book+8+i*2)); + Bytes pcm;pcm.reserve(w.length/9*32); + int32_t history[2]={}; + for(unsigned off=0;off+9<=w.length;off+=9) { + size_t p=table_base+w.base+off; + unsigned predictor=rom[p]&15,shift=std::min(12u,unsigned(rom[p]>>4)); + check(predictor<4); + int32_t residual[16]; + for(unsigned i=0;i<16;++i) { + int32_t nibble=(rom[p+1+i/2]>>(i%2?0:4))&15; + residual[i]=(nibble>=8?nibble-16:nibble)*int32_t(1u<=0?sum/2048:-((-sum+2047)/2048); + decoded[i]=int32_t(std::clamp(prediction+residual[half*8+i],-32768,32767)); + pcm.push_back(uint8_t(decoded[i]));pcm.push_back(uint8_t(uint32_t(decoded[i])>>8)); + } + history[0]=decoded[6];history[1]=decoded[7]; + } + } + result.emplace(voice,std::move(pcm)); + } + return result; +} +} // namespace tomba2::mario diff --git a/src/mods/mario/table_sites.h b/src/mods/mario/table_sites.h new file mode 100644 index 0000000..744a756 --- /dev/null +++ b/src/mods/mario/table_sites.h @@ -0,0 +1,29 @@ +// Original SCUS-94454 MAIN.EXE UI table address producers. +static constexpr TableSite table_sites[] = { + {0x80033D2Cu, 0x24842BE8u, false}, + {0x80034278u, 0x24C62BE8u, false}, + {0x800349C4u, 0x24632BE8u, false}, + {0x80034A18u, 0x24422BE8u, false}, + {0x80034A70u, 0x24422BE8u, false}, + {0x80034AA8u, 0x24632BE8u, false}, + {0x80035FC4u, 0x24632BE8u, false}, + {0x80036358u, 0x24452BE8u, false}, + {0x80036960u, 0x24472BE8u, false}, + {0x800369BCu, 0x24472BE8u, false}, + {0x80036A2Cu, 0x244D2BE8u, false}, + {0x80036BDCu, 0x245E2BE8u, false}, + {0x80036CB4u, 0x245E2BE8u, false}, + {0x800371D0u, 0x24422BE8u, false}, + {0x800376B4u, 0x24422BE8u, false}, + {0x8003774Cu, 0x24842BE8u, false}, + {0x800377B8u, 0x24632BE8u, false}, + {0x80037FDCu, 0x24422BE8u, false}, + {0x80038128u, 0x25082BE8u, false}, + {0x80038198u, 0x244224F4u, true}, + {0x80038338u, 0x25082BE8u, false}, + {0x800383ACu, 0x244224F4u, true}, + {0x800383DCu, 0x25082BE8u, false}, + {0x80039584u, 0x247524F4u, true}, + {0x800396CCu, 0x246324F4u, true}, + {0x800396FCu, 0x24632BE8u, false}, +}; diff --git a/src/mods/mario/voice_cues.h b/src/mods/mario/voice_cues.h new file mode 100644 index 0000000..d58ccec --- /dev/null +++ b/src/mods/mario/voice_cues.h @@ -0,0 +1,26 @@ +#pragma once +#include + +namespace tomba2::mario { +// US Smash FGM 429..441 selects tables 296..308. Their first pitch command +// (0x20, signed cents) lowers the raw waveform by approximately one octave. +// Output rate is not waveform playback rate. These integer rates apply those +// source pitches to the 32 kHz synthesizer and keep playback deterministic. +inline uint32_t voice_sample_rate(uint32_t voice) { + constexpr uint32_t rates[]={16009,16280,16009,16009,16009,16009,16374, + 16009,16009,16009,16009,16009,16009}; + return voice>=429 && voice<=441?rates[voice-429]:0; +} +inline uint32_t native_voice_replacement(uint8_t sound,uint8_t group) { + // MAIN.EXE's common sound table marks Tomba's vocal bank entries with + // group 1. In particular 0x2b is an ordinary effect, not a vocal cue. + if(group!=1 || sound<0x1d || sound>0x37)return 0; + switch(sound) { + case 0x1d:return 435; // jump + case 0x1e:return 439;case 0x1f:return 433; // death / long fall + case 0x23:case 0x24:case 0x25:return 440; // hurt / heavy reactions + case 0x26:case 0x27:return 438; // lifting effort + default:return 429+(sound%3); // short effort variants + } +} +} diff --git a/src/mods/tomba2_mario_plugin.cpp b/src/mods/tomba2_mario_plugin.cpp new file mode 100644 index 0000000..fd89a2a --- /dev/null +++ b/src/mods/tomba2_mario_plugin.cpp @@ -0,0 +1,546 @@ +#include "mod_plugins.h" +#include "cpu_state.h" +#include "mario/smash_assets.h" +#include "mario/pcm_voice.h" +#include "mario/voice_cues.h" +#include "mario/presentation.h" +#include "mario/action_animation.h" + +#include +#include +#include +#include +#include +#include + +namespace { +using namespace tomba2::mario; +constexpr const char* plugin="tomba2.mario.hat"; +constexpr uint32_t player=0x800e7e80, item_table=0x800a2be8; +constexpr uint32_t ownership=0x800bfab4, item_order=0x800bfbb4; +constexpr uint32_t virtual_hat=256, return_pc=0x8f00fff0; +constexpr uint32_t state_magic=0x4d484154; +enum StateOffset : uint32_t { + Magic=0,Requested=4,Equipped=8,DoubleJump=12,Clip=16,Clock=20, + LastAction=24,MenuPrimary=28,IconItem=32, + Voice=36,VoiceCursor=40,VoiceFraction=44,PreviousAction=48,PreviousAir=52,MenuObject=56,HatToast=60, + Name=128,Description=160,Toast=256,Table=512,Icons=4608,Weapons=6000 +}; +uint32_t memory=0,packets=0; +Model mario_model; +TextureAtlas mario_atlas; +std::map animations; +std::map voices; +bool builder_nested=false,equipment_nested=false; +bool weapon_nested=false,item_nested=false; +bool captured_draw_nested=false; + +uint8_t rb(uint32_t p) {return psx_mod_read_byte(p);} +uint16_t rh(uint32_t p) {return psx_mod_read_half(p);} +uint32_t rw(uint32_t p) {return psx_mod_read_word(p);} +void wb(uint32_t p,uint8_t v) {psx_mod_write_byte(p,v);} +void wh(uint32_t p,uint16_t v) {psx_mod_write_half(p,v);} +void ww(uint32_t p,uint32_t v) {psx_mod_write_word(p,v);} +uint32_t state(StateOffset offset) {return rw(memory+offset);} +void state(StateOffset offset,uint32_t value) {ww(memory+offset,value);} +void play_voice(uint32_t id) { + state(Voice,id);state(VoiceCursor,0);state(VoiceFraction,0); + psx_mod_counter_add("tomba2.mario.voice",1); +} +void mix_voice(int16_t* stereo,uint32_t frames) { + if(!memory || state(Magic)!=state_magic)return; + auto clip=voices.find(state(Voice)); + if(clip==voices.end())return; + const auto& bytes=clip->second; + VoicePosition position{state(VoiceCursor),state(VoiceFraction)}; + auto mixed=mix_pcm_voice(bytes.data(),bytes.size(),stereo,frames,position, + voice_sample_rate(state(Voice))); + state(VoiceCursor,position.cursor);state(VoiceFraction,position.fraction); + if(mixed.finished)state(Voice,0); + psx_mod_counter_add("tomba2.mario.voice_frames",mixed.frames); +} +bool ready() { + if(!memory || !psx_mod_game_started())return false; + if(state(Magic)==state_magic)return true; + // The table is part of MAIN.EXE, loaded after the boot executable. + if(rw(0x800368d0)!=0x90830024u)return false; + for(uint32_t i=0;i<256*12;i+=4)ww(memory+Table+i,rw(item_table+i)); + for(uint32_t i=0;i<512;i+=4)ww(memory+Icons+i,rw(0x800a24f4+i)); + static constexpr char name[]="Mario's\nHat"; + static constexpr char description[]="Become Mario! Double jump, punch,\nand grab and throw enemies."; + for(size_t i=0;i>21)&31; + int32_t displacement=int16_t(site.word); + uint32_t effective=cpu->gpr[rs]+uint32_t(displacement); + uint32_t old=site.icons?0x800a24f4:item_table; + uint32_t extent=site.icons?514:257*12; + if(effective>=old && effectivegpr[rs]+=memory+(site.icons?Icons:Table)-old; + return; + } +} +void original(CPUState* cpu,uint32_t function) { + psx_mod_call_guest(cpu,function,return_pc,cpu->gpr[4],cpu->gpr[5],cpu->gpr[6],cpu->gpr[7]); +} +int draw_item_name(CPUState* cpu,uint32_t address) { + if(item_nested || !ready())return 0; + uint32_t item=cpu->gpr[5],menu=cpu->gpr[4],column=cpu->gpr[6],y=cpu->gpr[7]; + uint32_t layer=rw(cpu->gpr[29]+20); + item_nested=true;original(cpu,address);item_nested=false; + state(IconItem,item); + if(item==virtual_hat && state(Requested) && column<=1 && rb(menu+8)==0) + psx_mod_call_guest(cpu,0x800342ec,return_pc,menu,column?0xb4:0x24,y,layer); + return 1; +} +int draw_item_icon(CPUState* cpu,uint32_t) { + if(!ready() || state(IconItem)!=virtual_hat)return 0; + state(IconItem,0xffffffffu); + uint32_t flip=rb(0x1f800135); + if(flip>1)return 0; + // Native inventory sprites have signed offsets around this anchor. Centre + // the imported 8x4 cap (drawn at 2x) in the same slot as the stock icons. + const auto& icon=mario_model.hat_icon; + int x=int16_t(cpu->gpr[5])-int(icon.width); + int y=int16_t(cpu->gpr[6])-int(icon.height); + uint32_t link=rw(0x800ed8c8)+(cpu->gpr[7]&255)*4; + uint32_t cursor=packets+(flip*1024+1016)*80; + for(unsigned row=0;row>5)&31)*255/31,b=((pixel>>10)&31)*255/31; + ww(cursor,0x03000000u|(rw(link)&0xffffff)); + ww(cursor+4,0x60000000u|r|(g<<8)|(b<<16)); + ww(cursor+8,uint16_t(x+int(col)*2)|(uint32_t(uint16_t(y+int(row)*2))<<16)); + ww(cursor+12,0x00020002u); + ww(link,cursor&0xffffff);cursor+=16; + } + return 1; +} +int inventory_build(CPUState* cpu,uint32_t address) { + if(builder_nested || !ready())return 0; + uint32_t menu=cpu->gpr[4];state(HatToast,0);state(MenuObject,menu); + builder_nested=true;original(cpu,address);builder_nested=false; + uint32_t count=rb(menu+0x24); + state(MenuPrimary,count); + if(count>=254) {psx_mod_counter_add("tomba2.mario.inventory_full",1);return 1;} + wb(menu+0x24,uint8_t(count+1)); + unsigned secondary=unsigned(rb(menu+0x25))+rb(menu+0x26); + wb(menu+0x22,uint8_t((count+2)/2+(secondary+2)/2)); + psx_mod_counter_add("tomba2.mario.inventory_build",1);return 1; +} +int inventory_lookup(CPUState* cpu,uint32_t) { + if(!ready())return 0; + uint32_t menu=cpu->gpr[4],row=cpu->gpr[5],column=cpu->gpr[6]; + unsigned count=rb(menu+0x24),rows=(count+1)/2; + if(!count || column>1)return 0; + int result=-1; + unsigned flat=row*2+column; + if(rowgpr[2]=uint32_t(result);return 1; +} +int equip_hat(CPUState* cpu,uint32_t) { + if(!ready() || cpu->gpr[5]!=virtual_hat)return 0; + bool restricted=(rb(0x800bf881)&4) || (rh(0x800bf89e)&0x200); + if(cpu->gpr[6]==1) {cpu->gpr[2]=restricted?1:0;return 1;} + // Use the existing costume transaction with the ordinary native costume. + // The virtual item's identity never enters the byte-sized save structure. + if(restricted) {cpu->gpr[2]=4;return 1;} + state(Requested,state(Requested)?0:1); + state(HatToast,1); + wb(0x800bf88f,0);wb(0x800bf81d,1); + cpu->gpr[2]=2;psx_mod_counter_add("tomba2.mario.equip_requested",1);return 1; +} +int equip_native(CPUState* cpu,uint32_t address) { + if(equipment_nested || !ready() || cpu->gpr[6]==1 || cpu->gpr[5]>=256 || + rb(item_table+cpu->gpr[5]*12+1)!=2)return 0; + equipment_nested=true; + uint32_t result=psx_mod_call_guest(cpu,address,return_pc,cpu->gpr[4],cpu->gpr[5],cpu->gpr[6],cpu->gpr[7]); + equipment_nested=false; + if(result==2 || result==3) {state(Requested,0);state(HatToast,0);} + cpu->gpr[2]=result;return 1; +} +void costume_reload(CPUState* cpu,uint32_t) { + if(!ready() || cpu->gpr[4]!=player)return; + state(Equipped,state(Requested) && rb(player+0x6f)==0?1:0); + if(state(Equipped))play_voice(437); + state(DoubleJump,0);state(Clock,0);state(Clip,0); + psx_mod_counter_add("tomba2.mario.costume_reload",1); +} +void costume_attributes(CPUState* cpu,uint32_t) { + if(ready() && cpu->gpr[4]==player && rb(player+0x6f)!=0)state(Requested,0); +} +void costume_toast(CPUState* cpu,uint32_t) { + if(!ready() || !state(HatToast) || cpu->gpr[6]!=0x40)return; + uint32_t caller=cpu->gpr[31]&0x1fffffff,source=cpu->gpr[4],sp=cpu->gpr[29]; + // The queue receives either an ordinary costume-zero equipped message or + // a two-item remove/equip message when replacing a native suit. Confirm + // the costume caller as well as the token list: weapon/shoe confirmations + // can also mention item zero during the same inventory transaction. + bool ordinary=caller==0x4ed84 && cpu->gpr[5]==1 && + (rw(sp+0x18)&0x1fffffff)==0x4d6e4 && (rw(sp+0x30)&0x1fffffff)==0x343e4 && + rh(source)==0 && rh(source+2)==0x8000 && rh(source+4)==0xfffe; + bool replacing=caller==0x4ee1c && cpu->gpr[5]==2 && + (rw(sp+0x20)&0x1fffffff)==0x343fc && + rh(source+2)==0x8003 && rh(source+4)==0xffff && rh(source+6)==0 && + rh(source+8)==0x8000 && rh(source+10)==0xfffe; + if(!ordinary && !replacing)return; + uint32_t cursor=memory+Toast; + wb(cursor++,0xf4); // native equipment-name colour + for(char c:std::string("Mario's Hat"))wb(cursor++,c==' '?0xfb:uint8_t(c-32)); + wb(cursor++,0xf0); + const char* suffix=state(Requested)?" equipped!":" removed!"; + for(const char* c=suffix;*c;++c)wb(cursor++,*c==' '?0xfb:uint8_t(*c-32)); + wb(cursor,0xff); + state(HatToast,0); + // Let the native queue consume already encoded text. Both the queued + // pointer and buffer live in guest memory and follow current-build states. + cpu->gpr[4]=memory+Toast;cpu->gpr[5]=0xffffffffu; + psx_mod_counter_add("tomba2.mario.costume_label",1); +} +int player_sound(CPUState* cpu,uint32_t) { + if(!ready() || !state(Equipped))return 0; + uint8_t sound=uint8_t(cpu->gpr[4]); + // High-bit sounds belong to the current area's bank, not the common + // Tomba vocal table. Do not silence an unrelated area/NPC effect. + if(sound>=0x70)return 0; + uint32_t replacement=native_voice_replacement(sound,rb(0x800a4d18+sound*8)); + if(!replacement)return 0; + play_voice(replacement); + psx_mod_counter_add("tomba2.mario.native_voice_replaced",1); + cpu->gpr[2]=0;return 1; +} + +uint32_t action_clip() { + unsigned action=rb(player+5),phase=rb(player+6); + if(rb(player+4)!=1)return 523; + switch(action) { + case 0:return 499;case 1:return 505; + case 2:return !rb(player+0x145)?499:rb(player+0x29)?518:state(DoubleJump)?511:rb(player+0x145)==1?509:513; + case 3:return capture_animation(phase,rb(player+0x145)); + case 4:return post_animation(phase,rb(player+0x145),state(Clip),state(Clock)); + case 5:case 0x32:return rb(player+0x146)==3?608:606; + case 6:return 622; + case 7:case 8:case 9:return 523; + case 0xa:case 0xb:case 0xc:case 0xd:return traversal_animation(phase,rb(player+0x46)); + case 0x10:return 588; + case 0x11:return 571; + default:return rb(player+0x145)?513:499; + } +} +void player_tick(CPUState* cpu,uint32_t) { + if(!ready() || cpu->gpr[4]!=player)return; + if(!state(Equipped))return; + if(rb(player+0x6f)!=0) {state(Equipped,0);state(Requested,0);return;} + unsigned action=rb(player+5); + // Native vocal events choose their replacement in player_sound. Do not + // restart that voice a second time just because an action byte changed. + state(PreviousAction,action); + state(PreviousAir,rb(player+0x145)); + // +0x145 is the native air phase (zero after landing). +0x29 is not a + // grounded flag: it remains zero during ordinary idle/walking here. + if(action<=2 && !rb(player+0x145))state(DoubleJump,0); + bool free=rb(player+4)==1 && rb(0x1f800137)==0 && !rb(0x800bf80f); + bool airborne=action==2 && !rb(player+0x29) && rb(player+0x145)!=0; + uint16_t jump=rh(0x1f800172); + if(free && airborne && !state(DoubleJump) && (rh(0x800e7e68)&jump)) { + // Same velocity units and gravity/collision path as native jumping. + wh(player+0x4a,uint16_t(-0x2800));wb(player+7,1);wb(player+0x145,1); + state(DoubleJump,1);state(Clock,0); + play_voice(436); + psx_mod_counter_add("tomba2.mario.double_jump",1); + } + uint32_t clip=action_clip(); + if(state(Clip)!=clip || state(LastAction)!=(action|(unsigned(rb(player+6))<<8))) { + state(Clip,clip);state(Clock,0); + } else state(Clock,state(Clock)+2); + state(LastAction,action|(unsigned(rb(player+6))<<8)); +} + +uint32_t weapon_record(uint32_t actor) { + for(unsigned i=0;i<8;++i) { + uint32_t entry=memory+Weapons+i*12; + if(rw(entry)==actor)return entry; + } + return 0; +} +void weapon_spawn(CPUState* cpu,uint32_t) { + if(!ready() || !state(Equipped) || cpu->gpr[16]!=player || !cpu->gpr[2])return; + uint32_t actor=cpu->gpr[2],entry=weapon_record(actor); + if(!entry)for(unsigned i=0;i<8;++i)if(!rw(memory+Weapons+i*12)){entry=memory+Weapons+i*12;break;} + if(!entry) {psx_mod_counter_add("tomba2.mario.punch_slots_full",1);return;} + ww(entry,actor);ww(entry+4,0);ww(entry+8,cpu->gpr[17]); + psx_mod_counter_add("tomba2.mario.punch_spawn",1); +} +void fist_position(uint32_t actor) { + float heading=float(rh(player+0x56)&4095)*6.28318530718f/4096; + // Native horizontal half/full widths are +0x80/+0x82 (6ACAC initializes + // ordinary weapons to 50/100). +0x76 is weapon-private state, not radius. + // 202 + 178 = 380 forward units (2.5x152), retaining the old near edge24. + // Keep vertical bounds and native damage rules. + wh(actor+0x80,punch_half_width);wh(actor+0x82,punch_half_width*2); + ww(actor+0x2c,rw(player+0x2c)+uint32_t(int32_t(std::cos(heading)*punch_offset*65536))); + ww(actor+0x30,rw(player+0x30)-16*65536); + ww(actor+0x34,rw(player+0x34)-uint32_t(int32_t(std::sin(heading)*punch_offset*65536))); +} +void punch_collision(CPUState* cpu,uint32_t) { + if(!ready() || !weapon_record(cpu->gpr[4]))return; + fist_position(cpu->gpr[4]); + psx_mod_counter_add("tomba2.mario.punch_collision",1); +} +int punch_weapon(CPUState* cpu,uint32_t address) { + if(weapon_nested || !ready())return 0; + uint32_t actor=cpu->gpr[4],entry=weapon_record(actor); + if(!entry)return 0; + // Hammer has a native windup before +6 reaches its damaging phase. Count + // the four active ticks, rather than retiring during that windup. Keep a + // separate bounded lifetime for interrupted/invalid native transactions. + const bool hammer=address==0x8006c478u; + uint32_t clock=rw(entry+4); + unsigned phase=rb(actor+4),age=clock&0xffffu,lifetime=clock>>16; + if(phase<2 && (age>=4 || lifetime>=120 || !state(Equipped))) { + // Let the original family handler release its model/pool and balance + // its own active-projectile counters through its normal retire states. + wb(actor+4,2); + if(unsigned(rb(player+0x176))+1==rw(entry+8))wb(player+0x79,0); + // The native hammer clears its terrain-impact latch on normal exit; + // its generic phase-2 retirement only advances to model destruction. + if(hammer)wb(0x800e807e,0); + } + if(phase==1 && age<4)fist_position(actor); + weapon_nested=true;original(cpu,address);weapon_nested=false; + if(phase==3) {ww(entry,0);psx_mod_counter_add("tomba2.mario.punch_retired",1);} + else { + if(age<4 && state(Equipped) && rb(actor+4)<2) { + fist_position(actor); + bool active=!hammer || (rb(actor+5)==1 && rb(actor+6)>=3); + if(active) {wb(actor,1);++age;} + } + wb(actor+1,0);ww(entry+4,((lifetime+1)<<16)|age); + } + return 1; +} +void player_initialized(CPUState* cpu,uint32_t) { + if(!ready() || cpu->gpr[4]!=player)return; + for(unsigned i=0;i<8;++i)ww(memory+Weapons+i*12,0); + state(DoubleJump,0);state(Clock,0);state(Voice,0); +} + +struct DrawVertex {Vec3 camera;float u,v,light;}; +void wf(uint32_t address,float value) {uint32_t bits;std::memcpy(&bits,&value,4);ww(address,bits);} +DrawVertex lerp(const DrawVertex& a,const DrawVertex& b,float t) { + return {{a.camera.x+(b.camera.x-a.camera.x)*t,a.camera.y+(b.camera.y-a.camera.y)*t, + a.camera.z+(b.camera.z-a.camera.z)*t},a.u+(b.u-a.u)*t,a.v+(b.v-a.v)*t,a.light+(b.light-a.light)*t}; +} +PoseBones presentation_bones() { + auto clip=animations.find(state(Clip)); + if(clip==animations.end())clip=animations.find(499); + const Animation& animation=clip->second; + unsigned frame=state(Clock); + bool loop=animation.id==499 || animation.id==505 || animation.id==500 || animation.id==520 || animation.id==573 || animation.id==562; + if(loop)frame%=std::max(1u,unsigned(animation.frames.size()-1)); + else frame=std::min(frame,unsigned(animation.frames.size()-1)); + auto bones=pose_bones(mario_model,animation.frames[frame]); + if(animation.id==573 && ladder_animation(rb(player+5),rb(player+0x46))) + climb_ladder(bones,float(state(Clock))*0.12f); + if(rb(player+4)==1 && rb(player+5)==3 && rb(player+6)>=1 && rb(player+6)<=4) + raise_capture_arms(bones); + return bones; +} +Matrix player_world() { + return mario_world(rh(player+0x56), + {float(int32_t(rw(player+0x2c)))/65536, + float(int32_t(rw(player+0x30)))/65536,float(int32_t(rw(player+0x34)))/65536}); +} +int draw_captured(CPUState* cpu,uint32_t address) { + uint32_t actor=cpu->gpr[4]; + if(captured_draw_nested || rb(player+4)!=1 || rb(player+5)!=3 || + rb(player+6)<1 || rb(player+6)>6 || rw(player+0x10)!=actor || + actor<0x80010000u || actor>0x801ffe00u || rb(actor+4)!=2 || rb(actor+5)!=1)return 0; + unsigned count=std::min(rb(actor+8),rb(actor+9)); + if(!count || count>32)return 0; + std::array pointers{}; + std::array,32> saved{}; + // Translate only the native model's render matrices. Never move the actor, + // change capture linkage, or replace native collision/aim/release logic. + // The callback safety gate prevents snapshots during this temporary span. + for(unsigned i=0;i0x801fffbc || (bone&3))return 0; + for(unsigned j=0;jgpr[4]!=player)return draw_captured(cpu,address); + auto bones=presentation_bones(); + Matrix world=player_world(); + Matrix view; + for(unsigned i=0;i<9;++i)view.m[i/3][i%3]=float(int16_t(rh(0x1f8000f8+i*2)))/4096; + for(unsigned i=0;i<3;++i)view.m[i][3]=float(int32_t(rw(0x1f80010c+i*4))); + Matrix camera=multiply(view,world); + for(auto& bone:bones)bone=multiply(camera,bone); + float h=float(cpu->gte_ctrl[26]&65535); + float ox=float(int32_t(cpu->gte_ctrl[24]))/65536,oy=float(int32_t(cpu->gte_ctrl[25]))/65536; + float near=std::max(16.0f,h*0.5f); + uint32_t ot=rw(0x800ed8c8),flip=rb(0x1f800135); + if(!h || flip>1 || !ot || (ot&3))return 0; + uint32_t cursor=packets+flip*1024*80;unsigned emitted=0; + for(const Triangle& triangle:mario_model.triangles) { + std::array input{},clipped{};unsigned count=3,out=0; + for(unsigned i=0;i<3;++i) { + const auto& v=triangle.vertices[i]; + Vec3 normal=v.normal; + float light=std::clamp(0.85f+0.15f*(normal.y-normal.z),0.6f,1.0f); + input[i]={transform(bones[v.joint],v.position),v.u,v.v,light}; + } + for(unsigned i=0;i=near,next=b.camera.z>=near; + if(inside)clipped[out++]=a; + if(inside!=next)clipped[out++]=lerp(a,b,(near-a.camera.z)/(b.camera.z-a.camera.z)); + } + if(out<3)continue; + const Texture& texture=mario_model.textures[triangle.texture]; + for(unsigned t=1;t+1=1016) {psx_mod_counter_add("tomba2.mario.packet_limit",1);return 1;} + DrawVertex v[3]={clipped[0],clipped[t],clipped[t+1]}; + float sx[3],sy[3],q[3],depth=0; + bool valid=true; + for(unsigned i=0;i<3;++i) { + q[i]=1/v[i].camera.z;sx[i]=ox+h*v[i].camera.x*q[i];sy[i]=oy+h*v[i].camera.y*q[i]; + depth+=v[i].camera.z; + valid=valid && std::abs(sx[i])<2048 && std::abs(sy[i])<2048; + } + if(!valid || !visible_face(triangle.cull,sx,sy))continue; + // Match the native AVSZ3 result and Tomba's nonlinear OT bins. + uint32_t z=uint32_t(std::max(0.0f,depth/12)); + uint32_t shift=z>>10; + if(shift>=32)continue; + z=(z>>shift)+(shift<<9); + if(z<4 || z>=2048)continue; + uint32_t link=ot+z*4,bank=0xe000u; + ww(cursor,0x09000000u|(rw(link)&0xffffff)); + for(unsigned i=0;i<3;++i) { + uint32_t light=uint32_t(128*v[i].light); + uint32_t rgb=light|(light<<8)|(light<<16); + if(i==0)rgb|=0x34000000u; + else if(i==1)rgb|=(bank&255)<<24; + else rgb|=(bank>>8)<<24; + ww(cursor+4+i*12,rgb); + int x=int(std::round(sx[i])),y=int(std::round(sy[i])); + ww(cursor+8+i*12,uint16_t(x)|(uint32_t(uint16_t(y))<<16)); + unsigned u=unsigned(std::clamp(v[i].u,0.0f,float(texture.width-1))); + unsigned vv=unsigned(std::clamp(v[i].v,0.0f,float(texture.height-1))); + u+=mario_atlas.origins[triangle.texture][0]; + vv+=mario_atlas.origins[triangle.texture][1]; + ww(cursor+12+i*12,u|(vv<<8)|(i==1?0x01000000u:0)); + } + ww(cursor+40,0x48545031u); + for(unsigned i=0;i<3;++i) { + wf(cursor+44+i*4,q[i]);wf(cursor+56+i*8,sx[i]);wf(cursor+60+i*8,sy[i]); + } + ww(link,cursor&0xffffff);cursor+=80;++emitted; + } + } + psx_mod_counter_add("tomba2.mario.draws",1); + psx_mod_counter_add("tomba2.mario.triangles",emitted); + return 1; +} + +// The original actor update, collision, attachments and shadow remain guest-owned. +void bind_textures() { + const auto& t=mario_atlas.texture; + if(!psx_mod_define_texture_bank(0xe000,t.width,t.height,t.pixels.data())) + throw std::runtime_error("Cannot allocate Mario texture atlas"); +} +void activate() { + const uint8_t* data=nullptr;uint64_t size=0; + if(!psx_mod_current_resource_bytes("mario-assets",&data,&size)) + throw std::runtime_error("Mario asset cache was not prepared"); + auto files=read_cache(Bytes(data,data+size));mario_model=decode_model(files); + mario_atlas=make_texture_atlas(mario_model); + voices.clear(); + for(uint32_t voice=429;voice<=441;++voice)voices.emplace(voice,files.at(voice_cache_base+voice)); + animations.clear(); + for(uint32_t id:{499,500,502,505,508,509,510,511,512,513,515,518,520,523,538, + 541,561,562,563,564,571,572,573,575,576,588,597,606,607,608, + 611,622,623,624,625,626}) + animations.emplace(id,decode_animation(id,files.at(id),mario_model)); + memory=psx_mod_alloc_guest_memory(8192,16); + packets=psx_mod_alloc_texture_packet_memory(2*1024*80,16); + if(!memory || !packets)throw std::runtime_error("Cannot allocate Mario runtime memory"); + psx_mod_counter_add("tomba2.mario.state_address",memory); + bind_textures(); +} +// Tables and playback cursors are restored with guest memory. Invalidating +// Magic here would suppress PCM until another gameplay/UI hook calls ready(). +void restored() {bind_textures();} +} + +PSX_MOD_CONSTRUCTOR(tomba2_register_mario_plugin) { + psx_mod_register_activation_plugin(plugin,activate); + psx_mod_register_pcm_plugin(plugin,mix_voice); + psx_mod_register_savestate_plugin(plugin,restored); + psx_mod_register_function_filter_plugin(plugin,0x80036240,inventory_build); + psx_mod_register_function_filter_plugin(plugin,0x800368d0,inventory_lookup); + psx_mod_register_function_filter_plugin(plugin,0x80035fac,equip_hat); + psx_mod_register_function_filter_plugin(plugin,0x800349a4,equip_native); + psx_mod_register_function_filter_plugin(plugin,0x800376ec,draw_item_name); + psx_mod_register_function_filter_plugin(plugin,0x8007e938,draw_item_icon); + psx_mod_register_function_filter_plugin(plugin,0x80074590,player_sound); + psx_mod_register_function_entry_plugin(plugin,0x8004fa38,costume_toast); + psx_mod_register_function_entry_plugin(plugin,0x80057fd4,costume_reload); + psx_mod_register_function_entry_plugin(plugin,0x80067ef4,costume_attributes); + psx_mod_register_function_entry_plugin(plugin,0x8005950c,player_tick); + psx_mod_register_function_filter_plugin(plugin,0x8003cdd8,draw_body); + psx_mod_register_instruction_plugin(plugin,0x800534dc,0x00402021,weapon_spawn); + psx_mod_register_function_entry_plugin(plugin,0x80069858,punch_collision); + psx_mod_register_function_entry_plugin(plugin,0x80058648,player_initialized); + for(uint32_t address:{0x8006ae28u,0x8006bb6cu,0x8006c478u}) + psx_mod_register_function_filter_plugin(plugin,address,punch_weapon); + for(const auto& site:table_sites) + psx_mod_register_instruction_plugin(plugin,site.address,site.word,redirect_table); +} diff --git a/src/mods/tomba2_mario_prepare.cpp b/src/mods/tomba2_mario_prepare.cpp new file mode 100644 index 0000000..46b2f11 --- /dev/null +++ b/src/mods/tomba2_mario_prepare.cpp @@ -0,0 +1,72 @@ +#include "mod_media.h" +#include "mod_packages.h" +#include "mod_plugins.h" +#include "mario/smash_assets.h" + +#include +#include +#include +#include + +namespace { +using namespace PSXRecompV4; +using namespace tomba2::mario; +constexpr uint64_t cache_size=678804; +constexpr const char* cache_hash="1cbe9ce74e63464da8582d14dea5f8ba94e30a8c0258f03fee80f9abb3ace588"; +std::atomic serial{0}; + +bool prepare(const ModPrepareContext& context, + std::map& outputs,std::string& error) { + std::filesystem::path temporary,staging; + try { + auto input=context.inputs.find("smash-rom"); + if(input==context.inputs.end())throw std::runtime_error("Select a Smash Bros. 64 US 1.0 ROM for Mario's Hat."); + std::shared_ptr rom; + if(!load_mod_media(input->second,"n64-rom",rom_size,rom_sha256,rom,&error))return false; + const auto directory=context.cache_root/"tomba2-mario"/"r2"; + const auto final=directory/(std::string(cache_hash)+".cache"); + std::shared_ptr verified; + std::string cache_error; + if(std::filesystem::is_regular_file(final) && + load_mod_media(final,"file",cache_size,cache_hash,verified,&cache_error)) { + outputs["mario-assets"]=final;return true; + } + Bytes bytes=build_cache(*rom); + // Decode before publishing: a cache must be useful, not merely present. + auto files=read_cache(bytes);(void)decode_model(files); + std::filesystem::create_directories(directory); + // create_directory atomically claims a staging location. Cleanup must + // never remove another process's incomplete import. + for(unsigned attempt=0;attempt<16 && staging.empty();++attempt) { + auto stamp=std::chrono::steady_clock::now().time_since_epoch().count(); + auto candidate=directory/("prepare-"+std::to_string(stamp)+"-"+std::to_string(++serial)); + if(std::filesystem::create_directory(candidate))staging=std::move(candidate); + } + if(staging.empty())throw std::runtime_error("Cannot create Mario import staging directory."); + temporary=staging/"assets.cache"; + { + std::ofstream out(temporary,std::ios::binary); + if(!out.write(reinterpret_cast(bytes.data()),std::streamsize(bytes.size()))) + throw std::runtime_error("Cannot write Mario's asset cache."); + } + if(!load_mod_media(temporary,"file",cache_size,cache_hash,verified,&error)) + throw std::runtime_error(error); + if(std::filesystem::exists(final)) { + if(std::filesystem::is_symlink(final) || !std::filesystem::is_regular_file(final)) + throw std::runtime_error("Mario cache destination is not a regular file."); + std::filesystem::remove(final); + } + std::filesystem::rename(temporary,final);temporary.clear(); + std::filesystem::remove(staging);staging.clear(); + outputs["mario-assets"]=final;return true; + }catch(const std::exception& e) { + error=e.what(); + if(!temporary.empty()) {std::error_code ec;std::filesystem::remove(temporary,ec);} + if(!staging.empty()) {std::error_code ec;std::filesystem::remove(staging,ec);} + return false; + } +} +} +PSX_MOD_CONSTRUCTOR(tomba2_register_mario_preparer) { + (void)PSXRecompV4::mod_register_media_preparer("tomba2.mario.import",prepare); +} diff --git a/tests/mario/CMakeLists.txt b/tests/mario/CMakeLists.txt new file mode 100644 index 0000000..3c9fb25 --- /dev/null +++ b/tests/mario/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.20) +project(Tomba2MarioAssets C CXX) +enable_testing() +set(CMAKE_CXX_STANDARD 17) +add_executable(mario-pcm-test test_pcm.cpp) +target_include_directories(mario-pcm-test PRIVATE ../../src/mods/mario) +add_test(NAME mario-pcm COMMAND mario-pcm-test) +set(PSX_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../psxrecomp-v4") +add_executable(mario-import ../../tools/mario_import.cpp + ../../src/mods/mario/smash_assets.cpp ../../src/mods/mario/smash_audio.cpp + "${PSX_ROOT}/runtime/src/psx_sha256.c") +target_include_directories(mario-import PRIVATE + ../../src/mods/mario "${PSX_ROOT}/runtime/include") +if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(mario-import PRIVATE -Wall -Wextra -Werror) +endif() +add_executable(mario-asset-test test_assets.cpp ../../src/mods/mario/smash_assets.cpp ../../src/mods/mario/smash_audio.cpp) +target_include_directories(mario-asset-test PRIVATE ../../src/mods/mario) +set(MARIO_TEST_CACHE "" CACHE FILEPATH "Owner-derived Mario cache for integration checks") +add_test(NAME mario-assets COMMAND mario-asset-test "${MARIO_TEST_CACHE}") +set_tests_properties(mario-assets PROPERTIES SKIP_RETURN_CODE 77) diff --git a/tests/mario/test_assets.cpp b/tests/mario/test_assets.cpp new file mode 100644 index 0000000..24e1cce --- /dev/null +++ b/tests/mario/test_assets.cpp @@ -0,0 +1,182 @@ +#include "smash_assets.h" +#include "presentation.h" +#include "action_animation.h" +#include +#include +#include +#include + +using namespace tomba2::mario; +static void check(bool ok,const char* message) { + if(!ok)throw std::runtime_error(message); +} +template static void rejects(F call) { + bool rejected=false;try{call();}catch(const std::exception&){rejected=true;} + check(rejected,"Invalid input accepted"); +} +int main(int argc,char**argv) { + try { + rejects([]{decompress_vpk({},100);}); + rejects([]{read_cache({});}); + rejects([]{extract_reloc(Bytes(rom_size),296);}); + const float front_x[3]={0,1,0},front_y[3]={0,0,1}; + const float back_x[3]={0,0,1},back_y[3]={0,1,0}; + check(visible_face(2,front_x,front_y) && !visible_face(2,back_x,back_y), + "Backface mode does not preserve source front faces"); + check(!visible_face(1,front_x,front_y) && visible_face(1,back_x,back_y), + "Front-cull mode does not preserve source back faces"); + check(visible_face(0,front_x,front_y) && visible_face(0,back_x,back_y) && + !visible_face(3,front_x,front_y) && !visible_face(3,back_x,back_y), + "Explicit two-sided/both-cull modes were lost"); + // Native RotMatrix's +X column at four quarter turns, independently + // of the imported model's +Z bind-facing convention. + const Vec3 native_forward[]={{1,0,0},{0,0,-1},{-1,0,0},{0,0,1}}; + for(unsigned h=0;h<4096;h+=1024) { + Matrix world=mario_world(uint16_t(h),{}); + Vec3 a=transform(world,{}),b=transform(world,{0,0,1}); + check(std::abs(b.x-a.x-0.72f*native_forward[h/1024].x)<0.0001f && + std::abs(b.z-a.z-0.72f*native_forward[h/1024].z)<0.0001f, + "Imported forward axis does not match native heading"); + } + // The native costume presentation ends at yaw 0x400, facing the + // camera. Imported +Z must point toward the viewer there, not away. + Matrix costume=mario_world(1024,{}); + Vec3 origin=transform(costume,{}),face=transform(costume,{0,0,1}); + check(std::abs(face.x-origin.x)<0.0001f && face.z(stream),{}}; + auto files=read_cache(cache);auto model=decode_model(files); + check(model.joints.size()==25,"Unexpected Mario joint count"); + check(model.triangles.size()==320,"Mario geometry lost/duplicated"); + unsigned back_culled=0; + for(const auto& t:model.triangles) {check(t.cull<=3,"Invalid source cull mode");back_culled+=t.cull==2;} + check(back_culled>0,"Source fighter backface mode was not retained"); + auto capture=decode_animation(562,files.at(562),model); + auto hang=decode_animation(573,files.at(573),model); + auto base=pose_bones(model,hang.frames[0]),reach_a=base,reach_b=base; + climb_ladder(reach_a,1.570796327f);climb_ladder(reach_b,4.71238898f); + for(auto segment:{std::array{3,5},{5,6},{9,11},{11,12}, + {14,16},{16,17},{19,21},{21,22}}) { + auto length=[&](const PoseBones& bones) { + Vec3 a=transform(bones[segment[0]],{}),b=transform(bones[segment[1]],{}); + return std::sqrt((a.x-b.x)*(a.x-b.x)+(a.y-b.y)*(a.y-b.y)+(a.z-b.z)*(a.z-b.z)); + }; + check(std::abs(length(base)-length(reach_a))<0.001f && + std::abs(length(base)-length(reach_b))<0.001f,"Ladder motion stretches a limb"); + } + for(unsigned tip:{6,12,17,22}) { + Vec3 a=transform(reach_a[tip],{}),b=transform(reach_b[tip],{}); + check(std::abs(a.y-b.y)+std::abs(a.z-b.z)>10, + "Ladder hand or foot does not alternate between steps"); + } + for(const auto& pose:capture.frames) { + auto before=pose_bones(model,pose),raised=before; + raise_capture_arms(raised); + for(auto chain:{std::array{3,6},{9,12}}) { + Vec3 root=transform(before[chain[0]],{}),hand=transform(before[chain[1]],{}); + Vec3 held=transform(raised[chain[1]],{}); + float length=std::sqrt((hand.x-root.x)*(hand.x-root.x)+ + (hand.y-root.y)*(hand.y-root.y)+(hand.z-root.z)*(hand.z-root.z)); + check(std::abs(held.x-root.x)<0.001f && std::abs(held.z-root.z)<0.001f && + std::abs(held.y-root.y-length)<0.001f, + "Overhead hold does not raise the imported hands without stretching"); + } + // Legs/head/torso retain their imported pose, independent of arms. + for(unsigned i:{0,1,2,7,8,14,15,16,17,18,19,20,21,22,23,24}) + for(unsigned r=0;r<3;++r)for(unsigned c=0;c<4;++c) + check(before[i].m[r][c]==raised[i].m[r][c],"Capture pose changed a non-arm bone"); + } + auto atlas=make_texture_atlas(model); + check(atlas.origins.size()==model.textures.size(),"Atlas lost a material"); + for(size_t i=0;i2) { + // Inspectable data for an independent software-rendered asset QA. + std::ofstream out(std::filesystem::u8path(argv[2])); + out<<"{\"joints\":["; + uint32_t inspect_id=argc>3?uint32_t(std::stoul(argv[3])):499; + auto inspect=decode_animation(inspect_id,files.at(inspect_id),model); + unsigned inspect_frame=argc>4?unsigned(std::stoul(argv[4])):0; + auto pose=inspect.frames.at(inspect_frame); + std::cout<<"Exported motion "< +#include +#include +using namespace tomba2::mario; +static void check(bool ok,const char* message) { + if(!ok)throw std::runtime_error(message); +} +int main() { + try { + std::vector pcm(1024); + for(unsigned i=0;i<512;++i) { + uint16_t value=uint16_t(int16_t(int(i*127%65536)-32768)); + pcm[i*2]=uint8_t(value);pcm[i*2+1]=uint8_t(value>>8); + } + std::vector whole(2048,5000),chunks=whole; + VoicePosition a,b; + auto result=mix_pcm_voice(pcm.data(),pcm.size(),whole.data(),1024,a); + unsigned offset=0,total=0; + for(unsigned n:{17u,113u,1u,227u,311u,355u}) { + total+=mix_pcm_voice(pcm.data(),pcm.size(),chunks.data()+offset*2,n,b).frames; + offset+=n; + } + check(whole==chunks && a.cursor==b.cursor && a.fraction==b.fraction, + "PCM depends on callback block size"); + check(result.finished && total==result.frames && total>512 && total<1024, + "Voice failed to finish at its resampled length"); + VoicePosition current;std::vector prefix(114); + mix_pcm_voice(pcm.data(),pcm.size(),prefix.data(),57,current); + auto saved=current; + std::vector first(400),replayed(400); + mix_pcm_voice(pcm.data(),pcm.size(),first.data(),200,current); + mix_pcm_voice(pcm.data(),pcm.size(),replayed.data(),200,saved); + check(first==replayed && current.cursor==saved.cursor && current.fraction==saved.fraction, + "Restoring a mid-voice cursor changed the PCM suffix"); + std::vector untouched(64,1234),bad=untouched; + VoicePosition invalid{0xffffffffu,0}; + check(mix_pcm_voice(pcm.data(),pcm.size(),bad.data(),32,invalid).finished && bad==untouched, + "Invalid restored cursor touched output"); + invalid={0,44100}; + check(mix_pcm_voice(pcm.data(),pcm.size(),bad.data(),32,invalid).finished && bad==untouched, + "Invalid restored fraction touched output"); + invalid={}; + check(mix_pcm_voice(pcm.data(),1,bad.data(),32,invalid).finished && bad==untouched, + "Odd PCM input accepted"); + std::vector loud(1024,0xff); + for(unsigned i=0;i<512;++i)loud[i*2+1]=0x7f; + std::vector clipped(2,32000);VoicePosition middle{200,0}; + mix_pcm_voice(loud.data(),loud.size(),clipped.data(),1,middle); + check(clipped[0]==32767 && clipped[1]==32767,"Voice overflow wrapped instead of saturating"); + // A 1 kHz raw 32 kHz tone under the source's -1199-cent articulation + // must last about twice as long and produce approximately 500 Hz. + std::vector tone(32000); + for(unsigned i=0;i<16000;++i) { + uint16_t value=uint16_t(int16_t(i%32<16?10000:-10000)); + tone[i*2]=uint8_t(value);tone[i*2+1]=uint8_t(value>>8); + } + std::vector pitched(88200);VoicePosition tone_position; + auto slowed=mix_pcm_voice(tone.data(),tone.size(),pitched.data(),44100, + tone_position,voice_sample_rate(429)); + unsigned crossings=0; + for(unsigned i=1;i0; + check(slowed.finished && slowed.frames>=44070 && slowed.frames<=44080 && + crossings>=499 && crossings<=501, + "Source pitch did not restore the voice's duration/frequency"); + check(native_voice_replacement(0x1d,1)==435 && + native_voice_replacement(0x1e,1)==439 && + native_voice_replacement(0x23,1)==440, + "Jump/death/damage voices were not replaced"); + check(!native_voice_replacement(0x2b,0) && !native_voice_replacement(0x38,0) && + !native_voice_replacement(0x90,1),"Voice replacement swallowed an ordinary effect"); + VoicePosition bad_rate{}; + check(mix_pcm_voice(tone.data(),tone.size(),pitched.data(),1,bad_rate,0).finished, + "A zero source rate never completes"); + std::cout<<"PASS: PCM block-size independence, mid-voice restore, completion, invalid state, saturation\n"; + }catch(const std::exception& e){std::cerr<version != "0.0.1" || !feature || + feature->channel != PSXRecompV4::ModChannel::Experimental || + feature->default_enabled || manager.feature_enabled(kUsMarioPackage, "mario-hat")) + return fail("Mario must ship as Experimental 0.0.1 and default off"); + bool required_rom = false; + for (const auto& resource : package->resources) + if (resource.id == "smash-rom") + required_rom = resource.required && resource.input_only && + resource.format == "n64-rom" && resource.size == 16777216 && + resource.sha256 == "15592e79d3c5295cef4371d4992f0bd25bec2102fc29644c93e682f7ea99ef3d"; + if (!required_rom || package->prepare != "tomba2.mario.import") + return fail("Mario must require the verified original Smash 64 ROM"); + return 0; +} + } // namespace int main(int argc, char** argv) { @@ -211,14 +234,31 @@ int main(int argc, char** argv) { fs::copy(fs::path(argv[2]), ita_root, fs::copy_options::recursive); PSXRecompV4::ModPackageManager us_manager; - if (load_catalog(us_root, us_manager, 4, 4) != 0) return 1; + us_manager.set_developer_channel_visible(false); + if (load_catalog(us_root, us_manager, 5, 5) != 0) return 1; + if (check_mario(us_manager) != 0) return 1; if (us_manager.packages().find(kUsDebugMenuPackage) != us_manager.packages().end()) { return fail("US catalog must not ship the uncompiled Debug Menu variant"); } - if (expect_single_plugin(us_manager.resolve(kGameId, "", kDiscSha256), - "tomba2.seamless", "default US resident loading") != 0) - return 1; + const auto defaults = us_manager.resolve(kGameId, "", kDiscSha256); + bool resident = false, pgxp = false, adaptive = false; + for (const auto& plugin : defaults.plugins) { + resident |= plugin.id == "tomba2.seamless"; + pgxp |= plugin.id == "psx.pgxp"; + adaptive |= plugin.id == "tomba2.widescreen.adaptive"; + } + if (!defaults.ok || !defaults.writes.empty() || defaults.plugins.size() != 3 || !resident || !pgxp || !adaptive) { + for (const auto& error : defaults.errors) std::cerr << error << '\n'; + for (const auto& plugin : defaults.plugins) std::cerr << "default plugin: " << plugin.id << '\n'; + std::cerr << "default writes: " << defaults.writes.size() << '\n'; + return fail("US defaults must retain resident loading, adaptive widescreen and texture correction"); + } + std::string pgxp_error; + if (!us_manager.set_feature_enabled("psx.enhancement.pgxp", "pgxp", false, &pgxp_error)) + return fail(pgxp_error); + if (!us_manager.set_feature_enabled(kUsWidescreenPackage, "widescreen", false, &pgxp_error)) + return fail(pgxp_error); const auto unsupported = us_manager.resolve(kItaGameId, "", kItaDiscSha256); if (unsupported.ok && !unsupported.plugins.empty()) return fail("US resident loader must not activate on another region"); @@ -231,15 +271,14 @@ int main(int argc, char** argv) { if (check_widescreen(us_manager, kUsWidescreenPackage, kGameId, kDiscSha256, "US") != 0) return 1; - if (check_frame_rate(us_manager, kUsFrameRatePackage, kGameId, - kDiscSha256, "US") != 0) - return 1; + if (us_manager.selected_package(kUsFrameRatePackage)) + return fail("US catalog must not ship the retired frame-rate package"); if (check_skip_fmvs(us_manager, kUsSkipFmvsPackage, kGameId, kDiscSha256, "US") != 0) return 1; PSXRecompV4::ModPackageManager ita_manager; - if (load_catalog(ita_root, ita_manager, 7, 7) != 0) return 1; + if (load_catalog(ita_root, ita_manager, 5, 5) != 0) return 1; if (ita_manager.packages().find(kUsDebugMenuPackage) != ita_manager.packages().end()) { return fail("Italian catalog must not include Debug Menu"); @@ -259,8 +298,8 @@ int main(int argc, char** argv) { fs::remove_all(us_root, ec); fs::remove_all(ita_root, ec); - std::cout << "Tomba 2 preloaded mods: 4 US packages, resident loading opt-out, " - "7 localized Italian packages, no bundled debug menu, " + std::cout << "Tomba 2 preloaded mods: 5 US packages, Experimental Mario 0.0.1 with required ROM, resident loading opt-out, " + "5 localized Italian packages, no bundled debug menu, " "3 widescreen choices, 7 interpolated frame-rate choices, " "motion-adaptive clarity blend, game-owned FMV skipping, " "stock guest code untouched\n"; diff --git a/tools/mario_import.cpp b/tools/mario_import.cpp new file mode 100644 index 0000000..b4c7fa1 --- /dev/null +++ b/tools/mario_import.cpp @@ -0,0 +1,48 @@ +#include "smash_assets.h" +#include "psx_sha256.h" + +#include +#include +#include +#include +#include +#include + +using namespace tomba2::mario; +static Bytes read(const std::filesystem::path& p) { + std::ifstream in(p,std::ios::binary|std::ios::ate); + if(!in || in.tellg()<0 || in.tellg()>32*1024*1024) + throw std::runtime_error("Cannot read input, or input exceeds 32 MiB"); + Bytes b(size_t(in.tellg()));in.seekg(0); + if(!in.read(reinterpret_cast(b.data()),std::streamsize(b.size()))) + throw std::runtime_error("Incomplete input read"); + return b; +} +static std::string hash(const Bytes& b) { + uint8_t h[32];psx_sha256_compute(b.data(),b.size(),h); + std::ostringstream s; + for(uint8_t c:h)s< "); + Bytes rom=read(std::filesystem::u8path(argv[1])); + if(rom.size()!=rom_size)throw std::runtime_error("Expected a 16 MiB Smash 64 US 1.0 ROM"); + if(rom[0]==0x37 && rom[1]==0x80) + for(size_t i=0;i(cache.data()),std::streamsize(cache.size()))) + throw std::runtime_error("Cannot write output cache"); + std::cout<<"cache_sha256="< 0) + check("presentation retains native captured actor position", all( + row["player"]["position"][0] == row["pig"]["position"][0] and + row["player"]["position"][2] == row["pig"]["position"][2] and + row["pig"]["position"][1] - row["player"]["position"][1] == 100*65536 + for row in held["frames"])) + trace("capture-aim-left", [(12, 0x80), (8, 0)]) + check("native aim turns left while holding", probe.player()["heading"] == 2048 + and probe.player()["action"] == 3 and probe.player()["phase"] == 2) + trace("capture-aim-right", [(12, 0x20), (8, 0)]) + check("native aim turns right while holding", probe.player()["heading"] == 0 + and probe.player()["action"] == 3 and probe.player()["phase"] == 2) + thrown = trace("capture-throw", [(4, 0x4000), (160, 0)]) + check("Cross executes native throw and release", any( + row["player"]["action"] == 3 and row["player"]["phase"] == 6 + for row in thrown["frames"]) and any(row["pig"]["mode"] == 0 + for row in thrown["frames"])) + check("native throw clears captured target kind", probe.ram(PLAYER+0x78, 1) == b"\0" + and probe.state()["equipped"] == 1) + expected = bytearray(ownership) + if weapon: + expected[weapon] = 1 + check("native ownership bytes preserved apart from declared grant", + probe.ram(0x800bfab4, 256) == expected) + report["passed"] = True + except Exception as error: + report["passed"] = False + report["error"] = str(error) + raise + finally: + try: + probe.call("clear_input") + for snapshot in snapshots: + probe.call("set_snapshot", slot=snapshot["slot"], + addr=snapshot["addr"] if snapshot["active"] else "0x0") + probe.savestate("load", mario_slot) + if "ownership" in locals(): + report["ownership_restored"] = probe.ram(0x800bfab4, 256) == ownership + finally: + (output / "report.json").write_text(json.dumps(report, indent=2)+"\n", encoding="utf-8") + return report + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=4597) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--mario-slot", type=int, default=8) + parser.add_argument("--near-slot", type=int, default=10) + parser.add_argument("--capture-slot", type=int, default=11) + parser.add_argument("--weapon", type=int, choices=range(9), default=0) + args = parser.parse_args() + result = run(Probe(args.port), args.output, args.mario_slot, args.near_slot, args.capture_slot, args.weapon) + if not result.get("ownership_restored"): + raise SystemExit("Native ownership was not restored") + print(f"PASS: {len(result['checks'])} interaction assertions; inspect captures in {args.output}") diff --git a/tools/mario_native_reload_smoke.py b/tools/mario_native_reload_smoke.py new file mode 100644 index 0000000..8a479fe --- /dev/null +++ b/tools/mario_native_reload_smoke.py @@ -0,0 +1,132 @@ +"""Reload an existing private native save through Tomba 2's actual Load menu. + +Start in grounded gameplay. The expected JSON is the pre-save observation from +the isolated card test, not a savestate. This never writes RAM or card files +directly; the native BIOS may write the card's system test frame (sector 63). +Screenshots of each menu must also be reviewed. A fresh-process run requires +zero savestate transactions and unequipped Mario at entry. +""" +import argparse +import hashlib +import json +from pathlib import Path +import time + +from mario_probe import Probe + + +def run(probe, output, expected_path, cards, fresh_process=False): + expected = json.loads(expected_path.read_text(encoding="utf-8")) + output.mkdir(parents=True, exist_ok=True) + report = {"checks": [], "menus": [], "fresh_process": fresh_process, + "ram_writes": False, "savestate_loads": False} + + def check(name, condition): + report["checks"].append({"name": name, "passed": bool(condition)}) + if not condition: + raise AssertionError(name) + + def card_hashes(): + result = {} + for name in ("card1.mcd", "card2.mcd"): + data = (cards / name).read_bytes() + check(name + " is a full raw card", len(data) == 131072 and data[:2] == b"MC") + result[name] = hashlib.sha256(data).hexdigest() + return result + + def menu(label, steps): + receipt = probe.route(steps) + probe.screenshot(output / (label + ".png")) + report["menus"].append({"label": label, "route": steps, "receipt": receipt}) + + try: + probe.wait_game_ready() + report["entry_player"], report["entry_mario"] = probe.player(), probe.state() + player = report["entry_player"] + check("starts in grounded gameplay", player["mode"] == 1 and + player["action"] in (0, 1) and player["air"] == 0) + report["snapshot_entry"] = probe.call("savestate_status") + if fresh_process: + check("fresh process has never restored a snapshot", report["snapshot_entry"]["generation"] == 0) + check("fresh process has no Mario selection", not report["entry_mario"]["requested"] + and not report["entry_mario"]["equipped"]) + report["cards_before"] = card_hashes() + for name in report["cards_before"]: + (output / (name + ".before")).write_bytes((cards / name).read_bytes()) + card1 = (cards / "card1.mcd").read_bytes() + check("private file 01 exists in block 1", card1[128] == 0x51 and + card1[138:159].split(b"\0", 1)[0] == b"BASCUSP94454T2-00") + + # Start / Down selects Load data. The remaining confirmations choose + # memory-card slot 1, native file 01 and its default Yes confirmation. + menu("load-selected", [(4, 8), (20, 0), (4, 0x40), (10, 0)]) + menu("card-slot", [(4, 0x4000), (80, 0)]) + menu("native-files", [(4, 0x4000), (120, 0)]) + menu("load-confirm", [(4, 0x4000), (30, 0)]) + probe.tap(0x4000, 4, 120) + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + player = probe.player() + if (player["mode"] == 1 and player["action"] == 0 and not player["air"] + and player["position"] == expected["player"]["position"]): + break + probe.wait_frames(60) + else: + raise TimeoutError("Native load did not return to the saved position") + probe.wait_frames(30) + probe.screenshot(output / "loaded.png") + report["loaded_player"], report["loaded_mario"] = probe.player(), probe.state() + check("native load restores saved location", report["loaded_player"]["position"] == + expected["player"]["position"]) + check("native ownership matches saved progress", probe.ram(0x800bfab4, 256).hex() == + expected["native_ownership"]) + check("native costume remains valid", report["loaded_player"]["costume"] == + expected["player"]["costume"]) + check("native load resets airborne extra jump", not report["loaded_mario"]["double_jump"]) + if fresh_process: + check("native save does not embed private Mario state", not report["loaded_mario"]["requested"] + and not report["loaded_mario"]["equipped"]) + old_position = probe.player()["position"] + probe.route([(10, 0x20), (15, 0)]) + check("loaded gameplay responds to movement", probe.player()["position"] != old_position) + report["cards_after"] = card_hashes() + report["card_changed_sectors"] = {} + for name in report["cards_after"]: + before = (output / (name + ".before")).read_bytes() + after = (cards / name).read_bytes() + (output / (name + ".after")).write_bytes(after) + report["card_changed_sectors"][name] = [i for i in range(1024) + if before[i*128:(i+1)*128] != after[i*128:(i+1)*128]] + # The real card test writes frame 63. Check every other sector, + # including all directory and saved-game sectors, byte for byte. + # runtime/src/memcard.c identifies frame 63 as the write-test frame. + check("loading preserves all native progress sectors", all( + set(changed) <= {63} for changed in report["card_changed_sectors"].values())) + check("unused card 2 is unchanged", not report["card_changed_sectors"]["card2.mcd"]) + report["dispatch"] = probe.call("unknown_dispatch_log", limit=8) + check("no unresolved dispatches", report["dispatch"]["total"] == 0) + report["snapshot_exit"] = probe.call("savestate_status") + check("no savestate transaction was used", report["snapshot_exit"]["generation"] == + report["snapshot_entry"]["generation"]) + report["passed"] = True + except Exception as error: + report.update(passed=False, error=str(error)) + raise + finally: + try: + probe.call("clear_input") + finally: + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + return report + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=4597) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--expected", type=Path, required=True) + parser.add_argument("--cards", type=Path, required=True) + parser.add_argument("--fresh-process", action="store_true") + args = parser.parse_args() + result = run(Probe(args.port), args.output, args.expected, args.cards, args.fresh_process) + print(f"PASS: {len(result['checks'])} native reload assertions; review {args.output}") diff --git a/tools/mario_perf.py b/tools/mario_perf.py new file mode 100644 index 0000000..4519b76 --- /dev/null +++ b/tools/mario_perf.py @@ -0,0 +1,70 @@ +"""Brief matched-scene Tomba/Mario comparison in the same private debug runtime. + +This measures costume cost with the mod loaded in both cases. It does not +qualify the framework against a separate pre-change or mod-disabled binary. +""" +import argparse +import json +from pathlib import Path +import subprocess +import time +from mario_probe import Probe + + +def measure_route(probe, steps): + # Queueing many steps performs one debug RPC per step. Exclude that setup + # cost, then count actual guest progress between midpoint-timed frame reads. + route = probe.start_route(steps) + t0 = time.monotonic() + first = probe.counters()["frame"] + t1 = time.monotonic() + route.update(probe.wait_route()) + t2 = time.monotonic() + last = probe.counters()["frame"] + t3 = time.monotonic() + elapsed = (t2 + t3 - t0 - t1) / 2 + frames = last - first + if frames <= 0 or elapsed <= 0: + raise RuntimeError("No forward guest-frame interval for performance measurement") + return {"route": route, "wall_seconds": elapsed, "measured_vblanks": frames, + "guest_vblanks_per_second": frames / elapsed, + "frame_read_uncertainty_seconds": ((t1 - t0) + (t3 - t2)) / 2} + + +def compare(probe, frames=700, reverse=False): + if frames < 600: + raise ValueError("Allow the 256-present performance ring to replace its prior samples") + report = {"method": __doc__.strip(), "vblanks_per_case": frames, "cases": []} + probe.wait_game_ready() + cases = [("Tomba", 7, 0), ("Mario", 8, 1)] + if reverse: + cases.reverse() + for label, slot, equipped in cases: + probe.savestate("load", slot) + probe.wait_frames(24) + state, player = probe.state(), probe.player() + if state["equipped"] != equipped or player["mode"] != 1 or player["air"]: + raise RuntimeError(f"{label} fixture is not quiet grounded gameplay") + measurement = measure_route(probe, [(frames, 0)]) + report["cases"].append({"form": label, "player": player, + **measurement, "perf": probe.call("frame_perf"), + "native": probe.call("overlay_loader_status")}) + if report["cases"][0]["player"]["position"] != report["cases"][1]["player"]["position"]: + raise RuntimeError("Fixture positions differ; comparison is not matched") + for key, path in (("title_head", "."), ("framework_head", "psxrecomp-v4")): + report[key] = subprocess.check_output(["git", "-C", path, "rev-parse", "HEAD"], text=True).strip() + report["source_state"] = "uncommitted implementation; retain build log and source diff with results" + return report + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=4597) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--reverse", action="store_true") + args = parser.parse_args() + report = compare(Probe(args.port), reverse=args.reverse) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + for case in report["cases"]: + print(case["form"], f"{case['guest_vblanks_per_second']:.2f} guest VBlanks/s", case["perf"]["all"]) diff --git a/tools/mario_playtest_gate.py b/tools/mario_playtest_gate.py new file mode 100644 index 0000000..8c5e53a --- /dev/null +++ b/tools/mario_playtest_gate.py @@ -0,0 +1,388 @@ +"""Prepare, qualify, and launch Mario playtests through an artifact/performance gate. + +Use a private runtime directory and private memory cards. `launch` never starts +a game unless every audited shard and the exact executable/configuration have +passed `qualify`. This is development tooling, not redistributable packaging. +""" +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys + +from mario_probe import Probe +from mario_perf import compare, measure_route + +ROOT = Path(__file__).resolve().parents[1] +FRAMEWORK = ROOT / "psxrecomp-v4" + + +def digest(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +def framework_tools(): + sys.path.insert(0, str(FRAMEWORK / "tools")) + import compile_overlays + return compile_overlays + + +def validate_aot(directory, expected_tag): + receipt = json.loads((directory / "AOT_CACHE_AUDIT.json").read_text()) + require(receipt["cache_tag"] == expected_tag, "Native shard audit is for another configuration") + require(receipt.get("all_pairs_valid") is True and + receipt.get("all_guards_match_known_input_bytes") is True, + "Native shards have no successful original-input audit") + require(receipt.get("recipe_count", 0) > 0 and receipt.get("required_images") and + len(receipt.get("image_coverage", [])) == receipt["recipe_count"], + "Native audit does not cover every verified recipe") + pairs = receipt.get("pairs", []) + require(pairs and len(pairs) == receipt.get("published_pairs"), "Incomplete audited shard inventory") + arch = framework_tools().cache_arch_abi() + cache = directory / "cache" / receipt["game_id"] / "gcc" / arch / expected_tag + require(cache.is_dir(), "The matching native shard directory is missing") + for pair in pairs: + name = pair["dll"] + require(Path(name).name == name and "\\" not in name, "Invalid shard path in receipt") + files = [(name, "dll_sha256"), (Path(name).with_suffix(".ranges").name, "manifest_sha256")] + if "resident_sha256" in pair: + files.append((Path(name).with_suffix(".resident").name, "resident_sha256")) + for name, key in files: + path = cache / name + require(path.is_file() and digest(path) == pair[key], "Missing or changed audited shard: " + name) + return receipt + + +def identity(args): + compiler = framework_tools() + tag = compiler.cache_tag(str(FRAMEWORK / "runtime/include"), str(args.recompiler), str(args.config), 2) + receipt = validate_aot(args.exe.parent, tag) + cache = args.exe.parent / "cache" / receipt["game_id"] / "gcc" / compiler.cache_arch_abi() / tag + artifacts = sorted((str(p.relative_to(cache)), digest(p)) for p in cache.rglob("*") + if p.is_file() and p.suffix in (".dll", ".so", ".dylib", ".ranges", ".resident")) + return {"exe_sha256": digest(args.exe), "config_sha256": digest(args.config), + "audit_sha256": digest(args.exe.parent / "AOT_CACHE_AUDIT.json"), "cache_tag": tag, + "gate_sha256": digest(Path(__file__)), + "measurement_tool_sha256": digest(ROOT / "tools/mario_perf.py"), + "cache_artifacts_sha256": hashlib.sha256(json.dumps(artifacts).encode()).hexdigest()} + + +def audit_runtime_additions(args): + """Prove page/orphan shards against original recipe bytes, not RAM captures. + + The framework's offline audit requires matching image bases. Runtime shards + can start at a page within that image, so prove their bounded function CRCs + separately while retaining the complete offline image audit. + """ + compiler = framework_tools() + current = identity(args) + receipt = validate_aot(args.exe.parent, current["cache_tag"]) + inventory_bytes = args.inventory.read_bytes() + require(hashlib.sha256(inventory_bytes).hexdigest() == receipt["recipe_input_sha256"], + "Original-input inventory differs from the offline native audit") + recipes = [] + for job in json.loads(inventory_bytes)["jobs"]: + record = json.loads(Path(job["input"]).read_text(encoding="utf-8-sig"))[0] + data = base64.b64decode(record["bytes_b64"], validate=True) + require(not record.get("executed_pcs") and len(data) == record["size"] and + hashlib.sha256(data).hexdigest() == job["sha256"], + "Native audit needs unchanged original-disc recipes") + bounds = [(lo & 0x1fffffff, hi & 0x1fffffff) for lo, hi in job["known_ranges"]] + recipes.append((job["name"], int(record["load_addr"], 0), data, bounds)) + cache = (args.exe.parent / "cache" / receipt["game_id"] / "gcc" / + compiler.cache_arch_abi() / current["cache_tag"]) + offline = {pair["dll"] for pair in receipt["pairs"]} + abi = compiler.overlay_abi_tag(str(FRAMEWORK / "runtime/include"), 2) + additions = [] + for dll in sorted(cache.glob("*" + compiler.overlay_ext())): + if dll.name in offline: + continue + ids = compiler.load_shard_func_ids(str(dll), abi) + require(ids, "Invalid runtime native shard: " + dll.name) + matched = next((name for name, load, data, bounds in recipes + if all(any(lo <= (start & 0x1fffffff) and + (start & 0x1fffffff) + length <= hi for lo, hi in bounds) + for _, _, ranges in ids for start, length in ranges) + and len(compiler.current_variant_func_ids(ids, data, load, len(data))) == len(ids)), None) + require(matched, "Runtime shard has no original-input guard proof: " + dll.name) + additions.append({"dll": dll.name, "dll_sha256": digest(dll), + "manifest_sha256": digest(dll.with_suffix(".ranges")), + "matched_recipe": matched, "functions": len(ids)}) + return {"inventory_sha256": hashlib.sha256(inventory_bytes).hexdigest(), + "all_additional_guards_match_original_bytes": True, "pairs": additions} + + +def qualification_process(port): + require(os.name == "nt", "Process/endpoint qualification is currently implemented for Windows") + command = ("$ids = @(Get-NetTCPConnection -LocalPort " + str(int(port)) + + " -State Listen | Select-Object -ExpandProperty OwningProcess -Unique); " + "if ($ids.Count -ne 1) { throw 'Expected one debug listener' }; " + "Get-CimInstance Win32_Process -Filter ('ProcessId=' + $ids[0]) | " + "Select-Object ExecutablePath,CommandLine,ProcessId | ConvertTo-Json -Compress") + process = json.loads(subprocess.check_output( + ["powershell.exe", "-NoProfile", "-Command", command], text=True)) + require("--hidden-window" in process["CommandLine"] and "--memcard-dir" in process["CommandLine"], + "Automated qualification requires a hidden runtime with explicitly isolated cards") + return process + + +def prepare(args): + # Reuse the established disc extraction, then rebuild through the supported + # recipe compiler and auditor. Never rename an older cache namespace. + sys.path.insert(0, str(FRAMEWORK / "tools")) + import aot_overlay_pipeline as pipeline + inventory = json.loads(args.inventory.read_text()) + work = args.work / "native-build" + work.mkdir(parents=True, exist_ok=False) + cache = pipeline.build(inventory, args.config, args.recompiler, work, + args.gcc, args.workers, ROOT, flavor=2) + receipt = pipeline.audit(args.config, args.recompiler, cache, args.inventory, + work / "audit.json", flavor=2) + for key in ("profile_sha256", "original_disc_sha256", "required_images"): + receipt[key] = inventory[key] + pipeline.stage(cache, args.stage, receipt) + + +def qualify(args): + additions = audit_runtime_additions(args) + before = identity(args) + process = qualification_process(args.port) + require(digest(process["ExecutablePath"]) == before["exe_sha256"], + "The debug endpoint belongs to a different executable") + probe = Probe(args.port, timeout=75) + loader = probe.call("overlay_loader_status") + require(Path(loader["cache_dir"]).resolve() == (args.exe.parent / "cache").resolve(), + "Qualification runtime is using another native cache") + report = {"identity": before, "passed": False, "minimum_hz": args.minimum_hz, + "runtime_additions_audit": additions} + try: + report["quiet"] = compare(probe) + probe.savestate("load", args.moving_slot) + probe.wait_frames(30) + require(probe.player()["mode"] == 1 and probe.state()["equipped"] == 1, + "Moving fixture must start as live Mario gameplay") + native_start = probe.call("overlay_loader_status") + dirty_start = probe.call("dirty_ram_stats") + # Stay on the safe village path while exercising walking, both turns, + # jumps and attacks. No success flags or actor coordinates are written. + steps = [(55, 0x20), (55, 0x80), (4, 0x4000), (6, 0), + (4, 0x4000), (65, 0), (4, 0x2000), (55, 0)] * 3 + measurement = measure_route(probe, steps) + native_end = probe.call("overlay_loader_status") + dirty_end = probe.call("dirty_ram_stats") + native = native_end["dispatch_native"] - native_start["dispatch_native"] + fallback = native_end["dispatch_interp_fallback"] - native_start["dispatch_interp_fallback"] + report["moving"] = {**measurement, + "native_dispatches": native, "fallback_dispatches": fallback, + "fallback_fraction": fallback / max(1, native + fallback), + "interpreted_instructions": dirty_end["insns_run"] - dirty_start["insns_run"], + "perf": probe.call("frame_perf")} + report["dispatch"] = probe.call("unknown_dispatch_log", limit=8) + report["checks"] = { + "quiet_pacing": all(c["guest_vblanks_per_second"] >= args.minimum_hz for c in report["quiet"]["cases"]), + "moving_pacing": report["moving"]["guest_vblanks_per_second"] >= args.minimum_hz, + "native_execution": native > 0 and report["moving"]["fallback_fraction"] < 0.01, + "no_dispatch_misses": report["dispatch"]["total"] == 0, + "still_in_gameplay": probe.player()["mode"] == 1 and probe.state()["equipped"] == 1, + "artifacts_unchanged": before == identity(args), + } + report["passed"] = all(report["checks"].values()) + except BaseException as error: + report["error"] = repr(error) + raise + finally: + probe.call("clear_input") + probe.savestate("load", 8) + args.qualification.write_text(json.dumps(report, indent=2) + "\n") + require(report["passed"], "Playtest performance gate failed; see " + str(args.qualification)) + + +def validate_qualification(current, report): + require(report.get("identity") == current, "Qualification is stale for this executable/config/cache") + require(report.get("passed") is True and report.get("checks") and all(report["checks"].values()), + "No passing runtime performance qualification") + + +def validate_production_build(build, exe): + """Refuse a diagnostic, stale, or unstaged owner-facing executable.""" + cache = (build / "CMakeCache.txt").read_text().splitlines() + require("CMAKE_BUILD_TYPE:STRING=Release" in cache, "Production handoff requires Release") + require("PSX_DEBUG_TOOLS:BOOL=OFF" in cache, "Production handoff requires diagnostics OFF") + require("-DPSX_NO_DEBUG_TOOLS=1" in (build / "build.ninja").read_text(), + "Production compile flags do not disable diagnostic instrumentation") + built = build / exe.name + require(built.is_file() and digest(built) == digest(exe), + "Staged production executable differs from the completed build") + + +RUNTIME_REQUIRED = ( + "assets/fonts/LatoLatin-Regular.ttf", "assets/fonts/LatoLatin-Bold.ttf", + "assets/fonts/NotoSansSymbols2-Regular.ttf", "assets/fonts/OpenMoji-black-glyf.ttf", + "assets/img/boxart.tga", "assets/img/brand_mark.tga", "assets/img/brand_psx.tga", + "assets/img/flags.png", "assets/img/memcard.tga", "assets/img/pad_analog.tga", + "assets/img/pad_digital.tga", "assets/img/verdict_ok.tga", "assets/img/verdict_bad.tga", + "assets/img/verdict_none.tga", "assets/img/verdict_warn.tga", "assets/psxrecomp.png", + "bios/openbios.bin", "bios/OpenBIOS.LICENSE", +) + + +def ordinary_path(path): + require(not path.is_symlink() and not getattr(path, "is_junction", lambda: False)(), + "Runtime support path must not be a link: " + str(path)) + + +def runtime_files(build): + """Inventory the build's bounded support folders, never user profiles/cards.""" + files = [] + for name in ("assets", "bios"): + folder = build / name + ordinary_path(folder) + require(folder.is_dir(), "Build runtime support folder is missing: " + name) + for parent, directories, names in os.walk(folder, followlinks=False): + for entry in directories + names: + ordinary_path(Path(parent) / entry) + files.extend((Path(parent) / entry).relative_to(build) for entry in names) + require(set(map(Path, RUNTIME_REQUIRED)).issubset(files), + "Build runtime support inventory is incomplete") + return sorted(files) + + +def staged_support_path(directory, relative): + path = directory + for part in relative.parts: + path = path / part + ordinary_path(path) + return path + + +def validate_runtime_files(build, exe): + manifest = [] + for relative in runtime_files(build): + target = staged_support_path(exe.parent, relative) + expected = digest(build / relative) + require(target.is_file() and digest(target) == expected, + "Missing or changed runtime support file: " + relative.as_posix()) + manifest.append({"path": relative.as_posix(), "sha256": expected}) + return manifest + + +def stage_runtime_files(build, exe): + for relative in runtime_files(build): + source = build / relative + target = staged_support_path(exe.parent, relative) + if source.resolve() != target.resolve(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + return validate_runtime_files(build, exe) + + +def verify_production(args): + validate_production_build(args.build, args.exe) + report = {"identity": identity(args), "runtime_additions_audit": audit_runtime_additions(args), + "runtime_support_files": validate_runtime_files(args.build, args.exe), + "debug_tools": False, "cmake_build_type": "Release", "compile_flags_verified": True, + "validation_scope": "Production artifact/cache verification; no TCP performance measurement claimed."} + args.qualification.write_text(json.dumps(report, indent=2) + "\n") + print("Verified diagnostics-free production build and original-byte-audited native cache", flush=True) + return report + + +def launch_production(args): + validate_production_build(args.build, args.exe) + stage_runtime_files(args.build, args.exe) + report = verify_production(args) + env = dict(os.environ) + for key in ("SDL_AUDIO_DRIVER", "SDL_AUDIODRIVER", "PSX_HEADLESS", "PSX_NO_LAUNCHER", + "PSX_INTERNAL_RESOLUTION", "PSX_HIRES_WINDOW", "PSX_TEXWIN_BATCH", + "LNG_SCRIPT", "LNG_TEST_HIDDEN", "LNG_FORCE_SCALE", "LNG_SMOKE_FRAMES"): + env.pop(key, None) + env["PATH"] = str(Path(args.gcc).parent) + os.pathsep + env.get("PATH", "") + command = [str(args.exe), "--launcher", "--game", str(args.config), + "--bios", str(args.bios), "--memcard-dir", str(args.cards)] + with args.exe.with_suffix(".stdout.log").open("w") as out, args.exe.with_suffix(".stderr.log").open("w") as err: + process = subprocess.Popen(command, cwd=ROOT, env=env, stdout=out, stderr=err) + report["owner_launch"] = {"pid": process.pid, "command": command, "normal_audio": True, + "entry": "recomp-ui launcher", "no_automated_input": True} + args.qualification.write_text(json.dumps(report, indent=2) + "\n") + print("Owner recomp-ui launcher opened; PID", process.pid, flush=True) + + +def launch(args): + current = identity(args) + validate_qualification(current, json.loads(args.qualification.read_text())) + require(not (args.exe.parent / "settings.toml").exists(), + "Use a private handoff directory without inherited fullscreen/audio settings") + # Do not steal an existing owner's debug endpoint or close their process. + with socket.socket() as check: + require(check.connect_ex(("127.0.0.1", args.port)) != 0, "Debug port is already in use") + env = dict(os.environ) + for key in ("SDL_AUDIO_DRIVER", "SDL_AUDIODRIVER", "PSX_HEADLESS"): + env.pop(key, None) + env["PATH"] = str(Path(args.gcc).parent) + os.pathsep + env.get("PATH", "") + command = [str(args.exe), "--no-launcher", "--game", str(args.config), + "--bios", str(args.bios), "--debug-port", str(args.port), + "--memcard-dir", str(args.cards)] + process = subprocess.Popen(command, cwd=ROOT, env=env) + probe = Probe(args.port, timeout=75) + probe.wait_game_ready(90) + probe.savestate("load", 8) + print("Owner playtest ready; PID", process.pid, flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("prepare", "qualify", "launch", "verify-production", "launch-production")) + parser.add_argument("--exe", type=Path) + parser.add_argument("--config", type=Path, default=ROOT / ".research/game-test.toml") + parser.add_argument("--recompiler", type=Path, default=FRAMEWORK / "recompiler/build/psxrecomp-game.exe") + parser.add_argument("--inventory", type=Path) + parser.add_argument("--work", type=Path) + parser.add_argument("--stage", type=Path) + parser.add_argument("--build", type=Path) + parser.add_argument("--gcc", default="C:/msys64/mingw64/bin/gcc.exe") + parser.add_argument("--workers", type=int, default=2) + parser.add_argument("--qualification", type=Path) + parser.add_argument("--minimum-hz", type=float, default=57.0) + parser.add_argument("--moving-slot", type=int, default=10) + parser.add_argument("--port", type=int, default=4598) + parser.add_argument("--bios", type=Path, default=ROOT / "build-mario/bios/openbios.bin") + parser.add_argument("--cards", type=Path) + args = parser.parse_args() + for key, value in vars(args).items(): + if isinstance(value, Path): + setattr(args, key, value.resolve()) + require(sys.version_info >= (3, 11), "Use Python 3.11 or newer for the native-cache tooling") + os.chdir(ROOT) + require(args.workers > 0 and args.minimum_hz >= 57, "Do not weaken the playtest gate") + if args.action == "prepare": + require(args.inventory and args.work and args.stage, "prepare needs inventory, work, stage") + prepare(args) + else: + require(args.exe and args.qualification, "qualify/launch need exe and qualification") + if args.action in ("verify-production", "launch-production"): + require(args.build and args.inventory, "verify-production needs build and original-input inventory") + if args.action == "launch-production": + require(args.cards and args.cards.is_dir(), "launch-production needs the retained native cards") + launch_production(args) + else: + verify_production(args) + elif args.action == "qualify": + require(args.inventory and args.inventory.is_file(), "qualify needs the audited original-input inventory") + qualify(args) + else: + require(args.cards and args.cards.is_dir(), "launch needs private cards/fixtures") + launch(args) + + +if __name__ == "__main__": + main() diff --git a/tools/mario_probe.py b/tools/mario_probe.py new file mode 100644 index 0000000..5b2bcb0 --- /dev/null +++ b/tools/mario_probe.py @@ -0,0 +1,217 @@ +"""Non-pausing development probe for an isolated Tomba 2 mod test runtime.""" +import argparse +import json +import socket +import struct +import time + + +def request(port, command, **params): + with socket.create_connection(("127.0.0.1", port), timeout=20) as connection: + connection.sendall((json.dumps({"id": 1, "cmd": command, **params})+"\n").encode()) + # Diagnostics may pretty-print across lines. Stop at the complete JSON + # object: waiting for EOF races the server's close/reset on Windows. + # Track nesting outside quoted strings, then let json.loads validate. + chunks, size, depth = [], 0, 0 + quoted = escaped = started = False + while True: + chunk = connection.recv(65536) + if not chunk: + break + size += len(chunk) + if size > 8 * 1024 * 1024: + raise RuntimeError("Debug response exceeded the 8 MiB probe limit") + chunks.append(chunk) + for byte in chunk: + if quoted: + if escaped: + escaped = False + elif byte == 92: + escaped = True + elif byte == 34: + quoted = False + elif byte == 34: + quoted = True + elif byte in (123, 91): + depth += 1 + started = True + elif byte in (125, 93): + depth -= 1 + if started and depth == 0 and not quoted: + break + payload = b"".join(chunks) + response = json.loads(payload) + if not response.get("ok"): + raise RuntimeError(response) + return response + + +class Probe: + """Frame-counted input on a live runtime; never pauses or focuses a window. + + Use only with a development runtime and private memory cards. State slots + are build-specific fixtures. RAM reads are live observations, not atomic + snapshots; use the runtime's frame history for exact frame observations. + """ + + def __init__(self, port=4597, timeout=30): + self.port, self.timeout = port, timeout + + def call(self, command, **params): + return request(self.port, command, **params) + + def ram(self, address, size): + return bytes.fromhex(self.call("read_ram", addr=address, len=size)["hex"]) + + def write(self, address, data): + self.write_fields([(address, data)]) + + def write_fields(self, fields): + # The server closes each connection after one command. These are live + # byte writes; native physics may update fields between requests. + for address, data in fields: + for offset, value in enumerate(data): + self.call("write_ram", addr=address+offset, val=value) + + def counters(self): + return self.call("mod_counters") + + def wait_game_ready(self, timeout=60): + # A restore before the host has entered MAIN.EXE can leave boot/title + # host context active while RAM already looks like gameplay. Wait for + # the real entry and US MAIN function identity before loading fixtures. + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + if self.call("turbo_loads")["game_started"] and self.ram(0x800368d0, 4) == bytes.fromhex("24008390"): + return + except OSError: + pass + time.sleep(0.05) + raise TimeoutError("US MAIN.EXE did not become ready before fixture restore") + + def state(self): + counters = self.counters() + addresses = [c["count"] for c in counters["counters"] + if c["name"] == "tomba2.mario.state_address"] + if len(addresses) != 1: + raise RuntimeError("Mario plugin is not active") + address = addresses[0] + words = struct.unpack("<16I", self.ram(address, 64)) + names = ("magic", "requested", "equipped", "double_jump", "clip", "clock", + "last_action", "menu_primary", "icon_item", "voice", "voice_cursor", + "voice_fraction", "previous_action", "previous_air", "menu", "hat_toast") + return {"address": address, "frame": counters["frame"], **dict(zip(names, words))} + + def player(self): + data = self.ram(0x800e7e80, 0x180) + return {"mode": data[4], "action": data[5], "phase": data[6], + "position": list(struct.unpack_from("<3i", data, 0x2c)), + "velocity_y": struct.unpack_from("= 256 or not 0x80010000 <= address <= 0x801fff40: + raise RuntimeError(f"Invalid actor list {group}: {address:#x}") + seen.add(address) + offset = address & 0x1fffff + result.append({"address": address, "group": group, "handler": word(address+0x1c), + "flags": ram[offset:offset+16].hex(), + "position": list(struct.unpack_from("<3i", ram, offset+0x2c))}) + address = word(address+0x24) + return result + + def start_route(self, steps): + """Steps are (VBlank count, pressed-button mask); zero means release.""" + self.call("clear_input") + self.call("input_route_clear") + for frames, pressed in steps: + if not 0 < frames <= 36000 or not 0 <= pressed <= 0xffff: + raise ValueError("Invalid input route step") + self.call("input_route_append", frames=frames, buttons=0xffff ^ pressed) + return self.call("input_route_start") + + def wait_route(self): + deadline = time.monotonic() + self.timeout + try: + while self.call("input_route_status")["active"]: + if time.monotonic() >= deadline: + raise TimeoutError("Input route did not complete") + time.sleep(0.025) + finally: + self.call("clear_input") + return {"end_frame": self.counters()["frame"]} + + def route(self, steps): + return {**self.start_route(steps), **self.wait_route()} + + def wait_frames(self, frames): + return self.route([(frames, 0)]) + + def tap(self, pressed, frames=4, release=8): + return self.route([(frames, pressed), (release, 0)]) + + def savestate(self, operation, slot): + generation = self.call("savestate_status")["generation"] + self.call("savestate", op=operation, slot=slot) + return self.wait_savestate(operation, slot, generation) + + def wait_savestate(self, operation, slot, generation): + deadline = time.monotonic() + self.timeout + waits = set() + while True: + status = self.call("savestate_status") + if status.get("wait"): + waits.add(status["wait"]) + if status["generation"] > generation and not status["pending"]: + if not status["last_ok"] or status["last_op"] != operation or status["last_slot"] != slot: + raise RuntimeError(status) + return {**status, "observed_waits": sorted(waits)} + if time.monotonic() >= deadline: + raise TimeoutError("Save-state transaction did not complete") + time.sleep(0.025) + + def screenshot(self, path): + from pathlib import Path + return self.call("screenshot_hires", path=str(Path(path).resolve())) + + def composed_screenshot(self, path): + """Wait for the queued present capture before changing the scene.""" + from pathlib import Path + path = Path(path).resolve() + before = self.call("present_shot_seq")["seq"] + receipt = self.call("present_shot", path=str(path)) + deadline = time.monotonic() + self.timeout + while True: + completion = self.call("present_shot_seq") + if completion["seq"] != before: + break + if time.monotonic() >= deadline: + raise TimeoutError("Composed screenshot did not complete") + time.sleep(0.02) + if not completion["wrote"] or not path.is_file(): + raise RuntimeError("Composed screenshot did not produce its file") + return receipt + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command") + parser.add_argument("--port", type=int, default=4597) + parser.add_argument("--path") + parser.add_argument("--addr", type=lambda v: int(v, 0)) + parser.add_argument("--len", type=int) + parser.add_argument("--buttons", type=lambda v: int(v, 0)) + parser.add_argument("--slot", type=int) + args = vars(parser.parse_args()) + command, port = args.pop("command"), args.pop("port") + print(json.dumps(request(port, command, **{k: v for k, v in args.items() if v is not None}), indent=2)) diff --git a/tools/mario_reach_smoke.py b/tools/mario_reach_smoke.py new file mode 100644 index 0000000..a89e042 --- /dev/null +++ b/tools/mario_reach_smoke.py @@ -0,0 +1,83 @@ +"""Compare real punch reach using identical private post-pig input routes. + +Requires slots 8 (quiet Mario) and 10 (post approach). Does not write RAM, +actor positions, damage, capture links or success flags. +""" +import argparse +import json +from pathlib import Path + +from mario_probe import Probe +from mario_interaction_smoke import PLAYER, PIG_HANDLER, actor, find_post_pig + + +def run(probe, output, baseline=None): + output.mkdir(parents=True, exist_ok=True) + report = {"cases": [], "passed": False, "ram_writes": False} + snapshots = probe.call("get_snapshots")["snapshots"] + try: + probe.savestate("load", 8) + probe.wait_frames(8) + pig = find_post_pig(probe) + report["pig_address"] = hex(pig) + probe.call("set_snapshot", slot=0, addr=hex(PLAYER)) + probe.call("set_snapshot", slot=1, addr=hex(pig)) + for travel in (0, 2, 4, 6, 8, 10, 12, 14): + probe.savestate("load", 10) + probe.wait_frames(8) + target = actor(probe.ram(pig, 128)) + assert target["handler"] == PIG_HANDLER and target["mode"] == 1 + before = {c["name"]: c["count"] for c in probe.counters()["counters"]} + start = probe.call("history")["newest"] + 1 + route = [] + if travel: + route.append((travel, 0x20)) + route += [(4, 0x4020), (4, 0x2020), (65, 0)] + receipt = probe.route(route) + end = probe.call("history")["newest"] + frames = [] + for frame in range(start, end + 1, 2): + data = bytes.fromhex(probe.call("read_frame_ram", frame=frame, + addr=hex(pig), len=128)["hex"]) + frames.append({"frame": frame, **actor(data)}) + after = {c["name"]: c["count"] for c in probe.counters()["counters"]} + case = {"travel": travel, "route": receipt, "frames": frames, + "stunned": any(f["flags"][:2] in ("03", "07") and f["mode"] == 2 for f in frames), + "spawns": after.get("tomba2.mario.punch_spawn", 0) - before.get("tomba2.mario.punch_spawn", 0), + "retired": after.get("tomba2.mario.punch_retired", 0) - before.get("tomba2.mario.punch_retired", 0)} + report["cases"].append(case) + assert case["spawns"] == case["retired"] == 1, case + probe.savestate("load", 8) + before = actor(probe.ram(pig, 128)) + probe.route([(4, 0x2000), (70, 0)]) + after = actor(probe.ram(pig, 128)) + report["distant_miss"] = before["mode"] == after["mode"] == 1 and after["action"] == 0 + assert report["distant_miss"], "A distant punch reached the post pig" + if baseline: + original = {c["travel"]: c for c in json.loads(baseline.read_text())["cases"]} + report["newly_reachable_inputs"] = [c["travel"] for c in report["cases"] + if c["stunned"] and not original[c["travel"]]["stunned"]] + assert report["newly_reachable_inputs"], "No real reach improvement over the same baseline inputs" + report["passed"] = True + except BaseException as error: + report["error"] = repr(error) + raise + finally: + probe.call("clear_input") + for snapshot in snapshots: + probe.call("set_snapshot", slot=snapshot["slot"], + addr=snapshot["addr"] if snapshot["active"] else "0x0") + probe.savestate("load", 8) + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n") + return report + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=4598) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--baseline", type=Path) + args = parser.parse_args() + result = run(Probe(args.port), args.output, args.baseline) + print({"passed": result["passed"], "hits": [c["travel"] for c in result["cases"] if c["stunned"]], + "newly_reachable_inputs": result.get("newly_reachable_inputs"), "distant_miss": result["distant_miss"]}) diff --git a/tools/mario_smoke.py b/tools/mario_smoke.py new file mode 100644 index 0000000..1590e86 --- /dev/null +++ b/tools/mario_smoke.py @@ -0,0 +1,187 @@ +"""Live US Mario's Hat smoke checks. Run only against an isolated private-card runtime. + +Requires a quiet, grounded Tomba gameplay fixture in --tomba-slot (default 7). +Writes build-specific fixtures in --mario-slot/--scratch-slot (default 8/9). +Screenshots require visual review; this does not certify enemy interaction, +progression, memory-card saves, rewind or release readiness. +""" +import argparse +import hashlib +import json +import struct +import time +from pathlib import Path + +from mario_probe import Probe + + +def run(probe, output, tomba_slot, mario_slot, scratch_slot): + output.mkdir(parents=True, exist_ok=True) + report = {"checks": [], "observations": [], "unverified": [ + "enemy damage and weapon-specific target requirements", "overhead grab/aim/throw", + "other suits", "death/continue", "native memory-card save/reload", + "rewind audio", "matched baseline performance", "adaptive widescreen", + "native picker/import UI", "redistributable packaging"]} + + def observe(label): + record = {"label": label, "state": probe.state(), "player": probe.player()} + report["observations"].append(record) + return record + + def check(name, condition): + report["checks"].append({"name": name, "passed": bool(condition)}) + if not condition: + raise AssertionError(name) + + def count(name): + return next((c["count"] for c in probe.counters()["counters"] if c["name"] == name), 0) + + def wait_grounded(): + for _ in range(20): + player = probe.player() + if player["mode"] == 1 and player["action"] in (0, 1) and player["air"] == 0: + return + probe.wait_frames(20) + raise AssertionError("Did not return to grounded gameplay") + + def choose_hat(): + probe.tap(0x1000, release=24) + menu = probe.state()["menu"] + check("native inventory has opened", menu != 0 and probe.ram(menu, 1) == b"\1") + probe.tap(0x4000) + # This fixture owns exactly two ordinary equipment entries. Fail if + # the menu layout differs instead of equipping an unknown native item. + check("fixture has two native equipment entries", probe.state()["menu_primary"] == 2) + probe.tap(0x0040) + selection = probe.ram(menu, 48) + check("virtual hat is selected before confirmation", selection[9] == 1 and + selection[0x1d] == 0 and selection[0x1f] == 1) + probe.tap(0x4000) + + snapshot = None + try: + probe.wait_game_ready() + probe.savestate("load", tomba_slot) + probe.wait_frames(24) + start = observe("Tomba fixture") + check("fixture starts as grounded Tomba", not start["state"]["equipped"] and + not start["state"]["requested"] and start["player"]["air"] == 0) + ownership = probe.ram(0x800bfab4, 256) + report["ownership_sha256"] = hashlib.sha256(ownership).hexdigest() + label_before = count("tomba2.mario.costume_label") + choose_hat() + check("hat selection requests native costume transaction", probe.state()["requested"] == 1) + probe.screenshot(output / "inventory-equipped.png") + probe.tap(0x1000, release=25) + probe.screenshot(output / "equip-confirmation.png") + check("one native hat confirmation queued", count("tomba2.mario.costume_label") == label_before + 1) + wait_grounded() + check("native costume transaction equips Mario", probe.state()["equipped"] == 1) + probe.screenshot(output / "mario-gameplay.png") + probe.savestate("save", mario_slot) + jumps = count("tomba2.mario.double_jump") + native_voices = count("tomba2.mario.native_voice_replaced") + probe.tap(0x4000, release=6) + first = observe("first jump") + check("first jump follows native airborne state", first["player"]["air"] != 0 and + first["state"]["double_jump"] == 0) + check("native Tomba jump vocal is replaced through its real sound event", + count("tomba2.mario.native_voice_replaced") > native_voices) + probe.tap(0x4000, release=4) + second = observe("second jump") + check("second jump applies upward velocity once", second["state"]["double_jump"] == 1 and + second["player"]["velocity_y"] < 0 and count("tomba2.mario.double_jump") == jumps + 1) + probe.tap(0x4000, release=4) + observe("third press") + check("third airborne press adds no jump", count("tomba2.mario.double_jump") == jumps + 1) + probe.wait_frames(100) + wait_grounded() + check("landing restores extra jump", probe.state()["double_jump"] == 0) + probe.tap(0x4000, release=6) + probe.tap(0x4000, release=4) + check("next airborne cycle permits another extra jump", count("tomba2.mario.double_jump") == jumps + 2) + probe.wait_frames(100) + wait_grounded() + # Submit the save as soon as the actual extra-jump voice starts. Its + # 311 ms duration can end while a completed route/save receipt and + # several live observations are travelling through the debug server. + memory = probe.state()["address"] + snapshot = probe.call("get_snapshots")["snapshots"][0] + probe.call("set_snapshot", slot=0, addr=hex(memory)) + generation = probe.call("savestate_status")["generation"] + probe.start_route([(4, 0x4000), (4, 0), (4, 0x4000), (80, 0)]) + deadline = time.monotonic() + probe.timeout + while True: + words = struct.unpack("<16I", probe.ram(memory, 64)) + if words[9] == 436: + report["voice_save_request"] = {"voice": words[9], "cursor": words[10], + "fraction": words[11]} + probe.call("savestate", op="save", slot=scratch_slot) + break + if time.monotonic() >= deadline or not probe.call("input_route_status")["active"]: + raise AssertionError("Native extra-jump voice did not start during the input route") + report["voice_save_receipt"] = probe.wait_savestate("save", scratch_slot, generation) + probe.wait_route() + observe("mid-voice save receipt (live, may already have completed)") + start = probe.call("history")["newest"] + 1 + probe.savestate("load", scratch_slot) + # The completion receipt can arrive at a BB before the first restored + # VBlank has entered the history ring. Retain a few actual frames. + probe.wait_frames(4) + end = probe.call("history")["newest"] + recorded = [] + for frame in range(start, end + 1): + raw = probe.call("read_frame_ram", frame=frame, addr=hex(memory), len=64) + words = struct.unpack("<16I", bytes.fromhex(raw["hex"])) + if words[9] == 436: + recorded.append({"frame": frame, "equipped": words[2], "voice": words[9], + "cursor": words[10], "fraction": words[11]}) + report["recorded_voice_restore"] = recorded + check("mid-voice fixture contains an active imported voice", bool(recorded)) + check("current-build load restores Mario and active voice", recorded[0]["equipped"] == 1 and + recorded[0]["cursor"] <= report["voice_save_request"]["cursor"] + 2000 and + recorded[0]["fraction"] < 44100) + observe("mid-voice load receipt (live, may already have completed)") + probe.wait_frames(100) + wait_grounded() + check("restored voice completes", probe.state()["voice"] == 0) + choose_hat() + check("hat toggle requests Tomba", probe.state()["requested"] == 0) + probe.tap(0x1000, release=25) + probe.screenshot(output / "remove-confirmation.png") + wait_grounded() + check("native costume transaction restores Tomba", probe.state()["equipped"] == 0) + probe.screenshot(output / "tomba-restored.png") + check("native ownership bytes preserved", probe.ram(0x800bfab4, 256) == ownership) + probe.savestate("load", mario_slot) + probe.wait_frames(8) + observe("final Mario fixture") + report["passed"] = True + except Exception as error: + report["passed"] = False + report["error"] = str(error) + raise + finally: + try: + probe.call("clear_input") + if snapshot is not None: + probe.call("set_snapshot", slot=0, + addr=snapshot["addr"] if snapshot["active"] else "0x0") + report["counters"] = probe.counters() + except Exception as error: + report["cleanup_error"] = str(error) + finally: + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + return report + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=4597) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--tomba-slot", type=int, default=7) + parser.add_argument("--mario-slot", type=int, default=8) + parser.add_argument("--scratch-slot", type=int, default=9) + args = parser.parse_args() + result = run(Probe(args.port), args.output, args.tomba_slot, args.mario_slot, args.scratch_slot) + print(f"PASS: {len(result['checks'])} live smoke assertions; screenshots in {args.output}") diff --git a/tools/mario_suit_smoke.py b/tools/mario_suit_smoke.py new file mode 100644 index 0000000..8a42c70 --- /dev/null +++ b/tools/mario_suit_smoke.py @@ -0,0 +1,82 @@ +"""Native suit exclusivity fixture; acquisition is bypassed in private RAM only. + +Run against the isolated US debug runtime with Mario fixture slot 8. Native +inventory equip and transformation perform both swaps. Restores the original +fixture/ownership on completion; never writes a native memory-card save. +""" +import argparse +import json +from pathlib import Path +from mario_probe import Probe + + +def run(probe, item, output): + output.mkdir(parents=True, exist_ok=True) + report = {"item": item, "acquisition_bypassed": True, "checks": []} + def check(name, passed): + report["checks"].append({"name": name, "passed": bool(passed)}) + if not passed: + raise AssertionError(name) + def choose(flat): + probe.tap(0x1000, release=24) + state = probe.state() + check("fixture has three native equipment entries", state["menu_primary"] == 3) + # Fix only the granted item's sort position; the native builder has not + # run a pickup transaction for this cheat. Existing item order stays. + probe.write(0x800bfbb4+item, b"\2") + probe.tap(0x4000) + probe.tap(0x0040) + if flat%2: + probe.tap(0x0020) + selected = probe.ram(state["menu"], 48) + check("expected item selected before confirmation", selected[0x1f] == 1 and selected[0x1d] == flat%2) + probe.screenshot(output/"selected.png") + probe.tap(0x4000) + probe.tap(0x1000, release=25) + + probe.wait_game_ready() + probe.savestate("load", 8) + probe.wait_frames(8) + ownership = probe.ram(0x800bfab4, 256) + try: + check("starts as Mario", probe.state()["equipped"] == 1) + probe.write(0x800bfab4+item, b"\1") + choose(2) + probe.wait_frames(220) + report["native_suit"] = {"state": probe.state(), "player": probe.player()} + check("native suit restores Tomba", report["native_suit"]["state"]["equipped"] == 0 and + report["native_suit"]["state"]["requested"] == 0 and + report["native_suit"]["player"]["costume"] == item) + probe.screenshot(output/"native-suit.png") + choose(3) + probe.screenshot(output/"hat-confirmation.png") + probe.wait_frames(220) + report["hat"] = {"state": probe.state(), "player": probe.player()} + check("hat restores Mario from native suit", report["hat"]["state"]["equipped"] == 1 and + report["hat"]["state"]["requested"] == 1 and report["hat"]["player"]["costume"] == 0) + check("hat confirmation was consumed", report["hat"]["state"]["hat_toast"] == 0) + probe.screenshot(output/"mario-restored.png") + report["passed"] = True + except Exception as error: + report["passed"] = False + report["error"] = str(error) + raise + finally: + probe.call("clear_input") + probe.savestate("load", 8) + probe.wait_frames(8) + report["ownership_restored"] = probe.ram(0x800bfab4, 256) == ownership + (output/"report.json").write_text(json.dumps(report, indent=2)+"\n", encoding="utf-8") + return report + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--item", type=int, choices=range(14, 23), default=15) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, default=4597) + args = parser.parse_args() + report = run(Probe(args.port), args.item, args.output) + if not report["ownership_restored"]: + raise SystemExit("Fixture ownership was not restored") + print(f"PASS: {len(report['checks'])} native suit/hat assertions; acquisition bypassed") diff --git a/tools/mario_traversal_smoke.py b/tools/mario_traversal_smoke.py new file mode 100644 index 0000000..5f8896a --- /dev/null +++ b/tools/mario_traversal_smoke.py @@ -0,0 +1,139 @@ +"""Private US chest/post/ladder presentation fixtures; no RAM or progress writes. + +Requires chest perch slot1, native post hang slot4, house mid-ladder slot5. +These development-only snapshots bypass travel; they do not certify acquisition. +Never run against an owner-facing process or its memory cards. +""" +import argparse +import json +import struct +from pathlib import Path + +from mario_probe import Probe + +PLAYER = 0x800e7e80 + + +def run(probe, output): + output.mkdir(parents=True, exist_ok=True) + report = {"checks": [], "observations": [], "ram_writes": False, + "travel_bypassed_by_private_fixtures": True} + snapshots = probe.call("get_snapshots")["snapshots"] + + def check(name, condition): + report["checks"].append({"name": name, "passed": bool(condition)}) + if not condition: + raise AssertionError(name) + + def observe(label, screenshot=False): + actor, state = probe.player(), probe.state() + report["observations"].append({"label": label, "player": actor, "mario": state}) + check(label + " has live Mario", actor["mode"] == 1 and state["equipped"] == 1) + if screenshot: + probe.composed_screenshot(output / (label + ".png")) + return actor, state + + def restore(slot): + probe.savestate("load", slot) + # Let restored RAM reach the renderer before queuing a composed shot. + probe.wait_frames(8) + + def post_trace(label, route): + state_address = probe.state()["address"] + probe.call("set_snapshot", slot=2, addr=hex(PLAYER)) + probe.call("set_snapshot", slot=3, addr=hex(state_address)) + start = probe.call("history")["newest"] + 1 + probe.route(route) + end = probe.call("history")["newest"] + rows = [] + for frame in range(start, end + 1, 2): + actor = bytes.fromhex(probe.call("read_frame_ram", frame=frame, + addr=hex(PLAYER), len=128)["hex"]) + mario = bytes.fromhex(probe.call("read_frame_ram", frame=frame, + addr=hex(state_address), len=32)["hex"]) + words = struct.unpack("<8I", mario) + rows.append({"frame": frame, "action": actor[5], "phase": actor[6], + "y": struct.unpack_from(" 0) + report["passed"] = True + except BaseException as error: + report["error"] = repr(error) + raise + finally: + probe.call("clear_input") + for snapshot in snapshots: + probe.call("set_snapshot", slot=snapshot["slot"], + addr=snapshot["addr"] if snapshot["active"] else "0x0") + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n") + return report + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=4598) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + result = run(Probe(args.port, 75), args.output) + print({"passed": result["passed"], "checks": len(result["checks"])}) diff --git a/tools/mario_voice_smoke.py b/tools/mario_voice_smoke.py new file mode 100644 index 0000000..60766da --- /dev/null +++ b/tools/mario_voice_smoke.py @@ -0,0 +1,101 @@ +"""Verify an imported voice in the real post-SPU capture (private runtime/cache). + +Requires NumPy for correlation. Stimulates guest voice state directly, so this +checks audio integration, not the gameplay event that normally triggers it. +Native event selection and state transactions are checked by mario_smoke.py. +""" +import argparse +import hashlib +import json +from pathlib import Path +import struct +import wave +import numpy as np +from mario_probe import Probe + + +def cached_voice(path, voice=437): + cache = path.read_bytes() + if hashlib.sha256(cache).hexdigest() != "1cbe9ce74e63464da8582d14dea5f8ba94e30a8c0258f03fee80f9abb3ace588": + raise ValueError("Expected the verified private r2 cache") + count, = struct.unpack_from("= 0 else -((-a)//b) + cursor = fraction = 0 + samples = [] + while cursor < len(source): + a, b = int(source[cursor]), int(source[min(cursor+1, len(source)-1)]) + value = a + divide((b-a)*fraction, 44100) + fade = min(128, cursor, len(source)-1-cursor) + samples.append(divide(value*fade*3, 128*4)) + fraction += source_rate + cursor += fraction//44100 + fraction %= 44100 + return np.asarray(samples, dtype=np.float64) + + +def correlate(captured, expected): + # Eight phases avoid hiding a real voice because its first sample is not + # aligned to our decimation. Bound work to the short private capture. + expected = expected[::8] + expected -= expected.mean() + norm = np.linalg.norm(expected) + best = (-1.0, 0) + for phase in range(8): + signal = captured[phase::8].astype(np.float64) + size = len(expected) + if len(signal) < size: + continue + sums = np.r_[0., np.cumsum(signal)] + energy = np.r_[0., np.cumsum(signal*signal)] + variance = energy[size:]-energy[:-size] - (sums[size:]-sums[:-size])**2/size + values = np.correlate(signal, expected, "valid") / (norm*np.sqrt(np.maximum(variance, 1))) + peak = int(np.argmax(values)) + if values[peak] > best[0]: + best = float(values[peak]), peak*8+phase + return {"normalized_correlation": best[0], "voice_offset_samples": best[1]} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cache", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--port", type=int, default=4597) + args = parser.parse_args() + expected = expected_pcm(cached_voice(args.cache)) + args.output.mkdir(parents=True, exist_ok=True) + probe = Probe(args.port) + probe.wait_game_ready() + probe.savestate("load", 8) + probe.wait_frames(8) + address = probe.state()["address"] + probe.write(address+36, bytes(12)) + start = probe.call("audio_stats")["taps"][0]["frames"] + probe.write(address+36, struct.pack(" 0.55 and result["state_after"]["voice"] == 0 + (args.output/"report.json").write_text(json.dumps(result, indent=2)+"\n", encoding="utf-8") + print(json.dumps(result, indent=2)) + if not result["passed"]: + raise SystemExit(1) diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index e10be7a..5716d38 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -142,8 +142,7 @@ function Copy-TreeTo { throw "Copy-TreeTo: source directory not found: $Source" } if (Test-Path -LiteralPath $Destination) { - Assert-StagePath $Destination - Remove-Item -LiteralPath $Destination -Recurse -Force + Remove-StageTree $Destination } New-Dir (Split-Path -Parent $Destination) | Out-Null Copy-Item -LiteralPath $Source -Destination $Destination -Recurse -Force @@ -160,6 +159,29 @@ function Assert-StagePath { } } +function Remove-StageTree { + param([Parameter(Mandatory)][string]$Path) + Assert-StagePath $Path + $pending = [System.Collections.Generic.Stack[string]]::new() + $pending.Push([System.IO.Path]::GetFullPath($Path)) + while ($pending.Count) { + $directory = $pending.Pop() + $item = Get-Item -LiteralPath $directory -Force + if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + throw "Refusing stage cleanup through a reparse point: $directory" + } + foreach ($child in Get-ChildItem -LiteralPath $directory -Force) { + if ($child.Name -eq '.git' -or + ($child.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "Unexpected repository or reparse point in stage: $($child.FullName)" + } + if ($child.PSIsContainer) { $pending.Push($child.FullName) } + } + } + Remove-Item -LiteralPath $Path -Recurse -ErrorAction Stop + if (Test-Path -LiteralPath $Path) { throw "Stage cleanup incomplete: $Path" } +} + # cmake writes benign warnings to STDERR; under Stop, PS 5.1 promotes native # stderr to a terminating error. Gate on $LASTEXITCODE instead (house pattern). function Invoke-Native { @@ -250,7 +272,11 @@ if ($SkipRegen) { Invoke-Native { & $Cmake -S $Root -B $BuildPath -G Ninja -DCMAKE_BUILD_TYPE=Release ` "-DPSX_EXECUTION_PROFILE=$ExecutionProfile" ` + "-DPSX_GAME_VERSION=$Version" ` + -DPSX_RELEASE_CHANNEL=public ` + -DPSXRECOMP_BIOS_STEMS=OpenBIOS ` -DPSX_DEBUG_TOOLS=OFF ` + -DPSX_MOD_DEVELOPER_CHANNEL=OFF ` -DPSX_PGXP_VARIANT=OFF ` -DPSX_SETUP_WIZARD=OFF ` -DPSX_NETPLAY=OFF ` @@ -258,9 +284,8 @@ Invoke-Native { } "cmake configure" Invoke-Native { & $Cmake --build $BuildPath --target $RuntimeTarget -j $Jobs } "cmake build" -if (Test-Path -LiteralPath $StageRoot) { - Assert-StagePath $StageRoot - Remove-Item -LiteralPath $StageRoot -Recurse -Force +if (Test-Path -LiteralPath $Stage) { + Remove-StageTree $Stage } New-Dir $Stage | Out-Null New-Dir (Join-Path $Stage "saves") | Out-Null @@ -316,6 +341,11 @@ Write-Host "Bundled recomp-ui launcher assets: $fontCount font(s) + $imgCount im # Italian retains its separately localized catalog. Add-ModCatalog -BuildPath $BuildPath -Stage $Stage ` -RuntimeTarget $RuntimeTarget | Out-Null +foreach ($forbidden in @('psx.enhancement.cd-speed', 'psx.enhancement.fast-loading')) { + if (Test-Path -LiteralPath (Join-Path $Stage "mods/bundled/$forbidden")) { + throw "Prohibited loading mod in release catalog: $forbidden" + } +} # Player-facing game.toml: same effective runtime settings as the dev config, # minus dev-only sections (debug port, overlay autocompile command, [audit]). @@ -381,7 +411,7 @@ Write-Host "Verified no baked absolute BIOS path in the exe" # No user-machine or copyrighted files may ride along in the stage. OpenBIOS # and its license are intentionally bundled; retail BIOS images remain banned. -$strayPatterns = @("SCPH*.BIN","*.cue","*.iso","*.mcd","bios.cfg","disc.cfg", +$strayPatterns = @("SCPH*.BIN","*.cue","*.iso","*.mcd","*.mcr","*.z64","*.v64","*.n64","*.wav","*.cache","bios.cfg","disc.cfg", "settings.toml","keybinds.ini","overlay_captures.json") $stray = foreach ($pat in $strayPatterns) { Get-ChildItem $Stage -Recurse -File -Filter $pat -ErrorAction SilentlyContinue } if ($stray) { @@ -401,7 +431,7 @@ $PsxRecompSha = (& git -C (Join-Path $Root "psxrecomp-v4") rev-parse --short HEA $LoadingReleaseNote = if ($Variant -eq 'usa') { '- Seamless Loading defaults on; generic CD Speed/Fast Loading and the slow debug menu are excluded.' } else { - '- Italian retains its existing loading options; Seamless Loading is USA-only.' + '- Generic CD Speed/Fast Loading are excluded; Seamless Loading is USA-only.' } @" @@ -422,6 +452,8 @@ $LoadingReleaseNote - Interpreter/runtime-compilation fallback remains available for gaps. - Adaptive widescreen and 1080p default on; supported scene visibility and sea/cloud seams are repaired. - Original two-VBlank scene cadence is retained; interpolation is off and hidden. +- USA Mario's Hat is optional, EXPERIMENTAL version 0.0.1. It requires your verified Super Smash Bros. 64 US 1.0 ROM. +- Imported Nintendo assets and ROMs are never included in the package. - Existing memory cards and disc selection remain available. This package includes the MIT-licensed OpenBIOS from PCSX-Redux and its notice @@ -441,7 +473,7 @@ Known items in this release: # sorted entry names and one fixed timestamp (SOURCE_DATE_EPOCH, defaulting to # the git commit date) so a rebuild of the same sources is byte-identical and # the published SHA256 is meaningful. -if (Test-Path -LiteralPath $ZipPath) { Remove-Item -LiteralPath $ZipPath -Force } +if (Test-Path -LiteralPath $ZipPath) { Remove-Item -LiteralPath $ZipPath -ErrorAction Stop } if ($env:SOURCE_DATE_EPOCH) { $epoch = [int64]$env:SOURCE_DATE_EPOCH diff --git a/tools/preview_mario_assets.py b/tools/preview_mario_assets.py new file mode 100644 index 0000000..b11415b --- /dev/null +++ b/tools/preview_mario_assets.py @@ -0,0 +1,77 @@ +"""Software-render the native asset-test's JSON (development QA only).""" +import argparse +import json +import math +from pathlib import Path + +import numpy as np +from PIL import Image + + +def matrix(joint): + _, x, y, z, rx, ry, rz, sx, sy, sz = joint + cx, cy, cz = map(math.cos, (rx, ry, rz)) + ax, ay, az = map(math.sin, (rx, ry, rz)) + r = np.array([[cy*cz, ax*ay*cz-cx*az, cx*ay*cz+ax*az, x], + [cy*az, ax*ay*az+cx*cz, cx*ay*az-ax*cz, y], + [-ay, ax*cy, cx*cy, z], [0, 0, 0, 1]], dtype=float) + return r @ np.diag([sx, sy, sz, 1]) + + +def render(source, output): + data = json.loads(source.read_text()) + joints = [] + for j in data["joints"]: + local = matrix(j) + joints.append(local if j[0] < 0 else joints[j[0]] @ local) + canvas = np.full((640, 640, 3), (38, 43, 50), dtype=np.uint8) + depth = np.full((640, 640), -float("inf")) + angle = math.radians(-28) + view = np.array([[math.cos(angle), 0, math.sin(angle)], + [0, 1, 0], [-math.sin(angle), 0, math.cos(angle)]]) + for tid, *vertices in data["triangles"]: + xyz = np.array([(view @ (joints[v[0]] @ np.array([*v[1:4], 1]))[:3]) + for v in vertices]) + xy = xyz[:, :2] * [1.1, -1.1] + [320, 560] + x0, y0 = np.maximum(xy.min(axis=0).astype(int), 0) + x1, y1 = np.minimum(np.ceil(xy.max(axis=0)).astype(int), 639) + if x1 < x0 or y1 < y0: + continue + a, b, c = xy + denominator = (b[1]-c[1])*(a[0]-c[0])+(c[0]-b[0])*(a[1]-c[1]) + if abs(denominator) < 0.001: + continue + xx, yy = np.meshgrid(np.arange(x0, x1+1)+0.5, np.arange(y0, y1+1)+0.5) + u = ((b[1]-c[1])*(xx-c[0])+(c[0]-b[0])*(yy-c[1]))/denominator + v = ((c[1]-a[1])*(xx-c[0])+(a[0]-c[0])*(yy-c[1]))/denominator + w = 1-u-v + z = u*xyz[0, 2]+v*xyz[1, 2]+w*xyz[2, 2] + tw, th, pixels, mode_s, mode_t = data["textures"][tid] + uv = np.array([v[4:6] for v in vertices]) + tx = np.floor(u*uv[0, 0]+v*uv[1, 0]+w*uv[2, 0]).astype(int) + ty = np.floor(u*uv[0, 1]+v*uv[1, 1]+w*uv[2, 1]).astype(int) + def address(coord, size, mode): + if mode & 2: + return np.clip(coord, 0, size-1) + if mode & 1: + coord = coord % (size*2) + return np.where(coord >= size, size*2-1-coord, coord) + return coord % size + tx = address(tx, tw, mode_s) + ty = address(ty, th, mode_t) + tex = np.array(pixels, dtype=np.uint16).reshape(th, tw)[ty, tx] + rgb = np.stack([(tex & 31)*255//31, ((tex>>5)&31)*255//31, + ((tex>>10)&31)*255//31], axis=2).astype(np.uint8) + region = depth[y0:y1+1, x0:x1+1] + mask = (u >= 0) & (v >= 0) & (w >= 0) & (z > region) & (tex != 0) + region[mask] = z[mask] + canvas[y0:y1+1, x0:x1+1][mask] = rgb[mask] + Image.fromarray(canvas).save(output) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path) + parser.add_argument("output", type=Path) + args = parser.parse_args() + render(args.source, args.output) diff --git a/tools/verify_mario_release.py b/tools/verify_mario_release.py new file mode 100644 index 0000000..d936125 --- /dev/null +++ b/tools/verify_mario_release.py @@ -0,0 +1,25 @@ +"""Verify the shipped Mario contract and exclude private imported material.""" +from pathlib import Path +import sys +import tomllib + + +def verify(stage): + stage = Path(stage) + manifest = stage / "mods/bundled/tomba2.character.mario-hat/0.0.1/manifest.toml" + package = tomllib.loads(manifest.read_text(encoding="utf-8")) + assert package["version"] == "0.0.1" and package["channel"] == "experimental" + assert not package["feature"][0]["default_enabled"] + rom = next(r for r in package["resource"] if r["id"] == "smash-rom") + assert rom["required"] and rom["input_only"] and rom["format"] == "n64-rom" + assert rom["size"] == 16777216 + assert rom["sha256"] == "15592e79d3c5295cef4371d4992f0bd25bec2102fc29644c93e682f7ea99ef3d" + for forbidden in ("psx.enhancement.cd-speed", "psx.enhancement.fast-loading"): + assert not (stage / "mods/bundled" / forbidden).exists(), forbidden + for path in stage.rglob("*"): + assert path.suffix.lower() not in (".z64", ".v64", ".n64", ".wav", ".cache"), str(path) + print("Mario release contract: EXPERIMENTAL 0.0.1, default off, verified ROM required, no imported assets") + + +if __name__ == "__main__": + verify(sys.argv[1])