diff --git a/ENHANCEMENTS.md b/ENHANCEMENTS.md index 0b5c1507..e10f983d 100644 --- a/ENHANCEMENTS.md +++ b/ENHANCEMENTS.md @@ -766,3 +766,104 @@ defect, which is the whole finding of G1.1–G1.8. The withdrawn row, the full post-mortem, the mechanism that would fix it, and the re-test protocol are preserved on `park/pgxp-geometry-correction` (see its G1.9). + +### G1.10 — PGXP value-propagation engine LANDED, phases 0+1 (2026-08-15) + +The G1.2/G1.3 mechanism — precision travelling WITH the data — now exists +(`feat/pgxp-dataflow`, tracked as `beads-eio.3.48`). **Clean-room**: the +vendored `duckstation/` tree is CC BY-NC-ND (NoDerivatives) and `beetle-psx/` +is GPL, both incompatible with this project's PolyForm Noncommercial license, +so NO code was ported from either; they remain black-box behavioral oracles +only. The engine implements the publicly documented technique from psx-spx + +our own G1 analysis. + +**What exists now:** + +- `runtime/src/pgxp.cpp` + `runtime/include/pgxp.h`: per-word RAM+scratchpad + shadows (~10 MB, lazily allocated, fail-closed), per-GPR (+HI/LO) and + per-GTE-data-reg shadows. Each `PGXPValue` records the sub-pixel 16.16 + screen X/Y, the projected SZ depth, per-half validity flags, and the exact + guest word it describes. **The one safety invariant: a shadow is only + believed after validating that word against reality.** Overwrite-and- + validate only — no side-effect modelling, so DMA/memcpy/untracked writers + need no hooks at all (their stale shadows fail validation at use). The + plain-store `gte_precision_invalidate_word` calls in memory.c are removed + as redundant under this model (a small store-path win). +- Hook funnel (`runtime/include/pgxp_hooks.h`): 5 entry points + (`psx_pgxp_load/store/alu/muldiv/cop2`) taking the raw instruction word. + Generated code will call them via `PGXP_*()` macros that expand to nothing + unless compiled with `-DPSX_PGXP=1` — the G1.3 codegen-time gate, as a + preprocessor define over ONE generated tree (no flavour drift, base objects + byte-identical). `OverlayCallbacks` gained an appended-last `PGXPHooks*` + table (NULL-tolerant); the ABI bump arrives with the Phase-2 emitter change. +- Both interpreters (`dirty_ram_interp.c`, `psx_interpreter.c`) call the + hooks directly on loads/stores/COP2 transfers and the precision-carrying + arithmetic (moves, add/sub, or-merge, 16-bit shifts, LUI, HI/LO). Ops that + merely destroy precision are deliberately NOT hooked — validation already + drops their stale shadows. +- `pgxp_cpu_mode` (tier-2 arithmetic propagation, default off, matching the + references' default) and `pgxp_tolerance` (sub-pixel clamp, default off) + are new `[video]` keys and live-tunable via the TCP `pgxp` verb, which also + live-toggles `geometry`/`texture` for same-scene A/B (the G1.6 method rule + no longer needs a restart between arms). +- GPU consumption is now PER-VERTEX (`pgxp_get_precise_vertex`): dataflow + shadow first (validated against the actual packet word), the G1.4 exact + ambiguity-gated position cache second (never carries depth), native + integers last. Two safeguards on every precise vertex: truncation agreement + (integer part must equal the native 11-bit parse) and the tolerance clamp. + Mixed precise/native triangles are correct — a native vertex sits exactly + where the uncorrected pipeline put it, bounding any seam to < 1px of + sub-pixel fraction (the G1.1 cracks came from wrong-vertex substitution and + all-or-nothing triangles, both gone). Geometry and perspective UVs now + share one provenance source and compose per triangle. +- Census: `geom_correction` grew a `pgxp` object (dataflow_hit / + fallback_hit / native / value_mismatch / trunc_reject / tolerance_reject / + w_valid). The G1.9 gate now reads: dataflow-hit share must be dramatically + above the 5.2% position-cache ceiling before any launcher surface returns. +- Tests: `pgxp_test` (roundtrips, half-word semantics, validate-on-read, + DMA-overwrite rejection, repack arithmetic, suppression bracket, + safeguards) + `gte_register_access_test` updated to the precise + invalidation contract. 33/33 enabled runtime tests green. + +**Not yet (Phase 2+):** emitter macros at the ~47 sites, the `-DPSX_PGXP` +dual link targets + flavor-namespaced shard caches + ABI bump, BIOS regen, +and the Ape/Crash/Tomba2 census + `screenshot_hires` A/B campaign. Until the +emitter lands, compiled code feeds the engine only through the v14 swc2 +sites, so dataflow coverage on a running title comes from interpreted code +paths; the full-coverage census gate is a Phase-2/3 measurement. + +Incidental fixes while landing this: the per-target `psx_game_version.txt` +`file(GENERATE)` collision that broke every fresh single-config configure +(runtime.cmake once-only stamp), and `overlay_capture_retry_test` not +compiling under the SDL3 default (SDL_SetMainReady gone + inverted SDL_Init +check — it now uses `psx_sdl_init`). + +**First real-title census (Ape Escape, 2026-08-15) — the G1.9 gate is +PASSED.** Worktree build against this branch (`_wt-ape-pgxp`, +`-DPSX_PGXP_VARIANT=ON -DPSX_DEBUG_TOOLS=ON`, game C regenerated), memory-mode +only (`pgxp_cpu_mode` off), boot → title → attract, per-10s windows: +**79–90% dataflow hits** (vs the 5.2% position-cache ceiling of G1.4), +fallback ≤0.4%, `value_mismatch` 0, `trunc_reject` ~0, and every dataflow hit +carried a usable depth. The residual native share is 2D HUD/sprite content, +which is exactly what must stay native. Still to run before any ship surface: +the windowed `screenshot_hires` off/on A/B (headless has NO hi-res mirror — +`scale:1` fallback, the same blind-instrument trap G1.5 documented), on Ape + +Crash with Tomba 2 as the 2D negative control; the hook-overhead FPS measure +(pgxp build, feature off, vs base); and pgxp-flavour (`_f2`) overlay-shard +autocompile validation. Validation-build notes: title Release builds default +`PSX_DEBUG_TOOLS=OFF` (no TCP), and a title launched without `--game` binds +the framework default port 4370. + +**Interactive visual isolation, USER-VALIDATED (Ape Escape, in-game, +2026-08-15).** Same scene, one toggle at a time, player observing live: +OFF = baseline vertex jiggle; geometry ON unclamped = jiggle largely gone but +sparse hairline background-bleed seams (a <1px disagreement along a long +shared edge between a corrected triangle and an uncorrected neighbour — the +THPS2 line class, NOT wild vertices; both reported artifacts vanished with +the toggle off); geometry ON with `pgxp_tolerance = 0.5` = **seams gone**, +only sub-half-pixel misalignment remains. 0.5 is therefore the shipped +default (config_loader.h), live-tunable via the `pgxp` TCP verb. Known +tooling defect found on the way: `screenshot_hires` produces a tiled/black +PNG at 768x480 scale-2 windowed (row-pitch bug in the hires readback) — +the census + the player's own captures carried the session; fix it before +the formal Crash/Tomba2 A/B. diff --git a/mods/builtin/packages/psx.enhancement.pgxp/1.0.0/manifest.toml b/mods/builtin/packages/psx.enhancement.pgxp/1.0.0/manifest.toml new file mode 100644 index 00000000..8f0e2952 --- /dev/null +++ b/mods/builtin/packages/psx.enhancement.pgxp/1.0.0/manifest.toml @@ -0,0 +1,33 @@ +format_version = 5 +id = "psx.enhancement.pgxp" +version = "1.0.0" +name = "PGXP Precision (geometry + textures)" +author = "psxrecomp" +description = "Sub-pixel vertex precision and perspective-correct texturing (the PGXP technique): the runtime shadows the GTE's full-precision projections and follows them to the GPU, so 3D polygons stop wobbling between whole pixels and large floor/wall textures stop warping. Visual-only: the game's own math, culling and readback see exactly what real hardware produces." +resolver = "declarative" +save_compatibility = "shared" + +# Sub-pixel precision is a property of the emulated GTE/GPU pair, not of any +# particular disc, so this ships with the framework and applies to every game. +[[target]] +game_id = "*" + +[[feature]] +id = "pgxp" +name = "PGXP Precision" +description = "Disabled by default (the faithful floor). Corrections are validated per vertex against the exact packet words the game drew with and clamped to half a pixel, so 2D sprites and UI are never touched and a vertex the tracker cannot prove simply draws the faithful way. Most effective on a build compiled with the PGXP hook variant; on other builds coverage is reduced but never harmful. Needs supersampling >= 2 to be visible." +group = "Visual" +default_enabled = false + +[[option]] +feature = "pgxp" +id = "cpu_mode" +label = "CPU-mode propagation" +description = "Also track precision through CPU arithmetic (register repacking math). Off matches the reference implementations' default; on can raise coverage in games that rebuild vertices with integer math. Always safe either way - validation rejects anything stale." +group = "Visual" +type = "boolean" +default = "false" + +[[plugin]] +feature = "pgxp" +id = "psx.pgxp" diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 47e978b1..71c16cc0 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -1,407 +1,416 @@ -cmake_minimum_required(VERSION 3.20) -project(PSXRecomp C CXX) -set(CMAKE_CXX_STANDARD 20) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -include(CTest) - -include("${CMAKE_CURRENT_SOURCE_DIR}/../runtime/chd_dependency.cmake") - -add_subdirectory(lib/fmt) - -# Vendored fmt 9.1.0 enables its `consteval` format-string check on Apple -# clang via the `__cpp_consteval` clause in core.h, even though the same -# block tries to exclude Apple toolchains — and Apple clang 21 then rejects -# fmt's own literal format strings ("call to consteval function is not a -# constant expression"). Force FMT_CONSTEVAL empty to take fmt's -# non-consteval compile-time-check path. Scoped to non-MSVC so Windows is -# untouched. -if(NOT MSVC) - target_compile_definitions(fmt PUBLIC FMT_CONSTEVAL=) -endif() - -# Rabbitizer MIPS decoder -add_library(rabbitizer STATIC) -target_sources(rabbitizer PRIVATE - lib/rabbitizer/cplusplus/src/analysis/LoPairingInfo.cpp - lib/rabbitizer/cplusplus/src/analysis/RegistersTracker.cpp - lib/rabbitizer/cplusplus/src/instructions/InstrId.cpp - lib/rabbitizer/cplusplus/src/instructions/InstrIdType.cpp - lib/rabbitizer/cplusplus/src/instructions/InstructionBase.cpp - lib/rabbitizer/cplusplus/src/instructions/InstructionCpu.cpp - lib/rabbitizer/cplusplus/src/instructions/InstructionR3000GTE.cpp - lib/rabbitizer/cplusplus/src/instructions/InstructionR5900.cpp - lib/rabbitizer/cplusplus/src/instructions/InstructionRsp.cpp - lib/rabbitizer/src/analysis/RabbitizerLoPairingInfo.c - lib/rabbitizer/src/analysis/RabbitizerRegistersTracker.c - lib/rabbitizer/src/analysis/RabbitizerTrackedRegisterState.c - lib/rabbitizer/src/common/RabbitizerConfig.c - lib/rabbitizer/src/common/RabbitizerVersion.c - lib/rabbitizer/src/common/Utils.c - lib/rabbitizer/src/instructions/RabbitizerInstrCategory.c - lib/rabbitizer/src/instructions/RabbitizerInstrDescriptor.c - lib/rabbitizer/src/instructions/RabbitizerInstrId.c - lib/rabbitizer/src/instructions/RabbitizerInstrIdType.c - lib/rabbitizer/src/instructions/RabbitizerInstrSuffix.c - lib/rabbitizer/src/instructions/RabbitizerInstructionCpu/RabbitizerInstructionCpu_OperandType.c - lib/rabbitizer/src/instructions/RabbitizerInstructionR3000GTE/RabbitizerInstructionR3000GTE.c - lib/rabbitizer/src/instructions/RabbitizerInstructionR3000GTE/RabbitizerInstructionR3000GTE_OperandType.c - lib/rabbitizer/src/instructions/RabbitizerInstructionR3000GTE/RabbitizerInstructionR3000GTE_ProcessUniqueId.c - lib/rabbitizer/src/instructions/RabbitizerInstructionR5900/RabbitizerInstructionR5900.c - lib/rabbitizer/src/instructions/RabbitizerInstructionR5900/RabbitizerInstructionR5900_OperandType.c - lib/rabbitizer/src/instructions/RabbitizerInstructionR5900/RabbitizerInstructionR5900_ProcessUniqueId.c - lib/rabbitizer/src/instructions/RabbitizerInstructionRsp/RabbitizerInstructionRsp.c - lib/rabbitizer/src/instructions/RabbitizerInstructionRsp/RabbitizerInstructionRsp_OperandType.c - lib/rabbitizer/src/instructions/RabbitizerInstructionRsp/RabbitizerInstructionRsp_ProcessUniqueId.c - lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction.c - lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_Disassemble.c - lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_Examination.c - lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_Operand.c - lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_ProcessUniqueId.c - lib/rabbitizer/src/instructions/RabbitizerRegister.c - lib/rabbitizer/src/instructions/RabbitizerRegisterDescriptor.c -) -target_include_directories(rabbitizer PRIVATE - lib/rabbitizer/include - lib/rabbitizer/cplusplus/include - lib/rabbitizer/tables -) - -# PSXRecomp — MIPS→C recompiler tool -set(PSXRECOMP_GAME_SOURCES - src/ps1_exe_parser.cpp - src/mips_decoder.cpp - src/control_flow.cpp - src/function_analysis.cpp - src/code_generator.cpp - src/bios_address_model.cpp - src/annotations.cpp - src/config_loader.cpp - src/recompiler_patch.cpp - src/main_psx.cpp -) - -add_executable(psxrecomp-game ${PSXRECOMP_GAME_SOURCES}) -target_include_directories(psxrecomp-game PRIVATE - include - lib/rabbitizer/include - lib/rabbitizer/cplusplus/include - lib/fmt/include - lib/toml11 - lib/ELFIO -) -target_link_libraries(psxrecomp-game PRIVATE fmt rabbitizer) -if(MINGW) - target_link_options(psxrecomp-game PRIVATE -Wl,--stack,16777216) -elseif(MSVC) - target_link_options(psxrecomp-game PRIVATE /STACK:16777216) -endif() - -# psxrecomp-toml — PS-X EXE → game.toml generator. -# Standalone tool that analyses a PS-X EXE and generates a complete game.toml -# config with auto-detected fields (load_address, entry_pc, text_size, -# stack_base) and optional JAL-target seed generation. -add_executable(psxrecomp-toml - src/ps1_exe_parser.cpp - src/main_toml.cpp -) -target_include_directories(psxrecomp-toml PRIVATE - include - lib/fmt/include -) -target_link_libraries(psxrecomp-toml PRIVATE fmt) - -# psxrecomp — user-facing media-to-source project generator. -add_executable(psxrecomp - src/main_cli.cpp - src/cli_boot_path.cpp - src/ps1_exe_parser.cpp - ../runtime/src/cue_sheet.cpp - ../runtime/src/iso_reader.cpp -) -target_include_directories(psxrecomp PRIVATE - include - ../runtime/include - lib/fmt/include -) -target_link_libraries(psxrecomp PRIVATE fmt chdr-static) -if(MINGW) - target_link_options(psxrecomp PRIVATE -Wl,--stack,16777216) -elseif(MSVC) - target_link_options(psxrecomp PRIVATE /STACK:16777216) -endif() - -# ---- baked codegen hash (stale-binary guard) -------------------------------- -# Bake the SAME emitter-source hash the runtime folds into its overlay cache -# tag (runtime/runtime.cmake -> hash_codegen.cmake, shared canonical list in -# runtime/codegen_hash_sources.cmake) into THIS binary, exposed via -# `psxrecomp-game --codegen-hash`. compile_overlays.py compares it against the -# hash it stamps on the cache dir (from --runtime-include) and HARD-FAILS on -# mismatch. Closes the 2026-07-01 Tomba pause-menu wedge class: a stale -# recompiler binary emitting shards that get filed under the CURRENT cg tag -# (read tag == write tag, content stale — invisible to the tag alone). -set(PSXRECOMP_CODEGEN_HASH_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/..) -include(${CMAKE_CURRENT_SOURCE_DIR}/../runtime/codegen_hash_sources.cmake) -set(_baked_hash_hdr ${CMAKE_CURRENT_BINARY_DIR}/psxrecomp_baked_codegen_hash.h) -add_custom_command( - OUTPUT ${_baked_hash_hdr} - COMMAND ${CMAKE_COMMAND} -DOUT=${_baked_hash_hdr} - "-DSRCS=${PSXRECOMP_CODEGEN_HASH_SRCS}" - -P ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/hash_codegen.cmake - DEPENDS ${PSXRECOMP_CODEGEN_HASH_SRCS} - ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/hash_codegen.cmake - COMMENT "Hashing codegen sources -> psxrecomp_baked_codegen_hash.h" - VERBATIM) -add_custom_target(psxrecomp_baked_codegen_hash DEPENDS ${_baked_hash_hdr}) -add_dependencies(psxrecomp-game psxrecomp_baked_codegen_hash) -# Force main_psx.cpp to recompile whenever the hash header content changes -# (same OBJECT_DEPENDS hardening as runtime.cmake — __has_include depfile gap). -set_source_files_properties(src/main_psx.cpp PROPERTIES OBJECT_DEPENDS ${_baked_hash_hdr}) -target_include_directories(psxrecomp-game PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) - -# NOTE (2026-07-27): the legacy `PSXRecomp` target was REMOVED here. -# It built ${PSXRECOMP_GAME_SOURCES} — byte-for-byte the same source list as -# `psxrecomp-game` above — and was a leftover from before that target was -# introduced. Because Windows and macOS filesystems are case-insensitive, its -# output `PSXRecomp.exe` collided with the user-facing CLI `psxrecomp.exe` -# (the `psxrecomp` target above): both wrote the SAME file on disk and the -# last one linked won. In practice the CLI lost, so `psxrecomp.exe` shipped in -# `psxrecomp-cli-windows-x86_64.zip` (tools/build_cli.py) was actually the game -# recompiler, with a completely different argument grammar than the README -# documents. The bug is invisible on Linux, which is why it survived. -# The `PSXRecomp.exe` users are told to run in README's "Release Package" -# section is a DIFFERENT artifact — the BIOS-only runtime, whose name comes -# from OUTPUT_NAME in runtime/runtime.cmake — and is unaffected by this removal. -# See the collision guard at the bottom of this file. - -# psxrecomp-bios — Phase 1a BIOS boot slice recompiler. -# Strict, fail-loud, ingests bios/SCPH1001.BIN as a flat ROM at 0xBFC00000. -# Independent of PSXRecomp's PS1-EXE entry point. Shares mips_decoder + -# ps1_exe_parser sources because mips_decoder.h depends on PS1Executable -# (used only by decode_executable, which psxrecomp-bios does not call). -add_executable(psxrecomp-bios - src/ps1_exe_parser.cpp - src/mips_decoder.cpp - src/strict_translator.cpp - src/bios_slice_walker.cpp - src/bios_address_model.cpp - src/function_discovery.cpp - src/full_function_emitter.cpp - src/config_loader.cpp - src/recompiler_patch.cpp - src/main_bios.cpp -) -target_include_directories(psxrecomp-bios PRIVATE - include - src - lib/rabbitizer/include - lib/rabbitizer/cplusplus/include - lib/fmt/include - lib/toml11 -) -target_link_libraries(psxrecomp-bios PRIVATE fmt rabbitizer) -if(MINGW) - target_link_options(psxrecomp-bios PRIVATE -Wl,--stack,16777216) -elseif(MSVC) - target_link_options(psxrecomp-bios PRIVATE /STACK:16777216) -endif() - -# Released Windows CLI archives must run without a separate MinGW/MSYS2 -# install. Prefer llvm-mingw (+ -static) in CI so emitters do not need -# libgcc/libstdc++ harvested from MSYS2. Keep opt-in for local debug builds. -option(PSXRECOMP_STATIC_CLI "Statically link Windows runtime into CLI tools" OFF) -if(PSXRECOMP_STATIC_CLI AND WIN32 AND (MINGW OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")) - foreach(_cli_target psxrecomp psxrecomp-game psxrecomp-toml psxrecomp-bios) - if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") - target_link_options(${_cli_target} PRIVATE - -static -static-libgcc -static-libstdc++) - else() - # llvm-mingw / Clang: fully static CRT + libc++ / libunwind / pthread. - target_link_options(${_cli_target} PRIVATE -static) - endif() - endforeach() -endif() - -# BIOS address-model equivalence test — sweeps every 4-byte address through -# the profile-driven BiosAddressModel and compares against verbatim copies of -# the pre-refactor hardcoded SCPH1001 helpers; also exercises the from_config -# invariants. Run from the framework root: recompiler/build/bios_address_model_test -add_executable(bios_address_model_test - tests/bios_address_model_test.cpp - src/bios_address_model.cpp - src/config_loader.cpp - src/ps1_exe_parser.cpp -) -target_include_directories(bios_address_model_test PRIVATE - include - src - lib/fmt/include - lib/toml11 -) -target_link_libraries(bios_address_model_test PRIVATE fmt) - -# L1 decoder conformance test — compares MipsDecoder against Rabbitizer -# oracle for every instruction in SCPH1001.BIN code regions. -add_executable(l1_decoder_test - tests/l1_decoder_test.cpp - src/mips_decoder.cpp - src/ps1_exe_parser.cpp -) -target_include_directories(l1_decoder_test PRIVATE - include - lib/rabbitizer/include - lib/rabbitizer/cplusplus/include - lib/fmt/include -) -target_link_libraries(l1_decoder_test PRIVATE fmt rabbitizer) - -# L2 structural codegen test — verifies StrictTranslator output patterns -# for each MIPS instruction form. -add_executable(l2_structural_test - tests/l2_structural_test.cpp - src/mips_decoder.cpp - src/ps1_exe_parser.cpp - src/strict_translator.cpp -) -target_include_directories(l2_structural_test PRIVATE - include - src - lib/rabbitizer/include - lib/rabbitizer/cplusplus/include - lib/fmt/include -) -target_link_libraries(l2_structural_test PRIVATE fmt rabbitizer) - -if(BUILD_TESTING) - add_executable(debug_trace_ranges_test - ../runtime/tests/test_debug_trace_ranges.c - ../runtime/src/debug_trace_ranges.c - ) - target_include_directories(debug_trace_ranges_test PRIVATE - ../runtime/include) - add_test(NAME debug_trace_ranges_test COMMAND debug_trace_ranges_test) - - add_executable(cli_boot_path_test - tests/cli_boot_path_test.cpp - src/cli_boot_path.cpp - ) - target_include_directories(cli_boot_path_test PRIVATE src) - add_test(NAME cli_boot_path_test COMMAND cli_boot_path_test) - - add_executable(recompiler_patch_test - tests/recompiler_patch_test.cpp - src/ps1_exe_parser.cpp - src/mips_decoder.cpp - src/control_flow.cpp - src/function_analysis.cpp - src/code_generator.cpp - src/annotations.cpp - src/bios_address_model.cpp - src/config_loader.cpp - src/recompiler_patch.cpp - ) - target_include_directories(recompiler_patch_test PRIVATE - include - src - lib/rabbitizer/include - lib/rabbitizer/cplusplus/include - lib/fmt/include - lib/toml11 - lib/ELFIO - ) - target_link_libraries(recompiler_patch_test PRIVATE fmt rabbitizer) - add_test(NAME recompiler_patch_test COMMAND recompiler_patch_test) - - add_executable(full_function_emitter_test - tests/full_function_emitter_test.cpp - src/ps1_exe_parser.cpp - src/mips_decoder.cpp - src/strict_translator.cpp - src/bios_address_model.cpp - src/function_discovery.cpp - src/full_function_emitter.cpp - src/config_loader.cpp - src/recompiler_patch.cpp - ) - target_include_directories(full_function_emitter_test PRIVATE - include - src - lib/rabbitizer/include - lib/rabbitizer/cplusplus/include - lib/fmt/include - lib/toml11 - ) - target_link_libraries(full_function_emitter_test PRIVATE fmt rabbitizer) - add_test(NAME full_function_emitter_test COMMAND full_function_emitter_test) - - add_executable(recompiler_patch_cxx17_header_test - tests/recompiler_patch_cxx17_header_test.cpp - ) - target_include_directories(recompiler_patch_cxx17_header_test PRIVATE src) - set_target_properties(recompiler_patch_cxx17_header_test PROPERTIES - CXX_STANDARD 17 - CXX_STANDARD_REQUIRED ON - ) - add_test(NAME recompiler_patch_cxx17_header_test - COMMAND recompiler_patch_cxx17_header_test) - - # [video] geometry_correction / perspective_texturing opt-in plumbing. - add_executable(video_enhancement_settings_test - tests/video_enhancement_settings_test.cpp - src/config_loader.cpp - src/ps1_exe_parser.cpp - ) - target_include_directories(video_enhancement_settings_test PRIVATE - include - src - lib/fmt/include - lib/toml11 - ) - target_link_libraries(video_enhancement_settings_test PRIVATE fmt) - add_test(NAME video_enhancement_settings_test - COMMAND video_enhancement_settings_test) - - # Needs bios/SCPH1001.toml — run from the framework root. - add_test(NAME bios_address_model_test - COMMAND bios_address_model_test - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - - # These targets were already built by every test build, but without - # add_test() entries ctest could neither run nor fail them. - add_test(NAME l2_structural_test COMMAND l2_structural_test) - add_test(NAME reachable_discovery_test COMMAND reachable_discovery_test) - - find_package(Python3 COMPONENTS Interpreter) - if(Python3_Interpreter_FOUND) - add_test( - NAME cli_project_packaging - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_cli_project_packaging.py - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - - # These two invoke psxrecomp-game, whose BIOS-profile resolution - # probes fixed working-directory-relative paths (bios/.toml, - # then psxrecomp/bios/.toml) -- run them from the framework - # root so the shipped profiles are found. - add_test( - NAME overlay_cross_page_delay_codegen - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_overlay_cross_page_delay_codegen.py - --recompiler $ - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - add_test( - NAME fmv_cycle_batch_codegen - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_fmv_cycle_batch_codegen.py - --recompiler $ - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - add_test( - NAME overlay_shim_compile_contract - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_overlay_shim_compile.py) +cmake_minimum_required(VERSION 3.20) +project(PSXRecomp C CXX) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +include(CTest) + +include("${CMAKE_CURRENT_SOURCE_DIR}/../runtime/chd_dependency.cmake") + +add_subdirectory(lib/fmt) + +# Vendored fmt 9.1.0 enables its `consteval` format-string check on Apple +# clang via the `__cpp_consteval` clause in core.h, even though the same +# block tries to exclude Apple toolchains — and Apple clang 21 then rejects +# fmt's own literal format strings ("call to consteval function is not a +# constant expression"). Force FMT_CONSTEVAL empty to take fmt's +# non-consteval compile-time-check path. Scoped to non-MSVC so Windows is +# untouched. +if(NOT MSVC) + target_compile_definitions(fmt PUBLIC FMT_CONSTEVAL=) +endif() + +# Rabbitizer MIPS decoder +add_library(rabbitizer STATIC) +target_sources(rabbitizer PRIVATE + lib/rabbitizer/cplusplus/src/analysis/LoPairingInfo.cpp + lib/rabbitizer/cplusplus/src/analysis/RegistersTracker.cpp + lib/rabbitizer/cplusplus/src/instructions/InstrId.cpp + lib/rabbitizer/cplusplus/src/instructions/InstrIdType.cpp + lib/rabbitizer/cplusplus/src/instructions/InstructionBase.cpp + lib/rabbitizer/cplusplus/src/instructions/InstructionCpu.cpp + lib/rabbitizer/cplusplus/src/instructions/InstructionR3000GTE.cpp + lib/rabbitizer/cplusplus/src/instructions/InstructionR5900.cpp + lib/rabbitizer/cplusplus/src/instructions/InstructionRsp.cpp + lib/rabbitizer/src/analysis/RabbitizerLoPairingInfo.c + lib/rabbitizer/src/analysis/RabbitizerRegistersTracker.c + lib/rabbitizer/src/analysis/RabbitizerTrackedRegisterState.c + lib/rabbitizer/src/common/RabbitizerConfig.c + lib/rabbitizer/src/common/RabbitizerVersion.c + lib/rabbitizer/src/common/Utils.c + lib/rabbitizer/src/instructions/RabbitizerInstrCategory.c + lib/rabbitizer/src/instructions/RabbitizerInstrDescriptor.c + lib/rabbitizer/src/instructions/RabbitizerInstrId.c + lib/rabbitizer/src/instructions/RabbitizerInstrIdType.c + lib/rabbitizer/src/instructions/RabbitizerInstrSuffix.c + lib/rabbitizer/src/instructions/RabbitizerInstructionCpu/RabbitizerInstructionCpu_OperandType.c + lib/rabbitizer/src/instructions/RabbitizerInstructionR3000GTE/RabbitizerInstructionR3000GTE.c + lib/rabbitizer/src/instructions/RabbitizerInstructionR3000GTE/RabbitizerInstructionR3000GTE_OperandType.c + lib/rabbitizer/src/instructions/RabbitizerInstructionR3000GTE/RabbitizerInstructionR3000GTE_ProcessUniqueId.c + lib/rabbitizer/src/instructions/RabbitizerInstructionR5900/RabbitizerInstructionR5900.c + lib/rabbitizer/src/instructions/RabbitizerInstructionR5900/RabbitizerInstructionR5900_OperandType.c + lib/rabbitizer/src/instructions/RabbitizerInstructionR5900/RabbitizerInstructionR5900_ProcessUniqueId.c + lib/rabbitizer/src/instructions/RabbitizerInstructionRsp/RabbitizerInstructionRsp.c + lib/rabbitizer/src/instructions/RabbitizerInstructionRsp/RabbitizerInstructionRsp_OperandType.c + lib/rabbitizer/src/instructions/RabbitizerInstructionRsp/RabbitizerInstructionRsp_ProcessUniqueId.c + lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction.c + lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_Disassemble.c + lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_Examination.c + lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_Operand.c + lib/rabbitizer/src/instructions/RabbitizerInstruction/RabbitizerInstruction_ProcessUniqueId.c + lib/rabbitizer/src/instructions/RabbitizerRegister.c + lib/rabbitizer/src/instructions/RabbitizerRegisterDescriptor.c +) +target_include_directories(rabbitizer PRIVATE + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/rabbitizer/tables +) + +# PSXRecomp — MIPS→C recompiler tool +set(PSXRECOMP_GAME_SOURCES + src/ps1_exe_parser.cpp + src/mips_decoder.cpp + src/control_flow.cpp + src/function_analysis.cpp + src/code_generator.cpp + src/pgxp_hook_emitter.cpp + src/bios_address_model.cpp + src/annotations.cpp + src/config_loader.cpp + src/recompiler_patch.cpp + src/main_psx.cpp +) + +add_executable(psxrecomp-game ${PSXRECOMP_GAME_SOURCES}) +target_include_directories(psxrecomp-game PRIVATE + include + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include + lib/toml11 + lib/ELFIO +) +target_link_libraries(psxrecomp-game PRIVATE fmt rabbitizer) +if(MINGW) + target_link_options(psxrecomp-game PRIVATE -Wl,--stack,16777216) +elseif(MSVC) + target_link_options(psxrecomp-game PRIVATE /STACK:16777216) +endif() + +# psxrecomp-toml — PS-X EXE → game.toml generator. +# Standalone tool that analyses a PS-X EXE and generates a complete game.toml +# config with auto-detected fields (load_address, entry_pc, text_size, +# stack_base) and optional JAL-target seed generation. +add_executable(psxrecomp-toml + src/ps1_exe_parser.cpp + src/main_toml.cpp +) +target_include_directories(psxrecomp-toml PRIVATE + include + lib/fmt/include +) +target_link_libraries(psxrecomp-toml PRIVATE fmt) + +# psxrecomp — user-facing media-to-source project generator. +add_executable(psxrecomp + src/main_cli.cpp + src/cli_boot_path.cpp + src/ps1_exe_parser.cpp + ../runtime/src/cue_sheet.cpp + ../runtime/src/iso_reader.cpp +) +target_include_directories(psxrecomp PRIVATE + include + ../runtime/include + lib/fmt/include +) +target_link_libraries(psxrecomp PRIVATE fmt chdr-static) +if(MINGW) + target_link_options(psxrecomp PRIVATE -Wl,--stack,16777216) +elseif(MSVC) + target_link_options(psxrecomp PRIVATE /STACK:16777216) +endif() + +# ---- baked codegen hash (stale-binary guard) -------------------------------- +# Bake the SAME emitter-source hash the runtime folds into its overlay cache +# tag (runtime/runtime.cmake -> hash_codegen.cmake, shared canonical list in +# runtime/codegen_hash_sources.cmake) into THIS binary, exposed via +# `psxrecomp-game --codegen-hash`. compile_overlays.py compares it against the +# hash it stamps on the cache dir (from --runtime-include) and HARD-FAILS on +# mismatch. Closes the 2026-07-01 Tomba pause-menu wedge class: a stale +# recompiler binary emitting shards that get filed under the CURRENT cg tag +# (read tag == write tag, content stale — invisible to the tag alone). +set(PSXRECOMP_CODEGEN_HASH_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/..) +include(${CMAKE_CURRENT_SOURCE_DIR}/../runtime/codegen_hash_sources.cmake) +set(_baked_hash_hdr ${CMAKE_CURRENT_BINARY_DIR}/psxrecomp_baked_codegen_hash.h) +add_custom_command( + OUTPUT ${_baked_hash_hdr} + COMMAND ${CMAKE_COMMAND} -DOUT=${_baked_hash_hdr} + "-DSRCS=${PSXRECOMP_CODEGEN_HASH_SRCS}" + -P ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/hash_codegen.cmake + DEPENDS ${PSXRECOMP_CODEGEN_HASH_SRCS} + ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/hash_codegen.cmake + COMMENT "Hashing codegen sources -> psxrecomp_baked_codegen_hash.h" + VERBATIM) +add_custom_target(psxrecomp_baked_codegen_hash DEPENDS ${_baked_hash_hdr}) +add_dependencies(psxrecomp-game psxrecomp_baked_codegen_hash) +# Force main_psx.cpp to recompile whenever the hash header content changes +# (same OBJECT_DEPENDS hardening as runtime.cmake — __has_include depfile gap). +set_source_files_properties(src/main_psx.cpp PROPERTIES OBJECT_DEPENDS ${_baked_hash_hdr}) +target_include_directories(psxrecomp-game PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) + +# NOTE (2026-07-27): the legacy `PSXRecomp` target was REMOVED here. +# It built ${PSXRECOMP_GAME_SOURCES} — byte-for-byte the same source list as +# `psxrecomp-game` above — and was a leftover from before that target was +# introduced. Because Windows and macOS filesystems are case-insensitive, its +# output `PSXRecomp.exe` collided with the user-facing CLI `psxrecomp.exe` +# (the `psxrecomp` target above): both wrote the SAME file on disk and the +# last one linked won. In practice the CLI lost, so `psxrecomp.exe` shipped in +# `psxrecomp-cli-windows-x86_64.zip` (tools/build_cli.py) was actually the game +# recompiler, with a completely different argument grammar than the README +# documents. The bug is invisible on Linux, which is why it survived. +# The `PSXRecomp.exe` users are told to run in README's "Release Package" +# section is a DIFFERENT artifact — the BIOS-only runtime, whose name comes +# from OUTPUT_NAME in runtime/runtime.cmake — and is unaffected by this removal. +# See the collision guard at the bottom of this file. + +# psxrecomp-bios — Phase 1a BIOS boot slice recompiler. +# Strict, fail-loud, ingests bios/SCPH1001.BIN as a flat ROM at 0xBFC00000. +# Independent of PSXRecomp's PS1-EXE entry point. Shares mips_decoder + +# ps1_exe_parser sources because mips_decoder.h depends on PS1Executable +# (used only by decode_executable, which psxrecomp-bios does not call). +add_executable(psxrecomp-bios + src/ps1_exe_parser.cpp + src/mips_decoder.cpp + src/strict_translator.cpp + src/pgxp_hook_emitter.cpp + src/bios_slice_walker.cpp + src/bios_address_model.cpp + src/function_discovery.cpp + src/full_function_emitter.cpp + src/config_loader.cpp + src/recompiler_patch.cpp + src/main_bios.cpp +) +target_include_directories(psxrecomp-bios PRIVATE + include + src + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include + lib/toml11 +) +target_link_libraries(psxrecomp-bios PRIVATE fmt rabbitizer) +if(MINGW) + target_link_options(psxrecomp-bios PRIVATE -Wl,--stack,16777216) +elseif(MSVC) + target_link_options(psxrecomp-bios PRIVATE /STACK:16777216) +endif() + +# Released Windows CLI archives must run without a separate MinGW/MSYS2 +# install. Prefer llvm-mingw (+ -static) in CI so emitters do not need +# libgcc/libstdc++ harvested from MSYS2. Keep opt-in for local debug builds. +option(PSXRECOMP_STATIC_CLI "Statically link Windows runtime into CLI tools" OFF) +if(PSXRECOMP_STATIC_CLI AND WIN32 AND (MINGW OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")) + foreach(_cli_target psxrecomp psxrecomp-game psxrecomp-toml psxrecomp-bios) + if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + target_link_options(${_cli_target} PRIVATE + -static -static-libgcc -static-libstdc++) + else() + # llvm-mingw / Clang: fully static CRT + libc++ / libunwind / pthread. + target_link_options(${_cli_target} PRIVATE -static) + endif() + endforeach() +endif() + +# BIOS address-model equivalence test — sweeps every 4-byte address through +# the profile-driven BiosAddressModel and compares against verbatim copies of +# the pre-refactor hardcoded SCPH1001 helpers; also exercises the from_config +# invariants. Run from the framework root: recompiler/build/bios_address_model_test +add_executable(bios_address_model_test + tests/bios_address_model_test.cpp + src/bios_address_model.cpp + src/config_loader.cpp + src/ps1_exe_parser.cpp +) +target_include_directories(bios_address_model_test PRIVATE + include + src + lib/fmt/include + lib/toml11 +) +target_link_libraries(bios_address_model_test PRIVATE fmt) + +# L1 decoder conformance test — compares MipsDecoder against Rabbitizer +# oracle for every instruction in SCPH1001.BIN code regions. +add_executable(l1_decoder_test + tests/l1_decoder_test.cpp + src/mips_decoder.cpp + src/ps1_exe_parser.cpp +) +target_include_directories(l1_decoder_test PRIVATE + include + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include +) +target_link_libraries(l1_decoder_test PRIVATE fmt rabbitizer) + +# L2 structural codegen test — verifies StrictTranslator output patterns +# for each MIPS instruction form. +add_executable(l2_structural_test + tests/l2_structural_test.cpp + src/mips_decoder.cpp + src/ps1_exe_parser.cpp + src/strict_translator.cpp + src/pgxp_hook_emitter.cpp +) +target_include_directories(l2_structural_test PRIVATE + include + src + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include +) +target_link_libraries(l2_structural_test PRIVATE fmt rabbitizer) + +if(BUILD_TESTING) + add_executable(debug_trace_ranges_test + ../runtime/tests/test_debug_trace_ranges.c + ../runtime/src/debug_trace_ranges.c + ) + target_include_directories(debug_trace_ranges_test PRIVATE + ../runtime/include) + add_test(NAME debug_trace_ranges_test COMMAND debug_trace_ranges_test) + + add_executable(cli_boot_path_test + tests/cli_boot_path_test.cpp + src/cli_boot_path.cpp + ) + target_include_directories(cli_boot_path_test PRIVATE src) + add_test(NAME cli_boot_path_test COMMAND cli_boot_path_test) + + add_executable(recompiler_patch_test + tests/recompiler_patch_test.cpp + src/ps1_exe_parser.cpp + src/mips_decoder.cpp + src/control_flow.cpp + src/function_analysis.cpp + src/code_generator.cpp + src/pgxp_hook_emitter.cpp + src/annotations.cpp + src/bios_address_model.cpp + src/config_loader.cpp + src/recompiler_patch.cpp + ) + target_include_directories(recompiler_patch_test PRIVATE + include + src + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include + lib/toml11 + lib/ELFIO + ) + target_link_libraries(recompiler_patch_test PRIVATE fmt rabbitizer) + add_test(NAME recompiler_patch_test COMMAND recompiler_patch_test) + + add_executable(full_function_emitter_test + tests/full_function_emitter_test.cpp + src/ps1_exe_parser.cpp + src/mips_decoder.cpp + src/strict_translator.cpp + src/pgxp_hook_emitter.cpp + src/bios_address_model.cpp + src/function_discovery.cpp + src/full_function_emitter.cpp + src/config_loader.cpp + src/recompiler_patch.cpp + ) + target_include_directories(full_function_emitter_test PRIVATE + include + src + lib/rabbitizer/include + lib/rabbitizer/cplusplus/include + lib/fmt/include + lib/toml11 + ) + target_link_libraries(full_function_emitter_test PRIVATE fmt rabbitizer) + add_test(NAME full_function_emitter_test COMMAND full_function_emitter_test) + + add_executable(recompiler_patch_cxx17_header_test + tests/recompiler_patch_cxx17_header_test.cpp + ) + target_include_directories(recompiler_patch_cxx17_header_test PRIVATE src) + set_target_properties(recompiler_patch_cxx17_header_test PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + ) + add_test(NAME recompiler_patch_cxx17_header_test + COMMAND recompiler_patch_cxx17_header_test) + + # [video] geometry_correction / perspective_texturing opt-in plumbing. + add_executable(video_enhancement_settings_test + tests/video_enhancement_settings_test.cpp + src/config_loader.cpp + src/ps1_exe_parser.cpp + ) + target_include_directories(video_enhancement_settings_test PRIVATE + include + src + lib/fmt/include + lib/toml11 + ) + target_link_libraries(video_enhancement_settings_test PRIVATE fmt) + add_test(NAME video_enhancement_settings_test + COMMAND video_enhancement_settings_test) + + # Needs bios/SCPH1001.toml — run from the framework root. + add_test(NAME bios_address_model_test + COMMAND bios_address_model_test + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + + # These targets were already built by every test build, but without + # add_test() entries ctest could neither run nor fail them. + add_test(NAME l2_structural_test COMMAND l2_structural_test) + add_test(NAME reachable_discovery_test COMMAND reachable_discovery_test) + + find_package(Python3 COMPONENTS Interpreter) + if(Python3_Interpreter_FOUND) + add_test( + NAME cli_project_packaging + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_cli_project_packaging.py + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + + # These two invoke psxrecomp-game, whose BIOS-profile resolution + # probes fixed working-directory-relative paths (bios/.toml, + # then psxrecomp/bios/.toml) -- run them from the framework + # root so the shipped profiles are found. + add_test( + NAME overlay_cross_page_delay_codegen + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_overlay_cross_page_delay_codegen.py + --recompiler $ + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + add_test( + NAME fmv_cycle_batch_codegen + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_fmv_cycle_batch_codegen.py + --recompiler $ + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + add_test( + NAME overlay_shim_compile_contract + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_overlay_shim_compile.py) add_test( NAME dirty_text_continuation_guards COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_dirty_text_continuation_guards.py) + add_test( + NAME dirty_text_admission_guards + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_dirty_text_admission_guards.py) add_test( NAME bios_backend_constant_codegen COMMAND ${Python3_EXECUTABLE} @@ -415,140 +424,140 @@ if(BUILD_TESTING) WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) # ---- codegen tests that were written but never registered ----------- - # These existed in recompiler/tests/ with no add_test() entry, so they - # had no way to run and no way to fail. Each was executed manually - # before being wired here; all pass. They drive psxrecomp-game, so they - # run from the framework root for BIOS-profile resolution, exactly like - # the two above. - foreach(_t IN ITEMS - aot_base_recovery - bios_resident_shard - msvc_cps_codegen - overlay_guard_codegen - overlay_store_barrier_codegen - reachable_discovery_codegen) - add_test( - NAME ${_t} - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_${_t}.py - --recompiler $ - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - endforeach() - - # test_aot_overlay_discovery.py had no add_test() entry at all. It runs - # far enough to exercise real discovery, then fails in - # check_real_batched_fragment_publication: that check reaches - # MOD.compile_fragment_batch, which spawns psxrecomp-game with cwd set - # to its own TemporaryDirectory, so BIOS-profile resolution (which - # probes bios/SCPH1001.toml relative to the working directory) finds - # nothing and returns "recompiler-error: no BIOS profile found". Every - # other subprocess in the same file passes cwd=str(ROOT) explicitly, so - # this looks like a missed cwd rather than a product defect — but it - # needs an owner, and DISABLED keeps it counted until then. - add_test( - NAME aot_overlay_discovery - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_aot_overlay_discovery.py - --recompiler $ - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - - # ---- runtime source-invariant guards -------------------------------- - # These live in runtime/tests/ but only READ runtime source, so they - # need neither a generated BIOS nor a built runtime. Registering them - # here (as dirty_text_continuation_guards already was) means they run - # from a plain recompiler build, which is the only tree a newcomer can - # configure before regenerating a BIOS. Each was run manually first; - # all pass. - # - foreach(_t IN ITEMS - bios_selection_guards - capture_history - default_renderer_guards - mod_owned_display_controls - fmv_quiet_guards - fps_telemetry_opt_in - frame_interpolation_display_rate - hybrid_dev_any_input - launcher_vulkan_option - mod_controller_override - mod_load_acceleration - overlay_candidate_capacity - release_zip - overlay_decodable_fallback - overlay_init_guard - sdl3_main_single_include - vk_build_default - vk_color_self_barrier - vk_command_buffer_batching - vk_draw_area_batch_boundary - vk_mailbox_policy - vk_present_timing - vk_present_wait_stage - vk_staging_cache) - add_test( - NAME ${_t} - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_${_t}.py - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - endforeach() - - # ---- known-failing guards: DISABLED, not de-registered --------------- - # These three were previously left out of the list above with a comment - # explaining that they FAIL and that wiring a red check would recreate - # the problem that took CI off PRs. That reasoning is right; the remedy - # was not. De-registering is precisely how a test stops being counted, - # and it is the mechanism that let 16 C/C++ tests rot in runtime/tests/. - # - # ctest's DISABLED property is the intended tool: the test stays - # registered, stays visible in `ctest -N`, and is reported as Disabled - # rather than silently absent — without turning the run red. Delete the - # matching set_tests_properties line to re-enable one once it is fixed. - # - # interpreter_perf_guards psx_devices_mmio_sync's recompute-deadline - # branch never clears g_psx_cycle_fast_limit, - # and the guard still names the pre-rename - # s_next_service_cycle - # runtime_perf_diag_guards asserts a substring no longer present in - # the runtime source - # overlay_pair_dedup_runtime needs its companion harness built; not a - # source-only guard - foreach(_t IN ITEMS - interpreter_perf_guards - runtime_perf_diag_guards - overlay_pair_dedup_runtime) - add_test( - NAME ${_t} - COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_${_t}.py - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) - set_tests_properties(${_t} PROPERTIES DISABLED TRUE) - endforeach() - endif() -endif() - -# Reachable main-EXE discovery test: parser-bound validation plus exact-entry -# analysis behavior for direct, indirect, seeded, and out-of-bound targets. -add_executable(reachable_discovery_test - tests/reachable_discovery_test.cpp - src/function_analysis.cpp - src/ps1_exe_parser.cpp -) -target_include_directories(reachable_discovery_test PRIVATE - include - lib/fmt/include -) -target_link_libraries(reachable_discovery_test PRIVATE fmt) - -# ---- test-registration completeness guard ----------------------------------- -# Reads both CMakeLists.txt files as text, so it catches an orphan in either -# project from whichever one is being configured. Placed before the collision -# guard, which must stay last because it needs every target declared; this one -# inspects files on disk and has no such ordering requirement. -include(${CMAKE_CURRENT_SOURCE_DIR}/../runtime/check_test_registration.cmake) -psxrecomp_check_all_tests_registered() - -# ---- output-name collision guard (must stay LAST) --------------------------- -# Fails configure if any two targets here would write the same file on a -# case-insensitive filesystem. See the module header for the shipped-bug story. -include(${CMAKE_CURRENT_SOURCE_DIR}/../runtime/check_target_name_collisions.cmake) -psxrecomp_check_output_name_collisions() + # These existed in recompiler/tests/ with no add_test() entry, so they + # had no way to run and no way to fail. Each was executed manually + # before being wired here; all pass. They drive psxrecomp-game, so they + # run from the framework root for BIOS-profile resolution, exactly like + # the two above. + foreach(_t IN ITEMS + aot_base_recovery + bios_resident_shard + msvc_cps_codegen + overlay_guard_codegen + overlay_store_barrier_codegen + reachable_discovery_codegen) + add_test( + NAME ${_t} + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_${_t}.py + --recompiler $ + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + endforeach() + + # test_aot_overlay_discovery.py had no add_test() entry at all. It runs + # far enough to exercise real discovery, then fails in + # check_real_batched_fragment_publication: that check reaches + # MOD.compile_fragment_batch, which spawns psxrecomp-game with cwd set + # to its own TemporaryDirectory, so BIOS-profile resolution (which + # probes bios/SCPH1001.toml relative to the working directory) finds + # nothing and returns "recompiler-error: no BIOS profile found". Every + # other subprocess in the same file passes cwd=str(ROOT) explicitly, so + # this looks like a missed cwd rather than a product defect — but it + # needs an owner, and DISABLED keeps it counted until then. + add_test( + NAME aot_overlay_discovery + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_aot_overlay_discovery.py + --recompiler $ + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + + # ---- runtime source-invariant guards -------------------------------- + # These live in runtime/tests/ but only READ runtime source, so they + # need neither a generated BIOS nor a built runtime. Registering them + # here (as dirty_text_continuation_guards already was) means they run + # from a plain recompiler build, which is the only tree a newcomer can + # configure before regenerating a BIOS. Each was run manually first; + # all pass. + # + foreach(_t IN ITEMS + bios_selection_guards + capture_history + default_renderer_guards + mod_owned_display_controls + fmv_quiet_guards + fps_telemetry_opt_in + frame_interpolation_display_rate + hybrid_dev_any_input + launcher_vulkan_option + mod_controller_override + mod_load_acceleration + overlay_candidate_capacity + release_zip + overlay_decodable_fallback + overlay_init_guard + sdl3_main_single_include + vk_build_default + vk_color_self_barrier + vk_command_buffer_batching + vk_draw_area_batch_boundary + vk_mailbox_policy + vk_present_timing + vk_present_wait_stage + vk_staging_cache) + add_test( + NAME ${_t} + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_${_t}.py + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + endforeach() + + # ---- known-failing guards: DISABLED, not de-registered --------------- + # These three were previously left out of the list above with a comment + # explaining that they FAIL and that wiring a red check would recreate + # the problem that took CI off PRs. That reasoning is right; the remedy + # was not. De-registering is precisely how a test stops being counted, + # and it is the mechanism that let 16 C/C++ tests rot in runtime/tests/. + # + # ctest's DISABLED property is the intended tool: the test stays + # registered, stays visible in `ctest -N`, and is reported as Disabled + # rather than silently absent — without turning the run red. Delete the + # matching set_tests_properties line to re-enable one once it is fixed. + # + # interpreter_perf_guards psx_devices_mmio_sync's recompute-deadline + # branch never clears g_psx_cycle_fast_limit, + # and the guard still names the pre-rename + # s_next_service_cycle + # runtime_perf_diag_guards asserts a substring no longer present in + # the runtime source + # overlay_pair_dedup_runtime needs its companion harness built; not a + # source-only guard + foreach(_t IN ITEMS + interpreter_perf_guards + runtime_perf_diag_guards + overlay_pair_dedup_runtime) + add_test( + NAME ${_t} + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_${_t}.py + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..) + set_tests_properties(${_t} PROPERTIES DISABLED TRUE) + endforeach() + endif() +endif() + +# Reachable main-EXE discovery test: parser-bound validation plus exact-entry +# analysis behavior for direct, indirect, seeded, and out-of-bound targets. +add_executable(reachable_discovery_test + tests/reachable_discovery_test.cpp + src/function_analysis.cpp + src/ps1_exe_parser.cpp +) +target_include_directories(reachable_discovery_test PRIVATE + include + lib/fmt/include +) +target_link_libraries(reachable_discovery_test PRIVATE fmt) + +# ---- test-registration completeness guard ----------------------------------- +# Reads both CMakeLists.txt files as text, so it catches an orphan in either +# project from whichever one is being configured. Placed before the collision +# guard, which must stay last because it needs every target declared; this one +# inspects files on disk and has no such ordering requirement. +include(${CMAKE_CURRENT_SOURCE_DIR}/../runtime/check_test_registration.cmake) +psxrecomp_check_all_tests_registered() + +# ---- output-name collision guard (must stay LAST) --------------------------- +# Fails configure if any two targets here would write the same file on a +# case-insensitive filesystem. See the module header for the shipped-bug story. +include(${CMAKE_CURRENT_SOURCE_DIR}/../runtime/check_target_name_collisions.cmake) +psxrecomp_check_output_name_collisions() diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index 10ba7902..803a2687 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -146,6 +146,14 @@ struct CodeGenConfig { // (INT32_MAX while revealed, vanilla at 4:3); empty by default. std::set ws_cull_xclip_load_sites; + // Exact `bltz MAC0, reject`-style NCLIP/backface rejects to suppress while + // widescreen reveals extra world. 4:3 keeps the original branch predicate. + std::set ws_cull_nclip_keep_sites; + + // Exact branch PCs whose reject target is suppressed while widescreen + // reveals extra world. 4:3 keeps the original branch predicate. + std::set ws_cull_branch_keep_sites; + // Exact, full-word-guarded comparison sites whose result is forced only // while widescreen reveals extra world. 4:3 evaluates the original compare. std::vector ws_cull_keep_sites; diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index b84dc492..073c5e22 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -1,4 +1,5 @@ #include "code_generator.h" +#include "pgxp_hook_emitter.h" #include "control_flow.h" #include "gte_register_classification.h" #include "../src/bios_address_model.h" @@ -737,6 +738,12 @@ std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t ad uint32_t opcode = (instr >> 26) & 0x3F; uint32_t rs = get_rs(instr); uint32_t rt = get_rt(instr); + auto keep_branch_if_wide = [&](std::string cond) { + if (!config_.ws_cull_branch_keep_sites.count(addr)) + return cond; + return fmt::format("psx_ws_x_margin() > 0 ? 0 : ({}) /* ws branch keep */", + cond); + }; // REGIMM branches. R3000A hardware decodes EVERY rt value here, not just // the four assembler mnemonics: the branch sense is rt bit 0 (0 = bltz, @@ -758,9 +765,14 @@ std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t ad (ws_cull_bltz_pcs_.count(addr) || config_.ws_cull_bltz_sites.count(addr))) return fmt::format("psx_ws_cull_bltz({}) /* ws cull (left edge) */", reg_name(rs)); - return fmt::format("(int32_t){} < 0", reg_name(rs)); + if (regimm_op == 0x00 && config_.ws_cull_nclip_keep_sites.count(addr)) + return fmt::format("psx_ws_x_margin() > 0 ? 0 : ((int32_t){} < 0) /* ws nclip keep */", + reg_name(rs)); + return keep_branch_if_wide( + fmt::format("(int32_t){} < 0", reg_name(rs))); } else { // bgez family (incl. bgezal + undefined mirrors) - return fmt::format("(int32_t){} >= 0", reg_name(rs)); + return keep_branch_if_wide( + fmt::format("(int32_t){} >= 0", reg_name(rs))); } } @@ -768,22 +780,26 @@ std::string CodeGenerator::generate_branch_condition(uint32_t instr, uint32_t ad switch (opcode) { case 0x04: // beq case 0x14: // beql - return fmt::format("{} == {}", reg_name(rs), reg_name(rt)); + return keep_branch_if_wide( + fmt::format("{} == {}", reg_name(rs), reg_name(rt))); case 0x05: // bne case 0x15: // bnel - return fmt::format("{} != {}", reg_name(rs), reg_name(rt)); + return keep_branch_if_wide( + fmt::format("{} != {}", reg_name(rs), reg_name(rt))); case 0x06: // blez case 0x16: // blezl - return fmt::format("(int32_t){} <= 0", reg_name(rs)); + return keep_branch_if_wide( + fmt::format("(int32_t){} <= 0", reg_name(rs))); case 0x07: // bgtz case 0x17: // bgtzl - return fmt::format("(int32_t){} > 0", reg_name(rs)); + return keep_branch_if_wide( + fmt::format("(int32_t){} > 0", reg_name(rs))); } - return "0 /* unknown branch condition: defaults to not-taken */"; + return keep_branch_if_wide("0 /* unknown branch condition: defaults to not-taken */"); } std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) { @@ -1500,17 +1516,21 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) const std::string gte_read = fmt::format( "\n#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_gte_read(cpu, {});\n#endif\n ", rt); if (cop_op == 0x00) { // MFC2 - move from COP2 data - if (PSXRecompGTERegisters::data_read_needs_helper(static_cast(rd))) { - code = gte_read + fmt::format("{} = gte_read_data(cpu, {}); /* mfc2 */", reg_name(rt), rd); - } else { - code = gte_read + fmt::format("{} = cpu->gte_data[{}]; /* mfc2 */", reg_name(rt), rd); - } + const std::string value = + PSXRecompGTERegisters::data_read_needs_helper(static_cast(rd)) + ? fmt::format("gte_read_data(cpu, {})", rd) + : fmt::format("cpu->gte_data[{}]", rd); + code = gte_read + (rt == 0 + ? fmt::format("(void){}; /* mfc2 to $zero: read preserved, write discarded */", value) + : fmt::format("{} = {}; /* mfc2 */", reg_name(rt), value)); } else if (cop_op == 0x02) { // CFC2 - move from COP2 control - if (PSXRecompGTERegisters::ctrl_read_needs_helper(static_cast(rd))) { - code = gte_read + fmt::format("{} = gte_read_ctrl(cpu, {}); /* cfc2 */", reg_name(rt), rd); - } else { - code = gte_read + fmt::format("{} = cpu->gte_ctrl[{}]; /* cfc2 */", reg_name(rt), rd); - } + const std::string value = + PSXRecompGTERegisters::ctrl_read_needs_helper(static_cast(rd)) + ? fmt::format("gte_read_ctrl(cpu, {})", rd) + : fmt::format("cpu->gte_ctrl[{}]", rd); + code = gte_read + (rt == 0 + ? fmt::format("(void){}; /* cfc2 to $zero: read preserved, write discarded */", value) + : fmt::format("{} = {}; /* cfc2 */", reg_name(rt), value)); } else if (cop_op == 0x04) { // MTC2 - move to COP2 data if (PSXRecompGTERegisters::data_write_needs_helper(static_cast(rd))) { code = gte_stall + fmt::format("gte_write_data(cpu, {}, {}); /* mtc2 */", rd, reg_name(rt)); @@ -1568,12 +1588,19 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) ? reg_name(rs) : fmt::format("{} + {}", reg_name(rs), offset); bool special = PSXRecompGTERegisters::data_write_needs_helper(static_cast(rt)); + /* The raw loaded word is captured for the PGXP hook: the + * register may hold a MASKED value (write helper), and + * the shadow must validate against the word as loaded. */ if (special) { - code = gte_stall + fmt::format("gte_write_data(cpu, {}, psx_cyc_lwc2_read(cpu, {})); /* lwc2 gte[{}] */", - rt, addr, rt); + code = gte_stall + fmt::format( + "{{ uint32_t _pgxa = {}; uint32_t _pgxv = psx_cyc_lwc2_read(cpu, _pgxa); " + "gte_write_data(cpu, {}, _pgxv); PGXP_COP2(0x{:08X}u, _pgxv, _pgxa); }} /* lwc2 gte[{}] */", + addr, rt, instr, rt); } else { - code = gte_stall + fmt::format("cpu->gte_data[{}] = psx_cyc_lwc2_read(cpu, {}); /* lwc2 gte[{}], ({}) */", - rt, addr, rt, addr); + code = gte_stall + fmt::format( + "{{ uint32_t _pgxa = {}; uint32_t _pgxv = psx_cyc_lwc2_read(cpu, _pgxa); " + "cpu->gte_data[{}] = _pgxv; PGXP_COP2(0x{:08X}u, _pgxv, _pgxa); }} /* lwc2 gte[{}], ({}) */", + addr, rt, instr, rt, addr); } } break; @@ -1589,16 +1616,15 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) ? fmt::format("gte_read_data(cpu, {})", rt) : fmt::format("cpu->gte_data[{}]", rt); std::string swc2_store_pc = fmt::format("g_debug_last_store_pc = 0x{:08X}u; ", addr); - if (offset == 0) { - code = gte_stall + swc2_store_pc + fmt::format( - "psx_store_cycle_barrier(); cpu->write_word({}, {}); gte_precision_store_word({}, {}); /* swc2 gte[{}], ({}) */", - reg_name(rs), value, reg_name(rs), rt, rt, reg_name(rs)); - } else { - code = gte_stall + swc2_store_pc + fmt::format( - "psx_store_cycle_barrier(); cpu->write_word({} + {}, {}); gte_precision_store_word({} + {}, {}); /* swc2 gte[{}], {}({}) */", - reg_name(rs), offset, value, reg_name(rs), offset, rt, - rt, offset, reg_name(rs)); - } + std::string swc2_addr = (offset == 0) + ? reg_name(rs) + : fmt::format("{} + {}", reg_name(rs), offset); + code = gte_stall + swc2_store_pc + fmt::format( + "{{ uint32_t _pgxa = {}; uint32_t _pgxv = {}; " + "psx_store_cycle_barrier(); cpu->write_word(_pgxa, _pgxv); " + "gte_precision_store_word(_pgxa, {}); " + "PGXP_COP2(0x{:08X}u, _pgxv, _pgxa); }} /* swc2 gte[{}], {}({}) */", + swc2_addr, value, rt, instr, rt, offset, reg_name(rs)); } break; default: @@ -1606,6 +1632,7 @@ std::string CodeGenerator::translate_instruction(uint32_t addr, uint32_t instr) } } + PSXRecomp::append_pgxp_hooks(instr, code); return config_.indent + code + comment; } diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index f35c3782..f9d75249 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -72,6 +72,8 @@ uint32_t overlay_codegen_config_hash(const GameConfig& c) { h.words("cull_depth", c.ws_cull_depth_sites); h.words("cull_plane_nx", c.ws_cull_plane_nx_sites); h.words("cull_xclip_load", c.ws_cull_xclip_load_sites); + h.words("cull_nclip_keep", c.ws_cull_nclip_keep_sites); + h.words("cull_branch_keep", c.ws_cull_branch_keep_sites); h.words("cull_w_imms", c.ws_cull_w_imms); h.words("cull_h_imms", c.ws_cull_h_imms); h.words("backdrop_x", c.ws_backdrop_x_sites); @@ -327,10 +329,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& } } } - if (!cfg.contains("runtime")) return rt; - const toml::value& runtime = toml::find(cfg, "runtime"); - if (runtime.contains("language")) // [runtime].language convenience alias - rt.language = toml::find(runtime, "language"); + if (cfg.contains("runtime")) { + const toml::value& runtime = toml::find(cfg, "runtime"); + if (runtime.contains("language")) // [runtime].language convenience alias + rt.language = toml::find(runtime, "language"); if (runtime.contains("debug_port")) { const auto port = toml::find(runtime, "debug_port"); @@ -502,9 +504,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (runtime.contains("overlay_backend")) { rt.overlay_backend = toml::find(runtime, "overlay_backend"); } - if (runtime.contains("overlay_native_block")) { - for (const auto& a : toml::find>(runtime, "overlay_native_block")) { - rt.overlay_native_block.push_back(parse_hex(a, "runtime.overlay_native_block")); + if (runtime.contains("overlay_native_block")) { + for (const auto& a : toml::find>(runtime, "overlay_native_block")) { + rt.overlay_native_block.push_back(parse_hex(a, "runtime.overlay_native_block")); + } } } @@ -571,6 +574,13 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& rt.video_perspective_texturing = toml::find(video, "perspective_texturing"); } + if (video.contains("pgxp_cpu_mode")) { + rt.video_pgxp_cpu_mode = toml::find(video, "pgxp_cpu_mode"); + } + if (video.contains("pgxp_tolerance")) { + rt.video_pgxp_tolerance = + toml::find(video, "pgxp_tolerance"); + } if (video.contains("crt_filter")) { const auto mode = toml::find(video, "crt_filter"); if (mode == "raw") rt.video_screen_kind = 0; @@ -702,6 +712,16 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& toml::find(ct, "p2_mode"), PAD_MODE_ANALOG); rt.has_default_mode = true; } + if (ct.contains("p1_device")) { + rt.default_p1_device = toml::find(ct, "p1_device"); + if (!rt.default_p1_device.empty()) + rt.has_default_p1_device = true; + } + if (ct.contains("p2_device")) { + rt.default_p2_device = toml::find(ct, "p2_device"); + if (!rt.default_p2_device.empty()) + rt.has_default_p2_device = true; + } if (ct.contains("lock_mode")) { rt.controller_lock_mode = toml::find(ct, "lock_mode"); } @@ -1180,6 +1200,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { bool has_netplay_required_leadout = false; uint32_t netplay_required_leadout_lba = 0; std::string netplay_required_disc_fp; + std::string netplay_local_viewport; + std::string netplay_local_viewport_aspect; if (cfg.contains("netplay")) { const toml::value& np = toml::find(cfg, "netplay"); if (np.contains("require_cue")) @@ -1196,6 +1218,37 @@ GameConfig load_game_config(const fs::path& config_path_in) { for (char& c : netplay_required_disc_fp) c = (char)std::tolower((unsigned char)c); } + if (np.contains("local_viewport")) { + netplay_local_viewport = toml::find(np, "local_viewport"); + for (char& c : netplay_local_viewport) + c = (char)std::tolower((unsigned char)c); + if (!netplay_local_viewport.empty() && + netplay_local_viewport != "vertical_split") { + throw std::runtime_error(fmt::format( + "[netplay] local_viewport must be \"vertical_split\", got '{}'", + netplay_local_viewport)); + } + } + if (np.contains("local_viewport_aspect")) { + netplay_local_viewport_aspect = + toml::find(np, "local_viewport_aspect"); + for (char& c : netplay_local_viewport_aspect) + c = (char)std::tolower((unsigned char)c); + if (!netplay_local_viewport_aspect.empty() && + netplay_local_viewport_aspect != "16:9" && + netplay_local_viewport_aspect != "21:9" && + netplay_local_viewport_aspect != "adaptive") { + throw std::runtime_error(fmt::format( + "[netplay] local_viewport_aspect must be \"16:9\", " + "\"21:9\", or \"adaptive\", got '{}'", + netplay_local_viewport_aspect)); + } + if (!netplay_local_viewport_aspect.empty() && + netplay_local_viewport.empty()) { + throw std::runtime_error( + "[netplay] local_viewport_aspect requires local_viewport"); + } + } } // [recompiler] @@ -1307,6 +1360,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { bool ws_auto_ui_squash = false; bool ws_full_2d = false; bool ws_gte_game_mode = false; + bool ws_precise_nclip = false; uint32_t ws_gameplay_state_addr = 0; std::vector ws_gameplay_state_values; bool ws_native_wide = true; @@ -1454,6 +1508,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { ws_full_2d = toml::find(ws, "full_2d"); if (ws.contains("gte_game_mode")) ws_gte_game_mode = toml::find(ws, "gte_game_mode"); + if (ws.contains("precise_nclip")) + ws_precise_nclip = toml::find(ws, "precise_nclip"); const bool has_gameplay_state_addr = ws.contains("gameplay_state_addr"); const bool has_gameplay_state_values = ws.contains("gameplay_state_values"); if (has_gameplay_state_addr != has_gameplay_state_values) @@ -1556,6 +1612,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { std::vector ws_cull_depth_sites; std::vector ws_cull_plane_nx_sites; std::vector ws_cull_xclip_load_sites; + std::vector ws_cull_nclip_keep_sites; + std::vector ws_cull_branch_keep_sites; std::vector ws_cull_keep_sites; std::vector ws_cull_angle_sites; WidescreenAspectConeConfig ws_aspect_cone; @@ -1589,6 +1647,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { load_sites("depth_sites", ws_cull_depth_sites); load_sites("plane_nx_sites", ws_cull_plane_nx_sites); load_sites("xclip_load_sites", ws_cull_xclip_load_sites); + load_sites("nclip_keep_sites", ws_cull_nclip_keep_sites); + load_sites("branch_keep_sites", ws_cull_branch_keep_sites); if (cull.contains("keep")) { std::set seen; for (const auto& item : toml::find(cull, "keep")) { @@ -1975,6 +2035,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*has_netplay_required_leadout*/ has_netplay_required_leadout, /*netplay_required_leadout_lba*/ netplay_required_leadout_lba, /*netplay_required_disc_fp*/ netplay_required_disc_fp, + /*netplay_local_viewport*/ netplay_local_viewport, + /*netplay_local_viewport_aspect*/ netplay_local_viewport_aspect, /*seeds_path*/ seeds_path, /*bios_thunks_path*/ bios_thunks_path, /*bios_config_path*/ bios_config_path, @@ -2013,6 +2075,8 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*ws_cull_depth_sites*/ ws_cull_depth_sites, /*ws_cull_plane_nx_sites*/ ws_cull_plane_nx_sites, /*ws_cull_xclip_load_sites*/ ws_cull_xclip_load_sites, + /*ws_cull_nclip_keep_sites*/ ws_cull_nclip_keep_sites, + /*ws_cull_branch_keep_sites*/ ws_cull_branch_keep_sites, /*ws_cull_keep_sites*/ ws_cull_keep_sites, /*ws_cull_angle_sites*/ ws_cull_angle_sites, /*ws_aspect_cone*/ ws_aspect_cone, @@ -2027,6 +2091,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*ws_auto_backdrop_preload*/ ws_auto_backdrop_preload, /*ws_full_2d*/ ws_full_2d, /*ws_gte_game_mode*/ ws_gte_game_mode, + /*ws_precise_nclip*/ ws_precise_nclip, /*ws_gameplay_state_addr*/ ws_gameplay_state_addr, /*ws_gameplay_state_values*/ ws_gameplay_state_values, /*ws_native_wide*/ ws_native_wide, @@ -2103,6 +2168,10 @@ GameOptions load_game_options(const fs::path& path) { // ---- UserSettings (settings.toml) — launcher-written override layer ---- +static bool valid_user_audio_freq(int n) { + return n == 32040 || n == 32000 || n == 44100 || n == 48000; +} + UserSettings load_user_settings(const fs::path& path) { UserSettings s; std::error_code ec; @@ -2245,6 +2314,13 @@ UserSettings load_user_settings(const fs::path& path) { } if (doc.contains("audio")) { const toml::value& a = toml::find(doc, "audio"); + if (a.contains("frequency")) try_get([&]{ + const auto n = toml::find(a, "frequency"); + if (valid_user_audio_freq((int)n)) { + s.audio_freq = (int)n; + s.has_audio_freq = true; + } + }); if (a.contains("spu_hq")) try_get([&]{ s.spu_hq = toml::find(a, "spu_hq"); s.has_spu_hq = true; }); @@ -2487,6 +2563,8 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { if (s.has_rewind_interval) f << "rewind_interval = " << s.rewind_interval << "\n"; f << "\n[audio]\n"; + if (s.has_audio_freq) + f << "frequency = " << s.audio_freq << "\n"; if (s.has_spu_hq) f << "spu_hq = " << (s.spu_hq ? "true" : "false") << "\n"; if (s.has_hotkey_pad_rewind || s.has_hotkey_pad_save_state_menu) { diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 011eaf4d..9e7e829f 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -387,6 +387,24 @@ struct RuntimeConfig { // [video] perspective_texturing = true. bool video_perspective_texturing = false; + // pgxp_cpu_mode: propagate sub-pixel precision through CPU arithmetic as + // well as memory moves (the PGXP engine's tier-2 hooks). Off by default — + // the same default as the reference implementations — because some games + // deliberately rely on integer truncation in their own math; value + // validation keeps it SAFE either way, this only trades coverage. + // Meaningful only in a pgxp-flavour build; live-tunable over TCP. + bool video_pgxp_cpu_mode = false; + + // pgxp_tolerance: reject a corrected vertex whose sub-pixel offset from + // the native integer position exceeds this many pixels (the truncation- + // agreement check already bounds offsets to < 1px; this narrows them + // further). Default 0.5 — user-validated on Ape Escape (2026-08-15): + // unclamped, sparse hairline background-bleed seams appear where a + // corrected triangle borders an uncorrected one; at 0.5 the seams are + // gone and only sub-half-pixel misalignment remains. Negative disables + // the clamp. Live-tunable over TCP (pgxp verb). + double video_pgxp_tolerance = 0.5; + // offer_vulkan: expose the experimental Vulkan renderer in the launcher. // Defaults false even for Vulkan-enabled builds; developers must opt in per // game once visuals are validated. @@ -497,6 +515,15 @@ struct RuntimeConfig { int default_p1_mode = PAD_MODE_ANALOG; int default_p2_mode = PAD_MODE_ANALOG; + // p1_device / p2_device: optional per-game default input sources for + // fresh settings and launcher-less boots. Same vocabulary as settings.toml: + // "none", "keyboard", "auto"/"gamepad"/"controller", or an SDL GUID. + // Per-install settings.toml still overrides these defaults. + bool has_default_p1_device = false; + bool has_default_p2_device = false; + std::string default_p1_device; + std::string default_p2_device; + // lock_mode: when true the launcher HIDES the whole pad-mode selector // (Hybrid | Analog | D-Pad) and forces every port to default_p1_mode. For a // game that supports exactly one pad type — e.g. Tomba 2, whose driver only @@ -676,6 +703,15 @@ struct GameConfig { bool has_netplay_required_leadout = false; uint32_t netplay_required_leadout_lba = 0; std::string netplay_required_disc_fp; // lowercase hex SHA-256 + // local_viewport = "vertical_split": while real netplay is active, crop + // presentation to this peer's left/right split-screen half. This is a + // presentation-only helper for titles that still render native split-screen + // in netplay; unset keeps every peer seeing the full framebuffer. + std::string netplay_local_viewport; + // Optional display aspect to use with local_viewport. Accepted values: + // "16:9", "21:9", or "adaptive" (initial 16:9, live-window capped 21:9). + // Unset keeps netplay at the title's normal mod-cleared aspect. + std::string netplay_local_viewport_aspect; // [recompiler] block std::filesystem::path seeds_path; // absolute path to seeds (text or json) @@ -812,6 +848,14 @@ struct GameConfig { // scissor clips the overflow and wrapped off-left coords pass); the // vanilla loaded value at 4:3. Empty by default; regen required. std::vector ws_cull_xclip_load_sites; + // Exact `bltz MAC0, reject`-style NCLIP/backface rejects that are forced + // not-taken only while widescreen reveals extra world. This is deliberately + // separate from bltz_sites, whose helper adjusts screen-X edge thresholds. + std::vector ws_cull_nclip_keep_sites; + // Exact branch PCs whose reject path is forced not-taken only while + // widescreen reveals extra world. Use only after screenshot-validated + // evidence that the target is a visibility reject. + std::vector ws_cull_branch_keep_sites; // Exact comparison sites whose result is forced only while widescreen // reveals extra world. Used for proven object/model participation gates // where maximal overdraw is preferable to range guessing. Each entry is @@ -903,6 +947,14 @@ struct GameConfig { // (native-wide engages); genuine full-2D screens (save/options) still // pillarbox 4:3. Runtime-only — no regen required. Off by default. bool ws_gte_game_mode = false; + + // [widescreen] precise_nclip — use the runtime's unsaturated GTE projection + // provenance for NCLIP/backface tests while classic adaptive widescreen is + // active. This is for 3D titles whose wide side geometry otherwise hits the + // PS1 SXY +/-1024 clamp and then disappears from game-side visibility tests. + // Runtime-only; off by default. + bool ws_precise_nclip = false; + // Optional authoritative game-state gate for titles whose menus also // render enough 3D geometry to fool gte_game_mode. When configured, // native-wide is active only while the guest word matches one listed @@ -1115,6 +1167,7 @@ struct UserSettings { bool has_hotkey_pad_save_state_menu = false; int hotkey_pad_save_state_menu = 2040; /* select+r1 */ // [audio] bool has_spu_hq = false; bool spu_hq = false; + bool has_audio_freq = false; int audio_freq = 44100; // [bios] / [disc] / [memcard] bool has_bios_path = false; std::filesystem::path bios_path; bool has_disc_path = false; std::filesystem::path disc_path; diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index 23e1318a..5df7cb7c 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -209,6 +209,8 @@ int main(int argc, char** argv) { std::set ws_cull_depth; // [widescreen.cull] depth_sites std::set ws_cull_plane_nx; // [widescreen.cull] plane_nx_sites std::set ws_cull_xclip_load; // [widescreen.cull] xclip_load_sites + std::set ws_cull_nclip_keep; // [widescreen.cull] nclip_keep_sites + std::set ws_cull_branch_keep; // [widescreen.cull] branch_keep_sites std::vector ws_cull_keep; std::vector ws_cull_angle; PSXRecompV4::WidescreenAspectConeConfig ws_aspect_cone; @@ -270,6 +272,8 @@ int main(int argc, char** argv) { ws_cull_depth.insert(cfg.ws_cull_depth_sites.begin(), cfg.ws_cull_depth_sites.end()); ws_cull_plane_nx.insert(cfg.ws_cull_plane_nx_sites.begin(), cfg.ws_cull_plane_nx_sites.end()); ws_cull_xclip_load.insert(cfg.ws_cull_xclip_load_sites.begin(), cfg.ws_cull_xclip_load_sites.end()); + ws_cull_nclip_keep.insert(cfg.ws_cull_nclip_keep_sites.begin(), cfg.ws_cull_nclip_keep_sites.end()); + ws_cull_branch_keep.insert(cfg.ws_cull_branch_keep_sites.begin(), cfg.ws_cull_branch_keep_sites.end()); ws_cull_keep = cfg.ws_cull_keep_sites; ws_cull_angle = cfg.ws_cull_angle_sites; ws_aspect_cone = cfg.ws_aspect_cone; @@ -363,6 +367,8 @@ int main(int argc, char** argv) { ws_cull_depth.insert(wscfg.ws_cull_depth_sites.begin(), wscfg.ws_cull_depth_sites.end()); ws_cull_plane_nx.insert(wscfg.ws_cull_plane_nx_sites.begin(), wscfg.ws_cull_plane_nx_sites.end()); ws_cull_xclip_load.insert(wscfg.ws_cull_xclip_load_sites.begin(), wscfg.ws_cull_xclip_load_sites.end()); + ws_cull_nclip_keep.insert(wscfg.ws_cull_nclip_keep_sites.begin(), wscfg.ws_cull_nclip_keep_sites.end()); + ws_cull_branch_keep.insert(wscfg.ws_cull_branch_keep_sites.begin(), wscfg.ws_cull_branch_keep_sites.end()); if (ws_cull_keep.empty()) ws_cull_keep = wscfg.ws_cull_keep_sites; if (ws_cull_angle.empty()) ws_cull_angle = wscfg.ws_cull_angle_sites; if (ws_aspect_cone.sites.empty()) @@ -1225,6 +1231,8 @@ int main(int argc, char** argv) { codegen_config.ws_cull_depth_sites = ws_cull_depth; codegen_config.ws_cull_plane_nx_sites = ws_cull_plane_nx; codegen_config.ws_cull_xclip_load_sites = ws_cull_xclip_load; + codegen_config.ws_cull_nclip_keep_sites = ws_cull_nclip_keep; + codegen_config.ws_cull_branch_keep_sites = ws_cull_branch_keep; codegen_config.ws_cull_keep_sites = ws_cull_keep; codegen_config.ws_cull_angle_sites = ws_cull_angle; codegen_config.ws_aspect_cone = ws_aspect_cone; diff --git a/recompiler/src/pgxp_hook_emitter.cpp b/recompiler/src/pgxp_hook_emitter.cpp new file mode 100644 index 00000000..1455983c --- /dev/null +++ b/recompiler/src/pgxp_hook_emitter.cpp @@ -0,0 +1,134 @@ +#include "pgxp_hook_emitter.h" + +#include + +namespace PSXRecomp { + +namespace { +/* Same register naming both emitters use ("cpu->gpr[n]"); kept local so this + * TU links into psxrecomp-bios without pulling the game CodeGenerator. */ +inline std::string reg_name(uint32_t n) { return fmt::format("cpu->gpr[{}]", n); } +inline uint32_t get_rs(uint32_t i) { return (i >> 21) & 0x1F; } +inline uint32_t get_rt(uint32_t i) { return (i >> 16) & 0x1F; } +inline uint32_t get_rd(uint32_t i) { return (i >> 11) & 0x1F; } +inline int16_t get_imm16(uint32_t i) { return (int16_t)(i & 0xFFFF); } +} + +/* PGXP dataflow-shadowing hook emission (ENHANCEMENTS.md G1.10; pgxp_hooks.h). + * + * Wraps the translated statement with a PGXP_*() macro carrying the raw + * instruction word plus the operand values the runtime engine needs. Source + * operands that the statement may clobber (a load/ALU destination aliasing a + * source, a load overwriting its own base register) are captured into locals + * BEFORE the statement; everything else is read after it. In the base build + * the macros preprocess to ((void)0) and the optimizer erases the dead + * capture locals, so base objects are unchanged; only a -DPSX_PGXP=1 TU pays. + * + * Deliberately unhooked: AND/XOR/NOR/SLT-family and the exotic immediates — + * they only ever DESTROY precision, and the engine's validate-on-read drops + * their stale shadows without help. The configured widescreen special sites + * return early above translate_instruction's main dispatch and are likewise + * unhooked (they are cull compares, not vertex moves; validation covers). */ +void append_pgxp_hooks(uint32_t instr, std::string& code) { + const uint32_t opcode = (instr >> 26) & 0x3F; + const uint32_t rs = get_rs(instr); + const uint32_t rt = get_rt(instr); + const uint32_t rd = get_rd(instr); + const int16_t offset = get_imm16(instr); + const auto addr_expr = [&]() { + return (offset == 0) ? reg_name(rs) + : fmt::format("{} + {}", reg_name(rs), offset); + }; + + switch (opcode) { + case 0x00: { /* SPECIAL */ + const uint32_t funct = instr & 0x3F; + switch (funct) { + case 0x00: case 0x02: case 0x03: /* SLL / SRL / SRA */ + if (rd == 0 || instr == 0) return; /* skip plain nop */ + code = fmt::format( + "{{ uint32_t _pgx1 = {}; {} PGXP_ALU(0x{:08X}u, {}, _pgx1, {}u); }}", + reg_name(rt), code, instr, reg_name(rd), (instr >> 6) & 31u); + return; + case 0x04: case 0x06: case 0x07: /* SLLV / SRLV / SRAV */ + if (rd == 0) return; + code = fmt::format( + "{{ uint32_t _pgx1 = {}; uint32_t _pgx2 = {}; {} " + "PGXP_ALU(0x{:08X}u, {}, _pgx1, _pgx2); }}", + reg_name(rt), reg_name(rs), code, instr, reg_name(rd)); + return; + case 0x10: case 0x12: /* MFHI / MFLO */ + if (rd == 0) return; + code = fmt::format("{} PGXP_ALU(0x{:08X}u, {}, {}, 0u);", + code, instr, reg_name(rd), + funct == 0x10 ? "cpu->hi" : "cpu->lo"); + return; + case 0x11: case 0x13: /* MTHI / MTLO */ + code = fmt::format("{} PGXP_ALU(0x{:08X}u, {}, {}, 0u);", + code, instr, + funct == 0x11 ? "cpu->hi" : "cpu->lo", + funct == 0x11 ? "cpu->hi" : "cpu->lo"); + return; + case 0x18: case 0x19: case 0x1A: case 0x1B: /* MULT(U)/DIV(U) */ + code = fmt::format( + "{} PGXP_MULDIV(0x{:08X}u, cpu->hi, cpu->lo, {}, {});", + code, instr, reg_name(rs), reg_name(rt)); + return; + case 0x20: case 0x21: case 0x22: case 0x23: /* ADD(U)/SUB(U) */ + case 0x25: /* OR */ + if (rd == 0) return; + code = fmt::format( + "{{ uint32_t _pgx1 = {}; uint32_t _pgx2 = {}; {} " + "PGXP_ALU(0x{:08X}u, {}, _pgx1, _pgx2); }}", + reg_name(rs), reg_name(rt), code, instr, reg_name(rd)); + return; + default: + return; + } + } + case 0x08: case 0x09: /* ADDI / ADDIU */ + if (rt == 0) return; + code = fmt::format( + "{{ uint32_t _pgx1 = {}; {} PGXP_ALU(0x{:08X}u, {}, _pgx1, 0x{:08X}u); }}", + reg_name(rs), code, instr, reg_name(rt), + (uint32_t)(int32_t)offset); + return; + case 0x0D: /* ORI */ + if (rt == 0) return; + code = fmt::format( + "{{ uint32_t _pgx1 = {}; {} PGXP_ALU(0x{:08X}u, {}, _pgx1, 0x{:04X}u); }}", + reg_name(rs), code, instr, reg_name(rt), + (uint32_t)(uint16_t)offset); + return; + case 0x0F: /* LUI */ + if (rt == 0) return; + code = fmt::format("{} PGXP_ALU(0x{:08X}u, {}, 0u, 0u);", + code, instr, reg_name(rt)); + return; + case 0x12: { /* COP2 register transfers */ + const uint32_t cop_op = (instr >> 21) & 0x1F; + if ((cop_op == 0x00 && rt != 0) || cop_op == 0x04) /* MFC2 / MTC2 */ + code = fmt::format("{} PGXP_COP2(0x{:08X}u, {}, 0u);", + code, instr, reg_name(rt)); + return; + } + case 0x20: case 0x21: case 0x22: case 0x23: + case 0x24: case 0x25: case 0x26: /* loads */ + if (rt == 0) return; + code = fmt::format( + "{{ uint32_t _pgxa = {}; {} PGXP_LOAD(0x{:08X}u, _pgxa, {}); }}", + addr_expr(), code, instr, reg_name(rt)); + return; + case 0x28: case 0x29: case 0x2A: case 0x2B: + case 0x2E: /* stores */ + code = fmt::format( + "{{ uint32_t _pgxa = {}; {} PGXP_STORE(0x{:08X}u, _pgxa, {}); }}", + addr_expr(), code, instr, reg_name(rt)); + return; + default: + return; + } +} + + +} // namespace PSXRecomp diff --git a/recompiler/src/pgxp_hook_emitter.h b/recompiler/src/pgxp_hook_emitter.h new file mode 100644 index 00000000..6f3b8dab --- /dev/null +++ b/recompiler/src/pgxp_hook_emitter.h @@ -0,0 +1,31 @@ +#ifndef PGXP_HOOK_EMITTER_H +#define PGXP_HOOK_EMITTER_H + +#include +#include + +namespace PSXRecomp { + +/* PGXP dataflow-shadowing hook emission (ENHANCEMENTS.md G1.10; runtime + * pgxp_hooks.h). Shared by BOTH emitters — CodeGenerator (game) and + * StrictTranslator (BIOS) — so the hook grammar can never drift between them. + * + * Wraps the translated statement `code` with a PGXP_*() macro invocation for + * the instruction classes that move sub-pixel projection provenance. Source + * operands the statement may clobber are captured into locals BEFORE it; + * everything else is read after it. In the base build the macros preprocess + * to ((void)0) and the optimizer erases the dead captures, so base objects + * are unchanged; only a -DPSX_PGXP=1 TU pays. + * + * NOT applied to LWC2/SWC2 — those emissions capture the raw loaded/stored + * word themselves (a masked GTE register write must validate against the + * word as loaded, not the register's masked value). + * + * Deliberately unhooked: AND/XOR/NOR/SLT-family and the exotic immediates — + * they only ever DESTROY precision, and the engine's validate-on-read drops + * their stale shadows without help. */ +void append_pgxp_hooks(uint32_t instr, std::string& code); + +} // namespace PSXRecomp + +#endif /* PGXP_HOOK_EMITTER_H */ diff --git a/recompiler/src/strict_translator.cpp b/recompiler/src/strict_translator.cpp index e72eb9e5..85be8666 100644 --- a/recompiler/src/strict_translator.cpp +++ b/recompiler/src/strict_translator.cpp @@ -7,6 +7,7 @@ // records this substitution explicitly. #include "strict_translator.h" +#include "pgxp_hook_emitter.h" /* shared PGXP hook grammar */ #include "gte_register_classification.h" #include @@ -63,6 +64,30 @@ TranslateResult unsupported(const PSXRecomp::DecodedInstruction& d, const std::s } // namespace TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& d) { + TranslateResult r = translate_impl(d); + if (!r.supported) return r; + const uint32_t opcode = (d.raw >> 26) & 0x3F; + /* Shared PGXP hook grammar (ENHANCEMENTS.md G1.10). LWC2/SWC2 capture + * their raw word inside translate_impl and are skipped here. */ + if (opcode != 0x32 && opcode != 0x3A && !r.c_code.empty()) + PSXRecomp::append_pgxp_hooks(d.raw, r.c_code); + /* The deferred load-delay variant assigns into the function-scope + * psx_ldd_ temp instead of the GPR; hook it with that temp as the + * loaded value (the shadow keys on the value, not on where it lands — + * the emitter's later writeback makes the GPR match). */ + if (r.load_dest >= 0 && !r.c_code_deferred.empty()) { + const uint32_t rs = (d.raw >> 21) & 0x1F; + const int16_t offset = static_cast(d.raw & 0xFFFF); + r.c_code_deferred = fmt::format( + "{{ uint32_t _pgxa = cpu->gpr[{}] + {}; {} " + "PGXP_LOAD(0x{:08X}u, _pgxa, psx_ldd_{:08X}); }}", + static_cast(rs), static_cast(offset), + r.c_code_deferred, d.raw, d.address); + } + return r; +} + +TranslateResult StrictTranslator::translate_impl(const PSXRecomp::DecodedInstruction& d) { TranslateResult r; // NOP (raw == 0, i.e. SLL $zero,$zero,0) — emit as a comment with no semantics. @@ -1405,14 +1430,18 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& ? fmt::format("cpu->gpr[{}]", static_cast(rs)) : fmt::format("(uint32_t)((int32_t)cpu->gpr[{}] + ({}))", static_cast(rs), static_cast(offset)); bool special = PSXRecompGTERegisters::data_write_needs_helper(rt); + /* The raw loaded word is captured for the PGXP hook: a masked + * register write must validate against the word as loaded. */ if (special) { r.c_code = gte_stall + fmt::format( - "gte_write_data(cpu, {}, psx_cyc_lwc2_read(cpu, {}));", - static_cast(rt), addr); + "{{ uint32_t _pgxa = {}; uint32_t _pgxv = psx_cyc_lwc2_read(cpu, _pgxa); " + "gte_write_data(cpu, {}, _pgxv); PGXP_COP2(0x{:08X}u, _pgxv, _pgxa); }}", + addr, static_cast(rt), d.raw); } else { r.c_code = gte_stall + fmt::format( - "cpu->gte_data[{}] = psx_cyc_lwc2_read(cpu, {});", - static_cast(rt), addr); + "{{ uint32_t _pgxa = {}; uint32_t _pgxv = psx_cyc_lwc2_read(cpu, _pgxa); " + "cpu->gte_data[{}] = _pgxv; PGXP_COP2(0x{:08X}u, _pgxv, _pgxa); }}", + addr, static_cast(rt), d.raw); } r.comment = fmt::format("lwc2 gte[{}], {}({})", rt, static_cast(offset), gpr_name(rs)); @@ -1431,20 +1460,18 @@ TranslateResult StrictTranslator::translate(const PSXRecomp::DecodedInstruction& std::string value = PSXRecompGTERegisters::data_read_needs_helper(rt) ? fmt::format("gte_read_data(cpu, {})", static_cast(rt)) : fmt::format("cpu->gte_data[{}]", static_cast(rt)); - if (offset == 0) { + { + std::string addr = offset == 0 + ? fmt::format("cpu->gpr[{}]", static_cast(rs)) + : fmt::format("(uint32_t)((int32_t)cpu->gpr[{}] + ({}))", + static_cast(rs), static_cast(offset)); r.c_code = gte_stall + fmt::format( "psx_store_cycle_barrier(); g_debug_last_store_pc = 0x{:08X}u; " - "cpu->write_word(cpu->gpr[{}], {}); " - "gte_precision_store_word(cpu->gpr[{}], {});", - d.address, static_cast(rs), value, - static_cast(rs), static_cast(rt)); - } else { - r.c_code = gte_stall + fmt::format( - "psx_store_cycle_barrier(); g_debug_last_store_pc = 0x{:08X}u; " - "cpu->write_word((uint32_t)((int32_t)cpu->gpr[{}] + ({})), {}); " - "gte_precision_store_word((uint32_t)((int32_t)cpu->gpr[{}] + ({})), {});", - d.address, static_cast(rs), static_cast(offset), value, - static_cast(rs), static_cast(offset), static_cast(rt)); + "{{ uint32_t _pgxa = {}; uint32_t _pgxv = {}; " + "cpu->write_word(_pgxa, _pgxv); " + "gte_precision_store_word(_pgxa, {}); " + "PGXP_COP2(0x{:08X}u, _pgxv, _pgxa); }}", + d.address, addr, value, static_cast(rt), d.raw); } r.comment = fmt::format("swc2 gte[{}], {}({})", rt, static_cast(offset), gpr_name(rs)); diff --git a/recompiler/src/strict_translator.h b/recompiler/src/strict_translator.h index 777defba..c13ca58d 100644 --- a/recompiler/src/strict_translator.h +++ b/recompiler/src/strict_translator.h @@ -63,6 +63,13 @@ class StrictTranslator { public: // Translate a single decoded instruction. Pure function — no state. static TranslateResult translate(const PSXRecomp::DecodedInstruction& d); + +private: + // The per-opcode translation body; translate() wraps its c_code with the + // shared PGXP hook grammar (CodeGenerator::append_pgxp_hooks). + static TranslateResult translate_impl(const PSXRecomp::DecodedInstruction& d); + +public: }; } // namespace PSXRecompV4 diff --git a/recompiler/tests/recompiler_patch_test.cpp b/recompiler/tests/recompiler_patch_test.cpp index dc1df97a..6c04c885 100644 --- a/recompiler/tests/recompiler_patch_test.cpp +++ b/recompiler/tests/recompiler_patch_test.cpp @@ -142,6 +142,36 @@ buffer_ms = 60 check(audio_config.runtime.audio_buffer_ms == 60, "parser preserves per-game audio buffer target"); + const auto controller_defaults = write_config(root, "controller-defaults", R"toml( +[controller] +p1_device = "keyboard" +p2_device = "gamepad" +p2_mode = "digital" +)toml"); + const auto controller_defaults_config = + PSXRecompV4::load_game_config(controller_defaults); + check(controller_defaults_config.runtime.has_default_p1_device && + controller_defaults_config.runtime.default_p1_device == "keyboard" && + controller_defaults_config.runtime.has_default_p2_device && + controller_defaults_config.runtime.default_p2_device == "gamepad" && + controller_defaults_config.runtime.default_p2_mode == + PSXRecompV4::PAD_MODE_DIGITAL, + "parser preserves per-game controller device defaults"); + + const auto netplay_viewport = write_config(root, "netplay-viewport", R"toml( +[runtime] + +[netplay] +local_viewport = "vertical_split" +local_viewport_aspect = "adaptive" +)toml"); + const auto netplay_viewport_config = + PSXRecompV4::load_game_config(netplay_viewport); + check(netplay_viewport_config.netplay_local_viewport == "vertical_split", + "parser preserves netplay local viewport mode"); + check(netplay_viewport_config.netplay_local_viewport_aspect == "adaptive", + "parser preserves netplay local viewport aspect"); + const auto bad_audio_buffer = write_config(root, "bad-audio-buffer", R"toml( [runtime] @@ -206,6 +236,15 @@ negsub_sites = ["0x80012340"] std::vector{0x80012340u}, "parser preserves negsub cull sites"); + const auto branch_keep = write_config(root, "branch-keep", R"toml( +[widescreen.cull] +branch_keep_sites = ["0x80012340"] +)toml"); + const auto branch_keep_config = PSXRecompV4::load_game_config(branch_keep); + check(branch_keep_config.ws_cull_branch_keep_sites == + std::vector{0x80012340u}, + "parser preserves branch keep sites"); + const auto vxrange = write_config(root, "vxrange", R"toml( [widescreen.cull] vxrange_sites = ["0x80012340"] @@ -680,6 +719,16 @@ void codegen_tests() { std::string::npos, "activation bias gains the same isolated resident-object lead"); + PSXRecomp::CodeGenConfig branch_keep_config; + branch_keep_config.ws_cull_branch_keep_sites.insert(0x80010000u); + const std::string branch_keep = generate_first_instruction( + 0x14400002u, {}, false, branch_keep_config); // bne v0,zero,+2 + check(branch_keep.find( + "psx_ws_x_margin() > 0 ? 0 : (cpu->gpr[2] != cpu->gpr[0])") != + std::string::npos && + branch_keep.find("ws branch keep") != std::string::npos, + "codegen emits guarded branch keep predicate"); + PSXRecomp::CodeGenConfig plane_nx_config; plane_nx_config.ws_cull_plane_nx_sites.insert(0x80010000u); const std::string plane_nx = generate_first_instruction( @@ -814,6 +863,19 @@ void gte_codegen_classification_tests() { all_ok = false; } }; + const auto expect_zero_destination = [&](uint32_t word, + const std::string& read, + const char *kind) { + const std::string code = generate_first_instruction(word, {}, false); + if (code.find("cpu->gpr[0] =") != std::string::npos || + code.find(read) == std::string::npos || + code.find("psx_gte_read(cpu, 0)") == std::string::npos) { + fmt::print(stderr, + "FAIL full-game GTE {} reg {} writes $zero or drops read/timing\n", + kind, reg); + all_ok = false; + } + }; const uint32_t cop2 = 0x12u << 26; expect(cop2 | (0x00u << 21) | (2u << 16) | (uint32_t(reg) << 11), @@ -822,6 +884,18 @@ void gte_codegen_classification_tests() { expect(cop2 | (0x02u << 21) | (2u << 16) | (uint32_t(reg) << 11), fmt::format("gte_read_ctrl(cpu, {})", reg), PSXRecompGTERegisters::ctrl_read_needs_helper(reg), "CFC2"); + expect_zero_destination( + cop2 | (0x00u << 21) | (0u << 16) | (uint32_t(reg) << 11), + PSXRecompGTERegisters::data_read_needs_helper(reg) + ? fmt::format("gte_read_data(cpu, {})", reg) + : fmt::format("cpu->gte_data[{}]", reg), + "MFC2"); + expect_zero_destination( + cop2 | (0x02u << 21) | (0u << 16) | (uint32_t(reg) << 11), + PSXRecompGTERegisters::ctrl_read_needs_helper(reg) + ? fmt::format("gte_read_ctrl(cpu, {})", reg) + : fmt::format("cpu->gte_ctrl[{}]", reg), + "CFC2"); expect(cop2 | (0x04u << 21) | (2u << 16) | (uint32_t(reg) << 11), fmt::format("gte_write_data(cpu, {}", reg), PSXRecompGTERegisters::data_write_needs_helper(reg), "MTC2"); diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp index 3f51f9d8..d099a203 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -122,13 +122,17 @@ static void test_user_settings_read() { fs::path p = write_temp("psxrecomp_pgxp_settings.toml", "[video]\n" "geometry_correction = true\n" - "perspective_texturing = false\n"); + "perspective_texturing = false\n" + "[audio]\n" + "frequency = 48000\n"); auto us = PSXRecompV4::load_user_settings(p); check(!us.parse_error, "settings.toml parses"); check(us.has_geometry_correction && us.geometry_correction, "settings.toml geometry_correction = true read"); check(us.has_perspective_texturing && !us.perspective_texturing, "settings.toml perspective_texturing = false read (explicit off)"); + check(us.has_audio_freq && us.audio_freq == 48000, + "settings.toml audio frequency read"); fs::remove(p); } @@ -150,6 +154,7 @@ static void test_user_settings_round_trip() { PSXRecompV4::UserSettings out; out.geometry_correction = true; out.has_geometry_correction = true; out.perspective_texturing = true; out.has_perspective_texturing = true; + out.audio_freq = 48000; out.has_audio_freq = true; fs::path p = fs::temp_directory_path() / "psxrecomp_pgxp_roundtrip.toml"; check(PSXRecompV4::save_user_settings(p, out), "save_user_settings writes"); @@ -160,6 +165,8 @@ static void test_user_settings_round_trip() { "geometry_correction survives a save/load round trip"); check(back.has_perspective_texturing && back.perspective_texturing, "perspective_texturing survives a save/load round trip"); + check(back.has_audio_freq && back.audio_freq == 48000, + "audio frequency survives a save/load round trip"); fs::remove(p); } diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index b6692c46..afa71189 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -17,6 +17,11 @@ psxrecomp_add_runtime_target(psx-runtime EXE_NAME "PSXRecomp" ) +# With -DPSX_PGXP_VARIANT=ON, psxrecomp_add_runtime_target auto-clones a +# psx-runtime-pgxp sibling (PSXRecomp_pgxp.exe) compiled with -DPSX_PGXP=1 — +# the framework-dev validation surface for the PGXP value-propagation engine +# (ENHANCEMENTS.md G1.10). Titles get their _pgxp the same way. + if(BUILD_TESTING) add_executable(launcher_device_roundtrip_test tests/test_launcher_device_roundtrip.cpp) @@ -52,6 +57,17 @@ if(BUILD_TESTING) PSX_OVERLAY_DLL_BUILD=1) add_test(NAME psx_icache_fastpath_test COMMAND psx_icache_fastpath_test) + add_executable(host_keymap_test + tests/test_host_keymap.c + src/host_keymap.c) + target_include_directories(host_keymap_test PRIVATE + include ${PSX_SDL_INCLUDE_DIRS}) + target_compile_definitions(host_keymap_test PRIVATE + SDL_MAIN_HANDLED + $<$:PSX_SDL3=1>) + target_link_libraries(host_keymap_test PRIVATE ${PSX_SDL_LIBRARIES}) + add_test(NAME host_keymap_test COMMAND host_keymap_test) + # Registered since before this change, but it did NOT LINK on origin/master: # sio.c had grown psx_read_word / psx_get_cycle_count / i_mask / # g_debug_last_store_pc references that this test's stub block never gained, @@ -72,6 +88,7 @@ if(BUILD_TESTING) add_executable(gte_register_access_test tests/test_gte_register_access.cpp src/gte.cpp + src/pgxp.cpp ) target_include_directories(gte_register_access_test PRIVATE include) target_compile_definitions(gte_register_access_test PRIVATE @@ -80,6 +97,16 @@ if(BUILD_TESTING) PSX_ENABLE_BLOCK_CYCLES=1) add_test(NAME gte_register_access_test COMMAND gte_register_access_test) + # PGXP value-propagation engine (ENHANCEMENTS.md G1.2/G1.3): shadow + # load/store/COP2 roundtrips, half-word semantics, validate-on-read + # (foreign overwrite + DMA-overwrite rejection), MOVE/repack arithmetic, + # suppression bracket, invalidate-all, and GetPreciseVertex safeguards + # (truncation agreement + tolerance clamp). White-box: includes src/pgxp.cpp + # and stubs the gte.cpp fallback-cache extern. + add_executable(pgxp_test tests/test_pgxp.cpp src/pgxp.cpp) + target_include_directories(pgxp_test PRIVATE include) + add_test(NAME pgxp_test COMMAND pgxp_test) + 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) @@ -178,6 +205,14 @@ if(BUILD_TESTING) target_link_libraries(iso_reader_cdda_test PRIVATE chdr-static) add_test(NAME iso_reader_cdda_test COMMAND iso_reader_cdda_test) + add_executable(iso_reader_sbi_test + tests/test_iso_reader_sbi.cpp + src/iso_reader.cpp + src/cue_sheet.cpp) + target_include_directories(iso_reader_sbi_test PRIVATE include) + target_link_libraries(iso_reader_sbi_test PRIVATE chdr-static) + add_test(NAME iso_reader_sbi_test COMMAND iso_reader_sbi_test) + add_executable(mod_packages_test tests/test_mod_packages.cpp src/mod_packages.cpp diff --git a/runtime/codegen_hash_sources.cmake b/runtime/codegen_hash_sources.cmake index c6bbd938..96124d2b 100644 --- a/runtime/codegen_hash_sources.cmake +++ b/runtime/codegen_hash_sources.cmake @@ -28,6 +28,8 @@ set(PSXRECOMP_CODEGEN_HASH_SRCS ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/src/full_function_emitter.h ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/src/strict_translator.cpp ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/src/strict_translator.h + ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/src/pgxp_hook_emitter.cpp + ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/src/pgxp_hook_emitter.h ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/src/function_discovery.cpp ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/src/function_discovery.h ${PSXRECOMP_CODEGEN_HASH_ROOT}/recompiler/include/gte_register_classification.h @@ -44,6 +46,7 @@ set(PSXRECOMP_CODEGEN_HASH_SRCS # .inc is the shim every shard links against. ${PSXRECOMP_CODEGEN_HASH_ROOT}/runtime/include/overlay_api.h ${PSXRECOMP_CODEGEN_HASH_ROOT}/runtime/include/cpu_state.h + ${PSXRECOMP_CODEGEN_HASH_ROOT}/runtime/include/pgxp_hooks.h ${PSXRECOMP_CODEGEN_HASH_ROOT}/runtime/include/psx_cyc.h ${PSXRECOMP_CODEGEN_HASH_ROOT}/runtime/include/psx_cycles.h ${PSXRECOMP_CODEGEN_HASH_ROOT}/runtime/include/overlay_dispatch_preamble.c.inc) diff --git a/runtime/include/boot_state.h b/runtime/include/boot_state.h index 4da5e4b0..922f981d 100644 --- a/runtime/include/boot_state.h +++ b/runtime/include/boot_state.h @@ -38,10 +38,11 @@ extern "C" { #define BOOT_STATE_MAGIC 0x50535842u /* "PSXB" */ /* v1 = incomplete RAM-only; v2 = full machine but host-struct memcpy (padding); * v3 = little-endian field wire (portable Win/Linux/macOS ARM); - * v4 = v3 + optional zlib on large sections (section pad bit0 = compressed). */ -#define BOOT_STATE_VERSION 4u -/* Older readers reject v4; load still accepts v3 uncompressed blobs. */ -#define BOOT_STATE_VERSION_MIN_READ 3u + * v4 = v3 + optional zlib on large sections (section pad bit0 = compressed); + * v5 = v4 + CD-ROM Sub-Q replacement state. */ +#define BOOT_STATE_VERSION 5u +/* v5 intentionally breaks older savestates after the CD-ROM wire grew. */ +#define BOOT_STATE_VERSION_MIN_READ 5u /* Section pad bit0: payload is u32 LE uncompressed_len + zlib deflate bytes. */ #define BOOT_STATE_SEC_ZLIB 1u diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index 6de04789..5d1713bc 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -205,6 +205,7 @@ extern void gte_precision_timeline_invalidate(void); extern void gte_precision_speculative_begin(void); extern void gte_precision_speculative_end(void); extern void gte_precision_store_word(uint32_t addr, uint8_t reg); +extern void gte_precision_tracking_set(int enabled); /* Sub-pixel vertex precision ([video] geometry_correction). Enables the side * cache that retains the 16.16 projection fraction the GTE discards when it * saturates SXY to integer screen pixels. Guest-visible GTE state is @@ -220,6 +221,12 @@ extern void gte_geometry_correction_stats(uint32_t *lookups, uint32_t *hits, uint32_t *miss_unrecorded, uint32_t *miss_ambiguous); +/* PGXP dataflow-shadowing hook macros (PGXP_LOAD/STORE/ALU/MULDIV/COP2). + * The emitter writes them unconditionally; they expand to real calls only + * under -DPSX_PGXP=1 (the pgxp build variant) and to ((void)0) otherwise. + * Pulled in here so every generated translation unit sees them. */ +#include "pgxp_hooks.h" + /* ============================================================================ * Dispatch call contract (Bug D / wild-return family fix) * ---------------------------------------------------------------------------- diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index c7f4ed56..f5d8c449 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -75,6 +75,11 @@ typedef struct { int32_t offset_x, offset_y; } GpuDrawArea; void gpu_get_draw_area(GpuDrawArea* out); +/* Non-zero when the frame that just reached vblank used two side-by-side draw + * areas. Presentation-only helpers use this to expand a local split-screen + * viewport without changing the underlying framebuffer or netplay hashes. */ +int gpu_last_frame_vertical_split_screen(void); +void gpu_vertical_split_debug(int *active, int *left_age, int *right_age); uint16_t gpu_vram_peek(int x, int y); /* Shaded quad vertex capture (Phase 4.5 debug). */ @@ -207,6 +212,10 @@ void psx_ws_sprite_tag(struct CPUState* cpu); * by this; 0 when native-wide is inactive). */ int ws_native_wide_active(void); int ws_nw_extra(void); +int ws_nw_present_width(void); +void gpu_ws_set_netplay_local_viewport(int enabled, int slot); +int gpu_ws_netplay_local_viewport_base_x(void); +int gpu_ws_netplay_local_viewport_width(void); /* True when the current frame must present at native 4:3 (FMV video or a * full-2D menu/title screen), so the squash is suppressed and content drawn * pixel-native. The present path uses the same predicate to pillarbox. */ @@ -310,12 +319,14 @@ int psx_ws_auto_cull_on(void); * tracking it needs, so a polygon only qualifies when every position word in * its DMA packet was written by a GTE projection store. Default off. */ void gpu_texture_correction_set(int enabled); +int gpu_texture_correction_enabled(void); /* Triangles drawn with perspective-correct UVs since startup. */ uint32_t gpu_texture_correction_hits(void); /* GTE-activity gameplay detector ([widescreen] gte_game_mode) for 3D titles * with no sprite-tag helper: gte.cpp notes every RTPS/RTPT projection; a frame * that projects enough vertices is stamped as gameplay. */ void gpu_ws_set_gte_game_mode(int on); +void gpu_ws_set_precise_nclip(int on); void psx_ws_note_gte_project(int nverts); /* Optional authoritative gameplay-state gate. When configured, it replaces * heuristic gameplay classification for native-wide presentation. */ diff --git a/runtime/include/host_keymap.h b/runtime/include/host_keymap.h index 24d78314..7ad5476e 100644 --- a/runtime/include/host_keymap.h +++ b/runtime/include/host_keymap.h @@ -10,13 +10,15 @@ extern "C" { /* * Host hotkeys from recomp-ui config.ini [KeyMap] (SDL key names + Ctrl+/Alt+/ - * Shift+ prefixes). VolumeUp / VolumeDown are honored by the runtime; missing - * lines keep the historic keypad +/- defaults. + * Shift+ prefixes). Missing lines keep the historic runtime defaults. */ typedef enum HostKeymapAction { - HOST_KEYMAP_VOLUME_UP = 0, + HOST_KEYMAP_FULLSCREEN = 0, + HOST_KEYMAP_TURBO, + HOST_KEYMAP_VOLUME_UP, HOST_KEYMAP_VOLUME_DOWN, + HOST_KEYMAP_DISPLAY_PERF, HOST_KEYMAP_REWIND, /* default F8 */ HOST_KEYMAP_SAVE_STATE_MENU, /* default F7 */ HOST_KEYMAP_ACTION_COUNT @@ -28,6 +30,9 @@ void host_keymap_load(const char *config_ini_path); /* 1 if (keycode, mod) matches a binding for `action`. */ int host_keymap_match(HostKeymapAction action, int keycode, int mod); +/* 1 if any configured bind for `action` is currently held. */ +int host_keymap_down(HostKeymapAction action, const uint8_t *keys, int mod); + /* Format the primary bind for `action` into out (e.g. "F8", "Ctrl+R", * "GRAVE"). Punctuation key names are tokenized for the rewind overlay font. * Returns out, or "F8" / "" on empty action. Always NUL-terminates. */ diff --git a/runtime/include/host_osd.h b/runtime/include/host_osd.h index fefab1dd..e4e6f307 100644 --- a/runtime/include/host_osd.h +++ b/runtime/include/host_osd.h @@ -19,6 +19,9 @@ extern "C" { /* Top-left text toast. duration_ms <= 0 uses 2000. */ 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); + /* Right-side vertical volume bar (percent clamped 0..100). */ void host_osd_show_volume(int percent, int duration_ms); diff --git a/runtime/include/iso_reader.h b/runtime/include/iso_reader.h index ada6db0c..7111c0fb 100644 --- a/runtime/include/iso_reader.h +++ b/runtime/include/iso_reader.h @@ -1,9 +1,11 @@ #pragma once -#include -#include +#include +#include +#include #include #include +#include #include /** @@ -108,7 +110,15 @@ public: * @param buffer Buffer to read into (must be at least 2352 bytes) * @return true if the image stores raw sectors and read succeeded */ - bool ReadRawSector(uint32_t lba, uint8_t* buffer); + bool ReadRawSector(uint32_t lba, uint8_t* buffer); + + /** + * Read generated or SBI-replacement subchannel Q for a disc LBA. + * SBI records intentionally carry invalid CRC data; valid is false for + * those sectors so the CD controller can retain its last valid Q packet. + */ + bool ReadSubChannelQ(uint32_t lba, uint8_t* buffer, bool* valid) const; + bool HasSubChannelReplacements() const { return !subq_replacements_.empty(); } /** * Check if a file is currently open @@ -213,7 +223,9 @@ private: * Helper: find the BinSegment containing a disc-relative LBA. * @return segment pointer, or nullptr when lba is past the disc end */ - BinSegment* SegmentForLBA(uint32_t lba); + BinSegment* SegmentForLBA(uint32_t lba); + + bool LoadSBICompanion(const std::string& image_path); bool is_open_; std::string volume_id_; @@ -222,6 +234,7 @@ private: std::vector tracks_; // from the .cue TOC; >=1 entry after Open() std::vector segments_; // cue FILE entries in disc order; >=1 after Open() std::unique_ptr chd_; // present only for a directly mounted CHD + std::unordered_map> subq_replacements_; }; } // namespace PS1 diff --git a/runtime/include/overlay_api.h b/runtime/include/overlay_api.h index a0c3a7e8..2ceda151 100644 --- a/runtime/include/overlay_api.h +++ b/runtime/include/overlay_api.h @@ -8,6 +8,7 @@ */ #include "cpu_state.h" +#include "pgxp_hooks.h" #include /* ABI version exported by every overlay DLL as `overlay_abi()`. The loader @@ -85,6 +86,13 @@ * model participation with queue-safe guard/hysteresis policy. */ /* v21: ws_angle_widen forwarder for exact, aspect-scaled 12-bit terrain * frustum half-angle constants. */ +/* (PGXP, no version bump): OverlayCallbacks grew an appended-last PGXPHooks + * table pointer (pgxp_hooks.h). Only pgxp-flavour shards (compiled with + * -DPSX_PGXP=1, PSX_OVERLAY_FLAVOR_PGXP set) reference the psx_pgxp_* + * forwarders, and the flavor half of the ABI tag already rejects any + * host/DLL flavor mix — base-flavour DLLs and hosts are untouched, so + * the version stays. The emit-content change (PGXP_*() macros in all + * generated C) is covered by the codegen hash + CODEGEN_VER below. */ #define PSX_OVERLAY_ABI_VERSION 21 /* Process-lifetime overlay candidate capacity. Every accepted manifest F @@ -112,6 +120,12 @@ #define PSX_OVERLAY_FLAVOR 0 #endif +/* Flavor bits (compose bitwise; base = 0): + * 1 — widescreen variant codegen + * 2 — PGXP precision shadowing (-DPSX_PGXP=1 objects; pgxp_hooks.h) */ +#define PSX_OVERLAY_FLAVOR_WIDESCREEN 1 +#define PSX_OVERLAY_FLAVOR_PGXP 2 + /* Combined tag exported by overlay_abi() and checked by the loader. */ #define PSX_OVERLAY_ABI_TAG \ ((int)((PSX_OVERLAY_ABI_VERSION & 0xFFFF) | ((PSX_OVERLAY_FLAVOR & 0xFFFF) << 16))) @@ -138,8 +152,11 @@ * masked-16-bit screen-X, and depth cull emits. * 7: + configured side-plane normal-X (plane_nx) cull emit. * 8: + configured per-primitive bound-load (xclip) cull emit. - * 9: + full-word-guarded object/model participation compare emit. */ -#define PSX_OVERLAY_CODEGEN_VER 9 + * 9: + full-word-guarded object/model participation compare emit. + * 10: + PGXP dataflow-shadowing PGXP_*() macro emission at loads/stores/ + * COP2 transfers/precision-carrying ALU ops (pgxp_hooks.h; no-ops in + * base-flavour objects, real hooks under -DPSX_PGXP=1). */ +#define PSX_OVERLAY_CODEGEN_VER 10 /* Auto codegen hash (generated by hash_codegen.cmake from the recompiler codegen * sources). Folded into the cache PATH next to cg (gcc//cg_/) @@ -291,6 +308,15 @@ typedef struct { /* Aspect-scaled terrain-frustum angle helper (ABI v21). */ uint32_t (*ws_angle_widen)(uint32_t vanilla); + + /* PGXP dataflow-shadowing hook table (pgxp_hooks.h). Only pgxp-flavour + * shards (PSX_OVERLAY_FLAVOR pgxp bit, compiled with -DPSX_PGXP=1) emit + * calls to psx_pgxp_*; their preamble forwards through this table. Base- + * flavour shards never reference it. Appended LAST; NULL on an older host + * no-ops every hook — precision shadowing is visual-only, never + * load-bearing. The ABI version bump that arms this ships with the + * emitter change (Phase 2 of ENHANCEMENTS.md G1 value propagation). */ + const PGXPHooks *pgxp; } OverlayCallbacks; #ifdef __cplusplus diff --git a/runtime/include/overlay_dispatch_preamble.c.inc b/runtime/include/overlay_dispatch_preamble.c.inc index 6cd4b10e..c2acb8e7 100644 --- a/runtime/include/overlay_dispatch_preamble.c.inc +++ b/runtime/include/overlay_dispatch_preamble.c.inc @@ -265,4 +265,27 @@ uint32_t psx_ws_angle_widen(uint32_t vanilla) { void gte_precision_store_word(uint32_t addr, uint8_t reg) { if (g_cbs.gte_precision_store_word) g_cbs.gte_precision_store_word(addr, reg); } +#if defined(PSX_PGXP) && PSX_PGXP +/* PGXP dataflow-shadowing hooks (pgxp_hooks.h; ENHANCEMENTS.md G1.10). Only a + * pgxp-flavour shard (compiled -DPSX_PGXP=1, flavor bit set) references these + * — base-flavour shards preprocess the PGXP_*() macros away, so this block is + * compiled out of them entirely. Forwards to the host engine through the + * appended-last table; every hook no-ops on a host that predates it + * (precision shadowing is visual-only, never load-bearing). */ +void psx_pgxp_load(struct CPUState *cpu, uint32_t instr, uint32_t addr, uint32_t value) { + if (g_cbs.pgxp && g_cbs.pgxp->load) g_cbs.pgxp->load(cpu, instr, addr, value); +} +void psx_pgxp_store(struct CPUState *cpu, uint32_t instr, uint32_t addr, uint32_t value) { + if (g_cbs.pgxp && g_cbs.pgxp->store) g_cbs.pgxp->store(cpu, instr, addr, value); +} +void psx_pgxp_alu(struct CPUState *cpu, uint32_t instr, uint32_t result, uint32_t s1, uint32_t s2) { + if (g_cbs.pgxp && g_cbs.pgxp->alu) g_cbs.pgxp->alu(cpu, instr, result, s1, s2); +} +void psx_pgxp_muldiv(struct CPUState *cpu, uint32_t instr, uint32_t hi, uint32_t lo, uint32_t s1, uint32_t s2) { + if (g_cbs.pgxp && g_cbs.pgxp->muldiv) g_cbs.pgxp->muldiv(cpu, instr, hi, lo, s1, s2); +} +void psx_pgxp_cop2(struct CPUState *cpu, uint32_t instr, uint32_t value, uint32_t addr) { + if (g_cbs.pgxp && g_cbs.pgxp->cop2) g_cbs.pgxp->cop2(cpu, instr, value, addr); +} +#endif /* PSX_PGXP */ /* ----------------------------------------------------------------------- */ diff --git a/runtime/include/pgxp.h b/runtime/include/pgxp.h new file mode 100644 index 00000000..db026ad6 --- /dev/null +++ b/runtime/include/pgxp.h @@ -0,0 +1,132 @@ +#ifndef PGXP_H +#define PGXP_H + +/* PGXP value-propagation engine — runtime-internal API (pgxp.cpp). + * + * The engine shadows every 32-bit word of guest RAM/scratchpad, every GPR, + * and every GTE data register with the sub-pixel projection it carries + * (screen X/Y in 16.16 plus the projected SZ depth). The GTE fills shadows at + * RTPS/RTPT; the psx_pgxp_* hooks (pgxp_hooks.h) move them along with the + * data; the GPU asks for the precise position of each GP0 vertex word by the + * packet's RAM address, validated against the actual word — never guessed + * from the rounded position (the measured G1.4 dead end). + * + * Guest-visible state is NEVER touched: shadows are host-only, dropped on + * savestate/rewind, and suppressed during speculative validation passes. + */ + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* --- lifecycle / configuration ------------------------------------------ */ + +/* Master arm. Allocates the RAM shadow lazily; fails closed (stays disabled) + * if the allocation fails. Idempotent. */ +void pgxp_set_enabled(int enabled); +int pgxp_enabled(void); + +/* Tier-2 propagation through CPU arithmetic (default off, like the reference + * implementations' default). Off is SAFE — value validation already stops + * stale shadows — it only bounds coverage. Live-tunable (TCP). */ +void pgxp_set_cpu_mode(int enabled); +int pgxp_cpu_mode(void); + +/* Reject a precise position whose sub-pixel offset from the native integer + * position exceeds this many pixels. < 0 disables the clamp (default). */ +void pgxp_set_tolerance(float pixels); +float pgxp_tolerance(void); + +/* Drop all shadows (savestate load, raw RAM restore, timeline breaks). + * O(1) via generation bump. Deferred while suppressed. */ +void pgxp_invalidate_all(void); + +/* Counted suppression bracket for speculative native-validation passes and + * the GTE replay sandbox: hooks and producers no-op inside it. */ +void pgxp_suppress_begin(void); +void pgxp_suppress_end(void); + +/* --- GTE producer (gte.cpp) ---------------------------------------------- */ + +/* Called at the RTPS/RTPT projection with the pre-truncation 16.16 screen + * coordinates, the projected depth (SZ3), and the packed SXY word the guest + * sees. Shifts the shadow FIFO exactly like push_sxy (regs 12..15). */ +void pgxp_gte_push_sxy(int32_t x16, int32_t y16, uint16_t sz3, uint32_t packed); + +/* Current SXY FIFO shadow (index 0..3 selects GTE data regs 12..15). + * Returns nonzero when the shadow is live and carries X/Y precision. */ +int pgxp_get_gte_sxy(uint32_t index, int32_t *x16, int32_t *y16); + +/* Guest write to a GTE data register outside gte_execute (MTC2/CTC2 handled + * by psx_pgxp_cop2; this is for direct gte_write_data paths): reg 15 performs + * the SXYP FIFO push on the shadows, others just invalidate/overwrite. */ +void pgxp_gte_reg_written(int reg, uint32_t value); + +/* --- GPU consumer (gpu.c) ------------------------------------------------ */ + +enum { + PGXP_SRC_NATIVE = 0, /* no shadow — caller uses the parsed integers */ + PGXP_SRC_FALLBACK = 1, /* position-cache fallback (never carries depth)*/ + PGXP_SRC_DATAFLOW = 2, /* address-keyed shadow, value-validated */ +}; + +/* Precise position for one GP0 vertex word. + * addr guest address the word was DMA'd from, or 0xFFFFFFFF if + * unknown (immediate GP0 writes) — skips the dataflow tier. + * packet_word the actual word (validation key). + * int_x/int_y the natively parsed 11-bit positions (pre-draw-offset). + * On DATAFLOW/FALLBACK, *x16/*y16 hold the sub-pixel position (16.16, same + * coordinate space as the packet halves); *sz is the projected depth or 0. + * Safeguards applied here: the integer part must match the native parse + * (truncation agreement) and the tolerance clamp. */ +int pgxp_get_precise_vertex(uint32_t addr, uint32_t packet_word, + int32_t int_x, int32_t int_y, + int32_t *x16, int32_t *y16, uint16_t *sz); + +/* --- observability -------------------------------------------------------- */ + +typedef struct PGXPStats { + uint64_t lookups; /* pgxp_get_precise_vertex calls */ + uint64_t dataflow_hit; + uint64_t fallback_hit; + uint64_t native; + uint64_t value_mismatch; /* shadow present but wrong word (stale) */ + uint64_t trunc_reject; /* integer part disagreed with native parse */ + uint64_t tolerance_reject; + uint64_t w_valid; /* lookups that also carried a usable depth */ + uint64_t produced; /* RTPS/RTPT projections pushed into shadows */ + uint64_t swc2_stores; /* GTE reg shadows copied to RAM shadows */ +} PGXPStats; + +void pgxp_get_stats(PGXPStats *out); + +/* --- gte.cpp forwarding surface (v14 ABI compat) -------------------------- */ + +/* 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); + +/* Address-keyed precise-word lookup (perspective texturing path). Returns + * nonzero when the tracked word matches `packed` AND carries a depth. */ +int pgxp_load_precise_word(uint32_t addr, uint32_t packed, + int32_t *x16, int32_t *y16, uint16_t *z); + +/* --- test accessors (always compiled; trivial) ---------------------------- */ + +/* index 0..3 selects the SXY0..SXYP register shadow (GTE data regs 12..15). */ +void pgxp_test_seed_gte_sxy(uint32_t index, uint32_t packed, + int32_t x16, int32_t y16, uint16_t z, int valid); +void pgxp_test_get_gte_sxy(uint32_t index, uint32_t *packed, + int32_t *x16, int32_t *y16, uint16_t *z, + uint8_t *valid); +uint32_t pgxp_test_generation(void); +uint32_t pgxp_test_suppress_depth(void); +int pgxp_test_active(void); +void pgxp_test_set_generation(uint32_t gen); + +#ifdef __cplusplus +} +#endif + +#endif /* PGXP_H */ diff --git a/runtime/include/pgxp_hooks.h b/runtime/include/pgxp_hooks.h new file mode 100644 index 00000000..257525f1 --- /dev/null +++ b/runtime/include/pgxp_hooks.h @@ -0,0 +1,93 @@ +#ifndef PGXP_HOOKS_H +#define PGXP_HOOKS_H + +/* PGXP dataflow-shadowing hook surface (ENHANCEMENTS.md G1.2/G1.3). + * + * The runtime keeps a host-only "shadow" of sub-pixel GTE projection results + * and follows them BY PROVENANCE as the game moves them through registers and + * RAM into a GP0 packet (pgxp.cpp). Generated code participates by calling + * these hooks after the guest operation completes. + * + * Gating model (the G1.3 decision): the emitter writes PGXP_*() macro + * invocations unconditionally; this header expands them to real calls only + * when the translation unit is compiled with -DPSX_PGXP=1 (the pgxp build + * variant). In the base variant the macros expand to ((void)0), so base + * objects compile to exactly the pre-feature code — the feature costs nothing + * unless a title links the pgxp target. + * + * RULES for emit sites: + * - Macro arguments MUST be side-effect free (they vanish in base builds). + * - Hooks run AFTER the guest architectural effect (value = what actually + * loaded/stored/resulted), so the shadow can validate against reality. + * - `cpu` must be in scope at every emit site (it always is in generated C). + * + * The interpreters (dirty_ram_interp.c, psx_interpreter.c) call the + * psx_pgxp_* functions directly — they are runtime code compiled once, and + * every hook early-outs on a single global when the feature is off. + * + * Overlay DLLs cannot link runtime symbols directly; they forward through the + * PGXPHooks table below (OverlayCallbacks.pgxp, overlay_api.h). A NULL table + * no-ops every hook — precision shadowing is a visual enhancement, never + * load-bearing. + * + * LICENSING: this is a clean-room implementation of the publicly documented + * PGXP technique (psx-spx, public design write-ups). Do NOT port code from + * the vendored duckstation/ (CC BY-NC-ND) or beetle-psx/ (GPL) trees; they + * are black-box behavioral oracles only. See ENHANCEMENTS.md G1. + */ + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct CPUState; + +/* Funnel entry points (implemented in runtime/src/pgxp.cpp). The raw + * instruction word carries the op/rs/rt/rd/imm decode so the emitted call is + * uniform per class; `value` is the architectural result of the operation. + * + * load: rt <- [addr]; value = loaded word/half/byte (post-extension) + * store: [addr] <- rt; value = the stored word/half/byte + * alu: rd/rt <- op(s1, s2); result = the written value (also LUI, HILO + * moves and MOVE idioms — the body decodes the class from instr) + * muldiv: HI/LO <- op(s1, s2) + * cop2: MFC2/CFC2/MTC2/CTC2 (addr = 0) and LWC2/SWC2 (addr = guest addr); + * value = the transferred word + */ +void psx_pgxp_load (struct CPUState *cpu, uint32_t instr, uint32_t addr, uint32_t value); +void psx_pgxp_store (struct CPUState *cpu, uint32_t instr, uint32_t addr, uint32_t value); +void psx_pgxp_alu (struct CPUState *cpu, uint32_t instr, uint32_t result, uint32_t s1, uint32_t s2); +void psx_pgxp_muldiv(struct CPUState *cpu, uint32_t instr, uint32_t hi, uint32_t lo, uint32_t s1, uint32_t s2); +void psx_pgxp_cop2 (struct CPUState *cpu, uint32_t instr, uint32_t value, uint32_t addr); + +/* Forwarder table for overlay DLLs (OverlayCallbacks.pgxp). Appended-last + * member semantics apply: a NULL pointer (older host) means "no shadowing". */ +typedef struct PGXPHooks { + void (*load) (struct CPUState *cpu, uint32_t instr, uint32_t addr, uint32_t value); + void (*store) (struct CPUState *cpu, uint32_t instr, uint32_t addr, uint32_t value); + void (*alu) (struct CPUState *cpu, uint32_t instr, uint32_t result, uint32_t s1, uint32_t s2); + void (*muldiv)(struct CPUState *cpu, uint32_t instr, uint32_t hi, uint32_t lo, uint32_t s1, uint32_t s2); + void (*cop2) (struct CPUState *cpu, uint32_t instr, uint32_t value, uint32_t addr); +} PGXPHooks; + +#if defined(PSX_PGXP) && PSX_PGXP +#define PGXP_LOAD(instr, addr, val) psx_pgxp_load(cpu, (instr), (addr), (val)) +#define PGXP_STORE(instr, addr, val) psx_pgxp_store(cpu, (instr), (addr), (val)) +#define PGXP_ALU(instr, res, s1, s2) psx_pgxp_alu(cpu, (instr), (res), (s1), (s2)) +#define PGXP_MULDIV(instr, hi, lo, s1, s2) psx_pgxp_muldiv(cpu, (instr), (hi), (lo), (s1), (s2)) +#define PGXP_COP2(instr, val, addr) psx_pgxp_cop2(cpu, (instr), (val), (addr)) +#else +#define PGXP_LOAD(instr, addr, val) ((void)0) +#define PGXP_STORE(instr, addr, val) ((void)0) +#define PGXP_ALU(instr, res, s1, s2) ((void)0) +#define PGXP_MULDIV(instr, hi, lo, s1, s2) ((void)0) +#define PGXP_COP2(instr, val, addr) ((void)0) +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* PGXP_HOOKS_H */ diff --git a/runtime/include/psx_rewind.h b/runtime/include/psx_rewind.h index 72069b2c..ad65befc 100644 --- a/runtime/include/psx_rewind.h +++ b/runtime/include/psx_rewind.h @@ -8,7 +8,7 @@ * g_snaps. Disabled while psx_netplay_active(). * * Keyboard: F8 (config.ini [KeyMap] Rewind). Controller: View/Back or L3. - * While open: D-pad / left-stick Left/Right, Cross/A load, Circle/B/Back close. + * While open: D-pad / left-stick Left/Right, Cross load, Circle/Back close. * * Env: PSX_REWIND=0 disables; PSX_REWIND_INTERVAL (1/4/8/12/15, default 15); * PSX_REWIND_FMV_INTERVAL (frames while depth24/MDEC/XA, default 4); diff --git a/runtime/include/psx_sdl.h b/runtime/include/psx_sdl.h index ef1ceb70..1749b46f 100644 --- a/runtime/include/psx_sdl.h +++ b/runtime/include/psx_sdl.h @@ -11,7 +11,6 @@ #define SDL_ENABLE_OLD_NAMES #define SDL_FUNCTION_POINTER_IS_VOID_POINTER #include -#include #ifndef SDL_WINDOW_SHOWN #define SDL_WINDOW_SHOWN 0 diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 5d13b6a9..5946a3a9 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -299,6 +299,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/crash_trace.c ${PSXRECOMP_ROOT}/runtime/src/freeze_heartbeat.c ${PSXRECOMP_ROOT}/runtime/src/gte.cpp + ${PSXRECOMP_ROOT}/runtime/src/pgxp.cpp ${PSXRECOMP_ROOT}/runtime/src/nd_intro_ot.c ${PSXRECOMP_ROOT}/runtime/src/crc32.c ${PSXRECOMP_ROOT}/runtime/src/psx_sha256.c @@ -331,6 +332,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/event_ring.c ${PSXRECOMP_ROOT}/runtime/src/game_options.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_speed.c + ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_pgxp.c ${PSXRECOMP_ROOT}/runtime/src/mod_packages.cpp ${PSXRECOMP_ROOT}/runtime/src/mod_runtime.cpp ${PSXRECOMP_ROOT}/runtime/src/psx_keybinds.c @@ -441,18 +443,34 @@ endif() # Local rewind: full rbengine when netplay is on; otherwise compile snap_ring.c # only (no recomp-net / sched / hash_confirm). Never both — duplicate symbols. +# Defaults ON because local rewind is useful for every PSX title. If a title +# deliberately opts out with -DPSX_REWIND=OFF, recomp-ui hides the Rewind +# controls instead of exposing dead hotkeys. +option(PSX_REWIND "Build and expose local rewind support" ON) set(PSXRECOMP_HAS_RBENGINE_SNAP FALSE) set(PSXRECOMP_RBENGINE_SNAP_INCLUDE "") -if(PSXRECOMP_HAS_RBENGINE) - set(PSXRECOMP_HAS_RBENGINE_SNAP TRUE) -elseif(RECOMP_RBENGINE_ROOT - AND EXISTS "${RECOMP_RBENGINE_ROOT}/src/snap/rbe_snap_ring.c" - AND EXISTS "${RECOMP_RBENGINE_ROOT}/include/retcomm_rbengine/snap_ring.h") - list(APPEND PSXRECOMP_RUNTIME_SOURCES - ${RECOMP_RBENGINE_ROOT}/src/snap/rbe_snap_ring.c) - set(PSXRECOMP_HAS_RBENGINE_SNAP TRUE) - set(PSXRECOMP_RBENGINE_SNAP_INCLUDE "${RECOMP_RBENGINE_ROOT}/include") - message(STATUS "psxrecomp: rewind snap_ring (${RECOMP_RBENGINE_ROOT})") +if(PSX_REWIND) + if(PSXRECOMP_HAS_RBENGINE) + set(PSXRECOMP_HAS_RBENGINE_SNAP TRUE) + elseif(RECOMP_RBENGINE_ROOT + AND EXISTS "${RECOMP_RBENGINE_ROOT}/src/snap/rbe_snap_ring.c" + AND EXISTS "${RECOMP_RBENGINE_ROOT}/include/retcomm_rbengine/snap_ring.h") + list(APPEND PSXRECOMP_RUNTIME_SOURCES + ${RECOMP_RBENGINE_ROOT}/src/snap/rbe_snap_ring.c) + set(PSXRECOMP_HAS_RBENGINE_SNAP TRUE) + set(PSXRECOMP_RBENGINE_SNAP_INCLUDE "${RECOMP_RBENGINE_ROOT}/include") + message(STATUS "psxrecomp: rewind snap_ring (${RECOMP_RBENGINE_ROOT})") + endif() + if(NOT PSXRECOMP_HAS_RBENGINE_SNAP) + message(FATAL_ERROR + "psxrecomp: PSX_REWIND=ON exposes the Rewind launcher controls " + "but no retcomm-rbengine snap-ring backend was found.\n" + " git submodule update --init lib/retcomm-rbengine\n" + " or -DRECOMP_RBENGINE_ROOT=/path/to/retcomm-rbengine\n" + " or configure with -DPSX_REWIND=OFF to hide Rewind.") + endif() +else() + message(STATUS "psxrecomp: local rewind disabled (PSX_REWIND=OFF)") endif() # Lobby WebSocket client helpers are vendored under runtime/src/lobby_ws/ @@ -744,7 +762,13 @@ function(psxrecomp_ensure_zlib) endfunction() function(psxrecomp_add_runtime_target target) - set(options ORACLE COSIM) + # PGXP: build this target's objects with -DPSX_PGXP=1 so the PGXP_*() + # hook macros the emitter writes into ALL generated C become real calls + # into the value-propagation engine (pgxp_hooks.h; ENHANCEMENTS.md G1.10), + # and stamp the pgxp overlay flavor so the shard cache and the ABI gate + # keep pgxp and base DLLs fully separate. The base target is untouched — + # the macros preprocess away without the define. + set(options ORACLE COSIM PGXP) set(oneValueArgs GAME_GENERATED_DISPATCH_C GAME_OVERLAY_STATIC_C @@ -900,6 +924,15 @@ function(psxrecomp_add_runtime_target target) if(PSXRT_ORACLE) set(_psxrt_exe_name "${_psxrt_exe_name}_oracle") endif() + if(PSXRT_PGXP) + # Distinct binary beside the base one; the launcher (or the player) + # picks the variant. Same debug port as the base build — run one at a + # time (the A/B protocol is one-toggle-per-run anyway). + set(_psxrt_exe_name "${_psxrt_exe_name}_pgxp") + target_compile_definitions(${target} PRIVATE + PSX_PGXP=1 + PSX_OVERLAY_FLAVOR=2) # PSX_OVERLAY_FLAVOR_PGXP (overlay_api.h) + endif() set_target_properties(${target} PROPERTIES OUTPUT_NAME "${_psxrt_exe_name}") # ---- Windows / desktop app icon --------------------------------------- @@ -1153,16 +1186,27 @@ function(psxrecomp_add_runtime_target target) # second GENERATE to the same path is rejected ("Files to be generated by # multiple different commands"). Multi-config (VS): exe lives in Release/, # so also drop a copy at the build root for packager lookup. - file(GENERATE - OUTPUT "$/psx_game_version.txt" - CONTENT "${PSXRT_GAME_VERSION}\n" - ) - get_property(_psxrt_ver_multi GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) - if(_psxrt_ver_multi) + # Once per build tree, not per target: with several runtime targets + # (psx-runtime + psx-oracle + psx-beetle) each TARGET_FILE_DIR genex is a + # DIFFERENT command producing the SAME path on single-config generators, + # which CMake rejects even when the content is identical (the ae9e6a4e + # hotfix fixed the same-target duplicate but not the multi-target one). + # All runtime targets in one tree share one PSXRT_GAME_VERSION, so a + # single stamp is the correct semantics anyway. + get_property(_psxrt_ver_stamped GLOBAL PROPERTY PSXRT_GAME_VERSION_STAMPED) + if(NOT _psxrt_ver_stamped) + set_property(GLOBAL PROPERTY PSXRT_GAME_VERSION_STAMPED 1) file(GENERATE - OUTPUT "${CMAKE_BINARY_DIR}/psx_game_version.txt" + OUTPUT "$/psx_game_version.txt" CONTENT "${PSXRT_GAME_VERSION}\n" ) + get_property(_psxrt_ver_multi GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) + if(_psxrt_ver_multi) + file(GENERATE + OUTPUT "${CMAKE_BINARY_DIR}/psx_game_version.txt" + CONTENT "${PSXRT_GAME_VERSION}\n" + ) + endif() endif() # OpenBIOS is part of the native runtime product, not a developer-machine @@ -1358,6 +1402,8 @@ function(psxrecomp_add_runtime_target target) list(APPEND _psx_recomp_ui_args BRAND "${PSXRT_LAUNCHER_BRAND}") endif() recomp_target_launcher_ui(${target} ${_psx_recomp_ui_args}) + target_compile_definitions(${target} PRIVATE + RECOMP_UI_PSX_HAS_REWIND=$) endif() if(WIN32 OR MINGW) @@ -1540,6 +1586,22 @@ function(psxrecomp_add_runtime_target target) COMMENT "Staging trusted xdelta3 decoder for derived-disc mods" VERBATIM) endif() + + # PGXP variant auto-clone (ENHANCEMENTS.md G1.10): with + # -DPSX_PGXP_VARIANT=ON, every primary runtime target grows an + # _pgxp sibling — the SAME arguments (same generated C, extras, + # ports) compiled with -DPSX_PGXP=1 (see the PGXP option above). Done + # HERE, at the end, by replaying ARGN recursively, so it works for + # every caller — titles that call this function directly (Ape) and + # the psxrecomp_add_game_runtime wrapper alike. Oracle/cosim targets + # and the clone itself are excluded. + if(NOT PSXRT_PGXP AND NOT PSXRT_ORACLE AND NOT PSXRT_COSIM) + option(PSX_PGXP_VARIANT + "Also build the _pgxp PGXP precision-shadowing variant" OFF) + if(PSX_PGXP_VARIANT) + psxrecomp_add_runtime_target(${target}-pgxp PGXP ${ARGN}) + endif() + endif() endfunction() # Compatibility for early v4 game projects that used the longer helper name. @@ -1760,20 +1822,30 @@ function(psxrecomp_add_game_runtime target) GAME_GENERATED_DISPATCH_C "${_psxg_marker}" ${_psxg_rt_args} ) + # psxrecomp_add_runtime_target auto-clones a ${target}-pgxp sibling + # when PSX_PGXP_VARIANT is ON; fold it into the tail configuration + # below so it gets the same game-codegen defines and includes. + set(_psxg_targets ${target}) + if(TARGET ${target}-pgxp) + list(APPEND _psxg_targets ${target}-pgxp) + endif() else() message(STATUS "psxrecomp: setup host (no game C, no BIOS backends) — " "first-run Generate & rebuild") psxrecomp_add_runtime_target(${target} ${_psxg_rt_args}) + set(_psxg_targets ${target}) endif() - target_compile_definitions(${target} PRIVATE PSX_HAS_GAME_CODEGEN=1) + foreach(_psxg_t IN LISTS _psxg_targets) + target_compile_definitions(${_psxg_t} PRIVATE PSX_HAS_GAME_CODEGEN=1) - if(PSX_NET_LOBBY_DEFAULT_URL) - # Stringify for C: PSX_NET_LOBBY_DEFAULT_URL="ws://..." - target_compile_definitions(${target} PRIVATE - "PSX_NET_LOBBY_DEFAULT_URL=\"${PSX_NET_LOBBY_DEFAULT_URL}\"") - endif() + if(PSX_NET_LOBBY_DEFAULT_URL) + # Stringify for C: PSX_NET_LOBBY_DEFAULT_URL="ws://..." + target_compile_definitions(${_psxg_t} PRIVATE + "PSX_NET_LOBBY_DEFAULT_URL=\"${PSX_NET_LOBBY_DEFAULT_URL}\"") + endif() + endforeach() # Include the portable codegen host. Do NOT add CMAKE_CURRENT_SOURCE_DIR # wholesale to -I: on case-insensitive macOS, #include can pick @@ -1786,5 +1858,7 @@ function(psxrecomp_add_game_runtime target) if(RECOMP_UI_ROOT) list(APPEND _psxg_inc "${RECOMP_UI_ROOT}/src") endif() - target_include_directories(${target} PRIVATE ${_psxg_inc}) + foreach(_psxg_t IN LISTS _psxg_targets) + target_include_directories(${_psxg_t} PRIVATE ${_psxg_inc}) + endforeach() endfunction() diff --git a/runtime/shaders/geo_tex.frag b/runtime/shaders/geo_tex.frag index 78948905..02f961c6 100644 --- a/runtime/shaders/geo_tex.frag +++ b/runtime/shaders/geo_tex.frag @@ -66,13 +66,17 @@ void main() { } else { /* Bilinear, Beetle-PSX formulation: the NEAREST texel is the base * (cutout + STP authority), the neighbours lie toward the sub-texel - * offset and clamp to v_limits, and each texel's weight is gated by its - * opacity with the colour renormalised. */ + * offset and clamp to v_limits. Transparent neighbours are ignored and + * the colour is renormalised, but a transparent base stays cut out. + * v_uv is aligned for PS1 top-left point sampling; recenter the + * bilinear footprint so 1x rect tiles sample their own texels. */ + uv += vec2(pc.u_shift); int iu = int(floor(uv.x)), iv = int(floor(uv.y)); float fx = uv.x - float(iu) - 0.5, fy = uv.y - float(iv) - 0.5; int sx = fx < 0.0 ? -1 : 1, sy = fy < 0.0 ? -1 : 1; fx = abs(fx); fy = abs(fy); int c00 = fetch_texel(iu, iv); + if (c00 == 0) discard; int c10 = fetch_texel(iu + sx, iv); int c01 = fetch_texel(iu, iv + sy); int c11 = fetch_texel(iu + sx, iv + sy); @@ -81,7 +85,6 @@ void main() { float w01 = (c01 == 0 ? 0.0 : 1.0) * (1.0 - fx) * fy; float w11 = (c11 == 0 ? 0.0 : 1.0) * fx * fy; float opac = w00 + w10 + w01 + w11; - if (opac < 0.5) discard; rgb = (col5(c00) * w00 + col5(c10) * w10 + col5(c01) * w01 + col5(c11) * w11) / opac; float stpf = (float((c00 >> 15) & 1) * w00 + float((c10 >> 15) & 1) * w10 + float((c01 >> 15) & 1) * w01 + float((c11 >> 15) & 1) * w11) / opac; diff --git a/runtime/src/cdrom.c b/runtime/src/cdrom.c index b783ecd5..22a7d57d 100644 --- a/runtime/src/cdrom.c +++ b/runtime/src/cdrom.c @@ -36,6 +36,9 @@ extern void* iso_open(const char* path); extern int iso_read_sector(void* handle, uint32_t lba, uint8_t* buffer, int size); extern int iso_read_raw_sector(void* handle, uint32_t lba, uint8_t* buffer, int size); +extern int iso_read_subq(void* handle, uint32_t lba, uint8_t* buffer, int size, + int* valid); +extern int iso_has_subq_replacements(void* handle); extern uint32_t iso_sector_count(void* handle); extern void iso_close(void* handle); /* Multi-track TOC accessors (CD-DA / multi-track discs). track is 1-based. */ @@ -684,6 +687,18 @@ static void exec_command(uint8_t cmd); /* ISO reader */ static void* iso_handle = NULL; +static uint8_t last_valid_subq[12]; +static int last_valid_subq_available; +static int subq_replacements_active; + +static void update_last_valid_subq(uint32_t lba) { + uint8_t subq[12]; + int valid = 0; + if (iso_read_subq(iso_handle, lba, subq, sizeof(subq), &valid) && valid) { + memcpy(last_valid_subq, subq, sizeof(last_valid_subq)); + last_valid_subq_available = 1; + } +} static CDROMTraceEntry cdrom_trace[CDROM_TRACE_CAP]; static uint64_t cdrom_trace_seq; @@ -1316,6 +1331,7 @@ static int read_sector_at(int min, int sec, int sect) { } else if (!iso_read_sector(iso_handle, lba, user_data, SECTOR_SIZE)) { memset(user_data, 0, sizeof(user_data)); } + if (subq_replacements_active) update_last_valid_subq((uint32_t)lba); } else { memset(user_data, 0, sizeof(user_data)); } @@ -2013,18 +2029,23 @@ static void exec_command(uint8_t cmd) { } else { lba = msf_to_lba(seek_min, seek_sec, seek_sect); } - int rm, rs, rf; - int am, as, af; - lba_to_msf(lba - track_lba, 0, &rm, &rs, &rf); - lba_to_msf(lba, 150, &am, &as, &af); - response_push(bin_to_bcd(track)); - response_push(0x01); - response_push(bin_to_bcd(rm)); - response_push(bin_to_bcd(rs)); - response_push(bin_to_bcd(rf)); - response_push(bin_to_bcd(am)); - response_push(bin_to_bcd(as)); - response_push(bin_to_bcd(af)); + if (subq_replacements_active) update_last_valid_subq((uint32_t)lba); + if (subq_replacements_active && last_valid_subq_available) { + for (int i = 1; i <= 8; ++i) response_push(last_valid_subq[i]); + } else { + int rm, rs, rf; + int am, as, af; + lba_to_msf(lba - track_lba, 0, &rm, &rs, &rf); + lba_to_msf(lba, 150, &am, &as, &af); + response_push(bin_to_bcd(track)); + response_push(0x01); + response_push(bin_to_bcd(rm)); + response_push(bin_to_bcd(rs)); + response_push(bin_to_bcd(rf)); + response_push(bin_to_bcd(am)); + response_push(bin_to_bcd(as)); + response_push(bin_to_bcd(af)); + } set_irq(CDIRQ_ACK); break; } @@ -2478,6 +2499,9 @@ void cdrom_init(const char* cue_path) { iso_handle = NULL; } iso_handle = iso_open(cue_path); + last_valid_subq_available = 0; + subq_replacements_active = iso_has_subq_replacements(iso_handle); + if (subq_replacements_active) update_last_valid_subq(0); } stat_reg = has_disc() ? CDSTAT_MOTOR : CDSTAT_SHELL; @@ -2924,6 +2948,7 @@ static int cdrom_snap_emit(PstW *w) { WB(response_fifo); WI(response_read); WI(response_count); WB(sector_buffer); WI(sector_read_pos); WI(sector_available); WI(sector_size); WB(last_sector_buffer); WI(last_sector_lba); WI(last_sector_size); + WB(last_valid_subq); WI(last_valid_subq_available); /* last_sector_frame is host s_frame_count — zero on the wire so netplay * digests/pins are not forked by present-skip / FPS skew. */ WU(0u); W8(last_sector_mode); W8(last_sector_have_raw); @@ -2971,6 +2996,7 @@ static int cdrom_snap_parse(PstR *r) { RB(response_fifo); RI(response_read); RI(response_count); RB(sector_buffer); RI(sector_read_pos); RI(sector_available); RI(sector_size); RB(last_sector_buffer); RI(last_sector_lba); RI(last_sector_size); + RB(last_valid_subq); RI(last_valid_subq_available); RU(last_sector_frame); R8(last_sector_mode); R8(last_sector_have_raw); R8(last_sector_raw_mode); R8(last_sector_xa_file); R8(last_sector_xa_channel); R8(last_sector_xa_submode); R8(last_sector_xa_coding); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 61cd2464..84fea146 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -24,6 +24,7 @@ #include "code_provider.h" #include "overlay_backend.h" #include "cpu_state.h" +#include "pgxp.h" #include "dma.h" #include "gpu.h" #include "gpu_render.h" /* gr_scale + gr_render_display_hires (screenshot_hires) */ @@ -5196,16 +5197,75 @@ static void handle_geom_correction(int id, const char *json) * distinct vertices are landing on the same pixel. */ uint32_t lookups = 0, hits = 0, unrec = 0, ambig = 0; gte_geometry_correction_stats(&lookups, &hits, &unrec, &ambig); + /* PGXP dataflow census (per-vertex): the primary provenance source. + * dataflow_hit is the number that had to move — the G1.9 gate is a + * dataflow-hit share dramatically above the 5.2% the position table + * measured on its own. value_mismatch counts shadows that were present + * but described a different word (stale = provenance hole to hunt). */ + PGXPStats ps; + pgxp_get_stats(&ps); send_fmt("{\"id\":%d,\"ok\":true," "\"geometry_correction\":%d," "\"geometry_vertex_hits\":%u," "\"perspective_triangles\":%u," - "\"lookups\":%u,\"miss_unrecorded\":%u,\"miss_ambiguous\":%u}", + "\"lookups\":%u,\"miss_unrecorded\":%u,\"miss_ambiguous\":%u," + "\"pgxp\":{\"enabled\":%d,\"cpu_mode\":%d,\"tolerance\":%.3f," + "\"lookups\":%llu,\"dataflow_hit\":%llu,\"fallback_hit\":%llu," + "\"native\":%llu,\"value_mismatch\":%llu,\"trunc_reject\":%llu," + "\"tolerance_reject\":%llu,\"w_valid\":%llu," + "\"produced\":%llu,\"swc2_stores\":%llu}}", id, gte_geometry_correction_enabled(), (unsigned)hits, (unsigned)gpu_texture_correction_hits(), - (unsigned)lookups, (unsigned)unrec, (unsigned)ambig); + (unsigned)lookups, (unsigned)unrec, (unsigned)ambig, + pgxp_enabled(), pgxp_cpu_mode(), (double)pgxp_tolerance(), + (unsigned long long)ps.lookups, + (unsigned long long)ps.dataflow_hit, + (unsigned long long)ps.fallback_hit, + (unsigned long long)ps.native, + (unsigned long long)ps.value_mismatch, + (unsigned long long)ps.trunc_reject, + (unsigned long long)ps.tolerance_reject, + (unsigned long long)ps.w_valid, + (unsigned long long)ps.produced, + (unsigned long long)ps.swc2_stores); +} + +/* pgxp — live-tune the value-propagation engine for one-toggle isolation runs + * without a rebuild: {"cmd":"pgxp","cpu_mode":0|1,"tolerance":F}. Fields are + * optional; the reply echoes the resulting state (same shape as + * geom_correction's "pgxp" object, flattened). */ +static void handle_pgxp(int id, const char *json) +{ + /* Live toggles for the one-toggle-at-a-time A/B protocol (ENHANCEMENTS.md + * G1.6 method rule): same scene, flip one knob, screenshot_hires. */ + int geom = json_get_int(json, "geometry", -1); + if (geom >= 0) + gte_geometry_correction_set(geom != 0); + int tex = json_get_int(json, "texture", -1); + if (tex >= 0) + gpu_texture_correction_set(tex != 0); + if (geom >= 0 && tex < 0) { + /* keep the engine armed consistently with both flags */ + gpu_texture_correction_set(gpu_texture_correction_enabled()); + } + int cm = json_get_int(json, "cpu_mode", -1); + if (cm >= 0) + pgxp_set_cpu_mode(cm != 0); + /* tolerance is fractional (sub-pixel), so scan it directly — json_get_int + * would truncate 0.5 to 0. */ + const char *p = strstr(json, "\"tolerance\""); + if (p) { + p += 11; + while (*p == ' ' || *p == ':' || *p == '"') p++; + if (*p == '-' || (*p >= '0' && *p <= '9') || *p == '.') + pgxp_set_tolerance((float)strtod(p, NULL)); + } + send_fmt("{\"id\":%d,\"ok\":true,\"enabled\":%d,\"cpu_mode\":%d," + "\"tolerance\":%.3f,\"suppress\":%u,\"active\":%d}", + id, pgxp_enabled(), pgxp_cpu_mode(), (double)pgxp_tolerance(), + (unsigned)pgxp_test_suppress_depth(), pgxp_test_active()); } static void handle_gpu_state(int id, const char *json) @@ -5221,6 +5281,8 @@ static void handle_gpu_state(int id, const char *json) gpu_get_draw_area(&da); uint64_t nop, fill, draw, env, copy; gpu_get_gp0_stats(&nop, &fill, &draw, &env, ©); + int split_active = 0, split_left_age = 0, split_right_age = 0; + gpu_vertical_split_debug(&split_active, &split_left_age, &split_right_age); GpuWsDebug ws; gpu_ws_get_debug(&ws); send_fmt("{\"id\":%d,\"ok\":true," @@ -5235,6 +5297,7 @@ static void handle_gpu_state(int id, const char *json) "\"gp0_nop\":%llu,\"gp0_fill\":%llu,\"gp0_draw\":%llu,\"gp0_env\":%llu,\"gp0_copy\":%llu," "\"draw_area\":[%u,%u,%u,%u]," "\"draw_offset\":[%d,%d]," + "\"vertical_split\":{\"active\":%d,\"left_age\":%d,\"right_age\":%d}," "\"ws\":{\"configured\":%d,\"active\":%d,\"game_mode\":%d," "\"present_native_43\":%d,\"x_margin\":%d," "\"activation_margin\":%d,\"squash\":[%d,%d]," @@ -5263,6 +5326,7 @@ static void handle_gpu_state(int id, const char *json) (unsigned long long)copy, da.left, da.top, da.right, da.bottom, da.offset_x, da.offset_y, + split_active, split_left_age, split_right_age, ws.configured, ws.active, ws.game_mode, ws.present_native_43, ws.x_margin, ws.activation_margin, ws.xnum, ws.xden, @@ -13163,6 +13227,7 @@ static const CmdEntry s_commands[] = { { "write_ram", handle_write_ram }, { "gpu_state", handle_gpu_state }, { "geom_correction", handle_geom_correction }, + { "pgxp", handle_pgxp }, { "ws_aspect_cone_site", handle_ws_aspect_cone_site }, { "ws_margin", handle_ws_margin }, { "ws_hud_mode", handle_ws_hud_mode }, diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index fc3c5ad0..377f35aa 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -1512,30 +1512,48 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, switch (opc) { case 0x00: /* SPECIAL */ switch (fnt) { - case 0x00: /* SLL rd, rt, sh (also nop when all fields are 0) */ - cpu->gpr[rd] = cpu->gpr[rt] << sh; + case 0x00: { /* SLL rd, rt, sh (also nop when all fields are 0) */ + uint32_t a = cpu->gpr[rt]; + cpu->gpr[rd] = a << sh; + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, sh); cpu->gpr[0] = 0; return 0; - case 0x02: /* SRL */ - cpu->gpr[rd] = cpu->gpr[rt] >> sh; + } + case 0x02: { /* SRL */ + uint32_t a = cpu->gpr[rt]; + cpu->gpr[rd] = a >> sh; + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, sh); cpu->gpr[0] = 0; return 0; - case 0x03: /* SRA */ - cpu->gpr[rd] = (uint32_t)((int32_t)cpu->gpr[rt] >> sh); + } + case 0x03: { /* SRA */ + uint32_t a = cpu->gpr[rt]; + cpu->gpr[rd] = (uint32_t)((int32_t)a >> sh); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, sh); cpu->gpr[0] = 0; return 0; - case 0x04: /* SLLV */ - cpu->gpr[rd] = cpu->gpr[rt] << (cpu->gpr[rs] & 31); + } + case 0x04: { /* SLLV */ + uint32_t a = cpu->gpr[rt], b = cpu->gpr[rs]; + cpu->gpr[rd] = a << (b & 31); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, b); cpu->gpr[0] = 0; return 0; - case 0x06: /* SRLV */ - cpu->gpr[rd] = cpu->gpr[rt] >> (cpu->gpr[rs] & 31); + } + case 0x06: { /* SRLV */ + uint32_t a = cpu->gpr[rt], b = cpu->gpr[rs]; + cpu->gpr[rd] = a >> (b & 31); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, b); cpu->gpr[0] = 0; return 0; - case 0x07: /* SRAV */ - cpu->gpr[rd] = (uint32_t)((int32_t)cpu->gpr[rt] >> (cpu->gpr[rs] & 31)); + } + case 0x07: { /* SRAV */ + uint32_t a = cpu->gpr[rt], b = cpu->gpr[rs]; + cpu->gpr[rd] = (uint32_t)((int32_t)a >> (b & 31)); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, b); cpu->gpr[0] = 0; return 0; + } case 0x08: { /* JR rs */ uint32_t target = cpu->gpr[rs]; if (target & 3) return interp_exception(cpu, 4, target, pc); /* LoadAddressError */ @@ -1615,25 +1633,30 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, psx_muldiv_stall(cpu); /* stall to mult/div completion (faithful) */ #endif cpu->gpr[rd] = cpu->hi; + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], cpu->hi, 0); cpu->gpr[0] = 0; return 0; case 0x11: /* MTHI */ cpu->hi = cpu->gpr[rs]; + psx_pgxp_alu(cpu, insn, cpu->hi, cpu->hi, 0); return 0; case 0x12: /* MFLO */ #ifdef PSX_ENABLE_BLOCK_CYCLES psx_muldiv_stall(cpu); /* stall to mult/div completion (faithful) */ #endif cpu->gpr[rd] = cpu->lo; + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], cpu->lo, 0); cpu->gpr[0] = 0; return 0; case 0x13: /* MTLO */ cpu->lo = cpu->gpr[rs]; + psx_pgxp_alu(cpu, insn, cpu->lo, cpu->lo, 0); return 0; case 0x18: { /* MULT */ int64_t r = (int64_t)(int32_t)cpu->gpr[rs] * (int64_t)(int32_t)cpu->gpr[rt]; cpu->lo = (uint32_t)r; cpu->hi = (uint32_t)((uint64_t)r >> 32); + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, cpu->gpr[rs], cpu->gpr[rt]); #ifdef PSX_ENABLE_BLOCK_CYCLES psx_muldiv_set(cpu, psx_mult_latency_s(cpu->gpr[rs])); /* completion deadline */ #endif @@ -1643,6 +1666,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, uint64_t r = (uint64_t)cpu->gpr[rs] * (uint64_t)cpu->gpr[rt]; cpu->lo = (uint32_t)r; cpu->hi = (uint32_t)(r >> 32); + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, cpu->gpr[rs], cpu->gpr[rt]); #ifdef PSX_ENABLE_BLOCK_CYCLES psx_muldiv_set(cpu, psx_mult_latency_u(cpu->gpr[rs])); /* completion deadline */ #endif @@ -1661,6 +1685,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->lo = (uint32_t)(a / b); cpu->hi = (uint32_t)(a % b); } + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, cpu->gpr[rs], cpu->gpr[rt]); #ifdef PSX_ENABLE_BLOCK_CYCLES psx_muldiv_set(cpu, 37u); /* DIV completion deadline (fixed) */ #endif @@ -1674,31 +1699,41 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->lo = cpu->gpr[rs] / cpu->gpr[rt]; cpu->hi = cpu->gpr[rs] % cpu->gpr[rt]; } + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, cpu->gpr[rs], cpu->gpr[rt]); #ifdef PSX_ENABLE_BLOCK_CYCLES psx_muldiv_set(cpu, 37u); /* DIVU completion deadline (fixed) */ #endif return 0; case 0x20: /* ADD - overflow traps are delegated if they occur. */ - case 0x21: /* ADDU rd, rs, rt */ - cpu->gpr[rd] = cpu->gpr[rs] + cpu->gpr[rt]; + case 0x21: { /* ADDU rd, rs, rt */ + uint32_t a = cpu->gpr[rs], b = cpu->gpr[rt]; + cpu->gpr[rd] = a + b; + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, b); cpu->gpr[0] = 0; return 0; + } case 0x22: /* SUB - overflow traps are delegated if they occur. */ - case 0x23: /* SUBU */ + case 0x23: { /* SUBU */ + uint32_t a = cpu->gpr[rs], b = cpu->gpr[rt]; if (rs == 0 && psx_ws_is_cull_negsub_site(pc)) - cpu->gpr[rd] = 0u - cpu->gpr[rt] - (uint32_t)psx_ws_x_margin(); + cpu->gpr[rd] = 0u - b - (uint32_t)psx_ws_x_margin(); else - cpu->gpr[rd] = cpu->gpr[rs] - cpu->gpr[rt]; + cpu->gpr[rd] = a - b; + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, b); cpu->gpr[0] = 0; return 0; + } case 0x24: /* AND */ cpu->gpr[rd] = cpu->gpr[rs] & cpu->gpr[rt]; cpu->gpr[0] = 0; return 0; - case 0x25: /* OR */ - cpu->gpr[rd] = cpu->gpr[rs] | cpu->gpr[rt]; + case 0x25: { /* OR */ + uint32_t a = cpu->gpr[rs], b = cpu->gpr[rt]; + cpu->gpr[rd] = a | b; + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, b); cpu->gpr[0] = 0; return 0; + } case 0x26: /* XOR */ cpu->gpr[rd] = cpu->gpr[rs] ^ cpu->gpr[rt]; cpu->gpr[0] = 0; @@ -1852,24 +1887,28 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, case 0x08: /* ADDI rt, rs, simm — same as ADDIU, sans overflow trap (we don't model traps here) */ { uint32_t widened = 0; + uint32_t a = cpu->gpr[rs]; if (psx_ws_angle_site(pc, insn, &widened)) cpu->gpr[rt] = widened; else - cpu->gpr[rt] = cpu->gpr[rs] + (uint32_t)simm + cpu->gpr[rt] = a + (uint32_t)simm + (psx_ws_is_cull_bias_site(pc) ? (uint32_t)psx_ws_activation_margin() : 0u); + psx_pgxp_alu(cpu, insn, cpu->gpr[rt], a, (uint32_t)simm); cpu->gpr[0] = 0; return 0; } case 0x09: /* ADDIU rt, rs, simm */ { uint32_t widened = 0; + uint32_t a = cpu->gpr[rs]; if (psx_ws_angle_site(pc, insn, &widened)) cpu->gpr[rt] = widened; else - cpu->gpr[rt] = cpu->gpr[rs] + (uint32_t)simm + cpu->gpr[rt] = a + (uint32_t)simm + (psx_ws_is_cull_bias_site(pc) ? (uint32_t)psx_ws_activation_margin() : 0u); + psx_pgxp_alu(cpu, insn, cpu->gpr[rt], a, (uint32_t)simm); cpu->gpr[0] = 0; return 0; } @@ -1930,10 +1969,13 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->gpr[rt] = cpu->gpr[rs] & imm; cpu->gpr[0] = 0; return 0; - case 0x0D: /* ORI */ - cpu->gpr[rt] = cpu->gpr[rs] | imm; + case 0x0D: { /* ORI */ + uint32_t a = cpu->gpr[rs]; + cpu->gpr[rt] = a | imm; + psx_pgxp_alu(cpu, insn, cpu->gpr[rt], a, imm); cpu->gpr[0] = 0; return 0; + } case 0x0E: /* XORI */ cpu->gpr[rt] = cpu->gpr[rs] ^ imm; cpu->gpr[0] = 0; @@ -1943,6 +1985,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->gpr[rt] = (uint32_t)psx_ws_player_x_bound((int32_t)(imm << 16)); else cpu->gpr[rt] = imm << 16; + psx_pgxp_alu(cpu, insn, cpu->gpr[rt], 0, 0); cpu->gpr[0] = 0; return 0; case 0x10: { /* COP0 */ @@ -2033,6 +2076,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, psx_gte_read(cpu, rt); #endif cpu->gpr[rt] = gte_read_data(cpu, (uint8_t)rd); + psx_pgxp_cop2(cpu, insn, cpu->gpr[rt], 0); cpu->gpr[0] = 0; return 0; } @@ -2041,6 +2085,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, psx_gte_read(cpu, rt); #endif cpu->gpr[rt] = gte_read_ctrl(cpu, (uint8_t)rd); + psx_pgxp_cop2(cpu, insn, cpu->gpr[rt], 0); cpu->gpr[0] = 0; return 0; } @@ -2049,6 +2094,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, psx_gte_stall(cpu); #endif gte_write_data(cpu, (uint8_t)rd, cpu->gpr[rt]); + psx_pgxp_cop2(cpu, insn, cpu->gpr[rt], 0); return 0; } if (cop_op == 0x06) { /* CTC2 */ @@ -2072,6 +2118,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, case 0x20: { /* LB rt, simm(rs) */ uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; cpu->gpr[rt] = (uint32_t)(int32_t)(int8_t)psx_cyc_load_byte(cpu, addr, rt, 1u << rs); + psx_pgxp_load(cpu, insn, addr, cpu->gpr[rt]); cpu->gpr[0] = 0; return 0; } @@ -2079,6 +2126,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; if (addr & 1) return interp_exception(cpu, 4, addr, pc); /* LoadAddressError */ cpu->gpr[rt] = (uint32_t)(int32_t)(int16_t)psx_cyc_load_half(cpu, addr, rt, 1u << rs); + psx_pgxp_load(cpu, insn, addr, cpu->gpr[rt]); cpu->gpr[0] = 0; return 0; } @@ -2086,6 +2134,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, 1u << rs); cpu->gpr[rt] = lwl_merge(addr, word, cpu->gpr[rt]); + psx_pgxp_load(cpu, insn, addr, cpu->gpr[rt]); cpu->gpr[0] = 0; return 0; } @@ -2101,12 +2150,14 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->gpr[rt] = psx_ws_xclip_bound(psx_cyc_load_word(cpu, addr, rt, 1u << rs)); else cpu->gpr[rt] = psx_cyc_load_word(cpu, addr, rt, 1u << rs); + psx_pgxp_load(cpu, insn, addr, cpu->gpr[rt]); cpu->gpr[0] = 0; return 0; } case 0x24: { /* LBU */ uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; cpu->gpr[rt] = (uint32_t)psx_cyc_load_byte(cpu, addr, rt, 1u << rs); + psx_pgxp_load(cpu, insn, addr, cpu->gpr[rt]); cpu->gpr[0] = 0; return 0; } @@ -2114,6 +2165,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; if (addr & 1) return interp_exception(cpu, 4, addr, pc); /* LoadAddressError */ cpu->gpr[rt] = (uint32_t)psx_cyc_load_half(cpu, addr, rt, 1u << rs); + psx_pgxp_load(cpu, insn, addr, cpu->gpr[rt]); cpu->gpr[0] = 0; return 0; } @@ -2121,6 +2173,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; uint32_t word = psx_cyc_load_word(cpu, addr & ~3u, rt, 1u << rs); cpu->gpr[rt] = lwr_merge(addr, word, cpu->gpr[rt]); + psx_pgxp_load(cpu, insn, addr, cpu->gpr[rt]); cpu->gpr[0] = 0; return 0; } @@ -2128,6 +2181,7 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; g_debug_last_store_pc = pc; cpu->write_byte(addr, (uint8_t)cpu->gpr[rt]); + psx_pgxp_store(cpu, insn, addr, cpu->gpr[rt] & 0xFFu); return 0; } case 0x29: { /* SH */ @@ -2143,12 +2197,14 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, val = (uint16_t)psx_ws_backdrop_x((int16_t)val); g_debug_last_store_pc = pc; cpu->write_half(addr, val); + psx_pgxp_store(cpu, insn, addr, val); return 0; } case 0x2A: { /* SWL */ uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; g_debug_last_store_pc = pc; interp_swl(cpu, addr, cpu->gpr[rt]); + psx_pgxp_store(cpu, insn, addr, cpu->gpr[rt]); return 0; } case 0x2B: { /* SW */ @@ -2156,12 +2212,14 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, if (addr & 3) return interp_exception(cpu, 5, addr, pc); /* StoreAddressError */ g_debug_last_store_pc = pc; cpu->write_word(addr, cpu->gpr[rt]); + psx_pgxp_store(cpu, insn, addr, cpu->gpr[rt]); return 0; } case 0x2E: { /* SWR */ uint32_t addr = cpu->gpr[rs] + (uint32_t)simm; g_debug_last_store_pc = pc; interp_swr(cpu, addr, cpu->gpr[rt]); + psx_pgxp_store(cpu, insn, addr, cpu->gpr[rt]); return 0; } case 0x32: { /* LWC2 — §1+DO_LDS charged by exec_one's top step (mask 0) */ @@ -2169,7 +2227,11 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, #ifdef PSX_ENABLE_BLOCK_CYCLES psx_gte_stall(cpu); /* COP2 reg write stalls to GTE completion */ #endif - gte_write_data(cpu, (uint8_t)rt, psx_cyc_lwc2_read(cpu, addr)); + { + uint32_t lw2 = psx_cyc_lwc2_read(cpu, addr); + gte_write_data(cpu, (uint8_t)rt, lw2); + psx_pgxp_cop2(cpu, insn, lw2, addr); + } return 0; } case 0x3A: { /* SWC2 */ @@ -2178,8 +2240,12 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, psx_gte_stall(cpu); /* COP2 reg read stalls to GTE completion */ #endif g_debug_last_store_pc = pc; - cpu->write_word(addr, gte_read_data(cpu, (uint8_t)rt)); - gte_precision_store_word(addr, (uint8_t)rt); + { + uint32_t sw2 = gte_read_data(cpu, (uint8_t)rt); + cpu->write_word(addr, sw2); + gte_precision_store_word(addr, (uint8_t)rt); + psx_pgxp_cop2(cpu, insn, sw2, addr); + } return 0; } default: diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 99f926f6..c4b80f1a 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -11,6 +11,7 @@ */ #include "gpu.h" +#include "pgxp.h" #include "mod_memory.h" #include "gpu_primitive_reject.h" #include "gpu_sw_renderer.h" @@ -86,6 +87,7 @@ static uint32_t ws_anchor_addr = 0; /* scratchpad addr of anchor SXY */ static int ws_hud_sprt = 0; /* edge-anchor untagged HUD SPRTs */ static int ws_auto_ui_squash; static int ws_auto_ui_dense; +static int ws_active(void); static uint64_t ws_auto_ui_candidate_count; static uint64_t ws_auto_ui_transform_count; #define WS_UI_PREPASS_MAX 2048u @@ -126,6 +128,7 @@ static int ws_engaged(void) { return ws_mode != 0; } /* Forward decls: defined later but used by psx_ws_backdrop_x above them. */ static int32_t ws_scale_about(int32_t x, int32_t ax); +static int32_t ws_disp_x(void); static int32_t ws_disp_w(void); static void ws_clear_all_reveal_margins(void); @@ -166,6 +169,7 @@ static int ws_gte_game_mode_cfg = 0; static uint32_t ws_gte_frame = (uint32_t)-1; static uint32_t ws_gte_count = 0; static uint32_t ws_last_gte_stamp = (uint32_t)-1000; +static int ws_precise_nclip_cfg = 0; #define WS_GAMEPLAY_STATE_VALUES_MAX 16 static uint32_t ws_gameplay_state_addr = 0; static uint32_t ws_gameplay_state_values[WS_GAMEPLAY_STATE_VALUES_MAX]; @@ -180,6 +184,8 @@ static int ws_gameplay_state_value_count = 0; * this many consecutive frames (save/options/memory-card) — reverts to 4:3. */ #define WS_GTE_GAME_MODE_HYSTERESIS 45u void gpu_ws_set_gte_game_mode(int on) { ws_gte_game_mode_cfg = on ? 1 : 0; } +void gpu_ws_set_precise_nclip(int on) { ws_precise_nclip_cfg = on ? 1 : 0; } +int gpu_ws_precise_nclip_enabled(void) { return ws_precise_nclip_cfg && ws_active(); } void gpu_ws_set_gameplay_state_gate(uint32_t addr, const uint32_t *values, int nvalues) { if (nvalues < 0) nvalues = 0; @@ -344,8 +350,48 @@ int ws_native_wide_active(void) { static int ws_native_wide_configured(void) { return ws_mode == 2 && ws_cfg_num * 3 > ws_cfg_den * 4; } + +static int ws_local_viewport_cfg = 0; +static int ws_local_viewport_slot = 0; +static int ws_local_viewport_draw_target(int *base_x); +static int ws_vertical_split_active(void); +void gpu_ws_set_netplay_local_viewport(int enabled, int slot) { + ws_local_viewport_cfg = enabled ? 1 : 0; + ws_local_viewport_slot = slot == 1 ? 1 : 0; + ws_nw_sync_target(); +} + +static int ws_local_viewport_layout(int *base_x, int *source_w, + int *wide_w, int *offset) { + if (!ws_local_viewport_cfg || !ws_native_wide_configured() || + !ws_vertical_split_active()) + return 0; + GpuDisplayInfo di; + gpu_get_display_info(&di); + if (di.disabled || di.depth24 || di.width < 2 || di.height == 0) + return 0; + if ((di.width & 1u) != 0) + return 0; + + int src_w = (int)di.width / 2; + int target_w = ((int)di.height * ws_cfg_num + ws_cfg_den / 2) / ws_cfg_den; + if (target_w < src_w) + target_w = src_w; + int off = (target_w - src_w) / 2; + int final_w = src_w + off * 2; + + if (base_x) *base_x = (int)di.display_x + (ws_local_viewport_slot ? src_w : 0); + if (source_w) *source_w = src_w; + if (wide_w) *wide_w = final_w; + if (offset) *offset = off; + return final_w > src_w && off > 0; +} + static int ws_nw_configured_offset(void) { if (!ws_native_wide_configured()) return 0; + int local_offset = 0; + if (ws_local_viewport_layout(NULL, NULL, NULL, &local_offset)) + return local_offset; int numr = 3 * ws_cfg_num - 4 * ws_cfg_den; int w = (int)ws_disp_w(); return (w * numr + 4 * ws_cfg_den) / (8 * ws_cfg_den); @@ -356,6 +402,23 @@ static int ws_nw_offset(void) { } int ws_nw_extra(void) { return 2 * ws_nw_offset(); } +int ws_nw_present_width(void) { + int wide_w = 0; + if (ws_local_viewport_layout(NULL, NULL, &wide_w, NULL)) + return wide_w; + return (int)ws_disp_w() + ws_nw_extra(); +} + +int gpu_ws_netplay_local_viewport_base_x(void) { + int base = 0; + return ws_local_viewport_layout(&base, NULL, NULL, NULL) ? base : 0; +} + +int gpu_ws_netplay_local_viewport_width(void) { + int wide_w = 0; + return ws_local_viewport_layout(NULL, NULL, &wide_w, NULL) ? wide_w : 0; +} + /* Per-side X cull margin in screen/world units (the game's draw classifier * works in objX-camX where 1 unit ~= 1 native-4:3 screen pixel). The squash * shows a half-view of 160/s pixels (s = squash factor = ws_xnum/ws_xden), so @@ -1408,14 +1471,15 @@ int psx_ws_cull_bltz_at(const uint32_t *words, int n, int idx) { * x is the int16 screenX the handler was about to store. */ int psx_ws_backdrop_x(int x) { if (ws_native_wide_active()) { + int32_t X = ws_disp_x(); int32_t W = (int32_t)ws_disp_w(); int32_t extra = ws_nw_extra(); int32_t cx = W / 2; - int32_t d = (int16_t)x - cx; - return (int)(cx + (d * (W + extra) + (d >= 0 ? W / 2 : -W / 2)) / W); + int32_t d = (int16_t)x - X - cx; + return (int)(X + cx + (d * (W + extra) + (d >= 0 ? W / 2 : -W / 2)) / W); } if (!ws_active()) return (int16_t)x; - int32_t cx = ws_disp_w() / 2; /* screen centre (=160 @ 320) */ + int32_t cx = ws_disp_x() + ws_disp_w() / 2; /* screen centre (=160 @ 320) */ return ws_scale_about((int16_t)x, cx); } @@ -1823,8 +1887,20 @@ static int32_t ws_scale_len(int32_t w) { return s < 1 ? 1 : s; } -/* Horizontal display width in drawing space (e.g. 320). */ +/* Horizontal display viewport in drawing space (e.g. 320). For a selected + * netplay-local split viewport this is that player's authored half, not the + * whole composed display. */ +static int32_t ws_disp_x(void) { + int base = 0; + if (ws_local_viewport_layout(&base, NULL, NULL, NULL)) + return (int32_t)base; + return 0; +} + static int32_t ws_disp_w(void) { + int src_w = 0; + if (ws_local_viewport_layout(NULL, &src_w, NULL, NULL)) + return (int32_t)src_w; GpuDisplayInfo di; gpu_get_display_info(&di); return di.width ? (int32_t)di.width : 320; @@ -1841,8 +1917,8 @@ static int32_t ws_disp_h(void) { * display; ordinary world-space rectangles remain untouched. */ static void ws_expand_fullscreen_rect(int32_t *x, int32_t y, int *w, int h) { if (!ws_native_wide_active()) return; - int W = (int)ws_disp_w(), H = (int)ws_disp_h(); - if (*x <= 0 && *x + *w >= W && y <= 0 && y + h >= H) { + int X = (int)ws_disp_x(), W = (int)ws_disp_w(), H = (int)ws_disp_h(); + if (*x <= X && *x + *w >= X + W && y <= 0 && y + h >= H) { int off = ws_nw_offset(); *x -= off; *w += 2 * off; @@ -1857,11 +1933,12 @@ static void ws_expand_fullscreen_rect(int32_t *x, int32_t y, int *w, int h) { * one zone, so its pieces share a pivot and stay aligned. (Full-2D menu * screens never reach here — they get zero squash + 4:3 pillarbox instead.) */ static int32_t ws_hud_pivot(int32_t x, int32_t w) { + int32_t X = ws_disp_x(); int32_t W = ws_disp_w(); - int32_t cx = 2 * x + w; /* 2*centre, avoids losing the half */ - if (3 * cx < 2 * W) return 0; - if (3 * cx > 4 * W) return W; - return W / 2; + int32_t cx = 2 * (x - X) + w; /* 2*centre, avoids losing the half */ + if (3 * cx < 2 * W) return X; + if (3 * cx > 4 * W) return X + W; + return X + W / 2; } /* Automatic UI correction is deliberately tied to draw provenance, not to @@ -1933,8 +2010,8 @@ static int ws_auto_ui_transform_quad(int32_t vx[4], const int32_t vy[4]) { if (vy[i] > max_y) max_y = vy[i]; } int32_t width = max_x - min_x, height = max_y - min_y; - int32_t W = ws_disp_w(), H = ws_disp_h(); - if ((min_x <= 0 && max_x >= W && min_y <= 0 && max_y >= H) || + int32_t X = ws_disp_x(), W = ws_disp_w(), H = ws_disp_h(); + if ((min_x <= X && max_x >= X + W && min_y <= 0 && max_y >= H) || (width > W / 2 && height > H / 4)) return 0; @@ -1948,8 +2025,8 @@ 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 || psx_ws_prim_is_tagged()) return 0; - int32_t W = ws_disp_w(), H = ws_disp_h(); - if ((*x <= 0 && *x + *w >= W && y <= 0 && y + h >= H) || + 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)) return 0; int32_t anchor; @@ -2026,8 +2103,9 @@ static int32_t ws_nw_hud_shift(int32_t x, int32_t w) { * for live A/B: some HUD composites (Tomba's AP counter) render through * the tagged sprite funnel and stay inset without it. */ if (ws_anchor_addr && !ws_nw_hud_tag_rects && psx_ws_prim_is_tagged()) return 0; + int32_t X = ws_disp_x(); int32_t W = ws_disp_w(); - int32_t cx = 2 * x + w; /* 2*centre, avoids losing the half */ + int32_t cx = 2 * (x - X) + w; /* 2*centre, avoids losing the half */ if (3 * cx < 2 * W) return -off; /* left third -> pull to left edge */ if (3 * cx > 4 * W) return off; /* right third -> push to right edge */ return 0; /* middle third -> stay centred */ @@ -2072,6 +2150,7 @@ static int ws_nw_backdrop_stretch_quad(int32_t *vx, const int32_t *vy) { if (!ws_nw_backdrop || !ws_native_wide_active()) return 0; int32_t extra = ws_nw_extra(); if (extra <= 0) return 0; + int32_t X = ws_disp_x(); int32_t W = ws_disp_w(); const int32_t EDGE = 24; /* slack for "touches the frame edge" */ int32_t minx = vx[0], maxx = vx[0], miny = vy[0], maxy = vy[0]; @@ -2082,7 +2161,7 @@ static int ws_nw_backdrop_stretch_quad(int32_t *vx, const int32_t *vy) { if (vy[i] > maxy) maxy = vy[i]; } /* Must span the full display width and a real vertical extent. */ - if (minx > EDGE || maxx < W - EDGE || (maxy - miny) < 64) return 0; + if (minx > X + EDGE || maxx < X + W - EDGE || (maxy - miny) < 64) return 0; /* Axis-aligned: every vertex X sits at either the min or the max edge, and * every Y at the top or bottom edge (a true screen-space rectangle). */ for (int i = 0; i < 4; i++) { @@ -2092,7 +2171,7 @@ static int ws_nw_backdrop_stretch_quad(int32_t *vx, const int32_t *vy) { } /* Stretch X about the display centre by (W+extra)/W so [0,W] -> [-off, W+off], * which the wide compositor (+off) maps onto the full [0, W+extra] surface. */ - int32_t cx = W / 2; + int32_t cx = X + W / 2; for (int i = 0; i < 4; i++) { int32_t d = vx[i] - cx; vx[i] = cx + (d * (W + extra) + (d >= 0 ? W / 2 : -W / 2)) / W; @@ -2165,6 +2244,59 @@ static uint32_t texture_disable; /* bit 15 */ static uint32_t draw_area_left, draw_area_top; static uint32_t draw_area_right, draw_area_bottom; +static int draw_area_intersects_rect(int x, int y, int w, int h) { + if (w <= 0 || h <= 0) + return 0; + int r = x + w - 1; + int b = y + h - 1; + return !((int)draw_area_right < x || + (int)draw_area_left > r || + (int)draw_area_bottom < y || + (int)draw_area_top > b); +} + +static int ws_local_viewport_draw_target(int *base_x) { + int base = 0, src_w = 0; + if (!ws_local_viewport_layout(&base, &src_w, NULL, NULL)) + return 0; + GpuDisplayInfo di; + gpu_get_display_info(&di); + if (draw_area_intersects_rect(base, (int)di.display_y, + src_w, (int)di.height)) { + if (base_x) *base_x = base; + return 1; + } + return 0; +} + +static int ws_display_viewport_draw_target(int *base_x) { + if (!ws_native_wide_active()) + return 0; + GpuDisplayInfo di; + gpu_get_display_info(&di); + if (di.disabled || di.depth24 || di.width == 0 || di.height == 0) + return 0; + if (!draw_area_intersects_rect((int)di.display_x, (int)di.display_y, + (int)di.width, (int)di.height)) + return 0; + if (base_x) *base_x = (int)di.display_x; + return 1; +} + +typedef struct GpuVerticalSplitTrace { + uint8_t left_seen; + uint8_t right_seen; + uint16_t display_w; + uint16_t display_h; +} GpuVerticalSplitTrace; + +static GpuVerticalSplitTrace split_trace_this; +static GpuVerticalSplitTrace split_trace_last; +static uint8_t split_recent_left_age = 255; +static uint8_t split_recent_right_age = 255; +static uint16_t split_recent_display_w = 0; +static uint16_t split_recent_display_h = 0; + /* Draw offset (set by GP0(E5h)) */ static int32_t draw_offset_x, draw_offset_y; /* Instrumentation: per-vblank range/count of GP0(E5) draw-offset-Y sets. If a @@ -2231,12 +2363,80 @@ static int ws_is_fb_base(uint32_t bx) { for (int i = 0; i < ws_fb_n; i++) if (ws_fb_base[i] == bx) return 1; return 0; } + +static void split_trace_reset(GpuVerticalSplitTrace *trace) { + memset(trace, 0, sizeof(*trace)); +} + +static void split_trace_note_draw_area(void) { + GpuDisplayInfo di; + gpu_get_display_info(&di); + if (di.disabled || di.depth24 || di.width < 256 || di.height < 128) + return; + if ((di.width & 1u) != 0) + return; + if (di.display_x + di.width > 1024u || di.display_y + di.height > 512u) + return; + if (draw_area_right <= draw_area_left || draw_area_bottom <= draw_area_top) + return; + + const int disp_l = (int)di.display_x; + const int disp_r = (int)(di.display_x + di.width - 1u); + const int mid = disp_l + (int)(di.width / 2u); + const int tol = (int)di.width / 32 > 8 ? (int)di.width / 32 : 8; + + const int area_l = (int)draw_area_left; + const int area_r = (int)draw_area_right; + + if (split_trace_this.display_w != 0 && + (split_trace_this.display_w != (uint16_t)di.width || + split_trace_this.display_h != (uint16_t)di.height)) { + split_trace_reset(&split_trace_this); + } + split_trace_this.display_w = (uint16_t)di.width; + split_trace_this.display_h = (uint16_t)di.height; + + if (area_l <= disp_l + tol && + area_r >= mid - 1 - tol && area_r <= mid - 1 + tol) { + split_trace_this.left_seen = 1; + } + if (area_l >= mid - tol && area_l <= mid + tol && + area_r >= disp_r - tol) { + split_trace_this.right_seen = 1; + } +} + +static int ws_vertical_split_active(void) { + return split_recent_left_age <= 8 && split_recent_right_age <= 8; +} + +int gpu_last_frame_vertical_split_screen(void) { + return ws_vertical_split_active(); +} + +void gpu_vertical_split_debug(int *active, int *left_age, int *right_age) { + if (active) *active = gpu_last_frame_vertical_split_screen(); + if (left_age) *left_age = split_recent_left_age; + if (right_age) *right_age = split_recent_right_age; +} + /* Point the renderer's wide mirror at the current back buffer (draw_area_left) * when native-wide is active and that buffer is a known display buffer; else * disable mirroring for this draw. Called when the draw env changes. */ static void ws_nw_sync_target(void) { if (!ws_native_wide_active()) { gr_wide_disable_target(); return; } - gr_wide_configure((int)ws_disp_w() + ws_nw_extra(), ws_nw_offset()); + gr_wide_configure(ws_nw_present_width(), ws_nw_offset()); + int local_base = 0; + if (ws_local_viewport_draw_target(&local_base)) { + gr_wide_set_target(local_base); + return; + } + if (ws_local_viewport_cfg) { gr_wide_disable_target(); return; } + int display_base = 0; + if (ws_display_viewport_draw_target(&display_base)) { + gr_wide_set_target(display_base); + return; + } uint32_t base = draw_area_left; if (ws_is_fb_base(base)) gr_wide_set_target((int)base); else gr_wide_disable_target(); @@ -2362,6 +2562,12 @@ static void gpu_reset_state(int clear_vram) { draw_area_top = 0; draw_area_right = 0; draw_area_bottom = 0; + split_trace_reset(&split_trace_this); + split_trace_reset(&split_trace_last); + split_recent_left_age = 255; + split_recent_right_age = 255; + split_recent_display_w = 0; + split_recent_display_h = 0; draw_offset_x = 0; draw_offset_y = 0; texture_window_value = 0; @@ -2654,6 +2860,26 @@ void gpu_vblank_tick(void) { g_doff_cnt_last = g_doff_cnt_this; } g_doff_min_this = 0x7fffffff; g_doff_max_this = -0x7fffffff; g_doff_cnt_this = 0; + split_trace_last = split_trace_this; + if (split_trace_this.display_w != 0 && + (split_recent_display_w != split_trace_this.display_w || + split_recent_display_h != split_trace_this.display_h)) { + split_recent_left_age = 255; + split_recent_right_age = 255; + split_recent_display_w = split_trace_this.display_w; + split_recent_display_h = split_trace_this.display_h; + } + if (split_trace_this.left_seen) { + split_recent_left_age = 0; + } else if (split_recent_left_age < 255) { + split_recent_left_age++; + } + if (split_trace_this.right_seen) { + split_recent_right_age = 0; + } else if (split_recent_right_age < 255) { + split_recent_right_age++; + } + split_trace_reset(&split_trace_this); gpustat_poll_count = 0; /* Trusted package-selected plugins run on guest VBlank, independent of * host presentation, pacing, turbo, or skipped frames. */ @@ -2956,48 +3182,64 @@ static void parse_vertex(uint32_t word, int32_t* x, int32_t* y) { *y = sign_extend((word >> 16) & 0x7FFu, 11); } -extern int gte_geometry_correction_lookup(uint32_t packed, - int32_t *x16, int32_t *y16); extern int gte_geometry_correction_enabled(void); static int s_texture_correction_enabled = 0; -extern void gte_precision_tracking_set(int enabled); extern int gte_precision_load_word(uint32_t addr, uint32_t packed, int32_t *x16, int32_t *y16, uint16_t *z); void gpu_texture_correction_set(int enabled) { s_texture_correction_enabled = enabled ? 1 : 0; - gte_precision_tracking_set(enabled); + /* The PGXP dataflow engine feeds BOTH corrections; arm it while either + * is on (geometry correction is toggled in gte.cpp, so re-derive here). */ + pgxp_set_enabled(s_texture_correction_enabled || + gte_geometry_correction_enabled()); +} + +int gpu_texture_correction_enabled(void) { + return s_texture_correction_enabled; } uint32_t gpu_texture_correction_hits(void) { return sw_perspective_triangle_count(); } -/* Match all three GP0 positions to recent GTE projections. Requiring a full - * triangle match prevents screen-space HUD/sprites that happen to share one - * coordinate from receiving world-geometry correction. The integer delta - * folds in draw offsets and any widescreen adjustment already applied. */ -static void prepare_precise_triangle(uint32_t w0, uint32_t w1, uint32_t w2, +/* Per-vertex precise positions (PGXP, ENHANCEMENTS.md G1). Each of the three + * packet words is resolved independently: the address-keyed dataflow shadow + * first (validated against the actual word — exact provenance, survives + * ordering-table reordering), the ambiguity-gated position cache second, the + * parsed integers last. Mixing precise and native vertices in one triangle + * is correct — a native vertex is exactly where the uncorrected pipeline put + * it, so shared edges between neighbouring triangles cannot disagree by more + * than the sub-pixel fraction. The integer delta folds in draw offsets and + * any widescreen adjustment already applied by the caller. */ +static void prepare_precise_triangle(int i0, int i1, int i2, const int32_t vx[3], const int32_t vy[3]) { - uint32_t words[3] = { w0, w1, w2 }; - int32_t fx[3], fy[3]; - const int geometry_enabled = gte_geometry_correction_enabled(); gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); - if (!geometry_enabled) { + if (!gte_geometry_correction_enabled()) { gr_set_precise_triangle(0, 0,0, 0,0, 0,0); return; } + const int idx[3] = { i0, i1, i2 }; + int32_t fx[3], fy[3]; + int any_precise = 0; for (int i = 0; i < 3; i++) { + uint32_t word = gp0_cmd_buf[idx[i]]; int32_t raw_x, raw_y; - parse_vertex(words[i], &raw_x, &raw_y); - if (!gte_geometry_correction_lookup(words[i], &fx[i], &fy[i])) { - gr_set_precise_triangle(0, 0,0, 0,0, 0,0); - return; - } - fx[i] = (int32_t)((int64_t)fx[i] + - (int64_t)(vx[i] - raw_x) * 65536); - fy[i] = (int32_t)((int64_t)fy[i] + - (int64_t)(vy[i] - raw_y) * 65536); + parse_vertex(word, &raw_x, &raw_y); + uint32_t addr = (gp0_cmd_source_addr == 0xFFFFFFFFu) + ? 0xFFFFFFFFu + : gp0_cmd_source_addr + (uint32_t)idx[i] * 4u; + int32_t px, py; + uint16_t sz; + if (pgxp_get_precise_vertex(addr, word, raw_x, raw_y, + &px, &py, &sz) != PGXP_SRC_NATIVE) + any_precise = 1; + fx[i] = (int32_t)((int64_t)px + (int64_t)(vx[i] - raw_x) * 65536); + fy[i] = (int32_t)((int64_t)py + (int64_t)(vy[i] - raw_y) * 65536); + } + if (!any_precise) { + gr_set_precise_triangle(0, 0,0, 0,0, 0,0); + return; } gr_set_precise_triangle(1, fx[0],fy[0], fx[1],fy[1], fx[2],fy[2]); } @@ -3061,7 +3303,11 @@ static void raster_pixel(int32_t x, int32_t y, uint16_t color) { * https://github.com/mstan/psxrecomp/pull/73 * Keep that credit with this guarded framebuffer-target variant. */ static inline int32_t draw_area_wide_x_margin(void) { - if (!ws_native_wide_active() || !ws_is_fb_base(draw_area_left)) return 0; + int local_base = 0; + if (!ws_native_wide_active()) return 0; + if (ws_local_viewport_draw_target(&local_base)) return (int32_t)ws_nw_offset(); + if (ws_display_viewport_draw_target(NULL)) return (int32_t)ws_nw_offset(); + if (!ws_is_fb_base(draw_area_left)) return 0; return (int32_t)ws_nw_offset(); } @@ -3209,7 +3455,7 @@ static void gp0_exec_mono_tri(void) { } if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[2], gp0_cmd_buf[3], + prepare_precise_triangle(1, 2, 3, vx, vy); gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); } @@ -3275,15 +3521,13 @@ static void gp0_exec_mono_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[2], - gp0_cmd_buf[3], tx, ty); + prepare_precise_triangle(1, 2, 3, tx, ty); gr_draw_flat_triangle(vx[0], vy[0], vx[1], vy[1], vx[2], vy[2], color); } if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[3], gp0_cmd_buf[2], - gp0_cmd_buf[4], tx, ty); + prepare_precise_triangle(3, 2, 4, tx, ty); gr_draw_flat_triangle(vx[2], vy[2], vx[1], vy[1], vx[3], vy[3], color); } } @@ -3306,7 +3550,7 @@ static void gp0_exec_shaded_tri(void) { } if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], + prepare_precise_triangle(1, 3, 5, vx, vy); gr_draw_gouraud_triangle(vx[0], vy[0], c[0], vx[1], vy[1], c[1], @@ -3352,8 +3596,7 @@ static void gp0_exec_shaded_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], - gp0_cmd_buf[5], tx, ty); + prepare_precise_triangle(1, 3, 5, tx, ty); gr_draw_gouraud_triangle(vx[0], vy[0], c[0], vx[1], vy[1], c[1], vx[2], vy[2], c[2]); @@ -3361,8 +3604,7 @@ static void gp0_exec_shaded_quad(void) { if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[5], gp0_cmd_buf[3], - gp0_cmd_buf[7], tx, ty); + prepare_precise_triangle(5, 3, 7, tx, ty); gr_draw_gouraud_triangle(vx[2], vy[2], c[2], vx[1], vy[1], c[1], vx[3], vy[3], c[3]); @@ -3433,7 +3675,7 @@ static void gp0_exec_textured_tri(void) { if (draw_area_out_bbox(vx, vy, 3)) return; setup_textured_draw(color24, semi_trans, raw_texture); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], gp0_cmd_buf[5], + prepare_precise_triangle(1, 3, 5, vx, vy); prepare_texture_triangle(1, 3, 5); gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], @@ -3515,8 +3757,7 @@ static void gp0_exec_textured_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[3], - gp0_cmd_buf[5], tx, ty); + prepare_precise_triangle(1, 3, 5, tx, ty); prepare_texture_triangle(1, 3, 5); gr_draw_textured_triangle(vx[0], vy[0], u[0], v[0], vx[1], vy[1], u[1], v[1], @@ -3526,8 +3767,7 @@ static void gp0_exec_textured_quad(void) { if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[5], gp0_cmd_buf[3], - gp0_cmd_buf[7], tx, ty); + prepare_precise_triangle(5, 3, 7, tx, ty); prepare_texture_triangle(5, 3, 7); gr_draw_textured_triangle(vx[2], vy[2], u[2], v[2], vx[1], vy[1], u[1], v[1], @@ -3569,7 +3809,7 @@ static void gp0_exec_shaded_textured_tri(void) { if (draw_area_out_bbox(vx, vy, 3)) return; gr_set_semi_transparency(semi_trans, (int)semi_transparency); - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[4], gp0_cmd_buf[7], + prepare_precise_triangle(1, 4, 7, vx, vy); prepare_texture_triangle(1, 4, 7); gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], @@ -3620,8 +3860,7 @@ static void gp0_exec_shaded_textured_quad(void) { if (!rej_a) { int32_t tx[3] = { vx[0], vx[1], vx[2] }; int32_t ty[3] = { vy[0], vy[1], vy[2] }; - prepare_precise_triangle(gp0_cmd_buf[1], gp0_cmd_buf[4], - gp0_cmd_buf[7], tx, ty); + prepare_precise_triangle(1, 4, 7, tx, ty); prepare_texture_triangle(1, 4, 7); gr_draw_shaded_textured_triangle(vx[0], vy[0], u[0], v[0], c[0], vx[1], vy[1], u[1], v[1], c[1], @@ -3631,8 +3870,7 @@ static void gp0_exec_shaded_textured_quad(void) { if (!rej_b) { int32_t tx[3] = { vx[2], vx[1], vx[3] }; int32_t ty[3] = { vy[2], vy[1], vy[3] }; - prepare_precise_triangle(gp0_cmd_buf[7], gp0_cmd_buf[4], - gp0_cmd_buf[10], tx, ty); + prepare_precise_triangle(7, 4, 10, tx, ty); prepare_texture_triangle(7, 4, 10); gr_draw_shaded_textured_triangle(vx[2], vy[2], u[2], v[2], c[2], vx[1], vy[1], u[1], v[1], c[1], @@ -3865,7 +4103,11 @@ static void gp0_exec_fill_rect(void) { /* Native-wide: when the game clears a display buffer, clear the full width * of that buffer's wide surface over the same rows — refreshing the centred * content region and keeping the revealed margins clean. */ - if (ws_native_wide_active() && ws_is_fb_base(dst_x)) { + int local_base = 0; + if (ws_native_wide_active() && + (ws_is_fb_base(dst_x) || + (ws_local_viewport_draw_target(&local_base) && + dst_x == (uint32_t)local_base))) { gr_wide_clear((int)dst_x, (int)dst_y, (int)height, color16); } } @@ -3907,6 +4149,7 @@ static void gp0_exec_draw_area_tl(void) { uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; draw_area_left = param & 0x3FF; draw_area_top = (param >> 10) & 0x3FF; + split_trace_note_draw_area(); gr_set_draw_area((int)draw_area_left, (int)draw_area_top, (int)draw_area_right, (int)draw_area_bottom); ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ @@ -3919,6 +4162,7 @@ static void gp0_exec_draw_area_br(void) { uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; draw_area_right = param & 0x3FF; draw_area_bottom = (param >> 10) & 0x3FF; + split_trace_note_draw_area(); gr_set_draw_area((int)draw_area_left, (int)draw_area_top, (int)draw_area_right, (int)draw_area_bottom); ws_nw_sync_target(); /* back buffer (draw_area_left) → wide mirror surface */ @@ -4254,15 +4498,15 @@ static void ws_ui_prepass_add(const uint32_t *words, uint32_t source_addr, } int32_t width = max_x - min_x, height = max_y - min_y; - int32_t W = ws_disp_w(), H = ws_disp_h(); - if ((min_x <= 0 && max_x >= W && min_y <= 0 && max_y >= H) || + int32_t X = ws_disp_x(), W = ws_disp_w(), H = ws_disp_h(); + if ((min_x <= X && max_x >= X + W && min_y <= 0 && max_y >= H) || (width > W / 2 && height > H / 4)) return; WsUiPrepassItem *item = &ws_ui_prepass[ws_ui_prepass_count++]; item->group.key = ws_auto_ui_group_key_words(words, op, min_y, height); - item->group.x = min_x; + item->group.x = min_x - X; item->group.width = width; item->group.anchor = 0; item->src_addr = source_addr & 0x1FFFFCu; @@ -4343,16 +4587,26 @@ void gpu_ws_prepass_linked_list(uint32_t start_addr) { ws_ui_prepass[out++] = ws_ui_prepass[i]; } ws_ui_prepass_count = out; - ws_auto_ui_dense = ws_ui_prepass_count >= 32u; + /* + * A high final-layer primitive count is a good "dense 2D menu" signal for + * titles without an explicit gameplay detector, where grouping everything + * around the centre avoids tearing text grids apart. For GTE-gated 3D + * titles, though, reaching this path already means a gameplay frame is + * being stretched. WipEout 3's race HUD is dense enough to trip the old + * threshold, which pinned every HUD group to the 4:3 centre. Keep edge + * groups edge-anchored in those frames so the HUD adapts to the wide view. + */ + ws_auto_ui_dense = ws_ui_prepass_count >= 32u && !ws_gte_game_mode_cfg; if (ws_ui_prepass_count == 0) return; + const int32_t group_origin = ws_disp_x(); WsUiGroupItem groups[WS_UI_PREPASS_MAX]; for (uint32_t i = 0; i < ws_ui_prepass_count; i++) groups[i] = ws_ui_prepass[i].group; ws_ui_group_assign(groups, ws_ui_prepass_count, ws_disp_w(), ws_auto_ui_dense); for (uint32_t i = 0; i < ws_ui_prepass_count; i++) - ws_ui_prepass[i].group.anchor = groups[i].anchor; + ws_ui_prepass[i].group.anchor = group_origin + groups[i].anchor; } /* Per-opcode execution counters (exposed via gpu_get_opcode_stats) */ diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index c7b77325..8b34a195 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -983,10 +983,11 @@ static const char *TEX_FS = "uniform usampler2D u_vram;\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 ivec4 u_twin; /* texture window: mask_x, mask_y, off_x, off_y */\n" - "uniform int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" - "uniform int u_filter; /* 1 = bilinear */\n" - "int vram_at(int x, int y){\n" + "uniform ivec4 u_twin; /* texture window: mask_x, mask_y, off_x, off_y */\n" + "uniform int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" + "uniform int u_filter; /* 1 = bilinear */\n" + "uniform float u_shift;\n" + "int vram_at(int x, int y){\n" " return int(texelFetch(u_vram, ivec2(x & 1023, y & 511), 0).r);\n" "}\n" "int fetch_texel(int u, int v){\n" @@ -1027,12 +1028,15 @@ static const char *TEX_FS = " * offset and clamp to u_limits, and each texel's weight is gated by\n" " * its opacity with the colour renormalised — so prim edges and\n" " * cutout borders keep their colour instead of dissolving into the\n" - " * transparent (black) neighbour and discarding whole edge columns. */\n" + " * transparent (black) neighbour and discarding whole edge columns.\n" + " * Recenter from PS1 top-left point sampling to the bilinear footprint. */\n" + " uv += vec2(u_shift);\n" " int iu = int(floor(uv.x)), iv = int(floor(uv.y));\n" " float fx = uv.x - float(iu) - 0.5, fy = uv.y - float(iv) - 0.5;\n" " int sx = fx < 0.0 ? -1 : 1, sy = fy < 0.0 ? -1 : 1;\n" " fx = abs(fx); fy = abs(fy);\n" " int c00 = fetch_texel(iu, iv);\n" + " if (c00 == 0) discard;\n" " int c10 = fetch_texel(iu + sx, iv);\n" " int c01 = fetch_texel(iu, iv + sy);\n" " int c11 = fetch_texel(iu + sx, iv + sy);\n" @@ -1041,7 +1045,6 @@ static const char *TEX_FS = " float w01 = (c01 == 0 ? 0.0 : 1.0) * (1.0 - fx) * fy;\n" " float w11 = (c11 == 0 ? 0.0 : 1.0) * fx * fy;\n" " float opac = w00 + w10 + w01 + w11;\n" - " if (opac < 0.5) discard;\n" " rgb = (col5(c00)*w00 + col5(c10)*w10 + col5(c01)*w01 + col5(c11)*w11) / opac;\n" " float stpf = (float((c00 >> 15) & 1) * w00 + float((c10 >> 15) & 1) * w10\n" " + float((c01 >> 15) & 1) * w01 + float((c11 >> 15) & 1) * w11) / opac;\n" diff --git a/runtime/src/gpu_sw_renderer.c b/runtime/src/gpu_sw_renderer.c index 07f54688..ff40acee 100644 --- a/runtime/src/gpu_sw_renderer.c +++ b/runtime/src/gpu_sw_renderer.c @@ -408,6 +408,7 @@ static uint16_t texel_fetch_bilinear(float fu, float fv, uint16_t texpage, if (fy > 128) fy = 128; uint16_t c00 = bl_fetch(iu, iv, texpage, clut_x, clut_y); + if (c00 == 0) return 0x0000; uint16_t c10 = bl_fetch(iu + su, iv, texpage, clut_x, clut_y); uint16_t c01 = bl_fetch(iu, iv + sv, texpage, clut_x, clut_y); uint16_t c11 = bl_fetch(iu + su, iv + sv, texpage, clut_x, clut_y); @@ -417,7 +418,6 @@ static uint16_t texel_fetch_bilinear(float fu, float fv, uint16_t texpage, int w01 = (c01 ? 1 : 0) * (256 - fx) * fy; int w11 = (c11 ? 1 : 0) * fx * fy; int opac = w00 + w10 + w01 + w11; - if (opac < 32768) return 0x0000; /* opacity < 0.5: transparent */ int r = ((c00 & 0x1F) * w00 + (c10 & 0x1F) * w10 + (c01 & 0x1F) * w01 + (c11 & 0x1F) * w11) / opac; @@ -431,6 +431,11 @@ static uint16_t texel_fetch_bilinear(float fu, float fv, uint16_t texpage, return (uint16_t)(r | (g << 5) | (b << 10) | (stp ? 0x8000 : 0)); } +static inline float bilinear_center_shift_for_target(const RTarget *t) { + int s = (t && t->s > 0) ? t->s : 1; + return 0.5f / (float)s - 1.0f / 64.0f; +} + /* uv sampling bounds (see g_uv_lim): the shared PS1 uv model in gpu_uv.h. * The software DDA truncates exactly like the PS1, so unlike the GL/VK * backends NO mirrored-2D compensation is applied here — these bounds only @@ -982,10 +987,16 @@ static void raster_textured_triangle(const RTarget *t, } } - uint16_t texel = g_texture_filter - ? texel_fetch_bilinear(fu, fv, texpage, clut_x, clut_y) - : texel_fetch((int)fu & 0xFF, (int)fv & 0xFF, texpage, clut_x, clut_y); - put_textured(t, x, y, texel, g_mod_r, g_mod_g, g_mod_b, g_raw_texture); + if (g_texture_filter) { + float sh = bilinear_center_shift_for_target(t); + uint16_t texel = texel_fetch_bilinear(fu + sh, fv + sh, + texpage, clut_x, clut_y); + put_textured(t, x, y, texel, g_mod_r, g_mod_g, g_mod_b, g_raw_texture); + } else { + uint16_t texel = texel_fetch((int)fu & 0xFF, (int)fv & 0xFF, + texpage, clut_x, clut_y); + put_textured(t, x, y, texel, g_mod_r, g_mod_g, g_mod_b, g_raw_texture); + } } } } @@ -1161,10 +1172,16 @@ static void raster_shaded_textured_triangle(const RTarget *t, if (mg < 0) mg = 0; if (mg > 31) mg = 31; if (mb < 0) mb = 0; if (mb > 31) mb = 31; - uint16_t texel = g_texture_filter - ? texel_fetch_bilinear(fu, fv, texpage, clut_x, clut_y) - : texel_fetch((int)fu & 0xFF, (int)fv & 0xFF, texpage, clut_x, clut_y); - put_textured(t, x, y, texel, mr, mg, mb, raw_texture); + if (g_texture_filter) { + float sh = bilinear_center_shift_for_target(t); + uint16_t texel = texel_fetch_bilinear(fu + sh, fv + sh, + texpage, clut_x, clut_y); + put_textured(t, x, y, texel, mr, mg, mb, raw_texture); + } else { + uint16_t texel = texel_fetch((int)fu & 0xFF, (int)fv & 0xFF, + texpage, clut_x, clut_y); + put_textured(t, x, y, texel, mr, mg, mb, raw_texture); + } } } } @@ -1300,7 +1317,8 @@ static void raster_textured_rect(const RTarget *t, int x, int y, int w, int h, uint16_t texel; if (g_texture_filter) { float fu = (float)u + (float)col / (float)s; - texel = texel_fetch_bilinear(fu, fv, texpage, clut_x, clut_y); + float sh = bilinear_center_shift_for_target(t); + texel = texel_fetch_bilinear(fu + sh, fv + sh, texpage, clut_x, clut_y); } else { int tu = (u + col / s) & 0xFF; texel = texel_fetch(tu, tv, texpage, clut_x, clut_y); @@ -1391,7 +1409,8 @@ static void raster_textured_rect_scaled(const RTarget *t, int x, int y, uint16_t texel; if (g_texture_filter) { float fu = (float)u0 + (float)du * (float)col / (float)w; - texel = texel_fetch_bilinear(fu, fv, texpage, clut_x, clut_y); + float sh = bilinear_center_shift_for_target(t); + texel = texel_fetch_bilinear(fu + sh, fv + sh, texpage, clut_x, clut_y); } else { int tu = (int)(u0 + ((int64_t)du * col) / w) & 0xFF; texel = texel_fetch(tu, tv, texpage, clut_x, clut_y); diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 7c8ffd94..4ef73575 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -1,12 +1,14 @@ #include "gte.h" #include "cpu_state.h" #include "nd_intro_ot.h" +#include "pgxp.h" #include #include #include #include extern "C" uint32_t psx_read_word(uint32_t addr); +extern "C" int gpu_ws_precise_nclip_enabled(void); namespace PSXRecomp { namespace GTE { @@ -222,38 +224,16 @@ static uint32_t s_geom_lookups = 0; /* lookups attempted * static uint32_t s_geom_miss_unrec = 0; /* nothing was ever recorded here */ static uint32_t s_geom_miss_ambig = 0; /* recorded, but not unambiguously */ -/* Exact GTE projection provenance for perspective texture correction. The - * recompiler/interpreters call gte_precision_store after SWC2 writes an SXY - * register into guest RAM. GPU DMA later supplies that exact RAM address, so - * depth lookup follows the packet rather than guessing from rounded X/Y. */ -struct PreciseProjection { - uint32_t packed; - int32_t x16, y16; - uint16_t z; - uint8_t valid; -}; -static PreciseProjection s_precise_sxy[4] = {}; - -#define PRECISION_STORE_SIZE 65536u -struct PrecisionStoreEntry { - uint32_t addr; - PreciseProjection projection; - uint32_t generation; -}; -static PrecisionStoreEntry s_precision_store[PRECISION_STORE_SIZE]; -static uint32_t s_precision_generation = 1; -static int s_precision_tracking = 0; -static PreciseProjection s_speculative_saved_sxy[4]; +/* Exact GTE projection provenance now lives in the PGXP value-propagation + * engine (pgxp.cpp): per-word RAM/scratchpad shadows plus per-GPR and per-GTE- + * register shadows, validated on read (ENHANCEMENTS.md G1.2/G1.3). The + * gte_precision_* entry points below are kept as the stable emit/ABI surface + * (v14 swc2 sites, tests) and forward into that engine. The hashed + * s_precision_store table and the s_precise_sxy FIFO it fed are retired — + * the GTE register shadows ARE the FIFO now. */ static uint32_t s_speculative_depth = 0; static int s_speculative_timeline_invalidated = 0; -static void gte_precision_generation_advance(void) { - if (++s_precision_generation == 0) { - std::memset(s_precision_store, 0, sizeof(s_precision_store)); - s_precision_generation = 1; - } -} - static void gte_geom_generation_advance(void) { if (++s_geom_generation == 0) { if (s_geom_cache) @@ -263,95 +243,63 @@ static void gte_geom_generation_advance(void) { } extern "C" void gte_precision_timeline_invalidate(void) { - for (int i = 0; i < 4; ++i) s_precise_sxy[i].valid = 0; /* Raw machine-state restore is authoritative even if host polling reached * it during a speculative validation pass. Defer the generation advance - * until the outer transaction ends so old provenance cannot be restored. */ + * until the outer transaction ends so old provenance cannot be restored. + * (pgxp_invalidate_all defers the same way behind its suppress bracket.) */ + pgxp_invalidate_all(); if (s_speculative_depth != 0) { s_speculative_timeline_invalidated = 1; return; } - gte_precision_generation_advance(); gte_geom_generation_advance(); } extern "C" void gte_precision_speculative_begin(void) { - if (s_speculative_depth++ == 0) { - std::memcpy(s_speculative_saved_sxy, s_precise_sxy, - sizeof(s_precise_sxy)); + if (s_speculative_depth++ == 0) s_speculative_timeline_invalidated = 0; - } + /* The speculative pass runs real GTE commands and hooks on state that is + * rolled back afterwards: suppress all shadow recording so the shadows + * still describe the restored timeline when the bracket closes. */ + pgxp_suppress_begin(); } extern "C" void gte_precision_speculative_end(void) { if (s_speculative_depth == 0) return; + pgxp_suppress_end(); if (--s_speculative_depth == 0) { if (s_speculative_timeline_invalidated) { - gte_precision_generation_advance(); gte_geom_generation_advance(); s_speculative_timeline_invalidated = 0; - } else { - std::memcpy(s_precise_sxy, s_speculative_saved_sxy, - sizeof(s_precise_sxy)); } } } -static inline uint32_t precision_hash(uint32_t addr) { - addr >>= 2; - addr ^= addr >> 11; - return (addr * 2654435761u) & (PRECISION_STORE_SIZE - 1u); -} - -static inline int precision_ram_address(uint32_t addr, uint32_t *physical) { - uint32_t mapped = addr & 0x1FFFFFFFu; - if (mapped >= 0x00800000u) return 0; - *physical = mapped & 0x1FFFFCu; - return 1; -} - extern "C" void gte_precision_tracking_set(int enabled) { - s_precision_tracking = enabled ? 1 : 0; - gte_precision_generation_advance(); + pgxp_set_enabled(enabled); } +/* v14 ABI emit surface: every swc2 site (all backends, overlay DLLs) calls + * this. The pgxp engine copies the GTE register shadow to the RAM shadow; + * the GPU-side lookup validates against the actual packet word, so a stale + * register shadow can never be believed. */ extern "C" void gte_precision_store_word(uint32_t addr, uint8_t reg) { - if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_precision_tracking || - reg < 12 || reg > 15) return; - int index = reg == 15 ? 2 : (int)reg - 12; - const PreciseProjection &projection = s_precise_sxy[index]; - if (!projection.valid) return; - uint32_t physical; - if (!precision_ram_address(addr, &physical)) return; - PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; - entry.addr = physical; - entry.projection = projection; - entry.generation = s_precision_generation; + if (s_gte_replay_sandbox || reg < 12 || reg > 15) return; + pgxp_store_gte_reg(addr, reg); } +/* Retired: plain-store invalidation is unnecessary under validate-on-read + * (the GPU compares the tracked word against the actual packet word). Kept + * as a no-op for link compatibility. */ extern "C" void gte_precision_invalidate_word(uint32_t addr) { - if (s_speculative_depth != 0 || s_gte_replay_sandbox || !s_precision_tracking) return; - uint32_t physical; - if (!precision_ram_address(addr, &physical)) return; - PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; - if (entry.generation == s_precision_generation && entry.addr == physical) - entry.generation = 0; + (void)addr; } extern "C" int gte_precision_load_word(uint32_t addr, uint32_t packed, int32_t *x16, int32_t *y16, uint16_t *z) { - if (s_speculative_depth != 0 || !s_precision_tracking) return 0; - uint32_t physical; - if (!precision_ram_address(addr, &physical)) return 0; - const PrecisionStoreEntry &entry = s_precision_store[precision_hash(physical)]; - if (entry.generation != s_precision_generation || entry.addr != physical || - !entry.projection.valid || entry.projection.packed != packed) - return 0; - if (x16) *x16 = entry.projection.x16; - if (y16) *y16 = entry.projection.y16; - if (z) *z = entry.projection.z; - return entry.projection.z != 0; + if (s_speculative_depth != 0) return 0; + return pgxp_load_precise_word(addr, packed, x16, y16, z); } /* Exact slot for a packed SXY pair, or -1 if it is outside the representable @@ -940,16 +888,9 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) int64_t sx = sx16 >> 16; int64_t sy = sy16 >> 16; gte->push_sxy(sx, sy); - if (!s_gte_replay_sandbox) { - s_precise_sxy[0] = s_precise_sxy[1]; - s_precise_sxy[1] = s_precise_sxy[2]; - s_precise_sxy[2].packed = (uint32_t)gte->SXY[2]; - s_precise_sxy[2].x16 = (int32_t)sx16; - s_precise_sxy[2].y16 = (int32_t)sy16; - s_precise_sxy[2].z = gte->SZ[3]; - s_precise_sxy[2].valid = gte->SZ[3] != 0; - s_precise_sxy[3] = s_precise_sxy[2]; - } + if (!s_gte_replay_sandbox) + pgxp_gte_push_sxy((int32_t)sx16, (int32_t)sy16, gte->SZ[3], + (uint32_t)gte->SXY[2]); geom_note((uint32_t)gte->SXY[2], sx16, sy16); // Step 5: Depth cueing (MAC0/IR0) — only for last vertex of RTPT or RTPS @@ -982,12 +923,24 @@ void gte_rtpt(GTEState* gte, uint32_t instr) { // --------------------------------------------------------------------------- void gte_nclip(GTEState* gte, uint32_t instr) { gte->FLAG = 0; - int16_t sx0 = static_cast(gte->SXY[0] & 0xFFFF); - int16_t sy0 = static_cast(gte->SXY[0] >> 16); - int16_t sx1 = static_cast(gte->SXY[1] & 0xFFFF); - int16_t sy1 = static_cast(gte->SXY[1] >> 16); - int16_t sx2 = static_cast(gte->SXY[2] & 0xFFFF); - int16_t sy2 = static_cast(gte->SXY[2] >> 16); + int32_t sx0 = static_cast(gte->SXY[0] & 0xFFFF); + int32_t sy0 = static_cast(gte->SXY[0] >> 16); + int32_t sx1 = static_cast(gte->SXY[1] & 0xFFFF); + int32_t sy1 = static_cast(gte->SXY[1] >> 16); + int32_t sx2 = static_cast(gte->SXY[2] & 0xFFFF); + int32_t sy2 = static_cast(gte->SXY[2] >> 16); + int32_t px0 = 0, py0 = 0, px1 = 0, py1 = 0, px2 = 0, py2 = 0; + if (gpu_ws_precise_nclip_enabled() && + pgxp_get_gte_sxy(0, &px0, &py0) && + pgxp_get_gte_sxy(1, &px1, &py1) && + pgxp_get_gte_sxy(2, &px2, &py2)) { + sx0 = px0 >> 16; + sy0 = py0 >> 16; + sx1 = px1 >> 16; + sy1 = py1 >> 16; + sx2 = px2 >> 16; + sy2 = py2 >> 16; + } int64_t mac0 = (int64_t)sx0 * (sy1 - sy2) + (int64_t)sx1 * (sy2 - sy0) + (int64_t)sx2 * (sy0 - sy1); @@ -2054,24 +2007,32 @@ extern "C" void gte_write_data(CPUState* cpu, uint8_t reg, uint32_t val) { cpu->gte_data[29] = packed; } break; + /* Guest writes to the SXY FIFO drop the affected register shadows: + * we never model FIFO side effects on shadows — the shifted regs 12/13 + * now describe words their shadows no longer match, and validation + * drops those on next use. */ case 12: case 13: cpu->gte_data[reg] = val; if (!PSXRecomp::GTE::s_gte_replay_sandbox) - for (int i = 0; i < 4; ++i) PSXRecomp::GTE::s_precise_sxy[i].valid = 0; + pgxp_gte_reg_written(reg, val); break; case 14: cpu->gte_data[14] = val; cpu->gte_data[15] = val; - if (!PSXRecomp::GTE::s_gte_replay_sandbox) - for (int i = 0; i < 4; ++i) PSXRecomp::GTE::s_precise_sxy[i].valid = 0; + if (!PSXRecomp::GTE::s_gte_replay_sandbox) { + pgxp_gte_reg_written(14, val); + pgxp_gte_reg_written(15, val); + } break; case 15: cpu->gte_data[12] = cpu->gte_data[13]; cpu->gte_data[13] = cpu->gte_data[14]; cpu->gte_data[14] = val; cpu->gte_data[15] = val; - if (!PSXRecomp::GTE::s_gte_replay_sandbox) - for (int i = 0; i < 4; ++i) PSXRecomp::GTE::s_precise_sxy[i].valid = 0; + if (!PSXRecomp::GTE::s_gte_replay_sandbox) { + pgxp_gte_reg_written(14, val); + pgxp_gte_reg_written(15, val); + } break; case 23: cpu->gte_data[23] = 0; @@ -2134,26 +2095,31 @@ extern "C" void gte_canonicalize_cpu_state(CPUState* cpu) { } #ifdef PSX_GTE_REGISTER_TEST +/* The precise-projection FIFO is the pgxp GTE register shadow (regs 12..15); + * these keep the historical test entry points working over it. */ extern "C" void gte_test_set_precise_valid_mask(uint32_t mask) { for (uint32_t i = 0; i < 4; ++i) - PSXRecomp::GTE::s_precise_sxy[i].valid = (mask >> i) & 1u; + pgxp_test_seed_gte_sxy(i, 0, 0, 0, 1, (mask >> i) & 1u); } extern "C" uint32_t gte_test_get_precise_valid_mask(void) { uint32_t mask = 0; - for (uint32_t i = 0; i < 4; ++i) - mask |= (PSXRecomp::GTE::s_precise_sxy[i].valid ? 1u : 0u) << i; + for (uint32_t i = 0; i < 4; ++i) { + uint8_t valid = 0; + pgxp_test_get_gte_sxy(i, nullptr, nullptr, nullptr, nullptr, &valid); + mask |= (valid ? 1u : 0u) << i; + } return mask; } extern "C" void gte_test_set_timeline_generations(uint32_t precision, uint32_t geometry) { - PSXRecomp::GTE::s_precision_generation = precision; + pgxp_test_set_generation(precision); PSXRecomp::GTE::s_geom_generation = geometry; } extern "C" uint32_t gte_test_get_precision_generation(void) { - return PSXRecomp::GTE::s_precision_generation; + return pgxp_test_generation(); } extern "C" uint32_t gte_test_get_geometry_generation(void) { @@ -2165,13 +2131,7 @@ extern "C" void gte_test_seed_precise_projection(uint32_t index, int32_t x16, int32_t y16, uint16_t z) { - if (index >= 4) return; - auto &p = PSXRecomp::GTE::s_precise_sxy[index]; - p.packed = packed; - p.x16 = x16; - p.y16 = y16; - p.z = z; - p.valid = 1; + pgxp_test_seed_gte_sxy(index, packed, x16, y16, z, 1); } extern "C" void gte_test_get_precise_projection(uint32_t index, @@ -2180,13 +2140,7 @@ extern "C" void gte_test_get_precise_projection(uint32_t index, int32_t* y16, uint16_t* z, uint8_t* valid) { - if (index >= 4) return; - const auto &p = PSXRecomp::GTE::s_precise_sxy[index]; - if (packed) *packed = p.packed; - if (x16) *x16 = p.x16; - if (y16) *y16 = p.y16; - if (z) *z = p.z; - if (valid) *valid = p.valid; + pgxp_test_get_gte_sxy(index, packed, x16, y16, z, valid); } extern "C" void gte_test_seed_geometry(uint32_t packed, int32_t x16, diff --git a/runtime/src/host_keymap.c b/runtime/src/host_keymap.c index ff28ea69..da2bbfbf 100644 --- a/runtime/src/host_keymap.c +++ b/runtime/src/host_keymap.c @@ -18,6 +18,7 @@ typedef struct HostKeyBind { int keycode; + int scancode; int mods; /* KMOD_CTRL | KMOD_ALT | KMOD_SHIFT subset */ } HostKeyBind; @@ -65,32 +66,44 @@ static void clear_all(void) { memset(s_actions, 0, sizeof(s_actions)); } -static void add_bind(HostKeymapAction action, int keycode, int mods) { +static void add_bind(HostKeymapAction action, int keycode, int scancode, int mods) { HostKeyAction *a; if (action < 0 || action >= HOST_KEYMAP_ACTION_COUNT) return; if (keycode == 0 || keycode == SDLK_UNKNOWN) return; a = &s_actions[action]; if (a->count >= HOST_KEYMAP_MAX_BINDS) return; a->binds[a->count].keycode = keycode; + a->binds[a->count].scancode = scancode; a->binds[a->count].mods = mods; a->count++; } static void apply_defaults(void) { + if (s_actions[HOST_KEYMAP_FULLSCREEN].count == 0) { + add_bind(HOST_KEYMAP_FULLSCREEN, (int)SDLK_RETURN, (int)SDL_SCANCODE_RETURN, KMOD_ALT); + add_bind(HOST_KEYMAP_FULLSCREEN, (int)SDLK_f, (int)SDL_SCANCODE_F, KMOD_CTRL); + } + if (s_actions[HOST_KEYMAP_TURBO].count == 0) + add_bind(HOST_KEYMAP_TURBO, (int)SDLK_TAB, (int)SDL_SCANCODE_TAB, 0); if (s_actions[HOST_KEYMAP_VOLUME_UP].count == 0) - add_bind(HOST_KEYMAP_VOLUME_UP, (int)SDLK_KP_PLUS, 0); + add_bind(HOST_KEYMAP_VOLUME_UP, (int)SDLK_KP_PLUS, (int)SDL_SCANCODE_KP_PLUS, 0); if (s_actions[HOST_KEYMAP_VOLUME_DOWN].count == 0) - add_bind(HOST_KEYMAP_VOLUME_DOWN, (int)SDLK_KP_MINUS, 0); + add_bind(HOST_KEYMAP_VOLUME_DOWN, (int)SDLK_KP_MINUS, (int)SDL_SCANCODE_KP_MINUS, 0); + if (s_actions[HOST_KEYMAP_DISPLAY_PERF].count == 0) + add_bind(HOST_KEYMAP_DISPLAY_PERF, (int)SDLK_f, (int)SDL_SCANCODE_F, 0); +#if defined(PSX_HAS_RBENGINE_SNAP) if (s_actions[HOST_KEYMAP_REWIND].count == 0) - add_bind(HOST_KEYMAP_REWIND, (int)SDLK_F8, 0); + add_bind(HOST_KEYMAP_REWIND, (int)SDLK_F8, (int)SDL_SCANCODE_F8, 0); +#endif if (s_actions[HOST_KEYMAP_SAVE_STATE_MENU].count == 0) - add_bind(HOST_KEYMAP_SAVE_STATE_MENU, (int)SDLK_F7, 0); + add_bind(HOST_KEYMAP_SAVE_STATE_MENU, (int)SDLK_F7, (int)SDL_SCANCODE_F7, 0); } /* Parse one "Ctrl+Alt+PageUp" token into key+mods. */ static void parse_one_token(HostKeymapAction action, char *tok) { int mods = 0; SDL_Keycode key; + SDL_Scancode sc; trim_inplace(tok); if (!tok[0] || ieq(tok, "(unbound)") || ieq(tok, "None")) return; for (;;) { @@ -114,7 +127,8 @@ static void parse_one_token(HostKeymapAction action, char *tok) { fprintf(stderr, "host_keymap: unknown key '%s'\n", tok); return; } - add_bind(action, (int)key, mods); + sc = SDL_GetScancodeFromName(tok); + add_bind(action, (int)key, (int)sc, mods); } static void parse_value(HostKeymapAction action, const char *value) { @@ -133,8 +147,11 @@ static void parse_value(HostKeymapAction action, const char *value) { } static HostKeymapAction action_for_key(const char *name) { + if (ieq(name, "Fullscreen")) return HOST_KEYMAP_FULLSCREEN; + if (ieq(name, "Turbo")) return HOST_KEYMAP_TURBO; if (ieq(name, "VolumeUp")) return HOST_KEYMAP_VOLUME_UP; if (ieq(name, "VolumeDown")) return HOST_KEYMAP_VOLUME_DOWN; + if (ieq(name, "DisplayPerf")) return HOST_KEYMAP_DISPLAY_PERF; if (ieq(name, "Rewind")) return HOST_KEYMAP_REWIND; if (ieq(name, "SaveStateMenu")) return HOST_KEYMAP_SAVE_STATE_MENU; return HOST_KEYMAP_ACTION_COUNT; @@ -201,6 +218,21 @@ int host_keymap_match(HostKeymapAction action, int keycode, int mod) { return 0; } +int host_keymap_down(HostKeymapAction action, const uint8_t *keys, int mod) { + const HostKeyAction *a; + const int relevant = (int)(KMOD_CTRL | KMOD_ALT | KMOD_SHIFT); + int i; + if (!keys || action < 0 || action >= HOST_KEYMAP_ACTION_COUNT) return 0; + a = &s_actions[action]; + for (i = 0; i < a->count; i++) { + const int sc = a->binds[i].scancode; + if (sc <= 0 || sc >= SDL_NUM_SCANCODES) continue; + if (!keys[sc]) continue; + if ((mod & relevant) == a->binds[i].mods) return 1; + } + return 0; +} + /* Rewind overlay FONT8 only draws ASCII 32..90 (space..Z); lowercase is * uppercased by the drawer. Map punctuation / odd SDL single-glyph names to * short tokens so binds like backtick don't render as "?". */ @@ -257,9 +289,12 @@ const char *host_keymap_label(HostKeymapAction action, char *out, size_t cap) { if (action < 0 || action >= HOST_KEYMAP_ACTION_COUNT) return out; a = &s_actions[action]; if (a->count <= 0) { +#if defined(PSX_HAS_RBENGINE_SNAP) if (action == HOST_KEYMAP_REWIND) snprintf(out, cap, "F8"); - else if (action == HOST_KEYMAP_SAVE_STATE_MENU) + else +#endif + if (action == HOST_KEYMAP_SAVE_STATE_MENU) snprintf(out, cap, "F7"); return out; } diff --git a/runtime/src/host_osd.c b/runtime/src/host_osd.c index 63d27505..62c387d0 100644 --- a/runtime/src/host_osd.c +++ b/runtime/src/host_osd.c @@ -130,6 +130,10 @@ static Uint32 s_expire_ms; static int s_active; 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 int s_volume = 100; /* host master 0..100 */ static int s_vol_pct = 100; /* last shown bar fill */ static Uint32 s_vol_expire_ms; @@ -197,8 +201,8 @@ static int vol_visible(void) { return 1; } -static void rasterize_text(void) { - int n = (int)strlen(s_msg); +static void rasterize_text(const char *msg) { + int n = (int)strlen(msg); if (n > OSD_MAX_CHARS) n = OSD_MAX_CHARS; 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; @@ -211,7 +215,7 @@ static void rasterize_text(void) { s_img[i] = 0xFF202020u; for (int ci = 0; ci < n; ci++) { - unsigned char ch = (unsigned char)s_msg[ci]; + unsigned char ch = (unsigned char)msg[ci]; if (ch < 32 || ch > 126) ch = '?'; const uint8_t *g = FONT8X8[ch - 32]; for (int row = 0; row < OSD_GLYPH_H; row++) { @@ -338,6 +342,25 @@ void host_osd_push(const char *msg, int duration_ms) { #endif } +void host_osd_set_status(const char *msg) { +#if !HOST_OSD_VISUAL + (void)msg; + return; +#else + if (!msg || !msg[0]) { + if (s_status_active) s_needs_clear = 1; + s_status_msg[0] = '\0'; + s_status_active = 0; + s_status_dirty = 1; + return; + } + snprintf(s_status_msg, sizeof(s_status_msg), "%s", msg); + s_status_active = 1; + s_status_dirty = 1; + s_needs_clear = 0; +#endif +} + void host_osd_show_volume(int percent, int duration_ms) { #if !HOST_OSD_VISUAL (void)percent; @@ -375,7 +398,7 @@ int host_osd_needs_present(void) { #if !HOST_OSD_VISUAL return 0; #else - if (msg_visible() || vol_visible()) return 1; + if (msg_visible() || s_status_active || vol_visible()) return 1; return s_needs_clear; #endif } @@ -387,13 +410,21 @@ int host_osd_image(const uint32_t **pixels, int *w, int *h) { if (h) *h = 0; return 0; #else - if (!msg_visible()) { + const int show_msg = msg_visible(); + if (!show_msg && !s_status_active) { if (pixels) *pixels = NULL; if (w) *w = 0; if (h) *h = 0; return 0; } - if (s_img_dirty) rasterize_text(); + 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 (pixels) *pixels = s_img; if (w) *w = s_img_w; if (h) *h = s_img_h; @@ -426,7 +457,7 @@ void host_osd_present_done(void) { #if !HOST_OSD_VISUAL return; #else - if (!s_active && !s_vol_active) s_needs_clear = 0; + if (!s_active && !s_status_active && !s_vol_active) s_needs_clear = 0; #endif } diff --git a/runtime/src/iso_reader.cpp b/runtime/src/iso_reader.cpp index 4a76158c..c4774bb1 100644 --- a/runtime/src/iso_reader.cpp +++ b/runtime/src/iso_reader.cpp @@ -22,6 +22,33 @@ constexpr size_t RAW_DATA_OFFSET = 24; // Primary Volume Descriptor location constexpr uint32_t PVD_SECTOR = 16; +constexpr uint32_t CD_FRAMES_PER_SECOND = 75; +constexpr uint32_t CD_FRAMES_PER_MINUTE = 60 * CD_FRAMES_PER_SECOND; +constexpr uint32_t CD_LEAD_IN_FRAMES = 2 * CD_FRAMES_PER_SECOND; + +static bool valid_bcd(uint8_t value) { + return (value & 0x0f) <= 9 && (value >> 4) <= 9; +} + +static uint32_t bcd_to_binary(uint8_t value) { + return (value >> 4) * 10u + (value & 0x0f); +} + +static uint8_t binary_to_bcd(uint32_t value) { + return static_cast(((value / 10u) << 4) | (value % 10u)); +} + +static uint16_t subq_crc(const uint8_t* data) { + uint16_t crc = 0; + for (int i = 0; i < 10; ++i) { + crc ^= static_cast(data[i]) << 8; + for (int bit = 0; bit < 8; ++bit) + crc = (crc & 0x8000) ? static_cast((crc << 1) ^ 0x1021) + : static_cast(crc << 1); + } + return static_cast(~crc); +} + enum class CHDTrackMode { Mode1, Mode1Raw, @@ -236,6 +263,10 @@ bool ISOReader::Open(const std::string& filename) { root_dir_.lba = 0; root_dir_.size = 0; } + if (!LoadSBICompanion(filename)) { + Close(); + return false; + } return true; } @@ -331,14 +362,19 @@ bool ISOReader::Open(const std::string& filename) { // Parse the volume descriptor to extract filesystem metadata when // present. Runtime CD-ROM access only needs sector reads, so keep the // image mounted even if an ISO9660 header is missing or nonstandard. - if (!ParseVolumeDescriptor()) { + if (!ParseVolumeDescriptor()) { volume_id_.clear(); root_dir_.lba = 0; root_dir_.size = 0; - } - - return true; -} + } + + if (!LoadSBICompanion(filename)) { + Close(); + return false; + } + + return true; +} void ISOReader::Close() { chd_.reset(); @@ -348,13 +384,87 @@ void ISOReader::Close() { } } segments_.clear(); - tracks_.clear(); + tracks_.clear(); + subq_replacements_.clear(); bin_path_.clear(); volume_id_.clear(); root_dir_.lba = 0; root_dir_.size = 0; is_open_ = false; -} +} + +bool ISOReader::LoadSBICompanion(const std::string& image_path) { + std::filesystem::path path(image_path); + path.replace_extension(".sbi"); + if (!std::filesystem::exists(path)) return true; + + std::ifstream file(path, std::ios::binary); + if (!file.is_open()) return false; + char header[4] = {}; + file.read(header, sizeof(header)); + if (file.gcount() != sizeof(header) || + std::memcmp(header, "SBI\0", sizeof(header)) != 0) { + return false; + } + + while (true) { + uint8_t record[14] = {}; + file.read(reinterpret_cast(record), sizeof(record)); + if (file.gcount() == 0 && file.eof()) break; + if (file.gcount() != sizeof(record) || record[3] != 1 || + !valid_bcd(record[0]) || !valid_bcd(record[1]) || + !valid_bcd(record[2])) { + return false; + } + const uint32_t absolute_frame = + bcd_to_binary(record[0]) * CD_FRAMES_PER_MINUTE + + bcd_to_binary(record[1]) * CD_FRAMES_PER_SECOND + + bcd_to_binary(record[2]); + if (absolute_frame < CD_LEAD_IN_FRAMES) return false; + const uint32_t lba = absolute_frame - CD_LEAD_IN_FRAMES; + std::array subq = {}; + std::memcpy(subq.data(), record + 4, 10); + const uint16_t invalid_crc = static_cast(subq_crc(subq.data()) ^ 0xffff); + subq[10] = static_cast(invalid_crc); + subq[11] = static_cast(invalid_crc >> 8); + if (!subq_replacements_.emplace(lba, subq).second) return false; + } + return !subq_replacements_.empty(); +} + +bool ISOReader::ReadSubChannelQ(uint32_t lba, uint8_t* buffer, bool* valid) const { + if (!buffer || !valid || !is_open_) return false; + const auto replacement = subq_replacements_.find(lba); + if (replacement != subq_replacements_.end()) { + std::memcpy(buffer, replacement->second.data(), replacement->second.size()); + *valid = false; + return true; + } + + int track = 1; + for (const CDTrack& candidate : tracks_) { + if (candidate.start_lba > lba) break; + track = candidate.number; + } + const uint32_t track_lba = TrackStartLBA(track); + const uint32_t relative = lba >= track_lba ? lba - track_lba : 0; + const uint32_t absolute = lba + 150; + std::memset(buffer, 0, 12); + buffer[0] = TrackIsAudio(track) ? 0x01 : 0x41; + buffer[1] = binary_to_bcd(static_cast(track)); + buffer[2] = 0x01; + buffer[3] = binary_to_bcd(relative / CD_FRAMES_PER_MINUTE); + buffer[4] = binary_to_bcd((relative / CD_FRAMES_PER_SECOND) % 60); + buffer[5] = binary_to_bcd(relative % CD_FRAMES_PER_SECOND); + buffer[6] = binary_to_bcd(absolute / CD_FRAMES_PER_MINUTE); + buffer[7] = binary_to_bcd((absolute / CD_FRAMES_PER_SECOND) % 60); + buffer[8] = binary_to_bcd(absolute % CD_FRAMES_PER_SECOND); + const uint16_t crc = subq_crc(buffer); + buffer[10] = static_cast(crc); + buffer[11] = static_cast(crc >> 8); + *valid = true; + return true; +} BinSegment* ISOReader::SegmentForLBA(uint32_t lba) { // Linear scan: images carry a handful of segments (one per track file). diff --git a/runtime/src/iso_reader_c.cpp b/runtime/src/iso_reader_c.cpp index 13c0beb2..0dc8e173 100644 --- a/runtime/src/iso_reader_c.cpp +++ b/runtime/src/iso_reader_c.cpp @@ -36,6 +36,20 @@ int iso_read_raw_sector(void* handle, uint32_t lba, uint8_t* buffer, int size) { return 1; } +int iso_read_subq(void* handle, uint32_t lba, uint8_t* buffer, int size, + int* valid) { + if (!handle || !buffer || size < 12 || !valid) return 0; + bool crc_valid = false; + if (!static_cast(handle)->ReadSubChannelQ( + lba, buffer, &crc_valid)) return 0; + *valid = crc_valid ? 1 : 0; + return 1; +} + +int iso_has_subq_replacements(void* handle) { + return handle && static_cast(handle)->HasSubChannelReplacements(); +} + uint32_t iso_sector_count(void* handle) { if (!handle) return 0; auto* reader = static_cast(handle); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 0807cce1..ab9410e0 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -32,6 +32,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "overlay_backend.h" #include "gpu.h" +#include "pgxp.h" #include "interrupts.h" #include "present_ring.h" #include "load_transition_ring.h" @@ -89,6 +90,14 @@ extern "C" void psx_game_codegen_forward_if_built(int argc, char** argv); #endif #endif #include "psx_sdl.h" +#if defined(PSX_SDL3) +/* + * SDL_main.h is a single-header implementation in SDL3. Keep it in the one + * translation unit that defines main(); including it through psx_sdl.h makes + * every SDL-using source emit WinMain under MinGW. + */ +#include +#endif #include "psx_sdl_audio.h" #if defined(PSX_WEB) #include @@ -465,6 +474,7 @@ static int post_load_probe_env_on(void) { static Uint64 s_fps_last_time = 0; static uint64_t s_fps_last_frame = 0; static std::string s_fps_base_title; +static int s_fps_telemetry_enabled = -1; /* -1 = unread env */ static FramePacer s_frame_pacer = { 0 }; static int s_turbo_present_skip = 0; static int s_fmv_skip_present_skip = 0; @@ -517,6 +527,46 @@ static void present_session_reset(void) { smooth_60_reset(); } +static int fps_telemetry_enabled(void) { + if (s_fps_telemetry_enabled < 0) { + const char *e = std::getenv("PSX_FPS_TELEMETRY"); + s_fps_telemetry_enabled = (e && e[0] && e[0] != '0') ? 1 : 0; + } + return s_fps_telemetry_enabled; +} + +static void fps_telemetry_toggle(void) { + const int enabled = fps_telemetry_enabled() ? 0 : 1; + s_fps_telemetry_enabled = enabled; + s_fps_last_time = 0; + s_fps_last_frame = 0; + if (!enabled && sdl_window && !s_fps_base_title.empty()) + SDL_SetWindowTitle(sdl_window, s_fps_base_title.c_str()); + if (!enabled) + host_osd_set_status(NULL); + s_fps_base_title.clear(); + host_osd_push(enabled ? "FPS readout on" : "FPS readout off", 1500); +} + +static int manual_fast_forward_multiplier(void) { + static int value = -2; /* -2 = unread, -1 = max/unbounded */ + if (value == -2) { + const char *e = std::getenv("PSX_FAST_FORWARD_SPEED"); + value = 4; + if (e && e[0]) { + if (std::strcmp(e, "max") == 0 || std::strcmp(e, "MAX") == 0 || + std::strcmp(e, "0") == 0) { + value = -1; + } else { + int parsed = std::atoi(e); + if (parsed >= 2 && parsed <= 16) + value = parsed; + } + } + } + return value; +} + static void post_load_probe_stall_pc_note(uint32_t pc) { if (!pc) return; for (int i = 0; i < s_stall_pc_used; i++) { @@ -1059,12 +1109,15 @@ static int g_video_texfilter = 0; /* 0=nearest, 1=bilinear */ * SXY readback are untouched. Default off = the faithful floor. */ static int g_video_geometry_correction = 0; static int g_video_perspective_texturing = 0; +static int g_video_pgxp_cpu_mode = 0; +static float g_video_pgxp_tolerance = 0.5f; static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderless (desktop) * fullscreen, 2 exclusive fullscreen */ static int g_video_screen = 0; /* 0=raw,1=crt,2=composite,3=trinitron */ static int g_video_win_w = 1280; /* window width (height follows aspect) */ static bool g_audio_spu_hq = false; /* SPU float-shadow (env overrides) */ +static int g_audio_freq = 44100; /* host device request */ static int g_auto_skip_fmv = 0; /* skip FMVs the instant they're detected */ static int g_rewind_depth = 50; /* local rewind snap count (50/100/150/200) */ static int g_rewind_interval = 15; /* frames between snaps (1/4/8/12/15) */ @@ -1172,6 +1225,13 @@ static int g_video_aspect_den = 3; static bool g_ws_adaptive_view = false; static int g_ws_adaptive_max_num = 16; static int g_ws_adaptive_max_den = 9; +/* game.toml [netplay] local_viewport = "vertical_split": during real netplay, + * present only this peer's native split-screen half and stretch it to the + * window. Presentation-only; the guest still renders the original framebuffer. */ +static int g_netplay_local_viewport = 0; /* 0 off, 1 vertical split */ +/* Optional aspect for netplay local-view extraction. Mirrors trusted mod aspect + * activation, but remains game.toml opt-in so normal netplay stays vanilla. */ +static int g_netplay_local_viewport_aspect = 0; /* 0 off, 1 16:9, 2 21:9, 3 adaptive */ extern "C" int psx_mod_set_fixed_display_aspect( uint32_t numerator, uint32_t denominator) { @@ -1374,6 +1434,10 @@ static bool g_ws_hud_sprt = false; /* Runtime-only transition cleanup; kept out of gpu.h because generated game * units include that ABI header and do not need this frontend-only setter. */ extern "C" void gpu_ws_set_clear_reveal(int on); +extern "C" void gpu_ws_set_precise_nclip(int on); +extern "C" void gpu_ws_set_netplay_local_viewport(int enabled, int slot); +extern "C" int gpu_ws_netplay_local_viewport_base_x(void); +extern "C" int gpu_ws_netplay_local_viewport_width(void); extern "C" void gpu_ws_set_nw_textured_edges(int on, int scale_pct); extern "C" void gpu_ws_set_signed_x_bound_sites(const uint32_t*, const uint32_t*, int); /* Widescreen engages at game entry (fntrace_is_game_started): the BIOS boot @@ -1409,6 +1473,87 @@ static int aspect_gcd(int a, int b) { return a > 0 ? a : 1; } +static int64_t aspect_gcd64(int64_t a, int64_t b) { + if (a < 0) a = -a; + if (b < 0) b = -b; + while (b) { int64_t t = a % b; a = b; b = t; } + return a > 0 ? a : 1; +} + +static void netplay_local_viewport_projection_aspect( + int present_num, int present_den, int* proj_num, int* proj_den) { + if (!proj_num || !proj_den) return; + *proj_num = present_num; + *proj_den = present_den; + + if (g_netplay_local_viewport != 1 || + !psx_netplay_active() || + !gpu_last_frame_vertical_split_screen() || + present_num * 3 <= present_den * 4) { + return; + } + + GpuDisplayInfo di; + gpu_get_display_info(&di); + if (di.disabled || di.depth24 || di.width < 2 || di.height == 0) + return; + + /* The normal widescreen squash assumes a 4:3 source. A split-screen peer + * source is only half the display width, so derive the equivalent aspect + * that produces source_aspect / target_aspect as the X squash factor: + * + * source = (display_w / 2) / display_h + * effective = (4:3) * target / source + */ + int64_t n = (int64_t)present_num * 8 * (int64_t)di.height; + int64_t d = (int64_t)present_den * 3 * (int64_t)di.width; + if (n <= 0 || d <= 0) return; + int64_t gcd = aspect_gcd64(n, d); + n /= gcd; + d /= gcd; + if (n <= 0 || d <= 0 || n > INT32_MAX || d > INT32_MAX) + return; + + *proj_num = (int)n; + *proj_den = (int)d; +} + +static int g_ws_projection_num = 4; +static int g_ws_projection_den = 3; +static int g_ws_projection_mode = -1; +static void refresh_widescreen_projection() { + if (!g_ws_engaged) return; + + const bool wide = g_video_aspect_num * 3 != g_video_aspect_den * 4; + const bool local_native_wide = + g_netplay_local_viewport == 1 && psx_netplay_active() && + gpu_last_frame_vertical_split_screen(); + const bool native_wide = (g_netplay_local_viewport == 1) + ? local_native_wide + : (g_ws_native_wide != 0); + const int mode = wide ? (native_wide ? 2 : 1) : 0; + int proj_num = g_video_aspect_num; + int proj_den = g_video_aspect_den; + if (mode == 1) { + netplay_local_viewport_projection_aspect( + g_video_aspect_num, g_video_aspect_den, &proj_num, &proj_den); + } + + if (mode == g_ws_projection_mode && + proj_num == g_ws_projection_num && + proj_den == g_ws_projection_den) { + return; + } + + g_ws_projection_mode = mode; + g_ws_projection_num = proj_num; + g_ws_projection_den = proj_den; + gte_set_display_aspect(mode == 1 ? proj_num : 4, + mode == 1 ? proj_den : 3); + gpu_ws_configure(proj_num, proj_den, g_ws_anchor_addr, + g_ws_hud_sprt ? 1 : 0, mode); +} + /* Follow the host window without feeding its absolute pixel size into guest * rendering. Only the ratio matters: gpu_ws_configure derives the PSX-native * sidecar width from it, just as the fixed 16:9/21:9 modes do. */ @@ -1441,14 +1586,7 @@ static void update_adaptive_widescreen() { SDL_RenderSetLogicalSize(sdl_renderer, g_logical_w, 480 * g_video_scale); } - if (g_ws_engaged) { - const bool wide = num * 3 != den * 4; - const int mode = wide ? (g_ws_native_wide ? 2 : 1) : 0; - gte_set_display_aspect(mode == 1 ? num : 4, - mode == 1 ? den : 3); - gpu_ws_configure(num, den, g_ws_anchor_addr, - g_ws_hud_sprt ? 1 : 0, mode); - } + refresh_widescreen_projection(); } /* SDL GL attributes are global inputs to the next context creation. Set the @@ -1466,13 +1604,8 @@ static void configure_core_gl_context_attributes() { * Re-engages with the chosen mode in place if widescreen is already running. */ extern "C" void psx_ws_set_native_wide(int on) { g_ws_native_wide = on ? 1 : 0; - if (g_ws_engaged && g_video_aspect_num * 3 != g_video_aspect_den * 4) { - int mode = g_ws_native_wide ? 2 : 1; - gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4, - mode == 1 ? g_video_aspect_den : 3); - gpu_ws_configure(g_video_aspect_num, g_video_aspect_den, - g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); - } + g_ws_projection_mode = -1; + refresh_widescreen_projection(); } extern "C" int psx_ws_get_native_wide(void) { return g_ws_native_wide; } @@ -2362,6 +2495,31 @@ static void shutdown_runtime(void); static int g_netplay_from_lobby = 0; static int g_netplay_vsync_forced_off = 0; +static void apply_netplay_local_viewport_aspect(bool netplay_enabled) { + if (!netplay_enabled || + g_netplay_local_viewport != 1 || + g_netplay_local_viewport_aspect == 0) { + gpu_ws_set_netplay_local_viewport(0, 0); + return; + } + + gpu_ws_set_netplay_local_viewport(1, psx_netplay_local_slot()); + switch (g_netplay_local_viewport_aspect) { + case 1: + (void)psx_mod_set_fixed_display_aspect(16u, 9u); + break; + case 2: + (void)psx_mod_set_fixed_display_aspect(21u, 9u); + break; + case 3: + (void)psx_mod_set_fixed_display_aspect(16u, 9u); + (void)psx_mod_set_adaptive_display_aspect(21u, 9u); + break; + default: + break; + } +} + /* Host-only: lockstep already couples peers. Driver vsync on top of the * wall-clock pacer double-blocks the vblank callback (present is before the * guest resumes), which shows up as MotK FMV ~30–40 FPS in netplay vs ~50+ @@ -4659,6 +4817,36 @@ static void netplay_note_present(void) { s_present_last_ms = now ? now : 1ull; } +static int netplay_local_viewport_slot(void) { + if (g_netplay_local_viewport != 1 || !psx_netplay_active()) + return -1; + if (!gpu_last_frame_vertical_split_screen()) + return -1; + int slot = psx_netplay_local_slot(); + return (slot == 0 || slot == 1) ? slot : -1; +} + +static int crop_present_to_netplay_local_viewport(uint32_t* pixels, + int* width, + int height) { + if (!pixels || !width || *width < 2 || height <= 0) + return 0; + const int slot = netplay_local_viewport_slot(); + if (slot < 0) + return 0; + + const int src_w = *width; + const int crop_w = src_w / 2; + const int crop_x = slot == 0 ? 0 : (src_w - crop_w); + for (int y = 0; y < height; ++y) { + uint32_t* dst = pixels + (size_t)y * (size_t)crop_w; + uint32_t* src = pixels + (size_t)y * (size_t)src_w + crop_x; + memmove(dst, src, (size_t)crop_w * sizeof(uint32_t)); + } + *width = crop_w; + return 1; +} + static void netplay_present_gap_stats(uint32_t *p95_out, uint32_t *max_out) { unsigned n = s_present_gaps_n; unsigned idx; @@ -5373,20 +5561,19 @@ static void savestate_menu_move(int delta) { savestate_menu_sync_overlay(); } -static void savestate_menu_submit(int save) { - const int slot = savestate_menu_slot; +static int savestate_submit_slot(int slot, int save) { if (!save && !savestate_slot_exists(slot)) { char msg[32]; snprintf(msg, sizeof(msg), "Slot %d is empty", slot + 1); host_osd_push(msg, 1200); - return; + return 0; } if (!save) savestate_input_guard_arm(); if (psx_netplay_active()) { if (!psx_netplay_is_host()) { host_osd_push("Save states are host-only in netplay", 1500); - return; + return 0; } if (save) (void)psx_netplay_request_save(slot); @@ -5397,8 +5584,14 @@ static void savestate_menu_submit(int save) { } else { (void)savestate_request_load(slot); } - savestate_menu_open = 0; - savestate_menu_sync_overlay(); + return 1; +} + +static void savestate_menu_submit(int save) { + if (savestate_submit_slot(savestate_menu_slot, save) && savestate_menu_open) { + savestate_menu_open = 0; + savestate_menu_sync_overlay(); + } } static int savestate_menu_slot_from_key(SDL_Keycode key) { @@ -5713,14 +5906,14 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * than presents so turbo and skipped-frame modes still report game speed. * Skip during netplay post-load barrier — admit is stalled and the window * is not updating, so a climbing FPS line is misleading. */ - if (!psx_netplay_in_load_barrier()) { + if (fps_telemetry_enabled() && !psx_netplay_in_load_barrier()) { extern uint64_t s_frame_count; const Uint64 now = SDL_GetPerformanceCounter(); const Uint64 frequency = SDL_GetPerformanceFrequency(); if (!s_fps_last_time) { s_fps_last_time = now; s_fps_last_frame = s_frame_count; - if (sdl_window) { + if (sdl_window && s_fps_base_title.empty()) { const char *title = SDL_GetWindowTitle(sdl_window); if (title) s_fps_base_title = title; } @@ -5728,12 +5921,36 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { const double seconds = (double)(now - s_fps_last_time) / (double)frequency; const double fps = (double)(s_frame_count - s_fps_last_frame) / seconds; const double speed = fps / 59.94; + double display_fps = 0.0; + if (g_frame_interpolation && g_gl_active) { + display_fps = g_frame_interpolation_fps > 0 + ? (double)g_frame_interpolation_fps + : g_host_refresh_hz; + } if (!g_headless && sdl_window) { char title[256]; - snprintf(title, sizeof(title), "%s [%.0f fps %.2fx]", - s_fps_base_title.c_str(), fps, speed); + if (display_fps > 0.0) { + snprintf(title, sizeof(title), + "%s [Game %.0f fps %.2fx | Display %.0f fps]", + s_fps_base_title.c_str(), fps, speed, display_fps); + } else { + snprintf(title, sizeof(title), "%s [Game %.0f fps %.2fx]", + s_fps_base_title.c_str(), fps, speed); + } SDL_SetWindowTitle(sdl_window, title); } + if (!g_headless) { + char osd[96]; + if (display_fps > 0.0) { + snprintf(osd, sizeof(osd), + "Game %.0f FPS %.2fx | Display %.0f FPS", + fps, speed, display_fps); + } else { + snprintf(osd, sizeof(osd), "Game %.0f FPS %.2fx", + fps, speed); + } + host_osd_set_status(osd); + } if (netplay_timing_on() && s_np_timing_frames > 0) { const double invf = 1000.0 / (double)frequency; const double admit_ms = @@ -5851,6 +6068,11 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { debug_force_cd_reinsert(); host_osd_push("CD reinsert", 1500); } + else if (!key_repeat && + host_keymap_match(HOST_KEYMAP_DISPLAY_PERF, (int)key, + (int)mod)) { + fps_telemetry_toggle(); + } /* Host volume: config.ini [KeyMap] VolumeUp/VolumeDown * (defaults: keypad +/-). 5% steps; shows right-side bar. */ else if (host_keymap_match(HOST_KEYMAP_VOLUME_UP, (int)key, @@ -5868,8 +6090,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * set in both SDL_WINDOW_FULLSCREEN and * SDL_WINDOW_FULLSCREEN_DESKTOP, so testing just that bit * detects "currently fullscreen, either mode". */ - else if ((key == SDLK_RETURN && (mod & KMOD_ALT)) || - (key == SDLK_f && (mod & (KMOD_GUI | KMOD_CTRL)))) { + else if (!key_repeat && + host_keymap_match(HOST_KEYMAP_FULLSCREEN, (int)key, + (int)mod)) { Uint32 is_fs = SDL_GetWindowFlags(sdl_window) & SDL_WINDOW_FULLSCREEN; if (is_fs) { @@ -6073,23 +6296,45 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { } #endif - /* Turbo mode: while TAB is held, skip both VRAM->ARGB conversion and - * SDL_RenderPresent. The recompiled BIOS still advances simulated - * cycles every vblank, so the BIOS proceeds at whatever rate the host - * CPU sustains without graphics-driver vsync overhead. Present once - * every TURBO_PRESENT_EVERY frames so the user sees visual progress. */ + bool manual_turbo_active = false; + bool turbo_load_paced = false; + + /* Manual fast-forward: bounded by default so the game visibly advances and + * audio is less hostile. PSX_FAST_FORWARD_SPEED=2..16 changes the cap; + * PSX_FAST_FORWARD_SPEED=max restores the old unbounded simulation rate. */ { const Uint8* keys = SDL_GetKeyboardState(NULL); static int turbo_skip = 0; - const int TURBO_PRESENT_EVERY = 30; - if (keys[SDL_SCANCODE_TAB]) { - turbo_skip = (turbo_skip + 1) % TURBO_PRESENT_EVERY; + static int turbo_was_down = 0; + if (host_keymap_down(HOST_KEYMAP_TURBO, keys, (int)SDL_GetModState())) { + const int mult = manual_fast_forward_multiplier(); + const int present_every = (mult < 0) ? 4 : (mult <= 4 ? 2 : 4); + manual_turbo_active = true; + if (!turbo_was_down) { + char msg[40]; + if (mult < 0) + snprintf(msg, sizeof(msg), "Fast forward: max"); + else + snprintf(msg, sizeof(msg), "Fast forward: %dx", mult); + host_osd_push(msg, 900); + } + turbo_was_down = 1; + if (mult >= 2 && g_frame_period_ms > 0.0) { + uint64_t perf_start = runtime_perf_section_begin(); + frame_pacer_wait(&s_frame_pacer, + g_frame_period_ms / (double)mult); + runtime_perf_section_end(perf_start, + &g_runtime_perf.pacer_ticks); + latency_ring_mark(LAT_PACED); + } + turbo_skip = (turbo_skip + 1) % present_every; if (turbo_skip != 0) { ep.skip_pace = 1; return ep; /* skip render this frame */ } } else { turbo_skip = 0; + turbo_was_down = 0; } } @@ -6101,7 +6346,6 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * real time (2.2-4.8 sectors/frame against a 32-256 IRQ budget), so * host-speed execution is the lever that compresses load wall-time. * Presents 1-in-30 so visual progress stays visible. */ - bool turbo_load_paced = false; if (turbo_loads_active) { g_turbo_loads_frames++; /* A mod may request a bounded wall-clock multiplier instead of the @@ -6176,7 +6420,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * resim. */ if (!psx_netplay_active() && !psx_selfcheck_resim_active()) { uint64_t perf_start = runtime_perf_section_begin(); - if (!turbo_load_paced && present_should_wall_pace()) + if (!manual_turbo_active && !turbo_load_paced && present_should_wall_pace()) frame_pacer_wait(&s_frame_pacer, g_frame_period_ms); runtime_perf_section_end(perf_start, &g_runtime_perf.pacer_ticks); latency_ring_mark(LAT_PACED); @@ -6214,14 +6458,11 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { extern int fntrace_is_game_started(void); if (fntrace_is_game_started()) { g_ws_engaged = true; - int mode = g_ws_native_wide ? 2 : 1; - /* Native-wide: GTE drawn un-squashed — feed it the 4:3 ratio - * (identity squash). Squash mode: feed the real wide aspect. */ - gte_set_display_aspect(mode == 1 ? g_video_aspect_num : 4, - mode == 1 ? g_video_aspect_den : 3); - gpu_ws_configure(g_video_aspect_num, g_video_aspect_den, - g_ws_anchor_addr, g_ws_hud_sprt ? 1 : 0, mode); + g_ws_projection_mode = -1; + refresh_widescreen_projection(); } + } else { + refresh_widescreen_projection(); } /* Rollback resim (§33/§47): short catch-up keeps hold-last; long catch-up @@ -6276,6 +6517,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { bool pin_43 = false; /* pillarbox this present (FMV, or a native-wide game frame that could not present wide) */ bool depth24_frame = false; + bool local_viewport_crop_applied = false; if (s_force_present_after_load && g_gl_active) gl_renderer_flush_cpu_uploads(); { @@ -6330,6 +6572,11 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { if (g_gl_active) gl_renderer_set_interpolation_suspended( fmv_frame || mdec_recently_active(2)); + const int local_viewport_slot = netplay_local_viewport_slot(); + const bool local_viewport_crop = local_viewport_slot >= 0; + bool local_viewport_wide = + local_viewport_crop && g_ws_engaged && ws_native_wide_active() && + gr_wide_supported() && gpu_ws_netplay_local_viewport_width() > 0; /* Canonical present width. Native-wide does NOT widen the canonical read * (that bled across adjacent framebuffers); it composites into a separate @@ -6340,8 +6587,13 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * wide compositor, present the wider surface (canonical width + EXTRA) * from the displayed buffer's surface. FMV/menu frames stay 4:3. */ bool wide_present = (!fmv_frame && !di.depth24 && g_ws_engaged && - ws_native_wide_active() && gr_wide_supported()); - if (wide_present) present_w = w + (uint32_t)ws_nw_extra(); + ws_native_wide_active() && gr_wide_supported() && + (!local_viewport_crop || local_viewport_wide)); + if (wide_present) { + present_w = local_viewport_wide + ? (uint32_t)gpu_ws_netplay_local_viewport_width() + : (w + (uint32_t)ws_nw_extra()); + } /* Native-wide invariant: canonical (320-wide) content is NEVER * stretched across the wide window — a game frame that cannot present @@ -6366,7 +6618,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * do NOT flush_cpu_uploads (MDEC already wrote the CPU mirror; forcing * FBO uploads every frame cut MotK intro from ~50 to ~30 FPS). */ #ifndef PSX_SDL_NO_RENDER - if (g_gl_active && g_gl_fbo_present && !di.depth24) { + if (g_gl_active && g_gl_fbo_present && !di.depth24 && + !local_viewport_crop) { if (wide_present) { /* GPU-direct native-wide present: blit the displayed buffer's * wide FBO straight to the window (GPU-side, like the canonical @@ -6393,7 +6646,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * VRAM image (deterministic blit, no readback), mirroring the GL path; * 24-bit (FMV) frames go through the CPU present (Phase 3). The Vulkan * window has no SDL_Renderer, so we must never fall through below. */ - if (g_vk_active) { + if (g_vk_active && !local_viewport_crop) { if (di.depth24) { /* 24-bit (FMV): packed RGB lives in the CPU mirror — do NOT * sync_cpu (FBO readback clobbers RGB888). Batch per-scanline @@ -6443,12 +6696,16 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * hires path if the displayed buffer has no surface yet. */ int s = gr_scale(); int sw = (int)present_w * s; + int base_x = local_viewport_wide + ? gpu_ws_netplay_local_viewport_base_x() + : (int)di.display_x; int n = gr_render_wide_display(sdl_pixel_buf, (int)(sw * sizeof(uint32_t)), - (int)di.display_x, (int)di.display_y, (int)h); + base_x, (int)di.display_y, (int)h); if (n > 0) { active_scale = s; } else { wide_present = false; + local_viewport_wide = false; present_w = w; pres_entry->path = PRES_PATH_CANONICAL; pres_entry->wide_fellback = 1; @@ -6489,9 +6746,19 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { depth24_fix_trailing_margin(sdl_pixel_buf, present_w, h, di.display_x); + int present_px_w = (int)present_w * active_scale; + int present_px_h = (int)h * active_scale; + if (!local_viewport_wide && + crop_present_to_netplay_local_viewport(sdl_pixel_buf, + &present_px_w, + present_px_h)) { + pin_43 = false; + local_viewport_crop_applied = true; + } + smooth_60_present(sdl_pixel_buf, - present_w * (uint32_t)active_scale, - h * (uint32_t)active_scale, + (uint32_t)present_px_w, + (uint32_t)present_px_h, !g_gl_active && !g_vk_active && !di.depth24 && !fmv_frame); /* Frame blending (CRT-persistence masker for 30fps double-buffered @@ -6516,7 +6783,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { !g_smooth_60fps.load(std::memory_order_acquire)) { static uint32_t prev_buf[640 * 512]; static uint32_t prev_px = 0; - const uint32_t npx = present_w * h; + const uint32_t npx = local_viewport_crop_applied + ? (uint32_t)(present_px_w * present_px_h) + : present_w * h; if (npx <= (uint32_t)(640 * 512)) { if (prev_px == npx) { for (uint32_t i = 0; i < npx; i++) { @@ -6544,6 +6813,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { #ifndef PSX_SDL_NO_RENDER int src_w = (int)present_w * active_scale; int src_h = (int)h * active_scale; + if (local_viewport_crop_applied && src_w >= 2) + src_w /= 2; if (g_gl_active) { /* OpenGL present: upload the active display rect and draw a full-screen * quad. Either SwapWindow vsync OR the wall-clock pacer owns timing, @@ -10298,6 +10569,12 @@ int main(int argc, char** argv) { g_netplay_disc_expect.required_leadout_lba = gc.netplay_required_leadout_lba; g_netplay_disc_expect.required_disc_fp = gc.netplay_required_disc_fp; + g_netplay_local_viewport = + (gc.netplay_local_viewport == "vertical_split") ? 1 : 0; + g_netplay_local_viewport_aspect = + (gc.netplay_local_viewport_aspect == "16:9") ? 1 : + (gc.netplay_local_viewport_aspect == "21:9") ? 2 : + (gc.netplay_local_viewport_aspect == "adaptive") ? 3 : 0; if (!gc.discs.empty()) resolved_disc = gc.discs.front(); if (gc.runtime.has_memcard_dir) memcard_dir = gc.runtime.memcard_dir; if (gc.runtime.has_window_title) window_title = gc.runtime.window_title; @@ -10358,6 +10635,8 @@ int main(int argc, char** argv) { gc.runtime.video_geometry_correction ? 1 : 0; g_video_perspective_texturing = gc.runtime.video_perspective_texturing ? 1 : 0; + g_video_pgxp_cpu_mode = gc.runtime.video_pgxp_cpu_mode ? 1 : 0; + g_video_pgxp_tolerance = (float)gc.runtime.video_pgxp_tolerance; g_video_renderer = gc.runtime.video_renderer; g_video_screen = gc.runtime.video_screen_kind; g_video_aspect_num = gc.runtime.video_aspect_num; @@ -10391,6 +10670,7 @@ int main(int argc, char** argv) { gc.ws_bg2d_packet_cap); /* [widescreen] gte_game_mode — 3D-title gameplay detector (Ape). */ gpu_ws_set_gte_game_mode(gc.ws_gte_game_mode ? 1 : 0); + gpu_ws_set_precise_nclip(gc.ws_precise_nclip ? 1 : 0); gpu_ws_set_gameplay_state_gate( gc.ws_gameplay_state_addr, gc.ws_gameplay_state_values.data(), @@ -10548,6 +10828,10 @@ int main(int argc, char** argv) { if (i >= 2) player_mode[i] = gc.runtime.default_p1_mode; } } + if (gc.runtime.has_default_p1_device) + player_device[0] = gc.runtime.default_p1_device; + if (PSX_MAX_PLAYERS >= 2 && gc.runtime.has_default_p2_device) + player_device[1] = gc.runtime.default_p2_device; for (int i = 0; i < PSX_MAX_PLAYERS; ++i) ctrl_locked_mode[i] = player_mode[i]; ctrl_lock_mode = gc.runtime.controller_lock_mode; @@ -10737,6 +11021,7 @@ int main(int argc, char** argv) { g_video_aspect_num = us.aspect_num; g_video_aspect_den = us.aspect_den; } + if (us.has_audio_freq) g_audio_freq = us.audio_freq; if (us.has_spu_hq) g_audio_spu_hq = us.spu_hq; if (us.has_rewind_depth) g_rewind_depth = us.rewind_depth; if (us.has_rewind_interval) g_rewind_interval = us.rewind_interval; @@ -11154,6 +11439,7 @@ int main(int argc, char** argv) { seed.has_frame_interpolation_fps = true; seed.aspect_num = g_video_aspect_num; seed.aspect_den = g_video_aspect_den; seed.has_aspect_ratio = true; + seed.audio_freq = g_audio_freq; seed.has_audio_freq = true; seed.spu_hq = g_audio_spu_hq; seed.has_spu_hq = true; seed.rewind_depth = g_rewind_depth; seed.has_rewind_depth = true; seed.rewind_interval = g_rewind_interval; seed.has_rewind_interval = true; @@ -11259,7 +11545,7 @@ int main(int argc, char** argv) { ls.widescreen = (seed.aspect_num == 16 && seed.aspect_den == 9) ? 1 : 0; ls.widescreen_hud = ls.widescreen; ls.enable_audio = 1; - ls.audio_freq = 44100; + ls.audio_freq = seed.audio_freq; ls.volume = host_volume_get(); { const int n = std::min(PSX_MAX_PLAYERS, RECOMP_LAUNCHER_MAX_PLAYERS); @@ -11547,6 +11833,7 @@ int main(int argc, char** argv) { seed.screen_kind = ls.screen_kind; seed.has_screen_kind = true; seed.frame_interpolation = ls.frame_interp != 0; seed.has_frame_interpolation = true; seed.frame_interpolation_fps = ls.frame_interp_fps; seed.has_frame_interpolation_fps = true; + seed.audio_freq = ls.audio_freq; seed.has_audio_freq = true; seed.spu_hq = ls.spu_hq != 0; seed.has_spu_hq = true; seed.rewind_depth = ls.rewind_depth > 0 ? ls.rewind_depth : 50; seed.has_rewind_depth = true; @@ -11758,6 +12045,7 @@ int main(int argc, char** argv) { g_frame_interpolation_fps = seed.frame_interpolation_fps; g_video_aspect_num = seed.aspect_num; g_video_aspect_den = seed.aspect_den; + g_audio_freq = seed.audio_freq; g_audio_spu_hq = seed.spu_hq; g_rewind_depth = seed.has_rewind_depth && seed.rewind_depth > 0 ? seed.rewind_depth : 50; @@ -11875,6 +12163,7 @@ int main(int argc, char** argv) { g_turbo_loads_enabled = 0; g_frame_interpolation_blend = g_frame_interpolation_blend_default; mod_runtime_activate_plugins(); + apply_netplay_local_viewport_aspect(net_cfg.enabled); if (g_mod_controller_mode_override[0] >= 0) player_mode[0] = g_mod_controller_mode_override[0]; if (g_mod_controller_mode_override[1] >= 0) @@ -12089,8 +12378,20 @@ session_reboot: /* Sub-pixel vertex precision + perspective-correct UVs. Both default off; * with both off every setter below leaves the tracking caches disabled and * the draw path is the faithful integer one, unchanged. */ + /* Env overrides (debug/validation path, like PSX_BIOS_HLE): arm the + * corrections from process start so free-running (headless) boots can be + * measured from the first projected vertex — a TCP toggle always arrives + * after the interesting window. '0' = off, anything else = on. */ + if (const char* e = std::getenv("PSX_GEOMETRY_CORRECTION")) + g_video_geometry_correction = (*e && *e != '0') ? 1 : 0; + if (const char* e = std::getenv("PSX_PERSPECTIVE_TEXTURING")) + g_video_perspective_texturing = (*e && *e != '0') ? 1 : 0; + if (const char* e = std::getenv("PSX_PGXP_CPU_MODE")) + g_video_pgxp_cpu_mode = (*e && *e != '0') ? 1 : 0; gte_geometry_correction_set(g_video_geometry_correction); gpu_texture_correction_set(g_video_perspective_texturing); + pgxp_set_cpu_mode(g_video_pgxp_cpu_mode); + pgxp_set_tolerance(g_video_pgxp_tolerance); if (g_video_geometry_correction || g_video_perspective_texturing) { std::fprintf(stdout, "psxrecomp: geometry correction %s, perspective texturing %s%s\n", @@ -12331,7 +12632,7 @@ session_reboot: if (SDL_InitSubSystem(SDL_INIT_AUDIO) == 0) { PsxSdlAudioSpec want = {}; PsxSdlAudioSpec have = {}; - want.freq = 44100; + want.freq = g_audio_freq; want.format = AUDIO_S16SYS; want.channels = 2; want.samples = 1024; @@ -12633,6 +12934,7 @@ session_reboot: nrc, net_cfg.local_slot, net_cfg.bind_hostport, net_cfg.peer_hostport); return 1; } + apply_netplay_local_viewport_aspect(net_cfg.enabled); std::printf("psxrecomp: netplay transport=%s slot=%d input_player=%d delay=%d " "force_turn=%d bind=%s peer=%s session=%u\n", psx_netplay_transport_name(), @@ -12757,14 +13059,14 @@ session_reboot: psx_rewind_set_depth((uint32_t)g_rewind_depth); psx_rewind_set_interval((uint32_t)g_rewind_interval); psx_rewind_configure(memory_get_bios_checksum(), game_entry_pc); - /* Headless / agent load: PSX_LOAD_SLOT=N stages F1..F12 load (0..11) + /* Headless / agent load: PSX_LOAD_SLOT=N stages slot load (0..11) * at the next safe block boundary after boot. */ if (const char *ls = std::getenv("PSX_LOAD_SLOT")) { int slot = atoi(ls); if (slot >= 0 && slot < 12) { if (psx_netplay_active()) { std::fprintf(stdout, - "psxrecomp: PSX_LOAD_SLOT=%d ignored (use host F1–F12 after netplay starts)\n", + "psxrecomp: PSX_LOAD_SLOT=%d ignored (use the save-state menu after netplay starts)\n", slot); } else if (savestate_request_load(slot)) { std::fprintf(stdout, "psxrecomp: PSX_LOAD_SLOT=%d staged\n", slot); @@ -13087,7 +13389,7 @@ soft_return_lobby: (g_video_aspect_num == 16 && g_video_aspect_den == 9) ? 1 : 0; ls.widescreen_hud = ls.widescreen; ls.enable_audio = 1; - ls.audio_freq = 44100; + ls.audio_freq = g_audio_freq; ls.volume = host_volume_get(); ls.window_width = g_video_win_w; ls.renderer = g_video_renderer; @@ -13358,6 +13660,8 @@ soft_return_lobby: us.has_frame_interpolation = true; us.frame_interpolation_fps = ls.frame_interp_fps; us.has_frame_interpolation_fps = true; + us.audio_freq = ls.audio_freq; + us.has_audio_freq = true; us.spu_hq = ls.spu_hq != 0; us.has_spu_hq = true; us.rewind_depth = ls.rewind_depth > 0 ? ls.rewind_depth : 50; @@ -13416,6 +13720,7 @@ soft_return_lobby: g_fullscreen = ls.fullscreen != 0; g_frame_interpolation = ls.frame_interp ? 1 : 0; g_frame_interpolation_fps = ls.frame_interp_fps; + g_audio_freq = ls.audio_freq; g_audio_spu_hq = ls.spu_hq != 0; if (ls.rewind_depth > 0) { g_rewind_depth = ls.rewind_depth; @@ -13502,6 +13807,7 @@ soft_return_lobby: return 1; } } + apply_netplay_local_viewport_aspect(net_cfg.enabled); std::printf("psxrecomp: rematch from lobby (netplay=%d)\n", net_cfg.enabled ? 1 : 0); std::fflush(stdout); diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 74c4df0c..9c309083 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1555,7 +1555,6 @@ static inline void d44_note(uint32_t phys, uint32_t old, uint32_t val) { } static void psx_write_word_raw(uint32_t addr, uint32_t val); -extern void gte_precision_invalidate_word(uint32_t addr); void psx_write_word(uint32_t addr, uint32_t val) { extern void (*g_overlay_flush_pending_cycles)(void); if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); @@ -1572,7 +1571,9 @@ void psx_write_word(uint32_t addr, uint32_t val) { } static void psx_write_word_raw(uint32_t addr, uint32_t val) { g_guest_store_count++; - gte_precision_invalidate_word(addr); + /* (pgxp) plain-store shadow invalidation retired: the PGXP engine + * validates tracked words against the actual packet word on read, so an + * overwritten word can never be believed (ENHANCEMENTS.md G1). */ /* KSEG2 cache control — before physical translation. */ if (addr == 0xFFFE0130u) { cache_ctrl = val; return; } /* KSEG2 guard — see psx_read_word_raw. */ @@ -1772,7 +1773,6 @@ void psx_write_half(uint32_t addr, uint16_t val) { } static void psx_write_half_raw(uint32_t addr, uint16_t val) { g_guest_store_count++; - gte_precision_invalidate_word(addr); if (sr_ptr && (*sr_ptr & 0x10000u)) return; /* KSEG2 guard — see psx_read_word_raw. */ @@ -2108,7 +2108,6 @@ void psx_write_byte(uint32_t addr, uint8_t val) { } static void psx_write_byte_raw(uint32_t addr, uint8_t val) { g_guest_store_count++; - gte_precision_invalidate_word(addr); if (sr_ptr && (*sr_ptr & 0x10000u)) return; /* KSEG2 guard — see psx_read_word_raw. */ diff --git a/runtime/src/mod_builtin_pgxp.c b/runtime/src/mod_builtin_pgxp.c new file mode 100644 index 00000000..4db67d42 --- /dev/null +++ b/runtime/src/mod_builtin_pgxp.c @@ -0,0 +1,43 @@ +/* + * Framework-owned PGXP precision mod, available to every game. + * + * Sub-pixel vertex precision + perspective-correct texturing is a property of + * the emulated GTE/GPU pair, not of any particular disc, so it ships here and + * mods/builtin/packages/psx.enhancement.pgxp targets game_id "*". Default off + * (the faithful floor, ENHANCEMENTS.md G1.9); enabling arms the value- + * propagation engine (pgxp.cpp) with the user-validated defaults — tolerance + * clamp 0.5px, cpu-mode per the mod option. + * + * Coverage note: the engine reaches near-total dataflow coverage on a binary + * compiled with the PGXP hook variant (-DPSX_PGXP=1, PSX_PGXP_VARIANT); on a + * base-flavour binary only the always-emitted swc2 tier and the interpreters + * feed it, so the correction is weaker there but never harmful — every vertex + * is validated against the exact packet word before it is believed. + */ +#include "mod_plugins.h" +#include "pgxp.h" + +#include + +#define PKG_PGXP "psx.enhancement.pgxp" + +/* gte.cpp / gpu.c — the two correction toggles this mod arms. */ +extern void gte_geometry_correction_set(int enabled); +extern void gpu_texture_correction_set(int enabled); + +static int pgxp_option_flag(const char* feature, const char* id) { + char text[16] = ""; + return psx_mod_option_value(PKG_PGXP, feature, id, text, sizeof text) && + strcmp(text, "true") == 0; +} + +static void builtin_pgxp_activate(void) { + gte_geometry_correction_set(1); + gpu_texture_correction_set(1); /* also arms the shadow engine */ + pgxp_set_cpu_mode(pgxp_option_flag("pgxp", "cpu_mode")); +} + +PSX_MOD_CONSTRUCTOR(psx_register_builtin_pgxp_plugin) { + (void)psx_mod_register_activation_plugin("psx.pgxp", + builtin_pgxp_activate); +} diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index edd5af69..6a0ca686 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -1727,9 +1727,9 @@ static void warn_on_cgtag_mismatch(const char *tier) { snprintf(base, sizeof base, "%s/%s/%s/%s", s_cache_dir, s_game_id, tier, PSX_OVERLAY_ARCH_ABI); char expect[64]; - snprintf(expect, sizeof expect, "cg%d_%08x_gc%08x", + snprintf(expect, sizeof expect, "cg%d_%08x_gc%08x_f%u", PSX_OVERLAY_CODEGEN_VER, (unsigned)PSX_OVERLAY_CODEGEN_HASH, - (unsigned)s_config_hash); + (unsigned)s_config_hash, (unsigned)PSX_OVERLAY_FLAVOR); snprintf(pattern, sizeof pattern, "%s/cg*", base); WIN32_FIND_DATAA fd; HANDLE h = FindFirstFileA(pattern, &fd); @@ -1755,9 +1755,9 @@ static void warn_on_cgtag_mismatch(const char *tier) { char base[768], expect[64], found[256]; snprintf(base, sizeof base, "%s/%s/%s/%s", s_cache_dir, s_game_id, tier, PSX_OVERLAY_ARCH_ABI); - snprintf(expect, sizeof expect, "cg%d_%08x_gc%08x", + snprintf(expect, sizeof expect, "cg%d_%08x_gc%08x_f%u", PSX_OVERLAY_CODEGEN_VER, (unsigned)PSX_OVERLAY_CODEGEN_HASH, - (unsigned)s_config_hash); + (unsigned)s_config_hash, (unsigned)PSX_OVERLAY_FLAVOR); if (psx_overlay_posix_find_other_cache_tag(base, expect, found, sizeof(found))) { loader_log("*** OVERLAY CACHE HASH MISMATCH: this build reads %s/%s but " @@ -1906,14 +1906,16 @@ static void scan_cache_dir(void) { /* Index both tiers and every immutable artifact. Runtime selection prefers * usable GCC over TCC; an invalid GCC artifact cannot suppress a valid TCC * fallback merely because its filename was enumerated first. */ - snprintf(dir, sizeof(dir), "%s/%s/gcc/%s/cg%d_%08x_gc%08x", + snprintf(dir, sizeof(dir), "%s/%s/gcc/%s/cg%d_%08x_gc%08x_f%u", s_cache_dir, s_game_id, PSX_OVERLAY_ARCH_ABI, PSX_OVERLAY_CODEGEN_VER, - (unsigned)PSX_OVERLAY_CODEGEN_HASH, (unsigned)s_config_hash); + (unsigned)PSX_OVERLAY_CODEGEN_HASH, (unsigned)s_config_hash, + (unsigned)PSX_OVERLAY_FLAVOR); scan_one_cache_dir(dir, CACHE_TIER_GCC); abi_preflight_sweep(dir); - snprintf(dir, sizeof(dir), "%s/%s/tcc/%s/cg%d_%08x_gc%08x", + snprintf(dir, sizeof(dir), "%s/%s/tcc/%s/cg%d_%08x_gc%08x_f%u", s_cache_dir, s_game_id, PSX_OVERLAY_ARCH_ABI, PSX_OVERLAY_CODEGEN_VER, - (unsigned)PSX_OVERLAY_CODEGEN_HASH, (unsigned)s_config_hash); + (unsigned)PSX_OVERLAY_CODEGEN_HASH, (unsigned)s_config_hash, + (unsigned)PSX_OVERLAY_FLAVOR); scan_one_cache_dir(dir, CACHE_TIER_TCC); abi_preflight_sweep(dir); @@ -2355,6 +2357,21 @@ static void init_callbacks(void) { extern uint32_t psx_ws_angle_widen(uint32_t vanilla); s_callbacks.ws_angle_widen = psx_ws_angle_widen; } + /* PGXP dataflow-shadowing hook table (pgxp_hooks.h, appended last). + * Referenced only by pgxp-flavour shards; the flavor half of the ABI + * tag already rejects any host/DLL flavor mix, and a NULL table on an + * older host makes every hook a no-op (visual-only, never + * load-bearing). */ + { + static const PGXPHooks pgxp_hooks_table = { + psx_pgxp_load, + psx_pgxp_store, + psx_pgxp_alu, + psx_pgxp_muldiv, + psx_pgxp_cop2, + }; + s_callbacks.pgxp = &pgxp_hooks_table; + } } } diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp new file mode 100644 index 00000000..94f03870 --- /dev/null +++ b/runtime/src/pgxp.cpp @@ -0,0 +1,803 @@ +/* pgxp.cpp — PGXP value-propagation engine (ENHANCEMENTS.md G1.2/G1.3). + * + * CLEAN-ROOM implementation of the publicly documented PGXP technique + * (psx-spx GTE docs + public design write-ups + our own G1 measurements). + * The vendored duckstation/ (CC BY-NC-ND) and beetle-psx/ (GPL) trees are + * black-box behavioral oracles only — no code from them appears here. + * + * Model + * ----- + * Every 32-bit word of guest RAM/scratchpad, every GPR (plus HI/LO), and + * every GTE data register owns a shadow slot recording the sub-pixel screen + * position that word carries (16.16 X/Y + projected SZ depth), the exact + * guest word it describes (`value`), and per-half validity flags. RTPS/RTPT + * fill the SXY shadow FIFO with the pre-truncation projection; the + * psx_pgxp_* hooks copy shadows along with the data (loads, stores, COP2 + * transfers, and — in cpu-mode — the arithmetic games use to repack vertex + * halves); the GPU asks for the precise position of a GP0 vertex word by the + * packet's RAM address. + * + * The single safety invariant: a shadow is only ever BELIEVED after + * validation against the actual guest word it claims to describe. Anything + * that writes guest state without a hook (DMA, memcpy loaders, un-hooked + * instructions) simply leaves a stale shadow behind, and the next validation + * drops it. We never model side effects — overwrite and validate only. The + * one accepted hole (shared with the reference implementations): an untracked + * writer storing the byte-identical word keeps the shadow alive, which is + * harmless because the position it describes is still that word. + * + * Everything here is host-only and visual-only: guest-visible state is never + * read back from shadows, shadows are dropped on savestate/rewind, and the + * speculative native-validation bracket suppresses all recording. + */ + +#include "pgxp.h" +#include "pgxp_hooks.h" +#include "cpu_state.h" + +#include +#include + +/* gte.cpp — position-cache fallback tier (ambiguity-gated, G1.4 exact table) */ +extern "C" int gte_geometry_correction_lookup(uint32_t packed, + int32_t *x16, int32_t *y16); + +/* ------------------------------------------------------------------------- */ +/* Shadow storage */ +/* ------------------------------------------------------------------------- */ + +enum { + PGXP_F_VX = 1u << 0, /* low half (screen X) tracked */ + PGXP_F_VY = 1u << 1, /* high half (screen Y) tracked */ + PGXP_F_VZ = 1u << 2, /* projected depth rode along intact */ + PGXP_F_VXY = PGXP_F_VX | PGXP_F_VY, +}; + +struct PGXPValue { + int32_t x16, y16; /* sub-pixel screen coords, 16.16 */ + uint16_t z; /* projected SZ depth (perspective source), 0 = none */ + uint16_t flags; + uint32_t value; /* the guest word this shadow describes */ + uint32_t gen; /* valid iff == s_gen (O(1) invalidate-all) */ +}; + +#define PGXP_RAM_WORDS (0x200000u >> 2) /* 2 MB RAM */ +#define PGXP_SCRATCH_WORDS (0x400u >> 2) /* 1 KB scratchpad */ +#define PGXP_REG_HI 32 +#define PGXP_REG_LO 33 + +static PGXPValue *s_ram = nullptr; /* lazily allocated, ~10 MB */ +static PGXPValue s_scratch[PGXP_SCRATCH_WORDS]; +static PGXPValue s_gpr[34]; /* 32 GPRs + HI + LO */ +static PGXPValue s_gte[32]; /* GTE data registers */ + +static uint32_t s_gen = 1; +static int s_enabled = 0; +static int s_cpu_mode = 0; +static float s_tolerance = 0.5f; /* user-validated seam clamp (G1.10) */ +static uint32_t s_suppress = 0; +static int s_deferred_invalidate = 0; + +/* Single hot-path gate for every hook. */ +static int g_pgxp_active = 0; +static inline void recompute_active(void) { + g_pgxp_active = (s_enabled && s_suppress == 0) ? 1 : 0; +} + +static PGXPStats s_stats; + +/* ------------------------------------------------------------------------- */ +/* Lifecycle */ +/* ------------------------------------------------------------------------- */ + +extern "C" void pgxp_invalidate_all(void) { + if (s_suppress != 0) { s_deferred_invalidate = 1; return; } + if (++s_gen == 0) { + /* generation wrapped: physically clear so stale slots can't revive */ + if (s_ram) std::memset(s_ram, 0, PGXP_RAM_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)); + s_gen = 1; + } +} + +extern "C" void pgxp_set_enabled(int enabled) { + if (enabled && !s_ram) { + s_ram = (PGXPValue *)std::calloc(PGXP_RAM_WORDS, sizeof(PGXPValue)); + if (!s_ram) enabled = 0; /* fail closed: stay faithful */ + } + s_enabled = enabled ? 1 : 0; + pgxp_invalidate_all(); + recompute_active(); +} + +extern "C" int pgxp_enabled(void) { return s_enabled; } + +extern "C" void pgxp_set_cpu_mode(int enabled) { s_cpu_mode = enabled ? 1 : 0; } +extern "C" int pgxp_cpu_mode(void) { return s_cpu_mode; } + +extern "C" void pgxp_set_tolerance(float pixels) { s_tolerance = pixels; } +extern "C" float pgxp_tolerance(void) { return s_tolerance; } + +extern "C" void pgxp_suppress_begin(void) { + ++s_suppress; + recompute_active(); +} + +extern "C" void pgxp_suppress_end(void) { + if (s_suppress != 0 && --s_suppress == 0) { + if (s_deferred_invalidate) { + s_deferred_invalidate = 0; + pgxp_invalidate_all(); + } + recompute_active(); + } +} + +extern "C" void pgxp_get_stats(PGXPStats *out) { + if (out) *out = s_stats; +} + +/* ------------------------------------------------------------------------- */ +/* Address mapping + validation */ +/* ------------------------------------------------------------------------- */ + +/* Guest address -> shadow slot, or NULL for BIOS/MMIO/KSEG2 (untrackable). */ +static inline PGXPValue *pgxp_ptr(uint32_t addr) { + uint32_t m = addr & 0x1FFFFFFFu; + if (m < 0x00800000u) /* RAM + its mirrors */ + return s_ram ? &s_ram[(m & 0x1FFFFCu) >> 2] : nullptr; + if ((m & 0xFFFFFC00u) == 0x1F800000u) /* scratchpad */ + return &s_scratch[(m & 0x3FCu) >> 2]; + return nullptr; +} + +static inline int pv_live(const PGXPValue *pv) { + return pv && pv->gen == s_gen && (pv->flags & PGXP_F_VXY) != 0; +} + +/* Drop whichever tracked halves no longer match the actual guest word; the + * depth belongs to the whole vertex, so any half going stale kills it too. */ +static inline void pv_validate(PGXPValue *pv, uint32_t actual) { + if (pv->gen != s_gen) return; + uint32_t diff = pv->value ^ actual; + if ((pv->flags & PGXP_F_VX) && (diff & 0x0000FFFFu)) + pv->flags &= (uint16_t)~(PGXP_F_VX | PGXP_F_VZ); + if ((pv->flags & PGXP_F_VY) && (diff & 0xFFFF0000u)) + pv->flags &= (uint16_t)~(PGXP_F_VY | PGXP_F_VZ); + pv->value = actual; +} + +/* Mark a slot as "known word, no precision" — keeps `value` current so later + * half-merges stay keyed correctly. */ +static inline void pv_reset(PGXPValue *pv, uint32_t value) { + pv->x16 = 0; pv->y16 = 0; pv->z = 0; + pv->flags = 0; + pv->value = value; + pv->gen = s_gen; +} + +static inline void pv_kill(PGXPValue *pv) { pv->gen = 0; } + +/* ------------------------------------------------------------------------- */ +/* Instruction field helpers */ +/* ------------------------------------------------------------------------- */ + +static inline uint32_t f_op(uint32_t i) { return i >> 26; } +static inline uint32_t f_rs(uint32_t i) { return (i >> 21) & 31u; } +static inline uint32_t f_rt(uint32_t i) { return (i >> 16) & 31u; } +static inline uint32_t f_rd(uint32_t i) { return (i >> 11) & 31u; } +static inline uint32_t f_shamt(uint32_t i) { return (i >> 6) & 31u; } +static inline uint32_t f_funct(uint32_t i) { return i & 63u; } +static inline int32_t f_simm(uint32_t i) { return (int32_t)(int16_t)(i & 0xFFFFu); } + +/* ------------------------------------------------------------------------- */ +/* Memory-mode hooks: loads / stores */ +/* ------------------------------------------------------------------------- */ + +extern "C" void psx_pgxp_load(struct CPUState *cpu, uint32_t instr, + uint32_t addr, uint32_t value) { + (void)cpu; + if (!g_pgxp_active) return; + uint32_t rt = f_rt(instr); + if (rt == 0) return; + PGXPValue *dst = &s_gpr[rt]; + + switch (f_op(instr)) { + case 0x23: { /* LW */ + PGXPValue *src = pgxp_ptr(addr); + if (src && src->gen == s_gen) { + pv_validate(src, value); + *dst = *src; + } else { + pv_reset(dst, value); + } + return; + } + case 0x21: /* LH */ + case 0x25: { /* LHU */ + PGXPValue *src = pgxp_ptr(addr); + int hi_half = (addr >> 1) & 1; + pv_reset(dst, value); + /* The high half of the extended GPR is a known-exact constant + * (0/-1 for LH, 0 for LHU): track it so re-packing via sll/or in + * cpu-mode keeps working. */ + dst->y16 = ((int32_t)value >> 16) << 16; + dst->flags = PGXP_F_VY; + if (src && src->gen == s_gen) { + uint32_t actual_half = (value & 0xFFFFu) << (hi_half ? 16 : 0); + uint32_t mask = hi_half ? 0xFFFF0000u : 0x0000FFFFu; + uint16_t want = hi_half ? PGXP_F_VY : PGXP_F_VX; + if ((src->flags & want) && ((src->value ^ actual_half) & mask) == 0) { + dst->x16 = hi_half ? src->y16 : src->x16; + dst->flags |= PGXP_F_VX; + } + } + return; + } + default: /* LB/LBU/LWL/LWR: untrackable */ + pv_reset(dst, value); + return; + } +} + +extern "C" void psx_pgxp_store(struct CPUState *cpu, uint32_t instr, + uint32_t addr, uint32_t value) { + (void)cpu; + if (!g_pgxp_active) return; + PGXPValue *dst = pgxp_ptr(addr); + if (!dst) return; + uint32_t rt = f_rt(instr); + PGXPValue *src = (rt != 0) ? &s_gpr[rt] : nullptr; + + switch (f_op(instr)) { + case 0x2B: { /* SW */ + if (src && src->gen == s_gen) { + pv_validate(src, value); + *dst = *src; + } else { + pv_reset(dst, value); + } + return; + } + case 0x29: { /* SH */ + int hi_half = (addr >> 1) & 1; + uint32_t half = value & 0xFFFFu; + if (dst->gen != s_gen) pv_reset(dst, half << (hi_half ? 16 : 0)); + /* Patch the stored half into the tracked word; the other half's + * validity (if any) survives untouched. Depth never survives a + * half-write — the vertex it described no longer exists whole. */ + if (hi_half) dst->value = (dst->value & 0x0000FFFFu) | (half << 16); + else dst->value = (dst->value & 0xFFFF0000u) | half; + dst->flags &= (uint16_t)~((hi_half ? PGXP_F_VY : PGXP_F_VX) | PGXP_F_VZ); + dst->z = 0; + if (src && src->gen == s_gen && (src->flags & PGXP_F_VX) && + ((src->value ^ value) & 0xFFFFu) == 0) { + if (hi_half) { dst->y16 = src->x16; dst->flags |= PGXP_F_VY; } + else { dst->x16 = src->x16; dst->flags |= PGXP_F_VX; } + } + return; + } + case 0x28: { /* SB */ + if (dst->gen != s_gen) return; /* nothing tracked: stay dead */ + uint32_t shift = (addr & 3u) * 8u; + dst->value = (dst->value & ~(0xFFu << shift)) | + ((value & 0xFFu) << shift); + dst->flags &= (uint16_t)~(((addr & 2u) ? PGXP_F_VY : PGXP_F_VX) | PGXP_F_VZ); + dst->z = 0; + return; + } + default: /* SWL/SWR: forget the word */ + pv_kill(dst); + return; + } +} + +/* ------------------------------------------------------------------------- */ +/* Memory-mode hooks: COP2 transfers */ +/* ------------------------------------------------------------------------- */ + +extern "C" void psx_pgxp_cop2(struct CPUState *cpu, uint32_t instr, + uint32_t value, uint32_t addr) { + (void)cpu; + if (!g_pgxp_active) return; + + switch (f_op(instr)) { + case 0x32: { /* LWC2: gte[rt] <- [addr] */ + PGXPValue *src = pgxp_ptr(addr); + PGXPValue *dst = &s_gte[f_rt(instr)]; + if (src && src->gen == s_gen) { + pv_validate(src, value); + *dst = *src; + } else { + pv_reset(dst, value); + } + return; + } + case 0x3A: { /* SWC2: [addr] <- gte[rt] */ + PGXPValue *dst = pgxp_ptr(addr); + if (!dst) return; + PGXPValue *src = &s_gte[f_rt(instr)]; + if (src->gen == s_gen) { + pv_validate(src, value); + *dst = *src; + } else { + pv_reset(dst, value); + } + return; + } + case 0x12: { /* COP2 register transfers */ + switch (f_rs(instr)) { + case 0x00: { /* MFC2: gpr[rt] <- gte[rd] */ + uint32_t rt = f_rt(instr); + if (rt == 0) return; + PGXPValue *src = &s_gte[f_rd(instr)]; + if (src->gen == s_gen) { + pv_validate(src, value); + s_gpr[rt] = *src; + } else { + pv_reset(&s_gpr[rt], value); + } + return; + } + case 0x04: { /* MTC2: gte[rd] <- gpr[rt] */ + uint32_t rt = f_rt(instr); + PGXPValue *dst = &s_gte[f_rd(instr)]; + if (rt != 0 && s_gpr[rt].gen == s_gen) { + pv_validate(&s_gpr[rt], value); + *dst = s_gpr[rt]; + } else { + pv_reset(dst, value); + } + /* An SXYP write (rd==15) shifts the hardware FIFO; we do not + * model it — regs 12..14 now describe words they no longer + * match, and validation drops them on next use. */ + return; + } + case 0x02: { /* CFC2: control regs carry no + positions */ + uint32_t rt = f_rt(instr); + if (rt != 0) pv_reset(&s_gpr[rt], value); + return; + } + default: /* CTC2: nothing shadowed */ + return; + } + } + default: + return; + } +} + +/* ------------------------------------------------------------------------- */ +/* CPU-mode hooks: arithmetic (tier 2, default off) */ +/* ------------------------------------------------------------------------- */ + +/* One 16-bit operand component in 16.16: a tracked half contributes its + * sub-pixel value; an untracked half contributes its exact integer value + * (adding an exact offset to a tracked coordinate keeps the fraction). */ +static inline int32_t comp16(const PGXPValue *pv, uint32_t value, int hi_half) { + if (pv && pv->gen == s_gen) { + if (!hi_half && (pv->flags & PGXP_F_VX) && + ((pv->value ^ value) & 0x0000FFFFu) == 0) + return pv->x16; + if (hi_half && (pv->flags & PGXP_F_VY) && + ((pv->value ^ value) & 0xFFFF0000u) == 0) + return pv->y16; + } + return ((int32_t)(int16_t)(hi_half ? (value >> 16) : value)) << 16; +} + +static inline int comp_tracked(const PGXPValue *pv, uint32_t value, int hi_half) { + if (!pv || pv->gen != s_gen) return 0; + if (!hi_half) + return (pv->flags & PGXP_F_VX) && + ((pv->value ^ value) & 0x0000FFFFu) == 0; + return (pv->flags & PGXP_F_VY) && + ((pv->value ^ value) & 0xFFFF0000u) == 0; +} + +/* Half-wise add/sub is only meaningful when the guest result shows no carry + * crossed the half boundary; otherwise the halves did not combine + * independently and the shadow must not pretend they did. */ +static inline int halves_independent(uint32_t a, uint32_t b, uint32_t r, int sub) { + uint32_t lo = sub ? ((a & 0xFFFFu) - (b & 0xFFFFu)) + : ((a & 0xFFFFu) + (b & 0xFFFFu)); + return ((lo ^ r) & 0xFFFFu) == 0 && + ((((sub ? a - b : a + b)) ^ r) == 0) && + ((lo >> 16) == 0); /* no carry/borrow out of low */ +} + +extern "C" void psx_pgxp_alu(struct CPUState *cpu, uint32_t instr, + uint32_t result, uint32_t s1, uint32_t s2) { + (void)cpu; + if (!g_pgxp_active) return; + + uint32_t op = f_op(instr); + uint32_t dst_reg; + PGXPValue *a = nullptr, *b = nullptr; /* source shadows (may be 0) */ + + if (op == 0) { /* SPECIAL */ + dst_reg = f_rd(instr); + uint32_t rs = f_rs(instr), rt = f_rt(instr); + uint32_t funct = f_funct(instr); + if (funct == 0x11 || funct == 0x13) { /* MTHI / MTLO */ + PGXPValue *hl = &s_gpr[funct == 0x11 ? PGXP_REG_HI : PGXP_REG_LO]; + if (s_cpu_mode && rs != 0 && s_gpr[rs].gen == s_gen) { + pv_validate(&s_gpr[rs], result); + *hl = s_gpr[rs]; + } else pv_reset(hl, result); + return; + } + if (dst_reg == 0) return; + PGXPValue *dst = &s_gpr[dst_reg]; + + switch (funct) { + case 0x10: /* MFHI */ + if (s_cpu_mode && s_gpr[PGXP_REG_HI].gen == s_gen) { + pv_validate(&s_gpr[PGXP_REG_HI], result); + *dst = s_gpr[PGXP_REG_HI]; + } else pv_reset(dst, result); + return; + case 0x12: /* MFLO */ + if (s_cpu_mode && s_gpr[PGXP_REG_LO].gen == s_gen) { + pv_validate(&s_gpr[PGXP_REG_LO], result); + *dst = s_gpr[PGXP_REG_LO]; + } else pv_reset(dst, result); + return; + case 0x00: case 0x02: case 0x03: /* SLL / SRL / SRA */ + case 0x04: case 0x06: case 0x07: { /* SLLV / SRLV / SRAV */ + uint32_t sh = (funct < 4) ? f_shamt(instr) : (s2 & 31u); + uint32_t src_reg = rt; + PGXPValue *src = (src_reg != 0) ? &s_gpr[src_reg] : nullptr; + int right = (funct & 3u) != 0; + if (!s_cpu_mode || sh != 16 || !src || src->gen != s_gen) { + pv_reset(dst, result); + return; + } + pv_validate(src, s1); + pv_reset(dst, result); + if (!right) { /* << 16: low comp -> Y */ + if (src->flags & PGXP_F_VX) { + dst->y16 = src->x16; + dst->flags |= PGXP_F_VY; + } + dst->x16 = 0; + dst->flags |= PGXP_F_VX; /* low half exactly zero */ + } else { /* >> 16: high comp -> X */ + if (src->flags & PGXP_F_VY) { + dst->x16 = src->y16; + dst->flags |= PGXP_F_VX; + } + dst->y16 = ((int32_t)result >> 16) << 16; /* 0 or sign fill */ + dst->flags |= PGXP_F_VY; + } + return; + } + case 0x20: case 0x21: /* ADD / ADDU */ + case 0x22: case 0x23: /* SUB / SUBU */ + case 0x25: { /* OR */ + /* MOVE idioms first — they matter even without full cpu-mode + * arithmetic, and they are exact. */ + int is_or = (funct == 0x25); + int is_sub = (funct == 0x22 || funct == 0x23); + if ((is_or || !is_sub) && rt == 0) { /* op rd, rs, $zero */ + if (rs != 0 && s_gpr[rs].gen == s_gen) { + pv_validate(&s_gpr[rs], result); + *dst = s_gpr[rs]; + } else pv_reset(dst, result); + return; + } + if ((is_or || !is_sub) && rs == 0) { /* op rd, $zero, rt */ + if (rt != 0 && s_gpr[rt].gen == s_gen) { + pv_validate(&s_gpr[rt], result); + *dst = s_gpr[rt]; + } else pv_reset(dst, result); + return; + } + if (!s_cpu_mode) { pv_reset(dst, result); return; } + a = (rs != 0) ? &s_gpr[rs] : nullptr; + b = (rt != 0) ? &s_gpr[rt] : nullptr; + if (is_or) { + /* Merge disjoint halves (the lhu/sll/or repack pattern). */ + if ((s1 & s2) != 0) { pv_reset(dst, result); return; } + pv_reset(dst, result); + const PGXPValue *xsrc; uint32_t xval; + if ((s1 & 0xFFFFu) != 0) { xsrc = a; xval = s1; } + else { xsrc = b; xval = s2; } + const PGXPValue *ysrc; uint32_t yval; + if ((s1 & 0xFFFF0000u) != 0) { ysrc = a; yval = s1; } + else { ysrc = b; yval = s2; } + if (comp_tracked(xsrc, xval, 0)) { + dst->x16 = xsrc->x16; dst->flags |= PGXP_F_VX; + } else if ((result & 0xFFFFu) == 0) { + dst->x16 = 0; dst->flags |= PGXP_F_VX; + } + if (comp_tracked(ysrc, yval, 1)) { + dst->y16 = ysrc->y16; dst->flags |= PGXP_F_VY; + } else if ((result & 0xFFFF0000u) == 0) { + dst->y16 = 0; dst->flags |= PGXP_F_VY; + } + return; + } + /* add/sub: require at least one tracked component and halves + * that combined independently (no cross-half carry). */ + if (!halves_independent(s1, s2, result, is_sub) || + (!comp_tracked(a, s1, 0) && !comp_tracked(a, s1, 1) && + !comp_tracked(b, s2, 0) && !comp_tracked(b, s2, 1))) { + pv_reset(dst, result); + return; + } + pv_reset(dst, result); + dst->x16 = is_sub ? comp16(a, s1, 0) - comp16(b, s2, 0) + : comp16(a, s1, 0) + comp16(b, s2, 0); + dst->y16 = is_sub ? comp16(a, s1, 1) - comp16(b, s2, 1) + : comp16(a, s1, 1) + comp16(b, s2, 1); + dst->flags = PGXP_F_VXY; + return; + } + default: + pv_reset(dst, result); + return; + } + } + + /* immediates */ + dst_reg = f_rt(instr); + if (dst_reg == 0) return; + PGXPValue *dst = &s_gpr[dst_reg]; + uint32_t rs = f_rs(instr); + + switch (op) { + case 0x0F: { /* LUI: both halves exact */ + pv_reset(dst, result); + dst->x16 = 0; + dst->y16 = ((int32_t)result >> 16) << 16; + dst->flags = PGXP_F_VXY; + return; + } + case 0x08: case 0x09: { /* ADDI / ADDIU */ + int32_t imm = f_simm(instr); + if (imm == 0) { /* MOVE idiom */ + if (rs != 0 && s_gpr[rs].gen == s_gen) { + pv_validate(&s_gpr[rs], result); + *dst = s_gpr[rs]; + } else pv_reset(dst, result); + return; + } + a = (rs != 0) ? &s_gpr[rs] : nullptr; + /* A negative immediate is a subtraction of its magnitude: checking + * carry on the sign-extended form would reject nearly every -N. */ + { + int neg = imm < 0; + uint32_t mag = (uint32_t)(neg ? -imm : imm); + if (!s_cpu_mode || + !halves_independent(s1, mag, result, neg) || + (!comp_tracked(a, s1, 0) && !comp_tracked(a, s1, 1))) { + pv_reset(dst, result); + return; + } + int32_t dx = (int32_t)(mag & 0xFFFFu) << 16; + pv_reset(dst, result); + dst->x16 = comp16(a, s1, 0) + (neg ? -dx : dx); + dst->y16 = comp16(a, s1, 1); /* no carry crossed: Y untouched */ + dst->flags = PGXP_F_VXY; + } + return; + } + case 0x0D: { /* ORI */ + uint32_t imm = instr & 0xFFFFu; + if (imm == 0 || (s_cpu_mode && (s1 & 0xFFFFu) == 0)) { + a = (rs != 0) ? &s_gpr[rs] : nullptr; + pv_reset(dst, result); + if (comp_tracked(a, s1, 1)) { + dst->y16 = a->y16; dst->flags |= PGXP_F_VY; + } + if (imm == 0) { + if (comp_tracked(a, s1, 0)) { + dst->x16 = a->x16; dst->flags |= PGXP_F_VX; + } + } else { /* exact constant low half */ + dst->x16 = ((int32_t)(int16_t)imm) << 16; + dst->flags |= PGXP_F_VX; + } + return; + } + pv_reset(dst, result); + return; + } + default: /* ANDI/XORI/SLTI/... */ + pv_reset(dst, result); + return; + } +} + +extern "C" void psx_pgxp_muldiv(struct CPUState *cpu, uint32_t instr, + uint32_t hi, uint32_t lo, + uint32_t s1, uint32_t s2) { + (void)cpu; (void)instr; (void)s1; (void)s2; + if (!g_pgxp_active) return; + /* Products/quotients of screen coordinates are not screen coordinates: + * record the results as known-but-imprecise so MFHI/MFLO stay honest. */ + pv_reset(&s_gpr[PGXP_REG_HI], hi); + pv_reset(&s_gpr[PGXP_REG_LO], lo); +} + +/* ------------------------------------------------------------------------- */ +/* GTE producer */ +/* ------------------------------------------------------------------------- */ + +extern "C" void pgxp_gte_push_sxy(int32_t x16, int32_t y16, uint16_t sz3, + uint32_t packed) { + if (!g_pgxp_active) return; + s_stats.produced++; + s_gte[12] = s_gte[13]; + s_gte[13] = s_gte[14]; + PGXPValue *pv = &s_gte[14]; + pv->x16 = x16; + pv->y16 = y16; + pv->z = sz3; + pv->flags = (uint16_t)(PGXP_F_VXY | (sz3 != 0 ? PGXP_F_VZ : 0)); + pv->value = packed; + pv->gen = s_gen; + s_gte[15] = *pv; /* SXYP mirrors SXY2 */ +} + +extern "C" int pgxp_get_gte_sxy(uint32_t index, int32_t *x16, int32_t *y16) { + if (index >= 4) return 0; + const PGXPValue *pv = &s_gte[12 + index]; + if (!pv_live(pv) || (pv->flags & PGXP_F_VXY) != PGXP_F_VXY) + return 0; + if (x16) *x16 = pv->x16; + if (y16) *y16 = pv->y16; + return 1; +} + +extern "C" void pgxp_gte_reg_written(int reg, uint32_t value) { + /* Invalidation-class bookkeeping: runs even with the engine disarmed so + * seeded/leftover shadows can never outlive a guest register write. Only + * the suppression bracket skips it (the speculative pass rolls the + * machine state back, so its writes must not stick to the shadows). */ + if (s_suppress != 0) return; + if (reg < 0 || reg > 31) return; + pv_kill(&s_gte[reg]); + pv_reset(&s_gte[reg], value); +} + +/* ------------------------------------------------------------------------- */ +/* GPU consumer */ +/* ------------------------------------------------------------------------- */ + +extern "C" int pgxp_get_precise_vertex(uint32_t addr, uint32_t packet_word, + int32_t int_x, int32_t int_y, + int32_t *x16, int32_t *y16, + uint16_t *sz) { + s_stats.lookups++; + + int32_t px = 0, py = 0; + uint16_t pz = 0; + int have = 0; + + if (s_enabled && addr != 0xFFFFFFFFu) { + PGXPValue *pv = pgxp_ptr(addr); + if (pv && pv->gen == s_gen && (pv->flags & PGXP_F_VXY) != 0) { + if (pv->value == packet_word && + (pv->flags & PGXP_F_VXY) == PGXP_F_VXY) { + px = pv->x16; + py = pv->y16; + pz = (pv->flags & PGXP_F_VZ) ? pv->z : 0; + have = PGXP_SRC_DATAFLOW; + } else { + s_stats.value_mismatch++; + } + } + } + + if (!have && gte_geometry_correction_lookup(packet_word, &px, &py)) { + pz = 0; /* fallback never carries depth */ + have = PGXP_SRC_FALLBACK; + } + + if (have) { + /* Truncation agreement: the GPU parsed 11-bit integers out of the + * packet; a precise position whose integer part disagrees (a + * wrapped/CPU-modified coordinate) must not be believed. */ + if ((px >> 16) != int_x || (py >> 16) != int_y) { + s_stats.trunc_reject++; + have = 0; + } else if (s_tolerance >= 0.0f) { + float dx = (float)(px - (int_x << 16)) * (1.0f / 65536.0f); + float dy = (float)(py - (int_y << 16)) * (1.0f / 65536.0f); + if (dx > s_tolerance || dy > s_tolerance) { + s_stats.tolerance_reject++; + have = 0; + } + } + } + + if (!have) { + s_stats.native++; + *x16 = int_x << 16; + *y16 = int_y << 16; + *sz = 0; + return PGXP_SRC_NATIVE; + } + + if (have == PGXP_SRC_DATAFLOW) s_stats.dataflow_hit++; + else s_stats.fallback_hit++; + if (pz != 0) s_stats.w_valid++; + *x16 = px; + *y16 = py; + *sz = pz; + return have; +} + +/* ------------------------------------------------------------------------- */ +/* Legacy v14 SWC2 tracker — kept as the base-flavour feed */ +/* ------------------------------------------------------------------------- */ + +/* Emitted at every swc2 since ABI v14 (all flavours, all backends, overlay + * DLLs). In the pgxp flavour psx_pgxp_cop2 supersedes it for the same swc2 — + * the double write is idempotent (same source shadow, same destination). */ +extern "C" void pgxp_store_gte_reg(uint32_t addr, uint8_t reg) { + if (!g_pgxp_active) return; + PGXPValue *dst = pgxp_ptr(addr); + if (!dst) return; + const PGXPValue *src = &s_gte[reg & 31u]; + if (src->gen != s_gen) return; + s_stats.swc2_stores++; + *dst = *src; +} + +/* ------------------------------------------------------------------------- */ +/* Test accessors */ +/* ------------------------------------------------------------------------- */ + +extern "C" void pgxp_test_seed_gte_sxy(uint32_t index, uint32_t packed, + int32_t x16, int32_t y16, uint16_t z, + int valid) { + if (index >= 4) return; + PGXPValue *pv = &s_gte[12 + index]; + pv->x16 = x16; + pv->y16 = y16; + pv->z = z; + pv->value = packed; + pv->flags = (uint16_t)(PGXP_F_VXY | (z != 0 ? PGXP_F_VZ : 0)); + pv->gen = valid ? s_gen : 0; +} + +extern "C" void pgxp_test_get_gte_sxy(uint32_t index, uint32_t *packed, + int32_t *x16, int32_t *y16, uint16_t *z, + uint8_t *valid) { + if (index >= 4) return; + const PGXPValue *pv = &s_gte[12 + index]; + if (packed) *packed = pv->value; + if (x16) *x16 = pv->x16; + if (y16) *y16 = pv->y16; + if (z) *z = pv->z; + if (valid) *valid = pv_live(pv) ? 1 : 0; +} + +extern "C" uint32_t pgxp_test_generation(void) { return s_gen; } + +extern "C" uint32_t pgxp_test_suppress_depth(void) { return s_suppress; } +extern "C" int pgxp_test_active(void) { return g_pgxp_active; } + +extern "C" void pgxp_test_set_generation(uint32_t gen) { s_gen = gen; } + +/* Address-keyed depth lookup for the shipped perspective-texturing path + * (gpu.c prepare_texture_triangle). Same contract as the retired hashed + * table: hit only when the tracked word matches the packet word exactly. */ +extern "C" int pgxp_load_precise_word(uint32_t addr, uint32_t packed, + int32_t *x16, int32_t *y16, uint16_t *z) { + if (!s_enabled) return 0; + PGXPValue *pv = pgxp_ptr(addr); + if (!pv || pv->gen != s_gen || pv->value != packed || + (pv->flags & PGXP_F_VXY) != PGXP_F_VXY) + return 0; + if (x16) *x16 = pv->x16; + if (y16) *y16 = pv->y16; + if (z) *z = (pv->flags & PGXP_F_VZ) ? pv->z : 0; + return (pv->flags & PGXP_F_VZ) && pv->z != 0; +} diff --git a/runtime/src/psx_interpreter.c b/runtime/src/psx_interpreter.c index 0ca57ba3..340305f0 100644 --- a/runtime/src/psx_interpreter.c +++ b/runtime/src/psx_interpreter.c @@ -211,12 +211,18 @@ static void exec_one(CPUState* cpu) { uint32_t rd = RD(insn); uint32_t sa = SA(insn); switch (func) { - case 0x00: set_reg(cpu, rd, rt_val << sa); break; /* SLL */ - case 0x02: set_reg(cpu, rd, rt_val >> sa); break; /* SRL */ - case 0x03: set_reg(cpu, rd, (uint32_t)((int32_t)rt_val >> sa)); break; /* SRA */ - case 0x04: set_reg(cpu, rd, rt_val << (rs_val & 31)); break; /* SLLV */ - case 0x06: set_reg(cpu, rd, rt_val >> (rs_val & 31)); break; /* SRLV */ - case 0x07: set_reg(cpu, rd, (uint32_t)((int32_t)rt_val >> (rs_val & 31))); break; /* SRAV */ + case 0x00: set_reg(cpu, rd, rt_val << sa); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rt_val, sa); break; /* SLL */ + case 0x02: set_reg(cpu, rd, rt_val >> sa); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rt_val, sa); break; /* SRL */ + case 0x03: set_reg(cpu, rd, (uint32_t)((int32_t)rt_val >> sa)); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rt_val, sa); break; /* SRA */ + case 0x04: set_reg(cpu, rd, rt_val << (rs_val & 31)); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rt_val, rs_val); break; /* SLLV */ + case 0x06: set_reg(cpu, rd, rt_val >> (rs_val & 31)); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rt_val, rs_val); break; /* SRLV */ + case 0x07: set_reg(cpu, rd, (uint32_t)((int32_t)rt_val >> (rs_val & 31))); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rt_val, rs_val); break; /* SRAV */ case 0x08: { /* JR */ uint32_t target = rs_val; exec_one(cpu); /* delay slot */ @@ -232,41 +238,54 @@ static void exec_one(CPUState* cpu) { } case 0x0C: raise_exception(cpu, 8, 0); return; /* SYSCALL */ case 0x0D: raise_exception(cpu, 9, 0); return; /* BREAK */ - case 0x10: set_reg(cpu, rd, cpu->hi); break; /* MFHI */ - case 0x11: cpu->hi = rs_val; break; /* MTHI */ - case 0x12: set_reg(cpu, rd, cpu->lo); break; /* MFLO */ - case 0x13: cpu->lo = rs_val; break; /* MTLO */ + case 0x10: set_reg(cpu, rd, cpu->hi); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], cpu->hi, 0); break; /* MFHI */ + case 0x11: cpu->hi = rs_val; + psx_pgxp_alu(cpu, insn, cpu->hi, rs_val, 0); break; /* MTHI */ + case 0x12: set_reg(cpu, rd, cpu->lo); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], cpu->lo, 0); break; /* MFLO */ + case 0x13: cpu->lo = rs_val; + psx_pgxp_alu(cpu, insn, cpu->lo, rs_val, 0); break; /* MTLO */ case 0x18: { /* MULT */ int64_t r = (int64_t)(int32_t)rs_val * (int64_t)(int32_t)rt_val; - cpu->lo = (uint32_t)r; cpu->hi = (uint32_t)(r >> 32); break; + cpu->lo = (uint32_t)r; cpu->hi = (uint32_t)(r >> 32); + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, rs_val, rt_val); break; } case 0x19: { /* MULTU */ uint64_t r = (uint64_t)rs_val * (uint64_t)rt_val; - cpu->lo = (uint32_t)r; cpu->hi = (uint32_t)(r >> 32); break; + cpu->lo = (uint32_t)r; cpu->hi = (uint32_t)(r >> 32); + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, rs_val, rt_val); break; } case 0x1A: /* DIV */ if (rt_val == 0) { cpu->lo = (rs_val & 0x80000000u) ? 1 : 0xFFFFFFFFu; cpu->hi = rs_val; } else if (rs_val == 0x80000000u && rt_val == 0xFFFFFFFFu) { cpu->lo = 0x80000000u; cpu->hi = 0; } else { cpu->lo = (uint32_t)((int32_t)rs_val / (int32_t)rt_val); cpu->hi = (uint32_t)((int32_t)rs_val % (int32_t)rt_val); } + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, rs_val, rt_val); break; case 0x1B: /* DIVU */ if (rt_val == 0) { cpu->lo = 0xFFFFFFFFu; cpu->hi = rs_val; } else { cpu->lo = rs_val / rt_val; cpu->hi = rs_val % rt_val; } + psx_pgxp_muldiv(cpu, insn, cpu->hi, cpu->lo, rs_val, rt_val); break; case 0x20: { /* ADD (overflow trap) */ int64_t r = (int64_t)(int32_t)rs_val + (int64_t)(int32_t)rt_val; if (r < (int64_t)INT32_MIN || r > (int64_t)INT32_MAX) { raise_exception(cpu, 12, 0); return; } - set_reg(cpu, rd, (uint32_t)(int32_t)r); break; + set_reg(cpu, rd, (uint32_t)(int32_t)r); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rs_val, rt_val); break; } - case 0x21: set_reg(cpu, rd, rs_val + rt_val); break; /* ADDU */ + case 0x21: set_reg(cpu, rd, rs_val + rt_val); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rs_val, rt_val); break; /* ADDU */ case 0x22: { /* SUB (overflow trap) */ int64_t r = (int64_t)(int32_t)rs_val - (int64_t)(int32_t)rt_val; if (r < (int64_t)INT32_MIN || r > (int64_t)INT32_MAX) { raise_exception(cpu, 12, 0); return; } - set_reg(cpu, rd, (uint32_t)(int32_t)r); break; + set_reg(cpu, rd, (uint32_t)(int32_t)r); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rs_val, rt_val); break; } - case 0x23: set_reg(cpu, rd, rs_val - rt_val); break; /* SUBU */ + case 0x23: set_reg(cpu, rd, rs_val - rt_val); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rs_val, rt_val); break; /* SUBU */ case 0x24: set_reg(cpu, rd, rs_val & rt_val); break; /* AND */ - case 0x25: set_reg(cpu, rd, rs_val | rt_val); break; /* OR */ + case 0x25: set_reg(cpu, rd, rs_val | rt_val); + psx_pgxp_alu(cpu, insn, cpu->gpr[rd], rs_val, rt_val); break; /* OR */ case 0x26: set_reg(cpu, rd, rs_val ^ rt_val); break; /* XOR */ case 0x27: set_reg(cpu, rd, ~(rs_val | rt_val)); break; /* NOR */ case 0x2A: set_reg(cpu, rd, (int32_t)rs_val < (int32_t)rt_val ? 1 : 0); break; /* SLT */ @@ -339,15 +358,19 @@ static void exec_one(CPUState* cpu) { case 0x08: { /* ADDI (overflow trap) */ int64_t r = (int64_t)(int32_t)rs_val + (int64_t)SIMM(insn); if (r < (int64_t)INT32_MIN || r > (int64_t)INT32_MAX) { raise_exception(cpu, 12, 0); return; } - set_reg(cpu, RT(insn), (uint32_t)(int32_t)r); break; + set_reg(cpu, RT(insn), (uint32_t)(int32_t)r); + psx_pgxp_alu(cpu, insn, cpu->gpr[RT(insn)], rs_val, (uint32_t)SIMM(insn)); break; } - case 0x09: set_reg(cpu, RT(insn), rs_val + (uint32_t)SIMM(insn)); break; /* ADDIU */ + case 0x09: set_reg(cpu, RT(insn), rs_val + (uint32_t)SIMM(insn)); + psx_pgxp_alu(cpu, insn, cpu->gpr[RT(insn)], rs_val, (uint32_t)SIMM(insn)); break; /* ADDIU */ case 0x0A: set_reg(cpu, RT(insn), (int32_t)rs_val < SIMM(insn) ? 1 : 0); break; /* SLTI */ case 0x0B: set_reg(cpu, RT(insn), rs_val < (uint32_t)SIMM(insn) ? 1 : 0); break; /* SLTIU */ case 0x0C: set_reg(cpu, RT(insn), rs_val & IMM16(insn)); break; /* ANDI */ - case 0x0D: set_reg(cpu, RT(insn), rs_val | IMM16(insn)); break; /* ORI */ + case 0x0D: set_reg(cpu, RT(insn), rs_val | IMM16(insn)); + psx_pgxp_alu(cpu, insn, cpu->gpr[RT(insn)], rs_val, IMM16(insn)); break; /* ORI */ case 0x0E: set_reg(cpu, RT(insn), rs_val ^ IMM16(insn)); break; /* XORI */ - case 0x0F: set_reg(cpu, RT(insn), (uint32_t)IMM16(insn) << 16); break; /* LUI */ + case 0x0F: set_reg(cpu, RT(insn), (uint32_t)IMM16(insn) << 16); + psx_pgxp_alu(cpu, insn, cpu->gpr[RT(insn)], 0, 0); break; /* LUI */ /* --- COP0 --- */ case 0x10: { @@ -376,14 +399,21 @@ static void exec_one(CPUState* cpu) { case 0x12: { uint32_t cop_op = RS(insn); switch (cop_op) { - case 0x00: /* MFC2 */ - set_load_delay(RT(insn), gte_read_data(cpu, RD(insn))); + case 0x00: { /* MFC2 */ + uint32_t v = gte_read_data(cpu, RD(insn)); + set_load_delay(RT(insn), v); + psx_pgxp_cop2(cpu, insn, v, 0); break; - case 0x02: /* CFC2 */ - set_load_delay(RT(insn), gte_read_ctrl(cpu, RD(insn))); + } + case 0x02: { /* CFC2 */ + uint32_t v = gte_read_ctrl(cpu, RD(insn)); + set_load_delay(RT(insn), v); + psx_pgxp_cop2(cpu, insn, v, 0); break; + } case 0x04: /* MTC2 */ gte_write_data(cpu, RD(insn), rt_val); + psx_pgxp_cop2(cpu, insn, rt_val, 0); break; case 0x06: /* CTC2 */ gte_write_ctrl(cpu, RD(insn), rt_val); @@ -403,12 +433,16 @@ static void exec_one(CPUState* cpu) { /* --- Loads --- */ case 0x20: { /* LB */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); - set_load_delay(RT(insn), (uint32_t)(int32_t)(int8_t)cpu->read_byte(addr)); + uint32_t v = (uint32_t)(int32_t)(int8_t)cpu->read_byte(addr); + set_load_delay(RT(insn), v); + psx_pgxp_load(cpu, insn, addr, v); break; } case 0x21: { /* LH */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); - set_load_delay(RT(insn), (uint32_t)(int32_t)(int16_t)cpu->read_half(addr)); + uint32_t v = (uint32_t)(int32_t)(int16_t)cpu->read_half(addr); + set_load_delay(RT(insn), v); + psx_pgxp_load(cpu, insn, addr, v); break; } case 0x22: { /* LWL */ @@ -423,21 +457,28 @@ static void exec_one(CPUState* cpu) { case 3: cur = word; break; } set_reg(cpu, RT(insn), cur); + psx_pgxp_load(cpu, insn, addr, cur); break; } case 0x23: { /* LW */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); - set_load_delay(RT(insn), cpu->read_word(addr)); + uint32_t v = cpu->read_word(addr); + set_load_delay(RT(insn), v); + psx_pgxp_load(cpu, insn, addr, v); break; } case 0x24: { /* LBU */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); - set_load_delay(RT(insn), (uint32_t)cpu->read_byte(addr)); + uint32_t v = (uint32_t)cpu->read_byte(addr); + set_load_delay(RT(insn), v); + psx_pgxp_load(cpu, insn, addr, v); break; } case 0x25: { /* LHU */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); - set_load_delay(RT(insn), (uint32_t)cpu->read_half(addr)); + uint32_t v = (uint32_t)cpu->read_half(addr); + set_load_delay(RT(insn), v); + psx_pgxp_load(cpu, insn, addr, v); break; } case 0x26: { /* LWR */ @@ -452,6 +493,7 @@ static void exec_one(CPUState* cpu) { case 3: cur = (cur & 0xFFFFFF00u) | (word >> 24); break; } set_reg(cpu, RT(insn), cur); + psx_pgxp_load(cpu, insn, addr, cur); break; } @@ -459,11 +501,13 @@ static void exec_one(CPUState* cpu) { case 0x28: { /* SB */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); cpu->write_byte(addr, (uint8_t)(rt_val & 0xFF)); + psx_pgxp_store(cpu, insn, addr, rt_val & 0xFFu); break; } case 0x29: { /* SH */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); cpu->write_half(addr, (uint16_t)(rt_val & 0xFFFF)); + psx_pgxp_store(cpu, insn, addr, rt_val & 0xFFFFu); break; } case 0x2A: { /* SWL */ @@ -477,11 +521,13 @@ static void exec_one(CPUState* cpu) { case 3: cur = rt_val; break; } cpu->write_word(aligned, cur); + psx_pgxp_store(cpu, insn, addr, rt_val); break; } case 0x2B: { /* SW */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); cpu->write_word(addr, rt_val); + psx_pgxp_store(cpu, insn, addr, rt_val); break; } case 0x2E: { /* SWR */ @@ -495,19 +541,24 @@ static void exec_one(CPUState* cpu) { case 3: cur = (cur & 0x00FFFFFFu) | (rt_val << 24); break; } cpu->write_word(aligned, cur); + psx_pgxp_store(cpu, insn, addr, rt_val); break; } /* --- COP2 load/store (LWC2/SWC2) --- */ case 0x32: { /* LWC2 */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); - gte_write_data(cpu, RT(insn), cpu->read_word(addr)); + uint32_t v = cpu->read_word(addr); + gte_write_data(cpu, RT(insn), v); + psx_pgxp_cop2(cpu, insn, v, addr); break; } case 0x3A: { /* SWC2 */ uint32_t addr = rs_val + (uint32_t)SIMM(insn); - cpu->write_word(addr, gte_read_data(cpu, RT(insn))); + uint32_t v = gte_read_data(cpu, RT(insn)); + cpu->write_word(addr, v); gte_precision_store_word(addr, RT(insn)); + psx_pgxp_cop2(cpu, insn, v, addr); break; } diff --git a/runtime/src/psx_rewind.c b/runtime/src/psx_rewind.c index 6ed85fe5..9140732e 100644 --- a/runtime/src/psx_rewind.c +++ b/runtime/src/psx_rewind.c @@ -702,6 +702,80 @@ static void blit_thumb(uint32_t *dst, int dw, int x0, int y0, int tw, int th, } } +static void glyph_fill_rect(uint32_t *dst, int dw, int x0, int y0, + int w, int h, uint32_t col) +{ + int x, y; + if (x0 < 0) { w += x0; x0 = 0; } + if (y0 < 0) { h += y0; y0 = 0; } + if (x0 + w > dw) w = dw - x0; + if (y0 + h > RW_PANEL_H) h = RW_PANEL_H - y0; + if (w <= 0 || h <= 0) return; + for (y = y0; y < y0 + h; y++) + for (x = x0; x < x0 + w; x++) + dst[y * dw + x] = col; +} + +static void glyph_fill_disc(uint32_t *dst, int dw, int cx, int cy, + int r, uint32_t col) +{ + int x, y; + const int rr = r * r; + for (y = -r; y <= r; y++) { + for (x = -r; x <= r; x++) { + if (x * x + y * y > rr) continue; + glyph_fill_rect(dst, dw, cx + x, cy + y, 1, 1, col); + } + } +} + +static void glyph_line(uint32_t *dst, int dw, int x0, int y0, int x1, int y1, + int thickness, uint32_t col) +{ + int dx = x1 > x0 ? x1 - x0 : x0 - x1; + int sx = x0 < x1 ? 1 : -1; + int dy = y1 > y0 ? y0 - y1 : y1 - y0; + int sy = y0 < y1 ? 1 : -1; + int err = dx + dy; + int inset = thickness / 2; + + for (;;) { + glyph_fill_rect(dst, dw, x0 - inset, y0 - inset, thickness, thickness, + col); + if (x0 == x1 && y0 == y1) + break; + { + int e2 = err * 2; + if (e2 >= dy) { + err += dy; + x0 += sx; + } + if (e2 <= dx) { + err += dx; + y0 += sy; + } + } + } +} + +static void draw_psx_button(uint32_t *dst, int dw, int x, int y, char kind) +{ + switch (kind) { + case 'x': + glyph_line(dst, dw, x + 4, y + 4, x + 14, y + 14, 2, 0xFF5FA8FFu); + glyph_line(dst, dw, x + 14, y + 4, x + 4, y + 14, 2, 0xFF5FA8FFu); + break; + case 'o': + glyph_fill_disc(dst, dw, x + 9, y + 9, 8, 0xFFFF6B6Bu); + glyph_fill_disc(dst, dw, x + 9, y + 9, 5, 0xE0101218u); + break; + default: + glyph_fill_rect(dst, dw, x + 7, y + 2, 5, 15, 0xFFD7DCE6u); + glyph_fill_rect(dst, dw, x + 2, y + 7, 15, 5, 0xFFD7DCE6u); + break; + } +} + static void draw_char(uint32_t *dst, int dw, int x0, int y0, char c, uint32_t col) { const uint8_t *g; @@ -745,6 +819,7 @@ static void rasterize_panel(void) { uint32_t *d = s_panel; int i, n, card_w, card_h, gap, x, y, sel_w, sel_h; + int fy; int center_x, origin; char buf[96]; char hk[32]; @@ -762,10 +837,15 @@ static void rasterize_panel(void) draw_text(d, RW_PANEL_W, 120, 8, "NO SNAPSHOTS YET", 0xFF888888u); } host_keymap_label(HOST_KEYMAP_REWIND, hk, sizeof(hk)); - /* Overlay font is uppercase-only; keep PS labels dual for pad + KB users. */ - snprintf(buf, sizeof(buf), - "LEFT/RIGHT A/CROSS LOAD B/CIRCLE CLOSE %s", hk); - draw_text(d, RW_PANEL_W, 12, RW_PANEL_H - 16, buf, 0xFFAAAAAAu); + fy = RW_PANEL_H - 22; + draw_psx_button(d, RW_PANEL_W, 12, fy, 'd'); + draw_text(d, RW_PANEL_W, 36, fy + 5, "SEEK", 0xFFE2E5EBu); + draw_psx_button(d, RW_PANEL_W, 116, fy, 'x'); + draw_text(d, RW_PANEL_W, 140, fy + 5, "LOAD", 0xFFE2E5EBu); + draw_psx_button(d, RW_PANEL_W, 216, fy, 'o'); + draw_text(d, RW_PANEL_W, 240, fy + 5, "CLOSE", 0xFFE2E5EBu); + snprintf(buf, sizeof(buf), "%s MENU", hk[0] ? hk : "F8"); + draw_text(d, RW_PANEL_W, 512, fy + 5, buf, 0xFFAAAAAAu); n = (int)s_count; if (n <= 0) { diff --git a/runtime/src/psx_savestate_menu.c b/runtime/src/psx_savestate_menu.c index 77f8b893..1004b944 100644 --- a/runtime/src/psx_savestate_menu.c +++ b/runtime/src/psx_savestate_menu.c @@ -11,14 +11,14 @@ #define SSM_W 640 #define SSM_H 480 -#define SSM_ROW_H 132 -#define SSM_ROW_GAP 8 -#define SSM_ROWS_Y 56 +#define SSM_ROW_H 108 +#define SSM_ROW_GAP 6 +#define SSM_ROWS_Y 58 #define SSM_ROWS_X 28 #define SSM_ROWS_W 584 #define SSM_VISIBLE_ROWS 3 -#define SSM_THUMB_W 168 -#define SSM_THUMB_H 126 +#define SSM_THUMB_W 136 +#define SSM_THUMB_H 102 /* Public-domain 8x8 ASCII 32..90 subset from font8x8_basic. */ static const uint8_t FONT8[59][8] = { @@ -82,6 +82,68 @@ static void stroke_rect(uint32_t *dst, int x, int y, int w, int h, uint32_t col) fill_rect(dst, x + w - 1, y, 1, h, col); } +static void fill_disc(uint32_t *dst, int cx, int cy, int r, uint32_t col) +{ + int x, y; + const int rr = r * r; + for (y = -r; y <= r; y++) { + for (x = -r; x <= r; x++) { + if (x * x + y * y > rr) continue; + fill_rect(dst, cx + x, cy + y, 1, 1, col); + } + } +} + +static void draw_line(uint32_t *dst, int x0, int y0, int x1, int y1, + int thickness, uint32_t col) +{ + int dx = x1 > x0 ? x1 - x0 : x0 - x1; + int sx = x0 < x1 ? 1 : -1; + int dy = y1 > y0 ? y0 - y1 : y1 - y0; + int sy = y0 < y1 ? 1 : -1; + int err = dx + dy; + int inset = thickness / 2; + + for (;;) { + fill_rect(dst, x0 - inset, y0 - inset, thickness, thickness, col); + if (x0 == x1 && y0 == y1) + break; + { + int e2 = err * 2; + if (e2 >= dy) { + err += dy; + x0 += sx; + } + if (e2 <= dx) { + err += dx; + y0 += sy; + } + } + } +} + +static void draw_psx_button(uint32_t *dst, int x, int y, char kind) +{ + switch (kind) { + case 'x': + draw_line(dst, x + 4, y + 4, x + 14, y + 14, 2, 0xFF5FA8FFu); + draw_line(dst, x + 14, y + 4, x + 4, y + 14, 2, 0xFF5FA8FFu); + break; + case 's': + stroke_rect(dst, x + 3, y + 3, 13, 13, 0xFFFF7EB6u); + stroke_rect(dst, x + 4, y + 4, 11, 11, 0xFFFF7EB6u); + break; + case 'o': + fill_disc(dst, x + 9, y + 9, 8, 0xFFFF6B6Bu); + fill_disc(dst, x + 9, y + 9, 5, 0xFF171B25u); + break; + default: + fill_rect(dst, x + 7, y + 2, 5, 15, 0xFFD7DCE6u); + fill_rect(dst, x + 2, y + 7, 15, 5, 0xFFD7DCE6u); + break; + } +} + static void draw_char(uint32_t *dst, int x0, int y0, char c, uint32_t col, int scale) { @@ -203,15 +265,27 @@ static void rasterize_panel(void) SSM_THUMB_W, SSM_THUMB_H, 0xFF242A35u); stroke_rect(s_panel, SSM_ROWS_X + 118, y + 3, SSM_THUMB_W, SSM_THUMB_H, 0xFF3A4352u); - draw_text(s_panel, SSM_ROWS_X + 180, y + 58, "NEW", + draw_text(s_panel, SSM_ROWS_X + 169, y + 46, "NEW", 0xFF707887u, 1); } format_slot_status(i, buf, sizeof(buf)); - draw_text(s_panel, SSM_ROWS_X + 314, y + 50, buf, sub, 1); + draw_text(s_panel, SSM_ROWS_X + 278, y + 42, buf, sub, 1); if (sel) - draw_text(s_panel, SSM_ROWS_X + SSM_ROWS_W - 88, y + 100, + draw_text(s_panel, SSM_ROWS_X + SSM_ROWS_W - 88, y + 82, "SELECT", 0xFFFFD24Du, 1); } + + fill_rect(s_panel, 0, 410, SSM_W, 70, 0xFF171B25u); + draw_psx_button(s_panel, 32, 424, 'd'); + draw_text(s_panel, 56, 428, "SLOT", 0xFFE2E5EBu, 1); + draw_psx_button(s_panel, 132, 424, 'x'); + draw_text(s_panel, 156, 428, "LOAD", 0xFFE2E5EBu, 1); + draw_psx_button(s_panel, 230, 424, 's'); + draw_text(s_panel, 254, 428, "SAVE", 0xFFE2E5EBu, 1); + draw_psx_button(s_panel, 328, 424, 'o'); + draw_text(s_panel, 352, 428, "BACK", 0xFFE2E5EBu, 1); + draw_text(s_panel, 32, 454, "KEYS: ARROWS SLOT ENTER/L LOAD SHIFT+ENTER/S SAVE ESC BACK", + 0xFFB8BDC8u, 1); s_dirty = 0; } diff --git a/runtime/tests/test_dirty_text_admission_guards.py b/runtime/tests/test_dirty_text_admission_guards.py new file mode 100644 index 00000000..c39a5285 --- /dev/null +++ b/runtime/tests/test_dirty_text_admission_guards.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Keep CPU text writes separate from explicit overlay admission.""" + +from pathlib import Path +import argparse +import sys + + +def function_body(source: str, signature: str) -> str: + start = source.index(signature) + brace = source.index("{", start) + depth = 0 + for pos in range(brace, len(source)): + if source[pos] == "{": + depth += 1 + elif source[pos] == "}": + depth -= 1 + if depth == 0: + return source[brace : pos + 1] + raise AssertionError(f"unterminated function: {signature}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, + default=Path(__file__).resolve().parents[2]) + root = parser.parse_args().root.resolve() + memory = (root / "runtime/src/memory.c").read_text(encoding="utf-8") + dma = (root / "runtime/src/dma.c").read_text(encoding="utf-8") + + text_write = function_body(memory, "static inline void text_guard_note_write(") + if "text_modified_bitmap" not in text_write: + raise AssertionError("CPU text divergence is no longer recorded") + for admission in ("dirty_ram_mark_page(", "dirty_ram_mark_executable_range("): + if admission in text_write: + raise AssertionError( + "an ordinary CPU write inside game text automatically admits " + f"executable overlay code via {admission}" + ) + + cd_slice = function_body(dma, "void dma_advance(") + read = cd_slice.index("uint32_t word = cdrom_dma_read();") + store = cd_slice.index("psx_write_word(addr, word);", read) + admit = cd_slice.index("dirty_ram_mark_executable_range(addr, 4);", store) + advance = cd_slice.index("addr = (addr + addr_step)", admit) + if not read < store < admit < advance: + raise AssertionError("CD DMA overlay admission is not coupled to each RAM word") + + mark_range = function_body(memory, "void dirty_ram_mark_executable_range(") + for fragment in ( + "dirty_ram_bitmap[page >> 5] |= (1u << (page & 31u));", + "g_dirty_ram_code_gen++;", + ): + if fragment not in mark_range: + raise AssertionError(f"explicit executable-range admission lost: {fragment}") + + print("dirty-text admission guards: ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/runtime/tests/test_fps_telemetry_opt_in.py b/runtime/tests/test_fps_telemetry_opt_in.py index a38562da..3d302172 100644 --- a/runtime/tests/test_fps_telemetry_opt_in.py +++ b/runtime/tests/test_fps_telemetry_opt_in.py @@ -12,6 +12,8 @@ required = ( 'std::getenv("PSX_FPS_TELEMETRY")', "fps_telemetry_enabled() && !psx_netplay_in_load_barrier()", "sdl_window && s_fps_base_title.empty()", + "Game %.0f fps %.2fx | Display %.0f fps", + "Game %.0f FPS %.2fx | Display %.0f FPS", ) for needle in required: if needle not in main_cpp: diff --git a/runtime/tests/test_gte_register_access.cpp b/runtime/tests/test_gte_register_access.cpp index e00bf0a7..4de0b17a 100644 --- a/runtime/tests/test_gte_register_access.cpp +++ b/runtime/tests/test_gte_register_access.cpp @@ -488,7 +488,15 @@ int test_precise_sxy_invalidation() { for (uint8_t reg = 0; reg < 32; ++reg) { gte_test_set_precise_valid_mask(0xFu); gte_write_data(&cpu, reg, 0x12345678u); - const uint32_t expected = (reg >= 12 && reg <= 15) ? 0u : 0xFu; + /* The PGXP shadow drops exactly the register(s) the guest wrote: + * SXY0/SXY1 clear their own slot; SXY2 and SXYP clear both mirrors + * (regs 14+15). The untouched slots stay live — their register + * values did not change (SXYP's hardware FIFO shift makes 12/13 + * stale in VALUE, which validate-on-read handles at use; liveness + * alone is not a correctness claim). */ + uint32_t expected = 0xFu; + if (reg == 12 || reg == 13) expected &= ~(1u << (reg - 12)); + else if (reg == 14 || reg == 15) expected &= ~0xCu; const uint32_t actual = gte_test_get_precise_valid_mask(); if (actual != expected) return fail_value("precise SXY invalidation", 0, reg, diff --git a/runtime/tests/test_host_keymap.c b/runtime/tests/test_host_keymap.c new file mode 100644 index 00000000..25ddb8f5 --- /dev/null +++ b/runtime/tests/test_host_keymap.c @@ -0,0 +1,84 @@ +#include "host_keymap.h" +#include "psx_sdl.h" + +#include +#include + +static int failures = 0; + +static void check(int cond, const char *what) { + if (!cond) { + fprintf(stderr, "FAIL: %s\n", what); + failures++; + } +} + +static int mod_ctrl(void) { return (int)KMOD_CTRL; } +static int mod_alt(void) { return (int)KMOD_ALT; } + +int main(int argc, char **argv) { + uint8_t keys[SDL_NUM_SCANCODES]; + const char *cfg = "host_keymap_test.ini"; + FILE *f; + (void)argc; + (void)argv; + + memset(keys, 0, sizeof(keys)); + host_keymap_load(NULL); + check(host_keymap_match(HOST_KEYMAP_FULLSCREEN, (int)SDLK_RETURN, mod_alt()), + "default fullscreen includes Alt+Return"); + check(host_keymap_match(HOST_KEYMAP_FULLSCREEN, (int)SDLK_f, mod_ctrl()), + "default fullscreen includes Ctrl+F"); + keys[SDL_SCANCODE_TAB] = 1; + check(host_keymap_down(HOST_KEYMAP_TURBO, keys, 0), + "default turbo is held by Tab"); + keys[SDL_SCANCODE_TAB] = 0; + check(host_keymap_match(HOST_KEYMAP_VOLUME_UP, (int)SDLK_KP_PLUS, 0), + "default volume up is keypad plus"); + check(host_keymap_match(HOST_KEYMAP_VOLUME_DOWN, (int)SDLK_KP_MINUS, 0), + "default volume down is keypad minus"); + check(host_keymap_match(HOST_KEYMAP_DISPLAY_PERF, (int)SDLK_f, 0), + "default display perf is F"); + + f = fopen(cfg, "wb"); + check(f != NULL, "create temporary config.ini"); + if (!f) return 1; + fputs("[KeyMap]\n" + "Fullscreen = F11\n" + "Turbo = Q\n" + "VolumeUp = Up\n" + "VolumeDown = Down\n" + "DisplayPerf = F10\n", + f); + fclose(f); + + host_keymap_load(cfg); + check(!host_keymap_match(HOST_KEYMAP_FULLSCREEN, (int)SDLK_f, mod_ctrl()), + "fullscreen rebind disables Ctrl+F fallback"); + check(host_keymap_match(HOST_KEYMAP_FULLSCREEN, (int)SDLK_F11, 0), + "fullscreen rebind uses F11"); + memset(keys, 0, sizeof(keys)); + keys[SDL_SCANCODE_TAB] = 1; + check(!host_keymap_down(HOST_KEYMAP_TURBO, keys, 0), + "turbo rebind disables Tab fallback"); + keys[SDL_SCANCODE_TAB] = 0; + keys[SDL_SCANCODE_Q] = 1; + check(host_keymap_down(HOST_KEYMAP_TURBO, keys, 0), + "turbo rebind uses Q"); + check(host_keymap_match(HOST_KEYMAP_VOLUME_UP, (int)SDLK_UP, 0), + "volume up rebind uses Up"); + check(host_keymap_match(HOST_KEYMAP_VOLUME_DOWN, (int)SDLK_DOWN, 0), + "volume down rebind uses Down"); + check(!host_keymap_match(HOST_KEYMAP_DISPLAY_PERF, (int)SDLK_f, 0), + "display perf rebind disables F fallback"); + check(host_keymap_match(HOST_KEYMAP_DISPLAY_PERF, (int)SDLK_F10, 0), + "display perf rebind uses F10"); + + remove(cfg); + if (failures) { + fprintf(stderr, "host_keymap_test: %d failure(s)\n", failures); + return 1; + } + printf("PASS: host keymap defaults and rebinds\n"); + return 0; +} diff --git a/runtime/tests/test_iso_reader_sbi.cpp b/runtime/tests/test_iso_reader_sbi.cpp new file mode 100644 index 00000000..f8dcc6d5 --- /dev/null +++ b/runtime/tests/test_iso_reader_sbi.cpp @@ -0,0 +1,72 @@ +#include "iso_reader.h" + +#include +#include +#include +#include +#include +#include + +static int failures; + +static void check(bool condition, const char* message) { + if (!condition) { + std::cerr << "FAIL: " << message << '\n'; + ++failures; + } +} + +static void write_file(const std::filesystem::path& path, + const std::vector& bytes) { + std::ofstream out(path, std::ios::binary); + out.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); +} + +int main() { + const auto root = std::filesystem::current_path() / "iso_reader_sbi_scratch"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + const auto image = root / "protected.bin"; + write_file(image, std::vector(2352 * 20)); + + const std::array replacement = { + 0x41, 0x01, 0x01, 0x23, 0x06, 0x05, 0x00, 0x03, 0x08, 0x01}; + // SBI positions are absolute MSF. 00:02:00 is disc LBA zero after the + // standard 150-frame lead-in. + std::vector sbi = {'S', 'B', 'I', 0, 0x00, 0x02, 0x00, 0x01}; + sbi.insert(sbi.end(), replacement.begin(), replacement.end()); + write_file(root / "protected.sbi", sbi); + + PS1::ISOReader reader; + check(reader.Open(image.string()), "valid same-basename SBI mounts"); + std::array subq = {}; + bool valid = true; + check(reader.ReadSubChannelQ(0, subq.data(), &valid), + "replacement sub-Q can be read"); + check(!valid, "SBI replacement is reported with intentionally invalid CRC"); + check(std::equal(replacement.begin(), replacement.end(), subq.begin()), + "SBI replacement payload is preserved"); + + check(reader.ReadSubChannelQ(1, subq.data(), &valid), + "ordinary sub-Q can be generated"); + check(valid, "ordinary generated sub-Q has valid CRC"); + check(subq[0] == 0x41 && subq[1] == 0x01 && subq[2] == 0x01, + "generated data-track identity is correct"); + check(subq[6] == 0x00 && subq[7] == 0x02 && subq[8] == 0x01, + "generated absolute position includes the 150-sector lead-in"); + reader.Close(); + + write_file(root / "protected.sbi", {'B', 'A', 'D'}); + check(!reader.Open(image.string()), "malformed SBI fails the mount closed"); + + std::filesystem::remove(root / "protected.sbi"); + check(reader.Open(image.string()), "image without SBI still mounts"); + check(!reader.HasSubChannelReplacements(), + "image without SBI keeps the compatibility path inactive"); + check(reader.ReadSubChannelQ(0, subq.data(), &valid) && valid, + "image without SBI generates valid sub-Q"); + reader.Close(); + std::filesystem::remove_all(root); + return failures ? 1 : 0; +} diff --git a/runtime/tests/test_overlay_capture_retry.cpp b/runtime/tests/test_overlay_capture_retry.cpp index a8a8e0e0..fe126330 100644 --- a/runtime/tests/test_overlay_capture_retry.cpp +++ b/runtime/tests/test_overlay_capture_retry.cpp @@ -124,8 +124,13 @@ extern "C" uint32_t crc32_compute(const uint8_t *data, size_t size) { } int main() { - SDL_SetMainReady(); - if (SDL_Init(0) != 0) { + /* psx_sdl_init: 0 on success under BOTH backends. SDL3's SDL.h no longer + * pulls SDL_main.h, and its SDL_Init returns bool (true = success), so + * the previous SDL_SetMainReady() + `SDL_Init(0) != 0` rotted twice over + * when the default backend moved to SDL3: it did not compile, and the + * check was inverted. SDL_SetMainReady is unnecessary here — this test + * never uses SDL_main. */ + if (psx_sdl_init(0) != 0) { std::fprintf(stderr, "FAIL: SDL_Init: %s\n", SDL_GetError()); return 1; } diff --git a/runtime/tests/test_pgxp.cpp b/runtime/tests/test_pgxp.cpp new file mode 100644 index 00000000..59df32f3 --- /dev/null +++ b/runtime/tests/test_pgxp.cpp @@ -0,0 +1,294 @@ +/* test_pgxp.cpp — PGXP value-propagation engine unit tests (ENHANCEMENTS.md + * G1.2/G1.3). White-box over runtime/src/pgxp.cpp with the gte.cpp fallback + * cache stubbed, exercising exactly the properties the engine's safety rests + * on: provenance roundtrips, validate-on-read, half-word semantics, the + * repack arithmetic, the suppression bracket, and the GPU-side safeguards. */ + +#include "pgxp.h" +#include "pgxp_hooks.h" + +#include +#include + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, \ + #cond); \ + g_failures++; \ + } \ + } while (0) + +/* ---- gte.cpp fallback-cache stub ----------------------------------------- */ + +static uint32_t g_fb_packed = 0; +static int32_t g_fb_x16 = 0, g_fb_y16 = 0; +static int g_fb_valid = 0; + +extern "C" int gte_geometry_correction_lookup(uint32_t packed, + int32_t *x16, int32_t *y16) { + if (!g_fb_valid || packed != g_fb_packed) return 0; + if (x16) *x16 = g_fb_x16; + if (y16) *y16 = g_fb_y16; + return 1; +} + +/* ---- MIPS encodings ------------------------------------------------------ */ + +static uint32_t enc_i(uint32_t op, uint32_t rs, uint32_t rt, uint16_t imm) { + return (op << 26) | (rs << 21) | (rt << 16) | imm; +} +static uint32_t enc_r(uint32_t rs, uint32_t rt, uint32_t rd, uint32_t sh, + uint32_t funct) { + return (rs << 21) | (rt << 16) | (rd << 11) | (sh << 6) | funct; +} +static uint32_t enc_cop2(uint32_t sub, uint32_t rt, uint32_t rd) { + return (0x12u << 26) | (sub << 21) | (rt << 16) | (rd << 11); +} + +#define LW(rs, rt) enc_i(0x23, rs, rt, 0) +#define SW(rs, rt) enc_i(0x2B, rs, rt, 0) +#define LH(rs, rt) enc_i(0x21, rs, rt, 0) +#define LHU(rs, rt) enc_i(0x25, rs, rt, 0) +#define SH(rs, rt) enc_i(0x29, rs, rt, 0) +#define SB(rs, rt) enc_i(0x28, rs, rt, 0) +#define LWC2(rt) enc_i(0x32, 1, rt, 0) +#define SWC2(rt) enc_i(0x3A, 1, rt, 0) +#define MFC2(rt, rd) enc_cop2(0x00, rt, rd) +#define MTC2(rt, rd) enc_cop2(0x04, rt, rd) +#define ADDIU(rs, rt, imm) enc_i(0x09, rs, rt, (uint16_t)(imm)) +#define LUI(rt, imm) enc_i(0x0F, 0, rt, (uint16_t)(imm)) +#define SLL(rt, rd, sh) enc_r(0, rt, rd, sh, 0x00) +#define SRA(rt, rd, sh) enc_r(0, rt, rd, sh, 0x03) +#define OR(rs, rt, rd) enc_r(rs, rt, rd, 0, 0x25) +#define ADDU(rs, rt, rd) enc_r(rs, rt, rd, 0, 0x21) + +/* One projected vertex: x = 160.5, y = 80.25 -> packed integer word. */ +static const uint32_t PACKED = (80u << 16) | 160u; +static const int32_t X16 = (160 << 16) | 0x8000; /* 160.5 */ +static const int32_t Y16 = (80 << 16) | 0x4000; /* 80.25 */ +static const uint16_t SZ3 = 100; + +static const uint32_t ADDR_A = 0x80100000u; /* packet slot A (KSEG0) */ +static const uint32_t ADDR_B = 0x00100040u; /* packet slot B (KUSEG) */ + +static void produce_at(uint32_t addr) { + pgxp_gte_push_sxy(X16, Y16, SZ3, PACKED); + psx_pgxp_cop2(nullptr, SWC2(14), PACKED, addr); +} + +static int lookup(uint32_t addr, uint32_t word, int32_t ix, int32_t iy, + int32_t *x, int32_t *y, uint16_t *z) { + int32_t lx, ly; uint16_t lz; + int r = pgxp_get_precise_vertex(addr, word, ix, iy, &lx, &ly, &lz); + if (x) *x = lx; + if (y) *y = ly; + if (z) *z = lz; + return r; +} + +int main(void) { + pgxp_set_enabled(1); + pgxp_set_tolerance(-1.0f); + pgxp_set_cpu_mode(0); + + /* --- SWC2 produce -> GPU consume (the perspective-texturing spine) --- */ + produce_at(ADDR_A); + { + int32_t x, y; uint16_t z; + CHECK(lookup(ADDR_A, PACKED, 160, 80, &x, &y, &z) == PGXP_SRC_DATAFLOW); + CHECK(x == X16 && y == Y16 && z == SZ3); + /* mirrors resolve to the same shadow word */ + CHECK(lookup(0xA0100000u, PACKED, 160, 80, &x, &y, &z) == + PGXP_SRC_DATAFLOW); + } + + /* --- DMA/untracked overwrite: value validation rejects the shadow --- */ + { + int32_t x, y; uint16_t z; + uint32_t other = (81u << 16) | 161u; + CHECK(lookup(ADDR_A, other, 161, 81, &x, &y, &z) == PGXP_SRC_NATIVE); + CHECK(x == (161 << 16) && y == (81 << 16) && z == 0); + } + + /* --- LW/SW roundtrip: packet copied by the CPU keeps provenance --- */ + produce_at(ADDR_A); + psx_pgxp_load(nullptr, LW(1, 8), ADDR_A, PACKED); + psx_pgxp_store(nullptr, SW(1, 8), ADDR_B, PACKED); + { + int32_t x, y; uint16_t z; + CHECK(lookup(ADDR_B, PACKED, 160, 80, &x, &y, &z) == PGXP_SRC_DATAFLOW); + CHECK(x == X16 && y == Y16 && z == SZ3); + } + + /* --- stale GPR: register changed between load and store --- */ + psx_pgxp_load(nullptr, LW(1, 8), ADDR_A, PACKED); + psx_pgxp_store(nullptr, SW(1, 8), ADDR_B, 0xDEADBEEFu); /* r8 mutated */ + CHECK(lookup(ADDR_B, 0xDEADBEEFu, 0, 0, nullptr, nullptr, nullptr) == + PGXP_SRC_NATIVE); + + /* --- MOVE idiom (memory mode, no cpu_mode needed) --- */ + produce_at(ADDR_A); + psx_pgxp_load(nullptr, LW(1, 8), ADDR_A, PACKED); + psx_pgxp_alu(nullptr, ADDU(8, 0, 10), PACKED, PACKED, 0); + psx_pgxp_store(nullptr, SW(1, 10), ADDR_B, PACKED); + CHECK(lookup(ADDR_B, PACKED, 160, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_DATAFLOW); + + /* --- MFC2 -> SW (register transfer path) --- */ + pgxp_gte_push_sxy(X16, Y16, SZ3, PACKED); + psx_pgxp_cop2(nullptr, MFC2(9, 14), PACKED, 0); + psx_pgxp_store(nullptr, SW(1, 9), ADDR_B, PACKED); + CHECK(lookup(ADDR_B, PACKED, 160, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_DATAFLOW); + + /* --- LH/SH: halves travel independently, depth does not survive --- */ + produce_at(ADDR_A); + psx_pgxp_load(nullptr, LHU(1, 8), ADDR_A + 2u, PACKED >> 16); /* Y */ + psx_pgxp_store(nullptr, SH(1, 8), ADDR_B + 2u, PACKED >> 16); + psx_pgxp_load(nullptr, LHU(1, 8), ADDR_A, PACKED & 0xFFFFu); /* X */ + psx_pgxp_store(nullptr, SH(1, 8), ADDR_B, PACKED & 0xFFFFu); + { + int32_t x, y; uint16_t z; + CHECK(lookup(ADDR_B, PACKED, 160, 80, &x, &y, &z) == PGXP_SRC_DATAFLOW); + CHECK(x == X16 && y == Y16); + CHECK(z == 0); /* SH killed the vertex depth */ + } + + /* --- SB destroys the touched half only --- */ + produce_at(ADDR_B); + psx_pgxp_store(nullptr, SB(1, 8), ADDR_B, PACKED & 0xFFu); /* same byte */ + { + int32_t x, y; uint16_t z; + /* low half invalidated -> not a full XY hit anymore */ + CHECK(lookup(ADDR_B, PACKED, 160, 80, &x, &y, &z) != PGXP_SRC_DATAFLOW); + } + + /* --- cpu-mode repack: lhu / sll 16 / or (the classic vertex build) --- */ + pgxp_set_cpu_mode(1); + produce_at(ADDR_A); + psx_pgxp_load(nullptr, LHU(1, 8), ADDR_A + 2u, PACKED >> 16); /* Y */ + psx_pgxp_alu(nullptr, SLL(8, 9, 16), (PACKED >> 16) << 16, + PACKED >> 16, 16); + psx_pgxp_load(nullptr, LHU(1, 10), ADDR_A, PACKED & 0xFFFFu); /* X */ + psx_pgxp_alu(nullptr, OR(9, 10, 11), PACKED, + (PACKED >> 16) << 16, PACKED & 0xFFFFu); + psx_pgxp_store(nullptr, SW(1, 11), ADDR_B, PACKED); + { + int32_t x, y; + CHECK(lookup(ADDR_B, PACKED, 160, 80, &x, &y, nullptr) == + PGXP_SRC_DATAFLOW); + CHECK(x == X16 && y == Y16); + } + + /* --- cpu-mode addiu: fraction rides an integer offset (incl. -N) --- */ + psx_pgxp_load(nullptr, LW(1, 8), ADDR_A, PACKED); + psx_pgxp_alu(nullptr, ADDIU(8, 12, 4), PACKED + 4u, PACKED, 4u); + psx_pgxp_store(nullptr, SW(1, 12), ADDR_B, PACKED + 4u); + { + int32_t x, y; + CHECK(lookup(ADDR_B, PACKED + 4u, 164, 80, &x, &y, nullptr) == + PGXP_SRC_DATAFLOW); + CHECK(x == X16 + (4 << 16) && y == Y16); + } + psx_pgxp_load(nullptr, LW(1, 8), ADDR_A, PACKED); + psx_pgxp_alu(nullptr, ADDIU(8, 12, (uint16_t)-4), PACKED - 4u, PACKED, + (uint32_t)(int32_t)-4); + psx_pgxp_store(nullptr, SW(1, 12), ADDR_B, PACKED - 4u); + { + int32_t x; + CHECK(lookup(ADDR_B, PACKED - 4u, 156, 80, &x, nullptr, nullptr) == + PGXP_SRC_DATAFLOW); + CHECK(x == X16 - (4 << 16)); + } + pgxp_set_cpu_mode(0); + + /* --- cpu-mode OFF: the same repack must degrade to native, cleanly --- */ + produce_at(ADDR_A); + psx_pgxp_load(nullptr, LHU(1, 8), ADDR_A + 2u, PACKED >> 16); + psx_pgxp_alu(nullptr, SLL(8, 9, 16), (PACKED >> 16) << 16, + PACKED >> 16, 16); + psx_pgxp_store(nullptr, SW(1, 9), ADDR_B, (PACKED >> 16) << 16); + CHECK(lookup(ADDR_B, (PACKED >> 16) << 16, 0, 80, nullptr, nullptr, + nullptr) == PGXP_SRC_NATIVE); + + /* --- truncation agreement: integer part must match the native parse --- */ + produce_at(ADDR_A); + CHECK(lookup(ADDR_A, PACKED, 161, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_NATIVE); + + /* --- tolerance clamp --- */ + produce_at(ADDR_A); + pgxp_set_tolerance(0.25f); /* fraction is 0.5 -> reject */ + CHECK(lookup(ADDR_A, PACKED, 160, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_NATIVE); + pgxp_set_tolerance(0.75f); /* 0.5 <= 0.75 -> accept */ + CHECK(lookup(ADDR_A, PACKED, 160, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_DATAFLOW); + pgxp_set_tolerance(-1.0f); + + /* --- fallback tier: no address -> position cache, never a depth --- */ + g_fb_valid = 1; g_fb_packed = PACKED; g_fb_x16 = X16; g_fb_y16 = Y16; + { + int32_t x, y; uint16_t z; + CHECK(lookup(0xFFFFFFFFu, PACKED, 160, 80, &x, &y, &z) == + PGXP_SRC_FALLBACK); + CHECK(x == X16 && y == Y16 && z == 0); + } + g_fb_valid = 0; + + /* --- suppression bracket: nothing records inside it --- */ + pgxp_invalidate_all(); + pgxp_suppress_begin(); + produce_at(ADDR_A); + pgxp_suppress_end(); + CHECK(lookup(ADDR_A, PACKED, 160, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_NATIVE); + + /* --- deferred invalidate inside the bracket --- */ + produce_at(ADDR_A); + pgxp_suppress_begin(); + pgxp_invalidate_all(); /* deferred */ + pgxp_suppress_end(); /* applies here */ + CHECK(lookup(ADDR_A, PACKED, 160, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_NATIVE); + + /* --- invalidate-all + generation wrap --- */ + produce_at(ADDR_A); + pgxp_invalidate_all(); + CHECK(lookup(ADDR_A, PACKED, 160, 80, nullptr, nullptr, nullptr) == + PGXP_SRC_NATIVE); + pgxp_test_set_generation(0xFFFFFFFFu); + pgxp_invalidate_all(); + CHECK(pgxp_test_generation() == 1u); + + /* --- test accessors mirror the SXY FIFO shadows --- */ + pgxp_test_seed_gte_sxy(2, PACKED, X16, Y16, SZ3, 1); + { + uint32_t packed; int32_t x, y; uint16_t z; uint8_t valid; + pgxp_test_get_gte_sxy(2, &packed, &x, &y, &z, &valid); + CHECK(valid && packed == PACKED && x == X16 && y == Y16 && z == SZ3); + pgxp_test_seed_gte_sxy(2, 0, 0, 0, 0, 0); + pgxp_test_get_gte_sxy(2, &packed, &x, &y, &z, &valid); + CHECK(!valid); + } + + /* --- stats sanity: dataflow hits were counted --- */ + { + PGXPStats st; + pgxp_get_stats(&st); + CHECK(st.lookups > 0); + CHECK(st.dataflow_hit > 0); + CHECK(st.native > 0); + CHECK(st.fallback_hit > 0); + CHECK(st.value_mismatch > 0); + } + + if (g_failures) { + std::fprintf(stderr, "test_pgxp: %d FAILURES\n", g_failures); + return 1; + } + std::printf("test_pgxp: all checks passed\n"); + return 0; +} diff --git a/runtime/tests/test_rewind_toggle_combo.py b/runtime/tests/test_rewind_toggle_combo.py index 0498a4f9..63c9c533 100644 --- a/runtime/tests/test_rewind_toggle_combo.py +++ b/runtime/tests/test_rewind_toggle_combo.py @@ -25,6 +25,8 @@ assert ( ) not in MAIN assert "HOST_KEYMAP_SAVE_STATE_MENU" in MAIN assert "key >= SDLK_F1 && key <= SDLK_F12" not in MAIN +assert "savestate_slot_from_function_key" not in MAIN +assert "savestate_submit_slot(f_slot" not in MAIN assert "savestate_input_guard_arm();" in MAIN assert "savestate_input_guard_active()" in MAIN assert "out->buttons = 0xFFFFu;" in MAIN @@ -33,5 +35,9 @@ assert "savestate_menu_ignore_toggle_release" in MAIN assert "savestate_menu_open_key" in MAIN assert "savestate_menu_toggle(key)" in MAIN assert "savestate_menu_toggle(0)" in MAIN +assert 'std::getenv("PSX_FAST_FORWARD_SPEED")' in MAIN +assert "bool manual_turbo_active = false;" in MAIN +assert "Fast forward: %dx" in MAIN +assert "g_frame_period_ms / (double)mult" in MAIN print("host shortcut guard passed") diff --git a/tools/compile_overlays.py b/tools/compile_overlays.py index 14cc265d..b3f7a3c0 100644 --- a/tools/compile_overlays.py +++ b/tools/compile_overlays.py @@ -4068,6 +4068,9 @@ def _compile_dll_tcc(c_path: str, out_dll: str, include_dirs, flavor: int, '-DPSX_NO_DEBUG_TOOLS', '-DPSX_ENABLE_BLOCK_CYCLES=1', f'-DPSX_OVERLAY_FLAVOR={int(flavor)}', + # PGXP flavor bit (overlay_api.h PSX_OVERLAY_FLAVOR_PGXP): arm the + # PGXP_*() hook macros the emitter writes into every overlay C. + *(['-DPSX_PGXP=1'] if int(flavor) & 2 else []), native_path(c_path), '-o', native_path(out_dll)] for d in include_dirs: cmd.append('-I' + native_path(_bom_free_incdir(d))) @@ -4116,6 +4119,9 @@ def _compile_dll_direct(c_path: str, out_dll: str, include_dirs: list[str], # cache and a base cache can never cross-contaminate even if they share # a directory (they key by guest-bytes CRC, which is flavor-blind). f'-DPSX_OVERLAY_FLAVOR={int(flavor)}', + # PGXP flavor bit (overlay_api.h PSX_OVERLAY_FLAVOR_PGXP): arm the + # PGXP_*() hook macros the emitter writes into every overlay C. + *(['-DPSX_PGXP=1'] if int(flavor) & 2 else []), c_path, '-o', out_dll, *includes, @@ -5303,7 +5309,7 @@ def main(): ch = codegen_hash(args.runtime_include) gh = overlay_config_hash(args.recompiler, args.game_toml) cache_dir = os.path.join(args.out_dir, game_id, args.compiler, cache_arch_abi(), - f'cg{cg}_{ch:08x}_gc{gh:08x}') + f'cg{cg}_{ch:08x}_gc{gh:08x}_f{int(args.flavor)}') os.makedirs(cache_dir, exist_ok=True) print(f'Cache dir: {cache_dir} ' f'(codegen ver {cg}, emitter {ch:08x}, config {gh:08x})') diff --git a/tools/new_project_layout/setup_project.ps1 b/tools/new_project_layout/setup_project.ps1 index 2b3db0e4..f37edaf5 100644 --- a/tools/new_project_layout/setup_project.ps1 +++ b/tools/new_project_layout/setup_project.ps1 @@ -1,6 +1,6 @@ # New Project Layout scaffolding (Windows). # -# Path params (pass on the CLI — tab-complete in your shell): +# Path params (pass on the CLI -- tab-complete in your shell): # -Disc REQUIRED legal Redump .cue (kept on your dump drive) # -Dir Parent directory (default: .) # -Bios Optional retail BIOS for Generate @@ -129,7 +129,7 @@ if ($Players -eq 0) { } } if ($Players -lt 1 -or $Players -gt 8) { - throw "Players must be 1–8 (got: $Players)" + throw "Players must be 1-8 (got: $Players)" } $env:PYTHONPATH = "$ScriptDir" + $(if ($env:PYTHONPATH) { ";$env:PYTHONPATH" } else { "" }) @@ -171,21 +171,21 @@ $useWizard = $false $useNetplay = $false if (-not $useRecompUi) { if ($EnableWizard -or $EnableNetplay) { - Write-Warning "Wizard/netplay require recomp-ui — forcing both off." + Write-Warning "Wizard/netplay require recomp-ui -- forcing both off." } - Write-Host " (recomp-ui declined — skipping wizard/netplay; PSX_RECOMP_UI=OFF)" + Write-Host " (recomp-ui declined -- skipping wizard/netplay; PSX_RECOMP_UI=OFF)" } else { # Default ON (interactive + non-interactive): setup-host CI needs the wizard. $useWizard = Resolve-BoolOpt -Enable:$EnableWizard -No:$NoWizard -PromptDefault:$true ` -Question "Enable first-run setup wizard + Generate & rebuild?" if (-not $interactive -and -not $NoWizard) { $useWizard = $true } - # 1-player titles cannot use multiplayer netplay — skip prompts entirely. + # 1-player titles cannot use multiplayer netplay -- skip prompts entirely. if ($Players -eq 1) { if ($EnableNetplay) { Write-Warning "-EnableNetplay ignored for 1-player title." } $useNetplay = $false - Write-Host " (1-player title — netplay skipped)" + Write-Host " (1-player title -- netplay skipped)" } else { $useNetplay = Resolve-BoolOpt -Enable:$EnableNetplay -No:$NoNetplay -PromptDefault:$false ` -Question "Enable netplay UI (needs nested recomp-net)?" @@ -196,7 +196,7 @@ $NetplayLobbyWs = "" if ($useNetplay) { if (-not $LobbyUrl) { if ($interactive) { - $LobbyUrl = Prompt-Line "Netplay lobby server URL (host or ws://…)" $DefaultLobbyHost + $LobbyUrl = Prompt-Line "Netplay lobby server URL (host or ws://...)" $DefaultLobbyHost } else { $LobbyUrl = $DefaultLobbyHost } @@ -216,7 +216,7 @@ if ($doGenerate) { $doBuild = Resolve-BoolOpt -Enable:$EnableBuild -No:$NoBuild -PromptDefault:$true ` -Question "Configure & build psx-runtime after Generate?" } elseif ($EnableBuild) { - Write-Warning "-EnableBuild requires Generate — enabling Generate" + Write-Warning "-EnableBuild requires Generate -- enabling Generate" $doGenerate = $true $doBuild = $true } @@ -242,8 +242,8 @@ $GameName = $GameName.Trim() if (-not $GameName) { $GameName = $Name } $DiscHint = "your legally owned $GameName disc" $EntryPc = "0x80010000" -$PublisherDisp = if ($Publisher) { $Publisher } else { "—" } -$YearDisp = if ($Year) { $Year } else { "—" } +$PublisherDisp = if ($Publisher) { $Publisher } else { "-" } +$YearDisp = if ($Year) { $Year } else { "-" } $DescriptionMd = if ($Description) { $Description } else { "_Add a short pitch in catalog_identity.json / README._" } $Root = Join-Path (Resolve-Path $Dir) $Name @@ -263,7 +263,7 @@ if ($useRecompUi) { set(PSX_RECOMP_UI OFF CACHE BOOL "No recomp-ui submodule in this scaffold" FORCE) "@ - Set-Content -Encoding UTF8 -Path $CodegenArgFile -Value " # CODEGEN_SETUP_SOURCES — add with recomp-ui / wizard later" + Set-Content -Encoding UTF8 -Path $CodegenArgFile -Value " # CODEGEN_SETUP_SOURCES -- add with recomp-ui / wizard later" } if ($useNetplay) { @@ -279,9 +279,9 @@ endif() $NetplayLobbyUrlArg = " NETPLAY_LOBBY_URL `"$NetplayLobbyWs`"" } else { Set-Content -Encoding UTF8 -Path $NetplayBlockFile -Value @" -# Full netplay UI — enable after adding recomp-ui + testing: +# Full netplay UI -- enable after adding recomp-ui + testing: # if(EXISTS "`${PSXRECOMP_ROOT}/lib/recomp-net/CMakeLists.txt") -# set(PSX_NETPLAY ON CACHE BOOL "…" FORCE) +# set(PSX_NETPLAY ON CACHE BOOL "..." FORCE) # endif() "@ $NetplayRuntimeArg = " # ENABLE_NETPLAY_IF_PRESENT" @@ -296,8 +296,8 @@ set(PSX_SETUP_WIZARD ON CACHE BOOL $WizardRuntimeArg = " ENABLE_SETUP_WIZARD" } else { Set-Content -Encoding UTF8 -Path $WizardBlockFile -Value @" -# First-run setup wizard — enable after testing: -# set(PSX_SETUP_WIZARD ON CACHE BOOL "…" FORCE) +# First-run setup wizard -- enable after testing: +# set(PSX_SETUP_WIZARD ON CACHE BOOL "..." FORCE) "@ $WizardRuntimeArg = " # ENABLE_SETUP_WIZARD" } @@ -370,7 +370,7 @@ New-Item -ItemType Directory -Force -Path (Join-Path $Root "scripts") | Out-Null New-Item -ItemType Directory -Force -Path (Join-Path $Root "tools") | Out-Null New-Item -ItemType Directory -Force -Path (Join-Path $Root "mods\preloaded\packages") | Out-Null New-Item -ItemType Directory -Force -Path (Join-Path $Root "assets") | Out-Null -# Empty mod catalog tree (runtime copies mods/preloaded → beside the exe as mods/). +# Empty mod catalog tree (runtime copies mods/preloaded -> beside the exe as mods/). @' # Preloaded mods @@ -379,7 +379,7 @@ Ship reviewed, default-disabled packages here: ```text packages/// manifest.toml - … + ... ``` Build wiring copies `mods/preloaded` next to the game executable as `mods/`. @@ -456,7 +456,7 @@ if ($useCi) { --set "WINDOW_TITLE=$WindowTitle" Write-Host " wrote .github/workflows/release.yml" } else { - Write-Warning "Missing $WfSrc — skipped CI workflow copy" + Write-Warning "Missing $WfSrc -- skipped CI workflow copy" } } else { Write-Host "== CI workflow skipped ==" @@ -494,7 +494,7 @@ for m in re.finditer(r'FILE\s+"([^"]+)"', text, re.I): print(f" copied track: {src.name}") else: print(f" warning: cue FILE missing: {src}", file=sys.stderr) -print(" disc staged under disc/ (gitignored — never commit dumps)") +print(" disc staged under disc/ (gitignored -- never commit dumps)") '@ $pyFile = Join-Path $env:TEMP "psxrecomp_stage_disc.py" Set-Content -Encoding UTF8 -Path $pyFile -Value $py @@ -546,19 +546,19 @@ print(m.group(1) if m else '') $BootExe = $BootFromToml Fill-Template (Join-Path $TemplateDir "CMakeLists.txt.in") (Join-Path $Root "CMakeLists.txt") Fill-Template (Join-Path $TemplateDir "codegen_setup.c.in") (Join-Path $Root "codegen_setup.c") - Write-Host " synced boot EXE → $BootExe (CMake / codegen)" + Write-Host " synced boot EXE -> $BootExe (CMake / codegen)" } if ($EntryFromToml) { $EntryPc = $EntryFromToml Fill-Template (Join-Path $TemplateDir "symbols.toml.in") (Join-Path $Root "symbols.toml") - Write-Host " symbols.toml BootEntry → $EntryPc" + Write-Host " symbols.toml BootEntry -> $EntryPc" } if (-not $StageDisc) { - Write-Host " game.toml disc → $DiscTomlPath" - Write-Host " (local absolute path — do not commit machine-specific dumps)" + Write-Host " game.toml disc -> $DiscTomlPath" + Write-Host " (local absolute path -- do not commit machine-specific dumps)" } } catch { - Write-Warning "Disc probe failed — left template game.toml; fill by hand." + Write-Warning "Disc probe failed -- left template game.toml; fill by hand." } } else { Write-Warning "-Disc is not a .cue; skipped probe autofill." @@ -589,17 +589,17 @@ try { commit -q -m "Initial New Project Layout scaffold" $committed = $true } catch { - Write-Host " (skipped initial commit — commit manually when ready)" + Write-Host " (skipped initial commit -- commit manually when ready)" } # Create the GitHub repo + origin now; push deferred until after generate/build. $githubCreated = $false if ($createGithub) { - Write-Host "== GitHub repo (gh, create only — push deferred) ==" + Write-Host "== GitHub repo (gh, create only -- push deferred) ==" if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { - Write-Warning "gh not installed — skip create; push manually later." + Write-Warning "gh not installed -- skip create; push manually later." } elseif (-not $committed) { - Write-Warning "no initial commit — skip gh repo create." + Write-Warning "no initial commit -- skip gh repo create." } else { $vis = switch ($GithubVisibility) { "public" { "--public" } @@ -611,13 +611,13 @@ if ($createGithub) { Write-Host " created origin ($GithubVisibility); push deferred until end" $githubCreated = $true } catch { - Write-Warning "gh repo create failed — if the repo already exists, add origin and push at the end." + Write-Warning "gh repo create failed -- if the repo already exists, add origin and push at the end." if (-not (git remote get-url origin 2>$null)) { try { $ghUrl = (gh repo view $Name --json url -q .url 2>$null) if ($ghUrl) { git remote add origin $ghUrl - Write-Host " attached existing origin → $ghUrl" + Write-Host " attached existing origin -> $ghUrl" $githubCreated = $true } } catch {} @@ -647,9 +647,9 @@ if ($doGenerate) { } python @genArgs $GeneratedOk = $true - Write-Host " generate OK (generated/ is gitignored — not committed)" + Write-Host " generate OK (generated/ is gitignored -- not committed)" } catch { - Write-Warning "Generate failed — fix seeds/disc and re-run generate by hand." + Write-Warning "Generate failed -- fix seeds/disc and re-run generate by hand." $doBuild = $false } finally { Pop-Location @@ -663,9 +663,9 @@ if ($doBuild -and $GeneratedOk) { cmake -S . -B build-release -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build build-release --target psx-runtime $BuildOk = $true - Write-Host " build OK → build-release/ (gitignored)" + Write-Host " build OK -> build-release/ (gitignored)" } catch { - Write-Warning "Build failed — fix and re-run cmake by hand." + Write-Warning "Build failed -- fix and re-run cmake by hand." } finally { Pop-Location } @@ -700,7 +700,7 @@ function Ensure-ActionsRegistersReleaseYml([string]$OwnerRepo) { if ($i -lt 4) { Start-Sleep -Seconds 2 } } - Write-Host " Actions has not listed release.yml yet — nudging with a follow-up commit…" + Write-Host " Actions has not listed release.yml yet -- nudging with a follow-up commit..." $stamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mmZ") Add-Content -Encoding utf8 -Path $wfPath -Value "`n# Actions registration nudge ($stamp UTC)" git add $wfPath 2>$null @@ -732,7 +732,7 @@ if ($createGithub -and (git remote get-url origin 2>$null)) { try { git push -u origin HEAD $githubPushed = $true - Write-Host " pushed $(git rev-parse --abbrev-ref HEAD) → origin" + Write-Host " pushed $(git rev-parse --abbrev-ref HEAD) -> origin" if ($useCi -and (Test-Path (Join-Path $Root ".github\workflows\release.yml"))) { try { $ownerRepo = (gh repo view --json nameWithOwner -q .nameWithOwner 2>$null) @@ -740,7 +740,7 @@ if ($createGithub -and (git remote get-url origin 2>$null)) { } catch {} } } catch { - Write-Warning "git push failed — fetch/rebase or force-push manually if origin already has a different initial commit." + Write-Warning "git push failed -- fetch/rebase or force-push manually if origin already has a different initial commit." } } } @@ -751,7 +751,7 @@ $Step1 = if ($Probed) { "Edit game.toml / re-run probe_disc.py --write-seeds." } $Step2 = if ($BuildOk) { - "Runtime already built under build-release/ — soak offline boot." + "Runtime already built under build-release/ -- soak offline boot." } elseif ($GeneratedOk) { @" Build playable runtime: @@ -771,15 +771,15 @@ Build emitters, Generate, then playable runtime: } $CiNote = if ($useCi) { $n = "CI: .github/workflows/release.yml ready (zip prefix=$ZipPrefix; submodule gitlinks pin the build)." - if ($githubPushed) { $n += " Pushed — open Actions → Release builds (workflow_dispatch)." } + if ($githubPushed) { $n += " Pushed -- open Actions -> Release builds (workflow_dispatch)." } $n } else { "CI workflow not installed (declined)." } $GhNote = if ($createGithub) { if ($githubPushed) { "GitHub: created/attached origin and pushed ($GithubVisibility)." } - elseif ($githubCreated) { "GitHub: origin ready but push failed — see warnings above." } - else { "GitHub: create/push did not complete — check gh auth / remote." } + elseif ($githubCreated) { "GitHub: origin ready but push failed -- see warnings above." } + else { "GitHub: create/push did not complete -- check gh auth / remote." } } else { "GitHub remote not created (declined)." } @@ -791,11 +791,11 @@ Write-Host @" Next steps: 1. $Step1 2. $Step2 - 3. Label symbols in symbols.toml → python tools/sync_symbols.py - 4. Soak offline boot → netplay QA (if enabled) → tag vX.Y.Z. + 3. Label symbols in symbols.toml -> python tools/sync_symbols.py + 4. Soak offline boot -> netplay QA (if enabled) -> tag vX.Y.Z. 5. $CiNote Pins: submodule gitlinks (framework_pins.txt is an optional snapshot) 6. $GhNote -Docs: psxrecomp\docs\GAME_PROJECT_SETUP.md · psxrecomp\docs\SYMBOLS.md +Docs: psxrecomp\docs\GAME_PROJECT_SETUP.md ; psxrecomp\docs\SYMBOLS.md "@