diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..36e7ca73 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# Agent entry point + +Owner instructions and the current task take precedence over historical notes. +For HLE and performance replacements read [docs/HLE_EXECUTION.md](docs/HLE_EXECUTION.md) +and `F:/Projects/recomp-template/HLE.md`. Select implementations at build time; +maintain LLE as a runnable reference. Earlier runtime-switching, exact-internal- +timing, universal-game-support and mandatory-shadow rules are superseded. + +Track substantive work in the central Beads database at +`F:/Software/beads/issues`. The enhancement campaign is `beads-eio.3.277`; +shared HLE work is `beads-wuhs`. Do not initialize a local issue database. + +Work solo unless the owner authorizes delegation for the task. For the approved +parity campaign, serialize title builds and game runs: two compile jobs per +build, one game instance, and preserve the owner's sessions and personal saves. diff --git a/CLAUDE.md b/CLAUDE.md index 374c4467..8d9dbfa2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,9 @@ recompile yet". If a BIOS function cannot be recompiled, the recompiler is wrong and must be fixed. The interpreter does not exist. Do not write one. -There is **no HLE BIOS layer** in v4. No `bios.c` with case branches +For the faithful reference, the BIOS remains recompiled code. The historical +no-HLE restriction below is superseded for deliberate replacements by the owner +HLE policy in this section. Historically there was **no HLE BIOS layer** in v4. No `bios.c` with case branches intercepting A0/B0/C0 vectors. No C reimplementations of `OpenEvent` or `StartCard` or `alloc_kernel_memory`. The BIOS IS the recompiled C output of `SCPH1001.BIN`. If a BIOS routine misbehaves, the answer is @@ -81,76 +83,19 @@ stubs. Hand-delivering an event because the chain handler isn't installed is a stub wearing a costume and is the worst kind because it hides the missing integration. -**AMENDMENT 2026-06-29 — LLE-first baseline; a faithful HLE _subsystem -replacement_ is permitted (the three prohibitions above are the DEFAULT, not an -absolute ban on all HLE).** LLE / the recompiled BIOS is the BASELINE and the -spirit — architect as much as possible that way. But a whole subsystem MAY be -swapped for a host-side HLE reimplementation when, and ONLY when, ALL of these -hold: (1) the LLE path has a genuine landmine there — non-determinism with no -hardware analog (e.g. the host coroutine/fiber cooperative-thread scheduler), or -profound performance loss — not mere inconvenience; (2) the replacement is -GENERAL (every game, keyed to the documented PSX kernel/hardware mechanism), -never per-game; (3) it operates on the REAL guest structures (TCB / EvCB / -queues in guest RAM) and reproduces the DOCUMENTED mechanism -(`docs/psx_bios_disasm.txt` / PSX-SPX), not a guess; (4) it is continuously -validated against the independent Beetle oracle. This is a deliberate SUBSYSTEM -REPLACEMENT on top of a proven LLE baseline — NEVER the starting point or sole -implementation (HLE-first leaves "half an ecosystem"). It does NOT relax the -no-stubs / no-faking rule: the forbidden "HLE BIOS shim that hand-delivers the -answer the BIOS would have produced" (above) stays forbidden, because it fakes -the result and no oracle checks it. Discriminator — "if my reimplementation is -wrong, what happens?": "the game misbehaves / a recompiler bug stays hidden" ⇒ -forbidden; "we diverge loudly from Beetle / fall back to the faithful path" ⇒ -permitted. **Faithfulness is the FOUNDATION, not the destination:** once the -faithful core is proven, the goals are accelerated load times (toward 0) and -enhancements (widescreen), where per-game shims/hacks become legitimate -(`docs/ENHANCEMENTS.md`). Per-game hacks remain forbidden during foundation work. - -**AMENDMENT 2026-07-02 — HLE is a standing, swappable TIER (the gbarecomp -model), not just a per-landmine carve-out.** This goes further than the -2026-06-29 amendment (which permits HLE only as a targeted subsystem -replacement at an LLE landmine). User-directed pivot: psxrecomp now carries a -first-class High-Level Emulation tier alongside LLE, modeled on -`F:/Projects/gbarecomp/gbarecomp` (`src/runtime/bios_hle.{h,cpp}`, commits -23a57ce + 168e313): - -1. **Two selectable backends. AMENDMENT 2026-07-06 — HLE is now the DEFAULT, but - this changes NOTHING institutional.** We still BUILD LLE: the recompiled BIOS - is the foundation we architect against, the reference implementation, and the - oracle — fully linked, load-bearing, selectable, and the thing every accuracy - check runs against. HLE is a QoL layer we lay ON TOP (instant boot-skip for - players); the faithful LLE core is proven, so defaulting the convenience on is - an enhancement-phase load-time win, not an architecture change. Mechanically - this only flips the framework runtime default `bios_hle` false→true - (`config_loader.h`); opt OUT per-game with `[runtime] bios_hle = false` or env - `PSX_BIOS_HLE=0`. A null-by-default hook intercepts BIOS service dispatch - before the recompiled BIOS runs; a startup banner names the active backend. - With HLE off the build is byte-identical to a build without the tier. Keep new - bring-up and all verification on LLE; HLE-default is the shipping convenience. -2. **LLE remains the reference implementation and the oracle.** It stays fully - linked, load-bearing, and selectable; every BIOS call the HLE layer does - not implement transparently falls through to the recompiled BIOS, so HLE is - never load-bearing beyond what it covers and never becomes the verification - oracle. -3. **HLE boot is THE boot-skip mechanism.** Skipping BIOS boot = synthesize - the exact post-boot kernel handoff state (kernel tables, vectors, EvCB/TCB, - per-mode state) and jump to the game entry; the recompiled BIOS stays - linked for exception/IRQ dispatch and call fallback. This deprecates the - previous fast-boot mechanism. LLE always plays the real boot. -4. **No-stubs still stands, unchanged.** Every HLE implementation must be a - real, validated implementation of the documented kernel mechanism - (`docs/psx_bios_disasm.txt` / PSX-SPX, Beetle-oracle-checked), operating on - the real guest structures — never a "return the answer the BIOS would have - produced" fake. The discriminator from the 2026-06-29 amendment applies to - every handler. -5. **The HLE layer is an observability surface.** It carries always-on ring - buffers (calls, routes, arguments, results) queryable via the TCP debug - server, per rule 3 and the global ring-buffer rule. - -If you find yourself wanting to violate any of the above three -paragraphs **beyond the two amendments just above**, -**stop and re-read docs/internal/PLAN.md**. Every prior attempt failed by -violating exactly these rules under pressure. +**Owner HLE policy, 2026-10-06.** The older HLE amendments are superseded by +`F:/Projects/recomp-template/HLE.md` and [the build integration](docs/HLE_EXECUTION.md). +Keep LLE buildable and runnable as the correctness reference. Qualified HLE is +the default performance implementation, selected at build time. Preserve actual +caller inputs, outputs, required memory/register effects, continuation and +observable completion ordering. Batching, native/GPU algorithms, private host +state, caching and approximate internal timing are allowed. Small practically +unnoticeable differences are already authorized. HLE need not support every game, +dual-execute, retain a live LLE state or provide a runtime switch. Compare exact +outputs exactly, intentional approximations within their declared contract, and +measure useful performance. Missing effects, false success and material gameplay +changes remain defects. Faithful-core accuracy requirements apply to the LLE +reference, not to unobserved HLE internals. --- diff --git a/cmake/psx_build_revision.cmake b/cmake/psx_build_revision.cmake new file mode 100644 index 00000000..efc10278 --- /dev/null +++ b/cmake/psx_build_revision.cmake @@ -0,0 +1,26 @@ +include_guard(GLOBAL) + +function(psxrecomp_build_revision output source_dir) + # A native CMake invocation can still inherit an MSYS Git first on PATH. + # Prefer the Windows executable for Windows paths, retaining an explicit + # PSX_BUILD_GIT override and the normal PATH search on other hosts. + if(CMAKE_HOST_WIN32) + find_program(PSX_BUILD_GIT NAMES git.exe + PATHS "$ENV{ProgramFiles}/Git/cmd" "$ENV{ProgramW6432}/Git/cmd" + "C:/Program Files/Git/cmd" + NO_DEFAULT_PATH) + endif() + find_program(PSX_BUILD_GIT NAMES git) + set(_revision "unknown") + if(PSX_BUILD_GIT) + execute_process( + COMMAND "${PSX_BUILD_GIT}" -C "${source_dir}" + describe --always --dirty --tags + OUTPUT_VARIABLE _observed OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE _result ERROR_QUIET TIMEOUT 20) + if("${_result}" STREQUAL "0" AND NOT "${_observed}" STREQUAL "") + set(_revision "${_observed}") + endif() + endif() + set(${output} "${_revision}" PARENT_SCOPE) +endfunction() diff --git a/cmake/psx_execution_profile.cmake b/cmake/psx_execution_profile.cmake new file mode 100644 index 00000000..402387bf --- /dev/null +++ b/cmake/psx_execution_profile.cmake @@ -0,0 +1,196 @@ +# Implementation selection is a developer build choice, never a runtime mod. +include_guard(GLOBAL) +set(PSX_EXECUTION_PROFILE "ENHANCED" CACHE STRING "Execution implementation: ENHANCED or REFERENCE") +set_property(CACHE PSX_EXECUTION_PROFILE PROPERTY STRINGS ENHANCED REFERENCE) +if(NOT PSX_EXECUTION_PROFILE MATCHES "^(ENHANCED|REFERENCE)$") + message(FATAL_ERROR "PSX_EXECUTION_PROFILE must be ENHANCED or REFERENCE") +endif() + +function(_psx_execution_stamp target) + get_target_property(_profile ${target} PSX_EXECUTION_PROFILE) + get_target_property(_families ${target} PSX_IMPLEMENTATION_FAMILIES) + if(NOT _families) + set(_families "") + endif() + list(SORT _families) + set(_identity "psx-execution-v1\n${_profile}\n") + get_target_property(_shared_files ${target} PSX_EXECUTION_CONTRACT_FILES) + set(_shared_identity "") + if(_shared_files) + foreach(_source IN LISTS _shared_files) + file(SHA256 "${_source}" _hash) + string(APPEND _shared_identity "${_hash}\n") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${_source}") + endforeach() + endif() + string(SHA256 _shared_hash "${_shared_identity}") + string(APPEND _identity "shared:${_shared_hash}\n") + set(_json "") + foreach(_name IN LISTS _families) + get_target_property(_contract ${target} "PSX_IMPL_${_name}_CONTRACT") + get_target_property(_sources ${target} "PSX_IMPL_${_name}_SOURCES") + set(_source_identity "") + foreach(_source IN LISTS _sources) + # Generated private bodies must exist before configure. Their bytes, + # not workstation paths, identify the implementation. + file(SHA256 "${_source}" _hash) + string(APPEND _source_identity "${_hash}\n") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${_source}") + endforeach() + string(SHA256 _body_hash "${_source_identity}") + string(APPEND _identity "${_name}:${_contract}:${_body_hash}\n") + if(_json) + string(APPEND _json ",\n") + endif() + string(APPEND _json " {\"name\":\"${_name}\",\"contract\":\"${_contract}\",\"source_sha256\":\"${_body_hash}\"}") + endforeach() + string(SHA256 _digest "${_identity}") + string(SUBSTRING "${_digest}" 0 8 _cookie) + if(_profile STREQUAL "ENHANCED") + set(_enhanced 1) + else() + set(_enhanced 0) + endif() + set(_dir "${CMAKE_CURRENT_BINARY_DIR}/psx_execution/${target}") + file(MAKE_DIRECTORY "${_dir}") + file(CONFIGURE OUTPUT "${_dir}/profile.h" CONTENT +"#pragma once +#define PSX_EXECUTION_ENHANCED @_enhanced@ +#define PSX_EXECUTION_NAME \"@_profile@\" +#define PSX_EXECUTION_ID \"@_digest@\" +#define PSX_EXECUTION_COOKIE 0x@_cookie@u +" @ONLY) + file(CONFIGURE OUTPUT "${_dir}/manifest.json" CONTENT +"{\n \"schema\":1,\n \"profile\":\"@_profile@\",\n \"identity\":\"@_digest@\",\n \"shared_contract_sha256\":\"@_shared_hash@\",\n \"implementations\":[\n@_json@\n ]\n}\n" @ONLY) + set_property(TARGET ${target} PROPERTY PSX_EXECUTION_MANIFEST "${_dir}/manifest.json") +endfunction() + +function(psxrecomp_execution_profile target) + cmake_parse_arguments(P "" "PROFILE" "CONTRACT_FILES" ${ARGN}) + if(P_PROFILE) + set(_profile "${P_PROFILE}") + else() + set(_profile "${PSX_EXECUTION_PROFILE}") + endif() + if(NOT _profile MATCHES "^(ENHANCED|REFERENCE)$") + message(FATAL_ERROR "Invalid execution profile for ${target}: ${_profile}") + endif() + get_target_property(_existing ${target} PSX_EXECUTION_PROFILE) + if(_existing) + if(NOT _existing STREQUAL _profile) + message(FATAL_ERROR "Execution profile already selected for ${target}") + endif() + return() + endif() + set_property(TARGET ${target} PROPERTY PSX_EXECUTION_PROFILE "${_profile}") + set(_inputs "") + foreach(_source IN LISTS P_CONTRACT_FILES) + get_filename_component(_absolute "${_source}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_SOURCE_DIR}") + if(NOT EXISTS "${_absolute}") + message(FATAL_ERROR "Shared execution input does not exist: ${_absolute}") + endif() + list(APPEND _inputs "${_absolute}") + endforeach() + set_property(TARGET ${target} PROPERTY PSX_EXECUTION_CONTRACT_FILES "${_inputs}") + target_compile_definitions(${target} PRIVATE + "PSX_EXECUTION_PROFILE_CONFIG=\"${CMAKE_CURRENT_BINARY_DIR}/psx_execution/${target}/profile.h\"") + _psx_execution_stamp(${target}) + add_custom_command(TARGET ${target} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_BINARY_DIR}/psx_execution/${target}/manifest.json" + "$/$.execution.json" + VERBATIM) +endfunction() + +# A materialized game shard can also be a configure/hash input. Compile an +# include wrapper so Ninja does not give the shard both a generation rule and +# CMake's input-file phony rule. Keep hashing the original body. +function(_psx_execution_compile_source target source output) + get_filename_component(_absolute "${source}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_SOURCE_DIR}") + get_source_file_property(_generated "${_absolute}" GENERATED) + if(NOT _generated) + set(${output} "${source}" PARENT_SCOPE) + return() + endif() + string(SHA256 _path_hash "${_absolute}") + get_filename_component(_ext "${_absolute}" LAST_EXT) + set(_wrapper "${CMAKE_CURRENT_BINARY_DIR}/psx_execution/${target}/sources/${_path_hash}${_ext}") + file(CONFIGURE OUTPUT "${_wrapper}" CONTENT "#include \"@_absolute@\"\n" @ONLY) + foreach(_property LANGUAGE COMPILE_DEFINITIONS COMPILE_OPTIONS COMPILE_FLAGS + INCLUDE_DIRECTORIES OBJECT_DEPENDS SKIP_PRECOMPILE_HEADERS + SKIP_UNITY_BUILD_INCLUSION) + get_source_file_property(_value "${_absolute}" "${_property}") + if(NOT _value STREQUAL "NOTFOUND") + set_source_files_properties("${_wrapper}" PROPERTIES "${_property}" "${_value}") + endif() + endforeach() + set(${output} "${_wrapper}" PARENT_SCOPE) +endfunction() + +# Both source lists implement the same caller contract. Exclude these files +# from common target sources; exactly one list is linked. CONTRACT_FILES names +# adapter headers/recipes whose changes also affect the implementation identity. +function(psxrecomp_add_implementation target) + cmake_parse_arguments(I "" "NAME;CONTRACT" "HLE_SOURCES;LLE_SOURCES;CONTRACT_FILES" ${ARGN}) + if(I_UNPARSED_ARGUMENTS OR NOT I_NAME MATCHES "^[a-z0-9][a-z0-9_.-]*$" OR + NOT I_CONTRACT MATCHES "^[A-Za-z0-9][A-Za-z0-9_.-]*$" OR + NOT I_HLE_SOURCES OR NOT I_LLE_SOURCES) + message(FATAL_ERROR "Implementation needs NAME, CONTRACT, HLE_SOURCES and LLE_SOURCES") + endif() + get_target_property(_profile ${target} PSX_EXECUTION_PROFILE) + if(NOT _profile) + psxrecomp_execution_profile(${target}) + get_target_property(_profile ${target} PSX_EXECUTION_PROFILE) + endif() + get_target_property(_families ${target} PSX_IMPLEMENTATION_FAMILIES) + if(I_NAME IN_LIST _families) + message(FATAL_ERROR "Duplicate implementation family ${I_NAME}") + endif() + if(_profile STREQUAL "ENHANCED") + set(_selected ${I_HLE_SOURCES}) + else() + set(_selected ${I_LLE_SOURCES}) + endif() + set(_inputs "") + foreach(_source IN LISTS _selected I_CONTRACT_FILES) + get_filename_component(_absolute "${_source}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_SOURCE_DIR}") + if(NOT EXISTS "${_absolute}") + message(FATAL_ERROR "Implementation input does not exist: ${_absolute}") + endif() + list(APPEND _inputs "${_absolute}") + endforeach() + # CONTRACT_FILES can include common generated game bodies already added by + # runtime.cmake, not only selected replacement bodies. Normalize those + # existing source entries before declaring their configure dependency. + get_target_property(_common_sources ${target} SOURCES) + set(_common_compile_sources "") + foreach(_source IN LISTS _common_sources) + if(_source MATCHES "^\\$<") + list(APPEND _common_compile_sources "${_source}") + continue() + endif() + get_filename_component(_absolute "${_source}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_SOURCE_DIR}") + if(_absolute IN_LIST _inputs) + _psx_execution_compile_source(${target} "${_absolute}" _compiled) + list(APPEND _common_compile_sources "${_compiled}") + else() + list(APPEND _common_compile_sources "${_source}") + endif() + endforeach() + set_property(TARGET ${target} PROPERTY SOURCES "${_common_compile_sources}") + set(_compile_sources "") + foreach(_source IN LISTS _selected) + _psx_execution_compile_source(${target} "${_source}" _compiled) + list(APPEND _compile_sources "${_compiled}") + endforeach() + target_sources(${target} PRIVATE ${_compile_sources}) + set_property(TARGET ${target} APPEND PROPERTY PSX_IMPLEMENTATION_FAMILIES "${I_NAME}") + set_property(TARGET ${target} PROPERTY "PSX_IMPL_${I_NAME}_CONTRACT" "${I_CONTRACT}") + set_property(TARGET ${target} PROPERTY "PSX_IMPL_${I_NAME}_SOURCES" "${_inputs}") + _psx_execution_stamp(${target}) + # The runtime helper creates this optional sibling before title code adds + # implementation families. Both must receive the selected replacement TU. + if(TARGET ${target}-pgxp) + psxrecomp_add_implementation(${target}-pgxp ${ARGN}) + endif() +endfunction() diff --git a/cmake/psx_sha256_file.cmake b/cmake/psx_sha256_file.cmake new file mode 100644 index 00000000..c109599c --- /dev/null +++ b/cmake/psx_sha256_file.cmake @@ -0,0 +1,19 @@ +include_guard(GLOBAL) + +# Host utility only: both guest execution profiles retain exactly the same +# file identity. No OpenSSL installation is required for a portable build. +function(psxrecomp_sha256_file target) + target_sources(${target} PRIVATE + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../runtime/src/psx_sha256_file.cpp") + target_include_directories(${target} PRIVATE + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../runtime/include") + if(WIN32) + target_link_libraries(${target} PRIVATE bcrypt) + else() + find_package(OpenSSL QUIET COMPONENTS Crypto) + if(TARGET OpenSSL::Crypto) + target_compile_definitions(${target} PRIVATE PSX_SHA256_FILE_OPENSSL=1) + target_link_libraries(${target} PRIVATE OpenSSL::Crypto) + endif() + endif() +endfunction() diff --git a/docs/ENHANCEMENTS.md b/docs/ENHANCEMENTS.md index 7a2a5d5f..25739a1c 100644 --- a/docs/ENHANCEMENTS.md +++ b/docs/ENHANCEMENTS.md @@ -1474,3 +1474,22 @@ cover negative depth, stale packets, identical partial writes, rollback and mode guards. Live captures remove the previously observed triangular holes; the owner confirmed the hallway walls stay intact. Broader map coverage remains separate from this reproduction. + +### Precision in expanded primitive buffers (2026-10-07) + +The draw-distance enhancements can move primitive storage to the allocated +GPU-DMA aperture (`0x80800000` CPU pointers, `0x00800000` packet keys). PGXP +now tracks those words separately from main RAM, with sparse stable pages +created only when a tracked store writes them. Reads refuse unallocated ranges, +invalid aliases and stale words. Position, depth and projection receipts retain +the aperture address; checkpoint journaling restores their old shadows without +copying whole arrays or aliasing the low main-RAM word. + +THPS2 exposed the gap: correction was enabled, but every recorded texture-depth +lookup missed the expanded primitive buffers and no textured triangle received +perspective correction. The focused packet-shadow fixture covers allocation +bounds, CPU/DMA aliases, separation from RAM, stale words, generation changes, +relocated receipts and rollback including a page created during a checkpoint. +The existing PGXP propagation fixture still passes. The rebuilt title now +records actual dataflow hits and perspective-corrected triangles. These are +coverage checks; broader visible texture/geometry quality remains owner-reviewed. diff --git a/docs/HLE_EXECUTION.md b/docs/HLE_EXECUTION.md new file mode 100644 index 00000000..af4cf434 --- /dev/null +++ b/docs/HLE_EXECUTION.md @@ -0,0 +1,116 @@ +# Build-selected performance implementations + +Use the [shared HLE policy](https://github.com/mstan/recomp-ai-rules/blob/main/HLE.md): +maintain a runnable LLE reference, replace expensive operations behind their real +caller interfaces, and measure the result. Small unnoticeable differences are +allowed. Internal instruction, scratch-state and cycle identity are not universal +HLE requirements. A normal release selects qualified HLE; it does not execute +both implementations or offer live implementation switching. + +## Selecting a build + +Configure separate output directories with `-DPSX_EXECUTION_PROFILE=ENHANCED` +(default) or `-DPSX_EXECUTION_PROFILE=REFERENCE`. Oracle runtime targets always +select REFERENCE. The setting selects only declared implementation families; +it does not turn arbitrary game functions into HLE or certify their performance. +Existing video mods and resident-loading preferences are independent product +features. Older BIOS runtime controls have not yet been migrated by this helper. + +Each runtime writes `.execution.json`. Startup reports its profile +and identity. Snapshot layout compatibility and the pre-frame network content +gate incorporate the execution identity, including no-mod LAN sessions. Do not +promise cross-profile savestate compatibility or netplay. Normal memory cards +remain game data. Keep generated outputs, overlay caches and packages in their +own build directories; do not overlay a candidate's runtime on another profile. + +After copying a candidate executable, package its contract with the shared +stager (use the build's sidecar as `--manifest` if the binary was renamed): + +```sh +python tools/release_stage.py stage-execution --binary path/to/staged/Game.exe --manifest path/to/build/Game.execution.json +``` + +This validates the contract digest, checks that the staged binary embeds that +identity, and records the binary SHA-256 beside it. A mismatched or missing +sidecar stops staging. Use the same command for the executable inside AppDir +before creating an AppImage. It verifies artifact consistency, not gameplay. + +## A title implementation + +After creating the runtime target, remove the replaced translation unit from +its common source list and register both implementations: + +```cmake +psxrecomp_add_implementation(psx-runtime + NAME packet-builder CONTRACT title-disc-abi-v1 + HLE_SOURCES "${PRIVATE_PACKET_SOURCE}" + LLE_SOURCES "${ORIGINAL_PACKET_SOURCE}" + CONTRACT_FILES "${PRIVATE_ADAPTER_HEADER}") +``` + +The helper links exactly one list. Supply only selected-build headers in +CONTRACT_FILES when the HLE source is private. Missing selected input, an invalid +profile or a duplicate family is an error. A reference build needs no private +HLE files. Source and contract-file hashes identify a family independently of +absolute paths; changed inputs trigger CMake reconfiguration. Include all helper +headers/recipes that affect the contract. Generated bodies remain local inputs. +Runtime targets also hash the shared GTE service contract. The optional PGXP +build sibling receives the same selected implementation families automatically. +Pre-materialized sources marked `GENERATED` compile through a generated include +wrapper. Their original files remain the single body and automatic hash input; +titles do not need a copied reference shard to avoid duplicate Ninja rules. + +The title record names verified disc/code identities, supported entry points, +arguments, required register/memory results, continuation, observable side +effects and completion ordering. Unsupported cases need defined behavior. A +title may retain an original continuation where useful; the framework does not +require dynamic fallback or representation conversion. + +## Shared geometry operation + +`psx_hle_gte_execute(cpu, command)` is for a native service that owns batch +timing. It retains the same GTE math, precision propagation and projection +capture/replay as `gte_execute`. ENHANCED omits individual command latency; +REFERENCE uses the ordinary timed entry. The service resolves pending work and +charges/publishes the operation's completion at its caller boundary. There is +no mutable timing mode to leak through a nested call or watchdog unwind. + +This is an integration primitive, not a standalone FPS improvement. Ape's +packet producer is its first intended consumer. The original `gte_execute` +always retains faithful timing in both profiles. + +## Title-owned loading + +Generic host-pacing load detection is deprecated for new game integrations. +The 15-title parity campaign excludes `psx.enhancement.fast-loading` and generic +CD-speed controls. A title-named wrapper around `psx_mod_set_load_acceleration` +is still the generic detector, not a verified title loading implementation. +Legacy framework APIs/packages remain for compatibility, default off. + +Reuse the shared resident store, decompression, transfer and execution-family +machinery; each title owns the verified disc identity, loader entry/caller, +resource boundaries, completion/status publication and pacing policy. Preserve +music startup, audio streaming, gameplay cadence, callbacks, saves and both +players where applicable. Compare the selected service's caller outcomes with +LLE; do not qualify a loader by skipping its polling loop or changing its +package label. Any shortened presentation wait needs a title-specific contract. + +Tomba 1/2, MMX6, V8 2nd Offense, MediEvil II and THPS2 have resident/loading families. +THPS2's stock CD.HED/CD.WAD adapter is owner-approved, default on; its original +loader remains selectable through the REFERENCE execution profile. +The other campaign titles still need verified title loading adapters. BIOS +shell skipping, AOT caching and geometry HLE are separate optimizations; their +presence does not imply a resident loader. Keep missing bindings explicit and +retain the slower LLE reference until each title is qualified. + +## Validation + +The profile fixture links both implementations of one ABI, checks output and +portable identity, verifies changed selected sources and headers invalidate the +identity, and builds REFERENCE without private HLE inputs. The GTE suites compare +all 22 command families with the reference, exercise projection observers and +prove that a normal GTE call after HLE still arms its own latency. + +Title promotion additionally needs isolated HLE/LLE caller-result comparisons, +measured useful gameplay throughput/load time, clean audio, and owner playtest +acceptance. Preserve the LLE floor even when it is slower than real time. diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index 945d2b9c..d7679aa0 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -24,8 +24,37 @@ appears when the host advertises `GameInfo.netplay_supported`. ## Rollback netcode Titles that opt in with `[netplay] content_negotiation = true` (game.toml, -default false) get content negotiation; every other title keeps vanilla -netplay (mods cleared), the MOTK1 LAN protocol and plan-free lobby caps. +default false) get content negotiation; titles without a trusted simulation +profile keep vanilla netplay (simulation mods cleared), the MOTK1 LAN protocol +and plan-free lobby caps. + +A statically linked title can register `PSXModNetplayProfile` from +`runtime/include/mod_netplay.h` for a shared simulation such as experimental +co-op. The profile forces its native simulation plugin and permitted fixed view +without importing or saving offline package choices. Its compatibility revision +pins lobby versioning and a SHA-256 profile identity joins the normal +execution/content handshake on every transport. Unsupported rollback requests +use delay-sync; unsupported user and synchronized save/load requests are refused. +An optional read-only callback contributes canonical host-owned gameplay state +to the existing core CRC; digest logs report this as `mod=`. The callback must +exclude pointers, padding, rendering caches and local-only input state. + +`requires_retained_texture_banks` opts a trusted profile into retained GL +textures during dual-raster netplay. It requires a working OpenGL backend; +software/Vulkan selections and an unavailable bank implementation are rejected +before guest boot. Software at 1x remains the authoritative VRAM/GPUREAD/CRC +source, independent of GL presentation resolution/filter preferences. Retained +bank pixels replace textures only on the GL image; the software authority still +samples ordinary VRAM for those commands. This policy follows the X6 co-op +renderer behavior and does not provide software retained-bank rendering or +prove visual equivalence to authoritative VRAM. Validate gameplay state and +GL presentation separately. The flag is part of the session content identity. + +For an offline mode whose host state is not serialized, a trusted plugin can +call `psx_mod_set_savestate_blocked(1)` and `psx_mod_set_rewind_blocked(1)` during +activation. These policies reset before each new session; save blocking also +cancels already staged requests and in-memory load blobs. Memory-card saves +and the user's persisted settings are separate and remain available. Online rooms can publish a host-selected mod plan: package versions, enabled features/options and a portable plan fingerprint. Every peer resolves and diff --git a/docs/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md new file mode 100644 index 00000000..49170720 --- /dev/null +++ b/docs/PARITY_CAMPAIGN.md @@ -0,0 +1,350 @@ +# PlayStation enhancement parity + +Owner-approved scope, 2026-10-06. Tracking: `beads-eio.3.277`; shared execution +work: `beads-wuhs`. Implementation is in progress. Source presence, a successful +build, a short gameplay check, and owner acceptance are distinct milestones. + +## First review pass + +Owner steering after the status check: prioritize runnable candidates and human +validation. Use build/configuration checks and focused tests for risky changes; +defer broad automated gameplay and performance matrices until owner feedback. +Windows review candidates come first in the build queue, with native Linux +artifacts following. Missing enhancements and known defects stay explicit. + +For each candidate, provide its launcher/path and this short playtest checklist: + +1. Launch, start/load, and play for a few minutes. +2. Check ordinary and ultrawide aspect settings: scene edges, HUD and dialogue. +3. Move the camera: look for jitter, seams, missing geometry and bad textures. +4. Pause/resume, change areas and revisit; note stalls and load times. +5. Check audio and existing multiplayer where applicable. + +The shared guarded HUD anchor now also supports projection-and-stretch mode. +Title code identifies completed screen-space packets; a common edge/centre +anchor can span separate DMA lists. Native-wide translation, packet guards, +expiry and native 4:3 behavior remain covered by focused execution fixtures. +This addresses the mechanism behind Ape's separate Status backing/text batches; +the current title follows padded packet chains and repeatedly anchors its backing +with the labels. Broader visual review remains open. Tracking: +`beads-eio.3.125` and `beads-uutk`. + +## Acceptance target + +- Enhanced OpenGL is the normal renderer, with 1080p internal resolution. +- Latest owner scope avoids interpolation for the remaining serial work. + Keep it disabled and focus on internal resolution, draw distance, actual + title-owned loading and geometry/perspective texture correction. Preserve + earlier approved MediEvil I/II optional interpolation, default off. +- PGXP, stable world texture filtering and perspective correction apply where + useful. Preserve pixel-art/UI treatment and fix seams, jitter and texel bleed. +- Adaptive view covers 4:3 through at least 32:9: world geometry, culling and + activation, backgrounds, textures and HUD layout. Wider displays may stretch. +- Extend distance/fog and replace unnecessary subdivision when applicable; + preserve visible content and required game behavior. +- Use resident assets, native decompression and bulk transfers where measured + loads justify them. Initial startup, transitions and repeat visits all count. +- Qualified HLE is selected in normal builds; a functioning LLE reference is + separately buildable. Compare required ABI/results/effects, and measure useful + speed. See [HLE_EXECUTION.md](HLE_EXECUTION.md). +- Preserve and validate existing multiplayer enhancements. Do not add + multiplayer to single-player titles as part of this campaign. +- Deliver reproducible Windows ZIP and Linux AppImage candidates for the + primary disc variant. Other variants are explicit follow-up work. +- Owner playtesting supplies final acceptance. No title is marked complete + merely because its settings expose these options. + +## Current Windows review readiness, 2026-10-07 + +All 15 titles have executable-bound local Windows review launchers in +`F:/Projects/psxrecomp/parity-review-20261006`. `REVIEW.txt` is the current owner +checklist; `receipts/review-readiness.json` records the exact binary identities, +media/BIOS path checks and staged frame-rate defaults. These milestones do not +establish full feature parity or final gameplay/performance acceptance. + +Latest owner policy: interpolation **defaults off across the campaign** after +Ape choppiness and THPS2 flickering in owner playtests. Ape is owner-approved +with its interpolation package absent from the shipped catalog. MediEvil I/II +received positive interpolation feedback and keep the option available, +default off. THPS2 exhausted its two allowed repair attempts: the first caused +skater jitter and the second caused severe scene flickering. Its interpolation +package and native plugin are now archived and absent from the current build. +Other packages remain opt-in pending title qualification. + +Generic host pacing is deprecated and excluded from all 15 catalogs, along with +generic CD Speed. The MMX4 and old hidden Tomba Fast Loading wrappers used the +same generic detector and are now retained only in development. Tomba 1/2, +MMX6, V8 2nd Offense, MediEvil II and THPS2 retain their title resident/loading families. +The other nine title loaders remain unfinished; geometry HLE, BIOS shell skipping +and AOT caching do not satisfy that requirement. See [HLE_EXECUTION.md](HLE_EXECUTION.md). + +All review configurations retain 1080p OpenGL and available view/distance/filter +improvements. A live THPS2 resolution query reported the 1080p preset, 5x effective +raster scale and 1200 internal lines from its native 240; no GPU clamp. This is +rendering resolution, not higher-resolution original textures. THPS2's initial +music playback is owner-approved after the shared GetlocP position fix; +its title resident asset loading is also owner-approved, default on. +THPS2 interpolation is disabled and hidden after both repair attempts failed; +no third attempt is authorized. The owner accepted the resulting non-interpolated +first-pass build, then raised a separate texture-correction concern. Its expanded +primitive buffers were outside the PGXP tracker, so enabled perspective correction +armed no triangles. Shared allocated-aperture tracking is now corrected; the +updated gameplay/owner check remains open. Avoid interpolation work on the +remaining titles. Work proceeds serially by title. `GAME-PATHS.txt` +lists the executable paths; earlier review preferences are backed up. + +The owner suspects background system contention/long uptime and will perform +final review after restarting. Further performance comparisons on this session +are deferred. Human-review launchers set `PSX_RENDER_PASS_VERIFY=0` and +`PSX_GL_PERF=0`, restoring their parent environment. Jersey already compiles GPU +diagnostics out. Synchronous GPU profiling is an additional possible source of +measurement overhead, not an established cause of the reported hitching. + +| Title | Current first-review milestone | Main remaining gaps | +|---|---|---| +| Tomba! | Actual actor/camera replay, 1080p, resident kit and recent Any% FMV work; bounded native state checks pass | Exact native candidate owner approval; combined 0.17.0-alpha minor release, broader view/load/package review | +| Tomba! 2 | USA actual native replay; short moving-scene state checks pass; resident loading retained | Full 32:9, stable filtering, broad view/load coverage; Italian variant later | +| MMX4 | Actual native scene replay; moving attract check passes; fresh historical BIOS capture | Broader view/overlay coverage, loading HLE | +| MMX5 | Actual native scene replay; moving attract check passes; original-disc AOT | Broader view, full 32:9, loading HLE | +| MMX6 | Native replay/resident candidate includes current widescreen PR543 / `458e6ece` | Current combined-build owner gameplay/load/audio review | +| Ape Escape | **Owner-approved with interpolation off and hidden**; reviewed binary unchanged, other enhancements retained | Final packages; independent GitHub #18 and expanded streaming/activation remain separately tracked | +| Vigilante 8 | Title `95fa269` native fix, framework `4b754210`; main-thread replay and allocated terrain packet support; Sand Factory correctness check passes | Occasional taxing-scene hiccups; earlier slot01 unlocated; load gain not established; partial AOT | +| V8 2nd Offense | Built native replay/resident candidate and fresh captures | Broader distance/subdivision/performance/load review | +| Tsumu | OpenGL/1080p, PGXP/filtering and actual native first-puzzle replay; localization retained | 4:3 only, loading HLE and broader coverage | +| THPS2 | Owner-approved music and default-on resident loading; 1080p and distance retained; interpolation rejected after two attempts and removed | Broader level/visibility and existing multiplayer review; interpolation archived | +| Jersey Devil | Built native replay/renderer candidate, 13 native pairs | Wider view/scene/load review; loading HLE absent | +| Spider-Man | Built native replay/PGXP candidate, fresh historical shards | Broad view/scene/load review; loading HLE absent | +| Spider-Man 2 | Native packed-vertex HLE and bounded rooftop replay; persistent local review candidate | Broader levels, load coverage and display-refresh cadence | +| MediEvil | Built native replay/PGXP/filtering/distance/subdivision candidate, static original-disc AOT | Resident-loading adapter and broad gameplay/view review | +| MediEvil II | Built native fidelity/resident PP20/level candidate, static original-disc AOT | Broad combined-build gameplay/view/load review | + +Shared `77f3a909` retains the shown generation plus two pending generations +under the existing total 256 MiB image budget. Ape's old single-pending path +accepted 340 phases but presented only 24 native images in a six-second sample; +the queue fix presented 491 native images / 502 swaps at about 59.4 guest Hz. +Those presentation counts include reused phases; they are not unique-frame FPS +or proof of 165 Hz delivery. Separate exact rollback checks and focused LLE/HLE +command/provenance fixtures pass. Owner cadence review remains necessary. + +V8's main-thread binding initially refused its extended terrain packets because +the shared OT parser only accepted main RAM. `4b754210` accepts complete allocated +GPU-DMA aperture ranges while retaining malformed-packet, allocation-end and +BIOS guards. The mixed RAM/aperture fixture and 13-pass live exact state check +pass. Separate short redraw/feature-off samples are diagnostic evidence only; +they do not locate or resolve the earlier owner slideshow checkpoint. + +Windows review readiness, final owner acceptance, missing features and native +Linux/final release packaging remain separate. Nothing has been published. + +## Historical starting inventory and remaining work + +This records implementation evidence found during the initial repository audit, +not full-game qualification. Framework baseline is `7b253941` (CODEGEN 18). + +| Title | Existing evidence / starting point | Work still to qualify | +|---|---|---| +| Tomba! | `3c8d36e`; custom native renderer, shared resident kit | Native interpolation replacing blending, broad resident coverage, full adaptive view and packages | +| Tomba! 2 | `5fc54c8`; shared seamless USA loading | Native interpolation replacing blending, view/residency coverage and packages; Italian variant later | +| Mega Man X4 | `51f5f44`; earlier widescreen/blending | Native interpolation, current renderer/filtering, load path and view coverage | +| Mega Man X5 | `c8fde08`; earlier widescreen/blending | Native interpolation, current renderer/filtering, load path and view coverage | +| Mega Man X6 | master `3b4b5dd`; native interpolation; resident/mod branch `3bf0205` | Integrate resident work with current framework; all applicable defaults, views and packages | +| Ape Escape | release `a8e219a`; native interpolation, private native packet producer | Shared build-selected HLE, gameplay interpolation qualification, startup/load service and packages | +| Vigilante 8 | fidelity/packaging checkpoint `3591161`; OpenBIOS Ski Resort gameplay, adaptive 4:3–32:9, PGXP, 1080p and stable filtering checked | Native interpolation, shader batching performance, load throughput, Linux package and broader gameplay; donor content excluded | +| Vigilante 8: 2nd Offense | `e1133d10`; native replay, PGXP, filtering, resident load-floor improvement (~307 to 45 frames in one case) | Backgrounds, subdivision/distance and remaining performance cases; full validation | +| Tsumu | `9fa07da`; English localization, older software/4:3 baseline | Applicable enhanced rendering, presentation, adaptive view and load audit | +| Tony Hawk's Pro Skater 2 | `6890a0da`; native replay, extended distance, existing multiplayer | Wider visibility and distance limits, single/split/network play checks, load audit | +| Jersey Devil | `a3958a3`; native replay across banks | Full visual/view/residency matrix and current candidate packages | +| Spider-Man | `66412b4`; native replay and PGXP | Full view/texture/load coverage and current candidate packages | +| Spider-Man 2 | `60c8e80`; PGXP, motion-adaptive image blending | Native interpolation under implementation, then view/load and package qualification | +| MediEvil | enhancement branch `84202de`; native replay, PGXP, filtering, 3x distance, subdivision bypass | Resident loading adapter, full view and gameplay coverage, packages | +| MediEvil II | master `6816fc0`; quality reference `32ea083`; 5x/1080p, resident PP20/levels | Preserve reference features and validate full game; one measured load improved 8.30 to 1.38 seconds | + +## Vigilante 8 content parking + +The owner abandoned cross-game assets. These local branches retain that work: + +| Branch | Preserved checkpoint | +|---|---| +| `archive/v8-v10-content-20261006` | `8a68555` | +| `archive/v8-n64-arena-20261006` | `ea3a006` | +| `archive/v8-renderer-and-media-20261006` | `8169c75` | + +The earlier sequel archive at `f3e4ab1` is also retained. The active +`feat/v8-fidelity-parity-20261006` branch removes donor importers and converter +tests. Fidelity, rendering performance and loading speed are its only focus. + +## Shared work and validation receipts + +The framework now has build-selected implementation families, implementation +manifests, snapshot/network execution compatibility, and a stateless native GTE +entry for bulk services. It is not a measured speedup by itself. Ape's packet +producer is the first integration consumer. + +The initial six targeted CTests pass: execution profile linking and source +identity, reference/enhanced GTE contracts, reference/enhanced snapshot handoff +and atomic rejection, and network content gating. Production main/GTE/snapshot +translation units compile, including production netplay with networking enabled. +Ape, Spider-Man 2 and Vigilante 8 Windows runtime candidates have linked; +useful title HLE performance comparisons are still required. + +Baseline framework tests also exposed three existing stale fixtures: +`gl_texture_filter_test`, `gpu_gp0_history_guard_test`, and +`savestate_status_protocol_test`. Their wiring is repaired and all three now +pass, including the hidden real OpenGL filtering probe. A complete suite run +against the new worktree remains outstanding. + +## Execution and promotion + +The original plan used bounded title workers; the current owner instructions +require solo work and no worker resumes or delegation. Shared changes remain +coordinator-owned. At most two compile jobs and one owned game process run at a time; +validation uses isolated saves and ports. Preserve personal sessions and saves. + +For each title record disc identity, framework/game revisions, actual applied +features, rejected cases, screenshots and gameplay paths, load/frame metrics, +build profile and manifest, package hashes, and owner feedback. Shared +mechanisms belong in psxrecomp; disc addresses and asset contracts belong in +the game. A missing or inapplicable feature needs an explicit reason. + +Candidate branches and local commits are authorized. Public source pushes, +merges and releases are not part of the current authorization. Beads stays open +through owner acceptance. The central Dolt remote currently fails to push due +to a missing remote data ref; local issue updates remain durable. + +Use `tools/qualify_render_passes.py` during a known playable scene, with replay +verification enabled. It records before/after counters, the supplied executable +hash and its execution manifest. Zero replay, image blending, disabled replay, +counter resets or verification failures cannot produce a passing sample. This +is one evidence window; visual coverage, load speed and owner acceptance remain +separate checks. Use `tools/load_probe.py` for passive load-window measurements. + +Run correctness and performance windows separately. `PSX_RENDER_PASS_VERIFY=1` +adds synchronous GPU readbacks plus state hashing. After recording correctness, +relaunch without it and use `--purpose performance`. The tool rejects a +performance sample with active verification. Spider-Man 2's initial verified +replay took 28-35 ms; verification off measured 19.531 ms, of which 19.176 ms +was guest draw. That evidence points to title geometry processing rather than +shared GPU checkpoint/capture as the remaining throughput bottleneck. + +The shared presenter now honors HOLD for native phase generations instead of +crossfading neighboring phase images. Phase selection tests and the real OpenGL +probe pass, including red/blue native images that must stay distinct +under HOLD while explicit blend mode still mixes them. Title requalification +on this correction remains required. + +The expanded GL probe now passes 509 checks. Shared commit `7c9e13ec` carries +nearest/bilinear/stable selection per primitive, preserving sharp untracked UI +while batching filtered world geometry. Across all three texture depths, native +and 5x rendering and native-wide margins match the original filter-boundary +submission exactly. Its opaque fixture goes from eight batches to one; mask +checking stays at eight and semitransparency at nine. Vigilante 8's measured +filter-transition pressure motivated this work; game speed is still being +compared on an isolated shader-only framework checkout. + +The campaign framework includes these shared checkpoints: + +- `8fe08921`: pre-materialized generated reference bodies build through include + wrappers, avoiding duplicate Ninja rules without maintaining copied LLE code. + The profile fixture checks both profiles, source-specific flags, local + includes, automatic changed-body identity and relocation. +- `88730937`: render replay refuses SIO/CD/MDEC/SPU MMIO reads before their + uncheckpointed side effects and suppresses I_STAT's SIO tick. The real + transaction fixture verifies rollback/recovery; production memory, replay + and debug translation units compile. This is separate from correcting Ape's + unsuitable broad draw boundary, which reached a real CD command/poll loop. +- `04f871f0`: generation- and address-guarded packet precision capture/restore, + including derived coordinate flags, projection and absent shadows. The new + fixture and existing PGXP suite pass. Ape's smaller draw span uses this to + preserve recorded prefix/tail packets only when packet ownership and link + layout remain compatible; that title candidate still needs gameplay checks. + +Vigilante 8's earlier Windows candidate was staged twice byte-identically: +`Vigilante8PSXRecomp-qualified-candidate-windows-x64.zip`, SHA-256 +`060d8ef2195ca43162564870675b6b15ef2e49147d758e7cebf228cb62b08558`. +It excludes retail disc/BIOS and player saves, retains audited native AOT pairs, +and passes execution identity/catalog/ZIP checks. It predates the shader +batching comparison and is not final parity acceptance. No Linux candidate or +measured V8 load-speed gain is claimed. + +The shader-only V8 comparison did eliminate filter-change splits and reduced +CPU flush time, but it did not establish a GPU/FPS improvement. Even the first +window's similar primitive counts hid different textured fractions; later +windows and camera images diverged. Host precision/mod-renderer state around +the saved checkpoint needs investigation. The earlier b0/d8 title candidate +remains qualified, with the 7c executable and observations retained separately. +The subsequent fixed-work renderer probe establishes the batching mechanism's +benefit, but does not replace a comparable title performance sample. + +The probe compiled the exact b0 and 7c renderer sources at O3 and interleaved +baseline/candidate/candidate/baseline runs on an RTX 3080 Ti. Each scenario +draws the same 800 textured triangles at 5x with 32:9 margins, with 32 warm-up +and 64 timed frames. GPU queries exclude shader compilation; canonical and +wide output hashes match in every run. No game or compiler was active; the +receipt also records background host activity. + +| Fixed workload | Baseline GPU ms | Batched GPU ms | Batches before / after | +|---|---:|---:|---:| +| All tracked stable filtering | 0.491272 | 0.505192 | 1 / 1 | +| Stable world plus 20% untracked nearest | 10.165968 | 0.423384 | 320 / 1 | +| Alternating nearest, bilinear and stable | 24.188984 | 0.359664 | 800 / 1 | +| All stable with mask checking | 0.895328 | 0.929272 | 1 / 1 | + +These synthetic medians show large savings when filter changes split otherwise +compatible work, with small costs for homogeneous work. They do not establish +V8 FPS or negate its unmatched gameplay samples. Reproduction inputs and +receipts are private under `_build-parity-render-perf-20261006/`: +`prepare_filter_benchmark.py`, `run_filter_benchmark.py`, and +`filter-fixed-work/{comparison,host-inventory}.json`. + +Packet precision now also has an explicit relocation operation, +`pgxp_restore_relocated_word_shadow`. It retains checkpoint-entry generation +and packed-value validation, preserves projection/derived flags, journals the +destination, and leaves the source unchanged. An absent receipt clears the +destination's shadow. The same-address API stays strict. The caller must own +and validate both packet ranges and the relocation mapping, write the guest +word and supply its current value; this service never reads device memory. +Focused relocation/rollback cases and the existing PGXP suite pass (2/2). +Ape needs this because real clipping changes core packet counts: its captured +tail has 25 packets and two ordering-table head insertions. Relocation does +not by itself qualify that game's interpolation. + +Shared regular-file identity hashing now uses Windows BCrypt or available +Linux OpenSSL, with the portable SHA-256 implementation as fallback. Failed +native initialization, update or finalization restarts the entire file; +I/O failure leaves the caller's digest untouched. The existing C SHA context +and CHD decoded-sector fingerprint path are unchanged. This host utility +preserves exact identity in both execution profiles; it is not a guest HLE +selection or a shortcut that skips content verification. + +The Windows native/portable suites, mod runtime, resident-loading and PGXP +session CTests pass (5/5). Linux OpenSSL and portable builds pass 68 and 67 +checks respectively, including Unicode paths, SHA block/read-chunk boundaries, +known vectors, partially consumed native failures and unreadable inputs. +An isolated interleaved six-sample Windows measurement on V8's 257,200,608-byte +data track matches Python's SHA-256 in every sample: portable median 0.656533 s, +native median 0.130985 s (5.012x for hashing). Receipts are private under +`_build-parity-render-perf-20261006/{validate_hash,linux-hash-validation,benchmark_hash}.json`. +This saves about half a second for that operation, not the entire multi-second +launch delay observed on the busy host. Overall startup still needs attribution. + +## Historical title preparation milestones + +Tomba! source preparation is local commit `a46ca8ab` on +`feat/tomba1-parity-hle-20261006`, retaining the newer resident kit at +`3c8d36e`. It adds ENHANCED/REFERENCE resident selection, bounded queue +telemetry, OpenGL/1080p defaults and executable-bound staging. Metadata and +six decoder tests pass. The existing presentation still blends images; +native interpolation, current-build loader contracts, view coverage and +both platform packages remain outstanding. Its `docs/PARITY_HLE.md` records +the actual loader/draw candidates and capture route. + +Mega Man X6 source preparation is local commit `5c991f60` on +`campaign/mmx6-parity-hle-20261006`, retaining resident/native-rendering +base `3bf0205`. It adds ENHANCED/REFERENCE resident selection, 1080p and +display-refresh interpolation defaults, and final-byte package binding. +Three configuration tests and script syntax checks pass. Its archives are +uncompressed; the native work accelerates loading rather than decompression. +Historical native interpolation and adaptive 32:9 evidence were separate: +combined current-build coverage, actual caller results, audio/transitions, +fresh AOT and Windows/Linux artifacts still need qualification. See the +title's `docs/PARITY_QUALIFICATION.md` and `BLOCKING_LOADER_CONTRACT.md`. diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 3e687be4..74bbacf1 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -207,12 +207,13 @@ While `fn` runs (`g_psx_render_pass_active`): | Area | Behaviour | Where | |---|---|---| -| Guest clock | cycles are counted (GTE and mult/div deadlines work) but no device is serviced, no VBlank or device event fires | `psx_cycles.c` freeze (`psx_cycle_freeze.h`, runtime-only: the codegen-hashed `psx_cycles.h` is untouched) | +| Guest clock | cycles are counted (GTE and mult/div deadlines work) but no device is serviced, no VBlank or device event fires; render freezes restore the carried guest-cycle-scale fraction and gates as well as clock/deferred charges | `psx_cycles.c` freeze (`psx_cycle_freeze.h`, runtime-only: the codegen-hashed `psx_cycles.h` is untouched) | | Interrupts | never delivered | `interrupts.c` | | GPU DMA | linked lists and delayed completions finish synchronously | `dma.c` | | RAM / scratchpad stores | written directly, bypassing code-page tracking, overlay watch, write traces and fingerprints; RAM addresses fold through the live geometry (2 MiB mirrored, or 8 MiB with the 8 MB RAM mod), as outside a pass | `memory.c` `render_pass_store` | | Mod arena stores (Expansion-1 mod memory, GPU-DMA aperture) | written; each page is backed up on its first write in the pass and put back at the end, so a title may keep its primitive buffers there (extended draw distance) | `memory.c` `render_pass_mod_store` | | MMIO stores | allowed: GP0, GP1 DMA mode / info, GPU and OTC DMA channels, DPCR/DICR, I_STAT/I_MASK. Dropped and counted: SPU (key-ons), CD, timers, SIO, MDEC, other DMA channels, memory control | `memory.c` | +| MMIO reads | SIO, CD, MDEC and SPU reads abort the pass before consuming device state. I_STAT reads do not tick SIO. Checkpointed GPU, timer, DMA and interrupt reads remain available | `memory.c`, `render_pass.c` | | VRAM | only the declared rect; writes that bypass the scissor elsewhere (fills, copies, uploads, pokes: never a native-wide surface) are journaled and rolled back | `gpu_gl_renderer.c` | | Runaway code | an 8 M guest-cycle watchdog rolls the pass back | `render_pass.c` | @@ -238,7 +239,20 @@ unit depth, shard stack and cycle-flush hook, the DMA execution depth, and the interpreter's active/phase/precise flags, resume latch and pending load (`render_pass_abort_test`). A plugin must likewise not keep state that only its callback's normal return resets. -After 8 faults (watchdog or refused writes) passes stay off for the session. +For a verified draw subset with recorded static prefix/tail packets, +`pgxp_capture_word_shadow` and `pgxp_restore_word_shadow` preserve exact packet +precision, including CPU-derived coordinate flags and homogeneous projection. +The caller supplies the actual current RAM/scratchpad word. Restore requires +the same canonical address, matching word and checkpoint-entry generation; +it journals both live shadows and recorded absence. These ephemeral host +receipts belong to one timeline and must not be persisted in saves. Packet +layout/ownership guards remain the title's responsibility. + +After 8 faults (watchdog, refused VRAM writes or uncheckpointed device reads) +passes stay off for the session. `render_pass_stats` exposes `device_reads`, +`last_device_read` and `last_device_read_width`; these refusals also count as +`aborted`. A draw boundary that reaches a device service needs correction; +the sandbox does not manufacture its completion or return value. ## Netplay local view diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 0235c9e9..05c20cb9 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -35,6 +35,11 @@ args of the form `key=value` become JSON fields (ints when numeric, else strings), so every server command is reachable, e.g. `debug_client.py --port 4370 gpu_frame_dump frame=14528 count=65536`. +`host_osd_shot path=` captures the current host text bitmap +(status/toast and diagnostic input rows), including in hidden OpenGL runs. +It does not need a visible window or alter guest VRAM. An empty overlay returns +an error. The image is the text layer, rather than the full game presentation. + --- ## Command inventory diff --git a/docs/ci/BUNDLED_RELEASES.md b/docs/ci/BUNDLED_RELEASES.md index 7b09ae27..2ce0b22e 100644 --- a/docs/ci/BUNDLED_RELEASES.md +++ b/docs/ci/BUNDLED_RELEASES.md @@ -48,7 +48,8 @@ Template: [`templates/game-release.yml`](templates/game-release.yml). third-party notices (psxrecomp's in `licenses/`, plus recomp-ui's license as `licenses/recomp-ui-LICENSE` and its font/image notices as `assets/{fonts,img}/NOTICE.md`); refuse if anything kit-shaped is in the - stage; sign on Windows; zip. + stage; sign on Windows; bind the execution contract to the final executable + bytes; zip. 6. Verify the zip: executable + OpenBIOS + catalog + recomp-ui license and font/image notices present; no `psxrecomp/`, `recomp-ui/`, CLI, emitters at the root, sources, generated C, dumps, @@ -74,6 +75,51 @@ A developer packaging locally with a cache can ship it: routes it through `tools/release_stage.py`, the only place that knows the cache tag and layout. +## Execution-bound staging and Linux AppImage + +`package_game_release.sh --stage-only` performs the same payload/catalog/cache +gates and execution binding as ZIP packaging, then stops before compression. It +preserves an existing ZIP. The payload is `dist/stage-game-`; staging +still replaces that stage directory, so use a separate artifact label for each +candidate/profile. Missing or mismatched build `.execution.json` sidecars fail +packaging. Windows binding runs after signing and records the final binary SHA256. + +On native Linux x86_64, wrap that validated Linux payload without a compiler or +AOT invocation: + +```bash +bash "$fw/tools/package_game_release.sh" --root "$title" --build-dir "$build" \ + --artifact linux-x64 --zip-prefix "$package" --exe-name "$exe" \ + --display-name "$name" --recompiler-build "$emitter_build" \ + --overlay-cache-root "$audited_linux_cache" --stage-only +SOURCE_DATE_EPOCH="$(git -C "$title" show -s --format=%ct HEAD)" \ + bash "$fw/tools/package_appimage.sh" \ + --payload "$title/dist/stage-game-linux-x64" --exe-name "$exe" \ + --payload-name "$payload_name" --app-run "$rendered_app_run" \ + --desktop-file "$desktop" --icon "$png_icon" --output "$appimage" --jobs 2 +``` + +AppRun and desktop metadata are fully rendered title inputs. AppRun must use +`usr/bin/` and `usr/share/` and preserve user settings/cards +under a writable data directory. The wrapper creates AppDir on the native Linux +filesystem, gives SDL access to its real-ELF assets, rejects Windows binaries, +and binds the ELF after linuxdeploy may rewrite it. A prior payload hash cannot +substitute for that final-byte receipt. Output must be a fresh path; AppDir is +retained for inspection. This wrapper does not qualify gameplay or the artifact. + +The wrapper uses versioned upstream URLs and the following official GitHub asset +digests, checked 2026-10-07 UTC with small metadata requests only: + +| Tool release | Asset provenance | SHA256 | +|---|---|---| +| linuxdeploy `1-alpha-20251107-1` | [Asset 313839329](https://api.github.com/repos/linuxdeploy/linuxdeploy/releases/assets/313839329) | `c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d` | +| appimagetool `1.9.1` | [Asset 324406736](https://api.github.com/repos/AppImage/appimagetool/releases/assets/324406736) | `ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0` | + +These release records are not marked immutable. Every tool fetch/cache use still +checks the pinned SHA256; changed bytes fail verification. The old continuous +pins are not reused merely because a cache exists. Real tool execution and a +native Linux artifact test remain necessary before claiming an AppImage. + ## Faster host/UI bumps `workflow_dispatch` input **`reuse_cached_emitters`** (default `true`): diff --git a/mods/builtin/packages/psx.enhancement.fast-loading/1.0.0/manifest.toml b/mods/builtin/packages/psx.enhancement.fast-loading/1.0.0/manifest.toml index 821be0ac..8ea8b7d8 100644 --- a/mods/builtin/packages/psx.enhancement.fast-loading/1.0.0/manifest.toml +++ b/mods/builtin/packages/psx.enhancement.fast-loading/1.0.0/manifest.toml @@ -3,19 +3,19 @@ id = "psx.enhancement.fast-loading" version = "1.0.0" name = "Fast Loading (host pacing)" author = "psxrecomp" -description = "Speeds up the wall-clock pacing of loads without changing anything the game can observe. Every guest frame, CD interrupt and callback still happens on schedule." +description = "Deprecated generic loading detector retained for compatibility. New game integrations use title-owned resident loading or pacing at verified loading boundaries." resolver = "declarative" save_compatibility = "shared" -# Loading speed is a property of the emulated hardware, not of any particular -# disc, so this ships with the framework and applies to every game. +# Compatibility-only: the heuristic is not a verified per-game loading policy. +# Campaign titles exclude this package and own their loading implementations. [[target]] game_id = "*" [[feature]] id = "fast-loading" name = "Fast Loading (host pacing)" -description = "Disabled by default. Host pacing is the safe accelerator: it only changes how fast real time is fed to a load, so the guest cannot desync. It does speed the game up while a load is detected, which some speedrun routes rely on NOT happening -- if that is you, leave this off and use CD Speed instead." +description = "Deprecated and disabled by default. This compatibility feature accelerates host pacing when a generic heuristic detects loading. It does not implement a game-specific loading contract." group = "Quality of Life" default_enabled = false diff --git a/overlay_toolchain/include/mod_netplay.h b/overlay_toolchain/include/mod_netplay.h new file mode 100644 index 00000000..b079a882 --- /dev/null +++ b/overlay_toolchain/include/mod_netplay.h @@ -0,0 +1,57 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Executable-owned netplay policy for a trusted simulation plugin. It never + * imports the player's offline package selection. The descriptor and strings + * must live for the process lifetime. Change compatibility_id whenever the + * plugin's shared simulation/state contract changes (maximum 63 bytes). + * + * state_digest is optional, read-only and emulation-thread only. Hash a + * canonical encoding of deterministic host-owned gameplay state; exclude + * pointers, struct padding, rendering caches and local-only input state. It + * joins the normal netplay core digest while this profile's plan is active. */ +typedef uint32_t (*PSXModNetplayStateDigest)(void); +typedef struct PSXModNetplayProfile { + const char *plugin_id; + const char *compatibility_id; + int rollback_supported; + int savestates_supported; + int skip_bios_intro; /* Fixed boot policy: skip animation, kernel calls LLE. */ + unsigned fixed_aspect_mask; /* bit 0=4:3, 1=16:9, 2=21:9; zero=no policy */ + const char *widescreen_plugin_id; /* Optional forced renderer for wide. */ + PSXModNetplayStateDigest state_digest; + /* Require GL retained texture banks. Netplay retains software @1x VRAM + * authority, while GL may use immutable banks for presentation. Software + * and unsupported GL launches are refused before guest boot. */ + int requires_retained_texture_banks; +} PSXModNetplayProfile; + +int psx_mod_register_netplay_profile(const PSXModNetplayProfile *profile); +const PSXModNetplayProfile *psx_mod_netplay_profile(void); +const char *psx_mod_netplay_version(const char *vanilla_version); +int psx_mod_netplay_rollback_supported(void); +int psx_mod_netplay_savestates_supported(void); +void psx_mod_netplay_set_active(int active); +int psx_mod_netplay_is_active(void); +uint32_t psx_mod_netplay_session_id(uint32_t session_id); +/* Extend the modern execution/content handshake with a canonical SHA-256 + * profile identity. This rejects mismatches even if the 32-bit transport + * namespace happens to collide. Input must be empty or a 64-digit hex SHA. */ +int psx_mod_netplay_content_identity(const char *execution_content, char out[65]); +int psx_mod_netplay_set_aspect(int index); +int psx_mod_netplay_aspect(void); +int psx_mod_netplay_has_state_digest(void); +uint32_t psx_mod_netplay_state_digest(void); +int psx_mod_netplay_requires_texture_banks(void); +/* Runtime renderer index: 0=software, 1=OpenGL, 2=Vulkan. No profile leaves + * existing renderer policy unchanged; call only for a netplay launch. */ +int psx_mod_netplay_renderer_allowed(int renderer); + +#ifdef __cplusplus +} +#endif diff --git a/overlay_toolchain/include/mod_plugins.h b/overlay_toolchain/include/mod_plugins.h index 6f0f8cb6..3c3422f4 100644 --- a/overlay_toolchain/include/mod_plugins.h +++ b/overlay_toolchain/include/mod_plugins.h @@ -2,6 +2,15 @@ #include +/* Session-only title policy; see runtime/include/mod_plugins.h. */ +#ifdef __cplusplus +extern "C" { +#endif +void psx_mod_set_savestate_blocked(int blocked); +#ifdef __cplusplus +} +#endif + #ifdef __cplusplus extern "C" { #endif diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index ae3974f5..5e84519f 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -23,6 +23,38 @@ psxrecomp_add_runtime_target(psx-runtime # (docs/ENHANCEMENTS.md G1.10). Titles get their _pgxp the same way. if(BUILD_TESTING) + add_executable(mod_netplay_test tests/test_mod_netplay.c + src/mod_netplay.c src/netplay_state_digest.c src/crc32.c src/psx_sha256.c) + target_include_directories(mod_netplay_test PRIVATE include) + add_test(NAME mod_netplay_test COMMAND mod_netplay_test) + add_test(NAME mod_netplay_wide_test COMMAND mod_netplay_test --wide) + add_test(NAME mod_netplay_optional_bank_test COMMAND mod_netplay_test --no-banks) + add_test(NAME mod_netplay_options_policy_test COMMAND mod_netplay_test --options) + add_executable(savestate_title_policy_test tests/test_savestate_title_policy.c + src/mod_netplay.c src/psx_sha256.c) + target_include_directories(savestate_title_policy_test PRIVATE include) + add_test(NAME savestate_title_policy_test COMMAND savestate_title_policy_test) + add_executable(mod_capcom_bg2d_test tests/test_mod_capcom_bg2d.c) + target_include_directories(mod_capcom_bg2d_test PRIVATE include) + add_test(NAME mod_capcom_bg2d_test COMMAND mod_capcom_bg2d_test) + # White-box failure injection checks restart after partial native hashing. + foreach(_hash_variant native portable) + add_executable(sha256_file_${_hash_variant}_test + tests/test_psx_sha256_file.cpp src/psx_sha256.c) + target_include_directories(sha256_file_${_hash_variant}_test PRIVATE include) + if(_hash_variant STREQUAL "portable") + target_compile_definitions(sha256_file_${_hash_variant}_test PRIVATE + PSX_SHA256_FILE_PORTABLE=1) + elseif(WIN32) + target_link_libraries(sha256_file_${_hash_variant}_test PRIVATE bcrypt) + elseif(TARGET OpenSSL::Crypto) + target_compile_definitions(sha256_file_${_hash_variant}_test PRIVATE + PSX_SHA256_FILE_OPENSSL=1) + target_link_libraries(sha256_file_${_hash_variant}_test PRIVATE OpenSSL::Crypto) + endif() + add_test(NAME sha256_file_${_hash_variant}_test + COMMAND sha256_file_${_hash_variant}_test) + endforeach() find_package(Threads REQUIRED) add_executable(host_launch_timing_test tests/test_host_launch_timing.cpp tests/host_launch_timing_helper.cpp) @@ -138,6 +170,15 @@ if(BUILD_TESTING) target_link_libraries(boot_state_impl_tag_test PRIVATE ZLIB::ZLIB) add_test(NAME boot_state_impl_tag_test COMMAND boot_state_impl_tag_test) + add_executable(boot_state_execution_test tests/test_boot_state_handoff.c) + target_include_directories(boot_state_execution_test PRIVATE include src) + target_compile_definitions(boot_state_execution_test PRIVATE PSX_COSIM=1) + target_compile_options(boot_state_execution_test PRIVATE -O2 -flto -ffunction-sections -fdata-sections -UNDEBUG) + target_link_options(boot_state_execution_test PRIVATE -flto -Wl,--gc-sections) + target_link_libraries(boot_state_execution_test PRIVATE ZLIB::ZLIB) + psxrecomp_execution_profile(boot_state_execution_test PROFILE ENHANCED) + add_test(NAME boot_state_execution_test COMMAND boot_state_execution_test) + add_executable(static_match_cache_test tests/test_static_match_cache.c src/overlay_loader.c src/crc32.c) target_include_directories(static_match_cache_test PRIVATE include) @@ -175,6 +216,14 @@ if(BUILD_TESTING) --cmake "${CMAKE_COMMAND}" --compiler "${CMAKE_C_COMPILER}" --generator "${CMAKE_GENERATOR}" --work-root "${CMAKE_CURRENT_BINARY_DIR}/hle-binding-fixtures") + find_program(PSX_PROFILE_TEST_NINJA NAMES ninja ninja.exe) + if(PSX_PROFILE_TEST_NINJA) + add_test(NAME execution_profile_test COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_execution_profile.py" + --cmake "${CMAKE_COMMAND}" --cc "${CMAKE_C_COMPILER}" + --ninja "${PSX_PROFILE_TEST_NINJA}") + set_tests_properties(execution_profile_test PROPERTIES RUN_SERIAL TRUE) + endif() add_test(NAME host_launch_timings_protocol_test COMMAND ${CMAKE_COMMAND} -E env "CC=${CMAKE_C_COMPILER}" "CXX=${CMAKE_CXX_COMPILER}" "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_host_launch_timings_protocol.py") @@ -572,6 +621,16 @@ if(BUILD_TESTING) target_link_libraries(host_keymap_test PRIVATE ${PSX_SDL_LIBRARIES}) add_test(NAME host_keymap_test COMMAND host_keymap_test) + add_executable(host_osd_diagnostics_test + tests/test_host_osd_diagnostics.c src/host_osd.c) + target_include_directories(host_osd_diagnostics_test PRIVATE + include ${PSX_SDL_INCLUDE_DIRS}) + target_compile_definitions(host_osd_diagnostics_test PRIVATE + SDL_MAIN_HANDLED RECOMP_LAUNCHER=1 PSX_SDL_NO_RENDER=1 + $<$:PSX_SDL3=1>) + target_link_libraries(host_osd_diagnostics_test PRIVATE ${PSX_SDL_LIBRARIES}) + add_test(NAME host_osd_diagnostics_test COMMAND host_osd_diagnostics_test) + add_executable(keyboard_pad_chord_test tests/test_keyboard_pad_chord.c src/psx_keybinds.c src/host_keymap.c) @@ -709,6 +768,14 @@ if(BUILD_TESTING) PSX_GTE_REGISTER_TEST=1 PSX_ENABLE_BLOCK_CYCLES=1) add_test(NAME gte_register_access_test COMMAND gte_register_access_test) + # Same ABI/result corpus for the native operation entry and the reference. + add_executable(gte_hle_contract_test + tests/test_gte_register_access.cpp src/gte.cpp src/pgxp.cpp src/psx_ram_geometry.c) + target_include_directories(gte_hle_contract_test PRIVATE include) + target_compile_definitions(gte_hle_contract_test PRIVATE + PSX_NO_DEBUG_TOOLS=1 PSX_GTE_REGISTER_TEST=1 PSX_ENABLE_BLOCK_CYCLES=1) + psxrecomp_execution_profile(gte_hle_contract_test PROFILE ENHANCED) + add_test(NAME gte_hle_contract_test COMMAND gte_hle_contract_test) # PGXP value-propagation engine (docs/ENHANCEMENTS.md G1.2/G1.3): shadow # load/store/COP2 roundtrips, half-word semantics, validate-on-read @@ -721,6 +788,11 @@ if(BUILD_TESTING) target_include_directories(pgxp_test PRIVATE include) add_test(NAME pgxp_test COMMAND pgxp_test) + add_executable(pgxp_word_shadow_test tests/test_pgxp_word_shadow.cpp + src/pgxp.cpp src/psx_ram_geometry.c) + target_include_directories(pgxp_word_shadow_test PRIVATE include) + add_test(NAME pgxp_word_shadow_test COMMAND pgxp_word_shadow_test) + # psx.enhancement.pgxp at session start (docs/ENHANCEMENTS.md G1.11): the # real builtin activation (mod API stubbed) and the pgxp_session.h # resolver main.cpp's renderer setup uses, in main.cpp's order. Pins that @@ -750,6 +822,8 @@ if(BUILD_TESTING) target_compile_definitions(${_pgxp_session_test} PRIVATE PSX_BUILTIN_PGXP_MANIFEST="${CMAKE_CURRENT_SOURCE_DIR}/../mods/builtin/packages/psx.enhancement.pgxp/1.0.0/manifest.toml") target_link_libraries(${_pgxp_session_test} PRIVATE chdr-static) + target_sources(${_pgxp_session_test} PRIVATE src/mod_netplay.c) + psxrecomp_sha256_file(${_pgxp_session_test}) if(WIN32) if(MSVC) target_link_options(${_pgxp_session_test} PRIVATE /STACK:16777216) @@ -763,6 +837,11 @@ if(BUILD_TESTING) endif() endforeach() + foreach(_pgxp_isolated_test + gte_register_access_test gte_hle_contract_test pgxp_test pgxp_session_test) + target_sources(${_pgxp_isolated_test} PRIVATE tests/pgxp_test_mod_memory.cpp) + endforeach() + add_executable(psx_instr_cost_test tests/test_psx_instr_cost.cpp) target_include_directories(psx_instr_cost_test PRIVATE include) add_test(NAME psx_instr_cost_test COMMAND psx_instr_cost_test) @@ -1106,6 +1185,7 @@ if(BUILD_TESTING) tests/test_mod_runtime.cpp src/iso_reader_c.cpp src/mod_runtime.cpp + src/mod_netplay.c src/mod_packages.cpp src/mod_media.cpp src/disc_path.cpp @@ -1118,8 +1198,8 @@ if(BUILD_TESTING) ../recompiler/include ../recompiler/lib/toml11) target_link_libraries(mod_runtime_test PRIVATE chdr-static) - # The real disc hasher uses a 1 MiB scratch buffer. Match the runtime's - # stack allowance instead of overflowing MinGW's 1 MiB executable default. + psxrecomp_sha256_file(mod_runtime_test) + # Match the runtime's stack allowance for the remaining disc/mod helpers. if(WIN32) if(MSVC) target_link_options(mod_runtime_test PRIVATE /STACK:16777216) @@ -1128,11 +1208,30 @@ if(BUILD_TESTING) endif() endif() add_test(NAME mod_runtime_test COMMAND mod_runtime_test) + add_test(NAME mod_netplay_missing_plugin_test COMMAND mod_runtime_test --missing-profile) + add_test(NAME mod_netplay_missing_renderer_test COMMAND mod_runtime_test --missing-renderer) + add_test(NAME mod_netplay_profile_options_test COMMAND mod_runtime_test --profile-options) + add_test(NAME mod_netplay_option_renderer_missing_test COMMAND mod_runtime_test --option-renderer-missing) + add_executable(neversoft_helpers_test tests/test_neversoft_helpers.cpp) + target_include_directories(neversoft_helpers_test PRIVATE include) + target_compile_features(neversoft_helpers_test PRIVATE cxx_std_17) + add_test(NAME neversoft_helpers_test COMMAND neversoft_helpers_test) + + add_executable(aligned_archive_test tests/test_aligned_archive.cpp) + target_include_directories(aligned_archive_test PRIVATE include) + target_compile_features(aligned_archive_test PRIVATE cxx_std_17) + add_test(NAME aligned_archive_test COMMAND aligned_archive_test) + + add_executable(resident_sector_test tests/test_resident_sector.cpp) + target_include_directories(resident_sector_test PRIVATE include) + target_compile_features(resident_sector_test PRIVATE cxx_std_17) + add_test(NAME resident_sector_test COMMAND resident_sector_test) add_executable(netplay_view_mods_test tests/test_netplay_view_mods.cpp src/iso_reader_c.cpp src/mod_runtime.cpp + src/mod_netplay.c src/mod_packages.cpp src/mod_media.cpp src/disc_path.cpp @@ -1145,6 +1244,7 @@ if(BUILD_TESTING) ../recompiler/include ../recompiler/lib/toml11) target_link_libraries(netplay_view_mods_test PRIVATE chdr-static) + psxrecomp_sha256_file(netplay_view_mods_test) if(WIN32) if(MSVC) target_link_options(netplay_view_mods_test PRIVATE /STACK:16777216) @@ -1160,6 +1260,7 @@ if(BUILD_TESTING) target_compile_definitions(mod_runtime_cache_test PRIVATE PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE=1) target_link_libraries(mod_runtime_cache_test PRIVATE chdr-static) + psxrecomp_sha256_file(mod_runtime_cache_test) if(WIN32) if(MSVC) target_link_options(mod_runtime_cache_test PRIVATE /STACK:16777216) @@ -1173,6 +1274,7 @@ if(BUILD_TESTING) tests/test_mod_resident.cpp src/mod_resident.cpp src/mod_runtime.cpp + src/mod_netplay.c src/mod_packages.cpp src/mod_media.cpp src/disc_path.cpp @@ -1185,6 +1287,7 @@ if(BUILD_TESTING) ../recompiler/include ../recompiler/lib/toml11) target_link_libraries(mod_resident_test PRIVATE chdr-static) + psxrecomp_sha256_file(mod_resident_test) if(WIN32) if(MSVC) target_link_options(mod_resident_test PRIVATE /STACK:16777216) @@ -1422,6 +1525,20 @@ if(BUILD_TESTING) endif() add_test(NAME render_pass_sandbox_test COMMAND render_pass_sandbox_test) + add_executable(render_pass_ot_test tests/test_render_pass_ot.cpp) + target_include_directories(render_pass_ot_test PRIVATE include) + target_compile_features(render_pass_ot_test PRIVATE cxx_std_17) + add_test(NAME render_pass_ot_test COMMAND render_pass_ot_test) + + foreach(_submit_profile ENHANCED REFERENCE) + string(TOLOWER "${_submit_profile}" _submit_suffix) + set(_submit_test "render_pass_gpu_submit_${_submit_suffix}_test") + add_executable(${_submit_test} ../tests/test_render_pass_gpu_submit.cpp src/dma_gpu_ll.c) + target_include_directories(${_submit_test} PRIVATE include) + psxrecomp_execution_profile(${_submit_test} PROFILE ${_submit_profile}) + add_test(NAME ${_submit_test} COMMAND ${_submit_test}) + endforeach() + add_executable(gpu_primitive_reject_test tests/test_gpu_primitive_reject.c) target_include_directories(gpu_primitive_reject_test PRIVATE include) add_test(NAME gpu_primitive_reject_test COMMAND gpu_primitive_reject_test) @@ -1504,7 +1621,8 @@ if(BUILD_TESTING) # guard define and include roots the runtime build uses; the two WS # calls that TU makes are stubbed in the test and abort if reached. if(EXISTS "${PSXRECOMP_ROOT}/runtime/src/lobby_ws/rnet_ws.c") - add_executable(lobby_chat_ring_test tests/test_lobby_chat_ring.c) + add_executable(lobby_chat_ring_test tests/test_lobby_chat_ring.c + src/mod_netplay.c src/psx_sha256.c) target_include_directories(lobby_chat_ring_test PRIVATE include src @@ -1524,6 +1642,8 @@ if(BUILD_TESTING) add_test(NAME lobby_chat_ring_test COMMAND lobby_chat_ring_test) add_executable(netplay_bios_wire_test tests/test_netplay_bios_wire.c + src/mod_netplay.c + src/psx_sha256.c src/netplay_bios_settle.c src/lobby_ws/rnet_ws.c src/lobby_ws/rnet_sha1.c) @@ -1609,6 +1729,9 @@ if(BUILD_TESTING) add_executable(spu_guest_clock_test tests/test_spu_guest_clock.c) target_include_directories(spu_guest_clock_test PRIVATE include) add_test(NAME spu_guest_clock_test COMMAND spu_guest_clock_test) + add_executable(spu_sample_banks_test tests/test_spu_sample_banks.c) + target_include_directories(spu_sample_banks_test PRIVATE include) + add_test(NAME spu_sample_banks_test COMMAND spu_sample_banks_test) add_executable(spu_gaussian_test tests/test_spu_gaussian.c) target_include_directories(spu_gaussian_test PRIVATE include) @@ -1729,6 +1852,21 @@ if(BUILD_TESTING) add_test(NAME release_stage_cache_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_release_stage_cache.py) + find_program(PSX_PACKAGE_TEST_BASH NAMES bash + HINTS "C:/Program Files/Git/bin") + add_test(NAME package_execution_stage_test + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_package_execution_stage.py + --bash "${PSX_PACKAGE_TEST_BASH}") + if(NOT PSX_PACKAGE_TEST_BASH) + set_tests_properties(package_execution_stage_test PROPERTIES DISABLED TRUE) + endif() + add_test(NAME package_appimage_test + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_package_appimage.py) + if(NOT CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") + set_tests_properties(package_appimage_test PROPERTIES DISABLED TRUE) + endif() # Overlay shards and BIOS modules are compiled for the RUNTIME's # architecture: an x86_64 macOS runtime under Rosetta used to get arm64 # shards (arm64-only Xcode clang, no -arch) in its macos-x64 cache and diff --git a/runtime/include/boot_state.h b/runtime/include/boot_state.h index d47e6e4d..bb17e407 100644 --- a/runtime/include/boot_state.h +++ b/runtime/include/boot_state.h @@ -73,7 +73,7 @@ typedef struct { uint32_t codegen_ver; /* codegen version XOR build PSX_SAVESTATE_IMPL_TAG */ /* ---- layout ---- */ uint32_t section_count; /* number of sections that follow */ - uint32_t reserved; /* v8: enhancement-memory layout cookie; vanilla 0 */ + uint32_t reserved; /* enhancement-memory, RAM and execution cookie */ } BootStateHeader; #define BOOT_STATE_HEADER_WIRE_BYTES 36u diff --git a/runtime/include/execution_identity.h b/runtime/include/execution_identity.h new file mode 100644 index 00000000..5f1539bb --- /dev/null +++ b/runtime/include/execution_identity.h @@ -0,0 +1,43 @@ +#ifndef PSX_EXECUTION_IDENTITY_H +#define PSX_EXECUTION_IDENTITY_H +#include "execution_profile.h" +#include "psx_sha256.h" +#include + +/* Bind every network session, including a no-mod LAN session, to its fixed + * execution implementation. Empty profile IDs retain legacy fixture behavior. + * Validate before hashing so malformed content cannot become a valid digest. */ +static inline int psx_execution_content_identity(const char* content, char out[65]) { + const char* input = content ? content : ""; + char normalized[65]; + size_t n = strlen(input); + if (n && n != 64) return 0; + for (size_t i = 0; i < n; ++i) { + char c = input[i]; + if (c >= 'A' && c <= 'F') c += 'a' - 'A'; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) return 0; + normalized[i] = c; + } + normalized[n] = 0; + input = normalized; + if (!PSX_EXECUTION_ID[0]) { + memcpy(out, input, n + 1); + return 1; + } + psx_sha256_ctx ctx; + uint8_t bytes[32]; + const char salt[] = "psx-session-execution-v1:"; + const char hex[] = "0123456789abcdef"; + psx_sha256_init(&ctx); + psx_sha256_update(&ctx, (const uint8_t*)salt, sizeof(salt) - 1); + psx_sha256_update(&ctx, (const uint8_t*)PSX_EXECUTION_ID, strlen(PSX_EXECUTION_ID)); + psx_sha256_update(&ctx, (const uint8_t*)input, n); + psx_sha256_final(&ctx, bytes); + for (unsigned i = 0; i < 32; ++i) { + out[2*i] = hex[bytes[i] >> 4]; + out[2*i+1] = hex[bytes[i] & 15]; + } + out[64] = 0; + return 1; +} +#endif diff --git a/runtime/include/execution_profile.h b/runtime/include/execution_profile.h new file mode 100644 index 00000000..1ec8b4bc --- /dev/null +++ b/runtime/include/execution_profile.h @@ -0,0 +1,14 @@ +#ifndef PSX_EXECUTION_PROFILE_H +#define PSX_EXECUTION_PROFILE_H + +#ifdef PSX_EXECUTION_PROFILE_CONFIG +#include PSX_EXECUTION_PROFILE_CONFIG +#else +/* Standalone faithful fixtures and older non-CMake consumers. */ +#define PSX_EXECUTION_ENHANCED 0 +#define PSX_EXECUTION_NAME "REFERENCE" +#define PSX_EXECUTION_ID "" +#define PSX_EXECUTION_COOKIE 0u +#endif + +#endif diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index d16a3280..b2821fcd 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -88,6 +88,9 @@ void gpu_get_crtc_debug(uint32_t *x1, uint32_t *x2, uint32_t *y1, uint32_t *y2, uint32_t *hres1_out, uint32_t *hres2_out); uint64_t gpu_get_gp0_count(void); /* Total GP0 writes since init */ void gpu_get_gp0_stats(uint64_t* nop, uint64_t* fill, uint64_t* draw, uint64_t* env, uint64_t* copy); +/* Fixed command words; -1 denotes a terminated polyline, 0 an unknown opcode. + * Transfer payloads follow the returned header length. */ +int gpu_gp0_command_word_count(uint8_t opcode); typedef struct { uint32_t left, top, right, bottom; @@ -475,7 +478,8 @@ void gpu_ws_set_nw_hud_corners(int on); void gpu_ws_tag_hud_primitive(uint32_t primitive, int edge); void gpu_ws_tag_world_primitive(uint32_t primitive, int is_world); void gpu_ws_set_adaptive_backdrop_preload(int enabled); -/* Explicit native-wide HUD packet anchor from a trusted title plugin. +/* Explicit screen-space HUD packet anchor from a trusted title plugin. + * Native-wide translates; projection-and-stretch scales about the anchor. * `prim` is the address of the PsyQ P_TAG word; the drawn command starts at * prim+4. anchor: -1 = left, 0 = center, +1 = right. */ void gpu_ws_tag_hud_prim(uint32_t prim, int anchor); diff --git a/runtime/include/hle_gte.h b/runtime/include/hle_gte.h new file mode 100644 index 00000000..3bdfd1dd --- /dev/null +++ b/runtime/include/hle_gte.h @@ -0,0 +1,17 @@ +#ifndef PSX_HLE_GTE_H +#define PSX_HLE_GTE_H +#include +#ifdef __cplusplus +extern "C" { +#endif +struct CPUState; +/* For a build-selected native service which owns operation-level timing. + * Retains GTE outputs, PGXP and projection capture/replay. ENHANCED omits + * per-command latency; REFERENCE uses the ordinary timed entry. The caller + * resolves earlier pending work and publishes its batch completion itself. + * No mutable mode, scope nesting, or state survives this call. */ +void psx_hle_gte_execute(struct CPUState* cpu, uint32_t command); +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime/include/host_osd.h b/runtime/include/host_osd.h index e4e6f307..d4a184e3 100644 --- a/runtime/include/host_osd.h +++ b/runtime/include/host_osd.h @@ -21,6 +21,9 @@ void host_osd_push(const char *msg, int duration_ms); /* Persistent top-left status text. Passing NULL/empty clears it. */ void host_osd_set_status(const char *msg); +/* Independent diagnostic rows; NULL/empty clears a row. Host presentation + * only, retaining runtime toasts/status and leaving guest VRAM untouched. */ +void host_osd_set_diagnostics(const char *first, const char *second); /* Right-side vertical volume bar (percent clamped 0..100). */ void host_osd_show_volume(int percent, int duration_ms); diff --git a/runtime/include/launcher_device.h b/runtime/include/launcher_device.h index 5d57a793..03988f7b 100644 --- a/runtime/include/launcher_device.h +++ b/runtime/include/launcher_device.h @@ -8,6 +8,11 @@ namespace PSXRecompV4 { +inline bool launcher_controller_can_share(bool developer_build, + bool exact_guid_match) { + return developer_build && exact_guid_match; +} + inline std::string trim_launcher_device(const std::string& device) { const auto first = std::find_if_not(device.begin(), device.end(), [](unsigned char c) { return std::isspace(c) != 0; }); diff --git a/runtime/include/mod_aligned_archive.hpp b/runtime/include/mod_aligned_archive.hpp new file mode 100644 index 00000000..214e1ab7 --- /dev/null +++ b/runtime/include/mod_aligned_archive.hpp @@ -0,0 +1,55 @@ +#pragma once +#include +#include +#include + +// Version/count followed by {stored size, aligned size}; no title addresses. +// Header and every member must exactly cover the supplied archive. The header +// is itself a legal request; following requests must span whole members. +class PSXAlignedArchiveIndex { + struct Span { uint32_t begin, end; }; + std::vector spans; + static uint32_t word(const uint8_t* p) { + return uint32_t(p[0]) | uint32_t(p[1]) << 8 | + uint32_t(p[2]) << 16 | uint32_t(p[3]) << 24; + } +public: + bool assign(const uint8_t* data, uint32_t size, + uint32_t alignment = 2048, uint32_t version = 1) { + spans.clear(); + if (!data || alignment < 16 || size < alignment || + word(data) != version) return false; + const uint32_t count = word(data + 4); + if (!count || count > (alignment - 8) / 8) return false; + for (uint32_t i = 8 + count * 8; i < alignment; ++i) + if (data[i]) return false; + std::vector parsed{{0, alignment}}; + uint64_t cursor = alignment; + for (uint32_t i = 0; i < count; ++i) { + const uint32_t stored = word(data + 8 + i * 8); + const uint32_t padded = word(data + 12 + i * 8); + const uint64_t expected = (uint64_t(stored) + alignment - 1) / + alignment * alignment; + if (!stored || padded != expected || cursor + padded > size) + return false; + parsed.push_back({uint32_t(cursor), uint32_t(cursor + padded)}); + cursor += padded; + } + if (cursor != size) return false; + spans.swap(parsed); + return true; + } + bool matches(uint32_t offset, uint32_t bytes) const { + const uint64_t end = uint64_t(offset) + bytes; + if (!bytes || end > UINT32_MAX) return false; + for (size_t i = 0; i < spans.size(); ++i) { + if (spans[i].begin != offset) continue; + for (; i < spans.size(); ++i) { + if (spans[i].end == end) return true; + if (spans[i].end > end) return false; + } + break; + } + return false; + } +}; diff --git a/runtime/include/mod_capcom_bg2d.h b/runtime/include/mod_capcom_bg2d.h new file mode 100644 index 00000000..0c4fea98 --- /dev/null +++ b/runtime/include/mod_capcom_bg2d.h @@ -0,0 +1,153 @@ +#ifndef PSX_MOD_CAPCOM_BG2D_H +#define PSX_MOD_CAPCOM_BG2D_H + +#include "mod_plugins.h" +#include "gpu.h" +#include "ws_view_anchor.h" +#include + +/* Capcom's 16px tile engine: resolve the authored map, never a wrapping + * guest tile ring. The title supplies addresses, palette layout and policies. + * Host packets use the modern GPU view metadata and snapshot-owned DMA RAM. + * No guest camera, producer loop, ring, or native packet budget is changed. */ +typedef struct PSXCapcomTileMap { + uint32_t map, metatiles, descriptors; + unsigned width, height, layer_stride, layer, left_screen, right_screen; +} PSXCapcomTileMap; + +typedef struct PSXCapcomBackground { + uint32_t heads, tails; + unsigned layers, buckets, clut_base; +} PSXCapcomBackground; + +#define PSX_CAPCOM_BG_LAYER_BYTES (1024u * 1024u) + +static inline int psx_capcom_ram_range(uint32_t p, uint32_t n) { + p &= 0x1fffffffu; + return p >= 0x10000u && p < 0x200000u && n <= 0x200000u - p; +} + +static inline uint16_t psx_capcom_map_tile(const PSXCapcomTileMap *m, int x, int y) { + if (x < 0 || y < 0 || !m->width || !m->height || + !m->layer_stride || m->width > m->layer_stride / m->height) return 0; + unsigned col = (unsigned)x / 256u, row = (unsigned)y / 256u; + if (col < m->left_screen || col > m->right_screen || col >= m->width || row >= m->height) + return 0; + uint64_t cell = (uint64_t)m->map + m->layer * (uint64_t)m->layer_stride + row * m->width + col; + if (cell > UINT32_MAX || !psx_capcom_ram_range((uint32_t)cell, 1)) return 0; + uint64_t tile = (uint64_t)m->metatiles + psx_mod_read_byte((uint32_t)cell) * 512u + + (((unsigned)y / 16u) & 15u) * 32u + (((unsigned)x / 16u) & 15u) * 2u; + return tile <= UINT32_MAX && psx_capcom_ram_range((uint32_t)tile, 2) + ? psx_mod_read_half((uint32_t)tile) : 0; +} + +static inline uint32_t psx_capcom_tile_uvclut(uint32_t desc, unsigned clut_base) { + unsigned page = desc >> 24; + unsigned clut = clut_base + ((desc & 0xf000u) >> 6) + ((page & 0x40u) << 4); + clut |= (desc >> 8) & 15u; + return (clut << 16) | ((desc >> 12) & 0xf0u) | (((desc >> 16) & 0xf0u) << 8); +} + +static inline int psx_capcom_floor_div(int n, int d) { + return n >= 0 ? n / d : -((-n + d - 1) / d); +} + +/* The title evaluates its retail scroll mapping at the camera and at the + * wider view's left edge. Preserve its rounding and authored offsets instead + * of assuming every plane scrolls at the foreground camera's speed. */ +static inline WsViewAnchor psx_capcom_scroll_view(WsViewAnchor fg, + int current_scroll, int view_scroll) { + int extra = (fg.left + fg.right + fg.pad_left + fg.pad_right) / 2; + int origin = current_scroll - view_scroll + fg.pad_left; + /* A faster plane can crop part of the native window at a room end. + * Emit enough source tiles; keep the true shift for GPU viewport clipping. */ + int right = 2 * extra - origin; + WsViewAnchor result = {origin > 0 ? origin : 0, right > 0 ? right : 0, + origin - extra, fg.pad_left, fg.pad_right}; + return result; +} + +static inline WsViewAnchor psx_capcom_parallax_view(WsViewAnchor fg, int divisor, int camera) { + return psx_capcom_scroll_view(fg, psx_capcom_floor_div(camera, divisor), + psx_capcom_floor_div(camera - fg.left, divisor)); +} + +/* Optional per-tile title policy (panorama wrapping, retained texture bank, + * or authored region substitution). NULL uses live VRAM and the native map. */ +typedef void (*PSXCapcomTilePolicy)(unsigned layer, int *x, int *y, + unsigned *mirror_x, uint16_t *bank); + +static inline uint32_t psx_capcom_background_activate(unsigned layers) { + if (!layers || layers > 8u) return 0; + uint32_t bytes = 2u * layers * PSX_CAPCOM_BG_LAYER_BYTES; + uint32_t arena = psx_mod_alloc_gpu_dma_memory(bytes, 32u); + if (arena) gpu_ws_bg2d_set_host_arena(arena, bytes); + return arena; +} + +/* Return emitted packet count, or -1 if setup is invalid. All setup checks + * precede replacing the current layer's OT lists. Both its priority groups + * and the other display buffer stay distinct. Only the native callback that + * follows this operation splices these groups into the final game OT. */ +static inline int psx_capcom_background_render(const PSXCapcomBackground *b, + uint32_t arena, unsigned buffer, const PSXCapcomTileMap *map, + int sx, int sy, WsViewAnchor view, uint32_t native_packet, + PSXCapcomTilePolicy policy) { + if (!arena || !b->layers || b->layers > 8u || map->layer >= b->layers || buffer > 1u || + !b->buckets || b->buckets > 64u || !map->width || !map->height || + !psx_capcom_ram_range(map->map, 1) || !psx_capcom_ram_range(map->metatiles, 512) || + !psx_capcom_ram_range(map->descriptors, 4)) return -1; + if (view.left <= 0 && view.right <= 0) return 0; + if (view.left < 0 || view.right < 0 || view.left > 32768 || view.right > 32768) return -1; + int left = (view.left + 15) / 16, right = (view.right + 15) / 16; + int columns = left + 21 + right; + int screen_x = -(sx & 15), screen_y = -(sy & 15); + if (columns > 2048 || screen_x - left * 16 < -32768 || + screen_x + (20 + right) * 16 > 32767) return -1; + unsigned group_bytes = b->buckets * 4u, buffer_bytes = 2u * b->layers * group_bytes; + uint32_t ot_bytes = 2u * buffer_bytes; + if (!psx_capcom_ram_range(b->heads, ot_bytes) || !psx_capcom_ram_range(b->tails, ot_bytes)) return -1; + uint32_t cursor = arena + (buffer * b->layers + map->layer) * PSX_CAPCOM_BG_LAYER_BYTES; + uint32_t first = cursor; + uint32_t color = psx_mod_read_word(native_packet + 4u); + if (((color >> 24) & 0xfdu) != 0x7du) color = 0x7d808080u; + int start_col = sx / 16, start_row = sy / 16; /* Retail truncates negative scroll. */ + for (unsigned group = map->layer; group < 2u * b->layers; group += b->layers) { + for (unsigned i = 0; i < b->buckets; ++i) { + uint32_t offset = buffer * buffer_bytes + group * group_bytes + i * 4u; + psx_mod_write_word(b->heads + offset, 0); + psx_mod_write_word(b->tails + offset, b->heads + offset); + } + } + for (int row = 0; row < 16; ++row) for (int col = -left; col < 21 + right; ++col) { + int x = (start_col + col) * 16, y = (start_row + row) * 16; + unsigned mirror = 0; + uint16_t bank = 0; + if (policy) policy(map->layer, &x, &y, &mirror, &bank); + uint16_t tile = psx_capcom_map_tile(map, x, y); + if (!tile) continue; + uint64_t desc_addr = (uint64_t)map->descriptors + (tile & 0x3fffu) * 4u; + if (desc_addr > UINT32_MAX || !psx_capcom_ram_range((uint32_t)desc_addr, 4)) continue; + uint32_t desc = psx_mod_read_word((uint32_t)desc_addr); + unsigned bucket = (desc >> 24) & 0x3fu; + if ((desc >> 24) == 255u || bucket >= b->buckets) continue; + unsigned group = map->layer + ((tile & 0x8000u) ? b->layers : 0u); + uint32_t tail_slot = b->tails + buffer * buffer_bytes + group * group_bytes + bucket * 4u; + uint32_t tail = psx_mod_read_word(tail_slot); + psx_mod_write_word(cursor, 0x03000000u); + psx_mod_write_word(cursor + 4u, (color & ~0x02000000u) | ((tile & 0x4000u) ? 0x02000000u : 0u)); + psx_mod_write_word(cursor + 8u, (uint16_t)(screen_x + col * 16) | + ((uint32_t)(uint16_t)(screen_y + row * 16) << 16)); + psx_mod_write_word(cursor + 12u, psx_capcom_tile_uvclut(desc, b->clut_base)); + psx_mod_write_word(cursor + 16u, bank ? (uint16_t)view.shift | (uint32_t)bank << 16 : (uint32_t)view.shift); + psx_mod_write_word(cursor + 20u, (uint32_t)view.pad_left); + psx_mod_write_word(cursor + 24u, (uint32_t)view.pad_right); + psx_mod_write_word(cursor + 28u, (bank ? GPU_WS_BG2D_BANK_PACKET_MAGIC : GPU_WS_BG2D_PACKET_MAGIC) | + (mirror ? GPU_WS_BG2D_MIRROR_X : 0u)); + psx_mod_write_word(tail, (psx_mod_read_word(tail) & 0xff000000u) | (cursor & 0xffffffu)); + psx_mod_write_word(tail_slot, cursor); + cursor += 32u; + } + return (int)((cursor - first) / 32u); +} +#endif diff --git a/runtime/include/mod_memory.h b/runtime/include/mod_memory.h index 3c8e6307..98fb0ec6 100644 --- a/runtime/include/mod_memory.h +++ b/runtime/include/mod_memory.h @@ -73,6 +73,9 @@ static inline uint32_t psx_gpu_packet_key_for(uint32_t address, uint32_t used) { uint32_t psx_gpu_packet_key(uint32_t address); uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t size, uint32_t alignment); uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address); +/* A complete range in the allocated GPU packet aperture (CPU or tag address). + * Unallocated bytes, BIOS aliases and a range crossing its end are refused. */ +int psx_mod_gpu_dma_memory_contains(uint32_t address,uint32_t bytes); uint32_t psx_mod_memory_alloc(uint32_t size, uint32_t alignment); /* Allocations are fixed during activation. Snapshot allocated bytes, never * host pointers; immutable host assets are reconstructed separately. */ diff --git a/runtime/include/mod_named_wad.hpp b/runtime/include/mod_named_wad.hpp new file mode 100644 index 00000000..2670aeb5 --- /dev/null +++ b/runtime/include/mod_named_wad.hpp @@ -0,0 +1,48 @@ +#pragma once +// Neversoft's HED format: NUL-terminated name, word padding, offset/size, +// terminated by 0xff. Only the archive format is shared; title lookup and +// request/completion contracts remain with the game. +#include +#include + +class PSXNamedWadIndex { + std::unordered_map extents; + static uint32_t word(const uint8_t* p) { + return uint32_t(p[0]) | uint32_t(p[1]) << 8 | + uint32_t(p[2]) << 16 | uint32_t(p[3]) << 24; + } +public: + bool assign(const uint8_t* hed, uint32_t size, uint32_t wad_size) { + extents.clear(); + if (!hed) return false; + uint32_t cursor = 0; + while (cursor < size && hed[cursor] != 0xff) { + uint32_t end = cursor; + while (end < size && hed[end]) ++end; + if (end == size || end == cursor) { extents.clear(); return false; } + const uint64_t fields = (uint64_t(end) + 4) & ~uint64_t(3); + if (fields > size || size - fields < 8) { extents.clear(); return false; } + const uint32_t offset = word(hed + fields), bytes = word(hed + fields + 4); + const uint64_t padded = (uint64_t(bytes) + 2047) & ~uint64_t(2047); + if ((offset & 2047) || offset > wad_size || padded > wad_size - offset) { + extents.clear(); return false; + } + const auto found = extents.find(offset); + if (found != extents.end() && found->second != bytes) { + extents.clear(); return false; + } + extents[offset] = bytes; + cursor = uint32_t(fields + 8); + } + if (cursor >= size || hed[cursor] != 0xff || extents.empty()) { + extents.clear(); return false; + } + return true; + } + bool matches(uint32_t offset, uint32_t padded_bytes, uint32_t sectors) const { + const auto found = extents.find(offset); + return found != extents.end() && padded_bytes != 0 && + uint64_t(padded_bytes) == ((uint64_t(found->second) + 2047) & ~uint64_t(2047)) && + sectors == padded_bytes / 2048; + } +}; diff --git a/runtime/include/mod_netplay.h b/runtime/include/mod_netplay.h new file mode 100644 index 00000000..8f342b59 --- /dev/null +++ b/runtime/include/mod_netplay.h @@ -0,0 +1,62 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Executable-owned netplay policy for a trusted simulation plugin. It never + * imports the player's offline package selection. The descriptor and strings + * must live for the process lifetime. Change compatibility_id whenever the + * plugin's shared simulation/state contract changes (maximum 63 bytes). + * + * state_digest is optional, read-only and emulation-thread only. Hash a + * canonical encoding of deterministic host-owned gameplay state; exclude + * pointers, struct padding, rendering caches and local-only input state. It + * joins the normal netplay core digest while this profile's plan is active. */ +typedef uint32_t (*PSXModNetplayStateDigest)(void); +typedef struct PSXModNetplayProfile { + const char *plugin_id; + const char *compatibility_id; + int rollback_supported; + int savestates_supported; + int skip_bios_intro; /* Fixed boot policy: skip animation, kernel calls LLE. */ + unsigned fixed_aspect_mask; /* bit 0=4:3, 1=16:9, 2=21:9; zero=no policy */ + const char *widescreen_plugin_id; /* Optional forced renderer for wide. */ + PSXModNetplayStateDigest state_digest; + /* Require GL retained texture banks. Netplay retains software @1x VRAM + * authority, while GL may use immutable banks for presentation. Software + * and unsupported GL launches are refused before guest boot. */ + int requires_retained_texture_banks; + const char *settings_package_id; + const char *settings_feature_id; + const char *renderer_option_id; + const char *renderer_option_value; +} PSXModNetplayProfile; + +int psx_mod_register_netplay_profile(const PSXModNetplayProfile *profile); +const PSXModNetplayProfile *psx_mod_netplay_profile(void); +const char *psx_mod_netplay_version(const char *vanilla_version); +int psx_mod_netplay_rollback_supported(void); +int psx_mod_netplay_savestates_supported(void); +void psx_mod_netplay_set_active(int active); +int psx_mod_netplay_is_active(void); +uint32_t psx_mod_netplay_session_id(uint32_t session_id); +/* Extend the modern execution/content handshake with a canonical SHA-256 + * profile identity. This rejects mismatches even if the 32-bit transport + * namespace happens to collide. Input must be empty or a 64-digit hex SHA. */ +int psx_mod_netplay_content_identity(const char *execution_content, char out[65]); +int psx_mod_netplay_set_options_identity(const char *identity); +int psx_mod_netplay_set_aspect(int index); +int psx_mod_netplay_aspect(void); +int psx_mod_netplay_has_state_digest(void); +uint32_t psx_mod_netplay_state_digest(void); +int psx_mod_netplay_requires_texture_banks(void); +/* Runtime renderer index: 0=software, 1=OpenGL, 2=Vulkan. No profile leaves + * existing renderer policy unchanged; call only for a netplay launch. */ +int psx_mod_netplay_renderer_allowed(int renderer); + +#ifdef __cplusplus +} +#endif diff --git a/runtime/include/mod_neversoft_renderer.hpp b/runtime/include/mod_neversoft_renderer.hpp new file mode 100644 index 00000000..46193e94 --- /dev/null +++ b/runtime/include/mod_neversoft_renderer.hpp @@ -0,0 +1,125 @@ +#pragma once +// Shared Neversoft renderer extensions. Layout supplies retail code guards, +// globals, mod identifiers and counter names; this never changes simulation. +#include "mod_plugins.h" +#include "cpu_state.h" +#include "pgxp.h" +#include +#include + +struct PSXNeversoftPacketLengthSite { uint32_t address, word; }; + +template struct PSXNeversoftRenderer { + static constexpr uint32_t ArenaSize = 0x60000u, StockSize = 0x17000u; + static constexpr uint32_t MaxFar = 16380u; + inline static uint32_t arena = 0, peak = 0, packet_corrections = 0; + inline static bool extended = true; + + static bool boot_contract() { + return psx_mod_read_word(Layout::AllocA) == Layout::AllocWord && + psx_mod_read_word(Layout::AllocB) == Layout::AllocWord && + psx_mod_read_word(Layout::LimitPc + 4) == 0x34846F00u; + } + static void subdivision_precision(CPUState* cpu, uint32_t) { + // Runs after the original scratchpad SXY store, before RGB2 transfer. + const uint32_t address = cpu->gpr[5]; + if (address < 0x1F800000u || address > 0x1F8003FCu || (address & 3u) || + psx_mod_read_word(Layout::PrecisionPc - 4) != 0xACB00000u) return; + const uint32_t stored = psx_mod_read_word(address); + if (stored != cpu->gpr[16]) return; + if (pgxp_store_flagged_gte_sxy(address, 14, cpu->gte_data[14], stored)) + psx_mod_counter_add(Layout::PrecisionCounter, 1); + } + static void packet_length(CPUState* cpu, uint32_t reg) { + if (!arena) return; + const uint32_t address = cpu->gpr[reg] & 0x00FFFFFFu; + const uint32_t base = arena & 0x00FFFFFFu; + if (address < base || address >= base + 2 * ArenaSize) return; + // The stored DMA tag retains its link; only the subsequent byte-count + // calculation excludes the expanded aperture's address bits 22/23. + cpu->gpr[reg] &= 0xFF000000u; + ++packet_corrections; + } + static void packet_length_t2(CPUState* cpu, uint32_t) { packet_length(cpu, 10); } + static void packet_length_s2(CPUState* cpu, uint32_t) { packet_length(cpu, 18); } + static void fog(CPUState* cpu, uint32_t) { + const int32_t start = int32_t(cpu->gpr[5]), range = int32_t(cpu->gpr[6]); + if (!extended || start < 0 || range <= 0 || uint64_t(start) + range >= MaxFar || + psx_mod_read_word(Layout::FogPc) != 0x00803821u) return; + uint32_t span = 1; + while (span < uint32_t(range) && span < 16384u) span <<= 1; + if (span >= 16384u) return; + if (span < 8192u) span <<= 1; + uint32_t begin = uint32_t(start) * 2u; + if (begin > MaxFar - span) begin = MaxFar - span; + if (begin < uint32_t(start) || begin + span < uint32_t(start) + uint32_t(range)) return; + cpu->gpr[5] = begin; + cpu->gpr[6] = span; + psx_mod_counter_add(Layout::FogCounter, 1); + } + static void swap(CPUState*, uint32_t) { + if (!arena || !boot_contract()) return; + const uint32_t a = psx_mod_read_word(Layout::BufferA + 0x74); + const uint32_t b = psx_mod_read_word(Layout::BufferB + 0x74); + if (a == arena && b == arena + ArenaSize) return; + if (a < 0x80010000u || a > 0x80200000u - StockSize || + b < 0x80010000u || b > 0x80200000u - StockSize) return; + for (const auto& site : Layout::PacketLengthSites) + if (psx_mod_read_word(site.address) != site.word) return; + psx_mod_write_word(Layout::BufferA + 0x74, arena); + psx_mod_write_word(Layout::BufferB + 0x74, arena + ArenaSize); + psx_mod_counter_add(Layout::ArenaCounter, 1); + } + static int limit(CPUState* cpu, uint32_t) { + if (!arena || !boot_contract()) return 0; + const uint32_t cur = psx_mod_read_word(Layout::Current); + if (cur != Layout::BufferA && cur != Layout::BufferB) return 0; + const uint32_t base = psx_mod_read_word(cur + 0x74); + if (base != arena && base != arena + ArenaSize) return 0; + const uint32_t end = (base + ArenaSize - 0x100u) & 0x7FFFFFFFu; + psx_mod_write_word(Layout::Limit, end); + cpu->gpr[4] = ArenaSize - 0x100u; + cpu->gpr[5] = cur; + cpu->gpr[3] = 0x7FFFFFFFu; + cpu->gpr[2] = end; + return 1; + } + static void submit(CPUState*, uint32_t) { + if (packet_corrections) { + psx_mod_counter_add(Layout::PacketCounter, packet_corrections); + packet_corrections = 0; + } + const uint32_t cur = psx_mod_read_word(Layout::Current); + if (cur != Layout::BufferA && cur != Layout::BufferB) return; + const uint32_t base = psx_mod_read_word(cur + 0x74); + const uint32_t used = (psx_mod_read_word(Layout::Cursor) & 0x7FFFFFFFu) - + (base & 0x7FFFFFFFu); + const uint32_t size = arena && (base == arena || base == arena + ArenaSize) ? ArenaSize : StockSize; + if (used > size) return; + if (used + 0x200 >= size) psx_mod_counter_add(Layout::FullCounter, 1); + if (used > peak) { + psx_mod_counter_add(Layout::PeakCounter, used - peak); + peak = used; + } + } + static void activate() { + char value[16] = ""; + extended = !psx_mod_option_value(Layout::Package, "draw-distance", "distance", value, sizeof value) || + std::strcmp(value, "original") != 0; + arena = extended ? psx_mod_alloc_gpu_dma_memory(2 * ArenaSize, 8) : 0; + peak = 0; + packet_corrections = 0; + } + static void register_plugins() { + psx_mod_register_activation_plugin(Layout::Plugin, activate); + psx_mod_register_function_entry_plugin(Layout::Plugin, Layout::FogPc, fog); + psx_mod_register_function_entry_plugin(Layout::Plugin, Layout::SwapPc, swap); + psx_mod_register_function_filter_plugin(Layout::Plugin, Layout::LimitPc, limit); + psx_mod_register_function_entry_plugin(Layout::Plugin, Layout::SubmitPc, submit); + for (const auto& site : Layout::PacketLengthSites) + psx_mod_register_instruction_plugin(Layout::Plugin, site.address, site.word, + site.word == 0x000A5582u ? packet_length_t2 : packet_length_s2); + psx_mod_register_instruction_plugin(Layout::PrecisionPlugin, Layout::PrecisionPc, + 0x4819B000u, subdivision_precision); + } +}; diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 34d35658..81e92287 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -204,6 +204,15 @@ int psx_mod_host_write_ram(uint32_t address, const void* data, uint32_t bytes); * Library bookkeeping (transfer callbacks, busy flags) stays the caller's. */ int psx_mod_spu_upload(uint32_t spu_address, uint32_t guest_source, uint32_t bytes, int stop_after); +/* Private immutable ADPCM residency for opt-in title mods. Does not upload + * into or expand the 512 KiB hardware RAM. Bind before the original driver + * queues a voice; the bank is latched when its next KEYON reaches the SPU. */ +int psx_mod_spu_sample_bank(uint32_t bank, const void *adpcm, uint32_t bytes); +int psx_mod_spu_bind_voice_bank(unsigned voice, uint32_t bank); +int psx_mod_spu_private_voice_play(unsigned slot,uint32_t bank,const uint16_t regs[8],unsigned mode); +void psx_mod_spu_private_voice_stop(uint32_t mask); +int psx_mod_spu_private_voice_volume(unsigned slot,uint16_t left,uint16_t right); +uint32_t psx_mod_spu_private_voice_active(void); /* PsyQ LoadImage completed synchronously: texture-cache flush, GP0 A0h * rectangle copy of w*h 16-bit pixels from guest RAM (provenance attributed * per word), then GP1(04h) DMA direction CPU->GP0 for uploads the library @@ -264,8 +273,12 @@ void psx_mod_set_native_wide_nclip_previous_site(uint32_t address, uint32_t expe void psx_mod_tag_hud_primitive(uint32_t primitive, int edge); /* Packet-guarded screen-space anchor: -1 left, +1 right, 0 stays centred. * Unlike the legacy role tag above, zero explicitly protects centred text - * from automatic layout transforms. Word immediately before the GP0 colour - * command; for a compound E1+SPRT packet this is its E1 word (P_TAG+4). */ + * from automatic layout transforms. Native-wide translates the group; + * projection-and-stretch squashes around the selected screen anchor. This + * works across separate DMA lists and is inert at native 4:3. Title code + * owns classification and must tag each completed packet. Pass the word + * immediately before the GP0 colour command; for a compound E1+SPRT packet + * this is its E1 word (P_TAG+4). */ void psx_mod_anchor_hud_primitive(uint32_t primitive, int edge); /* Screen-space masks identified by their title's producer, P_TAG addresses. * Flat panels extend only their exterior boundaries. Radial flat/Gouraud @@ -1019,6 +1032,10 @@ int psx_mod_allow_direct_shortcut(uint32_t shortcut); * when the mode starts and 0 when it ends (setting it every frame from the * title's state is fine). Cleared at every mod/session reset. */ void psx_mod_set_rewind_blocked(int blocked); +/* Refuse saves and loads while trusted host gameplay state cannot be restored + * by the guest serializer. Includes synchronized netplay state operations; + * pending requests are cancelled. Cleared before each session's activation. */ +void psx_mod_set_savestate_blocked(int blocked); /* Title-supplied host rumble for one guest port (0-based): DualShock motor * values (small: 0 off / nonzero on, large: 0..255) for a title that diff --git a/runtime/include/mod_resident_sector.hpp b/runtime/include/mod_resident_sector.hpp new file mode 100644 index 00000000..4870923e --- /dev/null +++ b/runtime/include/mod_resident_sector.hpp @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include + +// A scoped PsyQ Mode 2/Form 1 data view, starting after the 12-byte sync. +// Only MSF, mode and data submode are synthesized; EDC/ECC are not reproduced. +// Consumers must guard that their guest callback ignores those fields. The +// title still owns request selection, guest DMA, callback and queue scheduling. +class PSXResidentMode2Sector { + std::array bytes_{}; + uint32_t cursor_ = 0; + bool valid_ = false; + static uint8_t bcd(uint32_t n) { return uint8_t((n / 10 << 4) | n % 10); } +public: + bool assign(uint32_t lba, const uint8_t* data, bool last = false) { + cursor_ = 0; valid_ = false; + // MSF has two decimal minute digits. Avoid wrapping on invalid LBAs. + if (!data || lba >= 450000u - 150u) return false; + bytes_.fill(0); + const uint32_t msf = lba + 150; + bytes_[0] = bcd(msf / 4500); + bytes_[1] = bcd(msf / 75 % 60); + bytes_[2] = bcd(msf % 75); bytes_[3] = 2; + bytes_[6] = bytes_[10] = last ? 0x89 : 0x08; + std::memcpy(bytes_.data() + 12, data, 2048); + valid_ = true; return true; + } + const uint8_t* take_words(uint32_t words) { + if (!valid_ || !words || words > (bytes_.size() - cursor_) / 4) + return nullptr; + const auto* p = bytes_.data() + cursor_; + cursor_ += words * 4; + return p; + } + uint32_t consumed() const { return cursor_; } +}; diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index 6c019c0b..4f51a2df 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -7,6 +7,7 @@ #include #include #include "mod_packages.h" +struct PsxLobbyMatchCaps; #if defined(RECOMP_LAUNCHER) #include "recomp_launcher.h" #endif @@ -42,8 +43,15 @@ bool mod_runtime_commit_for_netplay(const std::filesystem::path& disc_path = {}, bool mod_runtime_commit_for_direct_netplay(const std::filesystem::path& disc_path, std::string* error = nullptr); bool mod_runtime_clear_for_netplay(std::string* error = nullptr); -/* [netplay] content_negotiation from game.toml. Off (default): the launcher's - * netplay commit clears the plan exactly like mod_runtime_clear_for_netplay. */ +/* Install only the executable's trusted simulation profile (if registered). + * Original-disc reads remain available; offline selections are never saved. */ +bool mod_runtime_commit_netplay(const std::filesystem::path& disc_path = {}, + std::string* error = nullptr); +bool mod_runtime_netplay_settings_fingerprint(const PsxLobbyMatchCaps& caps, + std::string& fingerprint, + std::string* error = nullptr); +/* [netplay] content_negotiation from game.toml. Off (default): clear package + * simulation mods; an executable-owned profile takes precedence when present. */ void mod_runtime_set_netplay_content_negotiation(bool enabled); bool mod_runtime_netplay_content_negotiation(); /* Restore the pre-session selection before returning to the launcher. */ @@ -66,6 +74,8 @@ bool mod_runtime_netplay_input_active(); /* "package/feature" keys of `plan` that qualify as own-view features. */ std::vector mod_runtime_netplay_view_features(const ModResolution& plan); const std::string& mod_runtime_fingerprint(); +/* Host diagnostics may copy the persisted offline selection from this root. */ +const std::filesystem::path& mod_runtime_root(); const std::filesystem::path& mod_runtime_effective_disc_path(); /* Read an effective-disc file as whole sectors (true end-of-file tail bytes * included). max_bytes = 0 allows a full CD. Emulation-thread only. */ diff --git a/runtime/include/mod_visible_placements.h b/runtime/include/mod_visible_placements.h new file mode 100644 index 00000000..241fbf7a --- /dev/null +++ b/runtime/include/mod_visible_placements.h @@ -0,0 +1,26 @@ +#ifndef PSXRECOMP_MOD_VISIBLE_PLACEMENTS_H +#define PSXRECOMP_MOD_VISIBLE_PLACEMENTS_H +#include + +/* Authored placement visibility, independent of a title's actor allocator. + * Rectangles use the strict bounds of the original Capcom placement scan. + * Caller stores its visited bitmap/previous rectangle in snapshotted mod RAM. */ +typedef struct PSXPlacementRect { int32_t left,right,top,bottom; } PSXPlacementRect; +static inline int psx_placement_contains(PSXPlacementRect r,int32_t x,int32_t y) { + return x>r.left && xr.top && y=old.right || now.right<=old.left || + now.top>=old.bottom || now.bottom<=old.top) { out[0]=now;return 1; } + unsigned n=0; + if(now.leftold.right)out[n++]=(PSXPlacementRect){old.right-1,now.right,now.top,now.bottom}; + int32_t left=now.left>old.left?now.left:old.left,right=now.rightold.bottom)out[n++]=(PSXPlacementRect){left,right,old.bottom-1,now.bottom}; + return n; +} +#endif diff --git a/runtime/include/netplay_state_digest.h b/runtime/include/netplay_state_digest.h index 8e9da454..5717c813 100644 --- a/runtime/include/netplay_state_digest.h +++ b/runtime/include/netplay_state_digest.h @@ -28,6 +28,7 @@ typedef struct NetplayCoreParts { uint32_t timers; /* timer snapshot */ uint32_t ram; /* 2 MiB main RAM */ uint32_t dirty; /* dirty-RAM bitmap words */ + uint32_t mod; /* trusted profile's canonical host gameplay state */ uint32_t core; /* full core (same as netplay_core_digest) */ } NetplayCoreParts; diff --git a/runtime/include/packet_address_index.h b/runtime/include/packet_address_index.h new file mode 100644 index 00000000..f070e65d --- /dev/null +++ b/runtime/include/packet_address_index.h @@ -0,0 +1,31 @@ +#ifndef PSX_PACKET_ADDRESS_INDEX_H +#define PSX_PACKET_ADDRESS_INDEX_H +#include +#include + +/* Fixed-size lookup scratch for packet guards. Value zero denotes an empty + * slot; store array index + 1. First insertion wins, matching the old scan. + * Capacity must be a power of two and at least twice the node count. */ +typedef struct { uint32_t address,value; } PSXPacketAddressIndex; +static inline uint32_t psx_packet_address_bucket(uint32_t address,uint32_t capacity) { + return ((address>>2)*2654435761u)&(capacity-1u); +} +static inline void psx_packet_address_index_clear(PSXPacketAddressIndex* slots,uint32_t capacity) { + memset(slots,0,sizeof(*slots)*capacity); +} +static inline void psx_packet_address_index_add(PSXPacketAddressIndex* slots,uint32_t capacity,uint32_t address,uint32_t index) { + uint32_t bucket=psx_packet_address_bucket(address,capacity); + for(uint32_t n=0;n +#include + +enum class PsxFileHashResult { Ok, OpenError, ReadError }; + +// Hash the file's exact bytes, including an empty file. Publish out only on +// success. Host crypto accelerates the calculation; unavailable/failed native +// hashing restarts with the maintained portable implementation. +PsxFileHashResult psx_sha256_file(const std::filesystem::path& path, + uint8_t out[32]); diff --git a/runtime/include/render_pass.h b/runtime/include/render_pass.h index 3ef28b8f..70d4c6b5 100644 --- a/runtime/include/render_pass.h +++ b/runtime/include/render_pass.h @@ -26,6 +26,11 @@ enum { }; extern uint64_t g_render_pass_dropped_writes[RENDER_PASS_DROP_CLASSES]; +/* memory.c calls this before a pass reads MMIO. Reads of devices outside the + * checkpoint can consume FIFOs/ACKs even with their clock frozen; reject the + * entire pass before the access instead of fabricating a device result. */ +int render_pass_mmio_read_allowed(uint32_t phys, uint32_t width); + typedef struct RenderPassFailure { const char *reason; /* static name, NULL until the first refusal */ uint64_t attempt, plan, guest_cycle; @@ -49,6 +54,8 @@ typedef struct RenderPassStats { uint64_t discarded; /* passes whose plugin declined the image */ uint64_t watchdog; /* of which: guest-cycle watchdog overruns */ uint64_t vram_leaks; /* GPU writes outside the declared rect (dropped) */ + uint64_t device_reads; /* aborted before reading an uncheckpointed device */ + uint32_t last_device_read, last_device_read_width; uint64_t nesting_repairs; /* aborts whose skipped exits the restore undid */ char last_abort_detail[192]; /* skipped host exits; latched until session reset */ uint64_t verify_checks; /* PSX_RENDER_PASS_VERIFY comparisons */ diff --git a/runtime/include/render_pass_gpu_submit.hpp b/runtime/include/render_pass_gpu_submit.hpp new file mode 100644 index 00000000..c555ce87 --- /dev/null +++ b/runtime/include/render_pass_gpu_submit.hpp @@ -0,0 +1,64 @@ +#pragma once +#include "execution_profile.h" +#include "gpu.h" +#include "mod_memory.h" +#include "mod_plugins.h" +#include "psx_cycle_freeze.h" +#include +#include + +/* ENHANCED submission for a frozen render sandbox. The scene owns only the + * resulting picture: PsyQ queue bookkeeping/IRQ polling is restored at exit. + * REFERENCE continues to call the original SDK. Use the exact DMA provenance + * and widescreen prepass services; feeding bare GP0 words loses PGXP/UI data. + * Main RAM and explicitly allocated primitive arenas. Refuse an unsupported + * graph or an arena payload outside its allocation before GPU output. + */ +class PSXRenderSubmit { + struct Node { uint32_t address,header; }; +public: + static constexpr bool enhanced=PSX_EXECUTION_ENHANCED != 0; + static bool ot(uint32_t root) { +#if PSX_EXECUTION_ENHANCED + if(!g_psx_render_pass_active)return false; + // Persistent scratch tolerates a render-pass watchdog longjmp. + static std::vector nodes; + static std::unordered_set seen; + nodes.clear();seen.clear(); + uint32_t address=psx_mod_gpu_dma_resolve_address(root); + for(unsigned count=0;count<32768;++count) { + if((address>=psx_ram_live_bytes() && !psx_mod_gpu_dma_memory_contains(address,4)) || + (address&3) || !seen.insert(address).second)return false; + const uint32_t header=psx_mod_read_word(address),words=header>>24; + // The aperture must not fold an overrun into retail RAM. Retail + // payload words still wrap through the DMAC's live RAM geometry. + if(address>=psx_ram_live_bytes() && + !psx_mod_gpu_dma_memory_contains(address,(words+1)*4))return false; + for(uint32_t i=1;i<=words;++i) + if(psx_mod_gpu_dma_resolve_address(address+4*i)>=psx_ram_live_bytes() && + !psx_mod_gpu_dma_memory_contains(address+4*i,4))return false; + nodes.push_back({address,header}); + if((header&0xFFFFFF)==0xFFFFFF)break; + address=psx_mod_gpu_dma_resolve_address(header&0xFFFFFF); + } + if(nodes.empty() || (nodes.back().header&0xFFFFFF)!=0xFFFFFF)return false; + gpu_ws_begin_linked_list(); + gpu_ws_prepass_linked_list(psx_mod_gpu_dma_resolve_address(root)); + for(const Node& node:nodes) { + gpu_ws_validate_linked_list_header(node.address,node.header); + const uint32_t words=node.header>>24; + gpu_ws_validate_linked_list_node(node.address,words); + gpu_set_gp0_linked_list_node(node.address,words); + for(uint32_t i=1;i<=words;++i) { + address=psx_mod_gpu_dma_resolve_address(node.address+4*i); + gpu_set_gp0_source(address); + gpu_write_gp0(psx_mod_read_word(address)); + } + } + gpu_ws_end_linked_list(); + return true; +#else + (void)root;return false; +#endif + } +}; diff --git a/runtime/include/render_pass_ot.hpp b/runtime/include/render_pass_ot.hpp new file mode 100644 index 00000000..52c33c89 --- /dev/null +++ b/runtime/include/render_pass_ot.hpp @@ -0,0 +1,112 @@ +#pragma once +#include "render_pass_replay.hpp" +#include "gpu.h" +#include "mod_memory.h" +#include + +/* Preserve packets prepended after a title's world draw, and retarget the + * completed OT's screen environment to the impending display bank. Operates + * only on bounded, RAM-backed lists; command payloads are decoded, never + * searched for byte patterns which could also be colors or texture data. */ +class PSXOTReplay { + struct Packet { uint32_t address; std::vector words; }; + struct Bucket { uint32_t address, head; std::vector late; }; + std::vector buckets; + static uint32_t rd(uint32_t p) { return psx_mod_read_word(p); } + static void wr(uint32_t p,uint32_t v) { psx_mod_write_word(p,v); } + static bool valid(uint32_t p,uint32_t bytes) { + if(p&3)return false; + return (p>=0x10000 && p<=memory_get_ram_bytes() && + bytes<=memory_get_ram_bytes()-p) || psx_mod_gpu_dma_memory_contains(p,bytes); + } +public: + void reset() { buckets.clear(); } + bool capture(uint32_t first,uint32_t count) { + reset();first&=0x1FFFFFFF; + if(count>8192 || !valid(first,count*4))return false; + buckets.reserve(count); + for(uint32_t i=0;i8192)return false; + const uint32_t tag=rd(node),n=1+(tag>>24); + words+=n; + if(words>262144 || !valid(node,n*4))return false; + Packet p{node,{}};p.words.reserve(n); + for(uint32_t j=0;j512 || to+height>512)return false; + uint32_t node=tail&0x1FFFFFFF,nodes=0,total=0; + std::vector> edits; + for(;;) { + if(!valid(node,4) || ++nodes>65536)return false; + const uint32_t tag=rd(node),n=tag>>24; + total+=n; + if(total>1048576 || !valid(node,4+4*n))return false; + for(uint32_t i=0;i>24; + int count=gpu_gp0_command_word_count(uint8_t(op)); + if(count<0) { + // Both mono and shaded polylines have at least two + // vertices; shaded terminators occupy the next color. + const uint32_t stride=(op&0x10)?2:1; + uint32_t end=i+((op&0x10)?4:3); + for(;end=n)return false; + count=int(end-i+1); + } + if(count<=0 || i+uint32_t(count)>n)return false; + if(op>=0xA0 && op<=0xBF) { + const uint32_t wh=rd(p+8); + const uint32_t width=((wh-1)&1023)+1, rows=(((wh>>16)-1)&511)+1; + count+=int((width*rows+1)/2); + if(i+uint32_t(count)>n)return false; + } + if(op==0xE3 || op==0xE4) { + const int y=int((w>>10)&511); + if(y>=from && y>11)&2047);if(y&1024)y-=2048; + // Draw offsets may include authored camera shake. + if(y>=from-128 && y +#include +#include +#include + +// Preserve the real frame's work outside a bounded drawing section. Matrix +// replay may change clipping and core packet layout. Tail relocation accepts +// only proved append-only packet chains attached through zero-length OT heads. +// Packet allocations may contain padding; only linked packet words are copied. +class PSXOTRelocation { +public: + struct Node { uint32_t address, header; }; + struct Word { uint32_t address, value; }; + struct Range { uint32_t first, last; }; + std::vector prefix, core, complete; + std::vector outputs; + std::vector payloads; + std::vector changes; + std::vector prefix_payload; + std::vector headers; + std::vector after_core; + uint32_t cursor=0, final_cursor=0, ordering_table=0; + uint32_t refusal=0; + bool refuse(uint32_t line) {refusal=line;return false;} + bool captured=false, ready=false; + std::vector splice_writes; + uint32_t relocated_first=0, relocated_last=0, relocated_destination=0; + uint32_t arena_begin=0; + + static uint32_t word(const uint8_t* ram, uint32_t address) { + uint32_t value; + std::memcpy(&value,ram+(address&0x1fffffu),4); + return value; + } + static bool walk(const uint8_t* ram, uint32_t bytes, uint32_t ot, + std::vector& nodes) { + nodes.clear(); + if(bytes!=0x200000u)return false; + uint32_t address=ot&0xffffffu; + for(unsigned n=0;n<32768;++n) { + if(address==0xffffffu) { + std::vector spans; + for(const auto& node:nodes) + spans.push_back({node.address,node.address+4u+(node.header>>24)*4u}); + std::sort(spans.begin(),spans.end(),[](const Range& a,const Range& b){return a.first=bytes || bytes-address<4u) return false; + const uint32_t header=word(ram,address); + const uint32_t payload=(header>>24)*4u; + if(payload>bytes-address-4u) return false; + nodes.push_back({address,header}); + address=header&0xffffffu; + } + return false; + } + void clear() {captured=ready=false;after_core.clear();changes.clear();prefix_payload.clear();outputs.clear();payloads.clear();} + bool begin(const uint8_t* ram,uint32_t bytes,uint32_t ot,uint32_t initial_cursor=0) { + clear();ordering_table=ot;arena_begin=initial_cursor&0x1fffffu; + if((arena_begin&3u) || arena_begin>=bytes)return false; + if(!walk(ram,bytes,ot,prefix))return false; + for(const auto& node:prefix) + for(uint32_t offset=4;offset<=((node.header>>24)*4u);offset+=4) + prefix_payload.push_back({node.address+offset,word(ram,node.address+offset)}); + return true; + } + bool end(const uint8_t* ram,uint32_t bytes,uint32_t current_cursor) { + if(!walk(ram,bytes,ordering_table,core)) return false; + // This candidate owns newly appended packets only. Reject a core that + // reuses a prefix payload instead of silently restoring stale precision. + for(const auto& saved:prefix_payload) + if(word(ram,saved.address)!=saved.value)return false; + headers=core; + std::sort(headers.begin(),headers.end(),[](const Node& a,const Node& b){return a.addressaddress!=prior.address || + ((current->header^prior.header)&0xff000000u))return false; + } + std::vector prefix_addresses; + for(const auto& node:prefix) prefix_addresses.push_back(node.address); + std::sort(prefix_addresses.begin(),prefix_addresses.end()); + for(const auto& node:core) { + const uint32_t length=(node.header>>24)*4u; + if(length)payloads.push_back({node.address+4u,node.address+4u+length}); + if(length && !std::binary_search(prefix_addresses.begin(),prefix_addresses.end(),node.address)) + outputs.push_back({node.address+4u,node.address+4u+length}); + } + std::sort(outputs.begin(),outputs.end(),[](const Range& a,const Range& b){return a.first merged; + for(const auto& range:outputs) { + if(!merged.empty() && range.first<=merged.back().last) + merged.back().last=std::max(merged.back().last,range.last); + else merged.push_back(range); + } + outputs.swap(merged); + std::sort(payloads.begin(),payloads.end(),[](const Range& a,const Range& b){return a.firstlast)return false; + } + cursor=current_cursor; + headers=core; + std::sort(headers.begin(),headers.end(),[](const Node& a,const Node& b){return a.addresslast; + } + bool prepare(const uint8_t* ram,uint32_t bytes,uint32_t producer_end=0) { + final_cursor=producer_end&0x1fffffu; + ready=false;changes.clear(); + if(!captured || after_core.size()!=bytes || (bytes&3u) || + !walk(ram,bytes,ordering_table,complete)) return false; + for(uint32_t address=0;addresslast)return false; + const auto header=std::lower_bound(headers.begin(),headers.end(),address, + [](const Node& node,uint32_t word){return node.addressaddress==address && + ((value^header->header)&0xff000000u))return false; + changes.push_back({address,value}); + } + ready=true; + return true; + } + bool matches(const uint8_t* ram,uint32_t bytes,uint32_t ot,uint32_t current_cursor) const { + if(!ready || ot!=ordering_table || cursor!=current_cursor || after_core.size()!=bytes) return false; + for(const auto& node:core) + if(word(ram,node.address)!=node.header) return false; + return true; + } + // Only append-only tail packets and ordinary zero-length OT head inserts + // may move. This prepares all writes before touching the replay's RAM. + bool prepare_splice(const uint8_t* ram,uint32_t bytes,uint32_t ot, + uint32_t current_cursor,uint32_t cursor_word) { + refusal=0;splice_writes.clear();relocated_first=relocated_last=relocated_destination=0; + if(!ready || ot!=ordering_table || after_core.size()!=bytes || + (cursor_word&0x1fffffu)>bytes-4u || word(ram,cursor_word)!=current_cursor)return refuse(__LINE__); + const uint32_t old_first=cursor&0x1fffffu, new_first=current_cursor&0x1fffffu; + if((old_first|new_first)&3u || old_first=bytes || new_first>=bytes)return refuse(__LINE__); + std::vector live; + if(!walk(ram,bytes,ot,live))return refuse(__LINE__); + auto sorted=[](std::vector nodes){ + std::sort(nodes.begin(),nodes.end(),[](const Node&a,const Node&b){return a.address& nodes,uint32_t address)->const Node* { + auto at=std::lower_bound(nodes.begin(),nodes.end(),address, + [](const Node&n,uint32_t a){return n.addressaddress==address?&*at:nullptr; + }; + // Static prefix data must survive; its links may point at newly emitted + // core packets. Every zero-length OT node retains its identity. + for(const auto& node:prefix) { + const Node* actual=find(now,node.address); + if(!actual || ((actual->header^node.header)&0xff000000u))return refuse(__LINE__); + } + for(const auto& saved:prefix_payload) + if(word(ram,saved.address)!=saved.value)return refuse(__LINE__); + for(const auto& node:old)if(!(node.header>>24)) { + const Node* actual=find(now,node.address); + if(!actual || actual->header>>24)return refuse(__LINE__); + } + std::vector added; + for(const auto& node:complete)if(!find(old,node.address))added.push_back(node); + added=sorted(added); + if(added.empty())return refuse(__LINE__); + uint32_t end=old_first; + for(const auto& node:added) { + // The original allocator can reserve more bytes than DMA reads + // (e.g. an 8-byte DrawMode in a 16-byte allocation). Validate + // linked packets, not uninitialized padding between allocations. + if(node.address>24))return refuse(__LINE__); + end=node.address+4u+(node.header>>24)*4u; + if(end>bytes || (final_cursor && end>final_cursor))return refuse(__LINE__); + } + if(final_cursor) { + if(final_cursor=bytes)return refuse(__LINE__); + end=final_cursor; + } + const uint32_t length=end-old_first; + if(length>bytes-new_first)return refuse(__LINE__); + const uint32_t new_end=new_first+length; + for(const auto& node:live) { + const uint32_t node_end=node.address+4u+(node.header>>24)*4u; + if(node.address=old_first && address terminals; + for(const auto& change:changes) { + const Node* prior=find(old,change.address); + if(!prior)continue; + if(prior->header>>24 || change.value>>24 || !is_tail(change.value&0xffffffu))return refuse(__LINE__); + const Node* current=find(now,change.address); + if(!current || current->header>>24)return refuse(__LINE__); + uint32_t link=change.value&0xffffffu; + unsigned visited=0; + while(is_tail(link)) { + const Node* node=find(added,link); + if(!node || ++visited>added.size())return refuse(__LINE__); + const uint32_t next=node->header&0xffffffu; + if(!is_tail(next)) { + if(next!=(prior->header&0xffffffu))return refuse(__LINE__); + terminals.push_back({node->address,current->header&0xffffffu}); + } + link=next; + } + splice_writes.push_back({change.address,relocate(change.value&0xffffffu)}); + } + if(terminals.empty())return refuse(__LINE__); + // Every appended packet belongs to one proved head-insert chain. + std::vector owned; + for(const auto& change:changes)if(find(old,change.address)) { + uint32_t link=change.value&0xffffffu; + while(is_tail(link)) { + owned.push_back(link); + if(owned.size()>added.size())return refuse(__LINE__); + const Node* node=find(added,link); + if(!node)return refuse(__LINE__); + link=node->header&0xffffffu; + } + } + std::sort(owned.begin(),owned.end()); + if(owned.size()!=added.size() || std::adjacent_find(owned.begin(),owned.end())!=owned.end())return refuse(__LINE__); + for(const auto& node:added) { + uint32_t link=node.header&0xffffffu; + if(is_tail(link))link=relocate(link); + else { + auto terminal=std::find_if(terminals.begin(),terminals.end(),[&](const Word&w){return w.address==node.address;}); + if(terminal==terminals.end())return refuse(__LINE__); + link=terminal->value; + } + splice_writes.push_back({relocate(node.address),(node.header&0xff000000u)|link}); + for(uint32_t offset=4;offset<=((node.header>>24)*4u);offset+=4) { + uint32_t value=word(after_core.data(),node.address+offset); + const auto change=std::lower_bound(changes.begin(),changes.end(),node.address+offset, + [](const Word& w,uint32_t address){return w.addressaddress==node.address+offset)value=change->value; + splice_writes.push_back({relocate(node.address+offset),value}); + } + } + for(const auto& change:changes) { + if(is_tail(change.address) || find(old,change.address))continue; + if(change.address==(cursor_word&0x1fffffu)) { + if((change.value&0x1fffffu)!=end)return refuse(__LINE__); + splice_writes.push_back({change.address,(current_cursor&0xffe00000u)|new_end}); + } else { + // The real tail's ordinary state updates are authoritative. + // Core replay can change temporary counters/bounds, so matching + // every RAM word against the original core rejects valid draw + // output. Only refuse a tail write that aliases CURRENT packet + // storage; the surrounding sandbox restores gameplay state. + const auto next=std::upper_bound(now.begin(),now.end(),change.address, + [](uint32_t address,const Node& node){return address>24)*4u)return refuse(__LINE__); + } + splice_writes.push_back(change); + } + } + relocated_first=old_first;relocated_last=end;relocated_destination=new_first; + return true; + } + uint32_t shadow_destination(uint32_t address) const { + return address>=relocated_first && address #include +/* Keep the shown generation and up to two frames waiting for their actual + * display flip. Never overwrite the sole waiting frame just because drawing + * the following frame completed before the host observed its flip. */ +int render_pass_generation_oldest(uint32_t mask,const uint64_t* order,uint32_t count); +int render_pass_generation_write(uint32_t current,uint32_t valid_mask, + const uint64_t* order,uint32_t count); + #ifdef __cplusplus extern "C" { #endif @@ -64,6 +71,9 @@ int render_pass_select(const uint32_t *phases, uint32_t n, double p, * game that stops flipping for several frame lengths gets that back. */ int render_pass_gen_select(const uint32_t *phases, uint32_t n, double p, uint32_t *lo, uint32_t *hi, float *t); +/* HOLD presents one native phase image without a temporal crossfade. */ +int render_pass_gen_select_mode(const uint32_t *phases, uint32_t n, double p, + int hold, uint32_t *lo, uint32_t *hi, float *t); /* Is the flip the FLIP source just saw the one a pending generation waits * for? A generation built for the next flip (`shown` 0, the canonical PsyQ diff --git a/runtime/include/render_pass_replay.hpp b/runtime/include/render_pass_replay.hpp index fa3dff83..71c9b1af 100644 --- a/runtime/include/render_pass_replay.hpp +++ b/runtime/include/render_pass_replay.hpp @@ -4,6 +4,7 @@ #include "psx_cycle_freeze.h" #include "render_pass_projection.h" #include "pgxp.h" +#include "mod_memory.h" #include #include @@ -18,9 +19,11 @@ extern "C" uint32_t memory_get_ram_bytes(void); class PSXDrawReplay { CPUState entry{}; std::vector ram; + std::vector arenas; uint8_t scratch[1024]{}; uint32_t start = 0, stop = 0, previous_tick = 0; bool captured = false, ready = false, have_tick = false; + bool projections = true; PSXProjectionHistory* history = nullptr; public: PSXProjectionStats stats{}; @@ -30,44 +33,60 @@ public: captured = ready = have_tick = false; ticks = 0; psx_projection_invalidate(history); } - void capture(CPUState* cpu, uint32_t first, uint32_t last) { + // Titles with known actor/camera layouts can apply their motion sets after + // restore, instead of matching final GTE projections. Both routes share the + // same draw-entry snapshot and machine sandbox. + void capture(CPUState* cpu, uint32_t first, uint32_t last, + bool capture_projections = true) { if (g_psx_render_pass_active) return; if (captured) invalidate(); // previous drawing section never completed - if (!history) history = psx_projection_create(65536); - if (!history || first >= last || ((first | last) & 3)) return; + projections = capture_projections; + if (projections && !history) history = psx_projection_create(65536); + if ((projections && !history) || first >= last || ((first | last) & 3)) return; entry = *cpu; start = first; stop = last; ram.resize(memory_get_ram_bytes()); std::memcpy(ram.data(), memory_get_ram_ptr(), ram.size()); std::memcpy(scratch, memory_get_scratchpad_ptr(), sizeof scratch); + arenas.resize(psx_mod_memory_snapshot_bytes()); + if (!arenas.empty()) psx_mod_memory_snapshot_write(arenas.data()); captured = true; ready = false; - psx_projection_capture_begin(history); + if (projections) psx_projection_capture_begin(history); } - bool prepare(uint32_t tick) { + bool prepare(uint32_t tick, bool motion_ready = false) { if (!captured) { invalidate(); return false; } captured = false; ticks = have_tick ? tick - previous_tick : 0; previous_tick = tick; have_tick = true; - psx_projection_capture_end(history, ticks, 512.0); - psx_projection_stats(history, &stats); - ready = ticks && ticks <= 8 && stats.changed && !stats.overflow; + if (projections) { + psx_projection_capture_end(history, ticks, 512.0); + psx_projection_stats(history, &stats); + } + ready = ticks && ticks <= 8 && (projections + ? stats.changed && !stats.overflow : motion_ready); return ready; } bool restore(CPUState* cpu, uint32_t alpha) { - if (!ready || !g_psx_render_pass_active || ram.size() != memory_get_ram_bytes()) return false; + if (!ready || !g_psx_render_pass_active || ram.size() != memory_get_ram_bytes() || + arenas.size() != psx_mod_memory_snapshot_bytes()) return false; + // This reader journals the live arena before restoring draw entry. + // OT prefix packets may live outside main RAM when distance is raised. + if (!arenas.empty() && !psx_mod_memory_snapshot_read(arenas.data(), arenas.size())) return false; std::memcpy(memory_get_ram_ptr(), ram.data(), ram.size()); std::memcpy(memory_get_scratchpad_ptr(), scratch, sizeof scratch); // Raw RAM is now from section entry. Its current-frame precision // shadows are stale; the pass checkpoint restores them afterwards. pgxp_invalidate_all(); *cpu = entry; - psx_projection_replay_begin(history, alpha); + if (projections) psx_projection_replay_begin(history, alpha); return true; } bool draw(CPUState* cpu) { const bool reached = psx_mod_run_guest_span(cpu, start, stop) != 0; - psx_projection_replay_end(history); - psx_projection_stats(history, &stats); - return reached && stats.replayed != 0; + if (projections) { + psx_projection_replay_end(history); + psx_projection_stats(history, &stats); + } + return reached && (!projections || stats.replayed != 0); } static uint32_t call(CPUState* cpu, uint32_t function, uint32_t a0=0, uint32_t a1=0) { const CPUState saved = *cpu; diff --git a/runtime/include/render_pass_sprite_scene.hpp b/runtime/include/render_pass_sprite_scene.hpp new file mode 100644 index 00000000..c18b52d7 --- /dev/null +++ b/runtime/include/render_pass_sprite_scene.hpp @@ -0,0 +1,229 @@ +#pragma once +#include "cpu_state.h" +#include "mod_plugins.h" +#include "render_pass_motion.h" +#include "psx_cycle_freeze.h" +#include "pgxp.h" +#include "gpu.h" +#include +#include + +extern "C" uint8_t* memory_get_ram_ptr(void); +extern "C" uint8_t* memory_get_scratchpad_ptr(void); +extern "C" uint32_t memory_get_ram_bytes(void); + +/* Replay of a double-buffered sprite scene, with identified 16.16 actor and + * layer coordinates. Layout describes an audited guest ABI, including OT size + * and display-bank prefixes. Capture/replay never runs the gameplay update. + * Late HUD/effect packets are preserved, the existing VSync wait runs once, + * and all temporary guest changes remain inside the render-pass sandbox. + * Derived from the MMX6 native scene implementation; used by MMX4 and MMX5. + */ +template +class PSXSpriteSceneReplay { +static constexpr uint32_t Draw=Layout::Draw; +static constexpr uint32_t End=Layout::End; +static constexpr uint32_t Camera=Layout::Camera; +static constexpr uint32_t Actor=Layout::Actor; +static constexpr uint32_t VSync=Layout::VSync; +static constexpr uint32_t Base=Layout::Base; +static constexpr uint32_t Stride=Layout::Stride; +static constexpr uint32_t Layers=Layout::Layers; +static constexpr uint32_t PutDrawEnv=Layout::PutDrawEnv; +static constexpr uint32_t DrawOTag=Layout::DrawOTag; +static constexpr uint32_t DrawSync=Layout::DrawSync; +inline static CPUState entry{}; +inline static std::vector ram; +inline static uint8_t scratch[1024]{}; +inline static PSXMotionSet* motion=nullptr; +inline static PSXMotionStats stats{}; +inline static uint32_t vblanks=0, buffer=0, alpha=0; +inline static bool capturing=false, ready=false, waiting=false; +inline static uint32_t draw_heads[Layout::Buckets]{}; +struct LatePacket { uint32_t address;std::vector words; }; +inline static std::vector late[Layout::Buckets]; + +static bool capture_late_packets() { + // Other scene tasks can prepend a fade or overlay after the world wrapper + // returns. Preserve those exact current-frame packets in their OT buckets. + // Only a bounded prefix ending at the captured world head is accepted. + uint32_t total=0; + for(uint32_t i=0;imemory_get_ram_bytes()-4 || ++total>4096)return false; + const uint32_t tag=psx_mod_read_word(node),words=1+(tag>>24); + if(words*4>memory_get_ram_bytes()-node)return false; + LatePacket packet{node,{}};packet.words.reserve(words); + for(uint32_t j=0;j>24)!=0xE5 || + (psx_mod_read_word(offset_other+4)>>24)!=0xE5 || + (psx_mod_read_word(clip_other+4)>>24)!=0xE3 || + (psx_mod_read_word(clip_other+8)>>24)!=0xE4)return false; + psx_mod_write_word(offset+4,psx_mod_read_word(offset_other+4)); + psx_mod_write_word(clip+4,psx_mod_read_word(clip_other+4)); + psx_mod_write_word(clip+8,psx_mod_read_word(clip_other+8)); + return true; +} + +static uint32_t call(CPUState* cpu,uint32_t fn,uint32_t a0) { + const CPUState saved=*cpu; + cpu->pc=0;cpu->gpr[4]=a0; + psx_dispatch_call(cpu,fn,saved.gpr[31]); + const uint32_t result=cpu->gpr[2];*cpu=saved;return result; +} +static void reset() { capturing=ready=false;psx_motion_invalidate(motion); } +static void tick(){if(!g_psx_render_pass_active)++vblanks;} +static void begin(CPUState* cpu,uint32_t) { + if(g_psx_render_pass_active)return; + const uint32_t next=psx_mod_read_word(Layout::CurrentBuffer); + if(next!=Base && next!=Base+Stride){reset();return;} + if(!motion)motion=psx_motion_set_create(4096); + if(!motion)return; + if(capturing)reset(); + buffer=next;entry=*cpu; + ram.resize(memory_get_ram_bytes()); + std::memcpy(ram.data(),memory_get_ram_ptr(),ram.size()); + std::memcpy(scratch,memory_get_scratchpad_ptr(),sizeof scratch); + psx_motion_begin(motion,vblanks);capturing=true;ready=false; +} +static void position(uint32_t addr,uint32_t identity) { + if(g_psx_render_pass_active) { + int32_t value; + if(psx_motion_blend_at(motion,PSX_MOTION_SCALAR,addr, + double(alpha)/65536.0,&value)==1) + psx_mod_write_word(addr,uint32_t(value)); + } else if(capturing) { + if(!psx_motion_track(motion,PSX_MOTION_SCALAR,addr,identity))reset(); + } +} +static void cameras(CPUState*,uint32_t) { + if(!capturing && !g_psx_render_pass_active)return; + // After the layer initializer, before streaming and tile construction. + // Parent layers are captured separately; their authored offsets stay intact. + for(uint32_t i=0;i<3;++i) { + const uint32_t b=Layers+i*0x54; + if(!psx_mod_read_byte(b+3))continue; + const uint32_t id=psx_motion_identity(psx_mod_read_word(b+0x18), + psx_mod_read_word(b+0x1C),psx_mod_read_byte(b+0x52)); + position(b+8,id);position(b+12,id); + } +} +static void actor(CPUState* cpu,uint32_t) { + if(!capturing && !g_psx_render_pass_active)return; + const uint32_t b=cpu->gpr[4], phys=b&0x1FFFFFFF; + if(phys>memory_get_ram_bytes()-0x50 || (b&3))return; + // Negative camera indices are absolute screen coordinates (HUD/menus). + const int8_t layer=int8_t(psx_mod_read_byte(b+0x14)); + if(layer<0 || layer>=3)return; + const uint32_t id=psx_motion_identity(psx_mod_read_word(b+0x38), + psx_mod_read_word(b+0x3C),uint32_t(layer)); + position(b+8,id);position(b+12,id); +} +static void end(CPUState*,uint32_t) { + if(g_psx_render_pass_active || !capturing)return; + capturing=false; + for(uint32_t i=0;igpr[31]!=Layout::WaitReturn || cpu->gpr[4]!=0)return; + if(!ready)return; + ready=false; + const uint32_t other=buffer==Base?Base+Stride:Base; + if(psx_mod_read_word(Layout::CurrentBuffer)!=buffer || + psx_mod_read_word(buffer)!=psx_mod_read_word(other+0x14) || + psx_mod_read_word(buffer+4)!=psx_mod_read_word(other+0x18))return; + if(!capture_late_packets()){psx_mod_counter_add("sprite_scene.fr.overlay_rejected",1);return;} + // Let the preceding display flip be captured before opening this frame's + // generation. VSync(1) is only a query, so a plan before VSync(0)'s wait + // otherwise overwrites its predecessor before that predecessor is shown. + // Execute the original wait once and finish the intercepted function; + // running its body again would add a guest VBlank and slow gameplay. + waiting=true; + psx_dispatch_call(cpu,VSync,cpu->gpr[31]); + waiting=false; + ready=true; + PSXModRenderPassFrame frame{};frame.struct_size=sizeof frame; + frame.period_vblanks=stats.ticks; + frame.x=psx_mod_read_half(buffer);frame.y=psx_mod_read_half(buffer+2); + frame.w=psx_mod_read_half(buffer+4);frame.h=psx_mod_read_half(buffer+6); + psx_mod_counter_add("sprite_scene.fr.passes",psx_mod_render_pass_frame(cpu,&frame,pass,nullptr)); + ready=false; + psx_mod_finish_function(cpu); +} +static void activate() { + reset();vblanks=0; + psx_mod_activate_render_pass_rate(Layout::Package, + "frame-interpolation","rate",PSX_MOD_RENDER_PASS_FLIP_PENDING); +} + +public: +static void install(const char* id) { + psx_mod_register_activation_plugin(id,activate); + psx_mod_register_vblank_plugin(id,tick); + psx_mod_register_savestate_plugin(id,reset); + psx_mod_register_function_entry_plugin(id,Draw,begin); + psx_mod_register_function_entry_plugin(id,Actor,actor); + psx_mod_register_function_entry_plugin(id,VSync,finish); + psx_mod_register_instruction_plugin(id,Camera,Layout::CameraInstruction,cameras); + psx_mod_register_instruction_plugin(id,End,0x03E00008,end); +} +}; diff --git a/runtime/include/savestate.h b/runtime/include/savestate.h index 0a4d9505..52591a92 100644 --- a/runtime/include/savestate.h +++ b/runtime/include/savestate.h @@ -26,6 +26,12 @@ extern "C" { #define SAVESTATE_THUMB_W 128 #define SAVESTATE_THUMB_H 96 +/* Session-only trusted-title policy. Blocking cancels pending requests and + * refuses user and synchronized-protocol save/load; it never alters files. + * main resets this before each mod activation (including soft returns). */ +void savestate_set_title_blocked(int blocked); +int savestate_title_blocked(void); + /* Configure the slot directory + integrity key (from main, after config load). * * When bios_token is non-empty (e.g. "openbios" / "scph1001"): diff --git a/runtime/include/spu.h b/runtime/include/spu.h index 1964fcac..c05d0905 100644 --- a/runtime/include/spu.h +++ b/runtime/include/spu.h @@ -150,6 +150,23 @@ void spu_cd_audio_reset(void); /* Get pointer to SPU RAM for direct access (512KB) */ const uint8_t* spu_get_ram(void); +/* Opt-in enhancement assets. Bank 0 is hardware RAM; immutable ADPCM banks + * 1..31 reside outside it. A binding is consumed at the next KEYON on that + * voice, preserving the native decoder, pitch, envelopes, mixer and KEYOFF. + * Bank addresses are byte offsets (the voice registers still use /8 units). + * Both active and pending bindings are part of the SPU snapshot wire. */ +int spu_register_sample_bank(uint32_t bank, const void *adpcm, uint32_t bytes); +int spu_bind_next_voice_bank(unsigned voice, uint32_t bank); +/* Optional title-owned voices, independent of all 24 hardware channels. + * Register order is VOL_L, VOL_R, PITCH, START, ADSR_LO, ADSR_HI, ENV, LOOP. + * Mode bits: 0 noise, 1 reverb. Slots 0..23; nonzero immutable bank required. + * Decoder/envelope/sweep state is serialized; ordinary snapshots retain their + * existing size until this enhancement is used. */ +int spu_private_voice_play(unsigned slot,uint32_t bank,const uint16_t regs[8],unsigned mode); +void spu_private_voice_stop(uint32_t mask); +int spu_private_voice_volume(unsigned slot,uint16_t left,uint16_t right); +uint32_t spu_private_voice_active(void); + /* ---- Verified-enhancement shadow tap (consumed by spu_shadow.c) --------- * * Present-time, opt-in float SPU re-render. When the shadow is enabled, @@ -158,7 +175,7 @@ const uint8_t* spu_get_ram(void); * fractional phase, envelope level, and per-voice + main volumes). The shadow * re-interpolates these in float. The layout MUST match spu.c's internal * SpuShadowVoiceTap / SpuShadowFrameTap. */ -#define SPU_SHADOW_MAX_VOICES 24 +#define SPU_SHADOW_MAX_VOICES 48 typedef struct { int16_t s[4]; /* decoded samples sample_idx-1 .. +2 (block-edge clamped) */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 02f2876f..6a6d8931 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -1,4 +1,4 @@ -# Shared psxrecomp runtime CMake helpers. +# Shared psxrecomp runtime CMake helpers. # # Include this from either the framework runtime build or a sibling game # project. SDL3 is the default; set -DPSX_SDL_BACKEND=SDL2 for the legacy @@ -15,6 +15,9 @@ endif() set(PSX_PGO "" CACHE STRING "PGO mode: empty, generate, or use") set_property(CACHE PSX_PGO PROPERTY STRINGS "" generate use) include("${PSXRECOMP_ROOT}/cmake/psx_runtime_ipo.cmake") +include("${PSXRECOMP_ROOT}/cmake/psx_execution_profile.cmake") +include("${PSXRECOMP_ROOT}/cmake/psx_sha256_file.cmake") +include("${PSXRECOMP_ROOT}/cmake/psx_build_revision.cmake") include("${PSXRECOMP_ROOT}/cmake/psx_dependency_archive.cmake") include("${PSXRECOMP_ROOT}/runtime/chd_dependency.cmake") @@ -483,6 +486,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/mod_packages.cpp ${PSXRECOMP_ROOT}/runtime/src/mod_media.cpp ${PSXRECOMP_ROOT}/runtime/src/mod_runtime.cpp + ${PSXRECOMP_ROOT}/runtime/src/mod_netplay.c ${PSXRECOMP_ROOT}/runtime/src/mod_resident.cpp ${PSXRECOMP_ROOT}/runtime/src/mod_guest_services.c ${PSXRECOMP_ROOT}/runtime/src/mod_texture_banks.c @@ -1607,6 +1611,20 @@ function(psxrecomp_add_runtime_target target) ${PSXRT_EXTRAS_SOURCES} ) target_link_libraries(${target} PRIVATE chdr-static psx_texture_image_decode) + psxrecomp_sha256_file(${target}) + set(_execution_contract + "${PSXRECOMP_ROOT}/runtime/src/gte.cpp" + "${PSXRECOMP_ROOT}/runtime/src/pgxp.cpp" + "${PSXRECOMP_ROOT}/runtime/include/hle_gte.h" + "${PSXRECOMP_ROOT}/runtime/include/cpu_state.h" + "${PSXRECOMP_ROOT}/runtime/include/gte.h" + "${PSXRECOMP_ROOT}/runtime/include/pgxp.h" + "${PSXRECOMP_ROOT}/runtime/include/render_pass_projection.h") + if(PSXRT_ORACLE) + psxrecomp_execution_profile(${target} PROFILE REFERENCE CONTRACT_FILES ${_execution_contract}) + else() + psxrecomp_execution_profile(${target} CONTRACT_FILES ${_execution_contract}) + endif() # audio_trace.c uses C11 atomics. Make the runtime's actual language # requirement explicit instead of relying on a parent project's global # CMAKE_C_STANDARD setting. cxx_std_17 likewise — game CMakeLists may omit @@ -1930,12 +1948,7 @@ function(psxrecomp_add_runtime_target target) # can be correlated to an exact build (issue #1 user reports had no version). # Computed at configure time from the psxrecomp repo (this file's dir); empty # on failure (no git / not a repo) -> crash_trace.c falls back to "unknown". - execute_process( - COMMAND git -C "${CMAKE_CURRENT_FUNCTION_LIST_DIR}" describe --always --dirty --tags - OUTPUT_VARIABLE PSX_GIT_REV OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) - if(NOT PSX_GIT_REV) - set(PSX_GIT_REV "unknown") - endif() + psxrecomp_build_revision(PSX_GIT_REV "${CMAKE_CURRENT_FUNCTION_LIST_DIR}") # Release pin for lobby matching (create/join/list). Override via # GAME_VERSION arg or -DPSX_GAME_VERSION=...; default "dev". diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c index 6195d9ba..9bf4abdd 100644 --- a/runtime/src/boot_state.c +++ b/runtime/src/boot_state.c @@ -1,4 +1,5 @@ #include "boot_state.h" +#include "execution_profile.h" #include "fntrace.h" #include "mod_memory.h" #include "overlay_api.h" /* PSX_OVERLAY_CODEGEN_HASH / _ABI_TAG / _CODEGEN_VER */ @@ -93,17 +94,15 @@ extern int mdec_snapshot_read(const uint8_t* p, uint32_t len); extern int sio_snapshot_validate(const uint8_t* p, uint32_t len); extern int mdec_snapshot_validate(const uint8_t* p, uint32_t len); -/* Header compatibility cookie (h.reserved): the enhancement-memory layout plus - * the live main-RAM geometry. The RAM term is zero on retail 2 MiB, so retail - * states are byte-identical to states written before the 8 MB map existed; a - * state from the other geometry is rejected in the header, before any section - * is read, with a reason that names the size. */ +/* Header compatibility cookie (h.reserved): enhancement-memory layout, live + * RAM geometry and the fixed execution implementation. Reject incompatible + * snapshots in the header, before any section changes the live machine. */ #define BOOT_STATE_RAM_8MB_COOKIE 0x384D4252u /* "RBM8" */ static uint32_t boot_state_ram_cookie(void) { return psx_ram_8mb_active() ? BOOT_STATE_RAM_8MB_COOKIE : 0u; } static uint32_t boot_state_layout_cookie(void) { - return psx_mod_memory_layout_cookie() ^ boot_state_ram_cookie(); + return psx_mod_memory_layout_cookie() ^ boot_state_ram_cookie() ^ PSX_EXECUTION_COOKIE; } /* CPU regs wire: 32+3+32+32+32 LE u32 = 131 * 4 = 524 bytes (no padding). */ @@ -952,7 +951,7 @@ int boot_state_check_buffer(const uint8_t* file, size_t file_len, (unsigned)(memory_get_ram_bytes() >> 20)); boot_state_append_reason(reason, reason_cap, part); } else { - boot_state_append_reason(reason, reason_cap, "enhancement_memory_layout"); + boot_state_append_reason(reason, reason_cap, "enhancement_memory_layout_or_execution_profile"); } return 0; } diff --git a/runtime/src/cdrom.c b/runtime/src/cdrom.c index de472391..a0ed6d2d 100644 --- a/runtime/src/cdrom.c +++ b/runtime/src/cdrom.c @@ -2323,14 +2323,14 @@ static void exec_command(uint8_t cmd) { lba = (int)cdda_lba; track = cdda_track; track_lba = (int)iso_cdda_track_start_lba(iso_handle, track); - } else if (reading) { - /* GetlocP reports the drive/sub-Q position. During a read the - * sector stream has already advanced past the data-ready sector. */ - lba = msf_to_lba(read_min, read_sec, read_sect); - } else if (last_sector_lba >= 0) { - lba = last_sector_lba; } else { - lba = msf_to_lba(seek_min, seek_sec, seek_sect); + /* GetlocP reports drive/sub-Q position, not the data buffer's + * header (GetlocL). SeekL/SeekP update this cursor even though + * they leave reading stopped; Pause retains it. SetLoc alone + * only changes the target. Returning last_sector_lba here kept + * THPS2 polling the previous asset's position after its initial + * music seek, so it never issued ReadS to start the track. */ + lba = msf_to_lba(read_min, read_sec, read_sect); } if (subq_replacements_active) update_last_valid_subq((uint32_t)lba); if (subq_replacements_active && last_valid_subq_available) { diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index bfe63479..27b495ea 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -20,6 +20,7 @@ #include "host_launch_timing.h" #include "host_sampler.h" #include "guest_cycle_profile.h" +#include "host_osd.h" #include "psx_video_timing.h" #include "psx_netplay.h" #include "psx_bss.h" @@ -8815,6 +8816,7 @@ static void handle_render_pass_stats(int id, const char *json) "\"wanted\":%llu,\"refused\":%llu,\"passes\":%llu," "\"aborted\":%llu,\"discarded\":%llu,\"watchdog\":%llu,\"vram_leaks\":%llu," "\"nesting_repairs\":%llu," + "\"device_reads\":%llu,\"last_device_read\":\"0x%08X\",\"last_device_read_width\":%u," "\"verify_checks\":%llu,\"verify_mismatch\":%llu," "\"dropped\":{\"spu\":%llu,\"cd\":%llu,\"timer\":%llu," "\"dma\":%llu,\"gpu\":%llu,\"other\":%llu}," @@ -8843,6 +8845,7 @@ static void handle_render_pass_stats(int id, const char *json) (unsigned long long)st.passes, (unsigned long long)st.aborted, (unsigned long long)st.discarded, (unsigned long long)st.watchdog, (unsigned long long)st.vram_leaks, (unsigned long long)st.nesting_repairs, + (unsigned long long)st.device_reads, st.last_device_read, st.last_device_read_width, (unsigned long long)st.verify_checks, (unsigned long long)st.verify_mismatch, (unsigned long long)st.dropped[RENDER_PASS_DROP_SPU], @@ -10520,6 +10523,34 @@ static void handle_present_shot_seq(int id, const char *json) id, present_shot_seq(), present_shot_ok()); } +/* Capture host text independently of a visible present surface. This makes + * diagnostic input rows and runtime toasts inspectable in hidden runs. */ +static void handle_host_osd_shot(int id, const char *json) +{ + char path[512]; + const uint32_t *pixels = NULL; + int w = 0, h = 0; + if (!json_get_str(json, "path", path, sizeof(path))) { + send_err(id, "need path"); return; + } + if (!host_osd_image(&pixels, &w, &h) || !pixels || w <= 0 || h <= 0) { + send_err(id, "no host OSD text image"); return; + } + uint8_t *rgb = (uint8_t *)malloc((size_t)w * h * 3); + if (!rgb) { send_err(id, "alloc failed"); return; } + for (size_t i = 0; i < (size_t)w * h; ++i) { + rgb[i*3] = (uint8_t)(pixels[i] >> 16); + rgb[i*3+1] = (uint8_t)(pixels[i] >> 8); + rgb[i*3+2] = (uint8_t)pixels[i]; + } + FILE *file = fopen(path, "wb"); + int ok = file && png_write_rgb(file, rgb, (uint32_t)w, (uint32_t)h); + if (file) fclose(file); + free(rgb); + if (!ok) { send_err(id, "png write failed"); return; } + send_fmt("{\"id\":%d,\"ok\":true,\"width\":%d,\"height\":%d}", id, w, h); +} + /* dump_buffer: dump a raw 512x240 VRAM region starting at display Y = `y` to a * PNG, regardless of what the game currently displays. Used to inspect BOTH * double-buffer halves (y=0 and y=256) coherently in one call to see whether a @@ -16083,6 +16114,7 @@ static const CmdEntry s_commands[] = { { "screenshot_hires", handle_screenshot_hires }, { "present_shot", handle_present_shot }, { "present_shot_seq", handle_present_shot_seq }, + { "host_osd_shot", handle_host_osd_shot }, { "display_ring_get", handle_display_ring_get }, { "display_ring_aux", handle_display_ring_aux }, { "display_ring_stats", handle_display_ring_stats }, diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 434f22a5..346bd612 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -3134,7 +3134,12 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ /* Overlay flow above the kernel window — kernel window stays per-block (see * is_local_dirty_target / phys_is_overlay_flow_region). Includes boot-text * pages overwritten by a runtime overlay (Tomba 2), not just [FLOOR, RAM). */ - int allow_local_dirty_flow = phys_is_overlay_flow_region(ram_phys); + /* A bounded span's runner owns call/return contracts. Local overlay + * chaining can directly enter a native callee and consume its return + * continuation past stop_addr (Tomba 2: into the following audio tick). + * Surface transfers to the runner; it may still call native callees with + * their exact return address. Ordinary overlay execution is unchanged. */ + int allow_local_dirty_flow = !forced && phys_is_overlay_flow_region(ram_phys); /* Backend-invariant mod_function_entry hooks: generated code fires * psx_mod_function_entry at listed function entries, but a mod-patched diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 8ed683e6..8101a9e8 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -2454,7 +2454,8 @@ static int ws_hud_command_words(uint32_t command_addr, uint32_t *words, if (!words || !out_count) return 0; if ((command_addr & 3u) != 0) return 0; uint32_t phys = command_addr & 0x1FFFFFFFu; - if (phys > psx_ram_live_bytes() - 4u) return 0; + if (phys > psx_ram_live_bytes() - 4u && + !psx_mod_gpu_dma_memory_contains(command_addr, 4u)) return 0; words[0] = psx_read_word(command_addr); uint8_t op = (uint8_t)(words[0] >> 24); @@ -2462,7 +2463,8 @@ static int ws_hud_command_words(uint32_t command_addr, uint32_t *words, int count = gp0_command_word_count(op); if (count <= 0 || count > 12) return 0; uint32_t bytes = (uint32_t)count * 4u; - if (phys > psx_ram_live_bytes() - bytes) return 0; + if (phys > psx_ram_live_bytes() - bytes && + !psx_mod_gpu_dma_memory_contains(command_addr, bytes)) return 0; for (int i = 1; i < count; i++) words[i] = psx_read_word(command_addr + (uint32_t)i * 4u); @@ -2495,7 +2497,7 @@ void gpu_ws_tag_hud_prim(uint32_t prim, int anchor) { uint32_t command_addr = prim + 4u; uint32_t words[12]; uint32_t word_count = 0; - if (!ws_native_wide_configured() || + if (!(ws_native_wide_configured() || ws_configured()) || (prim & 3u) != 0 || prim > UINT32_MAX - 4u || !ws_hud_command_words(command_addr, words, &word_count)) { ws_hud_tag_stats.tag_rejected++; @@ -2626,25 +2628,53 @@ static int ws_nw_radial_mask_transform(int32_t x[4], int32_t y[4]) { return 1; } -static int ws_nw_explicit_hud_delta(int32_t *out_delta) { - if (out_delta) *out_delta = 0; - if (!ws_native_wide_active() || gp0_cmd_source_addr == 0xFFFFFFFFu) +static int ws_explicit_hud_anchor(int *out_anchor) { + if (gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; if (gp0_words_needed <= 0 || gp0_words_needed > 12) return 0; uint32_t command_addr = gp0_cmd_source_addr & 0x1FFFFFFFu; - if (command_addr >= psx_ram_live_bytes() || (command_addr & 3u)) return 0; + if ((command_addr & 3u) || + (command_addr >= psx_ram_live_bytes() && + !psx_mod_gpu_dma_memory_contains(command_addr, 4u))) return 0; int anchor = 0; int result = ws_hud_anchor_lookup_result( - ws_hud_anchor_tags, WS_HUD_ANCHOR_TABLE_SIZE, command_addr, + ws_hud_anchor_tags, WS_HUD_ANCHOR_TABLE_SIZE, GPU_RAM_KEY(command_addr), gp0_cmd_buf, (uint32_t)gp0_words_needed, (uint32_t)s_frame_count, &anchor); ws_tag_stats_note_lookup(&ws_hud_tag_stats, result); if (result != WS_HUD_ANCHOR_HIT) return 0; - int32_t off = ws_nw_offset(); + if (out_anchor) *out_anchor = anchor; + return 1; +} + +static int ws_nw_explicit_hud_delta(int32_t *out_delta) { + if (out_delta) *out_delta = 0; + if (!ws_native_wide_active()) return 0; + int anchor; + if (!ws_explicit_hud_anchor(&anchor)) return 0; if (out_delta) - *out_delta = ws_hud_anchor_native_delta(1, off, anchor); + *out_delta = ws_hud_anchor_native_delta(1, ws_nw_offset(), anchor); + return 1; +} + +/* A title can identify one widget whose pieces arrive in separate DMA lists. + * Per-list auto grouping cannot see that ownership. Use the same guarded + * edge/centre role as native-wide, expressed as a squash pivot here. */ +static int ws_projection_hud_anchor(int32_t *out_anchor) { + if (!ws_active()) return 0; + int edge; + if (!ws_explicit_hud_anchor(&edge)) return 0; + if (out_anchor) + *out_anchor = ws_disp_x() + (edge < 0 ? 0 : edge > 0 ? ws_disp_w() : ws_disp_w() / 2); + return 1; +} + +static int ws_projection_hud_transform(int32_t *x, int count) { + int32_t anchor; + if (!ws_projection_hud_anchor(&anchor)) return 0; + for (int i = 0; i < count; i++) x[i] = ws_scale_about(x[i], anchor); return 1; } @@ -2742,6 +2772,9 @@ static void ws_screen_transform_quad(int32_t vx[4]) { * a direct hit too in case a tag site passes the colour-word address. */ static int ws_tagged_anchor(int32_t *out_ax) { if (!ws_active() || gp0_cmd_source_addr == 0xFFFFFFFFu) return 0; + /* Explicit screen-space ownership wins over a recycled billboard tag. + * The regular UI transform below will apply it exactly once. */ + if (ws_projection_hud_anchor(NULL)) return 0; uint32_t now = (uint32_t)s_frame_count; for (int variant = 0; variant < 2; variant++) { uint32_t key = GPU_RAM_KEY((gp0_cmd_source_addr - (variant ? 0u : 4u))); @@ -3099,6 +3132,7 @@ static int ws_axis_aligned_quad(const int32_t vx[4], const int32_t vy[4]) { * quad): squashed with its widget when the prepass admitted it as lying * inside that widget (ws_ui_prepass_finish). */ static int ws_auto_ui_transform_part(int32_t *vx, int32_t *vy, int n) { + if (ws_projection_hud_transform(vx, n)) return 1; if (ws_nw_explicit_hud_delta(NULL) || psx_ws_prim_is_tagged()) return 0; const WsUiPrepassItem *it = ws_auto_ui_item(); if (!it) return 0; @@ -3112,6 +3146,7 @@ static int ws_auto_ui_transform_part(int32_t *vx, int32_t *vy, int n) { } static int ws_auto_ui_transform_quad(int32_t vx[4], int32_t vy[4]) { + if (ws_projection_hud_transform(vx, 4)) return 1; if (ws_nw_explicit_hud_delta(NULL) || psx_ws_prim_is_tagged()) return 0; if (!ws_axis_aligned_quad(vx, vy)) return ws_auto_ui_transform_part(vx, vy, 4); @@ -3140,7 +3175,14 @@ static int ws_auto_ui_transform_quad(int32_t vx[4], int32_t vy[4]) { } static int ws_auto_ui_transform_rect(int32_t *x, int32_t *y, int *w, int *h) { - if (!x || !y || !w || !h || *w <= 0 || ws_nw_explicit_hud_delta(NULL) || psx_ws_prim_is_tagged()) + if (!x || !y || !w || !h || *w <= 0) return 0; + int32_t explicit_anchor; + if (ws_projection_hud_anchor(&explicit_anchor)) { + *x = ws_scale_about(*x, explicit_anchor); + *w = (int)ws_scale_len(*w); + return 1; + } + if (ws_nw_explicit_hud_delta(NULL) || psx_ws_prim_is_tagged()) return 0; int32_t X = ws_disp_x(), W = ws_disp_w(), H = ws_disp_h(); if ((*x <= X && *x + *w >= X + W && *y <= 0 && *y + *h >= H) || @@ -6241,6 +6283,10 @@ static int gp0_command_word_count(uint8_t opcode) { } } +int gpu_gp0_command_word_count(uint8_t opcode) { + return gp0_command_word_count(opcode); +} + static void ws_ui_prepass_add(const uint32_t *words, uint32_t word_count, uint32_t source_addr, uint16_t rank, int full_draw_area) { diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 7e43f190..7cb449e9 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -81,6 +81,7 @@ #include "gpu_hd_textures.h" #include "mod_texture_banks.h" #include "gl_batch_policy.h" +#include "mod_netplay.h" #include "frame_interpolation.h" #include "render_pass_plan.h" #include "render_pass.h" @@ -749,7 +750,7 @@ static GLuint s_tex_prog = 0, s_tex_vao = 0, s_tex_vbo = 0; * fragment shader read the noperspective varying — i.e. bit-identical to the * pre-feature pipeline. twin is the prim's GP0(E2h) texture window, its low 20 * bits as a whole float (mask x, mask y, offset x, offset y; 5 bits each). */ -#define TEXV 26 +#define TEXV 27 static GLuint s_blit_prog = 0, s_blit_vao = 0, s_blit_vbo = 0; static GLuint s_blit_hi_prog = 0; /* windowed hi surface blit */ static GLint s_uBhSrc = -1, s_uBhPass = -1, s_uBhMaskset = -1, s_uBhSrcDiv = -1; @@ -795,7 +796,7 @@ static GLint s_uVram = -1, s_uTpage = -1, s_uClut = -1, s_uDepth = -1; static GLint s_uHdTexture = -1; static GLint s_uPalette = -1; static GLint s_uRaw = -1, s_uSemipass = -1, s_uSemimode = -1; -static GLint s_uMaskset = -1, s_uFilter = -1; +static GLint s_uMaskset = -1; static GLint s_uLimits = -1; /* Native-wide x-projection uniforms (per program). u_xoff = x translation in * native px (0 canonical), u_xhalf = x clip half-extent in native px (512 @@ -1857,7 +1858,8 @@ static const char *GEO_FS = * vertices — instead of uniforms, so consecutive textured prims with the same * blend/mask state batch into one draw (see flush_tex_batch). The texture * window rides in the vertex too (a_twin), so a GP0(E2h) change need not end - * a batch. The remaining uniforms (u_maskset/u_filter/u_semipass) are the + * a batch. Sampling mode also travels per primitive, so sharp UI and filtered + * world geometry can share a draw. The remaining uniforms (u_maskset/u_semipass) are the * batch keys + per-pass state. */ static const char *TEX_VS = "#version 330\n" @@ -1874,6 +1876,7 @@ static const char *TEX_VS = "layout(location=10) in float a_twin; /* GP0(E2h) bits 0..19 */\n" "layout(location=11) in vec4 a_hd_source; /* page origin + native extent */\n" "layout(location=12) in float a_hd_mode;\n" + "layout(location=13) in float a_filter; /* 0 nearest, 1 bilinear, 2 stable */\n" "uniform float u_shift;\n" "uniform float u_xoff; /* native-wide x translation (px); 0 canonical */\n" "uniform float u_xhalf; /* x clip half-extent (px); 512 canonical */\n" @@ -1887,6 +1890,7 @@ static const char *TEX_VS = "flat out int v_raw; flat out ivec4 v_limits; flat out int v_semi;\n" "flat out int v_twin;\n" "flat out vec4 v_hd_source; flat out int v_hd_mode;\n" + "flat out int v_filter;\n" "smooth out vec3 v_col_p; flat out int v_cp;\n" "void main(){ v_uv = a_uv; v_uv_p = a_uv; v_col = a_col; v_col_p = a_col.rgb;\n" " v_persp = (a_q > 0.0) ? 1 : 0;\n" @@ -1894,6 +1898,7 @@ static const char *TEX_VS = " v_depth = int(a_depth + 0.5); v_raw = int(a_raw + 0.5);\n" " v_semi = int(a_semi + 0.5);\n" " v_twin = int(a_twin + 0.5);\n" + " v_filter = int(a_filter + 0.5);\n" " v_hd_source=a_hd_source; v_hd_mode=int(a_hd_mode+0.5);\n" " v_limits = ivec4(floor(a_limits + 0.5));\n" " /* u_shift: align GL's center-sample grid with the PS1 integer grid (see\n" @@ -1931,6 +1936,7 @@ static const char *TEX_FS = "flat in ivec4 v_limits; /* prim uv sampling bounds (inclusive, post-wrap) */\n" "flat in int v_semi; /* GP0 command has semi-transparency enabled */\n" "flat in int v_twin; /* texture window, GP0(E2h) bits 0..19 */\n" + "flat in int v_filter; /* sampling mode belongs to this primitive */\n" "flat in vec4 v_hd_source; flat in int v_hd_mode;\n" "uniform sampler2D u_hd_texture;\n" "uniform usampler2D u_vram;\n" @@ -2077,7 +2083,7 @@ static const char *TEX_FS = "}\n" "void main(){\n" " int stp; vec3 rgb;\n" - " int fmode = u_filter & 15;\n" + " int fmode = v_filter & 15;\n" " /* v_persp is 0 for every prim unless [video] perspective_texturing is on\n" " * AND this prim's packet carried full GTE projection provenance, so the\n" " * default is the PS1's affine (noperspective) mapping. */\n" @@ -3412,10 +3418,10 @@ static void wide_clear_bd_scale(GLint uScale, GLint uCenter) { } /* ---- textured-prim batching -------------------------------------------- * - * Consecutive textured prims sharing blend/mask/texwindow/filter coalesce into + * Consecutive textured prims sharing blend/mask/texwindow coalesce into * one draw. Per-prim texture state (texpage/clut/depth/raw/uv-limits/texture - * window) rides in the vertex (TEXV flat attributes), so only `semi` (blend) - * and the global mask/filter are batch keys, plus the texture window unless + * window/filter) rides in the vertex (TEXV flat attributes), so only `semi` (blend) + * and the global mask are batch keys, plus the texture window unless * texture-window batching is on (below). flush_tex_batch() draws the queued verts; it * is called before any op that reads VRAM, writes it outside the batch, or * changes batch state (see callers: flush_cpu_upload, flush_pack_if_sampling, @@ -3690,7 +3696,6 @@ typedef struct { int8_t semi; /* tex: batch semi key; geo: blend mode, -1 opaque */ uint8_t mask; /* tex: batch set-mask; geo: stencil write value */ uint8_t check; /* mask-check state at draw time */ - uint8_t filter; GLuint tex; /* sampled texture (bank or raw mirror) */ GLuint palette, hd_tex; int ax0, ay0, ax1, ay1; /* draw area, inclusive */ @@ -3787,7 +3792,6 @@ static int hiw_enqueue_tex(int nverts, int semi, int mirror, int gate) { if (!c) { hiw_flush_queue(); return 0; } c->semi = (int8_t)semi; c->mask = (uint8_t)s_tb_mask; - c->filter = (uint8_t)s_tb_filter; c->tex = s_tb_bank_tex ? s_tb_bank_tex : s_raw_tex; c->palette = s_tb_bank_tex && !s_tb_bank_live_clut ? s_tb_bank_tex : s_raw_tex; c->hd_tex = s_tb_hd_tex; @@ -3846,7 +3850,6 @@ static void hiw_replay_wide(void) { p_glUniform1f(s_tex_uXcenter, c->wcenter); bind_textured_resources(c->tex, c->palette, c->hd_tex); p_glUniform1i(s_uMaskset, c->mask); - p_glUniform1i(s_uFilter, c->filter); p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)((size_t)c->vcount * TEXV * sizeof(float)), s_hq_v + c->vfirst, PSXGL_STREAM_DRAW); depth_apply_ex(c->depth, 1); @@ -3931,7 +3934,6 @@ static void hiw_flush_queue(void) { } bind_textured_resources(c->tex, c->palette, c->hd_tex); p_glUniform1i(s_uMaskset, c->mask); - p_glUniform1i(s_uFilter, c->filter); p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)((size_t)c->vcount * TEXV * sizeof(float)), s_hq_v + c->vfirst, PSXGL_STREAM_DRAW); depth_apply_ex(c->depth, 1); @@ -3991,7 +3993,6 @@ static void flush_tex_batch_impl(void) { bind_textured_resources(s_tb_bank_tex ? s_tb_bank_tex : s_raw_tex, s_tb_bank_tex && !s_tb_bank_live_clut ? s_tb_bank_tex : s_raw_tex, s_tb_hd_tex); p_glUniform1i(s_uMaskset, s_tb_mask); - p_glUniform1i(s_uFilter, s_tb_filter); p_glBindVertexArray(s_tex_vao); p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_tex_vbo); p_glBufferData(PSXGL_ARRAY_BUFFER, (ptrdiff_t)(nverts * TEXV * sizeof(float)), s_tb, PSXGL_STREAM_DRAW); @@ -4674,8 +4675,9 @@ static void gpu_textured_triangle(const int *xs, const int *ys, } mark_prim_dirty(xs, ys, 3, 1 /* textured */); - /* Append to the textured batch. Flush first if this prim's blend/mask/twin/ - * filter differ from the open batch, or the buffer is full. Per-prim texture + /* Append to the textured batch. Flush first if this prim's blend/mask/twin + * differ from the open batch, or the buffer is full. Under mask checking, + * retain filter boundaries for stencil visibility. Per-prim texture * state goes in the vertex; only these keys force a new draw (the window * not at all under texture-window batching, see s_twin_batching). */ { @@ -4697,7 +4699,10 @@ static void gpu_textured_triangle(const int *xs, const int *ys, else if (isolate) reason = 0; else if (batch_semi != s_tb_semi) reason = 1; else if (s_mask_set != s_tb_mask) reason = 2; - else if (filter != s_tb_filter) reason = 3; + /* With destination-mask checking, a batch's STP stencil fixup + * happens after its color pass. Keep the old filter boundary so + * a later primitive observes an earlier primitive's mask. */ + else if (s_mask_check && filter != s_tb_filter) reason = 3; else if (gate != s_tb_gate) reason = 4; else if ((!s_twin_batching || s_mask_check) && (twx != s_tb_twin[0] || twy != s_tb_twin[1] || @@ -4737,6 +4742,7 @@ static void gpu_textured_triangle(const int *xs, const int *ys, vp[21] = (float)hd.origin_u; vp[22] = (float)hd.origin_v; vp[23] = (float)hd.source_width; vp[24] = (float)hd.source_height; vp[25] = (float)hd.alpha_mode; + vp[26] = (float)filter; /* per-primitive filter */ } if (pgxp_tri_is_3d()) { float *t0 = &s_tb[s_tb_n * TEXV]; @@ -6009,6 +6015,7 @@ static int init_gpu_raster(void) { p_glVertexAttribPointer(10, 1, GL_FLOAT, GL_FALSE, st, (void*)(20*sizeof(float))); p_glEnableVertexAttribArray(10); /* twin */ p_glVertexAttribPointer(11, 4, GL_FLOAT, GL_FALSE, st, (void*)(21*sizeof(float))); p_glEnableVertexAttribArray(11); /* HD source */ p_glVertexAttribPointer(12, 1, GL_FLOAT, GL_FALSE, st, (void*)(25*sizeof(float))); p_glEnableVertexAttribArray(12); /* HD alpha mode */ + p_glVertexAttribPointer(13, 1, GL_FLOAT, GL_FALSE, st, (void*)(26*sizeof(float))); p_glEnableVertexAttribArray(13); /* filter */ } p_glGenVertexArrays(1, &s_blit_vao); @@ -6064,7 +6071,13 @@ static int init_gpu_raster(void) { return 1; } -int gl_renderer_texture_banks_supported(void) { return s_raster_ok && !s_cpu_auth_dual && !s_hd_native_authority; } +int gl_renderer_texture_banks_supported(void) { + /* A trusted title's bank pixels affect GL presentation only. Keep the + * existing software @1x VRAM authority during its delay-sync session. */ + return s_raster_ok && !s_hd_native_authority && + (!s_cpu_auth_dual || (psx_mod_netplay_is_active() && + psx_mod_netplay_requires_texture_banks())); +} int gl_renderer_fit_wide_aspect(int disp_w, int *num, int *den) { /* No sync point: pure arithmetic on limits that change only while the @@ -7649,13 +7662,15 @@ static void interp_present_source_interval(void) { * rect; its phases then map onto host time from that interval's start. At * each output deadline the presenter shows the newest image at or before the * deadline's phase, crossfading to the next one when passes were shed. Two - * generations exist: the one on screen and the one being built for the next - * flip. */ + * generations can wait behind the one on screen: IRQ-driven flips can become + * visible to the host after the game has already drawn its next frame. */ #define PASS_SLOTS (RENDER_PASS_MAX_PHASES + 1u) +#define PASS_GENERATIONS 3 typedef struct PassGen { int valid; int promoted; + uint64_t order,created_cycle; int x, y, w, h; /* captured VRAM rect (what is presented) */ int rx, ry, rw, rh; /* the plugin's pass rect (backed up) */ int tex_w, tex_h; /* slot size (hr pixels; wide band if wide) */ @@ -7667,7 +7682,8 @@ typedef struct PassGen { double t_start, t_len; /* host ticks, set on promotion */ } PassGen; static int s_pass_flip_shown = 0; -static PassGen s_pgen[2]; +static PassGen s_pgen[PASS_GENERATIONS]; +static uint64_t s_pgen_order; /* Double-buffer complete pairs: the unpublished set may be overwritten while * the last complete pair remains visible. No temporal phases or generation. */ typedef struct StereoPair { @@ -7693,13 +7709,14 @@ static uint32_t s_stereo_capture_count; static int s_present_crop_dx = 0, s_present_crop_dy = 0; static int s_present_crop_w = 0, s_present_crop_h = 0; /* Slot textures are made as slots fill: [0, s_pgen_alloc_n) exist, all at - * s_pgen_alloc_w x h. A generation never fills past pass_slot_cap, so two + * s_pgen_alloc_w x h. A generation never fills past pass_slot_cap, so three * generations stay inside its budget whatever the internal scale. */ -static GLuint s_pgen_tex[2][PASS_SLOTS]; -static uint32_t s_pgen_alloc_n[2]; -static int s_pgen_alloc_w[2], s_pgen_alloc_h[2]; +static GLuint s_pgen_tex[PASS_GENERATIONS][PASS_SLOTS]; +static uint32_t s_pgen_alloc_n[PASS_GENERATIONS]; +static int s_pgen_alloc_w[PASS_GENERATIONS], s_pgen_alloc_h[PASS_GENERATIONS]; static int s_pgen_cur = 0; -static int s_pgen_promote = 0; +static int s_pgen_write = 1; +static int s_pgen_promote = -1; static uint64_t s_pgen_promotions = 0, s_pgen_presents = 0, s_pgen_blends = 0; static uint64_t s_pgen_expired = 0, s_pgen_unmatched = 0, s_pgen_early = 0; static uint64_t s_pgen_late = 0; /* presents past the frame's planned end */ @@ -7760,9 +7777,8 @@ uint64_t gl_renderer_perf_ticks(void) { return SDL_GetPerformanceCounter(); } uint64_t gl_renderer_perf_frequency(void) { return SDL_GetPerformanceFrequency(); } static void pass_gens_invalidate(void) { - s_pgen[0].valid = s_pgen[1].valid = 0; - s_pgen[0].promoted = s_pgen[1].promoted = 0; - s_pgen_promote = 0; + for(int i=0;i 0.0 ? (uint32_t)((256.0 * 1024.0 * 1024.0) / bytes) : PASS_SLOTS; if (cap > PASS_SLOTS) cap = PASS_SLOTS; @@ -8123,8 +8139,7 @@ static void pass_resources_release(void) { } s_xr_color_prog=s_xr_native_tex=0; stereo_resources_release(); - pass_gen_release(0); - pass_gen_release(1); + for(int gi=0;givalid; @@ -8231,7 +8251,7 @@ static int transaction_begin(int x, int y, int w, int h, int open_gen, s_pass_begin_diag.wide = wide; s_pass_begin_diag.requested_w = tw; s_pass_begin_diag.requested_h = th; - gi = 1 - s_pgen_cur; + gi = s_pgen_write; g = &s_pgen[gi]; if (local) { /* No generation: the image stays in the presenter surface. */ @@ -8244,6 +8264,7 @@ static int transaction_begin(int x, int y, int w, int h, int open_gen, if (!pass_gen_reserve(gi, 1u, tw, th)) return pass_begin_refuse("generation_reserve"); memset(g, 0, sizeof *g); + g->order=++s_pgen_order;g->created_cycle=psx_cycle_count; g->x = cx; g->y = cy; g->w = cw; g->h = ch; g->rx = x; g->ry = y; g->rw = w; g->rh = h; g->tex_w = tw; g->tex_h = th; @@ -8333,7 +8354,7 @@ backed_up: /* keep_color: a committed netplay local view keeps the rect's presented * colour (hr surface, native-wide band); everything else is put back. */ static void transaction_restore_ex(int keep_color) { - int S = s_hr_scale, gi = 1 - s_pgen_cur; + int S = s_hr_scale, gi = s_pgen_write; const GLbitfield hr_bits = keep_color ? GL_STENCIL_BUFFER_BIT : GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT; (void)gi; @@ -8425,7 +8446,7 @@ int gl_renderer_stereo_begin(int x, int y, int w, int h, int reuse) { } void gl_renderer_pass_end(uint32_t alpha_q16, int keep) { GL_RT_SYNC("pass_end"); - int gi = 1 - s_pgen_cur; + int gi = s_pgen_write; PassGen *g = &s_pgen[gi]; if (!s_pass_active) return; flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); @@ -8809,13 +8830,13 @@ static int pass_gen_present(uint64_t deadline) { /* Past phase 1 the next flip is late (a lagging tick): the newest image * holds until it comes. Only a game that stops flipping for several * frame lengths expires the generation. */ - if (!render_pass_gen_select(g->phase, g->n, p, &lo, &hi, &t)) { + if (!render_pass_gen_select_mode(g->phase, g->n, p, s_interp_hold, &lo, &hi, &t)) { g->valid = 0; s_pgen_expired++; return 0; } if (!interp_present_pair(s_pgen_tex[s_pgen_cur][lo], - s_pgen_tex[s_pgen_cur][hi], t, 0)) + s_pgen_tex[s_pgen_cur][hi], t, s_interp_blend_mode)) return 0; s_pgen_presents++; if (lo != hi) s_pgen_blends++; @@ -8865,10 +8886,11 @@ uint64_t gl_renderer_pass_backups_reused(void) { uint32_t gl_renderer_pass_image_textures(uint64_t *bytes) { GL_RT_SYNC("pass_image_textures"); - uint32_t n = s_pgen_alloc_n[0] + s_pgen_alloc_n[1]; + uint32_t n = 0; + for(int gi=0;givalid && !pend->promoted && - render_pass_gen_flip_matches(pend->shown, pend->x, pend->y, - pend->source_path, pend->tex_w, pend->tex_h, - origin_x, origin_y, source_path, pw, ph)) { - s_pgen_promote = 1; - } else { - s_pgen_promote = 0; + uint64_t order[PASS_GENERATIONS];uint32_t matches=0; + for(int i=0;iorder; + if(!pend->valid || pend->promoted)continue; + if(psx_cycle_countcreated_cycle || psx_cycle_count-pend->created_cycle> + (uint64_t)pend->period*g_psx_vblank_cycles*4u){pend->valid=0;continue;} + if(render_pass_gen_flip_matches(pend->shown,pend->x,pend->y, + pend->source_path,pend->tex_w,pend->tex_h,origin_x,origin_y,source_path,pw,ph))matches|=1u< OSD_MAX_CHARS) n = OSD_MAX_CHARS; + int n = 0, columns = 0, lines = 1; + for(const char *p=msg;*p && lines<=3;++p) { + if(*p=='\n') {if(columns>n)n=columns;columns=0;++lines;} + else if(columnsn)n=columns; + if(lines>3)lines=3; s_img_w = (OSD_PAD_X * 2 + n * OSD_GLYPH_W) * OSD_SCALE; - s_img_h = (OSD_PAD_Y * 2 + OSD_GLYPH_H) * OSD_SCALE; + s_img_h = (OSD_PAD_Y * 2 + OSD_GLYPH_H * lines) * OSD_SCALE; if (s_img_w < 1) s_img_w = 1; if (s_img_h < 1) s_img_h = 1; if (s_img_w > OSD_IMG_W) s_img_w = OSD_IMG_W; @@ -214,8 +222,11 @@ static void rasterize_text(const char *msg) { for (int i = 0; i < s_img_w * s_img_h; i++) s_img[i] = 0xFF202020u; - for (int ci = 0; ci < n; ci++) { - unsigned char ch = (unsigned char)msg[ci]; + int ci=0,line=0; + for (const char *p=msg;*p && line<3;++p) { + unsigned char ch = (unsigned char)*p; + if(ch=='\n') {ci=0;++line;continue;} + if(ci>=OSD_MAX_CHARS)continue; if (ch < 32 || ch > 126) ch = '?'; const uint8_t *g = FONT8X8[ch - 32]; for (int row = 0; row < OSD_GLYPH_H; row++) { @@ -223,7 +234,7 @@ static void rasterize_text(const char *msg) { for (int col = 0; col < OSD_GLYPH_W; col++) { if (!(bits & (1u << col))) continue; int x0 = (OSD_PAD_X + ci * OSD_GLYPH_W + col) * OSD_SCALE; - int y0 = (OSD_PAD_Y + row) * OSD_SCALE; + int y0 = (OSD_PAD_Y + line*OSD_GLYPH_H + row) * OSD_SCALE; for (int dy = 0; dy < OSD_SCALE; dy++) { for (int dx = 0; dx < OSD_SCALE; dx++) { int x = x0 + dx, y = y0 + dy; @@ -234,6 +245,7 @@ static void rasterize_text(const char *msg) { } } } + ++ci; } s_img_dirty = 0; } @@ -361,6 +373,21 @@ void host_osd_set_status(const char *msg) { #endif } +void host_osd_set_diagnostics(const char *first,const char *second) { +#if !HOST_OSD_VISUAL + (void)first;(void)second; +#else + char rows[2][OSD_MAX_CHARS]={{0}}; + snprintf(rows[0],sizeof rows[0],"%s",first?first:""); + snprintf(rows[1],sizeof rows[1],"%s",second?second:""); + if(!memcmp(rows,s_diagnostic_msg,sizeof rows))return; + memcpy(s_diagnostic_msg,rows,sizeof rows); + int active=rows[0][0] || rows[1][0]; + if(s_diagnostic_active && !active)s_needs_clear=1; + s_diagnostic_active=active;s_diagnostic_dirty=1; +#endif +} + void host_osd_show_volume(int percent, int duration_ms) { #if !HOST_OSD_VISUAL (void)percent; @@ -398,7 +425,7 @@ int host_osd_needs_present(void) { #if !HOST_OSD_VISUAL return 0; #else - if (msg_visible() || s_status_active || vol_visible()) return 1; + if (msg_visible() || s_status_active || s_diagnostic_active || vol_visible()) return 1; return s_needs_clear; #endif } @@ -411,19 +438,20 @@ int host_osd_image(const uint32_t **pixels, int *w, int *h) { return 0; #else const int show_msg = msg_visible(); - if (!show_msg && !s_status_active) { + if (!show_msg && !s_status_active && !s_diagnostic_active) { if (pixels) *pixels = NULL; if (w) *w = 0; if (h) *h = 0; return 0; } - if (show_msg) { - if (s_img_dirty) rasterize_text(s_msg); - } else { - if (s_status_dirty || s_img_dirty) { - rasterize_text(s_status_msg); - s_status_dirty = 0; - } + if(s_img_dirty || s_status_dirty || s_diagnostic_dirty) { + char text[3*OSD_MAX_CHARS+3]; + const char *main=show_msg?s_msg:(s_status_active?s_status_msg:""); + snprintf(text,sizeof text,"%s%s%s%s%s",main, + main[0] && s_diagnostic_msg[0][0]?"\n":"",s_diagnostic_msg[0], + (main[0] || s_diagnostic_msg[0][0]) && s_diagnostic_msg[1][0]?"\n":"", + s_diagnostic_msg[1]); + rasterize_text(text);s_status_dirty=s_diagnostic_dirty=0; } if (pixels) *pixels = s_img; if (w) *w = s_img_w; @@ -457,7 +485,7 @@ void host_osd_present_done(void) { #if !HOST_OSD_VISUAL return; #else - if (!s_active && !s_status_active && !s_vol_active) s_needs_clear = 0; + if (!s_active && !s_status_active && !s_diagnostic_active && !s_vol_active) s_needs_clear = 0; #endif } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 09b644cf..ab294b0f 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -6,6 +6,8 @@ */ #include "cpu_state.h" +#include "mod_netplay.h" +#include "execution_profile.h" #include "projection_scale.hpp" #include "projection_scale_config.hpp" #include "mod_controller_source.h" @@ -424,6 +426,7 @@ struct PlayerInput { int deadzone = 3277; /* raw SDL axis units, ~10% default */ SDL_GameController* handle = nullptr; SDL_JoystickID instance = -1; + SDL_JoystickID preferred_instance = -1; /* live launcher choice only */ uint8_t rumble_small = 0; uint8_t rumble_large = 0; bool rumble_known = false; @@ -1455,6 +1458,9 @@ extern "C" int psx_mod_set_host_rumble(uint32_t player, uint32_t small, psx_host_rumble_set(&g_mod_rumble[player], g_rumble_vblank, small, large); return 1; } +extern "C" void psx_mod_set_savestate_blocked(int blocked) { + savestate_set_title_blocked(blocked); +} static uint32_t g_savestate_input_guard_min_until = 0; static uint32_t g_savestate_input_guard_max_until = 0; static int g_headless = 0; /* debug/CI frontend: no SDL window/audio */ @@ -1619,6 +1625,19 @@ static bool ensure_sdl_pixel_buf_capacity(size_t pixels) { * session vanilla (mods cleared), the LAN MOTK1 wire protocol, lobby caps * without mod fields, and the neutral headless idle pad. */ static int g_netplay_content_negotiation = 0; +static int title_netplay_aspect_index(int num, int den) { + if (num <= 0 || den <= 0) return 0; + return num * 9 == den * 21 ? 2 : num * 9 == den * 16 ? 1 : 0; +} +static void title_netplay_fix_caps_view(PsxLobbyMatchCaps* caps) { + const PSXModNetplayProfile* profile = psx_mod_netplay_profile(); + if (!caps || !profile || !profile->fixed_aspect_mask) return; + const int index = title_netplay_aspect_index(caps->aspect_num, caps->aspect_den); + if (!psx_mod_netplay_set_aspect(index)) (void)psx_mod_netplay_set_aspect(0); + const int selected = psx_mod_netplay_aspect(); + caps->aspect_num = selected == 2 ? 21 : selected == 1 ? 16 : 4; + caps->aspect_den = selected ? 9 : 3; +} /* Install the netplay session's mod plan without touching the persisted * offline selection. Default: vanilla (clear every mod). Opted in: the * host-published lobby plan, or the locally selected verified plan for a @@ -1626,6 +1645,14 @@ static int g_netplay_content_negotiation = 0; * fingerprint before guest execution). */ static bool netplay_commit_mods(const std::filesystem::path& disc, std::string* error) { + if (psx_mod_netplay_profile()) { + const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); + const int num = caps && caps->valid ? caps->aspect_num : g_video_aspect_num; + const int den = caps && caps->valid ? caps->aspect_den : g_video_aspect_den; + const int index = title_netplay_aspect_index(num, den); + if (!psx_mod_netplay_set_aspect(index)) (void)psx_mod_netplay_set_aspect(0); + return PSXRecompV4::mod_runtime_commit_netplay(disc, error); + } if (!g_netplay_content_negotiation) return PSXRecompV4::mod_runtime_commit_netplay_view(disc, error); const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); @@ -5199,10 +5226,29 @@ static void open_player(PlayerInput& p, int self_slot) { SDL_JoystickGUID g = SDL_JoystickGetDeviceGUID(i); char buf[40] = {0}; SDL_JoystickGetGUIDString(g, buf, sizeof(buf)); - /* Skip a device already opened by another player. */ + /* Development seats may explicitly choose the same physical pad. + * Auto/fallback allocation still reserves a separate device per seat. */ SDL_JoystickID inst = SDL_JoystickGetDeviceInstanceID(i); - if (device_claimed_by_other(self_slot, inst)) continue; - if (p.guid[0] && std::strcmp(buf, p.guid) == 0) { chosen = i; break; } + bool shared = false; +#if !defined(PSX_NO_DEBUG_TOOLS) + shared = PSXRecompV4::launcher_controller_can_share( + true, p.guid[0] && std::strcmp(buf, p.guid) == 0); +#endif + if (device_claimed_by_other(self_slot, inst) && !shared) continue; + /* Do not let an earlier seat's fallback steal a later seat's explicit + * selection. Two pads of the same model have the same GUID. */ + bool reserved = false; + for (int o = 0; o < PSX_MAX_PLAYERS; ++o) { + if (o != self_slot && g_players[o].kind == 2 && + g_players[o].preferred_instance == inst) reserved = true; + } + if (reserved && p.preferred_instance != inst && !shared) continue; + if (p.preferred_instance == inst && + (!p.guid[0] || std::strcmp(buf, p.guid) == 0)) { + chosen = i; + break; + } + if (chosen < 0 && p.guid[0] && std::strcmp(buf, p.guid) == 0) chosen = i; if (fallback < 0) fallback = i; } if (chosen < 0) chosen = fallback; @@ -5212,6 +5258,7 @@ static void open_player(PlayerInput& p, int self_slot) { if (p.handle) { SDL_Joystick* joy = SDL_GameControllerGetJoystick(p.handle); p.instance = joy ? SDL_JoystickInstanceID(joy) : -1; + p.preferred_instance = p.instance; /* Persist the GUID of the pad we actually opened so per-device * [mapping.] lookups match the live hardware (including the * first-available fallback when the saved GUID is offline). */ @@ -5221,7 +5268,8 @@ static void open_player(PlayerInput& p, int self_slot) { } const char* name = SDL_GameControllerName(p.handle); p.steering_wheel = psx_controller_name_is_wheel(name); - std::fprintf(stdout, "psxrecomp runtime: opened controller for slot: %s\n", + std::fprintf(stdout, "psxrecomp runtime: opened controller for slot %d (instance %d): %s\n", + self_slot + 1, (int)p.instance, name ? name : "(unnamed)"); if (p.steering_wheel) std::fprintf(stdout, @@ -6099,8 +6147,10 @@ static uint16_t host_layer_release_claimed(uint16_t word, uint32_t claimed) { * (host_view, the transform's host pad: the sticks in either mode). */ static int capture_host_layer_pad(PsxNetPad* out, uint16_t word, const uint8_t st[4], bool guarded, - bool host_view) { - const int mode = effective_player_mode_for_sio(g_players[0], 0); + bool host_view, int present_sio_slot = 0) { + const int present = present_sio_slot >= 0 && present_sio_slot < PSX_MAX_PLAYERS + ? present_sio_slot : 0; + const int mode = effective_player_mode_for_sio(g_players[0], present); const bool analog = mode != PSXRecompV4::PAD_MODE_DIGITAL; const bool sticks = analog || host_view; out->buttons = host_layer_release_claimed(word, g_p1_claimed_buttons); @@ -6241,6 +6291,16 @@ static int capture_pad_slot_exclusive(int s, PsxNetPad* out, int present_sio_slo out->analog = 0; out->connected = 0; + /* An explicit virtual host device follows the same local-device -> session + * seat route as a physical controller. It is not a simulation SIO override. */ + if (s == 0) { + uint16_t word; + uint8_t sticks[4], lt, rt; + if (host_pad_layer(&word, sticks, <, &rt)) + return capture_host_layer_pad(out, word, sticks, false, false, + present_sio_slot); + } + PlayerInput& p = g_players[s]; const int player = s + 1; /* keybinds.ini section (1..5) */ const bool dev_here = false; @@ -6769,6 +6829,10 @@ static void netplay_barrier_admit(int override) { (tip_hold && !psx_start_bisect_no_tiphold_capture()); if (need_sample) { PsxNetPad local{}; + uint16_t host_word; + uint8_t host_sticks[4], host_lt, host_rt; + const bool virtual_host = g_headless && + host_pad_layer(&host_word, host_sticks, &host_lt, &host_rt); /* An explicit debug-server override is the local player's input * windowed or headless: headless LAN peers are how netplay is * exercised without windows. With no override, headless has no @@ -6776,8 +6840,11 @@ static void netplay_barrier_admit(int override) { * into content negotiation, whose headless fixtures use the same * negotiated pad mode and local-to-session routing as windowed * injected input. */ - if (override >= 0 || (g_headless && g_netplay_content_negotiation)) { + if (override >= 0 || (g_headless && g_netplay_content_negotiation && !virtual_host)) { capture_override_pad(override >= 0 ? override : 0xFFFF, &local); + } else if (virtual_host) { + capture_host_layer_pad(&local, host_word, host_sticks, false, false, + psx_netplay_local_slot()); } else if (g_headless) { local.buttons = 0xFFFFu; local.lx = local.ly = local.rx = local.ry = 0x80u; @@ -7630,6 +7697,10 @@ static void savestate_menu_move(int delta) { } static int savestate_submit_slot(int slot, int save) { + if (savestate_title_blocked() || !psx_mod_netplay_savestates_supported()) { + host_osd_push("Save states are unavailable in this game mode", 1800); + return 0; + } if (!save && !savestate_slot_exists(slot)) { char msg[32]; snprintf(msg, sizeof(msg), "Slot %d is empty", slot + 1); @@ -11007,6 +11078,9 @@ namespace { } static void ae_np_lan_udp_close(void) { + if (std::getenv("PSX_NET_LOBBY_TRACE")) + std::fprintf(stderr, "LAN trace: close socket (host=%d guest=%d)\n", + g_lnch_hosting_lan, g_lnch_joined_lan); ae_np_lan_sock_close(&g_lnch_lan_udp); for (int i = 0; i < kAeLanMaxSlots; ++i) g_lnch_lan_peer_ok[i] = false; @@ -11253,6 +11327,10 @@ namespace { } static void ae_np_lan_udp_sendto(const sockaddr_in& to, const char* msg) { + if (std::getenv("PSX_NET_LOBBY_TRACE") && msg) + std::fprintf(stderr, "LAN trace: send %.*s to %08x:%u\n", + (int)std::strcspn(msg, "\n"), msg, + (unsigned)ntohl(to.sin_addr.s_addr), (unsigned)ntohs(to.sin_port)); if (g_lnch_lan_udp == kAeLanSockInvalid || !msg) return; const int n = (int)std::strlen(msg); #ifdef _WIN32 @@ -12510,6 +12588,34 @@ namespace { caps->mod_plan_fp[0] = '\0'; caps->mod_count = 0; std::memset(caps->mods, 0, sizeof(caps->mods)); + if (const PSXModNetplayProfile* profile = psx_mod_netplay_profile()) { + if (!profile->settings_package_id) return 1; + RecompLauncherCModPackage package{}; + if (!mods || !mods->feature_count || !mods->feature_get || + !ae_mod_package_get_by_id(profile->settings_package_id, &package)) { + g_lnch_mod_plan_error = "Title netplay settings package is unavailable"; + return 0; + } + for (int index = 0; index < mods->feature_count(mods->ctx); ++index) { + RecompLauncherCModFeature feature{}; + if (!mods->feature_get(mods->ctx, index, &feature) || + std::strcmp(feature.package_id, profile->settings_package_id) || + std::strcmp(feature.id, profile->settings_feature_id)) continue; + ae_fill_lobby_mod_pkg_from_package(&caps->mods[0], package); + char token[PSX_LOBBY_MOD_FEATS_LEN] = {}; + ae_copy_lobby_text(token, sizeof(token), feature.id); + ae_append_feature_options(token, sizeof(token), mods, feature); + ae_copy_lobby_text(caps->mods[0].feats, sizeof(caps->mods[0].feats), token); + caps->mod_count = 1; + std::string fingerprint; + if (!PSXRecompV4::mod_runtime_netplay_settings_fingerprint( + *caps, fingerprint, &g_lnch_mod_plan_error)) return 0; + ae_copy_lobby_text(caps->mod_plan_fp, sizeof(caps->mod_plan_fp), fingerprint.c_str()); + return 1; + } + g_lnch_mod_plan_error = "Title netplay settings feature is unavailable"; + return 0; + } if (!PSXRecompV4::mod_runtime_prepare_resources(g_launcher_disc_path, &g_lnch_mod_plan_error)) return 0; if (!mods || !mods->package_count || !mods->package_get) @@ -12576,6 +12682,7 @@ namespace { case 1: caps.aspect_num = 16; caps.aspect_den = 9; break; default: caps.aspect_num = 4; caps.aspect_den = 3; break; } + title_netplay_fix_caps_view(&caps); caps.turbo_loads = s ? (s->turbo_loads != 0) : 0; caps.auto_skip_fmv = s ? (s->auto_skip_fmv != 0) : 0; caps.input_delay = g_lnch_lobby_input_delay; @@ -12587,7 +12694,7 @@ namespace { caps.force_input_relay = g_lnch_force_input_relay != 0; caps.relay_host = g_lnch_relay_host != 0; caps.force_turn = g_lnch_force_turn != 0; - caps.rollback = g_lnch_rollback != 0; + caps.rollback = g_lnch_rollback != 0 && psx_mod_netplay_rollback_supported(); caps.multitap_analog = g_lnch_multitap_analog != 0; if (s) caps.multitap_analog = s->multitap_analog != 0; caps.guest_memcard = g_lnch_guest_memcard != 0; @@ -12638,6 +12745,7 @@ namespace { : ae_netplay_caps_from_settings(settings); caps.valid = 1; caps.input_delay = g_lnch_lobby_input_delay; + title_netplay_fix_caps_view(&caps); if (caps.input_delay < 2) caps.input_delay = 2; if (caps.input_delay > 20) caps.input_delay = 20; caps.input_prediction = g_lnch_lobby_input_prediction; @@ -12646,7 +12754,7 @@ namespace { caps.force_input_relay = g_lnch_force_input_relay != 0; caps.relay_host = g_lnch_relay_host != 0; caps.force_turn = g_lnch_force_turn != 0; - caps.rollback = g_lnch_rollback != 0; + caps.rollback = g_lnch_rollback != 0 && psx_mod_netplay_rollback_supported(); caps.multitap_analog = g_lnch_multitap_analog != 0; if (settings) caps.multitap_analog = settings->multitap_analog != 0; caps.guest_memcard = g_lnch_guest_memcard != 0; @@ -13252,6 +13360,7 @@ namespace { return 0; } int ae_np_rollback_get(void*) { + if (!psx_mod_netplay_rollback_supported()) return 0; if (!g_lnch_hosting_lan && !g_lnch_joined_lan) { const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (caps && caps->valid) @@ -13260,7 +13369,7 @@ namespace { return g_lnch_rollback; } int ae_np_rollback_set(void*, int enable) { - g_lnch_rollback = enable ? 1 : 0; + g_lnch_rollback = enable && psx_mod_netplay_rollback_supported(); ae_np_push_match_caps(nullptr); return 0; } @@ -13434,6 +13543,10 @@ namespace { /* Content negotiation: a guest accepts lobby datagrams (and so a * plan) only from the host endpoint it joined. */ + if (std::getenv("PSX_NET_LOBBY_TRACE")) + std::fprintf(stderr, "LAN trace: receive %.*s from %08x:%u\n", + (int)std::strcspn(buf, "\n"), buf, + (unsigned)ntohl(from.sin_addr.s_addr), (unsigned)ntohs(from.sin_port)); if (g_lnch_remote_lan && g_netplay_content_negotiation) { char host[64]; in_addr expected{}; if (!ae_np_lan_endpoint_host(g_lnch_lan_endpoint, host, sizeof(host)) || @@ -13951,7 +14064,8 @@ namespace { if (g_lnch_lan_start_plan_session == g_lnch_remote_lan_state.session_id && !g_lnch_remote_lan_state.mod_caps_json.empty()) { PsxLobbyMatchCaps caps{}; - if (psx_lobby_decode_match_caps(g_lnch_remote_lan_state.mod_caps_json.c_str(), &caps)) + if (psx_lobby_decode_match_caps(g_lnch_remote_lan_state.mod_caps_json.c_str(), &caps) && + caps.session_bios[0]) ae_np_set_session_bios_token(caps.session_bios, caps.session_bios_crc); } continue; @@ -14052,7 +14166,7 @@ namespace { void ae_np_request_list(void*) { ae_np_lan_rescan(); - psx_lobby_request_list(); + if (ae_np_list_want_online()) psx_lobby_request_list(); } int ae_np_list_count(void*) { @@ -14973,9 +15087,18 @@ namespace { : "openbios", session_crc); ae_np_log_settled_bios("LAN"); + ae_np_lan_sync_local_slot_memcard(); + g_lnch_lan_guest_memcard_active = ae_np_lan_guest_memcard_effective(state); if (g_netplay_content_negotiation) { PsxLobbyMatchCaps caps = ae_netplay_caps_from_settings(settings); + /* The START plan must carry the same settled boot image as + * its legacy fields. An empty BIOS in the plan cleared the + * guest's START token, leaving it in the lobby while the + * host waited forever for tick zero. */ + std::snprintf(caps.session_bios, sizeof(caps.session_bios), + "%s", g_lnch_session_bios); caps.session_bios_crc = session_crc; + caps.guest_memcard_active = g_lnch_lan_guest_memcard_active; char caps_wire[8192]; if (!g_lnch_mod_plan_error.empty() || !psx_lobby_encode_match_caps(caps_wire, sizeof(caps_wire), &caps)) return -1; @@ -14995,8 +15118,6 @@ namespace { if (delay > 20) delay = 20; if (pred < 2) pred = 2; if (pred > 16) pred = 16; - ae_np_lan_sync_local_slot_memcard(); - g_lnch_lan_guest_memcard_active = ae_np_lan_guest_memcard_effective(state); std::fprintf(stdout, "psxrecomp: LAN guest memcard (P2 card as slot 2) = %s\n", g_lnch_lan_guest_memcard_active ? "on" : "off"); char session_crc_text[16]; @@ -15541,6 +15662,9 @@ namespace { * the generic Display -> View mode launcher row. */ gi->widescreen_supported = 0; gi->num_players = game_players_n; +#if defined(RECOMP_LAUNCHER_HAS_SHARED_GAMEPAD) && !defined(PSX_NO_DEBUG_TOOLS) + gi->allow_shared_gamepad = 1; +#endif gi->msu1_supported = 0; gi->sram_path = nullptr; gi->has_bios = psx_bios_has_selectable(); @@ -15749,6 +15873,8 @@ int main(int argc, char** argv) { std::setvbuf(stdout, nullptr, _IOLBF, BUFSIZ); std::setvbuf(stderr, nullptr, _IOLBF, BUFSIZ); std::fprintf(stderr, "psxrecomp: main() entered\n"); + std::fprintf(stderr, "psxrecomp: execution=%s identity=%s\n", + PSX_EXECUTION_NAME, PSX_EXECUTION_ID); std::fflush(stderr); #if defined(RECOMP_LAUNCHER) launcher_boot_timing_mark("host:main_enter"); @@ -15990,6 +16116,7 @@ int main(int argc, char** argv) { * Release keeps "keyboard" as its pre-launch default (the launcher assigns * the selected physical device). */ std::string player_device[PSX_MAX_PLAYERS]; + uint32_t player_instance[PSX_MAX_PLAYERS] = {}; /* never persisted */ int player_mode[PSX_MAX_PLAYERS]; int player_deadzone[PSX_MAX_PLAYERS]; int ctrl_locked_mode[PSX_MAX_PLAYERS]; @@ -16108,7 +16235,9 @@ int main(int argc, char** argv) { gc.netplay_local_viewport_state_addr, gc.netplay_local_viewport_state_values.data(), (int)gc.netplay_local_viewport_state_values.size()); - g_netplay_content_negotiation = gc.netplay_content_negotiation ? 1 : 0; + const PSXModNetplayProfile* settings_profile = psx_mod_netplay_profile(); + g_netplay_content_negotiation = gc.netplay_content_negotiation || + (settings_profile && settings_profile->settings_package_id) ? 1 : 0; PSXRecompV4::mod_runtime_set_netplay_content_negotiation( g_netplay_content_negotiation != 0); game_discs = gc.discs; @@ -17805,6 +17934,10 @@ int main(int argc, char** argv) { const int n = std::min(PSX_MAX_PLAYERS, RECOMP_LAUNCHER_MAX_PLAYERS); const int un = std::min(n, PSXRecompV4::UserSettings::kMaxControllerPlayers); for (int i = 0; i < n; ++i) { +#if defined(RECOMP_LAUNCHER_HAS_PLAYER_GAMEPAD_INSTANCE) + player_instance[i] = ls.player_src[i] == 2 + ? ls.player_gamepad_instance[i] : 0; +#endif if (ls.player_src[i] == 1) { player_device[i] = "keyboard"; } else if (ls.player_src[i] == 0) { @@ -18304,6 +18437,7 @@ int main(int argc, char** argv) { std::memset(g_mod_rumble, 0, sizeof g_mod_rumble); g_p1_claimed_buttons = 0; psx_rewind_set_title_blocked(0); + savestate_set_title_blocked(0); for (auto& policy : g_mod_controller_policy) policy = ModControllerPresentationPolicy{}; g_mod_load_wall_multiplier = -1; @@ -18327,6 +18461,12 @@ int main(int argc, char** argv) { gpu_ws_set_local_view_only( netplay && PSXRecompV4::mod_runtime_netplay_view_active() ? 1 : 0); mod_runtime_activate_plugins(); + const PSXModNetplayProfile* profile = psx_mod_netplay_profile(); + if (netplay && profile && profile->fixed_aspect_mask) { + const int index = psx_mod_netplay_aspect(); + (void)psx_mod_set_fixed_display_aspect(index == 2 ? 21u : index == 1 ? 16u : 4u, + index ? 9u : 3u); + } apply_netplay_local_viewport_aspect(netplay); for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { if (g_mod_controller_mode_override[i] >= 0) @@ -18442,6 +18582,11 @@ session_reboot: } memory_init(bios_path_str.c_str()); if (g_headless_opengl) g_video_renderer = 1; + if (net_cfg.enabled && !psx_mod_netplay_renderer_allowed(g_video_renderer)) { + std::fprintf(stderr, "psxrecomp: this title's netplay profile requires OpenGL " + "retained textures; select the OpenGL renderer\n"); + return 1; + } #ifndef PSX_HAVE_VULKAN /* Vulkan was not compiled in (PSX_ENABLE_VULKAN=OFF or no SDK found). * Refuse a vulkan request from ANY source (config / CLI / launcher seed) @@ -18744,6 +18889,8 @@ session_reboot: * ports during early boot. */ for (int s = 0; s < PSX_MAX_PLAYERS; ++s) { set_player_device(g_players[s], player_device[s], player_mode[s]); + g_players[s].preferred_instance = player_instance[s] + ? (SDL_JoystickID)(player_instance[s] - 1) : -1; g_players[s].deadzone = player_deadzone[s]; } /* Multitap stays OFF through BIOS boot: SCPH-1070 on port 1 breaks shell / @@ -19351,6 +19498,12 @@ session_reboot: log_present_cadence(); } + if (net_cfg.enabled && psx_mod_netplay_requires_texture_banks() && + !psx_mod_texture_banks_supported()) { + std::fprintf(stderr, "psxrecomp: this title's netplay profile requires " + "OpenGL retained texture banks; the backend is unavailable\n"); + return 1; + } /* Register vblank presentation callback. */ gpu_set_vblank_callback(sdl_vblank_present); @@ -19537,6 +19690,12 @@ session_reboot: req.have_deliver_event_ret = (psx_bios_image.deliver_event_ret != 0); req.have_shell_entry = (psx_bios_image.shell_entry_phys != 0); req.have_game_entry = (game_entry_pc != 0); + const PSXModNetplayProfile* boot_profile = psx_mod_netplay_profile(); + if (net_cfg.enabled && boot_profile && boot_profile->skip_bios_intro) { + req.bios_hle = 0; + req.keep_intro = 0; + req.fast_boot = 1; + } const PsxBiosHlePlan plan = psx_bios_hle_plan(req); /* Call-HLE is a per-image capability, not just a preference: an image @@ -20259,6 +20418,10 @@ soft_return_lobby: { const int n = std::min(PSX_MAX_PLAYERS, RECOMP_LAUNCHER_MAX_PLAYERS); for (int i = 0; i < n; ++i) { +#if defined(RECOMP_LAUNCHER_HAS_PLAYER_GAMEPAD_INSTANCE) + player_instance[i] = ls.player_src[i] == 2 + ? ls.player_gamepad_instance[i] : 0; +#endif if (ls.player_src[i] == 1) { player_device[i] = "keyboard"; } else if (ls.player_src[i] == 0) { diff --git a/runtime/src/memory.c b/runtime/src/memory.c index a0b63a73..b1e8235b 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -41,6 +41,9 @@ #define BIOS_ROM_SIZE (512 * 1024) #define MOD_MEMORY_BASE PSX_MOD_MEMORY_BASE #define MOD_MEMORY_SIZE PSX_MOD_MEMORY_SIZE +#define RP_MOD_PAGE 4096u +static int rp_mod_journal(uint8_t *base, uint32_t page_base, uint32_t off, + uint32_t width); static uint8_t ram[RAM_SIZE]; static uint8_t scratchpad[SCRATCHPAD_SIZE]; @@ -90,6 +93,14 @@ int psx_mod_memory_snapshot_read(const uint8_t* data, uint32_t size) { if (!psx_mod_memory_snapshot_validate(data, size)) return 0; cpu_bytes = mod_memory_used; dma_bytes = mod_gpu_dma_memory_used; + // Draw-entry replay may restore an earlier image inside a frozen pass. + // Journal the completed live image first so rollback never retains that + // earlier arena or backs up already-restored primitive tags. + if (g_psx_render_pass_active && + ((cpu_bytes && !rp_mod_journal(mod_memory, 0, 0, cpu_bytes)) || + (dma_bytes && !rp_mod_journal(mod_gpu_dma_memory, + MOD_MEMORY_SIZE / RP_MOD_PAGE, 0, dma_bytes)))) + return 0; memcpy(mod_memory, data + 16u, cpu_bytes); memcpy(mod_gpu_dma_memory, data + 16u + cpu_bytes, dma_bytes); return 1; @@ -145,6 +156,10 @@ uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address) { address, mod_gpu_dma_memory_used); } +int psx_mod_gpu_dma_memory_contains(uint32_t address,uint32_t bytes) { + return bytes!=0 && mod_gpu_dma_memory_offset(address&0x1FFFFFFFu,bytes,NULL); +} + uint32_t psx_gpu_packet_key(uint32_t address) { return psx_gpu_packet_key_for(address, mod_gpu_dma_memory_used); } @@ -1271,7 +1286,10 @@ static uint32_t mmio_read32_impl(uint32_t addr) { return ram_size_reg; } /* Interrupts: 0x1F801070, 0x1F801074 */ - if (addr == 0x1F801070u) { sio_tick(0); return i_stat; } + if (addr == 0x1F801070u) { + if (!g_psx_render_pass_active) sio_tick(0); + return i_stat; + } if (addr == 0x1F801074u) return i_mask; /* DMA: 0x1F801080..0x1F8010FF */ if (addr >= 0x1F801080u && addr <= 0x1F8010FFu) { @@ -1306,6 +1324,7 @@ static uint32_t mmio_read32_impl(uint32_t addr) { * single (side-effecting) read. Callers use mmio_read32; the body is _impl, so * the device read executes exactly once. */ static uint32_t mmio_read32(uint32_t addr) { + if (g_psx_render_pass_active && !render_pass_mmio_read_allowed(addr, 4)) return 0; psx_devices_mmio_sync(); uint32_t v = mmio_read32_impl(addr); debug_server_trace_mmio_read(addr, v, 4); @@ -1397,7 +1416,7 @@ static uint16_t mmio_read16_impl(uint32_t addr) { } /* Interrupts */ if (addr >= 0x1F801070u && addr <= 0x1F801072u) { - sio_tick(0); + if (!g_psx_render_pass_active) sio_tick(0); uint32_t shift = (addr & 2u) ? 16u : 0u; return (uint16_t)(i_stat >> shift); } @@ -1428,6 +1447,7 @@ static uint16_t mmio_read16_impl(uint32_t addr) { } static uint16_t mmio_read16(uint32_t addr) { + if (g_psx_render_pass_active && !render_pass_mmio_read_allowed(addr, 2)) return 0; psx_devices_mmio_sync(); uint16_t v = mmio_read16_impl(addr); debug_server_trace_mmio_read(addr, (uint32_t)v, 2); @@ -1513,7 +1533,7 @@ static uint8_t mmio_read8_impl(uint32_t addr) { } /* Interrupts: 0x1F801070..0x1F801077 (I_STAT, I_MASK) */ if (addr >= 0x1F801070u && addr <= 0x1F801077u) { - if (addr < 0x1F801074u) sio_tick(0); + if (addr < 0x1F801074u && !g_psx_render_pass_active) sio_tick(0); uint32_t val = (addr < 0x1F801074u) ? i_stat : i_mask; return (uint8_t)(val >> (8 * (addr & 3))); } @@ -1547,6 +1567,7 @@ static uint8_t mmio_read8_impl(uint32_t addr) { } static uint8_t mmio_read8(uint32_t addr) { + if (g_psx_render_pass_active && !render_pass_mmio_read_allowed(addr, 1)) return 0; psx_devices_mmio_sync(); uint8_t v = mmio_read8_impl(addr); debug_server_trace_mmio_read(addr, (uint32_t)v, 1); @@ -1848,7 +1869,6 @@ static void render_pass_mmio_write(uint32_t phys, uint32_t val, * (an extended draw distance) has its passes build their packets there. Each * arena page a pass writes is backed up on its first write and put back by * render_pass_mod_arenas_rollback(), with the rest of the pass restore. */ -#define RP_MOD_PAGE 4096u #define RP_MOD_PAGES ((MOD_MEMORY_SIZE + PSX_MOD_GPU_DMA_APERTURE_SIZE) / RP_MOD_PAGE) typedef struct { uint8_t *page; uint32_t index; uint8_t data[RP_MOD_PAGE]; } RpModPage; static uint8_t s_rp_mod_bits[RP_MOD_PAGES / 8u]; diff --git a/runtime/src/mod_guest_services.c b/runtime/src/mod_guest_services.c index dd97046a..57c80a78 100644 --- a/runtime/src/mod_guest_services.c +++ b/runtime/src/mod_guest_services.c @@ -117,6 +117,21 @@ int psx_mod_host_write_ram(uint32_t address, const void* data, uint32_t bytes) { #define SPU_TRANSFER_ADDR 0x1F801DA6u #define SPU_CONTROL 0x1F801DAAu +int psx_mod_spu_sample_bank(uint32_t bank,const void *adpcm,uint32_t bytes) { + return spu_register_sample_bank(bank,adpcm,bytes); +} +int psx_mod_spu_bind_voice_bank(unsigned voice,uint32_t bank) { + return spu_bind_next_voice_bank(voice,bank); +} +int psx_mod_spu_private_voice_play(unsigned slot,uint32_t bank,const uint16_t regs[8],unsigned mode) { + return spu_private_voice_play(slot,bank,regs,mode); +} +void psx_mod_spu_private_voice_stop(uint32_t mask) {spu_private_voice_stop(mask);} +int psx_mod_spu_private_voice_volume(unsigned slot,uint16_t left,uint16_t right) { + return spu_private_voice_volume(slot,left,right); +} +uint32_t psx_mod_spu_private_voice_active(void) {return spu_private_voice_active();} + int psx_mod_spu_upload(uint32_t spu_address, uint32_t guest_source, uint32_t bytes, int stop_after) { if ((spu_address & 7u) || (guest_source & 3u) || (bytes & 3u) || diff --git a/runtime/src/mod_netplay.c b/runtime/src/mod_netplay.c new file mode 100644 index 00000000..403274d0 --- /dev/null +++ b/runtime/src/mod_netplay.c @@ -0,0 +1,150 @@ +#include "mod_netplay.h" +#include "psx_sha256.h" +#include +#include + +static const PSXModNetplayProfile *title_profile; +static int profile_active; +static int profile_aspect; +static char options_identity[65]; + +int psx_mod_register_netplay_profile(const PSXModNetplayProfile *profile) { + unsigned mask; + if (!profile || !profile->plugin_id || !profile->plugin_id[0] || + !profile->compatibility_id || !profile->compatibility_id[0] || + strlen(profile->compatibility_id) >= 64 || + (profile->fixed_aspect_mask & ~7u) || + (profile->widescreen_plugin_id && !profile->widescreen_plugin_id[0]) || + (!!profile->settings_package_id != !!profile->settings_feature_id) || + (profile->settings_package_id && (!profile->settings_package_id[0] || + !profile->settings_feature_id[0])) || + (!!profile->renderer_option_id != !!profile->renderer_option_value) || + (profile->renderer_option_id && (!profile->renderer_option_id[0] || + !profile->renderer_option_value[0] || !profile->settings_package_id || + !profile->widescreen_plugin_id))) + return 0; + if (title_profile) return title_profile == profile; + title_profile = profile; + mask = profile->fixed_aspect_mask; + /* Native is the default whenever supported; otherwise first allowed view. */ + profile_aspect = (mask && !(mask & 1u)) ? ((mask & 2u) ? 1 : 2) : 0; + return 1; +} +const PSXModNetplayProfile *psx_mod_netplay_profile(void) { return title_profile; } +const char *psx_mod_netplay_version(const char *vanilla_version) { + return title_profile ? title_profile->compatibility_id : vanilla_version; +} +int psx_mod_netplay_rollback_supported(void) { + return !title_profile || title_profile->rollback_supported != 0; +} +int psx_mod_netplay_savestates_supported(void) { + return !profile_active || !title_profile || title_profile->savestates_supported != 0; +} +void psx_mod_netplay_set_active(int active) { + profile_active = active && title_profile; +} +int psx_mod_netplay_is_active(void) { return profile_active; } +uint32_t psx_mod_netplay_session_id(uint32_t session_id) { + uint32_t hash = 2166136261u; + const unsigned char *p; + if (!title_profile) return session_id; + p = (const unsigned char *)title_profile->compatibility_id; + while (*p) { hash ^= *p++; hash *= 16777619u; } + if (title_profile->fixed_aspect_mask) { + hash ^= (uint32_t)profile_aspect; + hash *= 16777619u; + } + return session_id ^ hash; +} +int psx_mod_netplay_set_options_identity(const char *identity) { + size_t length; + char normalized[65]; + if (!identity) return 0; + length = strlen(identity); + if (length && (length != 64 || !title_profile || + !title_profile->settings_package_id)) return 0; + for (size_t index = 0; index < length; ++index) { + char value = identity[index]; + if (value >= 'A' && value <= 'F') value += 'a' - 'A'; + if (!((value >= '0' && value <= '9') || (value >= 'a' && value <= 'f'))) + return 0; + normalized[index] = value; + } + normalized[length] = 0; + memcpy(options_identity, normalized, length + 1); + return 1; +} +int psx_mod_netplay_content_identity(const char *execution_content, char out[65]) { + const char *input = execution_content ? execution_content : ""; + char normalized[65]; + size_t n = strlen(input), i; + psx_sha256_ctx ctx; + uint8_t bytes[32], policy[6]; + static const char domain[] = "psx-trusted-netplay-profile-v1"; + static const char hex[] = "0123456789abcdef"; + const char *renderer; + if (!out || (n != 0 && n != 64)) return 0; + for (i = 0; i < n; ++i) { + char c = input[i]; + if (c >= 'A' && c <= 'F') c += 'a' - 'A'; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) return 0; + normalized[i] = c; + } + normalized[n] = 0; + if (!title_profile) { memcpy(out, normalized, n + 1); return 1; } + renderer = profile_aspect && title_profile->widescreen_plugin_id + ? title_profile->widescreen_plugin_id : ""; + /* Explicit fields and NUL-delimited strings, never ABI-dependent struct + * bytes. Include runtime policy to catch incorrect reuse of a revision. */ + policy[0] = (uint8_t)title_profile->fixed_aspect_mask; + policy[1] = policy[0] ? (uint8_t)profile_aspect : 0u; + policy[2] = title_profile->rollback_supported != 0; + policy[3] = title_profile->savestates_supported != 0; + policy[4] = title_profile->skip_bios_intro != 0; + policy[5] = title_profile->requires_retained_texture_banks != 0; + psx_sha256_init(&ctx); + psx_sha256_update(&ctx, (const uint8_t*)domain, sizeof(domain)); + psx_sha256_update(&ctx, (const uint8_t*)normalized, n + 1); + psx_sha256_update(&ctx, (const uint8_t*)title_profile->plugin_id, + strlen(title_profile->plugin_id) + 1); + psx_sha256_update(&ctx, (const uint8_t*)title_profile->compatibility_id, + strlen(title_profile->compatibility_id) + 1); + psx_sha256_update(&ctx, policy, sizeof(policy)); + psx_sha256_update(&ctx, (const uint8_t*)renderer, strlen(renderer) + 1); + if (title_profile->settings_package_id) { + const char *fields[] = {title_profile->settings_package_id, + title_profile->settings_feature_id, options_identity, + title_profile->widescreen_plugin_id, + title_profile->renderer_option_id, title_profile->renderer_option_value}; + for (size_t index = 0; index < sizeof(fields) / sizeof(fields[0]); ++index) { + const char *value = fields[index] ? fields[index] : ""; + psx_sha256_update(&ctx, (const uint8_t *)value, strlen(value) + 1); + } + } + psx_sha256_final(&ctx, bytes); + for (i = 0; i < 32; ++i) { + out[2*i] = hex[bytes[i] >> 4]; + out[2*i + 1] = hex[bytes[i] & 15]; + } + out[64] = 0; + return 1; +} +int psx_mod_netplay_set_aspect(int index) { + if (!title_profile || index < 0 || index > 2 || + !(title_profile->fixed_aspect_mask & (1u << index))) return 0; + profile_aspect = index; + return 1; +} +int psx_mod_netplay_aspect(void) { return profile_aspect; } +int psx_mod_netplay_has_state_digest(void) { + return profile_active && title_profile && title_profile->state_digest; +} +uint32_t psx_mod_netplay_state_digest(void) { + return psx_mod_netplay_has_state_digest() ? title_profile->state_digest() : 0u; +} +int psx_mod_netplay_requires_texture_banks(void) { + return title_profile && title_profile->requires_retained_texture_banks != 0; +} +int psx_mod_netplay_renderer_allowed(int renderer) { + return !psx_mod_netplay_requires_texture_banks() || renderer == 1; +} diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index c2663e0b..86913b04 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -7,12 +7,14 @@ #include "iso_reader.h" #include "mod_packages.h" #include "mod_plugins.h" +#include "mod_netplay.h" #include "psx_netplay.h" #include "gpu.h" #include "gpu_hd_textures.h" #include "psx_memory.h" #include "render_pass_projection.h" #include "psx_sha256.h" +#include "psx_sha256_file.h" #include "cpu_state.h" #include "psx_lobby_client.h" @@ -195,7 +197,8 @@ bool catalog_files(RuntimeMods& s, std::map& bool prepared_ticket_matches(const std::filesystem::path& disc) { auto& s = state(); - if (!s.ticket_ready || s.main_applied || !mod_runtime_session_plan_fp().empty() || + if (!s.ticket_ready || s.main_applied || psx_mod_netplay_is_active() || + !mod_runtime_session_plan_fp().empty() || disc != s.disc_path || s.ticket_revision != s.preparation_revision) return false; std::map files; if (!dependency_files(s, disc, files) || files != s.ticket_files) return false; @@ -485,12 +488,12 @@ bool sha256_file(const std::filesystem::path& path, std::string& out, if (path.empty()) return true; const DiscPathResolution resolved = resolve_disc_path(path); const std::filesystem::path input = resolved.data; - psx_sha256_ctx hash; - psx_sha256_init(&hash); std::string extension = resolved.mount.extension().string(); std::transform(extension.begin(), extension.end(), extension.begin(), [](unsigned char c) { return (char)std::tolower(c); }); if (extension == ".chd") { + psx_sha256_ctx hash; + psx_sha256_init(&hash); PS1::ISOReader disc; if (!disc.Open(resolved.mount.string())) { if (error) *error = @@ -517,24 +520,17 @@ bool sha256_file(const std::filesystem::path& path, std::string& out, return true; } - std::array buffer{}; - std::ifstream file(input, std::ios::binary); - if (!file) { + uint8_t digest[32]; + const auto result = psx_sha256_file(input, digest); + if (result == PsxFileHashResult::OpenError) { if (error) *error = "cannot fingerprint image: " + input.string(); return false; } - while (file) { - file.read((char*)buffer.data(), (std::streamsize)buffer.size()); - const std::streamsize got = file.gcount(); - if (got > 0) psx_sha256_update(&hash, buffer.data(), (size_t)got); - } - if (!file.eof()) { + if (result != PsxFileHashResult::Ok) { if (error) *error = "cannot finish fingerprinting image: " + input.string(); return false; } - uint8_t digest[32]; - psx_sha256_final(&hash, digest); std::ostringstream text; for (uint8_t byte : digest) text << std::hex << std::setw(2) << std::setfill('0') << (unsigned)byte; @@ -1395,8 +1391,11 @@ int provider_commit(void*, const char* image_path) { int provider_commit_netplay(void*, const char* image_path) { HostLaunchTimingScope timing(HOST_LAUNCH_PROVIDER_NETPLAY_COMMIT); std::string error; - /* Without [netplay] content_negotiation every netplay session is vanilla. */ - if (!(mod_runtime_netplay_content_negotiation() + /* Trusted title profiles take precedence over optional package negotiation. */ + if (!(psx_mod_netplay_profile() + ? mod_runtime_commit_netplay(image_path ? std::filesystem::path(image_path) : + std::filesystem::path(), &error) + : mod_runtime_netplay_content_negotiation() ? mod_runtime_commit_for_netplay(image_path ? std::filesystem::path(image_path) : std::filesystem::path(), &error) : mod_runtime_clear_for_netplay(&error))) { @@ -1462,6 +1461,8 @@ bool mod_runtime_initialize(const std::filesystem::path& root, uint32_t game_entry_pc, const std::filesystem::path& exe_path, std::string* error) { + psx_mod_netplay_set_active(0); + (void)psx_mod_netplay_set_options_identity(""); RuntimeMods& s = state(); s.ticket_ready = false; ++s.preparation_revision; @@ -1565,6 +1566,8 @@ bool mod_runtime_initialize(const std::filesystem::path& root, } bool mod_runtime_clear_for_netplay(std::string* error) { + psx_mod_netplay_set_active(0); + (void)psx_mod_netplay_set_options_identity(""); state().ticket_ready = false; ++state().preparation_revision; RuntimeMods& s = state(); @@ -1593,6 +1596,151 @@ bool mod_runtime_clear_for_netplay(std::string* error) { return true; } +static void feat_token_split(const std::string& token, std::string& name, + std::string& opts); +static bool feat_csv_apply_options(ModPackageManager& mgr, + const std::string& package_id, + const std::string& feature_id, + const char* csv, std::string* error); + +static bool prepare_profile_settings(const PSXModNetplayProfile* profile, + const PsxLobbyMatchCaps* caps, + ModResolution& plan, + std::string& options_identity, + std::string* error) { + RuntimeMods& s = state(); + if (!s.initialized) { + if (error) *error = "Title netplay settings catalog is not initialized"; + return false; + } + options_identity.clear(); + if (profile->settings_package_id) { + const ModPackage* package = s.manager.selected_package(profile->settings_package_id); + if (!package || std::none_of(package->features.begin(), package->features.end(), + [&](const ModFeature& feature) { return feature.id == profile->settings_feature_id; })) { + if (error) *error = "Title netplay settings feature is not installed"; + return false; + } + ModPackageManager settings = s.manager; + if (!settings.set_feature_enabled(package->id, profile->settings_feature_id, true, error)) + return false; + const bool host_options = caps && caps->valid && caps->mod_count != 0; + if (host_options) { + if (caps->mod_count != 1 || package->id != caps->mods[0].id || + package->version != caps->mods[0].ver) { + if (error) *error = "Title netplay profile accepts only its declared settings package"; + return false; + } + std::string feature, options; + const std::string token = caps->mods[0].feats; + feat_token_split(token, feature, options); + if (feature != profile->settings_feature_id || token.find(',') != std::string::npos) { + if (error) *error = "Title netplay profile accepts only its declared settings feature"; + return false; + } + } + for (const ModOption& option : package->options) { + if (option.feature_id != profile->settings_feature_id) continue; + const std::string value = host_options ? option.default_value : + s.manager.feature_option_value(package->id, option.feature_id, option.id); + if (!settings.set_feature_option(package->id, option.feature_id, option.id, value, error)) + return false; + } + if (host_options && !feat_csv_apply_options(settings, package->id, + profile->settings_feature_id, caps->mods[0].feats, error)) return false; + ModSelection selection; + selection.version = package->version; + ModFeatureSelection& feature = selection.features[profile->settings_feature_id]; + feature.enabled = feature.has_enabled = true; + for (const ModOption& option : package->options) + if (option.feature_id == profile->settings_feature_id) + feature.values[option.id] = settings.feature_option_value( + package->id, option.feature_id, option.id); + plan.selections[package->id] = std::move(selection); + std::string identity = "psx-trusted-settings-v1\n" + package->id + "\n" + + package->version + "\n" + profile->settings_feature_id + "\n"; + for (const auto& value : plan.selections[package->id].features[profile->settings_feature_id].values) + identity += value.first + "=" + value.second + "\n"; + uint8_t digest[32]; + psx_sha256_compute(reinterpret_cast(identity.data()), identity.size(), digest); + static const char hex[] = "0123456789abcdef"; + for (uint8_t value : digest) { + options_identity += hex[value >> 4]; + options_identity += hex[value & 15]; + } + } + return true; +} + +bool mod_runtime_netplay_settings_fingerprint(const PsxLobbyMatchCaps& caps, + std::string& fingerprint, + std::string* error) { + fingerprint.clear(); + const PSXModNetplayProfile* profile = psx_mod_netplay_profile(); + if (!profile || !profile->settings_package_id || !caps.valid || caps.mod_count != 1) { + if (error) *error = "Title netplay settings offer is missing"; + return false; + } + ModResolution plan; + return prepare_profile_settings(profile, &caps, plan, fingerprint, error); +} + +bool mod_runtime_commit_netplay(const std::filesystem::path& disc_path, + std::string* error) { + if (!mod_runtime_clear_for_netplay(error)) return false; + const PSXModNetplayProfile* profile = psx_mod_netplay_profile(); + if (!profile) return true; + RuntimeMods& s = state(); + if (!s.initialized || !mod_plugin_registered(profile->plugin_id)) { + if (error) *error = "Title netplay simulation plugin is not registered"; + return false; + } + ModResolution plan; + std::string options_identity; + const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); + if (!prepare_profile_settings(profile, caps, plan, options_identity, error)) return false; + if (profile->settings_package_id && caps && caps->valid && caps->mod_count != 0 && + options_identity != caps->mod_plan_fp) { + if (error) *error = "Title netplay settings fingerprint does not match the host offer"; + return false; + } + const bool option_renderer = profile->renderer_option_id && + s.manager.feature_option_value(plan, profile->settings_package_id, + profile->settings_feature_id, profile->renderer_option_id) == profile->renderer_option_value; + const char* renderer = psx_mod_netplay_aspect() || option_renderer + ? profile->widescreen_plugin_id : nullptr; + if (renderer && !mod_plugin_registered(renderer)) { + if (error) *error = "Title netplay renderer plugin is not registered"; + return false; + } + if (!profile->settings_package_id && caps && caps->valid && caps->mod_count != 0) { + if (error) *error = "Title netplay profile does not accept package mod plans"; + return false; + } + if (!psx_mod_netplay_set_options_identity(options_identity.c_str())) { + if (error) *error = "Title netplay settings identity is invalid"; + return false; + } + plan.ok = true; + plan.fingerprint = profile->compatibility_id; + if (!options_identity.empty()) { + char identity[65]; + if (!psx_mod_netplay_content_identity("", identity)) return false; + plan.fingerprint = identity; + } + plan.plugins.push_back({profile->plugin_id, + profile->settings_package_id ? profile->settings_package_id : "", + profile->settings_feature_id ? profile->settings_feature_id : ""}); + if (renderer) plan.plugins.push_back({renderer, "", ""}); + s.disc_path = disc_path; + s.plan = std::move(plan); + mod_runtime_set_session_plan_fp({}); + psx_mod_netplay_set_active(1); + std::fprintf(stdout, "psxrecomp: title netplay profile %s (%s)\n", + profile->compatibility_id, profile->plugin_id); + return true; +} + /* One CSV element is "" or "=~[+~]". */ static void feat_token_split(const std::string& token, std::string& name, @@ -2007,7 +2155,8 @@ bool mod_runtime_try_prepare_cached(const std::filesystem::path& disc_path) { #if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) try { auto& s = state(); - if (!s.initialized || s.main_applied || !session_plan_fp().empty()) return false; + if (!s.initialized || s.main_applied || psx_mod_netplay_is_active() || + !session_plan_fp().empty()) return false; auto probe = s.manager; if (!probe.prepare_resources(s.game_id, disc_path, media_cache_root(), nullptr, true)) return false; ScopedFlag flag(s.cached_prepare); @@ -2058,6 +2207,7 @@ bool mod_runtime_verify_session_plan_fp(std::string* error) { bool mod_runtime_commit_for_netplay(const std::filesystem::path& disc_path, std::string* error) { + if (psx_mod_netplay_profile()) return mod_runtime_commit_netplay(disc_path, error); RuntimeMods& s = state(); const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); if (!caps || !caps->valid) @@ -2086,6 +2236,7 @@ bool mod_runtime_commit_for_netplay(const std::filesystem::path& disc_path, bool mod_runtime_commit_for_direct_netplay(const std::filesystem::path& disc_path, std::string* error) { + if (psx_mod_netplay_profile()) return mod_runtime_commit_netplay(disc_path, error); RuntimeMods& s = state(); /* No mod runtime (no mods root): vanilla on every peer, no content gate. */ if (!s.initialized) { @@ -2116,6 +2267,7 @@ bool mod_runtime_commit_for_direct_netplay(const std::filesystem::path& disc_pat } void mod_runtime_end_netplay() { + if (psx_mod_netplay_is_active()) mod_runtime_clear_for_netplay(); if (!offline_manager_before_netplay) return; RuntimeMods& s = state(); mod_runtime_clear_for_netplay(); @@ -2167,6 +2319,7 @@ bool mod_runtime_netplay_view_active() { bool mod_runtime_commit_netplay_view(const std::filesystem::path& disc_path, std::string* error) { + if (psx_mod_netplay_profile()) return mod_runtime_commit_netplay(disc_path, error); RuntimeMods& s = state(); std::string why; (void)mod_runtime_clear_for_netplay(&why); @@ -2211,6 +2364,10 @@ const std::string& mod_runtime_fingerprint() { return state().plan.fingerprint; } +const std::filesystem::path& mod_runtime_root() { + return state().manager.root(); +} + const std::filesystem::path& mod_runtime_effective_disc_path() { return state().effective_disc_path; } diff --git a/runtime/src/netplay_state_digest.c b/runtime/src/netplay_state_digest.c index 5c4ace73..52e57a7a 100644 --- a/runtime/src/netplay_state_digest.c +++ b/runtime/src/netplay_state_digest.c @@ -1,4 +1,5 @@ #include "netplay_state_digest.h" +#include "mod_netplay.h" #include "cdrom.h" #include "crc32.h" #include "dirty_ram_interp.h" @@ -182,6 +183,13 @@ void netplay_core_digest_parts(const CPUState* cpu, NetplayCoreParts* out) fold = crc32_update(fold, (const uint8_t*)&tim_h, sizeof(tim_h)); fold = crc32_update(fold, (const uint8_t*)&ram_h, sizeof(ram_h)); fold = crc32_update(fold, (const uint8_t*)&drt_h, sizeof(drt_h)); + /* Keep vanilla digests byte-for-byte unchanged. Zero is a valid plugin + * digest, so presence is separate from its value. */ + if (psx_mod_netplay_has_state_digest()) { + uint32_t mod = psx_mod_netplay_state_digest(); + fold = crc32_update(fold, (const uint8_t*)&mod, sizeof(mod)); + if (out) out->mod = mod; + } if (out) { out->cpu = cpu_h; diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp index b37936fd..610ebd47 100644 --- a/runtime/src/pgxp.cpp +++ b/runtime/src/pgxp.cpp @@ -357,6 +357,7 @@ static inline PGXPValue *pgxp_ptr(uint32_t addr) { * pass, fails closed: rollback invalidates everything. */ struct PGXPJournalEntry { PGXPValue *slot; + size_t index; PGXPValue old; }; @@ -378,10 +379,10 @@ static inline size_t ck_index(const PGXPValue *pv) { } /* Journal a RAM / scratchpad slot before its first mutation in a pass. */ -static inline void ck_note(PGXPValue *pv) { +static inline void ck_note(PGXPValue *pv, uint32_t addr) { if (s_ck_depth == 0 || !pv) return; - if (s_ram != s_ck_ram && !(pv >= s_scratch && - pv < s_scratch + PGXP_SCRATCH_WORDS)) { + if (s_ck_lossy || !s_ck_bits) { s_ck_lossy = 1; return; } + if (s_ram != s_ck_ram) { s_ck_lossy = 1; /* shadow RAM appeared mid-pass */ return; } @@ -398,6 +399,7 @@ static inline void ck_note(PGXPValue *pv) { } s_ck_bits[i >> 3] |= bit; s_ck_log[s_ck_n].slot = pv; + s_ck_log[s_ck_n].index = i; s_ck_log[s_ck_n].old = *pv; s_ck_n++; } @@ -409,11 +411,89 @@ static void ck_wrapped(void) { static int ck_in_pass(void) { return s_ck_depth != 0; } static inline PGXPValue *pgxp_ptr_w(uint32_t addr) { - PGXPValue *pv = pgxp_ptr(addr); - ck_note(pv); + PGXPValue *pv = pgxp_ptr(addr, true); + ck_note(pv, addr); return pv; } +static int packet_shadow_address(uint32_t addr, uint32_t *canonical) { + if ((addr & 3u) || addr >= 0xC0000000u) return 0; + const uint32_t physical = addr & 0x1FFFFFFFu; + if (physical < PSX_MAIN_RAM_WINDOW_BYTES) { + *canonical = psx_ram_canonical_offset(physical); + return 1; + } + if ((physical & 0xFFFFFC00u) == 0x1F800000u) { + *canonical = physical; + return 1; + } + if (psx_mod_gpu_dma_memory_contains(addr, 4u)) { + *canonical = physical; + return 1; + } + return 0; +} + +extern "C" int pgxp_capture_word_shadow(uint32_t addr, uint32_t expected, + PGXPWordShadow *out) { + uint32_t canonical; + if (!out || !packet_shadow_address(addr, &canonical)) return 0; + PGXPWordShadow receipt{}; + receipt.address = canonical; + receipt.value = expected; + receipt.source_generation = s_gen; + const PGXPValue *source = pgxp_ptr(addr); + if (s_enabled && source && source->gen == s_gen && source->value == expected) { + receipt.valid = 1; + receipt.x16 = source->x16; + receipt.y16 = source->y16; + receipt.z = source->z; + receipt.flags = source->flags; + receipt.projection = source->projection; + } + *out = receipt; + return 1; +} + +static int restore_word_shadow(uint32_t addr, uint32_t expected, + const PGXPWordShadow *in, bool relocated) { + uint32_t canonical, source; + if (!in || !s_ck_depth || s_ck_lossy || !packet_shadow_address(addr, &canonical) || + !packet_shadow_address(in->address, &source) || source != in->address || + (!relocated && in->address != canonical) || + in->value != expected || in->valid > 1u || + in->source_generation != s_ck_gen) return 0; + if (in->valid && !s_enabled) return 0; + PGXPValue *destination = pgxp_ptr(addr); + // With PGXP disabled/unallocated, there is already no RAM shadow to clear. + if (!destination) return in->valid == 0u; + ck_note(destination, addr); + if (s_ck_lossy) return 0; + PGXPValue restored{}; + if (in->valid) { + restored.value = in->value; + restored.gen = s_gen; + restored.x16 = in->x16; + restored.y16 = in->y16; + restored.z = in->z; + restored.flags = in->flags; + restored.projection = in->projection; + } + *destination = restored; + return 1; +} + +extern "C" int pgxp_restore_word_shadow(uint32_t addr, uint32_t expected, + const PGXPWordShadow *in) { + return restore_word_shadow(addr, expected, in, false); +} + +extern "C" int pgxp_restore_relocated_word_shadow(uint32_t destination, + uint32_t expected, + const PGXPWordShadow *in) { + return restore_word_shadow(destination, expected, in, true); +} + extern "C" void pgxp_checkpoint_begin(void) { if (s_ck_depth++ != 0) return; /* the outermost pass journals */ if (!s_ck_bits) { @@ -440,7 +520,7 @@ extern "C" void pgxp_checkpoint_rollback(void) { for (size_t i = s_ck_n; i-- > 0;) { PGXPJournalEntry *e = &s_ck_log[i]; *e->slot = e->old; - size_t k = ck_index(e->slot); + size_t k = e->index; s_ck_bits[k >> 3] &= (uint8_t)~(1u << (k & 7u)); } s_ck_n = 0; @@ -921,6 +1001,17 @@ static inline int half_exact(const PGXPValue *pv, uint32_t value, int hi_half) { } /* A saturated projection shadow sits beyond the clamped word; it is not * the value of that half and must not be carried as one. */ + if (s_preserve_projection) { + const int32_t field = (int32_t)((uint32_t)half << 21) >> 21; + const int64_t d = (int64_t)v16 - (int64_t)half * 65536; + // Clip metadata uses bits 13..15. Bits 11/12 must still be the + // coordinate's sign fill; a real wrapped coordinate stays strict. + const uint16_t sign_fill = field < 0 ? 0x1800u : 0; + if (field > -0x400 && field < 0x3FF && + (uint16_t(half) & 0x1800u) == sign_fill) + return d > -(int64_t)PGXP_PPP_AGREE_BELOW * 65536 && + d < (int64_t)PGXP_PPP_AGREE_ABOVE * 65536; + } return (v16 >> 16) == half; } @@ -1568,9 +1659,9 @@ extern "C" void pgxp_gte_reg_written(int reg, uint32_t value) { * packet half, `half` the raw 16-bit half in the word. Exact (integer part * equals the native parse) unless preserve-projection is on; then the precise * position may sit a bounded distance from it (PGXP_PPP_AGREE_*), except - * when the half is at or beyond the GTE saturation limits: there the guest - * integer is a clamp that says nothing about the vertex, or (beyond them) a - * CPU-modified word the GPU's 11-bit parse wraps. */ + * when the decoded coordinate is saturated or the word is a genuinely + * wrapped coordinate. Known outcode bits 13..15 alone are not saturation; + * bits 11/12 must still match the decoded coordinate's sign fill. */ static inline int pgxp_agrees(int32_t p16, int32_t native, int16_t half, int derived) { const int64_t d = (int64_t)p16 - (int64_t)native * 65536; @@ -1578,7 +1669,9 @@ static inline int pgxp_agrees(int32_t p16, int32_t native, int16_t half, * own arithmetic chose, so the value may sit one pixel either side of * the truncation window - still bounded, still the same vertex. */ if (derived) return d > -65536 && d < 2 * 65536; - if (!s_preserve_projection || half <= -0x400 || half >= 0x3FF) + const uint16_t sign_fill = native < 0 ? 0x1800u : 0; + if (!s_preserve_projection || native <= -0x400 || native >= 0x3FF || + (uint16_t(half) & 0x1800u) != sign_fill) return (p16 >> 16) == native; return d > -(int64_t)PGXP_PPP_AGREE_BELOW * 65536 && d < (int64_t)PGXP_PPP_AGREE_ABOVE * 65536; @@ -1749,6 +1842,27 @@ extern "C" void pgxp_store_gte_reg(uint32_t addr, uint8_t reg) { *dst = *src; } +extern "C" int pgxp_store_flagged_gte_sxy(uint32_t addr, uint8_t reg, + uint32_t source_word, uint32_t stored_word) { + if (!g_pgxp_active || (addr & 3u) || reg < 12 || reg > 15 || + ((source_word ^ stored_word) & 0x07FF07FFu)) return 0; + const PGXPValue* src = &s_gte[reg]; + if (src->gen != s_gen || src->value != source_word || !src->z || + (src->flags & (PGXP_F_VXY | PGXP_F_VZ)) != (PGXP_F_VXY | PGXP_F_VZ)) return 0; + const int64_t x = (int64_t)src->x16 + + ((int64_t)(int16_t)stored_word - (int16_t)source_word) * 65536; + const int64_t y = (int64_t)src->y16 + + ((int64_t)(int16_t)(stored_word >> 16) - (int16_t)(source_word >> 16)) * 65536; + if (x < INT32_MIN || x > INT32_MAX || y < INT32_MIN || y > INT32_MAX) return 0; + PGXPValue* dst = pgxp_ptr_w(addr); + if (!dst) return 0; + *dst = *src; + dst->value = stored_word; + dst->x16 = (int32_t)x; + dst->y16 = (int32_t)y; + return 1; +} + extern "C" int pgxp_debug_shadow(int space, uint32_t key, int *live, uint32_t *value, uint32_t *flags, int32_t *x16, int32_t *y16, uint16_t *z) { diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 76467c2e..df936415 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -987,6 +987,7 @@ static void freeze_save(PsxCycleFreeze *save) { save->local_acc_value = g_psx_cyc_local_acc ? *g_psx_cyc_local_acc : 0u; save->in_device_service = psx_in_device_service; save->bb_defer = g_psx_cyc_bb_defer; + psx_guest_cycle_scale_snapshot(save->guest_cycle_scale); /* Pending deferred charges stay pending: they belong to the interrupted * guest code and are republished, unchanged, after the freeze. */ g_psx_cyc_batch = 0; @@ -1034,6 +1035,7 @@ void psx_cycle_freeze_end(const PsxCycleFreeze *save) { if (!save) return; freeze_restore_clock(save); freeze_restore_deferred(save); + psx_guest_cycle_scale_restore(save->guest_cycle_scale); } int psx_cycle_uncharged_begin(PsxCycleFreeze *save, uint64_t budget_cycles) { diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 552a67db..5928cf47 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -1,4 +1,5 @@ #include "psx_lobby_client.h" +#include "mod_netplay.h" #include "psx_lobby_launch_transport.h" #include "netplay_bios_settle.h" @@ -1068,6 +1069,7 @@ void psx_lobby_set_max_slots(int max_slots) static const char *effective_game_version(const char *override_ver) { + if (psx_mod_netplay_profile()) return psx_mod_netplay_version(NULL); if (override_ver && override_ver[0]) { return override_ver; } diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 6224604f..30b310b3 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -3,6 +3,7 @@ #endif #include "psx_netplay.h" +#include "mod_netplay.h" #include "netplay_load_probe.h" #include "netplay_sim_pad_cache.h" #include "netplay_local_view.h" @@ -39,6 +40,7 @@ #include "crc32.h" #include "netplay_hash_confirm.h" #include "netplay_content_gate.h" +#include "execution_identity.h" #include "netplay_input_hist.h" #include "netplay_state_digest.h" #include "psx_netplay_rb.h" @@ -206,6 +208,7 @@ void psx_netplay_apply_env(PsxNetplayConfig *cfg) v = getenv("PSX_NET_HOST_SPECTATES"); if (v && v[0]) cfg->host_spectates = (v[0] != '0') ? 1 : 0; + if (!psx_mod_netplay_rollback_supported()) cfg->rollback = 0; } static void force_session_pads_connected(int slot_count) @@ -748,12 +751,12 @@ static void np_log_live_digest(uint32_t tick, const NetplayCoreParts *parts, { fprintf(stderr, "psxrecomp: rb live dig %s sim=%u core=%08x cpu=%08x clk=%08x " - "tim=%08x ram=%08x dirty=%08x av=%08x cd=%08x " + "tim=%08x ram=%08x dirty=%08x mod=%08x av=%08x cd=%08x " "spu=%08x mdec=%08x aux=%08x\n", tag ? tag : "tick", (unsigned)tick, (unsigned)parts->core, (unsigned)parts->cpu, (unsigned)parts->clock_irq, (unsigned)parts->timers, (unsigned)parts->ram, - (unsigned)parts->dirty, (unsigned)av, (unsigned)cd, + (unsigned)parts->dirty, (unsigned)parts->mod, (unsigned)av, (unsigned)cd, (unsigned)spu, (unsigned)mdec, (unsigned)aux); { static int s_parts = -1; @@ -1444,6 +1447,12 @@ static void np_apply_ready_state(void) if (!rnet_session_state_take_ready(g_np.session, &op, &slot, &data, &size)) return; + if ((savestate_title_blocked() || !psx_mod_netplay_savestates_supported()) && + (op == RNET_STATE_OP_SAVE || op == RNET_STATE_OP_LOAD)) { + rnet_session_state_finish(g_np.session, 0); + g_np.xfer = NP_XFER_NONE; + return; + } if (!data || size == 0) { rnet_session_state_finish(g_np.session, 0); g_np.xfer = NP_XFER_NONE; @@ -1611,6 +1620,11 @@ static void np_guest_handle_probe(void) if (g_np.local_slot == 0) return; if (!rnet_session_state_probe_pending(g_np.session, &op, &slot, &size, &crc)) return; + if ((savestate_title_blocked() || !psx_mod_netplay_savestates_supported()) && + (op == RNET_STATE_OP_SAVE || op == RNET_STATE_OP_LOAD)) { + (void)rnet_session_state_probe_reply(g_np.session, 0); + return; + } /* Post-load ready rendezvous (must be before SAVE size==0 coord). */ if (op == RNET_STATE_OP_LOAD && size == 0 && crc == NP_LOAD_READY_CRC) { @@ -4019,6 +4033,7 @@ static int ice_hub_acquire(const PsxNetplayConfig *cfg, int slots, int local, int psx_netplay_start(const PsxNetplayConfig *cfg) { + PsxNetplayConfig trusted_cfg; RNetConfig rcfg; RNetHostVTable host; int in_player; @@ -4032,6 +4047,16 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) struct RNetIceAgent *guest_agent = NULL; if (!cfg || !cfg->enabled) return -1; + if (psx_mod_netplay_profile() && !psx_mod_netplay_is_active()) { + fprintf(stderr, "psxrecomp: title netplay profile was not committed\n"); + return -1; + } + if (cfg->rollback && !psx_mod_netplay_rollback_supported()) { + trusted_cfg = *cfg; + trusted_cfg.rollback = 0; + cfg = &trusted_cfg; + fprintf(stderr, "psxrecomp: trusted netplay profile forces delay-sync\n"); + } if (g_np.session) psx_netplay_shutdown(); psx_netplay_sim_pad_cache_reset(&s_sim_pad_cache); psx_netplay_local_view_reset(&s_local_view); @@ -4103,7 +4128,7 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) fflush(stderr); rcfg.input_delay = 5u; } - rcfg.session_id = cfg->session_id ? cfg->session_id : 1u; + rcfg.session_id = psx_mod_netplay_session_id(cfg->session_id ? cfg->session_id : 1u); { uint32_t mask = cfg->occupied_mask; if (mask == 0u) { @@ -4124,7 +4149,11 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) } } - if (!np_content_init(&s_content, cfg->content_fingerprint, rcfg.occupied_mask, local)) + char execution_content[65]; + char profile_content[65]; + if (!psx_execution_content_identity(cfg->content_fingerprint, execution_content) || + !psx_mod_netplay_content_identity(execution_content, profile_content) || + !np_content_init(&s_content, profile_content, rcfg.occupied_mask, local)) return -5; s_content_send_ms = 0; /* Host resolves auto (-1) before start; accept 0..PSX_MAX_PLAYERS-1. */ @@ -4733,6 +4762,7 @@ int psx_netplay_request_save(int slot) uint32_t sim; uint32_t delay; uint32_t target; + if (savestate_title_blocked() || !psx_mod_netplay_savestates_supported()) return 0; if (!psx_netplay_active() || !rnet_session_is_running(g_np.session)) return 0; if (g_np.local_slot != 0) @@ -4768,6 +4798,7 @@ int psx_netplay_request_load(int slot) { uint32_t size = 0, crc = 0; char reason[192]; + if (savestate_title_blocked() || !psx_mod_netplay_savestates_supported()) return 0; if (!psx_netplay_active() || !rnet_session_is_running(g_np.session)) return 0; if (g_np.local_slot != 0) diff --git a/runtime/src/psx_sha256_file.cpp b/runtime/src/psx_sha256_file.cpp new file mode 100644 index 00000000..578b3838 --- /dev/null +++ b/runtime/src/psx_sha256_file.cpp @@ -0,0 +1,120 @@ +#include "psx_sha256_file.h" +#include "psx_sha256.h" + +#include +#include +#include + +#if defined(_WIN32) && !defined(PSX_SHA256_FILE_PORTABLE) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#elif defined(PSX_SHA256_FILE_OPENSSL) && !defined(PSX_SHA256_FILE_PORTABLE) +#include +#endif + +namespace { + +class NativeHash { +public: + NativeHash() = default; + NativeHash(const NativeHash&) = delete; + NativeHash& operator=(const NativeHash&) = delete; + +#if defined(_WIN32) && !defined(PSX_SHA256_FILE_PORTABLE) + ~NativeHash() { + if (hash_) BCryptDestroyHash(hash_); + if (algorithm_) BCryptCloseAlgorithmProvider(algorithm_, 0); + } + bool init() { + return BCryptOpenAlgorithmProvider(&algorithm_, BCRYPT_SHA256_ALGORITHM, + nullptr, 0) >= 0 && + BCryptCreateHash(algorithm_, &hash_, nullptr, 0, + nullptr, 0, 0) >= 0; + } + bool update(const uint8_t* data, size_t size) { + // The reader passes at most 1 MiB, within BCrypt's ULONG length. + return BCryptHashData(hash_, const_cast(data), + static_cast(size), 0) >= 0; + } + bool finish(uint8_t out[32]) { + return BCryptFinishHash(hash_, out, 32, 0) >= 0; + } +private: + BCRYPT_ALG_HANDLE algorithm_ = nullptr; + BCRYPT_HASH_HANDLE hash_ = nullptr; +#elif defined(PSX_SHA256_FILE_OPENSSL) && !defined(PSX_SHA256_FILE_PORTABLE) + ~NativeHash() { EVP_MD_CTX_free(ctx_); } + bool init() { + ctx_ = EVP_MD_CTX_new(); + return ctx_ && EVP_DigestInit_ex(ctx_, EVP_sha256(), nullptr) == 1; + } + bool update(const uint8_t* data, size_t size) { + return EVP_DigestUpdate(ctx_, data, size) == 1; + } + bool finish(uint8_t out[32]) { + unsigned int size = 0; + return EVP_DigestFinal_ex(ctx_, out, &size) == 1 && size == 32; + } +private: + EVP_MD_CTX* ctx_ = nullptr; +#else + bool init() { return false; } + bool update(const uint8_t*, size_t) { return false; } + bool finish(uint8_t[32]) { return false; } +#endif +}; + +// A backend error may occur after consuming input. Restart the whole stream, +// never mix states from different implementations or publish a partial hash. +template +bool try_native(std::ifstream& file, std::vector& buffer, + uint8_t digest[32], Hash& native) { + if (!native.init()) return false; + while (file) { + file.read(reinterpret_cast(buffer.data()), + static_cast(buffer.size())); + const auto got = file.gcount(); + if (got > 0 && !native.update(buffer.data(), static_cast(got))) + return false; + } + return !file.bad() && file.eof() && native.finish(digest); +} + +template +PsxFileHashResult hash_file(const std::filesystem::path& path, + uint8_t out[32], Hash& native) { + std::ifstream file(path, std::ios::binary); + if (!file) return PsxFileHashResult::OpenError; + std::vector buffer(1024 * 1024); + uint8_t digest[32]; + if (!try_native(file, buffer, digest, native)) { + if (file.bad()) return PsxFileHashResult::ReadError; + file.clear(); + file.seekg(0, std::ios::beg); + if (!file) return PsxFileHashResult::ReadError; + psx_sha256_ctx portable; + psx_sha256_init(&portable); + while (file) { + file.read(reinterpret_cast(buffer.data()), + static_cast(buffer.size())); + const auto got = file.gcount(); + if (got > 0) + psx_sha256_update(&portable, buffer.data(), static_cast(got)); + } + if (file.bad() || !file.eof()) return PsxFileHashResult::ReadError; + psx_sha256_final(&portable, digest); + } + std::copy(digest, digest + 32, out); + return PsxFileHashResult::Ok; +} + +} // namespace + +PsxFileHashResult psx_sha256_file(const std::filesystem::path& path, + uint8_t out[32]) { + NativeHash native; + return hash_file(path, out, native); +} diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 84d760df..fdeb5a6a 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -265,6 +265,7 @@ static jmp_buf s_abort_jmp; static volatile int s_abort_armed; static const CPUState* s_pass_cpu; static int s_nesting; +static int s_device_read_fault; static RenderPassStats s_stats; static int s_verify = -1; @@ -328,6 +329,7 @@ void render_pass_reset_session(void) { memset(&s_stats, 0, sizeof s_stats); memset(g_render_pass_dropped_writes, 0, sizeof g_render_pass_dropped_writes); s_open_generation = 0; + s_device_read_fault = 0; s_restored_valid = 0; memset(&s_attempt, 0, sizeof s_attempt); memset(&s_stereo, 0, sizeof s_stereo); @@ -476,6 +478,8 @@ static uint64_t state_hash(const CPUState *cpu) { int32_t ti[3]; uint32_t csv = interrupts_get_cycles_since_vblank(); uint64_t cyc = psx_cycle_count; + uint32_t guest_cycle_scale[3]; + psx_guest_cycle_scale_snapshot(guest_cycle_scale); h = fnv(h, cpu->gpr, sizeof cpu->gpr); h = fnv(h, &cpu->hi, sizeof cpu->hi); h = fnv(h, &cpu->lo, sizeof cpu->lo); @@ -493,6 +497,7 @@ static uint64_t state_hash(const CPUState *cpu) { h = fnv(h, &i_mask, sizeof i_mask); h = fnv(h, &csv, sizeof csv); h = fnv(h, &cyc, sizeof cyc); + h = fnv(h, guest_cycle_scale, sizeof guest_cycle_scale); timers_get_snapshot(tc, tm, tt, ti, tf); h = fnv(h, tc, sizeof tc); h = fnv(h, tm, sizeof tm); h = fnv(h, tt, sizeof tt); h = fnv(h, ti, sizeof ti); h = fnv(h, tf, sizeof tf); @@ -612,6 +617,31 @@ static void watchdog_overrun(void) { } } +int render_pass_mmio_read_allowed(uint32_t phys, uint32_t width) { + if (!g_psx_render_pass_active) return 1; + if (!((phys >= 0x1F801040u && phys <= 0x1F80105Fu) || /* SIO */ + (phys >= 0x1F801800u && phys <= 0x1F801803u) || /* CD */ + (phys >= 0x1F801820u && phys <= 0x1F801827u) || /* MDEC */ + (phys >= 0x1F801C00u && phys <= 0x1F801FFFu))) /* SPU */ + return 1; + s_device_read_fault = 1; + s_stats.device_reads++; + s_stats.last_device_read = phys; + s_stats.last_device_read_width = width; + if (s_stats.device_reads <= 4 && s_pass_cpu) + fprintf(stderr, "psxrecomp: render pass device read=0x%08X " + "pc=0x%08X function=0x%08X ra=0x%08X last_store=0x%08X\n", + phys, s_pass_cpu->pc, g_debug_current_func_addr, + s_pass_cpu->gpr[31], g_debug_last_store_pc); + s_attempt.reason = "device_read"; + s_stats.last_failure = s_attempt; + if (s_abort_armed) { + s_abort_armed = 0; + longjmp(s_abort_jmp, 1); + } + return 0; +} + static char s_abort_detail[192]; static void note_fault(const char *what) { @@ -621,7 +651,7 @@ static void note_fault(const char *what) { s_abort_detail[0] ? "; restored skipped exits: " : "", s_abort_detail); s_abort_detail[0] = '\0'; - if (s_stats.watchdog + s_stats.vram_leaks >= RP_FAULT_LIMIT && + if (s_stats.watchdog + s_stats.vram_leaks + s_stats.device_reads >= RP_FAULT_LIMIT && !s_stats.disabled) { s_stats.disabled = 1; fprintf(stderr, "psxrecomp: render passes disabled for this session " @@ -725,6 +755,7 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass tb = gl_renderer_perf_ticks(); s_nesting = 1; + s_device_read_fault = 0; cycles_before = psx_cycle_count; if (eye >= 0) { s_stereo.eye_cycle[eye] = cycles_before; @@ -748,7 +779,7 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass "(host nesting not balanced after the pass)\n"); } } else { - /* Watchdog abort from inside guest code. An overlay shard may still + /* Sandbox abort from inside guest code. An overlay shard may still * hold cycles it has not published: publish them now, into the * frozen clock the restore discards, never into the live one. The * nesting the longjmp skipped is put back by checkpoint_restore and @@ -767,6 +798,7 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass s_pass_cpu = NULL; leaks = gl_renderer_pass_leaks() - s_leaks_before; + if (s_device_read_fault) ok = 0; if (leaks) { s_stats.vram_leaks += leaks; ok = 0; @@ -823,6 +855,7 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass } else if (ok) s_stats.passes++; else if (leaks) note_fault("VRAM write outside the pass rect"); else if (s_stats.watchdog_flag) note_fault("guest-cycle watchdog"); + else if (s_device_read_fault) note_fault("read from an uncheckpointed device"); s_stats.watchdog_flag = 0; /* Present anything that fell due while the pass ran. */ if (eye == RP_EYE_PASS) gl_renderer_pass_service_presents(); diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c index ca7711c1..4795203c 100644 --- a/runtime/src/render_pass_plan.c +++ b/runtime/src/render_pass_plan.c @@ -111,6 +111,20 @@ int render_pass_select(const uint32_t *phases, uint32_t n, double p, return 1; } +int render_pass_generation_oldest(uint32_t mask,const uint64_t* order,uint32_t count) { + int selected=-1; + for(uint32_t i=0;i= 0.0) || !isfinite(sample)) return current; if (!(current > 0.0)) return sample; @@ -273,8 +294,18 @@ int render_pass_vram_policy(const RenderPassJournal *j, int px, int py, if (*x >= j->x[i] && *y >= j->y[i] && *x + *w <= j->x[i] + j->w[i] && *y + *h <= j->y[i] + j->h[i]) return RENDER_PASS_VRAM_ALLOW; /* already covered */ - return j->n < RENDER_PASS_JOURNAL_MAX ? RENDER_PASS_VRAM_JOURNAL - : RENDER_PASS_VRAM_REFUSE; + if (j->n >= RENDER_PASS_JOURNAL_MAX) return RENDER_PASS_VRAM_REFUSE; + /* Reserve the last entry for a whole-VRAM spill. A sprite scene can + * upload more than 64 disjoint animation/CLUT rectangles in one redraw. + * Back up the current contents once, including earlier temporary writes; + * reverse-order rollback restores this spill FIRST and then restores + * those earlier rectangles to their original contents. Subsequent writes + * are covered by the spill, so capacity remains bounded without dropping + * legitimate texture uploads or disabling native interpolation. */ + if (j->n == RENDER_PASS_JOURNAL_MAX - 1) { + *x = *y = 0; *w = vram_w; *h = vram_h; + } + return RENDER_PASS_VRAM_JOURNAL; } int render_pass_journal_add(RenderPassJournal *j, const uint16_t *vram, diff --git a/runtime/src/savestate.c b/runtime/src/savestate.c index f520d18d..f35f57c3 100644 --- a/runtime/src/savestate.c +++ b/runtime/src/savestate.c @@ -7,6 +7,7 @@ * restores the full machine then unwinds to the scheduler and re-dispatches. */ #include "savestate.h" +#include "mod_netplay.h" #include "boot_state.h" #include "cdrom.h" #include "gpu.h" @@ -55,6 +56,7 @@ static uint32_t s_openbios_wordsum; /* bundled OpenBIOS wordsum for migrate */ static uint32_t s_bios_checksum; static uint32_t s_entry_pc; static int s_configured = 0; +static int s_title_blocked; static int s_save_pending = -1; /* slot, or -1 */ static int s_load_pending = -1; static int s_load_completed = 0; @@ -193,6 +195,28 @@ static void clear_load_blob(void) { s_load_blob_len = 0; } +int savestate_title_blocked(void) { return s_title_blocked; } +void savestate_set_title_blocked(int blocked) { + s_title_blocked = blocked != 0; + if (!s_title_blocked) return; + if (s_save_pending >= 0 || s_save_defer_slot >= 0) s_save_failed = 1; + if (s_load_pending >= 0) s_load_failed = 1; + s_save_pending = s_load_pending = s_save_defer_slot = -1; + s_last_save_pc = 0; + s_status_pending = 0; + clear_load_blob(); +} + +static int title_save_request_blocked(int is_load, int slot) { + const char* reason; + if (!s_title_blocked && psx_mod_netplay_savestates_supported()) return 0; + reason = s_title_blocked ? "Save states are unavailable in this game mode" : + "Save states are unavailable in this netplay mode"; + fprintf(stderr, "savestate: %s\n", reason); + psx_frontend_on_savestate_refused(is_load, slot, reason); + return 1; +} + /* Peek .pst header bios_checksum (BOOT_STATE wire: magic, version, checksum). */ static int pst_peek_bios_checksum(const char* path, uint32_t* out_cksum) { FILE* f; @@ -731,6 +755,7 @@ static int netplay_user_blocked(void) { } static int request_save_inner(int slot) { + if (title_save_request_blocked(0, slot)) return 0; if (!s_configured) { fprintf(stderr, "savestate: not configured\n"); psx_frontend_on_savestate_refused(0, slot, "Save states are not available yet"); @@ -748,6 +773,7 @@ static int request_save_inner(int slot) { } static int request_load_inner(int slot) { + if (title_save_request_blocked(1, slot)) return 0; if (!s_configured) { fprintf(stderr, "savestate: not configured\n"); psx_frontend_on_savestate_refused(1, slot, "Save states are not available yet"); @@ -795,6 +821,7 @@ int savestate_request_load_protocol(int slot) { int savestate_request_load_blob_protocol(const void* data, size_t size) { uint8_t* copy; + if (title_save_request_blocked(1, -1)) return 0; if (!s_configured) { fprintf(stderr, "savestate: load_blob — not configured\n"); return 0; diff --git a/runtime/src/spu.c b/runtime/src/spu.c index ebb21347..cd0d2189 100644 --- a/runtime/src/spu.c +++ b/runtime/src/spu.c @@ -31,9 +31,20 @@ #define SPU_REG_COUNT 256 #define SPU_VOICE_COUNT 24 #define SPU_BLOCK_SAMPLES 28 +#define SPU_SAMPLE_BANKS 32u +#define SPU_PRIVATE_VOICES 24u +#define SPU_MIX_VOICES (SPU_VOICE_COUNT + SPU_PRIVATE_VOICES) + +/* Title enhancements preload assets from the owner's disc. Hardware DMA, + * capture buffers and reverb continue to use spu_ram exclusively. */ +static struct { uint8_t *data; uint32_t bytes; } sample_banks[SPU_SAMPLE_BANKS]; static uint8_t spu_ram[SPU_RAM_SIZE]; static uint16_t spu_regs[SPU_REG_COUNT]; +static uint16_t private_regs[SPU_PRIVATE_VOICES][8]; +static uint8_t private_modes[SPU_PRIVATE_VOICES]; +static unsigned private_enabled; +static inline uint16_t voice_reg(int voice,int reg); static uint32_t transfer_addr; static uint32_t key_on_count; static uint64_t render_frames; @@ -114,7 +125,7 @@ typedef struct { int16_t level; /* live effective volume, full signed 16-bit */ uint32_t divider; /* rate divider, same overflow scheme as ADSR */ } SweepEnv; -static SweepEnv sweep_voice_env[SPU_VOICE_COUNT][2]; /* [voice][0=L 1=R] */ +static SweepEnv sweep_voice_env[SPU_MIX_VOICES][2]; /* [voice][0=L 1=R] */ static SweepEnv sweep_main_env[2]; /* [0=L 1=R] */ /* External vblank counter (debug_server.c) used as event timestamp. */ @@ -149,6 +160,7 @@ typedef struct { int active; uint32_t cur_addr; uint32_t repeat_addr; + uint32_t sample_bank, pending_bank; int16_t samples[SPU_BLOCK_SAMPLES]; int16_t previous_samples[3]; int sample_idx; @@ -163,7 +175,23 @@ typedef struct { uint8_t adsr_phase; /* ADSR_ATTACK / DECAY / SUSTAIN / RELEASE */ } SpuVoice; -static SpuVoice voices[SPU_VOICE_COUNT]; +static SpuVoice voices[SPU_MIX_VOICES]; + +int spu_register_sample_bank(uint32_t bank,const void *adpcm,uint32_t bytes) { + if(!bank || bank>=SPU_SAMPLE_BANKS || !adpcm || !bytes || + bytes>SPU_RAM_SIZE || (bytes&15u))return 0; + /* Assets cannot change under a playing or queued voice. */ + if(sample_banks[bank].data) + return sample_banks[bank].bytes==bytes && !memcmp(sample_banks[bank].data,adpcm,bytes); + uint8_t *copy=malloc(bytes);if(!copy)return 0; + memcpy(copy,adpcm,bytes);sample_banks[bank].data=copy;sample_banks[bank].bytes=bytes; + return 1; +} +int spu_bind_next_voice_bank(unsigned voice,uint32_t bank) { + if(voice>=SPU_VOICE_COUNT || bank>=SPU_SAMPLE_BANKS || + (bank && !sample_banks[bank].data))return 0; + voices[voice].pending_bank=bank;return 1; +} static void spu_event_record(uint8_t kind, int voice, uint32_t addr) { SpuEvent *e = &s_events[s_event_idx & (SPU_EVENT_CAP - 1u)]; @@ -172,14 +200,14 @@ static void spu_event_record(uint8_t kind, int voice, uint32_t addr) { e->kind = kind; e->voice = (uint8_t)voice; e->addr = addr; - if (voice >= 0 && voice < SPU_VOICE_COUNT) { + if (voice >= 0 && voice < SPU_MIX_VOICES) { /* PSX voice block layout (16-bit register indices from voice base): * 0=VOL_L 1=VOL_R 2=PITCH 3=START 4=ADSR_LO 5=ADSR_HI 6=CURVOL 7=LOOP */ - e->pitch = spu_regs[(uint32_t)voice * 8u + 2u]; - e->adsr_lo = spu_regs[(uint32_t)voice * 8u + 4u]; - e->adsr_hi = spu_regs[(uint32_t)voice * 8u + 5u]; - e->vol_l = spu_regs[(uint32_t)voice * 8u + 0u]; - e->vol_r = spu_regs[(uint32_t)voice * 8u + 1u]; + e->pitch = voice_reg(voice,2); + e->adsr_lo = voice_reg(voice,4); + e->adsr_hi = voice_reg(voice,5); + e->vol_l = voice_reg(voice,0); + e->vol_r = voice_reg(voice,1); } else { /* Non-voice-attributable events (e.g. SPU_EV_IRQ, voice=0xFF). */ e->pitch = 0; @@ -234,8 +262,8 @@ static void calc_vc_delta(uint8_t zs, uint8_t speed, int log_mode, int dec_mode, /* Step ADSR envelope by one output sample for voice `idx`. Mirrors * Beetle's PS_SPU::RunEnvelope. */ static void adsr_run(int idx, SpuVoice *v) { - uint32_t raw = (uint32_t)spu_regs[(uint32_t)idx * 8u + 4u] - | ((uint32_t)spu_regs[(uint32_t)idx * 8u + 5u] << 16); + uint32_t raw = (uint32_t)voice_reg(idx,4) + | ((uint32_t)voice_reg(idx,5) << 16); int Sl = (int)(raw >> 0) & 0x0F; int Dr = (int)(raw >> 4) & 0x0F; @@ -316,7 +344,8 @@ static inline uint32_t reg_index(uint32_t addr) { } static inline uint16_t voice_reg(int voice, int reg) { - return spu_regs[(uint32_t)voice * 8u + (uint32_t)reg]; + return voicesample_bank) { + uint32_t bank=v->sample_bank; + block=bank> 4) & 0x0F; if (filter > 4) filter = 0; @@ -538,7 +576,7 @@ static void decode_block(SpuVoice *v) { int out = 0; for (int b = 0; b < 14; b++) { - uint8_t packed = spu_ram[addr + 2u + (uint32_t)b]; + uint8_t packed = block[2u + (uint32_t)b]; for (int n = 0; n < 2; n++) { int sample4 = (n == 0) ? (packed & 0x0F) : (packed >> 4); if (sample4 & 0x08) sample4 -= 0x10; @@ -828,7 +866,7 @@ typedef struct { /* One frame's worth of per-voice taps, plus the global scale used this block. */ typedef struct { - SpuShadowVoiceTap voice[SPU_VOICE_COUNT]; + SpuShadowVoiceTap voice[SPU_MIX_VOICES]; int16_t main_l; int16_t main_r; int enabled; /* SPU control enable bit this block */ @@ -847,7 +885,7 @@ typedef char spu_shadow_tap_voice_size_check[ typedef char spu_shadow_tap_frame_size_check[ (sizeof(SpuShadowFrameTap) == sizeof(SpuShadowFrameTapPub)) ? 1 : -1]; typedef char spu_shadow_voice_count_check[ - (SPU_VOICE_COUNT == SPU_SHADOW_MAX_VOICES) ? 1 : -1]; + (SPU_MIX_VOICES == SPU_SHADOW_MAX_VOICES) ? 1 : -1]; const void* spu_shadow_tap_buffer(void) { return s_shadow_tap; } int spu_shadow_tap_count(void) { return s_shadow_tap_frame; } @@ -896,7 +934,8 @@ static int16_t voice_next_sample(int idx) { int16_t raw_s; uint32_t non_mask = (uint32_t)spu_regs[reg_index(0x1F801D94u)] | ((uint32_t)spu_regs[reg_index(0x1F801D96u)] << 16); - if (non_mask & (1u << idx)) { + if (idxprevious_samples, @@ -961,7 +1000,9 @@ static void key_on(uint32_t mask) { for (int i = 0; i < SPU_VOICE_COUNT; i++) { if (!(mask & (1u << i))) continue; SpuVoice *v = &voices[i]; + uint32_t bank=v->pending_bank; memset(v, 0, sizeof(*v)); + v->sample_bank=bank; v->active = 1; /* Address registers count 8-byte units but hardware ignores bit0 * — block fetches are 16-byte aligned (DuckStation Voice::KeyOn @@ -995,10 +1036,53 @@ static void key_off(uint32_t mask) { } } +/* These opt-in voices never appear in hardware registers, KEYON/KEYOFF, + * capture channels or ENDX. They share the native sample DSP and main mix. + * Slots are chosen by the title; a slot's next sound replaces only that slot. */ +int spu_private_voice_play(unsigned slot,uint32_t bank,const uint16_t regs[8],unsigned mode) { + if(slot>=SPU_PRIVATE_VOICES || !bank || bank>=SPU_SAMPLE_BANKS || + !sample_banks[bank].data || !regs || mode>3u)return 0; + unsigned index=SPU_VOICE_COUNT+slot;SpuVoice *v=&voices[index]; + private_enabled=1;memcpy(private_regs[slot],regs,sizeof private_regs[slot]); + private_modes[slot]=(uint8_t)mode; + memset(v,0,sizeof *v);v->sample_bank=bank;v->active=1; + v->cur_addr=((uint32_t)(regs[3]&~1u)<<3)&(SPU_RAM_SIZE-1u); + v->repeat_addr=((uint32_t)(regs[7]&~1u)<<3)&(SPU_RAM_SIZE-1u); + v->sample_idx=SPU_BLOCK_SAMPLES;v->adsr_phase=ADSR_ATTACK; + sweep_env_write(&sweep_voice_env[index][0],regs[0]); + sweep_env_write(&sweep_voice_env[index][1],regs[1]); + key_on_count++;spu_event_record(SPU_EV_KEYON,(int)index,v->cur_addr);return 1; +} +void spu_private_voice_stop(uint32_t mask) { + for(unsigned slot=0;slotactive)continue; + spu_event_record(SPU_EV_KEYOFF,SPU_VOICE_COUNT+(int)slot,v->cur_addr); + v->adsr_phase=ADSR_RELEASE;v->adsr_divider=0; + } +} +int spu_private_voice_volume(unsigned slot,uint16_t left,uint16_t right) { + if(slot>=SPU_PRIVATE_VOICES)return 0; + private_regs[slot][0]=left;private_regs[slot][1]=right; + sweep_env_write(&sweep_voice_env[SPU_VOICE_COUNT+slot][0],left); + sweep_env_write(&sweep_voice_env[SPU_VOICE_COUNT+slot][1],right);return 1; +} +uint32_t spu_private_voice_active(void) { + uint32_t mask=0; + for(unsigned slot=0;slotactive) || !pst_w_u32(w, v->cur_addr) || - !pst_w_u32(w, v->repeat_addr)) + !pst_w_u32(w, v->repeat_addr) || !pst_w_u32(w,v->sample_bank) || + !pst_w_u32(w,v->pending_bank)) return 0; for (int i = 0; i < SPU_BLOCK_SAMPLES; i++) if (!pst_w_i16(w, v->samples[i])) return 0; @@ -1826,8 +1914,10 @@ static int spu_r_voice(PstR *r, int idx) { SpuVoice *v = &voices[idx]; int32_t active = 0, sample_idx = 0; if (!pst_r_i32(r, &active) || !pst_r_u32(r, &v->cur_addr) || - !pst_r_u32(r, &v->repeat_addr)) + !pst_r_u32(r, &v->repeat_addr) || !pst_r_u32(r,&v->sample_bank) || + !pst_r_u32(r,&v->pending_bank)) return 0; + if(v->sample_bank>=SPU_SAMPLE_BANKS || v->pending_bank>=SPU_SAMPLE_BANKS)return 0; v->active = (int)active; for (int i = 0; i < SPU_BLOCK_SAMPLES; i++) if (!pst_r_i16(r, &v->samples[i])) return 0; @@ -1851,16 +1941,19 @@ static int spu_r_voice(PstR *r, int idx) { * capture position, main L/R sweep envelopes). */ #define SPU_SNAPSHOT_TAIL_BYTES \ (20u + 1u + 2u + 4u + 4u + 1u + 4u + 4u + 4u + 4u + 4u + 2u * (2u + 4u)) +#define SPU_SNAPSHOT_BASE_BYTES ((SPU_REG_COUNT*2u)+(SPU_VOICE_COUNT*SPU_VOICE_WIRE_BYTES)+SPU_SNAPSHOT_TAIL_BYTES) +#define SPU_PRIVATE_WIRE_BYTES (SPU_PRIVATE_VOICES*(16u+1u+SPU_VOICE_WIRE_BYTES)) /* + 4: guest-clock phase (cycles since the SPU's last whole sample). States * written before the guest-clocked SPU lack it and still load (phase 0). */ static uint32_t spu_snapshot_bytes_v1(void) { - return (uint32_t)(SPU_REG_COUNT * 2u) + - (SPU_VOICE_COUNT * SPU_VOICE_WIRE_BYTES) + SPU_SNAPSHOT_TAIL_BYTES; + return SPU_SNAPSHOT_BASE_BYTES + (private_enabled ? SPU_PRIVATE_WIRE_BYTES : 0u); } uint32_t spu_snapshot_bytes(void) { return spu_snapshot_bytes_v1() + 4u; } int spu_snapshot_len_ok(uint32_t len) { - return len == spu_snapshot_bytes() || len == spu_snapshot_bytes_v1(); + return len == SPU_SNAPSHOT_BASE_BYTES || len == SPU_SNAPSHOT_BASE_BYTES + 4u || + len == SPU_SNAPSHOT_BASE_BYTES + SPU_PRIVATE_WIRE_BYTES || + len == SPU_SNAPSHOT_BASE_BYTES + SPU_PRIVATE_WIRE_BYTES + 4u; } void spu_snapshot_write(uint8_t *p) { @@ -1891,12 +1984,22 @@ void spu_snapshot_write(uint8_t *p) { pst_w_i16(&w, sweep_main_env[ch].level); pst_w_u32(&w, sweep_main_env[ch].divider); } + if(private_enabled)for(unsigned slot=0;slot= SPU_SNAPSHOT_BASE_BYTES + SPU_PRIVATE_WIRE_BYTES; + if(!private_enabled) { + memset(voices+SPU_VOICE_COUNT,0,sizeof(SpuVoice)*SPU_PRIVATE_VOICES); + memset(private_regs,0,sizeof private_regs);memset(private_modes,0,sizeof private_modes); + memset(sweep_voice_env+SPU_VOICE_COUNT,0,sizeof(SweepEnv)*2u*SPU_PRIVATE_VOICES); + } pst_r_init(&r, p, len); for (uint32_t i = 0; i < SPU_REG_COUNT; i++) if (!pst_r_u16(&r, &spu_regs[i])) return 0; @@ -1918,6 +2021,11 @@ int spu_snapshot_read(const uint8_t *p, uint32_t len) { !pst_r_u32(&r, &sweep_main_env[ch].divider)) return 0; } + if(private_enabled)for(unsigned slot=0;slot3u || + !spu_r_voice(&r,SPU_VOICE_COUNT+(int)slot))return 0; + } uint32_t phase = 0; if (len == spu_snapshot_bytes() && !pst_r_u32(&r, &phase)) return 0; /* Applied at the next catch-up, once the guest clock is restored too. */ @@ -1954,7 +2062,7 @@ void spu_snapshot_part_digests(SpuSnapPartDigests *out) spu_snapshot_write(buf); regs_n = (uint32_t)(SPU_REG_COUNT * 2u); voices_n = (uint32_t)(SPU_VOICE_COUNT * SPU_VOICE_WIRE_BYTES); - if (regs_n + voices_n + SPU_SNAPSHOT_TAIL_BYTES != n) + if (regs_n + voices_n + SPU_SNAPSHOT_TAIL_BYTES > n) return; crc = 0xFFFFFFFFu; crc = crc32_update(crc, buf, regs_n); @@ -1963,6 +2071,6 @@ void spu_snapshot_part_digests(SpuSnapPartDigests *out) crc = crc32_update(crc, buf + regs_n, voices_n); out->voices = crc ^ 0xFFFFFFFFu; crc = 0xFFFFFFFFu; - crc = crc32_update(crc, buf + regs_n + voices_n, SPU_SNAPSHOT_TAIL_BYTES); + crc = crc32_update(crc, buf + regs_n + voices_n, n-regs_n-voices_n); out->tail = crc ^ 0xFFFFFFFFu; } diff --git a/runtime/tests/gpu_exec_stubs.inc b/runtime/tests/gpu_exec_stubs.inc index 280623b9..b26c1bcf 100644 --- a/runtime/tests/gpu_exec_stubs.inc +++ b/runtime/tests/gpu_exec_stubs.inc @@ -252,6 +252,10 @@ void color_lut_map555(const ColorLut *lut, uint16_t bgr555, uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address) { return psx_mod_gpu_dma_resolve_address_for(address, test_aperture_used); } +int psx_mod_gpu_dma_memory_contains(uint32_t address, uint32_t bytes) { + return bytes && psx_mod_gpu_dma_aperture_offset_for( + address, bytes, test_aperture_used, NULL); +} uint32_t psx_gpu_packet_key(uint32_t address) { return psx_gpu_packet_key_for(address, test_aperture_used); } @@ -395,8 +399,12 @@ void gr_get_draw_area(int *x1, int *y1, int *x2, int *y2) { void gr_set_draw_offset(int x, int y) { (void)x; (void)y; } int gr_wide_supported(void) { return 1; } void gr_wide_configure(int wide_w, int offset) { (void)wide_w; (void)offset; } +static struct { int enabled, shift, left, right; } fixture_wide_view; void gr_wide_set_view(int enabled, int shift, int left, int right) { - (void)enabled; (void)shift; (void)left; (void)right; + fixture_wide_view.enabled = enabled; + fixture_wide_view.shift = shift; + fixture_wide_view.left = left; + fixture_wide_view.right = right; } void gr_wide_set_target(int base_x) { (void)base_x; } void gr_wide_disable_target(void) {} diff --git a/runtime/tests/gpu_hd_texture_stubs.inc b/runtime/tests/gpu_hd_texture_stubs.inc index fe68d6a5..4c4e444f 100644 --- a/runtime/tests/gpu_hd_texture_stubs.inc +++ b/runtime/tests/gpu_hd_texture_stubs.inc @@ -1,5 +1,9 @@ /* Existing GL fixtures deliberately run without any external pack. The HD * fixture links the real session adapter instead. */ +/* These renderer fixtures also run without a trusted title profile. */ +static int test_title_profile_active, test_title_profile_banks; +int psx_mod_netplay_is_active(void) { return test_title_profile_active; } +int psx_mod_netplay_requires_texture_banks(void) { return test_title_profile_banks; } #ifndef PSX_TEST_HD_TEXTURE_PACK int gpu_hd_textures_acquire_draw(uint16_t tp, uint16_t cx, uint16_t cy, const int limits[4], uint32_t window, int semi, GpuHdTextureImage* image) { diff --git a/runtime/tests/pgxp_test_mod_memory.cpp b/runtime/tests/pgxp_test_mod_memory.cpp new file mode 100644 index 00000000..b0303969 --- /dev/null +++ b/runtime/tests/pgxp_test_mod_memory.cpp @@ -0,0 +1,5 @@ +#include + +// These isolated instruction/session fixtures have no allocated mod memory. +// The packet-shadow fixture supplies its own bounded, allocated aperture. +extern "C" int psx_mod_gpu_dma_memory_contains(uint32_t, uint32_t) { return 0; } diff --git a/runtime/tests/psx_ram_runtime_map_stubs.c b/runtime/tests/psx_ram_runtime_map_stubs.c index ffb1324b..ede0e605 100644 --- a/runtime/tests/psx_ram_runtime_map_stubs.c +++ b/runtime/tests/psx_ram_runtime_map_stubs.c @@ -124,6 +124,7 @@ void gpu_ws_restore_linked_list_rank(uint32_t r) { (void)r; } * linked list synchronously while a pass runs; with the flag at 0 neither * path is taken, so the doubles trap if they ever are. */ int g_psx_render_pass_active; +int render_pass_mmio_read_allowed(uint32_t a, uint32_t w) { (void)a; (void)w; return 1; } int g_psx_guest_time_frozen; int render_pass_store_to(const void *t, uint32_t a, uint32_t v, uint32_t w) { (void)t; (void)a; (void)v; (void)w; __builtin_trap(); diff --git a/runtime/tests/run_gl_texture_filter.py b/runtime/tests/run_gl_texture_filter.py index bd6aa527..1ebecfd2 100644 --- a/runtime/tests/run_gl_texture_filter.py +++ b/runtime/tests/run_gl_texture_filter.py @@ -14,6 +14,9 @@ def main(): parser.add_argument('--sdl-library',required=True) parser.add_argument('--output',required=True) parser.add_argument('--fixture',type=Path) + parser.add_argument('--build-only',action='store_true', + help='build the probe without starting a GL context') + parser.add_argument('--optimization',choices=['0','1','2','3'],default='1') parser.add_argument('--hd-pack', action='store_true') parser.add_argument('--cxx') parser.add_argument('--webp-include') @@ -32,7 +35,7 @@ def main(): (out/'receipt.json').write_text(json.dumps(receipt,indent=2)+'\n',encoding='utf-8') print(r.stdout,r.stderr[-4000:],flush=True) if r.returncode: raise subprocess.CalledProcessError(r.returncode,command) - flags=['-std=gnu11','-O1','-DPSX_SDL3=1','-DPSX_NO_DEBUG_TOOLS=1', + flags=['-std=gnu11','-O'+args.optimization,'-DPSX_SDL3=1','-DPSX_NO_DEBUG_TOOLS=1', '-ffunction-sections','-fdata-sections','-I',fw/'runtime/include','-I',fw/'runtime/src'] for include in args.sdl_include.split(';'): if include: flags+=['-I',include] @@ -73,15 +76,16 @@ def main(): else: libraries+=['-lGL','-ldl','-lpthread'] if platform.system()!='Darwin': libraries+=['-Wl,--gc-sections'] run([args.cxx if args.hd_pack else args.cc,*objects,args.sdl_library,'-o',exe,*libraries]) - arguments=list(args.argument) - if args.hd_pack: - pack=out/'pack'; (pack/'replacements').mkdir(parents=True,exist_ok=True) - (pack/'beetle'/'demo-texture-replacements').mkdir(parents=True,exist_ok=True) - (pack/'beetle-other'/'demo-texture-replacements').mkdir(parents=True,exist_ok=True) - arguments=[pack] - run([exe,*arguments],{'PSX_GL_HIRES_WINDOW':'0'} if args.hd_pack else None) - if args.hd_pack: run([exe,*arguments],{'PSX_GL_HIRES_WINDOW':'1'}) - if args.hd_pack: run([exe,'--native-baseline'],{'PSX_GL_HIRES_WINDOW':'1'}) + if not args.build_only: + arguments=list(args.argument) + if args.hd_pack: + pack=out/'pack'; (pack/'replacements').mkdir(parents=True,exist_ok=True) + (pack/'beetle'/'demo-texture-replacements').mkdir(parents=True,exist_ok=True) + (pack/'beetle-other'/'demo-texture-replacements').mkdir(parents=True,exist_ok=True) + arguments=[pack] + run([exe,*arguments],{'PSX_GL_HIRES_WINDOW':'0'} if args.hd_pack else None) + if args.hd_pack: run([exe,*arguments],{'PSX_GL_HIRES_WINDOW':'1'}) + if args.hd_pack: run([exe,'--native-baseline'],{'PSX_GL_HIRES_WINDOW':'1'}) return 0 if __name__=='__main__': diff --git a/runtime/tests/test_aligned_archive.cpp b/runtime/tests/test_aligned_archive.cpp new file mode 100644 index 00000000..3f47e1f0 --- /dev/null +++ b/runtime/tests/test_aligned_archive.cpp @@ -0,0 +1,27 @@ +#undef NDEBUG +#include "mod_aligned_archive.hpp" +#include +#include +static void put(std::vector& b, unsigned at, uint32_t n) { + for (int i = 0; i < 4; ++i) b[at+i] = uint8_t(n >> (8*i)); +} +int main() { + std::vector bytes(8192); + put(bytes,0,1); put(bytes,4,2); + put(bytes,8,1500); put(bytes,12,2048); + put(bytes,16,2049); put(bytes,20,4096); + PSXAlignedArchiveIndex index; + assert(index.assign(bytes.data(),unsigned(bytes.size()))); + assert(index.matches(0,2048) && index.matches(2048,6144)); + assert(index.matches(4096,4096) && index.matches(0,8192)); + assert(!index.matches(2048,2049) && !index.matches(4097,4095)); + assert(!index.matches(UINT32_MAX-5,16) && !index.matches(0,0)); + put(bytes,20,UINT32_MAX); + assert(!index.assign(bytes.data(),unsigned(bytes.size()))); + assert(!index.matches(0,2048)); // stale catalogue cannot survive a failure. + put(bytes,20,4096); bytes[100] = 1; + assert(!index.assign(bytes.data(),unsigned(bytes.size()))); + bytes[100] = 0; put(bytes,16,UINT32_MAX); + assert(!index.assign(bytes.data(),unsigned(bytes.size()))); + assert(!index.assign(nullptr,8192)); +} diff --git a/runtime/tests/test_boot_state_handoff.c b/runtime/tests/test_boot_state_handoff.c index c661a052..5c56d33c 100644 --- a/runtime/tests/test_boot_state_handoff.c +++ b/runtime/tests/test_boot_state_handoff.c @@ -82,6 +82,11 @@ int main(void) { assert(!boot_state_load_buffer(saved,len,123,0x801b0000,&cpu)); assert(cpu.pc==0xdeadbeef && ram[0x100]==0x55 && fntrace_is_game_started()==!started); u32(saved + 24, implementation_key); + /* A foreign execution identity must fail before any state is applied. */ + u32(saved+32,boot_state_layout_cookie() ^ 0x12345678u); + assert(!boot_state_load_buffer(saved,len,123,0x801b0000,&cpu)); + assert(cpu.pc==0xdeadbeef && ram[0x100]==0x55 && fntrace_is_game_started()==!started); + u32(saved+32,boot_state_layout_cookie()); saved[len-4]=2; assert(!boot_state_load_buffer(saved,len,123,0x801b0000,&cpu)); saved[len-4]=started; diff --git a/runtime/tests/test_execution_profile.py b/runtime/tests/test_execution_profile.py new file mode 100644 index 00000000..27809fd2 --- /dev/null +++ b/runtime/tests/test_execution_profile.py @@ -0,0 +1,148 @@ +"""Link both implementation profiles and check identity/selection failures.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import tempfile + + +def run(args, *, ok=True): + result = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if (result.returncode == 0) != ok: + raise AssertionError(result.stdout) + return result.stdout + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--cmake", required=True) + parser.add_argument("--cc", required=True) + parser.add_argument("--ninja", required=True) + args = parser.parse_args() + root = Path(__file__).resolve().parents[2] + with tempfile.TemporaryDirectory(prefix="psx-execution-") as temp: + source = Path(temp) / "src" + source.mkdir() + (source / "main.c").write_text('''#include "execution_identity.h" +#include "netplay_content_gate.h" +#include +extern int operation(int); +extern int common_game_body(void); +int main(int argc, char** argv) { + if (common_game_body() != 5) return 4; + char no_mods[65], modded[65], upper[65]; + if (!psx_execution_content_identity("", no_mods) || + !psx_execution_content_identity("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", modded) || + !psx_execution_content_identity("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", upper) || + strcmp(modded, upper) || + psx_execution_content_identity("invalid", modded)) return 1; + if (argc == 3) { + NpContentGate local, peer; + if (!np_content_init(&local, no_mods, 3, 0) || + !np_content_init(&peer, argv[1], 3, 1) || + !local.enabled || np_content_ready(&local)) return 2; + for (unsigned i=0; i<4; ++i) + np_content_note(&local, 1, i, peer.words[2*i], peer.words[2*i+1], 3); + if (np_content_ready(&local) != (argv[2][0]=='1')) return 3; + } + printf("%d %s %s %s\\n", operation(7), PSX_EXECUTION_NAME, PSX_EXECUTION_ID, no_mods); + return 0; +} +''') + (source / "hle.c").write_text("int operation(int x) { return x * 3; }\n") + (source / "common.c").write_text("#ifndef COMMON_SOURCE_FLAG\n#error common source property lost\n#endif\nint common_game_body(void) { return 5; }\n") + (source / "local_step.h").write_text("#define LOCAL_STEP 3\n") + (source / "lle.c").write_text('#include "local_step.h"\n#ifndef LLE_SOURCE_FLAG\n#error source compile property lost\n#endif\nint operation(int x) { int s=0; while(x--) s+=LOCAL_STEP; return s; }\n') + (source / "contract.h").write_text("/* caller contract revision 1 */\n") + (source / "shared.h").write_text("/* shared service revision 1 */\n") + (source / "CMakeLists.txt").write_text(f'''cmake_minimum_required(VERSION 3.20) +project(profile_fixture C) +include("{root.as_posix()}/cmake/psx_execution_profile.cmake") +# Match the runtime helper's pre-materialized game shards. They must remain +# automatic configure/hash inputs without creating duplicate Ninja rules. +set_source_files_properties("${{CMAKE_CURRENT_SOURCE_DIR}}/lle.c" PROPERTIES + GENERATED TRUE COMPILE_DEFINITIONS LLE_SOURCE_FLAG=1) +set_source_files_properties("${{CMAKE_CURRENT_SOURCE_DIR}}/common.c" PROPERTIES + GENERATED TRUE COMPILE_DEFINITIONS COMMON_SOURCE_FLAG=1) +add_executable(fixture main.c common.c "{root.as_posix()}/runtime/src/psx_sha256.c") +target_include_directories(fixture PRIVATE "{root.as_posix()}/runtime/include") +add_executable(fixture-pgxp main.c common.c "{root.as_posix()}/runtime/src/psx_sha256.c") +target_include_directories(fixture-pgxp PRIVATE "{root.as_posix()}/runtime/include") +psxrecomp_execution_profile(fixture CONTRACT_FILES shared.h) +psxrecomp_execution_profile(fixture-pgxp CONTRACT_FILES shared.h) +psxrecomp_add_implementation(fixture NAME triple CONTRACT integer-v1 + HLE_SOURCES hle.c LLE_SOURCES lle.c CONTRACT_FILES contract.h common.c) +''') + + def configure(profile, name=None, ok=True): + build = Path(temp) / (name or profile) + run([args.cmake, "-S", str(source), "-B", str(build), "-G", "Ninja", + "-DCMAKE_C_COMPILER=" + args.cc, "-DCMAKE_MAKE_PROGRAM=" + args.ninja, + "-DPSX_EXECUTION_PROFILE=" + profile], ok=ok) + return build + + def build_run(build): + run([args.cmake, "--build", str(build), "--parallel", "2"]) + exe = build / ("fixture.exe" if (build / "fixture.exe").exists() else "fixture") + values = run([str(exe)]).strip().split() + manifest = json.loads((build / "fixture.execution.json").read_text()) + sibling = json.loads((build / "fixture-pgxp.execution.json").read_text()) + assert sibling == manifest, "auto-clone must use the same implementation contract" + assert values[0] == "21", values + assert values[1] == manifest["profile"], values + assert values[2] == manifest["identity"], values + assert values[3] == hashlib.sha256(("psx-session-execution-v1:" + values[2]).encode()).hexdigest() + return manifest + + enhanced_build = configure("ENHANCED") + enhanced = build_run(enhanced_build) + reference = build_run(configure("REFERENCE")) + assert enhanced["identity"] != reference["identity"] + # The reference body is maintained once, and modifications still + # refresh its selected-source fingerprint without manual configure. + (source / "lle.c").write_text("int operation(int x) { int s=0; for(int i=0;iwidth=320;out->height=240;} int psx_ws_prim_in_backdrop(void){return 0;} int gpu_ws_nw_flat_backdrop_enabled(void){return 0;} +int gpu_ws_background_requires_full_composite(void){return 0;} +void gpu_timeline_note(uint8_t kind,uint32_t a,uint32_t b){(void)kind;(void)a;(void)b;} int g_ws_tex_edge_pct=0; int psx_ws_prim_is_tagged(void){return 0;} void psx_ws_dbg_gate_frame_snapshot(void){} diff --git a/runtime/tests/test_gl_texture_filter.c b/runtime/tests/test_gl_texture_filter.c index 70e3aef0..eb0caaf8 100644 --- a/runtime/tests/test_gl_texture_filter.c +++ b/runtime/tests/test_gl_texture_filter.c @@ -87,6 +87,100 @@ static void atlas_edge(int depth) { check(gl_renderer_fbo_peek(0,0,64,64,peek),"atlas-edge readback"); check((peek[21*64+21]&0x7fff)==0x7c00,"footprint clamps before wrapping into far atlas row"); } +static void native_hold_images(void) { + GLuint textures[2]; + const uint8_t colors[2][4] = {{255,0,0,255},{0,0,255,255}}; + const uint32_t phases[2] = {0,32768}; + glGenTextures(2,textures); + for(int i=0;i<2;++i) { + glBindTexture(GL_TEXTURE_2D,textures[i]); + glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,1,1,0,GL_RGBA,GL_UNSIGNED_BYTE,colors[i]); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE); + } + for(int hold=0;hold<2;++hold) { + uint32_t lo,hi; float blend; uint8_t pixel[4]; + check(render_pass_gen_select_mode(phases,2,0.25,hold,&lo,&hi,&blend),"native phase selection"); + glDisable(GL_SCISSOR_TEST); + interp_draw_textures(textures[lo],textures[hi],blend,0,0,0,8,8); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,0); + glReadPixels(4,4,1,1,GL_RGBA,GL_UNSIGNED_BYTE,pixel); + if(hold) check(pixel[0]>250 && pixel[2]<5,"HOLD presents original native red image without ghosting"); + else check(pixel[0]>60 && pixel[2]>60,"explicit blend still mixes red and blue images"); + } + glDeleteTextures(2,textures); +} + +/* Compare mixed sampling modes with the original filter-boundary + * submission. Include overlap, STP masking, perspective, native-wide margins + * and scaled pixels: a lower batch count is useful only if all stay identical. */ +static void mixed_filter_scene(int depth,int mask_check,int semi,int separate, + uint64_t hashes[3],uint64_t* batches) { + upload_checker(depth,mask_check || semi); + glb_set_draw_area(0,0,319,239); glb_set_draw_offset(0,0); + glb_set_mask_bits(0,0); glb_set_semi_transparency(0,0); + glb_set_color_modulation(128,128,128,1); + glb_fill_rect(0,0,320,240,0x03e0); + /* Clear the reveal too, including untouched rows, before hashing it. */ + p_glBindFramebuffer(PSXGL_FRAMEBUFFER,g_wide_cur); + glDisable(GL_SCISSOR_TEST); glColorMask(GL_TRUE,GL_TRUE,GL_TRUE,GL_TRUE); + glStencilMask(0xff); glClearColor(0,0,0,0); glClearStencil(0); + glClear(GL_COLOR_BUFFER_BIT|GL_STENCIL_BUFFER_BIT); + glb_set_mask_bits(0,mask_check); glb_set_semi_transparency(semi,0); + uint64_t before=s_batch_total; + int previous_filter=-1; + for(int i=0;i<9;++i) { + int x=-24+i*6, y=16+i*2, mode=i%3; + int tracked=i!=5; /* stable mode must keep this untracked primitive sharp */ + int filter=mode==2 && !tracked ? 0 : mode; + /* Recreate the old uniform batch key. Keeping equal-filter primitives + * together matters under destination-mask checking: their stencil + * update is deferred to the end of that original batch too. */ + if(separate && filter!=previous_filter) flush_tex_batch(); + previous_filter=filter; + glb_set_texture_filter(mode); + glb_set_precise_triangle(tracked,x*65536,y*65536, + (x+60)*65536,y*65536,x*65536,(y+48)*65536); + glb_set_perspective_triangle(tracked,1.0f,0.8f,0.6f); + glb_draw_textured_triangle(x,y,0,0,x+60,y,127,0,x,y+48,0,127, + 512,256,(uint16_t)(8|(depth<<7))); + } + gl_renderer_sync_cpu(); + *batches=s_batch_total-before; + check(gl_renderer_fbo_peek(0,0,320,240,peek),"mixed filters native readback"); + hashes[0]=fnv(peek,320*240*2,0xcbf29ce484222325ull); + int scale=s_out_scale, w=320*scale,h=240*scale,ow=0,oh=0; + uint32_t* pixels=(uint32_t*)malloc((size_t)426*512*scale*scale*4); + check(pixels!=NULL,"mixed filters image allocation"); + if(!pixels) return; + int got=gl_renderer_read_display_hires(0,0,320,240,pixels,w*h,&ow,&oh); + check(got==w*h && ow==w && oh==h,"mixed filters scaled readback"); + hashes[1]=fnv(pixels,(size_t)got*4,0xcbf29ce484222325ull); + w=426*scale; h=512*scale; + got=glb_wide_dump_full(pixels,w*h,&ow,&oh,0); + check(got==w*h && ow==w && oh==h,"mixed filters wide readback"); + hashes[2]=fnv(pixels,(size_t)got*4,0xcbf29ce484222325ull); + free(pixels); +} +static void mixed_filter_batches(void) { + for(int scale=1;scale<=5;scale+=4) { + glb_set_scale(scale); + glb_wide_configure(426,53); glb_wide_set_target(0); + for(int depth=0;depth<3;++depth) for(int kind=0;kind<3;++kind) { + uint64_t ref[3]={0},batch[3]={0},nr=0,nb=0; + mixed_filter_scene(depth,kind==1,kind==2,1,ref,&nr); + mixed_filter_scene(depth,kind==1,kind==2,0,batch,&nb); + check(memcmp(ref,batch,sizeof ref)==0, + "mixed filters preserve native, scaled and wide pixels"); + if(kind==0) check(nb%llu\n", + scale,depth,kind,(unsigned long long)nr,(unsigned long long)nb); + } + } + glb_set_mask_bits(0,0); glb_set_semi_transparency(0,0); + glb_wide_configure(0,0); glb_set_scale(1); +} int main(void) { if(SDL_Init(SDL_INIT_VIDEO)!=0) return 2; SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION,3); @@ -96,6 +190,7 @@ int main(void) { if(!win) return 2; glb_init(vram); glb_set_scale(1); gl_renderer_set_swap_interval(0); if(!gl_renderer_init_context(win)) return 2; + native_hold_images(); for(int depth=0;depth<3;++depth) { upload_checker(depth,0); triangle(0,depth,0,1); int nearest=chroma(); @@ -135,6 +230,7 @@ int main(void) { upload_checker(2,1); triangle(2,2,0,1); check((peek[24*64+24]&0x7fff)==0x001f || (peek[24*64+24]&0xffff)==0xfc00, "filter never averages colors across opaque and STP classes"); + mixed_filter_batches(); check(glGetError()==GL_NO_ERROR,"GL errors"); printf("checks=%d failures=%d\n",checks,failures); gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); diff --git a/runtime/tests/test_gpu_gp0_history_guard.py b/runtime/tests/test_gpu_gp0_history_guard.py index 11170117..be57353d 100644 --- a/runtime/tests/test_gpu_gp0_history_guard.py +++ b/runtime/tests/test_gpu_gp0_history_guard.py @@ -236,6 +236,11 @@ GrBackend gr_backend(void) { return GR_BACKEND_SOFTWARE; } int gl_renderer_texture_banks_supported(void) { return 0; } int gl_renderer_select_texture_bank(uint16_t id) { return id == 0; } int gl_renderer_select_texture_bank_live_clut(uint16_t id) { return id == 0; } +void gl_renderer_note_wide_triangle_recovery(int enabled) { (void)enabled; } +int gl_renderer_projective_supported(void) { return 0; } +void gl_renderer_draw_projected_triangle(const PSXProjectedVertex vertices[3], + uint16_t texpage, uint16_t clut_x, uint16_t clut_y, int raw, int semi, int perspective) +{ (void)vertices; (void)texpage; (void)clut_x; (void)clut_y; (void)raw; (void)semi; (void)perspective; abort(); } uint16_t mod_texture_packet_bank(uint32_t p, const uint32_t *words, uint32_t n) { (void)p; (void)words; (void)n; return 0; } int mod_texture_packet_precision(uint32_t p, float q[3], float xy[6]) @@ -303,6 +308,10 @@ int gte_fg_source_enabled(void) { return 0; } int gte_fg_source_lookup(uint32_t packed, GteFgSrc out[2]) { (void)packed; (void)out; abort(); } void pgxp_set_enabled(int enabled) { (void)enabled; } +void pgxp_set_projection_tracking(int enabled) { (void)enabled; } +int pgxp_projection_tracking(void) { return 0; } +int pgxp_load_projection(uint32_t addr, uint32_t packed, PGXPProjection *out) +{ (void)addr; (void)packed; (void)out; return 0; } int pgxp_get_gte_sxy_checked(uint32_t slot, uint32_t packed, int require_valid, int32_t *x16, int32_t *y16) { (void)slot; (void)packed; (void)require_valid; (void)x16; (void)y16; return 0; } diff --git a/runtime/tests/test_gpu_render_pass_provenance.c b/runtime/tests/test_gpu_render_pass_provenance.c index a761aada..33b1fccf 100644 --- a/runtime/tests/test_gpu_render_pass_provenance.c +++ b/runtime/tests/test_gpu_render_pass_provenance.c @@ -109,6 +109,51 @@ static void test_pass_only_tags_and_phase(void) { assert(psx_ws_prim_in_backdrop() == 1); /* Original early-backdrop phase. */ } +static void test_aperture_hud_anchor(void) { + start_scene(); + test_aperture_used = 64; + memcpy(test_aperture + 1, canonical, sizeof(canonical)); + gp0_words_needed = 5; + memcpy(gp0_cmd_buf, canonical, sizeof(canonical)); + const uint32_t packet = PSX_MOD_GPU_DMA_GUEST_BASE; + for (int edge = -1; edge <= 1; ++edge) { + gpu_ws_tag_hud_prim(packet, edge); + for (unsigned alias = 0; alias < 2; ++alias) { + gp0_cmd_source_addr = (alias ? packet & 0xFFFFFFu : packet) + 4; + int32_t delta; + assert(ws_nw_explicit_hud_delta(&delta)); + assert(delta == edge * ws_nw_offset()); + } + } + gpu_ws_set_view_anchor(4, 8, 12, 16); + for (int side = -1; side <= 1; ++side) { + ws_view = (WsViewAnchor){0, 0, side * ws_nw_offset(), 7, 9}; + ws_view_frame = (uint32_t)s_frame_count; + gp0_cmd_buf[0] = canonical[0]; + gp0_execute_command(); + assert(fixture_wide_view.shift == 0); + assert(fixture_wide_view.left == 0 && fixture_wide_view.right == 0); + gp0_cmd_buf[0] ^= 1; + gp0_execute_command(); + assert(fixture_wide_view.shift == ws_view.shift); + assert(fixture_wide_view.left == 7 && fixture_wide_view.right == 9); + } + gpu_ws_set_view_anchor(0, 0, 0, 0); + gp0_cmd_buf[0] = canonical[0]; + gp0_cmd_buf[1] = 1; + assert(!ws_nw_explicit_hud_delta(NULL)); + gp0_cmd_buf[1] = canonical[1]; + gp0_cmd_source_addr = 4; + assert(!ws_nw_explicit_hud_delta(NULL)); + GpuWsTagStats before, after; + gpu_ws_get_tag_stats(&before, NULL); + test_aperture_used = 16; + gpu_ws_tag_hud_prim(packet, 1); + gpu_ws_get_tag_stats(&after, NULL); + assert(after.tag_rejected == before.tag_rejected + 1); + test_aperture_used = 0; +} + static void test_hud_dot_draw(void) { start_scene(); set_dot_packet(command_addr, 20, 20); @@ -245,6 +290,7 @@ static void test_auto_ui_prepass(void) { int main(void) { test_retagged_canonical_packet(); test_pass_only_tags_and_phase(); + test_aperture_hud_anchor(); test_hud_dot_draw(); test_screen_mask_packet(); test_repeat_rect_packet(); diff --git a/runtime/tests/test_gte_register_access.cpp b/runtime/tests/test_gte_register_access.cpp index b187aa3f..0ed00e40 100644 --- a/runtime/tests/test_gte_register_access.cpp +++ b/runtime/tests/test_gte_register_access.cpp @@ -1,5 +1,7 @@ #include "cpu_state.h" #include "gte.h" +#include "hle_gte.h" +#include "execution_profile.h" #include "pgxp.h" #include "projection_scale.hpp" #include "gte_view.h" @@ -476,6 +478,38 @@ int test_projection_override_restores_transform() { return 0; } +int test_hle_command_contract() { + constexpr uint8_t commands[] = {1,6,12,16,17,18,19,20,22,27,28,30,32,40,41,42,45,46,48,61,62,63}; + for (uint8_t function : commands) { + for (unsigned i = 0; i < 96; ++i) { + CPUState original; + randomize_gte(original); + original.gte_ts_done = 1234; + CPUState expected = original, actual = original; + const uint32_t cmd = (random_u32() & ~63u) | function; + gte_execute(&expected, cmd); + const uint32_t calls = g_test_gte_set_calls; + psx_hle_gte_execute(&actual, cmd); + CHECK(same_gte(expected, actual)); + CHECK(!std::memcmp(original.gpr, actual.gpr, sizeof actual.gpr)); + CHECK(g_test_gte_set_calls == calls + (PSX_EXECUTION_ENHANCED ? 0 : 1)); + if (PSX_EXECUTION_ENHANCED) CHECK(actual.gte_ts_done == 1234); + } + } + /* The untimed entry must still visit the camera interpolation observer. */ + CPUState cpu{}; + g_psx_projection_command = substitute_projection; + projection_callback_calls = 0; + psx_hle_gte_execute(&cpu, 0x80001u); + g_psx_projection_command = nullptr; + CHECK(projection_callback_calls == 1); + /* A normal call immediately after HLE still owns faithful latency. */ + const uint32_t calls = g_test_gte_set_calls; + gte_execute(&cpu, 6); + CHECK(g_test_gte_set_calls == calls + 1); + return 0; +} + int test_command_marshaling() { constexpr std::array kFunctions = { 0x01, 0x06, 0x0C, 0x10, 0x11, 0x12, 0x13, 0x14, @@ -1326,6 +1360,7 @@ int main() { if (int rc = test_writes()) return rc; if (int rc = test_sequence_fuzz()) return rc; if (int rc = test_command_marshaling()) return rc; + if (int rc = test_hle_command_contract()) return rc; if (int rc = test_projection_override_restores_transform()) return rc; if (int rc = test_command_timing_hook()) return rc; if (int rc = test_precise_sxy_invalidation()) return rc; diff --git a/runtime/tests/test_host_osd_diagnostics.c b/runtime/tests/test_host_osd_diagnostics.c new file mode 100644 index 00000000..b8ba5d43 --- /dev/null +++ b/runtime/tests/test_host_osd_diagnostics.c @@ -0,0 +1,25 @@ +#include "host_osd.h" +#include +#include +#include +int psx_rewind_needs_present(void) {return 0;} +int psx_savestate_menu_needs_present(void) {return 0;} +#define CHECK(x) do {if(!(x)) {fprintf(stderr,"line %d: %s\n",__LINE__,#x);return 1;}} while(0) +int main(void) { + const uint32_t *pixels;int w,h; + host_osd_set_diagnostics("P1 UP CROSS F1","P2 START F1"); + CHECK(host_osd_image(&pixels,&w,&h) && h==40 && pixels); + host_osd_set_status("Netplay connected"); + CHECK(host_osd_image(&pixels,&w,&h) && h==56); + host_osd_push("Game saved",5000); + CHECK(host_osd_image(&pixels,&w,&h) && h==56); + // Toast replaces status; both diagnostic rows remain visible below it. + unsigned white=0;for(int y=24;y<56;++y)for(int x=0;x100); + host_osd_set_diagnostics(NULL,NULL); + CHECK(host_osd_image(&pixels,&w,&h) && h==24); + char long_row[160];memset(long_row,'X',sizeof long_row-1);long_row[sizeof long_row-1]=0; + host_osd_set_diagnostics(long_row,"P2 -"); + CHECK(host_osd_image(&pixels,&w,&h) && h==56 && w<=1032); + puts("host OSD diagnostic/status/toast coexistence checks passed");return 0; +} diff --git a/runtime/tests/test_launcher_device_roundtrip.cpp b/runtime/tests/test_launcher_device_roundtrip.cpp index 2f1657c8..bf514858 100644 --- a/runtime/tests/test_launcher_device_roundtrip.cpp +++ b/runtime/tests/test_launcher_device_roundtrip.cpp @@ -3,6 +3,9 @@ #include int main() { + assert(PSXRecompV4::launcher_controller_can_share(true, true)); + assert(!PSXRecompV4::launcher_controller_can_share(true, false)); + assert(!PSXRecompV4::launcher_controller_can_share(false, true)); using PSXRecompV4::launcher_device_from_source; using PSXRecompV4::launcher_source_from_device; diff --git a/runtime/tests/test_lobby_chat_ring.c b/runtime/tests/test_lobby_chat_ring.c index 3831a5ac..339cce84 100644 --- a/runtime/tests/test_lobby_chat_ring.c +++ b/runtime/tests/test_lobby_chat_ring.c @@ -194,6 +194,30 @@ static void case_mod_caps_roundtrip(void) ck(!caps.mod_plan_fp[0], "invalid fingerprint rejected"); } +static void case_profile_settings_roundtrip(void) +{ + PsxLobbyMatchCaps caps = {0}, received = {0}; + char wire[4096]; + caps.valid = 1; + caps.aspect_num = 4; + caps.aspect_den = 3; + caps.mod_count = 1; + strcpy(caps.mods[0].id, "runtime.test"); + strcpy(caps.mods[0].ver, "1.0.0"); + strcpy(caps.mods[0].feats, "vblank-plugin=cameras~split"); + ck(psx_lobby_encode_match_caps(wire, sizeof(wire), &caps) && + !psx_lobby_decode_match_caps(wire, &received), "settings without an identity cannot enter a LAN session"); + strcpy(caps.mod_plan_fp, "24a3aeaf6034d1a59df3ac27334227343237550d4938b7b00c6ae77dfec21367"); + ck(psx_lobby_encode_match_caps(wire, sizeof(wire), &caps) && + psx_lobby_decode_match_caps(wire, &received) && received.mod_count == 1 && + !strcmp(received.mod_plan_fp, caps.mod_plan_fp) && + !strcmp(received.mods[0].feats, caps.mods[0].feats), "trusted settings survive the real LAN codec"); + ingest_match_caps_from_json(wire); + ck(g_lc.match_caps.mod_count == 1 && + !strcmp(g_lc.match_caps.mod_plan_fp, caps.mod_plan_fp) && + !strcmp(g_lc.match_caps.mods[0].feats, caps.mods[0].feats), "trusted settings survive the online lobby codec"); +} + int main(void) { case_room_order(); @@ -201,6 +225,7 @@ int main(void) case_empty_and_clear(); case_long_line_is_truncated_not_dropped(); case_mod_caps_roundtrip(); + case_profile_settings_roundtrip(); if (g_failures == 0) { printf("lobby_chat_ring_test: ok\n"); return 0; diff --git a/runtime/tests/test_mod_capcom_bg2d.c b/runtime/tests/test_mod_capcom_bg2d.c new file mode 100644 index 00000000..d98a44a9 --- /dev/null +++ b/runtime/tests/test_mod_capcom_bg2d.c @@ -0,0 +1,105 @@ +#ifdef NDEBUG +#undef NDEBUG +#endif +#include +#include +#include +#include "mod_capcom_bg2d.h" +#include "mod_visible_placements.h" + +static unsigned char memory[10u * 1024u * 1024u]; +static uint32_t writes; +uint8_t psx_mod_read_byte(uint32_t a) { return memory[a & 0x1fffffffu]; } +uint16_t psx_mod_read_half(uint32_t a) { + a &= 0x1fffffffu; return memory[a] | (uint16_t)memory[a+1] << 8; +} +uint32_t psx_mod_read_word(uint32_t a) { + a &= 0x1fffffffu; + return memory[a] | (uint32_t)memory[a+1] << 8 | (uint32_t)memory[a+2] << 16 | (uint32_t)memory[a+3] << 24; +} +void psx_mod_write_word(uint32_t a, uint32_t w) { + a &= 0x1fffffffu; assert(a <= sizeof memory - 4); ++writes; + for (unsigned i=0; i<4; ++i) memory[a+i] = (uint8_t)(w >> (8*i)); +} +uint32_t psx_mod_alloc_gpu_dma_memory(uint32_t n, uint32_t align) { + assert(n == 6u * 1024u * 1024u && align == 32u); return 0x400000u; +} +void gpu_ws_bg2d_set_host_arena(uint32_t base, uint32_t bytes) { + assert(base == 0x400000u && bytes == 6u * 1024u * 1024u); +} + +int main(void) { + PSXPlacementRect original={0,320,0,240},wide={-160,480,0,240},strips[4]; + assert(psx_placement_exposed(original,wide,0,strips)==1); + assert(psx_placement_exposed(wide,wide,1,strips)==0); /* No repeat scan while idle. */ + assert(psx_placement_exposed(original,wide,1,strips)==2); + assert(psx_placement_contains(strips[0],-100,100)); + assert(psx_placement_contains(strips[1],400,100)); + assert(!psx_placement_contains(strips[1],319,100)); + PSXPlacementRect moved={-128,512,0,240}; + assert(psx_placement_exposed(wide,moved,1,strips)==1); + assert(psx_placement_contains(strips[0],500,100)); + assert(!psx_placement_contains(strips[0],400,100)); /* A killed wide enemy is not revisited. */ + assert(psx_placement_contains(wide,400,100)); + assert(!psx_placement_contains((PSXPlacementRect){480,1120,0,240},400,100)); + WsViewAnchor centered = {106,106,0,0,0}; + WsViewAnchor mapped = psx_capcom_scroll_view(centered,800,667); + assert(mapped.left==133 && mapped.right==79 && mapped.shift==27); + WsViewAnchor edge = {212,0,106,0,0}; + mapped = psx_capcom_scroll_view(edge,800,535); + assert(mapped.left==265 && mapped.right==0 && mapped.shift==159); + /* Native fractional scroll crosses integer boundaries without replacing + * its quantization with a floating-point scale of the view shift. */ + mapped=psx_capcom_parallax_view((WsViewAnchor){53,53,0,0,0},2,65); + assert(mapped.left==26 && mapped.right==80 && mapped.shift==-27); + PSXCapcomTileMap map = {0x80010000u,0x80011000u,0x80014000u,4,1,4,0,0,3}; + /* Native map cell0 points to metatile1. A patterned row proves the + * wide output reads authored tile coordinates rather than a32-col ring. */ + for (unsigned i=0; i<4; ++i) memory[0x10000+i]=1; + for (unsigned i=0; i<256; ++i) { + uint16_t tile=(i&1) ? 0xc002u : 1u; + memory[0x11200+i*2]=(uint8_t)tile; + memory[0x11200+i*2+1]=(uint8_t)(tile>>8); + } + psx_mod_write_word(0x80014004u,0x01123400u); + psx_mod_write_word(0x80014008u,0x42123400u); + assert(psx_capcom_map_tile(&map,0,0)==1); + assert(psx_capcom_map_tile(&map,528,0)==0xc002u); + assert(psx_capcom_map_tile(&map,-16,0)==0); + assert(psx_capcom_map_tile(&map,1024,0)==0); + assert(psx_capcom_map_tile(&map,0,256)==0); + map.width=5; + assert(psx_capcom_map_tile(&map,0,0)==0); /* Invalid dimensions. */ + map.width=4; + /* SLUS01334 original80028144..80028164 palette recipe: base7900, + * descriptor bitsF000 shifted6, bits0F00 shifted8, page40 shifted4. */ + assert(psx_capcom_tile_uvclut(0x42123400u,0x7900u)==0x7dc41020u); + assert(psx_capcom_tile_uvclut(0x42123400u,0x7980u)==0x7e441020u); + uint32_t arena=psx_capcom_background_activate(3); + PSXCapcomBackground b={0x80015000u,0x80016000u,3,17,0x7900}; + psx_mod_write_word(b.heads+408,0xdeadbeefu); /* Other display buffer. */ + psx_mod_write_word(b.heads+68,0x12345678u); /* Other layer. */ + psx_mod_write_word(0x80017004u,0x7d808080u); + WsViewAnchor v={0,224,112,0,0}; + assert(psx_capcom_background_render(&b,arena,0,&map,0,0,v,0x80017000u,NULL)==560); + assert(psx_mod_read_word(b.heads+408)==0xdeadbeefu); + assert(psx_mod_read_word(b.heads+68)==0x12345678u); + assert(psx_mod_read_word(arena+8)==0); + assert(psx_mod_read_word(arena+32+4)==0x7f808080u); /* Tile semi-transparency. */ + assert(psx_mod_read_word(arena+32+12)==0x7dc41020u); + assert(psx_mod_read_word(arena+16)==112); + assert(psx_mod_read_word(arena+28)==GPU_WS_BG2D_PACKET_MAGIC); + assert((psx_mod_read_word(b.heads+3u*68u+2u*4u)&0xffffffu)==arena+32); + /* Column33 atx528 exists past the512px native ring. */ + assert((int16_t)psx_mod_read_word(arena+33u*32u+8u)==528); + assert(psx_mod_read_word(arena+560u*32u)==0); /* Slice overrun sentinel. */ + writes=0; + v.left=32768; + assert(psx_capcom_background_render(&b,arena,0,&map,15,0,v,0x80017000u,NULL)==-1); + assert(writes==0); /* Invalid setup must not erase native lists. */ + v=(WsViewAnchor){0,0,0,0,0}; + assert(psx_capcom_background_render(&b,arena,0,&map,0,0,v,0x80017000u,NULL)==0); + assert(writes==0); /* Native4:3 identity. */ + puts("Capcom adaptive map/palette/OT/buffer bounds passed"); + return 0; +} diff --git a/runtime/tests/test_mod_memory_snapshot.c b/runtime/tests/test_mod_memory_snapshot.c index 6fd4df01..e3d48178 100644 --- a/runtime/tests/test_mod_memory_snapshot.c +++ b/runtime/tests/test_mod_memory_snapshot.c @@ -1,6 +1,7 @@ /* Include implementation to inspect owned backing storage without pulling in * hardware routing. Section GC discards unrelated memory access functions. */ #include "memory.c" +int g_psx_render_pass_active = 0; static void check(int ok,const char* text){if(!ok){fprintf(stderr,"FAIL %s\n",text);exit(1);}} int main(void){ check(psx_mod_memory_snapshot_bytes()==0 && psx_mod_memory_layout_cookie()==0,"vanilla unchanged"); @@ -46,5 +47,21 @@ int main(void){ (render_pass_mod_arenas_rollback(), mod_gpu_dma_memory[8] == 0x34), "a second pass journals again"); } + // Replaying an earlier draw snapshot must still roll back to the completed + // live frame, including pages the replay never otherwise writes. + size = psx_mod_memory_snapshot_bytes(); + saved = (uint8_t*)realloc(saved, size);check(saved != NULL,"new-layout test buffer"); + psx_mod_memory_snapshot_write(saved); + memset(mod_memory,0xA5,mod_memory_used);memset(mod_gpu_dma_memory,0x5A,mod_gpu_dma_memory_used); + { + const uint64_t completed = render_pass_mod_arenas_hash(); + g_psx_render_pass_active = 1; + check(psx_mod_memory_snapshot_read(saved,size),"draw-entry arena restore in a pass"); + check(mod_memory[0]==0x12 && mod_gpu_dma_memory[0]==0x34,"earlier draw-entry bytes visible"); + check(render_pass_mod_store(PSX_MOD_GPU_DMA_GUEST_BASE,0xDEADBEEFu,4)==1,"replay writes restored arena"); + g_psx_render_pass_active = 0; + render_pass_mod_arenas_rollback(); + check(render_pass_mod_arenas_hash()==completed,"rollback restores complete live arena after earlier snapshot"); + } free(saved);puts("enhancement snapshot checks passed");return 0; } diff --git a/runtime/tests/test_mod_netplay.c b/runtime/tests/test_mod_netplay.c new file mode 100644 index 00000000..f7d810f1 --- /dev/null +++ b/runtime/tests/test_mod_netplay.c @@ -0,0 +1,174 @@ +#include "mod_netplay.h" +#include "netplay_state_digest.h" +#include "crc32.h" +#include +#include +#include + +/* Deterministic device providers: exercise the actual core CRC fold, including + * the trusted plugin's host-state partition, without creating a game/window. */ +static uint8_t ram[2048], scratchpad[1024], spu_ram[16]; +static uint16_t vram[1024 * 512]; +uint32_t i_stat, i_mask, g_psx_icache_tv[1024]; +uint64_t psx_cycle_count; +uint8_t* memory_get_ram_ptr(void) { return ram; } +uint32_t memory_get_ram_bytes(void) { return sizeof(ram); } +uint8_t* memory_get_scratchpad_ptr(void) { return scratchpad; } +uint8_t* spu_get_ram_ptr(void) { return spu_ram; } +uint32_t spu_get_ram_bytes(void) { return sizeof(spu_ram); } +const uint16_t* gpu_get_vram(void) { return vram; } +uint32_t interrupts_get_cycles_since_vblank(void) { return 0; } +uint32_t dirty_ram_get_bitmap_word_count(void) { return 1; } +uint32_t dirty_ram_get_bitmap_word(uint32_t index) { (void)index; return 0; } +void timers_get_snapshot(uint16_t counter[3], uint32_t mode[3], + uint16_t target[3], int32_t irq_line[3], uint32_t frac[3]) { + memset(counter, 0, 3 * sizeof(*counter)); + memset(mode, 0, 3 * sizeof(*mode)); + memset(target, 0, 3 * sizeof(*target)); + memset(irq_line, 0, 3 * sizeof(*irq_line)); + memset(frac, 0, 3 * sizeof(*frac)); +} +#define EMPTY_SNAPSHOT(module) \ + uint32_t module##_snapshot_bytes(void) { return 0; } \ + void module##_snapshot_write(uint8_t* out) { (void)out; } +EMPTY_SNAPSHOT(cdrom) +EMPTY_SNAPSHOT(gpu) +EMPTY_SNAPSHOT(spu) +EMPTY_SNAPSHOT(mdec) +EMPTY_SNAPSHOT(dma) +EMPTY_SNAPSHOT(sio) +void sio_snapshot_section_ends(uint32_t out[5]) { memset(out, 0, 5 * sizeof(*out)); } + +static uint32_t gameplay_state; +static unsigned digest_calls; +static uint32_t gameplay_digest(void) { ++digest_calls; return gameplay_state; } + +int main(int argc, char** argv) { + CPUState cpu; + NetplayCoreParts baseline, live, changed; + PSXModNetplayProfile invalid, duplicate; + static PSXModNetplayProfile profile = { + "test.simulation", "test-coop-delay-v1", 0, 0, 1, 1u, NULL, gameplay_digest, 1 + }; + const int wide = argc > 1 && strcmp(argv[1], "--wide") == 0; + const int no_banks = argc > 1 && strcmp(argv[1], "--no-banks") == 0; + const int with_options = argc > 1 && strcmp(argv[1], "--options") == 0; + uint32_t original_session, profile_session; + char original_identity[65], current_identity[65]; + const char* fingerprint = + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + memset(&cpu, 0, sizeof(cpu)); + cpu.pc = 0x80010000; + netplay_core_digest_parts(&cpu, &baseline); + assert(baseline.mod == 0); + assert(psx_mod_netplay_rollback_supported()); + assert(!psx_mod_netplay_requires_texture_banks()); + assert(psx_mod_netplay_renderer_allowed(0) && psx_mod_netplay_renderer_allowed(1) && + psx_mod_netplay_renderer_allowed(2)); + assert(psx_mod_netplay_savestates_supported()); + assert(strcmp(psx_mod_netplay_version("dev"), "dev") == 0); + assert(psx_mod_netplay_session_id(123) == 123); + assert(psx_mod_netplay_content_identity("", current_identity) && !current_identity[0]); + assert(psx_mod_netplay_content_identity(fingerprint, current_identity) && + strcmp(fingerprint, current_identity) == 0); + assert(!psx_mod_netplay_content_identity("invalid", current_identity)); + psx_mod_netplay_set_active(1); + assert(!psx_mod_netplay_is_active()); + assert(!psx_mod_register_netplay_profile(NULL)); + invalid = profile; + invalid.plugin_id = ""; + assert(!psx_mod_register_netplay_profile(&invalid)); + invalid = profile; + invalid.compatibility_id = + "0123456789012345678901234567890123456789012345678901234567890123"; + assert(!psx_mod_register_netplay_profile(&invalid)); + invalid = profile; + invalid.fixed_aspect_mask = 8u; + assert(!psx_mod_register_netplay_profile(&invalid)); + invalid = profile; + invalid.settings_package_id = "test.package"; + assert(!psx_mod_register_netplay_profile(&invalid)); + invalid = profile; + invalid.renderer_option_id = "cameras"; + assert(!psx_mod_register_netplay_profile(&invalid)); + if (wide) profile.fixed_aspect_mask = 6u; + if (no_banks) profile.requires_retained_texture_banks = 0; + if (with_options) { + profile.settings_package_id = "test.package"; + profile.settings_feature_id = "coop"; + } + assert(psx_mod_register_netplay_profile(&profile)); + assert(psx_mod_register_netplay_profile(&profile)); + duplicate = profile; + assert(!psx_mod_register_netplay_profile(&duplicate)); + assert(!psx_mod_netplay_rollback_supported()); + assert(psx_mod_netplay_requires_texture_banks() == !no_banks); + assert(psx_mod_netplay_renderer_allowed(1)); + assert(psx_mod_netplay_renderer_allowed(0) == no_banks); + assert(psx_mod_netplay_renderer_allowed(2) == no_banks); + assert(psx_mod_netplay_renderer_allowed(-1) == no_banks); + assert(psx_mod_netplay_savestates_supported()); + assert(strcmp(psx_mod_netplay_version("override"), "test-coop-delay-v1") == 0); + assert(psx_mod_netplay_aspect() == (wide ? 1 : 0)); + original_session = psx_mod_netplay_session_id(123); + assert(psx_mod_netplay_content_identity("", original_identity) && strlen(original_identity) == 64); + if (!wide && !no_banks && !with_options) + assert(strcmp(original_identity, + "73ac2c5e7ad58a0463c8234dc3cbe153a410ca18f03ee5794aca8b8015f92b0a") == 0); + assert(psx_mod_netplay_content_identity("", current_identity) && + strcmp(original_identity, current_identity) == 0); + assert(psx_mod_netplay_content_identity(fingerprint, current_identity) && + strcmp(original_identity, current_identity) != 0 && strcmp(fingerprint, current_identity) != 0); + assert(original_session != 123); + assert(original_session == psx_mod_netplay_session_id(123)); + assert(!psx_mod_netplay_set_options_identity("invalid")); + if (with_options) { + assert(psx_mod_netplay_set_options_identity(fingerprint)); + assert(psx_mod_netplay_session_id(123) == original_session); + assert(psx_mod_netplay_content_identity("", current_identity)); + assert(strcmp(original_identity, current_identity) != 0); + uint32_t settings_session = psx_mod_netplay_session_id(123); + assert(!psx_mod_netplay_set_options_identity("bad")); + assert(settings_session == psx_mod_netplay_session_id(123)); + assert(psx_mod_netplay_set_options_identity("")); + assert(original_session == psx_mod_netplay_session_id(123)); + assert(psx_mod_netplay_content_identity("", current_identity)); + assert(strcmp(original_identity, current_identity) == 0); + } else assert(!psx_mod_netplay_set_options_identity(fingerprint)); + assert(!psx_mod_netplay_set_aspect(-1)); + assert(!psx_mod_netplay_set_aspect(3)); + assert(!psx_mod_netplay_set_aspect(wide ? 0 : 1)); + assert(psx_mod_netplay_aspect() == (wide ? 1 : 0)); + if (wide) { + assert(psx_mod_netplay_set_aspect(2)); + assert(original_session != psx_mod_netplay_session_id(123)); + assert(psx_mod_netplay_content_identity("", current_identity) && + strcmp(original_identity, current_identity) != 0); + } + netplay_core_digest_parts(&cpu, &live); + assert(live.core == baseline.core && digest_calls == 0); + gameplay_state = 0x12345678; + psx_mod_netplay_set_active(1); + assert(psx_mod_netplay_is_active()); + assert(!psx_mod_netplay_savestates_supported()); + assert(psx_mod_netplay_has_state_digest()); + profile_session = psx_mod_netplay_session_id(123); + netplay_core_digest_parts(&cpu, &live); + assert(live.mod == gameplay_state && digest_calls == 1); + assert(live.core != baseline.core && live.ram == baseline.ram && live.cpu == baseline.cpu); + ++gameplay_state; + netplay_core_digest_parts(&cpu, &changed); + assert(changed.core != live.core && changed.mod == gameplay_state); + assert(changed.ram == live.ram && changed.cpu == live.cpu); + gameplay_state = 0; + netplay_core_digest_parts(&cpu, &changed); + assert(changed.mod == 0 && changed.core != baseline.core); /* zero is present */ + psx_mod_netplay_set_active(0); + assert(psx_mod_netplay_savestates_supported()); + assert(!psx_mod_netplay_has_state_digest()); + netplay_core_digest_parts(&cpu, &changed); + assert(changed.core == baseline.core && digest_calls == 3); + assert(psx_mod_netplay_session_id(123) == profile_session); + puts("trusted netplay policy and host-state digest tests passed"); + return 0; +} diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 36ed5812..a0a6ec1c 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1,4 +1,5 @@ #include "mod_runtime.h" +#include "mod_netplay.h" #include "host_launch_timing.h" #include "host_file_identity.h" #include "mod_packages.h" @@ -30,6 +31,8 @@ static int failures; static int activation_calls; static int plugin_calls; static int restore_calls; +static int option_renderer_activations; +static void option_renderer_activate(void) { ++option_renderer_activations; } static PsxLobbyMatchCaps test_match_caps; extern "C" const PsxLobbyMatchCaps* psx_lobby_match_caps(void) { return &test_match_caps; @@ -403,7 +406,7 @@ static uint64_t startup_counter(const char* requested) { return 0; } -int main() { +int main(int argc, char** argv) { #if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-cache-test"; const auto isolated_cache = root / "test-cache"; @@ -478,6 +481,10 @@ int main() { "[[feature]]\n" "id = \"vblank-plugin\"\n" "name = \"VBlank Plugin\"\n" + "[[option]]\nfeature = \"vblank-plugin\"\nid = \"cameras\"\n" + "label = \"Cameras\"\ntype = \"choice\"\ndefault = \"unified\"\n" + "[[option.choice]]\nvalue = \"unified\"\nlabel = \"Unified\"\n" + "[[option.choice]]\nvalue = \"split\"\nlabel = \"Split\"\n" "[[option]]\n" "feature = \"dynamic-main\"\n" "id = \"count\"\n" @@ -1780,6 +1787,173 @@ int main() { check(!PSXRecompV4::mod_runtime_try_prepare_cached(iso_path), "default runtime does not opt into cached startup"); } #endif + /* The executable-owned profile forces its trusted plugin, including hooks, + * without applying/saving any player's offline patches or selections. */ + test_match_caps = {}; + const bool missing_profile = argc > 1 && std::string(argv[1]) == "--missing-profile"; + const bool missing_renderer = argc > 1 && std::string(argv[1]) == "--missing-renderer"; + const bool option_renderer_missing = argc > 1 && std::string(argv[1]) == "--option-renderer-missing"; + const bool profile_options = option_renderer_missing || + (argc > 1 && std::string(argv[1]) == "--profile-options"); + static const PSXModNetplayProfile profile = { + missing_profile ? "runtime.missing-simulation" : "runtime.test-vblank", + "runtime-coop-delay-v1", 0, 0, 1, 3u, + profile_options ? "runtime.option-renderer" : "runtime.missing-renderer", nullptr, 0, + profile_options ? "runtime.test" : nullptr, + profile_options ? "vblank-plugin" : nullptr, + profile_options ? "cameras" : nullptr, + profile_options ? "split" : nullptr + }; + if (profile_options && !option_renderer_missing) + check(psx_mod_register_activation_plugin("runtime.option-renderer", option_renderer_activate), + "profile option renderer registration"); + check(psx_mod_register_netplay_profile(&profile), "trusted profile registration"); + check(PSXRecompV4::mod_runtime_initialize(root, "SLUS-RUNTIME", 0x80002000, {}, &error), + "profile reinitialize offline catalog"); + if (missing_profile || missing_renderer || option_renderer_missing) { + if (missing_renderer) check(psx_mod_netplay_set_aspect(1), "select profile renderer"); + if (option_renderer_missing) { + test_match_caps.valid = 1; + test_match_caps.mod_count = 1; + std::strcpy(test_match_caps.mods[0].id, "runtime.test"); + std::strcpy(test_match_caps.mods[0].ver, "1.0.0"); + std::strcpy(test_match_caps.mods[0].feats, "vblank-plugin=cameras~split"); + std::string fingerprint; + check(PSXRecompV4::mod_runtime_netplay_settings_fingerprint(test_match_caps, fingerprint, &error), + "missing renderer offer still has a valid settings identity"); + std::strcpy(test_match_caps.mod_plan_fp, fingerprint.c_str()); + } + check(!PSXRecompV4::mod_runtime_commit_netplay(iso_path, &error) && + !psx_mod_netplay_is_active() && !error.empty(), + "missing trusted plugin refuses profile activation with a reason"); + check(psx_mod_netplay_savestates_supported(), "failed profile cannot latch snapshot policy"); + const auto before_failed_activation = activation_calls; + mod_runtime_activate_plugins(); + check(activation_calls == before_failed_activation, "failed profile cannot activate stale offline plugin"); + fs::remove_all(root, ec); + return failures ? 1 : 0; + } + const auto selection_path = root / "state.toml"; + auto read_selection = [&]() { + std::ifstream file(selection_path, std::ios::binary); + return std::string(std::istreambuf_iterator(file), {}); + }; + std::string offline_selection = read_selection(); + check(PSXRecompV4::mod_runtime_commit_for_direct_netplay(iso_path, &error), error.c_str()); + check(psx_mod_netplay_is_active() && !psx_mod_netplay_savestates_supported(), + "profile activates host-state snapshot policy"); + const int activations_before = activation_calls; + mod_runtime_activate_plugins(); + check(activation_calls == activations_before + 1, "profile forces native plugin activation"); + ram[0x1000] = 0x5a; + mod_runtime_on_dispatch(0x80002000); + check(ram[0x1000] == 0x5a, "profile excludes offline executable writes"); + check(psx_mod_read_disc_file("S0/LEVEL.NSF", result.data(), 3000, &bytes) && bytes == 3000, + "profile retains original-disc reads"); + check(read_selection() == offline_selection, "profile does not save temporary selections"); + check(PSXRecompV4::mod_runtime_commit_netplay_view(iso_path, &error), "profile rematch commit"); + mod_runtime_activate_plugins(); + check(activation_calls == activations_before + 2, "profile rematch activates exactly once"); + CPUState profile_cpu{}; + test_netplay_active = 1; + filter_handles = true; + check(psx_mod_function_entry(&profile_cpu, 0x80003008) && profile_cpu.gpr[2] == 42u, + "profile retains modern trusted function filters"); + test_netplay_active = 0; + test_match_caps.valid = 1; + test_match_caps.mod_count = 1; + check(!PSXRecompV4::mod_runtime_commit_for_netplay(iso_path, &error) && + !psx_mod_netplay_is_active(), "profile rejects imported package plans"); + test_match_caps = {}; + check(PSXRecompV4::mod_runtime_commit_netplay(iso_path, &error), "profile restores after rejected plan"); + if (profile_options) { + char value[32], unified_identity[65], split_identity[65]; + check(psx_mod_option_value("runtime.test", "vblank-plugin", "cameras", value, sizeof(value)) && + std::string(value) == "unified", "profile includes default camera option"); + check(psx_mod_netplay_content_identity("", unified_identity), "default profile content identity"); + test_match_caps.valid = 1; + test_match_caps.mod_count = 1; + std::strcpy(test_match_caps.mods[0].id, "runtime.test"); + std::strcpy(test_match_caps.mods[0].ver, "1.0.0"); + std::strcpy(test_match_caps.mods[0].feats, "vblank-plugin=cameras~split"); + std::string fingerprint; + check(PSXRecompV4::mod_runtime_netplay_settings_fingerprint(test_match_caps, fingerprint, &error) && + fingerprint == "24a3aeaf6034d1a59df3ac27334227343237550d4938b7b00c6ae77dfec21367", + "published settings fingerprint matches the canonical codec fixture"); + char preview_identity[65]; + check(psx_mod_netplay_content_identity("", preview_identity) && + std::string(preview_identity) == unified_identity && read_selection() == offline_selection, + "offer preview does not mutate the active profile or preferences"); + check(!PSXRecompV4::mod_runtime_commit_netplay(iso_path, &error), "missing settings fingerprint rejected"); + std::strcpy(test_match_caps.mod_plan_fp, fingerprint.c_str()); + test_match_caps.mod_plan_fp[0] = '0'; + check(!PSXRecompV4::mod_runtime_commit_netplay(iso_path, &error), "tampered settings fingerprint rejected"); + std::strcpy(test_match_caps.mod_plan_fp, fingerprint.c_str()); + check(PSXRecompV4::mod_runtime_commit_netplay(iso_path, &error), error.c_str()); + mod_runtime_activate_plugins(); + check(psx_mod_option_value("runtime.test", "vblank-plugin", "cameras", value, sizeof(value)) && + std::string(value) == "split", "profile adopts host split camera option"); + check(option_renderer_activations == 1, "split activates the declared renderer at native aspect"); + check(psx_mod_netplay_content_identity("", split_identity) && + std::string(split_identity) != unified_identity, "different camera policies cannot handshake"); + check(read_selection() == offline_selection, "host options do not persist over offline choices"); + std::strcpy(test_match_caps.mods[0].feats, "vblank-plugin=cameras~unified"); + check(!PSXRecompV4::mod_runtime_commit_netplay(iso_path, &error), "changed options with stale fingerprint rejected"); + check(PSXRecompV4::mod_runtime_netplay_settings_fingerprint(test_match_caps, fingerprint, &error), + "unified offer fingerprint"); + std::strcpy(test_match_caps.mod_plan_fp, fingerprint.c_str()); + check(PSXRecompV4::mod_runtime_commit_netplay(iso_path, &error), error.c_str()); + mod_runtime_activate_plugins(); + check(option_renderer_activations == 1, "unified does not activate the option renderer"); + const char* rejected[] = {"dynamic-main", "vblank-plugin,dynamic-main", + "vblank-plugin=cameras~invalid", "vblank-plugin=unknown~split"}; + for (const char* token : rejected) { + std::strcpy(test_match_caps.mods[0].feats, token); + check(!PSXRecompV4::mod_runtime_commit_netplay(iso_path, &error) && + !psx_mod_netplay_is_active(), "profile rejects undeclared host policy"); + } + std::strcpy(test_match_caps.mods[0].feats, "vblank-plugin=cameras~split"); + test_match_caps.mod_count = 2; + check(!PSXRecompV4::mod_runtime_commit_netplay(iso_path, &error), "profile rejects extra packages"); + test_match_caps.mod_count = 1; + std::strcpy(test_match_caps.mods[0].ver, "different-version"); + check(!PSXRecompV4::mod_runtime_commit_netplay(iso_path, &error), "profile rejects different package versions"); + std::strcpy(test_match_caps.mods[0].ver, "1.0.0"); + std::strcpy(test_match_caps.mods[0].id, "different-package"); + check(!PSXRecompV4::mod_runtime_commit_netplay(iso_path, &error), "profile rejects unrelated settings packages"); + test_match_caps = {}; + check(PSXRecompV4::mod_runtime_commit_netplay(iso_path, &error), "profile recovers after rejected options"); + check(read_selection() == offline_selection, "all settings commits preserve offline state"); + PSXRecompV4::mod_runtime_end_netplay(); + PSXRecompV4::ModPackageManager preferences(root); + check(preferences.scan(&error) && preferences.load_state(&error) && + preferences.set_feature_option("runtime.test", "vblank-plugin", "cameras", "split", &error) && + preferences.save_state(&error), "explicit local split preference"); + offline_selection = read_selection(); + check(PSXRecompV4::mod_runtime_initialize(root, "SLUS-RUNTIME", 0x80002000, {}, &error), + "profile reloads the explicit local preference"); + check(PSXRecompV4::mod_runtime_commit_for_direct_netplay(iso_path, &error), error.c_str()); + check(psx_mod_option_value("runtime.test", "vblank-plugin", "cameras", value, sizeof(value)) && + std::string(value) == "split", "direct sessions use the local agreed preference"); + check(psx_mod_netplay_content_identity("", split_identity) && + std::string(split_identity) != unified_identity, "direct option identity covers camera policy"); + test_match_caps.valid = 1; + test_match_caps.mod_count = 1; + std::strcpy(test_match_caps.mods[0].id, "runtime.test"); + std::strcpy(test_match_caps.mods[0].ver, "1.0.0"); + std::strcpy(test_match_caps.mods[0].feats, "vblank-plugin"); + check(PSXRecompV4::mod_runtime_netplay_settings_fingerprint(test_match_caps, fingerprint, &error), + "default host offer fingerprint"); + std::strcpy(test_match_caps.mod_plan_fp, fingerprint.c_str()); + check(PSXRecompV4::mod_runtime_commit_netplay(iso_path, &error), error.c_str()); + check(psx_mod_option_value("runtime.test", "vblank-plugin", "cameras", value, sizeof(value)) && + std::string(value) == "unified", "omitted host options use defaults rather than guest preferences"); + check(read_selection() == offline_selection, "host defaults preserve the guest's split preference"); + } + PSXRecompV4::mod_runtime_end_netplay(); + check(!psx_mod_netplay_is_active() && psx_mod_netplay_savestates_supported() && + PSXRecompV4::mod_runtime_fingerprint().empty(), "profile end clears hooks and snapshot policy"); + check(read_selection() == offline_selection, "profile end preserves offline selection file"); fs::remove_all(root, ec); if (failures) return 1; std::cout << "mod runtime tests passed\n"; diff --git a/runtime/tests/test_neversoft_helpers.cpp b/runtime/tests/test_neversoft_helpers.cpp new file mode 100644 index 00000000..3665a29a --- /dev/null +++ b/runtime/tests/test_neversoft_helpers.cpp @@ -0,0 +1,108 @@ +// Focused host-only checks: no game launch, disc scan or renderer required. +#include "mod_named_wad.hpp" +#include "mod_neversoft_renderer.hpp" +#include +#include +#include +#include + +struct TestLayout { + static constexpr const char* Plugin = "test.draw", *PrecisionPlugin = "test.pgxp", *Package = "test.package"; + static constexpr const char* PrecisionCounter = "test.precision", *FogCounter = "test.fog", *ArenaCounter = "test.arena"; + static constexpr const char* PacketCounter = "test.packet", *FullCounter = "test.full", *PeakCounter = "test.peak"; + static constexpr uint32_t BufferA = 0x800A707C, BufferB = BufferA + 0x78; + static constexpr uint32_t Current = 0x800C247C, Cursor = 0x800C2484, Limit = 0x800C2034; + static constexpr uint32_t AllocA = 0x8006BDD8, AllocB = 0x8006BDF0, AllocWord = 0x0C01C0CB; + static constexpr uint32_t FogPc = 0x80081178, SwapPc = 0x8006BE18, LimitPc = 0x800314E4; + static constexpr uint32_t SubmitPc = 0x8006BE68, PrecisionPc = 0x80089A98; + static constexpr PSXNeversoftPacketLengthSite PacketLengthSites[] = { + {0x800891C0, 0x000A5582}, {0x8008A128, 0x00129582}, + }; +}; +using R = PSXNeversoftRenderer; +static bool loaded = false, reject_packet_code = false; +static uint32_t a = 0x800D0000, b = 0x800E7000, cursor = 0, limit = 0; +static unsigned registered = 0; +extern "C" { +int psx_mod_register_activation_plugin(const char*, PSXModActivationCallback) { return 1; } +int psx_mod_register_function_entry_plugin(const char*, uint32_t, PSXModFunctionEntryCallback) { return 1; } +int psx_mod_register_function_filter_plugin(const char*, uint32_t, PSXModFunctionFilterCallback) { return 1; } +int psx_mod_register_instruction_plugin(const char*, uint32_t, uint32_t, PSXModFunctionEntryCallback) { ++registered; return 1; } +int pgxp_store_flagged_gte_sxy(uint32_t, uint8_t, uint32_t, uint32_t) { return 0; } +int psx_mod_option_value(const char*, const char*, const char*, char*, uint32_t) { return 0; } +uint32_t psx_mod_alloc_gpu_dma_memory(uint32_t bytes, uint32_t alignment) { + assert(bytes == 2 * R::ArenaSize && alignment == 8); return 0x80800000; +} +uint32_t psx_mod_read_word(uint32_t address) { + if (address == TestLayout::BufferA + 0x74) return a; + if (address == TestLayout::BufferB + 0x74) return b; + if (address == TestLayout::Current) return TestLayout::BufferA; + if (address == TestLayout::Cursor) return cursor; + if (!loaded) return 0; + if (address == TestLayout::AllocA || address == TestLayout::AllocB) return TestLayout::AllocWord; + if (address == TestLayout::LimitPc + 4) return 0x34846F00; + if (address == TestLayout::FogPc) return 0x00803821; + for (const auto& site : TestLayout::PacketLengthSites) + if (address == site.address) return reject_packet_code ? 0 : site.word; + return 0; +} +void psx_mod_write_word(uint32_t address, uint32_t value) { + if (address == TestLayout::BufferA + 0x74) a = value; + else if (address == TestLayout::BufferB + 0x74) b = value; + else if (address == TestLayout::Limit) limit = value; + else assert(false); +} +void psx_mod_counter_add(const char*, uint32_t) {} +} +int main() { + PSXNamedWadIndex index; + std::vector hed = {'a',0,0,0, 0,0,0,0, 1,0,0,0, 0xff}; + assert(index.assign(hed.data(), uint32_t(hed.size()), 2048)); + assert(index.matches(0,2048,1)); + assert(!index.matches(0,1,1) && !index.matches(0,2048,2) && !index.matches(2048,2048,1)); + assert(!index.assign(hed.data(), uint32_t(hed.size()), 1)); + assert(!index.matches(0,2048,1)); // failed assignment clears prior catalog + assert(!index.assign(hed.data(),12,2048)); // absent sentinel + hed[4] = 1; + assert(!index.assign(hed.data(),13,4096)); // unaligned offset + hed[4] = 0; hed[8] = hed[9] = hed[10] = hed[11] = 0xff; + assert(!index.assign(hed.data(),13,UINT32_MAX)); // padded-size overflow + hed[8] = 1; hed[9] = hed[10] = hed[11] = 0; + hed.pop_back(); + hed.insert(hed.end(), {'b',0,0,0, 0,0,0,0, 2,0,0,0, 0xff}); + assert(!index.assign(hed.data(),25,2048)); // conflicting duplicate + hed[20] = 1; + assert(index.assign(hed.data(),25,2048)); // aliases with identical extent + R::register_plugins(); assert(registered == 3); + R::activate(); R::swap(nullptr,0); + assert(a == 0x800D0000 && b == 0x800E7000); + loaded = true; reject_packet_code = true; R::swap(nullptr,0); + assert(a == 0x800D0000 && b == 0x800E7000); + reject_packet_code = false; R::swap(nullptr,0); + assert(a == R::arena && b == R::arena + R::ArenaSize); + for (unsigned reg : {10u,18u}) for (unsigned length = 0; length < 256; ++length) + for (uint32_t address : {0x00800000u,0x00860000u,0x008BFFFCu}) { + CPUState cpu{}; + for (unsigned i=0; i<32; ++i) cpu.gpr[i] = 0x13570000 + i; + cpu.pc = 0x800891C0; + cpu.gpr[reg] = length << 24 | address; + R::packet_length(&cpu,reg); + assert((cpu.gpr[reg] >> 22) + 4 == 4 * (length + 1)); + assert(cpu.pc == 0x800891C0); + for (unsigned i=0; i<32; ++i) if (i != reg) assert(cpu.gpr[i] == 0x13570000 + i); + } + CPUState cpu{}; cpu.gpr[10] = 0x090D0000; + R::packet_length_t2(&cpu,0); assert(cpu.gpr[10] == 0x090D0000); + assert(R::limit(&cpu,0) == 1 && limit == 0x0085FF00 && cpu.gpr[2] == limit); + cursor = R::arena + 1232; R::submit(nullptr,0); assert(R::peak == 1232); + for (int32_t range : {1,128,2048,4096,8192,8193,16379}) { + cpu.gpr[5] = 0; cpu.gpr[6] = range; R::fog(&cpu,0); + assert(cpu.gpr[5] + cpu.gpr[6] <= R::MaxFar); + assert(cpu.gpr[5] + cpu.gpr[6] >= uint32_t(range)); + } + cpu.gpr[5] = 6000; cpu.gpr[6] = 2048; R::fog(&cpu,0); + assert(cpu.gpr[5] == 12000 && cpu.gpr[6] == 4096); + R::arena = 0; cpu.gpr[10] = 0x09805978; + R::packet_length_t2(&cpu,0); assert(cpu.gpr[10] == 0x09805978); + std::puts("PASS: shared named archive bounds, packet ABI, code guards, primitive capacity and fog ceiling"); +} diff --git a/runtime/tests/test_package_appimage.py b/runtime/tests/test_package_appimage.py new file mode 100644 index 00000000..8df65f4c --- /dev/null +++ b/runtime/tests/test_package_appimage.py @@ -0,0 +1,85 @@ +"""Exercise AppDir routing with tiny verified stand-in tools, never a game/build. + +The production provenance pins are asserted; only a PRIVATE script copy uses +fixture-tool hashes/file URLs. This checks final-byte identity binding after a +deployment rewrite, mandatory cache verification, and compression failure gates. +It does not certify a real ELF executable, linuxdeploy, or an AppImage artifact. +""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import tempfile + + +def main(): + root = Path(__file__).resolve().parents[2] + source = (root / "tools/package_appimage.sh").read_text() + deploy_pin = "c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d" + image_pin = "ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0" + assert deploy_pin in source and image_pin in source + assert source.index('"$LINUXDEPLOY" --appimage') < source.index('"$PY" "$HELPER" stage-execution') < source.index('ARCH=x86_64 "$APPIMAGETOOL"') + assert source.index('"$PY" "$HELPER" fetch-pinned') < source.index('"$LINUXDEPLOY" --appimage') + shared = "a" * 64 + identity = hashlib.sha256(f"psx-execution-v1\nENHANCED\nshared:{shared}\n".encode()).hexdigest() + manifest = dict(schema=1, profile="ENHANCED", identity=identity, + shared_contract_sha256=shared, implementations=[], binary_sha256="f" * 64) + with tempfile.TemporaryDirectory(prefix="psx-appimage-fixture-") as temp: + work = Path(temp) + payload, cache, remote, tmp = [work / name for name in ("payload with.space", "cache", "remote", "tmp")] + for directory in (payload,cache,remote,tmp): directory.mkdir() + (payload / "assets").mkdir() + original = b"\x7fELF" + identity.encode() + b"original payload bytes" + (payload / "Game").write_bytes(original) + (payload / "Game.execution.json").write_text(json.dumps(manifest)) + (payload / "game.toml").write_text("[game]\nname='Fixture'\n") + app_run, desktop, icon = [work / name for name in ("AppRun", "io.fixture.Game.desktop", "icon.png")] + app_run.write_text("#!/bin/sh\nexit 0\n") + desktop.write_text("[Desktop Entry]\nType=Application\nName=Fixture\nExec=Game\nIcon=io.fixture.Game\nCategories=Game;\n") + icon.write_bytes(b"\x89PNG\r\n\x1a\nfixture") + tools = { + "linuxdeploy-x86_64.AppImage": '#!/bin/bash\nset -eu\nwhile [ "$#" -gt 0 ]; do if [ "$1" = --executable ]; then exe=$2; shift 2; else shift; fi; done\nprintf "deployment rewrite" >> "$exe"\n', + "appimagetool-x86_64.AppImage": '#!/bin/bash\nset -eu\nout="${@: -1}"\nprintf "fixture squashfs output" > "$out"\n', + } + for name,text in tools.items(): + (remote / name).write_text(text) + # Invalid cached bytes must be replaced through verified fetching. + (cache / name).write_text("invalid previous cache") + deploy_url = "https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1/linuxdeploy-x86_64.AppImage" + image_url = "https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage" + private = source.replace(deploy_pin,hashlib.sha256((remote / "linuxdeploy-x86_64.AppImage").read_bytes()).hexdigest()).replace(image_pin,hashlib.sha256((remote / "appimagetool-x86_64.AppImage").read_bytes()).hexdigest()) + private = private.replace(deploy_url,(remote / "linuxdeploy-x86_64.AppImage").as_uri()).replace(image_url,(remote / "appimagetool-x86_64.AppImage").as_uri()) + script = work / "package_appimage.sh" + script.write_text(private) + environment = dict(os.environ,TMPDIR=str(tmp),SOURCE_DATE_EPOCH="12345") + def run(output): + return subprocess.run(["bash",str(script),"--framework",str(root),"--payload",str(payload),"--exe-name","Game","--payload-name","fixture","--app-run",str(app_run),"--desktop-file",str(desktop),"--icon",str(icon),"--tool-cache",str(cache),"--output",str(output),"--jobs","2"],env=environment,text=True,capture_output=True) + output = work / "candidate.AppImage" + result = run(output) + assert result.returncode == 0, result.stdout + result.stderr + appdir = Path(next(line.split("AppDir: ",1)[1] for line in result.stdout.splitlines() if line.startswith("AppDir: "))) + final = (appdir / "usr/bin/Game").read_bytes() + bound = json.loads((appdir / "usr/bin/Game.execution.json").read_text()) + assert final == original + b"deployment rewrite" + assert bound["binary_sha256"] == hashlib.sha256(final).hexdigest() + assert bound["binary_sha256"] != manifest["binary_sha256"] + assert (appdir / "usr/bin/assets").is_symlink() + assert not (appdir / "usr/share/fixture/Game").exists() + assert output.is_file() and Path(str(output)+".sha256").is_file() + for name in tools: assert (cache / name).read_bytes() == (remote / name).read_bytes() + # A bad build contract must fail before the stand-in compressor runs. + manifest["identity"] = "0" * 64 + (payload / "Game.execution.json").write_text(json.dumps(manifest)) + failed = work / "failed.AppImage" + result = run(failed) + assert result.returncode != 0 and not failed.exists() + assert "execution staging failed" in result.stderr + (payload / "wrong.dll").write_bytes(b"Windows cache") + result = run(failed) + assert result.returncode != 0 and "Windows binaries/cache" in result.stderr + print("PASS AppImage routing: verified tools, post-deploy final bytes, no stale hash, fail closed") + + +if __name__ == "__main__": + main() diff --git a/runtime/tests/test_package_execution_stage.py b/runtime/tests/test_package_execution_stage.py new file mode 100644 index 00000000..6872db73 --- /dev/null +++ b/runtime/tests/test_package_execution_stage.py @@ -0,0 +1,77 @@ +"""Exercise the ZIP packager's real execution-staging gate without compression.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--bash", default="bash") + args = parser.parse_args() + root = Path(__file__).resolve().parents[2] + packager = (root / "tools/package_game_release.sh").read_text() + marker = "# --- Bind the final executable to its original build execution contract ----" + block = packager[packager.index(marker):packager.index('\nfind "${STAGE}" -exec touch')] + assert packager.index(marker) > packager.index('bash "${SIGN_SH}"') + assert packager.index(marker) < packager.index('"${SCRIPT_DIR}/create_release_zip.py"') + shared = "a" * 64 + identity = hashlib.sha256(f"psx-execution-v1\nENHANCED\nshared:{shared}\n".encode()).hexdigest() + manifest = dict(schema=1, profile="ENHANCED", identity=identity, + shared_contract_sha256=shared, implementations=[]) + checks = 0 + with tempfile.TemporaryDirectory(prefix="psx-package-execution-") as temp: + work = Path(temp) + # Periods and spaces in parent directories must not alter sidecar names. + build, stage = work / "build.with space", work / "stage.with space" + build.mkdir(); stage.mkdir() + for name in ("Game.exe", "Game"): + original, final = build / name, stage / name + source = build / "Game.execution.json" + output = stage / "Game.execution.json" + source.write_text(json.dumps(manifest)) + original.write_bytes(identity.encode() + b"unsigned") + final.write_bytes(identity.encode() + b"signed-final-bytes") + # Staged stale metadata must never substitute for the build sidecar. + output.write_text('{"identity":"stale staged metadata"}') + script = 'set -euo pipefail\n' + '\n'.join( + f"{key}='{value.as_posix() if isinstance(value, Path) else value}'" + for key, value in dict(PY=Path(sys.executable), SCRIPT_DIR=root / "tools", + STAGE=stage, EXE=original, EXE_BASENAME=name).items()) + script += '\n' + block + '\nprintf "after-stage\\n"\n' + def run(): + return subprocess.run([args.bash, "-c", script], capture_output=True, text=True) + result = run() + assert result.returncode == 0 and "after-stage" in result.stdout, result.stderr + bound = json.loads(output.read_text()) + assert bound["identity"] == identity + assert bound["binary_sha256"] == hashlib.sha256(final.read_bytes()).hexdigest() + checks += 1 + source.unlink() + result = run() + assert result.returncode != 0 and "after-stage" not in result.stdout + checks += 1 + source.write_text(json.dumps(manifest)) + final.write_bytes(b"another executable lacking the contract identity") + result = run() + assert result.returncode != 0 and "after-stage" not in result.stdout + checks += 1 + # Execute the actual stage-only removal/exit gates. An existing ZIP + # must survive, and no compressor executable is needed or invoked. + archive = work / "existing.zip" + archive.write_bytes(b"preserved") + removal = next(line for line in packager.splitlines() if '"${STAGE_ONLY}" != 1' in line) + suffix = packager[packager.index('if [[ "${STAGE_ONLY}" == 1 ]]; then'):] + script = f"set -euo pipefail\nSTAGE_ONLY=1\nDIST='{work.as_posix()}'\nZIP_NAME=existing.zip\nSTAGE='{stage.as_posix()}'\n" + removal + '\n' + suffix + result = subprocess.run([args.bash,"-c",script],capture_output=True,text=True) + assert result.returncode == 0 and "compression skipped" in result.stdout, result.stderr + assert archive.read_bytes() == b"preserved" + checks += 1 + print(f"PASS {checks} Windows/Linux-name packaging gates: final bytes, build sidecar, fail closed") + + +if __name__ == "__main__": + main() diff --git a/runtime/tests/test_pgxp.cpp b/runtime/tests/test_pgxp.cpp index 4cbdacac..6128e68f 100644 --- a/runtime/tests/test_pgxp.cpp +++ b/runtime/tests/test_pgxp.cpp @@ -276,12 +276,16 @@ int main(void) { * The flag ops rewrite bits 14/15 and 30/31 of the word, never the 11-bit * fields GP0 decodes, so the vertex (and its depth) must survive - in * BOTH tiers, since bitwise carries are exact. --- */ - for (int mode = 0; mode < 2; mode++) { + for (int exact = 0; exact < 2; exact++) { + pgxp_set_preserve_projection(exact); + const int32_t expected_x = X16 - (exact ? 65536 : 0); + const int32_t expected_y = Y16 + (exact ? 65536 : 0); + for (int mode = 0; mode < 2; mode++) { pgxp_set_cpu_mode(mode); const uint32_t T2 = 10, T4 = 12, T5 = 13, T9 = 25, A3 = 7; const uint32_t bounds = (240u << 16) | 128u; /* x >= 128 -> flag */ const uint32_t m14 = 0xBFFFBFFFu, m15 = 0x7FFF7FFFu; - pgxp_gte_push_sxy(X16, Y16, SZ3, PACKED); + pgxp_gte_push_sxy(expected_x, expected_y, SZ3, PACKED); psx_pgxp_cop2(nullptr, MFC2(T2, 14), PACKED, 0); psx_pgxp_load(nullptr, LW(1, T4), 0x80180000u, bounds); /* untracked */ psx_pgxp_alu(nullptr, LUI(T5, 0xBFFF), 0xBFFF0000u, 0, 0); @@ -303,11 +307,13 @@ int main(void) { psx_pgxp_store(nullptr, SW(1, T2), ADDR_B, w); int32_t x, y; uint16_t z; CHECK(lookup(ADDR_B, w, 160, 80, &x, &y, &z) == PGXP_SRC_DATAFLOW); - CHECK(x == X16 && y == Y16 && z == SZ3); + CHECK(x == expected_x && y == expected_y && z == SZ3); uint16_t wz = 0; CHECK(pgxp_load_precise_word(ADDR_B, w, nullptr, nullptr, &wz) == 1); CHECK(wz == SZ3); } + } + pgxp_set_preserve_projection(0); pgxp_set_cpu_mode(0); /* --- quad from a projected-vertex table (Spider-Man 0x8007C5D4): diff --git a/runtime/tests/test_pgxp_word_shadow.cpp b/runtime/tests/test_pgxp_word_shadow.cpp new file mode 100644 index 00000000..74d640fc --- /dev/null +++ b/runtime/tests/test_pgxp_word_shadow.cpp @@ -0,0 +1,220 @@ +#include "pgxp.h" +#include "pgxp_hooks.h" +#include "mod_memory.h" +#include +#include +#include + +extern "C" int gte_geometry_correction_lookup(uint32_t,int32_t*,int32_t*) {return 0;} +extern "C" int gte_geometry_correction_lookup_probe(uint32_t,int32_t*,int32_t*) {return 0;} +static uint32_t gpu_bytes = 0; +extern "C" int psx_mod_gpu_dma_memory_contains(uint32_t address, uint32_t bytes) { + return psx_mod_gpu_dma_aperture_offset_for(address, bytes, gpu_bytes, nullptr); +} +static int failures=0; +#define CHECK(c) do {if(!(c)){std::fprintf(stderr,"FAIL line %d: %s\n",__LINE__,#c);++failures;}}while(0) +constexpr uint32_t Address=0x80001000u,Other=0x80002000u,Packed=0x000C0008u; +static PGXPWordShadow capture(uint32_t address,uint32_t packed) { + PGXPWordShadow result{}; + CHECK(pgxp_capture_word_shadow(address,packed,&result)); + return result; +} +static void seed(uint32_t address,uint32_t packed,int32_t x) { + pgxp_gte_push_sxy(x,(12<<16)+32768,300,packed); + pgxp_store_gte_reg(address,15); +} +static void check_attributes(const PGXPWordShadow& a,const PGXPWordShadow& b) { + CHECK(a.valid==b.valid && a.value==b.value && a.address==b.address); + CHECK(a.x16==b.x16 && a.y16==b.y16 && a.z==b.z && a.flags==b.flags); + CHECK(!std::memcmp(&a.projection,&b.projection,sizeof a.projection)); +} +int main() { + pgxp_set_enabled(1);pgxp_set_cpu_mode(1);pgxp_set_projection_tracking(1); + seed(Address,Packed,(8<<16)+16384); + PGXPProjection projection{12.5f,18.25f,2.0f,0.125f}; + pgxp_gte_set_projection(&projection);pgxp_store_gte_reg(Address,15); + const auto original=capture(Address,Packed); + CHECK(original.valid && (original.flags&8u)); + CHECK(!pgxp_restore_word_shadow(Address,Packed,&original)); // sandbox only + CHECK(!pgxp_restore_relocated_word_shadow(Other,Packed,&original)); + PGXPWordShadow ignored{}; + CHECK(!pgxp_capture_word_shadow(0x1F801814u,0,&ignored)); // no MMIO + CHECK(!pgxp_capture_word_shadow(Address+1,Packed,&ignored)); + CHECK(!pgxp_capture_word_shadow(0xC0001000u,Packed,&ignored)); + + pgxp_checkpoint_begin(); + pgxp_invalidate_all(); // raw entry RAM restoration invalidates live shadows + seed(Address,Packed,(8<<16)+49152); + const auto changed=capture(Address,Packed); + CHECK(!pgxp_restore_word_shadow(Address,Packed+1,&original)); + check_attributes(changed,capture(Address,Packed)); // refused restore is atomic + CHECK(!pgxp_restore_word_shadow(Other,Packed,&original)); // bound address + CHECK(pgxp_restore_word_shadow(0xA0201000u,Packed,&original)); // retail mirror + check_attributes(original,capture(Address,Packed)); + CHECK(capture(Address,Packed).source_generation!=original.source_generation); + pgxp_checkpoint_rollback(); + check_attributes(original,capture(Address,Packed)); + CHECK(capture(Address,Packed).source_generation==original.source_generation); + + // Relocating a packet changes only the explicitly owned destination. + // Preserve projection and the previous destination across sandbox rollback. + seed(Other,Packed,(9<<16)+32768); + const auto old_destination=capture(Other,Packed); + pgxp_checkpoint_begin();pgxp_invalidate_all(); + CHECK(!pgxp_restore_word_shadow(Other,Packed,&original)); + CHECK(pgxp_restore_relocated_word_shadow(0xA0202000u,Packed,&original)); + auto relocated=original;relocated.address=old_destination.address; + check_attributes(relocated,capture(Other,Packed)); + CHECK(capture(Other,Packed).source_generation!=original.source_generation); + CHECK(!capture(Address,Packed).valid); // no source writes/rebinding + const auto before_rejection=capture(Other,Packed); + CHECK(!pgxp_restore_relocated_word_shadow(Other,Packed+1,&original)); + CHECK(!pgxp_restore_relocated_word_shadow(Other+1,Packed,&original)); + CHECK(!pgxp_restore_relocated_word_shadow(0x1F801814u,Packed,&original)); + CHECK(!pgxp_restore_relocated_word_shadow(0xC0002000u,Packed,&original)); + CHECK(!pgxp_restore_relocated_word_shadow(Other,Packed,nullptr)); + for (const uint32_t invalid_source : {0x1F801814u, 0x80001000u, 0x1001u}) { + auto invalid=original;invalid.address=invalid_source; + CHECK(!pgxp_restore_relocated_word_shadow(Other,Packed,&invalid)); + } + auto invalid=original;invalid.valid=2; + CHECK(!pgxp_restore_relocated_word_shadow(Other,Packed,&invalid)); + check_attributes(before_rejection,capture(Other,Packed)); + pgxp_checkpoint_rollback(); + check_attributes(original,capture(Address,Packed)); + check_attributes(old_destination,capture(Other,Packed)); + pgxp_invalidate_all(); + + // CPU-derived coordinate flags must survive; XYZ reconstruction loses them. + // 100.9 * 2 propagates as 201.8 while the native MULT returns 200. + // Storing that scalar marks the coordinate as derived, rather than merely + // offsetting a packed word (which intentionally retains its original flags). + pgxp_gte_push_sxy((100<<16)+0xE666,(12<<16)+32768,300,0x000C0064u); + pgxp_store_gte_reg(Address,15); + psx_pgxp_load(nullptr,0x84280000u,Address,100); // lh t0,0(at) + psx_pgxp_alu(nullptr,0x24190002u,2,0,2); // addiu t9,zero,2 + psx_pgxp_muldiv(nullptr,0x01190018u,0,200,100,2); // mult t0,t9 + psx_pgxp_alu(nullptr,0x00006012u,200,200,0); // mflo t4 + psx_pgxp_store(nullptr,0xAC2C0000u,Address,200); + constexpr uint32_t DerivedPacked=200; + const auto derived=capture(Address,DerivedPacked); + CHECK(derived.valid && (derived.flags&0x30u)); + pgxp_checkpoint_begin();pgxp_invalidate_all(); + CHECK(pgxp_restore_word_shadow(Address,DerivedPacked,&derived)); + check_attributes(derived,capture(Address,DerivedPacked)); + CHECK(pgxp_restore_relocated_word_shadow(Other,DerivedPacked,&derived)); + auto relocated_derived=derived;relocated_derived.address=Other&0x1FFFFFFFu; + check_attributes(relocated_derived,capture(Other,DerivedPacked)); + pgxp_checkpoint_rollback(); + + const auto absent=capture(Other,Packed); + CHECK(!absent.valid); + pgxp_checkpoint_begin();seed(Other,Packed,8<<16); + CHECK(capture(Other,Packed).valid); + CHECK(pgxp_restore_word_shadow(Other,Packed,&absent)); + CHECK(!capture(Other,Packed).valid); + pgxp_checkpoint_rollback();CHECK(!capture(Other,Packed).valid); + + // Absence is transferable too: erase a destination shadow inside replay, + // then recover the original destination on rollback. + pgxp_checkpoint_begin(); + CHECK(pgxp_restore_relocated_word_shadow(Address,Packed,&absent)); + CHECK(!capture(Address,DerivedPacked).valid); + CHECK(!capture(Address,Packed).valid); + pgxp_checkpoint_rollback();check_attributes(derived,capture(Address,DerivedPacked)); + + // Both live and absent receipts belong to one generation/timeline. + pgxp_invalidate_all();seed(Address,DerivedPacked,12<<16); + const auto latest=capture(Address,DerivedPacked); + pgxp_checkpoint_begin(); + CHECK(!pgxp_restore_word_shadow(Address,DerivedPacked,&derived)); + CHECK(!pgxp_restore_word_shadow(Other,Packed,&absent)); + CHECK(!pgxp_restore_relocated_word_shadow(Other,DerivedPacked,&derived)); + CHECK(!pgxp_restore_relocated_word_shadow(Address,Packed,&absent)); + check_attributes(latest,capture(Address,DerivedPacked)); + pgxp_checkpoint_rollback(); + + // A stale/value-mismatched slot is captured as absence, never promoted. + CHECK(!capture(Address,Packed).valid); + const uint32_t Scratch=0x1F800080u; + seed(Scratch,Packed,8<<16); + const auto scratch=capture(Scratch,Packed); + pgxp_checkpoint_begin();pgxp_invalidate_all(); + CHECK(pgxp_restore_word_shadow(Scratch,Packed,&scratch)); + check_attributes(scratch,capture(Scratch,Packed)); + CHECK(pgxp_restore_relocated_word_shadow(Address,Packed,&scratch)); + auto relocated_scratch=scratch;relocated_scratch.address=Address&0x1FFFFFFFu; + check_attributes(relocated_scratch,capture(Address,Packed)); + pgxp_checkpoint_rollback(); + // Expanded primitive storage must retain the exact same XYZ/depth and + // projection as RAM, without aliasing the retail RAM word under its tag. + const uint32_t Arena = PSX_MOD_GPU_DMA_GUEST_BASE + 0x1000u; + const uint32_t ArenaOther = Arena + 0x1000u; + gpu_bytes = 0x4000u; + pgxp_invalidate_all(); + seed(Address, Packed, (8<<16)+16384); + seed(Arena, Packed, (8<<16)+32768); + const auto ram = capture(Address, Packed); + const auto arena = capture(Arena, Packed); + CHECK(arena.valid && arena.address == 0x00801000u); + CHECK(arena.x16 != ram.x16); + check_attributes(arena, capture(Arena & 0x1FFFFFFFu, Packed)); + check_attributes(arena, capture(Arena | 0x20000000u, Packed)); + uint16_t depth = 0; + CHECK(pgxp_load_precise_word(Arena, Packed, nullptr, nullptr, &depth)); + CHECK(depth == 300); + CHECK(!pgxp_load_precise_word(Arena, Packed + 1, nullptr, nullptr, &depth)); + PGXPWordShadow refused{}; + CHECK(!pgxp_capture_word_shadow(PSX_MOD_GPU_DMA_GUEST_BASE + gpu_bytes, Packed, &refused)); + CHECK(!pgxp_capture_word_shadow(0xBF801000u, Packed, &refused)); + CHECK(!pgxp_capture_word_shadow(0xC0801000u, Packed, &refused)); + const auto empty_arena = capture(ArenaOther, Packed); + CHECK(!empty_arena.valid); + pgxp_checkpoint_begin(); + seed(Arena, Packed, (8<<16)+49152); + // This page is allocated for the first time inside the checkpoint. + CHECK(pgxp_restore_relocated_word_shadow(ArenaOther, Packed, &arena)); + auto relocated_arena = arena; relocated_arena.address = 0x00802000u; + check_attributes(relocated_arena, capture(ArenaOther, Packed)); + check_attributes(ram, capture(Address, Packed)); + pgxp_checkpoint_rollback(); + check_attributes(arena, capture(Arena, Packed)); + check_attributes(empty_arena, capture(ArenaOther, Packed)); + check_attributes(ram, capture(Address, Packed)); + pgxp_checkpoint_begin(); pgxp_invalidate_all(); + CHECK(pgxp_restore_word_shadow(Arena, Packed, &arena)); + check_attributes(arena, capture(Arena, Packed)); + pgxp_checkpoint_rollback(); + check_attributes(arena, capture(Arena, Packed)); + pgxp_invalidate_all(); + CHECK(!capture(Arena, Packed).valid); + // Neversoft subdivision unpacks/re-packs SXY and adds screen outcodes. + // Reattach validated position/depth; exact projection may cross the + // original integer boundary while the guest packet remains unchanged. + pgxp_set_preserve_projection(1); + pgxp_set_tolerance(-1.0f); + const uint32_t Flagged = Packed | 0x80004000u; + pgxp_gte_push_sxy((8<<16)-16384, (12<<16)+73728, 300, Packed); + CHECK(pgxp_store_flagged_gte_sxy(Arena, 14, Packed, Flagged)); + int32_t precise_x=0, precise_y=0; + CHECK(pgxp_get_precise_vertex(Arena, Flagged, 8, 12, &precise_x, &precise_y, &depth) == PGXP_SRC_DATAFLOW); + CHECK(precise_x == (8<<16)-16384 && precise_y == (12<<16)+73728 && depth == 300); + CHECK(pgxp_load_precise_word(Arena, Flagged, nullptr, nullptr, &depth) && depth == 300); + const auto flagged = capture(Arena, Flagged); + CHECK(!pgxp_store_flagged_gte_sxy(Arena, 14, Packed + 1, Flagged)); + CHECK(!pgxp_store_flagged_gte_sxy(Arena, 14, Packed, Flagged ^ 1)); + CHECK(!pgxp_store_flagged_gte_sxy(Arena + 1, 14, Packed, Flagged)); + CHECK(!pgxp_store_flagged_gte_sxy(0x1F801814u, 14, Packed, Flagged)); + CHECK(!pgxp_store_flagged_gte_sxy(Arena, 11, Packed, Flagged)); + check_attributes(flagged, capture(Arena, Flagged)); + pgxp_checkpoint_begin(); + CHECK(pgxp_store_flagged_gte_sxy(Arena, 14, Packed, Packed)); + pgxp_checkpoint_rollback(); + check_attributes(flagged, capture(Arena, Flagged)); + pgxp_invalidate_all(); + CHECK(!pgxp_store_flagged_gte_sxy(Arena, 14, Packed, Flagged)); + gpu_bytes = 0; // pages retained on the host are not guest allocations + CHECK(!pgxp_capture_word_shadow(Arena, Packed, &refused)); + std::printf("PGXP packet shadow checks: %s\n",failures?"FAIL":"PASS"); + return failures?1:0; +} diff --git a/runtime/tests/test_psx_sha256_file.cpp b/runtime/tests/test_psx_sha256_file.cpp new file mode 100644 index 00000000..13413d6c --- /dev/null +++ b/runtime/tests/test_psx_sha256_file.cpp @@ -0,0 +1,125 @@ +// Include the maintained implementation to inject backend failures without a +// product hook or a second copy of its stream/fallback logic. +#include "../src/psx_sha256_file.cpp" + +#include +#include +#include +#include +#include + +static unsigned checks; +static void check(bool value, const char* message) { + ++checks; + if (!value) { + std::cerr << "FAIL: " << message << '\n'; + std::exit(1); + } +} + +static std::string hex(const uint8_t* digest) { + constexpr char alphabet[] = "0123456789abcdef"; + std::string result; + for (size_t i = 0; i < 32; ++i) { + result += alphabet[digest[i] >> 4]; + result += alphabet[digest[i] & 15]; + } + return result; +} + +struct FailedBackend { + int stage; + unsigned calls = 0; + bool failed = false; + bool init() { return !(failed = stage == 0); } + bool update(const uint8_t*, size_t) { + ++calls; + return !(failed = stage == 1 && calls == 2); + } + bool finish(uint8_t out[32]) { + std::fill(out, out + 32, 0x19); // failure must never publish this + failed = true; + return false; + } +}; + +int main(int argc, char** argv) { + // Optional private file probe: one real host backend or portable build, + // suitable for separate isolated throughput measurements. + if (argc == 2) { + uint8_t digest[32]; + const auto begin = std::chrono::steady_clock::now(); + const auto result = psx_sha256_file(std::filesystem::u8path(argv[1]), digest); + const double seconds = std::chrono::duration( + std::chrono::steady_clock::now() - begin).count(); + if (result != PsxFileHashResult::Ok) return 2; + std::cout << hex(digest) << " " << seconds << '\n'; + return 0; + } + +#if !defined(PSX_SHA256_FILE_PORTABLE) && (defined(_WIN32) || defined(PSX_SHA256_FILE_OPENSSL)) + NativeHash available; + check(available.init(), "native backend is available on qualification host"); +#endif + + const auto unique = std::chrono::high_resolution_clock::now().time_since_epoch().count(); + const auto root = std::filesystem::temp_directory_path() / + ("psx-sha256-" + std::to_string(unique)); + check(std::filesystem::create_directory(root), "private fixture directory"); + const auto path = root / std::filesystem::u8path(u8"disc-\u00e9-\u65e5.bin"); + std::vector bytes; + constexpr size_t lengths[] = { + 0, 1, 3, 55, 56, 63, 64, 65, 119, 120, 127, 128, 129, + 1048575, 1048576, 1048577, 2097169}; + uint32_t rng = 0x53484132; + for (size_t size : lengths) { + bytes.resize(size); + for (auto& byte : bytes) { + rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; + byte = static_cast(rng); + } + { + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if (!bytes.empty()) file.write(reinterpret_cast(bytes.data()), bytes.size()); + check(static_cast(file), "write fixture"); + } + std::array expected{}, observed{}; + psx_sha256_compute(bytes.data(), bytes.size(), expected.data()); + check(psx_sha256_file(path, observed.data()) == PsxFileHashResult::Ok, + "hash file"); + check(observed == expected, "native/portable exact digest"); + if (!size) check(hex(observed.data()) == + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "standard empty vector"); + } + // The largest vector consumes more than one native chunk before fallback. + std::array expected{}, observed{}; + psx_sha256_compute(bytes.data(), bytes.size(), expected.data()); + for (int stage = 0; stage < 3; ++stage) { + FailedBackend backend{stage}; + check(hash_file(path, observed.data(), backend) == PsxFileHashResult::Ok, + "native failure falls back"); + check(backend.failed && observed == expected, "fallback restarts at byte zero"); + if (stage == 1) check(backend.calls == 2, "injected partial update failure"); + } + { + std::ofstream file(path, std::ios::binary | std::ios::trunc); + file << "abc"; + } + check(psx_sha256_file(path, observed.data()) == PsxFileHashResult::Ok && + hex(observed.data()) == + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "standard abc vector"); + observed.fill(0xa5); + expected = observed; + check(psx_sha256_file(root / "absent", observed.data()) == PsxFileHashResult::OpenError, + "missing file rejected"); + check(observed == expected, "missing file leaves output untouched"); + check(psx_sha256_file(root, observed.data()) != PsxFileHashResult::Ok, + "directory rejected"); + check(observed == expected, "unreadable file leaves output untouched"); + // Delete only our two exact fixture paths; no recursive cleanup. + check(std::filesystem::remove(path), "remove fixture file"); + check(std::filesystem::remove(root), "remove empty fixture directory"); + std::cout << checks << " SHA-256 file checks passed\n"; +} diff --git a/runtime/tests/test_release_stage_cache.py b/runtime/tests/test_release_stage_cache.py index 1e7327d3..c4d4358d 100644 --- a/runtime/tests/test_release_stage_cache.py +++ b/runtime/tests/test_release_stage_cache.py @@ -30,6 +30,8 @@ on the day the tag grew a field. """ import os +import hashlib +import json import shutil import sys import tempfile @@ -461,5 +463,40 @@ class TinyccNoticeTest(unittest.TestCase): self.assertFalse(os.path.exists(os.path.join(self.tmp, 'COPYING'))) +class ExecutionStagingTest(unittest.TestCase): + def test_artifact_binding_and_wrong_sidecar_rejection(self): + with tempfile.TemporaryDirectory(prefix='psx_execution_stage_') as folder: + binary = os.path.join(folder, 'Game.exe') + manifest = os.path.join(folder, 'Game.execution.json') + output = os.path.join(folder, 'staged.execution.json') + shared = hashlib.sha256(b'').hexdigest() + identity = hashlib.sha256(('psx-execution-v1\nENHANCED\nshared:%s\n' % shared).encode()).hexdigest() + data = dict(schema=1, profile='ENHANCED', identity=identity, + shared_contract_sha256=shared, implementations=[]) + # The runtime embeds its identity. Straddle the reader's chunk + # boundary to catch searches that accidentally check each alone. + payload = b'x' * (1024 * 1024 - 7) + identity.encode() + b'code' + with open(binary, 'wb') as stream: + stream.write(payload) + with open(manifest, 'w') as stream: + json.dump(data, stream) + staged = rs.stage_execution(binary, manifest, output) + self.assertEqual(staged['binary_sha256'], hashlib.sha256(payload).hexdigest()) + with self.assertRaises(rs.StageError): + rs.stage_execution(binary, manifest, binary) + with open(binary, 'rb') as stream: + self.assertEqual(stream.read(), payload) + # A self-consistent sidecar for another profile still cannot bind + # an executable that lacks that identity. + data['profile'] = 'REFERENCE' + data['identity'] = hashlib.sha256(('psx-execution-v1\nREFERENCE\nshared:%s\n' % shared).encode()).hexdigest() + with open(manifest, 'w') as stream: + json.dump(data, stream) + with self.assertRaises(rs.StageError): + rs.stage_execution(binary, manifest, output) + with open(output) as stream: + self.assertEqual(json.load(stream), staged) + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index 1fd3f1ef..9a2abd1c 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -327,6 +327,21 @@ static int span_pass_fn(struct CPUState *cpu, void *user, uint32_t alpha_q16) { return psx_mod_run_guest_span(cpu, 0x800143FCu, 0x8001473Cu); } +static int s_device_accesses; +static int device_read_pass_fn(struct CPUState *cpu, void *user, uint32_t alpha_q16) { + const uint32_t *access = (const uint32_t *)user; + (void)alpha_q16; + cpu->gpr[8] = 0xBADu; + s_ram[0x1000] ^= 0xff; + psx_advance_cycles(1000); + CHECK(render_pass_mmio_read_allowed(0x1F801070u, 4), "checkpointed IRQ read allowed"); + CHECK(render_pass_mmio_read_allowed(0x1F801110u, 2), "checkpointed timer read allowed"); + CHECK(render_pass_mmio_read_allowed(0x1F801814u, 4), "checkpointed GPU read allowed"); + if (render_pass_mmio_read_allowed(access[0], access[1])) s_device_accesses++; + CHECK(0, "unsupported read must unwind before returning a fabricated value"); + return 1; +} + static int pass_fn(struct CPUState *cpu, void *user, uint32_t alpha_q16) { (void)alpha_q16; guest_frame(cpu, (const Frames *)user, 0); @@ -575,6 +590,35 @@ int main(void) { CHECK(s_prec_begins > 0 && s_prec_open == 0, "precision shadows were checkpointed and rolled back on every pass, aborts included"); + /* Reads can consume a FIFO or ACK while time is frozen. Refuse before + * the device access and restore the same live state as a watchdog abort. */ + { + const uint32_t reads[][2] = {{0x1F801040u,1}, {0x1F801044u,2}, + {0x1F801800u,1}, {0x1F801820u,4}, {0x1F801DA8u,2}}; + render_pass_reset_session(); + for (unsigned i=0;i #include #include +#include int g_ls_replay_active = 0; int g_ls_mode = 0; @@ -113,6 +114,38 @@ int main(void) { s_cd_events >= cd0 + 3u, "devices resume on the live clock after the pass"); + uint32_t scale_before[3],scale_after[3]; + psx_guest_cycle_scale_set(2);psx_guest_cycle_scale_set_gated(1); + psx_guest_cycle_scale_gate_open(1); + CHECK(psx_cpu_cycles(1)==0, "scaled charge starts with a carried half cycle"); + psx_guest_cycle_scale_snapshot(scale_before); + CHECK(psx_cycle_freeze_begin(&save,0,NULL), "scaled draw freeze begins"); + psx_cpu_charge(3); + psx_cycle_freeze_end(&save); + psx_guest_cycle_scale_snapshot(scale_after); + CHECK(!memcmp(scale_before,scale_after,sizeof scale_before), "draw freeze restores carried cycle-scale phase"); + CHECK(psx_cpu_cycles(1)==1, "next authoritative instruction retains its original scaled charge"); + psx_guest_cycle_scale_set(1);psx_guest_cycle_scale_set(2);psx_guest_cycle_scale_gate_open(1); + (void)psx_cpu_cycles(1);psx_guest_cycle_scale_snapshot(scale_before); + CHECK(psx_cycle_freeze_begin(&save,0,NULL), "scale gate draw freeze begins"); + psx_guest_cycle_scale_gate_open(0);psx_cpu_charge(3); + psx_cycle_freeze_end(&save);psx_guest_cycle_scale_snapshot(scale_after); + CHECK(!memcmp(scale_before,scale_after,sizeof scale_before) && psx_guest_cycle_scale()==2, + "draw freeze restores cycle-scale gates as well as phase"); + psx_guest_cycle_scale_gate_open(1);psx_guest_cycle_scale_set(1);psx_guest_cycle_scale_set(2); + (void)psx_cpu_cycles(1);psx_guest_cycle_scale_snapshot(scale_before); + if(setjmp(s_jmp)==0) { + CHECK(psx_cycle_freeze_begin(&save,100,overrun), "scaled abort freeze begins"); + psx_guest_cycle_scale_gate_open(0); + for(unsigned iteration=0;iteration<4;++iteration)psx_cpu_charge(101); + psx_devices_mmio_sync(); + CHECK(0, "scaled freeze watchdog must abort"); + } + psx_cycle_freeze_end(&save);psx_guest_cycle_scale_snapshot(scale_after); + CHECK(!memcmp(scale_before,scale_after,sizeof scale_before) && psx_guest_cycle_scale()==2, + "watchdog abort restores cycle-scale gates and phase"); + psx_guest_cycle_scale_set(1);psx_guest_cycle_scale_set_gated(0); + /* Watchdog: a pass that runs away is cut off. The longjmp skips the * bb_defer exits of the generated frames it leaves (their cleanup * handlers never run); end() puts the interrupted depth back. */ diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index d8285fa5..f4941417 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -53,6 +53,14 @@ assert "g_psx_render_pass_active" in b, ( "delayed DMA completion would never arrive in frozen time") mem = (SRC / "memory.c").read_text(encoding="utf-8") +for bits,width in ((8,1),(16,2),(32,4)): + b = body(mem, f"static uint{bits}_t mmio_read{bits}(uint32_t addr) {{") + guard = f"g_psx_render_pass_active && !render_pass_mmio_read_allowed(addr, {width})" + assert guard in b and b.index(guard) < b.index("psx_devices_mmio_sync();"), ( + "device reads must be refused before effects at every access width") +assert mem.count("if (!g_psx_render_pass_active) sio_tick(0);") == 2 and \ + "if (addr < 0x1F801074u && !g_psx_render_pass_active) sio_tick(0);" in mem, ( + "I_STAT reads must not advance the uncheckpointed SIO countdown in a pass") for fn in ("void psx_write_word(uint32_t addr, uint32_t val) {", "void psx_write_half(uint32_t addr, uint16_t val) {", "void psx_write_byte(uint32_t addr, uint8_t val) {"): @@ -147,8 +155,8 @@ assert re.search(r"if \(want && s_pass_cost_w == s_interp_w && " # A frame on screen longer than planned (a lagging tick) must hold its newest # pass image, never fall back to the older capture (render_pass_plan_test). pgp = definition(gl, "pass_gen_present") -assert "render_pass_gen_select(g->phase, g->n, p, &lo, &hi, &t)" in pgp, ( - "pass images must be selected through the tested late-flip rule") +assert "render_pass_gen_select_mode(g->phase, g->n, p, s_interp_hold, &lo, &hi, &t)" in pgp, ( + "pass images must honor HOLD through the tested late-flip selection rule") assert "render_pass_select(" not in pgp and "0.5 / (double)g->period" not in pgp, ( "no early expiry of a late frame's images") cls = body(plan, "int render_pass_mmio_class(") diff --git a/runtime/tests/test_render_pass_ot.cpp b/runtime/tests/test_render_pass_ot.cpp new file mode 100644 index 00000000..5ccd0b6d --- /dev/null +++ b/runtime/tests/test_render_pass_ot.cpp @@ -0,0 +1,64 @@ +#include "render_pass_ot.hpp" +#include +#include +static uint32_t ram[2*1024*1024/4]; +static uint32_t aperture[256/4]; +extern "C" { +int g_psx_render_pass_active=1; +uint32_t memory_get_ram_bytes(){return sizeof ram;} +int psx_mod_gpu_dma_memory_contains(uint32_t p,uint32_t bytes){return bytes && psx_mod_gpu_dma_aperture_offset_for(p,bytes,sizeof aperture,nullptr);} +uint32_t psx_mod_read_word(uint32_t p){p&=0x1FFFFFFF;return p>=0x800000 ? aperture[(p-0x800000)/4] : ram[p/4];} +void psx_mod_write_word(uint32_t p,uint32_t v){p&=0x1FFFFFFF;if(p>=0x800000)aperture[(p-0x800000)/4]=v;else ram[p/4]=v;} +// Parser lengths used by these fixtures. Runtime supplies its actual GPU decoder. +int gpu_gp0_command_word_count(uint8_t op) { + if(op==0x20)return 4;if(op==0xA0)return 3;if(op==0x48)return -1; + if(op==0 || (op>=0xE1 && op<=0xE6))return 1; + return 0; +} +} +static void check(bool ok,const char* name){if(!ok){std::fprintf(stderr,"FAIL: %s\n",name);std::exit(1);}} +static void put(uint32_t p,uint32_t v){psx_mod_write_word(p,v);} +static uint32_t get(uint32_t p){return psx_mod_read_word(p);} +int main() { + constexpr uint32_t slot=0x10000,world=0x20000,late=0x21000,next=0x22000; + put(slot,world);put(world,0x00FFFFFF); + PSXOTReplay ot;check(ot.capture(slot,1),"capture heads"); + put(slot,late);put(late,0x01000000|world);put(late+4,0xE1000123); + check(ot.preserve_late(),"capture late prefix"); + put(slot,next);put(late,0);put(late+4,0);ot.apply_late(); + check(get(slot)==late && get(late)==(0x01000000|next) && get(late+4)==0xE1000123,"late follows regenerated world"); + put(slot,late);put(late,late);check(!ot.preserve_late(),"cycle bounded"); + PSXOTReplay hud; + check(hud.capture_new_list(slot),"standalone late HUD head"); + put(slot,late);put(late,0x01FFFFFF);put(late+4,0xE1000123); + check(hud.preserve_late(),"capture complete HUD prefix"); + put(slot,0xFFFFFF);put(late,0);put(late+4,0);hud.apply_late(); + check(get(slot)==late && get(late)==0x01FFFFFF && get(late+4)==0xE1000123,"restore standalone HUD"); + constexpr uint32_t p=0x30000; + put(p,0x07FFFFFF);put(p+4,0x20010203);put(p+8,0xE3000042); + put(p+12,0xE4000012);put(p+16,0xE50000AB); + put(p+20,0xE3000000|(256u<<10));put(p+24,0xE400013F|(495u<<10)); + put(p+28,0xE5000000|(258u<<11)); + check(PSXOTReplay::retarget_y(p,256,0,240),"retarget packet stream"); + check(get(p+8)==0xE3000042 && get(p+12)==0xE4000012 && get(p+16)==0xE50000AB,"coordinate payload is never decoded as opcode"); + check(get(p+20)==0xE3000000 && get(p+24)==(0xE400013F|(239u<<10)) && get(p+28)==(0xE5000000|(2u<<11)),"environment preserves crop and shake"); + put(p,0x02031000);put(p+4,0xE3000000);put(p+8,0xE400013F|(239u<<10)); + put(0x31000,0x01FFFFFF);put(0x31004,0xF1000000); + check(!PSXOTReplay::retarget_y(p,0,256,240) && get(p+4)==0xE3000000,"invalid tail makes no partial edits"); + put(p,0x07FFFFFF);put(p+4,0xA0000000);put(p+8,0);put(p+12,0x00010002); + put(p+16,0xE3000123);put(p+20,0xE3000000);put(p+24,0xE400013F|(239u<<10));put(p+28,0xE5000000); + check(PSXOTReplay::retarget_y(p,0,256,240) && get(p+16)==0xE3000123,"image data is not a command"); + // Native widescreen terrain uses a real allocated GPU-DMA aperture. + // Apply the same parsed environment edits, with no unrelated memory access. + put(slot,0x800000);put(0x800000,0x02FFFFFF); + put(0x800004,0xE3000000);put(0x800008,0xE5000000); + check(PSXOTReplay::retarget_y(slot,0,256,240) && get(0x800004)==(0xE3000000|(256u<<10)),"allocated terrain aperture retargets"); + put(slot,0x800100); + check(!PSXOTReplay::retarget_y(slot,0,256,240),"unallocated aperture tail refused"); + check(!psx_mod_gpu_dma_memory_contains(0x1FC00000,4),"BIOS is never an aperture alias"); + put(slot,0x8000FC);put(0x8000FC,0x02FFFFFF); + check(!PSXOTReplay::retarget_y(slot,0,256,240),"packet spanning aperture end refused before edits"); + g_psx_render_pass_active=0; + check(!PSXOTReplay::retarget_y(p,256,0,240),"outside sandbox refused"); + std::puts("ALL PASS"); +} diff --git a/runtime/tests/test_render_pass_plan.c b/runtime/tests/test_render_pass_plan.c index 35ba0b35..12398f3c 100644 --- a/runtime/tests/test_render_pass_plan.c +++ b/runtime/tests/test_render_pass_plan.c @@ -414,6 +414,33 @@ static void test_stereo_pair_fresh(void) { } int main(void) { + { + const uint64_t order[]={0,1,2}; + CHECK(render_pass_generation_write(0,2,order,3)==2, + "keep pending frame while next frame is drawn before its flip"); + CHECK(render_pass_generation_oldest(6,order,3)==1, + "matching queued generations promote in drawing order"); + CHECK(render_pass_generation_write(1,6,order,3)==0, + "promotion frees the previous shown slot"); + CHECK(render_pass_generation_write(0,7,order,3)==1, + "queue overflow evicts oldest pending, preserving shown frame"); + CHECK(render_pass_generation_oldest(0,order,3)==-1, + "an unrelated flip cannot promote any queued generation"); + } + { + const uint32_t phases[] = {0, 16384, 32768, 65536}; + uint32_t lo, hi; float blend; + CHECK(render_pass_gen_select_mode(phases, 4, 0.375, 1, &lo, &hi, &blend) && + lo == 1 && hi == 1 && blend == 0.0f, "HOLD keeps a native sample between phases"); + CHECK(render_pass_gen_select_mode(phases, 4, 0.5, 1, &lo, &hi, &blend) && + lo == 2 && hi == 2 && blend == 0.0f, "HOLD advances at the next native phase"); + CHECK(render_pass_gen_select_mode(phases, 4, 0.375, 0, &lo, &hi, &blend) && + lo == 1 && hi == 2 && blend == 0.5f, "explicit blending remains supported"); + CHECK(render_pass_gen_select_mode(phases, 4, 2.0, 1, &lo, &hi, &blend) && + lo == 3 && hi == 3, "late HOLD uses newest native image"); + CHECK(!render_pass_gen_select_mode(phases, 4, 5.0, 1, &lo, &hi, &blend), + "HOLD cannot revive an expired generation"); + } test_stereo_pair_fresh(); test_store_policy(); test_counts_per_rate(); diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c index 4248c5f8..74fb1384 100644 --- a/runtime/tests/test_render_pass_sandbox.c +++ b/runtime/tests/test_render_pass_sandbox.c @@ -17,8 +17,8 @@ * 3. VRAM journal (render_pass_vram_policy / _journal_add / _rollback, the * CPU side of gpu_gl_renderer.c's out-of-rect journal): writes inside the * pass rect are allowed, writes outside are backed up first and rolled - * back exactly, covered writes are not journaled twice, a full journal or - * a renderer mode that cannot journal refuses. + * back exactly, covered writes are not journaled twice, the last slot spills + * to a whole-VRAM backup, and a mode that cannot journal refuses. * * The GPU, presenter and remaining devices are mocks. Build/run: * ctest -R render_pass_sandbox_test */ @@ -670,10 +670,16 @@ static void test_journal(void) { int k = j.n; CHECK(POLICY(400 + 8 * k, 100, 4, 4, 1) == RENDER_PASS_VRAM_JOURNAL, "fill journal"); render_pass_journal_add(&j, s_vram, VW, x, y, w, h); - paint(x, y, w, h, (uint16_t)(0x8000 + k)); + /* Policy can enlarge the BACKUP, never the actual GPU write. */ + paint(400 + 8 * k, 100, 4, 4, (uint16_t)(0x8000 + k)); } CHECK(j.n == RENDER_PASS_JOURNAL_MAX && n == 5, "journal full"); - CHECK(POLICY(900, 400, 4, 4, 1) == RENDER_PASS_VRAM_REFUSE, "full journal refuses"); + CHECK(j.x[j.n-1] == 0 && j.y[j.n-1] == 0 && + j.w[j.n-1] == VW && j.h[j.n-1] == VH, "last slot protects all VRAM"); + CHECK(POLICY(900, 400, 4, 4, 1) == RENDER_PASS_VRAM_ALLOW, "spill covers later writes"); + paint(900, 400, 4, 4, 0x9999); + CHECK(POLICY(650, 4, 16, 16, 1) == RENDER_PASS_VRAM_ALLOW, "spill permits overlapping rewrites"); + paint(650, 4, 16, 16, 0xAAAA); CHECK(render_pass_journal_add(&j, s_vram, VW, 900, 400, 4, 4) == -1, "add refuses past the limit"); diff --git a/runtime/tests/test_resident_sector.cpp b/runtime/tests/test_resident_sector.cpp new file mode 100644 index 00000000..ba0d33e0 --- /dev/null +++ b/runtime/tests/test_resident_sector.cpp @@ -0,0 +1,26 @@ +#undef NDEBUG +#include "mod_resident_sector.hpp" +#include +#include +int main() { + uint8_t data[2048]; + for (unsigned i=0; i<2048; ++i) data[i] = uint8_t(i * 17); + PSXResidentMode2Sector sector; + assert(!sector.take_words(3)); + assert(sector.assign(4500 + 75 * 23 + 14 - 150,data,true)); + auto* h = sector.take_words(3); + assert(h && h[0] == 1 && h[1] == 0x23 && h[2] == 0x14 && h[3] == 2); + assert(h[6] == 0x89 && h[10] == 0x89); + assert(!sector.take_words(UINT32_MAX) && sector.consumed() == 12); + assert(!sector.take_words(0) && sector.consumed() == 12); + assert(std::memcmp(sector.take_words(17),data,68) == 0); + assert(std::memcmp(sector.take_words(495),data+68,1980) == 0); + assert(sector.consumed() == 2060); + assert(sector.take_words(70)); // optional uninterpreted EDC/ECC tail + assert(!sector.take_words(1)); + assert(!sector.assign(UINT32_MAX,data) && !sector.take_words(1)); + assert(!sector.assign(449850,data)); + assert(!sector.assign(0,nullptr)); + assert(sector.assign(0,data)); h = sector.take_words(3); + assert(h[0] == 0 && h[1] == 2 && h[2] == 0 && h[6] == 8 && h[10] == 8); +} diff --git a/runtime/tests/test_savestate_title_policy.c b/runtime/tests/test_savestate_title_policy.c new file mode 100644 index 00000000..5e7bc8df --- /dev/null +++ b/runtime/tests/test_savestate_title_policy.c @@ -0,0 +1,103 @@ +/* White-box policy coverage against the real user/protocol request handlers. + * Device/serializer stubs must never be reached; no slot files are written. */ +#include +#include "../src/savestate.c" + +static int refusals; +static char refusal_reason[128]; +void psx_frontend_on_savestate_refused(int load, int slot, const char* reason) { + (void)load; (void)slot; + ++refusals; + snprintf(refusal_reason, sizeof(refusal_reason), "%s", reason); +} +int psx_netplay_active(void) { return 0; } +int psx_netplay_is_host(void) { return 1; } +int psx_hle_scheduler_enabled(void) { return 1; } +int psx_mod_function_entry_active(void) { return 0; } +int psx_is_dispatchable(uint32_t pc) { return pc != 0; } +uint32_t psx_last_irq_check_pc(void) { return 0; } +uint32_t psx_compiled_irq_resume_pc(void) { return 0; } +uint32_t psx_irq_resume_context_snapshot_pc(void) { return 0; } +uint32_t psx_netplay_rb_sticky_bb_pc(void) { return 0; } +int psx_irq_resume_context_snapshot_safe_at(uint32_t pc) { (void)pc; return 1; } +int psx_irq_resume_context_snapshot_site(void) { return 0; } +void interrupts_resync_after_restore(void) { assert(0); } +void psx_cycles_resync_after_restore(struct CPUState* cpu) { (void)cpu; assert(0); } +void cdrom_accelerate_after_savestate(void) { assert(0); } +void psx_scheduler_resume_at(uint32_t pc) { (void)pc; assert(0); } +void psx_frontend_on_savestate_loaded(void) { assert(0); } +void psx_frontend_on_savestate_notify(int load, int slot, int ok) { + (void)load; (void)slot; (void)ok; assert(0); +} +void gpu_get_display_info(GpuDisplayInfo* out) { (void)out; assert(0); } +uint32_t gpu_display_pixel_argb(const GpuDisplayInfo* out, uint32_t x, uint32_t y) { + (void)out; (void)x; (void)y; assert(0); return 0; +} +int boot_state_save(const CPUState* cpu, uint32_t bios, uint32_t entry, const char* path) { + (void)cpu; (void)bios; (void)entry; (void)path; assert(0); return 0; +} +int boot_state_load(const char* path, uint32_t bios, uint32_t entry, CPUState* cpu) { + (void)path; (void)bios; (void)entry; (void)cpu; assert(0); return 0; +} +int boot_state_load_buffer(const uint8_t* data, size_t size, uint32_t bios, + uint32_t entry, CPUState* cpu) { + (void)data; (void)size; (void)bios; (void)entry; (void)cpu; assert(0); return 0; +} +int boot_state_save_buffer_raw(const CPUState* cpu, uint32_t bios, uint32_t entry, + uint8_t** data, size_t* size) { + (void)cpu; (void)bios; (void)entry; (void)data; (void)size; assert(0); return 0; +} +int boot_state_check_buffer(const uint8_t* data, size_t size, uint32_t bios, + uint32_t entry, char* reason, size_t cap) { + (void)data; (void)size; (void)bios; (void)entry; (void)reason; (void)cap; + assert(0); return 0; +} + +int main(void) { + CPUState cpu; + const uint8_t blob = 1; + static const PSXModNetplayProfile profile = { + "test.simulation", "test-no-snapshots-v1", 0, 0, 0, 1u, NULL, NULL, 0 + }; + memset(&cpu, 0, sizeof(cpu)); + s_configured = 1; /* No configure/filesystem needed to test request staging. */ + assert(!savestate_title_blocked()); + assert(savestate_request_save(2) && savestate_pending()); + savestate_set_title_blocked(1); + assert(savestate_title_blocked() && !savestate_pending()); + assert(savestate_take_save_failed()); + assert(!savestate_request_save(2)); + assert(!savestate_request_load(2)); + assert(!savestate_request_save_protocol(2)); + assert(!savestate_request_load_protocol(2)); + assert(!savestate_request_load_blob_protocol(&blob, 1)); + assert(refusals == 5 && strstr(refusal_reason, "game mode")); + savestate_poll(&cpu, 0x80010000); /* No serializer or device call. */ + savestate_set_title_blocked(0); + assert(savestate_request_load_blob_protocol(&blob, 1)); + assert(s_load_blob && s_load_blob_len == 1 && savestate_pending()); + savestate_set_title_blocked(1); + assert(!s_load_blob && s_load_blob_len == 0 && !savestate_pending()); + assert(savestate_take_load_failed()); + savestate_set_title_blocked(0); /* The next session resets before activation. */ + assert(savestate_request_save_protocol(3)); + assert(savestate_request_load_protocol(3)); + savestate_set_title_blocked(1); /* Clear both staged operations. */ + assert(!savestate_pending()); + savestate_set_title_blocked(0); + assert(psx_mod_register_netplay_profile(&profile)); + assert(savestate_request_save_protocol(1)); /* Registered, inactive: stock. */ + savestate_set_title_blocked(1); + savestate_set_title_blocked(0); + psx_mod_netplay_set_active(1); + assert(!savestate_request_save_protocol(1)); + assert(!savestate_request_load_protocol(1)); + assert(!savestate_request_load_blob_protocol(&blob, 1)); + assert(strstr(refusal_reason, "netplay mode")); + psx_mod_netplay_set_active(0); + assert(savestate_request_save_protocol(1)); + savestate_set_title_blocked(1); + assert(!savestate_pending()); + puts("savestate title policy tests passed"); + return 0; +} diff --git a/runtime/tests/test_spu_sample_banks.c b/runtime/tests/test_spu_sample_banks.c new file mode 100644 index 00000000..5a15989a --- /dev/null +++ b/runtime/tests/test_spu_sample_banks.c @@ -0,0 +1,99 @@ +/* Private assets share the native voice DSP without touching hardware RAM. + * Exercise concurrent banks, delayed KEYON, driver slot reuse and rollback. */ +#include +#include +#include +#include "../src/spu.c" +uint64_t s_frame_count; +uint64_t psx_get_cycle_count(void) {return 0;} +void audio_trace_pcm(int t,const int16_t *s,int n) {(void)t;(void)s;(void)n;} +void audio_trace_event(uint16_t k,uint32_t a,uint32_t b) {(void)k;(void)a;(void)b;} +void psx_irq_raise(uint32_t b,uint32_t d) {(void)b;(void)d;} +uint32_t crc32_update(uint32_t c,const uint8_t *p,size_t n) {(void)p;(void)n;return c;} +bool spu_shadow_enabled(void) {return false;} +void spu_shadow_reset(void) {} +void spu_shadow_process(int16_t *p,int n) {(void)p;(void)n;} +#define CHECK(x) do {if(!(x)) {fprintf(stderr,"line %d: %s\n",__LINE__,#x);exit(1);}}while(0) +static void block(uint8_t *p,uint8_t packed) { + memset(p,0,32);p[1]=4;memset(p+2,packed,14);p[17]=3;memset(p+18,packed,14); +} +static void ready_voice(unsigned i) { + voices[i].env_level=0x6000;voices[i].adsr_phase=ADSR_SUSTAIN; + spu_regs[i*8+2]=0x1000; +} +int main(void) { + spu_init();uint8_t a[32],b[32];block(a,0x11);block(b,0x33); + CHECK(!spu_register_sample_bank(0,a,32));CHECK(!spu_register_sample_bank(32,a,32)); + CHECK(!spu_register_sample_bank(1,a,31));CHECK(!spu_bind_next_voice_bank(24,0)); + CHECK(!spu_bind_next_voice_bank(0,1)); + CHECK(spu_register_sample_bank(1,a,32));CHECK(spu_register_sample_bank(2,b,32)); + CHECK(spu_register_sample_bank(1,a,32));CHECK(!spu_register_sample_bank(1,b,32)); + /* Callers can release/overwrite their staging buffers after registration. */ + memset(a,0,sizeof a);memset(b,0,sizeof b);block(spu_ram,0x55); + CHECK(spu_bind_next_voice_bank(0,1));CHECK(spu_bind_next_voice_bank(1,2)); + CHECK(!voices[0].sample_bank && voices[0].pending_bank==1); + uint32_t n=spu_snapshot_bytes();uint8_t *snapshot=malloc(n);CHECK(snapshot); + spu_snapshot_write(snapshot); + CHECK(spu_bind_next_voice_bank(0,0));CHECK(spu_snapshot_read(snapshot,n)); + CHECK(voices[0].pending_bank==1 && voices[1].pending_bank==2); + key_on(7);for(unsigned i=0;i<3;++i)ready_voice(i); + CHECK(voices[0].sample_bank==1 && voices[1].sample_bank==2 && !voices[2].sample_bank); + CHECK(!voices[0].pending_bank && !voices[1].pending_bank); + /* A stage DMA can overwrite the same hardware address at any time. */ + uint8_t upload[32];block(upload,0x77); + spu_write(0x1F801DA6u,0); + for(unsigned at=0;atn); + uint8_t *full=malloc(expanded);CHECK(full);spu_snapshot_write(full); + int16_t together[160],replayed[160],only_first[160]; + spu_render(together,80); + CHECK(voices[20].samples[0]==4096 && voices[44].samples[0]==12288); + CHECK(spu_snapshot_read(full,expanded));spu_render(replayed,80); + CHECK(!memcmp(together,replayed,sizeof together)); + CHECK(spu_snapshot_read(full,expanded));voices[44].active=0;spu_render(only_first,80); + CHECK(memcmp(together,only_first,sizeof together)); + CHECK(spu_snapshot_read(full,expanded));key_off(1u<<20); + CHECK(voices[20].adsr_phase==ADSR_RELEASE && voices[44].adsr_phase==ADSR_SUSTAIN); + CHECK(spu_snapshot_read(full,expanded));spu_private_voice_stop(1u<<20); + CHECK(voices[20].adsr_phase==ADSR_SUSTAIN && voices[44].adsr_phase==ADSR_RELEASE); + CHECK(spu_private_voice_volume(20,0,0) && spu_regs[20*8]==0x1000); + CHECK(!spu_private_voice_volume(24,0,0)); + /* Restoring an ordinary snapshot disables private voices and restores + * its unchanged wire size. Initialization clears them and their banks. */ + CHECK(spu_snapshot_read(snapshot,n) && !spu_private_voice_active() && spu_snapshot_bytes()==n); + free(full);free(snapshot);spu_init();CHECK(!spu_bind_next_voice_bank(0,1)); + puts("SPU private sample-bank checks passed");return 0; +} diff --git a/runtime/tests/test_ws_auto_ui_untextured_exec.c b/runtime/tests/test_ws_auto_ui_untextured_exec.c index 907af11b..3172f817 100644 --- a/runtime/tests/test_ws_auto_ui_untextured_exec.c +++ b/runtime/tests/test_ws_auto_ui_untextured_exec.c @@ -216,11 +216,90 @@ static void test_wall_behind_front_layer(int triangle) { assert(ws_auto_ui_transform_count == 0); } +static void test_explicit_projection_widget(void) { + const uint32_t tile = 0x20000u, glyph = 0x21000u; + reset_state(0); + hres1 = 0; hres2 = 1; /* Captured Ape display: 384x240. */ + h_display_x2 = h_display_x1 + 384u * 7u; + ws_hud_sprt = 0; + gpu_ws_set_auto_ui_squash(0); /* Explicit ownership is independent. */ + ws_hud_anchor_clear(ws_hud_anchor_tags, WS_HUD_ANCHOR_TABLE_SIZE); + memset(test_ram, 0, sizeof(test_ram)); + /* Actual Status backing tile shape, followed by a separate glyph list. + * Replacing the prepass must not lose the earlier list's widget anchor. */ + const uint32_t backing[] = {0x76606060u, 0x00C60146u, 0x77ABB090u}; + const uint32_t text[] = {0x7D808080u, pack_vertex(54, 64), 0x7C5D9800u}; + put_node(tile, 0xFFFFFFu, backing, 3); + put_node(glyph, 0xFFFFFFu, text, 3); + gpu_ws_tag_hud_prim(tile, 0); + gpu_ws_tag_hud_prim(glyph, 0); + test_ram[OT_HEAD / 4u] = glyph; + gpu_ws_prepass_linked_list(OT_HEAD); + load_packet(tile, 3); + last_scaled_rect.calls = 0; + gp0_exec_textured_8x8(); + assert(last_scaled_rect.calls == 1); + assert(last_scaled_rect.x == ws_scale_about(326, 192)); + assert(last_scaled_rect.w == 3); + assert(last_scaled_rect.u1 - last_scaled_rect.u0 == 8); + + /* A stale legacy billboard classification cannot double-transform an + * explicitly owned screen-space packet. */ + WsTag *legacy = &ws_tags[(glyph >> 2) & (WS_TAG_BUCKETS - 1)]; + legacy->key = glyph; legacy->stamp = (uint32_t)s_frame_count; + legacy->anchor_x = 54; + for (int edge = -1; edge <= 1; edge++) { + gpu_ws_tag_hud_prim(glyph, edge); + load_packet(glyph, 3); + last_scaled_rect.calls = 0; + gp0_exec_textured_16x16(); + assert(last_scaled_rect.calls == 1); + assert(last_scaled_rect.x == ws_scale_about(54, (edge + 1) * 192)); + assert(last_scaled_rect.w == 6); + } + memset(ws_tags, 0, sizeof(ws_tags)); + + /* Complete packet guards, frame expiry and native 4:3 identity. */ + load_packet(glyph, 3); + gp0_cmd_buf[2] ^= 1u; + last_textured_rect.calls = last_scaled_rect.calls = 0; + gp0_exec_textured_16x16(); + assert(last_textured_rect.calls == 1 && last_textured_rect.x == 54); + assert(last_scaled_rect.calls == 0); + s_frame_count += 3; + load_packet(glyph, 3); + last_textured_rect.calls = last_scaled_rect.calls = 0; + gp0_exec_textured_16x16(); + assert(last_textured_rect.calls == 1 && last_textured_rect.x == 54); + assert(last_scaled_rect.calls == 0); + gpu_ws_tag_hud_prim(glyph, 0); + ws_xnum = ws_xden = 1; + last_textured_rect.calls = last_scaled_rect.calls = 0; + gp0_exec_textured_16x16(); + assert(last_textured_rect.calls == 1 && last_textured_rect.x == 54); + assert(last_scaled_rect.calls == 0); + + /* Explicitly identified panels do not depend on automatic size/rank + * heuristics; untagged world/fades still use the existing conservative gate. */ + ws_xnum = 3; ws_xden = 8; + const uint32_t panel[] = {0x28202020u, pack_vertex(30, 30), + pack_vertex(334, 30), pack_vertex(30, 206), pack_vertex(334, 206)}; + put_node(tile, 0xFFFFFFu, panel, 5); + gpu_ws_tag_hud_prim(tile, 0); + load_packet(tile, 5); + gpu_exec_reset_triangles(); + gp0_exec_mono_quad(); + assert(gpu_exec_triangles.min_x == ws_scale_about(30, 192)); + assert(gpu_exec_triangles.max_x == ws_scale_about(334, 192)); + ws_hud_anchor_clear(ws_hud_anchor_tags, WS_HUD_ANCHOR_TABLE_SIZE); +} + int main(void) { int gmin, gmax, fmin, fmax; test_wall_behind_front_layer(0); test_wall_behind_front_layer(1); + test_explicit_projection_widget(); /* A real HUD remains eligible when its last drawing layer is followed * by an empty OT bucket, as in Ape's memory-card menu. */ diff --git a/tests/test_render_pass_gpu_submit.cpp b/tests/test_render_pass_gpu_submit.cpp new file mode 100644 index 00000000..b1175809 --- /dev/null +++ b/tests/test_render_pass_gpu_submit.cpp @@ -0,0 +1,82 @@ +#ifdef NDEBUG +#undef NDEBUG +#endif +#include "render_pass_gpu_submit.hpp" +#include "dma_gpu_ll.h" +#include "packet_address_index.h" +#include +#include +#include +#include + +using Event=std::tuple; +static uint32_t ram[256]{}; +static uint32_t arena[8]{}; +static std::vector trace; +extern "C" { +int g_psx_render_pass_active=1; +uint32_t g_psx_ram_size=sizeof ram,g_psx_ram_mask=sizeof ram-1; +uint32_t psx_mod_gpu_dma_resolve_address(uint32_t a){return psx_mod_gpu_dma_resolve_address_for(a,sizeof arena);} +int psx_mod_gpu_dma_memory_contains(uint32_t a,uint32_t bytes){return psx_mod_gpu_dma_aperture_offset_for(a,bytes,sizeof arena,nullptr);} +uint32_t psx_mod_read_word(uint32_t a){ + uint32_t off; + if(psx_mod_gpu_dma_aperture_offset_for(a,4,sizeof arena,&off))return arena[off/4]; + return ram[(a&g_psx_ram_mask)/4]; +} +void gpu_ws_begin_linked_list(){trace.emplace_back('B',0,0);} +void gpu_ws_end_linked_list(){trace.emplace_back('F',0,0);} +void gpu_ws_prepass_linked_list(uint32_t a){trace.emplace_back('P',a,0);} +void gpu_ws_validate_linked_list_header(uint32_t a,uint32_t h){trace.emplace_back('H',a,h);} +void gpu_ws_validate_linked_list_node(uint32_t a,uint32_t n){trace.emplace_back('V',a,n);} +void gpu_set_gp0_linked_list_node(uint32_t a,uint32_t n){trace.emplace_back('N',a,n);} +void gpu_set_gp0_source(uint32_t a){trace.emplace_back('S',a,0);} +void gpu_write_gp0(uint32_t w){trace.emplace_back('W',w,0);} +} +static uint32_t resolve(void*,uint32_t a){return psx_mod_gpu_dma_resolve_address(a);} +static uint32_t read(void*,uint32_t a){return psx_mod_read_word(a);} +static void header(void*,uint32_t a,uint32_t h){gpu_ws_validate_linked_list_header(a,h);} +static int node(void*,uint32_t a,uint32_t n){gpu_ws_validate_linked_list_node(a,n);gpu_set_gp0_linked_list_node(a,n);return 1;} +static void emit(void*,uint32_t a,uint32_t w){gpu_set_gp0_source(a);gpu_write_gp0(w);} +static void complete(void*,int limited){assert(!limited);gpu_ws_end_linked_list();} +int main(){ + if constexpr(!PSXRenderSubmit::enhanced) { + assert(!PSXRenderSubmit::ot(0x40));assert(trace.empty()); + puts("PASS: REFERENCE leaves GPU submission to the original SDK");return 0; + } + // Empty OT links, a multi-command environment, a primitive, and a wrapped + // RAM payload: compare every command, source address and guard event with + // the real LLE DMA walker (only scheduling is deliberately different). + ram[0x40/4]=0x00000080;ram[0x80/4]=0x030003FC; + ram[0x84/4]=0xE1000020;ram[0x88/4]=0xE3000000;ram[0x8C/4]=0xE40003FF; + ram[0x3FC/4]=0x02FFFFFF;ram[0]=0x6000FF00;ram[1]=0x00100010; + const DMAGPULinkedListOps ops={resolve,read,header,node,emit,complete}; + DMAGPULinkedList ll{};gpu_ws_begin_linked_list();gpu_ws_prepass_linked_list(0x40); + dma_gpu_ll_start(&ll,0x40,32768);dma_gpu_ll_advance(&ll,UINT32_MAX,&ops,nullptr); + assert(!ll.active);const auto expected=trace;trace.clear(); + assert(PSXRenderSubmit::ot(0x80000040));assert(trace==expected); + // The same LLE trace comparison with an OT in RAM and payload in an + // expanded primitive arena. Source keys must retain the aperture address. + trace.clear();ram[0x40/4]=PSX_MOD_GPU_DMA_APERTURE_BASE; + arena[0]=0x02FFFFFF;arena[1]=0x6000FF00;arena[2]=0x00100010; + gpu_ws_begin_linked_list();gpu_ws_prepass_linked_list(0x40); + dma_gpu_ll_start(&ll,0x40,32768);dma_gpu_ll_advance(&ll,UINT32_MAX,&ops,nullptr); + assert(!ll.active);const auto arena_expected=trace;trace.clear(); + assert(PSXRenderSubmit::ot(0x80000040));assert(trace==arena_expected); + trace.clear();arena[0]=0x08FFFFFF; + assert(!PSXRenderSubmit::ot(0x40));assert(trace.empty()); + ram[0x40/4]=0x00000080; + trace.clear();ram[0x80/4]=0x03000040; + assert(!PSXRenderSubmit::ot(0x40));assert(trace.empty()); + g_psx_render_pass_active=0;assert(!PSXRenderSubmit::ot(0x40));assert(trace.empty()); + + // Collision and duplicate lookup must preserve the old first-match scan, + // including misses and clearing between generations. + PSXPacketAddressIndex slots[256]{};uint32_t addresses[120]; + for(unsigned i=0;i<120;++i){addresses[i]=((i*17)%97)*1024;psx_packet_address_index_add(slots,256,addresses[i],i);} + for(unsigned i=0;i<120;++i){unsigned first=0;while(addresses[first]!=addresses[i])++first; + assert(psx_packet_address_index_find(slots,256,addresses[i])==first);} + assert(psx_packet_address_index_find(slots,256,1)==UINT32_MAX); + psx_packet_address_index_clear(slots,256); + assert(psx_packet_address_index_find(slots,256,addresses[0])==UINT32_MAX); + puts("PASS: HLE command/provenance trace matches LLE DMA; cycle/outside-pass refusal; indexed guards match first scan"); +} diff --git a/tests/test_render_pass_ot_relocation.cpp b/tests/test_render_pass_ot_relocation.cpp new file mode 100644 index 00000000..f377a00c --- /dev/null +++ b/tests/test_render_pass_ot_relocation.cpp @@ -0,0 +1,132 @@ +#include "render_pass_ot_relocation.hpp" +#include + +static int failures=0; +#define CHECK(c) do {if(!(c)){std::fprintf(stderr,"FAIL line %d: %s\n",__LINE__,#c);++failures;}}while(0) +static void put(std::vector& ram,uint32_t address,uint32_t value) { + std::memcpy(ram.data()+address,&value,4); +} +int main() { + std::vector ram(0x200000); + constexpr uint32_t OT=0x1000,Prefix=0x1100,Core=0x1200,Tail=0x1400; + put(ram,OT,Prefix);put(ram,Prefix,0x02ffffffu); + put(ram,Prefix+4,0x64000000);put(ram,Prefix+8,0x000a000b); + PSXOTRelocation journal; + CHECK(journal.begin(ram.data(),ram.size(),OT)); + put(ram,OT,Core);put(ram,Core,0x02000000u|Prefix); + put(ram,Core+4,0x20000000);put(ram,Core+8,0x000c000d); + CHECK(journal.end(ram.data(),ram.size(),0x1300)); + const auto core=ram; + put(ram,OT,Tail);put(ram,Tail,0x02000000u|Core); + put(ram,Tail+4,0x64000000);put(ram,Tail+8,0x00100020); + CHECK(journal.prepare(ram.data(),ram.size())); + ram=core;put(ram,Core+8,0x000d000e); // interpolated core coordinates + CHECK(journal.matches(ram.data(),ram.size(),OT,0x1300)); + CHECK(!journal.matches(ram.data(),ram.size(),OT,0x1304)); + CHECK(!journal.matches(ram.data(),ram.size(),OT+4,0x1300)); + put(ram,Core,0x02000000u|Tail); + CHECK(!journal.matches(ram.data(),ram.size(),OT,0x1300)); + ram=core;put(ram,Core+8,0x000d000e);journal.apply(ram.data()); + CHECK(PSXOTRelocation::word(ram.data(),OT)==Tail); + CHECK(PSXOTRelocation::word(ram.data(),Core+8)==0x000d000e); + CHECK(PSXOTRelocation::word(ram.data(),Tail+8)==0x00100020); + + // Tail must never overwrite interpolated output, or grow a core packet. + ram=core;put(ram,Core+8,0x10000000); + CHECK(!journal.prepare(ram.data(),ram.size())); + ram=core;put(ram,Core,0x03000000u|Prefix); + CHECK(!journal.prepare(ram.data(),ram.size())); + ram=core;put(ram,OT,0x300000); // out-of-RAM link + CHECK(!journal.prepare(ram.data(),ram.size())); + ram=core;put(ram,OT,OT); // cycle, bounded traversal + CHECK(!journal.prepare(ram.data(),ram.size())); + ram=core; + CHECK(journal.begin(ram.data(),ram.size(),OT)); + put(ram,Prefix+8,0x11112222); // in-place core reuse is unsupported + CHECK(!journal.end(ram.data(),ram.size(),0x1300)); + ram=core;put(ram,Core,0x02000000u|(Core+4)); + put(ram,Core+4,0x00ffffffu); // linked header aliases a packet payload + std::vector aliased; + CHECK(!PSXOTRelocation::walk(ram.data(),ram.size(),OT,aliased)); + // Variable core packet layout: relocate a contiguous two-packet HUD tail + // and splice its terminal onto the current OT head, preserving live core. + ram.assign(0x200000,0); + put(ram,OT,Prefix);put(ram,Prefix,0x02ffffffu); + put(ram,Prefix+4,0x64000000);put(ram,Prefix+8,0x000a000b); + PSXOTRelocation relocated; + CHECK(relocated.begin(ram.data(),ram.size(),OT,0x80001200)); + put(ram,OT,Core);put(ram,Core,0x02000000u|Prefix); + put(ram,Core+4,0x20000000);put(ram,Core+8,0x000c000d); + put(ram,0x1800,0x80001300); + CHECK(relocated.end(ram.data(),ram.size(),0x80001300)); + const auto recorded_core=ram; + put(ram,0x1300,0x02000000u|Core); + put(ram,0x1304,0x64000000);put(ram,0x1308,0x00100020); + put(ram,0x130c,0x02001300u); + put(ram,0x1310,0x60000000);put(ram,0x1314,0x00300040); + put(ram,OT,0x130c);put(ram,0x1800,0x80001318); + CHECK(relocated.prepare(ram.data(),ram.size())); + ram=recorded_core; + put(ram,OT,0x1220);put(ram,0x1220,0x02000000u|Prefix); + put(ram,0x1224,0x20000000);put(ram,0x1228,0x000e000f); + put(ram,0x1800,0x80001320); + CHECK(relocated.prepare_splice(ram.data(),ram.size(),OT,0x80001320,0x1800)); + CHECK(relocated.shadow_destination(0x1308)==0x1328); + relocated.apply_splice(ram.data()); + CHECK(PSXOTRelocation::word(ram.data(),OT)==0x132c); + CHECK(PSXOTRelocation::word(ram.data(),0x1320)==(0x02000000u|0x1220)); + CHECK(PSXOTRelocation::word(ram.data(),0x132c)==0x02001320u); + CHECK(PSXOTRelocation::word(ram.data(),0x1228)==0x000e000f); + CHECK(PSXOTRelocation::word(ram.data(),0x1800)==0x80001338); + CHECK(!relocated.prepare_splice(ram.data(),ram.size(),OT+4,0x80001320,0x1800)); + ram=recorded_core;put(ram,Prefix+8,0x00010002); + CHECK(!relocated.prepare_splice(ram.data(),ram.size(),OT,0x80001320,0x1800)); + ram=recorded_core; + CHECK(!relocated.prepare_splice(ram.data(),ram.size(),OT,0x80001200,0x1800)); + CHECK(!relocated.prepare_splice(ram.data(),ram.size(),OT,0x801ffffc,0x1800)); + ram=recorded_core;put(ram,0x1800,0x800012e0); + CHECK(relocated.prepare_splice(ram.data(),ram.size(),OT,0x800012e0,0x1800)); + relocated.apply_splice(ram.data()); + CHECK(PSXOTRelocation::word(ram.data(),OT)==0x12ec); + CHECK(PSXOTRelocation::word(ram.data(),0x12e0)==(0x02000000u|Core)); + // DrawMode's allocator reserves 16 bytes but DMA reads 8. Stale padding + // must neither be interpreted as a tag nor copied onto live core output. + ram=recorded_core; + put(ram,0x1300,0x01000000u|Core); + put(ram,0x1304,0xe1000220); + put(ram,0x1308,0xffffffffu);put(ram,0x130c,0xaaaaaaaa); + put(ram,0x1310,0x02001300u); + put(ram,0x1314,0x64000000);put(ram,0x1318,0x00100020); + put(ram,OT,0x1310);put(ram,0x1800,0x80001320); + put(ram,0x1900,0x1234); // real tail advances an ordinary scratch counter + CHECK(relocated.prepare(ram.data(),ram.size(),0x80001320)); + ram=recorded_core;put(ram,0x1800,0x80001340); + put(ram,0x1900,0xabcd); // interpolation changed the temporary core value + put(ram,0x1348,0x12345678);put(ram,0x134c,0x87654321); + CHECK(relocated.prepare_splice(ram.data(),ram.size(),OT,0x80001340,0x1800)); + relocated.apply_splice(ram.data()); + CHECK(PSXOTRelocation::word(ram.data(),OT)==0x1350); + CHECK(PSXOTRelocation::word(ram.data(),0x1340)==(0x01000000u|Core)); + CHECK(PSXOTRelocation::word(ram.data(),0x1348)==0x12345678); + CHECK(PSXOTRelocation::word(ram.data(),0x134c)==0x87654321); + CHECK(PSXOTRelocation::word(ram.data(),0x1800)==0x80001360); + CHECK(PSXOTRelocation::word(ram.data(),0x1900)==0x1234); + ram=recorded_core;put(ram,0x1800,0x80001340); + put(ram,OT,0x18fc);put(ram,0x18fc,0x02000000u|Core); + put(ram,0x1900,0x64000000);put(ram,0x1904,0x10002000); + CHECK(!relocated.prepare_splice(ram.data(),ram.size(),OT,0x80001340,0x1800)); + ram=recorded_core;put(ram,0x1800,0x80001340); + // A packet outside the actual producer interval is still refused. + CHECK(relocated.prepare_splice(ram.data(),ram.size(),OT,0x801ffff0,0x1800)==false); + // A middle-list insertion into a primitive header is unsupported even if + // the complete DMA graph remains structurally valid. + ram=recorded_core; + put(ram,0x1300,0x02000000u|Prefix); + put(ram,0x1304,0x64000000);put(ram,0x1308,0x00100020); + put(ram,Core,0x02001300u);put(ram,0x1800,0x8000130c); + CHECK(relocated.prepare(ram.data(),ram.size())); + ram=recorded_core;put(ram,0x1800,0x80001320); + CHECK(!relocated.prepare_splice(ram.data(),ram.size(),OT,0x80001320,0x1800)); + std::printf("OT relocation guards: %s\n",failures?"FAIL":"PASS"); + return failures?1:0; +} diff --git a/tools/package_appimage.sh b/tools/package_appimage.sh new file mode 100755 index 00000000..afcf3ef7 --- /dev/null +++ b/tools/package_appimage.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Wrap a validated package_game_release.sh --stage-only Linux payload. +# No build, emitter or AOT invocation runs here. AppRun/desktop/PNG icon are +# caller-provided, fully rendered title metadata. AppRun must use usr/bin/ +# and usr/share/, preserving user settings/cards in writable data. +# Usage: package_appimage.sh --payload DIR --exe-name NAME --payload-name NAME +# --app-run FILE --desktop-file FILE.desktop --icon FILE.png --output FILE.AppImage +# [--framework DIR] [--tool-cache DIR] [--jobs N] +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +FRAMEWORK="$(cd "${SCRIPT_DIR}/.." && pwd)" +PAYLOAD=""; EXE_NAME=""; PAYLOAD_NAME=""; APP_RUN=""; DESKTOP=""; ICON=""; OUTPUT="" +TOOLS="${XDG_CACHE_HOME:-${HOME:?}/.cache}/recomp-appimage-tools" +JOBS=2 +while [[ $# -gt 0 ]]; do + case "$1" in + --payload) PAYLOAD="${2:?}"; shift 2;; + --exe-name) EXE_NAME="${2:?}"; shift 2;; + --payload-name) PAYLOAD_NAME="${2:?}"; shift 2;; + --app-run) APP_RUN="${2:?}"; shift 2;; + --desktop-file) DESKTOP="${2:?}"; shift 2;; + --icon) ICON="${2:?}"; shift 2;; + --output) OUTPUT="${2:?}"; shift 2;; + --framework) FRAMEWORK="${2:?}"; shift 2;; + --tool-cache) TOOLS="${2:?}"; shift 2;; + --jobs) JOBS="${2:?}"; shift 2;; + -h|--help) sed -n '2,8p' "$0"; exit 0;; + *) echo "unknown argument: $1" >&2; exit 2;; + esac +done +[[ "$(uname -s)" == Linux && "$(uname -m)" == x86_64 ]] || { + echo "AppImage packaging requires native Linux x86_64" >&2; exit 1; +} +for name in EXE_NAME PAYLOAD_NAME; do + [[ "${!name}" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]] || { + echo "invalid ${name}" >&2; exit 2; + } +done +[[ "$JOBS" =~ ^[1-9][0-9]*$ ]] || { echo "invalid jobs" >&2; exit 2; } +[[ -d "$PAYLOAD" && -n "$OUTPUT" && ! -e "$OUTPUT" && ! -L "$OUTPUT" ]] || { + echo "payload must exist and output must be a fresh path" >&2; exit 1; +} +PAYLOAD="$(cd "$PAYLOAD" && pwd)" +FRAMEWORK="$(cd "$FRAMEWORK" && pwd)" +for file in "$APP_RUN" "$DESKTOP" "$ICON" "$PAYLOAD/$EXE_NAME" "$PAYLOAD/$EXE_NAME.execution.json"; do + [[ -f "$file" ]] || { echo "missing ${file}" >&2; exit 1; } +done +[[ -d "$PAYLOAD/assets" && -f "$PAYLOAD/game.toml" ]] || { + echo "payload is missing staged runtime assets/config" >&2; exit 1; +} +DESKTOP_ID="$(basename "$DESKTOP" .desktop)" +[[ "$DESKTOP" == *.desktop && "$DESKTOP_ID" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]] || { + echo "invalid desktop metadata filename" >&2; exit 1; +} +PY="${PSX_RELEASE_STAGE_PYTHON:-python3}" +"$PY" - "$PAYLOAD/$EXE_NAME" "$ICON" <<'PY' +import sys +from pathlib import Path +with Path(sys.argv[1]).open('rb') as stream: + elf_magic = stream.read(4) +with Path(sys.argv[2]).open('rb') as stream: + png_magic = stream.read(8) +if elf_magic != b'\x7fELF': + raise SystemExit('Linux payload executable must be ELF') +if png_magic != b'\x89PNG\r\n\x1a\n': + raise SystemExit('AppImage icon must be PNG') +PY +if find "$PAYLOAD" -type f \( -iname '*.dll' -o -iname '*.exe' \) -print -quit | grep -q .; then + echo "Linux payload contains Windows binaries/cache" >&2; exit 1 +fi +sh -n "$APP_RUN" +APPDIR="$(mktemp -d "${TMPDIR:-/tmp}/psx-appimage.XXXXXXXX")" +case "$(stat -f -c %T "$APPDIR")" in + 9p|drvfs|ntfs*) echo "AppDir must be on the native Linux filesystem: $APPDIR" >&2; exit 1;; +esac +echo "AppDir: $APPDIR" +DATA="$APPDIR/usr/share/$PAYLOAD_NAME" +mkdir -p "$APPDIR/usr/bin" "$DATA" "$(dirname "$OUTPUT")" "$TOOLS" +cp -a "$PAYLOAD/." "$DATA/" +rm -f "$DATA/$EXE_NAME" "$DATA/$EXE_NAME.execution.json" +install -m 0755 "$PAYLOAD/$EXE_NAME" "$APPDIR/usr/bin/$EXE_NAME" +install -m 0755 "$APP_RUN" "$APPDIR/AppRun" +install -m 0644 "$DESKTOP" "$APPDIR/$DESKTOP_ID.desktop" +install -m 0644 "$ICON" "$APPDIR/$DESKTOP_ID.png" +ln -s "$DESKTOP_ID.png" "$APPDIR/.DirIcon" +# SDL resolves UI resources from the real ELF, rather than writable argv[0]. +ln -s "../share/$PAYLOAD_NAME/assets" "$APPDIR/usr/bin/assets" +HELPER="$FRAMEWORK/tools/release_stage.py" +# Official GitHub asset digests checked 2026-10-07 UTC; versioned releases +# avoid drifting continuous URLs. Asset IDs313839329/324406736 respectively. +# These releases are not marked immutable; SHA verification remains mandatory. +LINUXDEPLOY_SHA=c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d +APPIMAGETOOL_SHA=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 +LINUXDEPLOY="$TOOLS/linuxdeploy-x86_64.AppImage" +APPIMAGETOOL="$TOOLS/appimagetool-x86_64.AppImage" +"$PY" "$HELPER" fetch-pinned --url https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1/linuxdeploy-x86_64.AppImage \ + --sha256 "$LINUXDEPLOY_SHA" --destination "$LINUXDEPLOY" +"$PY" "$HELPER" fetch-pinned --url https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage \ + --sha256 "$APPIMAGETOOL_SHA" --destination "$APPIMAGETOOL" +chmod 0755 "$LINUXDEPLOY" "$APPIMAGETOOL" +export NO_STRIP=1 +"$LINUXDEPLOY" --appimage-extract-and-run --appdir "$APPDIR" \ + --executable "$APPDIR/usr/bin/$EXE_NAME" --desktop-file "$APPDIR/$DESKTOP_ID.desktop" \ + --icon-file "$APPDIR/$DESKTOP_ID.png" +# Deployment may rewrite RPATH. The original sidecar supplies the contract, +# while the helper verifies its embedded identity and hashes the FINAL ELF; +# a prior payload binary_sha256 is never accepted as the final-byte receipt. +"$PY" "$HELPER" stage-execution --binary "$APPDIR/usr/bin/$EXE_NAME" \ + --manifest "$PAYLOAD/$EXE_NAME.execution.json" \ + --output "$APPDIR/usr/bin/$EXE_NAME.execution.json" +SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-0}" +[[ "$SOURCE_DATE_EPOCH" =~ ^[0-9]+$ ]] || { echo "invalid SOURCE_DATE_EPOCH" >&2; exit 2; } +export SOURCE_DATE_EPOCH +find "$APPDIR" -exec touch -h -d "@$SOURCE_DATE_EPOCH" {} + +ARCH=x86_64 "$APPIMAGETOOL" --appimage-extract-and-run \ + --mksquashfs-opt -processors --mksquashfs-opt "$JOBS" "$APPDIR" "$OUTPUT" +chmod 0755 "$OUTPUT" +sha256sum "$OUTPUT" > "$OUTPUT.sha256" +echo "AppImage: $OUTPUT" diff --git a/tools/package_game_release.sh b/tools/package_game_release.sh index f4b6487a..b2d48a94 100755 --- a/tools/package_game_release.sh +++ b/tools/package_game_release.sh @@ -25,6 +25,7 @@ # [--overlay-cache-root DIR] # ship a prebuilt overlay shard cache # [--ship-without-overlay-cache-because REASON] # [--no-overlay-toolchain] # do not bundle overlay_toolchain/ +# [--stage-only] # validate/stage, without ZIP compression # [--disc-hint "your legally owned disc"] [--version-env RELEASE_VERSION] # # Overlay cache. The runtime's overlay shard cache is compiled FROM THE DISC, @@ -74,6 +75,7 @@ OVERLAY_CACHE_ROOT="" SHIP_WITHOUT_OVERLAY_CACHE_BECAUSE="" SHIP_WITHOUT_OVERLAY_CACHE_KEY_BECAUSE="" STAGE_OVERLAY_TOOLCHAIN=1 +STAGE_ONLY=0 if [[ -z "${EXCLUDE_DEV_MODS:-}" ]]; then if [[ -n "${CI:-}" ]]; then EXCLUDE_DEV_MODS=1; else EXCLUDE_DEV_MODS=0; fi fi @@ -103,6 +105,7 @@ while [[ $# -gt 0 ]]; do --ship-without-overlay-cache-because) SHIP_WITHOUT_OVERLAY_CACHE_BECAUSE="${2:?}"; shift 2 ;; --ship-without-overlay-cache-key-because) SHIP_WITHOUT_OVERLAY_CACHE_KEY_BECAUSE="${2:?}"; shift 2 ;; --no-overlay-toolchain) STAGE_OVERLAY_TOOLCHAIN=0; shift ;; + --stage-only) STAGE_ONLY=1; shift ;; --exclude-dev-mods) EXCLUDE_DEV_MODS=1; shift ;; --include-dev-mods) EXCLUDE_DEV_MODS=0; shift ;; --runtime-bin) RUNTIME_BIN_DIR="${2:?}"; shift 2 ;; @@ -222,7 +225,7 @@ STAGE="${DIST:?}/stage-game-${ARTIFACT:?}" ZIP_NAME="${ZIP_PREFIX:?}-${VERSION:?}-${ARTIFACT:?}.zip" rm -rf "${STAGE:?}" mkdir -p "${STAGE}" "${DIST}" -rm -f "${DIST:?}/${ZIP_NAME:?}" +if [[ "${STAGE_ONLY}" != 1 ]]; then rm -f "${DIST:?}/${ZIP_NAME:?}"; fi # --- The executable and what CMake staged beside it -------------------------- cp -a "${EXE}" "${STAGE}/" @@ -473,8 +476,21 @@ if [[ "${EXE_BASENAME}" == *.exe ]]; then fi fi +# --- Bind the final executable to its original build execution contract ---- +# Signing can change the PE bytes, so bind after it and before compression. +# Do not accept a sidecar copied into the stage from an unrelated build. +"${PY}" "${SCRIPT_DIR}/release_stage.py" stage-execution \ + --binary "${STAGE}/${EXE_BASENAME}" \ + --manifest "${EXE%.exe}.execution.json" \ + --output "${STAGE}/${EXE_BASENAME%.exe}.execution.json" + find "${STAGE}" -exec touch -c {} + 2>/dev/null || find "${STAGE}" -exec touch {} + +if [[ "${STAGE_ONLY}" == 1 ]]; then + echo "Staged ${STAGE} (ZIP compression skipped)" + exit 0 +fi + "${PY}" "${SCRIPT_DIR}/create_release_zip.py" --source "${STAGE}" --output "${DIST}/${ZIP_NAME}" >/dev/null echo "Wrote ${DIST}/${ZIP_NAME}" du -h "${DIST}/${ZIP_NAME}" diff --git a/tools/qualify_render_passes.py b/tools/qualify_render_passes.py new file mode 100644 index 00000000..805b5314 --- /dev/null +++ b/tools/qualify_render_passes.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Record evidence of native scene passes during a caller-selected gameplay case. + +This observes an already running debug build. It never boots, drives, pauses or +changes settings. A passing sample is one gameplay window, not title acceptance. +Use correctness with PSX_RENDER_PASS_VERIFY=1 to check guest-state restoration. +For performance, relaunch without verification and select --purpose performance; +synchronous verification readbacks and hashing are not normal-play costs. +""" +import argparse +import hashlib +import json +from pathlib import Path +import time + +from debug_client import connect, send_cmd + + +COUNTERS = ("passes", "pass_presents", "verify_checks", "verify_mismatch", + "vram_leaks", "watchdog", "nesting_repairs", "span_failures", + "aborted", "blended_presents") + + +def assess(before, after, purpose="correctness"): + reasons, delta = [], {} + for key in COUNTERS: + a, b = before.get(key), after.get(key) + if type(a) is not int or type(b) is not int or b < a: + reasons.append("missing, invalid or reset counter: " + key) + else: + delta[key] = b - a + required = ("passes", "pass_presents", "verify_checks") if purpose == "correctness" else ("passes", "pass_presents") + for key in required: + if key in delta and not delta[key]: + reasons.append("no evidence during this window: " + key) + for key in ("verify_mismatch", "vram_leaks", "watchdog", "nesting_repairs", + "span_failures", "aborted", "blended_presents"): + if delta.get(key, 0): + reasons.append("unexpected replay result: " + key) + if after.get("disabled") != 0: + reasons.append("replay disabled or status missing") + return delta, reasons + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--case", required=True, help="actual gameplay path being observed") + parser.add_argument("--seconds", type=float, default=10) + parser.add_argument("--purpose", choices=("correctness", "performance"), default="correctness") + parser.add_argument("--executable", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if not 0 < args.seconds <= 60: + parser.error("--seconds must be greater than zero and at most 60") + executable = args.executable.resolve(strict=True) + with executable.open("rb") as stream: + binary_sha = hashlib.file_digest(stream, "sha256").hexdigest() + manifest_path = executable.with_suffix(".execution.json") + manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else None + receipt = dict(schema=1, case=args.case, executable=str(executable), + executable_sha256=binary_sha, execution=manifest, + started_utc=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + port=args.port, purpose=args.purpose, sample_passed=False) + + def query(): + with connect(port=args.port) as sock: + response = send_cmd(sock, {"cmd": "render_pass_stats"}) + if not isinstance(response, dict) or response.get("ok") is not True: + raise RuntimeError("render_pass_stats refused: " + repr(response)) + return response + + try: + receipt["before"] = query() + start = time.monotonic() + time.sleep(args.seconds) + receipt["after"] = query() + receipt["elapsed_seconds"] = time.monotonic() - start + receipt["delta"], receipt["reasons"] = assess(receipt["before"], receipt["after"], args.purpose) + if args.purpose == "performance" and receipt["delta"].get("verify_checks", 0): + receipt["reasons"].append("performance sample includes verification readbacks and hashing") + receipt["sample_passed"] = not receipt["reasons"] + except (OSError, ValueError, RuntimeError) as error: + receipt["reasons"] = [str(error)] + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8") + print(json.dumps({key: receipt.get(key) for key in ("case", "sample_passed", "delta", "reasons")})) + return 0 if receipt["sample_passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release_stage.py b/tools/release_stage.py index b2906993..20364523 100644 --- a/tools/release_stage.py +++ b/tools/release_stage.py @@ -61,6 +61,7 @@ Usage (see each subcommand's --help): import argparse import hashlib +import json import os import re import shutil @@ -69,6 +70,7 @@ import tarfile import time import urllib.request import zipfile +from pathlib import Path _TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) if _TOOLS_DIR not in sys.path: @@ -679,6 +681,22 @@ def stage_toolchain(stage, recomp_dir, recomp_tools, recomp_include, dl_cache, # python-build-standalone: one `python/` top-level directory. _extract_tar_top_level(py_archive, toolchain, 'python') + if platform_tag == 'linux': + # Standalone Python includes pip's Windows entry-point resources even + # in Linux archives. They are data for installing Windows scripts, + # never Linux executables. Omit only these six known resources; keep + # the AppImage gate rejecting other mixed-platform binaries. + launchers = {'t32.exe', 't64.exe', 't64-arm.exe', + 'w32.exe', 'w64.exe', 'w64-arm.exe'} + removed = 0 + for candidate in Path(py_dest).glob( + 'lib/python*/site-packages/pip/_vendor/distlib/*.exe'): + if candidate.name in launchers: + candidate.unlink() + removed += 1 + log('Omitted unused pip Windows launcher resources from Linux Python: %d' + % removed) + if pins['tcc_url']: tcc_archive = get_pinned_archive( pins['tcc_url'], pins['tcc_sha256'], @@ -966,6 +984,64 @@ def cmd_stage_mods(args): return 0 +def stage_execution(binary, manifest=None, output=None): + """Bind a build's execution contract to the actual staged executable.""" + default = os.path.splitext(binary)[0] + '.execution.json' + manifest, output = manifest or default, output or default + if os.path.normcase(os.path.realpath(binary)) == os.path.normcase(os.path.realpath(output)): + _die('execution manifest output must not overwrite the executable') + try: + with open(manifest, encoding='utf-8') as stream: + data = json.load(stream) + identity = data['identity'] + profile = data['profile'] + shared = data['shared_contract_sha256'] + families = data['implementations'] + if data['schema'] != 1 or profile not in ('ENHANCED', 'REFERENCE'): + raise ValueError('unsupported execution schema/profile') + if not re.fullmatch('[0-9a-f]{64}', identity) or not re.fullmatch('[0-9a-f]{64}', shared): + raise ValueError('malformed execution identity') + material = 'psx-execution-v1\n%s\nshared:%s\n' % (profile, shared) + names = set() + for family in sorted(families, key=lambda item: item['name']): + name, contract, source = family['name'], family['contract'], family['source_sha256'] + if (not re.fullmatch('[a-z0-9][a-z0-9_.-]*', name) or name in names or + not re.fullmatch('[A-Za-z0-9][A-Za-z0-9_.-]*', contract) or + not re.fullmatch('[0-9a-f]{64}', source)): + raise ValueError('malformed or duplicate implementation family') + names.add(name) + material += '%s:%s:%s\n' % (name, contract, source) + if hashlib.sha256(material.encode()).hexdigest() != identity: + raise ValueError('manifest does not match its execution contract') + digest, tail, found = hashlib.sha256(), b'', False + with open(binary, 'rb') as stream: + while True: + block = stream.read(1024 * 1024) + if not block: + break + digest.update(block) + found |= identity.encode('ascii') in tail + block + tail = block[-63:] + # Runtime startup embeds/reports this exact identity. A sidecar from + # another profile or build must not certify the staged executable. + if not found: + raise ValueError('execution identity is absent from the staged binary') + data['binary_sha256'] = digest.hexdigest() + with open(output, 'w', encoding='utf-8', newline='\n') as stream: + json.dump(data, stream, indent=2) + stream.write('\n') + return data + except (OSError, ValueError, KeyError, TypeError) as error: + _die('execution staging failed: %s' % error) + + +def cmd_stage_execution(args): + data = stage_execution(args.binary, args.manifest, args.output) + print('execution staged: %s %s binary=%s' % + (data['profile'], data['identity'], data['binary_sha256'])) + return 0 + + # --------------------------------------------------------------------------- def main(argv=None): ap = argparse.ArgumentParser( @@ -974,6 +1050,12 @@ def main(argv=None): 'platform. See the module docstring and bead beads-eio.3.102.') sub = ap.add_subparsers(dest='cmd', required=True) + p = sub.add_parser('stage-execution', help='verify and bind the execution manifest to a staged binary') + p.add_argument('--binary', required=True, help='actual executable being packaged') + p.add_argument('--manifest', help='build-published .execution.json (default: beside the binary)') + p.add_argument('--output', help='manifest destination (default: beside the binary)') + p.set_defaults(func=cmd_stage_execution) + p = sub.add_parser('cg-tag', help='print the overlay codegen cache tag') p.add_argument('--runtime-include', required=True) p.add_argument('--recompiler', required=True,