diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f970752..625d4fa 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -6,7 +6,7 @@ body: attributes: value: | **Do not attach game files, disc images, or `default.xex`**, and do not - ask where to obtain the game — those issues are closed without an answer. + ask where to obtain the game. Those issues are closed without an answer. - type: dropdown id: platform @@ -24,7 +24,7 @@ body: id: version attributes: label: Version - placeholder: "e.g. v0.2.0, or a commit hash if you built it yourself" + placeholder: "e.g. v0.2.1, or a commit hash if you built it yourself" validations: required: true @@ -33,8 +33,8 @@ body: attributes: label: Where options: - - Launcher — first-run setup - - Launcher — everything else + - "Launcher: first-run setup" + - "Launcher: everything else" - The game itself - Building from source validations: @@ -52,7 +52,9 @@ body: id: logs attributes: label: Logs - description: Terminal output, and anything in the `logs/` folder. + description: > + In the launcher, choose Open logs folder. Paste the relevant text files + here, along with any terminal output. Do not attach game files. render: shell - type: checkboxes diff --git a/.github/ISSUE_TEMPLATE/windows_report.yml b/.github/ISSUE_TEMPLATE/windows_report.yml index d2e3d7c..08d6ee0 100644 --- a/.github/ISSUE_TEMPLATE/windows_report.yml +++ b/.github/ISSUE_TEMPLATE/windows_report.yml @@ -1,5 +1,5 @@ name: Windows report -description: You ran the Windows build. Tell us what happened — working or not. +description: You ran the Windows build. Tell us what happened, working or not. title: "[Windows] " labels: [windows, needs-triage] body: @@ -30,7 +30,7 @@ body: id: version attributes: label: Project8Recomp version - placeholder: "e.g. v0.2.0" + placeholder: "e.g. v0.2.1" validations: required: true @@ -68,7 +68,8 @@ body: attributes: label: Output and logs description: > - Console output, and anything in the `logs/` folder of your install. + In the launcher, choose Open logs folder. Paste the relevant text files + here, along with any console output. Do not attach game files. render: shell - type: checkboxes diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8e052de..d39daa6 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,4 +10,4 @@ - [ ] The launcher builds and `ctest` passes. - [ ] If this touches a third-party dependency, `NOTICE` was regenerated. - [ ] If this changes anything input-driven in the launcher, I drove it myself - with a controller or a mouse — CI cannot verify that. + with a controller or a mouse. CI cannot verify that. diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml index 7fd936e..366c5f3 100644 --- a/.github/workflows/launcher.yml +++ b/.github/workflows/launcher.yml @@ -116,7 +116,7 @@ jobs: run: ctest --test-dir build/launcher --output-on-failure # Parses every RML/RCSS document and fails on any diagnostic. It cannot - # prove a control can be activated — no window manager, so synthetic input + # prove a control can be activated: no window manager, so synthetic input # never lands. - name: Check UI documents if: runner.os == 'Linux' diff --git a/AGENTS.md b/AGENTS.md index db27b95..bd966b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,9 +10,9 @@ checkpointed releases. - **`src/`, `config/` and `patches/` are synchronised from the project's development tree.** Changes to them are applied there and arrive here in a release, so a pull request against those paths may be applied as a patch - rather than merged as a commit. It will be credited either way. Everything - else — the README, `docs/`, `tools/`, `.github/` and the licence files — - belongs to this repository and is edited here directly. + rather than merged as a commit. It will be credited either way. The README, + `docs/`, `tools/`, `.github/`, and the licence files belong to this repository + and are edited here directly. - **This repo has exactly one goal:** make it easy for someone who owns a legal copy to end up with a ready-to-play build. It is not a research dump. @@ -31,11 +31,11 @@ If you are unsure whether something counts: it counts. ## Layout ``` -src/launcher/ the GUI launcher. Links no SDK — builds anywhere, needs no dump. +src/launcher/ the GUI launcher. Links no SDK; builds anywhere, needs no dump. src/identify/ disc identity + extraction. Links the SDK; does what the GUI must not. src/game/ the game's host code. Needs the SDK and generated sources. src/common/ shared between the identity worker and the game's dump gate. -config/ recompiler configuration — addresses, sizes, names. +config/ recompiler configuration: addresses, sizes, names. patches/ patches against the SDK the port depends on. tools/ staging, licence generation, and the release gates. docs/ end-user and contributor documentation. @@ -49,8 +49,8 @@ linker. Disc identity is answered by a separate binary that does link the SDK. Do not "simplify" this by merging them. **One dump table, two gates.** `src/common/supported_dumps.h` is used by both -the launcher's identity check and the game's own startup gate. Never copy it — -two copies means one of them is never tested. +the launcher's identity check and the game's own startup gate. Never copy it; +two copies mean one of them is never tested. **Settings render to argv by omission.** An unset setting emits no flag at all, never `--flag=`. An empty value is consumed as the next argument and silently @@ -84,14 +84,14 @@ implying a machine confirmed it. Do not fake a screenshot, and do not describe a headless run as evidence that a control works. **Windows is unverified in the same way, only more so.** A community report -confirms that v0.1.0 started on Windows 10, but the maintainers have not run the -complete v0.2.0 build on real Windows hardware. Never describe it as verified, +confirms that v0.1.0 started on Windows 10, but the maintainers have not run a +current complete build on real Windows hardware. Never describe it as verified, and never describe a compatibility-layer result as evidence about real Windows. ## Comments -Explain why, not what — particularly when the obvious approach was tried and +Explain why, not what, particularly when the obvious approach was tried and failed. That history is the most valuable thing in a comment and the easiest to lose. diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b7d03..25828c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,98 @@ # Changelog -## v0.2.0 — Steam Deck performance and release packaging +## v0.2.1: Steam Deck performance refresh + +This patch release pushes Steam Deck performance further in busy scenes. The +exact Linux release archive averaged **25.00 ms per frame / 40.01 effective +FPS** in a deterministic Funpark view with roughly 2,667 draws per frame. It +also restores a complete Apple Silicon archive and improves the launcher's +player-facing guidance. The supported disc identity, existing executable +names, save locations, and launcher flow are unchanged. + +### Adjacent texture descriptor-set reuse + +The Performance preset now enables one additional default-off Vulkan path. It +reuses a texture descriptor set only when the immediately preceding request in +the same submission has the exact same descriptor-set layout and ordered image +views, layouts, and samplers. Submission and transient-pool boundaries clear +the entry, and every mismatch follows the original allocation/write path. + +On the deterministic 2,600–2,799-draw Funpark fixture, the path matched +**39.17%** of non-empty texture stages and avoided about **1,001 descriptor +writes per frame**. A same-binary, cool-start six-run gate moved the +median-of-three mean from **25.630 to 25.332 ms (-1.16%)**, effective FPS from +**39.02 to 39.48**, with process CPU flat at roughly **257%**. The wider +hash-table design was not shipped: although it found another 450 exact sets per +frame, it regressed mean frame time by 0.21% in all three ordered comparisons. + +The patch inventory now contains 37 entries. The four new source patches add +the accepted adjacent reuse path, default-off sequence capture used to measure +it, reproducible source-path mapping, and a usable clang-cl warning level; +capture is inactive during normal play. + +### Steam Deck launches match the measured path + +SteamOS includes RenderDoc's Vulkan loader as a system library. The runtime +probes for that library by name, so the v0.2.0 portable ZIP could attach the +debugger during an ordinary player launch even though no capture was requested. +That adds substantial per-draw overhead in the scenes where the Deck needs the +performance work most. + +The Linux archive now carries a dependency-free guard library, and both +`Project8Recomp` and the existing GUI launcher put it first for the game handoff. +This makes the normal portable launch match the no-debugger condition used for +the published Steam Deck measurements. Developers can still request the system +RenderDoc library explicitly with `THPS_P8_RENDERDOC=1`. + +### Player-facing wording + +The launcher now says directly that the port contains no game content and does +not download any. This is a wording clarification only: setup still reads a +disc image supplied from the player's own copy, writes the extracted data into +the portable install, and sends nothing elsewhere. + +The launcher copy has also been tightened across setup, settings, recovery, and +error states. Its home screen now has separate **Open save folder** and **Open +logs folder** buttons, and the issue forms direct reporters to the latter. Paths +and display names are escaped before insertion into RML, so characters such as +`<` and `&` in a local filename remain text instead of being parsed as markup. + +### Complete Apple Silicon release restored + +v0.2.1 again ships a complete `macos-arm64` archive, restoring the platform +package that v0.1.0 had and v0.2.0 omitted. The archive now carries the Vulkan +loader and MoltenVK itself, uses their adjacent manifest even when started from +Finder, and ad-hoc signs every Mach-O file after its install names and symbols +are normalised. A player does not need Homebrew; the archive is self-contained, +but it remains neither Developer ID-signed nor notarized, so the documented +right-click → Open step still applies. + +The exact release candidate was exercised on a 2020 Apple M1 MacBook Pro. Its +packaged supervisor started the game, the bundled Vulkan loader and MoltenVK +were loaded, and the marked fixture reached a visually verified Funpark frame +at 2,704 draws. This restores functional support, but it is not a performance +claim. Its 2,500-plus-draw rows averaged +**236.79 ms / 4.22 effective FPS** (233.75 ms p50, 251.29 ms p95), while the +process used roughly **317% CPU** during the run. Apple Silicon remains too +slow for comfortable play in that severe scene. + +### Exact release archives exercised + +The complete Linux and Windows release candidates were unpacked and tested on +a Steam Deck in Game Mode, using the same marked Funpark view as the performance +work. The Linux archive averaged **24.996 ms / 40.01 effective FPS**, with +24.998 ms p50, 27.003 ms p95, roughly 2,667 draws per frame, and **257.6% +process CPU**. The Windows archive rendered all nine expected checkpoints +through Proton 10. It held the player-default 30 FPS presentation mode at +**33.31 / 34.49 ms p50 / p95**; uncapped, it measured **27.83 / 32.02 ms p50 / +p95**, or 35.64 effective FPS. + +Proton reparents the Windows game outside the test supervisor, so this run does +not provide a trustworthy process-CPU comparison. These results prove the +archive, launcher handoff, Vulkan path, scene loading, and pacing under Proton. +They still do not substitute for a test on real Windows hardware. + +## v0.2.0: Steam Deck performance and release packaging v0.2.0 promotes the runtime configuration measured on Steam Deck, fixes the resolution control that v0.1.0 exposed but did not actually apply, and replaces @@ -26,7 +118,7 @@ release archives before tagging. binaries, so a clean player machine does not need a separate redistributable installer before the wrapper can start. -### Resolution changes now take effect — resolves #2 +### Resolution changes now take effect (resolves #2) The launcher previously saved and emitted a resolution choice while the runtime remained in its default borderless-fullscreen mode. Borderless @@ -41,13 +133,13 @@ was discarded and every selection looked identical. - Argument-rendering tests cover named modes, 1280x800, implicit windowed mode, and explicit-fullscreen precedence. -### Windows pacing and CPU use — mitigates #1 +### Windows pacing and CPU use (mitigates #1) The v0.1.0 runtime disabled SDL's Windows timer resolution. A 16.67 ms vblank deadline could therefore wake at 31.25 ms, after which the worker delivered one vblank and discarded the other elapsed interval. The guest could be paced near -32 Hz while threads waiting for that counter continued consuming CPU—the same -shape reported in #1. +32 Hz while threads waiting for that counter continued consuming CPU. This is +the same shape reported in #1. v0.2.0 uses a high-resolution deadline timer on Windows, keeps SDL's timer resolution enabled, and performs bounded vblank catch-up after a late wake. @@ -74,8 +166,8 @@ native vertex/index residency and unpack paths, upload prefetch, and exact reuse of adjacent sampler, texture-request, and Vulkan view work. Every lever can still be disabled through the launcher's Performance setting. -On the deterministic late-game Funpark fixture, the accepted Steam Deck LCD -build's three-run uncapped promotion median measured approximately 2,667 draws +On the deterministic late-game Funpark fixture, the v0.2.0 Steam Deck LCD +build's three-run uncapped median measured approximately 2,667 draws per frame, **25.307 ms mean**, **25.000 ms p50**, **27.015 ms p95**, **39.51 effective FPS**, and **257.1% process CPU** (about 2.57 logical cores). With the player-default frame cap, the release-candidate archive held a stable 30 FPS @@ -96,7 +188,7 @@ regression. GLIBC 2.39 / GLIBCXX 3.4.32 ceiling and redistribution boundary, and smoke tests the complete Linux and Windows ZIPs on Deck before any tag exists. -## v0.1.0 — first public release +## v0.1.0: first public release The port is playable start to finish on Linux and macOS from a copy of the game you own. @@ -107,10 +199,10 @@ you own. disc, copies the game, and starts it. After the first run it is a Play button. - **Controller support** throughout the launcher and the game, including plugging one in after the launcher is already open. -- **Display settings** — resolution, which monitor, windowed or fullscreen, and - the frame rate cap — saved in `config/settings.toml`. +- **Display settings:** resolution, monitor, windowed or fullscreen mode, and + the frame rate cap, saved in `config/settings.toml`. - **Portable install.** Everything lives in one folder. Move it, copy it between - your own machines, delete it — nothing is written anywhere else. + your own machines, or delete it. Nothing is written anywhere else. ### Refusing bad input @@ -129,7 +221,7 @@ Roughly 100 fps uncapped on a desktop Linux machine with a discrete GPU, up from they are in `patches/rexglue-sdk/`. macOS runs natively on Apple Silicon and is functionally correct but much -slower — see [docs/KNOWN_ISSUES.md](docs/KNOWN_ISSUES.md). +slower. See [docs/KNOWN_ISSUES.md](docs/KNOWN_ISSUES.md). ### Platforms diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 016beca..bdf5996 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,16 +16,16 @@ closed without an answer. **A Windows report.** This is the single most valuable thing anyone can send right now. v0.1.0 started on one reported Windows 10 machine but performed -poorly; the complete v0.2.0 build has only been exercised under Proton. Whether -it works or falls over on Windows, we want to know — see the Windows issue -template. +poorly; current complete builds have only been exercised under Proton. Whether +one works or fails on Windows, we want to know. See the Windows issue template. **A hash from another regional disc.** The launcher accepts one release today. If you own a PAL or NTSC-J disc, the SHA-256 of its `default.xex` and the size of that file are enough to add a row. Do not send the file. -**Bug reports with logs.** `logs/` in your install folder, plus the terminal -output. +**Bug reports with logs.** Choose **Open logs folder** on the launcher home +screen, then include the relevant text files and any terminal output. Do not +attach game files. **Fixes.** See the open issues. @@ -33,9 +33,9 @@ output. `src/`, `config/` and `patches/` are synchronised from the project's development tree. Pull requests against them are welcome and get read the same way as any -other — but they may be applied as a patch and arrive in the next release, -rather than appearing as your commit in this history. You will be credited in -the change that carries them. +other, but they may be applied as a patch and arrive in the next release rather +than appearing as your commit in this history. You will be credited in the +change that carries them. Everything else is edited here directly: the README, `docs/`, `tools/`, `.github/` and the licence files. A pull request against those is merged @@ -70,7 +70,7 @@ once. **Headless testing cannot verify the launcher's UI.** There is no window manager in CI, so nothing delivers focus and synthetic input never reaches a control. CI proves the documents parse and the code paths run. Anything input-driven needs a -human with a controller before it is called done — this has caught real defects +human with a controller before it is called done. This has caught real defects that a full headless pass reported as green. **Settings render to argv by omission, not by empty values.** An unset setting @@ -79,8 +79,8 @@ next argument and silently shifts the whole command line. `src/launcher/tests/` holds that rule; do not route around it. **The dump table is shared.** `src/common/supported_dumps.h` is used by both the -launcher's identity check and the game's own gate. Do not copy it — two copies -means one of them is untested. +launcher's identity check and the game's own gate. Do not copy it; two copies +mean one of them is untested. **The game's host code and the launcher are separate builds on purpose.** The launcher links no part of the SDK, so it still builds when the game does not. diff --git a/README.md b/README.md index 87c3b33..3ea1e2c 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ C++ ahead of time and compiled into a normal executable for your machine. > and its releases contain no disc image, original game executable, extracted > assets, or other files copied from the disc. The launcher reads a disc image you > supply from your own copy, checks it is a supported release, and sets up an -> install on your machine. Nobody here has a copy to give you, and asking — in -> an issue, a discussion, or anywhere else — will get the thread closed. +> install on your machine. Nobody here has a copy to give you. Asking in an +> issue, a discussion, or anywhere else will get the thread closed. **[Download the latest release →](../../releases/latest)** @@ -39,18 +39,18 @@ C++ ahead of time and compiled into a normal executable for your machine. | Platform | Status | | --- | --- | | Linux x86_64 | **Verified.** The main development and testing platform. | -| macOS arm64 | **v0.1.0 verified** on Apple Silicon. Correct, but slow; no v0.2.0 package yet — see [known issues](docs/KNOWN_ISSUES.md). | -| Windows x86_64 | **Experimental. Complete v0.2.0 build tested under Proton; not yet verified on real Windows.** | +| macOS arm64 | **Complete v0.2.1 archive verified on Apple M1.** Self-contained and correct, but too slow in heavy scenes; see [known issues](docs/KNOWN_ISSUES.md). | +| Windows x86_64 | **Experimental. Complete v0.2.1 archive tested under Proton; not yet verified on real Windows.** | The Windows situation, plainly: everything is cross-compiled from Linux, -because nobody on this project owns a Windows machine. The complete v0.2.0 +because nobody on this project owns a Windows machine. The complete v0.2.1 archive was tested through Proton 10 on a Steam Deck: the launcher and game started, the late-game 2,600-plus-draw fixture rendered correctly, and the -default frame cap sustained a 33.30 ms median there. Uncapped, the same Windows -build measured 28.00 ms median versus 25.00 ms for the native Linux build. +default frame cap sustained a 33.31 ms median there. Uncapped, the same Windows +build measured 27.83 ms median versus 25.00 ms for the native Linux build. That is useful compatibility and performance evidence, but it is still not a -test on Windows — Proton is a different implementation of the same interfaces, -and the places it differs are where an untested port breaks. +test on Windows. Proton is a different implementation of the same interfaces, +and the places where it differs are precisely where an untested port can break. If you try the Windows launcher, please tell us what happened. "It opened and the buttons worked" is as useful to us as a crash report, and there is @@ -59,7 +59,7 @@ the buttons worked" is as useful to us as a crash report, and there is ## What you need - Your own copy of Tony Hawk's Project 8 for Xbox 360. -- A disc image of it (`.iso`). You do not need to extract it — the launcher +- A disc image of it (`.iso`). You do not need to extract it; the launcher reads the image directly. - About 5 GB of free space. - A GPU with Vulkan support. @@ -77,8 +77,8 @@ the buttons worked" is as useful to us as a crash report, and there is 4. It checks the image. This takes well under a second and copies nothing yet. A disc that is not the supported release is refused here, before 4.7 GB is written rather than after. -5. On a match it copies the game data into the same folder — a few minutes, and - you can stop it. +5. On a match it copies the game data into the same folder. This takes a few + minutes, and you can stop it. 6. The game starts. From then on `Project8Recomp` goes straight through the launcher to Play. Run `Project8Recomp --gui` whenever you want settings, setup status, or the normal launcher home screen. @@ -89,11 +89,12 @@ so existing shortcuts continue to work. The new entry is a small wrapper over settings, single-instance handling, the supervisor, and crash cleanup stay on the same path. -Everything lives in that one folder: the game data, your saves, your settings. -Move it, copy it between your own machines, delete it — nothing is written -anywhere else on your system. +Everything lives in that one folder: the game data, your saves, and your +settings. Move it, copy it between your own machines, or delete it. Nothing is +written elsewhere on your system. -**macOS:** the app is not notarized, so Gatekeeper will refuse a double-click. +**macOS:** the archive includes its Vulkan runtime, so Homebrew is not required. +It is ad-hoc signed but not notarized, so Gatekeeper will refuse a double-click. Right-click → **Open** → **Open**, once per install. **Windows:** the binary is not signed, so SmartScreen will show "Windows @@ -132,13 +133,12 @@ runtime without a copy of the game has nothing to run. The launcher rejected my disc image. Why? It hashes `default.xex` inside the image and compares it against the releases -this port was built from. Today that is one: the 2006 retail disc, listed in -[docs/SETUP.md](docs/SETUP.md). It will tell you which of three things went -wrong — not a disc image, a different game, or the right game from a different -release. +this port was built from. Today that is one: the 2006 retail disc listed in +[docs/SETUP.md](docs/SETUP.md). It will distinguish three cases: a file that is +not a disc image, a different game, or the right game from a different release. If you own a PAL or NTSC-J disc, the hash and size of its `default.xex` are -genuinely useful to us — those two values are all it takes to add support for a +genuinely useful to us. Those two values are all it takes to add support for a release. **Do not send the file itself.** The tool that does the checking will print them for you. From the folder you @@ -165,17 +165,18 @@ Paste those two lines into an issue, along with the region printed on your disc.
-Where are my saves? +Where are my saves and logs? -In `saves/`, inside the folder you unpacked. The launcher has a button for it. +Saves are in `saves/` and logs are in `logs/`, both inside the folder you +unpacked. The launcher home screen has a button for each folder.
Can I get more than 60 fps? -Yes, by turning the cap off in the launcher's Display settings — but read the -[known issues](docs/KNOWN_ISSUES.md) first, because in-level cutscenes break -when you do. +Yes, by turning the cap off in the launcher's Display settings. Read the +[known issues](docs/KNOWN_ISSUES.md) first because in-level cutscenes break +when you do this.
@@ -208,11 +209,11 @@ non-commercial preservation project. It is not affiliated with, sponsored by, or endorsed by Activision, Neversoft, Tony Hawk, Microsoft, or any other rights holder. All trademarks and copyrights belong to their respective owners. -**What the licence covers.** The source in this repository — the launcher, the -disc identity worker, the supervisor, the game's host code, the recompiler -configuration, the build scripts and the documentation — is under the BSD -3-Clause License ([LICENSE](LICENSE)). Third-party components are used under -their own licences, reproduced in full in [NOTICE](NOTICE). +**What the licence covers.** The source in this repository includes the +launcher, disc identity worker, supervisor, game host code, recompiler +configuration, build scripts, and documentation. It is under the BSD 3-Clause +License ([LICENSE](LICENSE)). Third-party components are used under their own +licences, reproduced in full in [NOTICE](NOTICE). It does **not** cover anything derived from the game. The translation units the recompiler generates from `default.xex` are not published as source and are not @@ -224,16 +225,16 @@ game files. **Do not request or share game files.** Issues, discussions and pull requests asking where to obtain the game will be closed without an answer. Pull requests containing game assets, decrypted intermediates, platform keys or generated -translation units will be rejected — see [CONTRIBUTING.md](CONTRIBUTING.md). +translation units will be rejected; see [CONTRIBUTING.md](CONTRIBUTING.md). ## Credits -- **[ReXGlue](https://github.com/rexglue/rexglue-sdk)** by Tom Clay — the static +- **[ReXGlue](https://github.com/rexglue/rexglue-sdk)** by Tom Clay: the static recompilation runtime this is built on. -- **[Xenia](https://github.com/xenia-project/xenia)** — the Xbox 360 emulation +- **[Xenia](https://github.com/xenia-project/xenia)**: the Xbox 360 emulation work ReXGlue derives from. None of this exists without it. - **[XenonRecomp](https://github.com/hedge-dev/XenonRecomp)** and - **[rexdex's recompiler](https://github.com/rexdex/recompiler)** — for + **[rexdex's recompiler](https://github.com/rexdex/recompiler)**: for pioneering this approach on the platform. - **FFmpeg** (LGPL-2.1), **RmlUi** (MIT), **SDL3** and **SDL3_image** (Zlib), **FreeType**, **Noto Sans** (OFL-1.1). diff --git a/docs/BUILDING.md b/docs/BUILDING.md index c1fc4cc..5075010 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -17,10 +17,10 @@ FreeType. RmlUi is fetched by CMake at a pinned commit. ```sh # Arch sudo pacman -S cmake ninja sdl3 sdl3_image freetype2 -# Debian/Ubuntu — SDL3 may not be packaged yet; see tools/ci/build_sdl3.sh +# Debian/Ubuntu: SDL3 may not be packaged yet; see tools/ci/build_sdl3.sh sudo apt install cmake ninja-build libfreetype-dev -# macOS -brew install cmake ninja sdl3 sdl3_image freetype +# macOS (MoltenVK and the Vulkan loader are needed for a complete game archive) +brew install cmake ninja sdl3 sdl3_image freetype molten-vk vulkan-loader cmake -S src/launcher -B build/launcher -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build build/launcher @@ -37,9 +37,9 @@ repository substitutes for it. You will need: 1. **The rexglue SDK**, built with the patches in `patches/rexglue-sdk/`. - Apply them in the machine-readable order in `patches/rexglue-sdk/series` — - it is not numeric order, and the README says why. Without them the game runs about - four times slower, and on macOS it does not run at all. + Apply them in the machine-readable order in `patches/rexglue-sdk/series`. + It is not numeric order, and the README says why. Without them the game runs + about four times slower, and on macOS it does not run at all. 2. **Your extracted game data**, which the launcher produces, or which you can extract yourself with `thps_p8_identify --identify_disc=... --extract_to=...`. 3. **Codegen**, run against your `default.xex` using a local copy of @@ -60,8 +60,8 @@ cmake --build build/game The generated sources are not in this repository and never will be: they are a mechanical translation of a copyrighted binary. They are regenerated on your -machine from configuration that is committed here — addresses, sizes, names and -table entries, which are facts about a binary rather than the binary. +machine from committed configuration containing addresses, sizes, names, and +table entries. Those are facts about a binary rather than the binary itself. ## Building for Windows, from Linux @@ -92,7 +92,7 @@ and enabling it pulls in `thirdparty/dxbc/DXBCChecksum.cpp`, which is AMD-copyrighted and whose redistribution terms this project has not resolved. Vulkan avoids that file, and is the backend actually tested on Linux and macOS. -Windows builds are not tested on Windows hardware — see +Windows builds are not tested on Windows hardware. See [KNOWN_ISSUES.md](KNOWN_ISSUES.md). ## The disc identity worker @@ -115,7 +115,7 @@ launcher's assets. The GPU plugin is the easy one to forget: without `librexgpu-xenos*`, the game starts, opens a window and draws nothing. ```sh -tools/package_release.sh --platform linux-x86_64 --version v0.2.0 \ +tools/package_release.sh --platform linux-x86_64 --version vX.Y.Z \ --game build/game --launcher build/launcher --identify build/identify \ --sdk /path/to/your/sdk/prefix --out dist ``` @@ -124,6 +124,11 @@ The Windows package also needs the app-local Visual C++ runtime DLLs beside the launcher. Hosted CI stages them in its Windows launcher artifact; the packager refuses a Windows archive when they are absent. +The macOS package must be assembled on Apple Silicon. It copies the Homebrew +Vulkan loader and MoltenVK into the archive, rewrites their install names, and +ad-hoc signs the finished Mach-O files. Those Homebrew packages are build-time +inputs only; a player does not need Homebrew. + ## Before publishing anything ```sh diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 0077869..facdf72 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -1,6 +1,6 @@ # Known issues -As of v0.2.0. +As of v0.2.1. ## In-level cutscenes run too fast without a frame cap @@ -12,26 +12,35 @@ The cause is localised but not fixed. The variable is the guest frame rate and nothing else: uncapped, the game runs at ~200 fps and the in-level cutscene update advances with it. Capped, it is correct. The front-end attract cinematic is immune, so the front-end cinematic player is timebase-correct and the -in-level cutscene update is different code — that is the next thing to read. +in-level cutscene update is different code. That is the next thing to read. One thing this is *not*: an earlier belief that pressing F5 fixed it was wrong. Every session where F5 appeared to help was also a capped session. ## macOS performance -The v0.1.0 Apple Silicon package works and reaches gameplay, but is not fast. -The ~100 FPS figure comes from a desktop Linux machine with a discrete GPU and -does not transfer: on an M1, Free Skate reaches gameplay at roughly 6 FPS. It -is functionally correct and not performance-playable. There is no v0.2.0 macOS -package yet; v0.1.0 remains available from the previous release. +v0.2.1 restores a complete, self-contained Apple Silicon archive. It bundles +the Vulkan loader and MoltenVK, so players do not need Homebrew. The exact +archive was tested on a 2020 Apple M1 MacBook Pro: the packaged supervisor +started the game, the packaged Vulkan libraries loaded, and the marked fixture +reached a visually verified 2,704-draw Funpark frame. + +It is functionally correct but not performance-playable in that severe scene. +Across the fixture's 2,500-plus-draw rows, mean frame time was 236.79 ms (4.22 +effective FPS), with 233.75 ms p50 and 251.29 ms p95; the process used roughly +317% CPU. The much higher Linux desktop and Steam Deck figures elsewhere in the +documentation do not transfer to the base M1's MoltenVK path. + +The binaries are ad-hoc signed but not notarized. Gatekeeper therefore requires +the right-click → Open step documented in the main README. ## Windows is mitigated and Proton-tested, not hardware-verified -Everything for Windows — the launcher, the disc worker, the supervisor and the -game itself — is built for the MSVC ABI and exercised under Proton. A community +The launcher, disc worker, supervisor, and game are all built for the MSVC ABI +and exercised under Proton. A community report confirms that v0.1.0 started on Windows 10, but its CPU use and pacing were unusable there. The maintainers do not own a Windows machine, so the -complete v0.2.0 build has not been verified on real Windows hardware. +complete v0.2.1 build has not been verified on real Windows hardware. What has been confirmed under the compatibility layer: the launcher opens and navigates, a real disc image is identified correctly, a full 4.7 GB extraction @@ -54,14 +63,17 @@ deadline timer, and delivers a bounded number of elapsed vblanks after a late wake. Related guest waits now sleep or block on their actual producer instead of truncating sub-millisecond waits into yields. -The complete v0.2.0 archive sustained both the menu and a deterministic -2,600-plus-draw late-game fixture under Proton 10 on a Steam Deck. The menu -sample measured 16.68 ms p50 / 17.71 ms p95. The heavy fixture held the same -30 fps presentation mode as native at 33.30 / 34.45 ms p50 / p95; uncapped, it -measured 28.00 / 32.15 ms versus native Linux at 25.00 / 27.99 ms. The detached -Windows process used roughly 319–349% CPU in those heavy runs, compared with -253–256% natively. Those CPU figures are lifetime process snapshots because -Wine reparents the process outside the test supervisor. +The complete v0.2.1 archive sustained a deterministic 2,600-plus-draw +late-game fixture under Proton 10 on a Steam Deck and rendered all nine marked +checkpoints. It held the player-default 30 fps presentation mode at 33.31 / +34.49 ms p50 / p95. Uncapped, it measured 27.83 / 32.02 ms versus native Linux +at 25.00 / 27.00 ms, or 35.64 versus 40.01 effective FPS. + +Wine reparents the game outside the test supervisor, so this v0.2.1 run does +not provide a trustworthy process-CPU comparison. Earlier v0.2.0 lifetime +snapshots put the detached Windows process at roughly 319–349% CPU, compared +with 253–256% natively, but those figures are not precise enough for a release +to claim a CPU improvement. This is a concrete mitigation for the reported mechanism, with a measurable Proton overhead. Only a report from real Windows can tell us whether it fixes @@ -81,10 +93,10 @@ not been exercised on real Windows hardware. Headless testing can prove that code paths run. It cannot prove a user can reach them: with no window manager there is nothing to deliver focus, so -synthetic clicks and keypresses never land. An earlier round of this found three -defects only when someone sat down with a controller — every button silently -discarding its click, B doing nothing, and Play producing a black screen from a -missing GPU plugin flag. +synthetic clicks and keypresses never land. An earlier round found three +defects only when someone sat down with a controller: every button silently +discarded its click, B did nothing, and Play produced a black screen because a +GPU plugin flag was missing. So these are known-unverified rather than known-good: diff --git a/docs/RELINKING.md b/docs/RELINKING.md index f81a8a7..add7bc8 100644 --- a/docs/RELINKING.md +++ b/docs/RELINKING.md @@ -20,7 +20,7 @@ Components: `libavcodec`, `libavformat`, `libavutil`, `libswresample`, | --- | --- | | Fork | `https://github.com/wmarti/FFmpeg.git` | | Commit | `0604b464c7cb4ebc94940cf1f324a3b26b87717c` | -| Local modifications | none — the tree is used as the fork publishes it | +| Local modifications | none; the tree is used as the fork publishes it | The fork commits per-platform configuration headers rather than generating them at build time. All eight (`config_{android,linux,macos,windows}_{x86_64,aarch64}.h`) @@ -50,7 +50,7 @@ SDK, both published in full, so you can modify FFmpeg and rebuild rather than being handed object files to relink. 1. Clone the fork at the commit above, or your own modified version of it. - Keep the LGPL configuration — a GPL build changes the licence of everything + Keep the LGPL configuration; a GPL build changes the licence of everything downstream of it. 2. Build the ReXGlue SDK against your FFmpeg, with the patches in `patches/rexglue-sdk/` applied in the order that directory's README gives. diff --git a/docs/SETUP.md b/docs/SETUP.md index 38aa7e2..d9c4199 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -10,7 +10,7 @@ thread closed. That is the only thing this page will say on the subject. ## What the launcher accepts A disc image of the 2006 retail release, as a `.iso` (or `.img`). The launcher -reads it directly — there is no need to extract it yourself first. +reads it directly, so there is no need to extract it yourself first. | Release | `default.xex` SHA-256 | Size | | --- | --- | --- | @@ -29,11 +29,11 @@ bug. | World v1.0 (USA/Europe) | `415607DD` | `2CB96AE4` | Region-free. **The supported one.** | | Japan v1.0 | `41560810` | `05728741` | NTSC-J. A different title ID, so a different executable. | | Korea | unknown | unknown | A physical release is confirmed to exist; its disc identity is unresolved. | -| Demo v1.0 | — | `2932D558` | Disc label AV202950W0X11. | +| Demo v1.0 | N/A | `2932D558` | Disc label AV202950W0X11. | Adding a release is a data change rather than a change to the gate, but it -needs the executable's hash confirmed against a disc someone actually holds — -we will not add a row on hearsay. +needs the executable's hash confirmed against a disc someone actually holds. +We will not add a row on hearsay. So if you own any of these, the SHA-256 and size of its `default.xex` are the most useful thing you could send us. **Do not send the file.** The Korean disc @@ -49,7 +49,7 @@ all. things went wrong: not a disc image at all, a different game, or the right game from a different release. 4. On a match, it copies the game data into a `game/` folder next to the - launcher. This is the slow step — a few minutes, and you can stop it. + launcher. This step takes a few minutes, and you can stop it. 5. It writes `config/install.toml` and hands off to the game. After that, the launcher goes straight to a Play button and you do not need the @@ -131,5 +131,6 @@ every control on the screen. - **The game will not start and says it cannot find game data.** The `game/` folder is missing or incomplete. Delete `config/install.toml` and run the launcher again to redo setup. -- **Anything else.** `logs/` and the terminal output are what a bug report - needs. +- **Anything else.** Choose **Open logs folder** on the launcher home screen. + The relevant text files there, plus any terminal output, are what a bug report + needs. Do not attach game files. diff --git a/patches/rexglue-sdk/0035-draw-census-sequences.patch b/patches/rexglue-sdk/0035-draw-census-sequences.patch new file mode 100644 index 0000000..7e111d8 --- /dev/null +++ b/patches/rexglue-sdk/0035-draw-census-sequences.patch @@ -0,0 +1,174 @@ +diff --git a/src/graphics/vulkan/command_processor.cpp b/src/graphics/vulkan/command_processor.cpp +index f50344b..2af0992 100644 +--- a/src/graphics/vulkan/command_processor.cpp ++++ b/src/graphics/vulkan/command_processor.cpp +@@ -2456,12 +2456,11 @@ void VulkanCommandProcessor::IssueSwap(uint32_t frontbuffer_ptr, uint32_t frontb + if (REXCVAR_GET(trace_gpu_stream)) { + census_mode_flags |= draw_census::kModeTraceGpuStream; + } +- const bool census_was_open = draw_census_->HasOpenCapture(); +- draw_census_->OnSwap( ++ const bool census_started = draw_census_->OnSwap( + kernel_state_ ? kernel_state_->title_id() : 0, draw_census_swap_ordinal_, counter(), + GetCurrentFrame(), GetCurrentSubmission(), census_mode_flags, + rex::perf::GetSnapshotCounter(rex::perf::CounterId::kDrawCalls)); +- if (!census_was_open && draw_census_->IsRecording()) { ++ if (census_started) { + std::memset(draw_census_float_dirty_, 0, sizeof(draw_census_float_dirty_)); + std::memset(draw_census_fetch_dirty_, 0, sizeof(draw_census_fetch_dirty_)); + draw_census_boolloop_dirty_writes_ = 0; +diff --git a/src/graphics/vulkan/draw_census.cc b/src/graphics/vulkan/draw_census.cc +index 5523fa7..de73ce6 100644 +--- a/src/graphics/vulkan/draw_census.cc ++++ b/src/graphics/vulkan/draw_census.cc +@@ -1,6 +1,6 @@ + /** + ****************************************************************************** +- * ReXGlue : Xenos GPU plugin - single-frame draw census * ++ * ReXGlue : Xenos GPU plugin - bounded frame-sequence draw census * + ****************************************************************************** + * Released under the BSD license - see LICENSE in the root for more details. * + */ +@@ -27,11 +27,13 @@ + #endif + + REXCVAR_DEFINE_STRING(gpu_census_dir, "", "GPU/Vulkan", +- "Directory for one-frame draw census captures"); ++ "Directory for draw census captures"); + REXCVAR_DEFINE_STRING(gpu_census_tag, "", "GPU/Vulkan", + "Build identifier stored in draw census headers"); + REXCVAR_DEFINE_INT32(gpu_census_capacity_draws, 4096, "GPU/Vulkan", + "Maximum draw records in one draw census capture"); ++REXCVAR_DEFINE_INT32(gpu_census_sequence_frames, 1, "GPU/Vulkan", ++ "Consecutive frames written by one draw census capture request (1-8)"); + + // Defined by rexcore. The GPU plugin reaches it through the import library. + REXCVAR_DECLARE(std::string, perf_log_csv); +@@ -82,7 +84,7 @@ void RequestCapture() { + } // namespace + + REXCVAR_DEFINE_COMMAND(gpu_census_capture, RequestCapture, "GPU/Vulkan", +- "Capture the next complete guest GPU frame to a .dcen file"); ++ "Capture the configured complete guest GPU frame sequence"); + REXCVAR_DEFINE_COMMAND(gpu_census_status, LogStatus, "GPU/Vulkan", + "Log draw census request and completion counters"); + +@@ -187,7 +189,8 @@ bool Recorder::Start(uint32_t title_id, uint64_t guest_swap_ordinal, + return true; + } + +-void Recorder::Complete(uint64_t guest_swap_ordinal_close, int64_t draw_calls_snapshot) { ++void Recorder::Complete(uint64_t guest_swap_ordinal_close, int64_t draw_calls_snapshot, ++ bool release_storage) { + const uint32_t completed = g_completed.fetch_add(1, std::memory_order_relaxed) + 1; + + TrailerRecord trailer{}; +@@ -234,36 +237,56 @@ void Recorder::Complete(uint64_t guest_swap_ordinal_close, int64_t draw_calls_sn + recording_ = false; + capture_truncated_ = false; + buffer_.clear(); +- buffer_.shrink_to_fit(); + shaders_seen_.clear(); +- shaders_seen_.shrink_to_fit(); ++ if (release_storage) { ++ buffer_.shrink_to_fit(); ++ shaders_seen_.shrink_to_fit(); ++ } + } + +-void Recorder::OnSwap(uint32_t title_id, uint64_t guest_swap_ordinal, ++bool Recorder::OnSwap(uint32_t title_id, uint64_t guest_swap_ordinal, + uint64_t guest_vblank_counter, uint64_t vk_frame_current, + uint64_t vk_submission, uint32_t mode_flags, + int64_t draw_calls_snapshot) { + if (recording_) { + // Requests made while the capture frame was open are deliberately not +- // queued behind it: each command means exactly one next frame. ++ // queued behind its already-latched sequence. + const uint32_t requests_during_capture = + g_requests_pending.exchange(0, std::memory_order_acq_rel); + if (requests_during_capture) { + g_dropped.fetch_add(requests_during_capture, std::memory_order_relaxed); + } +- Complete(guest_swap_ordinal, draw_calls_snapshot); +- return; ++ const bool continue_sequence = sequence_frames_remaining_ != 0; ++ Complete(guest_swap_ordinal, draw_calls_snapshot, !continue_sequence); ++ if (continue_sequence) { ++ if (Start(title_id, guest_swap_ordinal, guest_vblank_counter, vk_frame_current, ++ vk_submission, mode_flags)) { ++ --sequence_frames_remaining_; ++ return true; ++ } else { ++ sequence_frames_remaining_ = 0; ++ } ++ } ++ return false; + } + + const uint32_t requests = g_requests_pending.exchange(0, std::memory_order_acq_rel); + if (!requests) { +- return; ++ return false; + } + if (requests > 1) { + g_dropped.fetch_add(requests - 1, std::memory_order_relaxed); + } +- Start(title_id, guest_swap_ordinal, guest_vblank_counter, vk_frame_current, +- vk_submission, mode_flags); ++ sequence_frames_remaining_ = ++ uint32_t(std::clamp(REXCVAR_GET(gpu_census_sequence_frames), 1, 8)); ++ if (Start(title_id, guest_swap_ordinal, guest_vblank_counter, vk_frame_current, ++ vk_submission, mode_flags)) { ++ --sequence_frames_remaining_; ++ return true; ++ } else { ++ sequence_frames_remaining_ = 0; ++ return false; ++ } + } + + bool Recorder::RecordShader(ShaderRecord record) { +diff --git a/src/graphics/vulkan/draw_census.h b/src/graphics/vulkan/draw_census.h +index cf1a7e5..52676c1 100644 +--- a/src/graphics/vulkan/draw_census.h ++++ b/src/graphics/vulkan/draw_census.h +@@ -1,7 +1,7 @@ + #pragma once + /** + ****************************************************************************** +- * ReXGlue : Xenos GPU plugin - single-frame draw census * ++ * ReXGlue : Xenos GPU plugin - bounded frame-sequence draw census * + ****************************************************************************** + * Released under the BSD license - see LICENSE in the root for more details. * + * +@@ -250,7 +250,10 @@ class Recorder { + bool IsRecording() const { return recording_ && !capture_truncated_; } + bool HasOpenCapture() const { return recording_; } + +- void OnSwap(uint32_t title_id, uint64_t guest_swap_ordinal, ++ // Returns true when this boundary opened a new capture frame. The caller ++ // uses that signal to reset its per-frame dirty-state tracking, including ++ // between members of one consecutive-frame sequence. ++ bool OnSwap(uint32_t title_id, uint64_t guest_swap_ordinal, + uint64_t guest_vblank_counter, uint64_t vk_frame_current, + uint64_t vk_submission, uint32_t mode_flags, int64_t draw_calls_snapshot); + +@@ -263,7 +266,8 @@ class Recorder { + bool Start(uint32_t title_id, uint64_t guest_swap_ordinal, + uint64_t guest_vblank_counter, uint64_t vk_frame_current, + uint64_t vk_submission, uint32_t mode_flags); +- void Complete(uint64_t guest_swap_ordinal_close, int64_t draw_calls_snapshot); ++ void Complete(uint64_t guest_swap_ordinal_close, int64_t draw_calls_snapshot, ++ bool release_storage); + bool Append(const void* data, size_t size, bool reserve_trailer = true); + void MarkTruncated(); + +@@ -278,6 +282,7 @@ class Recorder { + uint32_t overflow_vertex_ = 0; + uint32_t overflow_texture_ = 0; + uint32_t overflow_sampler_ = 0; ++ uint32_t sequence_frames_remaining_ = 0; + uint64_t guest_swap_ordinal_open_ = 0; + size_t max_buffer_bytes_ = 0; + std::filesystem::path output_path_; diff --git a/patches/rexglue-sdk/0036-adjacent-texture-descriptor-set-reuse.patch b/patches/rexglue-sdk/0036-adjacent-texture-descriptor-set-reuse.patch new file mode 100644 index 0000000..7210fa3 --- /dev/null +++ b/patches/rexglue-sdk/0036-adjacent-texture-descriptor-set-reuse.patch @@ -0,0 +1,337 @@ +diff --git a/include/rex/graphics/vulkan/command_processor.h b/include/rex/graphics/vulkan/command_processor.h +--- a/include/rex/graphics/vulkan/command_processor.h ++++ b/include/rex/graphics/vulkan/command_processor.h +@@ -378,6 +378,14 @@ class VulkanCommandProcessor : public CommandProcessor { + VkDescriptorSet set; + }; + ++ struct AdjacentTextureDescriptorSetCacheEntry { ++ VkDescriptorSetLayout descriptor_set_layout = VK_NULL_HANDLE; ++ VkDescriptorSet descriptor_set = VK_NULL_HANDLE; ++ std::vector texture_infos; ++ std::vector sampler_infos; ++ bool valid = false; ++ }; ++ + enum SwapApplyGammaDescriptorSet : uint32_t { + kSwapApplyGammaDescriptorSetRamp, + kSwapApplyGammaDescriptorSetSource, +@@ -443,6 +451,7 @@ class VulkanCommandProcessor : public CommandProcessor { + bool CanEndSubmissionImmediately() const; + + void ClearTransientDescriptorPools(); ++ void ClearAdjacentTextureDescriptorSetCache(); + bool IssueCopy_ReadbackResolvePath(); + bool IssueDraw_MemexportReadbackFullPath(uint32_t total_size); + bool IssueDraw_MemexportReadbackFastPath(uint32_t total_size); +@@ -527,6 +536,16 @@ class VulkanCommandProcessor : public CommandProcessor { + const VkDescriptorImageInfo* texture_image_info, + const VkDescriptorImageInfo* sampler_image_info, + VkWriteDescriptorSet* descriptor_set_writes_out); ++ VkDescriptorSet FindAdjacentTextureDescriptorSet( ++ bool is_vertex, VkDescriptorSetLayout descriptor_set_layout, uint32_t texture_count, ++ uint32_t sampler_count, const VkDescriptorImageInfo* texture_image_info, ++ const VkDescriptorImageInfo* sampler_image_info) const; ++ void StoreAdjacentTextureDescriptorSet(bool is_vertex, ++ VkDescriptorSetLayout descriptor_set_layout, ++ VkDescriptorSet descriptor_set, uint32_t texture_count, ++ uint32_t sampler_count, ++ const VkDescriptorImageInfo* texture_image_info, ++ const VkDescriptorImageInfo* sampler_image_info); + + bool device_lost_ = false; + +@@ -624,6 +643,7 @@ class VulkanCommandProcessor : public CommandProcessor { + std::unordered_map, + TextureDescriptorSetLayoutKey::Hasher> + texture_transient_descriptor_sets_free_; ++ std::array adjacent_texture_descriptor_set_cache_; + + std::unique_ptr shared_memory_; + +diff --git a/include/rex/perf/counter.h b/include/rex/perf/counter.h +--- a/include/rex/perf/counter.h ++++ b/include/rex/perf/counter.h +@@ -153,6 +153,13 @@ enum class CounterId : uint16_t { + kNativePrefetchHits, + kNativePrefetchMisses, + ++ // Exact reuse of the immediately preceding non-empty texture/sampler ++ // descriptor set in the same Vulkan submission. Collected only while ++ // gpu_native_stats is enabled so the timing arm doesn't pay for telemetry. ++ kTextureDescriptorSetAdjacentReuseLookups, ++ kTextureDescriptorSetAdjacentReuseHits, ++ kTextureDescriptorSetAdjacentReuseMisses, ++ + kCount // sentinel -- must be last + }; + +diff --git a/src/core/perf/counter.cpp b/src/core/perf/counter.cpp +--- a/src/core/perf/counter.cpp ++++ b/src/core/perf/counter.cpp +@@ -71,6 +71,9 @@ constexpr const char* kCounterNames[] = { + "native_prefetch_ranges", + "native_prefetch_hits", + "native_prefetch_misses", ++ "texture_descriptor_set_adjacent_reuse_lookups", ++ "texture_descriptor_set_adjacent_reuse_hits", ++ "texture_descriptor_set_adjacent_reuse_misses", + }; + static_assert(std::size(kCounterNames) == kNumCounters, "kCounterNames must match CounterId enum"); + +@@ -116,6 +119,9 @@ constexpr bool kIsGauge[] = { + false, // kNativePrefetchRanges + false, // kNativePrefetchHits + false, // kNativePrefetchMisses ++ false, // kTextureDescriptorSetAdjacentReuseLookups ++ false, // kTextureDescriptorSetAdjacentReuseHits ++ false, // kTextureDescriptorSetAdjacentReuseMisses + }; + static_assert(std::size(kIsGauge) == kNumCounters, "kIsGauge must match CounterId enum"); + +diff --git a/src/graphics/vulkan/command_processor.cpp b/src/graphics/vulkan/command_processor.cpp +--- a/src/graphics/vulkan/command_processor.cpp ++++ b/src/graphics/vulkan/command_processor.cpp +@@ -65,6 +65,10 @@ REXCVAR_DEFINE_BOOL(gpu_timestamp_queries, false, "GPU", + REXCVAR_DEFINE_BOOL(gpu_sampler_set_reuse, false, "GPU/Vulkan", + "EXPERIMENTAL - reuse an identical adjacent sampler set within a submission") + .lifecycle(rex::cvar::Lifecycle::kHotReload); ++REXCVAR_DEFINE_BOOL( ++ gpu_texture_descriptor_set_adjacent_reuse, false, "GPU/Vulkan", ++ "EXPERIMENTAL - reuse the immediately preceding exact texture/sampler descriptor set") ++ .lifecycle(rex::cvar::Lifecycle::kRequiresRestart); + REXCVAR_DEFINE_BOOL(vulkan_readback_resolve, false, "GPU/Vulkan", + "Read render-to-texture results on the CPU") + .lifecycle(rex::cvar::Lifecycle::kHotReload); +@@ -5759,6 +5763,8 @@ bool VulkanCommandProcessor::BeginSubmission(bool is_guest_command) { + if (!submission_open_) { + submission_open_ = true; + ++ ClearAdjacentTextureDescriptorSetCache(); ++ + // Start a new deferred command buffer - will submit it to the real one in + // the end of the submission (when async pipeline object creation requests + // are fulfilled). +@@ -6164,6 +6170,7 @@ bool VulkanCommandProcessor::EndSubmission(bool is_swap) { + } + + void VulkanCommandProcessor::ClearTransientDescriptorPools() { ++ ClearAdjacentTextureDescriptorSetCache(); + texture_transient_descriptor_sets_free_.clear(); + texture_transient_descriptor_sets_used_.clear(); + transient_descriptor_allocator_textures_.Reset(); +@@ -6179,6 +6186,16 @@ void VulkanCommandProcessor::ClearTransientDescriptorPools() { + transient_descriptor_allocator_uniform_buffer_.Reset(); + } + ++void VulkanCommandProcessor::ClearAdjacentTextureDescriptorSetCache() { ++ for (AdjacentTextureDescriptorSetCacheEntry& entry : adjacent_texture_descriptor_set_cache_) { ++ entry.descriptor_set_layout = VK_NULL_HANDLE; ++ entry.descriptor_set = VK_NULL_HANDLE; ++ entry.texture_infos.clear(); ++ entry.sampler_infos.clear(); ++ entry.valid = false; ++ } ++} ++ + void VulkanCommandProcessor::SplitPendingBarrier() { + size_t pending_buffer_memory_barrier_count = pending_barriers_buffer_memory_barriers_.size(); + size_t pending_image_memory_barrier_count = pending_barriers_image_memory_barriers_.size(); +@@ -7308,6 +7325,14 @@ bool VulkanCommandProcessor::UpdateBindings(const VulkanShader* vertex_shader, + write_descriptor_sets; + uint32_t write_descriptor_set_count = 0; + uint32_t write_descriptor_set_bits = 0; ++ const bool adjacent_texture_descriptor_set_reuse = ++ REXCVAR_GET(gpu_texture_descriptor_set_adjacent_reuse); ++ const bool collect_adjacent_texture_descriptor_set_reuse_stats = ++ adjacent_texture_descriptor_set_reuse && REXCVAR_GET(gpu_native_stats); ++ bool store_adjacent_vertex_texture_descriptor_set = false; ++ bool store_adjacent_pixel_texture_descriptor_set = false; ++ VkDescriptorSet adjacent_vertex_texture_descriptor_set = VK_NULL_HANDLE; ++ VkDescriptorSet adjacent_pixel_texture_descriptor_set = VK_NULL_HANDLE; + assert_not_zero(current_graphics_descriptor_set_values_up_to_date_ & + (UINT32_C(1) << SpirvShaderTranslator::kDescriptorSetSharedMemoryAndEdram)); + // Constant buffers. +@@ -7349,43 +7374,110 @@ bool VulkanCommandProcessor::UpdateBindings(const VulkanShader* vertex_shader, + } + // Vertex shader textures and samplers. + if (write_vertex_textures) { +- VkWriteDescriptorSet* write_textures = +- write_descriptor_sets.data() + write_descriptor_set_count; +- uint32_t texture_descriptor_set_write_count = WriteTransientTextureBindings( +- true, texture_count_vertex, sampler_count_vertex, +- current_guest_graphics_pipeline_layout_->descriptor_set_layout_textures_vertex_ref(), +- descriptor_write_image_info_.data() + vertex_texture_image_info_offset, +- descriptor_write_image_info_.data() + vertex_sampler_image_info_offset, write_textures); +- if (!texture_descriptor_set_write_count) { +- return false; ++ VkDescriptorSetLayout descriptor_set_layout = ++ current_guest_graphics_pipeline_layout_->descriptor_set_layout_textures_vertex_ref(); ++ const VkDescriptorImageInfo* texture_infos = ++ descriptor_write_image_info_.data() + vertex_texture_image_info_offset; ++ const VkDescriptorImageInfo* sampler_infos = ++ descriptor_write_image_info_.data() + vertex_sampler_image_info_offset; ++ VkDescriptorSet descriptor_set = VK_NULL_HANDLE; ++ if (adjacent_texture_descriptor_set_reuse) { ++ if (collect_adjacent_texture_descriptor_set_reuse_stats) { ++ rex::perf::IncrementCounter( ++ rex::perf::CounterId::kTextureDescriptorSetAdjacentReuseLookups); ++ } ++ descriptor_set = ++ FindAdjacentTextureDescriptorSet(true, descriptor_set_layout, texture_count_vertex, ++ sampler_count_vertex, texture_infos, sampler_infos); ++ if (collect_adjacent_texture_descriptor_set_reuse_stats) { ++ rex::perf::IncrementCounter( ++ descriptor_set != VK_NULL_HANDLE ++ ? rex::perf::CounterId::kTextureDescriptorSetAdjacentReuseHits ++ : rex::perf::CounterId::kTextureDescriptorSetAdjacentReuseMisses); ++ } ++ } ++ if (descriptor_set == VK_NULL_HANDLE) { ++ VkWriteDescriptorSet* write_textures = ++ write_descriptor_sets.data() + write_descriptor_set_count; ++ uint32_t texture_descriptor_set_write_count = WriteTransientTextureBindings( ++ true, texture_count_vertex, sampler_count_vertex, descriptor_set_layout, texture_infos, ++ sampler_infos, write_textures); ++ if (!texture_descriptor_set_write_count) { ++ return false; ++ } ++ descriptor_set = write_textures[0].dstSet; ++ write_descriptor_set_count += texture_descriptor_set_write_count; ++ store_adjacent_vertex_texture_descriptor_set = adjacent_texture_descriptor_set_reuse; ++ adjacent_vertex_texture_descriptor_set = descriptor_set; + } +- write_descriptor_set_count += texture_descriptor_set_write_count; + write_descriptor_set_bits |= UINT32_C(1) << SpirvShaderTranslator::kDescriptorSetTexturesVertex; + current_graphics_descriptor_sets_[SpirvShaderTranslator::kDescriptorSetTexturesVertex] = +- write_textures[0].dstSet; ++ descriptor_set; + } + // Pixel shader textures and samplers. + if (write_pixel_textures) { +- VkWriteDescriptorSet* write_textures = +- write_descriptor_sets.data() + write_descriptor_set_count; +- uint32_t texture_descriptor_set_write_count = WriteTransientTextureBindings( +- false, texture_count_pixel, sampler_count_pixel, +- current_guest_graphics_pipeline_layout_->descriptor_set_layout_textures_pixel_ref(), +- descriptor_write_image_info_.data() + pixel_texture_image_info_offset, +- descriptor_write_image_info_.data() + pixel_sampler_image_info_offset, write_textures); +- if (!texture_descriptor_set_write_count) { +- return false; ++ VkDescriptorSetLayout descriptor_set_layout = ++ current_guest_graphics_pipeline_layout_->descriptor_set_layout_textures_pixel_ref(); ++ const VkDescriptorImageInfo* texture_infos = ++ descriptor_write_image_info_.data() + pixel_texture_image_info_offset; ++ const VkDescriptorImageInfo* sampler_infos = ++ descriptor_write_image_info_.data() + pixel_sampler_image_info_offset; ++ VkDescriptorSet descriptor_set = VK_NULL_HANDLE; ++ if (adjacent_texture_descriptor_set_reuse) { ++ if (collect_adjacent_texture_descriptor_set_reuse_stats) { ++ rex::perf::IncrementCounter( ++ rex::perf::CounterId::kTextureDescriptorSetAdjacentReuseLookups); ++ } ++ descriptor_set = ++ FindAdjacentTextureDescriptorSet(false, descriptor_set_layout, texture_count_pixel, ++ sampler_count_pixel, texture_infos, sampler_infos); ++ if (collect_adjacent_texture_descriptor_set_reuse_stats) { ++ rex::perf::IncrementCounter( ++ descriptor_set != VK_NULL_HANDLE ++ ? rex::perf::CounterId::kTextureDescriptorSetAdjacentReuseHits ++ : rex::perf::CounterId::kTextureDescriptorSetAdjacentReuseMisses); ++ } ++ } ++ if (descriptor_set == VK_NULL_HANDLE) { ++ VkWriteDescriptorSet* write_textures = ++ write_descriptor_sets.data() + write_descriptor_set_count; ++ uint32_t texture_descriptor_set_write_count = WriteTransientTextureBindings( ++ false, texture_count_pixel, sampler_count_pixel, descriptor_set_layout, texture_infos, ++ sampler_infos, write_textures); ++ if (!texture_descriptor_set_write_count) { ++ return false; ++ } ++ descriptor_set = write_textures[0].dstSet; ++ write_descriptor_set_count += texture_descriptor_set_write_count; ++ store_adjacent_pixel_texture_descriptor_set = adjacent_texture_descriptor_set_reuse; ++ adjacent_pixel_texture_descriptor_set = descriptor_set; + } +- write_descriptor_set_count += texture_descriptor_set_write_count; + write_descriptor_set_bits |= UINT32_C(1) << SpirvShaderTranslator::kDescriptorSetTexturesPixel; + current_graphics_descriptor_sets_[SpirvShaderTranslator::kDescriptorSetTexturesPixel] = +- write_textures[0].dstSet; ++ descriptor_set; + } + // Write. + if (write_descriptor_set_count) { + dfn.vkUpdateDescriptorSets(device, write_descriptor_set_count, write_descriptor_sets.data(), 0, + nullptr); + } ++ // Publish only descriptor sets whose writes completed successfully. This is ++ // intentionally after the combined update so a later stage allocation ++ // failure can't leave an unwritten earlier-stage set reusable. ++ if (store_adjacent_vertex_texture_descriptor_set) { ++ StoreAdjacentTextureDescriptorSet( ++ true, current_guest_graphics_pipeline_layout_->descriptor_set_layout_textures_vertex_ref(), ++ adjacent_vertex_texture_descriptor_set, texture_count_vertex, sampler_count_vertex, ++ descriptor_write_image_info_.data() + vertex_texture_image_info_offset, ++ descriptor_write_image_info_.data() + vertex_sampler_image_info_offset); ++ } ++ if (store_adjacent_pixel_texture_descriptor_set) { ++ StoreAdjacentTextureDescriptorSet( ++ false, current_guest_graphics_pipeline_layout_->descriptor_set_layout_textures_pixel_ref(), ++ adjacent_pixel_texture_descriptor_set, texture_count_pixel, sampler_count_pixel, ++ descriptor_write_image_info_.data() + pixel_texture_image_info_offset, ++ descriptor_write_image_info_.data() + pixel_sampler_image_info_offset); ++ } + // Only make valid if all descriptor sets have been allocated and written + // successfully. + current_graphics_descriptor_set_values_up_to_date_ |= write_descriptor_set_bits; +@@ -7499,4 +7591,49 @@ uint32_t VulkanCommandProcessor::WriteTransientTextureBindings( + return descriptor_set_write_count; + } + ++VkDescriptorSet VulkanCommandProcessor::FindAdjacentTextureDescriptorSet( ++ bool is_vertex, VkDescriptorSetLayout descriptor_set_layout, uint32_t texture_count, ++ uint32_t sampler_count, const VkDescriptorImageInfo* texture_image_info, ++ const VkDescriptorImageInfo* sampler_image_info) const { ++ const AdjacentTextureDescriptorSetCacheEntry& entry = ++ adjacent_texture_descriptor_set_cache_[is_vertex ? 0 : 1]; ++ if (!entry.valid || entry.descriptor_set_layout != descriptor_set_layout || ++ entry.texture_infos.size() != texture_count || entry.sampler_infos.size() != sampler_count) { ++ return VK_NULL_HANDLE; ++ } ++ for (uint32_t i = 0; i < texture_count; ++i) { ++ if (entry.texture_infos[i].imageView != texture_image_info[i].imageView || ++ entry.texture_infos[i].imageLayout != texture_image_info[i].imageLayout) { ++ return VK_NULL_HANDLE; ++ } ++ } ++ for (uint32_t i = 0; i < sampler_count; ++i) { ++ if (entry.sampler_infos[i].sampler != sampler_image_info[i].sampler) { ++ return VK_NULL_HANDLE; ++ } ++ } ++ return entry.descriptor_set; ++} ++ ++void VulkanCommandProcessor::StoreAdjacentTextureDescriptorSet( ++ bool is_vertex, VkDescriptorSetLayout descriptor_set_layout, VkDescriptorSet descriptor_set, ++ uint32_t texture_count, uint32_t sampler_count, const VkDescriptorImageInfo* texture_image_info, ++ const VkDescriptorImageInfo* sampler_image_info) { ++ AdjacentTextureDescriptorSetCacheEntry& entry = ++ adjacent_texture_descriptor_set_cache_[is_vertex ? 0 : 1]; ++ entry.descriptor_set_layout = descriptor_set_layout; ++ entry.descriptor_set = descriptor_set; ++ if (texture_count) { ++ entry.texture_infos.assign(texture_image_info, texture_image_info + texture_count); ++ } else { ++ entry.texture_infos.clear(); ++ } ++ if (sampler_count) { ++ entry.sampler_infos.assign(sampler_image_info, sampler_image_info + sampler_count); ++ } else { ++ entry.sampler_infos.clear(); ++ } ++ entry.valid = true; ++} ++ + } // namespace rex::graphics::vulkan diff --git a/patches/rexglue-sdk/0037-reproducible-source-paths.patch b/patches/rexglue-sdk/0037-reproducible-source-paths.patch new file mode 100644 index 0000000..91fc4be --- /dev/null +++ b/patches/rexglue-sdk/0037-reproducible-source-paths.patch @@ -0,0 +1,29 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -80,6 +80,25 @@ if(CMAKE_CXX_COMPILER_VERSION VERSION_LESS "18.0") + message(FATAL_ERROR "ReXGlue requires Clang 18 or newer (found ${CMAKE_CXX_COMPILER_VERSION})") + endif() + ++# Keep build-machine paths out of diagnostics and other __FILE__ strings that ++# remain in stripped release binaries. Normalize first: Clang compares the ++# spelling literally, so a path ending in /build/.. does not match the ++# canonical source path emitted for an included file. ++get_filename_component(REXGLUE_SOURCE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}" REALPATH) ++get_filename_component(REXGLUE_BINARY_ROOT "${CMAKE_CURRENT_BINARY_DIR}" REALPATH) ++if(MSVC) ++ add_compile_options( ++ "/clang:-ffile-prefix-map=${REXGLUE_SOURCE_ROOT}=." ++ "/clang:-ffile-prefix-map=${REXGLUE_BINARY_ROOT}=./build" ++ ) ++ add_link_options("/PDBALTPATH:%_PDB%") ++else() ++ add_compile_options( ++ "-ffile-prefix-map=${REXGLUE_SOURCE_ROOT}=." ++ "-ffile-prefix-map=${REXGLUE_BINARY_ROOT}=./build" ++ ) ++endif() ++ + # Enforce x64 architecture + if(NOT CMAKE_SIZEOF_VOID_P EQUAL 8) + message(FATAL_ERROR "ReXGlue only supports 64‑bit architectures (x86_64 or ARM64)") diff --git a/patches/rexglue-sdk/0038-clang-cl-warning-level.patch b/patches/rexglue-sdk/0038-clang-cl-warning-level.patch new file mode 100644 index 0000000..6b9e51b --- /dev/null +++ b/patches/rexglue-sdk/0038-clang-cl-warning-level.patch @@ -0,0 +1,22 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -230,11 +230,14 @@ if(REXGLUE_BUILD_TESTS) + add_subdirectory(thirdparty) + + # Enable warnings after thirdparty +-add_compile_options( +- -Wall +- -Wextra +-) ++# In clang-cl, GNU-style `-Wall` means the extremely noisy MSVC `/Wall`, not ++# the Clang-driver warning group. `/W4` is the intended clang-cl equivalent of ++# `-Wall -Wextra`. ++if(MSVC) ++ add_compile_options(/W4) ++else() ++ add_compile_options(-Wall -Wextra) ++endif() + + include_directories(${REXGLUE_ROOT}/include) + diff --git a/patches/rexglue-sdk/README.md b/patches/rexglue-sdk/README.md index 2b01aa3..f23c081 100644 --- a/patches/rexglue-sdk/README.md +++ b/patches/rexglue-sdk/README.md @@ -28,9 +28,15 @@ the SDK tree until the entire series is present. real Windows hardware. - `0015`, `0017`-`0020`, `0027`: bound or block polling paths that otherwise burn CPU while waiting. -- `0024`-`0026`, `0029`-`0034`: the accepted graphics residency, reuse, and - verification work used by the v0.2.0 performance preset. +- `0024`-`0026`, `0029`-`0034`, `0036`: the accepted graphics residency and + reuse work used by the current performance preset. `0033` and `0035` add + default-off draw-census capture used to verify renderer changes; neither is + active during normal play. - `0028`: developer-console launch commands used by repeatable smoke fixtures. +- `0037`: removes build-machine source, binary and PDB directory prefixes from + diagnostic strings that remain in stripped release binaries. +- `0038`: uses clang-cl's `/W4` warning level instead of its pathological + `-Wall`/`/Wall` interpretation during Windows cross-builds. Every new patch must be listed exactly once in `series`. `tools/check_patch_series.sh` and CI check both directions: an unlisted patch diff --git a/patches/rexglue-sdk/series b/patches/rexglue-sdk/series index 593fd0b..d7fc3ab 100644 --- a/patches/rexglue-sdk/series +++ b/patches/rexglue-sdk/series @@ -31,3 +31,7 @@ mmio_handler.patch 0032-exact-texture-request-reuse.patch 0033-draw-census.patch 0034-exact-last-view-reuse.patch +0035-draw-census-sequences.patch +0036-adjacent-texture-descriptor-set-reuse.patch +0037-reproducible-source-paths.patch +0038-clang-cl-warning-level.patch diff --git a/src/game/CMakeLists.txt b/src/game/CMakeLists.txt index 975e9ca..fbcb27d 100644 --- a/src/game/CMakeLists.txt +++ b/src/game/CMakeLists.txt @@ -63,17 +63,48 @@ rexglue_setup_target(thps_p8) # clang-cl ignores the GNU spelling silently - it warns and carries on - so the # Windows build needs the /clang: passthrough or it quietly keeps leaking. function(thps_p8_anonymise_paths target) + # Resolve `..` before passing the mapping to Clang. The compiler compares + # path spellings literally, so `${CMAKE_CURRENT_SOURCE_DIR}/..` does not + # match the canonical path it writes into __FILE__ strings. + get_filename_component(_thps_source_root + "${CMAKE_CURRENT_SOURCE_DIR}/.." REALPATH) + get_filename_component(_thps_binary_root + "${CMAKE_CURRENT_BINARY_DIR}" REALPATH) + + set(_thps_path_roots + "${_thps_source_root}=." + "${_thps_binary_root}=./build") + + # Headers and the installed entrypoint sources also contribute __FILE__ + # strings. Cover either supported SDK mode: an in-tree SDK for Windows, or + # an installed prefix for portable Linux builds. + if(REXSDK_DIR) + get_filename_component(_thps_sdk_root "${REXSDK_DIR}" REALPATH) + list(APPEND _thps_path_roots "${_thps_sdk_root}=./sdk") + elseif(rexglue_DIR) + get_filename_component(_thps_sdk_root + "${rexglue_DIR}/../../.." REALPATH) + list(APPEND _thps_path_roots "${_thps_sdk_root}=./sdk") + endif() + + set(_thps_path_options) + foreach(_thps_mapping IN LISTS _thps_path_roots) + if(MSVC) + list(APPEND _thps_path_options + "/clang:-ffile-prefix-map=${_thps_mapping}") + else() + list(APPEND _thps_path_options + "-ffile-prefix-map=${_thps_mapping}") + endif() + endforeach() + if(MSVC) - target_compile_options(${target} PRIVATE - "/clang:-ffile-prefix-map=$ENV{HOME}=." - "/clang:-ffile-prefix-map=${CMAKE_CURRENT_SOURCE_DIR}/..=.") + target_compile_options(${target} PRIVATE ${_thps_path_options}) # A PE debug directory otherwise records the absolute build-tree path # to the external PDB even though the PDB itself is not distributed. target_link_options(${target} PRIVATE "/PDBALTPATH:%_PDB%") else() - target_compile_options(${target} PRIVATE - "-ffile-prefix-map=$ENV{HOME}=." - "-ffile-prefix-map=${CMAKE_CURRENT_SOURCE_DIR}/..=.") + target_compile_options(${target} PRIVATE ${_thps_path_options}) endif() endfunction() diff --git a/src/game/src/console_input_guard.h b/src/game/src/console_input_guard.h index f5b528b..593d1f6 100644 --- a/src/game/src/console_input_guard.h +++ b/src/game/src/console_input_guard.h @@ -14,8 +14,8 @@ // window. Typing is a keyboard activity, so with the pointer anywhere else - // which is where it naturally sits while both hands are on the keys - every // character typed into the console is ALSO delivered to `MnkInputDriver` and -// therefore to the guest pad. This workspace always runs `--mnk_mode=true` -// (the Makefile forces it; the manifest calls it required), so in practice +// therefore to the guest pad. This title enables `--mnk_mode=true` in its +// normal launch configuration, so in practice // typing `cheat_warp 100 0 250` in Free Skate makes the skater jump, grind and // bail while you type it. // diff --git a/src/game/src/guest_call.h b/src/game/src/guest_call.h index 5c30de7..2d61bad 100644 --- a/src/game/src/guest_call.h +++ b/src/game/src/guest_call.h @@ -10,9 +10,8 @@ // A console command runs on the UI thread, which has no guest thread state. So // a cheat implemented as a direct call from a console callback does not fail - // it silently succeeds at doing nothing, which is the single worst failure mode -// available to us. `prove-the-mechanism-engaged` is a standing rule in this -// workspace precisely because "no effect" and "not applied" are indistinguish- -// able after the fact. +// available to us. Counters are required here because "no effect" and "not +// applied" are otherwise indistinguishable after the fact. // // So: console commands POST closures here, and a disconnected input driver // DRAINS them after the title polls its real/scripted pads. Every post is counted and diff --git a/src/game/src/guest_ring_wait.h b/src/game/src/guest_ring_wait.h index de71d37..14c57b3 100644 --- a/src/game/src/guest_ring_wait.h +++ b/src/game/src/guest_ring_wait.h @@ -104,6 +104,10 @@ inline void BackOff() { } // namespace thps::ring_wait +// Generated guest functions expose weak ELF aliases for title hooks. Mach-O +// has no equivalent C/C++ alias attribute, so Apple retains the generated +// functions while these cvars remain available to the shared launcher preset. +#if !defined(__APPLE__) REX_HOOK_RAW(sub_8235DF50) { REX_FUNC_PROLOGUE(); uint32_t ea{}; @@ -252,3 +256,4 @@ REX_HOOK_RAW(sub_8235E068) { thps::ring_wait::g_produced.notify_one(); } } +#endif diff --git a/src/game/src/guest_swap_wait.h b/src/game/src/guest_swap_wait.h index b82edd1..8fddea9 100644 --- a/src/game/src/guest_swap_wait.h +++ b/src/game/src/guest_swap_wait.h @@ -73,6 +73,10 @@ inline void RecordWait(uint64_t elapsed_us, bool reached) { } // namespace thps::swap_wait +// Generated guest functions expose weak ELF aliases for title hooks. Mach-O +// has no equivalent C/C++ alias attribute, so Apple retains the generated +// functions while these cvars remain available to the shared launcher preset. +#if !defined(__APPLE__) REX_HOOK_RAW(sub_82397688) { REX_FUNC_PROLOGUE(); @@ -129,3 +133,4 @@ REX_HOOK_RAW(sub_823975B8) { } } +#endif diff --git a/src/game/src/guest_u8x4_unpack.h b/src/game/src/guest_u8x4_unpack.h index d624a78..83ebe93 100644 --- a/src/game/src/guest_u8x4_unpack.h +++ b/src/game/src/guest_u8x4_unpack.h @@ -65,6 +65,10 @@ inline void MaybeLog(uint64_t calls) { } // namespace thps::u8x4_unpack +// Generated guest functions expose their original bodies through weak ELF +// aliases. Mach-O has no equivalent C/C++ alias attribute, so retain the +// generated function on Apple while keeping the cvars parseable. +#if !defined(__APPLE__) REX_HOOK_RAW(sub_82354398) { REX_FUNC_PROLOGUE(); @@ -128,3 +132,4 @@ REX_HOOK_RAW(sub_82354398) { thps::u8x4_unpack::MaybeLog(calls); } } +#endif diff --git a/src/game/src/guest_vertex_unpack.h b/src/game/src/guest_vertex_unpack.h index c038500..50b5ba0 100644 --- a/src/game/src/guest_vertex_unpack.h +++ b/src/game/src/guest_vertex_unpack.h @@ -294,6 +294,10 @@ inline void MaybeLog(uint64_t calls) { } // namespace thps::vertex_unpack +// Generated guest functions expose their original bodies through weak ELF +// aliases. Mach-O has no equivalent C/C++ alias attribute, so retain the +// generated function on Apple while keeping the cvars parseable. +#if !defined(__APPLE__) REX_HOOK_RAW(sub_82354BE0) { REX_FUNC_PROLOGUE(); @@ -398,6 +402,7 @@ REX_HOOK_RAW(sub_82354BE0) { thps::vertex_unpack::MaybeLog(calls); } } +#endif #undef THPS_VERTEX_UNPACK_X86_SIMD #undef THPS_VERTEX_UNPACK_SIMD_TARGET diff --git a/src/game/src/launcher/main.cpp b/src/game/src/launcher/main.cpp index 883d319..ca61d9e 100644 --- a/src/game/src/launcher/main.cpp +++ b/src/game/src/launcher/main.cpp @@ -329,6 +329,21 @@ int RunChild(const std::string& game, const std::vector& args) { return 127; } if (pid == 0) { +#if defined(__APPLE__) + // The portable Mac archive carries its Vulkan loader and MoltenVK beside + // the game. A GUI launch does not inherit Homebrew's shell environment, so + // point the child at those packaged files before dyld and the loader run. + const std::string game_dir = DirectoryOf(game); + std::string library_path = game_dir; + if (const char* inherited = std::getenv("DYLD_LIBRARY_PATH"); + inherited && *inherited) { + library_path += ":"; + library_path += inherited; + } + const std::string driver_manifest = game_dir + "/MoltenVK_icd.json"; + ::setenv("DYLD_LIBRARY_PATH", library_path.c_str(), 1); + ::setenv("VK_ICD_FILENAMES", driver_manifest.c_str(), 1); +#endif ::execv(game.c_str(), argv.data()); std::fprintf(stderr, "thps_p8_launch: cannot exec %s: %s\n", game.c_str(), std::strerror(errno)); @@ -371,8 +386,8 @@ int RunChild(const std::string& game, const std::vector& args) { // stdout only, and only under --check. Everything the text path prints keeps // going to stderr byte-for-byte, so adding --json cannot change what a human or // an existing script sees; the two streams carry the same facts in two shapes. -// The envelope matches tools/xex-tools' --json convention so the workspace has -// one, not two. +// Keep one stable envelope for every machine-readable disc-check result rather +// than inventing a second shape for this entry point. std::string JsonEscape(const std::string& in) { std::string out; diff --git a/src/game/src/launcher/platform.h b/src/game/src/launcher/platform.h index 4e370c6..588efff 100644 --- a/src/game/src/launcher/platform.h +++ b/src/game/src/launcher/platform.h @@ -29,7 +29,7 @@ // TESTED: Linux and macOS. The Apple path was built natively on arm64 and used // for the attract, Free Skate and career fixture launches on 2026-08-02. // Windows remains written against documented APIs and compile-guarded, but has -// not been built or run in this workspace. +// not been exercised on real Windows hardware. #pragma once diff --git a/src/launcher/assets/ui/first_run.rml b/src/launcher/assets/ui/first_run.rml index 97f3cbc..a2a6255 100644 --- a/src/launcher/assets/ui/first_run.rml +++ b/src/launcher/assets/ui/first_run.rml @@ -17,7 +17,7 @@

Set up your game

-

Choose your disc image and this will do the rest.

+

Choose the disc image made from your own copy.

@@ -62,8 +62,8 @@ diff --git a/src/launcher/assets/ui/home.rml b/src/launcher/assets/ui/home.rml index 9c364c7..bf3acc1 100644 --- a/src/launcher/assets/ui/home.rml +++ b/src/launcher/assets/ui/home.rml @@ -9,11 +9,9 @@

TONY HAWK'S
- PROJECT 8

-

Xbox 360, 2006 — running natively on this machine.

+

Xbox 360, 2006. Running natively on this machine.

@@ -28,12 +26,13 @@ the status as a sentence, and repeating it as a heading says the same thing twice in two type sizes. SetText("heading") simply finds nothing, which is the intended no-op. --> -

Your game is set up. Nothing else needs doing.

+

Your game is set up on this machine.

+
@@ -59,7 +58,7 @@
- Window + Display mode

Performance

- Enhancements + Performance preset
@@ -96,8 +95,8 @@

A native port of the Xbox 360 release, built by static recompilation. - It includes no original game files: everything it plays came from - the disc image you supplied. + It contains no game content: everything it plays came from the disc + image you supplied.

Not affiliated with, endorsed by, or connected to Activision, @@ -105,10 +104,10 @@ respective owners.

@@ -116,11 +115,11 @@
- +
- +
@@ -128,8 +127,8 @@
diff --git a/src/launcher/src/launcher_app.cpp b/src/launcher/src/launcher_app.cpp index bac34d6..5df8e95 100644 --- a/src/launcher/src/launcher_app.cpp +++ b/src/launcher/src/launcher_app.cpp @@ -13,6 +13,7 @@ #include #include +#include namespace thps { namespace { @@ -62,8 +63,8 @@ Copy CopyFor(State s) { switch (s) { case State::kWelcome: return {"Set up your game", - "Choose your Tony Hawk's Project 8 disc image and this will do the rest. " - "It stays where it is - nothing is moved, changed or uploaded."}; + "Choose the disc image made from your own Tony Hawk's Project 8 copy. " + "The image stays where it is. Nothing is moved, changed, or uploaded."}; case State::kPicking: return {"Choose your disc image", "Pick the file you made from your own disc."}; case State::kVerifying: @@ -76,11 +77,11 @@ Copy CopyFor(State s) { case State::kFailNotDisc: return {"That file is not a game disc", "It does not look like a disc image. Choose the file you made from your " - "Tony Hawk's Project 8 disc - usually a .iso file."}; + "Tony Hawk's Project 8 disc, usually an .iso file."}; case State::kFailWrongBuild: return {"That is a different release", "This is Tony Hawk's Project 8, but a different release of it than this port was " - "built from. Only the release listed on the previous screen will work."}; + "built from. Only the supported 2006 retail disc works with this build."}; case State::kFailNotGame: return {"That is a different game", "The disc image opened, but it is not Tony Hawk's Project 8."}; @@ -99,24 +100,25 @@ Copy CopyFor(State s) { case State::kCancelled: return {"Setup stopped", "The copy was stopped and the partly-copied files have been removed. " - "Nothing was left behind."}; + "No partial game files remain."}; case State::kReady: - return {"Ready to play", "Your game is set up. Nothing else needs doing."}; + return {"Ready to play", "Your game is set up on this machine."}; case State::kStarting: - return {"Starting", "Handing over to the game."}; + return {"Starting", "Opening the game."}; case State::kDebris: // "orphaned shared-memory segment" is exactly the vocabulary to avoid. // What a player needs to know is that a past crash left memory behind and // one button gets it back. return {"Some memory needs freeing", - "A previous session ended badly and left memory in use. Free it and you can play."}; + "A previous session stopped unexpectedly and left temporary game memory in use. " + "Free it before starting again."}; case State::kBlocked: return {"The game is already running", "Close the running copy first, or stop it from here."}; case State::kSettings: - return {"Settings", "These apply the next time you press Play."}; + return {"Settings", "Changes apply the next time you press Play."}; case State::kAbout: - return {"About", "What this is, and what it is built from."}; + return {"About", "About this port and its third-party software."}; } return {"", ""}; } @@ -486,7 +488,10 @@ void LauncherApp::ApplyStateClass() { void LauncherApp::SetText(const char* element_id, const std::string& text) { if (!active_) return; if (Rml::Element* e = active_->GetElementById(element_id)) { - e->SetInnerRML(text); + // Paths and display names are untrusted text. SetInnerRML parses markup, + // so escape every value before inserting it; a filename containing '<' or + // '&' must remain visible text rather than becoming a broken RML element. + e->SetInnerRML(Rml::StringUtilities::EncodeRml(text)); } } @@ -668,7 +673,7 @@ void LauncherApp::Poll() { } char text[160]; if (extract_total_.load() > 0) { - std::snprintf(text, sizeof(text), "%d%% - %s of %s", int(fraction * 100.0), + std::snprintf(text, sizeof(text), "%d%% (%s of %s)", int(fraction * 100.0), HumanBytes(extract_done_.load()).c_str(), HumanBytes(extract_total_.load()).c_str()); } else { @@ -703,7 +708,9 @@ void LauncherApp::OnAction(const Rml::String& id) { ShowScreen(Screen::kHome); RefreshHomeState(); } else if (id == "folder") { - OpenSaveFolder(); + OpenPortableFolder("saves"); + } else if (id == "logs") { + OpenPortableFolder("logs"); } else if (id == "cancel_extract") { CancelExtract(); } else if (id == "reclaim") { @@ -1033,12 +1040,12 @@ void LauncherApp::ReadLastRun() { : "Your last session did not finish cleanly. You can play again from here."; } -void LauncherApp::OpenSaveFolder() const { +void LauncherApp::OpenPortableFolder(const char* folder) const { std::error_code ec; - const std::filesystem::path saves = portable_dir_ / "saves"; - std::filesystem::create_directories(saves, ec); - if (!platform::OpenFolder(saves)) { - std::fprintf(stderr, "thps_p8_gui: could not open %s\n", saves.string().c_str()); + const std::filesystem::path path = portable_dir_ / folder; + std::filesystem::create_directories(path, ec); + if (!platform::OpenFolder(path)) { + std::fprintf(stderr, "thps_p8_gui: could not open %s\n", path.string().c_str()); } } diff --git a/src/launcher/src/launcher_app.h b/src/launcher/src/launcher_app.h index 6c21bf9..cedefd3 100644 --- a/src/launcher/src/launcher_app.h +++ b/src/launcher/src/launcher_app.h @@ -137,7 +137,7 @@ class LauncherApp { CheckResult RunCheck() const; // Re-runs --check and moves home to ready / debris / blocked accordingly. void RefreshHomeState(); - void OpenSaveFolder() const; + void OpenPortableFolder(const char* folder) const; // Reads the breadcrumb the previous run left, if any. void ReadLastRun(); diff --git a/src/launcher/src/platform.h b/src/launcher/src/platform.h index 5673906..197679d 100644 --- a/src/launcher/src/platform.h +++ b/src/launcher/src/platform.h @@ -116,8 +116,8 @@ bool OpenFolder(const std::filesystem::path& path); // // Replace, not spawn: the launcher is one-shot and never comes back, so there // is nothing for it to wait for, and replacing the image means no orphaned -// parent is left holding the window's GPU file descriptors while the game runs -// while the game runs. Windows has no execve worth the name, so that side +// parent is left holding the window's GPU file descriptors while the game +// runs. Windows has no execve worth the name, so that side // spawns and exits - documented at the implementation, because it is a real // behavioural difference and the fd-inheritance question has to be answered // differently there. diff --git a/src/launcher/src/platform_posix.inc b/src/launcher/src/platform_posix.inc index 93072cd..b35f426 100644 --- a/src/launcher/src/platform_posix.inc +++ b/src/launcher/src/platform_posix.inc @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -167,6 +168,27 @@ bool OpenFolder(const std::filesystem::path& path) { } [[noreturn]] void ExecReplace(const std::vector& argv) { +#if defined(__linux__) + // SteamOS installs RenderDoc's loader system-wide. The runtime probes for + // that library by name, so merely having the developer tool installed makes + // every player launch attach it and pay its Vulkan-hook overhead. Release + // archives carry a tiny library with no RENDERDOC_GetAPI in no-renderdoc/; + // put it first for every supported launcher path. Developers can opt back + // into the system library explicitly when they actually want a capture. + if (const char* allow = std::getenv("THPS_P8_RENDERDOC"); + !allow || std::strcmp(allow, "1") != 0) { + const std::filesystem::path shadow = SelfDir() / "no-renderdoc"; + if (std::filesystem::is_regular_file(shadow / "librenderdoc.so")) { + std::string library_path = shadow.string(); + if (const char* inherited = std::getenv("LD_LIBRARY_PATH"); + inherited && *inherited) { + library_path += ":"; + library_path += inherited; + } + ::setenv("LD_LIBRARY_PATH", library_path.c_str(), 1); + } + } +#endif std::vector raw; raw.reserve(argv.size() + 1); for (const std::string& arg : argv) raw.push_back(const_cast(arg.c_str())); diff --git a/src/launcher/src/settings.cpp b/src/launcher/src/settings.cpp index e738c5c..5fb9969 100644 --- a/src/launcher/src/settings.cpp +++ b/src/launcher/src/settings.cpp @@ -83,15 +83,16 @@ const std::vector>& Settings::PerformanceFla // wave before the render pass; adjacent sampler reuse avoids redundant // same-submission Vulkan sampler lookups; exact texture-request reuse skips // an unchanged binding and image-usage walk; exact last-view reuse bypasses - // the Vulkan view-key map for an immediately repeated request; the timer - // queue blocks between deadlines instead of yield-spinning; the guest + // the Vulkan view-key map for an immediately repeated request; adjacent + // descriptor-set reuse skips an exact repeated stage allocation and write; + // the timer queue blocks between deadlines instead of yield-spinning; the guest // empty-ring wait backs off // after a bounded poll interval or blocks on its exact producer event; the // render thread blocks on the actual swap-complete counter producer; the // title's hot u8x4 render-preparation unpack bypasses the full guest vector // register model; the title's full vertex-format record unpack does the same // for the fixed 252-byte-to-496-byte transform, and its x86 SIMD path keeps - // the exact transform while vectorizing the packed decodes. All sixteen + // the exact transform while vectorizing the packed decodes. All seventeen // default OFF in the runtime because they are project patches rather than // upstream behaviour - which is exactly why the launcher has to ask for them. static const std::vector> flags = { @@ -104,6 +105,7 @@ const std::vector>& Settings::PerformanceFla {"gpu_sampler_set_reuse", "true"}, {"gpu_texture_request_reuse", "true"}, {"gpu_texture_last_view_cache", "true"}, + {"gpu_texture_descriptor_set_adjacent_reuse", "true"}, {"timer_queue_blocking_wait", "true"}, {"guest_ring_wait_backoff", "true"}, {"guest_ring_wait_event", "true"}, diff --git a/src/launcher/tests/test_settings.cpp b/src/launcher/tests/test_settings.cpp index 18602e3..da437f1 100644 --- a/src/launcher/tests/test_settings.cpp +++ b/src/launcher/tests/test_settings.cpp @@ -215,6 +215,8 @@ void TestPerformanceIsOnUnlessTurnedOff() { "unset performance still enables exact texture-request reuse"); Check(Has(unset, "--gpu_texture_last_view_cache=true"), "unset performance still enables exact last-view reuse"); + Check(Has(unset, "--gpu_texture_descriptor_set_adjacent_reuse=true"), + "unset performance still enables adjacent descriptor-set reuse"); Check(Has(unset, "--timer_queue_blocking_wait=true"), "unset performance still blocks the idle timer queue"); Check(Has(unset, "--guest_ring_wait_backoff=true"), diff --git a/tools/check_gpl_boundary.sh b/tools/check_gpl_boundary.sh index da84609..bf24e0f 100755 --- a/tools/check_gpl_boundary.sh +++ b/tools/check_gpl_boundary.sh @@ -2,7 +2,7 @@ # Verify no shipped binary links GPL binutils. # # The recompiler uses binutils to disassemble; the runtime must not. If this -# fails, fix the link line — do not relicense around it. +# fails, fix the link line; do not relicense around it. # # tools/check_gpl_boundary.sh [more...] diff --git a/tools/package_release.sh b/tools/package_release.sh index b1b93df..92e0c72 100755 --- a/tools/package_release.sh +++ b/tools/package_release.sh @@ -4,7 +4,7 @@ # This is the release artifact: unpack, run the launcher, point it at your own # disc image, play. It carries the runtime and no files copied from the disc. # -# tools/package_release.sh --platform linux-x86_64 --version v0.2.0 \ +# tools/package_release.sh --platform linux-x86_64 --version vX.Y.Z \ # --game --launcher \ # --identify --sdk # @@ -168,6 +168,52 @@ if [ "$PLATFORM" = "windows-x86_64" ]; then done fi +if [ "$PLATFORM" = "linux-x86_64" ]; then + # SteamOS carries librenderdoc.so as part of the base system. The runtime + # probes for it by name and otherwise attaches the debugger to ordinary + # player launches. Ship a dependency-free shadow that loads successfully but + # exports no RENDERDOC_GetAPI; the launcher puts this directory first unless + # THPS_P8_RENDERDOC=1 was requested explicitly. + compiler="${CC:-}" + if [ -z "$compiler" ]; then + compiler="$(command -v clang || command -v cc || command -v gcc || true)" + fi + [ -n "$compiler" ] || { + echo "a C compiler is required to build the SteamOS RenderDoc guard" >&2 + exit 1 + } + mkdir -p "$STAGE/no-renderdoc" + printf '%s\n' 'void thps_p8_renderdoc_stub(void) {}' | \ + "$compiler" -x c -shared -nostdlib -fPIC -o \ + "$STAGE/no-renderdoc/librenderdoc.so" - +fi + +if [ "$PLATFORM" = "macos-arm64" ]; then + # Vulkan is not a macOS system API. v0.1.0 happened to work on the build Mac + # because Homebrew supplied both the loader and MoltenVK; its archive did not + # carry either one. Stage them explicitly so a player needs no package + # manager, then let the supervisor select this adjacent manifest for the game + # child even when it was opened from Finder. + command -v brew >/dev/null 2>&1 || { + echo "Homebrew is required to locate the macOS Vulkan release libraries" >&2 + exit 1 + } + vulkan_loader="$(brew --prefix vulkan-loader)/lib/libvulkan.1.dylib" + moltenvk="$(brew --prefix molten-vk)/lib/libMoltenVK.dylib" + copy_one "$vulkan_loader" "the macOS Vulkan loader" + copy_one "$moltenvk" "MoltenVK" + cat > "$STAGE/MoltenVK_icd.json" <<'EOF' +{ + "file_format_version": "1.0.0", + "ICD": { + "library_path": "libMoltenVK.dylib", + "api_version": "1.4.0", + "is_portability_driver": true + } +} +EOF +fi + # macOS links its dependencies by absolute path, so a Homebrew-built launcher # names /opt/homebrew/... and runs only on a machine with the same Homebrew # packages installed. Copy the graph in and rewrite the paths. @@ -175,6 +221,7 @@ if [ "${PLATFORM#macos}" != "$PLATFORM" ]; then say "bundling macOS dependencies" bash "$HERE/tools/ci/bundle_macos.sh" "$STAGE" \ "$STAGE/thps_p8_gui" "$STAGE/thps_p8" "$STAGE/thps_p8_identify" "$STAGE/thps_p8_launch" \ + "$STAGE/libvulkan.1.dylib" "$STAGE/libMoltenVK.dylib" \ > /dev/null # An absolute Homebrew or local build path left in any binary means the # archive works here and nowhere else - the exact failure this step exists to @@ -230,6 +277,15 @@ Licences for everything this is built from are in NOTICE. Full documentation: https://github.com/theokyr/Project8Recomp EOF +if [ "$PLATFORM" = "macos-arm64" ]; then + cat >> "$STAGE/README.txt" <<'EOF' + +macOS: Homebrew is not required. The binaries are ad-hoc signed but not +notarized, so on first launch right-click Project8Recomp, choose Open, then +confirm Open. +EOF +fi + say "stripping debug symbols" # Consistency, not just size. Debug info is inline in ELF and external on the # other two (.pdb, .dSYM), so shipping "the same build" three ways otherwise @@ -245,6 +301,15 @@ case "$PLATFORM" in strip -x "$f" 2>/dev/null || true fi done + # install_name_tool and strip both invalidate Mach-O's linker signature. + # An unsigned local binary may run, but an invalid signature is killed by + # the kernel before main. Re-sign every mutated executable and dylib ad hoc; + # this provides integrity without pretending the release is notarized. + for f in "$STAGE"/Project8Recomp "$STAGE"/thps_p8 "$STAGE"/thps_p8_* "$STAGE"/*.dylib; do + if [ -f "$f" ]; then + codesign --force --sign - --timestamp=none "$f" + fi + done ;; windows*) # Debug info already lives in the .pdb files, which are not staged. The @@ -319,9 +384,28 @@ for f in "$STAGE"/*; do IFS=$old_ifs ;; esac - if strings "$f" 2>/dev/null | grep -qE "$(printf '/home/[a-z]|/Users/[a-z]')"; then - echo "!! $(basename "$f") contains an absolute home-directory path" >&2 - strings "$f" 2>/dev/null | grep -oE "(/home|/Users)/[A-Za-z0-9._-]+[^\"' ]*" | sort -u | head -3 >&2 + # __FILE__ strings survive debug stripping. Check the common Unix build + # roots plus the hosted Windows runner spelling (both ASCII and UTF-16LE). + # Project source names and diagnostics may remain; the directory that held + # them on the builder may not. + build_path_pattern='^[A-Za-z]:\\(a|actions|builds|Users|src|tmp)\\|^/(home|Users|src|sdkprefix|builds|__w)/|^/tmp/(thp8|rexglue|project8)' + # Microsoft's app-local VC runtime carries Microsoft's own fixed build PDB + # prefix. It identifies no player, maintainer, repository, or runner. Keep + # rejecting every other Actions path, including one emitted by our builds. + microsoft_crt_pdb='^[A-Za-z]:\\a\\_work\\1\\s\\binaries\\amd64ret\\bin\\amd64' + build_paths="$(strings "$f" 2>/dev/null | grep -E "$build_path_pattern" \ + | grep -Ev '^/tmp/(%sXXXXXX|rexglue-memory-XXXXXX)$' \ + | grep -Ev "$microsoft_crt_pdb" || true)" + case "$(file -bL "$f" 2>/dev/null)" in + *PE32*) + build_paths+=$'\n'"$(strings -el "$f" 2>/dev/null | grep -E "$build_path_pattern" \ + | grep -Ev '^/tmp/(%sXXXXXX|rexglue-memory-XXXXXX)$' \ + | grep -Ev "$microsoft_crt_pdb" || true)" + ;; + esac + if [ -n "${build_paths//$'\n'/}" ]; then + echo "!! $(basename "$f") contains an absolute build-directory path" >&2 + printf '%s\n' "$build_paths" | sed '/^$/d' | sort -u | head -3 >&2 leaked=1 fi done