diff --git a/.github/workflows/pr-bios-stamps.yml b/.github/workflows/pr-bios-stamps.yml new file mode 100644 index 00000000..d6fa5e16 --- /dev/null +++ b/.github/workflows/pr-bios-stamps.yml @@ -0,0 +1,17 @@ +name: BIOS emitter stamps + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + bios-stamps: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check committed BIOS emitter stamps + shell: bash + run: bash tools/ci/check_bios_stamps.sh --framework . diff --git a/AGENTS.md b/AGENTS.md index 36e7ca73..24dbeb89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,3 +13,7 @@ shared HLE work is `beads-wuhs`. Do not initialize a local issue database. Work solo unless the owner authorizes delegation for the task. For the approved parity campaign, serialize title builds and game runs: two compile jobs per build, one game instance, and preserve the owner's sessions and personal saves. + +Commit messages use a subject only by default. Add a body only when the reason +is not clear from the diff, and keep it to 1-3 lines. Do not add per-file +changelogs or repeat the diff or PR description. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0871f53..9240f5b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,6 +82,26 @@ written — is likely to be rejected if it: - Contains code the contributor cannot reasonably explain, or whose verification cannot be stated. +### Commit messages + +Use a subject only by default. Add a body only when the reason is not clear +from the diff, and keep it to 1-3 lines. Do not add per-file changelogs or +repeat the diff or PR description. + +### BIOS emitter changes + +After changing a BIOS fingerprint input, run +`bash tools/regen_bios.sh --config bios/OpenBIOS.toml` and commit the regenerated +OpenBIOS output and stamp together. PR CI checks the committed stamps without +requiring a retail BIOS dump. Never update a stamp without regenerating. + +Retail-dump users must also regenerate each linked retail stem, for example +`bash tools/regen_bios.sh --config bios/SCPH1001.toml`. Retail dumps and generated +retail C/stamps stay local and gitignored. In the PR validation notes, name the +BIOS stems checked and whether their output changed; explicitly say when a +retail stem was not checked. Unchanged OpenBIOS output does not establish +unchanged SCPH1001 output. + ## Game-specific work belongs in game repos The framework stays game-agnostic. Per-game patches, launcher UI, assets, memory @@ -253,6 +273,11 @@ By contributing, you agree your contribution is licensed under the same license the project. Do not submit code you don't have the right to contribute, and do not copy from incompatible licenses or proprietary sources. +Runtime comments cite hardware facts to PSX-SPX, and emulators are used only as +black-box references whose outputs we compare. See +[`docs/PROVENANCE.md`](docs/PROVENANCE.md) for the rules and for the passages +still to be replaced. + ### Upstream-derived work and credit When a change adapts code or behavior from another branch or repository, add a diff --git a/README.md b/README.md index da75d6ff..5e368bf6 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,41 @@ > ℹ️ **Note from mstan:** This repo and I are now part of [RetroPortingToolkit](https://retroportingtoolkit.com/). I remain a primary maintainer of psxrecomp alongside the team. [More info](https://1379.tech/forming-a-collective-retro-porting-toolkit/). -**A general-purpose static recompiler for the PlayStation 1.** It turns a PS1 -disc into a native executable — MIPS R3000A translated to C, compiled to x64, -linked against a hardware-accurate runtime. Not an emulator: the game becomes a -program your CPU runs directly. +**A beta static-recompilation framework for the PlayStation 1.** PSXRecomp +translates MIPS R3000A programs into C and builds native applications around +a shared PS1 hardware runtime. Streamed overlays can be captured, compiled +and cached; unresolved code has an interpreter fallback. -Titles brought up on it ship as standalone builds with widescreen, mods, live -language switching and a launcher. They can run on either the bundled, -open-source **OpenBIOS** or a compatible **retail BIOS** supplied by the player; -OpenBIOS is the default, so a retail BIOS dump is not normally required. The -framework is general-purpose: bringing up a new game is a matter of -configuration and reverse engineering, not new engine work. +Game projects add **adaptive widescreen, higher-resolution rendering, local +and online multiplayer, runtime translations, mods and modern launchers**. +Bundled open-source **OpenBIOS** is the default; a compatible retail BIOS is +optional. Each game verifies the player's own disc image. No commercial +disc or BIOS data is included. + +**[Play a game](#games)** · **[Create your first port](docs/GETTING_STARTED.md)** +— a focused Windows setup with no AI required. + +## Mega Man X4: X and Zero in co-op + +Take X and Zero through the same campaign with **two-player local co-op or +netplay**. The host chooses the shared options. Unified camera is the +default; independent online views and local side-by-side views are +experimental. **The X4 co-op mod remains an alpha playtest**, independently +of the framework's beta status, and requires OpenGL and a fresh boot. + +

+ X and Zero together at the Jungle waterfall, with separate lifebars +
Mega Man X4 — X + Zero Co-op. Local play and online rooms from the launcher. +

+ +
+See the online and LAN launcher + +Mega Man X4 launcher offering LAN / Direct IP and Online Netplay + +
+ +## More of the PlayStation world @@ -36,32 +60,42 @@ Background on the original prototype: ## Games -Each game is its own repository that pins a framework commit as a submodule and -ships its own playable release with an in-app launcher. Validation and feature -coverage continue to improve per title. +Each project pins its framework revision and owns its disc support, platforms +and validation. **Beta describes the framework; individual ports and mods +can still be previews.** This list was checked against public documentation +and release notes on **21 October 2026**. Open each project for current setup +and testing limits; source projects do not all provide finished downloads. -| Game | Repository | Latest build | Notes | -|---|---|---|---| -| *Tomba!* | [TombaRecomp](https://github.com/mstan/TombaRecomp) | [releases](https://github.com/mstan/TombaRecomp/releases/latest) | Widescreen, supersampling, save/load, mod packages. | -| *Tomba! 2* | [Tomba2Recomp](https://github.com/mstan/Tomba2Recomp) | [releases](https://github.com/mstan/Tomba2Recomp/releases/latest) | Multi-track disc support; adaptive widescreen through 21:9. | -| *Ape Escape* | [ApeEscapeRecomp](https://github.com/mstan/ApeEscapeRecomp) | [releases](https://github.com/mstan/ApeEscapeRecomp/releases/latest) | Widescreen to 21:9, memory-card save/load, dual-analog. | -| *Mega Man X4* | [MegaManX4Recomp](https://github.com/mstan/MegaManX4Recomp) | [releases](https://github.com/mstan/MegaManX4Recomp/releases/latest) | Playable; 2D widescreen. | -| *Mega Man X5* | [MegaManX5Recomp](https://github.com/mstan/MegaManX5Recomp) | [releases](https://github.com/mstan/MegaManX5Recomp/releases/latest) | Playable; 2D widescreen. | -| *Mega Man X6* | [MegaManX6Recomp](https://github.com/mstan/MegaManX6Recomp) | [releases](https://github.com/mstan/MegaManX6Recomp/releases/latest) | Playable; stages, controller, save/load; 2D widescreen to 21:9. | -| *Tsumu Light* | [TsumuLightRecomp](https://github.com/mstan/TsumuLightRecomp) | [releases](https://github.com/mstan/TsumuLightRecomp/releases/latest) | Japanese-only title (SLPS-02253); first consumer of [live language switching](#swap-language-on-the-fly). | -| *Xenogears* — **community** | [OpokXeno/xenogears-recomp](https://github.com/OpokXeno/xenogears-recomp) | — | Independent project by [@OpokXeno](https://github.com/OpokXeno), who also contributed widescreen cull-site work upstream. | -| *Mega Man Legends* — **community** | [VuloB1/MegaManLegendsRecomp](https://github.com/VuloB1/MegaManLegendsRecomp) | [releases](https://github.com/VuloB1/MegaManLegendsRecomp/releases/latest) | Native 16:9 widescreen, analog movement with right-stick camera, 60/120 Hz presentation, skip FMVs, PGXP precision. | +| Game | Project / downloads | Features and scope | +|---|---|---| +| *Mega Man X4* | [MegaManX4Recomp](https://github.com/mstan/MegaManX4Recomp) · [downloads](https://github.com/mstan/MegaManX4Recomp/releases) | X/Zero local and online co-op alpha, optional widescreen, experimental independent cameras, offline save states and rewind. | +| *Mega Man X5* | [MegaManX5Recomp](https://github.com/mstan/MegaManX5Recomp) · [downloads](https://github.com/mstan/MegaManX5Recomp/releases) | Preview with 2D widescreen, launcher and native gameplay. | +| *Mega Man X6* | [MegaManX6Recomp](https://github.com/mstan/MegaManX6Recomp) · [downloads](https://github.com/mstan/MegaManX6Recomp/releases) | 2D widescreen, controller support, save/load and game-specific mods. | +| *Tomba!* | [TombaRecomp](https://github.com/mstan/TombaRecomp) · [downloads](https://github.com/mstan/TombaRecomp/releases) | Widescreen, supersampling, save/load and mod packages; per-title release remains a preview. | +| *Tomba! 2* | [Tomba2Recomp](https://github.com/mstan/Tomba2Recomp) · [downloads](https://github.com/mstan/Tomba2Recomp/releases) | Multi-track disc support and adaptive widescreen through 21:9. | +| *Ape Escape* | [ApeEscapeRecomp](https://github.com/mstan/ApeEscapeRecomp) · [downloads](https://github.com/mstan/ApeEscapeRecomp/releases) | Dual-analog controls, memory-card saves and widened 3D views. | +| *Tsumu Light* | [TsumuLightRecomp](https://github.com/mstan/TsumuLightRecomp) · [downloads](https://github.com/mstan/TsumuLightRecomp/releases) | Japanese title with runtime language switching. | +| *Jersey Devil* | [JerseyDevilRecomp](https://github.com/mstan/JerseyDevilRecomp) · [downloads](https://github.com/mstan/JerseyDevilRecomp/releases) | Native desktop port with disc-verifying setup, controllers and game-specific presentation work. | +| *Um Jammer Lammy* | [UmJammerLammyRecomp](https://github.com/mstan/UmJammerLammyRecomp) · [downloads](https://github.com/mstan/UmJammerLammyRecomp/releases) | Initial preview: adaptive widescreen, higher internal resolution and optional rhythm timing/latency assist. | +| *Battle Arena Toshinden* | [BattleArenaToshindenRecomp](https://github.com/mstan/BattleArenaToshindenRecomp) · [downloads](https://github.com/mstan/BattleArenaToshindenRecomp/releases) | Adaptive combat view, expanded-roster mods and two-player rollback netplay. | +| *Dragon Ball GT: Final Bout* | [DragonBallGTFinalBoutRecomp](https://github.com/mstan/DragonBallGTFinalBoutRecomp) · [downloads](https://github.com/mstan/DragonBallGTFinalBoutRecomp/releases) | Adaptive combat view, online/LAN versus and an optional donor-disc English voice mod. | +| *Wipeout 3: Special Edition* | [Wipeout-3-Special-Edition-Recomp](https://github.com/mstan/Wipeout-3-Special-Edition-Recomp) | Source development project; consult its current build and qualification notes. | +| *Mega Man Legends — community* | [MegaManLegendsRecomp](https://github.com/VuloB1/MegaManLegendsRecomp) · [downloads](https://github.com/VuloB1/MegaManLegendsRecomp/releases) | Independent port with 16:9, analog movement/right-stick camera and enhanced presentation. | +| *Xenogears — community* | [xenogears-recomp](https://github.com/OpokXeno/xenogears-recomp) | Independent development project; also contributed widescreen cull-site work upstream. | +| *Gran Turismo 2 (Arcade Mode) — community* | [GranTurismo2Recomp](https://github.com/VuloB1/GranTurismo2Recomp) | Early: menus through a race. Experimental 16:9, analog pad, optional PGXP; no release builds yet. | +| *Gran Turismo 2 (Simulation Mode) — community* | [GranTurismo2SimRecomp](https://github.com/VuloB1/GranTurismo2SimRecomp) | Separate Simulation-disc program. Builds; not yet tested in play. | -Each game repo carries its own build/run instructions, keyboard/controller -mappings, and per-game settings. **This repository builds the framework and a -standalone BIOS runtime supporting OpenBIOS and a compatible retail BIOS** — -see [Release Package](#release-package) below. +

+ Dragon Ball GT Final Bout combat with additional stage scenery and edge-positioned HUD +
Dragon Ball GT: Final Bout — adaptive combat framing. +

-Bringing up a title of your own? Start with -[`docs/GAME_PROJECT_SETUP.md`](docs/GAME_PROJECT_SETUP.md) (submodules, -bundled-release CI template, release checklist), then -[`CONTRIBUTING.md`](CONTRIBUTING.md). Community projects are listed here -alongside the rest. +Each game supplies its own disc identity and controls. This repository also +builds a standalone BIOS runtime for OpenBIOS or a compatible retail BIOS; +that runtime is separate from the game downloads. + +Screenshot provenance and feature qualifications are recorded in +[the showcase sources](docs/SHOWCASE_SOURCES.md). ## What It Is @@ -108,6 +142,11 @@ and game code (`game.toml`, seeds, CMake) at the repo root. See ## Getting Started +For one recommended setup and a first game project, follow +[Your first PSXRecomp port](docs/GETTING_STARTED.md). It uses the bundled +toolchain and OpenBIOS, and keeps advanced choices for later. The sections +below are the broader reference. + ### Build requirements Every platform needs **Git**, **Python 3**, **CMake 3.20+**, **Ninja**, and a @@ -200,7 +239,7 @@ python3 tools/new_project_layout/update_disc_set.py --game-toml ~/src/MyGameReco ``` Then build with the generated `build.ps1` / `build.sh` in the new project. -Full flow, every flag, CI, and the release checklist: +Full flow, every flag, and the release checklist: [`docs/GAME_PROJECT_SETUP.md`](docs/GAME_PROJECT_SETUP.md). #### Project Folder Structure @@ -219,7 +258,7 @@ These are the 3 most important folders to be aware of: **Other Folders** the other folders produced are generated from template files by the setup script run above, which provide various features and tools specific to the title itself and might be referenced by the compiler, or by diagnostics/dev software. -### New here? +### New here? **The fastest way in:** @@ -229,13 +268,13 @@ These are the 3 most important folders to be aware of: | Architecture | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | | **AOT overlay sharding** (disc discovery, per-game recipes, validation) | [`docs/AOT_SHARDING.md`](docs/AOT_SHARDING.md) | | Build the framework | [`docs/BUILDING.md`](docs/BUILDING.md) | -| **Ship a game repo** (submodules + CI + release checklist) | [`docs/GAME_PROJECT_SETUP.md`](docs/GAME_PROJECT_SETUP.md) | +| **Ship a game repo** (submodules + release checklist) | [`docs/GAME_PROJECT_SETUP.md`](docs/GAME_PROJECT_SETUP.md) | | **Netplay** (rollback, SFU/ICE, dual-raster, disc gates) | [`docs/NETPLAY.md`](docs/NETPLAY.md) | -| Bundled-release CI template | [`docs/ci/templates/game-release.yml`](docs/ci/templates/game-release.yml) | | Local Generate & rebuild CLI | [`docs/LOCAL_CODEGEN_SDK.md`](docs/LOCAL_CODEGEN_SDK.md) | -| Mods | [`docs/MOD_PACKAGES.md`](docs/MOD_PACKAGES.md) | +| Make mods by hand | [`psxmod.md`](psxmod.md) | +| Mod schema and integration | [`docs/MOD_PACKAGES.md`](docs/MOD_PACKAGES.md) | | Widescreen / native-wide | [`docs/WIDESCREEN.md`](docs/WIDESCREEN.md) | -| Enhancement tier (PGXP, renderers, load accel) | [`docs/ENHANCEMENTS.md`](docs/ENHANCEMENTS.md) | +| Enhancement tier (PGXP and renderers) | [`docs/ENHANCEMENTS.md`](docs/ENHANCEMENTS.md) | | TCP debug command reference | [`docs/TCP_COMMANDS.md`](docs/TCP_COMMANDS.md) | | Contributing | [`CONTRIBUTING.md`](CONTRIBUTING.md) | @@ -301,6 +340,10 @@ See [`docs/WIDESCREEN.md`](docs/WIDESCREEN.md) for the per-game configuration. ## Mods +To make a package yourself, start with [`psxmod.md`](psxmod.md): a complete +first package, guarded patches, asset overlays, packing, and testing with +ordinary tools. No AI tools are required. + Mods are **versioned packages**, not patched discs. A `.psxmod` is an installation, provenance and trust boundary; each package contributes any number of independently toggleable **features**, and enabling one never silently @@ -466,19 +509,6 @@ new packages. It remains conversion scaffolding only. features, controller policy, and skip-FMV behavior; its [CMake file](https://github.com/mstan/TombaRecomp/blob/master/CMakeLists.txt) shows how to compile the plugin sources and preload the package catalog. -- [Mega Man X6's unmerged Tweaks package branch](https://github.com/mstan/MegaManX6Recomp/tree/integrate/mmx6-mod-packages-current) - is the large conversion example. The - [package generators](https://github.com/mstan/MegaManX6Recomp/tree/integrate/mmx6-mod-packages-current/tools) - preserve patcher parity while producing guarded stock-targeted operations and - content-addressed assets; the - [trusted resolvers](https://github.com/mstan/MegaManX6Recomp/tree/integrate/mmx6-mod-packages-current/src/mods) - demonstrate deterministic composition of shared records and injected code. - The earlier - [`feature/mmx6-tweaks`](https://github.com/mstan/MegaManX6Recomp/tree/feature/mmx6-tweaks) - branch records the patcher-parity and write-classification work that feeds - this conversion; its patched-disc/pre-bake experiments are history, not the - recommended package delivery model. Both branches are review material rather - than a released compatibility promise. ## Swap language on the fly @@ -619,8 +649,8 @@ a game is played; this branch is where that machinery is being built. ## Status -The LLE recompiled BIOS — either bundled OpenBIOS or the compatible retail -backend — boots and hands off to the game across supported projects. Games run +PSXRecomp is **beta software**. The recompiled BIOS — either bundled +OpenBIOS or the compatible retail backend — boots and hands off to the game across supported projects. Games run as majority-native code, with the capture-and-compile pipeline filling overlays as they're reached. The breadth-first push is essentially done; work now is depth and optimization. @@ -741,41 +771,14 @@ and deep design notes (`PLAN.md`, `FAITHFUL_TIMING_PLAN.md`, …). ## Load Times -The runtime models **authentic 1× CD-ROM timing by default** — the same read and -seek delays as real hardware. On top of that faithful baseline, load-time -acceleration is **opt-in**, per game, so the accurate path is never compromised: +The normal runtime models the disc's read, seek and streaming behavior. +Release packages exclude the CD Speed and Fast Loading/host-pacing packages; +their controls are not part of the player-facing release catalog. Developer +catalogs keep those experiments hidden and disabled by default. -- **Turbo** — a hold-to-fast-forward key that compresses loads on demand - (keyboard `[KeyMap] Turbo`, default Tab; controller `[hotkeys] - fast_forward_pad`, default Select+L1, rebindable under the launcher's - Controller → Host Shortcuts alongside Rewind and Save states). A - press-to-latch twin, **Turbo toggle** (`[KeyMap] TurboToggle`, default F9; - `[hotkeys] fast_forward_toggle_pad`, unbound by default), locks the same - speed until pressed again. Controller host shortcuts bound to a single - button or trigger are chords with Select — the launcher shows them as - `select + …`; only a two-button capture replaces the implicit Select. - Backspace while the launcher is listening clears a shortcut; in the files - that is `0` for a `[hotkeys]` pad value and `None` for a `[KeyMap]` key (a - present-but-empty or `None` line is an explicit unbind, a missing line - keeps the built-in default). -- **The "Fast Loading (host pacing)" and "CD Speed" mods** — automatic - acceleration during load waits, shipped with every title and **off by - default**, with `turbo_audio_sink` keeping the SPU timeline coherent through - the burst. Host pacing only changes how fast real time is fed to a load, so - the guest cannot desync; CD speed changes when the game receives CD - interrupts. The former `[runtime] turbo_loads` config key is deprecated and - ignored — see `docs/config_schema.md`. -- **`[runtime] idle_skip`** — proof-gated fast-forward through idle polling - loops, with guest time and device events still advancing exactly. -- **Warm CD routes (`[[runtime.warm_cd_routes]]`)** — narrowly-scoped fast - read cadence armed on a specific `SetLoc`, restoring authentic timing the - moment the read pattern diverges. - -FMV/XA and CDDA streaming, seek, and motor timing always stay authentic -regardless of the accelerators. Driving load times toward zero (via data -sharding) is an active depth-phase effort — see -[`docs/LOAD_TIME_ZERO.md`](docs/LOAD_TIME_ZERO.md) and -[`docs/disc-speed.md`](docs/disc-speed.md). +Title-specific resource caching and overlay compilation have separate +compatibility requirements. See [overlay sharding](docs/AOT_SHARDING.md) +for the native-code cache and each game's notes for its resource behavior. ## Help make your game faster — just by playing it diff --git a/docs/FRAME_TRACE.md b/docs/FRAME_TRACE.md new file mode 100644 index 00000000..dedf1db2 --- /dev/null +++ b/docs/FRAME_TRACE.md @@ -0,0 +1,72 @@ +# Frame tracing (stereo / OpenXR) + +`runtime/src/frame_trace.c` records one row per traced stereo frame so a visible +VR hitch can be attributed to a stage instead of guessed at. It is the +measurement floor for the VR optimization work: nothing else should be tuned +before there are traces. + +## Enable + + PSX_FRAME_TRACE=1 # off by default; one branch when off + PSX_FRAME_TRACE_CAP=2048 # optional power-of-two ring size (default 2048) + +The ring is static, the record holds fixed-size fields, and the record path +allocates nothing and writes no files (`runtime/tests/test_frame_trace_guard.py` +fails the build if that changes). Instrumentation off vs on is an A/B control. + +## Read + +Over the debug TCP server: + + {"cmd":"frame_trace","format":"json","max":240} + {"cmd":"frame_trace","format":"chrome","max":240} # Perfetto trace-event JSON + +`max` bounds the newest records (0 = all retained). The game-side helper is +`vr/trace_frames.py`, which writes `frame_trace.json`, `frame_trace.csv`, +optional `frame_trace_perfetto.json`, and a `summary.json` with P50/P95/P99 of +`pair_total`, each eye's total, and `xr_wait_frame`, plus stale/shed/failed +counts and the worst frames. + +## Stages (microseconds) + + guest_sim host time between traced frames + pair_total both eyes, pair open -> publish + eye_{begin,guest,end,restore}_{l,r} + one eye's VRAM backup+checkpoint, guest draw, + flush/capture/VRAM restore, machine restore + xr_wait_frame / xr_begin_frame xrWaitFrame / xrBeginFrame + xr_acquire / xr_wait_image / xr_copy / xr_release / xr_end_frame + hud_capture / hud_draw / hud_readback + a HUD capture transaction, its guest draw and + its readback + game_env / game_world / game_text / game_traverse / game_icon / + game_submit / game_laser + the guest scene rebuild one eye replays, split + by the call the game hook makes: draw env and + ordering-table setup, world passes, text, + scene traversal, HUD icon, DrawOTag, and the + hook's own per-eye raycast. PAIR totals (both + eyes write the same slots), so compare them + against eye_guest_l + eye_guest_r. Written by + the hook through + psx_mod_frame_trace_stage_begin/end, which + only accepts them inside a traced stereo eye. + +Per frame: `seq`, `open_ms`, guest frame/cycle, stereo attempt id, published +pair id, `pair_age_ms` (age of the retained whole pair), `source` (1 pair / 2 +native), `eye_failed`, shed skipped/streak, `prims`/`tex_prims` (the last eye's +own count), `prims_l`/`prims_r`, `tex_prims_l`/`tex_prims_r`, +`cycles_l`/`cycles_r` (guest cycles each eye's replay executed, separating +emulated machine work from host submission time), `vram_leaks`, `tex_bytes`, and +flags (`published`, `stale`, `shed`, `failed`, `xr_empty`, `scene`, `io`, +`no_telemetry`). + +## Guarantees + +* `xr_*` stages are CPU wall-clock waits, never GPU execution time. +* Only a complete pair is published; a stale frame retains the previous whole + pair (`pair_age_ms` says how old it is). +* A frame is `stale` when it published nothing, `shed` when the pair was skipped + on purpose, `failed` when a pair was attempted and lost, and + `no_telemetry` when an expected-to-be-instrumented frame recorded no stage. +* Timestamps are monotonic even if the host clock steps backwards. diff --git a/docs/GAME_PROJECT_SETUP.md b/docs/GAME_PROJECT_SETUP.md index ee1a68a8..90cc5bc1 100644 --- a/docs/GAME_PROJECT_SETUP.md +++ b/docs/GAME_PROJECT_SETUP.md @@ -491,77 +491,6 @@ Composite actions (from the game repo after checkout): --- -## CI workflow template - -For an existing project, `tools/generate_ci` writes the filled workflow and -nothing else (no commit, no push, no migration plan): - -```sh -sh psxrecomp/tools/generate_ci.sh # in the project; --check, --force, --dry-run -``` - -```powershell -powershell -File psxrecomp\tools\generate_ci.ps1 -``` - -It is the single-purpose face of Project Studio's `emit_ci_workflow`: the -template comes from the project's own psxrecomp submodule, the zip prefix from -`scripts/package_release.sh` so CI uploads the zips the packager built, -and an installed workflow is compared by step name -- `--check` exits 1 when -it is missing or stale, and only `--force` overwrites one. - -The New Project Layout scaffold **copies and fills** this for you after -submodules land: - -- `.github/workflows/release.yml` ← `docs/ci/templates/game-release.yml` -(Linux x64, Windows x64, macOS arm64 + Intel) -- `scripts/package_release.sh` ← zip prefix / exe / display name - -`--exe-name` / `codegen_setup.exe_basename` should match CMake `OUTPUT_NAME` -(`MAKE_C_IDENTIFIER(WINDOW_TITLE)`, e.g. `TwistedMetal4_Recompiled`), not the -bare project folder name (`TwistedMetal4Recomp`). Scaffolding derives -`EXE_BASENAME` from `WINDOW_TITLE` for that reason. - -It is no longer fatal when they disagree. CMake is the single source of truth: -`runtime.cmake` writes the `OUTPUT_NAME` it chose to -`/psxrecomp_exe_name-.txt`, and both the build CLI and the -in-runtime self-compiler read that in preference to their own copy. A build -tree predating the marker still falls back to `exe_basename`. - -This matters because the two values are separate copies of the same window -title, and renaming a game updates one of them. Before the marker existed, that -drift produced `build succeeded but binary missing` on a build with no errors -at all — the executable was sitting in the build directory under the name CMake -had chosen. To pin the name explicitly instead of deriving it, pass `EXE_NAME` -to `psxrecomp_add_game_runtime()`. - -Zip prefix defaults to a short acronym from `--name` (e.g. `MastersOfTerasKasiRecomp` -→ `motk`); override with `--zip-prefix` / `-ZipPrefix`. - -Manual install (existing repos): - -```bash -mkdir -p .github/workflows scripts -cp psxrecomp/docs/ci/templates/game-release.yml .github/workflows/release.yml -# replace YOUR_ZIP_PREFIX / YOUR_GAME_TITLE / yourgame-release -# add scripts/package_release.sh (fill tools/new_project_layout/templates/package_release.sh.in) -``` - -Or re-run token fill against the submodule template: - -```bash -python3 psxrecomp/tools/new_project_layout/fill_tokens.py \ - psxrecomp/docs/ci/templates/game-release.yml \ - .github/workflows/release.yml \ - --ci-placeholders \ - --set ZIP_PREFIX=mygame \ - --set GAME_TITLE='My Game Recompiled' -``` - -Full action reference: `[ci/README.md](ci/README.md)`. - ---- - ## Bundled release checklist Use this before tagging a release. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 00000000..3f451089 --- /dev/null +++ b/docs/GETTING_STARTED.md @@ -0,0 +1,104 @@ +# Your first PSXRecomp port + +This is one Windows x64 path from your own disc image to a generated game +project. It uses a terminal and an editor. No AI assistant or account is +required. Start with the stock game and OpenBIOS; add mods after you have a +repeatable working baseline. + +If you want to play an existing port, choose it from the [game list](../README.md#games) +and follow that game's download and disc-selection instructions instead. + +## 1. Install the tools + +Install [Git for Windows](https://git-scm.com/downloads/win) and +[Python 3](https://www.python.org/downloads/windows/), available on PATH. +Download the Windows x64 `cmake-clang-v1` toolchain ZIP from +[the toolchain releases](https://github.com/RetroPortingToolKit/RetroPorting-Toolchains/releases/latest) +and extract it to `D:\RecompDev\Toolchain`. Use the folder containing `bin`. +This supplies the compiler, CMake and Ninja for this guide. + +Open PowerShell and prepare the session: + +```powershell +New-Item -ItemType Directory -Force D:\RecompDev\Temp | Out-Null +$env:TEMP = 'D:\RecompDev\Temp' +$env:TMP = $env:TEMP +$env:PSXRECOMP_TOOLCHAIN_DIR = 'D:\RecompDev\Toolchain' +$env:Path = "D:\RecompDev\Toolchain\bin;$env:Path" +git --version +python --version +cmake --version +ninja --version +``` + +Replace `D:` with your development drive. Each version check should succeed +before you continue. Keep build output, caches and the extracted toolchain +on that drive. + +## 2. Prepare a disc and clone the framework + +Use your own supported CUE/BIN dump. Extract archives first and keep the +`.cue` with all of its referenced tracks. Leave the disc outside the source +repository. This example uses `D:\Discs\My Game\game.cue`. + +```powershell +Set-Location D:\RecompDev +git clone --recurse-submodules https://github.com/RetroPortingToolKit/psxrecomp.git +Set-Location psxrecomp +``` + +Check the [existing projects](../README.md#games) before starting another +port of the same game. Their per-disc configurations and validation may give +you a better starting point for a contribution. + +## 3. Generate one local game project + +Run the setup command with these fixed first-project choices: + +```powershell +powershell -File tools\new_project_layout\setup_project.ps1 ` + -Disc 'D:\Discs\My Game\game.cue' -Dir D:\RecompDev ` + -Name MyFirstPSXPort -GithubRepo my-first-psx-port -Players 1 -Yes ` + -EnableRecompUi -NoNetplay -NoFetchBoxart -NoGithub -Generate -EnableBuild +``` + +The output folder is `D:\RecompDev\my-first-psx-port`. The script probes +the disc, records its identity, creates the framework/UI submodules, generates +the game and open-source OpenBIOS code, then configures and builds. The +GitHub slug above controls the local folder; `-NoGithub` keeps the project +local. You do not need to supply a retail BIOS for this path. + +Wait for generation and compilation to succeed. Read errors at the step +where they occur; a created folder alone is not a completed build. Open the +executable at the path printed by setup, select the same disc if asked, and +try a cold boot with the default presentation. + +## 4. Make one short route reliable + +Your first goal is boot → title menu → first gameplay scene. Compare input, +graphics, sound and transitions with the original game in a trusted emulator. +Record what works and the smallest sequence that reproduces a failure. + +The new project owns `game.toml`, symbol/analysis inputs and game-specific +integration. The framework owns the shared hardware runtime. Keep original +disc data outside Git, and fix analysis/configuration or runtime behavior +instead of hand-editing generated functions to force a result. + +Games can stream executable overlays from disc. Reaching a title screen does +not establish that later overlays or a full campaign work. Read the +[execution model](EXECUTION_MODEL.md) and [overlay guide](AOT_SHARDING.md) +when the next failing scene needs more code coverage. + +## 5. Add enhancements after the baseline + +Keep one disc revision and the original game behavior working before adding +widescreen, multiplayer, translations or alternate content. Each feature +needs its own tested contract. Additional discs and content variants need +explicit identities and integration; selecting a different image does not +make it supported. + +Use the [project reference](GAME_PROJECT_SETUP.md) for regeneration and +packaging, and [manual mod authoring](../psxmod.md) for the mod system. Include +the required license notices when distributing a build. The +[full build reference](BUILDING.md) covers other host platforms and advanced +toolchain choices. diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 60431ea6..8a3de9b8 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -1,12 +1,16 @@ # PSXRecomp mod packages and features +For a hands-on guide to making mods with ordinary tools, start with +[`../psxmod.md`](../psxmod.md). + A `.psxmod` is a versioned installation, provenance, and trust boundary. A package may contribute any number of independently configurable **features**. The launcher presents those features as the primary Mods list; package installation, version selection, and removal are a secondary management view. -Feature identity is always `(package_id, feature_id)`. Enabling one feature -never enables, disables, or reconfigures another feature. +Feature identity is always `(package_id, feature_id)`. Player selections are +independent. Explicit `[[requirement]]` declarations can additionally activate +required features for a session without changing saved player selections. The player selects a verified stock BIN/CUE. Resolution produces guarded native operations and sparse disc overlays without rewriting or replacing that stock @@ -173,12 +177,12 @@ stale the moment either side gains a mod. ## Feature manifest -Write new manifests at the current format version, which is **7**. Older +Write new manifests at the current format version, which is **10**. Older versions stay readable so installed packages survive an update, and each section below notes the version a field first required. ```toml -format_version = 7 +format_version = 10 id = "example.localization" version = "1.2.0" name = "Example Localization Pack" diff --git a/docs/OPENXR_RENDERING.md b/docs/OPENXR_RENDERING.md index be7b4a7e..75eeee77 100644 --- a/docs/OPENXR_RENDERING.md +++ b/docs/OPENXR_RENDERING.md @@ -55,6 +55,10 @@ frame without recycling an old pair. Private swapchains receive eye textures; guest execution, simulation time and guest TR registers are not owned by XR. `psx_mod_openxr_recenter` establishes the shared pose origin. World scale, native camera/body mapping, aiming and gameplay policy belong to the plugin. +An inverted relative head pose can be a deliberate sustained roll, so the +backend reports its up-dot and inversion streak through `openxr_stats` without +changing the origin. Explicit recenter and OpenXR reference-space change events +own origin changes. ## Offline actions and controller snapshots diff --git a/docs/PROVENANCE.md b/docs/PROVENANCE.md new file mode 100644 index 00000000..4b0bac5d --- /dev/null +++ b/docs/PROVENANCE.md @@ -0,0 +1,76 @@ +# Where runtime code and its facts come from + +This note is for anyone who writes or reviews runtime code (`runtime/src`, +`runtime/include`). It explains which outside sources we use and how, and lists +the places that still need work. Issue #443 tracks the series of changes that +removes the remaining passages. + +## The rules + +1. **Hardware facts are cited to documentation.** When a comment states how the + PlayStation behaves (a register bit, a table, a timing, a quirk), it names + PSX-SPX and the page and section where the fact is written. PSX-SPX is + published at . A page is cited by its name + in the address, for example `graphicsprocessingunitgpu` for + , followed by + the section heading in quotes. +2. **Emulators are black-box references.** We run Beetle PSX, DuckStation, + Mednafen or PCSX-Redux and compare what they output with what we output. + That is how the oracle tools (`psx-beetle`, the trace and diff commands) are + used, and comments may say "the Beetle reference gives the same result". +3. **We do not copy from emulator source.** Do not copy code, tables, constants, + identifiers or the structure of a routine from an emulator's source, and do + not quote emulator source in a comment. Their licences (GPL for Beetle, + Mednafen and PCSX-Redux, CC BY-NC-ND for DuckStation) differ from this + project's, and [`CONTRIBUTING.md`](../CONTRIBUTING.md) already rules out + copying from incompatible licences. +4. **When documentation is silent, measure.** If PSX-SPX does not state a fact, + write a small test program, run it on hardware or on the references, and + record what it shows. Say in the comment that the value was measured and + where the measurement lives. +5. **When PSX-SPX and the code disagree, say so.** Write both in the comment and + leave the behaviour as it is. Changing it is a separate change with its own + evidence. + +## What the first change of the series did + +It changed comments and documentation only; no code changed. + +- Comments that named an emulator's source file, function or line as the + authority for a hardware fact now cite PSX-SPX, where PSX-SPX states the + fact. Each new citation was checked against the PSX-SPX text. +- Two comments quoted emulator source: the I-cache store rule in + `psx_icache.c` and the interrupt line rule in `interrupts.c`. The quotes are + removed. The comments now describe the behaviour and cite PSX-SPX. +- Where PSX-SPX says something different from the code, the comment now says + so (GP0 commands 03h and FFh in `gpu.c`, the I-cache tag value in + `psx_icache.c`). + +## What is still to do + +These passages say that code follows an emulator's source, or they name a +source file, function or line. Rewording would hide that, so they stay as they +are until the code itself is replaced. The order is the one agreed on #443. + +| Change | Files | What the comments say today | +|---|---|---| +| 2. spu | `spu.c`, `spu_gauss.h` | The envelope rate decoder is "ported verbatim" from Beetle's `CalcVCDelta`; envelope steps follow `RunEnvelope`; several Beetle `spu.cpp` line references; the Gaussian table names DuckStation's `spu.cpp`. | +| 3. cpu/icache and memory/cycles | `psx_icache.c`, `psx_icache.h`, `psx_instr_cost.h`, `psx_cyc.h`, `psx_cycles.c`, `memory.c`, `cpu_state.h`, `dirty_ram_interp.c`, `main.cpp`, `docs/internal/CYCLE_MODEL_BEETLE.md` | The fetch, load-timing and I-cache models are "transcribed" from Beetle `cpu.cpp`; MULT/DIV and GTE latencies are transcribed from Beetle; `ReadAbsorb`/`LDWhich` names; the MTC0 software-interrupt check is labelled with a PCSX-Redux function name. | +| 4. mdec | `mdec.c` | The zig-zag table, IDCT matrix load and passes, dequantisation and RGB packing follow Beetle `mdec.cpp` with line references. | +| 5. cdrom | `cdrom.c`, `cdrom.h` | Command latencies (ID, ReadTOC, Pause, Init) and the data-ready notification follow Beetle `PS_CDC` functions; DuckStation `cdrom.cpp` handlers are cited for command cancellation and read restarts. | + +Passages found in areas the agreed series does not cover yet: + +| Area | Files | What the comments say today | +|---|---|---| +| GPU rasteriser | `gpu.c` | The triangle edge stepping cites DuckStation `gpu_sw_rasterizer.inl` (`makefp_xy`, `makestep_xy`). | +| GPU display crop | `gpu.c` | The vertical crop follows DuckStation `GetFullDisplayResolution`. | +| Texture sampling | `gpu_uv.h`, `gpu_sw_renderer.c`, `gpu_gl_renderer.c` | The UV offset model and bilinear filter are described as the Beetle / parallel-psx formulation (`Calc_UVOffsets_Adjust_Verts`). | +| SPU DMA cost | `dma.c` | The 48-cycle per-word cost cites Beetle `dma.cpp` lines. | +| PGXP cache | `gte.cpp` | The vertex cache layout names Beetle `pgxp_gpu.c` and DuckStation `cpu_pgxp.cpp`. | + +Comparisons with the references (what the oracle showed, which emulator agrees), +the oracle tools themselves, and the DuckStation texture-pack file format +(`duckstation_texture_pack.*`, `docs/DUCKSTATION_TEXTURE_FORMAT.md`) are not on +these lists: they compare outputs or read a published file format, which the +rules allow. diff --git a/docs/SHOWCASE_SOURCES.md b/docs/SHOWCASE_SOURCES.md new file mode 100644 index 00000000..d0496825 --- /dev/null +++ b/docs/SHOWCASE_SOURCES.md @@ -0,0 +1,21 @@ +# README showcase sources + +Checked against public project READMEs and release notes on **21 October +2026**. Framework beta status and per-game/mod maturity are separate. + +The X4 co-op and netplay images are copied from +[`docs/images/`](https://github.com/mstan/MegaManX4Recomp/tree/e14700432ffefcfb878a4449be9dfe4e394de844/docs/images) +in the game project (`coop-jungle.png` and `netplay.png`). See the +[current game README](https://github.com/mstan/MegaManX4Recomp) for the alpha +playtest, OpenGL requirement, camera choices and platform limits. + +The Final Bout image is the game's +[`combat-ultrawide.png`](https://github.com/mstan/DragonBallGTFinalBoutRecomp/blob/main/docs/screenshots/combat-ultrawide.png). +The existing Tomba, Ape Escape and X4-X6 gallery images remain from the earlier +framework README. Images are game captures, with no generated replacement +scenery or characters. + +The public catalog distinguishes source projects from released downloads. +The README does not advertise the removed CD Speed or Fast Loading packages +as release features. This documentation change does not certify a new game +build or change runtime behavior. diff --git a/docs/STEREO_RENDERING.md b/docs/STEREO_RENDERING.md index c8536b87..87ade4da 100644 --- a/docs/STEREO_RENDERING.md +++ b/docs/STEREO_RENDERING.md @@ -16,9 +16,18 @@ that must be cleared after watchdog rollback. `period_vblanks` describes the game's draw cadence (1..8). An EMA of complete-pair host cost is compared with 80% of that cadence, using the runtime's live NTSC/PAL -period. Expensive work is shed as a complete pair; every thirtieth request retries -to recover a stale estimate. This is a conservative cadence budget, not a measured -headset idle budget. Allocating or slow initial pairs may exceed it; completed +period, unless `PSX_STEREO_SHED_BUDGET_MS` supplies the real headset deadline (the +VBlank cadence is a guest-budget fallback, not an OpenXR display deadline). +Expensive work is shed as a complete pair, never as a single eye, and the previous +complete pair is retained. Shedding is bounded both by wall clock +(`PSX_STEREO_SHED_PROBE_MS`, default 250 ms) and by a consecutive count +(`PSX_STEREO_SHED_MAX_STREAK`, default 8), so a stale estimate cannot suppress +indefinitely: whichever bound trips first forces a probe, and the probe's +measurement replaces the estimate rather than blending into it, so a single spike +ends after one bounded streak. Recovery uses a hysteresis band +(`PSX_STEREO_SHED_HYSTERESIS`, default 0.9) and scene/mode transitions reset the +estimate. `PSX_STEREO_SHED=0` disables shedding entirely for a baseline +diagnostic. Allocating or slow initial pairs may exceed the budget; completed guest work cannot be preempted by a host-time budget. Guest-cycle watchdogs remain the runaway bound. @@ -59,8 +68,10 @@ sees it. `test_render_pose` covers the override together with `fov_scale`. TCP inspection: - `stereo_stats`: pair outcomes, eye entry cycles/state hashes under VERIFY, - sampled view offsets, latched failed attempt/eye/retained pair, and published - pair metadata. `render_pass_stats` continues to + sampled view offsets, latched failed attempt/eye/retained pair, published + pair metadata, and the shedding policy (`shedding`, `shed_streak`, + `shed_streak_ms`, `shed_streak_max`, `stale_frames`, `budget_resets`, + `last_pair_age_ms`). `render_pass_stats` continues to expose shared transaction checks, aborts, leaks and dropped stores; its eye transactions also increment pass counters. - `stereo_dump path= count=N`: arm dumps of the next N diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index d937707a..f2be50f7 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -493,9 +493,9 @@ between Play and TCP availability. ## Complete command index (generated) -**376 commands registered** — 363 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**385 commands registered** — 372 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -84 of 376 have prose above; **292 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +85 of 385 have prose above; **300 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -574,6 +574,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `dirty_ram_unsupported` | ✓ | | | | `disasm` | ✓ | | | | `disc_select` | ✓ | | | +| `disc_tray_log` | ✓ | | | | `disp_ring` | ✓ | | | | `dispatch_check` | ✓ | | | | `dispatch_stats` | ✓ | | | @@ -626,6 +627,8 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `frame_perf` | ✓ | | | | `frame_range` | ✓ | ✓ | ✓ | | `frame_timeseries` | ✓ | ✓ | ✓ | +| `frame_trace` | ✓ | | | +| `frame_wall_ring` | ✓ | | | | `freeze_check` | ✓ | | | | `game_options` | ✓ | | | | `gcp_ring` | ✓ | | ✓ | @@ -657,12 +660,14 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `hd_textures` | ✓ | | ✓ | | `history` | ✓ | ✓ | ✓ | | `hle_dump` | ✓ | | ✓ | +| `host_button` | ✓ | | | | `host_key` | ✓ | | | | `host_launch_timings` | ✓ | | ✓ | | `host_mouse` | ✓ | | | | `host_osd_shot` | ✓ | | | | `host_pause` | ✓ | | | | `host_profile` | ✓ | | | +| `host_span_ring` | ✓ | | | | `idle_skip` | ✓ | | | | `imask_trace` | ✓ | | | | `input_route_append` | ✓ | | ✓ | @@ -691,6 +696,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `mmio_clear` | ✓ | | ✓ | | `mmio_dump` | ✓ | | ✓ | | `mmx6_freshfix` | ✓ | | | +| `mod_cmd` | ✓ | | ✓ | | `mod_counters` | ✓ | | | | `nclip_stats` | ✓ | | | | `netplay_status` | ✓ | | | @@ -699,6 +705,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `openxr_hands_override` | ✓ | | | | `openxr_input` | ✓ | | | | `openxr_input_override` | ✓ | | | +| `openxr_pose_override` | ✓ | | | | `openxr_stats` | ✓ | | | | `openxr_views` | ✓ | | | | `overlay_candidates` | ✓ | | | @@ -772,6 +779,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `rtrace_reset` | | ✓ | | | `rtrace_stats` | ✓ | ✓ | | | `run_to_frame` | ✓ | | ✓ | +| `runtime_menu` | ✓ | | | | `s3_smear_watch` | ✓ | | ✓ | | `savestate` | ✓ | | | | `savestate_status` | ✓ | | ✓ | @@ -781,6 +789,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `screenshot_hires` | ✓ | | ✓ | | `screenshot_wide_hires` | ✓ | | ✓ | | `segment_misses` | ✓ | | | +| `session_restart` | ✓ | | | | `set_input` | ✓ | ✓ | ✓ | | `set_snapshot` | ✓ | ✓ | ✓ | | `sio_arm_audit` | ✓ | | | @@ -882,3 +891,16 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc `wtrace_dump pc_lo= pc_hi=` filters the recorded producer PC, masked to the physical address (KSEG0/KSEG1/KUSEG alias, like `addr_lo`/`addr_hi`), with inclusive lower and exclusive upper bounds, before limiting results. Omitted bounds match every entry; DMA entries use their recorded initiator, and a DMA whose initiator is unknown (0) never matches an explicit PC bound. Address/frame/order filters still apply. `disasm addr= count=<1..256>` inspects aligned guest instructions; default count is 16. Resolved targets use the guest PC. Unknown words are shown verbatim. + +## Trusted mod tuning commands + +| Command | Parameters | Behavior | +|---|---|---| +| `mod_cmd` | `name`, optional `args` | Calls a statically linked trusted debug callback and returns its integer `result` and escaped text `reply`. | + +The process owns up to 16 names, each 1-63 bytes. Registering the same name +replaces its callback. Registration is independent of package activation; +this is a debug-control registry, not a way to activate a disabled mod. +Callbacks run on the debug-server thread and may only touch state guarded +against its other owners. Guest/shared-state changes must be requested through +the plugin's existing emulation callback and obey its session/netplay policy. diff --git a/docs/WIDESCREEN.md b/docs/WIDESCREEN.md index fb36c53d..7f7d35b5 100644 --- a/docs/WIDESCREEN.md +++ b/docs/WIDESCREEN.md @@ -340,8 +340,12 @@ screen_x_sites = [ ] ``` -`screen_x_sites` is applied when code is generated: native code and overlay -shards. The dirty-RAM interpreter does not consult this list. In the main EXE +`screen_x_sites` is applied when code is generated (native code and overlay +shards) and, at runtime, by the dirty-RAM interpreter: code that runs from +RAM through the interpreter (including under `PSX_FORCE_INTERP=1`) widens the +same four instruction forms at the listed addresses, with the same helpers and +the same identity at 4:3. A listed `bltz` follows the precedence `bltz_sites`, +then `screen_x_sites`, then the `auto_screen_x` classifier. In the main EXE a listed address holding another non-branch instruction is a hard build error; a branch other than `bltz` at a listed address is left as it is. diff --git a/docs/assets/games/final-bout-ultrawide.png b/docs/assets/games/final-bout-ultrawide.png new file mode 100644 index 00000000..6f6b0781 Binary files /dev/null and b/docs/assets/games/final-bout-ultrawide.png differ diff --git a/docs/assets/games/mega-man-x4-coop.png b/docs/assets/games/mega-man-x4-coop.png new file mode 100644 index 00000000..76140f3c Binary files /dev/null and b/docs/assets/games/mega-man-x4-coop.png differ diff --git a/docs/assets/games/mega-man-x4-netplay.png b/docs/assets/games/mega-man-x4-netplay.png new file mode 100644 index 00000000..c11b4467 Binary files /dev/null and b/docs/assets/games/mega-man-x4-netplay.png differ diff --git a/docs/config_schema.md b/docs/config_schema.md index f6ffd56e..b2989de5 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -702,8 +702,23 @@ player's `settings.toml`, and a game may ship a default the same way: [video] internal_resolution = "4k" # native | 720p | 1080p | 1440p | 4k | 5k | 8k | display, or a number of lines resolution_reference_lines = 240 # game.toml only: the title's usual display height (120..1024) +supersample_disable_at_lines = 0 # game.toml only: supersample 1.0 at/above this display height (0 = off, 240..8640) +match_display_max_lines = 0 # game.toml only: cap on the display height Match display uses (0 = none, 240..8640) ``` +`match_display_max_lines` caps the monitor height that Match display, its +dynamic-resolution floor (`display`) and graphics-preset detection resolve +against. It also caps the final Match display target after supersampling, +so a larger saved factor cannot exceed it. Fixed presets keep their own target. +With no cap, an 8K panel asks 18x (27x with `supersample = 1.5`), which is +past the full-VRAM surface. The high-resolution window then engages, and +dynamic resolution cannot step there. A title whose GPU budget is set for +4K lines sets 2160. + +`supersample_disable_at_lines` uses the physical monitor height before the cap. +At or above its threshold the effective factor is 1.0; the saved factor is +retained for smaller monitors. Zero preserves the default behaviour. + A preset is a target height. The runtime renders at the integer scale `S = ceil(target / resolution_reference_lines)`: at the default 240 lines, 720p is 3x, 1080p is 5x (1200 lines, area-resolved to 1080), 1440p 6x, 4K 9x, diff --git a/docs/internal/FAITHFUL_TIMING_PLAN.md b/docs/internal/FAITHFUL_TIMING_PLAN.md index a61a0bc4..bae4ca9b 100644 --- a/docs/internal/FAITHFUL_TIMING_PLAN.md +++ b/docs/internal/FAITHFUL_TIMING_PLAN.md @@ -213,6 +213,11 @@ on a fixed region -> next. ## 5. Status / Log (update every session) +- **2026-10-23 — VR upstream split.** Precise projective replay reconstructed on + current master; six focused runtime regressions and the GPU-history guard pass. + Prior fork/headset acceptance remains historical. Other VR features are + separate single-commit branches. Central Beads F: is unavailable. + - **2026-10-07 — DuckStation texture authoring fidelity (beads-qi66).** The early Tomba capture report exposed per-draw crops and automatic page fallback in the opt-in HD texture enhancement. An independent synthetic diff --git a/docs/internal/upstream/vr-bounded-shedding.md b/docs/internal/upstream/vr-bounded-shedding.md new file mode 100644 index 00000000..a52c58df --- /dev/null +++ b/docs/internal/upstream/vr-bounded-shedding.md @@ -0,0 +1,17 @@ +# Bound stereo shedding and recover at the display deadline + +Source: [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp). +- [cae62891](https://github.com/FractalEngineer/psxrecomp/commit/cae62891e9236e9252e43b80aa9dd47f7554aea4) +- [b6a671c1](https://github.com/FractalEngineer/psxrecomp/commit/b6a671c1db4ba15d02369ce5561754d485881a55) + +Original author: Yves ; retained as commit author +and Co-authored-by on this reconstructed port. + +Adapted: this feature and its tests. Excluded: other VR features, game policy, +assets and chronological investigation notes. +Base: `8400b55dc02d3fa787df1280a9fb474901adfcf4` (current upstream master or the preceding feature). + +Validation: Windows/GCC 13.2 Release: policy, sandbox and abort tests pass (3/3). Sustained overload remains expensive; the MoH launcher disables shedding by default. + +Consumer: [Medal of Honor v0.1.6](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/1957e28a5dc7f5daa79e2b9b6ed867eb06b41ace). +Requires a framework pin bump and rebuild; no game/BIOS regeneration. diff --git a/docs/internal/upstream/vr-frame-tracing.md b/docs/internal/upstream/vr-frame-tracing.md new file mode 100644 index 00000000..db6e69c6 --- /dev/null +++ b/docs/internal/upstream/vr-frame-tracing.md @@ -0,0 +1,21 @@ +# VR frame tracing: source and validation + +Adapted from [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp): +- [022eb1ed](https://github.com/FractalEngineer/psxrecomp/commit/022eb1edb2d6b2d9d505641e198bd9aeb5eb8ce1) +- [0e0d2de8](https://github.com/FractalEngineer/psxrecomp/commit/0e0d2de82777857248ae6c1bf7e9b7cba05e3d0a) +- [5c67dced](https://github.com/FractalEngineer/psxrecomp/commit/5c67dced708a67e905b77be455263009f566d4bb) +- [63271ce7](https://github.com/FractalEngineer/psxrecomp/commit/63271ce7b4e2fb5f616050e7d59a2e4642a33b55) +- [571a8f10](https://github.com/FractalEngineer/psxrecomp/commit/571a8f1082dbbc95af19e90ce0226a0cb40f302d) +- [87a3e822](https://github.com/FractalEngineer/psxrecomp/commit/87a3e8223cf7c05761326bfce06e80ffc527e132) + +Original author: Yves ; retained as commit author +and Co-authored-by on this reconstructed port. + +Adapted: opt-in trace ring, XR cadence, HUD timing, nested replay attribution, +exports and tests. Excluded: shedding policy, checkpoint reuse, game hooks and +chronological research. Rebuilt on upstream master `9caf4c22`. + +Validation: Windows/GCC 13.2 Release: trace, source guard, sandbox, abort and +XR-off input tests pass (5/5); command index is current. XR-on integration is +checked separately after assembling the feature branches. +Consumer: [Medal of Honor v0.1.6](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/1957e28a5dc7f5daa79e2b9b6ed867eb06b41ace); pin bump and rebuild, no regeneration. diff --git a/docs/internal/upstream/vr-origin-recovery.md b/docs/internal/upstream/vr-origin-recovery.md new file mode 100644 index 00000000..0c22c75b --- /dev/null +++ b/docs/internal/upstream/vr-origin-recovery.md @@ -0,0 +1,21 @@ +# Report ambiguous XR origin diagnostics without recentering + +Source: [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp). +- [96981469](https://github.com/FractalEngineer/psxrecomp/commit/96981469c4a3de0b31c3d2a282efdc7892f81361) + +Original author: Yves ; retained as commit author +and Co-authored-by on this reconstructed port. + +Adapted: diagnostic up-dot and sustained-inversion reporting. Automatic +recentering was deliberately excluded because a valid sustained roll is +indistinguishable from a corrupt origin in the relative quaternion. Explicit +recenter and OpenXR reference-space change events retain ownership of origin +changes. +Base: `18d52c8bfbe5afc380292ef3116af2920225a92e` (current upstream master or the preceding feature). + +Validation: Windows/GCC 13.2 Release: the XR input fixture covers upright, +ordinary yaw, ten-frame ambiguous inversion, valid sustained roll, invalid +tracking reset, and reference-change timing. Physical reconnect is not retested. + +Consumer: [Medal of Honor v0.1.6](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/1957e28a5dc7f5daa79e2b9b6ed867eb06b41ace). +Requires a framework pin bump and rebuild; no game/BIOS regeneration. diff --git a/docs/internal/upstream/vr-pair-checkpoint.md b/docs/internal/upstream/vr-pair-checkpoint.md new file mode 100644 index 00000000..d4f7a352 --- /dev/null +++ b/docs/internal/upstream/vr-pair-checkpoint.md @@ -0,0 +1,16 @@ +# Reuse one guest checkpoint per stereo pair + +Source: [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp). +- [063b082a](https://github.com/FractalEngineer/psxrecomp/commit/063b082ae959488436596d90e6933d825e22e780) + +Original author: Yves ; retained as commit author +and Co-authored-by on this reconstructed port. + +Adapted: this feature and its tests. Excluded: other VR features, game policy, +assets and chronological investigation notes. +Base: `0cdf95957c3e34916aef5ff5b0b4e2b77fdc8790` (current upstream master or the preceding feature). + +Validation: Windows/GCC 13.2 Release: trace, sandbox and abort tests pass (3/3), including shared-baseline state and per-eye precision arms. + +Consumer: [Medal of Honor v0.1.6](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/1957e28a5dc7f5daa79e2b9b6ed867eb06b41ace). +Requires a framework pin bump and rebuild; no game/BIOS regeneration. diff --git a/docs/internal/upstream/vr-paired-hud-capture.md b/docs/internal/upstream/vr-paired-hud-capture.md new file mode 100644 index 00000000..143dc40a --- /dev/null +++ b/docs/internal/upstream/vr-paired-hud-capture.md @@ -0,0 +1,17 @@ +# Capture both HUD mattes with one checkpoint and readback + +Source: [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp). +- [a876bae3](https://github.com/FractalEngineer/psxrecomp/commit/a876bae3385a8b972d8a5cf083ad5e30cf05c4bd) +- [d2554481](https://github.com/FractalEngineer/psxrecomp/commit/d2554481fac39409cebef7602e59bd155e000fe6) + +Original author: Yves ; retained as commit author +and Co-authored-by on this reconstructed port. + +Adapted: this feature and its tests. Excluded: other VR features, game policy, +assets and chronological investigation notes. +Base: `4419d234ab66c839da94dba597794ac47a09eb00` (current upstream master or the preceding feature). + +Validation: Windows/GCC 13.2 Release: sandbox and abort tests pass (2/2), including both matte buffers and capture-only checkpoint reuse. + +Consumer: [Medal of Honor v0.1.6](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/1957e28a5dc7f5daa79e2b9b6ed867eb06b41ace). +Requires a framework pin bump and rebuild; no game/BIOS regeneration. diff --git a/docs/internal/upstream/vr-projective-replay.md b/docs/internal/upstream/vr-projective-replay.md new file mode 100644 index 00000000..92deaee4 --- /dev/null +++ b/docs/internal/upstream/vr-projective-replay.md @@ -0,0 +1,22 @@ +# Preserve precise eye poses and clip near-camera geometry + +Source: [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp). +- [efa10eee](https://github.com/FractalEngineer/psxrecomp/commit/efa10eee331bf9d6c5ab4c7ea987bc80e434fa9a) + +Original author: Yves ; retained as commit author +and Co-authored-by on this reconstructed port. + +Adapted: this feature and its tests. Projective PGXP mode opens after the +precision checkpoint and closes before rollback, preserving the real PGXP +generation and prior enabled/CPU-mode state through success and abort. +Excluded: other VR features, game policy, assets and chronological +investigation notes. +Base: `36f1ff3e905b4437cb2933c4f107f7dbe1c825e2` (current upstream master or the preceding feature). + +Validation: Windows/GCC 13.2 Release: precise pose, GTE projective/facing, GPU +projection/provenance, real PGXP scope rollback, sandbox and abort tests pass; +production GPU-history guard passes. Prior Quest 3/VDXR acceptance applies to +the source fork, not this reconstructed branch. + +Consumer: [Medal of Honor v0.1.6](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/1957e28a5dc7f5daa79e2b9b6ed867eb06b41ace). +Requires a framework pin bump and rebuild; no game/BIOS regeneration. diff --git a/docs/internal/upstream/vr-synthetic-head-pose.md b/docs/internal/upstream/vr-synthetic-head-pose.md new file mode 100644 index 00000000..d4ea41db --- /dev/null +++ b/docs/internal/upstream/vr-synthetic-head-pose.md @@ -0,0 +1,17 @@ +# Add debug synthetic head translation and pitch + +Source: [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp). +- [2ed12640](https://github.com/FractalEngineer/psxrecomp/commit/2ed12640adab3602acbf3307d42e9c2739b7d0eb) +- [a3714fad](https://github.com/FractalEngineer/psxrecomp/commit/a3714fada4d0582500001b0c22e4dfa00840403e) + +Original author: Yves ; retained as commit author +and Co-authored-by on this reconstructed port. + +Adapted: this feature and its tests. Excluded: other VR features, game policy, +assets and chronological investigation notes. +Base: `e4f1f985428101eb6c8f83fce834e281860b1fd2` (current upstream master or the preceding feature). + +Validation: Windows/GCC 13.2 Release: XR-off input fixture passes, including finite/range rejection, binocular translation/pitch, disabled state and clearing; command index is current. + +Consumer: [Medal of Honor v0.1.6](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/1957e28a5dc7f5daa79e2b9b6ed867eb06b41ace). +Requires a framework pin bump and rebuild; no game/BIOS regeneration. diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 31ebf084..70cb65a7 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -8d1ed472c570e347918c0097fc6703a8707b1751fb3c8e93fa254454d7fa38ed +61bf4f6d29680623d215fbe407958408cab0a1e08dbda3c62baa25e607d63b7b diff --git a/psxmod.md b/psxmod.md new file mode 100644 index 00000000..6e2a9aa9 --- /dev/null +++ b/psxmod.md @@ -0,0 +1,474 @@ +# Making a `.psxmod` by hand + +You can make PSXRecomp mods with a text editor, a ZIP packer, and the usual +tools for editing or understanding game data. No AI service, account, or +generated assets are required. Many mods need no C/C++ and no game rebuild. + +The extension is **`.psxmod`** (singular). It is a ZIP with a `manifest.toml` +at its root. The manifest describes the mod, launcher controls, and supported +operations. The game keeps using the player's stock disc; mod operations are +applied in memory or when reading sectors. + +Check the target game's pinned framework version. This guide describes +manifest format **10**; an older game release may need an update to read it. + +- [Choose the right kind of mod](#choose-the-right-kind-of-mod) +- [First package: your own bezel artwork](#first-package-your-own-bezel-artwork) +- [Make a guarded gameplay patch](#make-a-guarded-gameplay-patch) +- [Add options](#add-options) +- [Replace disc data with an overlay](#replace-disc-data-with-an-overlay) +- [When you need C or C++](#when-you-need-c-or-c) +- [Test and distribute](#test-and-distribute) +- [Troubleshooting](#troubleshooting) + +## Choose the right kind of mod + +| Goal | Mechanism | Rebuild the game? | +| --- | --- | --- | +| Change known bytes in the main executable | Guarded `[[patch]]` with `target = "main_exe"` | Usually no | +| Change a few known bytes on the disc | Guarded `disc_raw` or `disc_user` patch | Usually no | +| Replace a larger range of asset data | File-backed `[[overlay]]` | Usually no | +| Use an existing host feature | `[[plugin]]` naming an implementation the game already contains | No, if that implementation is present | +| Add new host logic or a recurring gameplay callback | Write and statically link a trusted C/C++ plugin | Yes | + +An overlay replaces bytes at a particular location. It is not an automatic +"replace an ISO file by filename" service. Formats, pointers, compression, +and file sizes still matter. A package also cannot load a DLL, compile C, or +invent a plugin implementation by naming it. + +A **package** is what you install and version. Its **features** are independent +switches; each feature can have **options**. An operation names its owning +feature and may have conditions on that feature's options. The loader resolves +the whole enabled set, checks compatibility and conflicting byte ranges, and +produces a plan before boot. Runtime guards verify original bytes when the +corresponding executable or disc data is encountered. + +## First package: your own bezel artwork + +This example uses the existing `psx.bezel` plugin to draw a player-selected +image behind the game frame in OpenGL margins. It requires a game build that +contains that plugin and supports format 10. It does not require a compiler. +You can draw the image yourself with any ordinary image editor. + +### 1. Create a source folder + +Make a folder named `my-bezel` containing `manifest.toml` and `README.txt`. +Use UTF-8 text and save the manifest with that exact name, not +`manifest.toml.txt`. Python **3.11 or newer** can run all commands in this guide. + +Save this complete manifest: + +```toml +format_version = 10 +id = "yourname.my-bezel" +version = "1.0.0" +name = "My Bezel" +author = "Your Name" +description = "Show my chosen artwork behind the game frame." +license = "MIT" +resolver = "declarative" +save_compatibility = "shared" + +[[target]] +game_id = "*" + +[[feature]] +id = "bezel" +name = "My Bezel" +description = "Choose an image, then launch with an OpenGL renderer." +group = "Visual" +default_enabled = false + +[[resource]] +feature = "bezel" +id = "artwork" +label = "Bezel image" +format = "file" +file_patterns = "*.png,*.jpg,*.jpeg,*.bmp" +file_description = "Image files" +required = false + +[[plugin]] +feature = "bezel" +id = "psx.bezel" +``` + +Here `game_id = "*"` is appropriate because the existing bezel implementation +is a generic presentation feature. Game-specific patches should target exact +game IDs and supported revisions instead. + +Use your own package namespace when publishing. Package and feature IDs are +machine identifiers: use lowercase letters, numbers, dots, underscores, and +hyphens. Names and labels are the human-facing text. `format_version` is the +schema version, while `version` is your mod's version. Keep the package ID +stable and increase the version when distributing changes. + +The image is a **resource**: the player chooses its path, and the package does +not contain or copy it. A plugin must understand the resource ID; this plugin +specifically looks for `artwork`. With no image selected, it draws nothing. +If you want packaged artwork loaded automatically, you need an implementation +that supports that behavior; adding an image to the ZIP is not sufficient. + +### 2. Check and pack it + +Put target-build requirements and instructions in `README.txt`. From the +**framework repository root**, run: + +```sh +python -c "import pathlib,tomllib; tomllib.loads(pathlib.Path('my-bezel/manifest.toml').read_text(encoding='utf-8')); print('TOML syntax OK')" +python tools/psxmod_pack.py my-bezel dist/my-bezel-1.0.0.psxmod +python -m zipfile -l dist/my-bezel-1.0.0.psxmod +python -m zipfile -t dist/my-bezel-1.0.0.psxmod +``` + +The packer uses only Python's standard library. It packages files in a stable +order with fixed ZIP timestamps. It checks that a manifest exists, but does +**not** validate the manifest schema or the mod's behavior. Keep its output +outside the source folder: it includes every file under that folder. + +The archive must look like this: + +```text +my-bezel-1.0.0.psxmod + manifest.toml + README.txt +``` + +A graphical ZIP utility works too: archive the folder's **contents**, use +ordinary ZIP storage or DEFLATE without encryption, and rename `.zip` to +`.psxmod`. The loader accepts up to 4096 entries and 256 MiB expanded. Absolute +paths and `..` paths are rejected. + +### 3. Install and try it + +Open the target game's launcher and its **Mods** package manager, install the +archive, enable **My Bezel**, select your image, and launch with OpenGL. Use +a window/aspect that leaves margins so there is somewhere to see the artwork. +Disable any other enabled bezel feature: two features claiming `psx.bezel` +conflict instead of taking turns. + +Disable your feature and relaunch to verify the normal presentation returns. +Changes generally require a new launch. Management button labels can vary +with the game's pinned recomp-ui version. + +## Make a guarded gameplay patch + +A guarded patch means "at this location, expect these original bytes, then +replace them with these bytes." The guard catches a wrong revision or a +mistaken address. It is not a substitute for understanding the change. + +### 1. Find and verify the change + +Use the game's symbols, disassembly, debugger, and existing mod source to find +the relevant code or data. Record: + +- The exact game ID, disc revision, and game build. +- The location and whether it is a guest address or a disc-stream offset. +- The original and replacement bytes, with a written explanation. +- When the code/data is loaded and what should change during play. + +For example, suppose you have verified a MIPS instruction that initializes a +value to 3: the word `0x24020003` (`addiu v0, zero, 3`). PS1 words are +little-endian, so its bytes are `03 00 02 24`. Changing the immediate to 5 +gives `05 00 02 24`. That only means "starting lives" if you have established +that the game uses the result that way. + +### 2. Write the package + +This is a complete **template**, not a cheat for a particular game. Replace +the target placeholders and the illustrative address with surveyed values. +Do not paste the address into an unrelated title. + +```toml +format_version = 10 +id = "yourname.starting-lives" +version = "1.0.0" +name = "Starting Lives" +author = "Your Name" +description = "Start a new game with five lives." +resolver = "declarative" +save_compatibility = "shared" + +[[target]] +game_id = "REPLACE_WITH_EXACT_GAME_ID" +disc_sha256 = "REPLACE_WITH_STOCK_DISC_SHA256" + +[[feature]] +id = "starting-lives" +name = "Starting Lives" +description = "Apply the surveyed new-game lives change." +group = "Gameplay" +default_enabled = false + +[[patch]] +feature = "starting-lives" +target = "main_exe" +address = 0x80012340 +expected = "03 00 02 24" +replace = "05 00 02 24" +``` + +`main_exe` uses a **guest virtual address**, not an offset in a BIN, ISO, or +PS-X EXE. These writes are applied after the BIOS loads the main executable +and before its entry point. A runtime variable that is initialized later can +overwrite your write; a streamed code overlay is not automatically part of +the main executable. Choose the operation that matches the actual load path. + +For a standard PS-X EXE, the payload begins at file offset `0x800`, and its +load address is the little-endian word at header offset `0x18`. For a location +inside that payload: + +```text +guest address = load address + (PS-X EXE file offset - 0x800) +``` + +This formula does not apply to arbitrary files or runtime-relocated overlays. +Confirm the address in a debugger. Guard a complete instruction when changing +an instruction, keep ordinary replacements equal in length to `expected`, and +account for MIPS delay slots and register use when editing logic. + +### 3. Get the right hashes + +Copy known target identities from the game's manifests and source where +possible. `exe_sha256`, when used, hashes the loose **PS-X EXE file**, not the +native PC executable; some releases do not carry that loose file, so requiring +it can make an otherwise correct package incompatible with those releases. + +For a typical BIN/CUE, `disc_sha256` hashes the resolved BIN data track, +**not the CUE text**. For CHD, the current runtime decodes the disc and hashes +its complete sequence of raw 2352-byte sectors; hashing the compressed CHD +file will give the wrong identity. Check the target title's pinned +`mod_runtime_commit()` and disc-resolution code, especially for multiple +tracks and older releases. Do not guess a hash from the disc's display name. + +For a BIN data file whose identity you have confirmed, this command prints its +hash without loading the whole disc into memory: + +```sh +python -c "import hashlib; f=open('game.bin','rb'); print(hashlib.file_digest(f,'sha256').hexdigest()); f.close()" +``` + +Use lowercase 64-character hashes. Additional `[[target]]` rows are +alternatives, not a way to translate addresses across revisions. Separate +packages or implementations may be needed if the actual byte locations differ. + +## Add options + +To make the example's value configurable, add this option to that package: + +```toml +[[option]] +feature = "starting-lives" +id = "count" +label = "Starting lives" +type = "integer" +min = 1 +max = 9 +step = 1 +default = 3 +``` + +Then **replace**, rather than add to, its original `[[patch]]`: + +```toml +[[patch]] +feature = "starting-lives" +target = "main_exe" +address = 0x80012340 +expected = "03 00 02 24" +replace_from = { option = "count", encoding = "u16le", offset = 0 } +``` + +The loader copies the expected bytes and fills the selected field with the +option value. `u8`, `u16le`, and `u32le` encode unsigned values. The declared +range must fit the encoding; the loader will not infer signed arithmetic or +arbitrary expressions. The linked schema guide covers split MIPS immediates +and sparse field ownership for more complex patches. + +Boolean and choice options can select operations with feature-local +conditions. For example, after declaring a `style` choice on this feature +with values `stock` and `extra`, a patch may contain: + +```toml +when = { style = "extra" } +``` + +All named conditions must match. Declare choice values with `[[option.choice]]` +entries immediately after their owning option; `default` must name one of +those values. Options alone do not change the game: patches consume them, or +a trusted plugin must query and implement them. + +## Replace disc data with an overlay + +Use `[[overlay]]` for a larger asset range stored in a separate package file. +Find the original asset and its format with ordinary disc/file tools, edit it +by hand, and export the replacement bytes. Preserve the game's expected +layout or explicitly implement the additional pointer/size changes needed. + +The two disc coordinate systems are: + +| Target | Offset calculation | Typical use | +| --- | --- | --- | +| `disc_raw` | `LBA * 2352 + byte within raw sector` | Raw-sector-aware changes, including formats outside a 2048-byte data payload | +| `disc_user` | `LBA * 2048 + byte within user data` | Logical 2048-byte data sectors | + +LBA is the logical sector number in the runtime's disc reader. A file-relative +offset is not an LBA. Match the runtime's track mapping; do not treat a +multi-track CUE as one flat file. A raw 2352-byte sector contains headers and +other bytes besides its data payload. The runtime maps Mode 2 Form 1 raw data +to its 2048-byte payload; `disc_user` is not the mechanism for XA/Form 2 audio +or CD audio sectors. + +Small disc `[[patch]]` writes use `offset` instead of `address` and cannot +cross a sector boundary. File-backed overlays may span sectors. For example, +add this fragment to a package that already declares a feature named `art`: + +```toml +[[overlay]] +feature = "art" +target = "disc_user" +offset = 204800 +file = "assets/replacement.bin" +sha256 = "REPLACE_WITH_PAYLOAD_SHA256" +expected_sha256 = "REPLACE_WITH_ORIGINAL_RANGE_SHA256" +``` + +The illustrative offset is LBA 100, byte 0. Use your own verified location. +`file` is relative to the package root. Its length determines the replacement +range; it does not insert space into the disc. Hash the replacement file for +`sha256`. The optional `expected_sha256` hashes the **same-length original +range in the selected target stream**, not the whole disc or raw sectors +when the target is `disc_user`. + +Every target row must declare an exact `disc_sha256` when using feature disc +overlays. Include the payload in the archive, update its hash whenever it +changes, and pack using the same command as the first example. The loader +checks payloads during installation/scanning and checks enabled payloads again +during resolution. + +To adapt an existing disc mod, inspect and understand its changes, then +reimplement the behavior as guarded operations, overlays, or a trusted plugin +against the stock disc. Do not just rename an IPS/PPF/VCDIFF to `.psxmod`. +The legacy `derived_disc` path is not supported for new feature-style mods. + +## When you need C or C++ + +Use a trusted plugin for new host behavior or a change that cannot be +expressed by the declarative operations. It lives in the **game repository**, +is reviewed and compiled into that game, and is selected by a manifest ID. + +A minimal implementation in `src/hello_mod.c` looks like this: + +```c +#include +#include "mod_plugins.h" + +static void hello_activate(void) { + fprintf(stdout, "My PSX mod is active.\n"); + fflush(stdout); +} + +PSX_MOD_CONSTRUCTOR(register_hello_mod) { + (void)psx_mod_register_activation_plugin( + "yourname.hello.startup", hello_activate); +} +``` + +Add it with `target_sources(MyGame PRIVATE src/hello_mod.c)`, using the game's +actual target name, and follow that repository's build instructions. A package +with the normal metadata, target, and a feature named `hello` selects it with: + +```toml +[[plugin]] +feature = "hello" +id = "yourname.hello.startup" +``` + +The plugin ID is a registry key, not a filename or exported symbol. Shipping +that C file inside a package does not install the implementation. Players +need the game build containing it. + +Activation callbacks run before renderer/window initialization. Deterministic +guest-VBlank callbacks can be registered with +`psx_mod_register_vblank_plugin()`; gate gameplay work until +`psx_mod_game_started()`. The narrow guest read/write services and option and +resource queries are in [`mod_plugins.h`](runtime/include/mod_plugins.h). +For guest instructions, use `psx_mod_write_code_word()` rather than an ordinary +data write so executable-RAM handling sees the change. Test actual execution +and save-state restoration; compilation alone cannot establish correctness. + +For assets or settings, read the committed package/feature option or resource +instead of opening a player's `state.toml` yourself. Register no unconditional +gameplay effects merely because the source file is linked. Disabling the +feature must restore stock behavior on a fresh launch. + +## Test and distribute + +Use a separate game installation and fresh mod selections, with normal saves +backed up. Test the **packed archive**, including its actual assets. + +- Verify the intended game revision and build, feature on/off behavior, and + each option including range limits. +- Exercise what changed: new-game initialization, relevant stages or menus, + transitions, death/retry, audio, and save/load as appropriate. +- Test wrong original bytes and missing/changed assets. A rejected guard is + evidence to investigate, not a reason to remove the guard. +- Test relevant mod combinations. Different writes to the same owned bytes + fail rather than letting a load order choose a winner. Match the reported + package, feature, target, and range to the source operations. +- Validate netplay in actual supported sessions before claiming it. Matching + compilation or version labels alone does not prove compatible simulation. + +Use `channel = "experimental"` on an unvalidated feature; it ships with a +badge and defaults off. `channel = "developer"` is for local development and +is unavailable in release builds. Features can declare their own channel. +Elective enhancements should be opt-in; engine correctness fixes +belong in the normal implementation. + +Repository release policy excludes **CD Speed** +(`psx.enhancement.cd-speed`) and **Fast Loading / host pacing** +(`psx.enhancement.fast-loading`) entirely from game releases. Do not ship +their packages or controls. Development catalogs keep both hidden and off. + +Installed and bundled packages have different owners: + +```text +/mods/ + bundled/// staged build output; rebuilt by the game + installed/// player packages installed by the launcher + state.toml player selections +``` + +Keep your editable sources outside both staged and installed trees. Game +maintainers put bundled sources under +`mods/preloaded/packages///` and pass the catalog through +`PRELOADED_MODS_DIR` when creating the runtime target. The framework stages it. + +Distribute the `.psxmod` with source, license/credits, supported game IDs and +hashes, minimum tested build, known limits, and a short changelog. State when +a custom executable is needed. The package's license covers only work you can +license; include only assets/code you have permission to redistribute. Stock +disc/BIOS images, personal saves, and `state.toml` do not belong in the ZIP. +`save_compatibility` metadata is not a promise that a gameplay change can load +every existing save; validate that behavior explicitly. + +## Troubleshooting + +| Symptom | What to check | +| --- | --- | +| No root manifest | Archive the contents, not an extra parent folder. | +| Unsupported format version | The game's pinned framework may predate format 10. Update the game or deliberately use only fields its older schema supports. | +| Package absent / manifest ignored | Read the game's scan error for the path and parsing reason. Syntax validation alone is not schema validation. | +| Target incompatible | Match exact game ID and actual runtime disc/EXE hash inputs. | +| Expected-byte guard failed | Recheck revision, load timing, byte order, and address/offset coordinates. | +| Overlay payload checksum failed | Pack the right file and update its lowercase SHA-256. | +| Missing trusted plugin | Use a game build containing the implementation. An ID alone cannot add code. | +| Conflicting features | Inspect the reported owned range or plugin ID and disable one; ZIP order is not patch priority. | +| Bezel enabled but invisible | Select an image, use OpenGL, and leave visible margins. | +| Package version already installed | Bump `version` or remove your prior test version through the manager. | +| Changes vanish after rebuilding | Edit source catalogs, not the build's `mods/bundled` output. | + +See [`docs/MOD_PACKAGES.md`](docs/MOD_PACKAGES.md) for advanced conditions, +sparse fields, dependencies, and resolution rules; the implementations are +[`mod_packages.cpp`](runtime/src/mod_packages.cpp) and +[`mod_runtime.cpp`](runtime/src/mod_runtime.cpp). diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index a8cb2764..768350b7 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -772,6 +772,21 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& } rt.video_resolution_reference_lines = static_cast(n); } + if (video.contains("match_display_max_lines")) { + const auto n = toml::find(video, "match_display_max_lines"); + if (n != 0 && (n < 240 || n > 8640)) { + throw std::runtime_error(fmt::format( + "[video] match_display_max_lines must be 0 (no cap) or 240..8640: {}", n)); + } + rt.video_match_display_max_lines = static_cast(n); + } + if (video.contains("supersample_disable_at_lines")) { + const auto n = toml::find(video, "supersample_disable_at_lines"); + if (n != 0 && (n < 240 || n > 8640)) + throw std::runtime_error(fmt::format( + "[video] supersample_disable_at_lines must be 0 or 240..8640: {}", n)); + rt.video_supersample_disable_at_lines = static_cast(n); + } if (video.contains("window_width")) { const auto n = toml::find(video, "window_width"); if (n < 640 || n > 7680) { diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 3766dffd..1cca1185 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -469,6 +469,11 @@ struct RuntimeConfig { // preset divides into (S = ceil(target / reference)). 240 for NTSC // 320x240 games; 120..1024. int video_resolution_reference_lines = 240; + /* [video] match_display_max_lines: cap on the display height Match + * display (and its dynamic-resolution floor) resolves against; 0 = none. */ + int video_match_display_max_lines = 0; + /* Disable target supersampling on displays at least this tall (0 = off). */ + int video_supersample_disable_at_lines = 0; // Optional initial window width declared by the title profile. Zero keeps // the historical fit-to-display behavior; player settings may override it. diff --git a/recompiler/tests/video_enhancement_settings_test.cpp b/recompiler/tests/video_enhancement_settings_test.cpp index 33dda58c..cdc5b83a 100644 --- a/recompiler/tests/video_enhancement_settings_test.cpp +++ b/recompiler/tests/video_enhancement_settings_test.cpp @@ -224,6 +224,19 @@ static void test_internal_resolution_game_toml() { "internal_resolution defaults unset (supersampling stands)"); check(gc.runtime.video_resolution_reference_lines == 240, "resolution_reference_lines defaults to 240"); + check(gc.runtime.video_match_display_max_lines == 0, + "match_display_max_lines defaults to 0 (no cap)"); + check(gc.runtime.video_supersample_disable_at_lines == 0, "display SS policy defaults off"); + fs::remove(p); + + p = write_game_toml("psxrecomp_ir_match_cap.toml", + "[video]\n" + "internal_resolution = \"display\"\n" + "match_display_max_lines = 2160\n" + "supersample_disable_at_lines = 2160\n"); + gc = PSXRecompV4::load_game_config(p); + check(gc.runtime.video_match_display_max_lines == 2160, "match_display_max_lines parses"); + check(gc.runtime.video_supersample_disable_at_lines == 2160, "display SS policy parses"); fs::remove(p); p = write_game_toml("psxrecomp_ir_4k.toml", @@ -246,6 +259,8 @@ static void test_internal_resolution_game_toml() { for (const char* bad : { "internal_resolution = \"9k\"\n", "internal_resolution = 1\n", "resolution_reference_lines = 50\n", + "match_display_max_lines = 100\n", + "supersample_disable_at_lines = 100\n", "supersampling = 33\n" }) { p = write_game_toml("psxrecomp_ir_bad.toml", std::string("[video]\n") + bad); bool rejected = false; diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 85843c06..e251c208 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -26,6 +26,10 @@ if(BUILD_TESTING) # Windows UTF-8 text boundary (issue #371): the helpers legacy Windows # relies on, and — with the shipped manifest — a UTF-8 process code page. if(WIN32) + add_executable(host_time_threads_test tests/test_host_time_threads.c src/host_time.c) + target_include_directories(host_time_threads_test PRIVATE include) + add_test(NAME host_time_threads_test COMMAND host_time_threads_test) + set_tests_properties(host_time_threads_test PROPERTIES TIMEOUT 15) add_executable(win_utf8_helpers_test tests/test_win_utf8.cpp) target_include_directories(win_utf8_helpers_test PRIVATE include) add_test(NAME win_utf8_helpers_test COMMAND win_utf8_helpers_test) @@ -1424,6 +1428,7 @@ if(BUILD_TESTING) endif() endif() add_test(NAME mod_runtime_test COMMAND mod_runtime_test) + add_test(NAME mod_commands_test COMMAND mod_runtime_test --commands-only) add_test(NAME mod_netplay_missing_plugin_test COMMAND mod_runtime_test --missing-profile) add_test(NAME mod_netplay_missing_renderer_test COMMAND mod_runtime_test --missing-renderer) add_test(NAME mod_netplay_profile_options_test COMMAND mod_runtime_test --profile-options) @@ -1516,6 +1521,10 @@ if(BUILD_TESTING) add_test(NAME mod_resident_test COMMAND mod_resident_test) if(WIN32) + add_executable(autocompile_child_env_test tests/test_autocompile_child_env.c) + target_include_directories(autocompile_child_env_test PRIVATE src) + add_test(NAME autocompile_child_env_test COMMAND autocompile_child_env_test) + add_executable(autocompile_publication_test tests/test_autocompile_publication.c src/autocompile.c @@ -1625,6 +1634,17 @@ if(BUILD_TESTING) add_test(NAME present_sync_test COMMAND present_sync_test) target_include_directories(frame_pacing_test PRIVATE include) add_test(NAME frame_pacing_test COMMAND frame_pacing_test) + # Opt-in stereo/OpenXR frame-trace ring (frame_trace.c): disabled-by-default, + # ring wrap, timestamp monotonicity, missing telemetry, export validity. + add_executable(frame_trace_test tests/test_frame_trace.c src/frame_trace.c) + target_include_directories(frame_trace_test PRIVATE include) + add_test(NAME frame_trace_test COMMAND frame_trace_test) + # Stereo shedding/recovery policy (docs/STEREO_RENDERING.md): bounded streaks, + # authoritative probes, hysteresis, pair age separate from the estimate. + add_executable(stereo_shed_policy_test tests/test_stereo_shed_policy.c + src/stereo_shed_policy.c) + target_include_directories(stereo_shed_policy_test PRIVATE include) + add_test(NAME stereo_shed_policy_test COMMAND stereo_shed_policy_test) # Render thread core ([video] render_thread, docs/RENDER_THREAD.md): # ordering across ring wraps, the frame bound, stale-frame detection and # the drain + context hand-off sync point, against a fake executor. @@ -1770,6 +1790,8 @@ if(BUILD_TESTING) add_executable(render_pass_abort_test tests/test_render_pass_abort.c src/render_pass.c + src/frame_trace.c + src/stereo_shed_policy.c src/psx_cycles.c) target_include_directories(render_pass_abort_test PRIVATE include) add_test(NAME render_pass_abort_test COMMAND render_pass_abort_test) @@ -1781,6 +1803,8 @@ if(BUILD_TESTING) add_executable(render_pass_sandbox_test tests/test_render_pass_sandbox.c src/render_pass.c + src/frame_trace.c + src/stereo_shed_policy.c src/render_pass_plan.c src/psx_cycles.c src/timers.c @@ -2038,6 +2062,11 @@ if(BUILD_TESTING) COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_memory_debug_trace_guard.py --cc ${CMAKE_C_COMPILER}) + # The frame-trace record path must stay allocation- and IO-free, or + # it perturbs the frame it measures and no functional test notices. + add_test(NAME frame_trace_guard_test + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_frame_trace_guard.py) endif() add_test(NAME fntrace_c_linkage_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_fntrace_c_linkage.py) add_test(NAME depth24_present_hold_vblank_test @@ -2128,6 +2157,10 @@ if(BUILD_TESTING) COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_game_runtime_pgxp.py --cmake ${CMAKE_COMMAND}) + add_test(NAME game_runtime_memcard_slots_test + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_game_runtime_memcard_slots.py + --cmake ${CMAKE_COMMAND}) # tools/mod_channel_filter.py strips developer features from a staged # catalog; every entry that served one ([[requirement]] included) must # leave with it. diff --git a/runtime/include/frame_gen.h b/runtime/include/frame_gen.h index 587ed518..b987e7ee 100644 --- a/runtime/include/frame_gen.h +++ b/runtime/include/frame_gen.h @@ -57,6 +57,8 @@ typedef struct FgPrim { float x[3], y[3]; /* native px, relative to the list's display origin */ float p[3][3]; /* camera-space positions (GTE units) */ float h[3]; /* projection distance */ + float pz[3]; /* PGXP depth (GTE SZ) per vertex when the renderer had it, else 0: + * depth for geometry without a projection source */ } FgPrim; typedef struct FgPrimList { @@ -151,6 +153,9 @@ double fg_step_s(double flip_s, double refresh_hz, int n); double fg_clock_phase(uint64_t since_real_ns, uint64_t step_ns, uint64_t flip_ns); /* View vi's in-between camera at phase t as P_t = A P + b (row-major A). */ int fg_view_affine(const FgCamFit *fit, int vi, double t, float A[9], float b[3]); +/* The older frame's camera at phase t (P_t = A P_old + b): for warping the + * older frame's own pixels to the in-between camera. */ +int fg_view_affine_older(const FgCamFit *fit, int vi, double t, float A[9], float b[3]); void fg_hud_match(const FgPrimList *L, const FgPrimList *O, float max_px, uint8_t *moved); /* HUD motion between two frames' 2D triangles (frame_gen.c). */ void fg_hud_lerp(const FgPrimList *L, const FgPrimList *O, double u, float *x, float *y, diff --git a/runtime/include/frame_trace.h b/runtime/include/frame_trace.h new file mode 100644 index 00000000..7edd64c2 --- /dev/null +++ b/runtime/include/frame_trace.h @@ -0,0 +1,243 @@ +#ifndef PSX_FRAME_TRACE_H +#define PSX_FRAME_TRACE_H + +/* Opt-in, fixed-capacity frame-time tracing for the stereo/OpenXR path. + * + * WHY THIS EXISTS + * --------------- + * VR stutter reports ("it hitches when enemies spawn", "fast head turns judder", + * "one heavy frame causes a run of stutters") could not be attributed. The + * renderer already smoothed a few averages, but only for a handful of stages, + * they were never reset, and there was no per-frame history to correlate a + * visible hitch with. This module is the measurement floor the VR optimization + * program is built on: one record per stereo frame, monotonic, allocation-free + * on the hot path, exported only on demand. + * + * DESIGN CONTRACT + * --------------- + * * DISABLED BY DEFAULT (PSX_FRAME_TRACE=0). Every entry point returns + * immediately while disabled, so an instrumented build costs one branch. + * * NO per-frame heap allocation and NO file I/O on the record path. The ring + * is static; the record holds fixed-size fields only. Export writes into a + * caller buffer, outside the frame. + * * Stages are supplied by the caller in MICROSECONDS, measured with whatever + * host clock it already uses. The module never reads a clock for durations, + * only for the record's open timestamp, through one injectable function + * pointer. That keeps the module dependency-free and unit-testable. + * * CPU submission time is NOT GPU execution time. Stages named "xr_*" are + * wall-clock waits; nothing here claims GPU completion. + * * Emulation/main thread only, like the OpenXR lifecycle it observes. + */ + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Per-frame stages, in the order one stereo frame produces them. The header + * order IS the ABI; keep frame_trace.c's name table in sync. */ +enum { + PSX_TRACE_GUEST_SIM = 0, /* guest work outside the stereo pair */ + PSX_TRACE_PAIR_TOTAL, /* both eyes, pair open to publish */ + PSX_TRACE_EYE_BEGIN_L, /* VRAM backup + checkpoint */ + PSX_TRACE_EYE_GUEST_L, /* the plugin's guest draw */ + PSX_TRACE_EYE_END_L, /* GPU flush, capture, VRAM restore */ + PSX_TRACE_EYE_RESTORE_L, /* machine state restore */ + PSX_TRACE_EYE_BEGIN_R, + PSX_TRACE_EYE_GUEST_R, + PSX_TRACE_EYE_END_R, + PSX_TRACE_EYE_RESTORE_R, + PSX_TRACE_XR_WAIT_FRAME, /* xrWaitFrame */ + PSX_TRACE_XR_BEGIN_FRAME, /* xrBeginFrame */ + PSX_TRACE_XR_ACQUIRE, /* swapchain acquire (both eyes) */ + PSX_TRACE_XR_WAIT_IMAGE, /* xrWaitSwapchainImage (both eyes) */ + PSX_TRACE_XR_COPY, /* eye blit into the runtime image */ + PSX_TRACE_XR_RELEASE, /* swapchain release (both eyes) */ + PSX_TRACE_XR_END_FRAME, /* xrEndFrame */ + /* How old the submitted pose was: xrLocateViews return to xrEndFrame + * return. This is the pose the compositor reprojects from, so it bounds + * the correction a head turn can need. */ + PSX_TRACE_POSE_AGE, + /* Host time a HUD capture transaction costs (psx_mod_render_capture_rgba). + * The wrist HUD captures the same guest slice twice per frame, once per + * matte colour, and both are full render transactions on the frame's own + * thread, so this is frame time the pair stats never showed. */ + PSX_TRACE_HUD_CAPTURE, + /* The capture's two halves, so the cost can be aimed at: the transaction is + * the checkpoint plus the guest HUD draw plus its verify, the readback is + * the blit out of the pair target and the synchronous glReadPixels. */ + PSX_TRACE_HUD_DRAW, + PSX_TRACE_HUD_READBACK, + /* The guest scene rebuild one eye replays, split by the call the game hook + * makes, so a hitch can be aimed at the guest call that grew instead of at + * the whole eye. These are PAIR totals: both eyes write the same slots. + * Written by the hook through psx_mod_frame_trace_stage_begin/end, which + * only accepts them inside a traced stereo eye. + * game_env draw environment, GP0 clear, ordering-table setup + * game_world the world/level passes + * game_text the generic text renderer + * game_traverse scene traversal: level groups and entities + * game_icon the HUD icon + * game_submit DrawOTag: walks the ordering table and submits it + * game_laser the hook's own per-eye raycast and line (host work) + * game_select the hook's own level visibility override (the LVTX + * entry filter): the eye-view AABB test and the leaf + * queue it writes. It runs INSIDE the guest traversal, + * and game_traverse is that call's wall time, so the + * guest's own traversal work is game_traverse minus + * game_select. Per eye and summed over the pair like the + * rest, and the only one that nests. */ + PSX_TRACE_GAME_ENV, + PSX_TRACE_GAME_WORLD, + PSX_TRACE_GAME_TEXT, + PSX_TRACE_GAME_TRAVERSE, + PSX_TRACE_GAME_ICON, + PSX_TRACE_GAME_SUBMIT, + PSX_TRACE_GAME_LASER, + PSX_TRACE_GAME_SELECT, + PSX_TRACE_STAGE_COUNT +}; + +/* Record flags. A hitch is the interesting case, so absent conditions are + * explicit: a stale frame is one that published no new pair. */ +enum { + PSX_FRAME_TRACE_STEREO_PUBLISHED = 1u << 0, + PSX_FRAME_TRACE_STALE_PAIR = 1u << 1, + PSX_FRAME_TRACE_SHED = 1u << 2, + PSX_FRAME_TRACE_FAILED = 1u << 3, + PSX_FRAME_TRACE_XR_EMPTY = 1u << 4, + PSX_FRAME_TRACE_SCENE_TRANSITION = 1u << 5, + PSX_FRAME_TRACE_IO_EVENT = 1u << 6, + PSX_FRAME_TRACE_NO_TELEMETRY = 1u << 7, + /* A stereo eye rendered from the pair's shared baseline instead of taking + * its own machine snapshot (see PR3's pair-scoped checkpoint reuse). */ + PSX_FRAME_TRACE_BASELINE_REUSED = 1u << 8, + /* The XR frame carried no new pair, so the retained one was submitted (or + * nothing was, when resubmission is off). */ + PSX_FRAME_TRACE_XR_REPEAT = 1u << 9 +}; + +enum { + PSX_FRAME_TRACE_NOTE = 24, /* scene / IO tag */ + PSX_FRAME_TRACE_REASON = 24 /* failure reason text */ +}; + +typedef struct PSXFrameTraceRecord { + uint64_t seq; /* monotonic record id (starts at 1) */ + uint64_t open_ms; /* host monotonic ms at open */ + uint64_t guest_frame; /* guest frame counter, 0 if unknown */ + uint64_t guest_cycle; /* guest cycle at open */ + uint64_t stereo_attempt; /* stereo attempt id (0 if none) */ + uint64_t pair_id; /* published pair id (0 if none) */ + uint64_t pair_age_ms; /* ms since the last published pair (0 if none) */ + uint32_t stages_us[PSX_TRACE_STAGE_COUNT]; + uint32_t shed_skipped; /* pair attempts shed immediately before this frame */ + uint32_t shed_streak_ms; /* how long that shed streak had lasted */ + uint32_t prims; /* guest draw primitives (0 = not measured) */ + uint32_t textured_prims; /* of which textured */ + /* The same two counters per eye. `prims`/`textured_prims` above keep their + * meaning (the last eye of the pair wrote them); a change to what ONE eye + * selects needs these, because the pair's total hides which eye moved. */ + uint32_t prims_l, prims_r; + uint32_t tex_prims_l, tex_prims_r; + /* Guest cycles each eye's replay executed, the machine work the eye cost in + * the emulated CPU rather than in host submission. 0 = not measured. */ + uint32_t eye_cycles_l, eye_cycles_r; + uint32_t vram_leaks; /* GPU writes outside the pass rect this eye */ + uint64_t tex_upload_bytes; /* texture bytes uploaded (0 = not measured) */ + /* Microseconds between this submission's XrFrameState.predictedDisplayTime + * and the previous one, from the runtime's clock. Divided by the session's + * target_period_us this is how many granted display periods one presented + * pair consumed, which is the cadence the compositor actually saw. 0 on the + * first submission of a session or when there was no frame this record. */ + uint32_t xr_display_delta_us; + uint32_t flags; /* PSX_FRAME_TRACE_* */ + int32_t eye_failed; /* last eye that failed, -1 if none */ + uint8_t source; /* 1 = stereo pair, 2 = native render */ + uint8_t xr_submit_fresh; /* 1 = carried a new pair, 0 = repeat/none */ + uint8_t reserved[2]; + char note[PSX_FRAME_TRACE_NOTE]; + char failure[PSX_FRAME_TRACE_REASON]; +} PSXFrameTraceRecord; + +/* Injectable monotonic millisecond clock. NULL (the default) records 0; the + * runtime installs psx_host_mono_ms(). Tests install a fake. */ +typedef uint64_t (*PSXFrameTraceClock)(void); +void psx_frame_trace_set_clock(PSXFrameTraceClock fn); + +/* Enables/disables capture. Disabling keeps the ring (read it, then reset). + * Reads PSX_FRAME_TRACE / PSX_FRAME_TRACE_CAP once. */ +void psx_frame_trace_configure_from_env(void); +void psx_frame_trace_set_enabled(int on); +int psx_frame_trace_enabled(void); + +/* Clears the ring, counters and any open record. */ +void psx_frame_trace_reset(void); + +/* Opens a record for the frame that is starting. Returns 1 if this call opened + * it, 0 if capture is off or a record is already open (the caller must then NOT + * commit). Cost is a branch while disabled. */ +int psx_frame_trace_open(void); +/* Commits the open record, stamping pair age from the last published pair. + * No-op when nothing is open. */ +void psx_frame_trace_commit(void); + +/* Adds `us` microseconds to `stage` of the open record. Ignores unknown stages; + * saturates instead of wrapping. Cheap while closed. */ +void psx_frame_trace_add(uint32_t stage, uint64_t us); + +/* Per-frame annotations (open record). Each is a no-op when closed. */ +void psx_frame_trace_ids(uint64_t guest_frame, uint64_t guest_cycle); +void psx_frame_trace_attempt(uint64_t attempt_id, uint32_t shed_skipped, + uint32_t shed_streak_ms); +void psx_frame_trace_result(int published, uint64_t pair_id, int eye_failed, + const char *failure); +void psx_frame_trace_source(uint8_t source); +/* Draw counters for `eye` (PSX_MOD_EYE_LEFT/0 or RIGHT/1). Also fills `prims` + * and `textured_prims`, which therefore remain the last eye's values. */ +void psx_frame_trace_draw(int eye, uint32_t prims, uint32_t textured); +/* Guest cycles one eye's replay executed (the transaction's own delta). */ +void psx_frame_trace_eye_cycles(int eye, uint32_t cycles); +void psx_frame_trace_gpu(uint64_t tex_upload_bytes, uint32_t vram_leaks); +/* XR cadence annotation (open record): the runtime's predicted-display-time + * delta for this submission and whether it carried a newly rendered pair. A + * repeat also sets PSX_FRAME_TRACE_XR_REPEAT. No-op when closed. */ +void psx_frame_trace_xr_cadence(uint32_t display_delta_us, int submit_fresh); +void psx_frame_trace_flag(uint32_t flag); +void psx_frame_trace_note(const char *tag); +void psx_frame_trace_io(const char *tag); /* note + IO_EVENT flag */ + +/* Session-level metadata, emitted once in the export header rather than per + * record: the OpenXR runtime actually in use and the target display period in + * microseconds (0 = unknown, e.g. no headset). Without these a trace cannot be + * interpreted later, because the same pair time is over budget at 90 Hz and + * comfortably under it at 60 Hz. Change-checked internally, so calling it every + * frame is a strcmp; it is a no-op while capture is off. */ +void psx_frame_trace_session(const char *runtime, uint32_t target_period_us); + +/* Queries. `capacity` is the caller's array length; returns how many records + * were written (oldest first, at most the newest `capacity`). */ +uint32_t psx_frame_trace_read(PSXFrameTraceRecord *out, uint32_t capacity); +uint32_t psx_frame_trace_capacity(void); +uint64_t psx_frame_trace_committed(void); /* records committed since reset */ +uint64_t psx_frame_trace_dropped(void); /* records overwritten (ring wrap) */ +uint64_t psx_frame_trace_missing(void); /* commits with no stage at all */ + +/* Exports. Each returns the number of bytes the full text needs (like + * snprintf); the buffer is always NUL-terminated when cap > 0. `max_records` + * (0 = all retained) bounds the newest records emitted. */ +int psx_frame_trace_to_json(char *buf, int cap, uint32_t max_records); +int psx_frame_trace_to_csv(char *buf, int cap, uint32_t max_records); +/* Chrome trace-event JSON (Perfetto compatible): one span per stage. */ +int psx_frame_trace_to_chrome_json(char *buf, int cap, uint32_t max_records); + +const char *psx_frame_trace_stage_name(uint32_t stage); +/* Renders the set bits as "a|b|c" into buf; always NUL-terminated. */ +const char *psx_frame_trace_flag_names(uint32_t flags, char *buf, int cap); + +#ifdef __cplusplus +} +#endif + +#endif /* PSX_FRAME_TRACE_H */ diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index c78d21c7..9e6289b2 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -447,6 +447,8 @@ uint32_t psx_ws_clip_edge_x(uint32_t v, uint32_t w); * 4:3. */ uint32_t psx_ws_cull_lui_hi(uint32_t imm); int psx_ws_cull_bltz_hi(uint32_t v); +void gpu_ws_set_cull_screen_x_sites(const uint32_t *sites, int nsites); +int psx_ws_is_cull_screen_x_site(uint32_t pc); int psx_ws_cull_vxrange(uint32_t x, uint32_t imm); /* True if a run of instruction words carries the screen-extent reject signature * (a width compare AND a height compare from the configured immediate sets). diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 4a27bdb2..9b287802 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -136,7 +136,7 @@ uint32_t gl_renderer_stereo_unavailable(void); int gl_renderer_stereo_begin(int x, int y, int w, int h, int reuse_backup); int gl_renderer_stereo_end(uint32_t eye, int keep); void gl_renderer_stereo_stage_reset(void); -int gl_renderer_stereo_stage_readback(uint32_t eye,uint32_t width,uint32_t height,uint8_t *rgba); +int gl_renderer_stereo_stage_readback_pair(uint32_t width,uint32_t height,uint8_t *rgba); int gl_renderer_stereo_publish(uint64_t pair_id, uint64_t guest_cycle, const int32_t eye_view[2][3]); void gl_renderer_stereo_reset(void); @@ -164,6 +164,9 @@ void gl_renderer_interpolation_diag(int *enabled, int *suspended, /* Cumulative CPU-upload diagnostics: calls, rects, pixels, conversion ticks, * texture-upload ticks, FBO-draw ticks. Active only with PSX_RUNTIME_PERF_DIAG. */ void gl_renderer_runtime_diag(uint64_t out[6]); +/* Cumulative scene primitive counters (pre double-draw): all submitted, and of + * those textured. Callers take deltas; the counters are never reset here. */ +void gl_renderer_scene_prim_counts(uint64_t *prims, uint64_t *textured); /* Present an ARGB8888 image (BGRA byte order) as a letterboxed quad + swap. * Used for 24-bit (FMV) frames and the PSX_GL_FORCE_CPU_PRESENT diagnostic. diff --git a/runtime/include/internal_resolution.h b/runtime/include/internal_resolution.h index 50e61016..c37dc32a 100644 --- a/runtime/include/internal_resolution.h +++ b/runtime/include/internal_resolution.h @@ -143,6 +143,13 @@ static inline int psx_ss_parse_milli(const char *s) { return (int)m; } +/* Opt-in display policy; keep the saved factor intact for smaller displays. */ +static inline int psx_ir_display_supersample(int ss_milli, int display_px_h, + int disable_at_lines) { + return disable_at_lines > 0 && display_px_h >= disable_at_lines + ? PSX_SS_MILLI_MIN : ss_milli; +} + /* psx_resolve_internal_scale with the supersample factor on the target. */ static inline int psx_resolve_internal_scale_ss(int value, int ref_lines, int display_px_h, int s_max, @@ -160,6 +167,18 @@ static inline int psx_resolve_internal_scale_ss(int value, int ref_lines, return psx_resolve_internal_scale(value, ref_lines, display_px_h, s_max); } +/* A Match display cap bounds the final target, including supersampling. + * Fixed presets and the legacy integer scale retain their own ceilings. */ +static inline int psx_resolve_internal_scale_ss_cap(int value, int ref_lines, + int display_px_h, int s_max, int ss_milli, int max_display_lines) { + int s = psx_resolve_internal_scale_ss(value, ref_lines, display_px_h, s_max, ss_milli); + if (value == PSX_IR_DISPLAY && max_display_lines > 0) { + int cap = psx_resolve_internal_scale(max_display_lines, ref_lines, 0, s_max); + if (s > cap) s = cap; + } + return s; +} + /* A legacy [video] supersampling factor as a value the launcher can show: * the preset that resolves to the same scale (1 -> Native, 3 -> 720p, * 5 -> 1080p, 6 -> 1440p, 9 -> 4K, 12 -> 5K, 18 -> 8K at 240 lines), diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 8c6743a3..d77acc18 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -102,6 +102,20 @@ extern uint32_t g_psx_mod_function_entry_hooks; * kept, further names are counted in an overflow bucket. Emulation-thread only. * TCP: {"cmd":"mod_counters"} lists every counter with its last frame. */ void psx_mod_counter_add(const char* name, uint32_t delta); +/* Named debug commands for trusted plugins (test benches, tuning): `fn` gets the + * argument string and writes a short text reply (truncated to `cap`); it runs on + * the debug-server thread, so it must only touch state it guards itself. Up to 16 + * names (1..63 bytes, copied by the registry); callbacks have process lifetime. + * Re-registering a name replaces its callback, allowing restart registration. + * This debug-control registry is independent of package activation. It must + * not directly mutate guest/shared simulation state from the server thread: + * signal a guarded control request for the plugin's existing emulation hook. + * Callbacks must not throw. TCP: {"cmd":"mod_cmd","name":"","args":"..."}. */ +typedef int (*PSXModCommandFn)(const char* args, char* reply, int cap); +int psx_mod_register_command(const char* name, PSXModCommandFn fn); +/* Runs a registered command; -1 for unknown name/invalid output, otherwise the + * callback result. Valid replies are always terminated within cap bytes. */ +int psx_mod_run_command(const char* name, const char* args, char* reply, int cap); /* Presentation-only filtering: 0 nearest, 1 bilinear, 2 stable minification. * Mode 2 uses a bounded palette-aware footprint for proven 3D polygons on * OpenGL; untracked UI stays nearest. Other backends use bilinear. A session @@ -637,14 +651,20 @@ uint32_t psx_mod_render_pass_plan(uint32_t period_vblanks, * partially updated plugin state) across guest code, or tolerate the abort. */ int psx_mod_render_pass(struct CPUState* cpu, const PSXModRenderPass* pass, PSXModRenderPassFn fn, void* user); -/* Isolated draw/readback using the stereo-capable rollback transaction. - * Never publishes an eye pair or changes presentation. Returns native-sized - * top-to-bottom RGBA8 pixels (alpha remains the PSX mask bit, not coverage). - * Call outside another pass. rgba_bytes must cover rect->w*rect->h*4. */ -int psx_mod_render_capture_rgba(struct CPUState *cpu,const PSXModRenderPass *rect, - PSXModRenderPassFn fn,void *user, - uint8_t *rgba,uint32_t rgba_bytes); -#define PSX_MOD_RENDER_CAPTURE_RGBA 1 +/* Isolated dual-matte draw/readback using the stereo-capable rollback + * transaction. Draws two mattes of the same slice, the first into eye 0 of the + * stage surface and the second into eye 1, so both planes of a frame cost one + * readback instead of two. Never publishes an eye pair or changes presentation. + * Returns native-sized bottom-to-top RGBA8 pixels (alpha remains the PSX mask + * bit, not coverage). Call outside another pass. rgba_bytes must cover + * 2*rect->w*rect->h*4; the first matte arrives in the buffer's first half and + * the second in its second half. */ +int psx_mod_render_capture_rgba_pair(struct CPUState *cpu, + const PSXModRenderPass *rect, + PSXModRenderPassFn fn,void *user_first, + void *user_second,uint8_t *rgba, + uint32_t rgba_bytes); +#define PSX_MOD_RENDER_CAPTURE_RGBA_PAIR 1 /* * Why passes cannot run right now, the host-time budget aside (a plan that * returns 0 while this says READY was shed for time). A plugin that relies on @@ -719,6 +739,35 @@ typedef int (*PSXModStereoFn)(struct CPUState*, void*, uint32_t eye); int psx_mod_render_stereo(struct CPUState *cpu, const PSXModStereoFrame *frame, PSXModStereoFn fn, void *user); uint32_t psx_mod_render_stereo_status(void); +/* Annotate the current frame's trace record with a short tag (scene transition, + * state change). Only meaningful while PSX_FRAME_TRACE is on and a traced frame + * is open (a headset frame, opened by psx_mod_openxr_begin); a no-op otherwise. */ +void psx_mod_frame_trace_note(const char *tag); +/* Bracket one guest call of a stereo eye's replay with a frame-trace sub-stage, + * so a hitch can be aimed at the call that grew instead of at the whole eye + * (the PSX_TRACE_GAME_* stages in frame_trace.h). The runtime does the timing, + * with the same clock and conversion as the eye's own stages, so the parts sum + * against eye_guest. A no-op unless PSX_FRAME_TRACE is on AND the call is made + * inside a traced stereo eye: a HUD capture's guest draws, the flat path and + * anything outside the eye's callback are ignored. A begin without a matching + * end (or an eye aborted mid-entry) contributes nothing; the unmeasured time + * stays visible as the difference between eye_guest and the sub-stages. + * Spans may nest: each stage keeps its own start tick, so a stage bracketed + * inside another is timed on its own and reports a subset of the outer span's + * wall time rather than a sibling of it. The hook's visibility override nests + * inside the guest traversal it is part of (game_select in game_traverse). */ +void psx_mod_frame_trace_stage_begin(uint32_t stage); +void psx_mod_frame_trace_stage_end(uint32_t stage); +/* Drop the stereo shedding policy's pair-time estimate and derived budget. Call + * on a scene/mode transition so a new scene does not inherit the previous one's + * timing; cheap and idempotent. Shedding itself is unchanged. */ +void psx_mod_stereo_reset_budget(void); +/* Set the stereo pair budget from a real headset deadline in milliseconds, + * pushed by the OpenXR layer from the target display period (11.1 ms at 90 Hz, + * 8.3 ms at 120 Hz). 0 clears it. The guest-VBlank-derived value is only a + * fallback: a PSX redraw budget is not an OpenXR display deadline, and the two + * disagree on every non-60 Hz headset. PSX_STEREO_SHED_BUDGET_MS still wins. */ +void psx_mod_stereo_set_budget_ms(double ms); /* 0 disables stereo output; 1 shows a complete pair side by side. Capture * itself does not enable presentation. Default 0; applies to this session. */ int psx_mod_set_stereo_presentation(uint32_t mode); @@ -741,8 +790,15 @@ typedef struct PSXModRenderView { uint32_t projection; uint32_t projection_h_ref; /* 0 absolute FOV; otherwise scale focal lengths by guest H/ref */ int32_t fx_q16, fy_q16, cx_delta_q16, cy_delta_q16; + /* Optional host-only pose for projective replay. The ordinary path uses + * Q12/integer values; precise 1 preserves sub-unit movement inside replay. */ + uint32_t precise; + double rotation[9], translation_precise[3]; } PSXModRenderView; int psx_mod_render_view(const PSXModRenderView *view); +/* Signed homogeneous projection and near clipping for replayed 3D packets. + * Requires intact PGXP provenance; native gameplay stays architectural. */ +void psx_mod_set_render_projective(int enabled); /* Begin locates both views at one predicted time. End submits only a fresh * complete pair; failed/shed redraws submit zero layers. */ int psx_mod_openxr_enable(int enabled); diff --git a/runtime/include/pgxp.h b/runtime/include/pgxp.h index 8b5d4455..680703ba 100644 --- a/runtime/include/pgxp.h +++ b/runtime/include/pgxp.h @@ -62,7 +62,8 @@ int pgxp_position_fallback(void); * near geometry. Guest-visible SXY, MAC0 and FLAG are unchanged either way. * Because that position can then differ from the guest integer by more than * the fraction, truncation agreement becomes a window (PGXP_PPP_AGREE_*) and - * a vertex at the GTE's saturation limit must still agree exactly. One + * unclamped IR projections need an intact, value-validated GTE word; halves + * and cache fallbacks keep the strict saturation checks. One * consumer is not visual: [widescreen] precise_nclip takes its branch sign * from these shadows, so in a title that sets both, that (already * non-faithful) widescreen sign follows the exact projection too. */ @@ -91,6 +92,7 @@ int pgxp_preserve_projection(void); * when none). Returns 1 with the position in x16/y16 when the vertex * qualifies (sf=1, IR1/IR2 not saturated, SZ3 not saturated and nonzero, and * no divide overflow: H < 2*SZ3), else 0 and the caller keeps the IR path. */ +/* Return 2 for an opt-in IR-unclamped projection, 1 for ordinary precision. */ int pgxp_project_precise(int64_t mac1, int64_t mac2, int64_t mac3, int shift, int32_t ir1, int32_t ir2, uint32_t sz3, uint32_t h, int32_t ofx, int32_t ofy, int64_t x_num, int64_t x_den, @@ -164,6 +166,11 @@ void pgxp_suppress_end(void); /* 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). */ +/* Tag only immediately after push_sxy of a qualified unclamped projection. */ +void pgxp_gte_mark_unclamped(uint32_t depth); +/* Full positive MAC3 depth of an intact, validated exact-projection word; + * zero when unavailable. The legacy SZ APIs keep their hardware u16 value. */ +float pgxp_get_unclamped_depth(uint32_t addr, uint32_t word); void pgxp_gte_push_sxy(int32_t x16, int32_t y16, uint16_t sz3, uint32_t packed); /* Host-only homogeneous screen projection, before SZ/SXY/divider saturation. @@ -304,6 +311,15 @@ int pgxp_load_precise_word(uint32_t addr, uint32_t packed, void pgxp_checkpoint_begin(void); void pgxp_checkpoint_rollback(void); +/* Scoped PGXP mode change for an exact-projection render pass. Open after the + * precision checkpoint and close before rollback so the restored generation + * remains the pre-pass generation. */ +typedef struct PGXPProjectiveScope { + int saved_enabled, saved_cpu_mode, active; +} PGXPProjectiveScope; +int pgxp_projective_scope_begin(PGXPProjectiveScope *scope); +void pgxp_projective_scope_end(PGXPProjectiveScope *scope); + /* Exact address-keyed packet precision, for a renderer preserving completed * static packets around a smaller interpolated drawing section. `expected` * MUST be the actual destination RAM/scratchpad word, read by the caller diff --git a/runtime/include/psx_icache.h b/runtime/include/psx_icache.h index e45b9381..96692f95 100644 --- a/runtime/include/psx_icache.h +++ b/runtime/include/psx_icache.h @@ -14,13 +14,13 @@ void psx_icache_reset(void); void psx_icache_fetch(CPUState *cpu, uint32_t addr); void psx_icache_fetch_miss(CPUState *cpu, uint32_t addr); -/* BIU / cache control (0xFFFE0130) bits the cache model reads; names and values - * as Beetle's cpu.cpp BIU_* defines. */ -#define PSX_BIU_ICACHE_ENABLE 0x800u /* BIU_ENABLE_ICACHE_S1 */ -#define PSX_BIU_DCACHE_ENABLE 0x080u /* BIU_ENABLE_DCACHE */ -#define PSX_BIU_TAG_TEST 0x004u /* BIU_TAG_TEST_MODE */ -#define PSX_BIU_INVALIDATE 0x002u /* BIU_INVALIDATE_MODE */ -#define PSX_BIU_LOCK 0x001u /* BIU_LOCK_MODE */ +/* BIU / cache control (0xFFFE0130) bits the cache model reads (PSX-SPX + * memorycontrol, "FFFE0130h - BCC, BIU/Cache Configuration Register"). */ +#define PSX_BIU_ICACHE_ENABLE 0x800u /* bit 11, IS1 */ +#define PSX_BIU_DCACHE_ENABLE 0x080u /* bit 7, DS */ +#define PSX_BIU_TAG_TEST 0x004u /* bit 2, TAG */ +#define PSX_BIU_INVALIDATE 0x002u /* bit 1, INV */ +#define PSX_BIU_LOCK 0x001u /* bit 0, LOCK */ /* A CPU store made while SR.IsC is set, applied to the I-cache model: the * I-cache half of Beetle PS_CPU::WriteMemory's IsC branch. With the I-cache diff --git a/runtime/include/psx_openxr.h b/runtime/include/psx_openxr.h index 4455b098..86fd5e1a 100644 --- a/runtime/include/psx_openxr.h +++ b/runtime/include/psx_openxr.h @@ -11,6 +11,8 @@ typedef struct PSXOpenXRStats { const char *stage; uint64_t waits, submitted, empty, failures, predicted_time; float ipd_m; + float origin_up_dot; + uint32_t origin_inversion_streak, origin_ambiguous; double units_per_meter; uint64_t gl_version, min_gl_version, max_gl_version; int64_t swapchain_format; /* GL_SRGB8_ALPHA8 preferred; GL_RGBA8 decoded copy */ diff --git a/runtime/include/render_pass.h b/runtime/include/render_pass.h index 13594b9e..1bcfc64c 100644 --- a/runtime/include/render_pass.h +++ b/runtime/include/render_pass.h @@ -91,6 +91,22 @@ typedef struct RenderStereoStats { uint64_t last_failed_attempt, retained_pair_id; int last_failed_eye; const char *last_failed_reason; /* latched across later successes */ + /* Shedding/recovery policy (stereo_shed_policy.c). A shed streak is bounded + * by time and count; these report how the last one ended and how stale the + * retained pair got. */ + uint64_t shed_streak, shed_streak_max; /* consecutive sheds now / worst seen */ + uint32_t shed_streak_ms; /* wall clock of the current streak */ + uint64_t stale_frames; /* frames that published no new pair */ + uint64_t budget_resets; /* estimates reset on scene/mode change */ + double budget_ms; /* pair budget actually in use */ + uint32_t budget_source; /* 0 vblank (fallback), 1 headset, 2 env */ + uint64_t last_pair_age_ms; /* age of the retained pair, this frame */ + int shedding; /* policy is currently suppressing */ + /* Pair-scoped baseline: how often a machine snapshot was captured, and how + * often a later eye of the same pair reused it instead of re-capturing. */ + uint64_t checkpoint_saves; /* snapshots captured */ + uint64_t checkpoint_reuses; /* eyes that reused the pair baseline */ + uint64_t checkpoint_ram_bytes; /* RAM bytes copied by those captures */ } RenderStereoStats; void render_stereo_get_stats(RenderStereoStats *out); int render_pass_netplay_enabled(void); diff --git a/runtime/include/stereo_shed_policy.h b/runtime/include/stereo_shed_policy.h new file mode 100644 index 00000000..82c66d55 --- /dev/null +++ b/runtime/include/stereo_shed_policy.h @@ -0,0 +1,95 @@ +#ifndef PSX_STEREO_SHED_POLICY_H +#define PSX_STEREO_SHED_POLICY_H + +/* Whole-pair stereo shedding policy, extracted so it can be reasoned about and + * tested without a GPU, a headset or a guest. + * + * WHY THIS EXISTS + * --------------- + * The original rule was `avg_pair_ms > budget_ms && ++retry < 30` — an + * attempt counter with no notion of time. One short overload latched the + * average above budget and then silently suppressed the next 29 pair requests: + * a single heavy frame produced a long run of stale frames. There was also no + * way to say "stop shedding and just measure", and no bound on how long + * suppression could last. + * + * THIS POLICY + * ----------- + * * Enters shedding when the pair-time EMA is over budget, and leaves it only + * when a fresh measurement is clearly under `hysteresis * budget` (so noise + * at the boundary does not flap). + * * While shedding, suppression lasts at most `probe_ms` of wall clock and at + * most `max_streak` consecutive sheds, whichever comes first. Whichever way + * it ends, the next frame is rendered, which takes a fresh measurement. + * A stale average can therefore never suppress indefinitely: recovery is + * bounded in TIME, not in attempt count. + * * A forced probe is authoritative: it REPLACES the running estimate rather + * than blending into it. That is what makes a one-off spike cheap — one + * good probe ends the streak, instead of the EMA having to decay over + * several probes while the headset keeps showing stale pairs. + * * `enabled = 0` disables shedding entirely (baseline diagnostic: measure + * what happens with no suppression at all). + * + * The policy decides only whether to skip a pair. It never decides which eyes + * to publish; the caller keeps whole-pair publish (a stale frame retains the + * previous complete pair, never an unmatched eye). + * + * All time is passed in by the caller (host monotonic ms), so there is no clock + * dependency here and the interesting cases are unit-testable. + */ + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct StereoShedPolicy { + /* configuration */ + int enabled; /* 0 = never shed (diagnostic) */ + double budget_ms; /* per-pair budget; set each frame from the cadence */ + double probe_ms; /* bound on one shedding streak (wall clock) */ + uint32_t max_streak; /* bound on one shedding streak (count) */ + double hysteresis; /* resume threshold as a fraction of budget (0..1) */ + /* state */ + double avg_ms; /* EMA of measured pair time */ + int shedding; /* currently suppressing */ + int probing; /* the last decision forced a probe; the next + * measurement is authoritative */ + uint32_t streak; /* consecutive sheds in this streak */ + uint64_t streak_start_ms;/* when this streak began */ + uint64_t last_pair_ms; /* when the last pair was published */ + int have_pair; + uint64_t pairs_seen; /* measurements folded into the average */ + uint64_t shed_total; /* pairs skipped since reset */ +} StereoShedPolicy; + +void stereo_shed_policy_init(StereoShedPolicy *p); +/* Reads PSX_STEREO_SHED (0 disables), PSX_STEREO_SHED_PROBE_MS, + * PSX_STEREO_SHED_MAX_STREAK and PSX_STEREO_SHED_HYSTERESIS, clamping each to a + * safe range. Called once per session. */ +void stereo_shed_policy_configure_from_env(StereoShedPolicy *p); +void stereo_shed_policy_set_budget(StereoShedPolicy *p, double budget_ms); +/* Clears the timing estimate and streak but keeps configuration. Call on a + * scene/mode transition: a new scene must not inherit the previous one's + * estimate, and a cadence change invalidates the budget. */ +void stereo_shed_policy_reset(StereoShedPolicy *p); + +/* 1 = skip this frame's pair, 0 = render it. */ +int stereo_shed_policy_should_shed(StereoShedPolicy *p, uint64_t now_ms); +/* Fold a measured pair attempt into the estimate. `ok` = a complete pair was + * published (so the retained pair is now fresh). */ +void stereo_shed_policy_note_pair(StereoShedPolicy *p, double pair_ms, + uint64_t now_ms, int ok); + +uint32_t stereo_shed_policy_streak_sheds(const StereoShedPolicy *p); +uint32_t stereo_shed_policy_streak_ms(const StereoShedPolicy *p, uint64_t now_ms); +double stereo_shed_policy_avg_ms(const StereoShedPolicy *p); +/* Age of the retained pair, or 0 when no pair has been published yet. */ +uint64_t stereo_shed_policy_pair_age_ms(const StereoShedPolicy *p, uint64_t now_ms); + +#ifdef __cplusplus +} +#endif + +#endif /* PSX_STEREO_SHED_POLICY_H */ diff --git a/runtime/include/xr_origin_diagnostics.h b/runtime/include/xr_origin_diagnostics.h new file mode 100644 index 00000000..aae8eaee --- /dev/null +++ b/runtime/include/xr_origin_diagnostics.h @@ -0,0 +1,28 @@ +#pragma once + +/* Observe an inverted relative head pose for diagnostics only. A sustained + * roll is valid user motion and must never trigger an automatic recenter. */ +static inline unsigned xr_origin_observe_inversion(unsigned streak, int valid, + const double origin[4], + const double pose[4], + double *up_dot) { + if (!valid) { + if (up_dot) *up_dot = 1.0; + return 0; + } + double rx = origin[3]*pose[0] - origin[0]*pose[3] - + origin[1]*pose[2] + origin[2]*pose[1]; + double rz = origin[3]*pose[2] - origin[0]*pose[1] + + origin[1]*pose[0] - origin[2]*pose[3]; + double up = 1.0 - 2.0*(rx*rx + rz*rz); + if (up_dot) *up_dot = up; + if (up >= -0.5) return 0; + return streak < 10u ? streak + 1u : streak; +} + +/* Only the runtime's reference-space change event authorizes an origin reset. */ +static inline int xr_origin_reference_reset_due(int pending, + long long change_time, + long long now) { + return pending && now >= change_time; +} diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index f84eab05..bfaa49cb 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -486,6 +486,8 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/starvation_ring.c ${PSXRECOMP_ROOT}/runtime/src/guest_cycle_profile.c ${PSXRECOMP_ROOT}/runtime/src/latency_ring.c + ${PSXRECOMP_ROOT}/runtime/src/frame_trace.c + ${PSXRECOMP_ROOT}/runtime/src/stereo_shed_policy.c ${PSXRECOMP_ROOT}/runtime/src/present_image_ring.c ${PSXRECOMP_ROOT}/runtime/src/gpu_timeline.c ${PSXRECOMP_ROOT}/runtime/src/data_shards.c @@ -907,6 +909,13 @@ if(NOT PSXRECOMP_SKIP_BIOS_STALE_CHECK AND _psxrt_bios_linked) else() set(_psxrt_stale_sev WARNING) endif() + if(_psxrt_chk_stem MATCHES "^SCPH") + set(_psxrt_stale_publish + "Retail dump/generated outputs stay local and gitignored; do not commit them.") + else() + set(_psxrt_stale_publish + "Commit the regenerated ${_psxrt_chk_stem}_*.c and .emitter.sha together.") + endif() message(${_psxrt_stale_sev} "BIOS generated/${_psxrt_chk_stem}_*.c is STALE vs the recompiler " "emitter (fingerprint mismatch).\n" @@ -915,7 +924,7 @@ if(NOT PSXRECOMP_SKIP_BIOS_STALE_CHECK AND _psxrt_bios_linked) " The committed C may not match the current emitter source, " "seeds or profile.\n" " Fix: tools/regen_bios.sh --config bios/${_psxrt_chk_stem}.toml " - "and commit generated/${_psxrt_chk_stem}_*.c + .emitter.sha\n" + "\n ${_psxrt_stale_publish}\n" " (Suppress locally: -DPSXRECOMP_SKIP_BIOS_STALE_CHECK=ON)") endif() endif() @@ -1446,6 +1455,18 @@ function(_psxrt_stage_mod_catalog target preloaded_dir) endif() endfunction() +# Memory-card slots a title uses (MEMCARD_SLOTS, default 2). 1 for games that +# only talk to port 1: the launcher hides slot 2 and the runtime leaves it empty. +function(psxrt_resolve_memcard_slots out_var value) + if(value STREQUAL "") + set(value 2) + endif() + if(NOT "${value}" MATCHES "^[12]$") + message(FATAL_ERROR "MEMCARD_SLOTS must be 1 or 2 (got ${value})") + endif() + set(${out_var} ${value} PARENT_SCOPE) +endfunction() + function(psxrecomp_add_runtime_target target) # 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 @@ -1472,6 +1493,7 @@ function(psxrecomp_add_runtime_target target) EXE_NAME GAME_VERSION MAX_PLAYERS + MEMCARD_SLOTS APP_ICON # The title's own mod catalog source directory -- the one shaped like # /mods/preloaded, holding packages///manifest.toml @@ -2003,6 +2025,8 @@ function(psxrecomp_add_runtime_target target) endif() message(STATUS "psxrecomp ${target}: PSX_MAX_PLAYERS=${PSXRT_MAX_PLAYERS}") + psxrt_resolve_memcard_slots(PSXRT_MEMCARD_SLOTS "${PSXRT_MEMCARD_SLOTS}") + target_compile_definitions(${target} PRIVATE DEFAULT_DEBUG_PORT=${PSXRT_DEBUG_PORT} PSX_DEFAULT_BIOS_PATH="${PSXRT_DEFAULT_BIOS_PATH}" @@ -2018,6 +2042,7 @@ function(psxrecomp_add_runtime_target target) PSX_DEFAULT_GAME_CONFIG_PATH="${PSXRT_DEFAULT_GAME_CONFIG_PATH}" PSX_WINDOW_TITLE="${PSXRT_WINDOW_TITLE}" PSX_MAX_PLAYERS=${PSXRT_MAX_PLAYERS} + PSX_MEMCARD_SLOTS=${PSXRT_MEMCARD_SLOTS} FMT_HEADER_ONLY=1 $<$:NOMINMAX> $<$:PSX_SDL3=1> @@ -2623,6 +2648,7 @@ endfunction() # LAUNCHER_BOXART "${CMAKE_CURRENT_SOURCE_DIR}/launcher_assets/img/boxart.tga" # APP_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/psxrecomp.ico" # MAX_PLAYERS 2 +# MEMCARD_SLOTS 1 # optional; title only uses memory card port 1 # ENABLE_NETPLAY_IF_PRESENT # ENABLE_SETUP_WIZARD # PRELOADED_MODS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/mods/preloaded" diff --git a/runtime/src/autocompile.c b/runtime/src/autocompile.c index 22842246..3300923f 100644 --- a/runtime/src/autocompile.c +++ b/runtime/src/autocompile.c @@ -46,6 +46,9 @@ static char s_cwd[512]; * the loader's READ location, regardless of what the configured command says. */ static char s_cache_dir[512]; static char s_captures[512]; +#ifdef _WIN32 +#include "autocompile_child_env.h" +#endif enum { AC_IDLE = 0, AC_RUNNING = 1, AC_DONE = 2 }; #ifdef _WIN32 @@ -1191,10 +1194,18 @@ static int autocompile_request_impl(void) { /* Compilation is opportunistic: it must never outrank the interpreter that * keeps the current frame alive. cmd -> python -> gcc inherit below-normal * priority, while the live compile script also defaults to one worker. */ + char *child_env = s_cache_dir[0] ? ac_child_environment(s_cache_dir) : NULL; + if (s_cache_dir[0] && !child_env) { + if (job) CloseHandle(job); + CloseHandle(rd); CloseHandle(wr); + autocompile_note_failure(); + return 0; + } BOOL ok = CreateProcessA(NULL, full, NULL, NULL, TRUE, CREATE_NO_WINDOW | BELOW_NORMAL_PRIORITY_CLASS | CREATE_SUSPENDED, - NULL, s_cwd[0] ? s_cwd : NULL, &si, &pi); + child_env, s_cwd[0] ? s_cwd : NULL, &si, &pi); + free(child_env); CloseHandle(wr); if (!ok) { if (job) CloseHandle(job); diff --git a/runtime/src/autocompile_child_env.h b/runtime/src/autocompile_child_env.h new file mode 100644 index 00000000..3e0a7087 --- /dev/null +++ b/runtime/src/autocompile_child_env.h @@ -0,0 +1,53 @@ +/* Windows compiler scratch is private to the child, never the game process. */ +#ifndef PSX_AUTOCOMPILE_CHILD_ENV_H +#define PSX_AUTOCOMPILE_CHILD_ENV_H +#include +#include +#include +#include +static int ac_env_compare(const void *a, const void *b) { + return _stricmp(*(const char *const *)a, *(const char *const *)b); +} +/* cache is the canonical existing cache directory. Caller frees the returned + * sorted ANSI environment block after CreateProcessA. NULL is a hard failure; + * do not silently inherit a system-drive TEMP after a scratch setup failure. */ +static char *ac_child_environment(const char *cache) { + char temp_dir[sizeof(s_cache_dir) + 5]; + char temp_var[sizeof(temp_dir) + 6], tmp_var[sizeof(temp_dir) + 5]; + int n = snprintf(temp_dir, sizeof(temp_dir), "%s\\tmp", cache); + if (n < 0 || (size_t)n >= sizeof(temp_dir)) return NULL; + if (!CreateDirectoryA(temp_dir, NULL) && GetLastError() != ERROR_ALREADY_EXISTS) + return NULL; + DWORD attr = GetFileAttributesA(temp_dir); + if (attr == INVALID_FILE_ATTRIBUTES || !(attr & FILE_ATTRIBUTE_DIRECTORY)) return NULL; + snprintf(temp_var, sizeof(temp_var), "TEMP=%s", temp_dir); + snprintf(tmp_var, sizeof(tmp_var), "TMP=%s", temp_dir); + char *inherited = GetEnvironmentStringsA(); + if (!inherited) return NULL; + size_t count = 0; + for (char *p = inherited; *p; p += strlen(p) + 1) ++count; + const char **entries = (const char **)malloc((count + 2) * sizeof(*entries)); + if (!entries) { FreeEnvironmentStringsA(inherited); return NULL; } + size_t used = 0, bytes = 1; + for (char *p = inherited; *p; p += strlen(p) + 1) { + if (!_strnicmp(p, "TEMP=", 5) || !_strnicmp(p, "TMP=", 4)) continue; + entries[used++] = p; + } + entries[used++] = temp_var; + entries[used++] = tmp_var; + qsort(entries, used, sizeof(*entries), ac_env_compare); + for (size_t i = 0; i < used; ++i) bytes += strlen(entries[i]) + 1; + char *block = (char *)malloc(bytes); + if (block) { + char *out = block; + for (size_t i = 0; i < used; ++i) { + size_t length = strlen(entries[i]) + 1; + memcpy(out, entries[i], length); out += length; + } + *out = 0; + } + free(entries); + FreeEnvironmentStringsA(inherited); + return block; +} +#endif diff --git a/runtime/src/cdrom.c b/runtime/src/cdrom.c index d667ba3f..1d104243 100644 --- a/runtime/src/cdrom.c +++ b/runtime/src/cdrom.c @@ -1220,8 +1220,9 @@ static void xa_reset_decode(void) { } /* CD-audio volume matrix ([data_source][output_port], 0x80 = unity) applied - * to every decoded XA/CD-DA sample, exactly as the CD controller does on - * real hardware (Beetle PS_CDC::ApplyVolume, cdc.cpp:524). Written via the + * to every decoded XA/CD-DA sample, as the CD controller does on real + * hardware (PSX-SPX cdromdrive, the ATV0..ATV3 volume registers and + * ADPCTL). Written via the * index-2/3 register banks and latched by the "apply changes" bit; games * drive it CONSTANTLY — X5 fades music between scenes with it (measured * 0x7E steady, ramping to 0x00 at transitions on the Beetle oracle). Not @@ -2902,8 +2903,9 @@ uint32_t cdrom_read(uint32_t addr) { case 0x1F801800: { uint8_t s = index_reg & 0x03; /* Bit 2 is ADPBUSY (XA-ADPCM playback in progress), NOT "ADPCM - * empty" — it must idle at 0. The oracle (beetle cdc.cpp, Read - * A==0) never raises it. Raising it permanently made every BIOS + * empty" — it must idle at 0 (PSX-SPX cdromdrive, HSTS: "ADPBUSY + * ADPCM busy (R, 1=playing XA-ADPCM)"; the Beetle reference never + * raises it either). Raising it permanently made every BIOS * status poll see "XA busy" and steered the kernel CD driver * init down a different branch from real hardware. */ if (param_count == 0) s |= (1 << 3); @@ -2981,9 +2983,9 @@ void cdrom_write(uint32_t addr, uint32_t value) { if (index_reg == 2) { cd_pending_vol[0][1] = val; /* Left-CD -> Right-SPU */ } else if (index_reg == 3) { - /* Apply-changes latch (bit 5) copies pending -> live, exactly - * like Beetle cdc.cpp case 0x0B. (ADPMute bit 0 is not modeled - * by the Beetle oracle either.) */ + /* Apply-changes latch (bit 5) copies pending -> live (PSX-SPX + * cdromdrive, ADPCTL bit 5 CHNGATV). (ADPMUTE, bit 0, is not + * modeled; the Beetle reference does not model it either.) */ if (val & 0x20) { memcpy(cd_decode_vol, cd_pending_vol, sizeof(cd_decode_vol)); } diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 31f1b0bd..77e5e99d 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -66,6 +66,7 @@ #include "crash_trace.h" #include "gpu_gl_renderer.h" #include "render_pass.h" +#include "frame_trace.h" #include "mod_plugins.h" /* psx_mod_render_pass_status */ #include "lockstep.h" #include "guest_tty.h" @@ -8543,6 +8544,26 @@ static void handle_host_launch_timings(int id, const char *json) free(events); } +/* mod_cmd: {"cmd":"mod_cmd","name":"...","args":"..."} runs a plugin's named + * debug command (psx_mod_register_command) and returns its text reply. */ +static void handle_mod_cmd(int id, const char *json) +{ + extern int psx_mod_run_command(const char *name, const char *args, char *reply, int cap); + char name[64], args[1024], reply[1024], esc[2048]; + if (!json_get_str(json, "name", name, sizeof name)) { send_err(id, "name required"); return; } + if (!json_get_str(json, "args", args, sizeof args)) args[0] = '\0'; + const int r = psx_mod_run_command(name, args, reply, (int)sizeof reply); + if (r == -1) { send_err(id, "unknown mod command"); return; } + size_t e = 0; + for (const char *p = reply; *p && e + 3 < sizeof esc; p++) { + if (*p == '"' || *p == '\\') esc[e++] = '\\'; + if ((unsigned char)*p >= 0x20) esc[e++] = *p; + else if (*p == '\n') { esc[e++] = '\\'; esc[e++] = 'n'; } + } + esc[e] = '\0'; + send_fmt("{\"id\":%d,\"ok\":true,\"result\":%d,\"reply\":\"%s\"}\n", id, r, esc); +} + static void handle_mod_counters(int id, const char *json) { extern int psx_mod_counters_snapshot(const char **names, uint64_t *counts, @@ -9067,7 +9088,8 @@ static void handle_openxr_stats(int id, const char *json) { "\"frame_open\":%u,\"result\":%d,\"stage\":\"%s\",\"runtime\":\"%s\"," "\"last_failure\":\"%s\",\"last_failure_result\":%d,\"view_flags\":%llu," "\"waits\":%llu,\"submitted\":%llu,\"empty\":%llu,\"failures\":%llu," - "\"predicted_time\":%llu,\"ipd_m\":%.6f,\"units_per_meter\":%.6f," + "\"predicted_time\":%llu,\"ipd_m\":%.6f,\"origin_up_dot\":%.6f," + "\"origin_inversion_streak\":%u,\"origin_ambiguous\":%u,\"units_per_meter\":%.6f," "\"gl_version\":%llu,\"min_gl_version\":%llu,\"max_gl_version\":%llu,\"swapchain_format\":%lld," "\"submitted_layer\":%u,\"quad_submitted\":%llu," "\"submitted_source\":%u,\"native_submitted\":%llu,\"submitted_native_frame\":%llu," @@ -9077,7 +9099,8 @@ static void handle_openxr_stats(int id, const char *json) { s.stage?s.stage:"off",s.runtime,s.last_failure?s.last_failure:"",s.last_failure_result, (unsigned long long)s.view_flags,(unsigned long long)s.waits, (unsigned long long)s.submitted,(unsigned long long)s.empty, - (unsigned long long)s.failures,(unsigned long long)s.predicted_time,s.ipd_m,s.units_per_meter, + (unsigned long long)s.failures,(unsigned long long)s.predicted_time,s.ipd_m, + s.origin_up_dot,s.origin_inversion_streak,s.origin_ambiguous,s.units_per_meter, (unsigned long long)s.gl_version,(unsigned long long)s.min_gl_version, (unsigned long long)s.max_gl_version,(long long)s.swapchain_format,s.submitted_layer, (unsigned long long)s.quad_submitted,s.submitted_source, @@ -9197,6 +9220,32 @@ static void handle_openxr_hands_override(int id,const char *json) { /* Debug-only synthetic action sample; axes are signed thousandths [-1000,1000]. * Explicitly tagged so desktop controls cannot be mistaken for device evidence. */ #ifndef PSX_NO_DEBUG_TOOLS +/* openxr_pose_override: test-only synthetic head motion in millimetres, with an optional pitch_deg + * look angle, for headless head-motion checks. clear=1 disarms. Each call is absolute. The pitch is + * what reaches the defects that only appear when looking down, which no translation can show. */ +static void handle_openxr_pose_override(int id,const char *json) { + extern int psx_openxr_pose_override(double,double,double); + extern int psx_openxr_pose_pitch_override(double); + extern void psx_openxr_pose_clear(void); + if(json_get_int(json,"clear",0)){ psx_openxr_pose_clear(); send_fmt("{\"id\":%d,\"ok\":true,\"cleared\":1}",id); return; } + int x=json_get_int(json,"x_mm",0),y=json_get_int(json,"y_mm",0),z=json_get_int(json,"z_mm",0); + double pitch_deg=(double)json_get_int(json,"pitch_deg",0) + + (double)json_get_int(json,"pitch_mdeg",0)/1000.0; + /* Validate the complete absolute request before changing either override. */ + if(x < -10000 || x > 10000 || y < -10000 || y > 10000 || + z < -10000 || z > 10000 || pitch_deg < -90.0 || pitch_deg > 90.0) { + send_err(id,"pose must be within +/-10 m and pitch within +/-90 degrees");return; + } + + if(!psx_openxr_pose_override((double)x/1000.0,(double)y/1000.0,(double)z/1000.0)){ + send_err(id,"pose offset must be finite and within +/-10 m");return; + } + if(!psx_openxr_pose_pitch_override((double)pitch_deg)){ + send_err(id,"pitch_deg must be finite and within +/-90");return; + } + send_fmt("{\"id\":%d,\"ok\":true,\"x_mm\":%d,\"y_mm\":%d,\"z_mm\":%d,\"pitch_deg\":%.6f}",id,x,y,z,pitch_deg); +} + static void handle_openxr_input_override(int id,const char *json) { if(json_get_int(json,"clear",0)) psx_openxr_input_override(NULL); else { @@ -9271,6 +9320,12 @@ static void handle_stereo_stats(int id, const char *json) { "\"last_eye\":%d,\"last_failure\":\"%s\"," "\"last_failed_attempt\":%llu,\"last_failed_eye\":%d," "\"last_failed_reason\":\"%s\",\"retained_pair_id\":%llu," + "\"shedding\":%d,\"shed_streak\":%llu,\"shed_streak_ms\":%u," + "\"shed_streak_max\":%llu,\"stale_frames\":%llu,\"budget_resets\":%llu," + "\"budget_ms\":%.3f,\"budget_source\":%u," + "\"checkpoint_saves\":%llu,\"checkpoint_reuses\":%llu," + "\"checkpoint_ram_bytes\":%llu," + "\"last_pair_age_ms\":%llu," "\"last_pair_ms\":%.3f,\"avg_pair_ms\":%.3f," "\"published\":{\"valid\":%u,\"pair_id\":%llu,\"guest_cycle\":%llu," "\"width\":%u,\"height\":%u,\"staged_mask\":%u,\"mode\":%u,\"presents\":%llu},\"captures\":%s}", @@ -9286,6 +9341,13 @@ static void handle_stereo_stats(int id, const char *json) { (unsigned long long)s.last_failed_attempt, s.last_failed_eye, s.last_failed_reason ? s.last_failed_reason : "", (unsigned long long)s.retained_pair_id, + s.shedding, (unsigned long long)s.shed_streak, s.shed_streak_ms, + (unsigned long long)s.shed_streak_max, (unsigned long long)s.stale_frames, + (unsigned long long)s.budget_resets, s.budget_ms, s.budget_source, + (unsigned long long)s.checkpoint_saves, + (unsigned long long)s.checkpoint_reuses, + (unsigned long long)s.checkpoint_ram_bytes, + (unsigned long long)s.last_pair_age_ms, s.last_pair_ms, s.avg_pair_ms, g.valid, (unsigned long long)g.pair_id, (unsigned long long)g.guest_cycle, g.width, g.height, g.staged_mask, g.mode, (unsigned long long)g.presents, captures); @@ -9312,6 +9374,34 @@ static void handle_render_pass_dump(int id, const char *json) send_fmt("{\"id\":%d,\"ok\":true,\"count\":%d}", id, count); } +/* frame_trace [format=json|chrome] [max=]: the opt-in stereo/OpenXR frame + * trace (frame_trace.c). Capturing requires PSX_FRAME_TRACE=1 at launch; this + * command only reads the ring and never runs in the frame path. `max` bounds the + * newest records returned (default 240). Both formats are JSON objects, so the + * reply embeds the export verbatim. */ +static void handle_frame_trace(int id, const char *json) { + char format[16] = "json"; + int max = json_get_int(json, "max", 240); + (void)json_get_str(json, "format", format, sizeof format); + if (max < 0) max = 0; + int chrome = strcmp(format, "chrome") == 0; + int need = chrome ? psx_frame_trace_to_chrome_json(NULL, 0, (uint32_t)max) + : psx_frame_trace_to_json(NULL, 0, (uint32_t)max); + if (need <= 0) { send_err(id, "frame trace export failed"); return; } + char *buf = (char *)malloc((size_t)need + 1); + if (!buf) { send_err(id, "out of memory"); return; } + if (chrome) (void)psx_frame_trace_to_chrome_json(buf, need + 1, (uint32_t)max); + else (void)psx_frame_trace_to_json(buf, need + 1, (uint32_t)max); + send_fmt("{\"id\":%d,\"ok\":true,\"enabled\":%d,\"capacity\":%u," + "\"count\":%llu,\"dropped\":%llu,\"missing\":%llu," + "\"format\":\"%s\",\"max\":%d,\"trace\":%s}", + id, psx_frame_trace_enabled(), psx_frame_trace_capacity(), + (unsigned long long)psx_frame_trace_committed(), + (unsigned long long)psx_frame_trace_dropped(), + (unsigned long long)psx_frame_trace_missing(), format, max, buf); + free(buf); +} + /* gl_wide_fast on=<0|1>: native-wide centre-blit fast path. 1 (default) = skip * the redundant centre mirror and copy the canonical 4:3 frame into the wide * surface centre at present (fast). 0 = re-rasterize the whole wide surface @@ -16168,6 +16258,7 @@ static const CmdEntry s_commands[] = { { "ws_hud_mode", handle_ws_hud_mode }, { "ws_tag_stats", handle_ws_tag_stats }, { "mod_counters", handle_mod_counters }, + { "mod_cmd", handle_mod_cmd }, { "host_launch_timings", handle_host_launch_timings }, { "resident_status", handle_resident_status }, { "netplay_status", handle_netplay_status }, @@ -16231,9 +16322,11 @@ static const CmdEntry s_commands[] = { #endif { "openxr_hands", handle_openxr_hands }, #ifndef PSX_NO_DEBUG_TOOLS - { "openxr_hands_override", handle_openxr_hands_override }, + { "openxr_hands_override", handle_openxr_hands_override }, + { "openxr_pose_override", handle_openxr_pose_override }, #endif { "stereo_stats", handle_stereo_stats }, + { "frame_trace", handle_frame_trace }, { "stereo_dump", handle_stereo_dump }, { "render_pass_dump", handle_render_pass_dump }, { "render_pass_refuse", handle_render_pass_refuse }, diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index afe81bf5..0d8856fa 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -1973,8 +1973,11 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, { uint32_t vanilla = (cpu->gpr[rs] < cpu->gpr[rt]) ? 1u : 0u; uint32_t kept = vanilla; - if (!psx_ws_cull_scale_site(pc, insn, cpu->gpr[rs], cpu->gpr[rt], &kept)) - (void)psx_ws_cull_keep_site(pc, insn, vanilla, &kept); + /* Same precedence as the code generator: scale and keep sites first. */ + if (!psx_ws_cull_scale_site(pc, insn, cpu->gpr[rs], cpu->gpr[rt], &kept) && + !psx_ws_cull_keep_site(pc, insn, vanilla, &kept) && + psx_ws_is_cull_screen_x_site(pc)) + kept = (uint32_t)psx_ws_cull_sltu_band(cpu->gpr[rs], cpu->gpr[rt]); cpu->gpr[rd] = kept; psx_pgxp_alu(cpu, insn, kept, 0, 0); cpu->gpr[0] = 0; @@ -2092,8 +2095,11 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, /* Widescreen render-funnel LEFT-edge widen (auto_screen_x): a * classified funnel bltz rejects only past the revealed margin. * Identity at 4:3 (margin 0). Gated per-game, cheap cached scan. */ - if (psx_ws_is_cull_bltz_site(pc) || - (psx_ws_auto_cull_on() && ws_cull_bltz_site(pc))) + if (psx_ws_is_cull_bltz_site(pc)) + taken = psx_ws_cull_bltz(cpu->gpr[rs]); + else if (psx_ws_is_cull_screen_x_site(pc)) + taken = psx_ws_cull_bltz_hi(cpu->gpr[rs]); + else if (psx_ws_auto_cull_on() && ws_cull_bltz_site(pc)) taken = psx_ws_cull_bltz(cpu->gpr[rs]); else taken = ((int32_t)cpu->gpr[rs] < 0); @@ -2200,6 +2206,8 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, ((uint32_t)simm + 2u * (uint32_t)psx_ws_activation_margin())) ? 1u : 0u; } + else if (psx_ws_is_cull_screen_x_site(pc)) + cpu->gpr[rt] = (uint32_t)psx_ws_cull_sltiu(cpu->gpr[rs], imm); else if (psx_ws_auto_cull_on() && psx_ws_is_cull_w_imm(imm) && ws_cull_site(pc)) cpu->gpr[rt] = (uint32_t)psx_ws_cull_sltiu(cpu->gpr[rs], imm); else @@ -2235,6 +2243,8 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, case 0x0F: /* LUI rt, imm */ if (psx_ws_is_signed_x_bound_site(pc, insn)) cpu->gpr[rt] = (uint32_t)psx_ws_player_x_bound((int32_t)(imm << 16)); + else if (psx_ws_is_cull_screen_x_site(pc)) + cpu->gpr[rt] = psx_ws_cull_lui_hi(imm); else cpu->gpr[rt] = imm << 16; psx_pgxp_alu(cpu, insn, cpu->gpr[rt], 0, 0); @@ -2267,8 +2277,9 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, if (rd == 13) { /* Cause register: only the software-interrupt pending bits * [9:8] are writable; hardware IP [15:10], ExcCode [6:2] and - * BD [31] are read-only from software (psx-spx, matches - * PCSX-Redux/Beetle MTC0). */ + * BD [31] are read-only from software (PSX-SPX + * cpuspecifications, "cop0r13 - CAUSE - (Read-only, except, + * Bit8-9 are R/W)"). */ cpu->cop0[13] = (cpu->cop0[13] & ~0x0300u) | (val & 0x0300u); } else { cpu->cop0[rd] = val; diff --git a/runtime/src/dma.c b/runtime/src/dma.c index b60d8add..5bf713a3 100644 --- a/runtime/src/dma.c +++ b/runtime/src/dma.c @@ -9,7 +9,7 @@ * request-mode transfers are advanced from the guest cycle clock so games * which synchronize video decode through DMA busy/request state see realistic * backpressure. - * Reference: nocash PSX specs, DuckStation src/core/dma.cpp + * Reference: PSX-SPX, page dmachannels (DMA Channels). */ #include "dma.h" diff --git a/runtime/src/duckstation_texture_pack.cpp b/runtime/src/duckstation_texture_pack.cpp index 12c5e730..e577638f 100644 --- a/runtime/src/duckstation_texture_pack.cpp +++ b/runtime/src/duckstation_texture_pack.cpp @@ -589,14 +589,16 @@ bool finalize_source(DuckTexturePack& p, Source& source, bool keep_observations } return success; } -bool has_residency(const DuckTexturePack& p, const Source* source) { - for (const auto& upload : p.uploads) if (upload.source.get() == source) return true; - for (const auto& page : p.pages) if (page.source.get() == source) return true; - return false; -} void retire_unused(DuckTexturePack& p) { + /* One pass over the residency records, then one over the sources: this + * runs on every VRAM write, and a per-source scan of up to kMaxUploads + * records each cost ~140 ms a frame after a course load filled them. */ + std::unordered_set resident; + resident.reserve(p.uploads.size() + p.pages.size()); + for (const auto& upload : p.uploads) resident.insert(upload.source.get()); + for (const auto& page : p.pages) resident.insert(page.source.get()); for (auto it = p.sources.begin(); it != p.sources.end();) { - if (has_residency(p,it->get())) { ++it; continue; } + if (resident.count(it->get())) { ++it; continue; } if (!finalize_source(p,**it)) { /* Keep failed pending records bounded and visible to the caller. */ p.lifecycle_error = p.lifecycle_error.empty() ? "texture dump could not be finalized" : p.lifecycle_error; @@ -826,11 +828,21 @@ void duck_texture_pack_invalidate(DuckTexturePack* p, uint16_t x, uint16_t y, ui const std::array regions{{{px,py,w0,h0},{0,py,unsigned(w)-w0,h0}, {px,0,w0,unsigned(h)-h0},{0,0,unsigned(w)-w0,unsigned(h)-h0}}}; ++p->revision; + bool released = false; /* a residency record dropped or split */ for (const auto& r : regions) if (r.w && r.h) { + /* Most writes (render targets, CLUT animation) touch no tracked + * upload: skip rebuilding the list then. */ + const bool upload_hit = std::any_of(p->uploads.begin(), p->uploads.end(), + [&](const Upload& u) { const Rect c = intersection(u.active, r); return c.w && c.h; }); + if (upload_hit) { std::vector survivors; survivors.reserve(p->uploads.size()); for (const auto& upload : p->uploads) { const Rect cut = intersection(upload.active,r); - if (!cut.w || !cut.h) { if (survivors.size() < kMaxUploads) survivors.push_back(upload); continue; } + if (!cut.w || !cut.h) { + if (survivors.size() < kMaxUploads) survivors.push_back(upload); else released = true; + continue; + } + released = true; if (upload.splits >= p->config.max_vram_write_splits || contains(r,upload.active)) continue; const Rect a = upload.active; const Rect fragments[] = { @@ -842,12 +854,15 @@ void duck_texture_pack_invalidate(DuckTexturePack* p, uint16_t x, uint16_t y, ui survivors.push_back({upload.source,fragment,upload.splits+1}); } p->uploads.swap(survivors); + } + const size_t pages_before = p->pages.size(); p->pages.erase(std::remove_if(p->pages.begin(),p->pages.end(),[&](const PageSource& page) { if (intersects(page.source->rect,r)) return true; for (const auto& record : page.source->records) if (record.depth < 2 && intersects({record.clut_x,record.clut_y,record.palette_size,1},r)) return true; return false; }),p->pages.end()); + released = released || p->pages.size() != pages_before; p->rect_cache.erase(std::remove_if(p->rect_cache.begin(), p->rect_cache.end(), [&](const RectHash& v){ return intersects(v.rect,r); }), p->rect_cache.end()); p->match_cache.erase(std::remove_if(p->match_cache.begin(), p->match_cache.end(), [&](const MatchCache& c) { const auto& q = c.query; @@ -856,7 +871,8 @@ void duck_texture_pack_invalidate(DuckTexturePack* p, uint16_t x, uint16_t y, ui return intersects(page,r) || (q.depth < 2 && intersects(clut,r)); }), p->match_cache.end()); } - retire_unused(*p); + /* Only a dropped record can leave a source unreferenced. */ + if (released) retire_unused(*p); } catch (...) { /* Allocation/finalization failure must never preserve stale residency * after a native write. Pending immutable observations remain owned. */ diff --git a/runtime/src/frame_gen.c b/runtime/src/frame_gen.c index 5c4b1580..db265d36 100644 --- a/runtime/src/frame_gen.c +++ b/runtime/src/frame_gen.c @@ -923,3 +923,23 @@ int fg_view_affine(const FgCamFit *fit, int vi, double t, float A[9], float b[3] b[r] = (float)(t * v->t[r] - (Am[3 * r] * v->t[0] + Am[3 * r + 1] * v->t[1] + Am[3 * r + 2] * v->t[2])); return 1; } + +/* The older frame's camera at phase t: P_t = Rt P_old + t T (the fit's + * rotation interpolated from identity, its translation scaled), so the + * older frame's own pixels can be warped to the in-between camera too. */ +int fg_view_affine_older(const FgCamFit *fit, int vi, double t, float A[9], float b[3]) { + if (!fit || vi < 0 || (uint32_t)vi >= fit->nviews) return 0; + const FgView *v = &fit->v[vi]; + const double w = fmin(1.0, fabs(v->q[0])); + const double ang = 2.0 * acos(w), sn = sqrt(fmax(0.0, 1.0 - w * w)); + double qt[4] = { 1, 0, 0, 0 }, Rt[9]; + if (sn > 1e-9) { + const double sg = v->q[0] < 0 ? -1.0 : 1.0; + qt[0] = cos(0.5 * ang * t); + for (int k = 0; k < 3; k++) qt[k + 1] = sg * v->q[k + 1] / sn * sin(0.5 * ang * t); + } + q_to_m(qt, Rt); + for (int k = 0; k < 9; k++) A[k] = (float)Rt[k]; + for (int r = 0; r < 3; r++) b[r] = (float)(t * v->t[r]); + return 1; +} diff --git a/runtime/src/frame_trace.c b/runtime/src/frame_trace.c new file mode 100644 index 00000000..4567fbff --- /dev/null +++ b/runtime/src/frame_trace.c @@ -0,0 +1,498 @@ +/* frame_trace.c — opt-in per-frame stereo/OpenXR timing ring. See frame_trace.h. + * + * Emulation/main thread only. The record path allocates nothing and writes no + * files; export functions fill a caller buffer and are called outside the frame. + */ + +#include "frame_trace.h" + +#include +#include +#include +#include + +/* Compile-time ring bound. The live capacity is a power of two <= this, chosen + * by PSX_FRAME_TRACE_CAP (default 2048, ~34 s of 60 Hz or ~68 s of 30 Hz pairs). + * Static, so enabling the trace never adds an allocation to the frame path. */ +#define PSX_FRAME_TRACE_MAX 4096 +#define PSX_FRAME_TRACE_DEFAULT 2048 + +static PSXFrameTraceRecord s_ring[PSX_FRAME_TRACE_MAX]; +/* An in-progress frame never overwrites the retained committed window. */ +static PSXFrameTraceRecord s_pending; + +static int s_configured; +static int s_enabled; +static uint32_t s_cap = PSX_FRAME_TRACE_DEFAULT; + +static uint64_t s_opened; /* records opened since reset (== newest seq) */ +static uint64_t s_committed; /* records committed since reset */ +static uint64_t s_missing; /* commits that carried no stage at all */ +static uint64_t s_mono_high; /* last timestamp handed out (monotonic floor) */ + +static int s_have_open; +static int s_have_publish; +static uint64_t s_last_publish_ms; + +/* Session metadata (see psx_frame_trace_session). */ +static char s_runtime[128]; +static uint32_t s_target_period_us; + +static PSXFrameTraceClock s_clock; + +static const char *const s_stage_names[PSX_TRACE_STAGE_COUNT] = { + "guest_sim", "pair_total", + "eye_begin_l", "eye_guest_l", "eye_end_l", "eye_restore_l", + "eye_begin_r", "eye_guest_r", "eye_end_r", "eye_restore_r", + "xr_wait_frame", "xr_begin_frame", "xr_acquire", "xr_wait_image", + "xr_copy", "xr_release", "xr_end_frame", "pose_age", "hud_capture", + "hud_draw", "hud_readback", + "game_env", "game_world", "game_text", "game_traverse", "game_icon", + "game_submit", "game_laser", "game_select" +}; + +static const struct { uint32_t bit; const char *name; } s_flag_names[] = { + { PSX_FRAME_TRACE_STEREO_PUBLISHED, "published" }, + { PSX_FRAME_TRACE_STALE_PAIR, "stale" }, + { PSX_FRAME_TRACE_SHED, "shed" }, + { PSX_FRAME_TRACE_FAILED, "failed" }, + { PSX_FRAME_TRACE_XR_EMPTY, "xr_empty" }, + { PSX_FRAME_TRACE_SCENE_TRANSITION, "scene" }, + { PSX_FRAME_TRACE_IO_EVENT, "io" }, + { PSX_FRAME_TRACE_NO_TELEMETRY, "no_telemetry" }, + { PSX_FRAME_TRACE_BASELINE_REUSED, "baseline_reused" }, + { PSX_FRAME_TRACE_XR_REPEAT, "xr_repeat" } +}; + +const char *psx_frame_trace_stage_name(uint32_t stage) { + return stage < PSX_TRACE_STAGE_COUNT ? s_stage_names[stage] : "?"; +} + +const char *psx_frame_trace_flag_names(uint32_t flags, char *buf, int cap) { + int off = 0; + if (!buf || cap <= 0) return ""; + buf[0] = '\0'; + for (size_t i = 0; i < sizeof s_flag_names / sizeof s_flag_names[0]; ++i) { + if (!(flags & s_flag_names[i].bit)) continue; + off += snprintf(buf + off, (size_t)(cap - off), "%s%s", + off ? "|" : "", s_flag_names[i].name); + if (off >= cap) break; + } + return buf; +} + +static uint32_t next_pow2(uint32_t v) { + uint32_t p = 1; + while (p < v && p < PSX_FRAME_TRACE_MAX) p <<= 1; + return p; +} + +static void configure_from_env(void) { + const char *e = getenv("PSX_FRAME_TRACE"); + s_enabled = (e && e[0] && e[0] != '0') ? 1 : 0; + e = getenv("PSX_FRAME_TRACE_CAP"); + if (e && e[0]) { + long v = strtol(e, NULL, 0); + if (v >= 64) s_cap = next_pow2((uint32_t)v); + } +} + +static void ensure_configured(void) { + if (!s_configured) { + s_configured = 1; + configure_from_env(); + } +} + +void psx_frame_trace_configure_from_env(void) { + s_configured = 1; + configure_from_env(); +} + +void psx_frame_trace_set_clock(PSXFrameTraceClock fn) { s_clock = fn; } + +void psx_frame_trace_set_enabled(int on) { + ensure_configured(); + s_enabled = on ? 1 : 0; +} + +int psx_frame_trace_enabled(void) { + ensure_configured(); + return s_enabled; +} + +void psx_frame_trace_reset(void) { + memset(s_ring, 0, sizeof s_ring); + s_opened = s_committed = s_missing = s_mono_high = 0; + s_have_open = s_have_publish = 0; + s_last_publish_ms = 0; + s_runtime[0] = '\0'; + s_target_period_us = 0; +} + +static PSXFrameTraceRecord *open_record(void) { + return &s_pending; +} + +static uint64_t stamp_now(void) { + uint64_t now = s_clock ? s_clock() : 0; + /* Monotonic floor: a coarse or throttled clock must never make a later + * record look older than the one before it. */ + if (now < s_mono_high) now = s_mono_high; + s_mono_high = now; + return now; +} + +int psx_frame_trace_open(void) { + ensure_configured(); + if (!s_enabled || s_have_open) return 0; + uint64_t seq = s_opened + 1u; + PSXFrameTraceRecord *r = &s_pending; + memset(r, 0, sizeof *r); + r->seq = seq; + r->eye_failed = -1; + r->open_ms = stamp_now(); + s_opened = seq; + s_have_open = 1; + return 1; +} + +void psx_frame_trace_add(uint32_t stage, uint64_t us) { + if (!s_have_open || stage >= PSX_TRACE_STAGE_COUNT) return; + PSXFrameTraceRecord *r = open_record(); + uint64_t v = (uint64_t)r->stages_us[stage] + us; + r->stages_us[stage] = v > 0xFFFFFFFFu ? 0xFFFFFFFFu : (uint32_t)v; +} + +void psx_frame_trace_ids(uint64_t guest_frame, uint64_t guest_cycle) { + if (!s_have_open) return; + PSXFrameTraceRecord *r = open_record(); + r->guest_frame = guest_frame; + r->guest_cycle = guest_cycle; +} + +void psx_frame_trace_attempt(uint64_t attempt_id, uint32_t shed_skipped, + uint32_t shed_streak_ms) { + if (!s_have_open) return; + PSXFrameTraceRecord *r = open_record(); + r->stereo_attempt = attempt_id; + r->shed_skipped = shed_skipped; + r->shed_streak_ms = shed_streak_ms; + if (shed_skipped) r->flags |= PSX_FRAME_TRACE_SHED; +} + +void psx_frame_trace_result(int published, uint64_t pair_id, int eye_failed, + const char *failure) { + if (!s_have_open) return; + PSXFrameTraceRecord *r = open_record(); + r->pair_id = pair_id; + r->eye_failed = eye_failed < 0 ? -1 : eye_failed; + if (published) { + r->flags |= PSX_FRAME_TRACE_STEREO_PUBLISHED; + r->source = 1; + return; + } + r->flags |= PSX_FRAME_TRACE_STALE_PAIR; + if (failure && failure[0]) { + r->flags |= PSX_FRAME_TRACE_FAILED; + snprintf(r->failure, sizeof r->failure, "%s", failure); + } +} + +void psx_frame_trace_source(uint8_t source) { + if (!s_have_open) return; + PSXFrameTraceRecord *r = open_record(); + /* First writer wins: a published stereo pair must not be relabelled + * "native" by a later end-of-frame annotation. */ + if (!r->source) r->source = source; +} + +void psx_frame_trace_draw(int eye, uint32_t prims, uint32_t textured) { + if (!s_have_open || eye < 0 || eye > 1) return; + PSXFrameTraceRecord *r = open_record(); + /* Per-eye fields first: `prims`/`textured_prims` are the legacy pair-level + * view of the same counter and stay the last eye's values, as before. */ + if (eye == 0) { r->prims_l = prims; r->tex_prims_l = textured; } + else { r->prims_r = prims; r->tex_prims_r = textured; } + r->prims = prims; + r->textured_prims = textured; +} + +void psx_frame_trace_eye_cycles(int eye, uint32_t cycles) { + if (!s_have_open || eye < 0 || eye > 1) return; + PSXFrameTraceRecord *r = open_record(); + if (eye == 0) r->eye_cycles_l = cycles; + else r->eye_cycles_r = cycles; +} + +void psx_frame_trace_gpu(uint64_t tex_upload_bytes, uint32_t vram_leaks) { + if (!s_have_open) return; + PSXFrameTraceRecord *r = open_record(); + r->tex_upload_bytes += tex_upload_bytes; + uint64_t leaks = (uint64_t)r->vram_leaks + vram_leaks; + r->vram_leaks = leaks > 0xFFFFFFFFu ? 0xFFFFFFFFu : (uint32_t)leaks; +} + +void psx_frame_trace_xr_cadence(uint32_t display_delta_us, int submit_fresh) { + if (!s_have_open) return; + PSXFrameTraceRecord *r = open_record(); + r->xr_display_delta_us = display_delta_us; + r->xr_submit_fresh = submit_fresh ? 1u : 0u; + if (!submit_fresh) r->flags |= PSX_FRAME_TRACE_XR_REPEAT; +} + +void psx_frame_trace_flag(uint32_t flag) { + if (!s_have_open) return; + open_record()->flags |= flag; +} + +void psx_frame_trace_note(const char *tag) { + if (!s_have_open || !tag) return; + snprintf(open_record()->note, PSX_FRAME_TRACE_NOTE, "%s", tag); +} + +void psx_frame_trace_io(const char *tag) { + if (!s_have_open) return; + PSXFrameTraceRecord *r = open_record(); + r->flags |= PSX_FRAME_TRACE_IO_EVENT; + if (tag) snprintf(r->note, PSX_FRAME_TRACE_NOTE, "%s", tag); +} + +void psx_frame_trace_session(const char *runtime, uint32_t target_period_us) { + if (!s_enabled) return; + if (target_period_us != s_target_period_us) s_target_period_us = target_period_us; + if (runtime && runtime != s_runtime && strcmp(runtime, s_runtime) != 0) + snprintf(s_runtime, sizeof s_runtime, "%s", runtime); +} + +void psx_frame_trace_commit(void) { + if (!s_have_open) return; + PSXFrameTraceRecord *r = open_record(); + int any = 0; + for (uint32_t i = 0; i < PSX_TRACE_STAGE_COUNT; ++i) + if (r->stages_us[i]) { any = 1; break; } + /* A frame that only shed its pair genuinely has no stage: that is "stale", + * not "no telemetry". Only an expected-to-be-instrumented frame that + * recorded nothing at all is missing. */ + if (!any && !(r->flags & PSX_FRAME_TRACE_SHED)) { + s_missing++; + r->flags |= PSX_FRAME_TRACE_NO_TELEMETRY; + } + if (r->flags & PSX_FRAME_TRACE_STEREO_PUBLISHED) { + s_have_publish = 1; + s_last_publish_ms = r->open_ms; + r->pair_age_ms = 0; + } else if (s_have_publish) { + r->pair_age_ms = r->open_ms - s_last_publish_ms; + } + s_ring[(r->seq - 1u) & (uint64_t)(s_cap - 1u)] = *r; + s_committed++; + s_have_open = 0; +} + +uint32_t psx_frame_trace_capacity(void) { return s_cap; } +uint64_t psx_frame_trace_committed(void) { return s_committed; } +uint64_t psx_frame_trace_dropped(void) { + return s_committed > s_cap ? s_committed - s_cap : 0; +} +uint64_t psx_frame_trace_missing(void) { return s_missing; } + +uint32_t psx_frame_trace_read(PSXFrameTraceRecord *out, uint32_t capacity) { + uint32_t n = 0; + if (!out || !capacity || !s_committed) return 0; + uint64_t retained = s_committed < s_cap ? s_committed : s_cap; + uint64_t take = capacity < retained ? capacity : retained; + uint64_t first = s_committed - take + 1u; /* oldest of the newest `take` */ + for (uint64_t seq = first; seq <= s_committed; ++seq) { + const PSXFrameTraceRecord *r = + &s_ring[(seq - 1u) & (uint64_t)(s_cap - 1u)]; + if (r->seq != seq) continue; /* overwritten / not ours */ + out[n++] = *r; + } + return n; +} + +/* ---- export ------------------------------------------------------------- */ + +static int app(char *buf, int cap, int off, const char *fmt, ...) { + va_list ap; + if (off < 0) off = 0; + if (buf && off < cap) { + va_start(ap, fmt); + int n = vsnprintf(buf + off, (size_t)(cap - off), fmt, ap); + va_end(ap); + return n < 0 ? off : off + n; + } + va_start(ap, fmt); + int n = vsnprintf(NULL, 0, fmt, ap); /* measure only */ + va_end(ap); + return n < 0 ? off : off + n; +} + +/* JSON string body with the few escapes a generated tool must not break on. */ +static const char *jstr(const char *s, char *tmp, int cap) { + int o = 0; + if (cap <= 0) return ""; + tmp[0] = '\0'; + for (const unsigned char *p = (const unsigned char *)s; p && *p; ++p) { + const unsigned char c = *p; + const int need = c == '"' || c == '\\' || c == '\n' ? 2 : c < 0x20 ? 6 : 1; + if (need > cap - 1 - o) break; + if (c == '"' || c == '\\') { tmp[o++] = '\\'; tmp[o++] = (char)c; } + else if (c == '\n') { tmp[o++] = '\\'; tmp[o++] = 'n'; } + else if (c < 0x20) o += snprintf(tmp + o, (size_t)(cap - o), "\\u%04x", c); + else tmp[o++] = (char)c; + } + tmp[o] = '\0'; + return tmp; +} + +static int rec_json(char *buf, int cap, int off, const PSXFrameTraceRecord *r) { + char t1[PSX_FRAME_TRACE_REASON * 2 + 8], t2[PSX_FRAME_TRACE_NOTE * 2 + 8]; + off = app(buf, cap, off, + "{\"seq\":%llu,\"open_ms\":%llu,\"guest_frame\":%llu," + "\"guest_cycle\":%llu,\"attempt\":%llu,\"pair_id\":%llu," + "\"pair_age_ms\":%llu,\"source\":%u,\"eye_failed\":%d," + "\"shed\":%u,\"shed_ms\":%u,\"prims\":%u,\"tex_prims\":%u," + "\"prims_l\":%u,\"prims_r\":%u,\"tex_prims_l\":%u," + "\"tex_prims_r\":%u,\"cycles_l\":%u,\"cycles_r\":%u," + "\"vram_leaks\":%u,\"tex_bytes\":%llu,\"flags\":%u," + "\"xr_display_delta_us\":%u,\"xr_submit_fresh\":%u," + "\"failure\":\"%s\",\"note\":\"%s\",\"stages_us\":{", + (unsigned long long)r->seq, (unsigned long long)r->open_ms, + (unsigned long long)r->guest_frame, + (unsigned long long)r->guest_cycle, + (unsigned long long)r->stereo_attempt, + (unsigned long long)r->pair_id, + (unsigned long long)r->pair_age_ms, r->source, r->eye_failed, + r->shed_skipped, r->shed_streak_ms, r->prims, r->textured_prims, + r->prims_l, r->prims_r, r->tex_prims_l, r->tex_prims_r, + r->eye_cycles_l, r->eye_cycles_r, + r->vram_leaks, (unsigned long long)r->tex_upload_bytes, r->flags, + r->xr_display_delta_us, (unsigned)r->xr_submit_fresh, + jstr(r->failure, t1, sizeof t1), jstr(r->note, t2, sizeof t2)); + for (uint32_t s = 0; s < PSX_TRACE_STAGE_COUNT; ++s) + off = app(buf, cap, off, "%s\"%s\":%u", s ? "," : "", + s_stage_names[s], r->stages_us[s]); + return app(buf, cap, off, "}}"); +} + +/* Newest `max_records` (0 = all retained), oldest first. Returns the count and + * the seq of the first record to read; iteration indexes the ring in place so + * no export call touches the stack with a whole-ring copy. */ +static uint32_t export_window(uint32_t max_records, uint64_t *first_seq) { + uint64_t retained = s_committed < s_cap ? s_committed : s_cap; + if (!retained) { *first_seq = 1; return 0; } + uint32_t n = (uint32_t)retained; + uint32_t take = (max_records && max_records < n) ? max_records : n; + *first_seq = s_committed - take + 1u; + return take; +} + +static const PSXFrameTraceRecord *at_seq(uint64_t seq) { + const PSXFrameTraceRecord *r = &s_ring[(seq - 1u) & (uint64_t)(s_cap - 1u)]; + return r->seq == seq ? r : NULL; +} + +int psx_frame_trace_to_json(char *buf, int cap, uint32_t max_records) { + char t1[256]; + uint64_t first; + if (cap < 0 || (buf && cap == 0)) return 0; + if (buf) buf[0] = '\0'; + uint32_t take = export_window(max_records, &first); + int off = app(buf, cap, 0, + "{\"enabled\":%d,\"capacity\":%u,\"count\":%llu," + "\"dropped\":%llu,\"missing\":%llu," + "\"runtime\":\"%s\",\"target_period_us\":%u,\"records\":[", + s_enabled, s_cap, (unsigned long long)s_committed, + (unsigned long long)psx_frame_trace_dropped(), + (unsigned long long)s_missing, + jstr(s_runtime, t1, sizeof t1), s_target_period_us); + int emitted = 0; + for (uint64_t seq = first; emitted < (int)take; ++seq) { + const PSXFrameTraceRecord *r = at_seq(seq); + if (!r) continue; + off = app(buf, cap, off, "%s", emitted ? "," : ""); + off = rec_json(buf, cap, off, r); + emitted++; + } + return app(buf, cap, off, "]}"); +} + +int psx_frame_trace_to_csv(char *buf, int cap, uint32_t max_records) { + char flags[96]; + uint64_t first; + if (cap < 0 || (buf && cap == 0)) return 0; + if (buf) buf[0] = '\0'; + uint32_t take = export_window(max_records, &first); + int off = app(buf, cap, 0, + "seq,open_ms,guest_frame,guest_cycle,attempt,pair_id," + "pair_age_ms,source,eye_failed,flags,shed,streak_ms,prims," + "tex_prims,prims_l,prims_r,tex_prims_l,tex_prims_r,cycles_l," + "cycles_r,vram_leaks,tex_bytes,xr_display_delta_us," + "xr_submit_fresh"); + for (uint32_t s = 0; s < PSX_TRACE_STAGE_COUNT; ++s) + off = app(buf, cap, off, ",%s", s_stage_names[s]); + off = app(buf, cap, off, ",failure,note\n"); + int emitted = 0; + for (uint64_t seq = first; emitted < (int)take; ++seq) { + const PSXFrameTraceRecord *r = at_seq(seq); + if (!r) continue; + emitted++; + off = app(buf, cap, off, + "%llu,%llu,%llu,%llu,%llu,%llu,%llu,%u,%d,\"%s\",%u,%u,%u," + "%u,%u,%u,%u,%u,%u,%u,%u,%llu,%u,%u", + (unsigned long long)r->seq, (unsigned long long)r->open_ms, + (unsigned long long)r->guest_frame, + (unsigned long long)r->guest_cycle, + (unsigned long long)r->stereo_attempt, + (unsigned long long)r->pair_id, + (unsigned long long)r->pair_age_ms, r->source, r->eye_failed, + psx_frame_trace_flag_names(r->flags, flags, sizeof flags), + r->shed_skipped, r->shed_streak_ms, r->prims, r->textured_prims, + r->prims_l, r->prims_r, r->tex_prims_l, r->tex_prims_r, + r->eye_cycles_l, r->eye_cycles_r, + r->vram_leaks, (unsigned long long)r->tex_upload_bytes, + r->xr_display_delta_us, (unsigned)r->xr_submit_fresh); + for (uint32_t s = 0; s < PSX_TRACE_STAGE_COUNT; ++s) + off = app(buf, cap, off, ",%u", r->stages_us[s]); + off = app(buf, cap, off, ",\"%s\",\"%s\"\n", r->failure, r->note); + } + return off; +} + +int psx_frame_trace_to_chrome_json(char *buf, int cap, uint32_t max_records) { + char flags[96]; + uint64_t first; + if (cap < 0 || (buf && cap == 0)) return 0; + if (buf) buf[0] = '\0'; + uint32_t take = export_window(max_records, &first); + int off = app(buf, cap, 0, "{\"traceEvents\":["); + int first_ev = 1, emitted = 0; + for (uint64_t seq = first; emitted < (int)take; ++seq) { + const PSXFrameTraceRecord *r = at_seq(seq); + if (!r) continue; + emitted++; + uint64_t base_us = r->open_ms * 1000u, at = 0; + for (uint32_t s = 0; s < PSX_TRACE_STAGE_COUNT; ++s) { + uint64_t dur = r->stages_us[s]; + if (dur) { + off = app(buf, cap, off, + "%s{\"name\":\"%s\",\"ph\":\"X\",\"ts\":%llu," + "\"dur\":%llu,\"pid\":1,\"tid\":1,\"args\":{" + "\"seq\":%llu,\"attempt\":%llu,\"pair_id\":%llu," + "\"flags\":\"%s\"}}", + first_ev ? "" : ",", s_stage_names[s], + (unsigned long long)(base_us + at), + (unsigned long long)dur, + (unsigned long long)r->seq, + (unsigned long long)r->stereo_attempt, + (unsigned long long)r->pair_id, + psx_frame_trace_flag_names(r->flags, flags, sizeof flags)); + first_ev = 0; + } + at += dur; + } + } + return app(buf, cap, off, "]}"); +} diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index b09a29e9..0e50a7cd 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -7,7 +7,7 @@ * - GP0 command write — ABORTS (not yet implemented) * - GPUREAD — returns last latched value * - * Reference: nocash PSX specs, DuckStation src/core/gpu.cpp + * Reference: PSX-SPX, page graphicsprocessingunitgpu (Graphics Processing Unit). */ #include "gpu.h" @@ -784,8 +784,21 @@ WS_EXPLICIT_LIST(bltz); WS_EXPLICIT_LIST(bgez); WS_EXPLICIT_LIST(branch_keep); WS_EXPLICIT_LIST(clip_edge_x_load); +WS_EXPLICIT_LIST(screen_x); static uint32_t ws_clip_edge_width = 320; +/* [widescreen.cull] screen_x_sites for interpreted code: native code compiles + * the list in (code_generator.cpp); this keeps a dirty-RAM copy of a listed + * site on the same semantics. */ +void gpu_ws_set_cull_screen_x_sites(const uint32_t *sites, int nsites) { + ws_explicit_screen_x_n = ws_explicit_store( + ws_explicit_screen_x_sites, sites, nsites, "screen_x_sites"); +} +int psx_ws_is_cull_screen_x_site(uint32_t pc) { + return ws_explicit_site(ws_explicit_screen_x_sites, + ws_explicit_screen_x_n, pc); +} + void gpu_ws_set_explicit_cull_sites(const uint32_t *bias, int nbias, const uint32_t *slti, int nslti, const uint32_t *range, int nrange) { @@ -4472,7 +4485,8 @@ void gpu_get_display_info(GpuDisplayInfo* out) { out->depth24 = (int)(display_depth & 1u); out->disabled = (int)display_disabled; - /* Dot-clock divider from GP1(08h) hres (psx-spx / DuckStation). */ + /* Dot-clock divider from GP1(08h) hres (PSX-SPX graphicsprocessingunitgpu, + * "Horizontal Timings", Dotclocks: /10, /8, /7, /5, /4). */ uint32_t cycles; uint32_t mode_w; if (hres2) { @@ -4580,6 +4594,27 @@ static void parse_vertex(uint32_t word, int32_t* x, int32_t* y) { extern int gte_geometry_correction_enabled(void); static int s_texture_correction_enabled = 0; static int s_native_wide_projection_correction = 0; +static PGXPProjectiveScope s_render_pgxp_scope; +static int s_render_projective, s_render_scope; +extern int g_psx_render_pass_active; +int gpu_render_projective_enabled(void) { + return s_render_projective && s_render_scope && g_psx_render_pass_active; +} +int gpu_render_projective_begin(void) { + if (!s_render_projective || s_render_scope) return 1; + if (!pgxp_projective_scope_begin(&s_render_pgxp_scope)) return 0; + s_render_scope = 1; + return 1; +} +void gpu_render_projective_end(void) { + if (!s_render_scope) return; + pgxp_projective_scope_end(&s_render_pgxp_scope); + s_render_scope = 0; +} +void psx_mod_set_render_projective(int enabled) { + s_render_projective = !!enabled; + pgxp_set_projection_tracking(s_render_projective || s_native_wide_projection_correction); +} static uint64_t s_native_wide_projection_vertices = 0; extern int gte_precision_load_word(uint32_t addr, uint32_t packed, int32_t *x16, int32_t *y16, uint16_t *z); @@ -4598,7 +4633,7 @@ void psx_mod_set_native_wide_projection_correction(int enabled) { s_native_wide_projection_vertices = 0; /* Signed camera-space provenance is required for correct native-wide * faces, including corners behind the camera. Arm it with the mode. */ - pgxp_set_projection_tracking(s_native_wide_projection_correction); + pgxp_set_projection_tracking(s_render_projective || s_native_wide_projection_correction); gpu_pgxp_rederive_enable(); } @@ -4756,7 +4791,7 @@ static void prepare_precise_triangle(int i0, int i1, int i2, } const int idx[3] = { i0, i1, i2 }; int32_t fx[3], fy[3]; - uint16_t vz[3] = {0, 0, 0}; + float vz[3] = {0, 0, 0}; int any_precise = 0, n_precise = 0; PGXPTriRecord rec; memset(&rec, 0, sizeof rec); @@ -4782,6 +4817,10 @@ static void prepare_precise_triangle(int i0, int i1, int i2, /* PGXP depth (G1.14): only a value-validated dataflow shadow carries * SZ, also when the seam clamp kept the native position. */ vz[i] = sz; + if (sz == 65535u) { + float full = pgxp_get_unclamped_depth(addr, word); + if (full > 0.0f) vz[i] = full; + } /* Native-wide edge recovery is not PGXP: it does not count in n_precise. */ if (native_wide_projection_x(addr, word, raw_x, raw_y, &px)) any_precise = 1; @@ -4794,7 +4833,7 @@ static void prepare_precise_triangle(int i0, int i1, int i2, rec.word[i] = word; rec.x16[i] = fx[i]; rec.y16[i] = fy[i]; - rec.z[i] = vz[i]; + rec.z[i] = sz; /* receipt retains the hardware SZ, not the visual depth */ } if (geometry) { pgxp_note_triangle(n_precise); /* G1.1 crack exposure: mixed share */ @@ -4882,6 +4921,7 @@ static void prepare_texture_triangle(int i0, int i1, int i2) { if (gp0_cmd_source_addr == 0xFFFFFFFFu) { s_texcorr.no_source++; return; } int indices[3] = { i0, i1, i2 }; uint16_t z[3]; + float depth[3]; for (int i = 0; i < 3; i++) { uint32_t addr = GPU_RAM_KEY((gp0_cmd_source_addr + (uint32_t)indices[i] * 4u)); if (!gte_precision_load_word(addr, gp0_cmd_buf[indices[i]], NULL, NULL, &z[i]) || @@ -4889,8 +4929,13 @@ static void prepare_texture_triangle(int i0, int i1, int i2) { s_texcorr.no_depth++; return; } + depth[i] = z[i]; + if (z[i] == 65535u) { + float full = pgxp_get_unclamped_depth(addr, gp0_cmd_buf[indices[i]]); + if (full > 0.0f) depth[i] = full; + } } - float q[3] = { 1.0f / (float)z[0], 1.0f / (float)z[1], 1.0f / (float)z[2] }; + float q[3] = { 1.0f / depth[0], 1.0f / depth[1], 1.0f / depth[2] }; float qmax = q[0]; if (q[1] > qmax) qmax = q[1]; if (q[2] > qmax) qmax = q[2]; @@ -5311,7 +5356,8 @@ static uint16_t current_texpage(void) { * (GPUSTAT bits 0-8) exactly like GP0(E1) bits 0-8 — the poly's own word, not * the last E1, decides its semi-transparency mode, and later rectangle/sprite * prims (which carry no texpage word) consume the state the poly left behind. - * Beetle: SetTPage(CB[4 + ((cc>>4)&1)] >> 16) on every textured poly. + * PSX-SPX graphicsprocessingunitgpu, "TPage Attribute (Parameter for textured polygon + * commands)": bits 0-8 and 11 are the same as GP0(E1h), bits 9-10 unused. * Bits 9-10 (dither / draw-to-display) exist only in E1; bit 11 (texture * disable) only latches when GP1(09h) allowed it, which polys can't grant. */ static void set_tpage_from_poly(uint16_t tpage_word) { @@ -5337,16 +5383,22 @@ static void setup_textured_draw(uint32_t color24, int semi_trans, int raw_textur * Use only intact GTE-derived words, clip their signed homogeneous projection, * and submit through the normal ordered GL passes and VRAM authority path. */ static int native_wide_projective_draw(void) { - if (!gpu_ws_projective_enabled() || gp0_cmd_source_addr == UINT32_MAX) return 0; + const int render_projective = gpu_render_projective_enabled() && + gr_backend() == GR_BACKEND_OPENGL && gl_renderer_projective_supported(); + if ((!render_projective && !gpu_ws_projective_enabled()) || gp0_cmd_source_addr == UINT32_MAX) return 0; + if (render_projective) psx_mod_counter_add("vr.projective.packet",1); const unsigned op=gp0_cmd_buf[0]>>24; const unsigned stride=(op&16u)?3u:2u, n=(op&8u)?4u:3u; PSXProjectedVertex vertices[4]; - int needs_clip=0; + int needs_clip=render_projective; for (unsigned i=0;i>8)&255)/255.0,((color>>16)&255)/255.0}; } if (!needs_clip) return 0; + if (render_projective) psx_mod_counter_add("vr.projective.draw",1); const uint16_t clut=gp0_cmd_buf[2]>>16; const uint16_t tpage=gp0_cmd_buf[2+stride]>>16; set_tpage_from_poly(tpage); const int semi=(op&2u)?(int)semi_transparency:-1; gl_renderer_draw_projected_triangle(vertices,tpage&0x1ff, - (clut&63)*16,(clut>>6)&511,op&1u,semi,s_texture_correction_enabled); + (clut&63)*16,(clut>>6)&511,op&1u,semi,render_projective || s_texture_correction_enabled); if (n==4) { const PSXProjectedVertex second[3]={vertices[2],vertices[1],vertices[3]}; gl_renderer_draw_projected_triangle(second,tpage&0x1ff, - (clut&63)*16,(clut>>6)&511,op&1u,semi,s_texture_correction_enabled); + (clut&63)*16,(clut>>6)&511,op&1u,semi,render_projective || s_texture_correction_enabled); } return 1; } @@ -6401,7 +6454,10 @@ static int gp0_command_word_count(uint8_t opcode) { case 0xE6: return 1; /* mask bits */ default: - /* 0x03-0x1E, 0xE7-0xEF, 0xFF: NOP (1 word) per DuckStation */ + /* 0x03-0x1E, 0xE7-0xEF, 0xFF: NOP (1 word). PSX-SPX graphicsprocessingunitgpu, + * "GP0(04h..1Eh,E0h,E7h..EFh) - Mirrors of GP0(00h) - NOP (?)", + * lists 04h-1Eh and E7h-EFh; it calls 03h unknown and does not + * list FFh. This code also treats 03h and FFh as NOPs. */ if ((opcode >= 0x03 && opcode <= 0x1E) || (opcode >= 0xE7 && opcode <= 0xEF) || opcode == 0xFF) { @@ -7539,8 +7595,9 @@ static void gpu_write_gp0_body(uint32_t val) { return; } - /* Polyline terminator rule (Beetle mednafen/psx/gpu.c INCMD_PLINE, - * DuckStation gpu.cpp HandleRenderPolyLineCommand/DrawingPolyLine): + /* Polyline terminator rule (PSX-SPX graphicsprocessingunitgpu, "GPU Render Line + * Commands": at least two vertices; the terminator is tested on the + * first word of a vertex, the colour word when shaded): * * 1. A polyline always has at least two vertices. The words of the first * two vertices are consumed unconditionally — mono [V0][V1], shaded @@ -7804,8 +7861,10 @@ static void gp1_display_mode(uint32_t val) { static void gp1_get_info(uint32_t val) { /* GP1(10h): Get GPU info — writes result to GPUREAD latch. - * Mednafen-psx masks the subcommand to 4 bits (val & 0x0F) and - * services cases 2..5, 7, 8. Tomba's ResetGraph() uses param 7 to + * PSX-SPX graphicsprocessingunitgpu, "GP1(10h) - Read GPU internal + * register" (v2 GPU): indices 10h and up mirror 00h..0Fh, so the + * subcommand is val & 0x0F; 02h..05h, 07h and 08h return data. + * Tomba's ResetGraph() uses param 7 to * read the GPU version (must be 2) to pick its video-mode path — * the wrong value here lands the game on a no-draw branch. */ uint32_t which = val & 0x0F; @@ -7823,10 +7882,10 @@ static void gp1_get_info(uint32_t val) { gpuread_latch = ((uint32_t)draw_offset_x & 0x7FFu) | (((uint32_t)draw_offset_y & 0x7FFu) << 11); break; - case 7: /* GPU version (real-hw + mednafen return 2) */ + case 7: /* GPU version: PSX-SPX gives 1 or 2; this models 2 */ gpuread_latch = 2; break; - case 8: /* unknown info index, real hw / mednafen return 0 */ + case 8: /* unknown info index, returns 0 (PSX-SPX) */ gpuread_latch = 0; break; default: diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 87d2c5bc..5d879bb0 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -1816,12 +1816,15 @@ static const char *GEO_VS = " float a = a_col.a, code = floor(a * 0.5), zn = 0.0, w = 1.0;\n" " float m = a - 2.0 * code;\n" " /* + 2*131072: the primitive is dither-eligible (GPU_GEO_DITHER_CODE). */\n" - " float dth = (a >= 0.0 && code >= 131072.0) ? 1.0 : 0.0; code -= 131072.0 * dth; v_dith = int(dth);\n" + " float dth = (a >= 0.0 && code >= 131072.0) ? 1.0 : 0.0; code -= 131072.0 * dth;\n" " float cp = code >= 65536.0 ? 1.0 : 0.0, sz = code - 65536.0 * cp;\n" - " if (a < 0.0) { m = 0.0; zn = 0.9999; sz = 0.0; cp = 0.0; } /* inside the far plane: never clipped */\n" + " bool far_code = a < -1.5;\n" + " if (far_code) { uint bits = floatBitsToUint(-a); m = float(bits & 1u); cp = float((bits >> 1u) & 1u); dth = float((bits >> 2u) & 1u); sz = uintBitsToFloat(bits & 0xfffffff8u) - 2.0; }\n" + " v_dith = int(dth);\n" + " if (a < 0.0 && !far_code) { m = 0.0; zn = 1.0; sz = 0.0; cp = 0.0; }\n" " else if (sz > 0.5) { zn = 1.0 - 512.0 / (max(sz * (1.0 - u_zbias) - (u_zbias > 0.0 ? 48.0 : 0.0), 1.0) + 256.0); if (cp > 0.5) w = sz / 1024.0; }\n" " v_col = vec4(a_col.rgb, m); v_col_p = a_col.rgb; v_cp = int(cp);\n" - " v_b3d = (a >= 0.0 && sz > 0.5) ? 1.0 : 0.0;\n" + " v_b3d = ((a >= 0.0 || far_code) && sz > 0.5) ? 1.0 : 0.0;\n" " float xb = a_pos.x;\n" " if (u_xscale < 0.0) {\n" " float s = -u_xscale; float h = u_xhalf / s;\n" @@ -1918,6 +1921,7 @@ static const char *TEX_VS = " * the vertex stays TEXV floats for every title. */\n" " float pz = a_col.a < 0.0 ? -a_col.a : 0.0;\n" " float cp = pz >= 65536.0 ? 1.0 : 0.0, sz = pz - 65536.0 * cp, zn = 0.0;\n" + " if (a_col.a > 1.5) { uint bits = floatBitsToUint(a_col.a); cp = float((bits >> 1u) & 1u); sz = uintBitsToFloat(bits & 0xfffffff8u) - 2.0; }\n" " if (sz > 0.5) { zn = 1.0 - 512.0 / (max(sz * (1.0 - u_zbias) - (u_zbias > 0.0 ? 48.0 : 0.0), 1.0) + 256.0); if (a_q <= 0.0 && cp > 0.5) w = sz / 1024.0; }\n" " v_cp = int(cp); v_b3d = sz > 0.5 ? 1.0 : 0.0;\n" " vec2 ndc = vec2((xb+u_shift+u_xoff)/u_xhalf - 1.0, (a_pos.y+u_shift)/256.0 - 1.0);\n" @@ -4404,6 +4408,21 @@ static float pgxp_vertex_code(float sz) { return sz + (s_pgxp_cpersp ? 65536.0f : 0.0f); } +/* Depth beyond the hardware SZ range cannot share its old numeric flag + * offsets. Keep that format byte-identical for normal vertices. Far vertices + * carry a finite positive float (depth+2), with three low mantissa bits for + * mask/colour/dither. Integer depths through 2^20 retain every depth bit; + * larger depths lose at most seven float ULPs. Negative GEO / positive TEX + * alpha distinguishes this format from legacy draws and depth-clear (-1). */ +static float pgxp_far_vertex_code(float sz, int mask, int dither) { + float value = sz + 2.0f; + uint32_t bits; + memcpy(&bits, &value, sizeof bits); + bits = (bits & ~7u) | (mask ? 1u : 0u) | (s_pgxp_cpersp ? 2u : 0u) | (dither ? 4u : 0u); + memcpy(&value, &bits, sizeof value); + return value; +} + /* The depth buffer is cleared (beyond every SZ, over the drawing area and * the native-wide margins) by a colourless depth-only draw through the flat * batch, so every surface a draw reaches (the hr surface, the high-resolution @@ -4523,13 +4542,14 @@ static void seam_q(const float B[3][3], float q[3]) { for (int k = 0; k < 3; k++) q[k] = o[k] > 1e-9f ? o[k] : q[k]; } static void seam_sz(const float B[3][3], float z[3]) { + float limit = fmaxf(z[0], fmaxf(z[1], z[2])) > 65535.0f ? 2147483648.0f : 65535.0f; float o[3]; for (int k = 0; k < 3; k++) { float iz = 0.0f; for (int i = 0; i < 3; i++) iz += B[k][i] / z[i]; o[k] = iz > 1e-9f ? 1.0f / iz : z[k]; if (o[k] < 1.0f) o[k] = 1.0f; - if (o[k] > 65535.0f) o[k] = 65535.0f; + if (o[k] > limit) o[k] = limit; } for (int k = 0; k < 3; k++) z[k] = o[k]; } @@ -4741,7 +4761,9 @@ static void gpu_geometry(GLenum mode, const int *xs, const int *ys, } } for (int i = 0; i < 3; i++) - v0[i * 6 + 5] = mask_a + 2.0f * pgxp_vertex_code(z[i]); + v0[i * 6 + 5] = z[i] > 65535.0f + ? -pgxp_far_vertex_code(z[i], ((int)mask_a & 1), mask_a >= GPU_GEO_DITHER_CODE) + : mask_a + 2.0f * pgxp_vertex_code(z[i]); } if (n == 3) pgxp_tri_log_tri('G', dmode, semi, v0, 6); } @@ -4924,7 +4946,8 @@ static void gpu_textured_triangle(const int *xs, const int *ys, } for (int i = 0; i < 3; i++) { const float code = pgxp_vertex_code(z[i]); - if (code > 0.0f) t0[i * TEXV + 7] = -code; /* a_col.a (TEX_VS) */ + if (z[i] > 65535.0f) t0[i * TEXV + 7] = pgxp_far_vertex_code(z[i], 0, 0); + else if (code > 0.0f) t0[i * TEXV + 7] = -code; /* a_col.a (TEX_VS) */ } } pgxp_tri_log_tri('T', tdmode, semi, &s_tb[s_tb_n * TEXV], TEXV); @@ -5330,6 +5353,8 @@ static void native_draw_end(int previous) { sw_set_faithful_authority(previous); static inline void precise_consumed(void) { s_pc_valid = 0; s_pq_valid = 0; s_projected_uv_valid = 0; s_pz_valid = 0; } int gl_renderer_projective_supported(void) { + /* Eye replay owns a separate FBO even when native-wide presentation is off. */ + if (s_pass_active) return s_raster_ok; if (s_rth_on && !rt_on_render_thread() && !rt_held()) return s_raster_ok && s_rthm_cur && !cpu_vram_authoritative(); return s_raster_ok && g_wide_cur && !cpu_vram_authoritative(); @@ -7589,6 +7614,14 @@ uint64_t gl_renderer_perf_prim_split(double *out_tex_frac) { return s_scene_prims; } +/* Cumulative scene primitives for per-frame attribution (frame_trace). Callers + * take deltas; these counters are never reset here. */ +void gl_renderer_scene_prim_counts(uint64_t *prims, uint64_t *textured) { + GL_RT_SYNC("scene_prim_counts"); + if (prims) *prims = s_scene_prims; + if (textured) *textured = s_scene_prims_tex; +} + int gl_renderer_perf_aggregate(int wide_filter, double out[18]) { GL_RT_SYNC("perf_aggregate"); for (int i = 0; i < 18; i++) out[i] = 0.0; @@ -8800,27 +8833,33 @@ void gl_renderer_stereo_stage_reset(void) { GL_RT_SYNC("stereo_stage_reset"); s_stereo_pair[1 - s_stereo_current].mask = 0; } -int gl_renderer_stereo_stage_readback(uint32_t eye,uint32_t w,uint32_t h,uint8_t *rgba) { - GL_RT_SYNC("capture_readback"); - if(eye>=2 || !rgba || !w || !h || w>1024 || h>512)return 0; +/* Both staged eye images in one read. The blit target is double height, eye 0 + * into its lower half and eye 1 into its upper half, so one glReadPixels + * returns the two height-sized planes contiguously, eye 0 first. */ +int gl_renderer_stereo_stage_readback_pair(uint32_t w,uint32_t h,uint8_t *rgba) { + GL_RT_SYNC("capture_readback_pair"); + if(!rgba || !w || !h || w>1024 || h>512)return 0; StereoPair *pair=&s_stereo_pair[1-s_stereo_current]; - if(!(pair->mask&(1u<mask&3u)!=3u)return 0; GLint read,draw,texture,pack,pbo,row,skipr,skipp; GLboolean scissor=glIsEnabled(GL_SCISSOR_TEST),srgb=glIsEnabled(0x8DB9); glGetIntegerv(0x8CAA,&read);glGetIntegerv(0x8CA6,&draw);glGetIntegerv(GL_TEXTURE_BINDING_2D,&texture); glGetIntegerv(GL_PACK_ALIGNMENT,&pack);glGetIntegerv(0x88ED,&pbo);glGetIntegerv(GL_PACK_ROW_LENGTH,&row); glGetIntegerv(GL_PACK_SKIP_ROWS,&skipr);glGetIntegerv(GL_PACK_SKIP_PIXELS,&skipp); int ok=pass_make_color_fbo(&s_capture_read_tex,NULL,&s_capture_read_fbo,&s_capture_read_w,&s_capture_read_h, - w,h,GL_RGBA8,GL_RGBA,GL_UNSIGNED_BYTE); + w,h*2u,GL_RGBA8,GL_RGBA,GL_UNSIGNED_BYTE); if(ok) { glDisable(GL_SCISSOR_TEST);glDisable(0x8DB9); - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,pair->fbo[eye]); p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER,s_capture_read_fbo); - p_glBlitFramebuffer(0,0,pair->tw[eye],pair->th[eye],0,0,w,h,GL_COLOR_BUFFER_BIT,GL_NEAREST); + for(uint32_t eye=0;eye<2u;eye++) { + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,pair->fbo[eye]); + p_glBlitFramebuffer(0,0,pair->tw[eye],pair->th[eye],0,(GLint)(eye*h),w,(GLint)((eye+1u)*h), + GL_COLOR_BUFFER_BIT,GL_NEAREST); + } p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,s_capture_read_fbo); p_glBindBuffer(0x88EB,0);glPixelStorei(GL_PACK_ALIGNMENT,1);glPixelStorei(GL_PACK_ROW_LENGTH,0); glPixelStorei(GL_PACK_SKIP_ROWS,0);glPixelStorei(GL_PACK_SKIP_PIXELS,0); - glReadPixels(0,0,w,h,GL_RGBA,GL_UNSIGNED_BYTE,rgba); + glReadPixels(0,0,w,h*2u,GL_RGBA,GL_UNSIGNED_BYTE,rgba); } p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER,read);p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER,draw); glBindTexture(GL_TEXTURE_2D,texture);glPixelStorei(GL_PACK_ALIGNMENT,pack); @@ -11544,7 +11583,7 @@ typedef struct { } FgList; static int rp_is_car(const FgList *L, uint32_t pi); /* Raw triangle captured while recording a list (absolute coords). */ -typedef struct { uint32_t rec, key0, vid[3]; float x[3], y[3], p[3][3], h[3]; int area[4]; } FgRaw; +typedef struct { uint32_t rec, key0, vid[3]; float x[3], y[3], p[3][3], h[3], pz[3]; int area[4]; } FgRaw; static int s_fg_on = 0; /* configured (any thread reads) */ static int s_fg_force = 0; /* PSX_FRAME_GEN_FORCE: tests */ @@ -11557,6 +11596,7 @@ static int s_fg_older = 0, s_fg_newer = 1, s_fg_cur = 2; static int s_fg_pa = -1, s_fg_pb = -1; /* the pair a schedule draws from */ static FgRaw *s_fg_raw = NULL; static uint32_t s_fg_nraw = 0, s_fg_rawcap = 0; static int s_fg_pc_valid = 0; static int32_t s_fg_pc[6]; /* pending PRECISE */ +static int s_fg_dz_valid = 0; static float s_fg_dz[3]; /* pending PGXP DEPTH */ static int s_fg_have_last = 0, s_fg_last_dx = 0, s_fg_last_dy = 0; /* Display buffers seen in presents (x, y, w, h), and the one the capturing * list draws into: a list is one game frame's drawing into one buffer, closed @@ -11952,9 +11992,17 @@ static void fg_capture(const RtCmd *c, const void *payload) { if (s_fg_pc_valid) memcpy(s_fg_pc, v + 1, sizeof s_fg_pc); return; } + if (c->op == RTH_DEPTH) { /* PGXP per-vertex depth of the next triangle */ + const int32_t *v = (const int32_t *)payload; + s_fg_dz_valid = v[0] != 0; + if (s_fg_dz_valid) memcpy(s_fg_dz, v + 1, sizeof s_fg_dz); + return; + } FgRaw raw; if (fg_tri_geom(c->op, payload, s_fg_pc_valid ? s_fg_pc : NULL, raw.x, raw.y, &raw.key0)) { raw.rec = (uint32_t)r; + for (int k = 0; k < 3; k++) + raw.pz[k] = (s_fg_dz_valid && s_fg_dz[k] >= 1.0f) ? s_fg_dz[k] : 0.0f; /* The identities belong to this triangle only if it is drawn where * they were looked up (a culled triangle leaves its record behind). */ for (int k = 0; k < 3; k++) raw.vid[k] = 0; @@ -11977,6 +12025,7 @@ static void fg_capture(const RtCmd *c, const void *payload) { case RTH_FLAT_TRI: case RTH_GOURAUD_TRI: case RTH_TEX_TRI: case RTH_SHADED_TEX_TRI: case RTH_PROJ_TRI: s_fg_pc_valid = 0; + s_fg_dz_valid = 0; s_fg_src_valid = 0; break; default: break; @@ -12014,6 +12063,7 @@ static void fg_list_close(FgList *l, const int disp[4]) { p.vid[k] = r->vid[k]; memcpy(p.p[k], r->p[k], sizeof p.p[k]); p.h[k] = r->h[k]; + p.pz[k] = r->pz[k]; } for (int k = 0; k < 3; k++) { p.x[k] = r->x[k] - (float)disp[0]; p.y[k] = r->y[k] - (float)disp[1]; } if (!fg_prims_add(&l->prims, &p)) { l->valid = 0; return; } @@ -12443,12 +12493,17 @@ static void fg_compose(const FgList *b, GLuint wide_fbo, GLuint wide_tex, int li * allocates none of this. The object rules below (cars, needles, small * views) were tuned on R4 (docs/FRAME_GENERATION.md). */ static GLuint s_rp_zprog = 0, s_rp_wprog = 0, s_rp_vao = 0, s_rp_vbo = 0, s_rp_evao = 0; -static GLuint s_rp_ztex = 0, s_rp_zfbo = 0; +static GLuint s_rp_ztex = 0, s_rp_zfbo = 0, s_rp_zrb = 0; /* zrb: the depth image's own depth test */ +/* The older frame's depth image (two-frame warp): same size and format. */ +static GLuint s_rp_ztex2 = 0, s_rp_zfbo2 = 0, s_rp_zrb2 = 0; +static int s_rp_zw2 = 0, s_rp_zh2 = 0; +static uint64_t s_rp_key2 = 0, s_rp_older_frames = 0; static int s_rp_zw = 0, s_rp_zh = 0; static float *s_rp_v = NULL; static size_t s_rp_vcap = 0; static uint64_t s_rp_key = 0; /* the game frame the depth image is of */ static uint64_t s_rp_frames = 0, s_rp_fallback = 0; static GLint s_rp_z_uSize = -1; +static GLint s_rp_w_uDbias = -1; static GLint s_rp_w_uZ, s_rp_w_uZsize, s_rp_w_uZs, s_rp_w_uGrid, s_rp_w_uSize, s_rp_w_uOx, s_rp_w_uView, s_rp_w_uM, s_rp_w_uSrc, s_rp_w_uSrcOrg, s_rp_w_uS, s_rp_w_uDbg; static GLint s_rp_r_uSrc, s_rp_r_uZ, s_rp_r_uZsize, s_rp_r_uOrg, s_rp_r_uSrcOrg, s_rp_r_uS, @@ -12466,7 +12521,8 @@ static const char *RP_Z_VS = "uniform vec2 u_size;\n" "out float v_iz; flat out float v_id; flat out float v_view;\n" "void main(){ v_iz=a.z; v_id=a.w; v_view=av;\n" - " gl_Position=vec4(a.x/u_size.x*2.0-1.0, a.y/u_size.y*2.0-1.0, 0.0, 1.0); }\n"; + " float d = a.z > 0.0 ? clamp((1.0/a.z)/262144.0, 0.0, 0.99999) : 0.0;\n" + " gl_Position=vec4(a.x/u_size.x*2.0-1.0, a.y/u_size.y*2.0-1.0, d*2.0-1.0, 1.0); }\n"; static const char *RP_Z_FS = "#version 330\n" "in float v_iz; flat in float v_id; flat in float v_view; out vec4 frag;\n" @@ -12475,28 +12531,38 @@ static const char *RP_W_VS = "#version 330\n" "uniform sampler2D u_z; uniform ivec2 u_zsize; uniform float u_zs;\n" "uniform ivec2 u_grid; uniform vec2 u_size; uniform vec2 u_ox;\n" - "uniform vec4 u_view[4]; uniform vec4 u_M[12];\n" + "uniform vec4 u_view[4]; uniform vec4 u_M[12]; uniform float u_dbias;\n" "out vec2 v_src; out float v_bad;\n" "vec4 zat(ivec2 g){ return texelFetch(u_z, clamp(ivec2(vec2(g)*u_zs), ivec2(0), u_zsize-1), 0); }\n" + /* A grid point moved by its own depth and view: (x, y, depth). */ + "vec3 warp(vec2 p, vec4 z){\n" + " int vi=int(z.z+0.5); vec4 V=u_view[vi]; float Z=1.0/z.x; vec2 rel=p-u_ox;\n" + " vec3 P=vec3((rel.x-V.x)*Z/V.z,(rel.y-V.y)*Z/V.z,Z);\n" + " vec4 r0=u_M[vi*3], r1=u_M[vi*3+1], r2=u_M[vi*3+2];\n" + " vec3 Pt=vec3(dot(r0.xyz,P)+r0.w, dot(r1.xyz,P)+r1.w, dot(r2.xyz,P)+r2.w);\n" + " Pt.z=max(Pt.z, max(16.0, 0.25*Z));\n" + " return vec3(vec2(V.z*Pt.x/Pt.z+V.x, V.z*Pt.y/Pt.z+V.y)+u_ox, Pt.z); }\n" "void main(){\n" " int id=gl_VertexID, cell=id/6, c=id-cell*6;\n" " int cw=u_grid.x-1; ivec2 base=ivec2(cell-(cell/cw)*cw, cell/cw);\n" " ivec2 o = (c==0)?ivec2(0,0):(c==1||c==3)?ivec2(1,0):(c==2||c==5)?ivec2(0,1):ivec2(1,1);\n" " ivec2 gp=base+o; vec2 p=vec2(gp);\n" - " float zmin=1e30, zmax=0.0; float bad=0.0; float v0=-1.0;\n" - " for(int k=0;k<4;k++){ vec4 q=zat(base+ivec2(k&1,k>>1));\n" - " if(q.y<0.5||q.y>1.5||q.x<=0.0){bad=1.0;} else { float Z=1.0/q.x; zmin=min(zmin,Z); zmax=max(zmax,Z);\n" - " if(v0<0.0) v0=q.z; else if(q.z!=v0) bad=1.0; } }\n" - " if(zmax>zmin*1.06+4.0) bad=1.0;\n" - " vec4 z=zat(gp); vec2 np=p; float depth=1.0;\n" + " float bad=0.0; float v0=-1.0; vec4 q[4];\n" + " for(int k=0;k<4;k++){ q[k]=zat(base+ivec2(k&1,k>>1));\n" + " bool wk=(q[k].y>0.5&&q[k].y<1.5)||(q[k].y>3.5&&q[k].y<4.5);\n" + " if(!wk||q[k].x<=0.0) bad=1.0; else if(v0<0.0) v0=q[k].z; else if(q[k].z!=v0) bad=1.0; }\n" + " vec2 np=p; float depth=1.0;\n" " if(bad<0.5){\n" - " int vi=int(z.z+0.5); vec4 V=u_view[vi]; float Z=1.0/z.x; vec2 rel=p-u_ox;\n" - " vec3 P=vec3((rel.x-V.x)*Z/V.z,(rel.y-V.y)*Z/V.z,Z);\n" - " vec4 r0=u_M[vi*3], r1=u_M[vi*3+1], r2=u_M[vi*3+2];\n" - " vec3 Pt=vec3(dot(r0.xyz,P)+r0.w, dot(r1.xyz,P)+r1.w, dot(r2.xyz,P)+r2.w);\n" - " Pt.z=max(Pt.z, max(16.0, 0.25*Z));\n" - " np=vec2(V.z*Pt.x/Pt.z+V.x, V.z*Pt.y/Pt.z+V.y)+u_ox;\n" - " depth=clamp(Pt.z/262144.0, 0.0, 1.0);\n" + /* A cell is an edge (not drawn) only where the warp tears it: a side + * stretched past 1.5 native px. A depth ratio test also rejected the + * inside of near surfaces seen at a grazing angle, and the un-warped + * frame showed through them (doubled text on a passing billboard). */ + " vec3 w0=warp(vec2(base),q[0]), w1=warp(vec2(base+ivec2(1,0)),q[1]),\n" + " w2=warp(vec2(base+ivec2(0,1)),q[2]), w3=warp(vec2(base+ivec2(1,1)),q[3]);\n" + " float m=max(max(distance(w0.xy,w1.xy),distance(w0.xy,w2.xy)),max(distance(w1.xy,w3.xy),distance(w2.xy,w3.xy)));\n" + " if(m>1.5) bad=1.0;\n" + " vec3 w = (o==ivec2(0,0))?w0:(o==ivec2(1,0))?w1:(o==ivec2(0,1))?w2:w3;\n" + " np=w.xy; depth=clamp(w.z*(1.0+u_dbias)/262144.0, 0.0, 0.99999);\n" " }\n" " v_bad=bad; v_src=p;\n" " gl_Position=vec4(np.x/u_size.x*2.0-1.0, np.y/u_size.y*2.0-1.0, depth*2.0-1.0, 1.0);\n" @@ -12520,7 +12586,7 @@ static const char *RP_R_FS = "void main(){ vec2 p=(gl_FragCoord.xy-u_org)/u_S;\n" " vec4 z=texelFetch(u_z, clamp(ivec2(p*2.0), ivec2(0), u_zsize-1), 0);\n" " ivec2 t=ivec2(gl_FragCoord.xy-u_org+u_srcorg*u_S);\n" " if(u_dbg==2){ frag=vec4(z.y==1.0?0.0:1.0, z.y==2.0?1.0:0.0, z.y==3.0?1.0:(z.y==0.0?0.5:0.0), 1.0); return; }\n" - " if(z.y>2.5){ frag = u_has_prev==1 ? texelFetch(u_prev, t, 0) : texelFetch(u_src, t, 0); return; }\n" + " if(z.y>2.5&&z.y<3.5){ frag = u_has_prev==1 ? texelFetch(u_prev, t, 0) : texelFetch(u_src, t, 0); return; }\n" " if(z.y>0.5) discard;\n" " frag=texelFetch(u_src, t, 0); }\n"; static GLuint s_rp_rprog = 0; @@ -12540,6 +12606,7 @@ static int rp_resources(void) { s_rp_w_uOx = p_glGetUniformLocation(s_rp_wprog, "u_ox"); s_rp_w_uView = p_glGetUniformLocation(s_rp_wprog, "u_view"); s_rp_w_uM = p_glGetUniformLocation(s_rp_wprog, "u_M"); + s_rp_w_uDbias = p_glGetUniformLocation(s_rp_wprog, "u_dbias"); s_rp_w_uSrc = p_glGetUniformLocation(s_rp_wprog, "u_src"); s_rp_w_uSrcOrg = p_glGetUniformLocation(s_rp_wprog, "u_srcorg"); s_rp_w_uS = p_glGetUniformLocation(s_rp_wprog, "u_S"); @@ -12643,6 +12710,171 @@ static int rp_is_car(const FgList *L, uint32_t pi) { * surface; the region is (x0, y0, W, H) native px, rel x = x - ox. */ static double s_rp_t_snap = 0, s_rp_t_z = 0, s_rp_t_w = 0; static uint32_t s_rp_zn = 0; +/* Geometry without a projection source (a game's own near-plane + * subdivision) is world when the renderer had its PGXP depth: the view is the + * fitted one of its draw area. -1 otherwise. */ +/* PGXP depth is the GTE's SZ, which a game may scale against the camera + * space its projected vertices carry: the scale is measured per list as the + * median camera-z / SZ over vertices that have both (1 when none do). */ +static float *s_rp_pzr = NULL; static size_t s_rp_pzr_cap = 0; +static float s_rp_pzs_last = 0.0f; /* diagnostics: the newer list's scale */ +static int rp_float_cmp(const void *a, const void *b) { + const float x = *(const float *)a, y = *(const float *)b; + return x < y ? -1 : x > y; +} +static float rp_pz_scale(const FgList *L) { + size_t n = 0; + if ((size_t)L->prims.n * 3u > s_rp_pzr_cap) { + float *nb = (float *)realloc(s_rp_pzr, (size_t)L->prims.n * 3u * sizeof *nb); + if (!nb) return 1.0f; + s_rp_pzr = nb; s_rp_pzr_cap = (size_t)L->prims.n * 3u; + } + for (uint32_t j = 0; j < L->prims.n; j++) { + const FgPrim *p = &L->prims.v[j]; + for (int k = 0; k < 3; k++) + if (p->vid[k] && p->p[k][2] >= 1.0f && p->pz[k] >= 1.0f) s_rp_pzr[n++] = p->p[k][2] / p->pz[k]; + } + if (n < 16) return 1.0f; + qsort(s_rp_pzr, n, sizeof *s_rp_pzr, rp_float_cmp); + return s_rp_pzr[n / 2]; +} +static int rp_pgxp_world_view(const FgPrim *p) { + if (p->vid[0] || p->vid[1] || p->vid[2]) return -1; + if (p->pz[0] < 1.0f || p->pz[1] < 1.0f || p->pz[2] < 1.0f) return -1; + for (uint32_t vi = 0; vi < s_fg_fit.nviews && vi < FG_MAX_VIEWS; vi++) + if (s_fg_fit.v[vi].view == p->view && s_fg_fit.v[vi].ok) return (int)vi; + return -1; +} + +/* Two-frame warp: the older frame's own depth image. Its world is what the + * fit explains by the camera: a vertex with a projection in a fitted view, + * not one the fit called an object in the newer frame (cars are drawn again + * at their in-between position). Vertices only the older frame has (what + * the newer frame's nearer geometry now covers) are world too: they are what + * fills the newer frame's disocclusions. Nearest world depth wins, the rest + * keeps painter order as id 0 (not warped). */ +typedef struct { uint32_t vid; int8_t mode, view; } RpVidMode; +static RpVidMode *s_rp_vm = NULL; static size_t s_rp_vmcap = 0; +static int rp_vm_cmp(const void *a, const void *b) { + const uint32_t x = ((const RpVidMode *)a)->vid, y = ((const RpVidMode *)b)->vid; + return x < y ? -1 : x > y; +} +static int rp_build_older(const FgList *L, const FgList *O, float qox, int zw, int zh, float big) { + if (!O || !O->valid || !O->rec2prim) return 0; + /* The newer frame's placement by projection identity. */ + size_t nvm = 0; + const size_t need = (size_t)L->prims.n * 3u; + if (need > s_rp_vmcap) { + RpVidMode *nv = (RpVidMode *)realloc(s_rp_vm, need * sizeof *nv); + if (!nv) return 0; + s_rp_vm = nv; s_rp_vmcap = need; + } + for (uint32_t j = 0; j < L->prims.n; j++) + for (int k = 0; k < 3; k++) { + const uint32_t vid = L->prims.v[j].vid[k]; + if (!vid) continue; + const FgVert *fv = &s_fg_verts[3 * j + k]; + s_rp_vm[nvm].vid = vid; s_rp_vm[nvm].mode = (int8_t)fv->mode; s_rp_vm[nvm].view = fv->view; + nvm++; + } + qsort(s_rp_vm, nvm, sizeof *s_rp_vm, rp_vm_cmp); + if (zw != s_rp_zw2 || zh != s_rp_zh2) { + if (s_rp_zfbo2) { p_glDeleteFramebuffers(1, &s_rp_zfbo2); glDeleteTextures(1, &s_rp_ztex2); } + if (s_rp_zrb2) { p_glDeleteRenderbuffers(1, &s_rp_zrb2); s_rp_zrb2 = 0; } + s_rp_ztex2 = make_tex(0x8814 /* GL_RGBA32F */, zw, zh, GL_RGBA, GL_FLOAT); + p_glGenRenderbuffers(1, &s_rp_zrb2); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, s_rp_zrb2); + p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, zw, zh); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); + if (!make_fbo(&s_rp_zfbo2, s_rp_ztex2, s_rp_zrb2)) { s_rp_zfbo2 = 0; s_rp_zw2 = s_rp_zh2 = 0; return 0; } + s_rp_zw2 = zw; s_rp_zh2 = zh; + } + size_t n = 0; + const float pzs_old = rp_pz_scale(O); + for (uint32_t r = 0; r < O->n; r++) { + FgRec h; + memcpy(&h, O->buf + O->off[r], sizeof h); + const int32_t *v = (const int32_t *)(O->buf + O->off[r] + sizeof h); + if (h.op == RTH_FLAT_RECT || h.op == RTH_TEX_RECT || h.op == RTH_TEX_RECT_SCALED) { + if (!rp_push_quad(&n, ((float)(v[0] - O->disp[0]) + qox) * RP_ZS, (float)(v[1] - O->disp[1]) * RP_ZS, + ((float)(v[0] - O->disp[0] + v[2]) + qox) * RP_ZS, + (float)(v[1] - O->disp[1] + v[3]) * RP_ZS)) return 0; + continue; + } + if (h.op != RTH_FLAT_TRI && h.op != RTH_GOURAUD_TRI && h.op != RTH_TEX_TRI && + h.op != RTH_SHADED_TEX_TRI && h.op != RTH_PROJ_TRI) continue; + const int pi = O->rec2prim[r]; + if (pi < 0) continue; + const FgPrim *p = &O->prims.v[pi]; + int world = 1, view = -1, objv = 0; + const int pgv = rp_pgxp_world_view(p); + for (int k = 0; k < 3 && pgv < 0; k++) { + if (!p->vid[k] || p->p[k][2] < 1.0f) { world = 0; break; } + RpVidMode key; key.vid = p->vid[k]; + const RpVidMode *m = (const RpVidMode *)bsearch(&key, s_rp_vm, nvm, sizeof *s_rp_vm, rp_vm_cmp); + int vk = -1; + if (m) { + if (m->mode == FG_PLACE_OBJECT) { world = 0; objv = 1; break; } + vk = m->view; + } else { + for (uint32_t vi = 0; vi < s_fg_fit.nviews; vi++) + if (s_fg_fit.v[vi].view == p->view) { vk = (int)vi; break; } + } + if (vk < 0) { world = 0; break; } + if (view < 0) view = vk; else if (view != vk) { world = 0; break; } + } + if (pgv >= 0) view = pgv; + if (world && (view < 0 || view >= FG_MAX_VIEWS || !s_fg_fit.v[view].ok)) world = 0; + if (world) { + const float *ar = s_fg_fit.v[view].area; + if ((ar[2] - ar[0]) * (ar[3] - ar[1]) < 0.15f * big) world = 0; + } + /* 1 world, 4 a big moving object (sky dome: warped as infinitely + * far), 2 a car (drawn again), 0 the rest (2D, HUD). */ + int far_obj = 0, fview = -1; + if (objv) { + const float bw = fmaxf(p->x[0], fmaxf(p->x[1], p->x[2])) - fminf(p->x[0], fminf(p->x[1], p->x[2])); + const float bh = fmaxf(p->y[0], fmaxf(p->y[1], p->y[2])) - fminf(p->y[0], fminf(p->y[1], p->y[2])); + if (bw > 96.0f || bh > 64.0f) + for (uint32_t vi = 0; vi < s_fg_fit.nviews; vi++) + if (s_fg_fit.v[vi].view == p->view && s_fg_fit.v[vi].ok) { fview = (int)vi; far_obj = 1; break; } + } + const float id = world ? 1.0f : far_obj ? 4.0f : objv ? 2.0f : 0.0f; + for (int k = 0; k < 3; k++) + if (!rp_push(&n, (p->x[k] + qox) * RP_ZS, p->y[k] * RP_ZS, + world ? 1.0f / (pgv >= 0 ? p->pz[k] * pzs_old : p->p[k][2]) : far_obj ? 1e-6f : 0.0f, id, + world ? (float)view : far_obj ? (float)fview : 0.0f)) return 0; + } + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_rp_zfbo2); + glViewport(0, 0, zw, zh); + glClearColor(0.f, 0.f, 0.f, 0.f); + glClearDepth(1.0); + glDepthMask(GL_TRUE); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + if (n) { + p_glUseProgram(s_rp_zprog); + p_glUniform2f(s_rp_z_uSize, (float)zw, (float)zh); + p_glBindVertexArray(s_rp_vao); + p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_rp_vbo); + p_glBufferData(PSXGL_ARRAY_BUFFER, (GLsizeiptr)(n * 5 * sizeof(float)), s_rp_v, PSXGL_STREAM_DRAW); + glEnable(GL_DEPTH_TEST); + size_t r0 = 0; + while (r0 < n) { + const int w0 = s_rp_v[r0 * 5 + 3] == 1.0f || s_rp_v[r0 * 5 + 3] == 4.0f; + size_t r1 = r0 + 3; + while (r1 < n && (s_rp_v[r1 * 5 + 3] == 1.0f || s_rp_v[r1 * 5 + 3] == 4.0f) == w0) r1 += 3; + glDepthFunc(w0 ? GL_LESS : GL_ALWAYS); + glDepthMask(w0 ? GL_TRUE : GL_FALSE); + glDrawArrays(GL_TRIANGLES, (GLint)r0, (GLsizei)(r1 - r0)); + r0 = r1; + } + glDepthMask(GL_TRUE); + glDepthFunc(GL_LESS); + glDisable(GL_DEPTH_TEST); + } + return 1; +} + static int fg_reproject(const FgList *L, const FgList *O, double t, GLuint dst_fbo, GLuint src_fbo, int x0, int y0, int W, int H, float ox, int S) { if (!rp_resources() || W <= 0 || H <= 0) return 0; @@ -12671,15 +12903,21 @@ static int fg_reproject(const FgList *L, const FgList *O, double t, GLuint dst_f const int ok_ = O ? rp_snap_slot((int)(O - s_fg_l)) : -1; const int ok = (ok_ >= 0 && s_rp_ls_w[ok_] == W * S && s_rp_ls_h[ok_] == H * S) ? ok_ : -1; (void)src_fbo; - /* The picture under the warp: the snapshot. */ - fg_blit2(s_rp_snap_fbo, dst_fbo, 0, 0, x0 * S, y0 * S, W * S, H * S); + /* The picture under the warp, where neither frame's warp is valid: the + * nearest real frame (never a mix). */ + fg_blit2((t < 0.5 && ok >= 0) ? s_rp_ls_fbo[ok] : s_rp_snap_fbo, dst_fbo, 0, 0, x0 * S, y0 * S, W * S, H * S); const uint64_t qz = host_now_ns_rthf(); if (key != s_rp_key || zw != s_rp_zw || zh != s_rp_zh) { if (zw != s_rp_zw || zh != s_rp_zh) { if (s_rp_zfbo) { p_glDeleteFramebuffers(1, &s_rp_zfbo); glDeleteTextures(1, &s_rp_ztex); } + if (s_rp_zrb) { p_glDeleteRenderbuffers(1, &s_rp_zrb); s_rp_zrb = 0; } fg_cost_cold(fg_cost(), 3); s_rp_ztex = make_tex(0x8814 /* GL_RGBA32F */, zw, zh, GL_RGBA, GL_FLOAT); - if (!make_fbo(&s_rp_zfbo, s_rp_ztex, 0)) { s_rp_zfbo = 0; s_rp_zw = s_rp_zh = 0; goto fail; } + p_glGenRenderbuffers(1, &s_rp_zrb); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, s_rp_zrb); + p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, zw, zh); + p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0); + if (!make_fbo(&s_rp_zfbo, s_rp_ztex, s_rp_zrb)) { s_rp_zfbo = 0; s_rp_zw = s_rp_zh = 0; goto fail; } s_rp_zw = zw; s_rp_zh = zh; } size_t n = 0; @@ -12690,6 +12928,8 @@ static int fg_reproject(const FgList *L, const FgList *O, double t, GLuint dst_f } fg_hud_match(&L->prims, O ? &O->prims : NULL, 24.0f, s_rp_needle); s_rp_needle_n = L->prims.n; + const float pzs_new = rp_pz_scale(L); + s_rp_pzs_last = pzs_new; for (uint32_t r = 0; r < L->n; r++) { FgRec h; memcpy(&h, L->buf + L->off[r], sizeof h); @@ -12712,6 +12952,9 @@ static int fg_reproject(const FgList *L, const FgList *O, double t, GLuint dst_f } if (world && (view < 0 || view >= FG_MAX_VIEWS || !vp[view][3] || !s_fg_fit.v[view].ok)) world = 0; + /* Source-less geometry with PGXP depth: world at that depth. */ + const int pgv = world ? -1 : rp_pgxp_world_view(p); + if (pgv >= 0 && vp[pgv][3]) { world = 1; view = pgv; } if (world) { const float *ar = s_fg_fit.v[view].area; if ((ar[2] - ar[0]) * (ar[3] - ar[1]) < 0.15f * big) world = 0; @@ -12726,11 +12969,23 @@ static int fg_reproject(const FgList *L, const FgList *O, double t, GLuint dst_f const float *ar = s_fg_fit.v[view].area; if ((ar[2] - ar[0]) * (ar[3] - ar[1]) < 0.15f * big) obj = 0; } - const float id = world ? 1.0f : obj ? 2.0f : s_rp_needle[pi] ? 3.0f : 0.0f; + /* A big object the fit moves on its own (the sky dome, a + * camera-attached backdrop; cars are small, at most 96 x 64) + * is warped as infinitely far: the camera's turn only. */ + int far_obj = 0, fview = view; + if (!world && obj) { + const float bw = fmaxf(p->x[0], fmaxf(p->x[1], p->x[2])) - fminf(p->x[0], fminf(p->x[1], p->x[2])); + const float bh = fmaxf(p->y[0], fmaxf(p->y[1], p->y[2])) - fminf(p->y[0], fminf(p->y[1], p->y[2])); + if (bw > 96.0f || bh > 64.0f) { + for (int k = 0; k < 3 && fview < 0; k++) fview = s_fg_verts[3 * pi + k].view; + far_obj = fview >= 0 && fview < FG_MAX_VIEWS && s_fg_fit.v[fview].ok && vp[fview][3] != 0.0f; + } + } + const float id = world ? 1.0f : far_obj ? 4.0f : obj ? 2.0f : s_rp_needle[pi] ? 3.0f : 0.0f; for (int k = 0; k < 3; k++) if (!rp_push(&n, (p->x[k] + ox) * RP_ZS, p->y[k] * RP_ZS, - world ? 1.0f / p->p[k][2] : 0.0f, id, - world ? (float)view : 0.0f)) goto fail; + world ? 1.0f / (pgv >= 0 ? p->pz[k] * pzs_new : p->p[k][2]) : far_obj ? 1e-6f : 0.0f, id, + world ? (float)view : far_obj ? (float)fview : 0.0f)) goto fail; break; } case RTH_FLAT_RECT: case RTH_TEX_RECT: case RTH_TEX_RECT_SCALED: @@ -12791,19 +13046,51 @@ static int fg_reproject(const FgList *L, const FgList *O, double t, GLuint dst_f p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_rp_zfbo); glViewport(0, 0, zw, zh); glClearColor(0.f, 0.f, 0.f, 0.f); - glClear(GL_COLOR_BUFFER_BIT); + glClearDepth(1.0); + glDepthMask(GL_TRUE); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); if (n) { p_glUseProgram(s_rp_zprog); p_glUniform2f(s_rp_z_uSize, (float)zw, (float)zh); p_glBindVertexArray(s_rp_vao); p_glBindBuffer(PSXGL_ARRAY_BUFFER, s_rp_vbo); p_glBufferData(PSXGL_ARRAY_BUFFER, (GLsizeiptr)(n * 5 * sizeof(float)), s_rp_v, PSXGL_STREAM_DRAW); - glDrawArrays(GL_TRIANGLES, 0, (GLsizei)n); + /* World triangles take the nearest depth, like the PGXP depth + * buffer the real frame was drawn with: in painter order a far + * section drawn after a near wall (the game's own sort, or Max + * Detail's far sections) left far depth on the wall's pixels, + * and the warp moved them as distant scenery (objects showing + * through walls, doubled signs, sheared lane lines). Everything + * else keeps painter order: it always passes and writes no depth + * (a backdrop under the world, a car over the road, the HUD and + * mirror overlay last). Drawn in runs of the same kind. */ + glEnable(GL_DEPTH_TEST); + size_t r0 = 0; + while (r0 < n) { + const int w0 = s_rp_v[r0 * 5 + 3] == 1.0f || s_rp_v[r0 * 5 + 3] == 4.0f; + size_t r1 = r0 + 3; + while (r1 < n && (s_rp_v[r1 * 5 + 3] == 1.0f || s_rp_v[r1 * 5 + 3] == 4.0f) == w0) r1 += 3; + glDepthFunc(w0 ? GL_LESS : GL_ALWAYS); + glDepthMask(w0 ? GL_TRUE : GL_FALSE); + glDrawArrays(GL_TRIANGLES, (GLint)r0, (GLsizei)(r1 - r0)); + r0 = r1; + } + glDepthMask(GL_TRUE); + glDepthFunc(GL_LESS); + glDisable(GL_DEPTH_TEST); } s_rp_key = key; s_rp_zn = (uint32_t)n; s_rp_t_z = (double)(host_now_ns_rthf() - qz) * 1e-6; } + /* 1b. The older frame's depth image (two-frame warp), once per pair. */ + int older = 0; + if (t < 1.0 - 1e-3 && ok >= 0 && O && O->valid && O->wide_off == L->wide_off) { + const uint64_t key2 = key * 8u + (uint64_t)(O - s_fg_l) + 1u; + if (key2 == s_rp_key2 && zw == s_rp_zw2 && zh == s_rp_zh2) older = 1; + else if (rp_build_older(L, O, ox, zw, zh, big)) { s_rp_key2 = key2; older = 1; } + else s_rp_key2 = 0; + } const uint64_t qw = host_now_ns_rthf(); /* 2. The warp. */ { @@ -12850,14 +13137,78 @@ static int fg_reproject(const FgList *L, const FgList *O, double t, GLuint dst_f p_glUniform1f(s_rp_w_uS, (float)S); p_glUniform1i(s_rp_w_uDbg, s_rp_dbg); p_glBindVertexArray(s_rp_evao); - glDrawArrays(GL_TRIANGLES, 0, (GLsizei)(W * H * 6)); + /* Both bracketing real frames are warped to the in-between camera. + * The frame nearer in time is drawn first and marks the stencil + * wherever it has a valid sample; the other frame fills only the + * unmarked pixels (what the nearer frame cannot show: a cell over a + * depth edge the camera uncovers, a pixel that is not world). The two + * never compete by depth: a valid sample of the nearer frame always + * wins (otherwise the older frame's near ceiling covered the newer + * frame's sky at a tunnel exit, and two copies of one surface a few + * px apart interleaved). Where neither is valid the nearest real + * frame underneath shows. Each frame keeps its own depth test. */ + float Mo[12][4]; memset(Mo, 0, sizeof Mo); + if (older) { + for (uint32_t vi = 0; vi < s_fg_fit.nviews && vi < FG_MAX_VIEWS; vi++) { + float A[9], b[3]; + if (!fg_view_affine_older(&s_fg_fit, (int)vi, t, A, b)) continue; + for (int r = 0; r < 3; r++) { + Mo[vi * 3 + r][0] = A[3 * r]; Mo[vi * 3 + r][1] = A[3 * r + 1]; + Mo[vi * 3 + r][2] = A[3 * r + 2]; Mo[vi * 3 + r][3] = b[r]; + } + } + s_rp_older_frames++; + } + p_glUniform1f(s_rp_w_uDbias, 0.0f); + const int older_first = older && t < 0.5; + glClearStencil(0); + glStencilMask(0xFF); + glClear(GL_STENCIL_BUFFER_BIT); + glEnable(GL_STENCIL_TEST); + for (int pass = 0; pass < 2; pass++) { + const int use_older = pass == 0 ? older_first : !older_first; + if (use_older && !older) continue; + /* At the endpoints the nearer frame underneath is exact. */ + if (use_older && t <= 1e-3) continue; + if (!use_older && older && t <= 1e-3) continue; + p_glActiveTexture(PSXGL_TEXTURE0 + 1); + glBindTexture(GL_TEXTURE_2D, use_older ? s_rp_ztex2 : s_rp_ztex); + p_glActiveTexture(PSXGL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, use_older ? s_rp_ls_tex[ok] : s_rp_snap_tex); + p_glUniform4fv(s_rp_w_uM, 12, use_older ? &Mo[0][0] : &M[0][0]); + if (pass == 0) { + glStencilFunc(GL_ALWAYS, 1, 0xFF); + glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); + } else { + glClear(GL_DEPTH_BUFFER_BIT); + glStencilFunc(GL_EQUAL, 0, 0xFF); + glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP); + } + glDrawArrays(GL_TRIANGLES, 0, (GLsizei)(W * H * 6)); + } + glDisable(GL_STENCIL_TEST); + glStencilFunc(GL_ALWAYS, 0, 0xFF); + glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP); + p_glActiveTexture(PSXGL_TEXTURE0 + 1); + glBindTexture(GL_TEXTURE_2D, s_rp_ztex); + p_glActiveTexture(PSXGL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, s_rp_snap_tex); glDisable(GL_DEPTH_TEST); glDepthFunc(GL_LEQUAL); /* The warp's depth (nearest wins, its own scale) is not the scene's: * the cars drawn next test only against each other, as on a fresh * frame (with PGXP depth they would test LEQUAL against it). */ glClear(GL_DEPTH_BUFFER_BIT); - /* 2D and HUD pixels back over the warp, where the real frame has them. */ + /* 2D and HUD pixels back over the warp, where the nearer real frame + * has them (its classification and its picture: never the other + * frame's pixels un-warped). */ + const int keep_older = older && t < 0.5; + if (keep_older) { + p_glActiveTexture(PSXGL_TEXTURE0 + 1); + glBindTexture(GL_TEXTURE_2D, s_rp_ztex2); + p_glActiveTexture(PSXGL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, s_rp_ls_tex[ok]); + } p_glUseProgram(s_rp_rprog); p_glUniform1i(s_rp_r_uSrc, 0); p_glUniform1i(s_rp_r_uZ, 1); @@ -12871,7 +13222,7 @@ static int fg_reproject(const FgList *L, const FgList *O, double t, GLuint dst_f glBindTexture(GL_TEXTURE_2D, ok >= 0 ? s_rp_ls_tex[ok] : s_rp_snap_tex); p_glActiveTexture(PSXGL_TEXTURE0); p_glUniform1i(s_rp_r_uPrev, 2); - p_glUniform1i(s_rp_r_uHasPrev, ok >= 0 && !s_rp_noprev ? 1 : 0); + p_glUniform1i(s_rp_r_uHasPrev, ok >= 0 && !s_rp_noprev && !keep_older ? 1 : 0); p_glUniform1i(s_rp_r_uDbg, s_rp_dbg); glDrawArrays(GL_TRIANGLES, 0, 3); s_rp_t_w = (double)(host_now_ns_rthf() - qw) * 1e-6; @@ -12914,6 +13265,14 @@ static void rp_free(void) { } if (s_rp_zfbo) p_glDeleteFramebuffers(1, &s_rp_zfbo); if (s_rp_ztex) glDeleteTextures(1, &s_rp_ztex); + if (s_rp_zrb) p_glDeleteRenderbuffers(1, &s_rp_zrb); + s_rp_zrb = 0; + if (s_rp_zfbo2) p_glDeleteFramebuffers(1, &s_rp_zfbo2); + if (s_rp_ztex2) glDeleteTextures(1, &s_rp_ztex2); + if (s_rp_zrb2) p_glDeleteRenderbuffers(1, &s_rp_zrb2); + s_rp_zfbo2 = s_rp_ztex2 = s_rp_zrb2 = 0; s_rp_zw2 = s_rp_zh2 = 0; s_rp_key2 = 0; + free(s_rp_vm); s_rp_vm = NULL; s_rp_vmcap = 0; + free(s_rp_pzr); s_rp_pzr = NULL; s_rp_pzr_cap = 0; s_rp_zfbo = s_rp_ztex = 0; s_rp_zw = s_rp_zh = 0; s_rp_key = 0; if (s_rp_vbo) p_glDeleteBuffers(1, &s_rp_vbo); if (s_rp_vao) p_glDeleteVertexArrays(1, &s_rp_vao); @@ -12931,16 +13290,66 @@ static void rp_free(void) { /* Tests: reprojection holds nothing (no program, surface or buffer). */ static int rp_allocated(void) { int any = s_rp_zprog || s_rp_wprog || s_rp_rprog || s_rp_vao || s_rp_vbo || s_rp_evao || - s_rp_zfbo || s_rp_ztex || s_rp_v || s_rp_needle; + s_rp_zfbo || s_rp_ztex || s_rp_v || s_rp_needle || s_rp_zfbo2 || s_rp_ztex2 || s_rp_vm; for (int k = 0; k < 2; k++) any |= s_rp_ls_fbo[k] || s_rp_ls_tex[k]; return any; } +/* Share of the main view covered by 3D geometry the generated frame cannot + * place: triangles drawn after the scene's projected geometry started that + * carry no projection source at all (a game's own near-plane subdivision + * computes those vertices without the GTE). Their depth is unknown, so + * neither a warp nor a redraw can move them with the camera; left in place + * they split from the moving geometry around them (doubled text on a + * passing billboard). Small 2D pieces (HUD) are not counted, nor geometry + * with PGXP depth (the depth images place it). */ +static uint64_t s_fg_skip_unplaced = 0; +static double fg_unplaced_share(const FgList *L) { + if (!L || !L->valid || !L->rec2prim || !s_fg_fit.nviews) return 0.0; + double big = 0.0; + for (uint32_t vi = 0; vi < s_fg_fit.nviews; vi++) { + const float *ar = s_fg_fit.v[vi].area; + const double a = (double)(ar[2] - ar[0]) * (ar[3] - ar[1]); + if (a > big) big = a; + } + if (big <= 0.0) return 0.0; + int seen3d = 0; double area = 0.0; + for (uint32_t r = 0; r < L->n; r++) { + FgRec h; + memcpy(&h, L->buf + L->off[r], sizeof h); + if (h.op != RTH_FLAT_TRI && h.op != RTH_GOURAUD_TRI && h.op != RTH_TEX_TRI && + h.op != RTH_SHADED_TEX_TRI && h.op != RTH_PROJ_TRI) continue; + const int pi = L->rec2prim[r]; + if (pi < 0) continue; + const FgPrim *p = &L->prims.v[pi]; + const int proj = p->vid[0] || p->vid[1] || p->vid[2]; + if (proj) { seen3d = 1; continue; } + if (p->pz[0] >= 1.0f && p->pz[1] >= 1.0f && p->pz[2] >= 1.0f) continue; /* PGXP depth */ + if (!seen3d) continue; /* a backdrop under the scene */ + const float bw = fmaxf(p->x[0], fmaxf(p->x[1], p->x[2])) - fminf(p->x[0], fminf(p->x[1], p->x[2])); + const float bh = fmaxf(p->y[0], fmaxf(p->y[1], p->y[2])) - fminf(p->y[0], fminf(p->y[1], p->y[2])); + if (bw <= 160.0f && bh <= 120.0f && (bw < 24.0f || bh < 24.0f)) continue; /* HUD-sized */ + area += 0.5 * fabs((double)(p->x[1] - p->x[0]) * (p->y[2] - p->y[0]) - + (double)(p->x[2] - p->x[0]) * (p->y[1] - p->y[0])); + } + return area / big; +} +/* PSX_FG_MAX_UNPLACED: the share above which the step shows the real frame + * (default 0.02; 1 = never skip). */ +static double fg_max_unplaced(void) { + static double v = -1.0; + if (v < 0.0) { const char *e = getenv("PSX_FG_MAX_UNPLACED"); v = e && *e ? atof(e) : 0.02; } + return v; +} + static int fg_generate(double t, int swap) { if (s_fg_pa < 0 || s_fg_pb < 0) return 0; FgList *a = &s_fg_l[s_fg_pa], *b = &s_fg_l[s_fg_pb]; if (!a->valid || !b->valid || s_hiw) return 0; if (b->wide && (g_wide_w <= 0 || g_wide_w != b->wide_w)) return 0; + /* No visible artifact first: geometry the camera cannot place covers + * too much of the view, so this step shows the real frame. */ + if (s_fg_reproject && s_fg_fit.ok && fg_unplaced_share(b) > fg_max_unplaced()) { s_fg_skip_unplaced++; return 0; } int iw = -1; if (b->wide) { for (int i = 0; i < WIDE_MAX_SURF; i++) @@ -13668,7 +14077,7 @@ int gl_renderer_frame_gen_json(char *out, int cap) { "\"real_cpu_ms\":%.3f,\"real_gpu_ms\":%.3f,\"ceiling\":%d," "\"trips_late\":%u,\"trips_backed_up\":%u,\"trips_behind\":%u," "\"place_camera\":%u,\"place_object\":%u,\"place_neighbour\":%u," - "\"place_unchanged\":%u,\"cam_angle_deg\":%.3f,\"cam_shift\":%.1f," + "\"place_unchanged\":%u,\"skip_unplaced\":%llu,\"rp_older\":%llu,\"pz_scale\":%.4f,\"cam_angle_deg\":%.3f,\"cam_shift\":%.1f," "\"clamped\":%u,\"guessed\":%u,\"verdict_ok\":%d,\"rejected\":%llu,\"reject_why\":\"%s\",\"noplan_why\":\"%s\",\"rp_ms\":[%.3f,%.3f,%.3f],\"rp_zn\":%u,\"reprojected\":%llu,\"reproject_fallback\":%llu,\"reproject\":%d,\"np\":[%llu,%llu,%llu,%llu],\"end_ahead\":%llu,\"end_bp\":%llu,\"end_phase\":%llu", s_fg_on, s_fg_on && s_rth_on && open, s_fg_force, (unsigned long long)s_fg_generated, (unsigned long long)s_fg_real_presents, @@ -13689,6 +14098,7 @@ int gl_renderer_frame_gen_json(char *out, int cap) { s_fg_real_ema * 1e3, s_fg_real_gpu_ema * 1e3, fg_ceil()->cap, s_fg_trip_late, s_fg_trip_bp, s_fg_trip_behind, s_fg_fit.camera, s_fg_fit.object, s_fg_fit.neighbour, s_fg_fit.unchanged, + (unsigned long long)s_fg_skip_unplaced, (unsigned long long)s_rp_older_frames, (double)s_rp_pzs_last, 2.0 * acos(fmin(1.0, fabs(s_fg_fit.v[0].q[0]))) * 57.29578, sqrt(s_fg_fit.v[0].t[0] * s_fg_fit.v[0].t[0] + s_fg_fit.v[0].t[1] * s_fg_fit.v[0].t[1] + s_fg_fit.v[0].t[2] * s_fg_fit.v[0].t[2]), diff --git a/runtime/src/gpu_vk_renderer.c b/runtime/src/gpu_vk_renderer.c index 6ad261f3..0faa63c6 100644 --- a/runtime/src/gpu_vk_renderer.c +++ b/runtime/src/gpu_vk_renderer.c @@ -688,6 +688,20 @@ static void img_barrier(VkCommandBuffer cb, VkImage img, p_vkCmdPipelineBarrier(cb, src_stage, dst_stage, 0, 0, NULL, 0, NULL, 1, &b); } +/* Clear a swapchain image (TRANSFER_DST_OPTIMAL) to opaque black, then order + * that clear before the next transfer write to the same image. Validation + * reported SYNC-HAZARD-WRITE-AFTER-WRITE for clear -> blit without a barrier; + * on RDNA3 the clear overlapped the blit and tiles of the frame (24-bit FMV + * via vk_renderer_present_cpu) came out black. */ +static void sc_clear_black(VkCommandBuffer cb, VkImage sc) { + VkClearColorValue black = {{0, 0, 0, 1}}; + VkImageSubresourceRange rng = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }; + p_vkCmdClearColorImage(cb, sc, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &black, 1, &rng); + img_barrier(cb, sc, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); +} + /* Map a layout to its (access mask, pipeline stage) for barriers. */ static void layout_access(VkImageLayout l, VkAccessFlags *acc, VkPipelineStageFlags *stage) { switch (l) { @@ -1935,11 +1949,9 @@ int vk_renderer_present_vram(int disp_x, int disp_y, int w, int h, img_barrier(cb, sc, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT, - VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); /* clear the swapchain (letterbox bars) to black */ - VkClearColorValue black = {{0,0,0,1}}; - VkImageSubresourceRange rng = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }; - p_vkCmdClearColorImage(cb, sc, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &black, 1, &rng); + sc_clear_black(cb, sc); VkImageBlit blit = {0}; blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; @@ -1968,10 +1980,8 @@ void vk_renderer_present_blank(void) { if (!acquire_present(&sc, &cb, &idx, &fr)) return; img_barrier(cb, sc, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT, - VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); - VkClearColorValue black = {{0,0,0,1}}; - VkImageSubresourceRange rng = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }; - p_vkCmdClearColorImage(cb, sc, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &black, 1, &rng); + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + sc_clear_black(cb, sc); finish_present(cb, sc, idx, fr); perf_snapshot_present(); } @@ -2042,10 +2052,8 @@ void vk_renderer_present_cpu(const uint32_t *pixels, int src_w, int src_h, img_barrier(cb, sc, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT, - VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); - VkClearColorValue black = {{0,0,0,1}}; - VkImageSubresourceRange rng = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }; - p_vkCmdClearColorImage(cb, sc, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &black, 1, &rng); + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + sc_clear_black(cb, sc); VkOffset3D dst[2]; letterbox((int)s_sc_extent.width, (int)s_sc_extent.height, force_4_3 ? 4 : s_aspect_num, force_4_3 ? 3 : s_aspect_den, dst); @@ -2111,10 +2119,8 @@ int vk_renderer_present_wide(int disp_x, int disp_y, int disp_h, int linear) { img_barrier(cb, sc, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT, - VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); - VkClearColorValue black = {{0, 0, 0, 1}}; - VkImageSubresourceRange rng = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }; - p_vkCmdClearColorImage(cb, sc, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &black, 1, &rng); + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + sc_clear_black(cb, sc); img_to(cb, s_wide_img[i], &s_wide_layout[i], VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL); diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 59b7adc4..7c9c36ce 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -1,3 +1,4 @@ +#include #include "gte.h" #include "gte_view.h" #include "cpu_state.h" @@ -33,8 +34,8 @@ namespace GTE { // // The real PS1 GTE does NOT compute an exact H*0x20000/SZ3. It uses an // Unsigned Newton-Raphson (UNR) reciprocal approximation driven by a 257-entry -// seed table (documented in PSX-SPX "GTE Division Inaccuracy"; identical to the -// mednafen/Beetle oracle in beetle-psx/mednafen/psx/gte.cpp). Exact division +// seed table (documented in PSX-SPX geometrytransformationenginegte, "GTE Division +// Inaccuracy"; the Beetle reference gives the same results). Exact division // diverges from hardware by +/-1 (occasionally up to a few units) on ~25% of // inputs, which is enough to flip games' distance/intensity threshold branches // (e.g. Ape Escape's additive-glow CLUT semi-transparency bit). This is the @@ -766,6 +767,7 @@ static inline int gte_sxx(int32_t p){ int v=p&0xFFFF; return v>=0x8000? v-0x1000 static inline int gte_syy(int32_t p){ int v=(p>>16)&0xFFFF; return v>=0x8000? v-0x10000:v; } static int32_t gte_h_scaled(const GTEState* gte); +extern "C" int gpu_render_projective_enabled(void); static int32_t s_render_view[3]; static PSXModRenderView s_render_pose; static bool s_render_rotate; @@ -1080,7 +1082,7 @@ static int32_t gte_h_scaled(const GTEState* gte) { // --------------------------------------------------------------------------- // RTPS — Perspective Transformation (internal, operates on given vertex V) // -// Matches DuckStation/Beetle (psx-spx): +// PSX-SPX geometrytransformationenginegte, "COP2 0180001h - 15 Cycles - RTPS": // MAC1/2/3 = (TR*1000h + RT*V) SAR (sf*12) // IR1/IR2 = limB(MAC, lm) // IR3 FLAG = limB(MAC3_unshifted SAR 12, lm=0); stored IR3 = limB(MAC3, lm) @@ -1091,8 +1093,10 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) const bool lm = gte_instr_lm(instr); // Step 1: Matrix multiplication + translation + const bool render_projective = s_render_pose.projection && gpu_render_projective_enabled(); + const bool precise_render = render_projective && s_render_pose.precise; auto dot = [&](int row, int mac_num) { - int64_t acc = ((int64_t)gte->TR[row] + (s_render_rotate ? 0 : s_render_view[row])) * 4096 + + int64_t acc = ((int64_t)gte->TR[row] + ((s_render_rotate || precise_render) ? 0 : s_render_view[row])) * 4096 + (int64_t)gte->RT[row][0] * V[0]; acc = gte_mac44_stage(gte, acc, mac_num); acc += (int64_t)gte->RT[row][1] * V[1]; @@ -1102,6 +1106,7 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) int64_t mac1 = dot(0, 1); int64_t mac2 = dot(1, 2); int64_t mac3 = dot(2, 3); + const int64_t render_source[3] = {mac1, mac2, mac3}; if (s_render_rotate) { const int64_t v[3] = {mac1, mac2, mac3}; int64_t result[3]; @@ -1114,6 +1119,17 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) mac1 = result[0]; mac2 = result[1]; mac3 = result[2]; } + if (precise_render) { + int64_t camera[3]; + for (int row=0;row<3;++row) { + double acc = s_render_pose.translation_precise[row]*4096.0; + for (int col=0;col<3;++col) + acc += render_source[col]*s_render_pose.rotation[row*3+col]; + camera[row] = (int64_t)std::llround(acc); + } + mac1=camera[0];mac2=camera[1];mac3=camera[2]; + } + // MAC1..3 overflow flags observe the unshifted 44-bit accumulator. gte->check_mac_overflow(mac1, 1); gte->check_mac_overflow(mac2, 2); @@ -1124,7 +1140,7 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); - // IR3 quirk (psx-spx / DuckStation): FLAG.22 from (mac3>>12) as if lm=0; + // IR3 quirk (PSX-SPX, RTPS note): FLAG.22 from (mac3>>12) as if lm=0; // stored IR3 clamps MAC3 with the real lm bit and does not touch FLAG again. (void)gte->saturate_ir(static_cast(mac3 >> 12), 3, false); { @@ -1218,6 +1234,14 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) ((int64_t)gte->IR2 * s_render_pose.fy_q16 / gte->SZ[3]) * h / ref; } } + if (render_projective && mac3 >= 4096 && shift == 12 && !lm) { + const double scale = s_render_pose.projection_h_ref ? + (double)gte_h_scaled(gte) / s_render_pose.projection_h_ref : 1.0; + sx16 = (int64_t)gte->OFX + s_render_pose.cx_delta_q16 + + (int64_t)std::llround((double)mac1*s_render_pose.fx_q16/mac3*scale); + sy16 = (int64_t)gte->OFY + s_render_pose.cy_delta_q16 + + (int64_t)std::llround((double)mac2*s_render_pose.fy_q16/mac3*scale); + } /* RTPS/RTPT reuse MAC0's overflow flags for each projected X/Y * accumulator before MAC0 is replaced by the depth-cue result. */ gte->check_mac0_overflow(sx16); @@ -1232,6 +1256,7 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) int64_t cx16 = sx16 < -kLim ? -kLim : (sx16 > kLim - 1 ? kLim - 1 : sx16); int64_t cy16 = sy16 < -kLim ? -kLim : (sy16 > kLim - 1 ? kLim - 1 : sy16); int32_t px16 = (int32_t)cx16, py16 = (int32_t)cy16; + bool unclamped_projection = false; /* Preserve projection precision (G1.11): shadow the exact projection * instead when the vertex qualifies and it lies inside the GPU's * truncation-agreement window; outside it the IR path's shadow is @@ -1241,15 +1266,18 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) * passes), where the shadow would be dropped anyway. */ if (!proj_override && pgxp_preserve_projection() && pgxp_active()) { int32_t ex16, ey16; - if (pgxp_project_precise(mac1, mac2, mac3, shift, gte->IR1, gte->IR2, - gte->SZ[3], gte_h_scaled(gte), gte->OFX, gte->OFY, - x_num, x_den, &ex16, &ey16) && - pgxp_ppp_accept(ex16, ey16, (uint32_t)gte->SXY[2])) { + const int precise = pgxp_project_precise(mac1, mac2, mac3, shift, + gte->IR1, gte->IR2, gte->SZ[3], gte_h_scaled(gte), + gte->OFX, gte->OFY, x_num, x_den, &ex16, &ey16); + if (precise == 2 || (precise == 1 && + pgxp_ppp_accept(ex16, ey16, (uint32_t)gte->SXY[2]))) { + unclamped_projection = precise == 2; px16 = ex16; py16 = ey16; } } pgxp_gte_push_sxy(px16, py16, gte->SZ[3], (uint32_t)gte->SXY[2]); + if (unclamped_projection) pgxp_gte_mark_unclamped((uint32_t)(mac3 >> 12)); if (!proj_override && pgxp_projection_tracking() && shift == 12 && !lm) { const double z = (double)(mac3 >> 12); const double h_proj = (double)gte_h_scaled(gte); @@ -1267,6 +1295,24 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) } geom_note((uint32_t)gte->SXY[2], sx16, sy16); + /* A replay eye can see triangles crossing the camera plane. Preserve the + * signed, unsaturated projection alongside the architectural SXY word so + * the GPU clips in homogeneous space before perspective division. */ + if (s_render_pose.projection && gpu_render_projective_enabled() && + !s_gte_replay_sandbox && pgxp_projection_tracking() && shift == 12 && !lm) { + const double camera[3] = {mac1/4096.0, mac2/4096.0, mac3/4096.0}; + const double z = camera[2]; + const double scale = s_render_pose.projection_h_ref ? + (double)gte_h_scaled(gte) / s_render_pose.projection_h_ref : 1.0; + const double fx = s_render_pose.fx_q16 / 65536.0 * scale; + const double fy = s_render_pose.fy_q16 / 65536.0 * scale; + const double cx = (gte->OFX + (double)s_render_pose.cx_delta_q16) / 65536.0; + const double cy = (gte->OFY + (double)s_render_pose.cy_delta_q16) / 65536.0; + const PGXPProjection p = {(float)(cx*z + camera[0]*fx), + (float)(cy*z + camera[1]*fy), (float)z, 1.0f}; + pgxp_gte_set_projection(&p); + } + // Step 5: Depth cueing (MAC0/IR0) — only for last vertex of RTPT or RTPS if (setMac0) { int64_t mac0 = (int64_t)gte->DQA * h_div_sz + gte->DQB; @@ -1502,6 +1548,21 @@ void gte_nclip(GTEState* gte, uint32_t instr) { !s_gte_replay_sandbox && s_speculative_depth == 0) { s_nclip_fallbacks++; } + /* Screen-coordinate saturation can reverse winding at an eye's near + * plane. The signed homogeneous determinant keeps the true facing even + * when one or two corners lie behind the eye. Scope to the render pass. */ + if (s_render_pose.projection && gpu_render_projective_enabled()) { + PGXPProjection p[3]; + if (pgxp_get_gte_projection(0, gte->SXY[0], &p[0]) && + pgxp_get_gte_projection(1, gte->SXY[1], &p[1]) && + pgxp_get_gte_projection(2, gte->SXY[2], &p[2])) { + const double det = (double)p[0].x*((double)p[1].y*p[2].z-(double)p[2].y*p[1].z) + -(double)p[0].y*((double)p[1].x*p[2].z-(double)p[2].x*p[1].z) + +(double)p[0].z*((double)p[1].x*p[2].y-(double)p[2].x*p[1].y); + const int sign = (det > 0) - (det < 0); + if (sign && sign != ((out > 0) - (out < 0))) out = sign; + } + } gte->MAC0 = out; gte->set_error_flag(); } @@ -1509,7 +1570,7 @@ void gte_nclip(GTEState* gte, uint32_t instr) { // --------------------------------------------------------------------------- // AVSZ3 (0x2D) — Average Z (3 points) // --------------------------------------------------------------------------- -// Hardware (psx-spx / DuckStation): MAC0 keeps ZSF3*(SZ1+SZ2+SZ3); only OTZ +// Hardware (PSX-SPX, "AVSZ3"): MAC0 keeps ZSF3*(SZ1+SZ2+SZ3); only OTZ // gets MAC0/1000h. CTR NdIntroWoodEmitHelper indexes the batch OT with // MAC0>>17 (== OTZ>>5). Shifting MAC0 here collapsed every face into slot 0 // (arms/trophy/chest z-fight + missing banner under later same-bucket paint). @@ -1736,8 +1797,10 @@ void gte_mvmva(GTEState* gte, uint32_t instr) { case 1: std::memcpy(M, gte->L, sizeof(M)); break; case 2: std::memcpy(M, gte->LC, sizeof(M)); break; default: { - /* Undocumented matrix selector wiring, verified by hardware - * register vectors (also used by Beetle/DuckStation). */ + /* Matrix selector 3: PSX-SPX geometrytransformationenginegte, + * "MVMVA": "Setting Mx=3 selects a garbage matrix" (-R*10h, + * +R*10h, IR0, RT13 x3, RT22 x3), verified by hardware + * register vectors. */ const int16_t color = static_cast( static_cast(gte->RGBC & 0xFFu) << 4); M[0][0] = static_cast(-color); diff --git a/runtime/src/host_time.c b/runtime/src/host_time.c index c70cd94b..4fb5f435 100644 --- a/runtime/src/host_time.c +++ b/runtime/src/host_time.c @@ -21,31 +21,49 @@ #endif static LARGE_INTEGER s_qpc_freq; -static int s_qpc_freq_init; -static HANDLE s_sleep_timer; -static int s_sleep_timer_init; +static INIT_ONCE s_time_once = INIT_ONCE_STATIC_INIT; +static DWORD s_sleep_timer_slot = FLS_OUT_OF_INDEXES; -static void sleep_timer_ensure(void) +static void CALLBACK sleep_timer_close(void *timer) { - if (s_sleep_timer_init) - return; - s_sleep_timer_init = 1; - s_sleep_timer = + if (timer) CloseHandle((HANDLE)timer); +} + +static BOOL CALLBACK time_initialize(PINIT_ONCE once, PVOID parameter, PVOID *context) +{ + (void)once; (void)parameter; (void)context; + if (!QueryPerformanceFrequency(&s_qpc_freq)) s_qpc_freq.QuadPart = 0; + /* An auto-reset timer has one pending deadline and wakes one waiter. + * Sharing it lets a render/present worker cancel the guest pacer's wait. + * FLS also releases the handle when its thread/guest fiber is destroyed. */ + s_sleep_timer_slot = FlsAlloc(sleep_timer_close); + return TRUE; +} + +static HANDLE sleep_timer_get(void) +{ + HANDLE timer; + if (!InitOnceExecuteOnce(&s_time_once, time_initialize, NULL, NULL) || + s_sleep_timer_slot == FLS_OUT_OF_INDEXES) + return NULL; + timer = (HANDLE)FlsGetValue(s_sleep_timer_slot); + if (timer) return timer; + timer = CreateWaitableTimerExW(NULL, NULL, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS); - if (!s_sleep_timer) - s_sleep_timer = CreateWaitableTimerW(NULL, FALSE, NULL); + if (!timer) timer = CreateWaitableTimerW(NULL, FALSE, NULL); + if (timer && !FlsSetValue(s_sleep_timer_slot, timer)) { + CloseHandle(timer); + return NULL; + } + return timer; } uint64_t psx_host_mono_ms(void) { LARGE_INTEGER counter; - if (!s_qpc_freq_init) { - if (!QueryPerformanceFrequency(&s_qpc_freq)) - s_qpc_freq.QuadPart = 0; - s_qpc_freq_init = 1; - } + InitOnceExecuteOnce(&s_time_once, time_initialize, NULL, NULL); if (s_qpc_freq.QuadPart == 0) return (uint64_t)GetTickCount64(); if (!QueryPerformanceCounter(&counter)) @@ -57,18 +75,21 @@ void psx_host_sleep_micros(unsigned usec) { LARGE_INTEGER due; LONGLONG hundred_ns; + HANDLE timer; if (usec == 0u) return; - sleep_timer_ensure(); - if (s_sleep_timer) { + timer = sleep_timer_get(); + if (timer) { hundred_ns = -((LONGLONG)usec * 10LL); if (hundred_ns >= 0) hundred_ns = -1LL; due.QuadPart = hundred_ns; - if (SetWaitableTimer(s_sleep_timer, &due, 0, NULL, NULL, FALSE)) { - (void)WaitForSingleObject(s_sleep_timer, INFINITE); + /* Bounded: a missed signal costs at most the sleep plus 2 ms, never + * an indefinitely parked thread. */ + if (SetWaitableTimer(timer, &due, 0, NULL, NULL, FALSE)) { + (void)WaitForSingleObject(timer, (DWORD)(usec / 1000u) + 2u); return; } } diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index 275c9a02..c229733b 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -145,14 +145,13 @@ extern uint32_t i_mask; * again sees a pending interrupt that no longer exists and can spin in its * event scan forever. * - * Verified against the independent Beetle oracle rather than asserted: - * beetle-psx/mednafen/psx/irq.cpp defines - * #define Recalc() PSX_CPU->AssertIRQ(0, (bool)(Status & Mask)) - * and calls it from IRQ_Assert (raise), from IRQ_Write for BOTH the Status ack - * and the Mask write, and at power-on; cpu.cpp's AssertIRQ clears bit (10+n) - * unconditionally and re-sets it only when the level is asserted. So the line - * is recomputed at every point (I_STAT & I_MASK) can change, which is exactly - * the set of call sites below. + * PSX-SPX interrupts, "PSX specific COP0 Notes": cop0r13.bit10 "is NOT a + * latch, ie. it gets automatically cleared as soon as (I_STAT AND + * I_MASK)=zero", and "Interrupt Request / Execution": it is set while + * (I_STAT AND I_MASK) is nonzero. So the bit is recomputed at every point + * (I_STAT & I_MASK) can change: a raise, a Status ack, a Mask write and + * power-on, which is exactly the set of call sites below. The Beetle + * reference behaves the same. * * Ownership: this function is the ONLY writer of CAUSE bit 10. The delivery * path no longer ORs it in separately — one owner, no divergence. @@ -173,7 +172,7 @@ void psx_irq_refresh_cause_ip2(void) void psx_irq_set_cause_ptr(uint32_t *p) { s_cause_ptr = p; - /* Power-on recompute, mirroring Beetle's IRQ_Power() -> Recalc(). Without + /* Power-on recompute (the Beetle reference also recomputes here). Without * this the first mirror only happens at the first raise/ack, so a CAUSE * read before any interrupt activity would show a stale bit. */ psx_irq_refresh_cause_ip2(); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index b46655f8..6bf5eea9 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -82,6 +82,9 @@ extern "C" uint32_t g_guest_last_store_addr, g_guest_last_store_val; #ifndef PSX_MAX_PLAYERS #define PSX_MAX_PLAYERS 2 #endif +#ifndef PSX_MEMCARD_SLOTS +#define PSX_MEMCARD_SLOTS 2 /* runtime.cmake MEMCARD_SLOTS */ +#endif #include "psx_netplay.h" #include "psx_stick.h" /* radial SDL-stick -> DualShock response transform */ #include "psx_trigger.h" /* continuous SDL trigger -> 0..255 magnitude */ @@ -1262,6 +1265,23 @@ static int g_video_post_aa = 0; /* [video] antialiasing_mode / PSX_A static float g_video_bloom = 0.0f; /* [video] bloom (PSX_BLOOM overrides) */ static std::string g_video_post_shader; /* [video] post_shader (PSX_POST_SHADER overrides) */ static int g_video_ss_milli = 1000; /* [video] supersample / PSX_SUPERSAMPLE */ + +/* [video] match_display_max_lines (0 = none): the display height Match + * display, its dynamic-resolution floor and the quality detection resolve + * against, capped. On an 8K panel Match display x1.5 asks 27x, past the + * full-VRAM surface, where the high-resolution window engages and dynamic + * resolution cannot step; a title can cap it at, say, 4K lines. */ +static int g_video_match_max_lines = 0; +static int g_video_ss_disable_at_lines = 0; +static int display_supersample(SDL_Window *window) { + return psx_ir_display_supersample(g_video_ss_milli, + psx_sdl_display_pixel_height(window), g_video_ss_disable_at_lines); +} +static int match_display_px_h(SDL_Window *window) { + const int h = psx_sdl_display_pixel_height(window); + return (g_video_match_max_lines > 0 && h > g_video_match_max_lines) + ? g_video_match_max_lines : h; +} /* FMV present reconstruction (VIDEO_FMV_FILTER_*), pushed to the GL renderer * once the config is resolved. Only consulted while g_video_aa is on. */ static int g_video_fmv_filter = PSXRecompV4::VIDEO_FMV_FILTER_DEFAULT; @@ -1439,9 +1459,8 @@ static int effective_internal_resolution(void) { static void apply_internal_resolution(int display_px_h) { const int preset = effective_internal_resolution(); if (preset == PSX_IR_UNSET) return; - g_video_scale = psx_resolve_internal_scale_ss(preset, g_video_ref_lines, - display_px_h, video_scale_ceiling(), - g_video_ss_milli); + g_video_scale = psx_resolve_internal_scale_ss_cap(preset, g_video_ref_lines, + display_px_h, video_scale_ceiling(), display_supersample(nullptr), g_video_match_max_lines); } /* The value the launcher row starts on: the preset, or the legacy factor shown @@ -1771,6 +1790,7 @@ static int present_effective_swap_interval(void); static int present_should_wall_pace(void); static void apply_present_cadence(void); static void refresh_host_display_cadence(int force_log, int force_probe); +static void log_present_cadence(void); /* FMV auto-skip detection hooks (cdrom.c / mdec.c). */ @@ -3939,8 +3959,14 @@ static void sync_guest_cadence_to_video_standard(void) { psx_video_timing_is_pal() ? "PAL" : "NTSC", 1000.0 / period_ms); #ifndef PSX_SDL_NO_RENDER - if (sdl_window) - refresh_host_display_cadence(1, 0); + /* Force the probe too: the once-a-second probe throttle returns before + * force_log is looked at, so a standard switch inside that second (PAL + * boot -> NTSC title) kept the boot decision -- driver vsync off with a + * matching 60 Hz panel, an unsynchronised present for the whole session. */ + if (sdl_window) { + refresh_host_display_cadence(1, 1); + log_present_cadence(); + } if (g_frame_interpolation && g_gl_active) gl_renderer_set_interpolation(g_frame_interpolation, g_host_refresh_hz, (double)g_frame_interpolation_fps, @@ -10184,7 +10210,7 @@ static void dynres_setup(void) { int floor_s = psx_dynres_floor_scale(dynres_min_value(), effective_internal_resolution(), g_video_ref_lines, - psx_sdl_display_pixel_height(nullptr), + match_display_px_h(nullptr), ceiling); if (floor_s > ceiling) floor_s = ceiling; DynresParams params; @@ -16460,7 +16486,7 @@ namespace { void quality_fill_game_info(RecompLauncherCGameInfo* gi) { if (!g_quality_offered) return; quality_publish_strings(); - g_quality_display_h = psx_sdl_display_pixel_height(nullptr); + g_quality_display_h = match_display_px_h(nullptr); gi->quality_offered_mask = (int)g_quality_offered; gi->quality_detected = (g_quality.detected >= 0 && g_quality.detected < PSX_QUALITY_COUNT) ? g_quality.detected + 1 : 0; @@ -16552,6 +16578,9 @@ namespace { * the generic Display -> View mode launcher row. */ gi->widescreen_supported = 0; gi->num_players = game_players_n; +#if defined(RECOMP_LAUNCHER_HAS_MEMCARD_SLOTS) + gi->memcard_slots = PSX_MEMCARD_SLOTS; +#endif #if defined(RECOMP_LAUNCHER_HAS_SHARED_GAMEPAD) && !defined(PSX_NO_DEBUG_TOOLS) gi->allow_shared_gamepad = 1; #endif @@ -17089,7 +17118,7 @@ static void recomp_overlay_setup(const char* argv0, const std::string& title) { g_ovl_q_reason = g_quality.reason; g_ovl_q_detected = (g_quality.detected >= 0 && g_quality.detected < PSX_QUALITY_COUNT) ? g_quality.detected + 1 : 0; - g_quality_display_h = psx_sdl_display_pixel_height(nullptr); + g_quality_display_h = match_display_px_h(nullptr); { std::lock_guard lk(g_ovl_mu); g_ovl_q_sel.reset(); @@ -17834,6 +17863,8 @@ int main(int argc, char** argv) { gc.runtime.video_depth24_trailing_margin; g_video_internal_res = gc.runtime.video_internal_resolution; g_video_ref_lines = gc.runtime.video_resolution_reference_lines; + g_video_match_max_lines = gc.runtime.video_match_display_max_lines; + g_video_ss_disable_at_lines = gc.runtime.video_supersample_disable_at_lines; g_video_dynres = gc.runtime.video_dynamic_resolution ? 1 : 0; g_video_dynres_min = gc.runtime.video_dynamic_resolution_min; if (gc.runtime.video_window_width > 0) { @@ -18139,6 +18170,9 @@ int main(int argc, char** argv) { * must never have its live code scanned and rewritten. */ gpu_ws_set_auto_hooks(gc.ws_auto_screen_x_cull ? 1 : 0, gc.ws_auto_backdrop_preload ? 1 : 0); + gpu_ws_set_cull_screen_x_sites( + gc.ws_cull_screen_x_sites.data(), + (int)gc.ws_cull_screen_x_sites.size()); if (!gc.ws_cull_w_imms.empty() || !gc.ws_cull_h_imms.empty()) gpu_ws_set_cull_imms(gc.ws_cull_w_imms.data(), (int)gc.ws_cull_w_imms.size(), gc.ws_cull_h_imms.data(), (int)gc.ws_cull_h_imms.size()); @@ -18996,7 +19030,7 @@ int main(int argc, char** argv) { const int ir_preset_seeded = g_video_internal_res; const int ir_ss_seeded = psx_ir_launcher_seed_supersampling( kLauncherHasInternalResolution, g_video_internal_res, g_video_scale, - g_video_ref_lines, psx_sdl_display_pixel_height(nullptr)); + g_video_ref_lines, match_display_px_h(nullptr)); seed.supersampling = ir_ss_seeded; seed.has_supersampling = true; int ir_row_result = PSX_IR_UNSET; /* the row's pick, when it has one */ #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) @@ -19872,7 +19906,7 @@ int main(int argc, char** argv) { const PsxIrAdopted ir = psx_ir_adopt_launcher( kLauncherHasInternalResolution, ir_preset_seeded, ir_ss_seeded, seed.supersampling, ir_row_result, g_video_ref_lines, - psx_sdl_display_pixel_height(nullptr), video_scale_ceiling()); + match_display_px_h(nullptr), video_scale_ceiling()); g_video_internal_res = ir.preset; g_video_scale = ir.scale; seed.supersampling = ir.save_ss; @@ -20324,10 +20358,11 @@ session_reboot: else if (!std::strcmp(e, "fxaa_hq")) g_video_post_aa = GL_POST_AA_FXAA_HQ; else std::fprintf(stdout, "psxrecomp: PSX_AA_MODE=%s not understood (off, fxaa, fxaa_hq)\n", e); } - if (g_video_ss_milli != 1000) + const int effective_ss = display_supersample(nullptr); + if (!g_video_ss_disable_at_lines && effective_ss != 1000) std::fprintf(stdout, "psxrecomp: supersample %.3gx on the internal-resolution target\n", - g_video_ss_milli / 1000.0); - apply_internal_resolution(psx_sdl_display_pixel_height(nullptr)); + effective_ss / 1000.0); + apply_internal_resolution(match_display_px_h(nullptr)); if (g_video_scale < 1) g_video_scale = 1; if (const char* e = std::getenv("PSX_DYNRES")) g_video_dynres_env = (*e && *e != '0') ? 1 : 0; @@ -20715,9 +20750,12 @@ session_reboot: { std::string mc1 = memcard1_path.string(); std::string mc2 = memcard2_path.string(); + /* A one-slot title (MEMCARD_SLOTS 1) never reads port 2: leave it empty + * whatever settings.toml or the launcher stored. */ + const bool slot2_on = memcard2_enabled && PSX_MEMCARD_SLOTS >= 2; const MemcardSlotConfig slots[2] = { { mc1.empty() ? nullptr : mc1.c_str(), memcard1_enabled ? 1 : 0 }, - { mc2.empty() ? nullptr : mc2.c_str(), memcard2_enabled ? 1 : 0 }, + { mc2.empty() ? nullptr : mc2.c_str(), slot2_on ? 1 : 0 }, }; memcard_init_slots(memcard_dir_str.c_str(), slots); } @@ -20964,17 +21002,22 @@ session_reboot: /* Match display: the monitor's pixel height, now that the window says * which monitor. glb_set_scale only records the request; the hr * surface is allocated at context init below. */ - if (effective_internal_resolution() == PSX_IR_DISPLAY && g_video_scale_applies) { - const int dh = psx_sdl_display_pixel_height(sdl_window); - const int s = psx_resolve_internal_scale_ss(PSX_IR_DISPLAY, g_video_ref_lines, - dh, GL_MAX_INTERNAL_SCALE, - g_video_ss_milli); + const int ir = effective_internal_resolution(); + if (g_video_scale_applies && ir != PSX_IR_UNSET && + (ir == PSX_IR_DISPLAY || g_video_ss_disable_at_lines > 0)) { + const int dh = match_display_px_h(sdl_window); + const int ss = display_supersample(sdl_window); + const int s = psx_resolve_internal_scale_ss_cap(ir, g_video_ref_lines, + dh, GL_MAX_INTERNAL_SCALE, ss, g_video_match_max_lines); gr_set_scale(s); g_video_requested_scale = s; - std::fprintf(stdout, "psxrecomp: internal resolution Match display: " - "%d px -> %dx requested\n", dh, s); + const char *label = psx_ir_label_for(ir); + std::fprintf(stdout, "psxrecomp: internal resolution %s: " + "%d px -> %dx requested (supersample %.3gx)\n", + label ? label : "custom", dh, s, ss / 1000.0); } gl_renderer_set_swap_interval(present_effective_swap_interval()); /* applied at context init */ + /* Dynamic resolution: the surfaces are allocated at the scale above * (the ceiling) and the level steps under it (dynres_setup). */ #ifndef PSX_NO_DEBUG_TOOLS @@ -20986,7 +21029,7 @@ session_reboot: if (dbg_live_scale) { const int ds = psx_resolve_internal_scale( PSX_IR_DISPLAY, g_video_ref_lines, - psx_sdl_display_pixel_height(sdl_window), GL_MAX_INTERNAL_SCALE); + match_display_px_h(sdl_window), GL_MAX_INTERNAL_SCALE); if (ds > dbg_start_scale) gr_set_scale(ds); } #else @@ -21831,7 +21874,7 @@ soft_return_lobby: const int ir_preset_seeded = g_video_internal_res; const int ir_ss_seeded = psx_ir_launcher_seed_supersampling( kLauncherHasInternalResolution, g_video_internal_res, g_video_scale, - g_video_ref_lines, psx_sdl_display_pixel_height(nullptr)); + g_video_ref_lines, match_display_px_h(nullptr)); ls.supersampling = ir_ss_seeded; #if defined(RECOMP_LAUNCHER_HAS_INTERNAL_RESOLUTION) ls.internal_resolution = internal_resolution_for_launcher(); @@ -22161,7 +22204,7 @@ soft_return_lobby: const PsxIrAdopted ir = psx_ir_adopt_launcher( kLauncherHasInternalResolution, ir_preset_seeded, ir_ss_seeded, ls.supersampling, ir_row_result, g_video_ref_lines, - psx_sdl_display_pixel_height(nullptr), + match_display_px_h(nullptr), ls.renderer == 1 ? GL_MAX_INTERNAL_SCALE : SW_MAX_INTERNAL_SCALE); /* Persist controller (and rematch video) choices without wiping * the rest of settings.toml — merge into the on-disk file. */ diff --git a/runtime/src/memcard.c b/runtime/src/memcard.c index 8ff08baa..d3ba4b45 100644 --- a/runtime/src/memcard.c +++ b/runtime/src/memcard.c @@ -173,7 +173,9 @@ static uint8_t frame_checksum(const uint8_t *frame) { } /* Format a 128KB card image to match a real PS1 blank card. - * Layout verified against DuckStation's MemoryCardImage::Format(). */ + * Layout per PSX-SPX controllersandmemorycards, "Memory Card Data Format": + * header frame, directory frames (free A0h, next FFFFh), broken sector + * list (FFFFFFFFh = none) and the write test frame 63. */ static void memcard_format(uint8_t *data) { memset(data, 0xFF, MEMCARD_SIZE); diff --git a/runtime/src/memory.c b/runtime/src/memory.c index f994d3cc..53b3c460 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1689,8 +1689,8 @@ uint32_t psx_read_word(uint32_t addr) { } /* Physical address of a CPU/DMA main-RAM access. Fold KUSEG/KSEG0/KSEG1 first * (0x1FFFFFFF), then fold the 2nd-4th main-RAM mirrors: real hardware mirrors the - * 2 MB DRAM across the WHOLE 0..0x7FFFFF physical window (Beetle libretro.cpp:874 - * `A < 0x00800000` routes to main RAM; psx-spx "2048K RAM ... mirrored 4x"). A game + * 2 MB DRAM across the WHOLE 0..0x7FFFFF physical window (PSX-SPX memorymap, + * "Memory Mirrors": "2MB RAM can be mirrored to the first 8MB"). A game * may legitimately place its stack at the top of that window — Tsumu Light computes * sp = (ramtop-8)|0x80000000 with its ramtop constant 0x00800000, giving sp=0x807FFFF8 * (top of the 4th mirror). Without this fold those accesses miss DRAM (`< RAM_SIZE` @@ -1701,9 +1701,9 @@ static uint32_t psx_read_word_raw(uint32_t addr) { /* KSEG2 cache control — before physical translation. */ if (addr == 0xFFFE0130u) return cache_ctrl; /* KSEG2 (0xC0000000+): only cache control (0xFFFE0130, above where - * applicable) exists there. Real hardware maps NOTHING else — Beetle - * (cpu.cpp addr_mask[6..7]=0xFFFFFFFF) leaves KSEG2 addresses unmasked - * so they fall to unmapped space and the access is a no-op. Our flat + * applicable) exists there. PSX-SPX memorymap ("Memory Map") lists only + * the cache control registers at FFFE0000h in KSEG2; every other KSEG2 + * access is unmapped and is a no-op here, as on the Beetle reference. Our flat * 0x1FFFFFFF masking routed KSEG2 garbage onto LIVE registers: Tomba2's * attract runs a BIOS bzero over a wild 0xDF80xxxx pointer, which zeroed * the SIO/memctrl I/O block byte-by-byte and then hit the unmapped-MMIO diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 28792e85..f44d43ab 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -46,6 +46,7 @@ extern "C" const char* host_launch_timing_stage_name(uint32_t stage) { #include #include #include +#include #include #include #include @@ -3314,6 +3315,40 @@ size_t g_mod_counter_count = 0; uint64_t g_mod_counter_overflow = 0; } // namespace +namespace { +struct ModCommand { char name[64]; PSXModCommandFn fn; }; +std::mutex g_mod_commands_lock; +ModCommand g_mod_commands[16]; +int g_mod_command_count = 0; +} // namespace + +extern "C" int psx_mod_register_command(const char* name, PSXModCommandFn fn) { + if (!name || !name[0] || std::strlen(name) >= sizeof(g_mod_commands[0].name) || !fn) return 0; + std::lock_guard lock(g_mod_commands_lock); + for (int i = 0; i < g_mod_command_count; ++i) + if (std::strcmp(g_mod_commands[i].name, name) == 0) { g_mod_commands[i].fn = fn; return 1; } + if (g_mod_command_count >= 16) return 0; + auto& entry = g_mod_commands[g_mod_command_count++]; + std::memcpy(entry.name, name, std::strlen(name) + 1); + entry.fn = fn; + return 1; +} + +extern "C" int psx_mod_run_command(const char* name, const char* args, char* reply, int cap) { + if (!reply || cap <= 0) return -1; + PSXModCommandFn fn = nullptr; + { + std::lock_guard lock(g_mod_commands_lock); + for (int i = 0; i < g_mod_command_count; ++i) + if (name && std::strcmp(g_mod_commands[i].name, name) == 0) fn = g_mod_commands[i].fn; + } + if (cap > 0) reply[0] = '\0'; + if (!fn) return -1; + const int result = fn(args ? args : "", reply, cap); + reply[cap - 1] = '\0'; + return result; +} + extern "C" void psx_mod_counter_add(const char* name, uint32_t delta) { if (!name || !name[0]) return; for (size_t i = 0; i < g_mod_counter_count; i++) { diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp index df92faf0..6eb0abeb 100644 --- a/runtime/src/pgxp.cpp +++ b/runtime/src/pgxp.cpp @@ -63,6 +63,7 @@ enum { * precise value may sit up to one pixel below or two above it. */ PGXP_F_DX = 1u << 4, PGXP_F_DY = 1u << 5, + PGXP_F_UNCLAMPED = 1u << 6, /* exact GTE projection; intact word only */ PGXP_F_VXY = PGXP_F_VX | PGXP_F_VY, }; @@ -542,6 +543,29 @@ extern "C" void pgxp_checkpoint_rollback(void) { } } +extern "C" int pgxp_projective_scope_begin(PGXPProjectiveScope *scope) { + if (!scope || scope->active) return 0; + scope->saved_enabled = s_enabled; + scope->saved_cpu_mode = s_cpu_mode; + if (!scope->saved_enabled) { + pgxp_set_enabled(1); + if (!s_enabled) { + s_cpu_mode = scope->saved_cpu_mode; + return 0; + } + } + s_cpu_mode = 1; + scope->active = 1; + return 1; +} + +extern "C" void pgxp_projective_scope_end(PGXPProjectiveScope *scope) { + if (!scope || !scope->active) return; + s_cpu_mode = scope->saved_cpu_mode; + if (!scope->saved_enabled) pgxp_set_enabled(0); + scope->active = 0; +} + static inline int pv_live(const PGXPValue *pv) { return pv && pv->gen == s_gen && (pv->flags & PGXP_F_VXY) != 0; } @@ -551,7 +575,7 @@ static inline int pv_live(const PGXPValue *pv) { static inline void pv_validate(PGXPValue *pv, uint32_t actual) { if (pv->gen != s_gen) return; uint32_t diff = pv->value ^ actual; - if (diff) pv->flags &= (uint16_t)~PGXP_F_PROJECTION; + if (diff) pv->flags &= (uint16_t)~(PGXP_F_PROJECTION | PGXP_F_UNCLAMPED); if ((pv->flags & PGXP_F_VX) && (diff & 0x0000FFFFu)) pv->flags &= (uint16_t)~(PGXP_F_VX | PGXP_F_DX | PGXP_F_VZ); if ((pv->flags & PGXP_F_VY) && (diff & 0xFFFF0000u)) @@ -696,7 +720,7 @@ extern "C" void psx_pgxp_load(struct CPUState *cpu, uint32_t instr, * cpu-mode keeps working. */ dst->y16 = ((int32_t)value >> 16) << 16; dst->flags = PGXP_F_VY; - if (src && src->gen == s_gen) { + if (src && src->gen == s_gen && !(src->flags & PGXP_F_UNCLAMPED)) { 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; @@ -768,7 +792,7 @@ extern "C" void psx_pgxp_store(struct CPUState *cpu, uint32_t instr, 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_DY) : (PGXP_F_VX | PGXP_F_DX)) | - PGXP_F_VZ | PGXP_F_PROJECTION); + PGXP_F_VZ | PGXP_F_PROJECTION | PGXP_F_UNCLAMPED); dst->z = 0; int ok = 0, derived = 0; int32_t v16 = 0; @@ -782,7 +806,8 @@ extern "C" void psx_pgxp_store(struct CPUState *cpu, uint32_t instr, * coordinate half here. */ if (hi_half) { dst->y16 = v16; dst->flags |= (uint16_t)(PGXP_F_VY | (derived ? PGXP_F_DY : 0)); } else { dst->x16 = v16; dst->flags |= (uint16_t)(PGXP_F_VX | (derived ? PGXP_F_DX : 0)); } - } else if (src && src->gen == s_gen && (src->flags & PGXP_F_VX) && + } else if (src && src->gen == s_gen && !(src->flags & PGXP_F_UNCLAMPED) && + (src->flags & PGXP_F_VX) && ((src->value ^ value) & 0xFFFFu) == 0) { const uint16_t d = (src->flags & PGXP_F_DX) ? (hi_half ? PGXP_F_DY : PGXP_F_DX) : 0; if (hi_half) { dst->y16 = src->x16; dst->flags |= (uint16_t)(PGXP_F_VY | d); } @@ -797,7 +822,7 @@ extern "C" void psx_pgxp_store(struct CPUState *cpu, uint32_t instr, dst->value = (dst->value & ~(0xFFu << shift)) | ((value & 0xFFu) << shift); dst->flags &= (uint16_t)~(((addr & 2u) ? (PGXP_F_VY | PGXP_F_DY) : (PGXP_F_VX | PGXP_F_DX)) | - PGXP_F_VZ | PGXP_F_PROJECTION); + PGXP_F_VZ | PGXP_F_PROJECTION | PGXP_F_UNCLAMPED); dst->z = 0; return; } @@ -937,7 +962,7 @@ extern "C" void psx_pgxp_cop2(struct CPUState *cpu, uint32_t instr, * 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 (pv && pv->gen == s_gen && !(pv->flags & PGXP_F_UNCLAMPED)) { if (!hi_half && (pv->flags & PGXP_F_VX) && ((pv->value ^ value) & 0x0000FFFFu) == 0) return pv->x16; @@ -951,7 +976,7 @@ static inline int32_t comp16(const PGXPValue *pv, uint32_t value, int hi_half) { /* The derived-half marks (PGXP_F_DX / DY) an operand contributes through * comp16: a tracked derived half keeps its rounding window in the sum. */ static inline uint16_t derived_bits(const PGXPValue *pv, uint32_t value) { - if (!pv || pv->gen != s_gen) return 0; + if (!pv || pv->gen != s_gen || (pv->flags & PGXP_F_UNCLAMPED)) return 0; uint16_t d = 0; if ((pv->flags & PGXP_F_DX) && (pv->flags & PGXP_F_VX) && ((pv->value ^ value) & 0x0000FFFFu) == 0) d |= PGXP_F_DX; @@ -961,7 +986,7 @@ static inline uint16_t derived_bits(const PGXPValue *pv, uint32_t value) { } static inline int comp_tracked(const PGXPValue *pv, uint32_t value, int hi_half) { - if (!pv || pv->gen != s_gen) return 0; + if (!pv || pv->gen != s_gen || (pv->flags & PGXP_F_UNCLAMPED)) return 0; if (!hi_half) return (pv->flags & PGXP_F_VX) && ((pv->value ^ value) & 0x0000FFFFu) == 0; @@ -1504,13 +1529,26 @@ extern "C" int pgxp_project_precise(int64_t mac1, int64_t mac2, int64_t mac3, int64_t x_num, int64_t x_den, int32_t *x16, int32_t *y16) { /* Qualify exactly the vertices whose guest projection is the plain - * IR * (H / SZ3) of the continuous one: sf=1, no IR1/IR2 clamp (the - * stored IR equals MAC >> 12), SZ3 unclamped and nonzero, and no UNR + * IR * (H / SZ3) of the continuous one: sf=1, verified IR1/IR2 conversion, SZ3 unclamped and nonzero, and no UNR * divide overflow (the GTE saturates the quotient when H >= 2*SZ3). */ if (shift != 12 || x_den <= 0) return 0; - if ((mac1 >> 12) != (int64_t)ir1 || (mac2 >> 12) != (int64_t)ir2) return 0; + const int64_t ix = mac1 >> 12, iy = mac2 >> 12; const int64_t z_int = mac3 >> 12; - if (z_int <= 0 || z_int > 0xFFFF || (uint32_t)z_int != sz3) return 0; + if (z_int <= 0) return 0; + const bool xy_clamped = ix != ir1 || iy != ir2; + const bool z_clamped = z_int > 0xFFFF; + const bool unclamped = xy_clamped || z_clamped; + if (unclamped) { + /* Only the opt-in exact projection may bypass IR saturation. Reject + * wrapped accumulators, lm=1 sign clamps and every other mismatch. */ + const int64_t lo = -(INT64_C(1) << 43), hi = (INT64_C(1) << 43) - 1; + const int64_t cx = ix < -32768 ? -32768 : ix > 32767 ? 32767 : ix; + const int64_t cy = iy < -32768 ? -32768 : iy > 32767 ? 32767 : iy; + if (!s_preserve_projection || mac1 < lo || mac1 > hi || + mac2 < lo || mac2 > hi || mac3 < lo || mac3 > hi || + cx != ir1 || cy != ir2) return 0; + } + if ((uint32_t)(z_clamped ? 0xFFFF : z_int) != sz3) return 0; if ((int64_t)h >= 2 * z_int) return 0; /* (MAC/4096) * H / (MAC3/4096): the 4096s cancel. Double keeps ~52 bits, * far below the 1/65536 px the shadow carries. Host-only and visual-only, @@ -1528,7 +1566,7 @@ extern "C" int pgxp_project_precise(int64_t mac1, int64_t mac2, int64_t mac3, if (fy > lim - 1.0) fy = lim - 1.0; *x16 = (int32_t)std::floor(fx); *y16 = (int32_t)std::floor(fy); - return 1; + return unclamped ? 2 : 1; } /* Defined below with the GPU consumer. */ @@ -1562,6 +1600,21 @@ extern "C" void pgxp_gte_push_sxy(int32_t x16, int32_t y16, uint16_t sz3, s_gte[15] = *pv; /* SXYP mirrors SXY2 */ } +extern "C" void pgxp_gte_mark_unclamped(uint32_t depth) { + if (!g_pgxp_active) return; + s_gte[14].flags |= PGXP_F_UNCLAMPED; + s_gte[14].projection.z = (float)depth; + s_gte[15] = s_gte[14]; +} + +extern "C" float pgxp_get_unclamped_depth(uint32_t addr, uint32_t word) { + if (!g_pgxp_active || !s_preserve_projection) return 0.0f; + const PGXPValue *pv = pgxp_ptr(addr); + if (!pv || pv->gen != s_gen || pv->value != word || + !(pv->flags & PGXP_F_UNCLAMPED) || !(pv->flags & PGXP_F_VZ)) return 0.0f; + return pv->projection.z; +} + extern "C" int pgxp_get_gte_sxy(uint32_t index, int32_t *x16, int32_t *y16) { return pgxp_get_gte_sxy_checked(index, 0u, 0, x16, y16); } @@ -1695,8 +1748,12 @@ static inline int pgxp_accept(int32_t px, int32_t py, int32_t int_x, int32_t int_y, uint32_t word, uint16_t flags) { const int dx_derived = (flags & PGXP_F_DX) != 0; const int dy_derived = (flags & PGXP_F_DY) != 0; - if (!pgxp_agrees(px, int_x, (int16_t)(word & 0xFFFFu), dx_derived) || - !pgxp_agrees(py, int_y, (int16_t)(word >> 16), dy_derived)) + /* This tag can only ride an intact, value-validated GTE word. Arithmetic + * constructs new flags and loses it; the position-cache tier never has it. + * Clamped IR is an integer hardware limit, not the enhanced visual vertex. */ + if (!(s_preserve_projection && (flags & PGXP_F_UNCLAMPED)) && + (!pgxp_agrees(px, int_x, (int16_t)(word & 0xFFFFu), dx_derived) || + !pgxp_agrees(py, int_y, (int16_t)(word >> 16), dy_derived))) return 1; if (s_tolerance >= 0.0f) { if (pgxp_moved(px, int_x, dx_derived) > s_tolerance || diff --git a/runtime/src/psx_icache.c b/runtime/src/psx_icache.c index 60749aa4..1aa40992 100644 --- a/runtime/src/psx_icache.c +++ b/runtime/src/psx_icache.c @@ -108,23 +108,19 @@ void psx_icache_fetch_miss(CPUState* cpu, uint32_t addr) { #endif } -/* IsC store -> I-cache, transcribed from Beetle PS_CPU::WriteMemory - * (mednafen/psx/cpu.cpp:484-501): +/* IsC store -> I-cache. This function was transcribed from Beetle's + * PS_CPU::WriteMemory; it is to be replaced by the cpu/icache change + * tracked in issue #443 (docs/PROVENANCE.md). * - * if(BIU & BIU_ENABLE_ICACHE_S1) { - * if(BIU & (BIU_TAG_TEST_MODE | BIU_INVALIDATE_MODE | BIU_LOCK_MODE)) { - * valid_bits = (BIU & BIU_TAG_TEST_MODE) ? ((value << ((address & 0x3) * 8)) & 0x0F) : 0x00; - * ICI = &ICache[(address & 0xFF0) >> 2]; - * for(i = 0; i < 4; i++) - * ICI[i].TV = ((valid_bits & (1U << i)) ? 0x00 : 0x02) | (address & 0xFFFFFFF0) | (i << 2); - * } else - * ICache[(address & 0xFFC) >> 2].Data = value << ((address & 0x3) * 8); - * } - * - * The else arm writes an instruction word and leaves TV alone. This model keeps - * no instruction words (compiled code supplies them), so it has nothing to do. - * Beetle does not model SR.SwC (swapped caches), so neither does this. The - * store keeps the full virtual address as the tag, as Beetle's refill does. */ + * With SR.IsC set and the I-cache enabled (BCC bit 11, IS1), a store made + * with BCC TAG, INV or LOCK set rewrites the tag and valid bits of the four + * words of its line (PSX-SPX memorycontrol, "FFFE0130h - BCC"); that is + * how FlushCache (A 44h) invalidates the cache. A store with none of those + * bits writes an instruction word; this model keeps no instruction words + * (compiled code supplies them), so it does nothing. SR.SwC is not + * modeled. PSX-SPX gives the stored tag as (data AND 0Fh) OR (offset AND + * FFFFF000h); this model stores the line address with its own valid-bit + * encoding, and the replacement is to be checked against that text. */ void psx_icache_isc_store(uint32_t biu, uint32_t addr, uint32_t value) { #ifdef PSX_ENABLE_BLOCK_CYCLES /* Same gate as a refill: lockstep replay must not move the shared tags; diff --git a/runtime/src/psx_openxr.c b/runtime/src/psx_openxr.c index e9244553..26b44ccd 100644 --- a/runtime/src/psx_openxr.c +++ b/runtime/src/psx_openxr.c @@ -1,3 +1,4 @@ +#include "xr_origin_diagnostics.h" /* Opt-in Win32 OpenXR lifecycle. No guest state, console diagnostics or timing * phases: located poses and projection submission belong to one host frame. * @@ -9,6 +10,8 @@ #include "psx_openxr.h" #include "vr_pose_math.h" #include "openxr_color.h" +#include "frame_trace.h" +#include "gpu_gl_renderer.h" #include #include #include @@ -41,6 +44,41 @@ static int valid_pose_numbers(const double q[4],const double p[3]) { for(int i=0;i<3;i++)if(!isfinite(p[i]) || fabs(p[i])>100)return 0; return n>=.5 && n<=1.5; } +/* Test-only synthetic head motion: translation in room-space metres and an optional pitch, added to + * both eyes after the origin is established so it reads as the head moving in the world. The + * translation checks that the world shifts rigidly with the head. The pitch exists because the + * defects that only appear when looking down cannot be reached by translation, and because the only + * other look path is unwired. Armed by the first override call; default off, so shipped behaviour + * is unchanged. */ +static int s_pose_bob_armed; +static double s_pose_bob_m[3]; +static double s_pose_bob_pitch_deg; +void psx_openxr_pose_bob(double q[2][4],double p[2][3]) { + if(!s_pose_bob_armed)return; + for(int i=0;i<2;i++)for(int j=0;j<3;j++)p[i][j]+=s_pose_bob_m[j]; + if(s_pose_bob_pitch_deg!=0.0) { + double h=s_pose_bob_pitch_deg*(3.141592653589793/180.0)*0.5,sq=sin(h),cq=cos(h); + for(int i=0;i<2;i++) { + double x=q[i][0],y=q[i][1],z=q[i][2],w=q[i][3]; + /* Left multiply by a rotation about the XR X axis: a look up or down. */ + q[i][0]=sq*w+cq*x;q[i][1]=cq*y-sq*z;q[i][2]=sq*y+cq*z;q[i][3]=cq*w-sq*x; + } + } +} +int psx_openxr_pose_override(double x_m,double y_m,double z_m) { + if(!isfinite(x_m)||!isfinite(y_m)||!isfinite(z_m))return 0; + if(fabs(x_m)>10.0||fabs(y_m)>10.0||fabs(z_m)>10.0)return 0; + s_pose_bob_m[0]=x_m;s_pose_bob_m[1]=y_m;s_pose_bob_m[2]=z_m;s_pose_bob_armed=1; + return 1; +} +int psx_openxr_pose_pitch_override(double pitch_deg) { + if(!isfinite(pitch_deg) || fabs(pitch_deg)>90.0)return 0; + s_pose_bob_pitch_deg=pitch_deg;s_pose_bob_armed=1; + return 1; +} +void psx_openxr_pose_clear(void) { + s_pose_bob_m[0]=s_pose_bob_m[1]=s_pose_bob_m[2]=0.0;s_pose_bob_pitch_deg=0.0;s_pose_bob_armed=0; +} int psx_openxr_hands_override(const PSXModOpenXRHands *hands) { if(!hands){s_hands_override=0;hands_clear();return 1;} if(hands->struct_size!=sizeof *hands || hands->focused>1 || hands->origin_valid>1)return 0; @@ -103,6 +141,17 @@ int psx_openxr_surface_valid(const PSXModOpenXRSurface *v) { #define XR_USE_GRAPHICS_API_OPENGL #include #include + +/* Frame-trace stage helper: perf ticks -> microseconds (CPU submission/wait + * wall time only, never GPU execution time). */ +static uint64_t xr_trace_us(uint64_t ticks) { + static double inv = 0.0; + if (inv == 0.0) { + double f = (double)gl_renderer_perf_frequency(); + inv = f > 0.0 ? 1000000.0 / f : 1.0; + } + return (uint64_t)((double)ticks * inv); +} static XrInstance s_instance; static XrSystemId s_system; static XrSession s_session; @@ -115,6 +164,7 @@ static XrAction s_pose_action[2]; static XrSpace s_pose_space[2][2]; static XrPath s_hand[2]; static XrTime s_origin_reset_time; +static unsigned s_origin_inversion_streak; static XrSwapchain s_chain[2]; static XrSwapchainImageOpenGLKHR *s_images[2]; static uint32_t s_count[2]; @@ -123,6 +173,13 @@ static XrView s_views[2]; static PSXModRenderView s_render[2]; static double s_oq[4],s_op[3]; static XrTime s_time; +/* Cadence telemetry for the frame trace: the runtime's own predicted display + * time delta between submissions (what the compositor actually saw), and when + * the pose for the current frame was sampled (xrLocateViews return). */ +static XrTime s_prev_display_time; +static int s_prev_display_valid; +static uint32_t s_display_delta_us; +static uint64_t s_pose_sample_ticks; /* Loss handling. A lost session/instance (or an unusable swapchain) is only * flagged where it is detected; service_loss() tears the stack down at the end * of the public call so no caller loses handles mid-sequence. */ @@ -354,6 +411,9 @@ static void hands_locate(int focused) { /* Destroy every XR object (children before parents). Safe on a partially * initialised stack and on a lost session: destroy calls need no live session. */ static void xr_destroy(void) { + /* A new session must not inherit the previous one's display times: the + * first delta after a re-init would be meaningless. */ + s_prev_display_valid=0;s_prev_display_time=0; for(int eye=0;eye<2;eye++) { for(int k=0;k<2;k++) { if(s_pose_space[eye][k])xrDestroySpace(s_pose_space[eye][k]); @@ -369,7 +429,9 @@ static void xr_destroy(void) { s_trigger=s_squeeze=XR_NULL_HANDLE;memset(s_click,0,sizeof s_click); memset(s_pose_action,0,sizeof s_pose_action); if(s_instance)xrDestroyInstance(s_instance);s_instance=XR_NULL_HANDLE; - s_origin_valid=0;s_origin_reset_time=0;s_stats.state=0; + s_origin_valid=0;s_origin_reset_time=0;s_origin_inversion_streak=0; + s_stats.origin_inversion_streak=s_stats.origin_ambiguous=0;s_stats.origin_up_dot=1.0f; + s_stats.state=0; s_stats.initialized=s_stats.running=s_stats.tracking=s_stats.frame_open=0; } /* Act on a flagged loss. A lost session/instance or an unusable swapchain tears @@ -425,7 +487,8 @@ void psx_openxr_stats(PSXOpenXRStats *out) { void psx_openxr_recenter(void) { hands_clear(); #if defined(PSX_OPENXR) - s_origin_valid=0; + s_origin_valid=0;s_origin_inversion_streak=0; + s_stats.origin_inversion_streak=s_stats.origin_ambiguous=0; #endif } void psx_openxr_shutdown(void) { @@ -547,11 +610,48 @@ int psx_openxr_begin(int width,int height,double units) { s_recovering=0; } if(!events() || !s_stats.running){(void)service_loss();return 0;} + /* Open this frame's trace record before the blocking waits. The record is + * committed by psx_openxr_end(); the two failure returns below commit it + * themselves so a lost session cannot leave a record open forever. */ + psx_frame_trace_open(); + /* Cleared so a frame that never reaches xrLocateViews (no render request, + * lost tracking, bad view maths) cannot report a stale pose age. */ + s_pose_sample_ticks=0; + uint64_t trace_t = gl_renderer_perf_ticks(); XrFrameWaitInfo wait={XR_TYPE_FRAME_WAIT_INFO};XrFrameState fs={XR_TYPE_FRAME_STATE}; - if(!check(xrWaitFrame(s_session,&wait,&fs),"wait_frame")){(void)service_loss();return 0;} + if(!check(xrWaitFrame(s_session,&wait,&fs),"wait_frame")){ + psx_frame_trace_add(PSX_TRACE_XR_WAIT_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t)); + /* Session-level submission failure: the stereo cadence context is gone, + * so drop any stale pair-time estimate rather than carrying it into the + * recovery attempt. */ + psx_mod_stereo_reset_budget(); + (void)service_loss();psx_frame_trace_commit();return 0;} + psx_frame_trace_add(PSX_TRACE_XR_WAIT_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t)); s_stats.waits++;s_time=fs.predictedDisplayTime;s_stats.predicted_time=(uint64_t)s_time; + /* The runtime's own clock between submissions. This is the cadence the + * compositor saw, independent of our frame timing: delta / display period + * is how many granted periods one presented frame consumed. */ + s_display_delta_us=0; + if(s_prev_display_valid && s_time>s_prev_display_time) { + uint64_t d=(uint64_t)(s_time-s_prev_display_time)/1000u; + s_display_delta_us=d>0xFFFFFFFFull?0xFFFFFFFFu:(uint32_t)d; + } + s_prev_display_time=s_time;s_prev_display_valid=1; + /* Record the runtime display period with its name for cadence analysis. */ + /* What the runtime wants us to hit is the only true deadline for the pair. + * Hand it to stereo shedding (PSX VBlank is a guest redraw budget, not a + * compositor deadline) and record it with the runtime name so a trace can be + * read without guessing the headset's refresh. */ + psx_mod_stereo_set_budget_ms(fs.predictedDisplayPeriod>0?(double)fs.predictedDisplayPeriod/1e6:0.0); + psx_frame_trace_session(s_stats.runtime, + fs.predictedDisplayPeriod>0?(uint32_t)(fs.predictedDisplayPeriod/1000):0u); + trace_t = gl_renderer_perf_ticks(); XrFrameBeginInfo begin={XR_TYPE_FRAME_BEGIN_INFO}; - if(!check(xrBeginFrame(s_session,&begin),"begin_frame")){(void)service_loss();return 0;} + if(!check(xrBeginFrame(s_session,&begin),"begin_frame")){ + psx_frame_trace_add(PSX_TRACE_XR_BEGIN_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t)); + psx_mod_stereo_reset_budget(); + (void)service_loss();psx_frame_trace_commit();return 0;} + psx_frame_trace_add(PSX_TRACE_XR_BEGIN_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t)); s_stats.frame_open=1;s_stats.tracking=0; if(!fs.shouldRender){psx_openxr_end(0,NULL);return 0;} XrViewLocateInfo loc={XR_TYPE_VIEW_LOCATE_INFO};loc.viewConfigurationType=XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; @@ -559,6 +659,7 @@ int psx_openxr_begin(int width,int height,double units) { XrViewState vs={XR_TYPE_VIEW_STATE};uint32_t count=0; memset(s_views,0,sizeof s_views);s_views[0].type=s_views[1].type=XR_TYPE_VIEW; int located=check(xrLocateViews(s_session,&loc,&vs,2,&count,s_views),"locate_views"); + s_pose_sample_ticks=gl_renderer_perf_ticks(); s_stats.view_flags=(uint64_t)vs.viewStateFlags; if(!located || count!=2 || (vs.viewStateFlags&(XR_VIEW_STATE_ORIENTATION_VALID_BIT|XR_VIEW_STATE_POSITION_VALID_BIT))!= @@ -571,17 +672,30 @@ int psx_openxr_begin(int width,int height,double units) { q[i][0]=v->orientation.x;q[i][1]=v->orientation.y;q[i][2]=v->orientation.z;q[i][3]=v->orientation.w; p[i][0]=v->position.x;p[i][1]=v->position.y;p[i][2]=v->position.z; } - if(s_origin_reset_time && s_time>=s_origin_reset_time) { - s_origin_valid=0;s_origin_reset_time=0; + if(xr_origin_reference_reset_due(s_origin_reset_time!=0,s_origin_reset_time,s_time)) { + s_origin_valid=0;s_origin_reset_time=0;s_origin_inversion_streak=0; } if(!s_origin_valid) { memcpy(s_oq,q[0],sizeof s_oq); for(int j=0;j<3;j++)s_op[j]=(p[0][j]+p[1][j])*.5; s_origin_valid=1; + s_origin_inversion_streak=0; } + /* A sustained roll can be a legitimate headset pose. Report the ambiguous + * case without changing the reference; explicit recenter or the OpenXR + * reference-space change event owns origin recovery. */ + double origin_up_dot=1.0; + s_origin_inversion_streak=xr_origin_observe_inversion( + s_origin_inversion_streak,s_origin_valid,s_oq,q[0],&origin_up_dot); + s_stats.origin_up_dot=(float)origin_up_dot; + s_stats.origin_inversion_streak=s_origin_inversion_streak; + s_stats.origin_ambiguous=s_origin_inversion_streak>=10u; double separation=0; for(int j=0;j<3;j++)separation+=(p[1][j]-p[0][j])*(p[1][j]-p[0][j]); s_stats.ipd_m=(float)sqrt(separation); +#ifndef PSX_NO_DEBUG_TOOLS + psx_openxr_pose_bob(q, p); /* test-only synthetic head motion; no-op unless armed */ +#endif for(int i=0;i<2;i++) { XrFovf *v=&s_views[i].fov; double f[4]={v->angleLeft,v->angleRight,v->angleUp,v->angleDown}; @@ -619,26 +733,37 @@ int psx_openxr_quad(double distance,double width,double height) { int psx_openxr_end(int keep,PSXOpenXRCopy copy) { #if defined(PSX_OPENXR) if(!s_stats.frame_open)return 0; + /* Defensive open: normally psx_openxr_begin() already opened the record. */ + psx_frame_trace_open(); + uint64_t trace_t; XrCompositionLayerProjectionView pv[2]={{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW},{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}}; int complete=keep && copy && s_stats.tracking; int quad=s_quad_distance>0; for(int eye=0;eye<(quad?1:2) && complete;eye++) { uint32_t index=0; + trace_t = gl_renderer_perf_ticks(); XrSwapchainImageAcquireInfo ai={XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO}; if(!check(xrAcquireSwapchainImage(s_chain[eye],&ai,&index),"acquire")){complete=0;break;} + psx_frame_trace_add(PSX_TRACE_XR_ACQUIRE, xr_trace_us(gl_renderer_perf_ticks()-trace_t)); XrSwapchainImageWaitInfo wi={XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO};wi.timeout=1000000000; + trace_t = gl_renderer_perf_ticks(); XrResult wr=xrWaitSwapchainImage(s_chain[eye],&wi); /* A timeout leaves ownership acquired: wait again (bounded) before * releasing. Only XR_SUCCESS means the image is writable; the timeout * code is a success code and must not be mistaken for it. */ if(wr==XR_TIMEOUT_EXPIRED){wi.timeout=2000000000;wr=xrWaitSwapchainImage(s_chain[eye],&wi);} + psx_frame_trace_add(PSX_TRACE_XR_WAIT_IMAGE, xr_trace_us(gl_renderer_perf_ticks()-trace_t)); int writable=check(wr,"wait_image") && wr==XR_SUCCESS; if(writable) { + trace_t = gl_renderer_perf_ticks(); if(index>=s_count[eye] || !copy(eye,s_images[eye][index].image,s_w[eye],s_h[eye])) { s_stats.stage="copy_image";s_stats.failures++;complete=0; } + psx_frame_trace_add(PSX_TRACE_XR_COPY, xr_trace_us(gl_renderer_perf_ticks()-trace_t)); + trace_t = gl_renderer_perf_ticks(); XrSwapchainImageReleaseInfo ri={XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO}; if(!check(xrReleaseSwapchainImage(s_chain[eye],&ri),"release"))complete=0; + psx_frame_trace_add(PSX_TRACE_XR_RELEASE, xr_trace_us(gl_renderer_perf_ticks()-trace_t)); } else { /* Acquired but never writable. Release it best-effort so the acquire * count cannot leak; a runtime that refuses (the wait never completed) @@ -665,7 +790,13 @@ int psx_openxr_end(int keep,PSXOpenXRCopy copy) { XrFrameEndInfo end={XR_TYPE_FRAME_END_INFO};end.displayTime=s_time; end.environmentBlendMode=XR_ENVIRONMENT_BLEND_MODE_OPAQUE; end.layerCount=complete?1:0;end.layers=complete?layers:NULL; + trace_t = gl_renderer_perf_ticks(); int ok=check(xrEndFrame(s_session,&end),"end_frame");s_stats.frame_open=0; + psx_frame_trace_add(PSX_TRACE_XR_END_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t)); + if(s_pose_sample_ticks) + psx_frame_trace_add(PSX_TRACE_POSE_AGE, + xr_trace_us(gl_renderer_perf_ticks()-s_pose_sample_ticks)); + psx_frame_trace_xr_cadence(s_display_delta_us, complete); if(ok && complete) { s_stats.submitted_surfaces=quad?0:((s_surface_mask&1)!=0)+((s_surface_mask&2)!=0); s_stats.submitted++;s_stats.submitted_pair_id=s_pair_id; @@ -675,9 +806,18 @@ int psx_openxr_end(int keep,PSXOpenXRCopy copy) { if(s_source==2)s_stats.native_submitted++; if(quad){s_stats.quad_submitted++;s_stats.quad_distance_m=s_quad_distance; s_stats.quad_width_m=s_quad_width;s_stats.quad_height_m=s_quad_height;} - } else {s_stats.empty++;s_stats.submitted_surfaces=0;} + /* A fresh stereo pair already labelled the record source 1; only + * annotate native presentation when nothing else claimed it. */ + psx_frame_trace_source(2); + } else { + s_stats.empty++;s_stats.submitted_surfaces=0; + psx_frame_trace_flag(PSX_FRAME_TRACE_XR_EMPTY); + psx_frame_trace_flag(PSX_FRAME_TRACE_STALE_PAIR); + psx_frame_trace_source(2); + } if(ok && complete)s_stats.stage="submitted"; s_quad_distance=s_quad_width=s_quad_height=0; + psx_frame_trace_commit(); (void)service_loss(); /* after the frame is closed: lost session / bad swapchain */ return ok && complete; #else diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 705600c0..71d97ec4 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -19,6 +19,7 @@ #include "render_pass.h" #include +#include #include #include #include @@ -26,8 +27,10 @@ #include "cpu_state.h" #include "dirty_ram_interp.h" #include "dma.h" +#include "frame_trace.h" #include "gpu.h" #include "gpu_gl_renderer.h" +#include "host_time.h" #include "mod_plugins.h" #include "mod_runtime.h" #include "interrupts.h" @@ -36,6 +39,10 @@ #include "timers.h" #include "psx_video_timing.h" #include "gte_view.h" +#include "stereo_shed_policy.h" + +extern int gpu_render_projective_begin(void); +extern void gpu_render_projective_end(void); extern uint8_t *memory_get_ram_ptr(void); extern uint8_t *memory_get_scratchpad_ptr(void); @@ -281,7 +288,36 @@ static RenderPassFailure s_attempt; static const char *s_checkpoint_failure; static RenderStereoStats s_stereo; static int s_pair_busy; -static uint32_t s_pair_retry; +/* Whole-pair shedding/recovery policy (bounded in time and count; see + * stereo_shed_policy.c). Replaces the old attempt counter, which could suppress + * many pairs after a single over-budget frame. */ +static StereoShedPolicy s_shed; +/* Headset deadline pushed by the OpenXR layer (target display period, ms). + * 0 = no headset: fall back to the guest-VBlank redraw budget. */ +static double s_shed_xr_budget_ms; +static uint32_t s_shed_period; +/* Pair-scoped baseline (PR3). Both eyes of a stereo pair render from the same + * guest checkpoint: eye L captures it and the restore that ends eye L puts the + * live machine back to exactly that captured state, so eye R can reuse the + * capture instead of copying the whole live RAM a second time. Guards: + * - only stereo eyes inside one pair reuse it (s_baseline_ready is cleared by + * any non-stereo transaction and at the start of every pair); + * - a verify mismatch while reusing it retires the optimization for the + * session, falling back to a capture per eye (PSX_STEREO_BASELINE=0 does + * the same up front). */ +static int s_baseline_ready; /* live machine == captured baseline */ +static int s_baseline_enabled = -1; /* PSX_STEREO_BASELINE; -1 = not read yet */ +static int32_t s_shed_w, s_shed_h; +static int s_shed_geom_valid; +/* A HUD-only capture transaction reuses eye 0's slot but is not an eye draw; + * frame tracing must not attribute its cost to the left eye. */ +static int s_capture_only; +/* The two transactions of one HUD capture call are a pair of their own: the + * second matte draws from exactly the state the restore that ended the first + * one put back, so it may reuse the first one's checkpoint the way a pair's + * right eye reuses the left's. Scoped to the capture call, so the reuse never + * reaches the pair that renders later in the same frame. */ +static int s_capture_pair; static int pass_refuse(const char *reason, uint32_t status) { /* A refusal always carries a reason: last_failure.reason == NULL means @@ -317,6 +353,43 @@ static int verify_on(void) { return s_verify; } +/* PSX_STEREO_SHED_BUDGET_MS, when set, replaces the guest-VBlank-derived budget + * with a real headset deadline (e.g. 11.1 at 90 Hz, 8.3 at 120 Hz). The VBlank + * cadence is only a fallback: it is a guest redraw budget, not an OpenXR + * display deadline, and conflating the two is wrong on every non-60 Hz headset. + * Returns 0 when unset so the caller derives the fallback. */ +static double shed_budget_override_ms(void) { + static double v = -1.0; + if (v < 0.0) { + const char *e = getenv("PSX_STEREO_SHED_BUDGET_MS"); + double d = e ? atof(e) : 0.0; + v = (d > 0.0 && d < 1000.0) ? d : 0.0; + } + return v; +} + +/* The pair budget actually used, in priority order: the explicit env override, + * the headset display deadline the OpenXR layer pushed, then the guest-VBlank + * redraw budget as a last resort. */ +static double shed_budget_ms(uint32_t period_vblanks, uint32_t *source) { + double ov = shed_budget_override_ms(); + if (ov > 0.0) { if (source) *source = 2; return ov; } + if (s_shed_xr_budget_ms > 0.0) { if (source) *source = 1; return s_shed_xr_budget_ms; } + if (source) *source = 0; + return period_vblanks * (double)g_psx_vblank_cycles / 33868.8 * 0.8; +} + +/* Pair-scoped checkpoint reuse is on by default; PSX_STEREO_BASELINE=0 forces a + * machine capture per eye (the pre-PR3 behavior) so the two can be compared, and + * a restore-verify mismatch turns it off for the rest of the session. */ +static int baseline_reuse_enabled(void) { + if (s_baseline_enabled < 0) { + const char *e = getenv("PSX_STEREO_BASELINE"); + s_baseline_enabled = (e && e[0] == '0') ? 0 : 1; + } + return s_baseline_enabled; +} + void render_pass_get_stats(RenderPassStats *out) { if (!out) return; *out = s_stats; @@ -327,6 +400,7 @@ void render_pass_get_stats(RenderPassStats *out) { int render_pass_verification_enabled(void) { return verify_on(); } void render_pass_reset_session(void) { + psx_mod_set_render_projective(0); s_netplay_allowed = 0; memset(&s_stats, 0, sizeof s_stats); memset(g_render_pass_dropped_writes, 0, sizeof g_render_pass_dropped_writes); @@ -337,7 +411,20 @@ void render_pass_reset_session(void) { memset(&s_stereo, 0, sizeof s_stereo); s_stereo.last_eye = -1; s_pair_busy = 0; - s_pair_retry = 0; + /* Shedding policy: defaults, then env overrides (PSX_STEREO_SHED=0 gives the + * baseline diagnostic with shedding fully disabled). */ + stereo_shed_policy_init(&s_shed); + stereo_shed_policy_configure_from_env(&s_shed); + stereo_shed_policy_reset(&s_shed); + s_shed_geom_valid = 0; + /* Pair-scoped baseline: re-read the env and start uncaptured. */ + s_baseline_ready = 0; + s_baseline_enabled = -1; + /* Frame tracing is opt-in per session: re-read the env, install the host + * clock, and clear any previous session's records. */ + psx_frame_trace_configure_from_env(); + psx_frame_trace_set_clock(psx_host_mono_ms); + psx_frame_trace_reset(); gl_renderer_stereo_reset(); gl_renderer_pass_set_flip_shown(0); } @@ -393,6 +480,14 @@ void render_stereo_get_stats(RenderStereoStats *out) { if (out) *out = s_stereo; int psx_mod_set_stereo_presentation(uint32_t mode) { return mode <= 1u ? gl_renderer_stereo_set_presentation(mode) : 0; } + +/* Game-side annotation for the frame trace (mod_plugins.h): a title hook marks + * a scene/state transition on the record the runtime opened for this frame. A + * no-op when tracing is off or no record is open. */ +void psx_mod_frame_trace_note(const char *tag) { + psx_frame_trace_note(tag); + psx_frame_trace_flag(PSX_FRAME_TRACE_SCENE_TRANSITION); +} int psx_mod_render_view_offset(int32_t x, int32_t y, int32_t z) { int32_t xyz[3] = {x, y, z}; if (!g_psx_render_pass_active) return 0; @@ -403,13 +498,19 @@ int psx_mod_render_view_offset(int32_t x, int32_t y, int32_t z) { int psx_mod_render_view(const PSXModRenderView *view) { if (!g_psx_render_pass_active || !view || view->struct_size < sizeof *view || - view->projection > 1u || view->projection_h_ref > 65535u) return 0; + view->projection > 1u || view->precise > 1u || view->projection_h_ref > 65535u) return 0; /* Bound products well inside int64. Rigid matrices are supplied by caller. */ for (int i = 0; i < 9; ++i) if (view->rotation_q12[i] < -4096 || view->rotation_q12[i] > 4096) return 0; for (int i = 0; i < 3; ++i) if (view->translation[i] < -65536 || view->translation[i] > 65536) return 0; if (view->projection && (view->fx_q16 <= 0 || view->fy_q16 <= 0)) return 0; + if (view->precise) { + for (int i=0;i<9;++i) + if (!isfinite(view->rotation[i]) || fabs(view->rotation[i])>1.001) return 0; + for (int i=0;i<3;++i) + if (!isfinite(view->translation_precise[i]) || fabs(view->translation_precise[i])>65536) return 0; + } gte_render_pose_set(view); return 1; } @@ -531,46 +632,58 @@ static uint64_t state_hash(const CPUState *cpu) { return h; } -static int checkpoint_save(const CPUState *cpu) { +/* `reuse` skips the bulk capture (RAM, scratchpad, icache, DMA, CPU, timers) + * when a previous eye of the same pair already captured this exact machine + * state. The per-eye arms are never skipped: the GPU transaction snapshot and + * gte_precision_checkpoint_begin() bound the pass and end with the restore. */ +static int checkpoint_save(const CPUState *cpu, int reuse) { uint32_t dma_len = dma_snapshot_bytes(); /* The whole live RAM: a pass's stores fold through the same geometry * (render_pass_store_to), so every byte one can reach is in here. */ uint32_t ram_bytes = memory_get_ram_bytes(); s_checkpoint_failure = NULL; - if (s_ram_copy_cap < ram_bytes) { - uint8_t *p = (uint8_t *)realloc(s_ram_copy, ram_bytes); - if (!p) { s_checkpoint_failure = "checkpoint_ram"; return 0; } - s_ram_copy = p; - s_ram_copy_cap = ram_bytes; - } - if (!s_dma_copy) { - s_dma_copy = (uint8_t *)malloc(dma_len); - if (!s_dma_copy) { s_checkpoint_failure = "checkpoint_dma"; return 0; } + /* A reused baseline must own its buffers and describe this machine (RAM + * and DMA geometry cannot change inside a session). */ + int can_reuse = reuse && s_ram_copy && s_dma_copy && + s_ck.ram_bytes == ram_bytes && s_ck.dma_len == dma_len; + if (!can_reuse) { + if (s_ram_copy_cap < ram_bytes) { + uint8_t *p = (uint8_t *)realloc(s_ram_copy, ram_bytes); + if (!p) { s_checkpoint_failure = "checkpoint_ram"; return 0; } + s_ram_copy = p; + s_ram_copy_cap = ram_bytes; + } + if (!s_dma_copy) { + s_dma_copy = (uint8_t *)malloc(dma_len); + if (!s_dma_copy) { s_checkpoint_failure = "checkpoint_dma"; return 0; } + } } if (!gpu_pass_checkpoint_save()) { s_checkpoint_failure = "checkpoint_gpu"; return 0; } - s_ck.cpu = *cpu; - s_ck.ram_bytes = ram_bytes; - memcpy(s_ram_copy, memory_get_ram_ptr(), ram_bytes); - memcpy(s_ck.spad, memory_get_scratchpad_ptr(), RP_SPAD_SIZE); - memcpy(s_ck.icache, g_psx_icache_tv, sizeof s_ck.icache); - s_ck.i_stat = i_stat; - s_ck.i_mask = i_mask; - s_ck.csv = interrupts_get_cycles_since_vblank(); - timers_get_snapshot(s_ck.t_counter, s_ck.t_mode, s_ck.t_target, - s_ck.t_irq, s_ck.t_frac); - s_ck.dma_len = dma_len; - dma_snapshot_write(s_dma_copy); - s_ck.store_count = g_guest_store_count; - s_ck.mmio_count = g_mmio_access_count; - s_ck.last_store_pc = g_debug_last_store_pc; - s_ck.current_func = g_debug_current_func_addr; - s_ck.dispatch_depth = g_psx_dispatch_depth; - s_ck.call_bail = g_psx_call_bail; - memset(&s_ck.nest, 0, sizeof s_ck.nest); - nesting_save(&s_ck.nest); + if (!can_reuse) { + s_ck.cpu = *cpu; + s_ck.ram_bytes = ram_bytes; + memcpy(s_ram_copy, memory_get_ram_ptr(), ram_bytes); + memcpy(s_ck.spad, memory_get_scratchpad_ptr(), RP_SPAD_SIZE); + memcpy(s_ck.icache, g_psx_icache_tv, sizeof s_ck.icache); + s_ck.i_stat = i_stat; + s_ck.i_mask = i_mask; + s_ck.csv = interrupts_get_cycles_since_vblank(); + timers_get_snapshot(s_ck.t_counter, s_ck.t_mode, s_ck.t_target, + s_ck.t_irq, s_ck.t_frac); + s_ck.dma_len = dma_len; + dma_snapshot_write(s_dma_copy); + s_ck.store_count = g_guest_store_count; + s_ck.mmio_count = g_mmio_access_count; + s_ck.last_store_pc = g_debug_last_store_pc; + s_ck.current_func = g_debug_current_func_addr; + s_ck.dispatch_depth = g_psx_dispatch_depth; + s_ck.call_bail = g_psx_call_bail; + memset(&s_ck.nest, 0, sizeof s_ck.nest); + nesting_save(&s_ck.nest); + } gte_precision_checkpoint_begin(); return 1; } @@ -605,6 +718,51 @@ static double ema_ms(double cur, uint64_t ticks) { return cur > 0.0 ? cur * 0.9 + ms * 0.1 : ms; } +/* Ticks -> microseconds for frame_trace. CPU submission time only: it is not + * GPU execution time. */ +static double s_us_per_tick = 0.0; +static uint64_t trace_ticks_to_us(uint64_t ticks) { + if (s_us_per_tick == 0.0) { + double f = (double)gl_renderer_perf_frequency(); + s_us_per_tick = f > 0.0 ? 1000000.0 / f : 1.0; + } + return (uint64_t)((double)ticks * s_us_per_tick); +} + +/* ---- guest sub-stage spans (mod_plugins.h) ------------------------------ + * The game hook brackets the guest calls it replays once per eye, and the + * runtime times them with its own tick clock, exactly as the eye stages are + * timed, so the parts sum against eye_guest. Accepted only inside a traced + * stereo eye (s_sub_eye): a HUD capture's guest draws and the flat path report + * their own stages and must not write these slots. */ +static int s_sub_eye = -1; /* traced eye accepting spans, -1 = none */ +/* One start tick per stage rather than one open span at a time, so a stage can + * be bracketed inside another: the hook's visibility override runs inside the + * guest traversal it is part of. The outer span keeps its own wall time, so a + * nested stage is a subset of it rather than a sibling. */ +static int s_sub_open[PSX_TRACE_STAGE_COUNT]; +static uint64_t s_sub_ticks[PSX_TRACE_STAGE_COUNT]; + +/* No span survives an eye boundary, so an eye aborted mid-entry cannot carry a + * start tick into the next one. */ +static void sub_spans_forget(void) { + memset(s_sub_open, 0, sizeof s_sub_open); +} + +void psx_mod_frame_trace_stage_begin(uint32_t stage) { + if (s_sub_eye < 0 || stage >= PSX_TRACE_STAGE_COUNT) return; + s_sub_open[stage] = 1; /* an unpaired begin restarts that span */ + s_sub_ticks[stage] = gl_renderer_perf_ticks(); +} + +void psx_mod_frame_trace_stage_end(uint32_t stage) { + if (s_sub_eye < 0 || stage >= PSX_TRACE_STAGE_COUNT) return; + if (!s_sub_open[stage]) return; /* an end with no begin records nothing */ + uint64_t now = gl_renderer_perf_ticks(); + psx_frame_trace_add(stage, trace_ticks_to_us(now - s_sub_ticks[stage])); + s_sub_open[stage] = 0; +} + static void watchdog_overrun(void) { s_stats.watchdog++; s_stats.watchdog_flag = 1; @@ -692,6 +850,10 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass s_attempt.attempt = ++s_stats.pass_attempts; s_attempt.plan = s_plan_serial; s_attempt.guest_cycle = psx_cycle_count; + /* No guest sub-stage span may survive into this transaction: the eye that + * accepts them is decided below, once the trace decision is known. */ + s_sub_eye = -1; + sub_spans_forget(); if (pass) { s_attempt.struct_size = pass->struct_size; if (pass->struct_size >= sizeof *pass) { @@ -726,6 +888,16 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass if (inv == 0.0) inv = 1000.0 / (double)gl_renderer_perf_frequency(); s_ms_per_tick = inv; } + /* Per-eye trace sampling (opt-in): primitive counts and upload pixels are + * cumulative, so the eye's cost is the delta across its transaction. */ + int trace_on = eye >= 0 && !s_capture_only && psx_frame_trace_enabled(); + uint64_t trace_prims0 = 0, trace_tex0 = 0, trace_diag0[6] = {0,0,0,0,0,0}; + if (trace_on) { + gl_renderer_scene_prim_counts(&trace_prims0, &trace_tex0); + gl_renderer_runtime_diag(trace_diag0); + } + /* This eye, and only this eye, accepts the hook's guest sub-stage spans. */ + s_sub_eye = trace_on ? (int)eye : -1; reuse = !open && eye != RP_EYE_LOCAL && s_restored_valid && s_restored_plan == s_plan_serial && s_restored_cycle == psx_cycle_count && @@ -745,7 +917,11 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass s_open_generation = 0; s_leaks_before = gl_renderer_pass_leaks(); - if (!checkpoint_save(cpu)) { + /* A stereo eye, or the second matte of a capture, whose machine state still + * equals the baseline captured for it skips re-capturing that state. */ + int reuse_baseline = eye >= 0 && s_baseline_ready && baseline_reuse_enabled() && + (!s_capture_only || s_capture_pair); + if (!checkpoint_save(cpu, reuse_baseline)) { gl_renderer_pass_begin_diag(&s_attempt.gl); s_stats.checkpoint_refused++; /* Capture the rejection status before rolling the GL transaction back. */ @@ -753,6 +929,15 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass transaction_end(eye, 0, 0); return pass_refuse(s_checkpoint_failure ? s_checkpoint_failure : "checkpoint", status); } + if (eye >= 0 && !s_capture_only && !gpu_render_projective_begin()) { + gl_renderer_pass_begin_diag(&s_attempt.gl); + status = transaction_status(transaction_kind(eye)); + transaction_end(eye, 0, 0); + checkpoint_restore(cpu); + return pass_refuse("projective_pgxp", status); + } + if (reuse_baseline) s_stereo.checkpoint_reuses++; + else { s_stereo.checkpoint_saves++; s_stereo.checkpoint_ram_bytes += s_ck.ram_bytes; } if (verify_on()) hash_before = state_hash(cpu); tb = gl_renderer_perf_ticks(); @@ -794,7 +979,13 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass "%s", s_abort_detail); ok = 0; } - s_stats.guest_cycles_last = psx_cycle_count - cycles_before; + /* Any span still open here was never ended by the hook, or the eye aborted + * mid-entry through the longjmp above. Drop them instead of closing them: + * the elapsed time is then not charged to an entry that never finished, and + * it shows up as the difference between eye_guest and the guest sub-stages. */ + sub_spans_forget(); + uint64_t guest_cycles = psx_cycle_count - cycles_before; + s_stats.guest_cycles_last = guest_cycles; if (eye >= 0) gte_render_view_get(s_stereo.eye_view[eye]); tg = gl_renderer_perf_ticks(); s_pass_cpu = NULL; @@ -812,6 +1003,7 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass } te = gl_renderer_perf_ticks(); local_scope_set(0); + gpu_render_projective_end(); checkpoint_restore(cpu); psx_cycle_freeze_end(&s_freeze); s_nesting = 0; @@ -827,12 +1019,43 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass s_stats.avg_end_ms = ema_ms(s_stats.avg_end_ms, te - tg); s_stats.avg_restore_ms = ema_ms(s_stats.avg_restore_ms, tr - te); + if (trace_on) { + uint32_t base = eye == PSX_MOD_EYE_LEFT ? PSX_TRACE_EYE_BEGIN_L + : PSX_TRACE_EYE_BEGIN_R; + psx_frame_trace_add(base + 0, trace_ticks_to_us(tb - t0)); + psx_frame_trace_add(base + 1, trace_ticks_to_us(tg - tb)); + psx_frame_trace_add(base + 2, trace_ticks_to_us(te - tg)); + psx_frame_trace_add(base + 3, trace_ticks_to_us(tr - te)); + uint64_t p1 = 0, t1x = 0, diag1[6] = {0,0,0,0,0,0}; + gl_renderer_scene_prim_counts(&p1, &t1x); + gl_renderer_runtime_diag(diag1); + psx_frame_trace_draw(eye, (uint32_t)(p1 - trace_prims0), + (uint32_t)(t1x - trace_tex0)); + psx_frame_trace_eye_cycles(eye, (uint32_t)guest_cycles); + /* Upload pixels -> bytes assumes the 16-bit VRAM format uploads use. */ + psx_frame_trace_gpu((diag1[2] - trace_diag0[2]) * 2u, + (uint32_t)leaks); + s_sub_eye = -1; + sub_spans_forget(); + if (reuse_baseline) psx_frame_trace_flag(PSX_FRAME_TRACE_BASELINE_REUSED); + } + + int verify_ok = 1; if (verify_on()) { hash_after = state_hash(cpu); s_stats.verify_checks++; if (hash_after != hash_before || !gl_renderer_pass_verify_vram()) { + verify_ok = 0; s_stats.verify_mismatch++; if (eye >= 0) { ok = 0; s_stereo.last_failure = "restore_verify"; } + /* A mismatch while the eye rendered from the pair's shared baseline + * is exactly what this guard is for: retire the reuse for the + * session and capture per eye again from here on. */ + if (reuse_baseline && baseline_reuse_enabled()) { + s_baseline_enabled = 0; + fprintf(stderr, "psxrecomp: pair baseline reuse disabled after " + "a restore-verify mismatch\n"); + } if (s_stats.verify_mismatch <= 8) fprintf(stderr, "psxrecomp: RENDER PASS VERIFY mismatch " "(state %016llx -> %016llx)\n", @@ -840,6 +1063,13 @@ static int render_transaction(struct CPUState *cpu, const PSXModRenderPass *pass (unsigned long long)hash_after); } } + /* The restore just put the live machine back to the captured baseline, so + * the other eye of this pair, or the other matte of this capture, may reuse + * it. Anything else - a non-stereo transaction, a capture that is not part + * of a matte pair, a failed eye or an unverified restore - leaves nothing + * safe to reuse. */ + s_baseline_ready = (eye >= 0 && ok && verify_ok && + (!s_capture_only || s_capture_pair)) ? 1 : 0; t1 = gl_renderer_perf_ticks(); { @@ -920,12 +1150,61 @@ int psx_mod_render_stereo(CPUState *cpu, const PSXModStereoFrame *frame, if (status != PSX_MOD_RENDER_PASS_READY) { s_stereo.refused++; s_stereo.last_failure = "status"; return 0; } - budget_ms = frame->period_vblanks * (double)g_psx_vblank_cycles / - 33868.8 * 0.8; - if (s_stereo.avg_pair_ms > budget_ms && ++s_pair_retry < 30u) { - s_stereo.shed++; return 0; + /* Frame trace (opt-in). The runtime already opened this frame's record in + * psx_openxr_begin() when a headset is active; opening here covers the + * desktop stereo path, and the guest-sim gap is the host time since the + * previous traced frame. */ + int trace_on = psx_frame_trace_enabled(); + int trace_opened = trace_on ? psx_frame_trace_open() : 0; + uint64_t now_ms = psx_host_mono_ms(); + if (trace_on) { + static uint64_t prev_ms; + static int prev_valid; + if (prev_valid) + psx_frame_trace_add(PSX_TRACE_GUEST_SIM, (now_ms - prev_ms) * 1000u); + prev_ms = now_ms; prev_valid = 1; + psx_frame_trace_attempt(s_stereo.attempts, 0, 0); } - s_pair_retry = 0; + budget_ms = shed_budget_ms(frame->period_vblanks, &s_stereo.budget_source); + s_stereo.budget_ms = budget_ms; + /* A scene/mode transition changes cadence and workload, so the previous + * average and budget do not carry over. */ + if (!s_shed_geom_valid || frame->period_vblanks != s_shed_period || + (int32_t)frame->w != s_shed_w || (int32_t)frame->h != s_shed_h) { + if (s_shed_geom_valid) s_stereo.budget_resets++; + s_shed_period = frame->period_vblanks; + s_shed_w = (int32_t)frame->w; s_shed_h = (int32_t)frame->h; + s_shed_geom_valid = 1; + stereo_shed_policy_reset(&s_shed); + } + stereo_shed_policy_set_budget(&s_shed, budget_ms); + { + int was_shedding = s_shed.shedding; + if (stereo_shed_policy_should_shed(&s_shed, now_ms)) { + s_stereo.shed++; + s_stereo.stale_frames++; + s_stereo.shed_streak = stereo_shed_policy_streak_sheds(&s_shed); + if (s_stereo.shed_streak > s_stereo.shed_streak_max) + s_stereo.shed_streak_max = s_stereo.shed_streak; + s_stereo.shed_streak_ms = stereo_shed_policy_streak_ms(&s_shed, now_ms); + s_stereo.shedding = 1; + if (trace_on) { + psx_frame_trace_attempt(s_stereo.attempts, s_stereo.shed_streak, + s_stereo.shed_streak_ms); + psx_frame_trace_flag(PSX_FRAME_TRACE_STALE_PAIR | PSX_FRAME_TRACE_SHED); + if (trace_opened) psx_frame_trace_commit(); + } + return 0; + } + /* Log once per streak (not per frame), with both the skipped count and + * the elapsed wall clock the old attempt counter never tracked. */ + if (was_shedding) + fprintf(stderr, "psxrecomp: stereo shed streak ended after %llu " + "skipped, %u ms\n", + (unsigned long long)s_stereo.shed_streak, + s_stereo.shed_streak_ms); + } + s_stereo.shedding = s_shed.shedding; if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); cycle = psx_cycle_count; stores = g_guest_store_count; mismatches = s_stats.verify_mismatch; aborted = s_stats.aborted; @@ -939,6 +1218,10 @@ int psx_mod_render_stereo(CPUState *cpu, const PSXModStereoFrame *frame, call.fn = fn; call.user = user; s_pair_busy = 1; s_plan_serial++; /* separate the backup lease from any temporal plan */ + /* The guest has run since the previous pair, so the retained snapshot no + * longer describes the machine: only the second eye of this pair may reuse + * what the first one captures. */ + s_baseline_ready = 0; t0 = gl_renderer_perf_ticks(); for (call.eye = 0; call.eye < 2u; call.eye++) { s_stereo.last_eye = (int)call.eye; @@ -961,8 +1244,6 @@ int psx_mod_render_stereo(CPUState *cpu, const PSXModStereoFrame *frame, elapsed = (double)(gl_renderer_perf_ticks() - t0) * 1000.0 / (double)gl_renderer_perf_frequency(); s_stereo.last_pair_ms = elapsed; - s_stereo.avg_pair_ms = s_stereo.avg_pair_ms > 0.0 - ? s_stereo.avg_pair_ms * 0.9 + elapsed * 0.1 : elapsed; if (ok && !gl_renderer_stereo_publish(s_stereo.attempts, cycle, s_stereo.eye_view)) { ok = 0; s_stereo.last_failure = "pair_publish"; } @@ -978,22 +1259,81 @@ int psx_mod_render_stereo(CPUState *cpu, const PSXModStereoFrame *frame, s_stereo.retained_pair_id = s_stereo.last_pair_id; gl_renderer_stereo_stage_reset(); } + /* Fold the attempt into the policy's estimate (it owns the EMA now). A + * failed or aborted attempt still counts as a measurement, so a stale high + * average cannot outlive the condition that produced it; only a published + * pair refreshes the retained-pair age. Pair age is tracked separately from + * the compute-time estimate so "how old is the picture" never hides behind + * "how fast was the last attempt". */ + stereo_shed_policy_note_pair(&s_shed, elapsed, now_ms, ok); + s_stereo.avg_pair_ms = stereo_shed_policy_avg_ms(&s_shed); + s_stereo.last_pair_age_ms = stereo_shed_policy_pair_age_ms(&s_shed, now_ms); + s_stereo.shedding = s_shed.shedding; + if (!ok) s_stereo.stale_frames++; + if (trace_on) { + psx_frame_trace_add(PSX_TRACE_PAIR_TOTAL, (uint64_t)(elapsed * 1000.0)); + psx_frame_trace_result(ok, ok ? s_stereo.last_pair_id : 0, + ok ? -1 : s_stereo.last_eye, + ok ? NULL : s_stereo.last_failure); + if (trace_opened) psx_frame_trace_commit(); + } s_pair_busy = 0; return ok; } -int psx_mod_render_capture_rgba(CPUState *cpu,const PSXModRenderPass *rect, - PSXModRenderPassFn fn,void *user, - uint8_t *rgba,uint32_t bytes) { - if(s_pair_busy || !cpu || !rect || !fn || !rgba || rect->struct_size 0.0 && ms < 1000.0) ? ms : 0.0; +} + +/* Two mattes of one slice in a single capture: the first transaction stages + * eye 0 and the second eye 1, so one read returns both. The two transactions + * are captures, not eye draws: neither publishes a pair nor samples the + * per-eye trace, and the checkpoint they share is their own. */ +int psx_mod_render_capture_rgba_pair(CPUState *cpu,const PSXModRenderPass *rect, + PSXModRenderPassFn fn,void *user_first, + void *user_second,uint8_t *rgba,uint32_t bytes) { + if(s_pair_busy || !cpu || !rect || !fn || !rgba || !user_first || !user_second || + rect->struct_sizew || !rect->h || rect->x+rect->w>1024 || rect->y+rect->h>512 || - (uint64_t)rect->w*rect->h*4>bytes || transaction_status(1)!=PSX_MOD_RENDER_PASS_READY)return 0; + (uint64_t)rect->w*rect->h*8>bytes || transaction_status(1)!=PSX_MOD_RENDER_PASS_READY)return 0; if(g_overlay_flush_pending_cycles)g_overlay_flush_pending_cycles(); + uint64_t trace_t0=gl_renderer_perf_ticks(); s_pair_busy=1;s_plan_serial++; gl_renderer_stereo_stage_reset(); PSXModRenderPass capture=*rect;capture.alpha_q16=0; - int ok=render_transaction(cpu,&capture,fn,user,0); - if(ok)ok=gl_renderer_stereo_stage_readback(0,rect->w,rect->h,rgba); + /* Start uncaptured: the ready flag carries the pair's baseline from the + * previous frame, which describes a machine the guest has since changed. */ + s_capture_only=1;s_capture_pair=1;s_baseline_ready=0; + uint64_t cycle=psx_cycle_count,stores=g_guest_store_count; + int ok=render_transaction(cpu,&capture,fn,user_first,0); + /* The second matte must draw from the state the first one's restore put + * back. If the clock or the store count moved, that baseline no longer + * describes it, so capture the machine again instead of reusing it. */ + if(psx_cycle_count!=cycle || g_guest_store_count!=stores)s_baseline_ready=0; + if(ok)ok=render_transaction(cpu,&capture,fn,user_second,1); + s_capture_only=0;s_capture_pair=0;s_baseline_ready=0; + uint64_t trace_t1=gl_renderer_perf_ticks(); + psx_frame_trace_add(PSX_TRACE_HUD_DRAW, + trace_ticks_to_us(trace_t1-trace_t0)); + if(ok)ok=gl_renderer_stereo_stage_readback_pair(rect->w,rect->h,rgba); + psx_frame_trace_add(PSX_TRACE_HUD_READBACK, + trace_ticks_to_us(gl_renderer_perf_ticks()-trace_t1)); gl_renderer_stereo_stage_reset();s_pair_busy=0; + psx_frame_trace_add(PSX_TRACE_HUD_CAPTURE, + trace_ticks_to_us(gl_renderer_perf_ticks()-trace_t0)); return ok; } diff --git a/runtime/src/sio.c b/runtime/src/sio.c index 2d82ca85..e85f8b46 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -69,8 +69,9 @@ static uint8_t pad_analog_device[PSX_MAX_PLAYERS] = { /* DualShock command 0x4D maps the six writable bytes in a 0x42 poll onto the * two motors: 0x00 = small/high-frequency, 0x01 = large/low-frequency, - * 0xFF = unused. The map powers up unassigned and is echoed back while a new - * map is latched, matching the physical pad/Mednafen protocol. */ + * 0xFF = unused (PSX-SPX controllersandmemorycards, "Config Mode - Command 4Dh"). + * The map powers up unassigned and is echoed back while a new map is + * latched, as on the Mednafen reference. */ static uint8_t pad_rumble_map[PSX_MAX_PLAYERS][6] = { PSX_PAD_INIT({ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }) }; @@ -274,8 +275,9 @@ static uint8_t pad_protocol_id(int logical) { } /* NeGcon (NPC-101) poll payload: buttons, twist, then analog I, II and L - * (Mednafen psx/input/negcon.cpp). The device has no config mode and no - * motors; every byte is fixed by the current host sample. */ + * (PSX-SPX controllersandmemorycards, "neGcon Racing Controller"). + * The device has no config mode and no motors; every byte is fixed by the + * current host sample. */ static void pad_fill_negcon_poll(int logical, uint8_t out[8]) { out[0] = 0x23; out[1] = 0x5A; @@ -1412,7 +1414,7 @@ static void pad_process_byte(uint8_t tx_byte) { if (pad_analog[lp] == SIO_PAD_NEGCON) { /* NeGcon answers only the 0x42 poll. Its ID byte goes out with any * command, but for anything else it does not ACK and the - * transaction ends (Mednafen negcon.cpp phase 1). */ + * transaction ends, as on the Mednafen reference. */ pad_fill_negcon_poll(lp, pad_response); sio_rx_data = pad_response[0]; if (tx_byte == 0x42) { @@ -1817,8 +1819,9 @@ static void mc_process_byte(uint8_t tx_byte) { } else { mc_data_idx = 0; mc_state = MC_WRITE_LSB_ECHO; - /* Hardware/Beetle echo the high address byte on the address-LSB - * transfer (zero for all 1Mbit card sectors), then echo the low + /* Hardware echoes the high address byte on the address-LSB + * transfer (PSX-SPX controllersandmemorycards, "Writing Data to Memory + * Card": reply "pre"; zero for all 1Mbit card sectors), then echo the low * address byte while receiving the first payload byte. Some BIOS * card-write paths validate this handshake before they schedule the * follow-up directory reads. */ diff --git a/runtime/src/stereo_shed_policy.c b/runtime/src/stereo_shed_policy.c new file mode 100644 index 00000000..dd3621a2 --- /dev/null +++ b/runtime/src/stereo_shed_policy.c @@ -0,0 +1,145 @@ +/* stereo_shed_policy.c — bounded whole-pair shedding. See stereo_shed_policy.h. */ + +#include "stereo_shed_policy.h" + +#include +#include + +#define SHED_EMA_ALPHA 0.1 +#define SHED_DEFAULT_PROBE_MS 250.0 +#define SHED_DEFAULT_MAX_STREAK 8u +#define SHED_DEFAULT_HYSTERESIS 0.9 + +static double clamp_double(double v, double lo, double hi) { + if (!(v == v)) return lo; /* NaN */ + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +void stereo_shed_policy_init(StereoShedPolicy *p) { + if (!p) return; + memset(p, 0, sizeof *p); + p->enabled = 1; + p->probe_ms = SHED_DEFAULT_PROBE_MS; + p->max_streak = SHED_DEFAULT_MAX_STREAK; + p->hysteresis = SHED_DEFAULT_HYSTERESIS; +} + +void stereo_shed_policy_configure_from_env(StereoShedPolicy *p) { + const char *e; + if (!p) return; + e = getenv("PSX_STEREO_SHED"); + if (e) p->enabled = (e[0] && e[0] != '0') ? 1 : 0; + e = getenv("PSX_STEREO_SHED_PROBE_MS"); + if (e) p->probe_ms = clamp_double(strtod(e, NULL), 10.0, 5000.0); + e = getenv("PSX_STEREO_SHED_MAX_STREAK"); + if (e) { + long v = strtol(e, NULL, 0); + p->max_streak = (uint32_t)(v < 1 ? 1 : (v > 240 ? 240 : v)); + } + e = getenv("PSX_STEREO_SHED_HYSTERESIS"); + if (e) p->hysteresis = clamp_double(strtod(e, NULL), 0.5, 1.0); +} + +void stereo_shed_policy_set_budget(StereoShedPolicy *p, double budget_ms) { + if (!p) return; + p->budget_ms = budget_ms > 0.0 ? budget_ms : 0.0; +} + +void stereo_shed_policy_reset(StereoShedPolicy *p) { + if (!p) return; + p->avg_ms = 0.0; + p->shedding = 0; + p->probing = 0; + p->streak = 0; + p->streak_start_ms = 0; + p->pairs_seen = 0; +} + +int stereo_shed_policy_should_shed(StereoShedPolicy *p, uint64_t now_ms) { + if (!p || !p->enabled) return 0; + + if (p->avg_ms > p->budget_ms) { + if (!p->shedding) { /* enter shedding at the first over-budget sample */ + p->shedding = 1; + p->streak = 0; + p->streak_start_ms = now_ms; + } + } else if (p->avg_ms < p->budget_ms * p->hysteresis) { + p->shedding = 0; /* clearly recovered */ + p->streak = 0; + } else { + /* hysteresis band: hold whatever state we are in */ + } + + if (!p->shedding) return 0; + + /* Bounded recovery: neither an elapsed-time nor an attempt-count rule may + * suppress indefinitely. Whichever bound trips first forces a probe, whose + * result note_pair() treats as authoritative. The streak's timer restarts + * here so a persistent overload probes again after the same bound rather + * than every frame. */ + uint64_t elapsed = now_ms >= p->streak_start_ms ? now_ms - p->streak_start_ms : 0; + if (p->streak >= p->max_streak || elapsed >= (uint64_t)p->probe_ms) { + p->probing = 1; + p->streak = 0; + p->streak_start_ms = now_ms; + return 0; /* render this one: take a fresh sample */ + } + p->streak++; + p->shed_total++; + return 1; +} + +void stereo_shed_policy_note_pair(StereoShedPolicy *p, double pair_ms, + uint64_t now_ms, int ok) { + if (!p) return; + if (pair_ms < 0.0) pair_ms = 0.0; + if (p->probing) { + /* A forced probe is a deliberate fresh measurement: use it directly + * instead of blending it away. Otherwise one spike would keep the + * estimate high for many probes, and the headset would keep showing + * stale pairs long after the spike ended. */ + p->probing = 0; + p->avg_ms = pair_ms; + /* Exit on the deadline itself, not on the hysteresis threshold. A pair + * time that settles between the two (here 10.0 to 11.111 ms) would + * otherwise sit in the band forever: the band holds whatever state it + * is in, so a streak entered during a heavy moment never ends and + * shedding starves rendering while the pair is in fact meeting its + * deadline. A latched streak is bounded by the probe interval, which is + * still the recovery guarantee this policy promises. */ + if (pair_ms <= p->budget_ms) { + p->shedding = 0; /* the overload is over */ + p->streak = 0; + } + } else { + p->avg_ms = p->pairs_seen ? p->avg_ms * (1.0 - SHED_EMA_ALPHA) + pair_ms * SHED_EMA_ALPHA + : pair_ms; + } + p->pairs_seen++; + if (ok) { + p->last_pair_ms = now_ms; + p->have_pair = 1; + } +} + +uint32_t stereo_shed_policy_streak_sheds(const StereoShedPolicy *p) { + return p ? p->streak : 0; +} + +uint32_t stereo_shed_policy_streak_ms(const StereoShedPolicy *p, uint64_t now_ms) { + if (!p || !p->shedding || now_ms < p->streak_start_ms) return 0; + uint64_t d = now_ms - p->streak_start_ms; + return d > 0xFFFFFFFFu ? 0xFFFFFFFFu : (uint32_t)d; +} + +double stereo_shed_policy_avg_ms(const StereoShedPolicy *p) { + return p ? p->avg_ms : 0.0; +} + +uint64_t stereo_shed_policy_pair_age_ms(const StereoShedPolicy *p, uint64_t now_ms) { + if (!p || !p->have_pair || now_ms < p->last_pair_ms) return 0; + return now_ms - p->last_pair_ms; +} diff --git a/runtime/src/vr_pose_math.c b/runtime/src/vr_pose_math.c index 5a09cba4..1c6a295b 100644 --- a/runtime/src/vr_pose_math.c +++ b/runtime/src/vr_pose_math.c @@ -25,10 +25,12 @@ int vr_pose_to_view(const double q[4], const double p[3], for(int row=0;row<3;row++) { for(int col=0;col<3;col++) { double a=0;for(int k=0;k<3;k++)a+=e[k*3+row]*o[k*3+col]; - v.rotation_q12[row*3+col]=(int32_t)lround(a*sign[row]*sign[col]*4096); + v.rotation[row*3+col]=a*sign[row]*sign[col]; + v.rotation_q12[row*3+col]=(int32_t)lround(v.rotation[row*3+col]*4096); } double a=0;for(int k=0;k<3;k++)a+=e[k*3+row]*(op[k]-p[k]); a*=sign[row]*units;if(fabs(a)>65536) return 0; + v.translation_precise[row]=a; v.translation[row]=(int32_t)lround(a); } double fx=w/(r-l),fy=h/(u-d),cx=-l*fx-w*.5,cy=u*fy-h*.5; @@ -36,6 +38,7 @@ int vr_pose_to_view(const double q[4], const double p[3], for(int i=0;i<4;i++) if(!isfinite(pix[i]) || fabs(pix[i])>32767) return 0; v.fx_q16=(int32_t)lround(fx*65536);v.fy_q16=(int32_t)lround(fy*65536); v.cx_delta_q16=(int32_t)lround(cx*65536);v.cy_delta_q16=(int32_t)lround(cy*65536); + v.precise=1; *out=v;return 1; } int vr_pose_to_transform(const double q[4],const double p[3], diff --git a/runtime/tests/gpu_exec_stubs.inc b/runtime/tests/gpu_exec_stubs.inc index 249ff1ed..76716d1f 100644 --- a/runtime/tests/gpu_exec_stubs.inc +++ b/runtime/tests/gpu_exec_stubs.inc @@ -7,6 +7,28 @@ uint64_t s_frame_count; uint64_t psx_cycle_count; /* gpu_timeline.c stamps notes with it */ int g_exec_phase; +int g_psx_render_pass_active; +void psx_mod_counter_add(const char *name, uint32_t delta) { (void)name; (void)delta; } +static int fixture_pgxp_enabled; +int pgxp_enabled(void) { return fixture_pgxp_enabled; } +static int fixture_pgxp_cpu_mode; +int pgxp_cpu_mode(void) { return fixture_pgxp_cpu_mode; } +void pgxp_set_cpu_mode(int enabled) { fixture_pgxp_cpu_mode=enabled; } +int pgxp_projective_scope_begin(PGXPProjectiveScope *scope) { + if (!scope || scope->active) return 0; + scope->saved_enabled=fixture_pgxp_enabled; + scope->saved_cpu_mode=fixture_pgxp_cpu_mode; + fixture_pgxp_enabled=1; + fixture_pgxp_cpu_mode=1; + scope->active=1; + return 1; +} +void pgxp_projective_scope_end(PGXPProjectiveScope *scope) { + if (!scope || !scope->active) return; + fixture_pgxp_cpu_mode=scope->saved_cpu_mode; + fixture_pgxp_enabled=scope->saved_enabled; + scope->active=0; +} uint32_t g_debug_current_func_addr; uint32_t g_debug_last_store_pc; CPUState *debug_cpu_ptr; @@ -38,6 +60,10 @@ int pgxp_load_projection(uint32_t addr, uint32_t word, PGXPProjection *out) { } return 0; } +float pgxp_get_unclamped_depth(uint32_t addr, uint32_t word) { + PGXPProjection projection; + return pgxp_load_projection(addr, word, &projection) ? projection.z : 0.0f; +} static int fixture_projection_tracking; int pgxp_projection_tracking(void) { return fixture_projection_tracking; } void pgxp_set_projection_tracking(int enabled) { fixture_projection_tracking=!!enabled; } @@ -141,7 +167,6 @@ void psx_fatal_halt(const char *reason) { assert(!"psx_fatal_halt called"); } -static int fixture_pgxp_enabled; void pgxp_set_enabled(int enabled) { fixture_pgxp_enabled = enabled; } int gte_geometry_correction_enabled(void) { return g_test_geometry_correction; } int pgxp_get_precise_vertex(uint32_t addr, uint32_t packet_word, diff --git a/runtime/tests/run_gl_frame_gen.py b/runtime/tests/run_gl_frame_gen.py index e57ec21d..0186908d 100644 --- a/runtime/tests/run_gl_frame_gen.py +++ b/runtime/tests/run_gl_frame_gen.py @@ -176,6 +176,23 @@ def main(): or (pan == "1" and p["real"] != runs[0]["real"])): print(f"FAIL scale {s} {path} {timing} reprojection pgxp={pgxp} pan={pan}") ok = False + # Occlusion (FG_OCCLUDE=1): a far quad drawn after a near wall, + # hidden in the real frames by PGXP depth; no reprojected frame + # may show it through the wall (the depth image takes the nearest + # world depth, not painter order). + if path == "vram": # the occlusion scene draws through VRAM only + env = dict(os.environ, FG_METHOD="reprojection", FG_OCCLUDE="1") + command = [str(c) for c in (probe, s, 1, path, args.frames, timing)] + r = subprocess.run(command, cwd=dest, capture_output=True, text=True, + encoding="utf-8", errors="replace", env=env) + receipt.append({"cmd": command, "env": {"FG_METHOD": "reprojection", "FG_OCCLUDE": "1"}, + "exit": r.returncode, "stdout": r.stdout, "stderr": r.stderr[-4000:]}) + occl = [l for l in r.stdout.splitlines() if l.startswith("occlusion")] + print(f"scale {s} {path} {timing} reprojection occlusion: exit={r.returncode}", occl, + r.stderr.strip()[-800:]) + if r.returncode or not occl: + print(f"FAIL scale {s} {path} {timing} reprojection occlusion") + ok = False # Present thread ([video] present_thread): the fixture checks the # window shows every composed image bit for bit, in order. The real # images match the direct-swap run: bit for bit, except that diff --git a/runtime/tests/test_autocompile_child_env.c b/runtime/tests/test_autocompile_child_env.c new file mode 100644 index 00000000..c56627da --- /dev/null +++ b/runtime/tests/test_autocompile_child_env.c @@ -0,0 +1,60 @@ +/* Real child launch: private TEMP/TMP, inherited variables, parent isolation. */ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +static char s_cache_dir[512]; /* same input capacity as autocompile.c */ +#include "autocompile_child_env.h" +#define CHECK(x) do { if (!(x)) { fprintf(stderr,"FAIL line %d: %s\n",__LINE__,#x); return 1; } } while (0) +int main(int argc, char **argv) { + char temp[1024], tmp[1024]; + if (argc == 3 && !strcmp(argv[1], "--child")) { + CHECK(GetEnvironmentVariableA("TEMP", temp, sizeof(temp)) > 0); + CHECK(GetEnvironmentVariableA("TMP", tmp, sizeof(tmp)) > 0); + CHECK(!strcmp(temp, argv[2]) && !strcmp(tmp, argv[2])); + char marker[32]; + CHECK(GetEnvironmentVariableA("PSX_AC_TEMP_TEST", marker, sizeof(marker)) > 0); + CHECK(!strcmp(marker, "preserved")); + return 0; + } + char cwd[512], cache[600], expected[610], exe[1024], cmd[2300]; + CHECK(GetCurrentDirectoryA(sizeof(cwd), cwd) > 0); + CHECK(GetEnvironmentVariableA("TEMP", temp, sizeof(temp)) > 0); + CHECK(GetEnvironmentVariableA("TMP", tmp, sizeof(tmp)) > 0); + CHECK(SetEnvironmentVariableA("PSX_AC_TEMP_TEST", "preserved")); + snprintf(cache, sizeof(cache), "%s\\autocompile-child-cache", cwd); + CHECK(CreateDirectoryA(cache, NULL) || GetLastError() == ERROR_ALREADY_EXISTS); + char *env = ac_child_environment(cache); + CHECK(env != NULL); + int temps = 0, tmps = 0; + const char *previous = NULL; + for (char *p = env; *p; p += strlen(p) + 1) { + CHECK(!previous || _stricmp(previous, p) <= 0); + if (!_strnicmp(p, "TEMP=", 5)) ++temps; + if (!_strnicmp(p, "TMP=", 4)) ++tmps; + previous = p; + } + CHECK(temps == 1 && tmps == 1); + snprintf(expected, sizeof(expected), "%s\\tmp", cache); + CHECK(GetModuleFileNameA(NULL, exe, sizeof(exe)) > 0); + snprintf(cmd, sizeof(cmd), "\"%s\" --child \"%s\"", exe, expected); + STARTUPINFOA si = {0}; PROCESS_INFORMATION pi = {0}; si.cb = sizeof(si); + CHECK(CreateProcessA(NULL, cmd, NULL, NULL, FALSE, CREATE_NO_WINDOW, + env, cwd, &si, &pi)); + free(env); + CHECK(WaitForSingleObject(pi.hProcess, 10000) == WAIT_OBJECT_0); + DWORD code = 1; CHECK(GetExitCodeProcess(pi.hProcess, &code)); + CloseHandle(pi.hThread); CloseHandle(pi.hProcess); CHECK(code == 0); + char after[1024]; + CHECK(GetEnvironmentVariableA("TEMP", after, sizeof(after)) > 0 && !strcmp(after,temp)); + CHECK(GetEnvironmentVariableA("TMP", after, sizeof(after)) > 0 && !strcmp(after,tmp)); + /* A file occupying /tmp must refuse, never inherit another TEMP. */ + snprintf(cache, sizeof(cache), "%s\\autocompile-blocked-cache", cwd); + CHECK(CreateDirectoryA(cache, NULL) || GetLastError() == ERROR_ALREADY_EXISTS); + snprintf(expected, sizeof(expected), "%s\\tmp", cache); + FILE *file = fopen(expected, "wb"); CHECK(file != NULL); fclose(file); + CHECK(ac_child_environment(cache) == NULL); + puts("PASS: compiler child gets cache scratch; parent TEMP/TMP stay unchanged; invalid scratch refuses"); + return 0; +} diff --git a/runtime/tests/test_frame_gen.c b/runtime/tests/test_frame_gen.c index 3d15d1d7..f3540589 100644 --- a/runtime/tests/test_frame_gen.c +++ b/runtime/tests/test_frame_gen.c @@ -445,6 +445,34 @@ static void test_view_affine(void) { const double c = cos(2 * a), sn = sin(2 * a), dx = P[0] - 30, dz = P[2] - 400; check(fabs(o0 - (c * dx - sn * dz)) < 1e-2 && fabs(o2 - (sn * dx + c * dz)) < 1e-2, "t = 0: the older frame's position"); + /* Older frame (two-frame warp): t = 0 is the older camera itself, t = 1 + * the newer one (P_new = M P_old + T), and at any t an older point lands + * where the same point seen from the newer frame lands. */ + float Ao[9], bo[3]; + check(fg_view_affine_older(&f, 0, 0.0, Ao, bo) && + fabsf(Ao[0] - 1) < 1e-5f && fabsf(Ao[4] - 1) < 1e-5f && fabsf(Ao[8] - 1) < 1e-5f && + fabsf(bo[0]) < 1e-5f && fabsf(bo[2]) < 1e-5f, "older, t = 0: the older camera itself"); + for (int k = 0; k <= 4; k++) { + const double t = k / 4.0; + const float Po[3] = { -50, 10, 900 }; + double M[9]; + { const double w = f.v[0].q[0], x = f.v[0].q[1], y = f.v[0].q[2], z = f.v[0].q[3]; + M[0] = 1 - 2*(y*y + z*z); M[1] = 2*(x*y - w*z); M[2] = 2*(x*z + w*y); + M[3] = 2*(x*y + w*z); M[4] = 1 - 2*(x*x + z*z); M[5] = 2*(y*z - w*x); + M[6] = 2*(x*z - w*y); M[7] = 2*(y*z + w*x); M[8] = 1 - 2*(x*x + y*y); } + float Pn[3]; + for (int r = 0; r < 3; r++) + Pn[r] = (float)(M[3*r] * Po[0] + M[3*r+1] * Po[1] + M[3*r+2] * Po[2] + f.v[0].t[r]); + fg_view_affine(&f, 0, t, A, b); + fg_view_affine_older(&f, 0, t, Ao, bo); + int same = 1; + for (int r = 0; r < 3; r++) { + const float vn = A[3*r] * Pn[0] + A[3*r+1] * Pn[1] + A[3*r+2] * Pn[2] + b[r]; + const float vo = Ao[3*r] * Po[0] + Ao[3*r+1] * Po[1] + Ao[3*r+2] * Po[2] + bo[r]; + same &= fabsf(vn - vo) < 0.05f; + } + check(same, "older and newer warps agree on the same point at every phase"); + } } /* A 320x240 main view and a 64x48 inset with the same valid camera motion. diff --git a/runtime/tests/test_frame_trace.c b/runtime/tests/test_frame_trace.c new file mode 100644 index 00000000..8650980c --- /dev/null +++ b/runtime/tests/test_frame_trace.c @@ -0,0 +1,368 @@ +/* Unit tests for the opt-in frame-trace ring (frame_trace.c). + * + * The trace is the floor the VR stutter investigation stands on, so its + * contracts must hold without a headset: a disabled trace costs nothing and + * records nothing, timestamps never go backwards even when the host clock does, + * the ring wraps without losing the newest records, a frame that measured + * nothing is marked as such, and every export is NUL-terminated and safe to + * truncate. ctest -R frame_trace_test + */ +#include "frame_trace.h" + +#include +#include + +/* Evaluate side effects and failures even when NDEBUG is defined. */ +#define CHECK(expr) do { \ + if (!(expr)) { \ + fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #expr); \ + return 1; \ + } \ +} while (0) + +static uint64_t g_fake_ms; +static uint64_t fake_clock(void) { return g_fake_ms; } + +/* A drifting clock: monotonic except one deliberate backwards step. */ +static uint64_t g_step; +static uint64_t stepping_clock(void) { + static const uint64_t seq[] = { 100, 120, 90, 140, 200 }; + return seq[(g_step++) % (sizeof seq / sizeof seq[0])]; +} + +static int frame_with(uint64_t base_us, int publish, uint64_t pair_id, + int eye_failed, const char *failure) { + uint64_t us = base_us; + if (!psx_frame_trace_open()) return 0; + psx_frame_trace_ids(0x1000 + pair_id, 0x2000 + pair_id); + psx_frame_trace_add(PSX_TRACE_EYE_BEGIN_L, us); + psx_frame_trace_add(PSX_TRACE_EYE_GUEST_L, us * 2); + psx_frame_trace_add(PSX_TRACE_EYE_END_L, us + 3); + psx_frame_trace_add(PSX_TRACE_EYE_RESTORE_L, 5); + psx_frame_trace_add(PSX_TRACE_EYE_BEGIN_R, us + 1); + psx_frame_trace_add(PSX_TRACE_EYE_GUEST_R, us * 2 + 1); + psx_frame_trace_add(PSX_TRACE_XR_WAIT_FRAME, 700); + psx_frame_trace_add(PSX_TRACE_XR_END_FRAME, 40); + psx_frame_trace_result(publish, pair_id, eye_failed, failure); + psx_frame_trace_commit(); + return 1; +} + +int main(void) { + /* ---- disabled by default: nothing recorded, nothing to export ---- */ + psx_frame_trace_set_clock(fake_clock); + psx_frame_trace_reset(); + psx_frame_trace_set_enabled(0); + CHECK(psx_frame_trace_enabled() == 0); + CHECK(psx_frame_trace_open() == 0); + psx_frame_trace_add(PSX_TRACE_PAIR_TOTAL, 999); + psx_frame_trace_commit(); + CHECK(psx_frame_trace_committed() == 0); + { + char buf[256]; + int n = psx_frame_trace_to_json(buf, sizeof buf, 0); + CHECK(n > 0 && n < (int)sizeof buf); + CHECK(strstr(buf, "\"enabled\":0") != NULL); + CHECK(strstr(buf, "\"records\":[]") != NULL); + /* Session metadata is a no-op while capture is off. */ + psx_frame_trace_session("ShouldNotAppear", 12345); + CHECK(psx_frame_trace_to_json(buf, sizeof buf, 0) > 0); + CHECK(strstr(buf, "\"runtime\":\"\"") != NULL); + CHECK(strstr(buf, "\"target_period_us\":0") != NULL); + } + + /* ---- enabled: stages land, result/pair age, monotonic seq ---- */ + psx_frame_trace_set_enabled(1); + CHECK(psx_frame_trace_capacity() >= 64); + g_fake_ms = 1000; + CHECK(frame_with(10, 1, 7, -1, NULL)); + g_fake_ms = 1033; + CHECK(frame_with(100, 0, 7, 1, "eye_capture")); /* stale: publish failed */ + g_fake_ms = 1066; + CHECK(frame_with(12, 1, 8, -1, NULL)); + CHECK(psx_frame_trace_committed() == 3); + + { + PSXFrameTraceRecord recs[8]; + uint32_t n = psx_frame_trace_read(recs, 8); + CHECK(n == 3); + CHECK(recs[0].seq == 1 && recs[2].seq == 3); + CHECK(recs[0].open_ms <= recs[1].open_ms && + recs[1].open_ms <= recs[2].open_ms); + /* stage accumulation is exact */ + CHECK(recs[0].stages_us[PSX_TRACE_EYE_GUEST_L] == 20); + CHECK(recs[0].stages_us[PSX_TRACE_EYE_BEGIN_R] == 11); + CHECK(recs[0].stages_us[PSX_TRACE_XR_WAIT_FRAME] == 700); + /* published then stale */ + CHECK((recs[0].flags & PSX_FRAME_TRACE_STEREO_PUBLISHED) != 0); + CHECK(recs[0].pair_id == 7 && recs[0].pair_age_ms == 0); + CHECK((recs[1].flags & PSX_FRAME_TRACE_STALE_PAIR) != 0); + CHECK((recs[1].flags & PSX_FRAME_TRACE_FAILED) != 0); + CHECK(recs[1].eye_failed == 1); + CHECK(strcmp(recs[1].failure, "eye_capture") == 0); + CHECK(recs[1].pair_age_ms == 33); + CHECK((recs[2].flags & PSX_FRAME_TRACE_STEREO_PUBLISHED) != 0); + CHECK(recs[2].pair_id == 8 && recs[2].pair_age_ms == 0); + } + + /* ---- missing telemetry: an open + commit with no stage is marked ---- */ + g_fake_ms = 1100; + CHECK(psx_frame_trace_open() == 1); + CHECK(psx_frame_trace_open() == 0); /* no double open */ + psx_frame_trace_commit(); + CHECK(psx_frame_trace_missing() == 1); + { + PSXFrameTraceRecord recs[8]; + uint32_t n = psx_frame_trace_read(recs, 8); + CHECK(n == 4); + CHECK((recs[3].flags & PSX_FRAME_TRACE_NO_TELEMETRY) != 0); + } + + /* ---- shed annotation and flag rendering ---- */ + g_fake_ms = 1133; + CHECK(psx_frame_trace_open() == 1); + psx_frame_trace_attempt(42, 3, 250); + psx_frame_trace_flag(PSX_FRAME_TRACE_STALE_PAIR); + psx_frame_trace_commit(); + { + PSXFrameTraceRecord recs[8]; + char names[96]; + uint32_t n = psx_frame_trace_read(recs, 8); + CHECK(n == 5); + CHECK(recs[4].stereo_attempt == 42); + CHECK(recs[4].shed_skipped == 3 && recs[4].shed_streak_ms == 250); + CHECK((recs[4].flags & PSX_FRAME_TRACE_SHED) != 0); + /* A shed-only frame is stale, not "missing telemetry". */ + CHECK((recs[4].flags & PSX_FRAME_TRACE_NO_TELEMETRY) == 0); + CHECK(psx_frame_trace_missing() == 1); + psx_frame_trace_flag_names(recs[4].flags, names, sizeof names); + CHECK(strstr(names, "stale") && strstr(names, "shed")); + } + + /* ---- stage saturation rather than wrap ---- */ + g_fake_ms = 1200; + CHECK(psx_frame_trace_open() == 1); + psx_frame_trace_add(PSX_TRACE_PAIR_TOTAL, 0xF0000000u); + psx_frame_trace_add(PSX_TRACE_PAIR_TOTAL, 0xF0000000u); + psx_frame_trace_commit(); + { + PSXFrameTraceRecord recs[8]; + uint32_t n = psx_frame_trace_read(recs, 8); + CHECK(recs[n - 1].stages_us[PSX_TRACE_PAIR_TOTAL] == 0xFFFFFFFFu); + } + + /* ---- a backwards clock step must not produce a backwards timestamp ---- */ + psx_frame_trace_reset(); + { + PSXFrameTraceRecord recs[8]; + psx_frame_trace_set_clock(stepping_clock); + for (int i = 0; i < 5; ++i) { + CHECK(psx_frame_trace_open() == 1); + psx_frame_trace_add(PSX_TRACE_GUEST_SIM, (uint32_t)(1 + i)); + psx_frame_trace_commit(); + } + uint32_t n = psx_frame_trace_read(recs, 8); + CHECK(n == 5); + for (uint32_t i = 1; i < n; ++i) + CHECK(recs[i].open_ms >= recs[i - 1].open_ms); + CHECK(recs[0].open_ms == 100); + CHECK(recs[2].open_ms >= recs[1].open_ms); /* the 90 was clamped up */ + } + + /* ---- ring wrap keeps the newest records ---- */ + psx_frame_trace_reset(); + psx_frame_trace_set_clock(fake_clock); + { + uint32_t cap = psx_frame_trace_capacity(); + uint32_t extra = cap + 10u; + for (uint32_t i = 0; i < extra; ++i) { + g_fake_ms = 2000u + i; + CHECK(psx_frame_trace_open() == 1); + psx_frame_trace_add(PSX_TRACE_GUEST_SIM, i + 1u); + psx_frame_trace_commit(); + } + CHECK(psx_frame_trace_committed() == extra); + CHECK(psx_frame_trace_dropped() == extra - cap); + PSXFrameTraceRecord recs[8]; + CHECK(psx_frame_trace_read(recs, 8) == 8); + CHECK(recs[7].seq == extra); /* newest present */ + CHECK(recs[0].seq == extra - 7u); + CHECK(recs[7].stages_us[PSX_TRACE_GUEST_SIM] == extra); + /* capacity-limited read returns exactly `capacity` of the newest */ + PSXFrameTraceRecord one[1]; + CHECK(psx_frame_trace_read(one, 1) == 1); + CHECK(one[0].seq == extra); + } + + /* Opening the next frame must not retire a committed slot before commit. */ + { + static PSXFrameTraceRecord retained[4096]; + uint32_t capacity = psx_frame_trace_capacity(); + uint64_t committed = psx_frame_trace_committed(); + CHECK(psx_frame_trace_open() == 1); + psx_frame_trace_note("not committed"); + CHECK(psx_frame_trace_read(retained, capacity) == capacity); + CHECK(retained[0].seq == committed - capacity + 1); + CHECK(retained[capacity - 1].seq == committed); + psx_frame_trace_commit(); + CHECK(psx_frame_trace_read(retained, capacity) == capacity); + CHECK(retained[capacity - 1].seq == committed + 1); + } + + /* ---- exports: valid, NULL-terminated, truncation-safe ---- */ + { + static char big[1 << 20]; + int n = psx_frame_trace_to_json(big, sizeof big, 16); + CHECK(n > 0 && n < (int)sizeof big); + CHECK(big[0] == '{' && strstr(big, "\"records\":[") != NULL); + CHECK(big[n - 1] == '}' && big[n - 2] == ']'); + CHECK(strstr(big, "\"stages_us\"") != NULL); + /* The record limit caps the emitted count. */ + CHECK(strstr(big, "\"seq\":") != NULL); + + n = psx_frame_trace_to_csv(big, sizeof big, 4); + CHECK(n > 0 && n < (int)sizeof big); + CHECK(strncmp(big, "seq,open_ms,", 12) == 0); + CHECK(strstr(big, "guest_sim") && strstr(big, "xr_end_frame")); + + n = psx_frame_trace_to_chrome_json(big, sizeof big, 4); + CHECK(n > 0 && n < (int)sizeof big); + CHECK(strncmp(big, "{\"traceEvents\":[", 16) == 0 && big[n - 1] == '}'); + CHECK(strstr(big, "\"ph\":\"X\"") != NULL); + + /* A tiny buffer must still be terminated and report the full length. */ + char small[8]; + int need = psx_frame_trace_to_json(small, sizeof small, 0); + CHECK(need > (int)sizeof small); + CHECK(small[sizeof small - 1] == '\0'); + CHECK(small[0] == '{'); + } + + /* ---- session metadata: runtime + target refresh, JSON-escaped ---- */ + psx_frame_trace_session("TestRuntime/1.0", 11111); /* 90 Hz headset */ + { + static char meta[1 << 16]; + int m = psx_frame_trace_to_json(meta, sizeof meta, 1); + CHECK(m > 0 && m < (int)sizeof meta); + CHECK(strstr(meta, "\"runtime\":\"TestRuntime/1.0\"") != NULL); + CHECK(strstr(meta, "\"target_period_us\":11111") != NULL); + /* A runtime name with a quote must not break the document. */ + psx_frame_trace_session("Odd\"Runtime", 0); + m = psx_frame_trace_to_json(meta, sizeof meta, 1); + CHECK(m > 0 && m < (int)sizeof meta); + CHECK(strstr(meta, "\"runtime\":\"Odd\\\"Runtime\"") != NULL); + CHECK(strstr(meta, "\"target_period_us\":0") != NULL); + } + + /* Fixed scratch buffers may shorten a string, never split a JSON escape. */ + { + char controls[128]; static char json[1 << 16]; + memset(controls, 1, sizeof controls - 1); controls[127] = 0; + psx_frame_trace_session(controls, 0); + CHECK(psx_frame_trace_to_json(json, sizeof json, 1) > 0); + const char *value = strstr(json, "\"runtime\":\""); + CHECK(value != NULL); value += strlen("\"runtime\":\""); + const char *end = strchr(value, '\"'); + CHECK(end && (end - value) % 6 == 0); + for (; value < end; value += 6) CHECK(!memcmp(value, "\\u0001", 6)); + } + + /* ---- XR cadence: the runtime's display-time delta and repeat marking ---- + * One presented pair per granted display period is the pacing target, so a + * frame that carried no new pair must be distinguishable from one that did + * without reading the counterpart stats. */ + psx_frame_trace_reset(); + psx_frame_trace_set_enabled(1); + { + PSXFrameTraceRecord recs[8]; + static char cad[1 << 16]; + char names[96]; + + CHECK(psx_frame_trace_open()); /* carried a new pair */ + psx_frame_trace_add(PSX_TRACE_POSE_AGE, 4200); + psx_frame_trace_result(1, 11, -1, NULL); + psx_frame_trace_xr_cadence(22222, 1); + psx_frame_trace_commit(); + + CHECK(psx_frame_trace_open()); /* carried no new pair */ + psx_frame_trace_result(0, 11, -1, NULL); + psx_frame_trace_xr_cadence(22222, 0); + psx_frame_trace_commit(); + + CHECK(psx_frame_trace_read(recs, 8) == 2); + CHECK(recs[0].xr_display_delta_us == 22222); + CHECK(recs[0].xr_submit_fresh == 1); + CHECK((recs[0].flags & PSX_FRAME_TRACE_XR_REPEAT) == 0); + CHECK(recs[0].stages_us[PSX_TRACE_POSE_AGE] == 4200); + CHECK(recs[1].xr_submit_fresh == 0); + CHECK((recs[1].flags & PSX_FRAME_TRACE_XR_REPEAT) != 0); + /* The pose was never sampled for the repeat, so no age is claimed. */ + CHECK(recs[1].stages_us[PSX_TRACE_POSE_AGE] == 0); + + CHECK(psx_frame_trace_to_json(cad, sizeof cad, 0) > 0); + CHECK(strstr(cad, "\"xr_display_delta_us\":22222") != NULL); + CHECK(strstr(cad, "\"xr_submit_fresh\":1") != NULL); + CHECK(strstr(cad, "\"pose_age\":4200") != NULL); + CHECK(psx_frame_trace_to_csv(cad, sizeof cad, 0) > 0); + CHECK(strstr(cad, "xr_display_delta_us,xr_submit_fresh") != NULL); + CHECK(strstr(cad, ",22222,1,") != NULL); + CHECK(strstr(cad, ",22222,0,") != NULL); + CHECK(strstr(psx_frame_trace_flag_names(recs[1].flags, names, sizeof names), + "xr_repeat") != NULL); + } + + /* ---- per-eye draw counters and guest cycles --------------------------- + * The pair's own prim count is the last eye's, which is useless for asking + * which eye a change moved, and the guest sub-stages of the replay are pair + * totals written by the game hook. */ + psx_frame_trace_reset(); + psx_frame_trace_set_enabled(1); + { + PSXFrameTraceRecord recs[4]; + static char buf[1 << 16]; + + CHECK(psx_frame_trace_open()); + /* An unknown eye or a closed record must not write anything. */ + psx_frame_trace_draw(2, 999, 999); + psx_frame_trace_eye_cycles(-1, 999); + psx_frame_trace_add(PSX_TRACE_GAME_TRAVERSE, 0); + psx_frame_trace_draw(0, 669, 667); + psx_frame_trace_draw(1, 947, 944); + psx_frame_trace_eye_cycles(0, 145000); + psx_frame_trace_eye_cycles(1, 151000); + psx_frame_trace_add(PSX_TRACE_GAME_TRAVERSE, 1200); + psx_frame_trace_add(PSX_TRACE_GAME_TRAVERSE, 1400); + psx_frame_trace_add(PSX_TRACE_GAME_SUBMIT, 800); + psx_frame_trace_commit(); + + CHECK(psx_frame_trace_read(recs, 4) == 1); + CHECK(recs[0].prims_l == 669 && recs[0].prims_r == 947); + CHECK(recs[0].tex_prims_l == 667 && recs[0].tex_prims_r == 944); + /* `prims`/`tex_prims` keep their meaning: the last eye of the pair. */ + CHECK(recs[0].prims == 947 && recs[0].textured_prims == 944); + CHECK(recs[0].eye_cycles_l == 145000 && recs[0].eye_cycles_r == 151000); + /* A guest sub-stage accumulates both eyes' spans of that call. */ + CHECK(recs[0].stages_us[PSX_TRACE_GAME_TRAVERSE] == 2600); + CHECK(recs[0].stages_us[PSX_TRACE_GAME_SUBMIT] == 800); + + CHECK(psx_frame_trace_to_json(buf, sizeof buf, 0) > 0); + CHECK(strstr(buf, "\"prims_l\":669") && strstr(buf, "\"prims_r\":947")); + CHECK(strstr(buf, "\"cycles_l\":145000") && + strstr(buf, "\"cycles_r\":151000")); + CHECK(strstr(buf, "\"game_traverse\":2600") && + strstr(buf, "\"game_submit\":800") && + strstr(buf, "\"game_laser\":0")); + CHECK(psx_frame_trace_to_csv(buf, sizeof buf, 0) > 0); + CHECK(strstr(buf, "prims_l,prims_r,tex_prims_l,tex_prims_r,cycles_l," + "cycles_r") != NULL); + CHECK(strstr(buf, ",669,947,667,944,145000,151000,") != NULL); + /* The name table must stay in enum order (the header order is the ABI). */ + CHECK(strcmp(psx_frame_trace_stage_name(PSX_TRACE_GAME_ENV), "game_env") == 0 + && strcmp(psx_frame_trace_stage_name(PSX_TRACE_GAME_LASER), + "game_laser") == 0); + } + + psx_frame_trace_set_enabled(0); + printf("frame_trace_test: all checks passed\n"); + return 0; +} \ No newline at end of file diff --git a/runtime/tests/test_frame_trace_guard.py b/runtime/tests/test_frame_trace_guard.py new file mode 100644 index 00000000..a8b06f77 --- /dev/null +++ b/runtime/tests/test_frame_trace_guard.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Guard: the frame-trace RECORD path must not allocate or touch files. + +frame_trace.c exists so a visible VR hitch can be correlated with a stage +without changing the thing being measured. A malloc(), a stdio file write, or a +formatted print added to the per-frame path would perturb the frame it is +supposed to observe, and no functional test would notice. This check reads the +source and fails if such a call appears in a record-path function. + +Export functions (to_json/to_csv/to_chrome_json) are explicitly allowed to use +vsnprintf, and are never called from the record path; they are listed as +exceptions by name so the intent is not mistaken for an oversight. + +ctest -R frame_trace_guard_test +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SRC = ROOT / "runtime" / "src" / "frame_trace.c" + +# Functions that run per frame. Their bodies must stay allocation- and IO-free. +RECORD_PATH = [ + "psx_frame_trace_open", + "psx_frame_trace_commit", + "psx_frame_trace_add", + "psx_frame_trace_ids", + "psx_frame_trace_attempt", + "psx_frame_trace_result", + "psx_frame_trace_source", + "psx_frame_trace_draw", + "psx_frame_trace_eye_cycles", + "psx_frame_trace_gpu", + "psx_frame_trace_flag", + "psx_frame_trace_note", + "psx_frame_trace_io", + "psx_frame_trace_reset", + "psx_frame_trace_enabled", + "stamp_now", + "open_record", +] + +# Forbidden call patterns in a record-path body. Word boundaries matter: +# "printf" must not match "snprintf"/"vsnprintf", which write into the record's +# own fixed buffers and touch neither heap nor files. +FORBIDDEN = [ + r"(? str | None: + """The definition of `name` (top-level, so the signature starts at column 0).""" + lines = text.splitlines() + for i, line in enumerate(lines): + if re.match(r"^[A-Za-z_][^\n]*\b" + re.escape(name) + r"\s*\(", line): + body = [line] + for j in range(i + 1, len(lines)): + body.append(lines[j]) + if lines[j].rstrip() == "}": + break + return "\n".join(body) + return None + + +def main() -> int: + if not SRC.exists(): + print(f"frame_trace_guard_test: SKIP: {SRC} not found", file=sys.stderr) + return 77 + text = SRC.read_text(encoding="utf-8", errors="replace") + + failures: list[str] = [] + + if not re.search(r"static\s+PSXFrameTraceRecord\s+s_ring\s*\[", text): + failures.append("frame_trace.c: the ring is not a static array " + "(the record path must not own heap)") + + for name in RECORD_PATH: + body = function_body(text, name) + if body is None: + failures.append(f"frame_trace.c: record-path function {name}() not found") + continue + for pattern in FORBIDDEN: + if re.search(pattern, body): + failures.append(f"frame_trace.c: {name}() matches {pattern} " + "(the per-frame path must not allocate or do IO)") + + if failures: + print("frame_trace_guard_test: FAIL", file=sys.stderr) + for f in failures: + print(" " + f, file=sys.stderr) + return 1 + + print(f"frame_trace_guard_test: {len(RECORD_PATH)} record-path functions are " + "allocation- and IO-free") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/runtime/tests/test_game_runtime_memcard_slots.py b/runtime/tests/test_game_runtime_memcard_slots.py new file mode 100644 index 00000000..4fd34376 --- /dev/null +++ b/runtime/tests/test_game_runtime_memcard_slots.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""psxrecomp_add_game_runtime(... MEMCARD_SLOTS n): runs the REAL +psxrt_resolve_memcard_slots from runtime.cmake under `cmake -P`. Unset keeps +both slots, 1 and 2 pass through, anything else fails the configure. +""" +import argparse +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +RUNTIME_CMAKE = REPO / "runtime" / "runtime.cmake" + + +def extract_function(text: str, name: str) -> str: + m = re.search(r"^function\(%s\b.*?^endfunction\(\)" % re.escape(name), + text, re.S | re.M) + if not m: + raise SystemExit("FAIL: %s not found in runtime.cmake" % name) + return m.group(0) + + +def resolve(cmake: str, body: str, value: str): + with tempfile.TemporaryDirectory() as td: + driver = Path(td) / "driver.cmake" + driver.write_text(body + '\npsxrt_resolve_memcard_slots(_n "%s")\n' + 'message(STATUS "SLOTS=${_n}")\n' % value) + r = subprocess.run([cmake, "-P", str(driver)], capture_output=True, text=True) + m = re.search(r"SLOTS=(\S+)", r.stdout + r.stderr) + return (m.group(1) if r.returncode == 0 and m else None) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--cmake", default=shutil.which("cmake") or "cmake") + args = ap.parse_args() + if shutil.which(args.cmake) is None and not Path(args.cmake).is_file(): + print("SKIP: no cmake executable available (%s)" % args.cmake) + return 0 + body = extract_function(RUNTIME_CMAKE.read_text(encoding="utf-8"), + "psxrt_resolve_memcard_slots") + cases = (("", "2", "unset keeps both slots"), + ("1", "1", "a port-1-only title gets one slot"), + ("2", "2", "two slots pass through"), + ("3", None, "three slots fail the configure"), + ("0", None, "zero slots fail the configure"), + ("1.5", None, "fractional slots fail the configure"), + ("one", None, "non-numeric slots fail the configure"), + ("1;2", None, "lists fail the configure")) + failures = 0 + for value, want, what in cases: + ok = resolve(args.cmake, body, value) == want + print(("ok " if ok else "FAIL ") + what) + failures += not ok + if failures: + print("test_game_runtime_memcard_slots: %d FAILURES" % failures) + return 1 + print("test_game_runtime_memcard_slots: all checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/runtime/tests/test_gl_frame_gen.c b/runtime/tests/test_gl_frame_gen.c index 85c31496..f3f65c3e 100644 --- a/runtime/tests/test_gl_frame_gen.c +++ b/runtime/tests/test_gl_frame_gen.c @@ -57,6 +57,14 @@ uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;ret int psx_mod_host_output_scope(void){return 0;} uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} int g_psx_vram_dirty_tracking=0; +/* Host menu overlay and video telemetry (psx_host_menu.c, video_telemetry.cpp): + * none here. */ +int psx_host_menu_overlay_image(const uint32_t **pixels, int *w, int *h){(void)pixels;(void)w;(void)h;return 0;} +void psx_video_telemetry_present(int result){(void)result;} +void psx_video_telemetry_compose(int renderer, int scale, int source_w, int source_h, + int drawable_w, int drawable_h, int viewport_w, int viewport_h){ + (void)renderer;(void)scale;(void)source_w;(void)source_h;(void)drawable_w;(void)drawable_h; + (void)viewport_w;(void)viewport_h;} uint64_t s_frame_count=0; void gpu_vram_dirty_mark_row_impl(uint32_t y){(void)y;} void gpu_vram_dirty_mark_rect(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} @@ -267,6 +275,31 @@ static void backdrop(int g, int bx) { } } +/* FG_OCCLUDE=1 (reprojection, on the FG_PAN = 6 scene): after the near + * triangles (z = 1000) and over the backdrop (z = 2000), a far quad + * (z = 3000, moving with the same camera) is drawn last, as a game's sort or + * Max Detail's far sections can leave far geometry. The reprojection depth + * image must keep the nearest world depth there: painter order put the far + * quad's depth over the near triangles, and the warp moved them as distant + * scenery (objects through walls, doubled signs). */ +static int g_occlude; +static void occlude_quad(int g, int bx) { + const int T = (g % 9) * g_pan; /* the triangles' camera: T units at z = 1000 */ + const int qx = bx + 60 + T / 3, qy = 40, qw = 120, qh = 100, z = 3000; + const int xs4[4] = { qx, qx + qw, qx, qx + qw }, ys4[4] = { qy, qy, qy + qh, qy + qh }; + static const int tri[2][3] = { { 0, 1, 2 }, { 1, 3, 2 } }; + for (int t = 0; t < 2; t++) { + uint32_t id[3]; int32_t pc[9], h[3], xs[3], ys[3]; + for (int k = 0; k < 3; k++) { + const int c = tri[t][k]; + id[k] = 3000u + (uint32_t)c; xs[k] = xs4[c]; ys[k] = ys4[c]; + pc[3 * k] = (xs[k] - bx - T / 3) * 3 + T; pc[3 * k + 1] = ys[k] * 3; pc[3 * k + 2] = z; h[k] = 1000; + } + gl_renderer_fg_source(id, pc, h, xs, ys); + gr_draw_flat_triangle(xs[0], ys[0], xs[1], ys[1], xs[2], ys[2], 0x7c1f); + } +} + /* One game frame drawn into buffer bx. Positions move by whole pixels with * g; every fifth frame triangle 0 jumps across the screen. */ static void game_frame_part(int g, int bx, int part) { @@ -297,6 +330,7 @@ static void game_frame_part(int g, int bx, int part) { } } if (part == 0) return; + if (g_occlude) occlude_quad(g, bx); gr_draw_flat_rect(bx + 8, 220, 80, 12, 0x7fff); /* HUD */ gr_draw_flat_rect(900, 300, 32, 32, (uint16_t)(0x03e0 + g)); /* render to texture */ gr_copy_rect(bx + 10, 10, 900, 400, 16, 16); /* copy out of the display */ @@ -362,6 +396,8 @@ int main(int argc, char **argv) { g_pgxp_color = g_pgxp && strstr(e, "color") != NULL; g_pgxp_seam = g_pgxp && strstr(e, "seam") != NULL; } + g_occlude = getenv("FG_OCCLUDE") && getenv("FG_OCCLUDE")[0] == '1'; + if (g_occlude) g_pan = 6; if (SDL_Init(SDL_INIT_VIDEO) != 0) { fprintf(stderr, "SKIP no video (%s)\n", SDL_GetError()); return 77; @@ -452,6 +488,37 @@ int main(int argc, char **argv) { SDL_GL_GetDrawableSize(s_win, &ww, &wh); uint32_t *img = (uint32_t *)malloc((size_t)ww * wh * 4); check(img_last[0] && img_last[1], "two real images kept"); + if (g_occlude) { + /* The reprojection depth image under the wall must hold the + * wall's depth (the nearest world surface), whatever the draw + * order: painter order left the far quad's depth there, and the + * warp moved the wall's pixels as distant scenery. */ + check(fg_generate(0.5, 0) == 1, "occlusion: generated"); + check(s_rp_ztex && s_rp_zw > 0 && s_rp_zh > 0, "occlusion: depth image built"); + float *z = (float *)malloc((size_t)s_rp_zw * s_rp_zh * 4 * sizeof(float)); + glBindTexture(GL_TEXTURE_2D, s_rp_ztex); + glGetTexImage(GL_TEXTURE_2D, 0, GL_RGBA, GL_FLOAT, z); + glBindTexture(GL_TEXTURE_2D, 0); + long near_px = 0, far_px = 0; + /* The quad's box at the newer frame (g = 23: T = 30, +10 px). */ + for (int y = 45; y < 135; y++) + for (int x = 75; x < 185; x++) { + const int zx = x * RP_ZS, zy = y * RP_ZS; + if (zx >= s_rp_zw || zy >= s_rp_zh) continue; + const float *t = &z[((size_t)zy * s_rp_zw + zx) * 4]; + if (t[1] != 1.0f || t[0] <= 0.0f) continue; /* world pixels only */ + const float Z = 1.0f / t[0]; + near_px += Z < 1500.0f; far_px += Z > 2500.0f; + } + free(z); + printf("occlusion depth_image near=%ld far=%ld reprojected=%llu\n", near_px, far_px, + (unsigned long long)s_rp_frames); + check(near_px > 100, "occlusion: the near triangles are in the depth image"); + check(far_px == 0, "occlusion: the far quad drawn last leaves no depth over nearer world (nearest wins)"); + free(img); + printf(failures ? "FAIL\n" : "PASS\n"); + return failures ? 1 : 0; + } check(fg_generate(1.0, 0) == 1, "compose at phase 1"); uint64_t d1 = read_back(img); uint64_t r1 = fnv(img_last[1], (size_t)ww * wh * 4, 0xcbf29ce484222325ull); @@ -555,6 +622,15 @@ int main(int argc, char **argv) { "reprojection: the warp follows the camera (under half the newer frame's difference)"); } check(none * 100 < (long)ww * wh, "reprojection: no holes, every pixel from a real frame or the redraw (<1%)"); + /* Edge disocclusion (FG_PAN > 1: near triangles over a backdrop + * with parallax): what the in-between camera uncovers at a near + * object's trailing edge comes from the older frame's own warp + * (two-frame reprojection), not the newer frame's pixels + * un-warped. The single-frame warp left 0.5 % here. */ + if (g_pan > 1) { + check(s_rp_older_frames > 0, "reprojection: the older frame was warped too"); + check(none * 300 < (long)ww * wh, "reprojection: uncovered edges filled by the older frame's warp (<0.33%)"); + } check(hud > 0 && hud_bad == 0, "reprojection: HUD exactly the newer frame's"); free(ref); } @@ -567,6 +643,46 @@ int main(int argc, char **argv) { check(memcmp(before, s_vram, sizeof before) == 0, "generated frame leaves native VRAM unchanged under HD authority"); gl_renderer_set_hd_texture_mode(0); + /* A large unprojected triangle after projected geometry must reject + * reprojection, while preserving the pre-existing redraw route. */ + { + FgList *b = &s_fg_l[s_fg_pb]; + FgPrim *unplaced = NULL; + int seen = 0; + double view_area = 0; + for (uint32_t vi = 0; vi < s_fg_fit.nviews; ++vi) { + const float *a = s_fg_fit.v[vi].area; + double area = (double)(a[2]-a[0])*(a[3]-a[1]); + if (area > view_area) view_area = area; + } + for (uint32_t i = 0; i < b->n; ++i) { + int pi = b->rec2prim[i]; + if (pi < 0) continue; + FgPrim *p = &b->prims.v[pi]; + if (!(p->vid[0] || p->vid[1] || p->vid[2])) continue; + double area = 0.5*fabs((double)(p->x[1]-p->x[0])*(p->y[2]-p->y[0])- + (double)(p->x[2]-p->x[0])*(p->y[1]-p->y[0])); + if (seen) { unplaced = p; break; } + seen = 1; + } + check(unplaced != NULL && view_area > 0, "method scope: large geometry witness"); + if (unplaced) { + FgPrim saved = *unplaced; + int saved_method = s_fg_reproject; + const float *a = s_fg_fit.v[0].area; + unplaced->x[0] = a[0]; unplaced->x[1] = a[2]; unplaced->x[2] = a[0]; + unplaced->y[0] = a[1]; unplaced->y[1] = a[1]; unplaced->y[2] = a[3]; + memset(unplaced->vid, 0, sizeof(unplaced->vid)); + memset(unplaced->pz, 0, sizeof(unplaced->pz)); + check(fg_unplaced_share(b) > fg_max_unplaced(), "method scope: unplaced geometry exceeds threshold"); + s_fg_reproject = 1; + check(fg_generate(0.5, 0) == 0, "method scope: reprojection refuses unplaceable geometry"); + s_fg_reproject = 0; + check(fg_generate(0.5, 0) == 1, "method scope: redraw keeps its original route"); + *unplaced = saved; + s_fg_reproject = saved_method; + } + } free(img); check(n_gen > 0, "generated frames were presented"); } diff --git a/runtime/tests/test_gpu_gp0_history_guard.py b/runtime/tests/test_gpu_gp0_history_guard.py index db644c3e..2bacef54 100644 --- a/runtime/tests/test_gpu_gp0_history_guard.py +++ b/runtime/tests/test_gpu_gp0_history_guard.py @@ -236,11 +236,6 @@ GrBackend gr_backend(void) { return GR_BACKEND_SOFTWARE; } int gl_renderer_texture_banks_supported(void) { return 0; } int gl_renderer_select_texture_bank(uint16_t id) { return id == 0; } int gl_renderer_select_texture_bank_live_clut(uint16_t id) { return id == 0; } -void gl_renderer_note_wide_triangle_recovery(int enabled) { (void)enabled; } -int gl_renderer_projective_supported(void) { return 0; } -void gl_renderer_draw_projected_triangle(const PSXProjectedVertex vertices[3], - uint16_t texpage, uint16_t clut_x, uint16_t clut_y, int raw, int semi, int perspective) -{ (void)vertices; (void)texpage; (void)clut_x; (void)clut_y; (void)raw; (void)semi; (void)perspective; abort(); } uint16_t mod_texture_packet_bank(uint32_t p, const uint32_t *words, uint32_t n) { (void)p; (void)words; (void)n; return 0; } int mod_texture_packet_precision(uint32_t p, float q[3], float xy[6]) @@ -263,6 +258,12 @@ void crash_trace_note_gpu_fatal(uint32_t opcode, uint32_t word) { (void)opcode; void event_ring_note_gpu_irq(uint32_t stat) { (void)stat; } int g_psx_vram_dirty_tracking = 0; int g_exec_phase = 0; +int g_psx_render_pass_active = 0; +int pgxp_enabled(void) { return 0; } +int pgxp_cpu_mode(void) { return 0; } +void pgxp_set_cpu_mode(int enabled) { if (enabled) abort(); } +void psx_mod_counter_add(const char *name, uint32_t delta) +{ (void)name; (void)delta; abort(); } void gpu_vram_dirty_mark_all(void) {} void gpu_vram_dirty_mark_row_impl(uint32_t row) { (void)row; } void gpu_vram_dirty_mark_rect(uint32_t x, uint32_t y, uint32_t w, uint32_t h) @@ -273,6 +274,7 @@ void text_xlate_vram_upload(uint16_t x, uint16_t y, uint16_t w, uint16_t h) int ws_cull_should_keep(uint32_t addr) { (void)addr; return 1; } int ws_ui_group_should_keep(uint32_t addr) { (void)addr; return 1; } uint32_t psx_mod_gpu_dma_resolve_address(uint32_t address) { return address; } +int psx_mod_gpu_dma_memory_contains(uint32_t addr, uint32_t len) { (void)addr; (void)len; return 0; } uint32_t psx_gpu_packet_key(uint32_t address) { return address & 0x001FFFFCu; } void ws_ui_group_assign(WsUiGroupItem *items, size_t count, int32_t display_width, int dense_menu, int in_place) @@ -289,8 +291,14 @@ int gte_nclip_native_wide_previous_sign(int32_t mac0, int *sign) int pgxp_projection_tracking(void) { return 0; } void pgxp_set_projection_tracking(int enabled) { if (enabled) abort(); } +int pgxp_projective_scope_begin(PGXPProjectiveScope *scope) +{ (void)scope; abort(); } +void pgxp_projective_scope_end(PGXPProjectiveScope *scope) +{ (void)scope; abort(); } int pgxp_load_projection(uint32_t addr, uint32_t packed, PGXPProjection *out) { (void)addr; (void)packed; (void)out; abort(); } +float pgxp_get_unclamped_depth(uint32_t addr, uint32_t packed) +{ (void)addr; (void)packed; abort(); } int gl_renderer_projective_supported(void) { abort(); } void gl_renderer_draw_projected_triangle(const PSXProjectedVertex vertices[3], uint16_t texpage, uint16_t cx, uint16_t cy, int raw, int semi, int perspective) @@ -308,10 +316,6 @@ int gte_fg_source_enabled(void) { return 0; } int gte_fg_source_lookup(uint32_t packed, GteFgSrc out[2]) { (void)packed; (void)out; abort(); } void pgxp_set_enabled(int enabled) { (void)enabled; } -void pgxp_set_projection_tracking(int enabled) { (void)enabled; } -int pgxp_projection_tracking(void) { return 0; } -int pgxp_load_projection(uint32_t addr, uint32_t packed, PGXPProjection *out) -{ (void)addr; (void)packed; (void)out; return 0; } int pgxp_get_gte_sxy_checked(uint32_t slot, uint32_t packed, int require_valid, int32_t *x16, int32_t *y16) { (void)slot; (void)packed; (void)require_valid; (void)x16; (void)y16; return 0; } diff --git a/runtime/tests/test_gte_register_access.cpp b/runtime/tests/test_gte_register_access.cpp index 0ed00e40..c8f1f279 100644 --- a/runtime/tests/test_gte_register_access.cpp +++ b/runtime/tests/test_gte_register_access.cpp @@ -62,6 +62,8 @@ extern "C" void gte_test_execute_reference(CPUState *cpu, uint32_t cmd); /* gte.cpp runtime dependencies that are irrelevant to register-transfer tests. */ extern "C" int gpu_ws_present_native_43(void) { return 0; } static int g_test_precise_nclip_enabled; +static int g_test_render_projective; +extern "C" int gpu_render_projective_enabled(void) { return g_test_render_projective; } extern "C" int gpu_ws_precise_nclip_enabled(void) { return g_test_precise_nclip_enabled; } @@ -1413,6 +1415,54 @@ int main() { } gte_set_fov_scale(1,1); pgxp_set_projection_tracking(0); + // VR homogeneous provenance spans the eye plane, including fractional MACs. + PSXModRenderView eye = {}; eye.struct_size=sizeof eye; eye.projection=1; + eye.rotation_q12[0]=eye.rotation_q12[4]=eye.rotation_q12[8]=4096; + eye.fx_q16=200*65536; eye.fy_q16=100*65536; + eye.cx_delta_q16=10*65536; eye.cy_delta_q16=-5*65536; + gte_render_pose_set(&eye); pgxp_set_projection_tracking(1); + for (int depth : {-158,-1,0,1,149,900}) { + GTEState native{}, enhanced{}; + native.RT[0][0]=native.RT[1][1]=native.RT[2][2]=4096; + native.RT[0][1]=1; native.V0[0]=100; native.V0[1]=30; + native.TR[2]=depth; native.H=400; + native.OFX=256*65536; native.OFY=120*65536; + enhanced=native; + g_test_render_projective=0; + PSXRecomp::GTE::gte_rtps(&native,0x0180001u); + g_test_render_projective=1; + PSXRecomp::GTE::gte_rtps(&enhanced,0x0180001u); + PGXPProjection p; + CHECK(!std::memcmp(&native,&enhanced,sizeof native)); + CHECK(pgxp_get_gte_projection(2,enhanced.SXY[2],&p)); + CHECK(p.z==depth && p.near_z==1); + CHECK(std::fabs(p.x-(266.0*depth+(100+30.0/4096)*200))<0.02); + CHECK(std::fabs(p.y-(115.0*depth+3000))<0.02); + } + // A tenth of a camera unit survives even though the architectural pose rounds to zero. + eye.precise=1; eye.rotation[0]=eye.rotation[4]=eye.rotation[8]=1; + eye.translation[0]=17; // precision replaces the integer pose; never adds it twice. + eye.translation_precise[0]=.1; gte_render_pose_set(&eye); + GTEState fractional{}; + fractional.RT[0][0]=fractional.RT[1][1]=fractional.RT[2][2]=4096; + fractional.V0[2]=1000; fractional.H=400; + PSXRecomp::GTE::gte_rtps(&fractional,0x0180001u); + PGXPProjection exact; + CHECK(pgxp_get_gte_projection(2,fractional.SXY[2],&exact)); + CHECK(std::fabs(exact.x-(10*1000+20))<.05); + eye.precise=0;eye.translation[0]=0;gte_render_pose_set(&eye); + for (int winding : {1,-1}) { + GTEState face{}; + face.RT[0][0]=face.RT[1][1]=face.RT[2][2]=4096; + face.V0[0]=-20*winding;face.V0[1]=10;face.V0[2]=100; + face.V1[0]=20*winding;face.V1[1]=10;face.V1[2]=100; + face.V2[1]=10;face.V2[2]=-10;face.H=400; + PSXRecomp::GTE::gte_rtpt(&face,0x0280030u); + PSXRecomp::GTE::gte_nclip(&face,0x1400006u); + CHECK((face.MAC0>0)-(face.MAC0<0)==winding); + } + g_test_render_projective=0; eye={}; gte_render_pose_set(&eye); + pgxp_set_projection_tracking(0); std::puts("PASS: canonical GTE register helpers match GTEState transfer oracle"); return 0; } diff --git a/runtime/tests/test_host_time_threads.c b/runtime/tests/test_host_time_threads.c new file mode 100644 index 00000000..a8eae238 --- /dev/null +++ b/runtime/tests/test_host_time_threads.c @@ -0,0 +1,57 @@ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include "host_time.h" + +static HANDLE start_event; +static volatile LONG errors; +static DWORD WINAPI sleeper(void *arg) { + (void)arg; + WaitForSingleObject(start_event, INFINITE); + uint64_t previous = psx_host_mono_ms(); + for (unsigned i=0; i<250; ++i) { + psx_host_sleep_micros(1000); + uint64_t now = psx_host_mono_ms(); + if (now < previous) InterlockedIncrement(&errors); + previous = now; + } + return 0; +} +static int run_wave(void) { + HANDLE threads[8]; + start_event = CreateEventW(NULL, TRUE, FALSE, NULL); + if (!start_event) return 2; + for (unsigned i=0; i<8; ++i) { + threads[i] = CreateThread(NULL, 0, sleeper, NULL, 0, NULL); + if (!threads[i]) return 2; + } + SetEvent(start_event); + DWORD result = WaitForMultipleObjects(8, threads, TRUE, 10000); + if (result != WAIT_OBJECT_0) { + puts("FAIL: concurrent sleepers lost a wakeup"); + return 1; + } + for (unsigned i=0; i<8; ++i) CloseHandle(threads[i]); + CloseHandle(start_event); + return errors ? 1 : 0; +} +int main(void) { + /* The cold wave still tests concurrent InitOnce initialization. Measure + * handle growth only after process/CRT/Windows lazy initialization, then + * repeat whole thread lifetimes: a leaked caller timer grows every wave. */ + int result = run_wave(); + if (result) return result; + DWORD before, after; + if (!GetProcessHandleCount(GetCurrentProcess(), &before)) return 2; + for (int wave = 0; wave < 3; ++wave) { + result = run_wave(); + if (result) return result; + } + if (!GetProcessHandleCount(GetCurrentProcess(), &after) || after > before) { + printf("FAIL: sleeper timer handles leaked after thread exit: before=%lu after=%lu\n", before, after); + return 1; + } + if (errors) return 1; + puts("host time concurrent sleepers: PASS"); + return 0; +} diff --git a/runtime/tests/test_internal_resolution.cpp b/runtime/tests/test_internal_resolution.cpp index 25b04cdd..597c271c 100644 --- a/runtime/tests/test_internal_resolution.cpp +++ b/runtime/tests/test_internal_resolution.cpp @@ -222,6 +222,21 @@ int main() { expect("ss clamps to the ceiling", psx_resolve_internal_scale_ss(4320, 240, 0, 32, 4000), 32); expect("ss unknown display stays 1", psx_resolve_internal_scale_ss(PSX_IR_DISPLAY, 240, 0, 32, 2000), 1); + expect("ss policy opt-out", psx_ir_display_supersample(1500, 4320, 0), 1500); + expect("ss below threshold", psx_ir_display_supersample(1500, 2159, 2160), 1500); + expect("ss at threshold", psx_ir_display_supersample(1500, 2160, 2160), 1000); + expect("ss on 8K", psx_ir_display_supersample(1500, 4320, 2160), 1000); + expect("ss unknown display", psx_ir_display_supersample(1500, 0, 2160), 1500); + expect("cap includes SS below threshold", psx_resolve_internal_scale_ss_cap( + PSX_IR_DISPLAY, 240, 1440, 32, 4000, 4320), 18); + expect("cap below 8K", psx_resolve_internal_scale_ss_cap( + PSX_IR_DISPLAY, 240, 1080, 32, 1500, 4320), 7); + expect("8K capped after SS", psx_resolve_internal_scale_ss_cap( + PSX_IR_DISPLAY, 240, 8640, 32, 4000, 4320), 18); + expect("fixed preset keeps own ceiling", psx_resolve_internal_scale_ss_cap( + 4320, 240, 8640, 32, 4000, 4320), 32); + expect("cap opt-out", psx_resolve_internal_scale_ss_cap( + PSX_IR_DISPLAY, 240, 4320, 32, 1500, 0), 27); launcher_round_trips(); if (g_failures) return 1; diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index b7331f56..bd095287 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -452,7 +453,55 @@ static uint64_t startup_counter(const char* requested) { return 0; } +static int test_command_calls; +static int command_fill_reply(const char* args, char* reply, int cap) { + ++test_command_calls; + std::memset(reply, 'x', (size_t)cap); // deliberately fills the entire bound + return std::strcmp(args, "test") == 0 ? 17 : 11; +} +static int command_replacement(const char*, char* reply, int) { reply[0] = 0; return 99; } +static void test_commands(void) { + char name[] = "qa.owned"; + check(psx_mod_register_command(name, command_fill_reply) == 1, "register owned command name"); + std::memset(name, '?', sizeof(name)-1); + struct { char reply[8]; char guard; } buffer = {{0}, 'g'}; + check(psx_mod_run_command("qa.owned", "test", buffer.reply, sizeof(buffer.reply)) == 17, + "command owns name and forwards args/result"); + check(buffer.reply[7] == 0 && std::strlen(buffer.reply) == 7 && buffer.guard == 'g', + "full-capacity reply is bounded and terminated"); + check(psx_mod_run_command("qa.owned", nullptr, buffer.reply, 1) == 11 && buffer.reply[0] == 0, + "one-byte reply and null args are bounded"); + const int before = test_command_calls; + check(psx_mod_run_command("qa.owned", "", nullptr, 8) == -1 && + psx_mod_run_command("qa.owned", "", buffer.reply, 0) == -1 && + psx_mod_run_command("missing", "", buffer.reply, 8) == -1 && + test_command_calls == before, "invalid or unknown command does not invoke callback"); + check(!psx_mod_register_command(nullptr, command_fill_reply) && + !psx_mod_register_command("", command_fill_reply) && + !psx_mod_register_command("missing.callback", nullptr) && + !psx_mod_register_command(std::string(64, 'a').c_str(), command_fill_reply), + "invalid and unreachable names are refused"); + check(psx_mod_register_command("qa.owned", command_replacement) && + psx_mod_run_command("qa.owned", "", buffer.reply, 8) == 99, + "duplicate registration explicitly replaces callback"); + const auto maximum = std::string(63, 'b'); + check(psx_mod_register_command(maximum.c_str(), command_replacement) == 1, + "63-byte command name is reachable"); + for (int i = 0; i < 14; ++i) + check(psx_mod_register_command(("qa.extra." + std::to_string(i)).c_str(), command_replacement) == 1, + "temporary names fill bounded registry"); + check(!psx_mod_register_command("qa.overflow", command_replacement) && + psx_mod_run_command("qa.extra.13", "", buffer.reply, 8) == 99 && + psx_mod_run_command("qa.owned", "", buffer.reply, 8) == 99, + "overflow preserves existing owned registrations"); +} + int main(int argc, char** argv) { + test_commands(); + if (argc > 1 && std::string(argv[1]) == "--commands-only") { + std::cout << "mod command tests: " << failures << " failures\n"; + return failures ? 1 : 0; + } #if defined(PSX_LAUNCHER_MOD_COMMIT_WORKER_SAFE) const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-cache-test"; const auto isolated_cache = root / "test-cache"; diff --git a/runtime/tests/test_native_wide_projection.c b/runtime/tests/test_native_wide_projection.c index e11b399b..3feef6a7 100644 --- a/runtime/tests/test_native_wide_projection.c +++ b/runtime/tests/test_native_wide_projection.c @@ -165,6 +165,29 @@ int main(void) { fixture_projective_supported=1;gp0_cmd_source_addr=UINT32_MAX; assert(!native_wide_projective_draw()); assert(fixture_projective_draws==2); + /* VR replay uses the clipping transport at ordinary 4:3, including + * unsaturated positive vertices (smooth motion), only inside its pass. */ + gp0_cmd_source_addr=0x10004u; ws_cfg_num=4;ws_cfg_den=3; + fixture_projective_supported=1; + fixture_pgxp_enabled=0;fixture_pgxp_cpu_mode=0; + psx_mod_set_render_projective(1); + assert(!fixture_pgxp_enabled && fixture_projection_tracking); + assert(!native_wide_projective_draw()); + assert(gpu_render_projective_begin()); + assert(fixture_pgxp_enabled && fixture_pgxp_cpu_mode); + g_psx_render_pass_active=1; + assert(native_wide_projective_draw()); + fixture_homogeneous[1].valid=0; + assert(!native_wide_projective_draw()); + fixture_homogeneous[1].valid=1; + fixture_backend=GR_BACKEND_SOFTWARE; + assert(!native_wide_projective_draw()); + fixture_backend=GR_BACKEND_OPENGL; + g_psx_render_pass_active=0; + assert(!native_wide_projective_draw()); + gpu_render_projective_end(); + assert(!fixture_pgxp_enabled && !fixture_pgxp_cpu_mode); + psx_mod_set_render_projective(0); puts("native_wide_projection_test: PASS"); return 0; } diff --git a/runtime/tests/test_openxr_input.c b/runtime/tests/test_openxr_input.c index d9b7254a..b5cf9981 100644 --- a/runtime/tests/test_openxr_input.c +++ b/runtime/tests/test_openxr_input.c @@ -2,6 +2,11 @@ #include #include #include "xr_surface_math.h" +#include "xr_origin_diagnostics.h" +extern void psx_openxr_pose_bob(double q[2][4], double p[2][3]); +extern int psx_openxr_pose_override(double, double, double); +extern int psx_openxr_pose_pitch_override(double); +extern void psx_openxr_pose_clear(void); /* Evaluate side effects and failures even when NDEBUG is defined. */ #define CHECK(expr) do { \ if (!(expr)) { \ @@ -10,6 +15,43 @@ } \ } while (0) int main(void) { + const double upright[4]={0,0,0,1}, upside[4]={0,0,1,0}, yaw180[4]={0,1,0,0}; + unsigned inversion=0;double up_dot=0; + for(int i=0;i<9;i++) + inversion=xr_origin_observe_inversion(inversion,1,upright,upside,&up_dot); + CHECK(inversion==9 && up_dot < -0.5); + inversion=xr_origin_observe_inversion(inversion,1,upright,upside,&up_dot); + CHECK(inversion==10 && up_dot < -0.5); + /* A valid sustained roll stays ambiguous but never requests a reset. */ + inversion=xr_origin_observe_inversion(inversion,1,upright,yaw180,&up_dot); + CHECK(inversion==0 && fabs(up_dot-1.0)<1e-12); + inversion=xr_origin_observe_inversion(7,0,upright,upside,&up_dot); + CHECK(inversion==0 && up_dot==1.0); + inversion=xr_origin_observe_inversion(7,1,upside,upside,&up_dot); + CHECK(inversion==0 && fabs(up_dot-1.0)<1e-12); + CHECK(!xr_origin_reference_reset_due(0,100,100)); + CHECK(!xr_origin_reference_reset_due(1,101,100)); + CHECK(xr_origin_reference_reset_due(1,100,100)); + /* Synthetic controls are absolute, bounded and inert after clearing. */ + double bob_q[2][4]={{0,0,0,1},{0,0,0,1}}; + double bob_p[2][3]={{-.03,0,0},{.03,0,0}}; + psx_openxr_pose_clear(); + CHECK(!psx_openxr_pose_override(NAN,0,0)); + CHECK(!psx_openxr_pose_override(11,0,0)); + CHECK(!psx_openxr_pose_pitch_override(INFINITY)); + CHECK(!psx_openxr_pose_pitch_override(91)); + psx_openxr_pose_bob(bob_q,bob_p); + CHECK(bob_p[0][0]==-.03 && bob_q[0][3]==1); + CHECK(psx_openxr_pose_override(.1,.2,-.3)); + CHECK(psx_openxr_pose_pitch_override(30)); + psx_openxr_pose_bob(bob_q,bob_p); + CHECK(fabs(bob_p[0][0]-.07)<1e-12 && fabs(bob_p[1][0]-.13)<1e-12); + CHECK(bob_p[0][1]==.2 && bob_p[1][2]==-.3); + CHECK(fabs(bob_q[0][0]-sin(3.141592653589793/12))<1e-12); + CHECK(bob_q[0][0]==bob_q[1][0] && bob_q[0][3]==bob_q[1][3]); + psx_openxr_pose_clear(); + psx_openxr_pose_bob(bob_q,bob_p); + CHECK(fabs(bob_p[0][0]-.07)<1e-12); /* Shared head center gives coherent binocular geometry. Billboarding must * ignore attachment roll, respond to head motion, and reject near-plane * crossings. Common translation of eye/head/surface preserves projection. */ diff --git a/runtime/tests/test_pgxp.cpp b/runtime/tests/test_pgxp.cpp index 6128e68f..b2e40931 100644 --- a/runtime/tests/test_pgxp.cpp +++ b/runtime/tests/test_pgxp.cpp @@ -113,6 +113,24 @@ int main(void) { pgxp_set_tolerance(-1.0f); pgxp_set_cpu_mode(0); + /* PGXP mode is opened after the precision checkpoint and closed before + * rollback. The real generation and prior mode must survive the pass. */ + { + pgxp_set_enabled(0); + const uint32_t gen = pgxp_test_generation(); + pgxp_checkpoint_begin(); + PGXPProjectiveScope scope = {}; + CHECK(pgxp_projective_scope_begin(&scope)); + CHECK(pgxp_enabled() && pgxp_cpu_mode()); + CHECK(!pgxp_projective_scope_begin(&scope)); + produce_at(ADDR_A); + pgxp_projective_scope_end(&scope); + pgxp_checkpoint_rollback(); + CHECK(!pgxp_enabled() && !pgxp_cpu_mode()); + CHECK(pgxp_test_generation() == gen); + } + pgxp_set_enabled(1); + /* --- SWC2 produce -> GPU consume (the perspective-texturing spine) --- */ produce_at(ADDR_A); { @@ -622,6 +640,52 @@ int main(void) { &x, &y) == 1); } + /* Far-world projection: exact dataflow may recover IR saturation, while + * stale words, lm sign clamps and half writes must stay rejected. */ + { + const int saved_preserve = pgxp_preserve_projection(); + pgxp_set_preserve_projection(1); + pgxp_set_tolerance(-1.0f); + int32_t x, y; + CHECK(pgxp_project_precise(INT64_C(40000)*4096, INT64_C(-35000)*4096, + INT64_C(60000)*4096, 12, 32767, -32768, 60000, 300, + 160<<16, 120<<16, 1, 1, &x, &y) == 2); + /* Fused/non-fused host arithmetic may straddle the final floor by + * one 16.16 quantum (less than 0.00002 pixel). */ + CHECK(std::abs(x - 360*65536) <= 1 && std::abs(y + 55*65536) <= 1); + const uint32_t packed = (uint16_t)323 | ((uint32_t)(uint16_t)-44 << 16); + pgxp_gte_push_sxy(x, y, 60000, packed); + pgxp_gte_mark_unclamped(60000); + pgxp_store_gte_reg(0x80005000, 14); + int32_t px, py; uint16_t z; + CHECK(pgxp_get_precise_vertex(0x80005000, packed, 323, -44, &px, &py, &z)); + CHECK(px == x && py == y && z == 60000); + CHECK(pgxp_get_unclamped_depth(0x80005000, packed) == 60000.0f); + CHECK(!pgxp_get_precise_vertex(0x80005000, packed^1u, 322, -44, &px, &py, &z)); + psx_pgxp_cop2(nullptr, MFC2(2, 14), packed, 0); + psx_pgxp_store(nullptr, SH(1, 2), 0x80005000, packed); + CHECK(!pgxp_get_precise_vertex(0x80005000, packed, 323, -44, &px, &py, &z)); + CHECK(pgxp_project_precise(-4096, 0, INT64_C(60000)*4096, 12, 0, 0, + 60000, 300, 0, 0, 1, 1, &x, &y) == 0); + CHECK(pgxp_project_precise(INT64_C(40000)*4096, INT64_C(20000)*4096, + INT64_C(100000)*4096, 12, 32767, 20000, 65535, 300, + 160<<16, 120<<16, 1, 1, &x, &y) == 2); + CHECK(x == 280*65536 && y == 180*65536); + const uint32_t far_word = 310u | (211u << 16); + pgxp_gte_push_sxy(x, y, 65535, far_word); + pgxp_gte_mark_unclamped(100000); + pgxp_store_gte_reg(0x80005004, 14); + CHECK(pgxp_get_precise_vertex(0x80005004, far_word, 310, 211, &px, &py, &z)); + CHECK(px == x && py == y && z == 65535); + CHECK(pgxp_get_unclamped_depth(0x80005004, far_word) == 100000.0f); + CHECK(pgxp_get_unclamped_depth(0x80005004, far_word^1) == 0.0f); + pgxp_set_preserve_projection(0); + CHECK(pgxp_get_unclamped_depth(0x80005004, far_word) == 0.0f); + CHECK(pgxp_project_precise(INT64_C(40000)*4096, 0, INT64_C(60000)*4096, + 12, 32767, 0, 60000, 300, 0, 0, 1, 1, &x, &y) == 0); + pgxp_set_preserve_projection(saved_preserve); + } + /* --- probe: the same decision, not counted --- */ { produce_at(ADDR_A); diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index 2adba0e5..58eb58aa 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -215,8 +215,8 @@ int gl_renderer_stereo_end(uint32_t eye, int keep) { return 1; } void gl_renderer_stereo_stage_reset(void) { s_stereo_mask = 0; } -int gl_renderer_stereo_stage_readback(uint32_t eye,uint32_t w,uint32_t h,uint8_t *rgba) { - (void)eye;(void)w;(void)h;(void)rgba;return 0; +int gl_renderer_stereo_stage_readback_pair(uint32_t w,uint32_t h,uint8_t *rgba) { + (void)w;(void)h;(void)rgba;return 0; } void gl_renderer_stereo_reset(void) { s_stereo_mask = 0; s_stereo_published = 0; } int gl_renderer_stereo_set_presentation(uint32_t mode) { return mode <= 1; } @@ -225,6 +225,9 @@ void gte_render_view_get(int32_t xyz[3]) { memcpy(xyz, s_view, sizeof s_view); } void gte_render_view_set(const int32_t xyz[3]) { memcpy(s_view, xyz, sizeof s_view); } static PSXModRenderView s_pose; void gte_render_pose_get(PSXModRenderView *v) { *v = s_pose; memcpy(v->translation, s_view, sizeof s_view); } +int gpu_render_projective_begin(void) { return 1; } +void gpu_render_projective_end(void) {} +void psx_mod_set_render_projective(int enabled) { (void)enabled; } void gte_render_pose_set(const PSXModRenderView *v) { s_pose = *v; memcpy(s_view, v->translation, sizeof s_view); } int gl_renderer_stereo_publish(uint64_t id, uint64_t cycle, const int32_t view[2][3]) { (void)cycle; (void)view; @@ -232,6 +235,13 @@ int gl_renderer_stereo_publish(uint64_t id, uint64_t cycle, const int32_t view[2 s_stereo_published = id; return 1; } +/* Referenced by render_pass.c's frame tracing and stereo shedding. */ +void gl_renderer_runtime_diag(uint64_t out[6]) { memset(out, 0, 6 * sizeof *out); } +void gl_renderer_scene_prim_counts(uint64_t *prims, uint64_t *textured) { + if (prims) *prims = 0; + if (textured) *textured = 0; +} +uint64_t psx_host_mono_ms(void) { return 1; } /* ---- an overlay shard's cycle shim (overlay_dispatch_preamble.c.inc) --- */ static uint32_t s_shard_pending; diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c index 37732ce8..8c17493b 100644 --- a/runtime/tests/test_render_pass_sandbox.c +++ b/runtime/tests/test_render_pass_sandbox.c @@ -23,12 +23,14 @@ * The GPU, presenter and remaining devices are mocks. Build/run: * ctest -R render_pass_sandbox_test */ +#include "frame_trace.h" #include "render_pass.h" #include "render_pass_plan.h" #include #include #include +#include #include #include "cpu_state.h" @@ -222,6 +224,9 @@ void gpu_pass_checkpoint_restore(void) {} static uint64_t s_ticks; uint64_t gl_renderer_perf_ticks(void) { return s_ticks += 1000; } uint64_t gl_renderer_perf_frequency(void) { return 1000000000u; } +/* 1000 ticks per sample and a 1 GHz tick make one runtime sample exactly 1 us, + * so a bracketed guest span is the caller's own bump plus a known microsecond. */ +static void tick_bump(uint64_t ticks) { s_ticks += ticks; } static uint32_t s_gl_status = PSX_MOD_RENDER_PASS_READY; uint32_t gl_renderer_pass_unavailable(void) { return s_gl_status; } uint32_t gl_renderer_pass_plan(uint32_t p, uint32_t s, uint32_t *a, @@ -281,9 +286,9 @@ int gl_renderer_stereo_end(uint32_t eye, int keep) { return 1; } void gl_renderer_stereo_stage_reset(void) { s_stereo_mask = 0; } -int gl_renderer_stereo_stage_readback(uint32_t eye,uint32_t w,uint32_t h,uint8_t *rgba) { - if(!(s_stereo_mask & (1u<gpr[8] = eye + 1; s_ram[0x456] = (uint8_t)eye; psx_advance_cycles(5000u + eye); @@ -743,6 +777,13 @@ static void test_stereo(void) { "two restored eye transactions form one pair"); CHECK(stats.eye_cycle[0] == cycle && stats.eye_cycle[1] == cycle && stats.eye_hash[0] == stats.eye_hash[1], "same checkpoint identifiers"); + /* PR3: the pair captures one machine baseline and the second eye reuses it, + * yet both eyes still start from that same checkpoint (checked above). */ + CHECK(stats.checkpoint_saves == 1 && stats.checkpoint_reuses == 1, + "one baseline per pair, reused by the second eye"); + CHECK(stats.checkpoint_ram_bytes > 0, + "the captured baseline reports the RAM it copied"); + CHECK(s_prec_open == 0 && s_projective_scope == 0, "precision tracking is armed and rolled back per eye"); CHECK(cpu.gpr[8] == 123 && s_ram[0x456] == 0x77 && psx_cycle_count == cycle, "live state restored after the pair"); CHECK(s_view[0] == 0 && stats.eye_view[0][0] == -24 && stats.eye_view[1][0] == 24, @@ -750,6 +791,14 @@ static void test_stereo(void) { CHECK(s_pose.struct_size == 0 && !s_pose.projection, "pose restored after pair"); CHECK(!psx_mod_render_view_offset(1, 0, 0), "view rejected outside render callback"); published = s_stereo_published; + s_projective_refuse = 1; + CHECK(!psx_mod_render_stereo(&cpu, &f, pair_draw, NULL) && + s_stereo_published == published && s_stereo_mask == 0, + "projective PGXP refusal rolls the checkpoint back without publication"); + CHECK(s_prec_open == 0 && s_projective_scope == 0 && cpu.gpr[8] == 123 && + s_ram[0x456] == 0x77 && psx_cycle_count == cycle, + "projective refusal restores the complete live state"); + s_projective_refuse = 0; s_eye_decline = 1; CHECK(!psx_mod_render_stereo(&cpu, &f, pair_draw, NULL) && s_stereo_published == published && s_stereo_mask == 0, @@ -763,6 +812,175 @@ static void test_stereo(void) { "capture failure still restores live state"); f.period_vblanks = 0; CHECK(!psx_mod_render_stereo(&cpu, &f, pair_draw, NULL), "invalid cadence refused"); + + /* The headset deadline the OpenXR layer pushes outranks the guest-VBlank + * fallback, and dropping it restores the fallback. */ + f.period_vblanks = 2; + psx_mod_stereo_reset_budget(); + psx_mod_stereo_set_budget_ms(11.1); + CHECK(psx_mod_render_stereo(&cpu, &f, pair_draw, NULL) == 1, + "pair renders under a headset deadline"); + render_stereo_get_stats(&stats); + CHECK(stats.budget_source == 1 && stats.budget_ms > 11.0 && stats.budget_ms < 11.2, + "headset deadline drives the pair budget"); + psx_mod_stereo_reset_budget(); + CHECK(psx_mod_render_stereo(&cpu, &f, pair_draw, NULL) == 1, + "pair renders after the deadline is dropped"); + render_stereo_get_stats(&stats); + CHECK(stats.budget_source == 0 && stats.budget_ms > 0.0, + "guest-VBlank fallback restored when no headset deadline is set"); + + /* Tracing is observation only: the same pair must publish the same id and + * reach the same guest-state hashes with capture on as with it off. */ + { + uint64_t off_pub, off_hash[2], off_pair; + f.period_vblanks = 2; + psx_frame_trace_set_enabled(0); + render_pass_reset_session(); psx_frame_trace_set_enabled(0); + s_gl_status = 0; s_begin_ok = 1; s_eye_calls = 0; + cpu.gpr[8] = 123; s_ram[0x456] = 0x77; + CHECK(psx_mod_render_stereo(&cpu, &f, pair_draw, NULL) == 1, "pair with tracing off"); + render_stereo_get_stats(&stats); + off_pub = s_stereo_published; + off_hash[0] = stats.eye_hash[0]; off_hash[1] = stats.eye_hash[1]; + off_pair = stats.last_pair_id; + + render_pass_reset_session(); + psx_frame_trace_set_enabled(1); psx_frame_trace_reset(); + s_gl_status = 0; s_begin_ok = 1; s_eye_calls = 0; + cpu.gpr[8] = 123; s_ram[0x456] = 0x77; + CHECK(psx_mod_render_stereo(&cpu, &f, pair_draw, NULL) == 1, "pair with tracing on"); + render_stereo_get_stats(&stats); + CHECK(s_stereo_published == off_pub && stats.last_pair_id == off_pair && + stats.eye_hash[0] == off_hash[0] && stats.eye_hash[1] == off_hash[1], + "tracing changes neither the published pair nor the guest state"); + CHECK(psx_frame_trace_committed() > 0, "the traced pair was recorded"); + psx_frame_trace_set_enabled(0); psx_frame_trace_reset(); + } + + /* PSX_STEREO_BASELINE=0 restores the pre-PR3 capture per eye, and a pair + * rendered that way must still start both eyes from one checkpoint. */ + { +#ifdef _WIN32 + _putenv("PSX_STEREO_BASELINE=0"); +#else + setenv("PSX_STEREO_BASELINE", "0", 1); +#endif + f.period_vblanks = 2; + render_pass_reset_session(); s_gl_status = 0; s_begin_ok = 1; s_eye_calls = 0; + cpu.gpr[8] = 123; s_ram[0x456] = 0x77; + CHECK(psx_mod_render_stereo(&cpu, &f, pair_draw, NULL) == 1, + "pair renders with baseline reuse disabled"); + render_stereo_get_stats(&stats); + CHECK(stats.checkpoint_saves == 2 && stats.checkpoint_reuses == 0, + "reuse off captures a baseline per eye"); + CHECK(stats.eye_hash[0] == stats.eye_hash[1] && s_eye_calls == 2, + "capture-per-eye still starts both eyes from one checkpoint"); +#ifdef _WIN32 + _putenv("PSX_STEREO_BASELINE=1"); +#else + setenv("PSX_STEREO_BASELINE", "1", 1); +#endif + render_pass_reset_session(); + } +} + +/* ---- 4. per-eye trace counters and guest sub-stage spans ---------------- */ +/* The shape of the game hook's per-eye replay: bracket the guest calls, and let + * the runtime time them. See vr/psx_vr_stereo.c vr_draw_scene. */ +static int s_traced_calls; +static int traced_draw(CPUState *cpu, void *user, uint32_t eye) { + (void)user; + s_traced_calls++; + s_prims_stub += 100u + 100u * eye; /* 100 prims, then 200 */ + psx_mod_frame_trace_stage_begin(PSX_TRACE_GAME_TRAVERSE); + tick_bump(3000); /* 3 us, plus 1 per sample */ + /* The visibility override runs inside the traversal it belongs to, so its + * span nests in that one: it must be timed on its own without truncating + * the span it is inside. */ + psx_mod_frame_trace_stage_begin(PSX_TRACE_GAME_SELECT); + tick_bump(2000); + psx_mod_frame_trace_stage_end(PSX_TRACE_GAME_SELECT); + tick_bump(1000); + psx_mod_frame_trace_stage_end(PSX_TRACE_GAME_TRAVERSE); + /* An end for a stage that was never opened, and a begin that is never + * ended: neither may reach the record. */ + psx_mod_frame_trace_stage_begin(PSX_TRACE_GAME_ENV); + tick_bump(1000); + psx_mod_frame_trace_stage_end(PSX_TRACE_GAME_SUBMIT); + psx_mod_frame_trace_stage_begin(PSX_TRACE_GAME_ICON); + tick_bump(1000); + psx_advance_cycles(4000u + 1000u * eye); + return 1; +} + +static void test_trace_substages(void) { + CPUState cpu = {0}; + PSXModStereoFrame f = {sizeof f, 2, 0, 240, 320, 240}; + PSXFrameTraceRecord rec; + PSXModRenderPass rect = {sizeof rect, 0, 0, 0, 2, 2}; + int first = 1, second = 2; + uint8_t pixels[32] = {0}; + + /* A session reset re-reads PSX_FRAME_TRACE from the env, so enabling the + * trace has to come after it (as it does in a real session). */ + render_pass_reset_session(); + s_gl_status = 0; s_begin_ok = 1; s_eye_decline = -1; s_eye_calls = 0; + s_prims_stub = 0; s_traced_calls = 0; + psx_frame_trace_set_enabled(1); + psx_frame_trace_reset(); + + CHECK(psx_mod_render_stereo(&cpu, &f, traced_draw, NULL) == 1, + "the traced pair rendered"); + CHECK(s_traced_calls == 2, "both eyes replayed the guest draw"); + CHECK(psx_frame_trace_read(&rec, 1) == 1, "the pair left one record"); + CHECK(rec.prims_l == 100 && rec.prims_r == 200, + "each eye's own primitive delta is recorded"); + CHECK(rec.prims == 200 && rec.textured_prims == 100, + "the pair-level count is still the last eye's"); + CHECK(rec.eye_cycles_l >= 4000u && rec.eye_cycles_r >= 5000u && + rec.eye_cycles_r > rec.eye_cycles_l, + "each eye's own guest cycles are recorded"); + CHECK(rec.stages_us[PSX_TRACE_GAME_TRAVERSE] == 18, + "both eyes' guest spans are recorded (9 us per eye, nesting included)"); + CHECK(rec.stages_us[PSX_TRACE_GAME_SELECT] == 6, + "a nested span is timed on its own (3 us per eye) and stays a subset"); + CHECK(rec.stages_us[PSX_TRACE_GAME_ENV] == 0 && + rec.stages_us[PSX_TRACE_GAME_SUBMIT] == 0, + "an end that does not match an open span records nothing"); + CHECK(rec.stages_us[PSX_TRACE_GAME_ICON] == 0, + "an unpaired begin records nothing rather than leak into the next span"); + + /* A HUD capture's guest draws run outside a traced eye: the hook's spans + * must not land in the frame they belong to. */ + psx_frame_trace_reset(); + s_prims_stub = 0; + s_traced_calls = 0; + render_pass_reset_session(); s_gl_status = 0; s_begin_ok = 1; + psx_frame_trace_set_enabled(1); + CHECK(psx_frame_trace_open(), "a frame record is open across the capture"); + CHECK(psx_mod_render_capture_rgba_pair(&cpu, &rect, traced_draw, &first, + &second, pixels, sizeof pixels), + "the capture rendered both mattes"); + CHECK(s_traced_calls == 2 && psx_frame_trace_read(&rec, 1) == 0, + "the capture replayed the draw and left no record of its own"); + psx_frame_trace_commit(); + CHECK(psx_frame_trace_read(&rec, 1) == 1 && + rec.stages_us[PSX_TRACE_GAME_TRAVERSE] == 0 && + rec.prims_l == 0 && rec.prims_r == 0, + "a capture's guest draws write no per-eye or sub-stage slots"); + + /* Same with the trace off: the hook may bracket freely. */ + psx_frame_trace_set_enabled(0); + psx_frame_trace_reset(); + s_prims_stub = 0; + s_traced_calls = 0; + render_pass_reset_session(); s_gl_status = 0; s_begin_ok = 1; + CHECK(psx_mod_render_stereo(&cpu, &f, traced_draw, NULL) == 1, + "the pair renders with tracing off"); + CHECK(s_traced_calls == 2 && psx_frame_trace_committed() == 0, + "nothing is recorded while tracing is off"); + render_pass_reset_session(); } static int local_draw(CPUState *cpu, void *user, uint32_t alpha) { @@ -851,26 +1069,55 @@ static void test_texture_stream_journal(void) { render_pass_journal_free(&j); } +static void *s_dual_users[2]; +static int s_dual_draws; static int isolated_draw(CPUState *cpu,void *user,uint32_t alpha) { - (void)user; CHECK(alpha==0,"isolated capture has no temporal phase"); + if(s_dual_draws<2)s_dual_users[s_dual_draws]=user; + s_dual_draws++; cpu->gpr[8]=0x1234;s_ram[0x456]=0x42;psx_advance_cycles(5000); return 1; } static void test_isolated_capture(void) { CPUState cpu={0};PSXModRenderPass rect={sizeof rect,0,0,0,2,2}; - uint8_t pixels[16]={0}; + int first=1,second=2; + uint8_t pixels[32]={0}; render_pass_reset_session();s_gl_status=0;s_begin_ok=1;s_netplay=0; cpu.gpr[8]=123;s_ram[0x456]=0x77; uint64_t cycle=psx_cycle_count,published=s_stereo_published; - CHECK(psx_mod_render_capture_rgba(&cpu,&rect,isolated_draw,NULL,pixels,sizeof pixels), - "isolated capture succeeds without publishing a stereo pair"); + s_dual_draws=0; + CHECK(psx_mod_render_capture_rgba_pair(&cpu,&rect,isolated_draw,&first,&second, + pixels,sizeof pixels), + "isolated dual capture succeeds without publishing a stereo pair"); + CHECK(s_dual_draws==2 && s_dual_users[0]==&first && s_dual_users[1]==&second, + "one transaction per matte, each with its own user"); CHECK(cpu.gpr[8]==123 && s_ram[0x456]==0x77 && psx_cycle_count==cycle, "isolated capture restores CPU, RAM and the guest clock"); - CHECK(s_stereo_published==published && s_stereo_mask==0 && pixels[0]==0x5A, - "isolated pixels copied and temporary staging discarded"); - CHECK(!psx_mod_render_capture_rgba(&cpu,&rect,isolated_draw,NULL,pixels,sizeof pixels-1), + CHECK(s_stereo_published==published && s_stereo_mask==0 && + pixels[0]==0x5A && pixels[31]==0x5A, + "both isolated planes copied and temporary staging discarded"); + /* The two mattes draw from one machine state, so the second one reuses the + * checkpoint the first one captured, the way a pair's right eye does. */ + RenderStereoStats stats; + render_stereo_get_stats(&stats); + CHECK(stats.checkpoint_saves==1 && stats.checkpoint_reuses==1, + "the two mattes of one capture share one checkpoint"); + s_dual_draws=0; + CHECK(!psx_mod_render_capture_rgba_pair(&cpu,&rect,isolated_draw,&first,&second, + pixels,sizeof pixels-1), "undersized pixel buffer refused"); + CHECK(s_dual_draws==0 && !psx_mod_render_capture_rgba_pair(&cpu,&rect,isolated_draw, + &first,NULL,pixels,sizeof pixels), + "a missing second matte is refused before any draw"); + /* The capture's shared checkpoint is scoped to the capture: the pair that + * renders later in the same frame must still capture its first eye. */ + PSXModStereoFrame f={sizeof f,2,0,240,320,240}; + render_pass_reset_session();s_gl_status=0;s_begin_ok=1;s_eye_decline=-1;s_eye_calls=0; + CHECK(psx_mod_render_stereo(&cpu,&f,pair_draw,NULL)==1, + "a pair still renders after a capture"); + render_stereo_get_stats(&stats); + CHECK(stats.checkpoint_saves==1 && stats.checkpoint_reuses==1, + "the pair after a capture captures its own baseline"); } int main(void) { test_store_policy(); @@ -879,6 +1126,7 @@ int main(void) { test_journal(); test_stereo(); test_isolated_capture(); + test_trace_substages(); test_local_view(); test_texture_stream_journal(); CHECK(s_prec_open == 0, "precision checkpoints balanced"); diff --git a/runtime/tests/test_stereo_shed_policy.c b/runtime/tests/test_stereo_shed_policy.c new file mode 100644 index 00000000..dfb87f72 --- /dev/null +++ b/runtime/tests/test_stereo_shed_policy.c @@ -0,0 +1,210 @@ +/* test_stereo_shed_policy.c — PR2 shedding/recovery policy. + * + * Covers the plan's PR2 test list at the policy level: short spike, persistent + * overload, alternating load (hysteresis), stale estimate, no unbounded streak, + * transient measurement gaps, and the disable switch. Both-eye integrity and + * guest-cycle accounting are structural (the policy has no eye or guest state); + * the render-pass side already asserts eye-cycle equality. + */ + +#include "stereo_shed_policy.h" + +#include +#include + +static int g_fail; +#define CHECK(cond) do { \ + if (!(cond)) { printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); g_fail = 1; } \ +} while (0) + +/* Drive `budget`/`probe`/`streak` config the way a session would, without env. */ +static void configure(StereoShedPolicy *p, double avg_ms, uint64_t probe_ms, + uint32_t max_streak, double hysteresis) { + stereo_shed_policy_init(p); + p->budget_ms = 16.0; + p->probe_ms = (double)probe_ms; + p->max_streak = max_streak; + p->hysteresis = hysteresis; + if (avg_ms > 0.0) { p->avg_ms = avg_ms; p->pairs_seen = 1; } +} + +/* 1: disabled means never shed, whatever the timing. */ +static void test_disabled(void) { + StereoShedPolicy p; + configure(&p, 40.0, 250, 8, 0.9); + p.enabled = 0; + for (uint64_t t = 0; t < 2000; t += 8) + CHECK(stereo_shed_policy_should_shed(&p, t) == 0); +} + +/* 2: a single spike sheds, but the streak is bounded and one good probe ends it + * (the probe replaces the estimate instead of decaying into it). */ +static void test_short_spike_bounded(void) { + StereoShedPolicy p; + uint32_t sheds = 0; + configure(&p, 0.0, 250, 8, 0.9); + uint64_t t = 0; + for (int i = 0; i < 20; i++) { /* establish a fast baseline (~12 ms) */ + stereo_shed_policy_note_pair(&p, 12.0, t, 1); + t += 16; + CHECK(stereo_shed_policy_should_shed(&p, t) == 0); + } + stereo_shed_policy_note_pair(&p, 80.0, t, 1); /* one heavy frame */ + for (int i = 0; i < 200; i++) { + if (stereo_shed_policy_should_shed(&p, t)) { sheds++; t += 16; continue; } + stereo_shed_policy_note_pair(&p, 12.0, t, 1); /* the probe measures fast again */ + t += 16; + } + CHECK(sheds > 0); /* the spike did suppress something */ + CHECK(sheds <= 8u); /* ...but never more than one bounded streak */ + CHECK(stereo_shed_policy_avg_ms(&p) < 16.0); /* and recovery was immediate */ +} + +/* 3: sustained overload keeps shedding but must still probe periodically (so a + * stale average can never suppress forever), and each streak stays bounded. */ +static void test_persistent_overload_probes(void) { + StereoShedPolicy p; + uint32_t sheds = 0, attempts = 0; + uint32_t run = 0, max_run = 0; + configure(&p, 30.0, 250, 8, 0.9); + uint64_t t = 0; + for (int i = 0; i < 500; i++) { /* 500 * 8 ms = 4 s of sustained overload */ + if (stereo_shed_policy_should_shed(&p, t)) { sheds++; run++; } + else { attempts++; if (run > max_run) max_run = run; run = 0; + stereo_shed_policy_note_pair(&p, 30.0, t, 1); } /* still slow */ + t += 8; + } + CHECK(sheds > 0); + CHECK(attempts > 0); /* overload does not suppress everything */ + CHECK(max_run <= 8u); /* every streak bounded by max_streak */ + CHECK(sheds <= 500u - attempts); +} + +/* 4: alternating load must not flap: a value inside the hysteresis band holds + * the current state instead of toggling every frame. */ +static void test_alternating_load_hysteresis(void) { + StereoShedPolicy p; + int transitions = 0, prev = 0; + configure(&p, 20.0, 100000, 240, 0.9); /* wide bounds: isolate hysteresis */ + uint64_t t = 0; + for (int i = 0; i < 200; i++) { + int s = stereo_shed_policy_should_shed(&p, t); + if (i && s != prev) transitions++; + prev = s; + /* alternate just under budget and inside the band (14.4..16) */ + stereo_shed_policy_note_pair(&p, (i & 1) ? 15.8 : 15.0, t, 1); + t += 8; + } + CHECK(transitions <= 2); /* entered once, at most one exit: no flapping */ +} + +/* 5: stale estimate — a long gap with no measurement must still be interrupted + * by a probe within probe_ms, and must not suppress indefinitely. */ +static void test_stale_estimate_recovers(void) { + StereoShedPolicy p; + configure(&p, 40.0, 250, 1000, 0.9); /* high avg, count bound far away */ + CHECK(stereo_shed_policy_should_shed(&p, 0) == 1); /* enter */ + CHECK(stereo_shed_policy_should_shed(&p, 8) == 1); + CHECK(stereo_shed_policy_should_shed(&p, 260) == 0); /* >= probe_ms: probe */ + CHECK(stereo_shed_policy_should_shed(&p, 260) == 1); /* fresh streak begins */ + CHECK(stereo_shed_policy_should_shed(&p, 520) == 0); /* bounded again */ +} + +/* 6: a measurement gap (e.g. OpenXR failing so render_stereo never runs) leaves + * the average untouched, but the time bound still forces a probe. */ +static void test_transient_measurement_gap(void) { + StereoShedPolicy p; + uint64_t first_probe = 0; + configure(&p, 30.0, 250, 8, 0.9); + uint64_t t = 0; + for (int i = 0; i < 200; i++) { + if (stereo_shed_policy_should_shed(&p, t) == 0) { first_probe = t; break; } + t += 8; /* no note_pair: the average is never refreshed */ + } + CHECK(first_probe > 0 && first_probe <= 256u); +} + +/* 7: a failure that aborts the attempt early is still a measurement, so the + * average falls and shedding ends (no unbounded failure loop). */ +static void test_failure_ends_streak(void) { + StereoShedPolicy p; + configure(&p, 30.0, 100000, 240, 0.9); + CHECK(stereo_shed_policy_should_shed(&p, 0) == 1); + stereo_shed_policy_note_pair(&p, 3.0, 8, 0); /* aborted early, not published */ + for (int i = 0; i < 40; i++) + stereo_shed_policy_note_pair(&p, 3.0, (uint64_t)(16 + i * 8), 0); + CHECK(stereo_shed_policy_should_shed(&p, 1000) == 0); +} + +/* 8: pair age tracks the retained pair independently of the compute estimate. */ +static void test_pair_age_separate(void) { + StereoShedPolicy p; + configure(&p, 0.0, 250, 8, 0.9); + CHECK(stereo_shed_policy_pair_age_ms(&p, 100) == 0); /* nothing published yet */ + stereo_shed_policy_note_pair(&p, 12.0, 1000, 1); + CHECK(stereo_shed_policy_pair_age_ms(&p, 1000) == 0); + CHECK(stereo_shed_policy_pair_age_ms(&p, 1050) == 50); + /* a later failed attempt must not refresh the age */ + stereo_shed_policy_note_pair(&p, 12.0, 1100, 0); + CHECK(stereo_shed_policy_pair_age_ms(&p, 1100) == 100); +} + +/* 9: reset clears the estimate so a new scene does not inherit it. */ +static void test_reset_on_transition(void) { + StereoShedPolicy p; + configure(&p, 40.0, 250, 8, 0.9); + CHECK(stereo_shed_policy_should_shed(&p, 0) == 1); + stereo_shed_policy_reset(&p); + CHECK(stereo_shed_policy_avg_ms(&p) == 0.0); + CHECK(stereo_shed_policy_should_shed(&p, 10) == 0); /* fresh, under budget */ +} + +/* 11: a pair time that settles *inside* the hysteresis band must not latch the + * shedding state. Measured case (SteamVR null HMD at 90 Hz): deadline + * 11.111 ms, pair 10.4 ms, so the band is 10.0 to 11.111. Shedding entered + * during a heavy moment and then never ended, because the band holds whatever + * state it is in and the old probe exit test was the band's lower edge. That + * cost 26 sheds/s against 3.7 published pairs/s. The probe is authoritative and + * must exit on the deadline itself. */ +static void test_band_latch_recovers(void) { + StereoShedPolicy p; + uint32_t sheds = 0; + configure(&p, 0.0, 250, 8, 0.9); + p.budget_ms = 11.111; + uint64_t t = 0; + stereo_shed_policy_note_pair(&p, 15.0, t, 1); /* heavy moment: enter shedding */ + for (int i = 0; i < 60; i++) { /* 2 s at a 33 ms frame */ + if (stereo_shed_policy_should_shed(&p, t)) { sheds++; t += 33; continue; } + stereo_shed_policy_note_pair(&p, 10.4, t, 1); /* steady state inside the band */ + t += 33; + } + CHECK(sheds <= 12u); /* one bounded streak, then rendering resumes */ + CHECK(stereo_shed_policy_avg_ms(&p) <= 11.111); +} + +/* 10: env parsing clamps to safe ranges (and PSX_STEREO_SHED=0 disables). */ +static void test_env(void) { + StereoShedPolicy p; + stereo_shed_policy_init(&p); + CHECK(p.enabled == 1 && p.max_streak >= 1u && p.probe_ms > 0.0); + stereo_shed_policy_configure_from_env(&p); /* no env set in CI: defaults hold */ + CHECK(p.max_streak >= 1u && p.max_streak <= 240u); + CHECK(p.hysteresis >= 0.5 && p.hysteresis <= 1.0); +} + +int main(void) { + test_disabled(); + test_short_spike_bounded(); + test_persistent_overload_probes(); + test_alternating_load_hysteresis(); + test_stale_estimate_recovers(); + test_transient_measurement_gap(); + test_failure_ends_streak(); + test_pair_age_separate(); + test_reset_on_transition(); + test_band_latch_recovers(); + test_env(); + if (g_fail) { printf("stereo_shed_policy: FAILED\n"); return 1; } + printf("stereo_shed_policy: all checks passed\n"); + return 0; +} diff --git a/runtime/tests/test_vr_pose_math.c b/runtime/tests/test_vr_pose_math.c index 01af3db3..f5814cee 100644 --- a/runtime/tests/test_vr_pose_math.c +++ b/runtime/tests/test_vr_pose_math.c @@ -11,6 +11,10 @@ int main(void) { CHECK(v.rotation_q12[0]==4096 && v.rotation_q12[4]==4096 && v.rotation_q12[8]==4096); CHECK(v.fx_q16==256*65536 && v.fy_q16==240*65536); CHECK(v.cx_delta_q16==0 && v.cy_delta_q16==0); + CHECK(v.precise && fabs(v.translation_precise[0]-33.5)<1e-12); + const double tiny[3]={.0001,0,0}; + CHECK(vr_pose_to_view(q,tiny,q,origin,f,1000,512,240,&v)); + CHECK(v.translation[0]==0 && fabs(v.translation_precise[0]+.1)<1e-12); double move[3]={0,.1,-.2}; CHECK(vr_pose_to_view(q,move,q,origin,f,1000,512,240,&v)); CHECK(v.translation[1]==100 && v.translation[2]==-200); diff --git a/runtime/tests/test_ws_cull_edge_interp.c b/runtime/tests/test_ws_cull_edge_interp.c index 149452d4..3b946d3b 100644 --- a/runtime/tests/test_ws_cull_edge_interp.c +++ b/runtime/tests/test_ws_cull_edge_interp.c @@ -8,7 +8,7 @@ * * - the store: masking to physical addresses, sort, de-duplication, lookups * at both ends and between entries, the 256-entry cap and its log line, - * empty and negative-count lists, the clip-edge width default; + * empty and negative-count lists, and explicit screen-X sites; * - psx_ws_cull_bgez / psx_ws_clip_edge_x through a forced margin; * - real interpreted instructions (exec_one_fetched) at margin 0 and at a * forced margin: BGEZ and BLTZ sites, BNE and BLEZ branch-keep sites, and @@ -248,6 +248,17 @@ static void test_store(void) { !psx_ws_is_cull_slti_site(0x80030008u), "slti_sites store"); gpu_ws_set_explicit_cull_sites(NULL, 0, NULL, 0, NULL, 0); + const uint32_t screen_x[] = { 0x80030020u, 0x00030010u, 0x80030020u }; + gpu_ws_set_cull_screen_x_sites(screen_x, 3); + CHECK(ws_explicit_screen_x_n == 2, "screen_x_sites de-duplicated"); + CHECK(psx_ws_is_cull_screen_x_site(0xA0030020u) && + psx_ws_is_cull_screen_x_site(0x80030010u) && + !psx_ws_is_cull_screen_x_site(0x80030018u), + "screen_x_sites uses physical addresses and exact lookup"); + gpu_ws_set_cull_screen_x_sites(NULL, 0); + CHECK(!psx_ws_is_cull_screen_x_site(0x80030020u), + "empty screen_x_sites clears prior entries"); + /* Cap: exactly 256 is silent and complete. */ for (int i = 0; i < 300; i++) big_list[i] = 0x80040000u + 4u * (uint32_t)(299 - i); /* descending */ @@ -319,6 +330,11 @@ static void test_interpreter(void) { const uint32_t blez_site = CODE + 0x030, blez_plain = CODE + 0x130; const uint32_t lh_site = CODE + 0x040, lh_plain = CODE + 0x140; const uint32_t lhu_site = CODE + 0x050, lw_site = CODE + 0x060; + const uint32_t sltiu_x_site = CODE + 0x070; + const uint32_t lui_x_site = CODE + 0x080; + const uint32_t bltz_x_site = CODE + 0x090; + const uint32_t sltiu_x_plain = CODE + 0x170; + const uint32_t sltu_x_site = CODE + 0x0A0, sltu_x_plain = CODE + 0x1A0; const uint32_t bgez = i_type(0x01, V0, 0x01, 3); /* bgez v0,+3 */ const uint32_t bltz = i_type(0x01, V0, 0x00, 3); /* bltz v0,+3 */ @@ -327,17 +343,59 @@ static void test_interpreter(void) { const uint32_t lh = i_type(0x21, A0, V0, 0x70); /* lh v0,0x70(a0) */ const uint32_t lhu = i_type(0x25, A0, V0, 0x70); /* lhu v0,0x70(a0) */ const uint32_t lw = i_type(0x23, A0, V0, 0x70); /* lw v0,0x70(a0) */ + const uint32_t sltiu_x = i_type(0x0B, V1, V0, 0x200); + const uint32_t lui_x = i_type(0x0F, 0, V1, 0x200); + const uint32_t sltu_x = (V1 << 21) | (A0 << 16) | (V0 << 11) | 0x2Bu; /* sltu v0,v1,a0 */ const uint32_t bltz_l[] = { bltz_site }, bgez_l[] = { bgez_site }; const uint32_t keep_l[] = { bne_site, blez_site }; const uint32_t clip_l[] = { lh_site, lhu_site, lw_site }; + const uint32_t screen_x_l[] = { sltiu_x_site, lui_x_site, bltz_x_site, sltu_x_site }; gpu_ws_set_branch_cull_sites(bltz_l, 1, bgez_l, 1, keep_l, 2); gpu_ws_set_clip_edge_x_load_sites(clip_l, 3, 320); + gpu_ws_set_cull_screen_x_sites(screen_x_l, 4); for (int pass = 0; pass < 2; pass++) { const int m = pass ? 53 : 0; gpu_ws_set_margin_override(m); + cpu.gpr[V1] = (uint32_t)-53; + CHECK(load(&cpu, sltiu_x_site, sltiu_x) == (m > 0 ? 1u : 0u), + "explicit screen-X sltiu left margin, m=%d", m); + cpu.gpr[V1] = 564; + CHECK(load(&cpu, sltiu_x_site, sltiu_x) == (m > 0 ? 1u : 0u), + "explicit screen-X sltiu right edge, m=%d", m); + cpu.gpr[V1] = 565; + CHECK(load(&cpu, sltiu_x_site, sltiu_x) == 0u, + "explicit screen-X sltiu right margin, m=%d", m); + cpu.gpr[V1] = (uint32_t)-53; + CHECK(load(&cpu, sltiu_x_plain, sltiu_x) == 0u, + "unlisted screen-X sltiu remains vanilla, m=%d", m); + + cpu.gpr[A0] = 320; + cpu.gpr[V1] = (uint32_t)-100; + (void)step(&cpu, sltu_x_site, sltu_x); + CHECK(cpu.gpr[V0] == (m > 0 ? 1u : 0u), + "explicit screen-X sltu left band, m=%d", m); + cpu.gpr[V1] = (uint32_t)-300; + (void)step(&cpu, sltu_x_site, sltu_x); + CHECK(cpu.gpr[V0] == 0u, "explicit screen-X sltu past left reach, m=%d", m); + cpu.gpr[V1] = (uint32_t)-100; + (void)step(&cpu, sltu_x_plain, sltu_x); + CHECK(cpu.gpr[V0] == 0u, "unlisted sltu remains vanilla, m=%d", m); + + cpu.gpr[V1] = 0; + (void)step(&cpu, lui_x_site, lui_x); + CHECK(cpu.gpr[V1] == (uint32_t)(0x200 + m) << 16, + "explicit screen-X lui bound, m=%d", m); + + cpu.gpr[V0] = (uint32_t)-10 << 16; + CHECK(branch_taken(&cpu, bltz_x_site, bltz) == (m == 0), + "explicit screen-X high-half bltz at -10, m=%d", m); + cpu.gpr[V0] = (uint32_t)-54 << 16; + CHECK(branch_taken(&cpu, bltz_x_site, bltz) == 1, + "explicit screen-X high-half bltz at -54, m=%d", m); + /* bgez: x0 = -10 sits in the revealed band when m = 53. */ cpu.gpr[V0] = (uint32_t)-10; CHECK(branch_taken(&cpu, bgez_site, bgez) == (m > 0), @@ -403,6 +461,74 @@ static void test_interpreter(void) { gpu_ws_set_margin_override(-1); gpu_ws_set_branch_cull_sites(NULL, 0, NULL, 0, NULL, 0); gpu_ws_set_clip_edge_x_load_sites(NULL, 0, 0); + gpu_ws_set_cull_screen_x_sites(NULL, 0); +} + +/* A site listed in screen_x_sites and in another explicit list: the code + * generator returns for the other list first, so the interpreter must too. */ +static void test_screen_x_precedence(void) { + CPUState cpu; + memset(&cpu, 0, sizeof cpu); + memset(test_ram, 0, sizeof test_ram); + + const uint32_t sltu_keep = CODE + 0x200, sltu_scale = CODE + 0x210; + const uint32_t sltu_only = CODE + 0x220, lui_signed = CODE + 0x230; + const uint32_t lui_only = CODE + 0x240; + const uint32_t sltu = (V1 << 21) | (A0 << 16) | (V0 << 11) | 0x2Bu; /* sltu v0,v1,a0 */ + const uint32_t lui = i_type(0x0F, 0, V1, 0x200); + + const uint32_t screen_x[] = { sltu_keep, sltu_scale, sltu_only, + lui_signed, lui_only }; + const uint32_t keep_a[] = { sltu_keep }, keep_e[] = { sltu }, + keep_r[] = { 0 }; + const uint32_t scale_a[] = { sltu_scale }, scale_e[] = { sltu }, + scale_o[] = { 1 }, scale_h[] = { 160 }; + const uint32_t sb_a[] = { lui_signed }, sb_e[] = { lui }; + gpu_ws_set_cull_screen_x_sites(screen_x, 5); + gpu_ws_set_cull_keep_sites(keep_a, keep_e, keep_r, 1); + gpu_ws_set_cull_scale_sites(scale_a, scale_e, scale_o, scale_h, 1); + gpu_ws_set_signed_x_bound_sites(sb_a, sb_e, 1); + + for (int pass = 0; pass < 2; pass++) { + const int m = pass ? 53 : 0; + gpu_ws_set_margin_override(m); + + /* -100 < 320 as unsigned is false; the band would accept it at m=53. */ + cpu.gpr[V1] = (uint32_t)-100; + cpu.gpr[A0] = 320; + (void)step(&cpu, sltu_only, sltu); + CHECK(cpu.gpr[V0] == (m > 0 ? 1u : 0u), + "screen_x sltu alone uses the band, m=%d", m); + + /* keep site (forced 0 while widened) beats the band */ + cpu.gpr[V0] = 7; + (void)step(&cpu, sltu_keep, sltu); + CHECK(cpu.gpr[V0] == 0u, "keep site beats screen_x sltu, m=%d", m); + + /* scale site beats the band: 0xFFFFFF9C < scaled(320) is false */ + cpu.gpr[V0] = 7; + (void)step(&cpu, sltu_scale, sltu); + CHECK(cpu.gpr[V0] == 0u, "scale site beats screen_x sltu, m=%d", m); + + /* signed_x_bound beats the screen_x lui edge */ + cpu.gpr[V1] = 0; + (void)step(&cpu, lui_signed, lui); + CHECK(cpu.gpr[V1] == (uint32_t)psx_ws_player_x_bound((int32_t)(0x200u << 16)), + "signed_x_bound beats screen_x lui, m=%d", m); + CHECK(m == 0 || cpu.gpr[V1] != (uint32_t)(0x200 + m) << 16, + "signed_x_bound and screen_x lui differ while widened, m=%d", m); + cpu.gpr[V1] = 0; + (void)step(&cpu, lui_only, lui); + CHECK(cpu.gpr[V1] == (uint32_t)(0x200 + m) << 16, + "screen_x lui alone, m=%d", m); + } + + + gpu_ws_set_margin_override(-1); + gpu_ws_set_cull_screen_x_sites(NULL, 0); + gpu_ws_set_cull_keep_sites(NULL, NULL, NULL, 0); + gpu_ws_set_cull_scale_sites(NULL, NULL, NULL, NULL, 0); + gpu_ws_set_signed_x_bound_sites(NULL, NULL, 0); } static void test_masked_reject(void) { @@ -473,6 +599,7 @@ int main(int argc, char **argv) { test_store(); test_helpers(); test_interpreter(); + test_screen_x_precedence(); test_masked_reject(); test_wide_nclip_branches(); if (failures) { diff --git a/runtime/tests/test_ws_screen_tags_exec.py b/runtime/tests/test_ws_screen_tags_exec.py index aa79eaa9..2cc90e92 100644 --- a/runtime/tests/test_ws_screen_tags_exec.py +++ b/runtime/tests/test_ws_screen_tags_exec.py @@ -44,6 +44,12 @@ static int gp0_words_needed = 5; static int ws_engaged(void) { return engaged; } static int ws_active(void) { return active; } static uint32_t psx_ram_live_bytes(void) { return sizeof(ram); } +/* This fixture owns only ordinary RAM, with no extended GPU-DMA arena. */ +static int psx_mod_gpu_dma_memory_contains(uint32_t address, uint32_t bytes) { + (void)address; + (void)bytes; + return 0; +} static uint32_t psx_read_word(uint32_t address) { uint32_t phys = address & 0x1fffffffu; assert(!(phys & 3) && phys < sizeof(ram));