From 16d3672526158617f282757690051f9e7c6caf67 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 18:30:50 -0700 Subject: [PATCH 01/64] Add build-selected execution contracts and stateless HLE GTE service --- AGENTS.md | 15 +++ CLAUDE.md | 87 +++--------- cmake/psx_execution_profile.cmake | 147 +++++++++++++++++++++ docs/HLE_EXECUTION.md | 77 +++++++++++ runtime/CMakeLists.txt | 25 ++++ runtime/include/boot_state.h | 2 +- runtime/include/execution_identity.h | 43 ++++++ runtime/include/execution_profile.h | 14 ++ runtime/include/hle_gte.h | 17 +++ runtime/runtime.cmake | 14 ++ runtime/src/boot_state.c | 13 +- runtime/src/gte.cpp | 14 +- runtime/src/main.cpp | 3 + runtime/src/psx_netplay.c | 5 +- runtime/tests/test_boot_state_handoff.c | 5 + runtime/tests/test_execution_profile.py | 125 ++++++++++++++++++ runtime/tests/test_gte_register_access.cpp | 35 +++++ 17 files changed, 559 insertions(+), 82 deletions(-) create mode 100644 AGENTS.md create mode 100644 cmake/psx_execution_profile.cmake create mode 100644 docs/HLE_EXECUTION.md create mode 100644 runtime/include/execution_identity.h create mode 100644 runtime/include/execution_profile.h create mode 100644 runtime/include/hle_gte.h create mode 100644 runtime/tests/test_execution_profile.py 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_execution_profile.cmake b/cmake/psx_execution_profile.cmake new file mode 100644 index 00000000..39fdf37d --- /dev/null +++ b/cmake/psx_execution_profile.cmake @@ -0,0 +1,147 @@ +# 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() + +# 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() + target_sources(${target} PRIVATE ${_selected}) + 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/docs/HLE_EXECUTION.md b/docs/HLE_EXECUTION.md new file mode 100644 index 00000000..0cc04589 --- /dev/null +++ b/docs/HLE_EXECUTION.md @@ -0,0 +1,77 @@ +# 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. + +## 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. + +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. + +## 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/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index eacbe427..2fdcdcef 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -97,6 +97,15 @@ if(BUILD_TESTING) target_link_libraries(boot_state_handoff_test PRIVATE ZLIB::ZLIB) add_test(NAME boot_state_handoff_test COMMAND boot_state_handoff_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) @@ -129,6 +138,14 @@ if(BUILD_TESTING) set(PSX_GL_READBACK_COMPILER_BIN "${_gl_test_default_bin}" CACHE PATH "GCC/MinGW bin for GL fixture") find_package(Python3 QUIET COMPONENTS Interpreter) if(Python3_Interpreter_FOUND) + 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 wtrace_dump_test COMMAND ${CMAKE_COMMAND} -E env "CC=${CMAKE_C_COMPILER}" "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_wtrace_dump.py") @@ -453,6 +470,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 diff --git a/runtime/include/boot_state.h b/runtime/include/boot_state.h index 27a94b44..ca4f76df 100644 --- a/runtime/include/boot_state.h +++ b/runtime/include/boot_state.h @@ -69,7 +69,7 @@ typedef struct { uint32_t codegen_ver; /* PSX_OVERLAY_CODEGEN_VER */ /* ---- 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/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/runtime.cmake b/runtime/runtime.cmake index a065f3c8..3740e83b 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -15,6 +15,7 @@ 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_dependency_archive.cmake") include("${PSXRECOMP_ROOT}/runtime/chd_dependency.cmake") @@ -1570,6 +1571,19 @@ function(psxrecomp_add_runtime_target target) ${PSXRT_EXTRAS_SOURCES} ) target_link_libraries(${target} PRIVATE chdr-static) + 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 diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c index c48e929e..641222f7 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 */ @@ -85,17 +86,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). */ @@ -838,7 +837,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/gte.cpp b/runtime/src/gte.cpp index 6b2d4634..58d4e981 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -1,6 +1,8 @@ #include "gte.h" #include "gte_view.h" #include "cpu_state.h" +#include "execution_profile.h" +#include "hle_gte.h" #include "nd_intro_ot.h" #include "pgxp.h" #include "gte_nclip_stats.h" @@ -2221,7 +2223,7 @@ static void gte_run_command(PSXRecomp::GTE::GTEState* gte, uint32_t cmd) { } } -extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { +static void gte_execute_impl(CPUState* cpu, uint32_t cmd, bool charge_latency) { using namespace PSXRecomp::GTE; uint32_t original_transform[8]; const bool observe_projection = g_psx_projection_command && @@ -2337,10 +2339,18 @@ extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { * (serializing back-to-back ops). Any later COP2 register access stalls to * it. Single shared site for BOTH backends (compiled + dirty interp both * route GTE commands through gte_execute). */ - psx_gte_set(cpu, psx_gte_cmd_latency(cmd)); + if (charge_latency) psx_gte_set(cpu, psx_gte_cmd_latency(cmd)); #endif } +extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { + gte_execute_impl(cpu, cmd, true); +} + +extern "C" void psx_hle_gte_execute(CPUState* cpu, uint32_t cmd) { + gte_execute_impl(cpu, cmd, !PSX_EXECUTION_ENHANCED); +} + #ifdef PSX_GTE_REGISTER_TEST /* Preserved pre-optimization bridge: test oracle for full command marshaling. */ extern "C" void gte_test_execute_reference(CPUState* cpu, uint32_t cmd) { diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 77907729..aea03933 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -6,6 +6,7 @@ */ #include "cpu_state.h" +#include "execution_profile.h" #include "projection_scale.hpp" #include "projection_scale_config.hpp" #include "mod_controller_source.h" @@ -14218,6 +14219,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"); diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index f72c7e7d..fe689868 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -35,6 +35,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" @@ -3748,7 +3749,9 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) } } - if (!np_content_init(&s_content, cfg->content_fingerprint, rcfg.occupied_mask, local)) + char execution_content[65]; + if (!psx_execution_content_identity(cfg->content_fingerprint, execution_content) || + !np_content_init(&s_content, execution_content, rcfg.occupied_mask, local)) return -5; s_content_send_ms = 0; /* Host resolves auto (-1) before start; accept 0..PSX_MAX_PLAYERS-1. */ diff --git a/runtime/tests/test_boot_state_handoff.c b/runtime/tests/test_boot_state_handoff.c index 74040d65..e128af96 100644 --- a/runtime/tests/test_boot_state_handoff.c +++ b/runtime/tests/test_boot_state_handoff.c @@ -68,6 +68,11 @@ int main(void) { /* Malformed, missing and duplicate phase sections must reject before * changing CPU, RAM or the previously active handoff latch. */ cpu.pc=0xdeadbeef; ram[0x100]=0x55; fntrace_restore_game_started(!started); + /* 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..accb85e6 --- /dev/null +++ b/runtime/tests/test_execution_profile.py @@ -0,0 +1,125 @@ +"""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); +int main(int argc, char** argv) { + 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 / "lle.c").write_text("int operation(int x) { int s=0; while(x--) s+=3; 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") +add_executable(fixture main.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 "{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) +''') + + 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"] + # Exercise the real wire gate, including sessions without mods. + fixture = enhanced_build / ("fixture.exe" if (enhanced_build / "fixture.exe").exists() else "fixture") + def session_id(manifest): + return hashlib.sha256(("psx-session-execution-v1:" + manifest["identity"]).encode()).hexdigest() + run([str(fixture), session_id(enhanced), "1"]) + run([str(fixture), session_id(reference), "0"]) + configure("AUTO", ok=False) + # Relocating the entire source tree must not change portable identities. + import shutil + relocated = Path(temp) / "relocated" + shutil.copytree(source, relocated) + saved = source + source = relocated + assert build_run(configure("ENHANCED", "relocated-build"))["identity"] == enhanced["identity"] + source = saved + # A changed selected implementation or contract changes the identity. + (source / "hle.c").write_text("int operation(int x) { return x + x + x; }\n") + changed = build_run(enhanced_build) # automatic CMake reconfigure + assert changed["identity"] != enhanced["identity"] + assert build_run(configure("REFERENCE"))["identity"] == reference["identity"] + (source / "contract.h").write_text("/* caller contract revision 2 */\n") + changed_contract = build_run(enhanced_build) + assert changed_contract["identity"] != changed["identity"] + (source / "shared.h").write_text("/* shared service revision 2 */\n") + assert build_run(enhanced_build)["identity"] != changed_contract["identity"] + # Private HLE input is not required to build the maintained reference. + (source / "hle.c").unlink() + build_run(configure("REFERENCE", "reference-no-private")) + configure("ENHANCED", "missing-selected", ok=False) + print("execution profile link, ABI, identity and failure checks passed") + + +if __name__ == "__main__": + main() 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; From 4771a9c2362b18d213a22f289b5402437508bb48 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 18:31:05 -0700 Subject: [PATCH 02/64] Repair renderer fixture symbols and snapshot version guards --- runtime/tests/test_gl_scale_invariance.c | 2 ++ runtime/tests/test_gpu_gp0_history_guard.py | 12 ++++++++++++ runtime/tests/test_savestate_status_protocol.py | 9 +++++---- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index e0a13a97..9e7c2ce2 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -76,6 +76,8 @@ int gpu_display_is_depth24(void){return 0;} void gpu_get_display_info(GpuDisplayInfo *out){memset(out,0,sizeof(*out));out->width=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_gpu_gp0_history_guard.py b/runtime/tests/test_gpu_gp0_history_guard.py index dd77a53c..cf6132a9 100644 --- a/runtime/tests/test_gpu_gp0_history_guard.py +++ b/runtime/tests/test_gpu_gp0_history_guard.py @@ -191,6 +191,7 @@ int main(int argc, char **argv) { STUBS_C = r""" #include "gpu.h" #include "gpu_render.h" +#include "gpu_gl_renderer.h" #include "cpu_state.h" #include "pgxp.h" #include "color_lut.h" @@ -212,6 +213,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]) @@ -250,7 +256,13 @@ void ws_ui_group_assign(WsUiGroupItem *items, size_t count, int32_t ws_ui_anchor_for_bounds(int32_t x, int32_t width, int32_t display_width) { (void)x; (void)width; return display_width / 2; } int gte_geometry_correction_enabled(void) { return 0; } +int gte_nclip_native_wide_sign(int32_t mac0, int *sign) { (void)mac0; (void)sign; return 0; } +int gte_nclip_native_wide_previous_sign(int32_t mac0, int *sign) { (void)mac0; (void)sign; return 0; } 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_savestate_status_protocol.py b/runtime/tests/test_savestate_status_protocol.py index c36474cb..fbfc1417 100644 --- a/runtime/tests/test_savestate_status_protocol.py +++ b/runtime/tests/test_savestate_status_protocol.py @@ -14,11 +14,12 @@ DMA = (ROOT / "src/dma.c").read_text(encoding="utf-8") BOOT_STATE = (ROOT / "src/boot_state.c").read_text(encoding="utf-8") # The per-word DMA2 cursor and XA DATA_END pending bit grow the snapshot wire. -# Keep the v7 minimum: v8 adds enhancement memory, while vanilla writers still -# emit v7. Older files must be rejected before any live state is applied. +# Keep the v7 read minimum; all current writers emit v9, which requires the +# BIOS-to-game handoff latch. Older-than-v7 files reject before changing state. assert "#define DMA_GPU_LL_WIRE (4u + (10u * 4u))" in DMA -assert "#define BOOT_STATE_VERSION 8u" in BOOT_STATE_H -assert "psx_mod_memory_snapshot_bytes() ? BOOT_STATE_VERSION : 7u" in BOOT_STATE +assert "#define BOOT_STATE_VERSION 9u" in BOOT_STATE_H +assert "h.version = BOOT_STATE_VERSION;" in BOOT_STATE +assert "h.version >= 9u && !(seen & (1u << BS_SEC_GAME_START))" in BOOT_STATE assert "#define BOOT_STATE_VERSION_MIN_READ 7u" in BOOT_STATE_H assert "Reject\n * them at the header before any section changes the live machine." in BOOT_STATE_H From 711c1d933b7970875597f2ce3039e65a58febfd7 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 18:31:06 -0700 Subject: [PATCH 03/64] Track enhancement parity and capture verified native replay evidence --- docs/PARITY_CAMPAIGN.md | 110 +++++++++++++++++++++++++++++++++ tools/qualify_render_passes.py | 86 ++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 docs/PARITY_CAMPAIGN.md create mode 100644 tools/qualify_render_passes.py diff --git a/docs/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md new file mode 100644 index 00000000..5bff7a0f --- /dev/null +++ b/docs/PARITY_CAMPAIGN.md @@ -0,0 +1,110 @@ +# 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. + +## Acceptance target + +- Enhanced OpenGL is the normal renderer, with 1080p internal resolution. +- Native scene interpolation targets Display refresh. Temporal image blending + alone does not meet this requirement. HUD, FMV, menus, pauses and transitions + need appropriate presentation behavior without advancing gameplay twice. +- 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. + +## 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 | active fidelity branch `4bd8b4d`; native replay/wide work, PGXP, 1080p, stable filtering | Gameplay/32:9 qualification, residency/load throughput and packages; 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. +Full runtime linking and title 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 owner authorized one Sol worker per title in bounded waves. First wave: +Ape Escape, Spider-Man 2, and Vigilante 8. Shared changes are owned by the +coordinator. At most two compile jobs and one 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. diff --git a/tools/qualify_render_passes.py b/tools/qualify_render_passes.py new file mode 100644 index 00000000..a27ce0ff --- /dev/null +++ b/tools/qualify_render_passes.py @@ -0,0 +1,86 @@ +#!/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. +Run with PSX_RENDER_PASS_VERIFY=1 to check that replay restores guest state. +""" +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): + 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 + for key in ("passes", "pass_presents", "verify_checks"): + 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("--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, 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"]) + 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()) From b0bb115dd0b7fcfda42cb17ae5fa32a482b28ab9 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 18:36:52 -0700 Subject: [PATCH 04/64] Honor HOLD when presenting native replay generations --- docs/PARITY_CAMPAIGN.md | 14 ++++++++++++++ runtime/include/render_pass_plan.h | 3 +++ runtime/src/gpu_gl_renderer.c | 4 ++-- runtime/src/render_pass_plan.c | 7 +++++++ runtime/tests/test_gl_texture_filter.c | 24 ++++++++++++++++++++++++ runtime/tests/test_render_pass_guards.py | 4 ++-- runtime/tests/test_render_pass_plan.c | 14 ++++++++++++++ tools/qualify_render_passes.py | 16 +++++++++++----- 8 files changed, 77 insertions(+), 9 deletions(-) diff --git a/docs/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md index 5bff7a0f..ec56fe48 100644 --- a/docs/PARITY_CAMPAIGN.md +++ b/docs/PARITY_CAMPAIGN.md @@ -108,3 +108,17 @@ 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 (329 checks) 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. diff --git a/runtime/include/render_pass_plan.h b/runtime/include/render_pass_plan.h index 8d2d4c98..fea817a2 100644 --- a/runtime/include/render_pass_plan.h +++ b/runtime/include/render_pass_plan.h @@ -64,6 +64,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/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 09fca3e0..6f6fa2b5 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -7052,13 +7052,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++; diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c index e235f832..40fd6b14 100644 --- a/runtime/src/render_pass_plan.c +++ b/runtime/src/render_pass_plan.c @@ -127,6 +127,13 @@ int render_pass_gen_select(const uint32_t *phases, uint32_t n, double p, return render_pass_select(phases, n, p, lo, hi, t); } +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) { + if (!render_pass_gen_select(phases, n, p, lo, hi, t)) return 0; + if (hold) { *hi = *lo; *t = 0.0f; } + return 1; +} + double render_pass_ema(double current, double sample) { if (!(sample >= 0.0) || !isfinite(sample)) return current; if (!(current > 0.0)) return sample; diff --git a/runtime/tests/test_gl_texture_filter.c b/runtime/tests/test_gl_texture_filter.c index b0aa8ea5..9906e99a 100644 --- a/runtime/tests/test_gl_texture_filter.c +++ b/runtime/tests/test_gl_texture_filter.c @@ -73,6 +73,29 @@ 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); +} int main(void) { if(SDL_Init(SDL_INIT_VIDEO)!=0) return 2; SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION,3); @@ -82,6 +105,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(); diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index d8285fa5..25464ec9 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -147,8 +147,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_plan.c b/runtime/tests/test_render_pass_plan.c index 1c1edaf8..22ac3d86 100644 --- a/runtime/tests/test_render_pass_plan.c +++ b/runtime/tests/test_render_pass_plan.c @@ -327,6 +327,20 @@ static void test_stereo_pair_fresh(void) { } int main(void) { + { + 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/tools/qualify_render_passes.py b/tools/qualify_render_passes.py index a27ce0ff..805b5314 100644 --- a/tools/qualify_render_passes.py +++ b/tools/qualify_render_passes.py @@ -3,7 +3,9 @@ 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. -Run with PSX_RENDER_PASS_VERIFY=1 to check that replay restores guest state. +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 @@ -19,7 +21,7 @@ COUNTERS = ("passes", "pass_presents", "verify_checks", "verify_mismatch", "aborted", "blended_presents") -def assess(before, after): +def assess(before, after, purpose="correctness"): reasons, delta = [], {} for key in COUNTERS: a, b = before.get(key), after.get(key) @@ -27,7 +29,8 @@ def assess(before, after): reasons.append("missing, invalid or reset counter: " + key) else: delta[key] = b - a - for key in ("passes", "pass_presents", "verify_checks"): + 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", @@ -44,6 +47,7 @@ def main(): 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() @@ -57,7 +61,7 @@ def main(): 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, sample_passed=False) + port=args.port, purpose=args.purpose, sample_passed=False) def query(): with connect(port=args.port) as sock: @@ -72,7 +76,9 @@ def main(): time.sleep(args.seconds) receipt["after"] = query() receipt["elapsed_seconds"] = time.monotonic() - start - receipt["delta"], receipt["reasons"] = assess(receipt["before"], receipt["after"]) + 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)] From d8c5e9d0a5e589dc3898362ad3361b7ded5ece1a Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 18:52:15 -0700 Subject: [PATCH 05/64] Bind staged execution contracts to the packaged binary --- docs/HLE_EXECUTION.md | 12 +++++ runtime/tests/test_release_stage_cache.py | 37 +++++++++++++ tools/release_stage.py | 65 +++++++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/docs/HLE_EXECUTION.md b/docs/HLE_EXECUTION.md index 0cc04589..4e46b470 100644 --- a/docs/HLE_EXECUTION.md +++ b/docs/HLE_EXECUTION.md @@ -23,6 +23,18 @@ 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 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/tools/release_stage.py b/tools/release_stage.py index b2906993..990b8978 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 @@ -966,6 +967,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 +1033,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, From 7c9e13ec55b3c2bf948726ceaeb6d74d89a04f39 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 19:09:30 -0700 Subject: [PATCH 06/64] Batch mixed texture filters as per-primitive sampling state --- runtime/src/gpu_gl_renderer.c | 41 ++++++++------- runtime/tests/test_gl_texture_filter.c | 72 ++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 19 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 6f6fa2b5..21e7d48f 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -567,7 +567,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 21 +#define TEXV 22 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; @@ -593,7 +593,7 @@ static float s_pq[3]; static GLint s_uVram = -1, s_uTpage = -1, s_uClut = -1, s_uDepth = -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 @@ -1311,7 +1311,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" @@ -1326,6 +1327,7 @@ static const char *TEX_VS = "layout(location=8) in float a_semi;\n" "layout(location=9) in float a_q; /* persp weight; 0 = affine (default) */\n" "layout(location=10) in float a_twin; /* GP0(E2h) bits 0..19 */\n" + "layout(location=11) 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" @@ -1336,13 +1338,14 @@ static const char *TEX_VS = "flat out int v_persp;\n" "flat out ivec2 v_tpage; flat out ivec2 v_clut; flat out int v_depth;\n" "flat out int v_raw; flat out ivec4 v_limits; flat out int v_semi;\n" - "flat out int v_twin;\n" + "flat out int v_twin; flat out int v_filter;\n" "void main(){ v_uv = a_uv; v_uv_p = a_uv; v_col = a_col;\n" " v_persp = (a_q > 0.0) ? 1 : 0;\n" " v_tpage = ivec2(a_tpage + 0.5); v_clut = ivec2(a_clut + 0.5);\n" " 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_limits = ivec4(floor(a_limits + 0.5));\n" " /* u_shift: align GL's center-sample grid with the PS1 integer grid (see\n" " * GEO_VS) so interpolated uv at a fragment equals the PS1 DDA value. */\n" @@ -1371,12 +1374,12 @@ 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" "uniform usampler2D u_vram;\n" "uniform usampler2D u_palette;\n" "uniform int u_semipass; /* 0=all texels, 1=STP=0 only, 2=STP=1 only */\n" "uniform int u_semimode; /* PS1 blend mode; drives dual-source factors */\n" "uniform int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" - "uniform int u_filter; /* 0 nearest, 1 bilinear, 2 stable */\n" "uniform float u_shift;\n" "int vram_at(int x, int y){\n" " ivec2 p = ivec2(x & 1023, y & 511);\n" @@ -1434,12 +1437,12 @@ static const char *TEX_FS = " * default is the PS1's affine (noperspective) mapping. */\n" " vec2 uv = (v_persp != 0) ? v_uv_p : v_uv;\n" " vec2 dx=dFdx(uv), dy=dFdy(uv);\n" - " if (u_filter == 0) {\n" + " if (v_filter == 0) {\n" " int raw = fetch_texel(int(floor(uv.x)), int(floor(uv.y)));\n" " if (raw == 0) discard;\n" " rgb = col5(raw);\n" " stp = (raw >> 15) & 1;\n" - " } else if (u_filter == 2) {\n" + " } else if (v_filter == 2) {\n" " uv+=vec2(u_shift); int raw=stable_texel(ivec2(floor(uv)));\n" " if(raw==0) discard; stp=(raw>>15)&1;\n" " float lx=length(dx), ly=length(dy);\n" @@ -2559,10 +2562,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, @@ -2727,7 +2730,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) */ int ax0, ay0, ax1, ay1; /* draw area, inclusive */ size_t vfirst; /* float offset into s_hq_v */ @@ -2799,7 +2801,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; if (wq) hiw_wide_set(c, gate); return wq; @@ -2859,7 +2860,6 @@ static void hiw_replay_wide(void) { p_glUniform1f(s_tex_uXcenter, c->wcenter); glBindTexture(GL_TEXTURE_2D, c->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); tex_draw_passes_ex(c->vcount, c->semi, c->mask, c->check, 0); @@ -2935,7 +2935,6 @@ static void hiw_flush_queue(void) { } glBindTexture(GL_TEXTURE_2D, c->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); tex_draw_passes_ex(c->vcount, c->semi, c->mask, c->check, 0); @@ -2986,7 +2985,6 @@ static void flush_tex_batch(void) { p_glUniform1i(s_uPalette, 1); p_glActiveTexture(PSXGL_TEXTURE0); 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); @@ -3388,8 +3386,9 @@ static void gpu_textured_triangle(const int *xs, const int *ys, flush_pack_if_sampling(-1, 0, depth, clut_x, clut_y); 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). */ { @@ -3430,7 +3429,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] || @@ -3464,6 +3466,7 @@ static void gpu_textured_triangle(const int *xs, const int *ys, vp[18] = semi >= 0 ? (float)(semi + 1) : 0.0f; /* a_semi code */ vp[19] = s_pq_valid ? s_pq[i] : 0.0f; /* a_q; 0 = affine */ vp[20] = twin; /* a_twin */ + vp[21] = (float)filter; /* a_filter */ } s_tb_n += 3; if (isolate) flush_tex_batch(); /* draw this semi prim alone, in submission order */ @@ -4444,7 +4447,6 @@ static int init_gpu_raster(void) { s_uSemipass = p_glGetUniformLocation(s_tex_prog, "u_semipass"); s_uSemimode = p_glGetUniformLocation(s_tex_prog, "u_semimode"); s_uMaskset = p_glGetUniformLocation(s_tex_prog, "u_maskset"); - s_uFilter = p_glGetUniformLocation(s_tex_prog, "u_filter"); s_uLimits = p_glGetUniformLocation(s_tex_prog, "u_limits"); s_uBlitSrc = p_glGetUniformLocation(s_blit_prog, "u_src"); s_uBlitPass = p_glGetUniformLocation(s_blit_prog, "u_stp_pass"); @@ -4536,6 +4538,7 @@ static int init_gpu_raster(void) { p_glVertexAttribPointer(8, 1, GL_FLOAT, GL_FALSE, st, (void*)(18*sizeof(float))); p_glEnableVertexAttribArray(8); /* semi */ p_glVertexAttribPointer(9, 1, GL_FLOAT, GL_FALSE, st, (void*)(19*sizeof(float))); p_glEnableVertexAttribArray(9); /* q */ p_glVertexAttribPointer(10, 1, GL_FLOAT, GL_FALSE, st, (void*)(20*sizeof(float))); p_glEnableVertexAttribArray(10); /* twin */ + p_glVertexAttribPointer(11, 1, GL_FLOAT, GL_FALSE, st, (void*)(21*sizeof(float))); p_glEnableVertexAttribArray(11); /* filter */ } p_glGenVertexArrays(1, &s_blit_vao); diff --git a/runtime/tests/test_gl_texture_filter.c b/runtime/tests/test_gl_texture_filter.c index 9906e99a..d3880833 100644 --- a/runtime/tests/test_gl_texture_filter.c +++ b/runtime/tests/test_gl_texture_filter.c @@ -96,6 +96,77 @@ static void native_hold_images(void) { } 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); @@ -145,6 +216,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(); From 8fe08921a8879a4fdd47624bfc7720579f204bb6 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 19:18:33 -0700 Subject: [PATCH 07/64] Support generated reference shards in execution profile builds --- cmake/psx_execution_profile.cmake | 31 ++++++++++++++++++++++++- docs/HLE_EXECUTION.md | 3 +++ runtime/tests/test_execution_profile.py | 13 ++++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/cmake/psx_execution_profile.cmake b/cmake/psx_execution_profile.cmake index 39fdf37d..f3031131 100644 --- a/cmake/psx_execution_profile.cmake +++ b/cmake/psx_execution_profile.cmake @@ -134,7 +134,36 @@ function(psxrecomp_add_implementation target) endif() list(APPEND _inputs "${_absolute}") endforeach() - target_sources(${target} PRIVATE ${_selected}) + set(_compile_sources "") + foreach(_source IN LISTS _selected) + get_filename_component(_absolute "${_source}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_SOURCE_DIR}") + get_source_file_property(_generated "${_absolute}" GENERATED) + if(_generated) + # runtime.cmake marks game shards GENERATED, though they are + # materialized before configure. Giving Ninja that file both as + # a compiled generated source and a configure dependency creates + # two output rules (an empty generation rule and a CMake phony). + # Compile an include wrapper instead. The original remains the + # sole maintained body and configure/hash input; #include keeps + # its relative includes and __FILE__ behavior intact. + 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() + list(APPEND _compile_sources "${_wrapper}") + else() + list(APPEND _compile_sources "${_absolute}") + endif() + 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}") diff --git a/docs/HLE_EXECUTION.md b/docs/HLE_EXECUTION.md index 4e46b470..77f1495f 100644 --- a/docs/HLE_EXECUTION.md +++ b/docs/HLE_EXECUTION.md @@ -56,6 +56,9 @@ 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 diff --git a/runtime/tests/test_execution_profile.py b/runtime/tests/test_execution_profile.py index accb85e6..a5b9cec5 100644 --- a/runtime/tests/test_execution_profile.py +++ b/runtime/tests/test_execution_profile.py @@ -49,12 +49,17 @@ int main(int argc, char** argv) { } ''') (source / "hle.c").write_text("int operation(int x) { return x * 3; }\n") - (source / "lle.c").write_text("int operation(int x) { int s=0; while(x--) s+=3; return s; }\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) add_executable(fixture main.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 "{root.as_posix()}/runtime/src/psx_sha256.c") @@ -89,6 +94,12 @@ psxrecomp_add_implementation(fixture NAME triple CONTRACT integer-v1 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;i Date: Tue, 6 Oct 2026 19:19:09 -0700 Subject: [PATCH 08/64] Abort render replay before uncheckpointed device reads --- docs/RENDER_PASSES.md | 7 +++- runtime/include/render_pass.h | 7 ++++ runtime/src/debug_server.c | 2 ++ runtime/src/memory.c | 12 +++++-- runtime/src/render_pass.c | 29 +++++++++++++-- runtime/tests/psx_ram_runtime_map_stubs.c | 1 + runtime/tests/test_render_pass_abort.c | 44 +++++++++++++++++++++++ runtime/tests/test_render_pass_guards.py | 8 +++++ 8 files changed, 104 insertions(+), 6 deletions(-) diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index edc48fea..b9a0f5a5 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -212,6 +212,7 @@ While `fn` runs (`g_psx_render_pass_active`): | 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` | | 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` | @@ -228,7 +229,11 @@ 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. +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. ## Gates diff --git a/runtime/include/render_pass.h b/runtime/include/render_pass.h index 6ac3a5d2..c0177519 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; @@ -47,6 +52,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/src/debug_server.c b/runtime/src/debug_server.c index 6f64b711..d753c1b4 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8448,6 +8448,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}," @@ -8474,6 +8475,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], diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 9a8307fc..98189ebf 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1266,7 +1266,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) { @@ -1301,6 +1304,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); @@ -1392,7 +1396,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); } @@ -1423,6 +1427,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); @@ -1508,7 +1513,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))); } @@ -1542,6 +1547,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); diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 8424792c..b92612fe 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -250,6 +250,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; @@ -313,6 +314,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); @@ -586,6 +588,26 @@ 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; + 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) { @@ -595,7 +617,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 " @@ -684,6 +706,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; @@ -704,7 +727,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 @@ -723,6 +746,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; @@ -772,6 +796,7 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass 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 < 0) gl_renderer_pass_service_presents(); diff --git a/runtime/tests/psx_ram_runtime_map_stubs.c b/runtime/tests/psx_ram_runtime_map_stubs.c index edef5b7d..57958a02 100644 --- a/runtime/tests/psx_ram_runtime_map_stubs.c +++ b/runtime/tests/psx_ram_runtime_map_stubs.c @@ -113,6 +113,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/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index fe416b9b..f98c4638 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -311,6 +311,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); @@ -551,6 +566,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 Date: Tue, 6 Oct 2026 19:21:43 -0700 Subject: [PATCH 09/64] Preserve exact packet precision across guarded draw replay --- docs/RENDER_PASSES.md | 9 +++ runtime/CMakeLists.txt | 5 ++ runtime/include/pgxp.h | 22 ++++++ runtime/src/pgxp.cpp | 61 +++++++++++++++ runtime/tests/test_pgxp_word_shadow.cpp | 99 +++++++++++++++++++++++++ 5 files changed, 196 insertions(+) create mode 100644 runtime/tests/test_pgxp_word_shadow.cpp diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index b9a0f5a5..fdd35c07 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -229,6 +229,15 @@ 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. +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 diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 2fdcdcef..5490b54e 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -490,6 +490,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 diff --git a/runtime/include/pgxp.h b/runtime/include/pgxp.h index 755b9eb4..3c7ce24c 100644 --- a/runtime/include/pgxp.h +++ b/runtime/include/pgxp.h @@ -292,6 +292,28 @@ int pgxp_load_precise_word(uint32_t addr, uint32_t packed, void pgxp_checkpoint_begin(void); void pgxp_checkpoint_rollback(void); +/* Exact address-keyed packet precision, for a renderer preserving completed + * static packets around a smaller interpolated drawing section. `expected` + * MUST be the actual destination RAM/scratchpad word, read by the caller + * immediately before either operation (the same convention as the lookups + * above). Neither operation reads MMIO or changes guest memory. + * + * Capture also succeeds for an absent/stale/value-mismatched shadow, recording + * a valid no-shadow state. Restore is allowed only in an active checkpoint, + * only at the captured canonical address, and only when the capture generation + * equals that checkpoint's ENTRY generation. It may then rebind live metadata + * to the sandbox's current generation. This never promotes an older timeline's + * stale metadata. All restores, including clearing a shadow, are journaled. + * Receipts are runtime-only host data: do not persist them in game saves. */ +typedef struct PGXPWordShadow { + uint32_t address, value, source_generation, valid; + int32_t x16, y16; + uint16_t z, flags; + PGXPProjection projection; +} PGXPWordShadow; +int pgxp_capture_word_shadow(uint32_t addr, uint32_t expected, PGXPWordShadow *out); +int pgxp_restore_word_shadow(uint32_t addr, uint32_t expected, const PGXPWordShadow *in); + /* Refused precise-word lookups, newest at (seq - 1) % cap (TCP pgxp_miss_ring). */ enum { PGXP_MISS_UNTRACKED = 1, diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp index 784d9e55..a279fcf0 100644 --- a/runtime/src/pgxp.cpp +++ b/runtime/src/pgxp.cpp @@ -396,6 +396,67 @@ static inline PGXPValue *pgxp_ptr_w(uint32_t 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; + } + 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; +} + +extern "C" int pgxp_restore_word_shadow(uint32_t addr, uint32_t expected, + const PGXPWordShadow *in) { + uint32_t canonical; + if (!in || !s_ck_depth || s_ck_lossy || !packet_shadow_address(addr, &canonical) || + 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); + 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" void pgxp_checkpoint_begin(void) { if (s_ck_depth++ != 0) return; /* the outermost pass journals */ if (!s_ck_bits) { diff --git a/runtime/tests/test_pgxp_word_shadow.cpp b/runtime/tests/test_pgxp_word_shadow.cpp new file mode 100644 index 00000000..7fb9ae05 --- /dev/null +++ b/runtime/tests/test_pgxp_word_shadow.cpp @@ -0,0 +1,99 @@ +#include "pgxp.h" +#include "pgxp_hooks.h" +#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 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 + 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); + + // 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)); + 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); + + // 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_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)); + pgxp_checkpoint_rollback(); + std::printf("PGXP packet shadow checks: %s\n",failures?"FAIL":"PASS"); + return failures?1:0; +} From b389f9edf0a1649926910b9cd47442e702ce9cb5 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 19:29:46 -0700 Subject: [PATCH 10/64] Record first-wave parity evidence and remaining qualification --- docs/PARITY_CAMPAIGN.md | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md index ec56fe48..9ea5aa69 100644 --- a/docs/PARITY_CAMPAIGN.md +++ b/docs/PARITY_CAMPAIGN.md @@ -41,7 +41,7 @@ not full-game qualification. Framework baseline is `7b253941` (CODEGEN 18). | 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 | active fidelity branch `4bd8b4d`; native replay/wide work, PGXP, 1080p, stable filtering | Gameplay/32:9 qualification, residency/load throughput and packages; donor content excluded | +| 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 | @@ -76,7 +76,8 @@ 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. -Full runtime linking and title performance comparisons are still required. +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 @@ -119,6 +120,40 @@ 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 (329 checks) pass, including red/blue native images that must stay distinct +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 checkpoint is `04f871f0`. It also includes: + +- `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. From 1d201aa39ff874ef59359199637154b3451bcaa3 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 20:00:55 -0700 Subject: [PATCH 11/64] Accelerate exact startup file hashing with native host crypto --- cmake/psx_sha256_file.cmake | 19 ++++ docs/PARITY_CAMPAIGN.md | 27 ++++++ runtime/CMakeLists.txt | 24 ++++- runtime/include/psx_sha256_file.h | 12 +++ runtime/runtime.cmake | 2 + runtime/src/mod_runtime.cpp | 20 ++-- runtime/src/psx_sha256_file.cpp | 120 ++++++++++++++++++++++++ runtime/tests/test_psx_sha256_file.cpp | 125 +++++++++++++++++++++++++ 8 files changed, 334 insertions(+), 15 deletions(-) create mode 100644 cmake/psx_sha256_file.cmake create mode 100644 runtime/include/psx_sha256_file.h create mode 100644 runtime/src/psx_sha256_file.cpp create mode 100644 runtime/tests/test_psx_sha256_file.cpp 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/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md index 9ea5aa69..6fc50dfe 100644 --- a/docs/PARITY_CAMPAIGN.md +++ b/docs/PARITY_CAMPAIGN.md @@ -157,3 +157,30 @@ 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. +Use a fixed-work renderer probe before drawing a performance conclusion. + +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. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 5490b54e..f1e8b3de 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -23,6 +23,24 @@ psxrecomp_add_runtime_target(psx-runtime # (docs/ENHANCEMENTS.md G1.10). Titles get their _pgxp the same way. if(BUILD_TESTING) + # 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() add_executable(disasm_test tests/test_disasm.cpp src/disasm_shim.cpp ../recompiler/src/mips_decoder.cpp) target_include_directories(disasm_test PRIVATE include ../recompiler/include) @@ -522,6 +540,7 @@ 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) + psxrecomp_sha256_file(pgxp_session_test) if(WIN32) if(MSVC) target_link_options(pgxp_session_test PRIVATE /STACK:16777216) @@ -862,8 +881,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) @@ -889,6 +908,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) diff --git a/runtime/include/psx_sha256_file.h b/runtime/include/psx_sha256_file.h new file mode 100644 index 00000000..f31c5f06 --- /dev/null +++ b/runtime/include/psx_sha256_file.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#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/runtime.cmake b/runtime/runtime.cmake index 3740e83b..f06e1f7e 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -16,6 +16,7 @@ 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_dependency_archive.cmake") include("${PSXRECOMP_ROOT}/runtime/chd_dependency.cmake") @@ -1571,6 +1572,7 @@ function(psxrecomp_add_runtime_target target) ${PSXRT_EXTRAS_SOURCES} ) target_link_libraries(${target} PRIVATE chdr-static) + psxrecomp_sha256_file(${target}) set(_execution_contract "${PSXRECOMP_ROOT}/runtime/src/gte.cpp" "${PSXRECOMP_ROOT}/runtime/src/pgxp.cpp" diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 3e4fa78d..ecefdffa 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -9,6 +9,7 @@ #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" @@ -351,12 +352,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 = @@ -383,24 +384,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; 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/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"; +} From 37b11c0032414421bff80a6803b0bcc96aa3bdc9 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 20:04:49 -0700 Subject: [PATCH 12/64] Use bounded native Git lookup for Windows build metadata --- cmake/psx_build_revision.cmake | 26 ++++++++++++++++++++++++++ runtime/runtime.cmake | 8 ++------ 2 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 cmake/psx_build_revision.cmake 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/runtime/runtime.cmake b/runtime/runtime.cmake index f06e1f7e..3a886192 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -17,6 +17,7 @@ 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") @@ -1909,12 +1910,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". From 30d56d6e2b812404b6a13a88a559669d74d14d04 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 20:16:43 -0700 Subject: [PATCH 13/64] Bind packaged execution profiles and share Linux AppImage staging --- docs/ci/BUNDLED_RELEASES.md | 48 ++++++- runtime/CMakeLists.txt | 15 +++ runtime/tests/test_package_appimage.py | 85 +++++++++++++ runtime/tests/test_package_execution_stage.py | 77 ++++++++++++ tools/package_appimage.sh | 119 ++++++++++++++++++ tools/package_game_release.sh | 18 ++- 6 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 runtime/tests/test_package_appimage.py create mode 100644 runtime/tests/test_package_execution_stage.py create mode 100755 tools/package_appimage.sh 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/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index f1e8b3de..2587b785 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1384,6 +1384,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/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/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}" From 0326b2bdf70da5f5c78d20ae6785388315de6373 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 20:16:45 -0700 Subject: [PATCH 14/64] Separate GL probe compilation from performance execution --- runtime/tests/run_gl_texture_filter.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/runtime/tests/run_gl_texture_filter.py b/runtime/tests/run_gl_texture_filter.py index c7c6605a..61b25663 100644 --- a/runtime/tests/run_gl_texture_filter.py +++ b/runtime/tests/run_gl_texture_filter.py @@ -13,6 +13,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') args=parser.parse_args() fw=Path(__file__).resolve().parents[2] out=Path(args.output).resolve(); out.mkdir(parents=True,exist_ok=True) @@ -24,7 +27,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] @@ -48,7 +51,7 @@ def main(): else: libraries+=['-lGL','-ldl','-lpthread'] if platform.system()!='Darwin': libraries+=['-Wl,--gc-sections'] run([args.cc,*objects,args.sdl_library,'-o',exe,*libraries]) - run([exe]) + if not args.build_only: run([exe]) return 0 if __name__=='__main__': From 8f95b599997399a476062e99634ddbb67e5c8731 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 20:37:31 -0700 Subject: [PATCH 15/64] Support checkpointed relocation of packet precision metadata --- docs/PARITY_CAMPAIGN.md | 60 ++++++++++++++++++++++++- runtime/include/pgxp.h | 10 +++++ runtime/src/pgxp.cpp | 21 +++++++-- runtime/tests/test_pgxp_word_shadow.cpp | 47 +++++++++++++++++++ 4 files changed, 132 insertions(+), 6 deletions(-) diff --git a/docs/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md index 6fc50dfe..c2880426 100644 --- a/docs/PARITY_CAMPAIGN.md +++ b/docs/PARITY_CAMPAIGN.md @@ -133,7 +133,7 @@ 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 checkpoint is `04f871f0`. It also includes: +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. @@ -164,7 +164,41 @@ 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. -Use a fixed-work renderer probe before drawing a performance conclusion. +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 @@ -184,3 +218,25 @@ 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. + +## Title source milestones awaiting execution + +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/runtime/include/pgxp.h b/runtime/include/pgxp.h index 3c7ce24c..cedac6da 100644 --- a/runtime/include/pgxp.h +++ b/runtime/include/pgxp.h @@ -314,6 +314,16 @@ typedef struct PGXPWordShadow { int pgxp_capture_word_shadow(uint32_t addr, uint32_t expected, PGXPWordShadow *out); int pgxp_restore_word_shadow(uint32_t addr, uint32_t expected, const PGXPWordShadow *in); +/* Explicit packet relocation variant. The caller owns and validates both + * packet ranges and their relocation map, writes the destination guest word, + * then supplies that word's current value as expected. The captured source + * must still name canonical RAM/scratchpad, but need not equal the destination. + * All other restore rules above apply, including checkpoint ENTRY generation. + * Only destination metadata changes; an absent receipt clears its shadow. + * Rollback restores the destination's previous metadata. */ +int pgxp_restore_relocated_word_shadow(uint32_t destination, uint32_t expected, + const PGXPWordShadow *in); + /* Refused precise-word lookups, newest at (seq - 1) % cap (TCP pgxp_miss_ring). */ enum { PGXP_MISS_UNTRACKED = 1, diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp index a279fcf0..a160e913 100644 --- a/runtime/src/pgxp.cpp +++ b/runtime/src/pgxp.cpp @@ -431,11 +431,13 @@ extern "C" int pgxp_capture_word_shadow(uint32_t addr, uint32_t expected, return 1; } -extern "C" int pgxp_restore_word_shadow(uint32_t addr, uint32_t expected, - const PGXPWordShadow *in) { - uint32_t canonical; +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) || - in->address != canonical || in->value != expected || in->valid > 1u || + !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); @@ -457,6 +459,17 @@ extern "C" int pgxp_restore_word_shadow(uint32_t addr, uint32_t expected, 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) { diff --git a/runtime/tests/test_pgxp_word_shadow.cpp b/runtime/tests/test_pgxp_word_shadow.cpp index 7fb9ae05..9d421b5a 100644 --- a/runtime/tests/test_pgxp_word_shadow.cpp +++ b/runtime/tests/test_pgxp_word_shadow.cpp @@ -2,6 +2,7 @@ #include "pgxp_hooks.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;} @@ -30,6 +31,7 @@ int main() { 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)); @@ -49,6 +51,35 @@ int main() { 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 @@ -66,6 +97,9 @@ int main() { 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); @@ -76,12 +110,22 @@ int main() { 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(); @@ -93,6 +137,9 @@ int main() { 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(); std::printf("PGXP packet shadow checks: %s\n",failures?"FAIL":"PASS"); return failures?1:0; From 20b0ea04bcc87642a2eac3e2059de1004fb1ee33 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 21:49:56 -0700 Subject: [PATCH 16/64] Honor explicit HUD anchors in projection rendering --- docs/PARITY_CAMPAIGN.md | 24 ++++++ runtime/include/gpu.h | 3 +- runtime/include/mod_plugins.h | 8 +- runtime/src/gpu.c | 52 ++++++++++-- .../tests/test_ws_auto_ui_untextured_exec.c | 79 +++++++++++++++++++ 5 files changed, 156 insertions(+), 10 deletions(-) diff --git a/docs/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md index c2880426..d5045bb1 100644 --- a/docs/PARITY_CAMPAIGN.md +++ b/docs/PARITY_CAMPAIGN.md @@ -4,6 +4,30 @@ 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; +its title binding and actual visual result still need review. Tracking: +`beads-eio.3.125` and `beads-uutk`. + ## Acceptance target - Enhanced OpenGL is the normal renderer, with 1080p internal resolution. diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index b596394d..43f99c2c 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -451,7 +451,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/mod_plugins.h b/runtime/include/mod_plugins.h index 228b735b..a7e0bc61 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -238,8 +238,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 diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index dbd827d0..2887699d 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -2416,7 +2416,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++; @@ -2547,9 +2547,8 @@ 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; @@ -2563,9 +2562,36 @@ static int ws_nw_explicit_hud_delta(int32_t *out_delta) { 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; } @@ -2574,6 +2600,9 @@ static int ws_nw_explicit_hud_delta(int32_t *out_delta) { * 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))); @@ -2896,6 +2925,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, 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; int32_t anchor; if (!ws_auto_ui_anchor(&anchor)) return 0; @@ -2906,6 +2936,7 @@ static int ws_auto_ui_transform_part(int32_t *vx, int n) { } static int ws_auto_ui_transform_quad(int32_t vx[4], const 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, 4); @@ -2931,7 +2962,14 @@ static int ws_auto_ui_transform_quad(int32_t vx[4], const int32_t vy[4]) { } static int ws_auto_ui_transform_rect(int32_t *x, int32_t y, int *w, int h) { - if (!x || !w || *w <= 0 || ws_nw_explicit_hud_delta(NULL) || psx_ws_prim_is_tagged()) + if (!x || !w || *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) || diff --git a/runtime/tests/test_ws_auto_ui_untextured_exec.c b/runtime/tests/test_ws_auto_ui_untextured_exec.c index 572ad517..efa5a0fe 100644 --- a/runtime/tests/test_ws_auto_ui_untextured_exec.c +++ b/runtime/tests/test_ws_auto_ui_untextured_exec.c @@ -190,11 +190,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. */ From e5e2dca85c2e7c1758c6de27870599103a1f8bfc Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 22:18:08 -0700 Subject: [PATCH 17/64] Compile generated common contract bodies through wrappers for Ninja --- cmake/psx_execution_profile.cmake | 72 ++++++++++++++++--------- runtime/tests/test_execution_profile.py | 18 +++++-- 2 files changed, 61 insertions(+), 29 deletions(-) diff --git a/cmake/psx_execution_profile.cmake b/cmake/psx_execution_profile.cmake index f3031131..402387bf 100644 --- a/cmake/psx_execution_profile.cmake +++ b/cmake/psx_execution_profile.cmake @@ -102,6 +102,31 @@ function(psxrecomp_execution_profile target) 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. @@ -134,34 +159,29 @@ function(psxrecomp_add_implementation target) 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) - get_filename_component(_absolute "${_source}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_SOURCE_DIR}") - get_source_file_property(_generated "${_absolute}" GENERATED) - if(_generated) - # runtime.cmake marks game shards GENERATED, though they are - # materialized before configure. Giving Ninja that file both as - # a compiled generated source and a configure dependency creates - # two output rules (an empty generation rule and a CMake phony). - # Compile an include wrapper instead. The original remains the - # sole maintained body and configure/hash input; #include keeps - # its relative includes and __FILE__ behavior intact. - 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() - list(APPEND _compile_sources "${_wrapper}") - else() - list(APPEND _compile_sources "${_absolute}") - endif() + _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}") diff --git a/runtime/tests/test_execution_profile.py b/runtime/tests/test_execution_profile.py index a5b9cec5..27809fd2 100644 --- a/runtime/tests/test_execution_profile.py +++ b/runtime/tests/test_execution_profile.py @@ -28,7 +28,9 @@ def main(): #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) || @@ -49,6 +51,7 @@ int main(int argc, char** argv) { } ''') (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") @@ -60,14 +63,16 @@ include("{root.as_posix()}/cmake/psx_execution_profile.cmake") # 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) -add_executable(fixture main.c "{root.as_posix()}/runtime/src/psx_sha256.c") +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 "{root.as_posix()}/runtime/src/psx_sha256.c") +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) + HLE_SOURCES hle.c LLE_SOURCES lle.c CONTRACT_FILES contract.h common.c) ''') def configure(profile, name=None, ok=True): @@ -125,6 +130,13 @@ psxrecomp_add_implementation(fixture NAME triple CONTRACT integer-v1 assert changed_contract["identity"] != changed["identity"] (source / "shared.h").write_text("/* shared service revision 2 */\n") assert build_run(enhanced_build)["identity"] != changed_contract["identity"] + # Common generated bodies are compiled in both profiles and hashed as + # caller contracts. Their edits must also reconfigure automatically. + common_before = build_run(enhanced_build) + reference_before = build_run(Path(temp) / "REFERENCE") + (source / "common.c").write_text("int common_game_body(void) { return 2 + 3; }\n") + assert build_run(enhanced_build)["identity"] != common_before["identity"] + assert build_run(Path(temp) / "REFERENCE")["identity"] != reference_before["identity"] # Private HLE input is not required to build the maintained reference. (source / "hle.c").unlink() build_run(configure("REFERENCE", "reference-no-private")) From ce4ae025c699acb163e4be610b00a043f311581a Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 23:48:08 -0700 Subject: [PATCH 18/64] Support identified scene motion in shared draw replay --- runtime/include/render_pass_replay.hpp | 35 +++++++++++++++++--------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/runtime/include/render_pass_replay.hpp b/runtime/include/render_pass_replay.hpp index fa3dff83..c707eab7 100644 --- a/runtime/include/render_pass_replay.hpp +++ b/runtime/include/render_pass_replay.hpp @@ -21,6 +21,7 @@ class PSXDrawReplay { 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,26 +31,34 @@ 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); 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) { @@ -60,14 +69,16 @@ public: // 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; From 308d7b9ca65bcdbdcc5ceee1b6a45c23ff3c2dd4 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 2 Oct 2026 18:39:56 -0700 Subject: [PATCH 19/64] Widescreen HUD size: proportional option shrinks the auto-UI HUD beyond 16:9 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/WIDESCREEN.md | 14 ++ recompiler/src/config_loader.cpp | 11 + recompiler/src/config_loader.h | 5 + runtime/include/gpu.h | 3 + runtime/include/mod_plugins.h | 9 + runtime/src/gpu.c | 205 ++++++++++++------ runtime/src/main.cpp | 12 + runtime/tests/gpu_exec_stubs.inc | 11 +- .../tests/test_ws_auto_ui_untextured_exec.c | 31 +++ 9 files changed, 234 insertions(+), 67 deletions(-) diff --git a/docs/WIDESCREEN.md b/docs/WIDESCREEN.md index 741e6f66..9613631d 100644 --- a/docs/WIDESCREEN.md +++ b/docs/WIDESCREEN.md @@ -140,6 +140,10 @@ auto_ui_anchor = "edges" # "edges" (default): each run pins to # its left/centre/right third. # "in_place": each run squashes about # its own centre. +auto_ui_size = "proportional" # "original" (default): the HUD keeps + # the display height's scale. + # "proportional": beyond 16:9 it shrinks + # by sqrt((16:9) / aspect). clear_reveal = true # clear synthetic native-wide side margins # at opted-in scene/map boundaries (default false). nw_left_hud_packet_lo = "0x000E3400" # optional targeted left-HUD packet range @@ -204,6 +208,16 @@ its own centre instead, which corrects its proportions and leaves it where the stretched 4:3 layout places it. `gpu_state` reports the mode as `ws.auto_ui.in_place`. +The HUD is proportion-corrected horizontally but keeps the display height's +scale, so on a very wide window it reads large against the much wider view. +`auto_ui_size = "proportional"` leaves it unchanged up to 16:9 and shrinks it +by sqrt((16:9) / aspect) beyond (0.87 at 21:9, 0.71 at 32:9), on both axes: +each run about its horizontal anchor and its vertical anchor (its top edge in +the upper half of the display, its bottom edge in the lower half), scaling +both edges of rectangles and sprites so neighbouring parts stay butted. A mod +may switch it per session with `psx_mod_set_widescreen_hud_size()`. +Projection widescreen only (native-wide does not squash the HUD). + **Changing `sprite_tag_funcs` requires a game regen** (the tag callback is emitted into the generated C). `widescreen.cull.keep` is consumed by both the native recompiler and the dirty-RAM interpreter: regenerate main/overlay native diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 438cc04b..072c2d69 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -1673,6 +1673,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { bool ws_hud_sprt_squash = false; bool ws_auto_ui_squash = false; bool ws_auto_ui_in_place = false; + bool ws_auto_ui_proportional = false; bool ws_full_2d = false; bool ws_gte_game_mode = false; bool ws_precise_nclip = false; @@ -1836,6 +1837,15 @@ GameConfig load_game_config(const fs::path& config_path_in) { "{}: [widescreen] auto_ui_anchor must be \"edges\" or " "\"in_place\", got \"{}\"", config_path.string(), anchor)); } + if (ws.contains("auto_ui_size")) { + const auto size = toml::find(ws, "auto_ui_size"); + if (size == "proportional") + ws_auto_ui_proportional = true; + else if (size != "original") + throw std::runtime_error(fmt::format( + "{}: [widescreen] auto_ui_size must be \"original\" or " + "\"proportional\", got \"{}\"", config_path.string(), size)); + } if (ws.contains("full_2d")) ws_full_2d = toml::find(ws, "full_2d"); if (ws.contains("gte_game_mode")) @@ -2480,6 +2490,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*ws_hud_sprt_squash*/ ws_hud_sprt_squash, /*ws_auto_ui_squash*/ ws_auto_ui_squash, /*ws_auto_ui_in_place*/ ws_auto_ui_in_place, + /*ws_auto_ui_proportional*/ ws_auto_ui_proportional, /*data_shard_funcs*/ data_shard_funcs, /*mod_function_entry_funcs*/ mod_function_entry_funcs, /*mod_instruction_sites*/ mod_instruction_sites, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 8d20f96e..6ae59030 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -963,6 +963,11 @@ struct GameConfig { // For HUD widgets that combine flat quads with GTE-projected parts the // correction cannot move (Spider-Man's compass ring and 3D arrow). bool ws_auto_ui_in_place = false; + // auto_ui_size = "proportional": beyond 16:9 the auto-UI HUD shrinks by + // sqrt((16:9) / aspect) about each widget's anchor, so a wide window + // does not show a 4:3-height HUD across a much wider view. "original" + // (default) keeps the HUD at the display height's scale. + bool ws_auto_ui_proportional = false; // [data_shards] funcs: functions that get the memoized pure-function // replay entry/return hooks (psx_datashard_enter/psx_datashard_ret). diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index 43f99c2c..23750a13 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -212,6 +212,9 @@ void gpu_ws_set_auto_ui_squash(int on); /* [widescreen] auto_ui_anchor = "in_place": each UI run squashes about its own * centre rather than an edge/centre third (default "edges"). */ void gpu_ws_set_auto_ui_in_place(int on); +/* [widescreen] auto_ui_size: 1 = proportional (the auto-UI HUD shrinks by + * sqrt((16:9) / aspect) beyond 16:9), 0 = original. */ +void gpu_ws_set_auto_ui_proportional(int on); /* [widescreen.bg2d] Capcom 2D background tile-loop widen — hooked at the renderer's * column-count / start-tile-col / start-screen-x instructions. Identity at 4:3 * and in the engine's 512 hi-res mode. */ diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index a7e0bc61..97590729 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -393,6 +393,15 @@ int psx_mod_set_fixed_display_aspect(uint32_t numerator, */ int psx_mod_set_adaptive_display_aspect(uint32_t max_numerator, uint32_t max_denominator); +/* + * HUD size for the auto-UI widescreen HUD ([widescreen] auto_ui_squash): + * 0 keeps it at the display height's scale ("original"), 1 makes it + * proportional (unchanged up to 16:9, shrinking by sqrt((16:9) / aspect) + * beyond, about each widget's anchors). Overrides game.toml + * [widescreen] auto_ui_size for the session; every session start restores the + * title's setting before activation. + */ +int psx_mod_set_widescreen_hud_size(int proportional); /* * Set the wall-clock cadence of simulated guest VBlanks. A value of zero * removes frontend pacing; 60 and higher request that many native guest diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 2887699d..424bf70d 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -46,6 +46,8 @@ #include "ws_screen_mask.h" #include "ws_radial_screen_mask.h" #include +#include +#include #include #include #include @@ -140,6 +142,10 @@ typedef struct { /* A triangle or a rotated quad: UI only as part of a widget it lies * inside (ws_ui_prepass_admit_enclosed), never on its own. */ uint8_t enclosed_only; + /* Vertical anchor of the item's run (auto_ui_size proportional): the + * run's top edge in the upper half of the display, its bottom edge in + * the lower half, so a shrunken widget keeps hugging its screen edge. */ + int32_t anchor_y; WsPrepassPacketGuard packet_guard; } WsUiPrepassItem; static WsUiPrepassItem ws_ui_prepass[WS_UI_PREPASS_MAX]; @@ -245,6 +251,11 @@ void gpu_ws_set_full_2d(int on) { ws_full_2d = on ? 1 : 0; } void gpu_ws_set_auto_ui_in_place(int on) { ws_auto_ui_in_place = on ? 1 : 0; } +/* [widescreen] auto_ui_size (gpu.c ws_auto_ui_size). */ +static int ws_auto_ui_proportional = 0; +void gpu_ws_set_auto_ui_proportional(int on) { + ws_auto_ui_proportional = on ? 1 : 0; +} void gpu_ws_set_auto_ui_squash(int on) { ws_auto_ui_squash = on ? 1 : 0; @@ -2841,10 +2852,10 @@ static void ws_ui_prepass_invalidate_stale(void) { ws_ui_reject.stale++; } -static int ws_auto_ui_anchor(int32_t *out_anchor) { +static const WsUiPrepassItem *ws_auto_ui_item(void) { if (!ws_auto_ui_squash || !ws_active() || gp0_cmd_source_addr == 0xFFFFFFFFu) - return 0; + return NULL; uint32_t src = GPU_RAM_KEY(gp0_cmd_source_addr); for (uint32_t i = 0; i < ws_ui_prepass_count; i++) { if (ws_ui_prepass[i].src_addr != src) continue; @@ -2852,13 +2863,38 @@ static int ws_auto_ui_anchor(int32_t *out_anchor) { gp0_cmd_buf, (uint32_t)gp0_words_needed)) { ws_ui_prepass_invalidate_stale(); - return 0; + return NULL; } - if (out_anchor) *out_anchor = ws_ui_prepass[i].group.anchor; ws_auto_ui_candidate_count++; - return 1; + return &ws_ui_prepass[i]; } - return 0; + return NULL; +} + +static int ws_auto_ui_anchor(int32_t *out_anchor) { + const WsUiPrepassItem *it = ws_auto_ui_item(); + if (!it) return 0; + if (out_anchor) *out_anchor = it->group.anchor; + return 1; +} + +/* auto_ui_size = "proportional": the HUD keeps its authored size up to 16:9 + * and shrinks by sqrt((16:9) / aspect) beyond it (0.87 at 21:9, 0.71 at + * 32:9), on both axes about the widget's anchors. The squash factor + * xnum/xden is (4:3) / aspect, so (16:9) / aspect = (4/3) * xnum/xden. + * Projection widescreen only: native-wide does not squash the HUD. */ +static double ws_auto_ui_size(void) { + if (!ws_auto_ui_proportional || ws_mode == 2 || ws_xden <= 0) return 1.0; + const double r = (4.0 / 3.0) * (double)ws_xnum / (double)ws_xden; + return r >= 1.0 ? 1.0 : sqrt(r); +} +static int32_t ws_ui_x(int32_t x, int32_t ax, double s) { + if (s >= 1.0) return ws_scale_about(x, ax); + return ax + (int32_t)lround((double)(x - ax) * ws_xnum / ws_xden * s); +} +static int32_t ws_ui_y(int32_t y, int32_t ay, double s) { + if (s >= 1.0) return y; + return ay + (int32_t)lround((double)(y - ay) * s); } static int ws_shared_ui_packet(void) { @@ -2924,21 +2960,24 @@ static int ws_axis_aligned_quad(const int32_t vx[4], const int32_t vy[4]) { /* A widget part that is not an axis-aligned rectangle (a triangle, a rotated * 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, int n) { +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; - int32_t anchor; - if (!ws_auto_ui_anchor(&anchor)) return 0; - for (int i = 0; i < n; i++) - vx[i] = ws_scale_about(vx[i], anchor) + ws_shared_ui_delta(); + const WsUiPrepassItem *it = ws_auto_ui_item(); + if (!it) return 0; + const double s = ws_auto_ui_size(); + for (int i = 0; i < n; i++) { + vx[i] = ws_ui_x(vx[i], it->group.anchor, s) + ws_shared_ui_delta(); + vy[i] = ws_ui_y(vy[i], it->anchor_y, s); + } ws_auto_ui_transform_count++; return 1; } -static int ws_auto_ui_transform_quad(int32_t vx[4], const int32_t vy[4]) { +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, 4); + if (!ws_axis_aligned_quad(vx, vy)) return ws_auto_ui_transform_part(vx, vy, 4); int32_t min_x = vx[0], max_x = vx[0], min_y = vy[0], max_y = vy[0]; for (int i = 1; i < 4; i++) { @@ -2953,16 +2992,19 @@ static int ws_auto_ui_transform_quad(int32_t vx[4], const int32_t vy[4]) { (width > W / 2 && height > H / 4)) return 0; - int32_t anchor; - if (!ws_auto_ui_anchor(&anchor)) return 0; - for (int i = 0; i < 4; i++) - vx[i] = ws_scale_about(vx[i], anchor) + ws_shared_ui_delta(); + const WsUiPrepassItem *it = ws_auto_ui_item(); + if (!it) return 0; + const double s = ws_auto_ui_size(); + for (int i = 0; i < 4; i++) { + vx[i] = ws_ui_x(vx[i], it->group.anchor, s) + ws_shared_ui_delta(); + vy[i] = ws_ui_y(vy[i], it->anchor_y, s); + } ws_auto_ui_transform_count++; return 1; } -static int ws_auto_ui_transform_rect(int32_t *x, int32_t y, int *w, int h) { - if (!x || !w || *w <= 0) return 0; +static int ws_auto_ui_transform_rect(int32_t *x, int32_t *y, int *w, int *h) { + 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); @@ -2972,13 +3014,26 @@ static int ws_auto_ui_transform_rect(int32_t *x, int32_t y, int *w, int h) { 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) || - (*w > W / 2 && h > H / 4)) + if ((*x <= X && *x + *w >= X + W && *y <= 0 && *y + *h >= H) || + (*w > W / 2 && *h > H / 4)) return 0; - int32_t anchor; - if (!ws_auto_ui_anchor(&anchor)) return 0; - *x = ws_scale_about(*x, anchor) + ws_shared_ui_delta(); - *w = (int)ws_scale_len(*w); + const WsUiPrepassItem *it = ws_auto_ui_item(); + if (!it) return 0; + const double s = ws_auto_ui_size(); + if (s >= 1.0) { + *x = ws_scale_about(*x, it->group.anchor) + ws_shared_ui_delta(); + *w = (int)ws_scale_len(*w); + } else { + /* Scale both edges so neighbouring parts of a widget stay butted. */ + const int32_t x0 = ws_ui_x(*x, it->group.anchor, s); + const int32_t x1 = ws_ui_x(*x + *w, it->group.anchor, s); + const int32_t y0 = ws_ui_y(*y, it->anchor_y, s); + const int32_t y1 = ws_ui_y(*y + *h, it->anchor_y, s); + *x = x0 + ws_shared_ui_delta(); + *w = x1 - x0 < 1 ? 1 : (int)(x1 - x0); + *y = y0; + *h = y1 - y0 < 1 ? 1 : (int)(y1 - y0); + } ws_auto_ui_transform_count++; return 1; } @@ -2986,7 +3041,7 @@ static int ws_auto_ui_transform_rect(int32_t *x, int32_t y, int *w, int h) { /* Shared transform for fixed-size textured sprites (8x8 / 16x16 / 1x1 dot): * squash *x0 in place (around the tagged anchor, else the HUD pivot) and * return the squashed draw width, or 0 = no change. */ -static int ws_sprt_fixed_transform(int32_t *x0, int32_t y0, int w) { +static int ws_sprt_fixed_transform(int32_t *x0, int32_t *y0, int w, int *h) { if (!ws_active()) return 0; int32_t ax; if (ws_tagged_anchor(&ax)) { @@ -2994,7 +3049,7 @@ static int ws_sprt_fixed_transform(int32_t *x0, int32_t y0, int w) { return (int)ws_scale_len(w); } int auto_w = w; - if (ws_auto_ui_transform_rect(x0, y0, &auto_w, w)) + if (ws_auto_ui_transform_rect(x0, y0, &auto_w, h)) return auto_w; if (ws_hud_sprt) { *x0 = ws_scale_about(*x0, ws_hud_pivot(*x0, w)); @@ -4715,7 +4770,7 @@ static void gp0_exec_mono_tri(void) { parse_vertex(gp0_cmd_buf[1 + i], &vx[i], &vy[i]); } if (gpu_triangle_rejected(vx, vy, 0, 1, 2)) return; - ws_auto_ui_transform_part(vx, 3); + ws_auto_ui_transform_part(vx, vy, 3); ws_nw_hud_shift_vertices(vx, 3); for (int i = 0; i < 3; i++) { vx[i] += draw_offset_x; @@ -4856,7 +4911,7 @@ static void gp0_exec_shaded_tri(void) { parse_vertex(gp0_cmd_buf[1 + i * 2], &vx[i], &vy[i]); } if (gpu_triangle_rejected(vx, vy, 0, 1, 2)) return; - ws_auto_ui_transform_part(vx, 3); + ws_auto_ui_transform_part(vx, vy, 3); ws_nw_hud_shift_vertices(vx, 3); for (int i = 0; i < 3; i++) { vx[i] += draw_offset_x; @@ -5023,7 +5078,7 @@ static void gp0_exec_textured_tri(void) { uint16_t tpage = tpage_word & 0x1FF; set_tpage_from_poly(tpage_word); /* latches even for size-rejected polys */ if (gpu_triangle_rejected(vx, vy, 0, 1, 2)) return; - ws_auto_ui_transform_part(vx, 3); + ws_auto_ui_transform_part(vx, vy, 3); ws_nw_hud_shift_vertices(vx, 3); for (int i = 0; i < 3; i++) { @@ -5171,7 +5226,7 @@ static void gp0_exec_shaded_textured_tri(void) { uint16_t tpage = tpage_word & 0x1FF; set_tpage_from_poly(tpage_word); /* latches even for size-rejected polys */ if (gpu_triangle_rejected(vx, vy, 0, 1, 2)) return; - ws_auto_ui_transform_part(vx, 3); + ws_auto_ui_transform_part(vx, vy, 3); ws_nw_hud_shift_vertices(vx, 3); for (int i = 0; i < 3; i++) { @@ -5350,9 +5405,11 @@ static void gp0_exec_mono_rect(void) { * behind in open screen. Untouched when the prepass did not admit this * primitive, and rects never carry GTE output. */ if (ws_active() && w > 0) { - int corrected_w = w; - if (ws_auto_ui_transform_rect(&x0, y0, &corrected_w, h)) + int corrected_w = w, corrected_h = h; + if (ws_auto_ui_transform_rect(&x0, &y0, &corrected_w, &corrected_h)) { w = corrected_w; + h = corrected_h; + } } x0 += ws_nw_hud_shift(x0, w); /* native-wide HUD corner re-anchor (no-op else) */ x0 += draw_offset_x; y0 += draw_offset_y; @@ -5408,7 +5465,7 @@ static void gp0_exec_textured_rect(void) { /* Widescreen: tagged sprite parts squash around their projected anchor; * untagged SPRTs are screen-space 2D (HUD/menus) and squash around the * display centre. Texels keep full coverage via the scaled-rect path. */ - int ws_w = 0; + int ws_w = 0, ws_h = h; if (ws_active() && w > 0) { int32_t ws_ax; if (ws_tagged_anchor(&ws_ax)) { @@ -5416,7 +5473,7 @@ static void gp0_exec_textured_rect(void) { ws_w = (int)ws_scale_len(w); } else { int corrected_w = w; - if (ws_auto_ui_transform_rect(&x0, y0, &corrected_w, h)) + if (ws_auto_ui_transform_rect(&x0, &y0, &corrected_w, &ws_h)) ws_w = corrected_w; else if (ws_hud_sprt) { x0 = ws_scale_about(x0, ws_hud_pivot(x0, w)); @@ -5427,13 +5484,11 @@ static void gp0_exec_textured_rect(void) { x0 += ws_nw_hud_shift(x0, w); /* native-wide HUD corner re-anchor (no-op else) */ x0 += draw_offset_x; y0 += draw_offset_y; - { - int dw = (ws_w && ws_w != w) ? ws_w : w; - if (draw_area_out_rect(x0, y0, dw, h)) return; - } + const int dw = (ws_w && ws_w != w) ? ws_w : w; + if (draw_area_out_rect(x0, y0, dw, ws_h)) return; setup_textured_draw(color24, semi_trans, raw_texture); - if (ws_w && ws_w != w) - gr_draw_textured_rect_scaled(x0, y0, ws_w, h, u0, v0, u0 + w, v0 + h, + if (dw != w || ws_h != h) + gr_draw_textured_rect_scaled(x0, y0, dw, ws_h, u0, v0, u0 + w, v0 + h, clut_x, clut_y, current_texpage()); else gr_draw_textured_rect(x0, y0, w, h, u0, v0, clut_x, clut_y, current_texpage()); @@ -5446,7 +5501,7 @@ static void gp0_exec_mono_dot(void) { uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); int32_t x, y; parse_vertex(gp0_cmd_buf[1], &x, &y); - ws_sprt_fixed_transform(&x, y, 1); /* auto_ui squash; no-op when unadmitted */ + { int dot_h = 1; ws_sprt_fixed_transform(&x, &y, 1, &dot_h); } /* auto_ui squash; no-op when unadmitted */ x += ws_nw_hud_shift(x, 1); x += draw_offset_x; y += draw_offset_y; if (draw_area_out_point(x, y)) return; @@ -5461,7 +5516,7 @@ static void gp0_exec_textured_dot(void) { int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; int32_t x0, y0; parse_vertex(gp0_cmd_buf[1], &x0, &y0); - (void)ws_sprt_fixed_transform(&x0, y0, 1); /* position only; 1px stays 1px */ + { int dot_h = 1; (void)ws_sprt_fixed_transform(&x0, &y0, 1, &dot_h); } /* position only; 1px stays 1px */ x0 += ws_nw_hud_shift(x0, 1); x0 += draw_offset_x; y0 += draw_offset_y; int u0 = gp0_cmd_buf[2] & 0xFF; @@ -5480,13 +5535,12 @@ static void gp0_exec_textured_8x8(void) { int raw_texture = (gp0_cmd_buf[0] >> 24) & 1; int32_t x0, y0; parse_vertex(gp0_cmd_buf[1], &x0, &y0); - int ws_w = ws_sprt_fixed_transform(&x0, y0, 8); + int ws_h = 8; + int ws_w = ws_sprt_fixed_transform(&x0, &y0, 8, &ws_h); x0 += ws_nw_hud_shift(x0, 8); /* native-wide HUD corner re-anchor (no-op else) */ x0 += draw_offset_x; y0 += draw_offset_y; - { - int dw = (ws_w && ws_w != 8) ? ws_w : 8; - if (draw_area_out_rect(x0, y0, dw, 8)) return; - } + const int dw = (ws_w && ws_w != 8) ? ws_w : 8; + if (draw_area_out_rect(x0, y0, dw, ws_h)) return; int u0 = gp0_cmd_buf[2] & 0xFF; int v0 = (gp0_cmd_buf[2] >> 8) & 0xFF; uint16_t clut = (uint16_t)(gp0_cmd_buf[2] >> 16); @@ -5494,8 +5548,8 @@ static void gp0_exec_textured_8x8(void) { uint16_t clut_y = (clut >> 6) & 0x1FF; setup_textured_draw(color24, semi_trans, raw_texture); - if (ws_w && ws_w != 8) - gr_draw_textured_rect_scaled(x0, y0, ws_w, 8, u0, v0, u0 + 8, v0 + 8, + if (dw != 8 || ws_h != 8) + gr_draw_textured_rect_scaled(x0, y0, dw, ws_h, u0, v0, u0 + 8, v0 + 8, clut_x, clut_y, current_texpage()); else gr_draw_textured_rect(x0, y0, 8, 8, u0, v0, clut_x, clut_y, current_texpage()); @@ -5507,14 +5561,15 @@ static void gp0_exec_mono_8x8(void) { uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); int32_t x0, y0; parse_vertex(gp0_cmd_buf[1], &x0, &y0); - int ws_w = ws_sprt_fixed_transform(&x0, y0, 8); + int ws_h = 8; + int ws_w = ws_sprt_fixed_transform(&x0, &y0, 8, &ws_h); x0 += ws_nw_hud_shift(x0, 8); x0 += draw_offset_x; y0 += draw_offset_y; { int dw = (ws_w && ws_w != 8) ? ws_w : 8; - if (draw_area_out_rect(x0, y0, dw, 8)) return; + if (draw_area_out_rect(x0, y0, dw, ws_h)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_flat_rect(x0, y0, dw, 8, color); + gr_draw_flat_rect(x0, y0, dw, ws_h, color); } } @@ -5533,7 +5588,8 @@ static void gp0_exec_textured_16x16(void) { /* A pending full-width OT can outlive a resize back to 4:3. Keep its * central tiles; the normal draw area clips the extra columns. Dropping * every host packet here would blank that transition frame. */ - int ws_w = host_tile ? 0 : ws_sprt_fixed_transform(&x0, y0, 16); + int ws_h = 16; + int ws_w = host_tile ? 0 : ws_sprt_fixed_transform(&x0, &y0, 16, &ws_h); if (!host_tile) x0 += ws_nw_hud_shift(x0, 16); x0 += draw_offset_x; y0 += draw_offset_y; int u0 = gp0_cmd_buf[2] & 0xFF; @@ -5542,10 +5598,8 @@ static void gp0_exec_textured_16x16(void) { uint16_t clut_x = (clut & 0x3F) * 16; uint16_t clut_y = (clut >> 6) & 0x1FF; - { - int dw = (ws_w && ws_w != 16) ? ws_w : 16; - if (draw_area_out_rect(x0, y0, dw, 16)) return; - } + const int dw = (ws_w && ws_w != 16) ? ws_w : 16; + if (draw_area_out_rect(x0, y0, dw, ws_h)) return; uint16_t bank = host_tile && (psx_read_word(host_packet + 28u) & ~GPU_WS_BG2D_MIRROR_X) == GPU_WS_BG2D_BANK_PACKET_MAGIC ? (uint16_t)(psx_read_word(host_packet + 16u) >> 16) : 0; @@ -5555,8 +5609,8 @@ static void gp0_exec_textured_16x16(void) { if (host_tile && (psx_read_word(host_packet + 28u) & GPU_WS_BG2D_MIRROR_X)) gr_draw_textured_rect_scaled(x0, y0, 16, 16, u0 + 15, v0, u0 - 1, v0 + 16, clut_x, clut_y, current_texpage()); - else if (ws_w && ws_w != 16) - gr_draw_textured_rect_scaled(x0, y0, ws_w, 16, u0, v0, u0 + 16, v0 + 16, + else if (dw != 16 || ws_h != 16) + gr_draw_textured_rect_scaled(x0, y0, dw, ws_h, u0, v0, u0 + 16, v0 + 16, clut_x, clut_y, current_texpage()); else gr_draw_textured_rect(x0, y0, 16, 16, u0, v0, clut_x, clut_y, current_texpage()); @@ -6335,6 +6389,32 @@ void gpu_ws_prepass_linked_list(uint32_t start_addr) { ws_ui_prepass[i].group.anchor = group_origin + groups[i].anchor; ws_ui_prepass[i].group.root = groups[i].root; } + /* Vertical anchor per run (auto_ui_size): its top edge in the upper half + * of the display, its bottom edge in the lower half. */ + { + static int32_t run_y0[WS_UI_PREPASS_MAX], run_y1[WS_UI_PREPASS_MAX]; + const int32_t half = ws_disp_h() / 2; + for (uint32_t i = 0; i < ws_ui_prepass_count; i++) { + run_y0[i] = INT32_MAX; + run_y1[i] = INT32_MIN; + } + for (uint32_t i = 0; i < ws_ui_prepass_count; i++) { + const uint32_t r = (uint32_t)groups[i].root; + if (r >= ws_ui_prepass_count) continue; + if (groups[i].y < run_y0[r]) run_y0[r] = groups[i].y; + if (groups[i].y + groups[i].height > run_y1[r]) + run_y1[r] = groups[i].y + groups[i].height; + } + for (uint32_t i = 0; i < ws_ui_prepass_count; i++) { + const uint32_t r = (uint32_t)groups[i].root; + int32_t y0 = groups[i].y, y1 = groups[i].y + groups[i].height; + if (r < ws_ui_prepass_count && run_y0[r] <= run_y1[r]) { + y0 = run_y0[r]; + y1 = run_y1[r]; + } + ws_ui_prepass[i].anchor_y = (y0 + y1) / 2 < half ? y0 : y1; + } + } /* A shared counter can comprise many glyphs and shadow packets, none of * which individually crosses the split. Classify the complete admitted @@ -6841,11 +6921,12 @@ static void gp0_dispatch_command(uint8_t opcode) { uint16_t color = rgb888_to_rgb555(gp0_cmd_buf[0] & 0xFFFFFFu); int32_t x0, y0; parse_vertex(gp0_cmd_buf[1], &x0, &y0); - int ws_w = ws_sprt_fixed_transform(&x0, y0, 16); + int ws_h = 16; + int ws_w = ws_sprt_fixed_transform(&x0, &y0, 16, &ws_h); x0 += ws_nw_hud_shift(x0, 16); x0 += draw_offset_x; y0 += draw_offset_y; gr_set_semi_transparency(semi_trans, (int)semi_transparency); - gr_draw_flat_rect(x0, y0, (ws_w && ws_w != 16) ? ws_w : 16, 16, + gr_draw_flat_rect(x0, y0, (ws_w && ws_w != 16) ? ws_w : 16, ws_h, color); break; } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index aea03933..28a285b5 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1510,6 +1510,15 @@ static bool netplay_local_viewport_native_wide(void) { return g_netplay_local_viewport == 1 && !g_netplay_local_viewport_projection; } +/* [widescreen] auto_ui_size from game.toml; a mod may override it per session + * (psx_mod_set_widescreen_hud_size), reset_mod_owned_presentation restores. */ +static bool g_ws_auto_ui_proportional_cfg = false; +extern "C" int psx_mod_set_widescreen_hud_size(int proportional) { + if (proportional != 0 && proportional != 1) return 0; + gpu_ws_set_auto_ui_proportional(proportional); + return 1; +} + extern "C" int psx_mod_set_fixed_display_aspect( uint32_t numerator, uint32_t denominator) { if (numerator == 0 || denominator == 0 || @@ -1618,6 +1627,7 @@ static void reset_mod_owned_presentation(void) { g_ws_adaptive_view = false; g_ws_adaptive_max_num = 16; g_ws_adaptive_max_den = 9; + gpu_ws_set_auto_ui_proportional(g_ws_auto_ui_proportional_cfg ? 1 : 0); psx_mod_set_world_scene_predicate(nullptr); gpu_ws_set_native_scene_predicate(nullptr); psx_mod_set_retained_scene_predicate(nullptr); @@ -14699,6 +14709,8 @@ int main(int argc, char** argv) { g_ws_hud_sprt = gc.ws_hud_sprt_squash; gpu_ws_set_auto_ui_squash(gc.ws_auto_ui_squash ? 1 : 0); gpu_ws_set_auto_ui_in_place(gc.ws_auto_ui_in_place ? 1 : 0); + g_ws_auto_ui_proportional_cfg = gc.ws_auto_ui_proportional; + gpu_ws_set_auto_ui_proportional(g_ws_auto_ui_proportional_cfg ? 1 : 0); /* [widescreen] full_2d — opt a pure-2D sprite game (MMX6) into the * widescreen present path. Applied to the GPU layer up front so the * ws engage at game entry classifies every frame as gameplay. */ diff --git a/runtime/tests/gpu_exec_stubs.inc b/runtime/tests/gpu_exec_stubs.inc index 43e7a138..dbcb8e01 100644 --- a/runtime/tests/gpu_exec_stubs.inc +++ b/runtime/tests/gpu_exec_stubs.inc @@ -81,20 +81,21 @@ static int g_test_precise_triangles = 0; static int g_test_textured_triangles = 0; /* Bounding box of every flat/gouraud triangle drawn since the last reset. */ -static struct { int calls, min_x, max_x; } gpu_exec_triangles; +static struct { int calls, min_x, max_x, min_y, max_y; } gpu_exec_triangles; static inline void gpu_exec_reset_triangles(void) { gpu_exec_triangles.calls = 0; - gpu_exec_triangles.min_x = 1 << 30; - gpu_exec_triangles.max_x = -(1 << 30); + gpu_exec_triangles.min_x = gpu_exec_triangles.min_y = 1 << 30; + gpu_exec_triangles.max_x = gpu_exec_triangles.max_y = -(1 << 30); } static inline void gpu_exec_record_triangle(int x0, int y0, int x1, int y1, int x2, int y2) { - const int xs[3] = {x0, x1, x2}; - (void)y0; (void)y1; (void)y2; + const int xs[3] = {x0, x1, x2}, ys[3] = {y0, y1, y2}; gpu_exec_triangles.calls++; for (int i = 0; i < 3; i++) { if (xs[i] < gpu_exec_triangles.min_x) gpu_exec_triangles.min_x = xs[i]; if (xs[i] > gpu_exec_triangles.max_x) gpu_exec_triangles.max_x = xs[i]; + if (ys[i] < gpu_exec_triangles.min_y) gpu_exec_triangles.min_y = ys[i]; + if (ys[i] > gpu_exec_triangles.max_y) gpu_exec_triangles.max_y = ys[i]; } } diff --git a/runtime/tests/test_ws_auto_ui_untextured_exec.c b/runtime/tests/test_ws_auto_ui_untextured_exec.c index efa5a0fe..2dc60a80 100644 --- a/runtime/tests/test_ws_auto_ui_untextured_exec.c +++ b/runtime/tests/test_ws_auto_ui_untextured_exec.c @@ -130,6 +130,7 @@ static void reset_state(int in_place) { ws_full_2d = 0; gpu_ws_set_auto_ui_squash(1); gpu_ws_set_auto_ui_in_place(in_place); + gpu_ws_set_auto_ui_proportional(0); } /* Prepass the list and execute the two fills; returns their drawn X span. */ @@ -306,6 +307,36 @@ int main(void) { assert(gmax == ws_scale_about(120, left)); assert(fmin == gmin && fmax == gmax); + /* auto_ui_size proportional at 32:9 (squash 3/8): the HUD shrinks by + * sqrt((4/3) * 3/8) = sqrt(1/2) on both axes about its anchors. The run + * spans y 20..36 in the top half, so it keeps its top edge (y 20). */ + reset_state(0); + gpu_ws_set_auto_ui_proportional(1); + build_hud(60, 128, 64, 120); + run_fills(&gmin, &gmax, &fmin, &fmax); + { + const double s = sqrt(0.5); + const int32_t left = ws_disp_x(); + assert(gmin == left + (int32_t)lround((64 - left) * 3.0 / 8.0 * s)); + assert(gmax == left + (int32_t)lround((120 - left) * 3.0 / 8.0 * s)); + gpu_exec_reset_triangles(); + load_packet(NODE_FILL, 8); + gp0_exec_shaded_quad(); + assert(gpu_exec_triangles.min_y == 20 + (int32_t)lround(6 * s)); + assert(gpu_exec_triangles.max_y == 20 + (int32_t)lround(10 * s)); + } + /* At 16:9 (squash 3/4) proportional is identical to original. */ + reset_state(0); + ws_cfg_num = 16; ws_cfg_den = 9; ws_xnum = 3; ws_xden = 4; + gpu_ws_set_auto_ui_proportional(1); + build_hud(60, 128, 64, 120); + run_fills(&gmin, &gmax, &fmin, &fmax); + assert(gmin == ws_scale_about(64, ws_disp_x())); + gpu_exec_reset_triangles(); + load_packet(NODE_FILL, 8); + gp0_exec_shaded_quad(); + assert(gpu_exec_triangles.min_y == 26 && gpu_exec_triangles.max_y == 30); + /* A non-axis-aligned untextured quad reaching outside every widget is * world geometry, never UI (one inside a widget is a part of it: see the * needle below). */ From 37946ab43df50e955156c6d418be436fe9b50b9f Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 2 Oct 2026 18:50:27 -0700 Subject: [PATCH 20/64] ws_ui_groups: count why a UI prepass was discarded Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/src/gpu.c | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 424bf70d..98836849 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -162,6 +162,11 @@ typedef struct { WsPrepassPacketGuard payload_guard; } WsUiPrepassNode; static WsUiPrepassNode ws_ui_prepass_nodes[WS_UI_PREPASS_NODE_MAX]; +/* Why the last prepass was discarded (ws_ui_groups "stale_why"): 1 packet + * guard at draw, 2 DMA node not in the prepass, 3 node header changed, + * 4 node word count changed, 5 node payload changed. */ +static uint32_t ws_ui_stale_why[6]; +static uint32_t ws_ui_stale_addr; static uint32_t ws_ui_prepass_node_count; /* Why a UI-looking primitive did NOT reach the squash partition. @@ -2200,13 +2205,16 @@ int psx_ws_ui_groups_json(char *buf, int cap) { "\"disp_x\":%d,\"disp_w\":%d,\"join_gap\":%d," "\"rejected\":{\"opcode\":%u,\"not_axis\":%u,\"degenerate\":%u," "\"too_big\":%u,\"cap\":%u,\"rank\":%u,\"stale\":%u}," + "\"stale_why\":[%u,%u,%u,%u,%u],\"stale_addr\":\"0x%08X\"," "\"backing_panels\":%u,\"enclosed_parts\":%u,\"n\":%u,", ws_active(), ws_auto_ui_squash, ws_auto_ui_dense, ws_ui_prepass_rank != 0xFFFFu ? (int)ws_ui_prepass_rank : -1, ws_disp_x(), ws_disp_w(), WS_UI_GROUP_JOIN_GAP, ws_ui_reject.opcode, ws_ui_reject.not_axis, ws_ui_reject.degenerate, ws_ui_reject.too_big, ws_ui_reject.cap, ws_ui_reject.rank, - ws_ui_reject.stale, ws_ui_reject.backing, ws_ui_reject.enclosed, + ws_ui_reject.stale, ws_ui_stale_why[1], ws_ui_stale_why[2], + ws_ui_stale_why[3], ws_ui_stale_why[4], ws_ui_stale_why[5], + ws_ui_stale_addr, ws_ui_reject.backing, ws_ui_reject.enclosed, ws_ui_prepass_count); off += snprintf(buf + off, (size_t)(cap - off), "\"rank_dropped\":["); for (uint32_t i = 0; i < ws_ui_rankdrop_count && off < cap - 120; i++) { @@ -2844,6 +2852,10 @@ static int32_t ws_hud_pivot(int32_t x, int32_t w) { * before the list streams through GP0. This excludes CPU-built characters (the * source of the old squashed-Spike regression) even when their packets are * axis-aligned, and gives animated glyphs a shared anchor on their first frame. */ +static void ws_ui_prepass_invalidate_stale_why(int why, uint32_t addr) { + if (why > 0 && why < 6) ws_ui_stale_why[why]++; + ws_ui_stale_addr = addr; +} static void ws_ui_prepass_invalidate_stale(void) { ws_ui_prepass_count = 0; ws_ui_prepass_node_count = 0; @@ -2862,6 +2874,7 @@ static const WsUiPrepassItem *ws_auto_ui_item(void) { if (!ws_prepass_packet_matches(&ws_ui_prepass[i].packet_guard, gp0_cmd_buf, (uint32_t)gp0_words_needed)) { + ws_ui_prepass_invalidate_stale_why(1, src); ws_ui_prepass_invalidate_stale(); return NULL; } @@ -6454,6 +6467,7 @@ void gpu_ws_validate_linked_list_header(uint32_t addr, uint32_t header) { ws_ui_prepass_invalidate_stale(); return; } + ws_ui_prepass_invalidate_stale_why(2, resolved); ws_ui_prepass_invalidate_stale(); } @@ -6470,6 +6484,7 @@ void gpu_ws_validate_linked_list_node(uint32_t addr, uint32_t num_words) { } } if (!node || node->payload_guard.word_count != num_words) { + ws_ui_prepass_invalidate_stale_why(node ? 4 : 2, resolved); ws_ui_prepass_invalidate_stale(); return; } @@ -6481,8 +6496,10 @@ void gpu_ws_validate_linked_list_node(uint32_t addr, uint32_t num_words) { payload[i] = psx_read_word(psx_mod_gpu_dma_resolve_address( first_addr + i * 4u)); } - if (!ws_prepass_packet_matches(&node->payload_guard, payload, num_words)) + if (!ws_prepass_packet_matches(&node->payload_guard, payload, num_words)) { + ws_ui_prepass_invalidate_stale_why(5, resolved); ws_ui_prepass_invalidate_stale(); + } } /* Per-opcode execution counters (exposed via gpu_get_opcode_stats) */ From 4b0e5bd1db0487e507d84d49fcb6d96e9c73fb10 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 2 Oct 2026 19:08:13 -0700 Subject: [PATCH 21/64] GPU packet keys keep the enhancement aperture apart from the main RAM it aliases The UI prepass re-read aperture node payloads through the RAM-folded key, so THPS2's relocated prim arena discarded the HUD squash every frame. Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/include/mod_memory.h | 17 ++++++ runtime/include/ws_primitive_roles.h | 6 +- runtime/src/gpu.c | 17 +++--- runtime/src/memory.c | 10 +++- runtime/tests/gpu_exec_stubs.inc | 15 ++++- runtime/tests/test_gpu_gp0_history_guard.py | 1 + .../tests/test_ws_auto_ui_untextured_exec.c | 58 +++++++++++++++++++ 7 files changed, 109 insertions(+), 15 deletions(-) diff --git a/runtime/include/mod_memory.h b/runtime/include/mod_memory.h index 93c8de19..bab68684 100644 --- a/runtime/include/mod_memory.h +++ b/runtime/include/mod_memory.h @@ -49,6 +49,23 @@ static inline uint32_t psx_mod_gpu_dma_resolve_address_for( return psx_ram_canonical_offset(canonical) & ~3u; } +/* + * Identity of a GPU packet word at `address` (a CPU pointer or a DMA tag): + * the allocated enhancement aperture keeps its own physical address, main + * RAM folds to its word-aligned live offset. Every source-address key (UI + * prepass nodes and items, packet guards, title roles) uses this, so a packet + * in the aperture never shares a key with, or reads back, the main RAM its + * low bits alias. + */ +static inline uint32_t psx_gpu_packet_key_for(uint32_t address, uint32_t used) { + uint32_t canonical = address & 0x1FFFFFFCu; + if (psx_mod_gpu_dma_aperture_offset_for( + canonical, 4u, used, (uint32_t *)0)) + return canonical; + return psx_ram_canonical_offset(canonical) & ~3u; +} + +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); uint32_t psx_mod_memory_alloc(uint32_t size, uint32_t alignment); diff --git a/runtime/include/ws_primitive_roles.h b/runtime/include/ws_primitive_roles.h index e3ba25a0..f13103fb 100644 --- a/runtime/include/ws_primitive_roles.h +++ b/runtime/include/ws_primitive_roles.h @@ -2,7 +2,7 @@ #define PSX_WS_PRIMITIVE_ROLES_H #include #include -#include "psx_memory.h" +#include "mod_memory.h" /* Title-supplied packet identity, independent of draw order and sprite tags. * Keys name PsyQ P_TAG headers; GPU lookup names the following command word. */ @@ -15,7 +15,7 @@ typedef struct { static inline WsPrimitiveRole* ws_role_write(WsPrimitiveRole* tags, uint32_t primitive, uint32_t now) { - uint32_t key = psx_ram_canonical_offset(primitive) & ~3u; + uint32_t key = psx_gpu_packet_key(primitive); if (!key) return NULL; uint32_t slot = (key >> 2) & (WS_ROLE_BUCKETS - 1u), victim = slot; for (unsigned i = 0; i < 4; ++i) { @@ -36,7 +36,7 @@ static inline WsPrimitiveRole* ws_role_write(WsPrimitiveRole* tags, static inline const WsPrimitiveRole* ws_role_read(const WsPrimitiveRole* tags, uint32_t source, uint32_t now) { if (source == 0xFFFFFFFFu) return NULL; - uint32_t key = psx_ram_canonical_offset(source - 4u) & ~3u; + uint32_t key = psx_gpu_packet_key(source - 4u); if (!key) return NULL; uint32_t slot = (key >> 2) & (WS_ROLE_BUCKETS - 1u); for (unsigned i = 0; i < 4; ++i) { diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 98836849..13b37bf2 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -53,9 +53,10 @@ #include #include "gpu_timeline.h" -/* Word-aligned main-RAM key for a primitive/OT address through the live - * geometry (retail: the DMAC's 0x1FFFFC fold). */ -#define GPU_RAM_KEY(a) (psx_ram_canonical_offset(a) & ~3u) +/* Packet identity of a primitive/OT address: main RAM through the live + * geometry (retail: the DMAC's 0x1FFFFC fold), the enhancement aperture by its + * own address (mod_memory.h). */ +#define GPU_RAM_KEY(a) psx_gpu_packet_key(a) extern uint16_t psx_read_half(uint32_t addr); extern uint8_t psx_read_byte(uint32_t addr); @@ -2466,7 +2467,7 @@ void gpu_ws_tag_background_prim(uint32_t prim) { } WsPrepassPacketGuard guard = ws_prepass_packet_guard(words, count); ws_hud_anchor_insert(ws_background_tags, WS_HUD_ANCHOR_TABLE_SIZE, - (prim + 4u) & 0x1FFFFCu, 0, &guard, + GPU_RAM_KEY((prim + 4u)), 0, &guard, (uint32_t)s_frame_count); ws_background_tags_used = 1; ws_background_tag_frame = (uint32_t)s_frame_count; @@ -6474,8 +6475,8 @@ void gpu_ws_validate_linked_list_header(uint32_t addr, uint32_t header) { void gpu_ws_validate_linked_list_node(uint32_t addr, uint32_t num_words) { if (ws_ui_prepass_count == 0) return; - uint32_t resolved = - GPU_RAM_KEY(psx_mod_gpu_dma_resolve_address(addr)); + const uint32_t node_addr = psx_mod_gpu_dma_resolve_address(addr); + const uint32_t resolved = GPU_RAM_KEY(node_addr); const WsUiPrepassNode *node = NULL; for (uint32_t i = 0; i < ws_ui_prepass_node_count; i++) { if (ws_ui_prepass_nodes[i].addr == resolved) { @@ -6489,9 +6490,11 @@ void gpu_ws_validate_linked_list_node(uint32_t addr, uint32_t num_words) { return; } + /* Re-read the payload where the walk reads it (the node's own memory), + * never through the key. */ uint32_t payload[255]; uint32_t first_addr = - psx_mod_gpu_dma_resolve_address(resolved + 4u); + psx_mod_gpu_dma_resolve_address(node_addr + 4u); for (uint32_t i = 0; i < num_words; i++) { payload[i] = psx_read_word(psx_mod_gpu_dma_resolve_address( first_addr + i * 4u)); diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 98189ebf..a3ba149c 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -144,6 +144,10 @@ uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address) { address, mod_gpu_dma_memory_used); } +uint32_t psx_gpu_packet_key(uint32_t address) { + return psx_gpu_packet_key_for(address, mod_gpu_dma_memory_used); +} + /* Exposed for inlined main-RAM load helpers in psx_cyc.h (VLC/decode hot path). */ uint8_t *g_psx_ram = ram; /* PSX_LOAD_DELAY gate (default on). −1 = unread; 0/1 after first resolve. */ @@ -1355,9 +1359,9 @@ static void mmio_write32(uint32_t addr, uint32_t val) { * 0x80030000, so ROM 0xBFC38B1C executes at 0x80050B1C * (scph1001_relocated_store). */ if (debug_cpu_ptr && scph1001_relocated_store(0x80050B1Cu, 0xBFC38B1Cu)) { - /* Word-aligned RAM key through the live geometry (retail: the - * 0x1FFFFC fold, identical to the DMA/GPU source keys). */ - src = psx_ram_canonical_offset(debug_cpu_ptr->gpr[4] - 4u) & ~3u; + /* The same packet key as the DMA/GPU source keys (retail: the + * 0x1FFFFC fold). */ + src = psx_gpu_packet_key(debug_cpu_ptr->gpr[4] - 4u); } gpu_set_gp0_source(src); gpu_write_gp0(val); diff --git a/runtime/tests/gpu_exec_stubs.inc b/runtime/tests/gpu_exec_stubs.inc index dbcb8e01..c4807742 100644 --- a/runtime/tests/gpu_exec_stubs.inc +++ b/runtime/tests/gpu_exec_stubs.inc @@ -54,6 +54,10 @@ int mod_texture_packet_precision(uint32_t source, float q[3], float xy[6]) { } static uint32_t test_ram[0x00200000u / 4u]; +/* The enhancement GPU-DMA aperture (mod_memory.h): a test allocates it by + * setting test_aperture_used; reads and keys then follow the real rules. */ +static uint32_t test_aperture[0x00020000u / 4u]; +static uint32_t test_aperture_used; static struct { int calls; @@ -100,7 +104,9 @@ static inline void gpu_exec_record_triangle(int x0, int y0, int x1, int y1, } uint32_t psx_read_word(uint32_t addr) { - uint32_t phys = addr & 0x1FFFFFFFu; + uint32_t phys = addr & 0x1FFFFFFFu, off; + if (psx_mod_gpu_dma_aperture_offset_for(phys, 4u, test_aperture_used, &off)) + return test_aperture[off / 4u]; if (phys <= 0x00200000u - 4u) return test_ram[phys / 4u]; return 0; @@ -243,7 +249,12 @@ void color_lut_map555(const ColorLut *lut, uint16_t bgr555, if (g) *g = (uint8_t)(((bgr555 >> 5) & 0x1Fu) << 3); if (b) *b = (uint8_t)(((bgr555 >> 10) & 0x1Fu) << 3); } -uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address) { return address; } +uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address) { + return psx_mod_gpu_dma_resolve_address_for(address, test_aperture_used); +} +uint32_t psx_gpu_packet_key(uint32_t address) { + return psx_gpu_packet_key_for(address, test_aperture_used); +} void text_xlate_vram_upload(int x, int y, int w, int h) { (void)x; (void)y; (void)w; (void)h; } diff --git a/runtime/tests/test_gpu_gp0_history_guard.py b/runtime/tests/test_gpu_gp0_history_guard.py index cf6132a9..990a0bed 100644 --- a/runtime/tests/test_gpu_gp0_history_guard.py +++ b/runtime/tests/test_gpu_gp0_history_guard.py @@ -250,6 +250,7 @@ void text_xlate_vram_upload(uint16_t x, uint16_t y, uint16_t w, uint16_t h) int ws_cull_should_keep(uint32_t addr) { (void)addr; return 1; } int ws_ui_group_should_keep(uint32_t addr) { (void)addr; return 1; } uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address) { return address; } +uint32_t psx_gpu_packet_key(uint32_t address) { return address & 0x001FFFFCu; } void ws_ui_group_assign(WsUiGroupItem *items, size_t count, int32_t display_width, int dense_menu, int in_place) { (void)items; (void)count; (void)display_width; (void)dense_menu; (void)in_place; } diff --git a/runtime/tests/test_ws_auto_ui_untextured_exec.c b/runtime/tests/test_ws_auto_ui_untextured_exec.c index 2dc60a80..3172f817 100644 --- a/runtime/tests/test_ws_auto_ui_untextured_exec.c +++ b/runtime/tests/test_ws_auto_ui_untextured_exec.c @@ -107,6 +107,31 @@ static void add_rear_pieces(const RearPiece *pieces, int count) { test_ram[NODE_GAP / 4u] = NODE_RING; } +/* The same HUD list in the enhancement GPU-DMA aperture (THPS2's draw-distance + * prim arena), with unrelated words in the main RAM its low bits alias. */ +#define AP_BASE 0x00800000u +static void put_node_ap(uint32_t addr, uint32_t next, const uint32_t *words, + uint32_t count) { + const uint32_t off = (addr - AP_BASE) / 4u; + test_aperture[off] = (count << 24) | (next & 0xFFFFFFu); + for (uint32_t i = 0; i < count; i++) + test_aperture[off + 1u + i] = words[i]; +} +static void move_hud_to_aperture(void) { + const uint32_t nodes[3] = {NODE_RING, NODE_FILL, NODE_FLAT}; + test_aperture_used = sizeof test_aperture; + memset(test_aperture, 0, sizeof test_aperture); + for (int i = 0; i < 3; i++) { + const uint32_t h = test_ram[nodes[i] / 4u], n = h >> 24; + const uint32_t next = (h & 0xFFFFFFu) == 0xFFFFFFu + ? 0xFFFFFFu : AP_BASE + (h & 0xFFFFFFu); + put_node_ap(AP_BASE + nodes[i], next, &test_ram[nodes[i] / 4u + 1u], n); + } + test_ram[OT_HEAD / 4u] = AP_BASE + NODE_RING; + for (uint32_t a = NODE_RING; a < NODE_FLAT + 0x40u; a += 4u) + test_ram[a / 4u] = 0xDEAD0000u | a; +} + static void load_packet(uint32_t node, uint32_t count) { for (uint32_t i = 0; i < count; i++) gp0_cmd_buf[i] = test_ram[node / 4u + 1u + i]; @@ -473,6 +498,39 @@ int main(void) { assert(gpu_exec_triangles.min_x == 50 && gpu_exec_triangles.max_x == 140); } + /* A list in the aperture validates against its own words at DMA time: + * the prepass survives the walk and the fill squashes as in RAM. A RAM + * node at the aliased offset is a different node. */ + reset_state(1); + build_hud(60, 128, 64, 120); + move_hud_to_aperture(); + gpu_ws_prepass_linked_list(OT_HEAD); + assert(ws_ui_prepass_count == 3); + { + const uint32_t nodes[3] = {NODE_RING, NODE_FILL, NODE_FLAT}; + const uint32_t stale = ws_ui_reject.stale; + for (int i = 0; i < 3; i++) { + const uint32_t a = AP_BASE + nodes[i]; + const uint32_t h = test_aperture[(a - AP_BASE) / 4u]; + gpu_ws_validate_linked_list_header(a, h); + gpu_ws_validate_linked_list_node(a, h >> 24); + } + assert(ws_ui_reject.stale == stale && ws_ui_prepass_count == 3); + gpu_exec_reset_triangles(); + for (uint32_t i = 0; i < 8; i++) + gp0_cmd_buf[i] = test_aperture[(NODE_FILL + 4u) / 4u + i]; + gp0_cmd_source_addr = AP_BASE + NODE_FILL + 4u; + gp0_words_needed = 8; + gp0_exec_shaded_quad(); + const int32_t centre = 60 + (128 - 60) / 2; + assert(gpu_exec_triangles.min_x == ws_scale_about(64, centre)); + assert(gpu_exec_triangles.max_x == ws_scale_about(120, centre)); + assert(GPU_RAM_KEY(AP_BASE + NODE_FILL) != GPU_RAM_KEY(NODE_FILL)); + gpu_ws_validate_linked_list_header(NODE_FILL, test_ram[NODE_FILL / 4u]); + assert(ws_ui_reject.stale == stale + 1 && ws_ui_stale_why[2] > 0); + } + test_aperture_used = 0; + puts("ws_auto_ui_untextured_exec_test: PASS"); return 0; } From 6529c155a624ac76694466742f6b66963d592fea Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 6 Oct 2026 23:57:28 -0700 Subject: [PATCH 22/64] Keep existing GPU line endings during HUD integration --- runtime/src/gpu.c | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 13b37bf2..a1da2e92 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -53,10 +53,10 @@ #include #include "gpu_timeline.h" -/* Packet identity of a primitive/OT address: main RAM through the live - * geometry (retail: the DMAC's 0x1FFFFC fold), the enhancement aperture by its - * own address (mod_memory.h). */ -#define GPU_RAM_KEY(a) psx_gpu_packet_key(a) +/* Packet identity of a primitive/OT address: main RAM through the live + * geometry (retail: the DMAC's 0x1FFFFC fold), the enhancement aperture by its + * own address (mod_memory.h). */ +#define GPU_RAM_KEY(a) psx_gpu_packet_key(a) extern uint16_t psx_read_half(uint32_t addr); extern uint8_t psx_read_byte(uint32_t addr); @@ -2467,7 +2467,7 @@ void gpu_ws_tag_background_prim(uint32_t prim) { } WsPrepassPacketGuard guard = ws_prepass_packet_guard(words, count); ws_hud_anchor_insert(ws_background_tags, WS_HUD_ANCHOR_TABLE_SIZE, - GPU_RAM_KEY((prim + 4u)), 0, &guard, + GPU_RAM_KEY((prim + 4u)), 0, &guard, (uint32_t)s_frame_count); ws_background_tags_used = 1; ws_background_tag_frame = (uint32_t)s_frame_count; @@ -6468,15 +6468,15 @@ void gpu_ws_validate_linked_list_header(uint32_t addr, uint32_t header) { ws_ui_prepass_invalidate_stale(); return; } - ws_ui_prepass_invalidate_stale_why(2, resolved); + ws_ui_prepass_invalidate_stale_why(2, resolved); ws_ui_prepass_invalidate_stale(); } void gpu_ws_validate_linked_list_node(uint32_t addr, uint32_t num_words) { if (ws_ui_prepass_count == 0) return; - const uint32_t node_addr = psx_mod_gpu_dma_resolve_address(addr); - const uint32_t resolved = GPU_RAM_KEY(node_addr); + const uint32_t node_addr = psx_mod_gpu_dma_resolve_address(addr); + const uint32_t resolved = GPU_RAM_KEY(node_addr); const WsUiPrepassNode *node = NULL; for (uint32_t i = 0; i < ws_ui_prepass_node_count; i++) { if (ws_ui_prepass_nodes[i].addr == resolved) { @@ -6490,11 +6490,11 @@ void gpu_ws_validate_linked_list_node(uint32_t addr, uint32_t num_words) { return; } - /* Re-read the payload where the walk reads it (the node's own memory), - * never through the key. */ + /* Re-read the payload where the walk reads it (the node's own memory), + * never through the key. */ uint32_t payload[255]; uint32_t first_addr = - psx_mod_gpu_dma_resolve_address(node_addr + 4u); + psx_mod_gpu_dma_resolve_address(node_addr + 4u); for (uint32_t i = 0; i < num_words; i++) { payload[i] = psx_read_word(psx_mod_gpu_dma_resolve_address( first_addr + i * 4u)); From 9e694d5c0a448dcd3cd9ac0f4c4f80511d7b3036 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 00:02:55 -0700 Subject: [PATCH 23/64] Retain header mismatch diagnostics on bounded OT walk --- runtime/src/gpu.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index a1da2e92..43cfe69c 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -6464,8 +6464,10 @@ void gpu_ws_validate_linked_list_header(uint32_t addr, uint32_t header) { GPU_RAM_KEY(psx_mod_gpu_dma_resolve_address(addr)); for (uint32_t i = 0; i < ws_ui_prepass_node_count; i++) { if (ws_ui_prepass_nodes[i].addr != resolved) continue; - if (ws_ui_prepass_nodes[i].header != header) + if (ws_ui_prepass_nodes[i].header != header) { + ws_ui_prepass_invalidate_stale_why(3, resolved); ws_ui_prepass_invalidate_stale(); + } return; } ws_ui_prepass_invalidate_stale_why(2, resolved); From 586d01ee656205fb76d3581145d5bad3ac4ade00 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 00:17:17 -0700 Subject: [PATCH 24/64] Extract descriptor-based native sprite scene replay for X-series titles --- runtime/include/render_pass_sprite_scene.hpp | 226 +++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 runtime/include/render_pass_sprite_scene.hpp diff --git a/runtime/include/render_pass_sprite_scene.hpp b/runtime/include/render_pass_sprite_scene.hpp new file mode 100644 index 00000000..73bc6907 --- /dev/null +++ b/runtime/include/render_pass_sprite_scene.hpp @@ -0,0 +1,226 @@ +#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; also used by 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); +} +}; From bebf2d08dcef2b755d865932067fcec58819ef2e Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 00:23:48 -0700 Subject: [PATCH 25/64] Support sprite scenes whose bank state lives entirely in DRAWENV --- runtime/include/render_pass_sprite_scene.hpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/runtime/include/render_pass_sprite_scene.hpp b/runtime/include/render_pass_sprite_scene.hpp index 73bc6907..c18b52d7 100644 --- a/runtime/include/render_pass_sprite_scene.hpp +++ b/runtime/include/render_pass_sprite_scene.hpp @@ -17,7 +17,7 @@ extern "C" uint32_t memory_get_ram_bytes(void); * 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; also used by MMX5. + * Derived from the MMX6 native scene implementation; used by MMX4 and MMX5. */ template class PSXSpriteSceneReplay { @@ -75,7 +75,10 @@ static void apply_late_packets() { } } -static bool retarget_packets() { +static bool retarget_packets() { + // Older sprite engines carry all draw-bank state in DRAWENV. Later ones + // additionally prepend E3/E4/E5 packets which override that environment. + if constexpr (!Layout::OffsetPackets && !Layout::ClipPackets) return true; const uint32_t index=(buffer-Base)/Stride; // Main-loop OT prefixes carry their own draw offset and draw area. Those // override PutDrawEnv, so replay must select the pending display bank's From b8cf0aa8b6f4aef915f75ae588cfb07e949beb07 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 00:47:24 -0700 Subject: [PATCH 26/64] Keep native redraw VRAM journals bounded with an exact spill backup --- runtime/src/render_pass_plan.c | 14 ++++++++++++-- runtime/tests/test_render_pass_sandbox.c | 14 ++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c index 40fd6b14..c8a5e67c 100644 --- a/runtime/src/render_pass_plan.c +++ b/runtime/src/render_pass_plan.c @@ -279,8 +279,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/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c index cf67cfb9..3a029f6a 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 */ @@ -648,10 +648,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"); From ab4534455f8c467b6b2f8005af6381917228f67f Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 01:03:29 -0700 Subject: [PATCH 27/64] Share bounded late OT replay and decoded framebuffer retargeting --- runtime/CMakeLists.txt | 5 ++ runtime/include/gpu.h | 3 + runtime/include/render_pass_ot.hpp | 102 ++++++++++++++++++++++++++ runtime/src/gpu.c | 4 + runtime/tests/test_render_pass_ot.cpp | 46 ++++++++++++ 5 files changed, 160 insertions(+) create mode 100644 runtime/include/render_pass_ot.hpp create mode 100644 runtime/tests/test_render_pass_ot.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 2587b785..5a23c3f8 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1095,6 +1095,11 @@ 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) + 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) diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index 23750a13..a83196e2 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; diff --git a/runtime/include/render_pass_ot.hpp b/runtime/include/render_pass_ot.hpp new file mode 100644 index 00000000..743e1f05 --- /dev/null +++ b/runtime/include/render_pass_ot.hpp @@ -0,0 +1,102 @@ +#pragma once +#include "render_pass_replay.hpp" +#include "gpu.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) { + return !(p&3) && p>=0x10000 && p<=memory_get_ram_bytes() && + bytes<=memory_get_ram_bytes()-p; + } +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 +static uint32_t ram[2*1024*1024/4]; +extern "C" { +int g_psx_render_pass_active=1; +uint32_t memory_get_ram_bytes(){return sizeof ram;} +uint32_t psx_mod_read_word(uint32_t p){return ram[(p&0x1FFFFFFF)/4];} +void psx_mod_write_word(uint32_t p,uint32_t v){ram[(p&0x1FFFFFFF)/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"); + 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"); + g_psx_render_pass_active=0; + check(!PSXOTReplay::retarget_y(p,256,0,240),"outside sandbox refused"); + std::puts("ALL PASS"); +} From 89e9909ed0048f3a96be4f027e59e80475d895c5 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 01:30:01 -0700 Subject: [PATCH 28/64] Preserve independently submitted HUD lists during scene replay --- runtime/include/render_pass_ot.hpp | 8 ++++++++ runtime/tests/test_render_pass_ot.cpp | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/runtime/include/render_pass_ot.hpp b/runtime/include/render_pass_ot.hpp index 743e1f05..c8440544 100644 --- a/runtime/include/render_pass_ot.hpp +++ b/runtime/include/render_pass_ot.hpp @@ -26,6 +26,14 @@ public: for(uint32_t i=0;i Date: Wed, 7 Oct 2026 01:43:15 -0700 Subject: [PATCH 29/64] Identify the guest caller of refused replay device reads --- runtime/src/render_pass.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index b92612fe..3cc79753 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -599,6 +599,11 @@ int render_pass_mmio_read_allowed(uint32_t phys, uint32_t width) { 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) { From 0b225784676c87eedd5769ea0f8551c366c90505 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 01:49:32 -0700 Subject: [PATCH 30/64] Keep bounded draw spans in charge of overlay call returns --- runtime/src/dirty_ram_interp.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index d3e655dc..ec3b64f3 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -3126,7 +3126,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 From d1d35c32e56fdcc92b6d073dbe156236cb0fd59e Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 04:36:36 -0700 Subject: [PATCH 31/64] Preserve padded OT allocations with indexed late-packet relocation --- runtime/include/render_pass_ot_relocation.hpp | 287 ++++++++++++++++++ tests/test_render_pass_ot_relocation.cpp | 125 ++++++++ 2 files changed, 412 insertions(+) create mode 100644 runtime/include/render_pass_ot_relocation.hpp create mode 100644 tests/test_render_pass_ot_relocation.cpp diff --git a/runtime/include/render_pass_ot_relocation.hpp b/runtime/include/render_pass_ot_relocation.hpp new file mode 100644 index 00000000..e2d54a75 --- /dev/null +++ b/runtime/include/render_pass_ot_relocation.hpp @@ -0,0 +1,287 @@ +#pragma once +#include +#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 { + if(word(ram,change.address)!=word(after_core.data(),change.address))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 + +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); + CHECK(relocated.prepare(ram.data(),ram.size(),0x80001320)); + ram=recorded_core;put(ram,0x1800,0x80001340); + 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); + 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; +} From 5cf009df8404130b66c104f8c3e0ca7757131b83 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 09:33:59 -0700 Subject: [PATCH 32/64] Preserve padded OT allocations with indexed late-packet relocation --- runtime/include/render_pass_ot_relocation.hpp | 12 +++++++++++- tests/test_render_pass_ot_relocation.cpp | 7 +++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/runtime/include/render_pass_ot_relocation.hpp b/runtime/include/render_pass_ot_relocation.hpp index e2d54a75..841fc142 100644 --- a/runtime/include/render_pass_ot_relocation.hpp +++ b/runtime/include/render_pass_ot_relocation.hpp @@ -266,7 +266,17 @@ public: if((change.value&0x1fffffu)!=end)return refuse(__LINE__); splice_writes.push_back({change.address,(current_cursor&0xffe00000u)|new_end}); } else { - if(word(ram,change.address)!=word(after_core.data(),change.address))return refuse(__LINE__); + // 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); } } diff --git a/tests/test_render_pass_ot_relocation.cpp b/tests/test_render_pass_ot_relocation.cpp index 889c0e88..f377a00c 100644 --- a/tests/test_render_pass_ot_relocation.cpp +++ b/tests/test_render_pass_ot_relocation.cpp @@ -98,8 +98,10 @@ int main() { 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()); @@ -108,6 +110,11 @@ int main() { 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); From 77f3a909236849ba662e9c1b93e49c6a4de971f6 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 10:08:38 -0700 Subject: [PATCH 33/64] Preserve queued native frames and batch guarded GPU submission --- runtime/CMakeLists.txt | 9 +++ runtime/include/packet_address_index.h | 31 +++++++++ runtime/include/render_pass_gpu_submit.hpp | 57 ++++++++++++++++ runtime/include/render_pass_plan.h | 7 ++ runtime/src/gpu.c | 21 +++--- runtime/src/gpu_gl_renderer.c | 76 +++++++++++++--------- runtime/src/render_pass_plan.c | 14 ++++ runtime/tests/test_render_pass_plan.c | 13 ++++ tests/test_render_pass_gpu_submit.cpp | 65 ++++++++++++++++++ 9 files changed, 252 insertions(+), 41 deletions(-) create mode 100644 runtime/include/packet_address_index.h create mode 100644 runtime/include/render_pass_gpu_submit.hpp create mode 100644 tests/test_render_pass_gpu_submit.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 5a23c3f8..c6de56a0 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1100,6 +1100,15 @@ if(BUILD_TESTING) 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) 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 + +/* 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 lists only for now. Refuse an unsupported graph 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() || (address&3) || !seen.insert(address).second)return false; + const uint32_t header=psx_mod_read_word(address),words=header>>24; + for(uint32_t i=1;i<=words;++i) + if(psx_mod_gpu_dma_resolve_address(address+4*i)>=psx_ram_live_bytes())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_plan.h b/runtime/include/render_pass_plan.h index fea817a2..98fac31b 100644 --- a/runtime/include/render_pass_plan.h +++ b/runtime/include/render_pass_plan.h @@ -15,6 +15,13 @@ #include #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 diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 14bb9e90..df392fa3 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -163,6 +163,8 @@ typedef struct { WsPrepassPacketGuard payload_guard; } WsUiPrepassNode; static WsUiPrepassNode ws_ui_prepass_nodes[WS_UI_PREPASS_NODE_MAX]; +#include "packet_address_index.h" +static PSXPacketAddressIndex ws_ui_prepass_node_index[WS_UI_PREPASS_NODE_MAX*2u]; /* Why the last prepass was discarded (ws_ui_groups "stale_why"): 1 packet * guard at draw, 2 DMA node not in the prepass, 3 node header changed, * 4 node word count changed, 5 node payload changed. */ @@ -6188,6 +6190,7 @@ static int ws_ui_attached(const WsUiPrepassItem *a, const WsUiPrepassItem *b) { void gpu_ws_prepass_linked_list(uint32_t start_addr) { ws_ui_prepass_count = 0; ws_ui_prepass_node_count = 0; + psx_packet_address_index_clear(ws_ui_prepass_node_index,WS_UI_PREPASS_NODE_MAX*2u); ws_ui_prepass_rank = 0xFFFFu; ws_auto_ui_dense = 0; ws_ui_reject.opcode = ws_ui_reject.not_axis = ws_ui_reject.degenerate = @@ -6228,6 +6231,8 @@ void gpu_ws_prepass_linked_list(uint32_t start_addr) { WsUiPrepassNode *node = &ws_ui_prepass_nodes[ws_ui_prepass_node_count++]; node->addr = GPU_RAM_KEY(addr); + psx_packet_address_index_add(ws_ui_prepass_node_index,WS_UI_PREPASS_NODE_MAX*2u, + node->addr,ws_ui_prepass_node_count-1u); node->header = header; node->payload_guard = ws_prepass_packet_guard(payload, num_words); @@ -6466,8 +6471,9 @@ void gpu_ws_validate_linked_list_header(uint32_t addr, uint32_t header) { uint32_t resolved = GPU_RAM_KEY(psx_mod_gpu_dma_resolve_address(addr)); - for (uint32_t i = 0; i < ws_ui_prepass_node_count; i++) { - if (ws_ui_prepass_nodes[i].addr != resolved) continue; + const uint32_t i=psx_packet_address_index_find(ws_ui_prepass_node_index, + WS_UI_PREPASS_NODE_MAX*2u,resolved); + if (i < ws_ui_prepass_node_count) { if (ws_ui_prepass_nodes[i].header != header) { ws_ui_prepass_invalidate_stale_why(3, resolved); ws_ui_prepass_invalidate_stale(); @@ -6483,13 +6489,10 @@ void gpu_ws_validate_linked_list_node(uint32_t addr, uint32_t num_words) { const uint32_t node_addr = psx_mod_gpu_dma_resolve_address(addr); const uint32_t resolved = GPU_RAM_KEY(node_addr); - const WsUiPrepassNode *node = NULL; - for (uint32_t i = 0; i < ws_ui_prepass_node_count; i++) { - if (ws_ui_prepass_nodes[i].addr == resolved) { - node = &ws_ui_prepass_nodes[i]; - break; - } - } + const uint32_t i=psx_packet_address_index_find(ws_ui_prepass_node_index, + WS_UI_PREPASS_NODE_MAX*2u,resolved); + const WsUiPrepassNode *node = i < ws_ui_prepass_node_count + ? &ws_ui_prepass_nodes[i] : NULL; if (!node || node->payload_guard.word_count != num_words) { ws_ui_prepass_invalidate_stale_why(node ? 4 : 2, resolved); ws_ui_prepass_invalidate_stale(); diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 21e7d48f..3a170881 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -6037,13 +6037,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; /* guest VRAM rect */ int tex_w, tex_h; /* slot size (hr pixels; wide band if wide) */ int source_path; /* GL_PRES_VRAM or GL_PRES_WIDE */ @@ -6054,7 +6056,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 { @@ -6074,13 +6077,14 @@ enum { STEREO_CAPTURE_MAX = 100 }; static GLRenderStereoCapture s_stereo_captures[STEREO_CAPTURE_MAX]; static uint32_t s_stereo_capture_count; /* 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 */ @@ -6128,9 +6132,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; @@ -6449,8 +6452,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; @@ -6544,7 +6551,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 (stereo) { StereoPair *pair = &s_stereo_pair[1 - s_stereo_current]; @@ -6555,6 +6562,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 = x; g->y = y; g->w = w; g->h = h; g->tex_w = tw; g->tex_h = th; g->source_path = wide ? GL_PRES_WIDE : GL_PRES_VRAM; @@ -6637,7 +6645,7 @@ backed_up: } static void transaction_restore(void) { - int S = s_hr_scale, gi = 1 - s_pgen_cur; + int S = s_hr_scale, gi = s_pgen_write; (void)gi; if (!s_pass_active) return; /* Roll the journal, then the rect back. */ @@ -6689,7 +6697,7 @@ int gl_renderer_stereo_begin(int x, int y, int w, int h, int reuse) { return transaction_begin(x, y, w, h, 0, 0, reuse, 1); } void gl_renderer_pass_end(uint32_t alpha_q16, int keep) { - 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(); @@ -7106,10 +7114,11 @@ uint64_t gl_renderer_pass_journaled(void) { return s_pj_total; } uint64_t gl_renderer_pass_backups_reused(void) { return s_pb_reused; } uint32_t gl_renderer_pass_image_textures(uint64_t *bytes) { - 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< +#include +#include +#include + +using Event=std::tuple; +static uint32_t ram[256]{}; +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 a&g_psx_ram_mask&~3u;} +uint32_t psx_mod_read_word(uint32_t a){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); + 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"); +} From 4b754210bdd6aca64af64eb8fe35c835728b8442 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 10:27:26 -0700 Subject: [PATCH 34/64] Retarget native render packets in the allocated GPU aperture --- runtime/include/mod_memory.h | 3 +++ runtime/include/render_pass_ot.hpp | 6 ++++-- runtime/src/memory.c | 4 ++++ runtime/tests/test_render_pass_ot.cpp | 16 ++++++++++++++-- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/runtime/include/mod_memory.h b/runtime/include/mod_memory.h index bab68684..c28b8362 100644 --- a/runtime/include/mod_memory.h +++ b/runtime/include/mod_memory.h @@ -68,6 +68,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/render_pass_ot.hpp b/runtime/include/render_pass_ot.hpp index c8440544..52c33c89 100644 --- a/runtime/include/render_pass_ot.hpp +++ b/runtime/include/render_pass_ot.hpp @@ -1,6 +1,7 @@ #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 @@ -14,8 +15,9 @@ class PSXOTReplay { 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) { - return !(p&3) && p>=0x10000 && p<=memory_get_ram_bytes() && - bytes<=memory_get_ram_bytes()-p; + 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(); } diff --git a/runtime/src/memory.c b/runtime/src/memory.c index a3ba149c..5a5cded4 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -144,6 +144,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); } diff --git a/runtime/tests/test_render_pass_ot.cpp b/runtime/tests/test_render_pass_ot.cpp index b88f6ac1..5ccd0b6d 100644 --- a/runtime/tests/test_render_pass_ot.cpp +++ b/runtime/tests/test_render_pass_ot.cpp @@ -2,11 +2,13 @@ #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;} -uint32_t psx_mod_read_word(uint32_t p){return ram[(p&0x1FFFFFFF)/4];} -void psx_mod_write_word(uint32_t p,uint32_t v){ram[(p&0x1FFFFFFF)/4]=v;} +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; @@ -46,6 +48,16 @@ int main() { 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"); From 4b090010789cef1bae5cf608df9af518e0d3bd08 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 11:01:50 -0700 Subject: [PATCH 35/64] Record current owner review candidates and post-restart acceptance policy --- docs/PARITY_CAMPAIGN.md | 69 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/docs/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md index d5045bb1..e331e450 100644 --- a/docs/PARITY_CAMPAIGN.md +++ b/docs/PARITY_CAMPAIGN.md @@ -25,7 +25,8 @@ 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; -its title binding and actual visual result still need review. Tracking: +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 @@ -52,7 +53,63 @@ its title binding and actual visual result still need review. Tracking: - Owner playtesting supplies final acceptance. No title is marked complete merely because its settings expose these options. -## Starting inventory and remaining work +## 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: keep native interpolation **default on**. The earlier +default-off preference was withdrawn. Tomba 1/2, MMX4/5, Tsumu and V8 catalogs +now default their native feature on; other current candidates already do. +Existing explicit user choices are preserved. Select Display refresh for review. + +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 | Title `92235ff`, framework `77f3a909`; guarded bulk submission, indexed packet guards, queued native-frame generations, resident distance controls | All-level performance, flyover/Crumbling Castle/transition audio, ultrawide mailbox, CD subdivision residency/object activation | +| 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 | Built native replay/distance candidate and fresh historical captures | Wider visibility, loading and existing multiplayer review | +| 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). @@ -111,9 +168,9 @@ against the new worktree remains outstanding. ## Execution and promotion -The owner authorized one Sol worker per title in bounded waves. First wave: -Ape Escape, Spider-Man 2, and Vigilante 8. Shared changes are owned by the -coordinator. At most two compile jobs and one game process run at a time; +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 @@ -243,7 +300,7 @@ native median 0.130985 s (5.012x for hashing). Receipts are private under 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. -## Title source milestones awaiting execution +## Historical title preparation milestones Tomba! source preparation is local commit `a46ca8ab` on `feat/tomba1-parity-hle-20261006`, retaining the newer resident kit at From b3bc77e8ece40b94a4fb4f447f27500c2a20fbcd Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 15:05:08 -0700 Subject: [PATCH 36/64] Record Ape approval and enabled review presets --- docs/PARITY_CAMPAIGN.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md index e331e450..6c7577d4 100644 --- a/docs/PARITY_CAMPAIGN.md +++ b/docs/PARITY_CAMPAIGN.md @@ -61,10 +61,16 @@ 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: keep native interpolation **default on**. The earlier +Latest owner policy: keep native interpolation **default on**, except Ape Escape. The earlier default-off preference was withdrawn. Tomba 1/2, MMX4/5, Tsumu and V8 catalogs now default their native feature on; other current candidates already do. -Existing explicit user choices are preserved. Select Display refresh for review. +Ape Escape is owner-approved with interpolation **off and hidden** after the +owner A/B review found the choppiness disappears with it disabled. Its package +is absent from the shipped catalog and retained only under `mods/development`. +The other 14 review presets enable available visual/loading enhancements and +Display-refresh interpolation; all 14 launcher menus were requested open +together. Actual gameplay remains one title at a time. `GAME-PATHS.txt` lists +every exact executable, and previous review settings are backed up. The owner suspects background system contention/long uptime and will perform final review after restarting. Further performance comparisons on this session @@ -80,7 +86,7 @@ measurement overhead, not an established cause of the reported hitching. | 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 | Title `92235ff`, framework `77f3a909`; guarded bulk submission, indexed packet guards, queued native-frame generations, resident distance controls | All-level performance, flyover/Crumbling Castle/transition audio, ultrawide mailbox, CD subdivision residency/object activation | +| 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 | From c371091002be9e4226fdd57f8282fdaf9182d43e Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 15:38:53 -0700 Subject: [PATCH 37/64] Retire generic loading pacing from parity catalogs --- docs/HLE_EXECUTION.md | 22 ++++++++++++++ docs/PARITY_CAMPAIGN.md | 30 ++++++++++++------- .../1.0.0/manifest.toml | 8 ++--- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/docs/HLE_EXECUTION.md b/docs/HLE_EXECUTION.md index 77f1495f..14d5aa8a 100644 --- a/docs/HLE_EXECUTION.md +++ b/docs/HLE_EXECUTION.md @@ -79,6 +79,28 @@ 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 and MediEvil II have resident/loading families. +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 diff --git a/docs/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md index 6c7577d4..328ced98 100644 --- a/docs/PARITY_CAMPAIGN.md +++ b/docs/PARITY_CAMPAIGN.md @@ -61,16 +61,26 @@ 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: keep native interpolation **default on**, except Ape Escape. The earlier -default-off preference was withdrawn. Tomba 1/2, MMX4/5, Tsumu and V8 catalogs -now default their native feature on; other current candidates already do. -Ape Escape is owner-approved with interpolation **off and hidden** after the -owner A/B review found the choppiness disappears with it disabled. Its package -is absent from the shipped catalog and retained only under `mods/development`. -The other 14 review presets enable available visual/loading enhancements and -Display-refresh interpolation; all 14 launcher menus were requested open -together. Actual gameplay remains one title at a time. `GAME-PATHS.txt` lists -every exact executable, and previous review settings are backed up. +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. Other packages also 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 and MediEvil II retain their title resident/loading families. +The other ten 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 track fails in owner testing, while manual track selection starts playback; +that issue is unqualified even after removing generic pacing. `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 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 From 9f673fa2b2208063fce4debc97b01581f5ad795c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 15:43:08 -0700 Subject: [PATCH 38/64] Fix GetlocP drive position after explicit CD seeks --- runtime/src/cdrom.c | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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) { From 968788ccf659aef48f4dca56926ee8f20037123a Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 16:11:35 -0700 Subject: [PATCH 39/64] fix: accept allocated primitive arenas in sandbox OT submission --- runtime/include/render_pass_gpu_submit.hpp | 13 ++++++++++--- tests/test_render_pass_gpu_submit.cpp | 21 +++++++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/runtime/include/render_pass_gpu_submit.hpp b/runtime/include/render_pass_gpu_submit.hpp index 629da269..c555ce87 100644 --- a/runtime/include/render_pass_gpu_submit.hpp +++ b/runtime/include/render_pass_gpu_submit.hpp @@ -11,7 +11,8 @@ * 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 lists only for now. Refuse an unsupported graph before GPU output. + * 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; }; @@ -26,10 +27,16 @@ public: 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() || (address&3) || !seen.insert(address).second)return false; + 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())return false; + 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); diff --git a/tests/test_render_pass_gpu_submit.cpp b/tests/test_render_pass_gpu_submit.cpp index 371809a4..b1175809 100644 --- a/tests/test_render_pass_gpu_submit.cpp +++ b/tests/test_render_pass_gpu_submit.cpp @@ -11,12 +11,18 @@ 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 a&g_psx_ram_mask&~3u;} -uint32_t psx_mod_read_word(uint32_t a){return ram[(a&g_psx_ram_mask)/4];} +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);} @@ -48,6 +54,17 @@ int main(){ 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()); From 7d8b0c117a19cbb4778ef5d405a008d1be77a138 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 16:11:42 -0700 Subject: [PATCH 40/64] docs: record approved THPS2 loading and serial interpolation review --- docs/HLE_EXECUTION.md | 4 +++- docs/PARITY_CAMPAIGN.md | 10 ++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/HLE_EXECUTION.md b/docs/HLE_EXECUTION.md index 14d5aa8a..af4cf434 100644 --- a/docs/HLE_EXECUTION.md +++ b/docs/HLE_EXECUTION.md @@ -95,7 +95,9 @@ 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 and MediEvil II have resident/loading families. +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 diff --git a/docs/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md index 328ced98..0629da83 100644 --- a/docs/PARITY_CAMPAIGN.md +++ b/docs/PARITY_CAMPAIGN.md @@ -70,16 +70,18 @@ default off. Other packages also 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 and MediEvil II retain their title resident/loading families. -The other ten title loaders remain unfinished; geometry HLE, BIOS shell skipping +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 track fails in owner testing, while manual track selection starts playback; -that issue is unqualified even after removing generic pacing. `GAME-PATHS.txt` +music playback is owner-approved after the shared GetlocP position fix; +its title resident asset loading is also owner-approved, default on. +Interpolation remains default off and receives at most two new owner-reviewed +repair attempts. Work now 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 From 972d4f4c71ce1344dfb5a0f770b24ac09e8840f5 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 16:19:50 -0700 Subject: [PATCH 41/64] fix: restore draw-entry primitive arenas through the render sandbox journal --- runtime/include/render_pass_replay.hpp | 10 +++++++++- runtime/src/memory.c | 12 +++++++++++- runtime/tests/test_mod_memory_snapshot.c | 17 +++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/runtime/include/render_pass_replay.hpp b/runtime/include/render_pass_replay.hpp index c707eab7..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,6 +19,7 @@ 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; @@ -45,6 +47,8 @@ public: 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; if (projections) psx_projection_capture_begin(history); } @@ -62,7 +66,11 @@ public: 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 diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 5a5cded4..f0f88fd0 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -40,6 +40,9 @@ #define BIOS_ROM_SIZE (512 * 1024) #define MOD_MEMORY_BASE 0x1F000000u #define MOD_MEMORY_SIZE (1u * 1024u * 1024u) +#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]; @@ -89,6 +92,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; @@ -1847,7 +1858,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/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; } From 7133af4daeb617950992e1b0a23a4f4d232a5299 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 16:36:23 -0700 Subject: [PATCH 42/64] Record rejected THPS2 interpolation and retained loading approval --- docs/PARITY_CAMPAIGN.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md index 0629da83..f96c4ab8 100644 --- a/docs/PARITY_CAMPAIGN.md +++ b/docs/PARITY_CAMPAIGN.md @@ -65,7 +65,10 @@ 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. Other packages also remain opt-in pending title qualification. +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 @@ -80,8 +83,8 @@ 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. -Interpolation remains default off and receives at most two new owner-reviewed -repair attempts. Work now proceeds serially by title. `GAME-PATHS.txt` +THPS2 interpolation is disabled and hidden after both repair attempts failed; +no third attempt is authorized. 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 @@ -102,7 +105,7 @@ measurement overhead, not an established cause of the reported hitching. | 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 | Built native replay/distance candidate and fresh historical captures | Wider visibility, loading and existing multiplayer review | +| 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 | From 5b8d39e5bb6fb49f0341f7900923c5e456da4db0 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 16:52:26 -0700 Subject: [PATCH 43/64] Track PGXP geometry and texture depth in allocated GPU primitive buffers --- runtime/CMakeLists.txt | 5 +++ runtime/src/pgxp.cpp | 58 ++++++++++++++++++------- runtime/tests/pgxp_test_mod_memory.cpp | 5 +++ runtime/tests/test_pgxp_word_shadow.cpp | 49 +++++++++++++++++++++ 4 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 runtime/tests/pgxp_test_mod_memory.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index c6de56a0..119a55f1 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -550,6 +550,11 @@ if(BUILD_TESTING) endif() add_test(NAME pgxp_session_test COMMAND pgxp_session_test) + 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) diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp index a160e913..fdcc4fc2 100644 --- a/runtime/src/pgxp.cpp +++ b/runtime/src/pgxp.cpp @@ -35,6 +35,7 @@ #include "pgxp_hooks.h" #include "cpu_state.h" #include "psx_memory.h" +#include "mod_memory.h" #include #include @@ -81,6 +82,12 @@ struct PGXPValue { #define PGXP_REG_LO 33 static PGXPValue *s_ram = nullptr; /* lazily allocated, 72 MiB VA */ +// Extended draw buffers live outside the main-RAM decode window. Give their +// allocated words separate shadows, in stable pages created only on writes. +// Read misses never allocate, and a page cannot move while a checkpoint uses it. +#define PGXP_GPU_PAGE_WORDS 1024u +#define PGXP_GPU_WORDS (PSX_MOD_GPU_DMA_APERTURE_SIZE >> 2) +static PGXPValue *s_gpu_pages[PGXP_GPU_WORDS / PGXP_GPU_PAGE_WORDS]; static PGXPValue s_scratch[PGXP_SCRATCH_WORDS]; static PGXPValue s_gpr[34]; /* 32 GPRs + HI + LO */ static PGXPValue s_gte[32]; /* GTE data registers */ @@ -137,6 +144,8 @@ extern "C" void pgxp_invalidate_all(void) { ck_wrapped(); /* generation wrapped: physically clear so stale slots can't revive */ if (s_ram) std::memset(s_ram, 0, PGXP_RAM_WORDS * sizeof(PGXPValue)); + for (PGXPValue* page : s_gpu_pages) + if (page) std::memset(page, 0, PGXP_GPU_PAGE_WORDS * sizeof(PGXPValue)); std::memset(s_scratch, 0, sizeof(s_scratch)); std::memset(s_gpr, 0, sizeof(s_gpr)); std::memset(s_gte, 0, sizeof(s_gte)); @@ -315,12 +324,21 @@ extern "C" uint64_t pgxp_store_ring(const PGXPStoreRecord **ring, uint32_t *cap) /* ------------------------------------------------------------------------- */ /* Guest address -> shadow slot, or NULL for BIOS/MMIO/KSEG2 (untrackable). */ -static inline PGXPValue *pgxp_ptr(uint32_t addr) { +static inline PGXPValue *pgxp_ptr(uint32_t addr, bool create = false) { uint32_t m = addr & 0x1FFFFFFFu; if (m < PSX_MAIN_RAM_WINDOW_BYTES) /* RAM + its mirrors (live map) */ return s_ram ? &s_ram[psx_ram_canonical_offset(m) >> 2] : nullptr; if ((m & 0xFFFFFC00u) == 0x1F800000u) /* scratchpad */ return &s_scratch[(m & 0x3FCu) >> 2]; + if (addr < 0xC0000000u && m >= PSX_MOD_GPU_DMA_APERTURE_BASE && + m < PSX_MOD_GPU_DMA_APERTURE_BASE + PSX_MOD_GPU_DMA_APERTURE_SIZE && + psx_mod_gpu_dma_memory_contains(addr & ~3u, 4u)) { + const uint32_t word = (m - PSX_MOD_GPU_DMA_APERTURE_BASE) >> 2; + PGXPValue*& page = s_gpu_pages[word / PGXP_GPU_PAGE_WORDS]; + if (!page && create) + page = static_cast(std::calloc(PGXP_GPU_PAGE_WORDS, sizeof(PGXPValue))); + return page ? &page[word % PGXP_GPU_PAGE_WORDS] : nullptr; + } return nullptr; } @@ -339,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; }; @@ -353,21 +372,25 @@ static PGXPScalar s_ck_gpr_s[34], s_ck_gte_s[32], s_ck_gtc_s[32]; static uint32_t s_ck_gen = 0, s_ck_suppress = 0; static int s_ck_deferred = 0; -static inline size_t ck_index(const PGXPValue *pv) { - if (pv >= s_scratch && pv < s_scratch + PGXP_SCRATCH_WORDS) - return (size_t)PGXP_RAM_WORDS + (size_t)(pv - s_scratch); - return (size_t)(pv - s_ram); +static inline size_t ck_index(uint32_t addr) { + const uint32_t physical = addr & 0x1FFFFFFFu; + if (physical < PSX_MAIN_RAM_WINDOW_BYTES) + return psx_ram_canonical_offset(physical) >> 2; + if ((physical & 0xFFFFFC00u) == 0x1F800000u) + return (size_t)PGXP_RAM_WORDS + ((physical & 0x3FCu) >> 2); + return (size_t)PGXP_RAM_WORDS + PGXP_SCRATCH_WORDS + + ((physical - PSX_MOD_GPU_DMA_APERTURE_BASE) >> 2); } /* 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 && (addr & 0x1FFFFFFFu) < PSX_MAIN_RAM_WINDOW_BYTES) { s_ck_lossy = 1; /* shadow RAM appeared mid-pass */ return; } - size_t i = ck_index(pv); + size_t i = ck_index(addr); uint8_t bit = (uint8_t)(1u << (i & 7u)); if (s_ck_bits[i >> 3] & bit) return; if (s_ck_n == s_ck_cap) { @@ -380,6 +403,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++; } @@ -391,8 +415,8 @@ 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; } @@ -407,6 +431,10 @@ static int packet_shadow_address(uint32_t addr, uint32_t *canonical) { *canonical = physical; return 1; } + if (psx_mod_gpu_dma_memory_contains(addr, 4u)) { + *canonical = physical; + return 1; + } return 0; } @@ -440,10 +468,10 @@ static int restore_word_shadow(uint32_t addr, uint32_t expected, 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); + PGXPValue *destination = pgxp_ptr(addr, in->valid != 0u); // With PGXP disabled/unallocated, there is already no RAM shadow to clear. if (!destination) return in->valid == 0u; - ck_note(destination); + ck_note(destination, addr); if (s_ck_lossy) return 0; PGXPValue restored{}; if (in->valid) { @@ -474,7 +502,7 @@ extern "C" void pgxp_checkpoint_begin(void) { if (s_ck_depth++ != 0) return; /* the outermost pass journals */ if (!s_ck_bits) { s_ck_bits = (uint8_t *)std::calloc( - ((size_t)PGXP_RAM_WORDS + PGXP_SCRATCH_WORDS + 7u) / 8u, 1); + ((size_t)PGXP_RAM_WORDS + PGXP_SCRATCH_WORDS + PGXP_GPU_WORDS + 7u) / 8u, 1); if (!s_ck_bits) s_ck_lossy = 1; } s_ck_ram = s_ram; @@ -496,7 +524,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; 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/test_pgxp_word_shadow.cpp b/runtime/tests/test_pgxp_word_shadow.cpp index 9d421b5a..e649e8c0 100644 --- a/runtime/tests/test_pgxp_word_shadow.cpp +++ b/runtime/tests/test_pgxp_word_shadow.cpp @@ -1,11 +1,16 @@ #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; @@ -141,6 +146,50 @@ int main() { 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); + 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; } From 071bacb1154b58c24d23b7b0731283fdc9f8192d Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 16:56:28 -0700 Subject: [PATCH 44/64] Record serial fidelity scope and expanded-buffer texture correction --- docs/ENHANCEMENTS.md | 19 +++++++++++++++++++ docs/PARITY_CAMPAIGN.md | 14 ++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/ENHANCEMENTS.md b/docs/ENHANCEMENTS.md index 2a7ab42c..7f811bb9 100644 --- a/docs/ENHANCEMENTS.md +++ b/docs/ENHANCEMENTS.md @@ -1360,3 +1360,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/PARITY_CAMPAIGN.md b/docs/PARITY_CAMPAIGN.md index f96c4ab8..49170720 100644 --- a/docs/PARITY_CAMPAIGN.md +++ b/docs/PARITY_CAMPAIGN.md @@ -32,9 +32,10 @@ with the labels. Broader visual review remains open. Tracking: ## Acceptance target - Enhanced OpenGL is the normal renderer, with 1080p internal resolution. -- Native scene interpolation targets Display refresh. Temporal image blending - alone does not meet this requirement. HUD, FMV, menus, pauses and transitions - need appropriate presentation behavior without advancing gameplay twice. +- 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 @@ -84,7 +85,12 @@ 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. Work proceeds serially by title. `GAME-PATHS.txt` +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 From b77b18a3b1b53e973ded64ad0e9ed1bc79d2fb4a Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 18:06:07 -0700 Subject: [PATCH 45/64] Preserve PGXP projection and depth through flagged subdivision stores --- runtime/include/pgxp.h | 7 +++++ runtime/src/pgxp.cpp | 42 ++++++++++++++++++++++--- runtime/tests/test_pgxp.cpp | 12 +++++-- runtime/tests/test_pgxp_word_shadow.cpp | 25 +++++++++++++++ 4 files changed, 79 insertions(+), 7 deletions(-) diff --git a/runtime/include/pgxp.h b/runtime/include/pgxp.h index cedac6da..aff0f216 100644 --- a/runtime/include/pgxp.h +++ b/runtime/include/pgxp.h @@ -280,6 +280,13 @@ void pgxp_note_nclip(int disagree, int corrected); /* SWC2 site: copy the GTE register shadow (regs 12..15) to the RAM shadow. */ void pgxp_store_gte_reg(uint32_t addr, uint8_t reg); +/* After a title's original store: retain a projected SXY's precision/depth + * when only clip/outcode bits outside the GPU's two 11-bit fields changed. + * The caller supplies the current GTE word and actual stored word. Changed + * coordinates, clamping, stale sources and unmapped destinations fail closed. + * Host shadow only; no guest memory/register write. */ +int pgxp_store_flagged_gte_sxy(uint32_t addr, uint8_t reg, + uint32_t source_word, uint32_t stored_word); /* Address-keyed precise-word lookup (perspective texturing path). Returns * nonzero when the tracked word matches `packed` AND carries a depth. */ diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp index fdcc4fc2..ee6a6286 100644 --- a/runtime/src/pgxp.cpp +++ b/runtime/src/pgxp.cpp @@ -999,6 +999,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; } @@ -1646,9 +1657,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; @@ -1656,7 +1667,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; @@ -1815,6 +1828,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/tests/test_pgxp.cpp b/runtime/tests/test_pgxp.cpp index 3bbc8775..1e08bc5a 100644 --- a/runtime/tests/test_pgxp.cpp +++ b/runtime/tests/test_pgxp.cpp @@ -239,12 +239,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); @@ -266,11 +270,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 index e649e8c0..74d640fc 100644 --- a/runtime/tests/test_pgxp_word_shadow.cpp +++ b/runtime/tests/test_pgxp_word_shadow.cpp @@ -188,6 +188,31 @@ int main() { 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"); From fd489f47ec64e8aed3d3e5ab431f9bd6fd1cad4f Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 18:32:54 -0700 Subject: [PATCH 46/64] Share validated Neversoft archive and renderer extensions --- runtime/CMakeLists.txt | 5 + runtime/include/mod_named_wad.hpp | 48 ++++++++ runtime/include/mod_neversoft_renderer.hpp | 125 +++++++++++++++++++++ runtime/tests/test_neversoft_helpers.cpp | 108 ++++++++++++++++++ 4 files changed, 286 insertions(+) create mode 100644 runtime/include/mod_named_wad.hpp create mode 100644 runtime/include/mod_neversoft_renderer.hpp create mode 100644 runtime/tests/test_neversoft_helpers.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 119a55f1..447507fa 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -897,6 +897,11 @@ if(BUILD_TESTING) endif() add_test(NAME mod_runtime_test COMMAND mod_runtime_test) + 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(mod_resident_test tests/test_mod_resident.cpp src/mod_resident.cpp 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_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/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"); +} From 609cc88dd23173ad83730c83a7210af61d8a35d3 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 19:18:52 -0700 Subject: [PATCH 47/64] Share strict aligned archive extent validation for resident loading --- runtime/CMakeLists.txt | 5 +++ runtime/include/mod_aligned_archive.hpp | 55 +++++++++++++++++++++++++ runtime/tests/test_aligned_archive.cpp | 27 ++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 runtime/include/mod_aligned_archive.hpp create mode 100644 runtime/tests/test_aligned_archive.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 447507fa..91853561 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -902,6 +902,11 @@ if(BUILD_TESTING) 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(mod_resident_test tests/test_mod_resident.cpp src/mod_resident.cpp 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/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)); +} From 502393296a67e294f61b0f337484414543678dae Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 19:42:21 -0700 Subject: [PATCH 48/64] Share bounded resident Mode 2 sector cursor for title loader HLE --- runtime/CMakeLists.txt | 5 ++++ runtime/include/mod_resident_sector.hpp | 37 +++++++++++++++++++++++++ runtime/tests/test_resident_sector.cpp | 26 +++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 runtime/include/mod_resident_sector.hpp create mode 100644 runtime/tests/test_resident_sector.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 91853561..659d2221 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -907,6 +907,11 @@ if(BUILD_TESTING) 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(mod_resident_test tests/test_mod_resident.cpp src/mod_resident.cpp 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/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); +} From ec8c5c54c2cbe72573026d3f6ecc3d496ff55dd7 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 21:30:51 -0700 Subject: [PATCH 49/64] Share authored-map Capcom adaptive background packet rendering --- runtime/CMakeLists.txt | 3 + runtime/include/mod_capcom_bg2d.h | 144 +++++++++++++++++++++++++++ runtime/tests/test_mod_capcom_bg2d.c | 81 +++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 runtime/include/mod_capcom_bg2d.h create mode 100644 runtime/tests/test_mod_capcom_bg2d.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 659d2221..35bad1b3 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -23,6 +23,9 @@ psxrecomp_add_runtime_target(psx-runtime # (docs/ENHANCEMENTS.md G1.10). Titles get their _pgxp the same way. if(BUILD_TESTING) + 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 diff --git a/runtime/include/mod_capcom_bg2d.h b/runtime/include/mod_capcom_bg2d.h new file mode 100644 index 00000000..b2e196e0 --- /dev/null +++ b/runtime/include/mod_capcom_bg2d.h @@ -0,0 +1,144 @@ +#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); +} + +/* Use the same fractional scroll mapping as the retail parallax function. + * The title chooses the divisor; independent atlas layers may use their own + * authored bounds instead. */ +static inline WsViewAnchor psx_capcom_parallax_view(WsViewAnchor fg, int divisor, int camera) { + int extra = (fg.left + fg.right + fg.pad_left + fg.pad_right) / 2; + int origin = psx_capcom_floor_div(camera, divisor) - + psx_capcom_floor_div(camera - fg.left, divisor) + fg.pad_left; + WsViewAnchor result = {origin, 2 * extra - origin, origin - extra, fg.pad_left, fg.pad_right}; + return result; +} + +/* 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/tests/test_mod_capcom_bg2d.c b/runtime/tests/test_mod_capcom_bg2d.c new file mode 100644 index 00000000..3609c9fe --- /dev/null +++ b/runtime/tests/test_mod_capcom_bg2d.c @@ -0,0 +1,81 @@ +#ifdef NDEBUG +#undef NDEBUG +#endif +#include +#include +#include +#include "mod_capcom_bg2d.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) { + 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; +} From e740b81c3cf92314642383f25792b7180811fe23 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 7 Oct 2026 22:56:22 -0700 Subject: [PATCH 50/64] Preserve native fractional scroll mappings in Capcom wide views --- runtime/include/mod_capcom_bg2d.h | 23 ++++++++++++++++------- runtime/tests/test_mod_capcom_bg2d.c | 10 ++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/runtime/include/mod_capcom_bg2d.h b/runtime/include/mod_capcom_bg2d.h index b2e196e0..0c4fea98 100644 --- a/runtime/include/mod_capcom_bg2d.h +++ b/runtime/include/mod_capcom_bg2d.h @@ -52,17 +52,26 @@ static inline int psx_capcom_floor_div(int n, int d) { return n >= 0 ? n / d : -((-n + d - 1) / d); } -/* Use the same fractional scroll mapping as the retail parallax function. - * The title chooses the divisor; independent atlas layers may use their own - * authored bounds instead. */ -static inline WsViewAnchor psx_capcom_parallax_view(WsViewAnchor fg, int divisor, int camera) { +/* 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 = psx_capcom_floor_div(camera, divisor) - - psx_capcom_floor_div(camera - fg.left, divisor) + fg.pad_left; - WsViewAnchor result = {origin, 2 * extra - origin, origin - extra, fg.pad_left, fg.pad_right}; + 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, diff --git a/runtime/tests/test_mod_capcom_bg2d.c b/runtime/tests/test_mod_capcom_bg2d.c index 3609c9fe..3e0a993f 100644 --- a/runtime/tests/test_mod_capcom_bg2d.c +++ b/runtime/tests/test_mod_capcom_bg2d.c @@ -28,6 +28,16 @@ void gpu_ws_bg2d_set_host_arena(uint32_t base, uint32_t bytes) { } int main(void) { + 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. */ From bf4246f236a8c272e898d05c4c41bbb6f692c573 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 8 Oct 2026 05:49:27 -0700 Subject: [PATCH 51/64] Share newly exposed placement bounds for adaptive scene residency --- runtime/include/mod_visible_placements.h | 26 ++++++++++++++++++++++++ runtime/tests/test_mod_capcom_bg2d.c | 14 +++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 runtime/include/mod_visible_placements.h 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/tests/test_mod_capcom_bg2d.c b/runtime/tests/test_mod_capcom_bg2d.c index 3e0a993f..d98a44a9 100644 --- a/runtime/tests/test_mod_capcom_bg2d.c +++ b/runtime/tests/test_mod_capcom_bg2d.c @@ -5,6 +5,7 @@ #include #include #include "mod_capcom_bg2d.h" +#include "mod_visible_placements.h" static unsigned char memory[10u * 1024u * 1024u]; static uint32_t writes; @@ -28,6 +29,19 @@ void gpu_ws_bg2d_set_host_arena(uint32_t base, uint32_t bytes) { } 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); From 95010e1a2e9072c7b2c7d08a6f40d7a1f93012e2 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 8 Oct 2026 11:37:57 -0700 Subject: [PATCH 52/64] Omit unused Windows pip launchers from Linux release toolchains --- tools/release_stage.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tools/release_stage.py b/tools/release_stage.py index 990b8978..20364523 100644 --- a/tools/release_stage.py +++ b/tools/release_stage.py @@ -70,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: @@ -680,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'], From aa1035f9beaa591161fe66dc1a1c087890b75e0c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 8 Oct 2026 13:46:08 -0700 Subject: [PATCH 53/64] Add trusted co-op netplay profiles and host state digest policy --- docs/NETPLAY.md | 22 +++- overlay_toolchain/include/mod_netplay.h | 2 + overlay_toolchain/include/mod_plugins.h | 9 ++ runtime/CMakeLists.txt | 22 +++- runtime/include/mod_netplay.h | 49 +++++++ runtime/include/mod_plugins.h | 4 + runtime/include/mod_runtime.h | 8 +- runtime/include/netplay_state_digest.h | 1 + runtime/include/savestate.h | 6 + runtime/runtime.cmake | 1 + runtime/src/main.cpp | 52 +++++++- runtime/src/mod_netplay.c | 107 +++++++++++++++ runtime/src/mod_runtime.cpp | 52 +++++++- runtime/src/netplay_state_digest.c | 8 ++ runtime/src/psx_lobby_client.c | 2 + runtime/src/psx_netplay.c | 36 +++++- runtime/src/savestate.c | 27 ++++ runtime/tests/test_mod_netplay.c | 136 ++++++++++++++++++++ runtime/tests/test_mod_runtime.cpp | 65 +++++++++- runtime/tests/test_savestate_title_policy.c | 103 +++++++++++++++ 20 files changed, 695 insertions(+), 17 deletions(-) create mode 100644 overlay_toolchain/include/mod_netplay.h create mode 100644 runtime/include/mod_netplay.h create mode 100644 runtime/src/mod_netplay.c create mode 100644 runtime/tests/test_mod_netplay.c create mode 100644 runtime/tests/test_savestate_title_policy.c diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index e3475ed2..5c5de4d7 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -24,8 +24,26 @@ 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. + +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/overlay_toolchain/include/mod_netplay.h b/overlay_toolchain/include/mod_netplay.h new file mode 100644 index 00000000..b4d6e984 --- /dev/null +++ b/overlay_toolchain/include/mod_netplay.h @@ -0,0 +1,2 @@ +#pragma once +#include "../../runtime/include/mod_netplay.h" 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 9d06bc5d..01f4d164 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -23,6 +23,15 @@ 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_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) @@ -696,6 +705,7 @@ if(BUILD_TESTING) src/pgxp_session.cpp src/mod_builtin_pgxp.c src/pgxp.cpp src/psx_ram_geometry.c src/mod_runtime.cpp + src/mod_netplay.c src/mod_packages.cpp src/mod_media.cpp src/disc_path.cpp @@ -1064,6 +1074,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 @@ -1086,6 +1097,8 @@ 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_executable(neversoft_helpers_test tests/test_neversoft_helpers.cpp) target_include_directories(neversoft_helpers_test PRIVATE include) @@ -1105,6 +1118,7 @@ if(BUILD_TESTING) 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 @@ -1117,6 +1131,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) @@ -1132,6 +1147,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) @@ -1145,6 +1161,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 @@ -1485,7 +1502,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 @@ -1505,6 +1523,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) diff --git a/runtime/include/mod_netplay.h b/runtime/include/mod_netplay.h new file mode 100644 index 00000000..25756b80 --- /dev/null +++ b/runtime/include/mod_netplay.h @@ -0,0 +1,49 @@ +#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; +} 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); + +#ifdef __cplusplus +} +#endif diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 27d603b2..5702bebc 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -1009,6 +1009,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); /* Local P1 mouse policy. The runtime delivers ordered events on the SDL owner * (main) thread, owns relative capture and folds the resulting right-stick diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index dd735fca..d8d55d49 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -42,8 +42,12 @@ 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); +/* [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. */ 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/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/runtime.cmake b/runtime/runtime.cmake index 04191b12..ce7ff5f9 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -480,6 +480,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 diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index b3463e7b..52ba012c 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -6,6 +6,7 @@ */ #include "cpu_state.h" +#include "mod_netplay.h" #include "execution_profile.h" #include "projection_scale.hpp" #include "projection_scale_config.hpp" @@ -1414,6 +1415,9 @@ extern "C" int psx_mod_allow_direct_shortcut(uint32_t shortcut) { extern "C" void psx_mod_set_rewind_blocked(int blocked) { psx_rewind_set_title_blocked(blocked); } +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 */ @@ -1576,6 +1580,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 @@ -1583,6 +1600,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(); @@ -7516,6 +7541,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); @@ -12191,6 +12220,7 @@ namespace { caps->mod_plan_fp[0] = '\0'; caps->mod_count = 0; std::memset(caps->mods, 0, sizeof(caps->mods)); + if (psx_mod_netplay_profile()) return 1; 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) @@ -12257,6 +12287,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; @@ -12268,7 +12299,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; @@ -12319,6 +12350,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; @@ -12327,7 +12359,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; @@ -12861,6 +12893,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) @@ -12869,7 +12902,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; } @@ -17850,6 +17883,7 @@ int main(int argc, char** argv) { g_direct_shortcut_allowed = 0; 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; @@ -17873,6 +17907,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) @@ -19011,6 +19051,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 diff --git a/runtime/src/mod_netplay.c b/runtime/src/mod_netplay.c new file mode 100644 index 00000000..db466460 --- /dev/null +++ b/runtime/src/mod_netplay.c @@ -0,0 +1,107 @@ +#include "mod_netplay.h" +#include "psx_sha256.h" +#include +#include + +static const PSXModNetplayProfile *title_profile; +static int profile_active; +static int profile_aspect; + +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])) + 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_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[5]; + 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; + 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); + 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; +} diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 62759676..7047519e 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -7,6 +7,7 @@ #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" @@ -196,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; @@ -1389,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))) { @@ -1456,6 +1461,7 @@ 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); RuntimeMods& s = state(); s.ticket_ready = false; ++s.preparation_revision; @@ -1559,6 +1565,7 @@ bool mod_runtime_initialize(const std::filesystem::path& root, } bool mod_runtime_clear_for_netplay(std::string* error) { + psx_mod_netplay_set_active(0); state().ticket_ready = false; ++state().preparation_revision; RuntimeMods& s = state(); @@ -1587,6 +1594,38 @@ bool mod_runtime_clear_for_netplay(std::string* error) { return true; } +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; + } + const char* renderer = psx_mod_netplay_aspect() ? profile->widescreen_plugin_id : nullptr; + if (renderer && !mod_plugin_registered(renderer)) { + if (error) *error = "Title netplay renderer plugin is not registered"; + return false; + } + const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); + if (caps && caps->valid && caps->mod_count != 0) { + if (error) *error = "Title netplay profile does not accept package mod plans"; + return false; + } + s.disc_path = disc_path; + s.plan.ok = true; + s.plan.fingerprint = profile->compatibility_id; + s.plan.plugins.push_back({profile->plugin_id, "", ""}); + if (renderer) s.plan.plugins.push_back({renderer, "", ""}); + 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, @@ -2001,7 +2040,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); @@ -2052,6 +2092,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) @@ -2080,6 +2121,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) { @@ -2110,6 +2152,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(); @@ -2161,6 +2204,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); diff --git a/runtime/src/netplay_state_digest.c b/runtime/src/netplay_state_digest.c index 7a60e1d5..16745169 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" @@ -176,6 +177,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/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 64fc252c..97f11f6b 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 "netplay_bios_settle.h" #include @@ -1056,6 +1057,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 087f15ec..0b7731ca 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" @@ -205,6 +206,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) @@ -743,12 +745,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; @@ -1408,6 +1410,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; @@ -1575,6 +1583,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) { @@ -3827,6 +3840,7 @@ static int resolve_use_ice(const PsxNetplayConfig *cfg) int psx_netplay_start(const PsxNetplayConfig *cfg) { + PsxNetplayConfig trusted_cfg; RNetConfig rcfg; RNetHostVTable host; int in_player; @@ -3835,6 +3849,16 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) int use_ice; 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); @@ -3906,7 +3930,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) { @@ -3928,8 +3952,10 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) } char execution_content[65]; + char profile_content[65]; if (!psx_execution_content_identity(cfg->content_fingerprint, execution_content) || - !np_content_init(&s_content, execution_content, rcfg.occupied_mask, local)) + !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. */ @@ -4499,6 +4525,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) @@ -4534,6 +4561,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/savestate.c b/runtime/src/savestate.c index b09ca9d7..18662b47 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" @@ -54,6 +55,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; @@ -192,6 +194,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; @@ -689,6 +713,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"); @@ -706,6 +731,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"); @@ -753,6 +779,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/tests/test_mod_netplay.c b/runtime/tests/test_mod_netplay.c new file mode 100644 index 00000000..7a6b499d --- /dev/null +++ b/runtime/tests/test_mod_netplay.c @@ -0,0 +1,136 @@ +#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 + }; + const int wide = argc > 1 && strcmp(argv[1], "--wide") == 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_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)); + if (wide) profile.fixed_aspect_mask = 6u; + 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_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); + 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_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 f1719a4d..ba382927 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" @@ -385,7 +386,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"; @@ -1746,6 +1747,68 @@ 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"; + static const PSXModNetplayProfile profile = { + missing_profile ? "runtime.missing-simulation" : "runtime.test-vblank", + "runtime-coop-delay-v1", 0, 0, 1, 3u, + "runtime.missing-renderer", nullptr + }; + 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) { + if (missing_renderer) check(psx_mod_netplay_set_aspect(1), "select profile renderer"); + 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), {}); + }; + const 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"); + 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_savestate_title_policy.c b/runtime/tests/test_savestate_title_policy.c new file mode 100644 index 00000000..696938b5 --- /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 + }; + 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; +} From fbcf7d944f7cb731ea2f374daba3569f46711040 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 8 Oct 2026 13:47:00 -0700 Subject: [PATCH 54/64] Keep netplay profile header portable in standalone overlay toolchains --- overlay_toolchain/include/mod_netplay.h | 49 ++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/overlay_toolchain/include/mod_netplay.h b/overlay_toolchain/include/mod_netplay.h index b4d6e984..25756b80 100644 --- a/overlay_toolchain/include/mod_netplay.h +++ b/overlay_toolchain/include/mod_netplay.h @@ -1,2 +1,49 @@ #pragma once -#include "../../runtime/include/mod_netplay.h" + +#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; +} 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); + +#ifdef __cplusplus +} +#endif From 1e33761d2a4060a1d6092ecf18c243fb9604d43c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 8 Oct 2026 14:07:28 -0700 Subject: [PATCH 55/64] Route explicit virtual host devices through netplay input capture --- runtime/src/main.cpp | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 52ba012c..324fad11 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -6033,8 +6033,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); @@ -6175,6 +6177,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; @@ -6703,6 +6715,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 @@ -6710,8 +6726,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; From dfd29f76e268fd8298a849ea30c654841b732f8c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 8 Oct 2026 14:30:44 -0700 Subject: [PATCH 56/64] Require retained-bank OpenGL policy for trusted co-op sessions --- docs/NETPLAY.md | 11 ++++++++++ overlay_toolchain/include/mod_netplay.h | 8 +++++++ runtime/CMakeLists.txt | 1 + runtime/include/mod_netplay.h | 8 +++++++ runtime/src/gpu_gl_renderer.c | 9 +++++++- runtime/src/main.cpp | 11 ++++++++++ runtime/src/mod_netplay.c | 9 +++++++- runtime/tests/gpu_hd_texture_stubs.inc | 4 ++++ runtime/tests/test_gl_readback_region.c | 23 +++++++++++++++++++++ runtime/tests/test_mod_netplay.c | 12 ++++++++++- runtime/tests/test_mod_runtime.cpp | 2 +- runtime/tests/test_savestate_title_policy.c | 2 +- 12 files changed, 95 insertions(+), 5 deletions(-) diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index 5c5de4d7..e3b858ba 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -39,6 +39,17 @@ 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 diff --git a/overlay_toolchain/include/mod_netplay.h b/overlay_toolchain/include/mod_netplay.h index 25756b80..b079a882 100644 --- a/overlay_toolchain/include/mod_netplay.h +++ b/overlay_toolchain/include/mod_netplay.h @@ -25,6 +25,10 @@ typedef struct PSXModNetplayProfile { 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); @@ -43,6 +47,10 @@ 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 } diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 01f4d164..a3aebafb 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -28,6 +28,7 @@ if(BUILD_TESTING) 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_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) diff --git a/runtime/include/mod_netplay.h b/runtime/include/mod_netplay.h index 25756b80..b079a882 100644 --- a/runtime/include/mod_netplay.h +++ b/runtime/include/mod_netplay.h @@ -25,6 +25,10 @@ typedef struct PSXModNetplayProfile { 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); @@ -43,6 +47,10 @@ 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 } diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 9a138cbe..a313e92e 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -80,6 +80,7 @@ #include "gpu_gl_renderer.h" #include "gpu_hd_textures.h" #include "mod_texture_banks.h" +#include "mod_netplay.h" #include "frame_interpolation.h" #include "render_pass_plan.h" #include "render_pass.h" @@ -5218,7 +5219,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 diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 324fad11..b2a2311a 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -18047,6 +18047,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) @@ -18884,6 +18889,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); diff --git a/runtime/src/mod_netplay.c b/runtime/src/mod_netplay.c index db466460..3fa675d0 100644 --- a/runtime/src/mod_netplay.c +++ b/runtime/src/mod_netplay.c @@ -53,7 +53,7 @@ int psx_mod_netplay_content_identity(const char *execution_content, char out[65] char normalized[65]; size_t n = strlen(input), i; psx_sha256_ctx ctx; - uint8_t bytes[32], policy[5]; + uint8_t bytes[32], policy[6]; static const char domain[] = "psx-trusted-netplay-profile-v1"; static const char hex[] = "0123456789abcdef"; const char *renderer; @@ -75,6 +75,7 @@ int psx_mod_netplay_content_identity(const char *execution_content, char out[65] 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); @@ -105,3 +106,9 @@ int psx_mod_netplay_has_state_digest(void) { 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/tests/gpu_hd_texture_stubs.inc b/runtime/tests/gpu_hd_texture_stubs.inc index ef44dac4..0ea77cf0 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/test_gl_readback_region.c b/runtime/tests/test_gl_readback_region.c index e4da089b..4c47cba0 100644 --- a/runtime/tests/test_gl_readback_region.c +++ b/runtime/tests/test_gl_readback_region.c @@ -526,6 +526,29 @@ int main(int argc,char **argv){ for(test_full_composite=0;test_full_composite<2;test_full_composite++){verify_wide_overlay_band();verify_wide_overlay_order();} test_full_composite=0; verify_presentation_capture(); + /* A trusted retained-bank profile permits GL presentation in delay-sync, + * while the software authority still samples the original guest texture. */ + gl_renderer_sync_cpu(); + gl_renderer_set_cpu_auth_dual(1); + check(!gl_renderer_texture_banks_supported(),"dual banks refused without profile"); + test_title_profile_banks=1; + check(!gl_renderer_texture_banks_supported(),"dual banks refused for inactive profile"); + test_title_profile_active=1; + check(gl_renderer_texture_banks_supported(),"trusted dual retained-bank profile allowed"); + test_title_profile_banks=0; + check(!gl_renderer_texture_banks_supported(),"active profile must explicitly request banks"); + test_title_profile_banks=1; + s_hd_native_authority=1; + check(!gl_renderer_texture_banks_supported(),"HD authority still refuses retained banks"); + s_hd_native_authority=0; + glb_set_draw_area(0,0,1023,511); + glb_vram_write(0,0,0x7c00); + check(gl_renderer_select_texture_bank(7),"trusted dual bank selected"); + glb_draw_shaded_textured_triangle(100,300,0,0,0x808080,132,300,0,0,0x808080,100,332,0,0,0x808080,0,0,0x100,1); + check(gl_renderer_select_texture_bank(0),"reset trusted dual bank"); + check(glb_vram_read(102,302)==0x7c00,"dual software authority samples ordinary VRAM"); + test_title_profile_active=test_title_profile_banks=0; + gl_renderer_set_cpu_auth_dual(0); printf("checks=%d failures=%d\n",checks,failures); gl_renderer_shutdown();SDL_DestroyWindow(win);SDL_Quit();return failures?1:0; } diff --git a/runtime/tests/test_mod_netplay.c b/runtime/tests/test_mod_netplay.c index 7a6b499d..e9a0e1d8 100644 --- a/runtime/tests/test_mod_netplay.c +++ b/runtime/tests/test_mod_netplay.c @@ -48,9 +48,10 @@ int main(int argc, char** argv) { NetplayCoreParts baseline, live, changed; PSXModNetplayProfile invalid, duplicate; static PSXModNetplayProfile profile = { - "test.simulation", "test-coop-delay-v1", 0, 0, 1, 1u, NULL, gameplay_digest + "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; uint32_t original_session, profile_session; char original_identity[65], current_identity[65]; const char* fingerprint = @@ -60,6 +61,9 @@ int main(int argc, char** argv) { 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); @@ -81,11 +85,17 @@ int main(int argc, char** argv) { invalid.fixed_aspect_mask = 8u; assert(!psx_mod_register_netplay_profile(&invalid)); if (wide) profile.fixed_aspect_mask = 6u; + if (no_banks) profile.requires_retained_texture_banks = 0; 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)); diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index ba382927..5f42a97d 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1755,7 +1755,7 @@ int main(int argc, char** argv) { static const PSXModNetplayProfile profile = { missing_profile ? "runtime.missing-simulation" : "runtime.test-vblank", "runtime-coop-delay-v1", 0, 0, 1, 3u, - "runtime.missing-renderer", nullptr + "runtime.missing-renderer", nullptr, 0 }; check(psx_mod_register_netplay_profile(&profile), "trusted profile registration"); check(PSXRecompV4::mod_runtime_initialize(root, "SLUS-RUNTIME", 0x80002000, {}, &error), diff --git a/runtime/tests/test_savestate_title_policy.c b/runtime/tests/test_savestate_title_policy.c index 696938b5..5e7bc8df 100644 --- a/runtime/tests/test_savestate_title_policy.c +++ b/runtime/tests/test_savestate_title_policy.c @@ -57,7 +57,7 @@ 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 + "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. */ From 1483cc3831d05b2e29bef32dfb081e2330b78e4b Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 8 Oct 2026 16:36:24 -0700 Subject: [PATCH 57/64] Allow explicit shared controllers in development builds --- runtime/include/launcher_device.h | 5 +++++ runtime/src/main.cpp | 13 +++++++++++-- runtime/tests/test_launcher_device_roundtrip.cpp | 3 +++ 3 files changed, 19 insertions(+), 2 deletions(-) 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/src/main.cpp b/runtime/src/main.cpp index b2a2311a..24271e91 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -5140,9 +5140,15 @@ 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; + 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; if (p.guid[0] && std::strcmp(buf, p.guid) == 0) { chosen = i; break; } if (fallback < 0) fallback = i; } @@ -15175,6 +15181,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(); 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; From 8fff59f82cd2b712c0dd6f8b781722572f26f60b Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 9 Oct 2026 04:33:12 -0700 Subject: [PATCH 58/64] Agree trusted camera options and isolate local render transactions --- docs/RENDER_PASSES.md | 2 +- runtime/CMakeLists.txt | 3 + runtime/include/mod_netplay.h | 5 + runtime/include/mod_runtime.h | 4 + runtime/include/psx_cycle_freeze.h | 1 + runtime/src/gpu.c | 12 +- runtime/src/main.cpp | 33 ++++- runtime/src/mod_netplay.c | 38 +++++- runtime/src/mod_runtime.cpp | 129 +++++++++++++++++- runtime/src/psx_cycles.c | 2 + runtime/src/render_pass.c | 3 + runtime/tests/gpu_exec_stubs.inc | 10 +- .../tests/test_gpu_render_pass_provenance.c | 46 +++++++ runtime/tests/test_lobby_chat_ring.c | 25 ++++ runtime/tests/test_mod_netplay.c | 28 ++++ runtime/tests/test_mod_runtime.cpp | 117 +++++++++++++++- runtime/tests/test_render_pass_freeze.c | 33 +++++ 17 files changed, 472 insertions(+), 19 deletions(-) diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 40f6bd42..74bbacf1 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -207,7 +207,7 @@ 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` | diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index a3aebafb..e20c0ca4 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -29,6 +29,7 @@ if(BUILD_TESTING) 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) @@ -1100,6 +1101,8 @@ if(BUILD_TESTING) 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) diff --git a/runtime/include/mod_netplay.h b/runtime/include/mod_netplay.h index b079a882..8f342b59 100644 --- a/runtime/include/mod_netplay.h +++ b/runtime/include/mod_netplay.h @@ -29,6 +29,10 @@ typedef struct PSXModNetplayProfile { * 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); @@ -43,6 +47,7 @@ uint32_t psx_mod_netplay_session_id(uint32_t session_id); * 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); diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index d8d55d49..4fe1906c 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 @@ -46,6 +47,9 @@ bool mod_runtime_clear_for_netplay(std::string* error = nullptr); * 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); diff --git a/runtime/include/psx_cycle_freeze.h b/runtime/include/psx_cycle_freeze.h index 4e6f5c78..86df5e53 100644 --- a/runtime/include/psx_cycle_freeze.h +++ b/runtime/include/psx_cycle_freeze.h @@ -42,6 +42,7 @@ typedef struct PsxCycleFreeze { * cleanup handler, which a longjmp out of the pass (the watchdog) skips: * end() puts the interrupted code's depth back. */ int bb_defer; + uint32_t guest_cycle_scale[3]; } PsxCycleFreeze; int psx_cycle_freeze_begin(PsxCycleFreeze *save, uint64_t watchdog_cycles, void (*overrun)(void)); diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 7c2d34e1..bcbc4a42 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); @@ -2632,10 +2634,12 @@ static int ws_explicit_hud_anchor(int *out_anchor) { 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); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 24271e91..5214e79a 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -12245,7 +12245,34 @@ namespace { caps->mod_plan_fp[0] = '\0'; caps->mod_count = 0; std::memset(caps->mods, 0, sizeof(caps->mods)); - if (psx_mod_netplay_profile()) return 1; + 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) @@ -15749,7 +15776,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; diff --git a/runtime/src/mod_netplay.c b/runtime/src/mod_netplay.c index 3fa675d0..403274d0 100644 --- a/runtime/src/mod_netplay.c +++ b/runtime/src/mod_netplay.c @@ -6,6 +6,7 @@ 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; @@ -13,7 +14,14 @@ int psx_mod_register_netplay_profile(const PSXModNetplayProfile *profile) { !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->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; @@ -48,6 +56,24 @@ uint32_t psx_mod_netplay_session_id(uint32_t session_id) { } 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]; @@ -85,6 +111,16 @@ int psx_mod_netplay_content_identity(const char *execution_content, char out[65] 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]; diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 7047519e..87b7f4fa 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1462,6 +1462,7 @@ bool mod_runtime_initialize(const std::filesystem::path& root, 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; @@ -1566,6 +1567,7 @@ 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(); @@ -1594,6 +1596,95 @@ 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; @@ -1604,21 +1695,45 @@ bool mod_runtime_commit_netplay(const std::filesystem::path& disc_path, if (error) *error = "Title netplay simulation plugin is not registered"; return false; } - const char* renderer = psx_mod_netplay_aspect() ? profile->widescreen_plugin_id : nullptr; + 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; } - const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); - if (caps && caps->valid && caps->mod_count != 0) { + 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.ok = true; - s.plan.fingerprint = profile->compatibility_id; - s.plan.plugins.push_back({profile->plugin_id, "", ""}); - if (renderer) s.plan.plugins.push_back({renderer, "", ""}); + 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", diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 849a8828..be7526b1 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -948,6 +948,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; @@ -995,6 +996,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/render_pass.c b/runtime/src/render_pass.c index 305d3f55..17b1c096 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -466,6 +466,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); @@ -483,6 +485,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); diff --git a/runtime/tests/gpu_exec_stubs.inc b/runtime/tests/gpu_exec_stubs.inc index ec399126..57d16595 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); } @@ -390,8 +394,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/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_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_netplay.c b/runtime/tests/test_mod_netplay.c index e9a0e1d8..f7d810f1 100644 --- a/runtime/tests/test_mod_netplay.c +++ b/runtime/tests/test_mod_netplay.c @@ -52,6 +52,7 @@ int main(int argc, char** argv) { }; 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 = @@ -84,8 +85,18 @@ int main(int argc, char** argv) { 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; @@ -101,12 +112,29 @@ int main(int argc, char** argv) { 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)); diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 5f42a97d..ed6d7e29 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -30,6 +30,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; @@ -461,6 +463,10 @@ int main(int argc, char** argv) { "[[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" @@ -1752,16 +1758,37 @@ int main(int argc, char** argv) { 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, - "runtime.missing-renderer", nullptr, 0 + 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) { + 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"); @@ -1777,7 +1804,7 @@ int main(int argc, char** argv) { std::ifstream file(selection_path, std::ios::binary); return std::string(std::istreambuf_iterator(file), {}); }; - const std::string offline_selection = read_selection(); + 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"); @@ -1805,6 +1832,90 @@ int main(int argc, char** argv) { !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"); diff --git a/runtime/tests/test_render_pass_freeze.c b/runtime/tests/test_render_pass_freeze.c index 286b4243..39959bed 100644 --- a/runtime/tests/test_render_pass_freeze.c +++ b/runtime/tests/test_render_pass_freeze.c @@ -9,6 +9,7 @@ #include #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. */ From 2b2fd6fd86e6f6926e267382e8a9a82919be32da Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 9 Oct 2026 14:56:25 -0700 Subject: [PATCH 59/64] Honor launcher-selected controller instances and LAN list scope --- runtime/src/main.cpp | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 5214e79a..d15c2401 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -422,6 +422,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; @@ -5149,7 +5150,20 @@ static void open_player(PlayerInput& p, int self_slot) { true, p.guid[0] && std::strcmp(buf, p.guid) == 0); #endif if (device_claimed_by_other(self_slot, inst) && !shared) continue; - if (p.guid[0] && std::strcmp(buf, p.guid) == 0) { chosen = i; break; } + /* 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; @@ -5159,6 +5173,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). */ @@ -5168,7 +5183,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, @@ -13736,7 +13752,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*) { @@ -15658,6 +15674,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]; @@ -17453,6 +17470,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) { @@ -18349,6 +18370,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 / @@ -19846,6 +19869,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) { From 4cf3c5b0cd0cfa422a048e6ae28b8e3d28df2cb5 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 9 Oct 2026 16:41:18 -0700 Subject: [PATCH 60/64] Add private SPU sample residency for co-op character audio --- runtime/CMakeLists.txt | 4 ++ runtime/include/mod_plugins.h | 5 ++ runtime/include/spu.h | 8 ++++ runtime/runtime.cmake | 6 ++- runtime/src/mod_guest_services.c | 7 +++ runtime/src/spu.c | 56 ++++++++++++++++++---- runtime/tests/test_spu_sample_banks.c | 67 +++++++++++++++++++++++++++ 7 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 runtime/tests/test_spu_sample_banks.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index e20c0ca4..1d3f8614 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1610,6 +1610,10 @@ if(BUILD_TESTING) add_test(NAME spu_pitch_zero_test COMMAND spu_pitch_zero_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) add_test(NAME spu_gaussian_test COMMAND spu_gaussian_test) diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 5702bebc..eff43a17 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -196,6 +196,11 @@ 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); /* 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 diff --git a/runtime/include/spu.h b/runtime/include/spu.h index 31bf7b96..a7611295 100644 --- a/runtime/include/spu.h +++ b/runtime/include/spu.h @@ -138,6 +138,14 @@ 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); + /* ---- Verified-enhancement shadow tap (consumed by spu_shadow.c) --------- * * Present-time, opt-in float SPU re-render. When the shadow is enabled, diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index ce7ff5f9..cec1c165 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -1600,7 +1600,11 @@ function(psxrecomp_add_runtime_target target) "${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") + "${PSXRECOMP_ROOT}/runtime/include/render_pass_projection.h" + # SPU snapshot layout and immutable sample-bank playback are part of + # runtime compatibility, including sessions with no title mod enabled. + "${PSXRECOMP_ROOT}/runtime/src/spu.c" + "${PSXRECOMP_ROOT}/runtime/include/spu.h") if(PSXRT_ORACLE) psxrecomp_execution_profile(${target} PROFILE REFERENCE CONTRACT_FILES ${_execution_contract}) else() diff --git a/runtime/src/mod_guest_services.c b/runtime/src/mod_guest_services.c index d644ff49..d4df7fda 100644 --- a/runtime/src/mod_guest_services.c +++ b/runtime/src/mod_guest_services.c @@ -107,6 +107,13 @@ 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_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/spu.c b/runtime/src/spu.c index 0e3c0340..9343abdf 100644 --- a/runtime/src/spu.c +++ b/runtime/src/spu.c @@ -31,6 +31,11 @@ #define SPU_REG_COUNT 256 #define SPU_VOICE_COUNT 24 #define SPU_BLOCK_SAMPLES 28 +#define SPU_SAMPLE_BANKS 32u + +/* 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]; @@ -137,6 +142,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; @@ -153,6 +159,22 @@ typedef struct { static SpuVoice voices[SPU_VOICE_COUNT]; +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)]; e->seq = s_event_seq++; @@ -472,10 +494,19 @@ static void decode_block(SpuVoice *v) { /* The 16-byte ADPCM block fetch is an SPU RAM access: IRQ-address games * (CD streaming double-buffers, MDEC audio) park the IRQ inside a voice's * sample buffer and rely on this exact compare firing. */ - spu_irq_check(addr, 16u); + const uint8_t *block; + static const uint8_t silent_end[16]={0,1}; + if(v->sample_bank) { + uint32_t bank=v->sample_bank; + block=bank> 4) & 0x0F; if (filter > 4) filter = 0; @@ -490,7 +521,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; @@ -913,7 +944,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 @@ -948,6 +981,9 @@ static void key_off(uint32_t mask) { } void spu_init(void) { + for(unsigned bank=1;bankactive) || !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; @@ -1700,8 +1738,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; diff --git a/runtime/tests/test_spu_sample_banks.c b/runtime/tests/test_spu_sample_banks.c new file mode 100644 index 00000000..9db5d2b1 --- /dev/null +++ b/runtime/tests/test_spu_sample_banks.c @@ -0,0 +1,67 @@ +/* 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;at Date: Fri, 9 Oct 2026 16:58:40 -0700 Subject: [PATCH 61/64] Keep playtest execution identity aligned with validated Windows build The X4 co-op contract already fingerprints SPU and private audio sources. Defer the additional global execution-contract inputs, added after the approved Windows build, until a coordinated rebuild of both platforms. This preserves the owner-tested Windows artifact for playtest 2. --- runtime/runtime.cmake | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index cec1c165..ce7ff5f9 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -1600,11 +1600,7 @@ function(psxrecomp_add_runtime_target target) "${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" - # SPU snapshot layout and immutable sample-bank playback are part of - # runtime compatibility, including sessions with no title mod enabled. - "${PSXRECOMP_ROOT}/runtime/src/spu.c" - "${PSXRECOMP_ROOT}/runtime/include/spu.h") + "${PSXRECOMP_ROOT}/runtime/include/render_pass_projection.h") if(PSXRT_ORACLE) psxrecomp_execution_profile(${target} PROFILE REFERENCE CONTRACT_FILES ${_execution_contract}) else() From 8f6867225cadfd610696fd687ee840a36d8440fe Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 10 Oct 2026 00:53:39 -0700 Subject: [PATCH 62/64] Support independent co-op audio voices and diagnostic OSD rows --- docs/TCP_COMMANDS.md | 5 + runtime/CMakeLists.txt | 10 ++ runtime/include/host_osd.h | 3 + runtime/include/mod_plugins.h | 4 + runtime/include/mod_runtime.h | 2 + runtime/include/spu.h | 11 ++- runtime/src/debug_server.c | 30 ++++++ runtime/src/host_osd.c | 62 ++++++++---- runtime/src/mod_guest_services.c | 8 ++ runtime/src/mod_runtime.cpp | 4 + runtime/src/spu.c | 112 +++++++++++++++++----- runtime/tests/test_host_osd_diagnostics.c | 25 +++++ runtime/tests/test_spu_sample_banks.c | 34 ++++++- 13 files changed, 268 insertions(+), 42 deletions(-) create mode 100644 runtime/tests/test_host_osd_diagnostics.c diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 20dde483..cd2600dd 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/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 1d3f8614..8d7469df 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -537,6 +537,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) 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/mod_plugins.h b/runtime/include/mod_plugins.h index eff43a17..a063f5db 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -201,6 +201,10 @@ int psx_mod_spu_upload(uint32_t spu_address, uint32_t guest_source, * 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 diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index 4fe1906c..51d446d8 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -74,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/spu.h b/runtime/include/spu.h index a7611295..d706760c 100644 --- a/runtime/include/spu.h +++ b/runtime/include/spu.h @@ -145,6 +145,15 @@ const uint8_t* spu_get_ram(void); * 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) --------- * @@ -154,7 +163,7 @@ int spu_bind_next_voice_bank(unsigned voice, uint32_t bank); * 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/src/debug_server.c b/runtime/src/debug_server.c index eeb98560..5ffc1d47 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -19,6 +19,7 @@ #include "debug_server.h" #include "host_launch_timing.h" #include "host_sampler.h" +#include "host_osd.h" #include "psx_video_timing.h" #include "psx_netplay.h" #include "psx_bss.h" @@ -10374,6 +10375,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 @@ -15832,6 +15861,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/host_osd.c b/runtime/src/host_osd.c index 62c387d0..7a662b72 100644 --- a/runtime/src/host_osd.c +++ b/runtime/src/host_osd.c @@ -133,6 +133,9 @@ static int s_img_dirty = 1; static char s_status_msg[OSD_MAX_CHARS]; static int s_status_active; static int s_status_dirty = 1; +static char s_diagnostic_msg[2][OSD_MAX_CHARS]; +static int s_diagnostic_active; +static int s_diagnostic_dirty = 1; static int s_volume = 100; /* host master 0..100 */ static int s_vol_pct = 100; /* last shown bar fill */ @@ -143,7 +146,7 @@ static int s_vol_dirty = 1; static int s_needs_clear; #define OSD_IMG_W ((OSD_PAD_X * 2 + OSD_MAX_CHARS * OSD_GLYPH_W) * OSD_SCALE) -#define OSD_IMG_H ((OSD_PAD_Y * 2 + OSD_GLYPH_H) * OSD_SCALE) +#define OSD_IMG_H ((OSD_PAD_Y * 2 + OSD_GLYPH_H * 3) * OSD_SCALE) static uint32_t s_img[OSD_IMG_W * OSD_IMG_H]; static int s_img_w; static int s_img_h; @@ -202,10 +205,15 @@ static int vol_visible(void) { } static void rasterize_text(const char *msg) { - int n = (int)strlen(msg); - if (n > 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/mod_guest_services.c b/runtime/src/mod_guest_services.c index d4df7fda..f3a44e2b 100644 --- a/runtime/src/mod_guest_services.c +++ b/runtime/src/mod_guest_services.c @@ -113,6 +113,14 @@ 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) { 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) { diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 87b7f4fa..0471e18b 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -2364,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/spu.c b/runtime/src/spu.c index 9343abdf..cffb8048 100644 --- a/runtime/src/spu.c +++ b/runtime/src/spu.c @@ -32,6 +32,8 @@ #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. */ @@ -39,6 +41,10 @@ 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; @@ -107,7 +113,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. */ @@ -157,7 +163,7 @@ 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 || @@ -182,14 +188,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; @@ -244,8 +250,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; @@ -326,7 +332,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 voiceprevious_samples, @@ -980,6 +988,44 @@ 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;slot3u || + !spu_r_voice(&r,SPU_VOICE_COUNT+(int)slot))return 0; + } return 1; } uint8_t* spu_get_ram_ptr(void){ return spu_ram; } @@ -1854,7 +1920,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); @@ -1863,6 +1929,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/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_spu_sample_banks.c b/runtime/tests/test_spu_sample_banks.c index 9db5d2b1..5a15989a 100644 --- a/runtime/tests/test_spu_sample_banks.c +++ b/runtime/tests/test_spu_sample_banks.c @@ -62,6 +62,38 @@ int main(void) { /* Invalid bank addresses terminate silently instead of reading stage RAM. */ CHECK(spu_bind_next_voice_bank(0,1));spu_regs[3]=0x100;key_on(1);ready_voice(0); voice_next_sample(0);CHECK(voices[0].flags==1 && voices[0].samples[0]==0); - free(snapshot);spu_init();CHECK(!spu_bind_next_voice_bank(0,1)); + /* The same logical channel in two seats must mix independently. P2 is + * not a second binding on P1's hardware KEYON register. */ + memset(voices,0,sizeof voices);memset(spu_regs,0,sizeof spu_regs); + const uint16_t params[8]={0x1000,0x1000,0x1000,0,0x000F,0x0000,0,0}; + memcpy(spu_regs+20*8,params,sizeof params); + CHECK(spu_bind_next_voice_bank(20,1));key_on(1u<<20);ready_voice(20); + uint8_t hardware[sizeof spu_regs];memcpy(hardware,spu_regs,sizeof hardware); + CHECK(!spu_private_voice_play(24,2,params,0)); + CHECK(!spu_private_voice_play(20,0,params,0)); + CHECK(spu_private_voice_play(20,2,params,0)); + CHECK(!memcmp(hardware,spu_regs,sizeof hardware)); + voices[44].env_level=0x6000;voices[44].adsr_phase=ADSR_SUSTAIN; + CHECK(spu_private_voice_active()==1u<<20 && voices[20].sample_bank==1); + spu_write(0x1F801DAAu,0x8000);spu_write(0x1F801D80u,0x3FFF);spu_write(0x1F801D82u,0x3FFF); + uint32_t expanded=spu_snapshot_bytes();CHECK(expanded>n); + 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; } From 0b0641cbae070a888c0b556d674cde7dee559571 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:22:15 -0700 Subject: [PATCH 63/64] Lobby: honour transport "ice" launches (same-NAT peers no longer dial themselves) The lobby server starts a 2-player room the host relay cannot carry on ICE (recomp-net-server start_transport, launch transport "ice"). This client knew only "host" and the legacy server relay. It inferred a relay from equal host/guest endpoints -- which is exactly what two peers behind one NAT advertise -- and each peer dialled its own public address: the match stayed at "linking" forever. - psx_lobby_launch_transport.h: one pure decision (host / ice / relay / direct) with a unit test covering ICE, same-NAT endpoints, host relay and the legacy relay cases. - PsxLobbyJoinInfo.transport_ice; the launch handler records it and never turns it into a relay; launch fill and resolve_use_ice start ICE p2p (signalled over the lobby) for it. Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 3 + runtime/include/psx_lobby_client.h | 4 ++ runtime/include/psx_lobby_launch_transport.h | 59 ++++++++++++++++++++ runtime/src/main.cpp | 5 +- runtime/src/psx_lobby_client.c | 29 ++++++---- runtime/src/psx_netplay.c | 13 +++++ runtime/tests/test_lobby_launch_transport.c | 31 ++++++++++ 7 files changed, 130 insertions(+), 14 deletions(-) create mode 100644 runtime/include/psx_lobby_launch_transport.h create mode 100644 runtime/tests/test_lobby_launch_transport.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 8d7469df..63d66ed5 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -85,6 +85,9 @@ if(BUILD_TESTING) target_include_directories(netplay_sim_pad_cache_test PRIVATE include) target_compile_definitions(netplay_sim_pad_cache_test PRIVATE PSX_MAX_PLAYERS=4) add_test(NAME netplay_sim_pad_cache_test COMMAND netplay_sim_pad_cache_test) + add_executable(lobby_launch_transport_test tests/test_lobby_launch_transport.c) + target_include_directories(lobby_launch_transport_test PRIVATE include) + add_test(NAME lobby_launch_transport_test COMMAND lobby_launch_transport_test) add_executable(netplay_local_view_test tests/test_netplay_local_view.c) target_include_directories(netplay_local_view_test PRIVATE include) add_test(NAME netplay_local_view_test COMMAND netplay_local_view_test) diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 4302990d..6881841a 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -272,6 +272,10 @@ typedef struct PsxLobbyJoinInfo { * force_input_relay is 0. The host binds bind_hostport and accepts the * guest (2 seats) or hubs (3+). */ int transport_host; + /* Launch: transport "ice" -- a 2-player room started on ICE + * (peer-to-peer, signalled over the lobby). No endpoint is dialled and + * force_input_relay is 0 (psx_lobby_launch_transport.h). */ + int transport_ice; /* A server code (need_password | bad_password | …) or, from * psx_lobby_set_last_error, one sentence the launcher shows as is. */ char last_error[192]; diff --git a/runtime/include/psx_lobby_launch_transport.h b/runtime/include/psx_lobby_launch_transport.h new file mode 100644 index 00000000..1d2e8de8 --- /dev/null +++ b/runtime/include/psx_lobby_launch_transport.h @@ -0,0 +1,59 @@ +/* psx_lobby_launch_transport.h - how a lobby `launch` is carried. + * + * The lobby server picks the transport at `start` (recomp-net-server + * start_transport, WS_LOBBY.md "Host relay"): + * "host" -- the host carries the match on host_endpoint; guests dial it. + * "ice" -- a 2-player room the host relay cannot carry starts on ICE: + * peer-to-peer over STUN/TURN, signalled over the lobby socket. + * The endpoints are not dialled at all. + * a relay_endpoint, or equal host/guest endpoints from a server that + * rewrote both to its relay advertise address -- legacy server SFU. + * + * The last rule used to win over "ice": two peers behind one NAT advertise + * the same public endpoint, so an ICE launch between them looked like a relay + * and each peer dialled its own address -- the match never linked. */ +#ifndef PSX_LOBBY_LAUNCH_TRANSPORT_H +#define PSX_LOBBY_LAUNCH_TRANSPORT_H + +#include +#include + +enum { + PSX_LAUNCH_DIRECT = 0, /* dial the peer endpoint (legacy p2p UDP) */ + PSX_LAUNCH_HOST = 1, /* host relay: host binds, guests dial host_endpoint */ + PSX_LAUNCH_ICE = 2, /* ICE peer-to-peer, signalled over the lobby */ + PSX_LAUNCH_RELAY = 3 /* server input relay (SFU) */ +}; + +static inline int psx_lobby_endpoint_usable(const char *ep) +{ + const char *colon; + if (!ep || !ep[0]) return 0; + colon = strrchr(ep, ':'); + if (!colon || !colon[1]) return 0; + return strtoul(colon + 1, NULL, 10) != 0; +} + +/* transport: the launch's "transport" field ("" when absent). + * relay_endpoint: the launch's relay_endpoint ("" when absent). + * caps_force_relay: the host's match_caps asked for the server relay. + * my_bind: this peer's own advertised bind ("" when unknown). */ +static inline int psx_lobby_launch_transport(const char *transport, + const char *relay_endpoint, + int caps_force_relay, + const char *host_ep, + const char *guest_ep, + const char *my_bind) +{ + if (transport && strcmp(transport, "host") == 0) return PSX_LAUNCH_HOST; + if (transport && strcmp(transport, "ice") == 0) return PSX_LAUNCH_ICE; + if (psx_lobby_endpoint_usable(relay_endpoint) || caps_force_relay) + return PSX_LAUNCH_RELAY; + if (psx_lobby_endpoint_usable(host_ep) && psx_lobby_endpoint_usable(guest_ep) && + strcmp(host_ep, guest_ep) == 0 && + (!my_bind || !my_bind[0] || strcmp(host_ep, my_bind) != 0)) + return PSX_LAUNCH_RELAY; + return PSX_LAUNCH_DIRECT; +} + +#endif diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index d15c2401..eee4ad09 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -15062,13 +15062,14 @@ namespace { * §108's "always SFU"). Prefer caps.force_input_relay from the * relay_endpoint rewrite; also infer when host==guest advertise. */ out->force_input_relay = - (g_lnch_hosting_lan || g_lnch_joined_lan || ji->transport_host) + (g_lnch_hosting_lan || g_lnch_joined_lan || ji->transport_host || ji->transport_ice) ? 0 : (caps->force_input_relay ? 1 : 0); #if defined(RECOMP_LAUNCHER_HAS_HOST_RELAY) out->transport_host = ji->transport_host ? 1 : 0; #endif - if (!out->force_input_relay && !ji->transport_host && !g_lnch_hosting_lan && + if (!out->force_input_relay && !ji->transport_host && !ji->transport_ice && + !g_lnch_hosting_lan && !g_lnch_joined_lan && ji->host_endpoint[0] && ji->guest_endpoint[0] && std::strcmp(ji->host_endpoint, ji->guest_endpoint) == 0) { diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 97f11f6b..365cf4d0 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -1,5 +1,6 @@ #include "psx_lobby_client.h" #include "mod_netplay.h" +#include "psx_lobby_launch_transport.h" #include "netplay_bios_settle.h" #include @@ -1912,16 +1913,12 @@ static void copy_first_usable_endpoint(char *dst, size_t dst_len, const char *a, static int using_server_input_relay(const PsxLobbyJoinInfo *j) { - if (g_lc.match_caps.valid && g_lc.match_caps.force_input_relay) - return 1; - /* Server rewrote both endpoints to the same relay advertise address. */ - if (j && j->host_endpoint[0] && j->guest_endpoint[0] && - !endpoint_port_is_zero(j->host_endpoint) && - !endpoint_port_is_zero(j->guest_endpoint) && - strcmp(j->host_endpoint, j->guest_endpoint) == 0 && - (!g_lc.my_bind[0] || strcmp(j->host_endpoint, g_lc.my_bind) != 0)) - return 1; - return 0; + if (!j) + return g_lc.match_caps.valid && g_lc.match_caps.force_input_relay; + return psx_lobby_launch_transport(j->transport_ice ? "ice" : j->transport_host ? "host" : "", + "", g_lc.match_caps.valid && g_lc.match_caps.force_input_relay, + j->host_endpoint, j->guest_endpoint, + g_lc.my_bind) == PSX_LAUNCH_RELAY; } static void fill_peer_bind_from_join(void) @@ -3277,6 +3274,7 @@ static void handle_server_json(const char *json) transport_kind[0] = '\0'; json_get_str(json, "transport", transport_kind, sizeof(transport_kind)); g_lc.join.transport_host = strcmp(transport_kind, "host") == 0 ? 1 : 0; + g_lc.join.transport_ice = strcmp(transport_kind, "ice") == 0 ? 1 : 0; rnet_host_relay_release_port(g_host_relay); g_lc.join.player_count = json_get_int(json, "player_count", g_lc.join.player_count); g_lc.join.max_slots = json_get_int(json, "max_slots", g_lc.join.max_slots); @@ -3285,7 +3283,10 @@ static void handle_server_json(const char *json) /* Prefer explicit relay_endpoint when the server opened input relay. * Apply after caps ingest: omitted force_input_relay must not leave * hosts on the hub path while guests dial the relay. */ - if (relay_endpoint[0] && !endpoint_port_is_zero(relay_endpoint)) { + if (g_lc.join.transport_ice) { + /* ICE: nothing is dialled; a stale relay ask must not survive. */ + if (g_lc.match_caps.valid) g_lc.match_caps.force_input_relay = 0; + } else if (relay_endpoint[0] && !endpoint_port_is_zero(relay_endpoint)) { char relay_raw[PSX_LOBBY_ENDPOINT_LEN]; strncpy(relay_raw, relay_endpoint, sizeof(relay_raw) - 1); relay_raw[sizeof(relay_raw) - 1] = '\0'; @@ -3327,7 +3328,11 @@ static void handle_server_json(const char *json) json_get_str(json, "transport", transport, sizeof(transport)); if (strcmp(transport, "ice_p2p") == 0) ice_p2p = 1; - if (force_relay) { + if (g_lc.join.transport_ice) { + fprintf(stderr, "psx_lobby: launch transport=ice (peer-to-peer, " + "signalled over the lobby)\n"); + fflush(stderr); + } else if (force_relay) { if (peer_bad) { strncpy(g_lc.join.last_error, "missing_endpoints", sizeof(g_lc.join.last_error) - 1); diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 0b7731ca..8b929f49 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -3814,6 +3814,19 @@ static int resolve_use_ice(const PsxNetplayConfig *cfg) fflush(stderr); return 0; } +#if defined(PSX_HAS_LOBBY_CLIENT) && defined(RNET_ENABLE_ICE) + /* The server started this room on ICE (2 players the host relay cannot + * carry): peer-to-peer over STUN/TURN, signalled over the lobby socket. + * Same-NAT peers advertise equal endpoints; that is not a relay. */ + if (in_motk_room && !cfg->force_input_relay) { + const PsxLobbyJoinInfo *ji = psx_lobby_join_info(); + if (ji && ji->transport_ice) { + fprintf(stderr, "psx_netplay: lobby launch transport=ice — ICE peer-to-peer\n"); + fflush(stderr); + return 1; + } + } +#endif if (cfg->force_input_relay || in_motk_room) { if (!cfg->peer_hostport || !cfg->peer_hostport[0]) { fprintf(stderr, diff --git a/runtime/tests/test_lobby_launch_transport.c b/runtime/tests/test_lobby_launch_transport.c new file mode 100644 index 00000000..44ab8dda --- /dev/null +++ b/runtime/tests/test_lobby_launch_transport.c @@ -0,0 +1,31 @@ +#include "psx_lobby_launch_transport.h" +#undef NDEBUG +#include + +int main(void) +{ + /* ICE launch between two peers behind one NAT: same public endpoint on + * both sides must stay ICE, not turn into a relay to ourselves. */ + assert(psx_lobby_launch_transport("ice", "", 0, "136.25.29.198:7778", + "136.25.29.198:7778", "") == PSX_LAUNCH_ICE); + assert(psx_lobby_launch_transport("ice", "", 0, "1.2.3.4:7777", "5.6.7.8:7777", "") + == PSX_LAUNCH_ICE); + /* Host relay is unchanged, even with equal endpoints. */ + assert(psx_lobby_launch_transport("host", "", 0, "1.2.3.4:7777", "1.2.3.4:7777", "") + == PSX_LAUNCH_HOST); + /* Legacy server relay: explicit relay_endpoint, caps, or equal rewritten + * endpoints with no transport field. */ + assert(psx_lobby_launch_transport("", "9.9.9.9:8777", 0, "", "", "") == PSX_LAUNCH_RELAY); + assert(psx_lobby_launch_transport("", "", 1, "1.2.3.4:7777", "5.6.7.8:7777", "") + == PSX_LAUNCH_RELAY); + assert(psx_lobby_launch_transport("", "", 0, "9.9.9.9:8777", "9.9.9.9:8777", "") + == PSX_LAUNCH_RELAY); + /* ...but not our own bind echoed back, and not unusable ports. */ + assert(psx_lobby_launch_transport("", "", 0, "1.2.3.4:7777", "1.2.3.4:7777", + "1.2.3.4:7777") == PSX_LAUNCH_DIRECT); + assert(psx_lobby_launch_transport("", "", 0, "1.2.3.4:0", "1.2.3.4:0", "") + == PSX_LAUNCH_DIRECT); + assert(psx_lobby_launch_transport("", "", 0, "1.2.3.4:7777", "5.6.7.8:7777", "") + == PSX_LAUNCH_DIRECT); + return 0; +} From 4b96c039f609e96d28bc308b974f2cc96da4c329 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 10 Oct 2026 10:53:54 -0700 Subject: [PATCH 64/64] Integrate current ICE lobby transports and preserve settled LAN boot settings --- docs/NETPLAY.md | 47 +++- lib/recomp-net | 2 +- runtime/CMakeLists.txt | 6 + runtime/include/netplay_ice_hub.h | 79 ++++++ runtime/include/psx_lobby_client.h | 44 ++++ runtime/include/psx_netplay.h | 23 +- runtime/runtime.cmake | 1 + runtime/src/main.cpp | 129 +++++++++- runtime/src/netplay_ice_hub.c | 78 ++++++ runtime/src/psx_lobby_client.c | 360 ++++++++++++++++++++++++++- runtime/src/psx_netplay.c | 285 +++++++++++++++++---- runtime/tests/test_netplay_ice_hub.c | 147 +++++++++++ 12 files changed, 1138 insertions(+), 63 deletions(-) create mode 100644 runtime/include/netplay_ice_hub.h create mode 100644 runtime/src/netplay_ice_hub.c create mode 100644 runtime/tests/test_netplay_ice_hub.c diff --git a/docs/NETPLAY.md b/docs/NETPLAY.md index e3b858ba..d7679aa0 100644 --- a/docs/NETPLAY.md +++ b/docs/NETPLAY.md @@ -240,16 +240,47 @@ that identical frame; only what its window shows changes: ## Connectivity: ICE, TURN, SFU, LAN -### Online lobbies (WebSocket + SFU) +### Online lobbies (WebSocket + host relay, SFU fallback) Online matches go through the lobby at **netplay.retcomm.net** -(WebSocket control plane). Match **UDP pad traffic** for online rooms uses an -**SFU star** (selective forwarding unit on the lobby server): every peer sends -to the SFU; the SFU fans out to other seats. Peers do **not** mesh each other -for game data online. +(WebSocket control plane). Match **UDP pad traffic** is a star; who is the +hub depends on the launch the server sends (`transport`, `relay_via`): -Current lobby policy prefers **always SFU** for online starts so CGNAT / -asymmetric NAT does not strand players on failed ICE attempts. +| Launch | Hub | Transport in the engine | +|--------|-----|-------------------------| +| `transport "host"` + `relay_via "ice"` (default when the host's build has ICE) | **the host** | **ICE hub** — the ICE agents the waiting room already connected are adopted by the session (`rnet_session_start_ice_hub_adopt` on the host, `rnet_session_adopt_ice_agent` on a guest). No UDP port is bound or forwarded, nothing is dialled, no TURN. | +| `transport "host"`, no `relay_via` (older server / host without ICE) | **the host** | LAN path on the host's advertised UDP port (UPnP / NAT-PMP / STUN, probed by guests). | +| otherwise | the lobby server's **SFU** | LAN path to the server's relay endpoint. | + +Host relay is a preference of the hosting player (launcher toggles, default +on): `psx_lobby_set_relay_host_pref` publishes `match_caps.relay = "host"`; +`psx_lobby_set_relay_via_ice_pref` adds `relay_via = "ice"` (only when the +build can run ICE). The server falls back to its SFU unless every guest proved +its path to the host. + +**Host relay over ICE.** In the waiting room the host runs one ICE agent per +seated guest (host = answerer, guests offer), signalled over the lobby +`signal` op in its own type range (`130 + RNetSignalType`); a guest's proof is +`path_report direct`. `psx_lobby_client.c` carries that glue (the proof logic +is recomp-net's `RNetHostIce`). At launch the client captures the COMPLETED +agents; `ae_np_clear_launch_pending` takes them (before the lobby launch is +cleared) into `psx_netplay`'s stash and `psx_netplay_start` adopts them. A +seat that is not connected, or a spectator in the room, **refuses the launch**: +`join.last_error == "ice_not_connected"`, reason in +`psx_lobby_ice_launch_error()`; a smaller room is never started. + +Slot mapping: the lobby seat of each agent is mapped to its **session slot** +from the launch's `port_of_slot` table (`psx_ice_hub_map_seats`, +`netplay_ice_hub.c`), which already folds in `host_spectates` (slot 0 is the +silent host, players start at slot 1) and moved seats (slots follow ascending +lobby seat, the host is always slot 0). Every non-host session slot must get +exactly one agent. `psx_transport_decide` guarantees an ICE-hub launch is +never routed to the single-agent ICE path or the SFU, whatever else the config +carries. Covered ROM-free by `netplay_ice_hub_test`. + +Not on this path: spectators (gallery seats), and the waiting-room latency +column in ICE mode (the per-pair RTT probe is disabled there so it cannot race +the proof's `path_report`). ### ICE + TURN @@ -262,7 +293,7 @@ libjuice is pulled as a **pinned URL tarball** (not `git clone`) so Retro AppImage / mismatched-libcurl hosts can still configure; offline builds can vendor `lib/recomp-net/third_party/libjuice` or set `-DRNET_LIBJUICE_ROOT`. “Force TURN” in the UI can raise delay floors for relay-heavy paths; it -does not replace the SFU online architecture above. +does not change which transport the launch selects (see Online lobbies). ### LAN / Direct IP (P2P star) diff --git a/lib/recomp-net b/lib/recomp-net index b1eab87b..bb6a616c 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit b1eab87bf3c011da8c22890601c0e659f1f89fbb +Subproject commit bb6a616c4418a1b2209c7083a583bbd31ac7d797 diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 63d66ed5..7489e68e 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -91,6 +91,12 @@ if(BUILD_TESTING) add_executable(netplay_local_view_test tests/test_netplay_local_view.c) target_include_directories(netplay_local_view_test PRIVATE include) add_test(NAME netplay_local_view_test COMMAND netplay_local_view_test) + # Host relay over ICE: transport choice + lobby seat -> session slot map. + add_executable(netplay_ice_hub_test + tests/test_netplay_ice_hub.c + src/netplay_ice_hub.c) + target_include_directories(netplay_ice_hub_test PRIVATE include) + add_test(NAME netplay_ice_hub_test COMMAND netplay_ice_hub_test) add_executable(ws_scene_hold_test tests/test_ws_scene_hold.c) target_include_directories(ws_scene_hold_test PRIVATE include) add_test(NAME ws_scene_hold_test COMMAND ws_scene_hold_test) diff --git a/runtime/include/netplay_ice_hub.h b/runtime/include/netplay_ice_hub.h new file mode 100644 index 00000000..d104ca7d --- /dev/null +++ b/runtime/include/netplay_ice_hub.h @@ -0,0 +1,79 @@ +/* netplay_ice_hub.h -- host relay over ICE: the pure decisions. + * + * Two questions the engine must answer identically on every peer, kept free of + * recomp-net and of the lobby client so they can be tested without a ROM, a + * socket, or an ICE agent: + * + * 1. Which transport does a launch use? psx_transport_decide + * 2. Which SESSION slot does each connected guest agent serve? + * psx_ice_hub_map_seats + * + * Doctrine: a launch with transport_ice_hub binds no UDP port and dials + * nothing. It must never fall through to the single-agent ICE path (that is a + * different negotiation, signalled over the legacy ICE types) or to the + * server's SFU, even when the config also carries force_input_relay or an + * explicit transport=ice. A seat that cannot be mapped refuses the launch + * rather than starting a smaller room. + */ +#ifndef NETPLAY_ICE_HUB_H +#define NETPLAY_ICE_HUB_H + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum PsxTransportChoice { + PSX_TRANSPORT_ERROR_NO_PEER = -1, /* online / SFU launch with no endpoint */ + PSX_TRANSPORT_LAN_UDP = 0, /* bind + (hub | dial): LAN, host relay, SFU */ + PSX_TRANSPORT_SINGLE_ICE = 1, /* one negotiated agent (explicit ice only) */ + PSX_TRANSPORT_ICE_HUB = 2 /* adopt the waiting room's connected agents */ +} PsxTransportChoice; + +/* cfg_transport: PsxNetplayConfig.transport (0 auto, 1 force ICE, 2 force LAN). + * in_online_room: connected to the lobby and seated (a MotK online room). + * ice_built: this build can run ICE agents. + * An ICE hub launch under force-LAN is an error: the launch carries no + * endpoint to bind or dial. */ +PsxTransportChoice psx_transport_decide(int cfg_transport, int transport_ice_hub, + int transport_host, int force_input_relay, + int in_online_room, int has_peer_endpoint, + int ice_built); + +#define PSX_ICE_HUB_MAX_SEATS 8 + +/* Why a mapping was refused (for the log / the launch error). */ +typedef enum PsxIceHubMapError { + PSX_ICE_HUB_MAP_OK = 0, + PSX_ICE_HUB_MAP_BAD_ARGS, + PSX_ICE_HUB_MAP_SEAT_UNMAPPED, /* a lobby seat holds no session slot */ + PSX_ICE_HUB_MAP_SEAT_DUPLICATE, /* two agents for one lobby seat / slot */ + PSX_ICE_HUB_MAP_SEAT_MISSING /* a non-host session slot has no agent */ +} PsxIceHubMapError; + +/* Map each connected guest's LOBBY seat to the SESSION slot it serves. + * + * port_of_slot[s] (s in 0..slot_count-1): the lobby seat session slot s + * drives, -1 for none. Slot 0 is the host and is never an agent seat, so + * the table is searched from slot 1. This is the table the launch already + * carries (PsxNetplayConfig.port_of_slot): it folds in the host-in-gallery + * offset (host_spectates: slot 0 has port -1, players start at slot 1) and + * moved seats (slots follow ascending lobby seat, not lobby seat itself). + * port_map_valid == 0: no table; identity, or lobby seat + 1 with + * host_spectates (players sit one slot above their seat). + * + * Requires exactly slot_count - 1 distinct guest seats, every one mapped to a + * distinct session slot in 1..slot_count-1: a session slot with no agent would + * be a smaller room. Returns 0 and fills session_slots_out[i] (parallel to + * lobby_slots[i]), else -1 with *err_out set. */ +int psx_ice_hub_map_seats(const int *port_of_slot, int port_map_valid, + int slot_count, int host_spectates, + const int *lobby_slots, int n, + int *session_slots_out, PsxIceHubMapError *err_out); + +const char *psx_ice_hub_map_error_text(PsxIceHubMapError e); + +#ifdef __cplusplus +} +#endif + +#endif /* NETPLAY_ICE_HUB_H */ diff --git a/runtime/include/psx_lobby_client.h b/runtime/include/psx_lobby_client.h index 6881841a..a0960172 100644 --- a/runtime/include/psx_lobby_client.h +++ b/runtime/include/psx_lobby_client.h @@ -208,6 +208,11 @@ typedef struct PsxLobbyMatchCaps { * server falls back to its relay unless every guest proved the path * (recomp_net/host_relay.h). Guests read it to know the room asks. */ int relay_host; + /* 0/1 -- with relay_host: match_caps.relay_via = "ice". The host carries + * the match over the ICE agents the waiting room already connected (one + * per guest); no port is forwarded or advertised. Published by the host + * when psx_lobby_relay_via_ice() is on and this build has ICE. */ + int relay_via_ice; int force_turn; /* 0/1 — ICE relay-only (Force TURN for UDP) */ int rollback; /* 0/1 — invent/rollback netplay (default on) */ /* DualShock-on-multitap-tap hack (0/1). Host-authoritative for the match. */ @@ -276,6 +281,14 @@ typedef struct PsxLobbyJoinInfo { * (peer-to-peer, signalled over the lobby). No endpoint is dialled and * force_input_relay is 0 (psx_lobby_launch_transport.h). */ int transport_ice; + /* Launch: transport "host" AND relay_via "ice". transport_host is ALSO 1 + * then, so test this first. Nothing is bound or dialled (bind_hostport is + * the placeholder 0.0.0.0:0, peer_hostport empty): the match rides the + * agents psx_lobby_ice_take_* hands over. A launch whose seats are not all + * connected is refused in the client: launch_pending stays 0 and + * last_error is "ice_not_connected" (psx_lobby_ice_launch_error() says + * which seat). */ + int transport_ice_hub; /* A server code (need_password | bad_password | …) or, from * psx_lobby_set_last_error, one sentence the launcher shows as is. */ char last_error[192]; @@ -423,6 +436,37 @@ struct RNetHostRelayStatus; int psx_lobby_host_relay_status(struct RNetHostRelayStatus *out); void psx_lobby_host_relay_release_port(void); +/* Host relay over ICE (recomp_net/host_ice.h). The pref is the HOST's, default + * on; it only publishes relay_via "ice" when the build can run ICE. */ +void psx_lobby_set_relay_via_ice_pref(int on); +int psx_lobby_relay_via_ice_pref(void); +/* 1 when this build can run ICE agents at all. */ +int psx_lobby_ice_available(void); +struct RNetHostIceStatus; +/* Waiting-room per-seat state; 1 while the ICE proof is running. */ +int psx_lobby_host_ice_status(struct RNetHostIceStatus *out); + +/* Launch handover (transport_ice_hub). The agents are COMPLETED and owned by + * the caller on success: give them to the session or release them with + * psx_lobby_ice_destroy_agent. TAKE BEFORE psx_lobby_clear_launch_pending's + * consumer finishes with the launch -- an untaken bundle is destroyed after + * 60 s, at the next launch, or on leave. lobby_slot is the lobby's seat + * namespace; the engine maps it to its own session slot. */ +struct RNetIceAgent; +typedef struct PsxLobbyIceSeat { + int lobby_slot; + char player_id[PSX_LOBBY_ID_LEN]; + struct RNetIceAgent *agent; +} PsxLobbyIceSeat; +/* Host: n >= 1 (ownership moves to the caller) or -1. */ +int psx_lobby_ice_take_hub(PsxLobbyIceSeat *out, int max); +/* Guest: the one agent connected to the host, or NULL. */ +struct RNetIceAgent *psx_lobby_ice_take_guest_agent(void); +/* Why the last launch was refused / a take failed ("" when none). */ +const char *psx_lobby_ice_launch_error(void); +void psx_lobby_ice_discard(void); +void psx_lobby_ice_destroy_agent(struct RNetIceAgent *agent); + /* Live member table from lobby_update (and create/join). */ int psx_lobby_member_count(void); int psx_lobby_member_get(int index, PsxLobbyMember *out); diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index 60f9e5ad..0d433c68 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -98,6 +98,16 @@ typedef struct PsxNetplayConfig { * carries the match on bind_hostport; guests dial peer_hostport. Takes * the LAN / hub transport path, not the server-relay one. */ int transport_host; + /* 1 = the launch said transport "host" AND relay_via "ice" (host relay + * over ICE). transport_host is ALSO 1 then; this is tested first. No UDP + * port is bound and nothing is dialled (bind_hostport is a placeholder, + * peer_hostport empty): the match runs over the ICE agents the waiting + * room connected -- host: a hub over one agent per guest; guest: a 1:1 + * agent to the host. Neither the single-agent ICE path nor the SFU is + * ever chosen for it. The agents come from psx_netplay_ice_stash_from_lobby + * (taken when the launch was consumed) or, failing that, straight from the + * lobby client. */ + int transport_ice_hub; int force_turn; /* 1 = ICE relay-only (Force TURN for UDP) */ /* 0 = auto (MotK room → ICE, else LAN), 1 = force ICE, 2 = force LAN. * Env PSX_NET_TRANSPORT=lan|ice overrides. */ @@ -186,13 +196,24 @@ int psx_netplay_local_view(uint32_t display_w, uint32_t display_h, /* * Snapshot for diagnostic dumps (starvation_dump.jsonl meta, etc.). - * arch_out: "off" | "p2p" | "host_relay" | "server_relay" (never NULL when + * arch_out: "off" | "p2p" | "host_relay" | "ice_host_relay" | "server_relay" (never NULL when * arch_cap > 0). Returns 1 when netplay is/was configured this run. */ int psx_netplay_diag_snapshot(char *arch_out, size_t arch_cap, int *max_players_out, int *player_count_out); int psx_netplay_start(const PsxNetplayConfig *cfg); + +/* Host relay over ICE: take the launch's connected agents from the lobby + * client and hold them until psx_netplay_start adopts them. Call where the + * launch is consumed, BEFORE the lobby launch is cleared (the lobby destroys an + * untaken bundle after 60 s). No-op unless the lobby's launch is an ICE hub + * launch with a bundle pending. Returns 1 when agents are now held. */ +int psx_netplay_ice_stash_from_lobby(void); +/* Release held agents (a launch that never started). Safe at any time. */ +void psx_netplay_ice_stash_discard(void); +/* 1 when held agents await psx_netplay_start. */ +int psx_netplay_ice_stash_held(void); void psx_netplay_shutdown(void); /* Soft-return rematch / new lobby opponent: make host sim state match a cold * process peer. Call from session_reboot (rematch) and after BYE teardown. diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index ce7ff5f9..4eff0737 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -493,6 +493,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/psx_lobby_client.c ${PSXRECOMP_ROOT}/runtime/src/netplay_bios_settle.c ${PSXRECOMP_ROOT}/runtime/src/netplay_exit_reason.c + ${PSXRECOMP_ROOT}/runtime/src/netplay_ice_hub.c ${PSXRECOMP_ROOT}/recompiler/src/config_loader.cpp ${PSXRECOMP_ROOT}/recompiler/src/ps1_exe_parser.cpp # (sljit Tier-2 in-process JIT backend removed 2026-07-15 — was disabled by diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index eee4ad09..7c2f2fc0 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -84,6 +84,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #if defined(PSX_HAS_RECOMP_NET) #include "recomp_net/auth.h" #include "recomp_net/host_relay.h" /* RNetHostRelayStatus for the launcher relay line */ +#include "recomp_net/host_ice.h" /* RNetHostIceStatus: host relay over ICE waiting-room line */ #include "recomp_net/chat_filter.h" /* chat profanity mask, LAN rooms too */ #endif #include "spu.h" @@ -10194,6 +10195,11 @@ namespace { * (Network Settings / Lobby Settings, persisted there) and pushes it * through relay_host_set; default on. */ int g_lnch_relay_host = 1; + /* Host relay over ICE (2026-10-03): with the host relay on, publish + * relay_via "ice" so guests reach the host through ICE and nobody forwards + * a port. Default on; the lobby client mirrors it (psx_lobby_set_ + * relay_via_ice_pref) and only publishes it when the build can run ICE. */ + int g_lnch_relay_via_ice = 1; /* Lobby default on; host “Disable Rollback” clears this → delay_sync. */ int g_lnch_rollback = 1; int g_lnch_multitap_analog = 1; @@ -10758,6 +10764,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; @@ -11004,6 +11013,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 @@ -12901,11 +12914,87 @@ namespace { ae_np_push_match_caps(nullptr); /* no-op unless hosting online */ return 0; } + /* ICE-mode waiting-room text, from the agents' own state. Never says + * "port": no port is held in this mode. 1 = `out` holds the line. */ + static const char* ae_np_ice_state_word(int state) { + switch (state) { + case RNET_ICE_STATE_COMPLETED: return "connected"; + case RNET_ICE_STATE_FAILED: return "failed"; + default: return "connecting"; + } + } + static void ae_np_ice_peer_text(const RNetHostIcePeerStatus& p, char* out, size_t cap) { + if (p.state == RNET_ICE_STATE_COMPLETED && p.path[0]) + std::snprintf(out, cap, "connected (%s)", p.path); + else + std::snprintf(out, cap, "%s", ae_np_ice_state_word(p.state)); + } + static int ae_np_ice_status_line(char* out, size_t out_cap) { + RNetHostIceStatus st; + std::memset(&st, 0, sizeof(st)); + if (!psx_lobby_host_ice_status(&st)) return 0; + if (st.role == 2) { + char w[40]; + if (st.peer_count < 1) { + std::snprintf(out, out_cap, "Connecting to the host..."); + return 1; + } + ae_np_ice_peer_text(st.peer[0], w, sizeof(w)); + if (st.peer[0].state == RNET_ICE_STATE_FAILED) + std::snprintf(out, out_cap, + "Connection to the host failed. Check that your firewall " + "allows the game, then rejoin."); + else + std::snprintf(out, out_cap, "Connection to the host: %s.", w); + return 1; + } + if (st.peer_count < 1) { + std::snprintf(out, out_cap, "Waiting for guests to connect through you..."); + return 1; + } + size_t n = 0; + for (int i = 0; i < st.peer_count && i < RNET_HOST_ICE_MAX_PEERS; ++i) { + char w[40]; + ae_np_ice_peer_text(st.peer[i], w, sizeof(w)); + const int k = std::snprintf(out + n, out_cap - n, "%sSeat %d: %s", + i ? "\n" : "", st.peer[i].slot + 1, w); + if (k < 0 || (size_t)k >= out_cap - n) break; + n += (size_t)k; + } + if (st.completed < st.peer_count && n < out_cap) + std::snprintf(out + n, out_cap - n, + "\nPlay starts once every seat is connected. A seat that stays " + "on failed can't reach you; check its firewall."); + return 1; + } + int ae_np_relay_via_ice_get(void*) { + if (g_lnch_hosting_lan || g_lnch_joined_lan || !psx_lobby_ice_available()) return 0; + if (psx_lobby_in_lobby()) { + const PsxLobbyMatchCaps* caps = psx_lobby_match_caps(); + if (caps && caps->valid) return (caps->relay_host && caps->relay_via_ice) ? 1 : 0; + } + return g_lnch_relay_via_ice; + } + int ae_np_relay_via_ice_set(void*, int on) { + g_lnch_relay_via_ice = on ? 1 : 0; + psx_lobby_set_relay_via_ice_pref(g_lnch_relay_via_ice); + if (!g_lnch_hosting_lan && !g_lnch_joined_lan) + ae_np_push_match_caps(nullptr); /* no-op unless hosting online */ + return 0; + } int ae_np_relay_status(void*, char* out, size_t out_cap) { if (!out || !out_cap) return 0; out[0] = '\0'; #if defined(PSX_HAS_RECOMP_NET) if (g_lnch_hosting_lan || g_lnch_joined_lan || !psx_lobby_in_lobby()) return 0; + /* ICE mode replaces the port/reachability guidance entirely: the + * legacy status would talk about a forwarded port this room never opens. */ + if (ae_np_ice_status_line(out, out_cap)) return 1; + { + const PsxLobbyMatchCaps* c = psx_lobby_match_caps(); + if (c && c->valid && c->relay_host && c->relay_via_ice) + return 0; /* ICE room, agents not up yet: say nothing */ + } RNetHostRelayStatus st; if (!psx_lobby_host_relay_status(&st)) return 0; if (st.role == 1) { @@ -13144,6 +13233,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)) || @@ -13661,7 +13754,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; @@ -14673,9 +14767,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; @@ -14695,8 +14798,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]; @@ -14926,6 +15027,12 @@ namespace { return g_lnch_pending_direct_launch.enabled || psx_lobby_launch_pending(); } void ae_np_clear_launch_pending(void*) { + /* Host relay over ICE: the connected agents must be TAKEN before the + * launch is cleared (the lobby destroys an untaken bundle after 60 s + * and the game boots well after this). Held by psx_netplay until + * psx_netplay_start adopts them. No-op for every other launch. */ + if (!g_lnch_pending_direct_launch.enabled && psx_lobby_launch_pending()) + (void)psx_netplay_ice_stash_from_lobby(); g_lnch_pending_direct_launch = {}; psx_lobby_clear_launch_pending(); } @@ -14961,6 +15068,16 @@ namespace { return g_lnch_lan_endpoint.empty() ? nullptr : g_lnch_lan_endpoint.c_str(); } + /* Host relay over ICE: transport "host" + relay_via "ice". Decided from the + * lobby's launch (the same join info fill_launch read), and only for a + * launch the lobby filled: a LAN / direct launch is never an ICE hub, and + * transport_host is ALSO set for an ICE hub, so it gates the lobby read. */ + static int ae_np_launch_is_ice_hub(const RecompLauncherCNetplayLaunch& l) { + if (!l.transport_host || g_lnch_hosting_lan || g_lnch_joined_lan) return 0; + const PsxLobbyJoinInfo* ji = psx_lobby_join_info(); + return (ji && ji->ok && ji->transport_ice_hub) ? 1 : 0; + } + int ae_np_fill_launch(void*, RecompLauncherCNetplayLaunch* out) { if (!out) return 0; if (g_lnch_pending_direct_launch.enabled) { @@ -15317,6 +15434,10 @@ namespace { g_lnch_netplay_callbacks.relay_host_get = ae_np_relay_host_get; g_lnch_netplay_callbacks.relay_host_set = ae_np_relay_host_set; g_lnch_netplay_callbacks.relay_status = ae_np_relay_status; +#endif +#if defined(RECOMP_LAUNCHER_HAS_HOST_RELAY_ICE) + g_lnch_netplay_callbacks.relay_via_ice_get = ae_np_relay_via_ice_get; + g_lnch_netplay_callbacks.relay_via_ice_set = ae_np_relay_via_ice_set; #endif g_lnch_netplay_callbacks.memcard_offer_set = ae_np_memcard_offer_set; g_lnch_netplay_callbacks.guest_memcard_get = ae_np_guest_memcard_get; @@ -17744,6 +17865,7 @@ int main(int argc, char** argv) { net_cfg.force_input_relay = ls.netplay_launch.force_input_relay ? 1 : 0; #if defined(RECOMP_LAUNCHER_HAS_HOST_RELAY) net_cfg.transport_host = ls.netplay_launch.transport_host ? 1 : 0; + net_cfg.transport_ice_hub = ae_np_launch_is_ice_hub(ls.netplay_launch); #endif net_cfg.force_turn = ls.netplay_launch.force_turn ? 1 : 0; net_cfg.rollback = ls.netplay_launch.rollback ? 1 : 0; @@ -19834,6 +19956,7 @@ soft_return_lobby: net_cfg.force_input_relay = ls.netplay_launch.force_input_relay ? 1 : 0; #if defined(RECOMP_LAUNCHER_HAS_HOST_RELAY) net_cfg.transport_host = ls.netplay_launch.transport_host ? 1 : 0; + net_cfg.transport_ice_hub = ae_np_launch_is_ice_hub(ls.netplay_launch); #endif net_cfg.force_turn = ls.netplay_launch.force_turn ? 1 : 0; net_cfg.rollback = ls.netplay_launch.rollback ? 1 : 0; diff --git a/runtime/src/netplay_ice_hub.c b/runtime/src/netplay_ice_hub.c new file mode 100644 index 00000000..e3efa67e --- /dev/null +++ b/runtime/src/netplay_ice_hub.c @@ -0,0 +1,78 @@ +/* netplay_ice_hub.c -- see netplay_ice_hub.h. No recomp-net, no sockets. */ +#include "netplay_ice_hub.h" + +#include + +PsxTransportChoice psx_transport_decide(int cfg_transport, int transport_ice_hub, + int transport_host, int force_input_relay, + int in_online_room, int has_peer_endpoint, + int ice_built) +{ + /* Hub first: it implies transport_host, and it is decided by the launch + * alone. Neither force_input_relay nor an explicit transport=ice may + * redirect it onto the SFU or the single-agent path. */ + if (transport_ice_hub) { + if (cfg_transport == 2 || !ice_built) return PSX_TRANSPORT_ERROR_NO_PEER; + return PSX_TRANSPORT_ICE_HUB; + } + if (cfg_transport == 2) return PSX_TRANSPORT_LAN_UDP; + /* Legacy host relay (advertised UDP port): the LAN path, not the SFU. */ + if (transport_host) return PSX_TRANSPORT_LAN_UDP; + if (force_input_relay || in_online_room) + return has_peer_endpoint ? PSX_TRANSPORT_LAN_UDP : PSX_TRANSPORT_ERROR_NO_PEER; + if (cfg_transport == 1 && ice_built) return PSX_TRANSPORT_SINGLE_ICE; + return PSX_TRANSPORT_LAN_UDP; +} + +const char *psx_ice_hub_map_error_text(PsxIceHubMapError e) +{ + switch (e) { + case PSX_ICE_HUB_MAP_OK: return "ok"; + case PSX_ICE_HUB_MAP_BAD_ARGS: return "bad arguments"; + case PSX_ICE_HUB_MAP_SEAT_UNMAPPED: return "a connected seat holds no session slot"; + case PSX_ICE_HUB_MAP_SEAT_DUPLICATE: return "two connections for one seat"; + case PSX_ICE_HUB_MAP_SEAT_MISSING: return "a session slot has no connection"; + } + return "unknown"; +} + +static int fail(PsxIceHubMapError *err_out, PsxIceHubMapError e) +{ + if (err_out) *err_out = e; + return -1; +} + +int psx_ice_hub_map_seats(const int *port_of_slot, int port_map_valid, + int slot_count, int host_spectates, + const int *lobby_slots, int n, + int *session_slots_out, PsxIceHubMapError *err_out) +{ + int i, s; + unsigned used = 0; + if (err_out) *err_out = PSX_ICE_HUB_MAP_OK; + if (!lobby_slots || !session_slots_out || n < 1 || n > PSX_ICE_HUB_MAX_SEATS || + slot_count < 2 || slot_count > 32 || (port_map_valid && !port_of_slot)) + return fail(err_out, PSX_ICE_HUB_MAP_BAD_ARGS); + for (i = 0; i < n; ++i) { + int found = -1; + if (lobby_slots[i] < 0) return fail(err_out, PSX_ICE_HUB_MAP_SEAT_UNMAPPED); + for (s = 0; s < i; ++s) + if (lobby_slots[s] == lobby_slots[i]) + return fail(err_out, PSX_ICE_HUB_MAP_SEAT_DUPLICATE); + if (port_map_valid) { + for (s = 1; s < slot_count; ++s) + if (port_of_slot[s] == lobby_slots[i]) { found = s; break; } + } else { + found = lobby_slots[i] + (host_spectates ? 1 : 0); + if (found < 1 || found >= slot_count) found = -1; + } + if (found < 0) return fail(err_out, PSX_ICE_HUB_MAP_SEAT_UNMAPPED); + if (used & (1u << found)) return fail(err_out, PSX_ICE_HUB_MAP_SEAT_DUPLICATE); + used |= 1u << found; + session_slots_out[i] = found; + } + /* Every non-host session slot needs its agent. */ + for (s = 1; s < slot_count; ++s) + if (!(used & (1u << s))) return fail(err_out, PSX_ICE_HUB_MAP_SEAT_MISSING); + return 0; +} diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index 365cf4d0..5928cf47 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -52,6 +53,15 @@ void psx_lobby_set_relay_host_pref(int on) { (void)on; } int psx_lobby_relay_host_pref(void) { return 0; } int psx_lobby_host_relay_status(struct RNetHostRelayStatus *out) { (void)out; return 0; } void psx_lobby_host_relay_release_port(void) {} +void psx_lobby_set_relay_via_ice_pref(int on) { (void)on; } +int psx_lobby_relay_via_ice_pref(void) { return 0; } +int psx_lobby_ice_available(void) { return 0; } +int psx_lobby_host_ice_status(struct RNetHostIceStatus *out) { (void)out; return 0; } +int psx_lobby_ice_take_hub(PsxLobbyIceSeat *out, int max) { (void)out; (void)max; return -1; } +struct RNetIceAgent *psx_lobby_ice_take_guest_agent(void) { return NULL; } +const char *psx_lobby_ice_launch_error(void) { return ""; } +void psx_lobby_ice_discard(void) {} +void psx_lobby_ice_destroy_agent(struct RNetIceAgent *agent) { (void)agent; } int psx_lobby_kick(int slot) { (void)slot; return -1; } void psx_lobby_set_allow_spectators(int allow) { (void)allow; } int psx_lobby_allow_spectators_pref(void) { return 0; } @@ -210,6 +220,7 @@ void psx_lobby_clear_launch_pending(void) {} #include "rnet_sha1.h" #include "recomp_net/address.h" #include "recomp_net/host_relay.h" +#include "recomp_net/host_ice.h" #include "recomp_net/ice.h" #include "recomp_net/ice_rtt.h" #include "recomp_net/lan_beacon.h" @@ -1136,6 +1147,7 @@ static void match_caps_clear(PsxLobbyMatchCaps *c) * is recomp-net's and only the glue lives here. */ static RNetHostRelay *g_host_relay; static int g_relay_host_pref = 1; +static int room_relays_via_ice(void); void psx_lobby_set_relay_host_pref(int on) { g_relay_host_pref = on ? 1 : 0; } int psx_lobby_relay_host_pref(void) { return g_relay_host_pref; } @@ -1171,7 +1183,8 @@ static void host_relay_step(void) v.is_host = g_lc.is_host ? 1 : 0; v.active = psx_lobby_connected() && g_lc.in_lobby && !g_lc.launch_pending && !g_lc.join.local_is_spectator && - g_lc.match_caps.valid && g_lc.match_caps.relay_host; + g_lc.match_caps.valid && g_lc.match_caps.relay_host && + !room_relays_via_ice(); /* ICE mode: no port, no set_host_endpoint */ v.bind_port = (unsigned short)lobby_bind_port(); if (v.is_host && !v.bind_port) v.active = 0; v.host_endpoint = g_lc.join.host_endpoint; @@ -1198,6 +1211,259 @@ static void host_relay_leave(void) rnet_host_relay_leave(g_host_relay); } +/* ---- host relay over ICE (recomp_net/host_ice.h) ------------------------- + * psxrecomp's copy of recomp-net's rnet_lobby_client.c wiring (decision: the + * fork is not a thin copy -- own member table, spectator gallery, BIOS / + * memcard offers, signal queue -- so the glue is ported here and the proof + * logic is recomp-net's RNetHostIce, exactly as RNetHostRelay is above). + * + * The pref is the HOST's, default ON. In ICE mode the waiting-room proof is + * one agent pair per guest instead of a probe of an advertised UDP port, so + * host_relay_step stands down (no UPnP / STUN port, no set_host_endpoint). */ +static RNetHostIce *g_host_ice; +static int g_relay_via_ice_pref = 1; + +void psx_lobby_set_relay_via_ice_pref(int on) { g_relay_via_ice_pref = on ? 1 : 0; } +int psx_lobby_relay_via_ice_pref(void) { return g_relay_via_ice_pref; } +int psx_lobby_ice_available(void) { return rnet_host_ice_available(); } + +/* The room's published caps ask for ICE as the host relay's path. */ +static int room_relays_via_ice(void) +{ + return g_lc.match_caps.valid && g_lc.match_caps.relay_host && + g_lc.match_caps.relay_via_ice; +} + +/* Agents handed over at launch until the engine takes them (or the room / + * launch is abandoned). Kept apart from g_lc so a refused launch can still say + * why after the lobby state is reset. */ +typedef struct { + int valid; /* a bundle awaits take */ + int is_host; + int n; + PsxLobbyIceSeat seat[RNET_HOST_ICE_MAX_PEERS]; /* host */ + RNetIceAgent *guest; /* guest: connected to the host */ + uint64_t born_ms; + char error[200]; +} PsxIceLaunch; +static PsxIceLaunch g_il; + +static uint64_t ice_now_ms(void) +{ + return (uint64_t)psx_host_mono_ms(); +} + +static void ice_launch_discard(void) +{ + int i; + for (i = 0; i < g_il.n; ++i) + if (g_il.seat[i].agent) rnet_host_ice_destroy_agent(g_il.seat[i].agent); + if (g_il.guest) rnet_host_ice_destroy_agent(g_il.guest); + memset(&g_il, 0, offsetof(PsxIceLaunch, error)); +} + +static void host_ice_leave(void) +{ + ice_launch_discard(); + g_il.error[0] = '\0'; + rnet_host_ice_destroy(&g_host_ice); +} + +/* Lobby `signal` addressed to one player (the legacy sender broadcasts). */ +static int send_signal_to(const char *to_player_id, int type, int flag, const char *text) +{ + char esc[4096]; + char to_esc[JSON_ESC_CAP(PSX_LOBBY_ID_LEN)]; + char lid_esc[JSON_ESC_CAP(PSX_LOBBY_ID_LEN)]; + char msg[4608]; + const char *lid; + if (!psx_lobby_connected() || !g_lc.in_lobby) return -1; + lid = g_lc.join.lobby_id[0] ? g_lc.join.lobby_id : ""; + json_escape(text ? text : "", esc, sizeof(esc)); + json_escape(to_player_id ? to_player_id : "", to_esc, sizeof(to_esc)); + json_escape(lid, lid_esc, sizeof(lid_esc)); + snprintf(msg, sizeof(msg), + "{\"op\":\"signal\",\"lobby_id\":\"%s\",\"to_player_id\":\"%s\"," + "\"type\":%d,\"flag\":%d,\"text\":\"%s\"}", + lid_esc, to_esc, type, flag, esc); + if (g_lc.handshake_done && g_lc.fd >= 0) + return rnet_ws_write_text(g_lc.fd, msg, 1) < 0 ? -1 : 0; + queue_send(msg); + return 0; +} + +static int host_ice_send_signal(const char *to, int type, int flag, const char *text, + void *ctx) +{ + (void)ctx; + return send_signal_to(to, type, flag, text); +} + +/* Every pump, beside host_relay_step: the room decides which agents exist. */ +static void host_ice_step(void) +{ + RNetHostIceView v; + RNetHostIcePeer peers[RNET_HOST_ICE_MAX_PEERS]; + int n = 0, i; + if (!g_host_ice) { + g_host_ice = rnet_host_ice_create(); + if (!g_host_ice) return; + } + /* A bundle nobody took (the engine declined the launch) must not hold + * sockets for the rest of the session. */ + if (g_il.valid && ice_now_ms() - g_il.born_ms > 60000u) + ice_launch_discard(); + memset(&v, 0, sizeof(v)); + v.is_host = g_lc.is_host ? 1 : 0; + v.local_slot = local_member_slot(); + v.active = psx_lobby_connected() && g_lc.in_lobby && !g_lc.launch_pending && + !g_lc.join.local_is_spectator && room_relays_via_ice() && + v.local_slot >= 0; + if (v.active) { + if (v.is_host) { + for (i = 0; i < g_lc.member_count && n < RNET_HOST_ICE_MAX_PEERS; ++i) { + const PsxLobbyMember *m = &g_lc.members[i]; + if (m->is_spectator || !m->player_id[0] || + strcmp(m->player_id, g_lc.player_id) == 0) + continue; + peers[n].player_id = m->player_id; + peers[n].slot = m->slot; + n++; + } + } else { + const int hs = member_slot_for_player(g_lc.host_player_id); + if (g_lc.host_player_id[0] && hs >= 0) { + peers[0].player_id = g_lc.host_player_id; + peers[0].slot = hs; + n = 1; + } else { + v.active = 0; + } + } + } + v.peers = peers; + v.peer_count = n; + /* STUN only: hub agents never use TURN, so the Coturn mint is not used. */ + if (g_lc.turn.valid && g_lc.turn.stun_host[0]) { + v.stun_host = g_lc.turn.stun_host; + v.stun_port = (unsigned short)(g_lc.turn.stun_port > 0 ? g_lc.turn.stun_port : 3478); + } + v.send_signal = host_ice_send_signal; + v.send_json = host_relay_send; + rnet_host_ice_update(g_host_ice, &v); +} + +int psx_lobby_host_ice_status(struct RNetHostIceStatus *out) +{ + if (!out) return 0; + rnet_host_ice_status(g_host_ice, out); + return out->role != 0; +} + +/* Build the handover from the agents the waiting room already connected. A + * seat that is not COMPLETED is a refused launch with a reason, never a + * smaller room. Spectators are not on the ICE path: any gallery seat refuses + * the launch (host), and a spectating guest has no agent (it refuses itself). */ +static void ice_launch_capture(void) +{ + int i; + ice_launch_discard(); + g_il.error[0] = '\0'; + g_il.is_host = g_lc.is_host ? 1 : 0; + g_il.born_ms = ice_now_ms(); + if (!g_host_ice) { + snprintf(g_il.error, sizeof(g_il.error), + "no ICE agents exist (this build cannot run ICE, or the room was " + "not seated)"); + return; + } + if (g_il.is_host) { + for (i = 0; i < g_lc.member_count; ++i) { + const PsxLobbyMember *m = &g_lc.members[i]; + RNetIceAgent *a; + if (!m->player_id[0] || strcmp(m->player_id, g_lc.player_id) == 0) + continue; + if (m->is_spectator) { + snprintf(g_il.error, sizeof(g_il.error), + "\"%s\" is a spectator; spectators are not on the ICE " + "host-relay path", + m->display_name[0] ? m->display_name : m->player_id); + break; + } + if (g_il.n >= RNET_HOST_ICE_MAX_PEERS) { + snprintf(g_il.error, sizeof(g_il.error), "too many guests for the ICE hub"); + break; + } + a = rnet_host_ice_take_completed(g_host_ice, m->player_id); + if (!a) { + snprintf(g_il.error, sizeof(g_il.error), + "\"%s\" (seat %d) is not connected over ICE", + m->display_name[0] ? m->display_name : m->player_id, m->slot); + break; + } + g_il.seat[g_il.n].lobby_slot = m->slot; + snprintf(g_il.seat[g_il.n].player_id, sizeof(g_il.seat[g_il.n].player_id), + "%s", m->player_id); + g_il.seat[g_il.n].agent = a; + g_il.n++; + } + if (!g_il.error[0] && g_il.n == 0) + snprintf(g_il.error, sizeof(g_il.error), "no guest is seated"); + } else { + g_il.guest = rnet_host_ice_take_completed(g_host_ice, g_lc.host_player_id); + if (!g_il.guest) + snprintf(g_il.error, sizeof(g_il.error), + "this client is not connected to the host over ICE"); + } + if (g_il.error[0]) { + char why[sizeof(g_il.error)]; + snprintf(why, sizeof(why), "%s", g_il.error); + ice_launch_discard(); + snprintf(g_il.error, sizeof(g_il.error), "%s", why); + return; + } + g_il.valid = 1; +} + +int psx_lobby_ice_take_hub(PsxLobbyIceSeat *out, int max) +{ + int n; + if (!out || max < 1) return -1; + if (!g_il.valid || !g_il.is_host) { + if (!g_il.error[0]) + snprintf(g_il.error, sizeof(g_il.error), "no ICE hub handover is pending"); + return -1; + } + if (max < g_il.n) { + snprintf(g_il.error, sizeof(g_il.error), "output array too small for %d seats", g_il.n); + return -1; + } + n = g_il.n; + memcpy(out, g_il.seat, sizeof(out[0]) * (size_t)n); + memset(&g_il, 0, offsetof(PsxIceLaunch, error)); /* ownership is the caller's */ + return n; +} + +RNetIceAgent *psx_lobby_ice_take_guest_agent(void) +{ + RNetIceAgent *a; + if (!g_il.valid || g_il.is_host || !g_il.guest) { + if (!g_il.error[0]) + snprintf(g_il.error, sizeof(g_il.error), "no ICE guest handover is pending"); + return NULL; + } + a = g_il.guest; + memset(&g_il, 0, offsetof(PsxIceLaunch, error)); + return a; +} + +const char *psx_lobby_ice_launch_error(void) { return g_il.error; } +void psx_lobby_ice_discard(void) { ice_launch_discard(); } +void psx_lobby_ice_destroy_agent(RNetIceAgent *agent) +{ + if (agent) rnet_host_ice_destroy_agent(agent); +} + static int json_extract_object(const char *json, const char *key, char *out, size_t out_cap); static void parse_match_caps_object(const char *obj, PsxLobbyMatchCaps *out); static void ingest_match_caps_from_json(const char *json); @@ -1734,6 +2000,12 @@ static void parse_match_caps_object(const char *obj, PsxLobbyMatchCaps *out) json_get_str(obj, "relay", relay, sizeof(relay)); out->relay_host = strcmp(relay, "host") == 0 ? 1 : 0; } + { + char via[12]; + via[0] = '\0'; + json_get_str(obj, "relay_via", via, sizeof(via)); + out->relay_via_ice = strcmp(via, "ice") == 0 ? 1 : 0; + } out->force_turn = json_get_bool(obj, "force_turn", 0); /* Absent field → delay-sync (older hosts). New hosts always publish explicit. */ out->rollback = json_get_bool(obj, "rollback", 0); @@ -1820,7 +2092,12 @@ static int append_match_caps_json(char *dst, size_t dst_cap, const PsxLobbyMatch caps->auto_skip_fmv ? "true" : "false", caps->input_delay, caps->input_prediction, - caps->relay_host ? "\"relay\":\"host\"," : "", + /* only when asked: absent reads as "the SFU, as always" */ + caps->relay_host + ? ((g_relay_via_ice_pref && rnet_host_ice_available()) + ? "\"relay\":\"host\",\"relay_via\":\"ice\"," + : "\"relay\":\"host\",") + : "", caps->force_input_relay ? "true" : "false", caps->force_turn ? "true" : "false", caps->rollback ? "true" : "false", @@ -1929,7 +2206,11 @@ static void fill_peer_bind_from_join(void) const int host_hub = (g_lc.is_host && seats >= 3 && !force_relay) ? 1 : 0; memset(j->bind_hostport, 0, sizeof(j->bind_hostport)); memset(j->peer_hostport, 0, sizeof(j->peer_hostport)); - if (force_relay) { + if (j->transport_ice_hub) { + /* Nothing to bind or dial: the agents are already connected. Kept + * non-empty so a consumer's "usable bind" check passes. */ + strncpy(j->bind_hostport, "0.0.0.0:0", sizeof(j->bind_hostport) - 1); + } else if (force_relay) { /* Everyone dials the lobby-server UDP relay — ephemeral local bind * (same as LAN guests) so same-PC multi-instance doesn't collide. */ strncpy(j->bind_hostport, "0.0.0.0:0", sizeof(j->bind_hostport) - 1); @@ -3171,6 +3452,8 @@ static void handle_server_json(const char *json) } if (strcmp(op, "created") == 0) { psx_lobby_chat_clear(); + ice_launch_discard(); + g_lc.join.transport_ice_hub = 0; g_lc.in_lobby = 1; g_lc.is_host = 1; g_lc.join.ok = 1; @@ -3222,6 +3505,8 @@ static void handle_server_json(const char *json) (g_am.state == PSX_LOBBY_AUTOMATCH_ACCEPTED || g_am.state == PSX_LOBBY_AUTOMATCH_FOUND); automatch_reset_queue_state(); + ice_launch_discard(); + g_lc.join.transport_ice_hub = 0; g_lc.in_lobby = 1; g_lc.is_host = 0; g_lc.join.ok = 1; @@ -3276,10 +3561,23 @@ static void handle_server_json(const char *json) g_lc.join.transport_host = strcmp(transport_kind, "host") == 0 ? 1 : 0; g_lc.join.transport_ice = strcmp(transport_kind, "ice") == 0 ? 1 : 0; rnet_host_relay_release_port(g_host_relay); + ice_launch_discard(); /* a previous launch's agents are never reused */ + g_il.error[0] = '\0'; g_lc.join.player_count = json_get_int(json, "player_count", g_lc.join.player_count); g_lc.join.max_slots = json_get_int(json, "max_slots", g_lc.join.max_slots); g_lc.join.session_id = (uint32_t)json_get_int(json, "session_id", (int)g_lc.join.session_id); ingest_match_caps_from_json(json); + /* relay_via is decided by THIS launch alone (top level, or its + * match_caps), never by caps carried over from an earlier message. */ + { + char via[12]; + via[0] = '\0'; + json_get_str(json, "relay_via", via, sizeof(via)); + g_lc.match_caps.relay_via_ice = strcmp(via, "ice") == 0 ? 1 : 0; + if (g_lc.match_caps.relay_via_ice) g_lc.match_caps.valid = 1; + } + g_lc.join.transport_ice_hub = + (g_lc.join.transport_host && g_lc.match_caps.relay_via_ice) ? 1 : 0; /* Prefer explicit relay_endpoint when the server opened input relay. * Apply after caps ingest: omitted force_input_relay must not leave * hosts on the hub path while guests dial the relay. */ @@ -3311,6 +3609,32 @@ static void handle_server_json(const char *json) } fill_peer_bind_from_join(); parse_slots_array(json); + if (g_lc.join.transport_ice_hub) { + /* Host relay over ICE: no endpoints. Capture the connected agents + * NOW, before the next pump's view (launch_pending) tears them + * down; the engine takes them. A seat that is not connected is a + * refused launch with a reason, never a smaller room. */ + ice_launch_capture(); + if (g_il.error[0]) { + strncpy(g_lc.join.last_error, "ice_not_connected", + sizeof(g_lc.join.last_error) - 1); + g_lc.join.last_error[sizeof(g_lc.join.last_error) - 1] = '\0'; + g_lc.launch_pending = 0; + fprintf(stderr, "psx_lobby: launch refused - %s\n", g_il.error); + fflush(stderr); + return; + } + g_lc.join.last_error[0] = '\0'; + g_lc.launch_pending = 1; + g_lc.ice_rtt_suspended = 1; + lobby_ice_rtt_close(); + lobby_rtt_close(); + psx_lobby_clear_signals(); + g_lc.ice_signal_accept = 1; + rnet_lan_beacon_close(&g_lan_beacon_pub); + lobby_host_advertise_reset(); + return; + } /* §108: online start always SFU — every peer dials relay_endpoint. * Legacy ice_p2p from an old server is refused (need SFU). */ { @@ -3437,6 +3761,26 @@ static void handle_server_json(const char *json) * rnet_rtt_probe (peer path). REPORT still accepted from peers. */ if (type == PSX_LOBBY_SIG_RTT_PING || type == PSX_LOBBY_SIG_RTT_PONG) return; + if (rnet_host_ice_sig_is_ours(type)) { + /* Host-relay-over-ICE waiting-room agents. Dispatched by the + * sender's player id, which the SERVER stamps (a client cannot + * choose it); only a seated player is a peer, and the seat is + * looked up here, never read from the payload. Never queued for + * the legacy session-ICE drain. */ + const int from_slot = member_slot_for_player(from); + int from_spec = 0, mi; + for (mi = 0; mi < g_lc.member_count; ++mi) + if (strcmp(g_lc.members[mi].player_id, from) == 0) + from_spec = g_lc.members[mi].is_spectator ? 1 : 0; + if (!g_host_ice || !from[0] || from_slot < 0 || from_spec) { + fprintf(stderr, "psx_lobby: host-ice signal from \"%s\" refused " + "(not a seated player)\n", from); + return; + } + (void)rnet_host_ice_push_signal(g_host_ice, from, from_slot, type, flag, + text_buf); + return; + } if (type == PSX_LOBBY_SIG_RTT_REPORT) { int slot = rtt_index_for_slot(member_slot_for_player(from)); int ms = (int)strtol(text_buf, NULL, 10); @@ -3493,6 +3837,7 @@ static void handle_server_json(const char *json) if (strcmp(op, "lobby_closed") == 0 || strcmp(op, "left") == 0 || strcmp(op, "kicked") == 0) { psx_lobby_chat_clear(); + ice_launch_discard(); /* Leaving a room must never leave a queue ticket looking live, and * no seat means no automatch room either. */ automatch_reset_queue_state(); @@ -3913,6 +4258,7 @@ int psx_lobby_connect(const char *ws_url) void psx_lobby_disconnect(void) { host_relay_leave(); + host_ice_leave(); /* agents + any launch bundle */ /* Never block the UI on DNS/connect — cancel and let pump reap. */ lobby_cancel_connect_async(); automatch_on_connection_reset(); @@ -4129,8 +4475,11 @@ static void lobby_ice_rtt_tick(void) RNetIceState st; char path[16]; + /* Host relay over ICE: the host-ice agents ARE the waiting-room ICE + * session and own the path_report; a second per-pair probe would race it + * (and its "relay"/"fail" reports would undo the proof). */ if (!g_lc.in_lobby || g_lc.launch_pending || g_lc.ice_rtt_suspended || - g_lc.member_count < 2) { + g_lc.member_count < 2 || room_relays_via_ice()) { lobby_ice_rtt_close(); return; } @@ -4268,6 +4617,7 @@ void psx_lobby_pump(void) /* Host relay: hold / probe / report to match the room. Before the * connected() check so a dropped WS releases the port the same pump. */ host_relay_step(); + host_ice_step(); #if defined(_WIN32) int n; #else @@ -4546,6 +4896,8 @@ int psx_lobby_join(const char *lobby_id, const char *password, const char *guest int psx_lobby_leave(void) { host_relay_leave(); /* unmap the router, close the port, forget reports */ + ice_launch_discard(); + g_il.error[0] = '\0'; queue_send("{\"op\":\"leave\"}"); flush_pending(); g_lc.ice_rtt_suspended = 0; diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 8b929f49..3ee6500e 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -7,6 +7,7 @@ #include "netplay_load_probe.h" #include "netplay_sim_pad_cache.h" #include "netplay_local_view.h" +#include "netplay_ice_hub.h" #include "host_time.h" #include "memcard.h" @@ -53,10 +54,10 @@ #include "psx_cycles.h" #include "psx_scheduler.h" #include "spu.h" -#if defined(PSX_HAS_LOBBY_CLIENT) +/* Unconditional: the lobby client TU defines no-op stubs when it is off, and + * the host-relay-over-ICE handover below calls it either way. */ #include "psx_lobby_client.h" #endif -#endif #ifndef PSX_MAX_PLAYERS #define PSX_MAX_PLAYERS 2 @@ -142,6 +143,7 @@ void psx_netplay_config_defaults(PsxNetplayConfig *cfg) cfg->force_input_relay = 0; cfg->force_turn = 0; cfg->transport = 0; + cfg->transport_ice_hub = 0; cfg->guest_memcard = 0; cfg->host_spectates = 0; cfg->session_id = 1; @@ -488,6 +490,9 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) (void)cfg; return -1; } +int psx_netplay_ice_stash_from_lobby(void) { return 0; } +void psx_netplay_ice_stash_discard(void) {} +int psx_netplay_ice_stash_held(void) { return 0; } void psx_netplay_shutdown(void) {} void psx_netplay_cold_reset(void) {} void psx_netplay_stage_local(const PsxNetPad *pad) { (void)pad; } @@ -637,6 +642,7 @@ typedef struct { PsxNetplayLoadProbe load_probe; /* guest's verified local LOAD probe/apply */ /* Transport / ICE / diag (MotK online path). */ int use_ice; + int ice_hub; /* match runs over adopted waiting-room agents */ int ice_has_turn; int force_input_relay; int is_host; @@ -3398,6 +3404,7 @@ int psx_netplay_is_running(void) const char *psx_netplay_transport_name(void) { if (!psx_netplay_active()) return "none"; + if (g_np.ice_hub) return "ice_hub"; return g_np.use_ice ? "ice" : "lan"; } @@ -3712,7 +3719,9 @@ static void np_diag_capture(const PsxNetplayConfig *cfg, int slots) const char *arch = "p2p"; int players; if (!cfg) return; - if (cfg->force_input_relay) + if (cfg->transport_ice_hub) + arch = "ice_host_relay"; + else if (cfg->force_input_relay) arch = "server_relay"; else if (slots >= 3) arch = "host_relay"; @@ -3774,6 +3783,10 @@ static void drain_lobby_signals(void) if (!g_np.session) return; while (psx_lobby_poll_signal(&type, &flag, text, sizeof(text))) { RNetSignal sig; + /* Host relay over ICE: the adopted agents are frozen at COMPLETED. + * Nothing may be pushed into them; a stray legacy ICE signal is + * consumed and dropped. */ + if (g_np.ice_hub) continue; memset(&sig, 0, sizeof(sig)); /* Peers emit LOCAL_*; push_signal expects REMOTE_* for SDP/candidates. */ if (type == (int)RNET_SIGNAL_LOCAL_SDP) @@ -3790,35 +3803,29 @@ static void drain_lobby_signals(void) static void drain_lobby_signals(void) {} #endif -static int resolve_use_ice(const PsxNetplayConfig *cfg) +/* Transport for this launch: 0 = LAN UDP (bind + hub / dial), 1 = one + * negotiated ICE agent, 2 = the waiting room's connected agents (host relay + * over ICE), -1 = refuse. The decision itself is netplay_ice_hub.c (tested + * without a ROM); this adds the room lookup and the log lines. */ +static int resolve_transport(const PsxNetplayConfig *cfg) { int in_motk_room = 0; + int ice_built = 0; + PsxTransportChoice c; - if (cfg->transport == 2) return 0; /* force LAN */ #if defined(PSX_HAS_LOBBY_CLIENT) in_motk_room = psx_lobby_connected() && psx_lobby_in_lobby(); #endif - - /* §108: MotK/BPE online lobbies always use lobby UDP SFU — LAN dial to - * relay_endpoint (or equal host/guest advertise). Match ICE / ice_p2p - * selection was removed; waiting-room ICE RTT may still run for delay - * hints only. Direct IP / LAN file lobby (no MotK seat) stays LAN UDP. */ - /* Host relay (2026-10-01): an online room whose host carries the match - * launches transport "host" -- no server relay. Host: bind the advertised - * port, peer empty (accept-first / hub); guest: dial host_endpoint. That - * is the LAN path below, so it is excluded from the SFU rule here. */ - if (cfg->transport_host) { - fprintf(stderr, - "psx_netplay: host relay — LAN transport (%s)\n", - cfg->peer_hostport && cfg->peer_hostport[0] ? "dial host" : "host binds"); - fflush(stderr); - return 0; - } +#if defined(RNET_ENABLE_ICE) + ice_built = 1; +#endif #if defined(PSX_HAS_LOBBY_CLIENT) && defined(RNET_ENABLE_ICE) /* The server started this room on ICE (2 players the host relay cannot * carry): peer-to-peer over STUN/TURN, signalled over the lobby socket. - * Same-NAT peers advertise equal endpoints; that is not a relay. */ - if (in_motk_room && !cfg->force_input_relay) { + * Same-NAT peers advertise equal endpoints; that is not a relay. A host + * relay over ICE launch (hub) or a legacy host relay is decided below. */ + if (in_motk_room && !cfg->force_input_relay && !cfg->transport_ice_hub && + !cfg->transport_host) { const PsxLobbyJoinInfo *ji = psx_lobby_join_info(); if (ji && ji->transport_ice) { fprintf(stderr, "psx_netplay: lobby launch transport=ice — ICE peer-to-peer\n"); @@ -3827,29 +3834,171 @@ static int resolve_use_ice(const PsxNetplayConfig *cfg) } } #endif - if (cfg->force_input_relay || in_motk_room) { - if (!cfg->peer_hostport || !cfg->peer_hostport[0]) { - fprintf(stderr, - "psx_netplay: MotK online / force_input_relay needs a " - "peer or SFU endpoint (empty peer)\n"); - fflush(stderr); - return -1; - } - fprintf(stderr, - "psx_netplay: server input relay — LAN transport to %s\n", - cfg->peer_hostport); + /* §108: MotK/BPE online lobbies use the lobby UDP SFU unless the launch + * says otherwise. Host relay (2026-10-01): transport "host" -- the host + * carries the match on its advertised UDP port (the LAN path). Host relay + * over ICE (2026-10-03): transport "host" + relay_via "ice" -- no port, the + * match rides the waiting room's connected agents (psx_transport_decide + * keeps it off the SFU and off the single-agent ICE path). Direct IP / LAN + * file lobby (no MotK seat) stays LAN UDP. */ + c = psx_transport_decide(cfg->transport, cfg->transport_ice_hub, + cfg->transport_host, cfg->force_input_relay, + in_motk_room, + cfg->peer_hostport && cfg->peer_hostport[0], + ice_built); + switch (c) { + case PSX_TRANSPORT_ICE_HUB: + fprintf(stderr, "psx_netplay: host relay over ICE - adopting the " + "waiting room's connected agents (no UDP bind, no dial)\n"); + fflush(stderr); + return 2; + case PSX_TRANSPORT_SINGLE_ICE: + return 1; + case PSX_TRANSPORT_ERROR_NO_PEER: + if (cfg->transport_ice_hub) + fprintf(stderr, "psx_netplay: host relay over ICE refused (%s)\n", + cfg->transport == 2 ? "PSX_NET_TRANSPORT=lan forces UDP" + : "this build has no ICE"); + else + fprintf(stderr, "psx_netplay: MotK online / force_input_relay needs a " + "peer or SFU endpoint (empty peer)\n"); + fflush(stderr); + return -1; + case PSX_TRANSPORT_LAN_UDP: + default: + if (cfg->transport_host) + fprintf(stderr, "psx_netplay: host relay - LAN transport (%s)\n", + cfg->peer_hostport && cfg->peer_hostport[0] ? "dial host" + : "host binds"); + else if ((cfg->force_input_relay || in_motk_room) && cfg->transport != 2) + fprintf(stderr, "psx_netplay: server input relay - LAN transport to %s\n", + cfg->peer_hostport); fflush(stderr); return 0; } - -#if defined(RNET_ENABLE_ICE) - /* Explicit ICE only outside MotK online (rare). */ - if (cfg->transport == 1) - return 1; -#endif - return 0; } +/* Agents taken from the lobby when the launch was consumed, until start() + * adopts them. Outside g_np: shutdown wipes g_np, and this must survive the + * gap between the launcher closing and the session being created. */ +typedef struct { + int n; /* host: seats held */ + PsxLobbyIceSeat seat[PSX_ICE_HUB_MAX_SEATS]; + struct RNetIceAgent *guest; /* guest: agent held */ + int is_host; +} NpIceStash; +static NpIceStash g_np_ice_stash; + +void psx_netplay_ice_stash_discard(void) +{ +#if defined(PSX_HAS_LOBBY_CLIENT) + int i; + for (i = 0; i < g_np_ice_stash.n; ++i) + psx_lobby_ice_destroy_agent(g_np_ice_stash.seat[i].agent); + if (g_np_ice_stash.guest) + psx_lobby_ice_destroy_agent(g_np_ice_stash.guest); +#endif + memset(&g_np_ice_stash, 0, sizeof(g_np_ice_stash)); +} + +int psx_netplay_ice_stash_held(void) +{ + return g_np_ice_stash.n > 0 || g_np_ice_stash.guest != NULL; +} + +int psx_netplay_ice_stash_from_lobby(void) +{ +#if defined(PSX_HAS_LOBBY_CLIENT) + const PsxLobbyJoinInfo *ji = psx_lobby_join_info(); + if (!ji || !ji->transport_ice_hub || !psx_lobby_launch_pending()) + return 0; + psx_netplay_ice_stash_discard(); + g_np_ice_stash.is_host = psx_lobby_is_host() ? 1 : 0; + if (g_np_ice_stash.is_host) { + const int n = psx_lobby_ice_take_hub(g_np_ice_stash.seat, + PSX_ICE_HUB_MAX_SEATS); + if (n < 1) { + fprintf(stderr, "psx_netplay: ICE hub handover failed: %s\n", + psx_lobby_ice_launch_error()); + fflush(stderr); + return 0; + } + g_np_ice_stash.n = n; + } else { + g_np_ice_stash.guest = psx_lobby_ice_take_guest_agent(); + if (!g_np_ice_stash.guest) { + fprintf(stderr, "psx_netplay: ICE guest handover failed: %s\n", + psx_lobby_ice_launch_error()); + fflush(stderr); + return 0; + } + } + return 1; +#else + return 0; +#endif +} + +/* Host relay over ICE: take ownership of the connected agents and bind each to + * the SESSION slot it serves. Failure destroys everything held and leaves one + * sentence for the lobby's status line; never a smaller room. On success the + * caller owns the agents (adopt, or rnet_host_ice_destroy_agent them). */ +static int ice_hub_acquire(const PsxNetplayConfig *cfg, int slots, int local, + RNetIceAdoptSeat *seats_out, int *n_out, + struct RNetIceAgent **guest_out) +{ + char why[192]; + why[0] = '\0'; + *n_out = 0; + *guest_out = NULL; + if (cfg->spectator) { + snprintf(why, sizeof(why), + "spectators are not on the ICE host-relay path"); + } else if (!psx_netplay_ice_stash_held() && !psx_netplay_ice_stash_from_lobby()) { + snprintf(why, sizeof(why), "no ICE connection was handed over for this launch"); + } else if (g_np_ice_stash.is_host) { + int lobby_slots[PSX_ICE_HUB_MAX_SEATS]; + int session_slots[PSX_ICE_HUB_MAX_SEATS]; + PsxIceHubMapError err = PSX_ICE_HUB_MAP_OK; + int i; + if (local != 0) { + snprintf(why, sizeof(why), "host launch with a non-host seat (%d)", local); + } else { + for (i = 0; i < g_np_ice_stash.n; ++i) + lobby_slots[i] = g_np_ice_stash.seat[i].lobby_slot; + if (psx_ice_hub_map_seats(cfg->port_of_slot, cfg->port_map_valid, slots, + cfg->host_spectates, lobby_slots, + g_np_ice_stash.n, session_slots, &err) != 0) { + snprintf(why, sizeof(why), "cannot seat the connected players: %s", + psx_ice_hub_map_error_text(err)); + } else { + for (i = 0; i < g_np_ice_stash.n; ++i) { + seats_out[i].slot = session_slots[i]; + seats_out[i].agent = g_np_ice_stash.seat[i].agent; + fprintf(stderr, "psx_netplay: ICE hub lobby seat %d -> session slot %d\n", + lobby_slots[i], session_slots[i]); + } + *n_out = g_np_ice_stash.n; + memset(&g_np_ice_stash, 0, sizeof(g_np_ice_stash)); /* caller owns */ + fflush(stderr); + return 1; + } + } + } else { + if (local < 1) + snprintf(why, sizeof(why), "guest launch holding the host seat"); + else { + *guest_out = g_np_ice_stash.guest; + memset(&g_np_ice_stash, 0, sizeof(g_np_ice_stash)); + return 1; + } + } + psx_netplay_ice_stash_discard(); + fprintf(stderr, "psx_netplay: host relay over ICE refused: %s\n", why); + fflush(stderr); + psx_lobby_set_last_error(why); + return 0; +} int psx_netplay_start(const PsxNetplayConfig *cfg) { @@ -3860,6 +4009,11 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) int slots; int local; int use_ice; + int transport; + int ice_hub; + RNetIceAdoptSeat hub_agents[PSX_ICE_HUB_MAX_SEATS]; + int hub_seats = 0; + struct RNetIceAgent *guest_agent = NULL; if (!cfg || !cfg->enabled) return -1; if (psx_mod_netplay_profile() && !psx_mod_netplay_is_active()) { @@ -3975,9 +4129,13 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) in_player = cfg->input_player; if (in_player < 0 || in_player >= PSX_MAX_PLAYERS) in_player = 0; - use_ice = resolve_use_ice(cfg); - if (use_ice < 0) + transport = resolve_transport(cfg); + if (transport < 0) return -4; + use_ice = (transport == 1); + ice_hub = (transport == 2); + if (ice_hub && !ice_hub_acquire(cfg, slots, local, hub_agents, &hub_seats, &guest_agent)) + return -5; memset(&host, 0, sizeof(host)); host.sample_local = host_sample_local; @@ -3989,7 +4147,15 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) #endif g_np.session = rnet_session_create(&rcfg, &host); - if (!g_np.session) return -2; + if (!g_np.session) { + if (ice_hub) { + int i; + for (i = 0; i < hub_seats; ++i) + psx_lobby_ice_destroy_agent(hub_agents[i].agent); + if (guest_agent) psx_lobby_ice_destroy_agent(guest_agent); + } + return -2; + } if (use_ice) { #if defined(RNET_ENABLE_ICE) @@ -4156,6 +4322,31 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) #endif } + if (ice_hub) { + /* No socket, no dial, no signals: the agents are already COMPLETED and + * are frozen on adoption (never push signals to them). */ + int rc; + if (hub_seats > 0) { + rc = rnet_session_start_ice_hub_adopt(g_np.session, hub_agents, hub_seats); + } else { + rc = rnet_session_adopt_ice_agent(g_np.session, guest_agent); + } + if (rc != 0) { + int i; + /* -1: nothing adopted, the agents are still ours. */ + for (i = 0; i < hub_seats; ++i) + psx_lobby_ice_destroy_agent(hub_agents[i].agent); + if (guest_agent) psx_lobby_ice_destroy_agent(guest_agent); + fprintf(stderr, "psx_netplay: ICE adopt failed; the match is not started\n"); + fflush(stderr); + psx_lobby_set_last_error("The connection to the host was lost before the " + "match started."); + rnet_session_destroy(g_np.session); + g_np.session = NULL; + return -6; + } + } + if (!use_ice) { /* Host-as-relay: slot 0 with 3+ seats and no dial peer. */ #if PSX_MAX_PLAYERS >= 3 @@ -4193,6 +4384,7 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) * freezes local_slot + slot_count at create. Starting with zeroed g_np made * every peer seal as slot 0 and export the wrong seat (VS-select hang). */ g_np.use_ice = use_ice ? 1 : 0; + g_np.ice_hub = ice_hub ? 1 : 0; g_np.slot_count = (int)rcfg.slot_count; g_np_slot_count = g_np.slot_count; g_np.local_slot = (int)rcfg.local_slot; @@ -4368,10 +4560,11 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) fprintf(stderr, "psx_netplay: started transport=%s slot=%d input_player=%d session=%u " "delay=%u force_input_relay=%d force_turn=%d bind=%s peer=%s\n", - use_ice ? "ice" : "lan", g_np.local_slot, g_np.input_player, + ice_hub ? "ice_hub" : (use_ice ? "ice" : "lan"), g_np.local_slot, + g_np.input_player, (unsigned)rcfg.session_id, (unsigned)rcfg.input_delay, g_np.force_input_relay, cfg->force_turn ? 1 : 0, cfg->bind_hostport, - use_ice ? "(ice)" : cfg->peer_hostport); + (use_ice || ice_hub) ? "(ice)" : cfg->peer_hostport); return 0; } diff --git a/runtime/tests/test_netplay_ice_hub.c b/runtime/tests/test_netplay_ice_hub.c new file mode 100644 index 00000000..5f89751d --- /dev/null +++ b/runtime/tests/test_netplay_ice_hub.c @@ -0,0 +1,147 @@ +/* Host relay over ICE: transport choice and lobby-seat -> session-slot map. + * ROM-free, socket-free. Plain checks, not assert(): Release builds define + * NDEBUG. */ +#include "netplay_ice_hub.h" + +#include + +static int failures; +#define CHECK(cond) do { if (!(cond)) { \ + fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #cond); \ + ++failures; } } while (0) + +/* psx_transport_decide(cfg_transport, hub, host, force_input_relay, + * in_online_room, has_peer, ice_built) */ +static void test_transport(void) +{ + /* The hub is decided by the launch alone: neither force_input_relay, an + * online room, nor an explicit transport=ice may move it to the SFU or the + * single-agent ICE path. transport_host is ALSO 1 for a hub launch. */ + CHECK(psx_transport_decide(0, 1, 1, 0, 1, 0, 1) == PSX_TRANSPORT_ICE_HUB); + CHECK(psx_transport_decide(0, 1, 1, 1, 1, 1, 1) == PSX_TRANSPORT_ICE_HUB); + CHECK(psx_transport_decide(1, 1, 1, 0, 1, 0, 1) == PSX_TRANSPORT_ICE_HUB); + CHECK(psx_transport_decide(0, 1, 1, 0, 0, 0, 1) == PSX_TRANSPORT_ICE_HUB); + /* Forced LAN, or a build without ICE: refuse, never fall back to a UDP + * path the launch has no endpoint for. */ + CHECK(psx_transport_decide(2, 1, 1, 0, 1, 0, 1) == PSX_TRANSPORT_ERROR_NO_PEER); + CHECK(psx_transport_decide(0, 1, 1, 0, 1, 0, 0) == PSX_TRANSPORT_ERROR_NO_PEER); + + /* Legacy paths are unchanged. */ + CHECK(psx_transport_decide(0, 0, 1, 0, 1, 0, 1) == PSX_TRANSPORT_LAN_UDP); /* host relay, host binds */ + CHECK(psx_transport_decide(0, 0, 1, 0, 1, 1, 1) == PSX_TRANSPORT_LAN_UDP); /* host relay, guest dials */ + CHECK(psx_transport_decide(0, 0, 0, 1, 1, 1, 1) == PSX_TRANSPORT_LAN_UDP); /* SFU */ + CHECK(psx_transport_decide(0, 0, 0, 1, 1, 0, 1) == PSX_TRANSPORT_ERROR_NO_PEER); /* SFU, no endpoint */ + CHECK(psx_transport_decide(0, 0, 0, 0, 1, 0, 1) == PSX_TRANSPORT_ERROR_NO_PEER); /* online, no endpoint */ + CHECK(psx_transport_decide(2, 0, 0, 1, 1, 0, 1) == PSX_TRANSPORT_LAN_UDP); /* forced LAN */ + CHECK(psx_transport_decide(0, 0, 0, 0, 0, 1, 1) == PSX_TRANSPORT_LAN_UDP); /* direct IP */ + CHECK(psx_transport_decide(1, 0, 0, 0, 0, 0, 1) == PSX_TRANSPORT_SINGLE_ICE); + CHECK(psx_transport_decide(1, 0, 0, 0, 0, 0, 0) == PSX_TRANSPORT_LAN_UDP); +} + +static int map(const int *port, int valid, int slots, int hs, const int *lobby, int n, + int *out, PsxIceHubMapError *err) +{ + return psx_ice_hub_map_seats(port, valid, slots, hs, lobby, n, out, err); +} + +static void test_mapping(void) +{ + int out[PSX_ICE_HUB_MAX_SEATS]; + PsxIceHubMapError e; + + /* 2P: host seat 0, guest seat 1. */ + { + const int port[] = { 0, 1 }; + const int lobby[] = { 1 }; + CHECK(map(port, 1, 2, 0, lobby, 1, out, &e) == 0 && out[0] == 1); + } + /* 3P in any arrival order: parallel output. */ + { + const int port[] = { 0, 1, 2 }; + const int lobby[] = { 2, 1 }; + CHECK(map(port, 1, 3, 0, lobby, 2, out, &e) == 0); + CHECK(out[0] == 2 && out[1] == 1); + } + /* Host holds lobby seat 2 (moved): it is still session slot 0, and the + * guests follow in ascending seat order -- slot != seat. */ + { + const int port[] = { 2, 0, 1 }; + const int lobby[] = { 0, 1 }; + CHECK(map(port, 1, 3, 0, lobby, 2, out, &e) == 0); + CHECK(out[0] == 1 && out[1] == 2); + } + /* Host in the gallery (host_spectates): slot 0 drives no seat, players + * start at slot 1. */ + { + const int port[] = { -1, 0, 1 }; + const int lobby[] = { 0, 1 }; + CHECK(map(port, 1, 3, 1, lobby, 2, out, &e) == 0); + CHECK(out[0] == 1 && out[1] == 2); + } + /* Sparse room (seats 0 and 3 occupied). */ + { + const int port[] = { 0, 3 }; + const int lobby[] = { 3 }; + CHECK(map(port, 1, 2, 0, lobby, 1, out, &e) == 0 && out[0] == 1); + } + /* The host's own seat is never an agent seat (search starts at slot 1). */ + { + const int port[] = { 0, 1 }; + const int lobby[] = { 0 }; + CHECK(map(port, 1, 2, 0, lobby, 1, out, &e) == -1); + CHECK(e == PSX_ICE_HUB_MAP_SEAT_UNMAPPED); + } + /* A session slot with no agent is a smaller room: refused. */ + { + const int port[] = { 0, 1, 2 }; + const int lobby[] = { 1 }; + CHECK(map(port, 1, 3, 0, lobby, 1, out, &e) == -1); + CHECK(e == PSX_ICE_HUB_MAP_SEAT_MISSING); + } + /* A connected seat the launch does not seat. */ + { + const int port[] = { 0, 1 }; + const int lobby[] = { 5 }; + CHECK(map(port, 1, 2, 0, lobby, 1, out, &e) == -1); + CHECK(e == PSX_ICE_HUB_MAP_SEAT_UNMAPPED); + } + /* Two agents for one seat. */ + { + const int port[] = { 0, 1, 2 }; + const int lobby[] = { 1, 1 }; + CHECK(map(port, 1, 3, 0, lobby, 2, out, &e) == -1); + CHECK(e == PSX_ICE_HUB_MAP_SEAT_DUPLICATE); + } + /* Bad arguments never write a mapping the caller could adopt. */ + { + const int lobby[] = { 1 }; + CHECK(map(NULL, 1, 2, 0, lobby, 1, out, &e) == -1); + CHECK(e == PSX_ICE_HUB_MAP_BAD_ARGS); + CHECK(map(NULL, 0, 2, 0, lobby, 0, out, &e) == -1); + CHECK(map(NULL, 0, 1, 0, lobby, 1, out, &e) == -1); + } + /* No table: identity, or seat + 1 with the host in the gallery. */ + { + const int lobby[] = { 1, 2 }; + CHECK(map(NULL, 0, 3, 0, lobby, 2, out, &e) == 0); + CHECK(out[0] == 1 && out[1] == 2); + } + { + const int lobby[] = { 0, 1 }; + CHECK(map(NULL, 0, 3, 1, lobby, 2, out, &e) == 0); + CHECK(out[0] == 1 && out[1] == 2); + } + CHECK(psx_ice_hub_map_error_text(PSX_ICE_HUB_MAP_SEAT_MISSING) != NULL); +} + +int main(void) +{ + test_transport(); + test_mapping(); + if (failures) { + fprintf(stderr, "netplay_ice_hub_test: %d failure(s)\n", failures); + return 1; + } + printf("netplay_ice_hub_test: ok\n"); + return 0; +}