diff --git a/.github/workflows/windows-offline-build.yml b/.github/workflows/windows-offline-build.yml index 267b5c1..6ef1bf1 100644 --- a/.github/workflows/windows-offline-build.yml +++ b/.github/workflows/windows-offline-build.yml @@ -84,10 +84,11 @@ jobs: shell: msys2 {0} run: | set -e - ninja -C out/gpu pipeline-cache-blob-test pipeline-archive-test motion-history-test motion-shader-test image-overlap-test settings-test ui-composition-test engine-hooks-test engine-menu-memory-test rebirth-refund-test pc-input-test widescreen-test + ninja -C out/gpu pipeline-cache-blob-test pipeline-archive-test motion-history-test occlusion-test motion-shader-test image-overlap-test settings-test ui-composition-test engine-hooks-test engine-menu-memory-test rebirth-refund-test pc-input-test widescreen-test out/gpu/pipeline-cache-blob-test.exe out/gpu/pipeline-archive-test.exe out/gpu/motion-history-test.exe + out/gpu/occlusion-test.exe out/gpu/motion-shader-test.exe out/gpu/image-overlap-test.exe out/gpu/settings-test.exe diff --git a/README.md b/README.md index 60ef60b..ff4c629 100644 --- a/README.md +++ b/README.md @@ -420,10 +420,10 @@ covers file system and time zone details. With libc++ on Windows, `std::thread:: | `patches/` | Community patches for Bloodborne | | `tools/` | Developer tools: scripted runs, A/B toggles, FSR benchmark helpers, FSR 4 shader rewrites, `fsr4cap` (FSR 4.1.1 recording/extraction) | | `tests/` | Loader, runtime, patch and renderer tests | -| `docs/` | Design notes and measurements ([upscaler](docs/upscaler.md), [parallel GPU](docs/parallel_gpu.md), [motion vectors](docs/motion_vectors.md), [roadmap](docs/ROADMAP.md)) | +| `docs/` | Design notes and measurements ([upscaler](docs/upscaler.md), [HDR output](docs/hdr.md), [parallel GPU](docs/parallel_gpu.md), [motion vectors](docs/motion_vectors.md), [roadmap](docs/ROADMAP.md)) | Tests: `bash build.sh --test`, `python3 -m unittest discover -s tests`, and -`ninja -C out/gpu motion-history-test ui-composition-test scene-resolution-test motion-shader-test settings-test`. +`ninja -C out/gpu motion-history-test ui-composition-test scene-resolution-test motion-shader-test settings-test hdr-native-shader-test`. ## Roadmap diff --git a/README.ru.md b/README.ru.md index 47c5612..174a9e9 100644 --- a/README.ru.md +++ b/README.ru.md @@ -345,10 +345,10 @@ VRAM, GTT, RSS, текстуры и блоки памяти игры в VRAM), ` | `patches/` | Патчи сообщества для Bloodborne | | `tools/` | Утилиты разработчика: скриптовые запуски, A/B-переключатели, бенчмарк FSR, переписывание шейдеров FSR 4, `fsr4cap` (запись и извлечение FSR 4.1.1) | | `tests/` | Тесты загрузчика, рантайма, патчей и рендерера | -| `docs/` | Заметки и замеры ([апскейлер](docs/upscaler.md), [многопоточный GPU](docs/parallel_gpu.md), [векторы движения](docs/motion_vectors.md), [план](docs/ROADMAP.md), [журнал разработки](docs/DEVELOPMENT_LOG.ru.md)) | +| `docs/` | Заметки и замеры ([апскейлер](docs/upscaler.md), [вывод HDR](docs/hdr.md), [многопоточный GPU](docs/parallel_gpu.md), [векторы движения](docs/motion_vectors.md), [план](docs/ROADMAP.md), [журнал разработки](docs/DEVELOPMENT_LOG.ru.md)) | Тесты: `bash build.sh --test`, `python3 -m unittest discover -s tests` и -`ninja -C out/gpu motion-history-test ui-composition-test scene-resolution-test motion-shader-test settings-test`. +`ninja -C out/gpu motion-history-test ui-composition-test scene-resolution-test motion-shader-test settings-test hdr-native-shader-test`. ## Что будет дальше diff --git a/build.sh b/build.sh index 7a41a21..858148e 100644 --- a/build.sh +++ b/build.sh @@ -35,14 +35,32 @@ if [[ -n $windows ]]; then libraries=("${libraries[@]/-mwindows/-mconsole}") cstd=(-std=gnu11 -D_FILE_OFFSET_BITS=64 -D_WIN32_WINNT=0x0A00) fi +# BB_JOBS=N: at most N compile and link-time optimization jobs (less memory, e.g. while the +# game runs). # GPU library (shadPS4 video core + drivers), built by CMake into out/gpu/libbbgpu.so. # BB_PGO: generate (instrumented build that writes pgo/ while the game runs), use, off. # Default: use the profile in pgo/ when there is one. BB_LTO=OFF disables link-time optimization. +# Clang (Windows CLANG64) writes pgo/default_*.profraw: merged here into pgo/default.profdata, +# which the "use" build reads (GCC: pgo/*.gcda, read as they are). +mkdir -p pgo +if [[ ${BB_PGO:-} != generate && -n $(find pgo -name '*.profraw' -print -quit 2>/dev/null) && + ( ! -f pgo/default.profdata || -n $(find pgo -name '*.profraw' -newer pgo/default.profdata -print -quit) ) ]]; then + profdata=$(command -v llvm-profdata || true) + if [[ -n $profdata ]]; then + "$profdata" merge -o pgo/default.profdata pgo/*.profraw + echo "PGO: merged $(find pgo -name '*.profraw' | wc -l) raw profile(s) into pgo/default.profdata" + else + echo 'PGO: pgo/*.profraw found but no llvm-profdata in PATH; not merged' >&2 + fi +fi pgo=${BB_PGO:-} if [[ -z $pgo ]]; then - if [[ -n $(find pgo -name '*.gcda' -print -quit 2>/dev/null) ]]; then pgo=use; else pgo=off; fi + if [[ -n $(find pgo \( -name '*.gcda' -o -name 'default.profdata' \) -print -quit 2>/dev/null) ]]; then + pgo=use + else + pgo=off + fi fi -mkdir -p pgo # Submodules (git clone --recursive, or: git submodule update --init) and this port's changes # to FSR-Vulkan (gpu/patches/fsr-vulkan), applied to its working tree once. if [[ ! -f gpu/third_party/fsr-vulkan/CMakeLists.txt || ! -f gpu/third_party/imgui/imgui.h ]]; then @@ -53,11 +71,20 @@ for patch in gpu/patches/fsr-vulkan/*.patch; do git -C gpu/third_party/fsr-vulkan apply "$PWD/$patch" fi done -cmake -S gpu -B out/gpu -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DBB_PGO="$pgo" \ - -DBB_LTO="${BB_LTO:-ON}" -DBB_PGO_DIR="$PWD/pgo" >/dev/null -echo "GPU library: PGO $pgo, LTO ${BB_LTO:-ON}" +# Line tables only (not full debug info): crash offsets still resolve to function and +# file:line with llvm-symbolizer, and compiling and linking take much less time. +gpu_config=(-G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DBB_PGO="$pgo" -DBB_LTO="${BB_LTO:-ON}" + -DBB_PGO_DIR="$PWD/pgo" -DBB_MARCH="${BB_MARCH:-}" + "-DCMAKE_C_FLAGS_RELWITHDEBINFO=-O2 -gline-tables-only -DNDEBUG" + "-DCMAKE_CXX_FLAGS_RELWITHDEBINFO=-O2 -gline-tables-only -DNDEBUG") +# Configured once per set of options (ninja itself reruns CMake when a CMakeLists.txt changes). +if [[ ! -f out/gpu/build.ninja || $(cat out/gpu/bb-configure.txt 2>/dev/null) != "${gpu_config[*]}" ]]; then + cmake -S gpu -B out/gpu "${gpu_config[@]}" >/dev/null + printf '%s' "${gpu_config[*]}" > out/gpu/bb-configure.txt +fi +echo "GPU library: PGO $pgo, LTO ${BB_LTO:-ON}, march ${BB_MARCH:-default}" # A failed GPU build must stop here: an older libbbgpu.so would otherwise be used silently. -if ! ninja -C out/gpu bbgpu bb-engine-assets > out/gpu-build.log 2>&1; then +if ! ninja -C out/gpu ${BB_JOBS:+-j "$BB_JOBS"} bbgpu bb-engine-assets > out/gpu-build.log 2>&1; then grep -v '^\[' out/gpu-build.log | tail -40 >&2 echo 'GPU library build failed (full log: out/gpu-build.log)' >&2; exit 1 fi @@ -72,7 +99,11 @@ if [[ -n $windows ]]; then runtime+=(src/win32_*.c) # The static library's link-time optimization (ThinLTO in lld) runs here; on Linux it runs in # the .so link. - link=(-Wl,--disable-dynamicbase,--disable-high-entropy-va -lwinmm -lws2_32 -lpsapi -flto=thin) + # The ThinLTO cache keeps each module's optimized code: a rebuild after a small change + # optimizes only the modules it touched (and what inlines from them), on every thread. + link=(-Wl,--disable-dynamicbase,--disable-high-entropy-va -lwinmm -lws2_32 -lpsapi -flto=thin + -Wl,--thinlto-cache-dir="$PWD/out/thinlto-cache" -Wl,--thinlto-jobs="${BB_JOBS:-all}" + -Wl,--thinlto-cache-policy=cache_size_bytes=6g:prune_after=1209600s) fi # Third-party decoders: compiled once, without this project's -Werror policy. atrac9=(third_party/LibAtrac9/C/src/*.c) diff --git a/docs/hdr.md b/docs/hdr.md new file mode 100644 index 0000000..8db00a1 --- /dev/null +++ b/docs/hdr.md @@ -0,0 +1,127 @@ +# HDR output + +`hdr=1` in `bbport.ini` (`BB_HDR=1`, the menu's "HDR output", applied on restart) presents the +game in an HDR swapchain when the display offers one (Windows HDR on): HDR10 (BT.2020, PQ, +`A2B10G10R10`) or scRGB (linear BT.709 in FP16). The game itself only makes an 8-bit SDR frame; +`hdr_output.frag` encodes it. SDR white is shown at `hdr_paper_white` nits (80-500, default 200), +nothing goes above `hdr_peak` (400-10000, default 1000). The settings menu is drawn into its own +layer and composed at paper white. SDR is decoded with a 2.2 power curve (the piecewise sRGB curve +lifts Bloodborne's shadows into grey). + +How the highlights get above paper white is `hdr_mode` (`BB_HDR_MODE`, the menu's "HDR mode", +live): + +| mode | highlights from | midtones | UI | +|---|---|---|---| +| `native` (default) | the game's own linear HDR scene, before its tonemap | exactly the SDR picture | paper white | +| `expand` | the clipped SDR picture (a guess) | exactly the SDR picture | expanded like the rest | + +## Expand + +Above a knee (linear 0.8) the brightest channel of the SDR frame is stretched so that SDR white +reaches `1 + (peak / paper - 1) * hdr_boost` times paper white (boost 0..1, default 0.25: 400 +nits with the defaults). Hue and saturation stay. Anything near SDR white is expanded, a lamp and +a white menu text alike. + +## Native + +The game's tonemap compresses its HDR scene into SDR: midtones roughly by an exposure, highlights +by a shoulder that ends at SDR white. Native mode keeps the game's SDR frame (its grading, +contrast, saturation, vignette, fog colour, bloom and UI) and multiplies its highlights by how +much brighter the scene really is there, measured every frame on the GPU: + +1. **Scene luma** (`hdr_native.comp` LUMA): before the post-processing combine (compute shader + `9a9cf8a9`, the upscaler's hook; after FSR/DLSS/TAA wrote its output into the scene color, + so output-sized at 1080p output). The RGBA16F scene color's brightest channel at half size: + the 2x2 maximum (highlights) and the brightest channel of the 2x2 average (exposure). A + reduced scene (scaled presets) is resolved into the guest image first, as the game's own + compute passes reading it do. +2. **UI-free copy and exposure** (SNAPSHOT, EXPOSURE): at the first UI pass (at 1080p output + the first Scaleform draw: vertex shader `34e8a281` or another of + `UiComposition::MovieShader`, into the game's RGBA8 UI target; with scaled presets the + output-size UI image right after the upscaler or background copy wrote it) the tonemapped + frame is copied bit for bit, and + `log2(SDR / scene)` is averaged over the SDR midtones (linear brightest channel 0.02-0.45, at + least 1/200 of the pixels): what the game's curve does to the scene there. Bloom lights up + SDR midtones around lamps where the scene is dark; samples far from the last exposure count + less (Gaussian weight, one stop), so a uniform change still goes through. Smoothed over + frames (10% a frame); after frames without a scene it starts over. +3. **Gain map** (GAIN): at the display pass that copies the finished frame (UI included) into a + display buffer, the first pass of the next frame. Per pixel of the finished frame: + - UI: where the frame differs from the UI-free copy (HUD, menus, subtitles, their translucent + backgrounds), gain 1; + - below the knee (linear brightest channel 0.5) gain 1: the SDR picture exactly; above it the + gain fades in (smoothstep to SDR white); + - the scene's brightness on the SDR scale is `exposure * scene`, with the brightest scene + pixel of the 2x2 luma texels around (4x4 scene pixels: the footprint does not shimmer with + the upscalers' sub-pixel jitter). Where the SDR frame is brighter than that (bloom, glare: + they are added after the scene color) the gain stays 1; else the SDR pixel goes towards it: + `gain = (scene / sdr) ^ (sqrt(boost) * fade)`; + - the result goes smoothly into the peak (unchanged up to halfway between SDR white and the + peak, then towards the peak with the same slope), so very bright sources keep some + gradation. + + The map (R16F, the finished frame's size, negative where UI was found) belongs to that + display buffer. The flip of a display buffer comes a frame later in the command stream; the + presenter takes the buffer's map when it prepares the flip (`Presenter::PrepareFrame`, + exchanged with the frame's previous map, no copy), so a frame and its map always match. +4. **Encode** (`hdr_output.frag`): the letterboxed SDR frame times the map (filtered, mapped + over the frame's rectangle in the window), capped at the peak. + +A frame without a scene (title, menus, loading screens, pre-rendered movies), or whose display +pass reads another image than the UI-free copy came from (or whose UI target was not found), +has no map: it is shown as plain SDR at paper white. A frame with a scene but no UI pass (in-engine +cutscenes without subtitles) is its own UI-free copy. + +`hdr_boost` in native mode is the share of the scene's highlight range shown, in log2: +`sqrt(boost)`. The default 0.25 shows half of it (a lamp the scene has 10x brighter than SDR +white comes out about 3.2x), 1 all of it (up to the peak), 0 none (the SDR picture). + +HDR metadata (`VK_EXT_hdr_metadata`): max mastering luminance is the peak, MaxFALL paper white; +MaxCLL is the peak in native mode and expand's SDR white level in expand mode. It changes with +the settings, not per frame. + +### Cost + +Per frame, a handful of commands recorded on the recording threads (`Scheduler::Record`), none +per draw, no readback, no wait: one dispatch at each of the three points (and a one-group sum), +the encode samples one more image. GPU: the scene read once (RGBA16F), the frame read twice and +copied once (RGBA8), a half-size RG16F and a full-size R16F written, about 55 MB at 1080p. +Measured (`hdr-native-shader-test --bench `, all passes with their barriers, RTX 5060): +0.18 ms at 1920x1080, 0.37 ms at 2560x1440, 0.79 ms at 3840x2160 (in the game the scene luma +stays at the guest's 1920x1080 for larger outputs, so a little less). Native mode off (or HDR +off): nothing. + +### Debug view + +`BB_HDR_DEBUG=1` shows the gain instead of the picture: the frame in grey at a third of paper +white, the gain over it from blue (a little) through green and yellow to red (the peak), UI that +native mode keeps at paper white in magenta. The square in the top left corner is green while +native mode has the frame's gain map and orange while the frame has none and is shown as SDR (in +expand mode the heat map shows expand's gain). + +The log says which mode runs (`HDR output: HDR10 (PQ, BT.2020), mode native (...)`, again when +it is changed in the menu) and, in native mode, when frames stop or start having a gain map +(`HDR native: ...`, at most every 5 s, with the number of switches in between). + +### Tests + +`ninja -C out/gpu hdr-native-shader-test` runs the passes on a synthetic frame (plain Vulkan, +any 1.3 device; `BB_TEST_GPU=` picks one): the luma's 2x2 max/average with NaN and inf +texels, the bit-exact copy, the exposure of a known tonemap (exact to 0.03 stops) and its +resistance to bloom (0.27 stops off on the first frame, 0.001 after 60), empty frames, and the +gain map: exactly 1 in the midtones and on UI, the scene's ratio to the power of the strength +into the peak on an 8x highlight, 1 on bloom brighter than the scene. `settings-test` covers +`hdr_mode`. + +### Limits + +- The scene color is the pre-post scene: bloom, depth of field and motion blur are only in the + SDR frame. Bloom halos stay at SDR brightness; a blurred highlight gets the gain where its + sharp source is (the 4x4 footprint softens that). +- One exposure for the whole frame: the vignette darkens the SDR corners but not the scene, so + bright highlights in the corners lose a little of the vignette. +- Scaled presets (output other than 1080p): the scene luma is at the guest resolution, jittered + when an upscaler runs; the map is interpolated to the output. +- `BB_UPSCALER=none` disables the frame analysis the passes rely on: native mode shows every frame + as SDR (use expand mode instead). diff --git a/docs/parallel-stageb.md b/docs/parallel-stageb.md new file mode 100644 index 0000000..9f33d28 --- /dev/null +++ b/docs/parallel-stageb.md @@ -0,0 +1,299 @@ +# Stage B across more than one thread: design notes + +Stage B is the draw recording thread `bb:DrawRec` (`vk_draw_pipe.h`, `Rasterizer::RunDrawPacket`). +In heavy fights on the Ryzen 5 5600 / RTX 5060 it is the frame rate limit: 62-94 FPS, +1300-1900 draws per frame, stage B busy 89-94%, ~5.3 us per packet, ~2500 packets per frame +(13.3 ms of a 14.4 ms frame in the 69.6 FPS window of `route\p10.log`). The game's main thread +would allow ~130 FPS. This note records what stage B does per packet, which parts are order +dependent, the approaches considered for running it on more than one thread, the one implemented +(`BB_DRAWREC_WORKERS=1`, a lookahead thread that prefetches for stage B), and why the approach +that would really scale (an ownership split of stage B into two pipelined stages) is not +something I could make safe in this change. + +Evidence: `route\p9.log` and `route\p10.log` (played heavy-fight sessions with `BB_SECTIONS=1`, +`BB_SAMPLE_THREADS=load`), `symprofile.py` on `bb-probe-p10.exe`, `docs/parallel_gpu.md` (the +earlier measurements of the texture binding helper and the two-stage pipeline), and the code. + +## 1. What stage B does per packet today + +Stage A (`shadPS4:GpuCommandProcessor`) decodes PM4, keeps the register file, runs the constant +engine, selects the pipeline (with the prepared draws of the `bb:DrawPrep` workers) and copies +small read-only constants into the constant ring. It writes a packet into the draw pipe ring: the +32-word register blocks written since the previous packet, each stage's user data and flattened +user data, the ring bindings, the vertex stream V#s, the draw parameters. Ordered tasks (EOP/EOS +fences, WriteData, DmaData, the flip) are packets too. + +Stage B, per draw packet (`RunDrawPacket` -> `DrawRecord`), with the `stage B us per packet` +numbers of the 69.6 FPS window (5.29 us per packet; a packet is a draw, a dispatch or a task): + +| step | what it touches | us/packet | +|---|---|---| +| apply register blocks, install user data snapshots | its own register copy (`pipe_regs`), thread-local snapshots | ~0.2 | +| `PrepareRenderState` | render/depth target descriptions (memoized per slot) | 0.17 | +| `BindBuffers` (per stage) | ring bindings (no lookup), else `ObtainBuffer`: arena table, residency, the memory tracker's page words (CPU/GPU dirty bits), uploads, `gpu_modified_ranges`, barrier tracker | 0.41 (ObtainBuffer in total 0.89, `VRAM track` 0.45) | +| `BindTextures` (per stage) | texture set memo slot (97% hits in fights: 219k hits, 5.7k misses per 5 s); per image: flags, backing, up-to-date check, layout transition; else the full lookup (image description cache, `FindImage`, views, refresh) | 0.99 (set 0.59: check 0.22, apply 0.22; full 0.27; samplers 0.07) | +| `BeginRendering` | render state memo (84% hits), else target views, barriers, scene proxies | 0.28 | +| `ResolveVertexBuffers`, `ResolveIndexBuffer` | `ObtainBuffer` for the merged stream ranges and the index range | 0.52 + 0.11 | +| `EmitVertexBuffers`, `FlushBarriers` | record chunk | 0.13 | +| object motion | bone palette hashes, index range cache (reads guest memory) | 0.26 | +| descriptors, `UpdateDynamicState`, `MarkPass`, `scheduler.BeginRendering`, the draw command | record chunk, dynamic state | ~0.3 | +| `ResetBindings`, `KickRecording` | image binding flags, buffer access tracker, hand-over to `bb:VkRecorder` | 0.14 | +| dispatches (`DispatchRecord`, the copy shader HLE) | `ObtainBuffer` for sources and destinations, copies | 0.61 per packet averaged | +| ordered tasks (fences, WriteData, DMA, flip) | waits for host copies and deferred fences, guest memory stores | 0.46 | + +Already parallel: Vulkan command recording (`bb:VkRecorder` threads record the closures stage B +pushes into record chunks; `HandOver`/`KickRecording`), host copies (copy threads and the +ordered copies on the recorders), draw preparation (`bb:DrawScan`, `bb:DrawPrep`: pipeline keys, +sharps, texture description hashes, vertex inputs, all validated by stage A), and since the plan +in `perf-plan.md`, page re-protection (`bb:Protect`, `BB_DEFER_PROTECT`). The CPU profile of +stage B is flat (`symprofile.py`: `BindTextures` 4.8%, the spin in `DrawPipe::Run` 2.5%, +`RunDrawPacket` 2.4%, `DrawRecord` 2.2%, `Image::GetBarriers` 1.7%, `BindBuffers` 1.7%, nothing +else above 1.5%; ~21% in ntdll, mostly the sleeps, protects and waits the applied plan removes). +The earlier `perf` profile with call graphs (`parallel_gpu.md`, "Second pass") said the same: +no function above ~5% self time, the cost is cache misses spread over the texture, buffer and +barrier structures. + +## 2. What is order dependent + +Everything a draw does on stage B reads or writes state that the previous packets left: + +- The register copy: a draw reads the registers at its position; only the changed blocks travel. +- The barrier tracker (`Runtime::IsBufferAccessed`/`AccessBuffer`, `Transit`, image layouts): + the barrier a draw needs depends on what the previous draws and copies did to the same buffer + or image, and the transitions it records change what the next draw needs. +- The buffer cache: `ObtainBuffer` turns CPU-dirty pages into uploads recorded before this draw + and marks pages GPU-modified for written bindings; a later binding of the same range must see + those marks, and a GPU-modified range must not be re-uploaded from stale guest data. The + stream path copies guest memory into the stream buffer at this point of the stream: a later + WriteData/DMA to the same memory waits for that copy (`WaitHostCopiesFor`). +- The texture cache: images are registered, refreshed, invalidated by storage writes, tracked + and untracked (page watchers) in stream order; the texture set memo and the render state memo + are valid exactly because nothing ran between the memoized draw and this one that the checks + do not cover. +- The scheduler's producer side: one thread fills the current record chunk and the ordered + chunk (`ProducerScope` reports a second one); draws, uploads, barriers, copies and fences + must land in it in stream order, since `OrderedReady` and the deferred fences assume it. +- CPU side effects of tasks: WriteData and CPU DMA store into guest memory in order with the + host copies of earlier draws (`WaitHostCopiesFor`) and the deferred fences (`WaitDeferredSignals`); + later draws read what they wrote (constants, index data). +- The deferred-protect invariant (`page_manager.cpp` ~430): pages an upload copied may stay + writable until `bb:Protect` or the copy itself applies the protection; every reader of guest + memory on the GPU side goes through `CopySparseMemory`, which flushes pending protects first. + +So a draw's result (what it uploads, which barriers it emits, which memo entries it finds valid) +is a function of the whole prefix of the stream. The only parts without side effects are +computations from the packet's own data: hashes, keys, the addresses of what will be consulted. + +## 3. Approaches considered + +### A. N workers each running a whole draw from a register snapshot (fork/join) + +Each worker would need the caches, the barrier tracker and the scheduler's producer side; none +of them is thread-safe, and the barrier and upload decisions of draw N+1 depend on draw N. The +closest experiment that exists is the texture binding helper (`BB_TEXTURE_HELPER=1`, +`parallel_gpu.md`, "Texture binding on a helper thread"): the helper binds textures on the +memoized path while stage B binds buffers, with a join before any image state changes. 68% of +draws were bound in parallel and the frame rate did not move (85.9 vs 85.8 FPS): the helper's +task took ~6200 cycles where the same work cost ~4600 on the GPU thread and the join waited +~2500 cycles per fork, because the descriptor infos, image states and binding flags it writes +are read by stage B right after and move between cores. Moving ~7 us of cache-bound work to +another core per draw costs about what it saves. Gain ~0, risk high. Rejected. + +### B. Pipelining stage B into two owners (B1: decode, buffers, uploads, tasks; B2: textures, targets, barriers, recording) + +This is the design `parallel_gpu.md` ("What would scale", item 1) identifies as the one that +scales: each thread owns its data for long stretches instead of handing per-draw results across. +From the table above B2 would take textures (0.99), render state and begin rendering (0.45), +dynamic state, descriptors, the draw command and the kick (~0.45): ~1.9 of 5.3 us; stage B1 +would keep ~3.4 us, so stage B's critical path would fall by ~35% (plus whatever the dispatch +path splits), i.e. ~+50% FPS where stage B limits, bounded by the main thread at ~130 FPS. + +What it needs, and why I did not do it here: + +- Two producers into the scheduler's command stream in stream order while B2 lags B1 by several + draws: the scheduler would need a second ordered lane (B1's upload copies and barriers must be + recorded before B2 reaches the draw, after B2's previous draws), or B1 must stop recording + anything and hand its upload commands to B2 as data. Every `runtime.CopyBuffer`, + `IsBufferAccessed`, `AccessBuffer` call in the buffer cache moves to B2 or becomes a message. +- The buffer and texture caches call each other: `ObtainBuffer` invalidates images for storage + writes (`InvalidateMemoryFromGPU`), texel buffers sync from images (`SynchronizeMemoryFromImage`), + image refresh obtains buffers (`ObtainBufferForImage`, `RefreshObtain`), the copy shader HLE and + the DMA GPU paths use both and record copies. Each of these becomes a cross-stage drain or a + protocol. +- `ResetBindings` (buffer access marks of the draw's buffers) and `needs_barrier` are shared + between the halves. +- The memo validity arguments (texture sets, render state) assume the one-thread order; B2's + memos would have to be reasoned about against B1's uploads landing in between. +- Ordered tasks: WriteData/DMA stores (B1) are ordered against host copies and deferred fences + that the recorders run after B2's commands; the fence barrier logic of `perf-plan.md` C2-3 + assumes a single stage B position. + +That is a multi-week refactor across `vk_scheduler`, `buffer_cache`, `texture_cache`, +`vk_rasterizer` and `vk_shader_hle`, with failure modes that only show in play (stale uploads, +missing barriers, heap corruption through fence order). I cannot run the game here, and a +change of that size cannot be validated by compiling it. It is the right next step if the +remaining single-thread wins are exhausted; it is not safe within this scope. + +### C. Splitting the command stream at frame or submission boundaries + +Two stage B instances on alternate frames or submissions would need two copies of every cache +and of the image layout state, or locks around all of it; the stream of one frame depends on +the layouts, uploads and GPU-modified marks the previous frame left. Not viable. + +### D. Speculative cache lookups on the draw-preparation workers + +The `bb:DrawPrep` workers already compute everything that depends only on the packet (sharps, +hashes, vertex inputs). What remains on stage B is the part that reads mutable cache state and +must be validated against it, and the validation is the same read as the lookup (the texture set +check is 0.22 us of the 0.59 us set path; the lock-free dirty-page check is a bitset scan). A +"candidate" from a worker saves nothing stage B does not redo, and a stale candidate costs a +second pass. Rejected for the texture and buffer lookups; the remaining pure parts (bone +palette hash, the texture set key) are ~0.1 us. + +### E. A lookahead thread that prefetches for stage B (implemented) + +Stage B is a pointer-chasing consumer whose per-frame working set does not fit its core's +caches: the texture set memo alone is 32768 slots of ~1.1 KiB (~35 MiB, more than the 5600's +32 MiB L3), plus thousands of `Image` slots, the page tracking words of the touched 4 MiB +regions, the arena table, the record chunks and the packets themselves. The first touches of a +draw form short dependent chains (set slot -> entry id -> image slot; region pointer -> page +words) that nothing can overlap because the addresses come from the previous load. A thread that +walks the committed packets a few ahead of stage B can resolve those chains early and prefetch +the lines, so stage B finds them in the shared L3 or in the lookahead core's cache (an on-die +transfer, ~40-100 cycles) instead of memory (~250-300 cycles at DDR4-3200). + +It changes no state: the only loads are from the packet ring (committed packets stage A cannot +overwrite), the prepared draws (kept alive until stage B passes the packet), the shader infos and +the memo slot's key and ids; everything else is an address that is prefetched and never read, and +prefetch instructions do not fault. Risk is therefore low and the whole thing sits behind one +toggle, default off. The gain is bounded by the DRAM-miss share of stage B's time, which the +profiles cannot show directly (a flat profile is consistent with it but does not prove it); the +estimate is in section 5. It is honestly the only one of the five that I could make safe without +running the game, and it is the one with the best gain-to-risk ratio for a single toggle. It +does not reach the stated target of stage B well under the main thread's time; B does. + +## 4. The lookahead thread (`BB_DRAWREC_WORKERS=1`) + +Thread `bb:DrawAhead`, `Rasterizer::LookaheadLoop` (`vk_rasterizer.cpp`), started with the draw +pipe when `BB_DRAWREC_WORKERS` is set to anything but 0. Normal priority (`BB_DRAWREC_IDLE=1`: +idle priority, only spare cores, starved on a busy CPU). It keeps a cursor into the draw pipe +ring: + +1. If stage B passed the cursor (it fell behind), it jumps to stage B's position (counted: + `behind stage B`). +2. If it is more than `BB_DRAWREC_LOOKAHEAD` packets (default 8) ahead of stage B's count, it + waits (pause spin, yields after 100 us; counted: `waited for it`): lines fetched too early + would be evicted before use and only pollute the cache. +3. If no packet is committed past the cursor it spins 20 us and then sleeps; `DrawPipe::Commit` + wakes it with the same announce-then-check protocol as stage B's own sleep + (`DrawPipe::WaitForCommit`; one extra atomic load on stage A per commit). +4. Otherwise it reads the packet (`DrawPipe::PacketAt`) and runs `PrefetchPacket`. + +`PrefetchPacket` handles direct draws with a prepared draw (`PacketKind::Draw` with +`packet.prepared`; 82-98% of draws in the logs): tasks, dispatches, indirect and special draws +bind without prepared sharps and are skipped. It parses the packet exactly as `RunDrawPacket` +does and, per stage with a matching `PreparedStage` (`FindPreparedStage`, now also usable from +another thread): + +- Textures (the `BindTexturesFromSet` path): the same key as stage B + (`XXH3(image_hashes) ^ stage ^ point_native`), the slot's header and hashes; if the slot's key + matches (one relaxed load), its entries and the first 192 bytes of each image slot + (`TextureCache::ImageSlotAddress` -> `SlotVector::SlotAddress`: an address computed from the + storage pointer, never dereferenced); otherwise the two ways of the image description cache + for each texture (the start of the full lookup). +- Buffers (`BindBuffers` -> `ObtainBuffer`): for each non-special buffer that is not a ring + binding of this packet, `BufferCache::PrefetchBinding`: the arena table entry and, through + `MemoryTracker::Prefetch`, the CPU and GPU page bit words of every 4 MiB region of the range + (what `IsRegionModified` and `ForEachUploadRange` read first; regions are not created). +- The vertex streams (`ResolveVertexBuffers`): the same for each V# in the packet. + +Not prefetched: the index buffer and the render targets (they come from the register file, which +the lookahead thread does not replay), the memory tracker's region locks (taken only for written +bindings, and a shared prefetch of a line stage B will write only costs an extra invalidation), +anything behind a pointer read from a memo slot (an image's backing state), guest memory (reads +there could trigger the page tracker's read-fault path). + +Safety: + +- Ring: `Begin` waits for room below `consumed + Capacity`, so a packet at or past `consumed` is + never overwritten; the lookahead reads only packets between `Consumed()` and `Published()` + (acquire loads; `Commit` publishes after the bytes are written). +- Prepared draws: kept alive by `pipe_keepalive` until stage B reached the packet's position and + stage A ran `RetireSubmission`. If the lookahead falls behind and reads a packet stage B has + passed, the prepared pointer may be stale; the read is wrapped in `BB_RECOVER_SET` (the + loader's vectored handler resumes at the recovery point on any thread) and counted + (`faults recovered`). The ring bytes themselves stay valid for 16 MiB of further packets. +- Memo slots and image ids: read with relaxed atomic loads of the key and the ids only; a torn + or reused id produces a wrong address to prefetch, which is harmless. +- No locks, no page protection, no guest memory, no scheduler calls, no cache mutation: the + deferred-protect invariant, the fence ordering and the DMA/WriteData side effects are untouched + because the thread never participates in them. Stage B's order and results are the same with + the thread on or off. +- Stop: the thread is a `std::jthread` member declared after `draw_pipe`, so it is joined before + the ring goes; `WaitForCommit` registers a stop callback on its sleep. +- Live A/B: experiment bit 16 of `BB_TOGGLE_FILE` pauses it (it sleeps in 1 ms steps and + re-syncs on resume), so `-AbBits 16` compares on/off within one run. + +What can still go wrong (not ruled out without running it): + +- It takes a hardware thread. On 12 threads with the game's own pools, stage A, stage B, two + recorders, copy threads and `bb:Protect`, a spinning thread can slow a game thread or stage B + itself; the spin is short (20 us) and it sleeps between frames, but heavy fights keep it busy. + If the FPS drops with it on, `BB_DRAWREC_IDLE=1` is the first thing to try, then a smaller + window. +- Bandwidth and pollution: ~20-60 lines per draw at ~5 us per draw is ~1 GB/s of extra traffic + if every line missed; prefetches past the L2's outstanding-miss capacity are dropped, which + only loses the benefit. +- Lines stage B writes right after reading (binding flags, `tick_accessed_last`, set + generation) are in the lookahead core's cache in shared state: stage B's write costs an + invalidation. The texture helper measurements show the cost of moving written lines between + cores; here only reads are shared, which is cheaper, but it is not free. +- The stale-packet recovery relies on the vectored handler being installed and on nothing + holding a lock across the faulting read (nothing does); a fault in the middle of a prefetch + loop leaves no state behind. + +## 5. Expected gain, from the numbers + +Per draw, stage B's first touches that the lookahead covers: 1 texture set slot (3 lines header +and hashes, then 12 lines of entries for a full set) and 2-8 image slots (2-3 lines each) for +each of 2-3 stages with textures; 2-3 lines per buffer range for ~6-10 ranges (the arena entry is +hot). That is 20-60 lines per draw. How many of them miss to DRAM is what the logs cannot show. +If 4-8 of them do (the set slot in a 35 MiB table that exceeds the L3, images not bound since +the last frame, regions not touched for a while), each costs 80-100 ns on this CPU: 0.3-0.8 us +of the 5.3 us per packet, 6-15%. A lookahead that resolves them a few packets ahead removes +most of that latency from stage B's path but adds the shared-line invalidations and the thread's +own footprint, so I expect 0-8% less stage B time per frame, with a real chance of 0 and a small +chance of a loss on a core-starved run. The same window and `-AbBits 16` will tell. If it gives +nothing, the honest reading is that stage B is not DRAM-latency bound in the parts the lookahead +can reach, which is itself useful before investing in approach B. + +Measure: `Draw pipe` lines (`BB_FRAME_STATS=1`): `stage B busy`, and the new line + +``` + lookahead: N packets/s walked, D draws/s prefetched, L lines/draw, A packets ahead, + behind stage B B/s, waited for it W/s, busy X%, F faults recovered +``` + +`A` should sit near the window (a lookahead that keeps up); `behind` near 0; `busy` well under +stage B's. With `BB_SECTIONS=1`, `stage B us per packet`: `tex set`, `tex set check`, `buffers`, +`VRAM track`, `vertex` are the sections that should fall. FPS and `GPU thread us/draw` are the +result. Correctness: screenshots identical on/off (nothing changes what stage B does), no new +`Draw pipe:` reports, `faults recovered` ~0 (a steady count means the thread keeps falling +behind; raise nothing, it only loses the benefit). + +## 6. Files changed + +- `gpu/shim/bbport_toggles.h`: `BbStats::lookahead_*` counters (CRLF). +- `gpu/shadps4/video_core/renderer_vulkan/vk_draw_pipe.h`: `Published`, `ConsumedPackets`, + `PacketAt`, `WaitForCommit`; the lookahead's wake flag checked in `Commit` (CRLF). +- `gpu/shadps4/common/slot_vector.h`: `SlotAddress` (CRLF). +- `gpu/shadps4/video_core/texture_cache/texture_cache.h`: `ImageSlotAddress` (LF). +- `gpu/shadps4/video_core/buffer_cache/memory_tracker.h`: `Prefetch` (CRLF). +- `gpu/shadps4/video_core/buffer_cache/buffer_cache.h/.cpp`: `PrefetchBinding` (CRLF). +- `gpu/shadps4/video_core/renderer_vulkan/vk_rasterizer.h/.cpp`: `LookaheadWanted`, + `LookaheadWindow`, `LookaheadLoop`, `PrefetchPacket`, `PrefetchDraw`, the static + `FindPreparedStage`, the thread member, the startup line and the statistics line (CRLF). + +Environment: `BB_DRAWREC_WORKERS=1` (on; default off, 0 = today's behaviour; the value is a +switch, there is one lookahead thread), `BB_DRAWREC_LOOKAHEAD=` (default 8, 1..256), +`BB_DRAWREC_IDLE=1` (idle priority), experiment bit 16 of `BB_TOGGLE_FILE` (pause, for A/B). diff --git a/docs/pipeline_compiles.md b/docs/pipeline_compiles.md new file mode 100644 index 0000000..dfb8088 --- /dev/null +++ b/docs/pipeline_compiles.md @@ -0,0 +1,115 @@ +# Pipeline compiles and the waits behind them + +A pipeline first seen during play is compiled on the compile threads (`BB_ASYNC_PIPELINES`, +default on). With a cold NVIDIA cache a compile takes 100-300 ms; entering an area brings +20-90 at once. Pipelines known from the shader database are created at start (preload) and +come out of the driver cache (`.vkcache`) quickly. + +## What a draw does with a pipeline still compiling + +Before (0.9.10 beta 1) the draw recording thread (stage B of the draw pipe) waited for it when +it recorded the bind: every new pipeline stalled the frame by its compile time (user logs, RTX +5060: `2 compiles 594 ms` -> a 607 ms frame, `18 compiles 1110 ms` -> 1665 ms), and the GPU +command thread and the guest threads that waited for stage B (drains, see below) stalled with +it. Now (`Rasterizer::DrawRecord`, `HandleIfReady`): + +- **Scene geometry is left out** while its pipeline compiles (`SkipUncompiledDraw`): draws with + the scene depth (the G-buffer pass's, which was also the scene depth the frame before) that + are not full-screen passes (more than 6 indices or instanced) - the draws the upscaler + jitters. G-buffer, light volumes, forward/transparent draws and effects drawn with the scene + depth. The frame draws these targets again from a clear, so the object is missing for the + frames the compile takes (pop-in), nothing more. Everything else of the draw runs as before + (targets, barriers, render pass, resources): only `vkCmdDraw` is not recorded. Never left + out: draws the game measures (occlusion queries: zero samples would hide their predicated + draws), indirect draws, compute dispatches, shadow maps, half-resolution effects, + post-processing and UI (what they draw may be kept over frames or read as a whole), and + draws during a frame capture. A pipeline's draws are left out for at most + `BB_SKIP_UNCOMPILED_MS` (1000) from its first left-out draw; then they wait for it. "A few + frames" at 120 FPS is 25-40 ms, below every compile measured, so the bound is in time. + `BB_SKIP_UNCOMPILED=0`: never left out. +- **Everything else binds the pipeline on a recording thread** (`BindWhenCompiled`): the bind + becomes a command of its own, and the recording thread that records it into the command + buffer waits for the compile there. Stage B goes on with the next draws, so drains of the + GPU command thread (and the guest threads behind them) do not wait for the compile. The + frame itself still waits for it (its submission needs the command). `BB_LATE_PIPELINE_BIND=0` + (or commands recorded on the calling thread: `BB_VK_RECORD_THREAD=0`, direct segments): + stage B waits, as before. +- **A thread that must wait compiles the pipeline itself** when no compile thread has started + it (`Pipeline::WaitReady`, `TakePipelineJob`): in a burst it was queued behind the other + pipelines first. `BB_PIPELINE_TAKE=0`: waits for the compile threads. + +An already compiled pipeline costs what it did: `HandleIfReady` is the load and branch +`Handle()` was. The draw command gained a branch on the recording thread (bind or not). + +## Why not VK_EXT_graphics_pipeline_library + +DXVK links pipelines from libraries compiled when the game creates its shaders, long before the +draws. Here (measured on the user's shader database, 855 graphics pipelines): + +- Shaders reach the port at the draw that needs them: the guest binary is translated there. + Libraries would be compiled at that draw too; only linking is fast. +- All stages of a pipeline share descriptor set 0 (one push descriptor set; the vertex stage's + bindings follow the fragment stage's, and the vertex module is specialized on that offset). + Libraries need identically defined layouts, so they are shared only between pipelines with + the same stage modules: 780 distinct stage sets among the 855 pipelines (75, 9%, would reuse + libraries). Separate sets per stage would end push descriptors for one of them: a descriptor + set write per draw on the thread that limits the frame rate. +- NVIDIA compiles each library stage about as fully as in a pipeline: a draw would wait for + the slower of the vertex and fragment libraries instead of both (perhaps 30-40% less), at + about twice the compile work (libraries and the optimized pipeline after them). + +The start line reports whether the device offers it (`graphics pipeline library offered (fast +linking)`) for later work, e.g. if shader translation could run ahead of the draws. + +## Waits behind the draw recording thread (write faults, drains) + +`Stall: 335.8 ms frame; ... write faults 324.6 ms; GPU thread on CPU 328.1 ms (kernel 203.1 +ms)`, and from a boss fight `image create 575 ms`, `image refresh 608.6 ms` with `write faults +605.1 ms`. Two different waits: + +- **Guest threads** handle their write faults with locks (`OnWriteFault`, `assume_locks` + false); they never drain. `TextureCache::InvalidateMemory` takes the texture cache's lock, + which stage B holds through `FindImage` (image creation, page protection) and `UpdateImage` + (`RefreshImage`: the upload): 0.5-0.6 s with memory short. Most faults are on buffer pages + (the game rewriting constants and vertices) that no image watches. The texture cache now + counts, per 4 KiB page, the tracked images that protect it (`WatchedPages`, kept with the + page manager's watchers, readable without the lock); a fault on a page no image watches + updates the buffer tracking only. Images learn of CPU writes through their own page + protection alone, so this is what the lock gave them: an untracked image is already dirty, + or new (dirty), or hash-checked (MaybeCpuDirty). `BB_FAULT_SKIP_TEXTURES=0`: the lock for + every fault. A fault on a page an image watches still waits for the lock (unchanged), and + a guest thread's fault on GPU-written data still needs the GPU thread for the readback. +- **The GPU command thread** (stage A) drains before it handles its own faults (stream order: + the queued draws read the old bytes first; a readback records commands) and at many other + points. It waited with `SwitchToThread` in a loop for as long as stage B took: a core burnt + in the kernel while stage B (or a compile thread) needed it. Its waits (`Drain`, + `WaitForPacket`, a full ring) now spin, yield for `BB_DRAIN_SPIN_US` (500), then sleep until + stage B has run the packet they need (stage B checks after each packet whether stage A + sleeps for it: a load and a branch). `BB_DRAIN_SLEEP=0`: spin and yield as before. The wait + itself is as long as stage B's work: with compiles off stage B that is now mostly image + creation and uploads (memory pressure). + +## Statistics and log lines + +At start: + + Pipelines: new ones compiled on 4 threads; draws of one still compiling: scene geometry left out for up to 1000 ms, other draws bind it on the recording threads (they wait for it) (BB_SKIP_UNCOMPILED, BB_LATE_PIPELINE_BIND); graphics pipeline library offered (fast linking), not used + Waits: GPU command thread drains sleep after a spin (stage B wakes them); write faults on pages no image watches leave the texture cache alone (BB_DRAIN_SLEEP, BB_FAULT_SKIP_TEXTURES) + +`BB_FRAME_STATS`, every 5 s when anything happened: + + Pipelines: 18 compiled in the background (2 by the thread that needed them); 340 draws of 12 pipelines left out while compiling; 6 draws/dispatches bound on the recording threads; 3 waits, 210.4 ms, longest 120.3 ms + +and `(N/s slept)` in the `Draw pipe:` line. The `shader/pipeline compiles, ms` figure of +`Frame stats` still adds the waits (now on recording threads too) to the translation time. + +## Risks + +- Left-out scene draws: an object (or light volume, decal, particle) appears a few frames + late the first time its pipeline is needed. A transparent layer whose pipeline is ready + over an opaque one still compiling shows over the background for those frames. +- A recording thread waiting for a compile delays its segment, the submission and the guest's + fences after it, as stage B's wait did; ordered tasks (guest copies, fence signals) queue + behind it. +- `ReplaceShader` (shader patching from the debug UI) destroys pipelines at once; a late bind + still queued for one would use it after that. diff --git a/gpu/CMakeLists.txt b/gpu/CMakeLists.txt index f51df72..7fc48a7 100644 --- a/gpu/CMakeLists.txt +++ b/gpu/CMakeLists.txt @@ -16,6 +16,14 @@ add_compile_options(-fno-omit-frame-pointer) # profile). Covers bbgpu and the static libraries linked into it. No -march: the same build # runs on the Steam Deck. option(BB_LTO "Link-time optimization of bbgpu" ON) +# bbport: optional instruction set level (BB_MARCH=x86-64-v2 in build.sh; empty: the compiler's +# default, x86-64). v2 (SSE4.2, POPCNT, SSSE3, CX16) is a subset of what the game's own code +# already requires (probe.c check_cpu: AVX, BMI1, LZCNT, POPCNT, SSE4.2, MOVBE), so it shuts out +# no CPU that can run the game; v3 would (it adds AVX2/BMI2/FMA). +set(BB_MARCH "" CACHE STRING "-march for bbgpu and its static libraries (empty: default)") +if (BB_MARCH) + add_compile_options(-march=${BB_MARCH}) +endif() set(BB_PGO "off" CACHE STRING "Profile-guided optimization: off, generate, use") set(BB_PGO_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../pgo" CACHE PATH "Profile directory") if (BB_LTO) @@ -29,9 +37,20 @@ if (BB_LTO) endif() if (BB_PGO STREQUAL "generate") # Atomic counters: the GPU, recording, preparation and copy threads run the same code. + # GCC writes .gcda files into BB_PGO_DIR, clang default_.profraw files (build.sh merges + # them into default.profdata with llvm-profdata). add_compile_options(-fprofile-generate=${BB_PGO_DIR} -fprofile-update=atomic) add_link_options(-fprofile-generate=${BB_PGO_DIR}) add_compile_definitions(BB_PGO_GENERATE) +elseif (BB_PGO STREQUAL "use" AND CMAKE_CXX_COMPILER_ID MATCHES "Clang") + # bbport: clang (MSYS2 CLANG64 on Windows) reads one merged profile. It has no + # -fprofile-partial-training; -pgso=false keeps code the profile never ran from being + # optimized for size instead (the closest equivalent). Changed functions are compiled + # without profile data (warnings off). + add_compile_options(-fprofile-use=${BB_PGO_DIR}/default.profdata + -Wno-profile-instr-missing -Wno-profile-instr-out-of-date + -Wno-profile-instr-unprofiled -Wno-backend-plugin + "SHELL:-mllvm -pgso=false") elseif (BB_PGO STREQUAL "use") # Partial training keeps unprofiled code optimized normally; counters from concurrent # threads can be slightly inconsistent (correction). Functions changed since the profile @@ -139,6 +158,12 @@ add_host_shader(fs_tri.vert fs_tri_vert) add_host_shader(fsr.comp fsr_easu_comp SAMPLE_EASU=1) add_host_shader(fsr.comp fsr_rcas_comp SAMPLE_RCAS=1) add_host_shader(post_process.frag post_process_frag) +add_host_shader(hdr_output.frag hdr_output_frag) +add_host_shader(hdr_native.comp hdr_native_luma_comp LUMA=1) +add_host_shader(hdr_native.comp hdr_native_stats_comp STATS=1) +add_host_shader(hdr_native.comp hdr_native_snapshot_comp STATS=1 SNAPSHOT=1) +add_host_shader(hdr_native.comp hdr_native_exposure_comp EXPOSURE=1) +add_host_shader(hdr_native.comp hdr_native_gain_comp GAIN=1) add_host_shader(camera_motion.comp camera_motion_comp) add_host_shader(upscale_merge.comp upscale_merge_comp) add_host_shader(upscale_reactive.comp upscale_reactive_comp) @@ -194,9 +219,13 @@ target_sources(bbgpu PRIVATE third_party/imgui/imgui.cpp third_party/imgui/imgui third_party/imgui/backends/imgui_impl_vulkan.cpp) target_include_directories(bbgpu PRIVATE third_party/imgui third_party/imgui/backends) target_compile_definitions(bbgpu PRIVATE IMGUI_IMPL_VULKAN_NO_PROTOTYPES - BB_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/third_party/fonts/DejaVuSans.ttf") + BB_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/third_party/fonts/DejaVuSans.ttf" + BB_KEYCAP_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/third_party/imgui/misc/fonts/Roboto-Medium.ttf") set_source_files_properties(shim/bbport_overlay.cpp PROPERTIES OBJECT_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/third_party/fonts/DejaVuSans.ttf) +# Keyboard prompts' keycaps (shim/engine/keycap_art.cpp): Roboto Medium (Apache License 2.0). +set_source_files_properties(shim/engine/keycap_art.cpp PROPERTIES + OBJECT_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/third_party/imgui/misc/fonts/Roboto-Medium.ttf) target_include_directories(bbgpu PRIVATE shim shadps4 ${SHADER_INCLUDE} third_party/gcn/include third_party/half/include) target_compile_definitions(bbgpu PRIVATE BOOST_ASIO_STANDALONE NDEBUG) target_compile_options(bbgpu PRIVATE -Wno-deprecated-declarations -Wno-missing-field-initializers) @@ -212,6 +241,11 @@ if (WIN32) # Windows headers: no min/max macros, no winsock 1 clash; Windows 10+ APIs (SetThreadDescription). target_compile_definitions(bbgpu PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00) target_link_libraries(bbgpu PUBLIC xbyak::xbyak) + if (BB_PGO STREQUAL "generate") + # bbport: the instrumented static library needs the profile runtime in bb-probe.exe's + # link (add_link_options does not reach it): written into bbgpu_link.txt below. + target_link_options(bbgpu INTERFACE -fprofile-generate=${BB_PGO_DIR}) + endif() # bb-probe.exe links libbbgpu.a with these (out/gpu/bbgpu_link.txt, one argument per line, # in link order): the static library's transitive dependencies. include(cmake/LinkClosure.cmake) @@ -242,6 +276,18 @@ target_link_libraries(pipeline-archive-test PRIVATE fmt::fmt) add_executable(motion-history-test EXCLUDE_FROM_ALL ../tests/test_motion_history.cpp) target_compile_options(motion-history-test PRIVATE -UNDEBUG -Wall -Wextra -Werror) target_include_directories(motion-history-test PRIVATE ..) +add_executable(occlusion-test EXCLUDE_FROM_ALL ../tests/test_occlusion.cpp) +target_compile_options(occlusion-test PRIVATE -UNDEBUG -Wall -Wextra -Werror) +target_include_directories(occlusion-test PRIVATE ..) +# Pipeline compiles off the frame path (docs/pipeline_compiles.md); the draw pipe's waits run +# with real threads (bbgpu: the platform and thread helpers). +add_executable(pipeline-compile-test EXCLUDE_FROM_ALL ../tests/test_pipeline_compile.cpp ../tests/test_gpu_runtime_stubs.cpp) +target_compile_options(pipeline-compile-test PRIVATE -UNDEBUG) +target_include_directories(pipeline-compile-test PRIVATE shim shadps4 third_party/gcn/include) +target_link_libraries(pipeline-compile-test PRIVATE bbgpu) +if (WIN32) + target_link_libraries(pipeline-compile-test PRIVATE winmm ws2_32 psapi onecore) +endif() add_executable(motion-shader-test EXCLUDE_FROM_ALL ../tests/test_motion_shaders.cpp ../tests/test_gpu_runtime_stubs.cpp) target_compile_options(motion-shader-test PRIVATE -UNDEBUG) target_include_directories(motion-shader-test PRIVATE shim shadps4 third_party/gcn/include) @@ -252,7 +298,8 @@ endif() add_executable(ui-composition-test EXCLUDE_FROM_ALL ../tests/test_ui_composition.cpp) target_compile_options(ui-composition-test PRIVATE -UNDEBUG -Wall -Wextra -Werror) target_include_directories(ui-composition-test PRIVATE ..) -add_executable(settings-test EXCLUDE_FROM_ALL ../tests/test_settings.cpp shim/bbport_settings.cpp) +add_executable(settings-test EXCLUDE_FROM_ALL ../tests/test_settings.cpp shim/bbport_settings.cpp + shim/bbport_menu_i18n.cpp) target_compile_options(settings-test PRIVATE -UNDEBUG -Wall -Wextra -Werror) target_include_directories(settings-test PRIVATE .. shim) @@ -278,9 +325,16 @@ target_compile_options(camera-motion-test PRIVATE -UNDEBUG -fPIC) target_link_libraries(camera-motion-test PRIVATE bbgpu) add_dependencies(camera-motion-test host_shaders) +# bbport: native HDR output's shaders on a synthetic frame (docs/hdr.md); plain Vulkan, no bbgpu. +add_executable(hdr-native-shader-test EXCLUDE_FROM_ALL ../tests/test_hdr_native.cpp) +target_include_directories(hdr-native-shader-test PRIVATE ${SHADER_INCLUDE}) +target_compile_options(hdr-native-shader-test PRIVATE -UNDEBUG -Wall -Werror) +target_link_libraries(hdr-native-shader-test PRIVATE Vulkan::Vulkan) +add_dependencies(hdr-native-shader-test host_shaders) + # bbport: FSR 4 benchmark outside the game (tools/fsr4_bench.cpp): ninja -C out/gpu fsr4-bench. add_executable(upscaler-support-test EXCLUDE_FROM_ALL ../tests/test_upscaler_support.cpp - shim/bbport_settings.cpp) + shim/bbport_settings.cpp shim/bbport_menu_i18n.cpp) target_include_directories(upscaler-support-test PRIVATE shim) target_compile_options(upscaler-support-test PRIVATE -UNDEBUG) @@ -315,6 +369,7 @@ target_include_directories(pc-input-test PRIVATE shim) target_link_libraries(pc-input-test PRIVATE PkgConfig::SDL3) target_compile_options(pc-input-test PRIVATE -UNDEBUG -Wall -Wextra -Werror) add_executable(widescreen-test EXCLUDE_FROM_ALL ../tests/test_widescreen.cpp - shim/engine/widescreen.cpp shim/engine/engine_hooks.cpp shim/bbport_settings.cpp) + shim/engine/widescreen.cpp shim/engine/engine_hooks.cpp shim/bbport_settings.cpp + shim/bbport_menu_i18n.cpp) target_include_directories(widescreen-test PRIVATE shim) target_compile_options(widescreen-test PRIVATE -UNDEBUG -Wall -Wextra -Werror) diff --git a/gpu/bbnet/vendor/shadps4/core/libraries/network/net_upnp.cpp b/gpu/bbnet/vendor/shadps4/core/libraries/network/net_upnp.cpp index a014737..b3cfd54 100644 --- a/gpu/bbnet/vendor/shadps4/core/libraries/network/net_upnp.cpp +++ b/gpu/bbnet/vendor/shadps4/core/libraries/network/net_upnp.cpp @@ -17,6 +17,8 @@ namespace Libraries::Net { +u16 GetP2PConfiguredPort(); // p2p_sockets.cpp + UPnPClient& UPnPClient::Instance() { static UPnPClient instance; return instance; @@ -112,6 +114,12 @@ void UPnPClient::DiscoverThread() { m_done = true; m_cv.notify_all(); + + // The P2P transport usually has its port before the router answers; when it comes up later + // it asks for the mapping itself (P2PTransport::Start). + if (const u16 port = GetP2PConfiguredPort(); port != 0) { + AddMapping(port); + } } void UPnPClient::AddMapping(u16 port) { @@ -121,6 +129,10 @@ void UPnPClient::AddMapping(u16 port) { if (!m_available.load()) return; + std::lock_guard lock(m_mutex); + if (m_external_port.load() == port) + return; + const std::string port_str = std::to_string(port); const int ret = UPNP_AddPortMapping(m_urls.controlURL, m_data.first.servicetype, port_str.c_str(), diff --git a/gpu/bbnet/vendor/shadps4/core/libraries/network/p2p_sockets.cpp b/gpu/bbnet/vendor/shadps4/core/libraries/network/p2p_sockets.cpp index 54df194..46ec8ac 100644 --- a/gpu/bbnet/vendor/shadps4/core/libraries/network/p2p_sockets.cpp +++ b/gpu/bbnet/vendor/shadps4/core/libraries/network/p2p_sockets.cpp @@ -23,6 +23,7 @@ #include "common/logging/log.h" #include "common/singleton.h" #include "core/libraries/kernel/kernel.h" +#include "core/libraries/network/net_upnp.h" #include "core/libraries/network/net_util.h" #include "net.h" #include "net_error.h" @@ -33,6 +34,10 @@ namespace { constexpr u16 kSignalingVPortNbo = 0xffff; constexpr size_t kVPortHeaderSize = 4; +// The shadPS4 team's shadNet server frames its UDP (STUN) packets as a PS4 P2P datagram: +// [0xFF][0x80 | 3][src vport FFFF][dst vport FFFF][payload]. The shadp2p server takes the +// 4-byte vport header above; each server drops the other's frames. +constexpr u8 kServerSignalingHeader[] = {0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF}; constexpr size_t kMaxUdpPayload = 65507; constexpr size_t kMaxP2PPayload = kMaxUdpPayload - kVPortHeaderSize; @@ -54,6 +59,17 @@ struct QueuedPacket { std::vector payload; }; +// Two instances on one machine (SHADPS4_P2P_SAME_HOST=1): the server gives the peer as this +// network's own public address, which reaches it only through a router that loops such traffic +// back; the peer's socket is on this host, so the transport uses loopback for that address. +bool SameHostPeers() { + static const bool enabled = [] { + const char* env = std::getenv("SHADPS4_P2P_SAME_HOST"); + return env != nullptr && *env != '\0' && *env != '0'; + }(); + return enabled; +} + int FailWith(int error) { *Libraries::Kernel::__Error() = error; return -1; @@ -157,7 +173,7 @@ public: LOG_ERROR(Lib_Net, "P2P transport is currently supported on Linux and Windows"); return false; #else - std::lock_guard lock(start_mutex); + std::unique_lock lock(start_mutex); if (running.load(std::memory_order_acquire)) { return true; } @@ -169,8 +185,15 @@ public: } int one = 1; +#ifdef _WIN32 + // bbport: the port is this game's alone; SO_REUSEADDR let a second instance share it + // (incoming packets went to either). + (void)::setsockopt(host_socket, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, + reinterpret_cast(&one), sizeof(one)); +#else (void)::setsockopt(host_socket, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&one), sizeof(one)); +#endif if (!SetSocketNonBlocking(host_socket)) { LOG_ERROR(Lib_Net, "P2P transport failed to make socket nonblocking error={}", LastSocketError()); @@ -196,10 +219,18 @@ public: bind_addr.sin_port = htons(requested_port); if (::bind(host_socket, reinterpret_cast(&bind_addr), sizeof(bind_addr)) < 0) { - LOG_ERROR(Lib_Net, "P2P transport bind(port={}) failed error={}", requested_port, - LastSocketError()); - CloseSocket(host_socket); - return false; + // bbport: the launcher's default port (SHADPS4_P2P_PORT_DEFAULT=1, not one the player + // set) may be taken or reserved: any free port then, rather than no co-op at all. + const char* fallback = std::getenv("SHADPS4_P2P_PORT_DEFAULT"); + bind_addr.sin_port = 0; + if (requested_port == 0 || !fallback || std::strcmp(fallback, "1") != 0 || + ::bind(host_socket, reinterpret_cast(&bind_addr), sizeof(bind_addr)) < 0) { + LOG_ERROR(Lib_Net, "P2P transport bind(port={}) failed error={}", requested_port, + LastSocketError()); + CloseSocket(host_socket); + return false; + } + LOG_WARNING(Lib_Net, "P2P transport: port {} is not available; using another", requested_port); } sockaddr_in actual{}; @@ -217,6 +248,12 @@ public: LOG_INFO(Lib_Net, "P2P transport ready: UDP physical_port={} (set SHADPS4_P2P_PORT to pin it)", physical_port); + // Peers reach this port from outside only through a mapping on the router (no-op until + // the router has been found; UPnPClient asks again then). bbport: outside the start lock: + // it is a round trip to the router, and every sender calls Start(). + const u16 port = physical_port; + lock.unlock(); + UPnPClient::Instance().AddMapping(port); return true; #endif } @@ -304,6 +341,13 @@ public: std::memcpy(framed.data() + kVPortHeaderSize, data, len); } + if (SameHostPeers()) { + const u32 own = Common::Singleton::Instance()->GetExternalIp(); + if (own != 0 && dest_addr_nbo == own) { + dest_addr_nbo = htonl(INADDR_LOOPBACK); + } + } + sockaddr_in dest{}; dest.sin_family = AF_INET; dest.sin_addr.s_addr = dest_addr_nbo; @@ -326,10 +370,21 @@ public: } int SendInternal(const void* data, u32 len, u32 dest_addr_nbo, u16 dest_port_nbo) { + // To the shadNet server: once in each server's framing, as which one it is is not known + // (it answers only its own, and the reply is taken in either). + if (dest_addr_nbo != 0 && dest_addr_nbo == server_addr.load(std::memory_order_relaxed) && + dest_port_nbo == server_port.load(std::memory_order_relaxed)) { + SendServerFrame(data, len, dest_addr_nbo, dest_port_nbo); + } return SendFrame(kSignalingVPortNbo, kSignalingVPortNbo, data, len, dest_addr_nbo, dest_port_nbo); } + void SetServerEndpoint(u32 addr_nbo, u16 port_nbo) { + server_addr.store(addr_nbo, std::memory_order_relaxed); + server_port.store(port_nbo, std::memory_order_relaxed); + } + int ReceiveInternal(InternalChannel channel, void* buf, u32 len, u32* from_addr, u16* from_port) { std::lock_guard lock(internal_mutex); @@ -357,6 +412,26 @@ public: private: P2PTransport() = default; + void SendServerFrame(const void* data, u32 len, u32 dest_addr_nbo, u16 dest_port_nbo) { +#if defined(__linux__) || defined(_WIN32) + if (!Start() || !IsValidSocket(host_socket) || data == nullptr || len == 0 || + len > kMaxUdpPayload - sizeof(kServerSignalingHeader)) { + return; + } + std::vector framed(sizeof(kServerSignalingHeader) + len); + std::memcpy(framed.data(), kServerSignalingHeader, sizeof(kServerSignalingHeader)); + std::memcpy(framed.data() + sizeof(kServerSignalingHeader), data, len); + + sockaddr_in dest{}; + dest.sin_family = AF_INET; + dest.sin_addr.s_addr = dest_addr_nbo; + dest.sin_port = dest_port_nbo; + (void)::sendto(host_socket, reinterpret_cast(framed.data()), + static_cast(framed.size()), 0, + reinterpret_cast(&dest), sizeof(dest)); +#endif + } + std::deque& QueueFor(InternalChannel channel) { switch (channel) { case InternalChannel::Signaling: @@ -424,6 +499,26 @@ private: continue; } + // The other instance of SameHostPeers(): seen at the address the server gives for + // it, so the game and the signaling state know one address for the peer. + if (SameHostPeers() && from.sin_addr.s_addr == htonl(INADDR_LOOPBACK)) { + const u32 own = + Common::Singleton::Instance()->GetExternalIp(); + if (own != 0) { + from.sin_addr.s_addr = own; + } + } + + if (static_cast(rc) >= sizeof(kServerSignalingHeader) && + std::memcmp(buffer.data(), kServerSignalingHeader, + sizeof(kServerSignalingHeader)) == 0) { + const u8* payload = buffer.data() + sizeof(kServerSignalingHeader); + const size_t payload_size = static_cast(rc) - sizeof(kServerSignalingHeader); + PushInternal(ClassifyInternal(payload, payload_size), from.sin_addr.s_addr, + from.sin_port, payload, payload_size); + continue; + } + u16 source_vport_nbo = 0; u16 dest_vport_nbo = 0; std::memcpy(&source_vport_nbo, buffer.data(), sizeof(source_vport_nbo)); @@ -462,6 +557,8 @@ private: net_socket host_socket{kInvalidSocket}; u16 physical_port{0}; + std::atomic server_addr{0}; + std::atomic server_port{0}; std::mutex registry_mutex; std::unordered_map game_sockets; @@ -966,6 +1063,10 @@ bool P2PTransportIsReady() { return P2PTransport::Instance().IsReady(); } +void SetP2PServerEndpoint(u32 addr_nbo, u16 port_nbo) { + P2PTransport::Instance().SetServerEndpoint(addr_nbo, port_nbo); +} + int P2PSignalingSendTo(const void* data, u32 len, u32 dest_addr, u16 dest_port) { return P2PTransport::Instance().SendInternal(data, len, dest_addr, dest_port); } diff --git a/gpu/bbnet/vendor/shadps4/core/libraries/network/sockets.h b/gpu/bbnet/vendor/shadps4/core/libraries/network/sockets.h index 35faa3f..3f899cd 100644 --- a/gpu/bbnet/vendor/shadps4/core/libraries/network/sockets.h +++ b/gpu/bbnet/vendor/shadps4/core/libraries/network/sockets.h @@ -232,6 +232,7 @@ u16 GetP2PConfiguredPort(); u32 GetP2PAdvertisedAddr(); bool EnsureP2PTransport(); bool P2PTransportIsReady(); +void SetP2PServerEndpoint(u32 addr_nbo, u16 port_nbo); int P2PSignalingSendTo(const void* data, u32 len, u32 dest_addr, u16 dest_port); int P2PSignalingRecvFrom(void* buf, u32 len, u32* from_addr, u16* from_port); int P2PControlSendTo(const void* data, u32 len, u32 dest_addr, u16 dest_port); diff --git a/gpu/bbnet/vendor/shadps4/core/libraries/np/np_handler.cpp b/gpu/bbnet/vendor/shadps4/core/libraries/np/np_handler.cpp index d9ce291..c436270 100644 --- a/gpu/bbnet/vendor/shadps4/core/libraries/np/np_handler.cpp +++ b/gpu/bbnet/vendor/shadps4/core/libraries/np/np_handler.cpp @@ -56,7 +56,7 @@ std::pair NpHandler::ParseServerAddress() const { return {host, port}; } -bool NpHandler::ConnectUserById(s32 user_id) { +bool NpHandler::ConnectUserById(s32 user_id, bool announce) { if (!EmulatorSettings.IsShadNetEnabled()) return false; @@ -82,7 +82,7 @@ bool NpHandler::ConnectUserById(s32 user_id) { const auto [host, port] = ParseServerAddress(); return ConnectUser(u->user_id, host, port, u->shadnet_npid, u->shadnet_password, - u->shadnet_token); + u->shadnet_token, announce); } void NpHandler::StartWorker() { @@ -195,7 +195,7 @@ void NpHandler::Shutdown() { } bool NpHandler::ConnectUser(s32 user_id, const std::string& host, u16 port, const std::string& npid, - const std::string& password, const std::string& token) { + const std::string& password, const std::string& token, bool announce) { LOG_INFO(NpHandler, "Connecting user_id={} npid='{}' to {}:{} (timeout {}s)", user_id, npid, host, port, ShadNet::SHAD_CONNECT_TIMEOUT_MS / 1000); @@ -272,6 +272,11 @@ bool NpHandler::ConnectUser(s32 user_id, const std::string& host, u16 port, cons if (auth_state != ShadNet::ShadNetState::Ok) { LOG_ERROR(NpHandler, "user_id={} authentication failed (state={})", user_id, static_cast(auth_state)); + { + std::lock_guard lock(m_mutex_clients); + m_connect_failure[user_id] = auth_state; + m_connect_rejected[user_id] = client->WasRejected(); + } handle_protocol_mismatch(auth_state); client->Stop(); return false; @@ -293,10 +298,14 @@ bool NpHandler::ConnectUser(s32 user_id, const std::string& host, u16 port, cons SetNpId(np_id, npid); std::lock_guard lock(m_mutex_clients); m_np_ids[user_id] = np_id; + m_account_ids[user_id] = static_cast(client->GetUserId()); + m_connect_failure.erase(user_id); + m_connect_rejected.erase(user_id); m_clients[user_id] = std::move(client); } - FireStateCallback(user_id, NpManager::OrbisNpState::SignedIn); + if (announce) + FireStateCallback(user_id, NpManager::OrbisNpState::SignedIn); return true; } @@ -309,7 +318,7 @@ void NpHandler::SetAppearOffline(bool enable) { } } -void NpHandler::DisconnectUser(s32 user_id) { +void NpHandler::DisconnectUser(s32 user_id, bool report) { std::shared_ptr client; { std::lock_guard lock(m_mutex_clients); @@ -318,13 +327,16 @@ void NpHandler::DisconnectUser(s32 user_id) { return; client = std::move(it->second); m_clients.erase(it); + if (report) + m_account_ids.erase(user_id); } - { + if (report) { // a network drop being reconnected keeps the friend list std::lock_guard lock(m_mutex_friend_state); m_friend_state.erase(user_id); } client->Stop(); - FireStateCallback(user_id, NpManager::OrbisNpState::SignedOut); + if (report) + FireStateCallback(user_id, NpManager::OrbisNpState::SignedOut); LOG_INFO(NpHandler, "user_id={} disconnected", user_id); } @@ -348,37 +360,64 @@ void NpHandler::WorkerThread() { std::this_thread::sleep_for(INTERVAL); // Collect ids of dropped clients - std::vector dropped; + std::vector dropped, rejected; { std::lock_guard lock(m_mutex_clients); for (auto& [uid, client] : m_clients) { if (!client->IsConnected()) - dropped.push_back(uid); + (client->WasRejected() ? rejected : dropped).push_back(uid); } } + // bbport: turned away by the server (its message is in the log): signed out now, and no + // reconnecting into its login cooldown. + for (s32 uid : rejected) { + LOG_WARNING(NpHandler, "user_id={} turned away by the shadNet server; signed out", uid); + DisconnectUser(uid); + } + for (s32 uid : dropped) { - LOG_WARNING(NpHandler, "user_id={} connection dropped (network); will retry", uid); - DisconnectUser(uid); // reports SignedOut + stops/removes the dead client - MarkForReconnect(uid); // schedule transparent reconnect (network drop, not logout) + // In a room or waiting on a matching reply, the session is lost with the connection: + // the game is told (as before), and it gets SignedIn again when the reconnect comes. + const bool busy = NpMatching2::MmSessionBusy(); + if (busy) { + LOG_WARNING(NpHandler, + "user_id={} connection dropped (network) during co-op or matchmaking: " + "signed out; reconnecting", + uid); + } else { + LOG_WARNING(NpHandler, + "user_id={} connection dropped (network); reconnecting - still signed " + "in for the game for up to {}s", + uid, kReconnectGrace.count()); + } + MarkForReconnect(uid, busy); // first: IsPsnSignedIn sees no gap + DisconnectUser(uid, busy); // stops/removes the dead client } TryReconnect(); + ExpireReconnectGrace(); } } -void NpHandler::MarkForReconnect(s32 user_id) { +void NpHandler::MarkForReconnect(s32 user_id, bool reported_out) { if (!EmulatorSettings.IsShadNetEnabled()) return; // offline mode: nothing to reconnect to constexpr auto kInitialBackoff = std::chrono::milliseconds(2000); std::lock_guard lock(m_mutex_clients); + const bool already = m_reconnect.count(user_id) != 0; auto& st = m_reconnect[user_id]; st.backoff = kInitialBackoff; st.next_attempt = std::chrono::steady_clock::now() + st.backoff; + if (!already) { + st.since = std::chrono::steady_clock::now(); + st.reported_out = reported_out; + } } void NpHandler::TryReconnect() { - constexpr auto kMaxBackoff = std::chrono::milliseconds(30000); + // bbport: 10 s at most between attempts (was 30): within the grace, several of them. + constexpr auto kMaxBackoff = std::chrono::milliseconds(10000); const auto now = std::chrono::steady_clock::now(); std::vector due; @@ -398,14 +437,48 @@ void NpHandler::TryReconnect() { m_reconnect.erase(uid); continue; } + // The game hears of the reconnect only if it was told of the drop (the grace ran out). + bool told = true; + { + std::lock_guard lock(m_mutex_clients); + if (auto it = m_reconnect.find(uid); it != m_reconnect.end()) + told = it->second.reported_out; + } // ConnectUserById locks m_mutex_clients internally; call it unlocked. - const bool ok = ConnectUserById(uid); - std::lock_guard lock(m_mutex_clients); + const bool ok = ConnectUserById(uid, told); + std::unique_lock lock(m_mutex_clients); + bool refused = false; // a refused login: wait out shadNet's cooldown + // bbport: a failure retrying can't fix - a wrong name, password or token, or the server + // turning this client away - ends the reconnect (signed out, if the game wasn't told yet). + if (!ok && !m_clients.count(uid)) { + const auto failure = m_connect_failure.count(uid) ? m_connect_failure[uid] + : ShadNet::ShadNetState::Ok; + const bool rejected = m_connect_rejected.count(uid) && m_connect_rejected[uid]; + if (rejected || failure == ShadNet::ShadNetState::FailureUsername || + failure == ShadNet::ShadNetState::FailurePassword || + failure == ShadNet::ShadNetState::FailureToken) { + auto it = m_reconnect.find(uid); + const bool tell = it != m_reconnect.end() && !it->second.reported_out; + if (it != m_reconnect.end()) + m_reconnect.erase(it); + m_account_ids.erase(uid); + lock.unlock(); + LOG_WARNING(NpHandler, "user_id={} cannot sign in to shadNet again ({}); signed out", + uid, rejected ? "the server turned this client away" : "account rejected"); + if (tell) + FireStateCallback(uid, NpManager::OrbisNpState::SignedOut); + continue; + } + // A refused login (shadNet's cooldown after a rejection, 120 s): wait it out. + refused = failure == ShadNet::ShadNetState::FailureAuth; + } if (ok || m_clients.count(uid)) { m_reconnect.erase(uid); - LOG_INFO(NpHandler, "user_id={} reconnected to shadNet", uid); + LOG_INFO(NpHandler, "user_id={} reconnected to shadNet{}", uid, + told ? "" : " (the game stayed signed in)"); } else if (auto it = m_reconnect.find(uid); it != m_reconnect.end()) { - it->second.backoff = std::min(it->second.backoff * 2, kMaxBackoff); + it->second.backoff = refused ? std::chrono::milliseconds(60000) + : std::min(it->second.backoff * 2, kMaxBackoff); it->second.next_attempt = std::chrono::steady_clock::now() + it->second.backoff; LOG_DEBUG(NpHandler, "user_id={} reconnect failed; next attempt in {}ms", uid, it->second.backoff.count()); @@ -416,7 +489,33 @@ void NpHandler::TryReconnect() { bool NpHandler::IsPsnSignedIn(s32 user_id) const { std::lock_guard lock(m_mutex_clients); auto it = m_clients.find(user_id); - return it != m_clients.end() && it->second->IsAuthenticated(); + if (it != m_clients.end() && it->second->IsAuthenticated()) + return true; + // bbport: dropped a moment ago, not yet taken by the worker (it looks every 500 ms). + if (it != m_clients.end() && it->second->WasAuthenticated() && !it->second->WasRejected()) + return true; + // bbport: reconnecting after a network drop, within the grace: still signed in for the game. + const auto r = m_reconnect.find(user_id); + return r != m_reconnect.end() && !r->second.reported_out; +} + +void NpHandler::ExpireReconnectGrace() { + const auto now = std::chrono::steady_clock::now(); + std::vector expired; + { + std::lock_guard lock(m_mutex_clients); + for (auto& [uid, st] : m_reconnect) { + if (!st.reported_out && now - st.since >= kReconnectGrace) { + st.reported_out = true; + expired.push_back(uid); + } + } + } + for (s32 uid : expired) { + LOG_WARNING(NpHandler, "user_id={} could not reconnect to shadNet within {}s: signed out", + uid, kReconnectGrace.count()); + FireStateCallback(uid, NpManager::OrbisNpState::SignedOut); + } } bool NpHandler::IsAnySignedIn() const { @@ -446,7 +545,11 @@ std::string NpHandler::GetAvatarUrl(s32 user_id) const { OrbisNpAccountId NpHandler::GetAccountId(s32 user_id) const { std::lock_guard lock(m_mutex_clients); auto it = m_clients.find(user_id); - return it != m_clients.end() ? static_cast(it->second->GetUserId()) : 0; + if (it != m_clients.end()) + return static_cast(it->second->GetUserId()); + // bbport: reconnecting within the grace: the account id the game was given at sign-in. + auto kept = m_account_ids.find(user_id); + return kept != m_account_ids.end() ? static_cast(kept->second) : 0; } std::string NpHandler::GetBearerToken(s32 user_id) const { @@ -683,6 +786,10 @@ s32 NpHandler::GetUserIdByAccountId(u64 account_id) const { if (static_cast(client->GetUserId()) == account_id) return uid; } + for (auto& [uid, id] : m_account_ids) { // reconnecting within the grace + if (id == account_id) + return uid; + } return -1; } diff --git a/gpu/bbnet/vendor/shadps4/core/libraries/np/np_handler.h b/gpu/bbnet/vendor/shadps4/core/libraries/np/np_handler.h index 38dc51e..2fdad20 100644 --- a/gpu/bbnet/vendor/shadps4/core/libraries/np/np_handler.h +++ b/gpu/bbnet/vendor/shadps4/core/libraries/np/np_handler.h @@ -221,10 +221,16 @@ private: /// Connect one user. Blocks until connected+authenticated or failed. bool ConnectUser(s32 user_id, const std::string& host, u16 port, const std::string& npid, - const std::string& password, const std::string& token); + const std::string& password, const std::string& token, bool announce = true); // Connect a single logged-in user by id (looks up credentials, parses server). - bool ConnectUserById(s32 user_id); + /// `announce`: tell the game it signed in (not after a drop it was never told of). + bool ConnectUserById(s32 user_id, bool announce = true); + /// bbport: why this user's last connect failed for good (bad name/password/token, or the + /// server turned the client away), else Ok; and whether it was a login refusal (cooldown). + std::unordered_map m_connect_failure; + std::unordered_map m_connect_rejected; + std::unordered_map m_account_ids; // kept through a reconnect's grace // Parse the configured shadNet server "host:port" (default port 31313). std::pair ParseServerAddress() const; @@ -233,11 +239,14 @@ private: void StartWorker(); // Disconnect and remove one user's client. - void DisconnectUser(s32 user_id); + /// `report`: tell the game it signed out (not for a network drop being reconnected). + void DisconnectUser(s32 user_id, bool report = true); + /// bbport: a dropped user whose reconnect has not come within the grace: signed out now. + void ExpireReconnectGrace(); void WorkerThread(); // Transparent reconnect after a network drop - void MarkForReconnect(s32 user_id); + void MarkForReconnect(s32 user_id, bool reported_out = false); void TryReconnect(); void FireStateCallback(s32 user_id, NpManager::OrbisNpState state); @@ -297,11 +306,16 @@ private: std::atomic m_worker_running{false}; std::thread m_worker_thread; - // Users dropped by a network error and awaiting transparent reconnect + // Users dropped by a network error and awaiting transparent reconnect. bbport: for + // kReconnectGrace the game is not told: it stays signed in (IsPsnSignedIn), so a moment's + // drop of the server's connection no longer returns it to the title menu mid-play. struct ReconnectState { std::chrono::steady_clock::time_point next_attempt{}; std::chrono::milliseconds backoff{0}; + std::chrono::steady_clock::time_point since{}; + bool reported_out = false; // the grace ran out: the game was told it signed out }; + static constexpr std::chrono::seconds kReconnectGrace{90}; std::unordered_map m_reconnect; mutable std::mutex m_mutex_pending_invites; diff --git a/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_internal.cpp b/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_internal.cpp index 34f20a2..97e4737 100644 --- a/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_internal.cpp +++ b/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_internal.cpp @@ -1156,6 +1156,15 @@ s32 ContextManager::CreateContext(const OrbisNpId* owner_np_id, OrbisNpServiceLa return ORBIS_OK; } +bool ContextManager::AnyInRoom() { + std::lock_guard lock(m_mutex); + for (size_t i = 0; i < m_contexts.size(); ++i) { + if (m_used[i] && !m_contexts[i].room_cache.empty()) + return true; + } + return false; +} + bool ContextManager::Check(OrbisNpMatching2ContextId ctx_id) { std::lock_guard lock(m_mutex); return GetLocked(ctx_id) != nullptr; diff --git a/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_internal.h b/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_internal.h index 3e25b94..a8c82af 100644 --- a/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_internal.h +++ b/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_internal.h @@ -332,6 +332,9 @@ public: void ApplyContextCallback(OrbisNpMatching2ContextCallback callback, void* arg); + /// bbport: a context of this run is in a room (co-op, an invasion, a bell). + bool AnyInRoom(); + void Reset(); private: diff --git a/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_mm.cpp b/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_mm.cpp index 94882fd..3a583d9 100644 --- a/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_mm.cpp +++ b/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_mm.cpp @@ -673,6 +673,7 @@ void SetMmShadNetClient(std::shared_ptr client, } LOG_INFO(Lib_NpMatching2, "ShadNet features: matching2_enabled={}", matching2_enabled); Net::UPnPClient::Instance().SetP2PFeaturesEnabled(matching2_enabled); + Net::SetP2PServerEndpoint(client ? server_addr : 0, client ? server_udp_port : 0); if (!client) { NpSignaling::Stubs::SetTransportHooks({}); @@ -731,6 +732,15 @@ void ClearMmShadNetClient() { g_mm.sig_cv.notify_all(); } +bool MmSessionBusy() { + { + std::lock_guard lock(g_mm.pending_mutex); + if (!g_mm.pending.empty()) + return true; + } + return ContextManager::Instance().AnyInRoom(); +} + bool IsMmClientRunning() { std::lock_guard lock(g_mm.mutex); return g_mm.client && g_mm.client->IsAuthenticated(); @@ -1215,6 +1225,8 @@ bool RequestSignalingInfos(std::string_view target_online_id, u32* out_addr, u16 g_mm.sig_replies.erase(pkt_id); } if (reply.first != ShadNet::ErrorType::NoError) { + LOG_WARNING(Lib_NpMatching2, "'{}': server error {}", target_online_id, + static_cast(reply.first)); return false; } diff --git a/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_mm.h b/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_mm.h index b747071..5245ac8 100644 --- a/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_mm.h +++ b/gpu/bbnet/vendor/shadps4/core/libraries/np/np_matching2/np_matching2_mm.h @@ -41,6 +41,8 @@ void SetMmShadNetClient(std::shared_ptr client, std::string_view server_host, u16 tcp_port); void ClearMmShadNetClient(); bool IsMmClientRunning(); +/// bbport: in a room or waiting for a matching reply: a dropped connection loses that session. +bool MmSessionBusy(); void OnMatchingReply(ShadNet::CommandType cmd, u64 pkt_id, ShadNet::ErrorType error, const std::vector& body); diff --git a/gpu/bbnet/vendor/shadps4/shadnet/client.cpp b/gpu/bbnet/vendor/shadps4/shadnet/client.cpp index 7088df3..1b8a081 100644 --- a/gpu/bbnet/vendor/shadps4/shadnet/client.cpp +++ b/gpu/bbnet/vendor/shadps4/shadnet/client.cpp @@ -3,6 +3,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include +#include #include #include @@ -79,10 +80,7 @@ void ShadNetClient::Stop() { m_sem_connected.release(); } catch (...) { } - try { - m_sem_authenticated.release(); - } catch (...) { - } + SignalAuth(); { std::lock_guard lock(m_mutex_send_queue); m_cv_send_queue.notify_all(); @@ -113,7 +111,15 @@ ShadNetState ShadNetClient::WaitForAuthenticated() { return ShadNetState::Ok; } m_sem_authenticated.acquire(); - return m_authenticated ? ShadNetState::Ok : m_state.load(); + // bbport: signed in only if still connected; a failure without a reason is FailureOther. + if (m_authenticated && m_connected) + return ShadNetState::Ok; + return m_state == ShadNetState::Ok ? ShadNetState::FailureOther : m_state.load(); +} + +void ShadNetClient::SignalAuth() { + if (!m_auth_signalled.exchange(true)) + m_sem_authenticated.release(); } bool ShadNetClient::IsConnected() const { @@ -184,7 +190,7 @@ void ShadNetClient::ConnectThread() { if (!connected) { // m_state holds the last failure reason from DoConnect. m_sem_connected.release(); - m_sem_authenticated.release(); + SignalAuth(); return; } @@ -244,13 +250,13 @@ void ShadNetClient::ReaderThread() { DispatchPacket(ptype, cmd_raw, pkt_id, payload); } - if (!m_authenticated) { - if (m_state == ShadNetState::Ok) - m_state = ShadNetState::FailureOther; - m_sem_authenticated.release(); - } + // bbport: a sign-in still waiting is released whatever happened (before, only when the login + // reply had not come: a close right after it, before the server features reply, hung it). + if (!m_auth_signalled && m_state == ShadNetState::Ok) + m_state = ShadNetState::FailureOther; m_connected = false; m_authenticated = false; + SignalAuth(); LOG_INFO(ShadNet, "ReaderThread exiting"); } @@ -524,6 +530,31 @@ u64 ShadNetClient::SetAppearOffline(bool enable) { return SubmitRequest(CommandType::SetAppearOffline, MakeProtoPayload(req)); } +// bbport: right after signing in, what this client is (SetClientVersion, +// SetClientVersionRequest { string version = 1; }). shadNet 0.0.34 checks it against its +// clientversions.cfg and signs out, after 15 s, a client that reports nothing - every session of +// a build from before it. BB_CLIENT_VERSION is the launcher's ("BloodBorneButPc v0.9.12"): this +// client says what it is, never another emulator's name. +void ShadNetClient::SendClientVersion() { + const char* env = std::getenv("BB_CLIENT_VERSION"); + const std::string version = env && *env ? env : "BloodBorneButPc"; + std::string proto(1, '\x0a'); // field 1, length-delimited + for (u32 n = static_cast(version.size());; n >>= 7) { + if (n < 0x80) { + proto.push_back(static_cast(n)); + break; + } + proto.push_back(static_cast((n & 0x7f) | 0x80)); + } + proto += version; + const u32 len = static_cast(proto.size()); + std::vector payload{static_cast(len), static_cast(len >> 8), + static_cast(len >> 16), static_cast(len >> 24)}; + payload.insert(payload.end(), proto.begin(), proto.end()); + SubmitRequest(CommandType::SetClientVersion, payload); + LOG_INFO(ShadNet, "Client version reported: '{}'", version); +} + bool ShadNetClient::RequestServerFeatures() { const u64 pkt_id = m_pkt_counter.fetch_add(1); std::vector empty_payload; @@ -620,8 +651,10 @@ void ShadNetClient::HandleLoginReply(const std::vector& payload) { m_friends = res.friends; } m_authenticated = true; + m_was_authenticated = true; LOG_INFO(ShadNet, "Logged in npid='{}' userId={} friends={}", m_npid, m_user_id, m_friends.size()); + SendClientVersion(); const u64 pkt_id = m_pkt_counter.fetch_add(1); std::vector empty_payload; @@ -636,7 +669,7 @@ void ShadNetClient::HandleLoginReply(const std::vector& payload) { res.error = ErrorType::Malformed; LOG_ERROR(ShadNet, "Failed to parse LoginReply proto"); m_state = ShadNetState::FailureProtocol; - m_sem_authenticated.release(); + SignalAuth(); DoDisconnect(); } } else { @@ -658,7 +691,7 @@ void ShadNetClient::HandleLoginReply(const std::vector& payload) { break; } LOG_ERROR(ShadNet, "Login rejected error code {}", static_cast(res.error)); - m_sem_authenticated.release(); + SignalAuth(); DoDisconnect(); } } @@ -866,6 +899,34 @@ void ShadNetClient::HandleNotification(u16 cmd_raw, const std::vector& paylo onRoomMessage(n); break; } + case NotificationType::ClientRejected: { + // NotifyClientRejected { uint32 reason = 1; string message = 2; string version = 3; }: + // the message is shown in the log; the server disconnects right after. + std::string message; + for (size_t i = 0; i < blob.size();) { + const u8 key = static_cast(blob[i++]); + u64 value = 0; + for (int shift = 0; i < blob.size() && shift < 64; shift += 7) { + const u8 b = static_cast(blob[i++]); + value |= u64(b & 0x7f) << shift; + if (!(b & 0x80)) + break; + } + if ((key & 7) == 2) { + const size_t n = std::min(value, blob.size() - i); + if ((key >> 3) == 2) + message.assign(blob, i, n); + i += n; + } else if ((key & 7) != 0) { + break; + } + } + m_rejected = true; + m_state = ShadNetState::FailureAuth; + LOG_ERROR(ShadNet, "The shadNet server turned this client away: {}", + message.empty() ? std::string("(no reason given)") : message); + break; + } case NotificationType::WebApiPushEvent: { // Length-prefixed fields: npServiceName, npServiceLabel(u32 LE), dataType, // data, fromNpid, toNpid. ExtractBlob returns {} past the end, so a short diff --git a/gpu/bbnet/vendor/shadps4/shadnet/client.h b/gpu/bbnet/vendor/shadps4/shadnet/client.h index 5c0d041..2404d93 100644 --- a/gpu/bbnet/vendor/shadps4/shadnet/client.h +++ b/gpu/bbnet/vendor/shadps4/shadnet/client.h @@ -67,6 +67,7 @@ enum class CommandType : u16 { AddBlock = 10, RemoveBlock = 11, GetServerFeatures = 12, + SetClientVersion = 13, // bbport: shadNet 0.0.34 signs out clients that report none GetBoardInfos = 30, RecordScore = 31, RecordScoreData = 32, @@ -105,6 +106,7 @@ enum class NotificationType : u16 { RoomEvent = 10, RoomMessage = 11, WebApiPushEvent = 17, // Generic NP WebApi push event + ClientRejected = 19, // bbport: the server won't keep this client signed in (its message), then disconnects }; enum class ErrorType : uint8_t { @@ -268,6 +270,11 @@ public: bool IsConnected() const; bool IsAuthenticated() const; + /// bbport: the server turned this client away (notification 19): reconnecting only meets + /// its login cooldown. + bool WasRejected() const { + return m_rejected; + } ShadNetState GetState() const; const std::string& GetAvatarUrl() const; @@ -350,6 +357,21 @@ private: std::atomic m_terminate{false}; std::atomic m_connected{false}; std::atomic m_authenticated{false}; + std::atomic m_rejected{false}; + void SendClientVersion(); + // bbport: WaitForAuthenticated is released exactly once, on every way the sign-in can end + // (a close between the login reply and the server features reply left it waiting forever). + std::atomic m_auth_signalled{false}; + std::atomic m_was_authenticated{false}; + void SignalAuth(); + +public: + /// bbport: this client signed in once (it may since have dropped). + bool WasAuthenticated() const { + return m_was_authenticated; + } + +private: std::atomic m_state{ShadNetState::Ok}; std::binary_semaphore m_sem_connected{0}; diff --git a/gpu/shadps4/common/lru_cache.h b/gpu/shadps4/common/lru_cache.h index 4204fad..9062936 100644 --- a/gpu/shadps4/common/lru_cache.h +++ b/gpu/shadps4/common/lru_cache.h @@ -55,8 +55,10 @@ public: template void ForEachItemBelow(TickType tick, Func&& func) { + // bbport: invoke_result_t, not invoke_result (a struct, never bool): the callbacks' + // "stop" never stopped the walk, so every GC pass visited every old entry. static constexpr bool RETURNS_BOOL = - std::is_same_v, bool>; + std::is_same_v, bool>; Item* iterator = first_item; while (iterator) { if (static_cast(tick) - static_cast(iterator->tick) < 0) { diff --git a/gpu/shadps4/common/slot_vector.h b/gpu/shadps4/common/slot_vector.h index 2f693fb..fe68939 100644 --- a/gpu/shadps4/common/slot_vector.h +++ b/gpu/shadps4/common/slot_vector.h @@ -119,6 +119,12 @@ public: return ReadStorageBit(id.index); } + /// bbport: where slot `id` is, or null past the capacity: for a cache prefetch from another + /// thread (the storage pointer is read without a lock; nothing is read through the result). + [[nodiscard]] const T* SlotAddress(SlotId id) const noexcept { + return id.index < values_capacity ? &values[id.index].object : nullptr; + } + template SlotId insert(Args&&... args) noexcept { const u32 index = FreeValueIndex(); diff --git a/gpu/shadps4/common/thread.cpp b/gpu/shadps4/common/thread.cpp index e2061f7..724dbe2 100644 --- a/gpu/shadps4/common/thread.cpp +++ b/gpu/shadps4/common/thread.cpp @@ -3,6 +3,9 @@ // SPDX-FileCopyrightText: Copyright 2026 shadPS4 Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include +#include +#include #include #include #include @@ -35,8 +38,41 @@ #define cpu_set_t cpuset_t #endif +#if defined(_WIN32) && !defined(CREATE_WAITABLE_TIMER_HIGH_RESOLUTION) +// Older MinGW headers (or _WIN32_WINNT < 0x0600) lack it; Windows 10 1803+ understands it. +#define CREATE_WAITABLE_TIMER_HIGH_RESOLUTION 0x00000002 +#endif + namespace Common { +namespace { +// Lateness of AccurateSleep's timer wakeups (ns), averaged over the last ~16 precise sleeps. +std::atomic g_timer_late_ns{300'000}; +} // namespace + +std::chrono::nanoseconds PreciseSpin(std::chrono::nanoseconds cap) { + const auto spin = std::chrono::nanoseconds(g_timer_late_ns.load(std::memory_order_relaxed) + + 100'000); + return std::clamp(spin, std::chrono::microseconds(100), + std::max( + cap, std::chrono::microseconds(100))); +} + +bool PrecisePacingEnabled() { + static const bool value = [] { + const char* v = std::getenv("BB_PRECISE_PACING"); + if (v && v[0] != '\0') { + return v[0] != '0' && v[0] != 'n' && v[0] != 'N' && v[0] != 'f' && v[0] != 'F'; + } +#ifdef _WIN32 + return true; +#else + return false; // Linux keeps its original pacing unless asked. +#endif + }(); + return value; +} + #ifdef __APPLE__ void SetCurrentThreadRealtime(const std::chrono::nanoseconds period_ns) { @@ -109,6 +145,58 @@ void SetCurrentThreadPriority(ThreadPriority new_priority) { bool AccurateSleep(const std::chrono::nanoseconds duration, std::chrono::nanoseconds* remaining, const bool interruptible) { + if (PrecisePacingEnabled()) { + // Adapted from IFreemz/shadPS4-Bloodborne-DLSS-FSR (f296a02, src/common/thread.cpp), + // GPL-2.0-or-later. Same approach as host_sleep_until_ns in src/runtime_host.c. + const auto begin = std::chrono::steady_clock::now(); + DWORD ret = WAIT_OBJECT_0; + if (duration.count() <= 0) { + // A zero sleep is a yield. + SwitchToThread(); + } else { + // One timer per thread, high resolution where available (Windows 10 1803+): a plain + // waitable timer rounds up to the system timer period (1 ms at best), and creating + // one per call costs two more system calls. The handle lives as long as the process + // (one per thread that sleeps here; only the present thread does). + thread_local HANDLE timer = [] { + HANDLE handle = CreateWaitableTimerExW( + nullptr, nullptr, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS); + if (!handle) { + handle = CreateWaitableTimerExW(nullptr, nullptr, 0, TIMER_ALL_ACCESS); + } + return handle; + }(); + LARGE_INTEGER interval{ + .QuadPart = -std::max(1, (duration.count() + 99) / 100), + }; + if (timer && SetWaitableTimer(timer, &interval, 0, nullptr, nullptr, FALSE)) { + ret = WaitForSingleObjectEx(timer, INFINITE, interruptible ? TRUE : FALSE); + } else { + ret = SleepEx(static_cast((duration.count() + 999'999) / 1'000'000), + interruptible ? TRUE : FALSE) == 0 + ? WAIT_OBJECT_0 + : WAIT_IO_COMPLETION; + } + } + if (duration.count() > 0 && ret == WAIT_OBJECT_0) { + // How late the timer woke (for PreciseSpin): a running average, outliers clamped. + const auto late = std::chrono::duration_cast( + std::chrono::steady_clock::now() - begin - duration) + .count(); + const s64 sample = std::clamp(late, 0, 2'000'000); + const s64 old = g_timer_late_ns.load(std::memory_order_relaxed); + g_timer_late_ns.store(old + (sample - old) / 16, std::memory_order_relaxed); + } + if (remaining) { + const auto slept = std::chrono::steady_clock::now() - begin; + *remaining = duration > slept + ? std::chrono::duration_cast(duration - slept) + : std::chrono::nanoseconds(0); + } + return ret == WAIT_OBJECT_0; + } + + // The original sleep (a new waitable timer per call); BB_PRECISE_PACING=0. const auto begin_sleep = std::chrono::high_resolution_clock::now(); LARGE_INTEGER interval{ @@ -230,7 +318,24 @@ AccurateTimer::AccurateTimer(std::chrono::nanoseconds target_interval) void AccurateTimer::Start() { const auto begin_sleep = std::chrono::high_resolution_clock::now(); if (total_wait.count() > 0) { - AccurateSleep(total_wait, nullptr, false); + if (PrecisePacingEnabled()) { + // Adapted from IFreemz/shadPS4-Bloodborne-DLSS-FSR (1f75c63), GPL-2.0-or-later. + // OS timers wake up late. This timer paces vblank and therefore the frames a + // variable refresh rate display shows, so sleep most of the wait and spin the rest + // to keep every interval even (at most 1.5 ms, and 1/8 of the interval). + const std::chrono::nanoseconds spin = PreciseSpin( + std::min(std::chrono::microseconds(1500), + target_interval / 8)); + if (total_wait > spin) { + AccurateSleep(total_wait - spin, nullptr, false); + } + const auto deadline = begin_sleep + total_wait; + while (std::chrono::high_resolution_clock::now() < deadline) { + std::this_thread::yield(); + } + } else { + AccurateSleep(total_wait, nullptr, false); + } } start_time = std::chrono::high_resolution_clock::now(); total_wait -= std::chrono::duration_cast(start_time - begin_sleep); diff --git a/gpu/shadps4/common/thread.h b/gpu/shadps4/common/thread.h index a300d10..ae628fd 100644 --- a/gpu/shadps4/common/thread.h +++ b/gpu/shadps4/common/thread.h @@ -29,6 +29,16 @@ void SetThreadName(void* thread, const char* name); bool AccurateSleep(std::chrono::nanoseconds duration, std::chrono::nanoseconds* remaining, bool interruptible); +/// bbport: precise frame pacing (high-resolution timer sleeps plus a short spin, vblank ticks +/// on a fixed schedule, a minimum gap between flips). On by default on Windows, off elsewhere; +/// BB_PRECISE_PACING=0 / =1 overrides. +bool PrecisePacingEnabled(); + +/// bbport: the spin a precise wait needs after its timer sleep: how late the high-resolution +/// timer has been waking (a running average of AccurateSleep's lateness) plus 100 us, at least +/// 100 us and at most `cap`. A fixed spin either burns a core every wait or wakes late. +std::chrono::nanoseconds PreciseSpin(std::chrono::nanoseconds cap); + class AccurateTimer { std::chrono::nanoseconds target_interval{}; std::chrono::nanoseconds total_wait{}; diff --git a/gpu/shadps4/core/libraries/videoout/driver.cpp b/gpu/shadps4/core/libraries/videoout/driver.cpp index 30de04f..f438980 100644 --- a/gpu/shadps4/core/libraries/videoout/driver.cpp +++ b/gpu/shadps4/core/libraries/videoout/driver.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include +#include #include #include #include @@ -33,6 +34,7 @@ extern "C" void runtime_wait_report(double frames); #include "core/libraries/videoout/videoout_error.h" #include "imgui/renderer/imgui_core.h" #include "video_core/amdgpu/liverpool.h" +#include "video_core/renderer_vulkan/pipeline_compile_queue.h" #include "video_core/renderer_vulkan/vk_breadcrumbs.h" #include "video_core/renderer_vulkan/vk_instance.h" #include "video_core/renderer_vulkan/vk_presenter.h" @@ -372,6 +374,23 @@ static void PrintMemory() { #endif } +/// bbport: the game's RAM and the PC's, once a minute in every session: steady growth over a +/// session is a leak, and a crash right after "free" ran out is the PC running out of memory. +static void LogRam() { + BbPlatform::Memory m; + if (!BbPlatform::GetMemory(m)) { + return; + } + const auto mib = [](u64 bytes) { return static_cast(bytes >> 20); }; + std::printf("Memory: game %llu MiB in RAM (%llu MiB committed, peak %llu MiB); PC %llu of %llu " + "MiB free, %llu MiB commit left; GPU images %llu MiB, %llu MiB cached in %llu\n", + mib(m.working_set), mib(m.private_bytes), mib(m.peak_working_set), mib(m.free_phys), + mib(m.total_phys), mib(m.commit_left), mib(BbStats::vk_image_bytes.load()), + mib(BbStats::live_image_bytes.load()), + static_cast(BbStats::live_images.load())); + std::fflush(stdout); +} + void VideoOutDriver::Flip(const Request& req) { // Update HDR status before presenting, then present the frame (bbport: on the swap thread). RunPresenter([this, frame = req.frame, hdr = req.port->is_hdr] { @@ -381,6 +400,14 @@ void VideoOutDriver::Flip(const Request& req) { Vulkan::FrameCapture::OnFlip(req.index >= 0 ? req.port->buffer_slots[req.index].address_left : 0); + { + static auto next_ram = std::chrono::steady_clock::now() + std::chrono::seconds(30); + if (const auto now = std::chrono::steady_clock::now(); now >= next_ram) { + next_ram = now + std::chrono::seconds(60); + LogRam(); + } + } + // bbport: BB_FRAME_STATS=1 prints flip rate and frame time spread every 5 seconds. static const bool frame_stats = EmulatorSettingsImpl::Flag("BB_FRAME_STATS", false); if (frame_stats) { @@ -587,6 +614,85 @@ void VideoOutDriver::Flip(const Request& req) { BbStats::tick_wait_ns.exchange(0) / (window * 1e7), frames ? double(BbStats::reduced_draws.exchange(0)) / frames : 0.0, frames ? double(BbStats::scene_draws.exchange(0)) / frames : 0.0); + // bbport: where host copies and deferred fences were waited for (by call site, mostly + // the draw recording thread's tasks), and the page protection changes of the draw + // recording thread and of the others (BB_DMA_WAITS, BB_FENCE_ORDER, BB_DEFER_PROTECT). + { + const double f = frames ? double(frames) : 1.0; + const auto per_frame = [&](std::atomic& counter) { + return double(counter.exchange(0)) / f; + }; + const auto ms_per_frame = [&](std::atomic& ns) { + return double(ns.exchange(0)) / (f * 1e6); + }; + std::printf("Stage B waits: fences"); + for (u32 s = 0; s < BbStats::WaitSites; ++s) { + const double n = per_frame(BbStats::signal_waits[s]); + const double ms = ms_per_frame(BbStats::signal_wait_ns[s]); + const double skipped = per_frame(BbStats::signal_waits_skipped[s]); + if (n + skipped > 0.0) { + std::printf(" %s %.1f/frame %.3f ms", BbStats::WaitSiteNames[s], n, ms); + if (skipped > 0.0) { + std::printf(" (%.1f/frame not needed)", skipped); + } + } + } + std::printf("; copies"); + for (u32 s = 0; s < BbStats::WaitSites; ++s) { + const double n = per_frame(BbStats::copy_waits[s]); + const double ms = ms_per_frame(BbStats::copy_wait_ns[s]); + if (n > 0.0) { + std::printf(" %s %.1f/frame %.3f ms", BbStats::WaitSiteNames[s], n, ms); + } + } + std::printf("; fence barriers %.1f/frame; DMA into memory stored by the CPU " + "%.1f/frame, recorded for the GPU %.1f/frame\n", + per_frame(BbStats::fence_barriers), per_frame(BbStats::dma_on_cpu), + per_frame(BbStats::dma_on_gpu)); + double stage_b_calls = 0.0, stage_b_ms = 0.0; + std::array kind_calls{}; + for (u32 k = 0; k < BbStats::ProtectKinds; ++k) { + kind_calls[k] = per_frame(BbStats::stage_b_protect_calls[k]); + stage_b_calls += kind_calls[k]; + stage_b_ms += ms_per_frame(BbStats::stage_b_protect_ns[k]); + } + const double other_calls = per_frame(BbStats::other_protect_calls); + const double other_ms = ms_per_frame(BbStats::other_protect_ns); + const double deferred_pages = per_frame(BbStats::deferred_protect_pages); + const double deferred_calls = per_frame(BbStats::deferred_protect_calls); + const double deferred_ms = ms_per_frame(BbStats::deferred_protect_ns); + std::printf("Stage B protects: %.1f/frame %.3f ms (uploads %.1f, textures %.1f, " + "other %.1f); other threads %.1f/frame %.3f ms; deferred %.0f " + "pages/frame, applied in %.1f calls/frame %.3f ms (bb:Protect or " + "copies), copies that flushed %.1f/frame, cancelled %.0f pages/frame\n", + stage_b_calls, stage_b_ms, kind_calls[BbStats::ProtectUpload], + kind_calls[BbStats::ProtectTexture], kind_calls[BbStats::ProtectOther], + other_calls, other_ms, deferred_pages, deferred_calls, deferred_ms, + per_frame(BbStats::copy_flushes), + per_frame(BbStats::deferred_cancelled_pages)); + } + // bbport (docs/pipeline_compiles.md): what draws of pipelines still compiling did. + { + auto& p = Vulkan::pipeline_compile_stats; + const u64 background = p.background.exchange(0), taken = p.taken.exchange(0); + const u64 skipped = p.skipped_draws.exchange(0); + const u64 skipped_pipelines = p.skipped_pipelines.exchange(0); + const u64 late = p.late_binds.exchange(0), waits = p.waits.exchange(0); + const u64 wait_ns = p.wait_ns.exchange(0), longest = p.longest_wait_ns.exchange(0); + if (background + skipped + late + waits != 0) { + std::printf("Pipelines: %llu compiled in the background (%llu by the thread " + "that needed them); %llu draws of %llu pipelines left out while " + "compiling; %llu draws/dispatches bound on the recording threads; " + "%llu waits, %.1f ms, longest %.1f ms\n", + static_cast(background), + static_cast(taken), + static_cast(skipped), + static_cast(skipped_pipelines), + static_cast(late), + static_cast(waits), wait_ns / 1e6, + longest / 1e6); + } + } VideoCore::PageManager::ReportFaultSites(); if (const u64 notes = BbStats::cpu_write_notes.exchange(0)) { std::printf("Write notices: %.0f/s, %.2f MB/s (libc copies over pages the GPU side " @@ -695,8 +801,19 @@ void VideoOutDriver::Flip(const Request& req) { } } - // Update flip status. auto* port = req.port; + // bbport: release the buffer this flip replaced before the game can learn of the flip (flip + // status, flip events), not after: the game renders into that buffer next and sets its label + // to 1 again, which a later reset here cleared (the flip IRQ then waited up to 20 ms for a + // label that never came, video_out.cpp). Frames are prepared at submission, so the buffer + // is no longer read here. + if (port->prev_index != -1) { + port->buffer_labels[port->prev_index] = 0; + port->SignalVoLabel(); + } + port->prev_index = req.index; + + // Update flip status. { std::unique_lock lock{port->port_mutex}; auto& flip_status = port->flip_status; @@ -722,14 +839,6 @@ void VideoOutDriver::Flip(const Request& req) { (req.flip_arg << 16))); } } - - // Reset prev flip label - if (port->prev_index != -1) { - port->buffer_labels[port->prev_index] = 0; - port->SignalVoLabel(); - } - // save to prev buf index - port->prev_index = req.index; } void VideoOutDriver::DrawBlankFrame() { @@ -753,8 +862,18 @@ bool VideoOutDriver::SubmitFlip(VideoOutPort* port, s32 index, s64 flip_arg, { std::unique_lock lock{port->port_mutex}; if (index != -1 && port->flip_status.flip_pending_num > 16) { - LOG_ERROR(Lib_VideoOut, "Flip queue is full"); - return false; + // bbport: an EOP flip has nobody to return the error to: refusing it ended the game + // (assertion, exit 23), and dropping it would keep its buffer's label (the game may + // wait for it). It is queued anyway; frame preparation waits for a free frame. + if (!is_eop) { + LOG_ERROR(Lib_VideoOut, "Flip queue is full"); + return false; + } + static std::atomic notes{0}; + if (notes.fetch_add(1, std::memory_order_relaxed) < 4) { + std::printf("VideoOut: %d flips pending; EOP flip queued anyway\n", + port->flip_status.flip_pending_num); + } } if (is_eop) { @@ -813,8 +932,9 @@ void VideoOutDriver::PresentThread(std::stop_token token) { const auto frame_period = frame_limit ? std::chrono::nanoseconds(1000000000 / frame_limit) : std::chrono::nanoseconds(0); auto next_flip = std::chrono::steady_clock::now(); - std::printf("VideoOut: vblank %u Hz, frame limit %u FPS\n", - EmulatorSettings.GetVblankFrequency(), frame_limit); + std::printf("VideoOut: vblank %u Hz, frame limit %u FPS, precise pacing %s\n", + EmulatorSettings.GetVblankFrequency(), frame_limit, + Common::PrecisePacingEnabled() ? "on" : "off"); const auto receive_request = [this] -> Request { std::scoped_lock lk{mutex}; @@ -831,6 +951,137 @@ void VideoOutDriver::PresentThread(std::stop_token token) { // next vblank tick. const bool immediate_flips = frame_limit != 0 || EmulatorSettings.IsUncappedVblank(); + const auto signal_vblank = [this] { + auto& vblank_status = main_port.vblank_status; + // Needs lock here as can be concurrently read by `sceVideoOutGetVblankStatus` + std::scoped_lock lock{main_port.vo_mutex}; + + // Trigger flip events for the port + for (auto event : main_port.vblank_events) { + auto equeue = Kernel::GetEqueue(event); + if (equeue != nullptr) { + equeue->TriggerEvent(static_cast(OrbisVideoOutInternalEventId::Vblank), + Kernel::OrbisKernelEvent::Filter::VideoOut, + reinterpret_cast( + static_cast(OrbisVideoOutInternalEventId::Vblank) | + (vblank_status.count << 16))); + } + } + + // Update vblank status + vblank_status.count++; + vblank_status.process_time = Libraries::Kernel::sceKernelGetProcessTime(); + vblank_status.tsc = Libraries::Kernel::sceKernelReadTsc(); + main_port.vblank_cv.notify_all(); + }; + + // bbport: precise pacing for immediate flips (Common::PrecisePacingEnabled: on by default on + // Windows, BB_PRECISE_PACING=0 turns it off; off on Linux unless =1). Adapted from + // IFreemz/shadPS4-Bloodborne-DLSS-FSR (6c69c55, "Even flip pacing for G-Sync, precise + // vblank ticks"), GPL-2.0-or-later. + // - Vblank ticks run on a fixed schedule (next = previous + period), re-anchored to now only + // after a stall of more than a period; waits sleep on a high-resolution timer and spin the + // last stretch, so ticks neither drift nor jitter by the OS timer granularity. + // - Flips keep an even rhythm of one pace period (the frame limit's period, or the vblank + // period without a limit), and two flips are never closer than 7/8 of it: a late frame is + // not followed by a catch-up flip right after it. With G-Sync/FreeSync the display + // refreshes on every flip, so uneven flips are refresh rate swings (judder, flicker). + // Flips on vblank ticks without a frame limit keep the AccurateTimer path below. + if (immediate_flips && Common::PrecisePacingEnabled()) { + using Clock = std::chrono::steady_clock; + const std::chrono::nanoseconds pace = frame_limit ? frame_period : vblank_period; + const std::chrono::nanoseconds min_gap = pace * 7 / 8; + const std::chrono::nanoseconds slack = pace - min_gap; + // OS sleeps wake up late: sleep most of the way, spin the rest. The spin follows how late + // the timer actually wakes (Common::PreciseSpin; at most 1.5 ms and 1/8 of the vblank + // period), and only flips get it when there is a frame limit: vblank ticks then only + // count time, and spinning before each of 480 a second kept a core busy, taken from the + // game's own threads on 6-8 core CPUs. + const std::chrono::nanoseconds spin_cap = std::min( + std::chrono::microseconds(1500), vblank_period / 8); + const auto wait_until = [&](Clock::time_point deadline, bool exact) { + const auto spin = exact ? Common::PreciseSpin(spin_cap) : std::chrono::nanoseconds(0); + const auto left = deadline - Clock::now(); + if (left > spin) { + Common::AccurateSleep( + std::chrono::duration_cast(left - spin), nullptr, + false); + } + while (Clock::now() < deadline) { + std::this_thread::yield(); + } + }; + const std::chrono::nanoseconds spin = Common::PreciseSpin(spin_cap); + + Clock::time_point next_tick = Clock::now(); + next_flip = next_tick; + Clock::time_point last_flip = next_tick - min_gap; + const auto take_flip = [&](const Request& request, Clock::time_point now) { + next_flip = std::max(next_flip + pace, now + min_gap); + last_flip = now; + Flip(request); + FRAME_END; + }; + const auto end_tick = [&](Clock::time_point tick_deadline) { + wait_until(tick_deadline, !frame_limit); + // Fixed schedule; after a long stall (a breakpoint, a hitch) start again from now. + const auto now = Clock::now(); + next_tick = now - tick_deadline > vblank_period ? now : tick_deadline; + }; + + while (!token.stop_requested()) { + const Clock::time_point tick_deadline = next_tick + vblank_period; + + if (DebugState.IsGuestThreadsPaused()) { + DrawLastFrame(); + end_tick(tick_deadline); + continue; + } + + // Check if it's time to take a request. On a tick, a flip up to `slack` before its + // slot is fine (ticks and flips run on separate schedules), as long as it keeps the + // minimum gap. + const auto now = Clock::now(); + const bool flip_slot = now + slack >= next_flip && now >= last_flip + min_gap; + if (flip_slot && main_port.vblank_status.count % (main_port.flip_rate + 1) == 0) { + const auto request = receive_request(); + if (!request) { + if (!main_port.is_open) { + DrawBlankFrame(); + } else if (ImGui::Core::MustKeepDrawing()) { + DrawLastFrame(); + } + } else { + take_flip(request, now); + } + } + + signal_vblank(); + + // Until the next vblank: take flip requests as they arrive (not with a flip rate, + // which asks for flips on vblank ticks). + while (main_port.flip_rate == 0 && !token.stop_requested()) { + { + std::unique_lock lk{mutex}; + if (!request_cv.wait_until(lk, tick_deadline - spin, + [this] { return !requests.empty(); })) { + break; // next vblank + } + } + if (next_flip >= tick_deadline) { + break; // its slot is at (or after) the next vblank + } + wait_until(next_flip, true); + const auto request = receive_request(); + if (request) { + take_flip(request, Clock::now()); + } + } + end_tick(tick_deadline); + } + return; + } + while (!token.stop_requested()) { timer.Start(); const auto tick_deadline = std::chrono::steady_clock::now() + vblank_period; @@ -864,29 +1115,7 @@ void VideoOutDriver::PresentThread(std::stop_token token) { } } - { - // Needs lock here as can be concurrently read by `sceVideoOutGetVblankStatus` - std::scoped_lock lock{main_port.vo_mutex}; - - // Trigger flip events for the port - for (auto event : main_port.vblank_events) { - auto equeue = Kernel::GetEqueue(event); - if (equeue != nullptr) { - equeue->TriggerEvent( - static_cast(OrbisVideoOutInternalEventId::Vblank), - Kernel::OrbisKernelEvent::Filter::VideoOut, - reinterpret_cast( - static_cast(OrbisVideoOutInternalEventId::Vblank) | - (vblank_status.count << 16))); - } - } - - // Update vblank status - vblank_status.count++; - vblank_status.process_time = Libraries::Kernel::sceKernelGetProcessTime(); - vblank_status.tsc = Libraries::Kernel::sceKernelReadTsc(); - main_port.vblank_cv.notify_all(); - } + signal_vblank(); if (!immediate_flips || main_port.flip_rate != 0) { timer.End(); diff --git a/gpu/shadps4/core/libraries/videoout/video_out.cpp b/gpu/shadps4/core/libraries/videoout/video_out.cpp index 1cf6d2a..43f2e98 100644 --- a/gpu/shadps4/core/libraries/videoout/video_out.cpp +++ b/gpu/shadps4/core/libraries/videoout/video_out.cpp @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: Copyright 2024-2026 shadPS4 Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include +#include +#include +#include + #include "common/assert.h" #include "common/elf_info.h" #include "common/logging/log.h" @@ -351,7 +356,27 @@ s32 sceVideoOutSubmitEopFlip(s32 handle, u32 buf_id, u32 mode, s64 flip_arg, voi Platform::IrqC::Instance()->RegisterOnce( Platform::InterruptId::GfxFlip, [=](Platform::InterruptId irq) { ASSERT_MSG(irq == Platform::InterruptId::GfxFlip, "An unexpected IRQ occured"); - ASSERT_MSG(port->buffer_labels[buf_id] == 1, "Out of order flip IRQ"); + // bbport: the label write and this IRQ can arrive out of order (the write may still + // be on its way from the draw recording thread; timing changes such as precise frame + // pacing make it likelier). This used to stop the game ("Out of order flip IRQ", exit + // 23). Wait briefly for the label, bounded since the writer may be this very thread; + // then flip anyway: at worst a frame is shown twice, and the game still gets its flip + // event (skipping it could leave the game waiting for that buffer forever). + if (port->buffer_labels[buf_id] != 1) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(20); + while (reinterpret_cast(port->buffer_labels[buf_id]) != 1 && + std::chrono::steady_clock::now() < deadline) { + std::this_thread::yield(); + } + if (port->buffer_labels[buf_id] != 1) { + static std::atomic reported{0}; + if (reported.fetch_add(1, std::memory_order_relaxed) < 5) { + std::printf("VideoOut: flip IRQ for buffer %u before its label was set; " + "flipping anyway\n", + buf_id); + } + } + } const auto result = driver->SubmitFlip(port, buf_id, flip_arg, true); ASSERT_MSG(result, "EOP flip submission failed"); }); diff --git a/gpu/shadps4/shader_recompiler/info.h b/gpu/shadps4/shader_recompiler/info.h index 204fa91..6176f4f 100644 --- a/gpu/shadps4/shader_recompiler/info.h +++ b/gpu/shadps4/shader_recompiler/info.h @@ -216,6 +216,11 @@ struct Info : InfoPersistent { std::memcpy(&base, &ud[ptr_index], sizeof(base)); base = reinterpret_cast(VAddr(base) & 0xFFFFFFFFFFFFULL); } + // bbport: an unbound descriptor table (null pointer, seen while maps load) reads as an + // empty sharp, which the callers handle, instead of reading near address zero. + if (!base) { + return T{}; + } std::memcpy(&data, base + dword_offset, sizeof(T)); return data; } diff --git a/gpu/shadps4/shader_recompiler/ir/passes/flatten_extended_userdata_pass.cpp b/gpu/shadps4/shader_recompiler/ir/passes/flatten_extended_userdata_pass.cpp index 9175131..2715c6e 100644 --- a/gpu/shadps4/shader_recompiler/ir/passes/flatten_extended_userdata_pass.cpp +++ b/gpu/shadps4/shader_recompiler/ir/passes/flatten_extended_userdata_pass.cpp @@ -37,6 +37,10 @@ void EnsureSrtFaultHandler(); namespace Shader { +void RegisterSrtFaultHandler() { + EnsureSrtFaultHandler(); +} + PFN_SrtWalker RegisterWalkerCode(const u8* ptr, size_t size) { // bbport: walkers restored from the pipeline cache fault on null SRT pointers like freshly // generated ones; without the handler the first such fault ended the process. @@ -888,6 +892,8 @@ void FlattenExtendedUserdataPass(IR::Program& program) { namespace Shader { +void RegisterSrtFaultHandler() {} + PFN_SrtWalker RegisterWalkerCode(const u8* ptr, size_t size) { UNREACHABLE_MSG("RegisterWalkerCode unimplemented for target architecture."); } diff --git a/gpu/shadps4/shader_recompiler/ir/passes/srt.h b/gpu/shadps4/shader_recompiler/ir/passes/srt.h index 918b832..1fc2fb5 100644 --- a/gpu/shadps4/shader_recompiler/ir/passes/srt.h +++ b/gpu/shadps4/shader_recompiler/ir/passes/srt.h @@ -15,6 +15,8 @@ namespace Shader { using PFN_SrtWalker = void PS4_SYSV_ABI (*)(const u32* /*user_data*/, u32* /*flat_dst*/); PFN_SrtWalker RegisterWalkerCode(const u8* ptr, size_t size); +/// bbport: registers the walkers' fault handler (at startup, before guest threads run). +void RegisterSrtFaultHandler(); struct PersistentSrtInfo { // Special case when fetch shader uses step rates. diff --git a/gpu/shadps4/video_core/amdgpu/liverpool.cpp b/gpu/shadps4/video_core/amdgpu/liverpool.cpp index fa7e146..c4b0a36 100644 --- a/gpu/shadps4/video_core/amdgpu/liverpool.cpp +++ b/gpu/shadps4/video_core/amdgpu/liverpool.cpp @@ -36,6 +36,7 @@ #include "bbport_free_check.h" #include "video_core/amdgpu/pm4_cmds.h" #include "video_core/renderdoc.h" +#include "video_core/renderer_vulkan/vk_occlusion.h" #include "video_core/renderer_vulkan/vk_rasterizer.h" extern "C" int runtime_memory_vma_info(uintptr_t address, int* prot, int* type, uintptr_t* end); @@ -120,8 +121,24 @@ void Liverpool::ProcessCommands() { } } +namespace { +/// bbport BB_SUBMIT_END_TASKS (default on; 0: off): see the end of Liverpool::Process. +bool SubmitEndTasks() { + static const bool on = [] { + const char* env = std::getenv("BB_SUBMIT_END_TASKS"); + const bool enabled = !(env && env[0] == '0'); + std::printf("GPU: end of decoded work handed to the draw recording thread: %s " + "(BB_SUBMIT_END_TASKS)\n", + enabled ? "on" : "off"); + return enabled; + }(); + return on; +} +} // namespace + void Liverpool::Process(std::stop_token stoken) { Common::SetCurrentThreadName("shadPS4:GpuCommandProcessor"); + BbThreads::MakeCritical(); // bbport: Windows, see bbport_platform.cpp if (const int clock = BbPlatform::ThreadCpuClock(); clock != -1) { BbStats::gpu_thread_clock.store(clock); } @@ -167,7 +184,7 @@ void Liverpool::Process(std::stop_token stoken) { if (const char* env = std::getenv("BB_GPU_SPIN_US")) { return std::max(0, std::atoi(env)); } - return BbThreads::Available() >= 12 ? 200 : 0; + return int(BbThreads::CapSpinUs(BbThreads::Available() >= 12 ? 200 : 0)); }()); if (spin_time.count() > 0) { const auto spin_until = idle_start + spin_time; @@ -243,9 +260,41 @@ void Liverpool::Process(std::stop_token stoken) { } } + // bbport BB_SUBMIT_END_TASKS (default on; 0: the drains below): without honest labels the + // end of the decoded work is handed to the draw recording thread in order instead of this + // thread draining it (OnSubmit waited 27-35% of the time in dense areas, and the + // recording thread ran dry meanwhile and until the next submission was decoded). GPU + // idle comes from the deferred signal chain, after every fence before it, as the drain + // and WaitDeferredSignals below gave it; only if nothing was submitted since + // (SignalGpuIdle). It goes before the frame's cache work, which no longer delays it. + const bool end_tasks = + rasterizer && !Vulkan::Rasterizer::HonestLabels() && SubmitEndTasks(); + if (end_tasks) { + struct IdleSignal { + Liverpool* self; + u64 generation; + } signal{this, 0}; + bool decoded; + { + std::scoped_lock lk{submit_mutex}; + decoded = num_submits == 0; + signal.generation = submissions_total; + } + // Not everything decoded: a submission came after the loop; it runs again. + if (decoded) { + rasterizer->RunInOrder( + [](Vulkan::Rasterizer& r, const u8* data) { + IdleSignal idle; + std::memcpy(&idle, data, sizeof(idle)); + r.SignalAfterHostCopies( + [idle] { idle.self->SignalGpuIdle(idle.generation); }); + }, + &signal, sizeof(signal), BbToggle::PipelinedTasks, false, "GpuIdle"); + } + } if (submit_done) { VideoCore::EndCapture(); - if (rasterizer && rasterizer->PipeSignals()) { + if (rasterizer && (rasterizer->PipeSignals() || end_tasks)) { // BB_PIPE_SIGNALS: the frame's cache work runs on the recording thread after the // frame (this thread decodes the next one meanwhile instead of waiting for it). static constexpr u8 none = 0; @@ -254,7 +303,7 @@ void Liverpool::Process(std::stop_token stoken) { r.OnSubmit(); r.Flush(); }, - &none, 0); + &none, 0, BbToggle::PipelinedTasks, true, "SubmitDone"); } else if (rasterizer) { rasterizer->OnSubmit(); rasterizer->Flush(); @@ -263,6 +312,9 @@ void Liverpool::Process(std::stop_token stoken) { } // BB_SUBMIT_LOCK=decode: a frame ended after its submissions were all decoded. SignalDecodedFrames(); + if (end_tasks) { + continue; + } // bbport: the guest takes GPU idle (sceGnmSubmitDone) as its work being done and frees // the objects that hold its fence labels. The labels must be written before: the draw @@ -316,7 +368,7 @@ void Liverpool::Process(std::stop_token stoken) { r.SignalAfterGpu([idle] { idle.self->SignalGpuIdle(idle.generation); }); r.SubmitForSignals(); // at once if the GPU has run dry }, - &signal, sizeof(signal), BbToggle::PipelinedTasks, false); + &signal, sizeof(signal), BbToggle::PipelinedTasks, false, "GpuIdleHonest"); continue; } rasterizer->SignalAfterGpu([this, generation] { SignalGpuIdle(generation); }); @@ -341,6 +393,11 @@ namespace { /// work on earlier draws (register state, draws it hands over, nested buffers). Every other /// packet first waits for that thread to run dry (Rasterizer::DrainDrawPipe). bool PipelinedOpcode(PM4ItOpcode opcode) { + if (opcode == PM4ItOpcode::SetPredication) { + // Real occlusion results: handed over in order (Rasterizer::OcclusionPredication). + // BB_OCCLUSION=0 keeps the old drain before the ignored packet. + return Vulkan::Occlusion::Wanted(); + } switch (opcode) { case PM4ItOpcode::Nop: case PM4ItOpcode::ContextControl: @@ -466,12 +523,35 @@ void RunEventWriteEop(Vulkan::Rasterizer& rasterizer, const u8* data) { BbCopy::AfterCopies([eop, seq] { SignalEop(eop, seq); }); } else if (Vulkan::DrawPipe::OnStageB() && !BbToggle::Disabled(BbToggle::RecorderFences)) { // The draw recording thread does not wait: the Vulkan recording thread signals the - // fence after the copies queued before it. - rasterizer.SignalAfterHostCopies([eop, seq] { SignalEop(eop, seq); }); + // fence after the copies queued before it. A fence right behind this one (already in + // the draw pipe, run next) hands both to the recording threads at once. + const bool next_is_fence = rasterizer.NextPacketIsTask(&RunEventWriteEop); + if (next_is_fence) { + rasterizer.merged_fence_kicks.fetch_add(1, std::memory_order_relaxed); + } + // bbport: the label it writes, for the writes that may not overtake it + // (BB_FENCE_ORDER=ordered); an interrupt or no label: nothing may. + u32 label_size = 0; + if (eop.int_sel.Value() == InterruptSelect::None) { + switch (eop.data_sel.Value()) { + case DataSelect::Data32Low: + label_size = sizeof(u32); + break; + case DataSelect::Data64: + case DataSelect::GpuClock64: + case DataSelect::PerfCounter: + label_size = sizeof(u64); + break; + default: + break; + } + } + rasterizer.SignalAfterHostCopies([eop, seq] { SignalEop(eop, seq); }, !next_is_fence, + reinterpret_cast(eop.Address()), label_size); } else { // Labels are written in order: not before the ones deferred to the recording thread. - rasterizer.WaitHostCopies(); - rasterizer.WaitDeferredSignals(); + rasterizer.WaitHostCopies(BbStats::WaitEop); + rasterizer.WaitDeferredSignals(BbStats::WaitEop); SignalEop(eop, seq); } } @@ -486,10 +566,26 @@ void RunDmaData(Vulkan::Rasterizer& rasterizer, const u8* data) { // With the new memory model, recorded as a GPU fill or copy (memory used in place, or // written by the GPU) it lands after the submission goes out, which is after those copies // and signals: no wait (~16 us each, ~900 a second of 4-byte counter copies). - if ((dma_data->dst_sel == DmaDataDst::Memory || dma_data->dst_sel == DmaDataDst::MemoryUsingL2) && + // bbport (BB_DMA_WAITS, default: as needed; =all: before every DMA into memory): the same + // holds for the GPU fill or copy of the old model (GPU-modified memory): only a CPU store + // waits (Rasterizer::DmaWritesOnCpu). Should the store end up on the CPU after all, + // FillBuffer/CopyBuffer wait first (`waited` false). + const bool to_memory = + dma_data->dst_sel == DmaDataDst::Memory || dma_data->dst_sel == DmaDataDst::MemoryUsingL2; + const bool src_memory = + dma_data->src_sel == DmaDataSrc::Memory || dma_data->src_sel == DmaDataSrc::MemoryUsingL2; + bool waited = true; + if (to_memory && rasterizer.DmaMayWriteOnCpu(dma_data->DstAddress(), dma_data->NumBytes())) { - rasterizer.WaitHostCopiesFor(dma_data->DstAddress(), dma_data->NumBytes()); - rasterizer.WaitDeferredSignals(); + waited = rasterizer.DmaWritesOnCpu( + dma_data->DstAddress(), dma_data->NumBytes(), + dma_data->src_sel == DmaDataSrc::Data, src_memory ? dma_data->SrcAddress() : 0, + dma_data->src_sel == DmaDataSrc::Gds); + if (waited) { + rasterizer.WaitBeforeDmaStore(dma_data->DstAddress(), dma_data->NumBytes(), + src_memory ? dma_data->SrcAddress() : 0, + src_memory ? dma_data->NumBytes() : 0); + } } if (dma_data->src_sel == DmaDataSrc::Data && dma_data->dst_sel == DmaDataDst::Gds) { rasterizer.FillBuffer(dma_data->dst_addr_lo, dma_data->NumBytes(), @@ -503,19 +599,19 @@ void RunDmaData(Vulkan::Rasterizer& rasterizer, const u8* data) { (dma_data->dst_sel == DmaDataDst::Memory || dma_data->dst_sel == DmaDataDst::MemoryUsingL2)) { rasterizer.FillBuffer(dma_data->DstAddress(), dma_data->NumBytes(), - dma_data->data, false); + dma_data->data, false, waited); } else if (dma_data->src_sel == DmaDataSrc::Gds && (dma_data->dst_sel == DmaDataDst::Memory || dma_data->dst_sel == DmaDataDst::MemoryUsingL2)) { rasterizer.CopyBuffer(dma_data->DstAddress(), dma_data->src_addr_lo, - dma_data->NumBytes(), false, true); + dma_data->NumBytes(), false, true, waited); } else if ((dma_data->src_sel == DmaDataSrc::Memory || dma_data->src_sel == DmaDataSrc::MemoryUsingL2) && (dma_data->dst_sel == DmaDataDst::Memory || dma_data->dst_sel == DmaDataDst::MemoryUsingL2)) { rasterizer.CopyBuffer(dma_data->DstAddress(), dma_data->SrcAddress(), dma_data->NumBytes(), - false, false); + false, false, waited); } else { UNREACHABLE_MSG("WriteData src_sel = {}, dst_sel = {}", u32(dma_data->src_sel), u32(dma_data->dst_sel)); @@ -523,7 +619,7 @@ void RunDmaData(Vulkan::Rasterizer& rasterizer, const u8* data) { } void SignalFlip(Vulkan::Rasterizer& rasterizer, const u8*) { - rasterizer.WaitDeferredSignals(); + rasterizer.WaitDeferredSignals(BbStats::WaitFlip); Platform::IrqC::Instance()->Signal(Platform::InterruptId::GfxFlip); } @@ -541,10 +637,12 @@ void RunWriteData(Vulkan::Rasterizer& rasterizer, const u8* data) { // Copies deferred to the recording thread that read this range precede the write (the // command processor's writes do not wait for earlier draws: the game cannot take one for // a sign that the GPU is past them); fences deferred to it (RecorderFences) precede writes - // the guest sees. + // the guest sees (bbport BB_FENCE_ORDER=ordered: only those it must follow). rasterizer.WaitHostCopiesFor(write_data->Address(), - (header->type3.count.Value() - 2) * sizeof(u32)); - rasterizer.WaitDeferredSignals(); + (header->type3.count.Value() - 2) * sizeof(u32), + BbStats::WaitWriteData); + rasterizer.WaitDeferredSignalsFor(BbStats::WaitWriteData, write_data->Address(), + (header->type3.count.Value() - 2) * sizeof(u32)); BbFreeCheck::Check(write_data->Address(), (header->type3.count.Value() - 2) * sizeof(u32), write_data->data, BbFreeCheck::WriteData); BbWriteLog::Note(write_data->Address(), write_data->data, @@ -582,8 +680,8 @@ void RunEventWriteEos(Vulkan::Rasterizer& rasterizer, const u8* data) { return; } // Copies deferred to the recording thread precede writes the guest sees. - rasterizer.WaitHostCopies(); - rasterizer.WaitDeferredSignals(); + rasterizer.WaitHostCopies(BbStats::WaitEos); + rasterizer.WaitDeferredSignals(BbStats::WaitEos); rasterizer.ProcessDownloadImages(); event_eos.SignalFence(&WriteEosLabel); if (event_eos.command == PM4CmdEventWriteEos::Command::GdsStore) { @@ -1009,6 +1107,7 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span dcb, std::spanGetDrawPreparation() : nullptr; if (draw_prep) { draw_prep->BeginSubmission(seq, regs, gfx_reg_checksum); + rasterizer->OcclusionSubmissionStart(); } static const bool dcb_stats = EmulatorSettingsImpl::Flag("BB_DCB_STATS", false); const int dcb_depth = g_dcb_depth; @@ -1077,7 +1176,8 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span dcb, std::spanRunInOrder(&SignalFlip, header, sizeof(u32), - BbToggle::PipelinedMemoryWrites, false); + BbToggle::PipelinedMemoryWrites, false, + "SignalFlip"); } else { Platform::IrqC::Instance()->Signal(Platform::InterruptId::GfxFlip); } @@ -1209,6 +1309,12 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span dcb, std::spantype3.NumWords() >= 2 && + rasterizer->OcclusionPredication(reinterpret_cast(header) + 1)) { + break; + } LOG_WARNING(Render, "Unimplemented IT_SET_PREDICATION"); break; } @@ -1417,6 +1523,14 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span dcb, std::spanevent_type.Value() == EventType::SoVgtStreamoutFlush) { // registers: ApplyGraphicsRegisterPacket } else if (event->event_index.Value() == EventIndex::ZpassDone) { + // bbport: real sample counts (vk_occlusion.h): the draws after the dump are + // measured and the counts written once the GPU has run them. Nothing is + // written now: the game zeroed the block and reads it as not ready until then. + // BB_OCCLUSION=0 (or no query support): the fake rising count below. + if (event->event_type.Value() == EventType::PixelPipeStatDump && rasterizer && + rasterizer->OcclusionDump(event->Address(), num_counter_pairs)) { + break; + } if (event->event_type.Value() == EventType::PixelPipeStatDump) { static constexpr u64 OcclusionCounterValidMask = 0x8000000000000000ULL; static constexpr u64 OcclusionCounterStep = 0x2FFFFFFULL; @@ -1441,7 +1555,7 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span dcb, std::spanRunInOrder(&RunEventWriteEos, event_eos, sizeof(*event_eos), - BbToggle::PipelinedTasks, false)) { + BbToggle::PipelinedTasks, false, "EventWriteEos")) { NotePendingFences(*event_eos); } break; @@ -1457,7 +1571,8 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span dcb, std::spanRunInOrder(&RunEventWriteEop, event_eop, sizeof(*event_eop), - BbToggle::PipelinedTasks, false)) { + BbToggle::PipelinedTasks, false, + "EventWriteEop")) { NotePendingFences(*event_eop); } } else { @@ -1485,7 +1600,7 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span dcb, std::spanNumBytes()); } if (rasterizer->RunInOrder(&RunDmaData, dma_data, sizeof(PM4DmaData), - BbToggle::PipelinedMemoryWrites, false) && + BbToggle::PipelinedMemoryWrites, false, "DmaData") && (dma_data->dst_sel == DmaDataDst::Memory || dma_data->dst_sel == DmaDataDst::MemoryUsingL2)) { rasterizer->NotePendingGpuWrite(dma_data->DstAddress(), @@ -1507,7 +1622,8 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span dcb, std::spanRunInOrder(&RunWriteData, header, (count + 1) * sizeof(u32), - BbToggle::PipelinedMemoryWrites, false)) { + BbToggle::PipelinedMemoryWrites, false, + "WriteData")) { NotePendingWrite(*write_data, (count - 2) * sizeof(u32)); rasterizer->NotePendingGpuWrite(write_data->Address(), (count - 2) * sizeof(u32)); @@ -1578,6 +1694,11 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span dcb, std::spanAddress(), value) && wait_reg_mem->TestValue(value)) { + // bbport: the command processor would wait here for that fence: CPU writes + // after this point follow it (BB_FENCE_ORDER=ordered). + if (rasterizer) { + rasterizer->NoteFenceBarrier(); + } break; } // Else the value may come from a fence that thread has yet to write: let it @@ -1592,6 +1713,11 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span dcb, std::spanHasUnsubmittedSignals()) { rasterizer->Flush(); } + // bbport: occlusion counts are written once the GPU has run their + // submission: a wait on a query block must not hold that submission back. + if (rasterizer && rasterizer->OcclusionUnsubmitted()) { + rasterizer->Flush(); + } YIELD_GFX(); } break; @@ -1616,7 +1742,9 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span dcb, std::span(cblock.ce_count - cblock.de_count) <= 0 && + !ce_task.handle.done()) { RESUME_GFX(ce_task); } break; @@ -2125,7 +2253,7 @@ void Liverpool::SignalDecodedFrames() { [retired] { Libraries::GnmDriver::NoteFramesRetired(retired); }); self.Flush(); }, - &frame, sizeof(frame), BbToggle::PipelinedTasks, false); + &frame, sizeof(frame), BbToggle::PipelinedTasks, false, "FramesRetired"); return; } rasterizer->DrainDrawPipe(Vulkan::DrawPipe::ReasonSubmissionEnd); diff --git a/gpu/shadps4/video_core/amdgpu/liverpool.h b/gpu/shadps4/video_core/amdgpu/liverpool.h index c641a56..e7b0207 100644 --- a/gpu/shadps4/video_core/amdgpu/liverpool.h +++ b/gpu/shadps4/video_core/amdgpu/liverpool.h @@ -281,7 +281,8 @@ private: } [[nodiscard]] u32 Diff() const { - ASSERT_MSG(ce_count >= de_count, "DE counter is ahead of CE"); + // bbport: compared as a signed distance, right across the u32 wrap. + ASSERT_MSG(static_cast(ce_count - de_count) >= 0, "DE counter is ahead of CE"); return ce_count - de_count; } diff --git a/gpu/shadps4/video_core/amdgpu/pixel_format.cpp b/gpu/shadps4/video_core/amdgpu/pixel_format.cpp index d88b05f..3acd697 100644 --- a/gpu/shadps4/video_core/amdgpu/pixel_format.cpp +++ b/gpu/shadps4/video_core/amdgpu/pixel_format.cpp @@ -74,7 +74,7 @@ std::string_view NameOf(DataFormat fmt) { case DataFormat::FormatBc7: return "FormatBc7"; default: - UNREACHABLE(); + return "Unknown"; // bbport: reached while logging a garbage descriptor (was exit 23) } } @@ -107,7 +107,7 @@ std::string_view NameOf(NumberFormat fmt) { case NumberFormat::Ubscaled: return "Unscaled"; default: - UNREACHABLE(); + return "Unknown"; // bbport: as above } } diff --git a/gpu/shadps4/video_core/amdgpu/pixel_format.h b/gpu/shadps4/video_core/amdgpu/pixel_format.h index 8b83639..aeac901 100644 --- a/gpu/shadps4/video_core/amdgpu/pixel_format.h +++ b/gpu/shadps4/video_core/amdgpu/pixel_format.h @@ -356,7 +356,10 @@ constexpr NumberConversion MapNumberConversion(const NumberFormat num_fmt, case DataFormat::Format16_16_16_16: return NumberConversion::Sint16ToSnormNz; default: - UNREACHABLE_MSG("data_fmt = {}", u32(data_fmt)); + // bbport: Bloodborne sometimes binds SnormNz with a packed format (seen: data_fmt 7, + // 11_11_10) after minutes of play; this stopped the game (exit 23). Such a descriptor + // has no SnormNz meaning, so it is read without a conversion instead. + return NumberConversion::None; } } default: diff --git a/gpu/shadps4/video_core/amdgpu/resource.h b/gpu/shadps4/video_core/amdgpu/resource.h index 8255184..ef9c006 100644 --- a/gpu/shadps4/video_core/amdgpu/resource.h +++ b/gpu/shadps4/video_core/amdgpu/resource.h @@ -491,7 +491,9 @@ struct Sampler { case AnisoRatio::Sixteen: return 16.0f; default: - UNREACHABLE(); + // bbport: 5-7 (reserved, a stale or garbage sharp) clamp to 16x as the hardware's + // maximum; this ended the game (UNREACHABLE, exit 23). + return 16.0f; } } }; diff --git a/gpu/shadps4/video_core/amdgpu/tiling.cpp b/gpu/shadps4/video_core/amdgpu/tiling.cpp index fa3476b..8eb8214 100644 --- a/gpu/shadps4/video_core/amdgpu/tiling.cpp +++ b/gpu/shadps4/video_core/amdgpu/tiling.cpp @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: Copyright 2025 shadPS4 Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include +#include +#include #include "common/assert.h" +#include "common/logging/log.h" #include "video_core/amdgpu/tiling.h" #include @@ -11,6 +15,16 @@ namespace AmdGpu { static constexpr u32 MICROTILE_SIZE = 8; static constexpr u32 DRAM_ROW_SIZE = 1024; +/// bbport: a mode outside the tables (a garbage or unsupported descriptor) is reported a few +/// times and treated as a known one (linear, 1x1_16 banks); it ended the game (UNREACHABLE, +/// exit 23). At worst that surface renders wrong. +static void NoteUnknown(const char* what, u32 value) { + static std::atomic notes{0}; + if (notes.fetch_add(1, std::memory_order_relaxed) < 8) { + LOG_ERROR(Render, "Unknown {} = {}, using a fallback", what, value); + } +} + std::string_view NameOf(TileMode tile_mode) { return magic_enum::enum_name(tile_mode); } @@ -61,7 +75,8 @@ ArrayMode GetArrayMode(TileMode tile_mode) { case TileMode::DisplayLinearGeneral: return ArrayMode::ArrayLinearGeneral; default: - UNREACHABLE_MSG("Unknown tile mode = {}", u32(tile_mode)); + NoteUnknown("tile mode", u32(tile_mode)); + return ArrayMode::ArrayLinearAligned; } } @@ -100,7 +115,8 @@ MicroTileMode GetMicroTileMode(TileMode tile_mode) { case TileMode::Thick3DXThick: return MicroTileMode::Thick; default: - UNREACHABLE_MSG("Unknown tile mode = {}", u32(tile_mode)); + NoteUnknown("tile mode", u32(tile_mode)); + return MicroTileMode::Display; } } @@ -138,7 +154,8 @@ PipeConfig GetPipeConfig(TileMode tile_mode) { case TileMode::DisplayLinearGeneral: return PipeConfig::P2; default: - UNREACHABLE_MSG("Unknown tile mode = {}", u32(tile_mode)); + NoteUnknown("tile mode", u32(tile_mode)); + return PipeConfig::P8_32x32_16x16; } } @@ -175,7 +192,8 @@ PipeConfig GetAltPipeConfig(TileMode tile_mode) { case TileMode::DisplayLinearGeneral: return PipeConfig::P2; default: - UNREACHABLE_MSG("Unknown tile mode = {}", u32(tile_mode)); + NoteUnknown("tile mode", u32(tile_mode)); + return PipeConfig::P16_32x32_8x16; } } @@ -212,7 +230,8 @@ u32 GetSampleSplit(TileMode tile_mode) { case TileMode::Thin3DThinPrt: return 2; default: - UNREACHABLE_MSG("Unknown tile mode = {}", u32(tile_mode)); + NoteUnknown("tile mode", u32(tile_mode)); + return 1; } } @@ -252,7 +271,8 @@ u32 GetTileSplitHw(TileMode tile_mode) { case TileMode::Depth2DThinPrt1K: return 1024; default: - UNREACHABLE_MSG("Unknown tile mode = {}", u32(tile_mode)); + NoteUnknown("tile mode", u32(tile_mode)); + return 64; } } @@ -276,7 +296,8 @@ u32 GetBankWidth(MacroTileMode mode) { case MacroTileMode::Mode_1x1_2_Dup3: return 1; default: - UNREACHABLE_MSG("Unknown macro tile mode = {}", u32(mode)); + NoteUnknown("macro tile mode", u32(mode)); + return 1; } } @@ -303,7 +324,8 @@ u32 GetBankHeight(MacroTileMode mode) { case MacroTileMode::Mode_1x8_16: return 8; default: - UNREACHABLE_MSG("Unknown macro tile mode = {}", u32(mode)); + NoteUnknown("macro tile mode", u32(mode)); + return 1; } } @@ -330,7 +352,8 @@ u32 GetNumBanks(MacroTileMode mode) { case MacroTileMode::Mode_1x1_16_Dup2: return 16; default: - UNREACHABLE_MSG("Unknown macro tile mode = {}", u32(mode)); + NoteUnknown("macro tile mode", u32(mode)); + return 16; } } @@ -356,7 +379,8 @@ u32 GetMacrotileAspect(MacroTileMode mode) { case MacroTileMode::Mode_1x4_16_Dup: return 4; default: - UNREACHABLE_MSG("Unknown macro tile mode = {}", u32(mode)); + NoteUnknown("macro tile mode", u32(mode)); + return 2; } } @@ -382,7 +406,8 @@ u32 GetAltBankHeight(MacroTileMode mode) { case MacroTileMode::Mode_1x4_16_Dup: return 4; default: - UNREACHABLE_MSG("Unknown macro tile mode = {}", u32(mode)); + NoteUnknown("macro tile mode", u32(mode)); + return 2; } } @@ -409,7 +434,8 @@ u32 GetAltNumBanks(MacroTileMode mode) { case MacroTileMode::Mode_1x8_16: return 16; default: - UNREACHABLE_MSG("Unknown macro tile mode = {}", u32(mode)); + NoteUnknown("macro tile mode", u32(mode)); + return 8; } } @@ -434,7 +460,8 @@ u32 GetAltMacrotileAspect(MacroTileMode mode) { case MacroTileMode::Mode_1x4_16_Dup: return 2; default: - UNREACHABLE_MSG("Unknown macro tile mode = {}", u32(mode)); + NoteUnknown("macro tile mode", u32(mode)); + return 1; } } @@ -459,7 +486,8 @@ bool IsMacroTiled(ArrayMode array_mode) { case ArrayMode::ArrayPrt3DTiledThick: return true; default: - UNREACHABLE_MSG("Unknown array mode = {}", u32(array_mode)); + NoteUnknown("array mode", u32(array_mode)); + return false; } } @@ -484,7 +512,8 @@ bool IsPrt(ArrayMode array_mode) { case ArrayMode::Array3DTiledXThick: return false; default: - UNREACHABLE_MSG("Unknown array mode = {}", u32(array_mode)); + NoteUnknown("array mode", u32(array_mode)); + return false; } } @@ -510,7 +539,8 @@ u32 GetMicroTileThickness(ArrayMode array_mode) { case ArrayMode::Array3DTiledXThick: return 8; default: - UNREACHABLE_MSG("Unknown array mode = {}", u32(array_mode)); + NoteUnknown("array mode", u32(array_mode)); + return 1; } } @@ -524,7 +554,8 @@ u32 GetPipeCount(PipeConfig pipe_cfg) { case PipeConfig::P16_32x32_8x16: return 16; default: - UNREACHABLE_MSG("Unknown pipe config = {}", u32(pipe_cfg)); + NoteUnknown("pipe config", u32(pipe_cfg)); + return 8; } } @@ -541,19 +572,29 @@ u32 CalculateTileSplit(TileMode tile_mode, ArrayMode array_mode, MicroTileMode m } MacroTileMode CalculateMacrotileMode(TileMode tile_mode, u32 bpp, u32 num_samples) { - ASSERT_MSG(std::has_single_bit(num_samples) && num_samples <= 16, "Invalid sample count {}", - num_samples); - ASSERT_MSG(bpp >= 1 && bpp <= 128, "Invalid bpp {}", bpp); + // bbport: input out of range (a garbage sharp: MSAA levels, format) is clamped and reported + // (NoteUnknown) instead of the assertions that ended the game. + if (!std::has_single_bit(num_samples) || num_samples > 16) { + NoteUnknown("sample count", num_samples); + num_samples = 1; + } + if (bpp < 1 || bpp > 128) { + NoteUnknown("bits per element", bpp); + bpp = std::clamp(bpp, 1u, 128u); + } const ArrayMode array_mode = GetArrayMode(tile_mode); - ASSERT_MSG(IsMacroTiled(array_mode), "Tile mode not macro tiled"); + if (!IsMacroTiled(array_mode)) { + NoteUnknown("macro tiled mode", u32(tile_mode)); + return MacroTileMode::Mode_1x1_16; + } const MicroTileMode micro_tile_mode = GetMicroTileMode(tile_mode); const u32 tile_thickness = GetMicroTileThickness(array_mode); const u32 tile_bytes_1x = bpp * MICROTILE_SIZE * MICROTILE_SIZE * tile_thickness / 8; const u32 tilesplic = CalculateTileSplit(tile_mode, array_mode, micro_tile_mode, bpp); const u32 tile_bytes = std::min(tilesplic, num_samples * tile_bytes_1x); - const u32 mtm_idx = std::bit_width(tile_bytes / 64) - 1; + const u32 mtm_idx = std::bit_width(std::max(tile_bytes / 64, 1u)) - 1; return IsPrt(array_mode) ? MacroTileMode(mtm_idx + 8) : MacroTileMode(mtm_idx); } diff --git a/gpu/shadps4/video_core/buffer_cache/buffer_cache.cpp b/gpu/shadps4/video_core/buffer_cache/buffer_cache.cpp index 944e6f6..36c2d21 100644 --- a/gpu/shadps4/video_core/buffer_cache/buffer_cache.cpp +++ b/gpu/shadps4/video_core/buffer_cache/buffer_cache.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include "bbport_copy.h" #include "bbport_platform.h" @@ -494,6 +495,12 @@ BufferCache::BufferCache(const Vulkan::Instance& instance_, Vulkan::Scheduler& s static BufferCache* hooked_cache = this; BbGuestHooks::Install([](u64 address, u64 size) { hooked_cache->NoteFreshRange(address, size); }); } + // bbport BB_DEFER_PROTECT: an upload's write protection may wait for its copy only where every + // upload is a CPU copy of whole pages (not the GPU reading guest chunks, not twins keeping + // parts) and no read watchers exist (precise readbacks). + tracker.AllowDeferredProtects(WriteTracking() && !GuestInPlace() && !TwinsEnabled() && + EmulatorSettings.GetReadbacksMode() != + GpuReadbacksMode::Precise); const std::array families = {instance.GetGraphicsQueueFamilyIndex(), instance.GetReadbackQueueFamilyIndex()}; const bool shared = bool(instance.GetReadbackQueue()); // as Buffer creates the arenas @@ -523,6 +530,29 @@ BufferCache::BufferCache(const Vulkan::Instance& instance_, Vulkan::Scheduler& s FindMemoryType(instance.GetMemoryProperties(), vk::MemoryPropertyFlagBits::eDeviceLocal, reqs.memoryTypeBits) .value(); + // bbport: without the PC memory model (it needs an AMD GPU) every block of the game's memory + // the GPU uses gets VRAM and keeps it, so a 4 GB card ran out (exit 23). System memory the + // arena takes, read by the GPU over PCIe, holds new blocks once VRAM is tight instead. + if (!integrated_gpu) { + const auto& props = instance.GetMemoryProperties(); + for (u32 i = 0; i < props.memoryTypeCount; ++i) { + const auto heap = props.memoryHeaps[props.memoryTypes[i].heapIndex]; + if ((reqs.memoryTypeBits & (1u << i)) != 0 && + !(heap.flags & vk::MemoryHeapFlagBits::eDeviceLocal)) { + system_memory_type_index = i; + break; + } + } + } + if (system_memory_type_index) { + std::printf("Guest memory: the game's data in VRAM (memory type %u), in system memory " + "(type %u) while VRAM is tight\n", + arena_memory_type_index, *system_memory_type_index); + } else { + std::printf("Guest memory: the game's data in VRAM (memory type %u); no system memory " + "type for it on this GPU\n", + arena_memory_type_index); + } const u64 bda_pagetable_size = (blocks_per_arena_page * NUM_ARENA_PAGES) * sizeof(vk::DeviceAddress); @@ -548,6 +578,9 @@ void BufferCache::InvalidateMemory(VAddr device_addr, u64 size, bool assume_lock // Item: context = BufferCache, source = guest address, destination = host pointer, size, // extra = the Buffer whose mapping holds the destination (flushed after the copy). void BufferCache::RunGuestCopy(const BbCopy::Item& item) { + // bbport BB_DEFER_PROTECT: the pages an upload copies are write-protected before it reads + // them (the protection it deferred to its copy, page_manager.cpp). + PageManager::FlushDeferredProtects(); auto* cache = static_cast(item.context); auto* dst = reinterpret_cast(item.destination); cache->memory->CopySparseMemory(item.source, dst, item.size); @@ -891,6 +924,12 @@ std::pair BufferCache::ObtainBuffer(VAddr device_addr, u32 s bool is_written, bool is_texel_buffer) { BB_SECTION(ObtainBuffer); const bool stats = BufferStatsEnabled(); + const auto note_hle = [&](u32 path) { + if (hle_obtain) { + ++hle_path_calls[(is_written ? 3 : 0) + path]; + hle_path_bytes[(is_written ? 3 : 0) + path] += size; + } + }; if (stats) { PrintBufferStats(); NoteHotUse(device_addr, size); @@ -937,6 +976,7 @@ std::pair BufferCache::ObtainBuffer(VAddr device_addr, u32 s SynchronizeMemoryFromImage(arena, device_addr, size); } TraceBinding(device_addr, size, is_written, is_written ? 0 : 2); + note_hle(0); if (!is_written && !is_texel_buffer) { if (const auto shadow = ShadowCopy(device_addr, size)) { return *shadow; @@ -948,6 +988,7 @@ std::pair BufferCache::ObtainBuffer(VAddr device_addr, u32 s // For read-only buffers use device local stream buffer to reduce renderpass breaks. if (!is_written && size <= STREAM_THRESHOLD && !IsRegionGpuModified(device_addr, size)) { BB_SECTION(ObtainStream); + note_hle(1); if (stats) { auto& region = Stats().regions[RegionKey(device_addr)]; ++region.stream_count; @@ -981,6 +1022,7 @@ std::pair BufferCache::ObtainBuffer(VAddr device_addr, u32 s const auto* arena = GetArena(first_block, last_block); EnsureResident(arena, first_block, last_block); BB_SECTION(ObtainVram); + note_hle(2); const u64 uploaded_before = BbStats::buffer_upload_bytes.load(std::memory_order_relaxed); if (GuestInPlace()) { BbStats::bound_vram_bytes.fetch_add(size, std::memory_order_relaxed); @@ -994,6 +1036,7 @@ std::pair BufferCache::ObtainBuffer(VAddr device_addr, u32 s BbStats::buffer_upload_bytes.load(std::memory_order_relaxed) - uploaded_before; } if (is_written) { + BB_SECTION(VramWritten); gpu_modified_ranges.Add(device_addr, size); WriteTicks().Note(device_addr, size, scheduler.CurrentTick()); if (Readbacks()) { @@ -1021,6 +1064,19 @@ std::optional> BufferCache::CommandWriteTarget(VAd return std::pair{arena, arena->Offset(device_addr)}; } +u32 BufferCache::PrefetchBinding(VAddr device_addr, u32 size) const noexcept { + if (size == 0) { + return 0; + } + u32 lines = 0; + const u64 first_page = (device_addr >> block_shift) >> blocks_per_arena_page_shift; + if (first_page < address_space.size()) { + __builtin_prefetch(&address_space[first_page]); + ++lines; + } + return lines + memory_tracker->Prefetch(device_addr, size); +} + std::pair BufferCache::ObtainBufferForImage(VAddr device_addr, u32 size) { // bbport BB_GUEST_IN_PLACE: texture data is read by the GPU from the game's memory itself: // through the arena where it is bound in place, else (BB_CHUNK_TEXTURES) from its guest @@ -1093,6 +1149,7 @@ std::pair BufferCache::ObtainBufferForImage(VAddr device_add } return {staging.buffer, staging.offset}; } + PageManager::FlushDeferredProtects(); // see RunGuestCopy memory->CopySparseMemory(device_addr, staging.mapped, staging.size); staging.Flush(); return {staging.buffer, staging.offset}; @@ -1131,6 +1188,8 @@ void BufferCache::ProcessFaultBuffer() { } void BufferCache::SynchronizeDmaBuffers() { + dma_second.store(BbStats::coarse_second.load(std::memory_order_relaxed), + std::memory_order_relaxed); for (const auto& range : resident_ranges) { const u64 page = range.start >> (ARENA_PAGE_BITS - block_shift); const VAddr device_addr = range.start << block_shift; @@ -1211,6 +1270,24 @@ void BufferCache::EnsureResident(const Buffer* arena, u64 first_block, u64 last_ IntervalList bind_ranges; resident_ranges.ForEachGap(first_block, last_block + 1, [&](u64 start, u64 end) { bind_ranges.Add({start, end}); }); + // bbport: blocks evicted from the 2 MiB groups of this request (EvictIdleCopies) come back + // with it, whole. A draw reads past its own range into bytes another binding uploaded (vertex + // buffers are bound without a size): back in VRAM with only this range uploaded, those read + // zeros until that binding came again, and geometry went missing. + IntervalList restored; + if (!evicted_blocks.Empty() && block_shift <= USE_GROUP_BITS) { + const u64 group_shift = USE_GROUP_BITS - block_shift; + const u64 from = (first_block >> group_shift) << group_shift; + const u64 to = ((last_block >> group_shift) + 1) << group_shift; + evicted_blocks.ForEachInRange(from, to, [&](const Interval& range) { + const Interval part{std::max(from, range.start), std::min(to, range.end)}; + restored.Add(part); + bind_ranges.Add(part); + }); + if (!restored.Empty()) { + evicted_blocks.Subtract(from, to); + } + } if (bind_ranges.Empty()) { memo = {first_block, last_block, ResidencyGeneration()}; return; @@ -1232,6 +1309,8 @@ void BufferCache::EnsureResident(const Buffer* arena, u64 first_block, u64 last_ vram_blocks += range.end - range.start; } + // bbport: blocks put into slots other blocks left, cleared below. + boost::container::small_vector reused; if (vram_blocks != 0) { // bbport: the slots of blocks that left VRAM first, block by block. A request of several // blocks only ever took new space, and the free slots piled up while new 64 MiB chunks @@ -1240,6 +1319,7 @@ void BufferCache::EnsureResident(const Buffer* arena, u64 first_block, u64 last_ for (const auto& range : bind_ranges) { u64 block = range.start; for (; block < range.end && free_slot_count != 0; ++block) { + reused.push_back(block); const auto [memory, memory_offset] = AllocateResidency(block_size); auto* last = binds.empty() ? nullptr : &binds.back(); if (last && last->end == block && last->memory == memory && @@ -1297,6 +1377,26 @@ void BufferCache::EnsureResident(const Buffer* arena, u64 first_block, u64 last_ staging.Flush(); runtime.CopyBuffer(staging.buffer, bda_pagetable_buffer.get(), copies); + + // bbport: a slot another block left still holds that block's data; only the ranges bound are + // uploaded into it. Vertex buffers are bound without a size (dynamic vertex input), so a draw + // reading past its range read zeros in new VRAM (the driver clears it) and read the other + // block's data in a reused slot: stretched polygons, flashing lights. Cleared like new VRAM, + // before the uploads that follow. + for (size_t i = 0; i < reused.size();) { + size_t j = i + 1; + while (j < reused.size() && reused[j] == reused[j - 1] + 1) { + ++j; + } + runtime.FillBuffer(arena, (reused[i] << block_shift) - arena->cpu_addr, + u64(j - i) * block_size, 0u); + i = j; + } + // Evicted blocks back from the game's memory, whole (marked CPU-modified when they left). + for (const auto& range : restored) { + SynchronizeMemory(arena, range.start << block_shift, + static_cast((range.end - range.start) << block_shift), false, false); + } } std::pair BufferCache::AllocateResidency(u64 bytes) { @@ -1324,19 +1424,84 @@ std::pair BufferCache::AllocateResidency(u64 bytes) { // current one is used: the kernel clears new memory, on a Steam Deck with the CPU (16-22 ms a // burst). At most one spare block. constexpr u64 ResidencyBlock = 64_MB; - const auto allocate = [this](u64 size) { - const auto memory = Vulkan::Check(instance.GetDevice().allocateMemory({ + const auto try_allocate = [this](u64 size, u32 type) -> vk::DeviceMemory { + const auto [result, memory] = instance.GetDevice().allocateMemory({ .allocationSize = size, - .memoryTypeIndex = arena_memory_type_index, - })); + .memoryTypeIndex = type, + }); + if (result != vk::Result::eSuccess) { + return {}; + } BbStats::device_alloc_bytes.fetch_add(size, std::memory_order_relaxed); BbStats::residency_alloc_bytes.fetch_add(size, std::memory_order_relaxed); return memory; }; + // bbport: out of VRAM is no longer the end of the game. VRAM while there is room; while it is + // tight (vram_level, not with the PC memory model: there a copy in system memory would only + // duplicate the game's own) or once an allocation failed (full for 30 s), system memory. + // Without that, the empty chunks go back to the driver and VRAM is tried once more; only a + // failure then stops, as before. + const auto allocate = [this, &try_allocate](u64 size) { + const u32 now = BbStats::coarse_second.load(std::memory_order_relaxed); + const bool tight = !GuestInPlace() && vram_level.load(std::memory_order_relaxed) >= 1; + if (!tight || !system_memory_type_index) { + if (const auto memory = try_allocate(size, arena_memory_type_index)) { + return memory; + } + vram_full_until = now + 30; + vram_level.store(2, std::memory_order_relaxed); + std::printf("Guest memory: out of VRAM for %llu MiB of the game's data\n", + (unsigned long long)(size >> 20)); + } + if (system_memory_type_index) { + if (const auto memory = try_allocate(size, *system_memory_type_index)) { + system_residency_bytes += size; + if (system_residency_reports < 20 && + (system_residency_bytes == size || + (system_residency_bytes >> 28) != ((system_residency_bytes - size) >> 28))) { + ++system_residency_reports; + std::printf("Guest memory: VRAM tight, %llu MiB of the game's data in system " + "memory so far\n", + (unsigned long long)(system_residency_bytes >> 20)); + } + return memory; + } + std::printf("Guest memory: no system memory either for %llu MiB of the game's data\n", + (unsigned long long)(size >> 20)); + } + if (spare_residency.valid()) { + if (const auto spare = spare_residency.get()) { + instance.GetDevice().freeMemory(spare); + BbStats::residency_alloc_bytes.fetch_sub(64_MB, std::memory_order_relaxed); + BbStats::device_free_bytes.fetch_add(64_MB, std::memory_order_relaxed); + } + } + // Empty chunks the GPU is done with (as ProcessIdleBlocks, without its 5 s wait). + for (auto it = residency_chunks.begin(); it != residency_chunks.end();) { + if (it->second.used != 0 || it->first == static_cast(residency_memory) || + !pending_binds.empty() || !scheduler.GetWorkSemaphore()->IsFree(it->second.empty_tick + 1)) { + ++it; + continue; + } + free_slot_count -= it->second.free_offsets.size(); + instance.GetDevice().freeMemory(vk::DeviceMemory{it->first}); + BbStats::residency_alloc_bytes.fetch_sub(it->second.size, std::memory_order_relaxed); + BbStats::device_free_bytes.fetch_add(it->second.size, std::memory_order_relaxed); + it = residency_chunks.erase(it); + } + NoteResidencyUnused(); + return Vulkan::Check(instance.GetDevice().allocateMemory({ + .allocationSize = size, + .memoryTypeIndex = arena_memory_type_index, + })); + }; if (residency_used + bytes > residency_size) { const u64 size = std::max(bytes, ResidencyBlock); - residency_memory = size == ResidencyBlock && spare_residency.valid() ? spare_residency.get() - : allocate(size); + vk::DeviceMemory spare{}; + if (size == ResidencyBlock && spare_residency.valid()) { + spare = spare_residency.get(); // null when the background allocation failed + } + residency_memory = spare ? spare : allocate(size); residency_size = size; residency_used = 0; residency_chunks[static_cast(residency_memory)] = {size, 0, 0, 0}; @@ -1344,13 +1509,58 @@ std::pair BufferCache::AllocateResidency(u64 bytes) { const u64 offset = residency_used; residency_used += bytes; residency_chunks[static_cast(residency_memory)].used += bytes; - if (!spare_residency.valid() && residency_used * 2 >= residency_size) { - spare_residency = std::async(std::launch::async, allocate, ResidencyBlock); + if (!spare_residency.valid() && residency_used * 2 >= residency_size && + vram_level.load(std::memory_order_relaxed) == 0) { + spare_residency = + std::async(std::launch::async, try_allocate, ResidencyBlock, arena_memory_type_index); } NoteResidencyUnused(); return {residency_memory, offset}; } +void BufferCache::UpdateVramLevel(u32 now) { + static const u64 percent = [] { + const char* env = std::getenv("BB_VRAM_PRESSURE_PERCENT"); + return std::min(env ? std::strtoull(env, nullptr, 10) : 85, 95); + }(); + int level = 0; + u64 used = 0, budget = 0; + if (percent != 0 && instance.CanReportMemoryUsage()) { + used = instance.GetDeviceMemoryUsage(); + budget = instance.GetDeviceMemoryBudgetNow(); + if (budget != 0) { + level = used * 100 >= budget * (percent + 5) ? 2 : used * 100 >= budget * percent ? 1 : 0; + } + } + if (now < vram_full_until) { + level = 2; + } + const int was = vram_level.exchange(level, std::memory_order_relaxed); + if (level != was && vram_level_reports < 40) { + ++vram_level_reports; + static constexpr const char* kInPlace[] = { + "room again: the game's data may get VRAM copies", + "tight: copies of the game's data unused for 15 s go back to system memory", + "full: no new copies of the game's data, unused ones go back after 3 s", + }; + static constexpr const char* kCopies[] = { + "room again: the game's data stays in VRAM", + "tight: the game's data unused for 30 s leaves VRAM, new blocks go to system memory", + "full: the game's data unused for 10 s leaves VRAM, new blocks go to system memory", + }; + static constexpr const char* kCopiesVram[] = { + "room again: the game's data stays in VRAM", + "tight: the game's data unused for 30 s leaves VRAM", + "full: the game's data unused for 10 s leaves VRAM", + }; + const char* what = GuestInPlace() ? kInPlace[level] + : system_memory_type_index ? kCopies[level] + : kCopiesVram[level]; + std::printf("Guest memory: VRAM %llu of %llu MiB, %s\n", (unsigned long long)(used >> 20), + (unsigned long long)(budget >> 20), what); + } +} + void BufferCache::NoteResidencyUnused() { const u64 unused = free_slot_count * block_size + (residency_size - residency_used) + (spare_residency.valid() ? 64_MB : 0); @@ -1430,10 +1640,23 @@ void BufferCache::EvictVramBlock(u64 block) { void BufferCache::ProcessIdleBlocks() { const u32 now = BbStats::coarse_second.load(std::memory_order_relaxed); - if (now == idle_scan_second || !GuestInPlace() || VramIdleSeconds() == 0) { + if (now == idle_scan_second) { return; } idle_scan_second = now; + UpdateVramLevel(now); + if (VramIdleSeconds() == 0) { + return; + } + if (!GuestInPlace()) { + EvictIdleCopies(now); + FreeEmptyChunks(now); + return; + } + const int level = vram_level.load(std::memory_order_relaxed); + idle_seconds_now = level >= 2 ? std::min(VramIdleSeconds(), 3u) + : level == 1 ? std::min(VramIdleSeconds(), 15u) + : VramIdleSeconds(); // Blocks in VRAM whose 2 MiB were not bound for the idle time go back in place // (ProcessDemotions: only blocks of the game's direct memory without GPU-written data). const u64 tick = scheduler.CurrentTick(); @@ -1442,7 +1665,7 @@ void BufferCache::ProcessIdleBlocks() { continue; // in place } for (u64 block = range.start; block < range.end; ++block) { - if (now - group_use[(block << block_shift) >> USE_GROUP_BITS] >= VramIdleSeconds()) { + if (now - group_use[(block << block_shift) >> USE_GROUP_BITS] >= idle_seconds_now) { demotions_waiting.push_back({block, tick, false, true}); } } @@ -1452,12 +1675,14 @@ void BufferCache::ProcessIdleBlocks() { // into fuller chunks (AllocateResidency), and the chunk empties. Free slots spread evenly // (half of 1.3 GiB) left no chunk empty. One chunk every 10 s, each once a minute at most // (blocks with GPU-written data stay). - if (free_slot_count * block_size >= 128_MB && now - last_evacuation >= 10) { + // Under VRAM pressure (vram_level) whatever the free slots, every 2 s when full. + if ((free_slot_count * block_size >= 128_MB || level >= 1) && + now - last_evacuation >= (level >= 2 ? 2u : 10u)) { auto sparsest = residency_chunks.end(); for (auto it = residency_chunks.begin(); it != residency_chunks.end(); ++it) { const ResidencyChunk& chunk = it->second; if (it->first == static_cast(residency_memory) || chunk.used == 0 || - now - chunk.evacuated_second < 60) { + now - chunk.evacuated_second < (level >= 2 ? 10u : 60u)) { continue; } if (sparsest == residency_chunks.end() || chunk.used < sparsest->second.used) { @@ -1482,6 +1707,76 @@ void BufferCache::ProcessIdleBlocks() { } } } + FreeEmptyChunks(now); +} + +void BufferCache::EvictIdleCopies(u32 now) { + // bbport: without the PC memory model (it needs an AMD GPU) every block of the game's memory + // the GPU used kept its VRAM copy, and a 4 GB card ran out (exit 23). While VRAM is tight the + // copies unused for a while go. Kept: blocks with data the GPU wrote (only VRAM has it), and + // all of them while shaders read the game's memory through the page table (dma_second). + const int level = vram_level.load(std::memory_order_relaxed); + if (level == 0 || TwinsEnabled()) { + return; + } + // Longer than the PC memory model's 15 s and 3 s: an evicted block costs an upload and can show + // as late geometry when it comes back. + const u32 idle = std::min(VramIdleSeconds(), level >= 2 ? 10u : 30u); + const u32 dma = dma_second.load(std::memory_order_relaxed); + if (dma != 0 && now - dma < idle) { + if (dma_kept_reports < 10 && now - dma_kept_second >= 60) { + ++dma_kept_reports; + dma_kept_second = now; + std::printf("Guest memory: VRAM tight, but shaders read the game's memory directly: " + "its unused data stays in VRAM\n"); + } + return; + } + std::vector blocks; + for (const auto& range : resident_ranges) { + if (!residency_chunks.contains(static_cast(range.memory))) { + continue; + } + for (u64 block = range.start; block < range.end; ++block) { + const VAddr address = block << block_shift; + const u64 group = std::min(address >> USE_GROUP_BITS, group_use.size() - 1); + if (now - group_use[group] < idle || gpu_modified_ranges.Intersects(address, block_size) || + memory_tracker->IsRegionGpuModified(address, block_size)) { + continue; + } + blocks.push_back(block); + } + } + if (blocks.empty()) { + return; + } + for (const u64 block : blocks) { + EvictVramBlock(block); + evicted_blocks.Add({block, block + 1}); + } + // Their page table entries are cleared: a shader reading one through it reports a fault and + // the block is bound again (ProcessFaultBuffer) instead of the shader reading unbound memory. + for (size_t i = 0; i < blocks.size();) { + size_t j = i + 1; + while (j < blocks.size() && blocks[j] == blocks[j - 1] + 1) { + ++j; + } + runtime.FillBuffer(bda_pagetable_buffer.get(), blocks[i] * sizeof(vk::DeviceAddress), + (j - i) * sizeof(vk::DeviceAddress), 0u); + i = j; + } + const u64 bytes = u64(blocks.size()) * block_size; + evicted_bytes += bytes; + if (evict_reports < 20 && + (evicted_bytes == bytes || (evicted_bytes >> 28) != ((evicted_bytes - bytes) >> 28))) { + ++evict_reports; + std::printf("Guest memory: VRAM tight, %llu MiB of the game's data unused for %u s left VRAM " + "so far (uploaded again when used)\n", + (unsigned long long)(evicted_bytes >> 20), idle); + } +} + +void BufferCache::FreeEmptyChunks(u32 now) { // Chunks without blocks for 5 s, the rebinds away from them long done: back to the driver. for (auto it = residency_chunks.begin(); it != residency_chunks.end();) { const ResidencyChunk& chunk = it->second; @@ -1503,7 +1798,8 @@ void BufferCache::ProcessIdleBlocks() { } bool BufferCache::VramEligible(u64 block, bool garlic_known) { - if (!GarlicInVram() || dynamic_blocks.Contains(block)) { + if (!GarlicInVram() || dynamic_blocks.Contains(block) || + vram_level.load(std::memory_order_relaxed) >= 2) { return false; } if (!WriteTracking()) { @@ -1643,7 +1939,7 @@ void BufferCache::ProcessDemotions() { if (!force && BbStats::coarse_second.load(std::memory_order_relaxed) - group_use[address >> USE_GROUP_BITS] < - VramIdleSeconds()) { + idle_seconds_now) { return true; // bound again since } } else if (!cpu_written && VramEligible(block)) { @@ -2299,6 +2595,7 @@ bool BufferCache::SynchronizeMemory(const Buffer* arena, VAddr device_addr, u32 boost::container::small_vector copies; size_t total_size_bytes{}; const Buffer* src_buffer{}; + BbSections::Scope track_scope{BbSections::VramTrack}; memory_tracker->ForEachUploadRange( device_addr, size, is_written, [&](u64 addr, u64 size) { @@ -2364,8 +2661,10 @@ bool BufferCache::SynchronizeMemory(const Buffer* arena, VAddr device_addr, u32 copies.assign(staged.begin(), staged.end()); total_size_bytes = staged_bytes; } + BB_SECTION(VramUpload); src_buffer = UploadCopies(arena, copies, total_size_bytes); }); + track_scope.End(); if (src_buffer) { runtime.CopyBuffer(src_buffer, arena, copies); @@ -2417,6 +2716,7 @@ const Buffer* BufferCache::UploadCopies(const Buffer* arena, std::spanCopySparseMemory(copy.source, copy.destination, copy.size); } @@ -2446,6 +2746,7 @@ const Buffer* BufferCache::UploadCopies(const Buffer* arena, std::spanCopySparseMemory(copies[i].dstOffset, staging.mapped + copies[i].srcOffset, copies[i].size); }; + PageManager::FlushDeferredProtects(); // see RunGuestCopy if (total_size_bytes >= 2_MB && copies.size() > 1) { BbCopy::ParallelFor(copies.size(), copy); } else { @@ -2462,6 +2763,7 @@ const Buffer* BufferCache::UploadCopies(const Buffer* arena, std::span hle_path_calls{}, hle_path_bytes{}; + /// Finds a buffer for the specified region. [[nodiscard]] std::pair ObtainBuffer(VAddr device_addr, u32 size, bool is_written, @@ -128,6 +134,11 @@ public: /// Attempts to obtain a buffer without modifying the cache contents. [[nodiscard]] std::pair ObtainBufferForImage(VAddr device_addr, u32 size); + /// bbport (BB_DRAWREC_WORKERS, the lookahead thread): asks the cache for what ObtainBuffer + /// reads first for this range (the arena table entry, the page tracking words); nothing is + /// changed or read through. Returns the cache lines requested. + u32 PrefetchBinding(VAddr device_addr, u32 size) const noexcept; + /// bbport BB_GUEST_IN_PLACE: where a command processor write (WRITE_DATA) the GPU performs in /// stream order lands: the arena over the game's memory, if the range is bound there in place. /// Not counted as a GPU write of the block (those move blocks to VRAM): it is CPU data, as a PC @@ -300,10 +311,18 @@ private: /// bbport: idle VRAM blocks (see group_use). void NoteUse(VAddr address, u64 size); void ProcessIdleBlocks(); + /// bbport: vram_level from the driver's usage and budget (BB_VRAM_PRESSURE_PERCENT, 85). + void UpdateVramLevel(u32 now); /// A VRAM slot of a block that left it: reused by the next move to VRAM. void ReleaseResidencySlot(vk::DeviceMemory memory, u64 offset); /// Unbinds a VRAM block of memory the game unmapped (uploaded again if mapped and used). void EvictVramBlock(u64 block); + /// bbport: without the PC memory model, while VRAM is tight (vram_level): VRAM copies of the + /// game's data unused for 30 s (10 s when full) leave VRAM; their slots take the next blocks + /// instead of new VRAM, and they are uploaded again from the game's memory when bound. + void EvictIdleCopies(u32 now); + /// Residency chunks without blocks for 5 s, the rebinds away from them done: back to the driver. + void FreeEmptyChunks(u32 now); [[nodiscard]] static u32 VramIdleSeconds(); /// BB_CHUNK_TEXTURES: texture data read by the GPU straight from the guest memory chunk it is /// in (no CPU copy, no VRAM buffer copy), when the range is contiguous there. @@ -412,6 +431,11 @@ private: std::array, 256> chunk_buffers{}; ///< per guest memory chunk (Chunk::index) u32 arena_memory_type_index{}; + /// bbport: a memory type the arena takes outside the card's own heaps (discrete GPUs): new + /// blocks of the game's data go there while VRAM is tight or full (AllocateResidency). + std::optional system_memory_type_index; + u64 system_residency_bytes = 0; + u32 system_residency_reports = 0; vk::DeviceMemory residency_memory{}; ///< bbport: the 64 MiB block arena residency comes from u64 residency_size = 0, residency_used = 0; std::future spare_residency; ///< the next block, allocated in the background @@ -436,6 +460,21 @@ private: std::unordered_map residency_chunks; u32 idle_scan_second = 0; u32 last_evacuation = 0; ///< ProcessIdleBlocks: when a chunk was last emptied + /// bbport: how full VRAM is against the driver's budget, once a second (UpdateVramLevel): + /// 0 room, 1 tight (idle VRAM copies go back sooner), 2 full (no new ones either). + std::atomic vram_level{0}; + u32 vram_full_until = 0; ///< a failed VRAM allocation counts as full until then + u32 vram_level_reports = 0; + /// bbport: the second a shader last read the game's memory through the page table + /// (SynchronizeDmaBuffers). Those reads do not mark blocks as used, so nothing leaves VRAM + /// while such shaders run (EvictIdleCopies). + std::atomic dma_second{0}; + u64 evicted_bytes = 0; + /// Blocks EvictIdleCopies took out of VRAM: they come back with their whole 2 MiB group. + TrackedIntervalList<> evicted_blocks; + u32 evict_reports = 0, dma_kept_reports = 0, dma_kept_second = 0; + /// The seconds a VRAM copy may go unused before it goes back in place, at this level. + u32 idle_seconds_now = 60; u32 block_size{}; u32 block_shift{}; u32 blocks_per_arena_page{}; diff --git a/gpu/shadps4/video_core/buffer_cache/memory_tracker.h b/gpu/shadps4/video_core/buffer_cache/memory_tracker.h index 803181c..60c8bae 100644 --- a/gpu/shadps4/video_core/buffer_cache/memory_tracker.h +++ b/gpu/shadps4/video_core/buffer_cache/memory_tracker.h @@ -61,6 +61,32 @@ public: return 0; } + /// bbport (BB_DRAWREC_WORKERS, the lookahead thread): asks the cache for the page state + /// words a binding of the range reads (IsRegionModified, ForEachUploadRange); regions are + /// not created and nothing is read through them. Returns the cache lines requested. + u32 Prefetch(VAddr cpu_address, u64 size) const noexcept { + if (size == 0) { + return 0; + } + u32 lines = 0; + const std::size_t last = (cpu_address + size - 1) >> TRACKER_HIGHER_PAGE_BITS; + for (std::size_t page_index = cpu_address >> TRACKER_HIGHER_PAGE_BITS; + page_index <= last && page_index < top_tier.size(); ++page_index) { + const RegionManager* manager = top_tier[page_index]; + if (!manager) { + continue; + } + const auto* cpu = reinterpret_cast(&manager->GetRegionBits()); + const auto* gpu = reinterpret_cast(&manager->GetRegionBits()); + for (std::size_t offset = 0; offset < sizeof(RegionBits); offset += 64) { + __builtin_prefetch(cpu + offset); + __builtin_prefetch(gpu + offset); + lines += 2; + } + } + return lines; + } + /// Returns true if a region has been modified from the GPU bool IsRegionGpuModified(VAddr query_cpu_addr, u64 query_size) noexcept { return IteratePages( diff --git a/gpu/shadps4/video_core/buffer_cache/region_manager.h b/gpu/shadps4/video_core/buffer_cache/region_manager.h index d2e7d00..710dd70 100644 --- a/gpu/shadps4/video_core/buffer_cache/region_manager.h +++ b/gpu/shadps4/video_core/buffer_cache/region_manager.h @@ -161,7 +161,9 @@ public: bits.UnsetRange(start_page, end_page); if constexpr (type == Type::CPU) { KeepHotPagesModified(start_page, end_page); - UpdateProtection(); + // bbport: the caller copies these pages next (ForEachUploadRange): their write + // protection may wait for that copy (BB_DEFER_PROTECT). + UpdateProtection(); } else if (EmulatorSettings.GetReadbacksMode() != GpuReadbacksMode::Disabled) { UpdateProtection(); } @@ -229,8 +231,10 @@ private: * @param new_bits New state of the word * * @tparam track True when the tracker should start tracking the new pages + * @tparam defer bbport: the pages are copied next; the tracker may defer their write + * protection to that copy (PageManager::DeferPageWatchersForRegion) */ - template + template void UpdateProtection() { RENDERER_TRACE; RegionBits mask = is_read ? (~gpu ^ readable) : (cpu ^ writeable); @@ -242,6 +246,12 @@ private: } else { writeable = cpu; } + if constexpr (defer && track && !is_read) { + if (tracker->DeferringProtects()) { + tracker->DeferPageWatchersForRegion(cpu_addr, mask); + return; + } + } tracker->UpdatePageWatchersForRegion(cpu_addr, mask); } diff --git a/gpu/shadps4/video_core/host_shaders/hdr_native.comp b/gpu/shadps4/video_core/host_shaders/hdr_native.comp new file mode 100644 index 0000000..048efd8 --- /dev/null +++ b/gpu/shadps4/video_core/host_shaders/hdr_native.comp @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: native HDR output (hdr_mode=native, docs/hdr.md). The game tonemaps its linear HDR +// scene into an 8-bit SDR frame. These passes measure how much brighter than that frame its own +// scene is where the game's curve compressed the highlights, as a gain map the HDR encode +// (hdr_output.frag) multiplies the finished SDR frame (UI included) with: +// LUMA before the post-processing combine: the scene color's brightest channel at half +// size, the 2x2 maximum (highlights) and that of the 2x2 average (exposure). +// STATS the exposure statistics: log2(SDR / scene) over the SDR midtones (bloom's outliers +// weighted down), one sum per group; with SNAPSHOT also a copy of the tonemapped +// frame before the first UI pass. +// EXPOSURE one group: the sums added up, the exposure smoothed over frames. +// GAIN at the display pass: the gain per pixel of the finished frame. +// The midtones keep the SDR picture exactly (gain 1 below the knee); the game's look (grading, +// contrast, vignette, fog colour) stays, only the highlights get brighter. + +#version 450 +#extension GL_EXT_samplerless_texture_functions : require + +float Max3(vec3 c) { + return max(c.r, max(c.g, c.b)); +} + +// As hdr_output.frag: the game's picture is made for SDR displays, which show it with 2.2. +vec3 SdrToLinear(vec3 c) { + return pow(max(c, vec3(0.0)), vec3(2.2)); +} + +// What the game's curve does to the midtones, as log2(SDR linear / scene). +struct Exposure { + float log_scale; // smoothed over frames + float valid; // 1 once measured + float samples; // midtone samples of the last measurement (debugging) + float frames; // measurements so far +}; + +#if defined(LUMA) + +layout(local_size_x = 8, local_size_y = 8) in; + +layout(binding = 0) uniform texture2D scene_tex; +layout(binding = 1, rg16f) uniform writeonly image2D luma_img; + +void main() { + const ivec2 texel = ivec2(gl_GlobalInvocationID.xy); + if (any(greaterThanEqual(texel, imageSize(luma_img)))) { + return; + } + const ivec2 last = textureSize(scene_tex, 0) - 1; + float peak = 0.0; + vec3 sum = vec3(0.0); + for (int y = 0; y < 2; ++y) { + for (int x = 0; x < 2; ++x) { + vec3 c = texelFetch(scene_tex, min(texel * 2 + ivec2(x, y), last), 0).rgb; + // A NaN from an effect must not spread; an overflow (inf) is just very bright. + c = clamp(mix(c, vec3(0.0), isnan(c)), vec3(0.0), vec3(65504.0)); + peak = max(peak, Max3(c)); + sum += c; + } + } + imageStore(luma_img, texel, vec4(peak, Max3(sum * 0.25), 0.0, 0.0)); +} + +#elif defined(STATS) + +layout(local_size_x = 16, local_size_y = 16) in; + +layout(binding = 0) uniform texture2D frame_tex; // UNORM view: the display-encoded values +layout(binding = 1) uniform texture2D luma_tex; +layout(std430, binding = 2) writeonly buffer Partials { + vec4 partials[]; // per group: weighted sum of log2(SDR / scene), weights, samples +}; +layout(std430, binding = 3) readonly buffer State { + Exposure exposure; +}; +#ifdef SNAPSHOT +layout(binding = 4, rgba8) uniform writeonly image2D snapshot_img; +#endif +layout(push_constant) uniform Params { + uint reset; // the last exposure is stale (no measurement last frame) +}; + +// SDR midtones (linear brightest channel): above the toe of the game's curve, below its +// shoulder, where it is close to a plain exposure. +const float MID_LOW = 0.02; +const float MID_HIGH = 0.45; +// Bloom and glare light up SDR midtones around lamps where the scene is dark: samples far from +// the last exposure (in stops) count less. A uniform shift (a new area, the eye adapting) moves +// all samples alike and the weighted mean follows it. +const float SPREAD = 1.0; + +shared vec3 group_sums[256]; + +void main() { + const ivec2 size = textureSize(frame_tex, 0); + const ivec2 pixel = ivec2(gl_GlobalInvocationID.xy); + vec3 sample_sum = vec3(0.0); + if (all(lessThan(pixel, size))) { + const vec4 c = texelFetch(frame_tex, pixel, 0); +#ifdef SNAPSHOT + // Bit for bit (8-bit values in and out): the display pass finds the UI as any change. + imageStore(snapshot_img, pixel, c); +#endif + const float sdr = Max3(SdrToLinear(c.rgb)); + const ivec2 luma_size = textureSize(luma_tex, 0); + const ivec2 at = min(ivec2((vec2(pixel) + 0.5) * vec2(luma_size) / vec2(size)), + luma_size - 1); + const float scene = texelFetch(luma_tex, at, 0).g; + if (sdr >= MID_LOW && sdr <= MID_HIGH && scene > 1e-5) { + const float stops = clamp(log2(sdr / scene), -32.0, 32.0); + const float off = reset == 0u && exposure.valid > 0.0 + ? (stops - exposure.log_scale) / SPREAD + : 0.0; + const float weight = exp(-0.5 * off * off) + 1e-6; + sample_sum = vec3(stops * weight, weight, 1.0); + } + } + const uint index = gl_LocalInvocationIndex; + group_sums[index] = sample_sum; + barrier(); + for (uint stride = 128u; stride > 0u; stride >>= 1u) { + if (index < stride) { + group_sums[index] += group_sums[index + stride]; + } + barrier(); + } + if (index == 0u) { + partials[gl_WorkGroupID.y * gl_NumWorkGroups.x + gl_WorkGroupID.x] = + vec4(group_sums[0], 0.0); + } +} + +#elif defined(EXPOSURE) + +layout(local_size_x = 256) in; + +layout(std430, binding = 0) readonly buffer Partials { + vec4 partials[]; +}; +layout(std430, binding = 1) buffer State { + Exposure exposure; +}; +layout(push_constant) uniform Params { + uint count; // groups of the STATS pass + uint reset; // no measurement last frame: take this one as it is + float min_samples; // fewer midtone samples (a dark or white frame): keep the last value + float rate; // smoothing per frame +}; + +shared vec3 sums[256]; + +void main() { + const uint index = gl_LocalInvocationIndex; + vec3 sum = vec3(0.0); + for (uint i = index; i < count; i += 256u) { + sum += partials[i].xyz; + } + sums[index] = sum; + barrier(); + for (uint stride = 128u; stride > 0u; stride >>= 1u) { + if (index < stride) { + sums[index] += sums[index + stride]; + } + barrier(); + } + if (index != 0u) { + return; + } + const vec3 total = sums[0]; + exposure.samples = total.z; + if (total.z >= min_samples && total.y > 0.0) { + const float measured = total.x / total.y; + exposure.log_scale = reset != 0u || exposure.valid == 0.0 + ? measured + : mix(exposure.log_scale, measured, rate); + exposure.valid = 1.0; + exposure.frames += 1.0; + } else if (reset != 0u) { + // Back from menus or loading with too little to measure: no stale exposure. + exposure.valid = 0.0; + } +} + +#elif defined(GAIN) + +layout(local_size_x = 8, local_size_y = 8) in; + +layout(binding = 0) uniform texture2D frame_tex; // the finished frame (UI included) +layout(binding = 1) uniform texture2D snapshot_tex; // the same before the first UI pass +layout(binding = 2) uniform texture2D luma_tex; +layout(std430, binding = 3) readonly buffer State { + Exposure exposure; +}; +layout(binding = 4, r16f) uniform writeonly image2D gain_img; + +layout(push_constant) uniform Params { + float strength; // share of the scene's highlight range, in log2 (0: SDR, 1: all of it) + float white; // peak / paper white: the most a pixel may reach (SDR white = 1) + float knee; // linear brightest channel of the SDR frame where the gain starts + float unused; +}; + +// Unchanged up to halfway between SDR white and the peak, then towards the peak with the same +// slope there: highlights above the display's range keep some gradation instead of clipping. +float SoftCap(float x, float limit) { + const float start = 0.5 * (1.0 + limit); + if (x <= start) { + return x; + } + const float room = limit - start; + return start + room * (1.0 - exp(-(x - start) / room)); +} + +void main() { + const ivec2 pixel = ivec2(gl_GlobalInvocationID.xy); + const ivec2 size = imageSize(gain_img); + if (any(greaterThanEqual(pixel, size))) { + return; + } + const vec3 finished = texelFetch(frame_tex, pixel, 0).rgb; + const vec3 before_ui = texelFetch(snapshot_tex, pixel, 0).rgb; + // UI: whatever the UI passes changed (HUD, menus, subtitles, their translucent backgrounds). + const float ui = smoothstep(0.5 / 255.0, 2.5 / 255.0, Max3(abs(finished - before_ui))); + float gain = 1.0; + const float sdr = Max3(SdrToLinear(before_ui)); + const float ramp = smoothstep(knee, 1.0, sdr); + if (exposure.valid > 0.0 && ramp > 0.0 && white > 1.0) { + // The brightest scene pixel of the 2x2 luma texels around (4x4 scene pixels): the + // highlight's footprint does not shimmer with the upscalers' sub-pixel jitter or the + // resampling between the scene and this frame (scaled presets). + const ivec2 luma_size = textureSize(luma_tex, 0); + const vec2 at = (vec2(pixel) + 0.5) * vec2(luma_size) / vec2(size) - 0.5; + const ivec2 base = ivec2(floor(at)); + float peak = 0.0; + for (int y = 0; y < 2; ++y) { + for (int x = 0; x < 2; ++x) { + const ivec2 texel = clamp(base + ivec2(x, y), ivec2(0), luma_size - 1); + peak = max(peak, texelFetch(luma_tex, texel, 0).r); + } + } + // The scene's brightness on the SDR frame's scale, as the midtones are shown. Bloom and + // glare are in the SDR frame only: where it is brighter than the scene, gain stays 1. + const float scene = exp2(exposure.log_scale) * peak; + const float ratio = scene / max(sdr, 1e-4); + if (ratio > 1.0) { + const float target = SoftCap(sdr * exp2(strength * ramp * log2(ratio)), white); + gain = max(target / max(sdr, 1e-4), 1.0); + } + } + gain = mix(gain, 1.0, ui); + // Negative where UI was found (for the debug view); the encode uses the magnitude. + imageStore(gain_img, pixel, vec4(ui >= 0.5 ? -gain : gain, 0.0, 0.0, 0.0)); +} + +#endif diff --git a/gpu/shadps4/video_core/host_shaders/hdr_output.frag b/gpu/shadps4/video_core/host_shaders/hdr_output.frag new file mode 100644 index 0000000..b3d6246 --- /dev/null +++ b/gpu/shadps4/video_core/host_shaders/hdr_output.frag @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: HDR output. The game's SDR picture (letterboxed into a swapchain-sized sRGB-encoded +// image) and the settings menu's layer (premultiplied alpha, sRGB encoded) become the HDR +// swapchain's signal: HDR10 (BT.2020 primaries, PQ) or scRGB (linear BT.709, 1.0 = 80 nits). +// SDR white is shown at paper_white. Native mode (FLAG_NATIVE): the frame's gain map +// (hdr_native.comp: how much brighter the game's own scene is where its tonemap compressed the +// highlights; 1 for midtones and UI) brightens the highlights. Expand mode, and native frames +// without a scene (menus, loading, movies): boost expands the SDR picture's highlights towards +// the peak. +// bbport: in native mode the game's UI (where the gain map is negative, and all of a frame +// without a scene) has its own white (ui_white times paper white) and colours: ui_vivid of the +// way from sRGB's (exact) to a wide-gamut SDR monitor's (the values taken as DCI-P3), which is +// how most players have seen it - next to that, exact sRGB looks washed out. + +#version 450 + +layout (location = 0) out vec4 color; + +layout (binding = 0) uniform sampler2D game; // the letterboxed SDR frame, swapchain-sized +layout (binding = 1) uniform sampler2D overlay; // the menu layer (FLAG_OVERLAY) +layout (binding = 2) uniform sampler2D gain; // the frame's gain map (FLAG_NATIVE), filtered + +layout (push_constant) uniform HdrSettings { + float paper_white; // nits of SDR white + float peak; // nits the display reaches + float boost; // 0: SDR white at paper white .. 1: SDR white at the peak + uint flags; + vec2 gain_offset; // the frame's rectangle in the output: its top left corner, pixels, + vec2 gain_scale; // and 1 / its size: output pixels -> gain map coordinates + float ui_white; // native mode: the UI's white, times paper white + float ui_vivid; // native mode: 0 the UI in sRGB's colours .. 1 in DCI-P3's +} hdr; + +const uint FLAG_SCRGB = 1u; // scRGB output, else HDR10 +const uint FLAG_OVERLAY = 2u; // the menu layer has something drawn +const uint FLAG_NATIVE = 4u; // the gain map belongs to this frame +const uint FLAG_DEBUG = 8u; // BB_HDR_DEBUG: the gain as a heat map +const uint FLAG_UI_FRAME = 16u; // native mode, a frame without a scene: all of it is UI + +// Linear brightness (SDR white = 1) above which the highlights are expanded: high, so large +// bright areas (fog, sky) stay near paper white and only lamps, fire and sparks reach the peak. +const float KNEE = 0.8; + +// BT.709 -> BT.2020 primaries (columns). +const mat3 BT709_TO_BT2020 = mat3( + 0.6274, 0.0691, 0.0164, + 0.3293, 0.9195, 0.0880, + 0.0433, 0.0114, 0.8956); + +// DCI-P3 (D65) -> BT.709 primaries (columns): BT.709 values outside [0, 1] are the wider gamut, +// which the BT.2020 and scRGB encodes below both carry. +const mat3 P3_TO_BT709 = mat3( + 1.2249, -0.0421, -0.0196, + -0.2249, 1.0421, -0.0786, + 0.0, 0.0, 1.0983); + +// The game's picture is made for SDR displays, which show it with a 2.2 power curve. The +// piecewise sRGB curve (what Windows uses for SDR content in HDR) lifts the shadows: dark +// scenes turn grey and foggy. Bloodborne is mostly shadows, so 2.2 it is. +vec3 SdrToLinear(vec3 c) { + return pow(max(c, vec3(0.0)), vec3(2.2)); +} + +// SMPTE ST 2084 inverse EOTF; y: luminance / 10000 nits. +vec3 PqEncode(vec3 y) { + const float m1 = 0.1593017578125; + const float m2 = 78.84375; + const float c1 = 0.8359375; + const float c2 = 18.8515625; + const float c3 = 18.6875; + vec3 ym = pow(clamp(y, 0.0, 1.0), vec3(m1)); + return pow((c1 + c2 * ym) / (1.0 + c3 * ym), vec3(m2)); +} + +// Inverse tone map of the brightest channel (hue and saturation kept): unchanged up to KNEE, +// then a curve with the same slope there that reaches `white` at SDR white. +vec3 ExpandHighlights(vec3 rgb, float white) { + float m = max(rgb.r, max(rgb.g, rgb.b)); + if (white <= 1.0 || m <= KNEE) { + return rgb; + } + float t = (m - KNEE) / (1.0 - KNEE); + float expanded = m + (white - 1.0) * t * t; + return rgb * (expanded / m); +} + +// Debug view colours: blue (a little gain) .. cyan .. green .. yellow .. red (the peak). +vec3 Heat(float t) { + t = clamp(t, 0.0, 1.0); + return clamp(vec3(1.5 - abs(4.0 * t - 3.0), 1.5 - abs(4.0 * t - 2.0), + 1.5 - abs(4.0 * t - 1.0)), 0.0, 1.0); +} + +void main() { + ivec2 pixel = ivec2(gl_FragCoord.xy); + vec3 game_srgb = texelFetch(game, pixel, 0).rgb; + float peak_white = max(hdr.peak / hdr.paper_white, 1.0); + float white = max(mix(1.0, peak_white, clamp(hdr.boost, 0.0, 1.0)), 1.0); + vec3 sdr_linear = SdrToLinear(game_srgb); + vec3 linear; + float applied = 1.0; // the brightest channel's gain (debug view) + bool ui = false; + float ui_amount = (hdr.flags & FLAG_UI_FRAME) != 0u ? 1.0 : 0.0; // filtered: soft UI edges + if ((hdr.flags & FLAG_NATIVE) != 0u) { + // Outside the frame (letterbox bars) the map is not sampled: gain 1. + vec2 uv = (gl_FragCoord.xy - hdr.gain_offset) * hdr.gain_scale; + if (all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0)))) { + float g = textureLod(gain, uv, 0.0).r; + ui = g < 0.0; + ui_amount = clamp(-g, 0.0, 1.0); + applied = max(abs(g), 1.0); + } + // The map keeps the brightest channel within the peak; filtering and the letterbox + // blit can step over it by a little. + linear = min(sdr_linear * applied, vec3(peak_white)); + } else { + linear = ExpandHighlights(sdr_linear, white); + float m = max(sdr_linear.r, max(sdr_linear.g, sdr_linear.b)); + applied = m > 0.0 ? max(linear.r, max(linear.g, linear.b)) / m : 1.0; + } + if (ui_amount > 0.0) { + linear *= mix(1.0, hdr.ui_white, ui_amount); + linear = mix(linear, P3_TO_BT709 * linear, ui_amount * clamp(hdr.ui_vivid, 0.0, 1.0)); + linear = min(linear, vec3(peak_white)); + } + if ((hdr.flags & FLAG_DEBUG) != 0u) { + // The picture in grey at a third of paper white, the gain over it from blue (a little) + // to red (the peak), UI that native mode keeps at paper white in magenta. Top left + // corner: green while native mode has this frame's gain map, orange while it falls back + // to expand (or in expand mode). + float y = dot(sdr_linear, vec3(0.2126, 0.7152, 0.0722)); + vec3 shown = vec3(y * 0.33); + if (applied > 1.001) { + shown = mix(shown, Heat(log2(applied) / max(log2(peak_white), 0.1)), 0.8); + } + if (ui) { + shown = mix(shown, vec3(1.0, 0.0, 1.0), 0.5); + } + if (gl_FragCoord.x < 48.0 && gl_FragCoord.y < 48.0) { + shown = (hdr.flags & FLAG_NATIVE) != 0u ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.5, 0.0); + } + linear = shown; + } + if ((hdr.flags & FLAG_OVERLAY) != 0u) { + // The menu over the frame as the SDR path draws it (blended in sRGB), at paper white: + // the menu is not expanded. + vec4 menu = texelFetch(overlay, pixel, 0); + if (menu.a > 0.0) { + vec3 sdr = SdrToLinear(clamp(menu.rgb + game_srgb * (1.0 - menu.a), 0.0, 1.0)); + linear = mix(linear, sdr, clamp(menu.a * 4.0, 0.0, 1.0)); + } + } + if ((hdr.flags & FLAG_SCRGB) != 0u) { + color = vec4(linear * (hdr.paper_white / 80.0), 1.0); + } else { + vec3 nits = max(BT709_TO_BT2020 * linear, vec3(0.0)) * hdr.paper_white; + color = vec4(PqEncode(nits / 10000.0), 1.0); + } +} diff --git a/gpu/shadps4/video_core/page_manager.cpp b/gpu/shadps4/video_core/page_manager.cpp index 128e922..49b5c10 100644 --- a/gpu/shadps4/video_core/page_manager.cpp +++ b/gpu/shadps4/video_core/page_manager.cpp @@ -2,6 +2,8 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include +#include "bbport_sections.h" +#include "bbport_threads.h" #include "bbport_toggles.h" #include "common/assert.h" #include "common/debug.h" @@ -9,13 +11,19 @@ #include "common/error.h" #include "common/range_lock.h" #include "common/signal_context.h" +#include "common/thread.h" #include "core/emulator_settings.h" #include "core/memory.h" #include "video_core/buffer_cache/buffer.h" #include #include #include +#include #include +#include +#include +#include +#include #include "bbport_platform.h" #ifndef _WIN32 #include @@ -100,6 +108,19 @@ std::string SiteName(u64 key) { /// The write fault this thread is handling (guest offsets; 0: none). thread_local u64 current_fault_rip = 0, current_fault_caller = 0; +/// bbport (BB_FRAME_STATS): what the protection changes this thread makes are for +/// (BbStats::ProtectKind), set by PageManager's entry points. +thread_local u32 protect_kind = BbStats::ProtectOther; +struct ProtectKindScope { + explicit ProtectKindScope(u32 kind) : saved{protect_kind} { + protect_kind = kind; + } + ~ProtectKindScope() { + protect_kind = saved; + } + u32 saved; +}; + /// Guest code that wrote image (texture) memory: keyed by instruction and image. struct ImageFaultSite { std::atomic key{0}; // a mix of the site and the image address @@ -234,6 +255,17 @@ void PageManager::ReportFaultSites() { std::printf("\n"); } +/// bbport: a watcher count that would leave its range, reported (a few times) instead of the +/// assertion that ended the game (exit 23), or the 7-bit count wrapping to 0 with the page still +/// protected. +static void NoteWatcherCount(u64 page, const char* what) { + static std::atomic notes{0}; + if (notes.fetch_add(1, std::memory_order_relaxed) < 8) { + std::printf("GPU: page tracking: %s at page %#llx; tracking goes on\n", what, + static_cast(page << 12)); + } +} + struct PageManager::Impl { struct PageState { u8 num_write_watchers : 7; @@ -241,6 +273,8 @@ struct PageManager::Impl { // And buffers cannot overlap, thus only 1 can exist per page. u8 num_read_watchers : 1; + static constexpr u8 MaxWriteWatchers = 127; + Core::MemoryPermission WritePerm() const noexcept { return num_write_watchers == 0 ? Core::MemoryPermission::Write : Core::MemoryPermission::None; @@ -255,22 +289,46 @@ struct PageManager::Impl { return ReadPerm() | WritePerm(); } + /// bbport: `page` (its number) for the counts past the bit fields (SpillWatcher). Called + /// with the page's lock held. template - u8 AddDelta() { + u8 AddDelta(u64 page) { if constexpr (is_read) { if constexpr (delta == 1) { + if (num_read_watchers == 1) [[unlikely]] { + NoteWatcherCount(page, "a second read watcher"); + return num_read_watchers; + } return ++num_read_watchers; } else if (delta == -1) { - ASSERT_MSG(num_read_watchers > 0, "Not enough watchers"); + if (num_read_watchers == 0) [[unlikely]] { + NoteWatcherCount(page, "not enough read watchers"); + return 0; + } return --num_read_watchers; } else { return num_read_watchers; } } else { if constexpr (delta == 1) { + // 128 or more watchers (images piling up on one page): the count stays at + // its maximum, the page protected, and the excess is kept aside. + if (num_write_watchers == MaxWriteWatchers) [[unlikely]] { + if (!SpillWatcher(page, 1)) { + NoteWatcherCount(page, "write watchers beyond the overflow table"); + } + return num_write_watchers; + } return ++num_write_watchers; } else if (delta == -1) { - ASSERT_MSG(num_write_watchers > 0, "Not enough watchers"); + if (num_write_watchers == MaxWriteWatchers && SpillWatcher(page, -1)) + [[unlikely]] { + return num_write_watchers; + } + if (num_write_watchers == 0) [[unlikely]] { + NoteWatcherCount(page, "not enough write watchers"); + return 0; + } return --num_write_watchers; } else { return num_write_watchers; @@ -279,13 +337,50 @@ struct PageManager::Impl { } }; + /// bbport: write watchers past a page's 7-bit count. +1 keeps one more (false: the table is + /// full, the watcher is lost and the page may be unprotected early); -1 takes one back + /// (false: none kept for the page). Nests inside the page locks; nothing else takes it. + struct SpilledWatchers { + u64 page; + u32 extra; + }; + inline static std::array spilled_watchers{}; + inline static std::atomic_flag spill_lock{}; + + static bool SpillWatcher(u64 page, s32 delta) { + while (spill_lock.test_and_set(std::memory_order_acquire)) { + } + bool done = false; + SpilledWatchers* free_entry = nullptr; + for (auto& entry : spilled_watchers) { + if (entry.extra != 0 && entry.page == page) { + entry.extra += delta; + done = true; + break; + } + if (entry.extra == 0 && !free_entry) { + free_entry = &entry; + } + } + if (!done && delta > 0 && free_entry) { + *free_entry = {page, 1}; + done = true; + } + spill_lock.clear(std::memory_order_release); + return done; + } + static constexpr size_t ADDRESS_BITS = 40; static constexpr size_t NUM_ADDRESS_PAGES = 1ULL << (40 - PM_PAGE_BITS); static constexpr size_t NUM_ADDRESS_LOCKS = NUM_ADDRESS_PAGES / PAGES_PER_LOCK; inline static Vulkan::Rasterizer* rasterizer; Impl() = default; - virtual ~Impl() = default; + virtual ~Impl() { + for (auto& d : deferred) { + delete d.load(std::memory_order_relaxed); + } + } virtual void OnMap(VAddr address, size_t size) { // No-op @@ -297,6 +392,314 @@ struct PageManager::Impl { virtual void Protect(VAddr address, size_t size, Core::MemoryPermission perms) = 0; + /// bbport BB_DEFER_PROTECT (see below): per lock region, pages whose write protection is + /// deferred, and whether the region is listed for bb:Protect. Guarded by locks[region]. + struct Deferred { + RegionBits pages{}; + bool listed = false; + }; + struct ProtectRun { + VAddr address; + u64 size; + Core::MemoryPermission perms; + }; + using ProtectRuns = boost::container::small_vector; + + /// bbport: Protect, counted by thread and purpose (BB_FRAME_STATS) and timed on the draw + /// recording thread (BB_SECTIONS "protect"). + void ApplyProtect(VAddr address, size_t size, Core::MemoryPermission perms) { + BbSections::Scope scope{BbSections::Protect}; + if (!BbStats::enabled) { + Protect(address, size, perms); + return; + } + const auto start = std::chrono::steady_clock::now(); + Protect(address, size, perms); + const u64 ns = static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count()); + if (protect_kind == BbStats::ProtectDeferred) { + BbStats::deferred_protect_calls.fetch_add(1, std::memory_order_relaxed); + BbStats::deferred_protect_ns.fetch_add(ns, std::memory_order_relaxed); + } else if (Vulkan::DrawPipe::OnStageB()) { + BbStats::stage_b_protect_calls[protect_kind].fetch_add(1, std::memory_order_relaxed); + BbStats::stage_b_protect_ns[protect_kind].fetch_add(ns, std::memory_order_relaxed); + } else { + BbStats::other_protect_calls.fetch_add(1, std::memory_order_relaxed); + BbStats::other_protect_ns.fetch_add(ns, std::memory_order_relaxed); + } + } + + // bbport BB_DEFER_PROTECT (on by default; =0 off, or experiment bit 4 of BB_TOGGLE_FILE while + // the game runs): pages the buffer cache uploaded are watched for writes again + // (UpdateProtection after ForEachModifiedRange) by a count change on the calling + // thread, the draw recording thread; the system call that write-protects them is made by + // bb:Protect, or by the first upload copy that runs before it did (FlushDeferredProtects). + // Until then such a page is still writable: a write to it does not fault, and the copy + // queued for the upload, which reads the page only after the protection is in place, takes + // it along. Writes after that fault as before. + // + // Every path that reads guest memory for the GPU, and how it is covered: + // - BufferCache::UploadCopies (the VRAM path of ObtainBuffer, SynchronizeMemory): small + // uploads via SmallGuestCopy -> RunGuestCopy (Scheduler::RecordHostCopy on a recording + // thread, BbCopy::QueueCopy batches, or run inline in direct mode), larger ones as + // BbCopy::Async groups, and the synchronous path (toggle DeferredUploads): each flushes + // before it copies. + // - ObtainBufferForImage staging (texture sources): RunGuestCopy / BbCopy::Async running it / + // the synchronous path: flushed; its arena path is SynchronizeMemory (above). + // - ObtainBuffer's stream path (RunGuestCopy, flushed anyway) and stage A's constant ring + // (CopySparseMemory, not flushed): snapshots taken when they run, which rely on no page + // protection. + // - Texture hashes and other direct reads of image memory (RefreshImage, KeyPromptData): + // images watch their pages through the immediate path, which applies the deferred pages of + // its range first (ApplyDeferred). + // - CPU stores of the command processor (WriteData, DMA, constant RAM dumps) into a page still + // writable: no fault, but the copy queued for that page has not read it yet (it flushes + // before it reads), so it takes the stored bytes along, as it took a store that faulted. + // WriteData and DMA wait for copies reading their range first (WaitHostCopiesFor). + // - Not deferred: BB_GUEST_IN_PLACE (the GPU reads guest memory chunks itself: + // CopyFromGuestChunk), BB_GPU_WRITE_TWINS (copies of kept parts are dropped), precise + // readbacks (read watchers): BufferCache allows deferral only without them. + // + // A set bit in `deferred` means: the count says watched, the page is still writable. Only + // write revocations are deferred, so such a page has a count of at least one. The immediate + // paths reconcile the pages of their range under the page locks: when they add watchers they + // first apply the deferred pages (a texture then relies on the protection), when they drop a + // page's last watcher its deferred bit is dropped too (it is writable already). Physical + // changes of a region are made in order: protect_locks[] (taken before locks[]) is held across + // the system call by everyone except the deferral itself, which changes no protection, so the + // draw recording thread never waits for bb:Protect's system calls. + + /// Whether uploads defer their write protection now (BB_DEFER_PROTECT=1, experiment bit 4). + [[nodiscard]] bool Deferring() const { + return deferral_allowed && (DeferEnv() || (deferral_possible && BbToggle::Experiment(4))); + } + + static bool DeferEnv() { + static const bool on = [] { + // bbport: on unless BB_DEFER_PROTECT=0 (the opt-out after the beta A/B). + const char* env = std::getenv("BB_DEFER_PROTECT"); + return !(env && env[0] == '0'); + }(); + return on; + } + + /// Set by the buffer cache: deferral allowed in this memory model; starts bb:Protect when it + /// may be used (BB_DEFER_PROTECT=1, or BB_TOGGLE_FILE for experiment bit 4). + void AllowDeferral(bool allowed) { + deferral_allowed = allowed; + deferral_possible = DeferEnv() || std::getenv("BB_TOGGLE_FILE") != nullptr; + if (!allowed || !deferral_possible || protector.joinable()) { + return; + } + deferring_instance = this; + protector = std::jthread([this](std::stop_token stop) { ProtectorLoop(stop); }); + std::printf("Page tracking: write protection after uploads applied by bb:Protect (%s)\n", + DeferEnv() ? "BB_DEFER_PROTECT=0 turns it off" : "experiment bit 4 of BB_TOGGLE_FILE"); + } + + void StopProtector() { + if (!protector.joinable()) { + return; + } + protector.request_stop(); + protector_wake.fetch_add(1, std::memory_order_seq_cst); + protector_wake.notify_all(); + protector.join(); + FlushDeferred(false); + deferring_instance = nullptr; + } + + void ProtectorLoop(std::stop_token stop) { + Common::SetCurrentThreadName("bb:Protect"); + BbThreads::MakeCritical(); + // Uploads come in bursts within a frame: spin a while after each flush, then sleep. + static const auto spin_time = std::chrono::microseconds([] { + if (const char* env = std::getenv("BB_PROTECT_SPIN_US")) { + return std::max(0, std::atoi(env)); + } + return int(BbThreads::CapSpinUs(BbThreads::Available() >= 12 ? 100 : 20)); + }()); + while (!stop.stop_requested()) { + const auto spin_until = std::chrono::steady_clock::now() + spin_time; + for (u32 spins = 1; num_listed.load(std::memory_order_acquire) == 0; ++spins) { + if (stop.stop_requested()) { + return; + } + __builtin_ia32_pause(); + if (!(spins & 255) && std::chrono::steady_clock::now() >= spin_until) { + // Announced before the check, which DeferPageWatchersForRegion's count update + // and announcement check mirror (both sequentially consistent). + const u32 seen = protector_wake.load(std::memory_order_seq_cst); + protector_sleeping.store(true, std::memory_order_seq_cst); + if (num_listed.load(std::memory_order_seq_cst) == 0 && + !stop.stop_requested()) { + protector_wake.wait(seen, std::memory_order_seq_cst); + } + protector_sleeping.store(false, std::memory_order_relaxed); + } + } + FlushDeferred(false); + } + } + + Deferred& DeferredFor(size_t region) { + Deferred* d = deferred[region].load(std::memory_order_relaxed); + if (!d) { + d = new Deferred{}; + deferred[region].store(d, std::memory_order_release); + } + return *d; + } + + /// The deferral itself: counts change now, the pages that become watched are only marked. + void DeferPageWatchersForRegion(VAddr base_addr, RegionBits& mask) { + RENDERER_TRACE; + const size_t base_page = base_addr >> PM_PAGE_BITS; + ASSERT(base_page % PAGES_PER_LOCK == 0); + const size_t region = base_page / PAGES_PER_LOCK; + u64 pages = 0; + bool wake = false; + { + std::scoped_lock lk(locks[region]); + Deferred& d = DeferredFor(region); + deferral_used.store(true, std::memory_order_relaxed); + for (const auto& [start, end] : mask) { + for (size_t page = start; page < end; ++page) { + PageState& state = cached_pages[base_page + page]; + const auto old_perms = state.Perms(); + state.AddDelta<1, false>(base_page + page); + if (state.Perms() != old_perms) { + d.pages.Set(page); // never set before: such a page counts one at least + ++pages; + } + } + } + if (pages != 0 && !d.listed) { + d.listed = true; + num_listed.fetch_add(1, std::memory_order_seq_cst); + std::scoped_lock list{list_mutex}; + listed.push_back(static_cast(region)); + wake = true; + } + } + if (BbStats::enabled && pages != 0) { + BbStats::deferred_protect_pages.fetch_add(pages, std::memory_order_relaxed); + } + if (wake && protector_sleeping.load(std::memory_order_seq_cst)) { + protector_wake.fetch_add(1, std::memory_order_seq_cst); + protector_wake.notify_one(); + } + } + + /// Applies every deferred page listed so far. Any thread, no page lock held. `by_copy`: a + /// copy about to read guest memory found some (bb:Protect was behind). A flush that finds + /// nothing listed after waiting for another one: that one applied them. + void FlushDeferred(bool by_copy) { + if (num_listed.load(std::memory_order_acquire) == 0) { + return; + } + std::scoped_lock flush{flush_mutex}; + std::vector regions; + { + std::scoped_lock list{list_mutex}; + regions.swap(listed); + } + if (regions.empty()) { + return; + } + if (by_copy && BbStats::enabled) { + BbStats::copy_flushes.fetch_add(1, std::memory_order_relaxed); + } + const u32 kind = protect_kind; + protect_kind = BbStats::ProtectDeferred; + for (const u32 region : regions) { + // The system calls are made with the counts unlocked (the draw recording thread may + // defer more meanwhile; it only adds watchers), in order with every other protection + // change of the region (protect_locks). + std::scoped_lock plk(protect_locks[region]); + ProtectRuns runs; + { + std::scoped_lock lk(locks[region]); + TakeDeferredRuns(region, 0, PAGES_PER_LOCK, runs); + deferred[region].load(std::memory_order_relaxed)->listed = false; + } + for (const auto& run : runs) { + ApplyProtect(run.address, run.size, run.perms); + } + } + protect_kind = kind; + // Only now: a copy that saw them listed waits for this flush (flush_mutex) or finds none. + num_listed.fetch_sub(static_cast(regions.size()), std::memory_order_release); + } + + /// The deferred pages of region `region` within [first, end) (page offsets in the region) as + /// runs of equal permissions (as their counts say); cleared. Locks of the region held. + void TakeDeferredRuns(size_t region, size_t first, size_t end, ProtectRuns& runs) { + Deferred* d = deferred[region].load(std::memory_order_relaxed); + if (!d || first >= end || !d->pages.AnyInRange(first, end)) { + return; + } + const size_t base_page = region * PAGES_PER_LOCK; + const RegionBits pages(d->pages, first, end); + for (const auto& [start, stop] : pages) { + size_t run = start; + auto perms = cached_pages[base_page + start].Perms(); + for (size_t page = start + 1; page <= stop; ++page) { + if (page == stop || cached_pages[base_page + page].Perms() != perms) { + runs.push_back({(base_page + run) << PM_PAGE_BITS, + (page - run) << PM_PAGE_BITS, perms}); + if (page != stop) { + run = page; + perms = cached_pages[base_page + page].Perms(); + } + } + } + d->pages.UnsetRange(start, stop); + } + } + + /// Immediate path adding watchers (or read watchers) to [page, page_end): the deferred pages + /// there are protected first. protect_locks and locks of the range held. + void ApplyDeferred(u64 page, u64 page_end) { + if (!deferral_used.load(std::memory_order_relaxed)) { + return; + } + ProtectRuns runs; + for (u64 region = page / PAGES_PER_LOCK; + region < Common::DivCeil(page_end, PAGES_PER_LOCK); ++region) { + const u64 base = region * PAGES_PER_LOCK; + TakeDeferredRuns(region, std::max(page, base) - base, + std::min(page_end, base + PAGES_PER_LOCK) - base, runs); + } + for (const auto& run : runs) { + ApplyProtect(run.address, run.size, run.perms); + } + } + + /// Immediate path: `page` lost its last write watcher; a deferred protection of it is moot + /// (it is writable already). Its page lock held. + void CancelDeferred(u64 page) { + if (!deferral_used.load(std::memory_order_relaxed)) { + return; + } + Deferred* d = deferred[page / PAGES_PER_LOCK].load(std::memory_order_relaxed); + const size_t bit = page % PAGES_PER_LOCK; + if (d && d->pages.Get(bit)) { + d->pages.Unset(bit); + if (BbStats::enabled) { + BbStats::deferred_cancelled_pages.fetch_add(1, std::memory_order_relaxed); + } + } + } + + /// Switched off while pages are still deferred: applied before the immediate paths go on. + void FlushIfSwitchedOff() { + if (num_listed.load(std::memory_order_acquire) != 0 && !Deferring()) { + FlushDeferred(false); + } + } + static bool GuestFaultSignalHandler(void* context, void* fault_address) { const auto addr = reinterpret_cast(fault_address); NoteFaultSite(context, addr); @@ -315,7 +718,9 @@ struct PageManager::Impl { } else { BbStats::read_faults.fetch_add(1, std::memory_order_relaxed); BbStats::Timer timer{BbStats::t_read_faults}; - return rasterizer->ReadMemory(addr, 8, is_gpu_thread); + // bbport: within the faulting page (see Rasterizer::OnWriteFault). + return rasterizer->ReadMemory(addr, std::min(8, GetNextPageAddr(addr) - addr), + is_gpu_thread); } return false; } @@ -327,10 +732,16 @@ struct PageManager::Impl { size_t page = addr >> PM_PAGE_BITS; const u64 page_end = Common::DivCeil(addr + size, PM_PAGE_SIZE); - // Acquire locks for the range of pages - const auto lock_start = locks.begin() + (page / PAGES_PER_LOCK); - const auto lock_end = locks.begin() + Common::DivCeil(page_end, PAGES_PER_LOCK); - Common::RangeLockGuard lk(lock_start, lock_end); + // Acquire locks for the range of pages. bbport: protect_locks first (protection changes + // of a region in order with bb:Protect's, BB_DEFER_PROTECT). + const size_t first_lock = page / PAGES_PER_LOCK; + const size_t last_lock = Common::DivCeil(page_end, PAGES_PER_LOCK); + Common::RangeLockGuard plk(protect_locks.begin() + first_lock, + protect_locks.begin() + last_lock); + Common::RangeLockGuard lk(locks.begin() + first_lock, locks.begin() + last_lock); + if constexpr (track || is_read) { + ApplyDeferred(page, page_end); + } auto perms = cached_pages[page].Perms(); u64 range_begin = 0; @@ -341,16 +752,23 @@ struct PageManager::Impl { if (range_bytes > 0) { RENDERER_TRACE; // Perform pending (un)protect action - Protect(range_begin << PM_PAGE_BITS, range_bytes, perms); + ApplyProtect(range_begin << PM_PAGE_BITS, range_bytes, perms); range_bytes = 0; potential_range_bytes = 0; } }; // Iterate requested pages + // bbport (BB_TRACK_MAPPED_CHECK=1 restores it): the check behind this warning asked the + // rasterizer's mapped ranges (a shared lock and an interval lookup) on every protection + // change; a range that is not mapped is skipped by the protection change anyway. + static const bool check_mapped = [] { + const char* env = std::getenv("BB_TRACK_MAPPED_CHECK"); + return env && env[0] == '1'; + }(); const u64 aligned_addr = page << PM_PAGE_BITS; const u64 aligned_end = page_end << PM_PAGE_BITS; - if (!rasterizer->IsMapped(aligned_addr, aligned_end - aligned_addr)) { + if (check_mapped && !rasterizer->IsMapped(aligned_addr, aligned_end - aligned_addr)) { LOG_WARNING(Render, "Tracking memory region {:#x} - {:#x} which is not fully GPU mapped.", aligned_addr, aligned_end); @@ -360,7 +778,12 @@ struct PageManager::Impl { PageState& state = cached_pages[page]; // Apply the change to the page state - const u8 new_count = state.AddDelta(); + const u8 new_count = state.AddDelta(page); + if constexpr (!track && !is_read) { + if (new_count == 0) { + CancelDeferred(page); + } + } if (auto new_perms = state.Perms(); new_perms != perms) [[unlikely]] { // If the protection changed add pending (un)protect action @@ -402,7 +825,12 @@ struct PageManager::Impl { size_t base_page = (base_addr >> PM_PAGE_BITS); ASSERT(base_page % PAGES_PER_LOCK == 0); + // bbport: protect_locks first, as UpdatePageWatchers. + std::scoped_lock plk(protect_locks[base_page / PAGES_PER_LOCK]); std::scoped_lock lk(locks[base_page / PAGES_PER_LOCK]); + if constexpr (track || is_read) { + ApplyDeferred(base_page + start_range.first, base_page + end_range.second); + } auto perms = cached_pages[base_page + start_range.first].Perms(); u64 range_begin = 0; u64 range_bytes = 0; @@ -412,7 +840,7 @@ struct PageManager::Impl { if (range_bytes > 0) { RENDERER_TRACE; // Perform pending (un)protect action - Protect((range_begin << PM_PAGE_BITS), range_bytes, perms); + ApplyProtect((range_begin << PM_PAGE_BITS), range_bytes, perms); range_bytes = 0; potential_range_bytes = 0; } @@ -425,7 +853,13 @@ struct PageManager::Impl { // Apply the change to the page state const u8 new_count = - update ? state.AddDelta() : state.AddDelta<0, is_read>(); + update ? state.AddDelta(base_page + page) + : state.AddDelta<0, is_read>(base_page + page); + if constexpr (!track && !is_read) { + if (update && new_count == 0) { + CancelDeferred(base_page + page); + } + } if (auto new_perms = state.Perms(); new_perms != perms) [[unlikely]] { // If the protection changed add pending (un)protect action @@ -464,6 +898,22 @@ struct PageManager::Impl { using LockType = Common::SpinLock; #endif std::array locks{}; + + // bbport BB_DEFER_PROTECT (see Deferring): deferred pages per lock region (allocated on first + // use), the lock serializing a region's protection changes, the regions listed for bb:Protect. + std::array, NUM_ADDRESS_LOCKS> deferred{}; + std::array protect_locks{}; + std::mutex list_mutex; + std::vector listed; ///< guarded by list_mutex + std::mutex flush_mutex; ///< one flush at a time + inline static std::atomic num_listed{0}; ///< lowered only once the pages are protected + inline static Impl* deferring_instance = nullptr; + std::atomic deferral_used{false}; ///< some page was deferred once + bool deferral_allowed = false; + bool deferral_possible = false; + std::atomic protector_wake{0}; + std::atomic protector_sleeping{false}; + std::jthread protector; }; #ifdef __linux__ @@ -635,8 +1085,11 @@ struct SignalImpl : public PageManager::Impl { } auto* memory = Core::Memory::Instance(); auto& impl = memory->GetAddressSpace(); - ASSERT_MSG(perms != Core::MemoryPermission::Write, - "Attempted to protect region as write-only which is not a valid permission"); + // bbport: write-only (a read watcher on a page without write watchers, BB_READBACKS=2) + // cannot be expressed; reads must fault, so writes do too (the assertion ended the game). + if (perms == Core::MemoryPermission::Write) { + perms = Core::MemoryPermission::None; + } impl.Protect(address, size, perms); } @@ -664,7 +1117,10 @@ PageManager::PageManager(Vulkan::Rasterizer* rasterizer_) { impl = std::make_unique(rasterizer_); } -PageManager::~PageManager() = default; +PageManager::~PageManager() { + // bbport: bb:Protect calls Protect, which is the derived implementation's. + impl->StopProtector(); +} void PageManager::OnGpuMap(VAddr address, size_t size) { impl->OnMap(address, size); @@ -676,14 +1132,38 @@ void PageManager::OnGpuUnmap(VAddr address, size_t size) { template void PageManager::UpdatePageWatchers(VAddr addr, u64 size) const { + impl->FlushIfSwitchedOff(); + ProtectKindScope kind{BbStats::ProtectTexture}; impl->UpdatePageWatchers(addr, size); } template void PageManager::UpdatePageWatchersForRegion(VAddr base_addr, RegionBits& mask) const { + impl->FlushIfSwitchedOff(); + ProtectKindScope kind{track && !is_read ? BbStats::ProtectUpload : BbStats::ProtectOther}; impl->UpdatePageWatchersForRegion(base_addr, mask); } +bool PageManager::DeferringProtects() const { + return impl->Deferring(); +} + +void PageManager::DeferPageWatchersForRegion(VAddr base_addr, RegionBits& mask) const { + impl->DeferPageWatchersForRegion(base_addr, mask); +} + +void PageManager::FlushDeferredProtects() { + if (Impl::num_listed.load(std::memory_order_acquire) != 0) { + if (Impl* impl = Impl::deferring_instance) { + impl->FlushDeferred(true); + } + } +} + +void PageManager::AllowDeferredProtects(bool allowed) { + impl->AllowDeferral(allowed); +} + template void PageManager::UpdatePageWatchers(VAddr addr, u64 size) const; template void PageManager::UpdatePageWatchers(VAddr addr, u64 size) const; template void PageManager::UpdatePageWatchersForRegion(VAddr base_addr, diff --git a/gpu/shadps4/video_core/page_manager.h b/gpu/shadps4/video_core/page_manager.h index d5debce..c4a649a 100644 --- a/gpu/shadps4/video_core/page_manager.h +++ b/gpu/shadps4/video_core/page_manager.h @@ -54,6 +54,20 @@ public: template void UpdatePageWatchersForRegion(VAddr base_addr, RegionBits& mask) const; + /// bbport BB_DEFER_PROTECT (=1; off by default; experiment bit 4 while the game runs): like + /// UpdatePageWatchersForRegion for pages the buffer cache has just uploaded, whose copies + /// are queued after this call: the counts change now, the write protection is applied by + /// bb:Protect or by the first copy that reads the pages (FlushDeferredProtects). + void DeferPageWatchersForRegion(VAddr base_addr, RegionBits& mask) const; + /// Whether uploads defer their write protection now. + [[nodiscard]] bool DeferringProtects() const; + /// Before an upload copy reads guest memory (BufferCache::RunGuestCopy, UploadCopies): applies + /// the deferred write protections. Any thread; one atomic load when none is pending. + static void FlushDeferredProtects(); + /// Set by the buffer cache: whether its memory model allows deferral (page protection, no + /// in-place guest memory, no GPU write twins, readbacks not precise). + void AllowDeferredProtects(bool allowed); + /// Returns page aligned address. static constexpr VAddr GetPageAddr(VAddr addr) { return Common::AlignDown(addr, PM_PAGE_SIZE); diff --git a/gpu/shadps4/video_core/renderer_vulkan/host_passes/hdr_pass.cpp b/gpu/shadps4/video_core/renderer_vulkan/host_passes/hdr_pass.cpp new file mode 100644 index 0000000..a263cce --- /dev/null +++ b/gpu/shadps4/video_core/renderer_vulkan/host_passes/hdr_pass.cpp @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: HDR output encode pass (hdr_pass.h), modelled on the post-processing pass. + +#include "video_core/renderer_vulkan/host_passes/hdr_pass.h" + +#include "common/assert.h" +#include "core/emulator_settings.h" +#include "video_core/host_shaders/fs_tri_vert.h" +#include "video_core/host_shaders/hdr_output_frag.h" +#include "video_core/renderer_vulkan/vk_platform.h" +#include "video_core/renderer_vulkan/vk_shader_util.h" + +#include + +namespace Vulkan::HostPasses { + +void HdrOutputPass::Create(vk::Device device, vk::Format output_format) { + const std::array bindings{ + vk::DescriptorSetLayoutBinding{ + .binding = 0, + .descriptorType = vk::DescriptorType::eCombinedImageSampler, + .descriptorCount = 1, + .stageFlags = vk::ShaderStageFlagBits::eFragment, + }, + vk::DescriptorSetLayoutBinding{ + .binding = 1, + .descriptorType = vk::DescriptorType::eCombinedImageSampler, + .descriptorCount = 1, + .stageFlags = vk::ShaderStageFlagBits::eFragment, + }, + vk::DescriptorSetLayoutBinding{ + .binding = 2, + .descriptorType = vk::DescriptorType::eCombinedImageSampler, + .descriptorCount = 1, + .stageFlags = vk::ShaderStageFlagBits::eFragment, + }, + }; + desc_set_layout = Check<"create HDR output descriptor set layout">( + device.createDescriptorSetLayoutUnique(vk::DescriptorSetLayoutCreateInfo{ + .flags = vk::DescriptorSetLayoutCreateFlagBits::ePushDescriptorKHR, + .bindingCount = static_cast(bindings.size()), + .pBindings = bindings.data(), + })); + + const vk::PushConstantRange push_constants{ + .stageFlags = vk::ShaderStageFlagBits::eFragment, + .offset = 0, + .size = sizeof(Settings), + }; + pipeline_layout = Check<"create HDR output pipeline layout">( + device.createPipelineLayoutUnique(vk::PipelineLayoutCreateInfo{ + .setLayoutCount = 1U, + .pSetLayouts = &*desc_set_layout, + .pushConstantRangeCount = 1, + .pPushConstantRanges = &push_constants, + })); + + const auto vs_module = CompileSPV(FS_TRI_VERT, device); + ASSERT(vs_module); + SetObjectName(device, vs_module, "fs_tri.vert"); + const auto fs_module = CompileSPV(HDR_OUTPUT_FRAG, device); + ASSERT(fs_module); + SetObjectName(device, fs_module, "hdr_output.frag"); + + const std::array shaders_ci{ + vk::PipelineShaderStageCreateInfo{ + .stage = vk::ShaderStageFlagBits::eVertex, + .module = vs_module, + .pName = "main", + }, + vk::PipelineShaderStageCreateInfo{ + .stage = vk::ShaderStageFlagBits::eFragment, + .module = fs_module, + .pName = "main", + }, + }; + + const vk::PipelineRenderingCreateInfo pipeline_rendering_ci{ + .colorAttachmentCount = 1, + .pColorAttachmentFormats = &output_format, + }; + const vk::PipelineVertexInputStateCreateInfo vertex_input_info{}; + const vk::PipelineInputAssemblyStateCreateInfo input_assembly{ + .topology = vk::PrimitiveTopology::eTriangleList, + }; + const vk::Viewport viewport{ + .width = 1.0f, + .height = 1.0f, + .minDepth = 0.0f, + .maxDepth = 1.0f, + }; + const vk::Rect2D scissor{.extent = {1, 1}}; + const vk::PipelineViewportStateCreateInfo viewport_info{ + .viewportCount = 1, + .pViewports = &viewport, + .scissorCount = 1, + .pScissors = &scissor, + }; + const vk::PipelineRasterizationStateCreateInfo raster_state{ + .depthClampEnable = false, + .rasterizerDiscardEnable = false, + .polygonMode = vk::PolygonMode::eFill, + .cullMode = vk::CullModeFlagBits::eNone, + .frontFace = vk::FrontFace::eClockwise, + .depthBiasEnable = false, + .lineWidth = 1.0f, + }; + const vk::PipelineMultisampleStateCreateInfo multisampling{ + .rasterizationSamples = vk::SampleCountFlagBits::e1, + }; + const vk::PipelineColorBlendAttachmentState attachment{ + .blendEnable = false, + .colorWriteMask = vk::ColorComponentFlagBits::eR | vk::ColorComponentFlagBits::eG | + vk::ColorComponentFlagBits::eB | vk::ColorComponentFlagBits::eA, + }; + const vk::PipelineColorBlendStateCreateInfo color_blending{ + .logicOpEnable = false, + .logicOp = vk::LogicOp::eCopy, + .attachmentCount = 1, + .pAttachments = &attachment, + }; + const std::array dynamic_states{ + vk::DynamicState::eViewport, + vk::DynamicState::eScissor, + }; + const vk::PipelineDynamicStateCreateInfo dynamic_info{ + .dynamicStateCount = static_cast(dynamic_states.size()), + .pDynamicStates = dynamic_states.data(), + }; + const vk::GraphicsPipelineCreateInfo pipeline_info{ + .pNext = &pipeline_rendering_ci, + .stageCount = static_cast(shaders_ci.size()), + .pStages = shaders_ci.data(), + .pVertexInputState = &vertex_input_info, + .pInputAssemblyState = &input_assembly, + .pViewportState = &viewport_info, + .pRasterizationState = &raster_state, + .pMultisampleState = &multisampling, + .pColorBlendState = &color_blending, + .pDynamicState = &dynamic_info, + .layout = *pipeline_layout, + }; + pipeline = Check<"create HDR output pipeline">( + device.createGraphicsPipelineUnique(/*pipeline_cache*/ {}, pipeline_info)); + + device.destroyShaderModule(vs_module); + device.destroyShaderModule(fs_module); + + // The shader reads texels (texelFetch); a combined image sampler still needs a sampler. + sampler = Check<"create HDR output sampler">(device.createSamplerUnique(vk::SamplerCreateInfo{ + .magFilter = vk::Filter::eNearest, + .minFilter = vk::Filter::eNearest, + .mipmapMode = vk::SamplerMipmapMode::eNearest, + .addressModeU = vk::SamplerAddressMode::eClampToEdge, + .addressModeV = vk::SamplerAddressMode::eClampToEdge, + .addressModeW = vk::SamplerAddressMode::eClampToEdge, + })); + // bbport: native mode's gain map has the display buffer's size, not the output's: filtered + // like the letterbox blit filters the frame. + gain_sampler = Check<"create HDR gain sampler">(device.createSamplerUnique(vk::SamplerCreateInfo{ + .magFilter = vk::Filter::eLinear, + .minFilter = vk::Filter::eLinear, + .mipmapMode = vk::SamplerMipmapMode::eNearest, + .addressModeU = vk::SamplerAddressMode::eClampToEdge, + .addressModeV = vk::SamplerAddressMode::eClampToEdge, + .addressModeW = vk::SamplerAddressMode::eClampToEdge, + })); +} + +void HdrOutputPass::Render(vk::CommandBuffer cmdbuf, vk::ImageView game, vk::ImageView overlay, + vk::ImageView gain, vk::ImageLayout gain_layout, vk::ImageView output, + vk::Extent2D extent, const Settings& settings) { + if (EmulatorSettings.IsVkHostMarkersEnabled()) { + cmdbuf.beginDebugUtilsLabelEXT(vk::DebugUtilsLabelEXT{ + .pLabelName = "Host/HDR output", + }); + } + + const std::array image_infos{ + vk::DescriptorImageInfo{ + .sampler = *sampler, + .imageView = game, + .imageLayout = vk::ImageLayout::eShaderReadOnlyOptimal, + }, + vk::DescriptorImageInfo{ + .sampler = *sampler, + .imageView = overlay, + .imageLayout = vk::ImageLayout::eShaderReadOnlyOptimal, + }, + vk::DescriptorImageInfo{ + .sampler = *gain_sampler, + .imageView = gain, + .imageLayout = gain_layout, + }, + }; + const std::array set_writes{ + vk::WriteDescriptorSet{ + .dstSet = VK_NULL_HANDLE, + .dstBinding = 0, + .dstArrayElement = 0, + .descriptorCount = 1, + .descriptorType = vk::DescriptorType::eCombinedImageSampler, + .pImageInfo = &image_infos[0], + }, + vk::WriteDescriptorSet{ + .dstSet = VK_NULL_HANDLE, + .dstBinding = 1, + .dstArrayElement = 0, + .descriptorCount = 1, + .descriptorType = vk::DescriptorType::eCombinedImageSampler, + .pImageInfo = &image_infos[1], + }, + vk::WriteDescriptorSet{ + .dstSet = VK_NULL_HANDLE, + .dstBinding = 2, + .dstArrayElement = 0, + .descriptorCount = 1, + .descriptorType = vk::DescriptorType::eCombinedImageSampler, + .pImageInfo = &image_infos[2], + }, + }; + + const vk::RenderingAttachmentInfo attachment{ + .imageView = output, + .imageLayout = vk::ImageLayout::eColorAttachmentOptimal, + // Every pixel is written by the full-screen triangle. + .loadOp = vk::AttachmentLoadOp::eDontCare, + .storeOp = vk::AttachmentStoreOp::eStore, + }; + const vk::RenderingInfo rendering_info{ + .renderArea = {.extent = extent}, + .layerCount = 1, + .colorAttachmentCount = 1, + .pColorAttachments = &attachment, + }; + + cmdbuf.bindPipeline(vk::PipelineBindPoint::eGraphics, *pipeline); + cmdbuf.setViewport(0, vk::Viewport{ + .width = static_cast(extent.width), + .height = static_cast(extent.height), + .minDepth = 0.0f, + .maxDepth = 1.0f, + }); + cmdbuf.setScissor(0, vk::Rect2D{.extent = extent}); + cmdbuf.pushDescriptorSetKHR(vk::PipelineBindPoint::eGraphics, *pipeline_layout, 0, set_writes); + cmdbuf.pushConstants(*pipeline_layout, vk::ShaderStageFlagBits::eFragment, 0, + sizeof(Settings), &settings); + cmdbuf.beginRendering(rendering_info); + cmdbuf.draw(3, 1, 0, 0); + cmdbuf.endRendering(); + + if (EmulatorSettings.IsVkHostMarkersEnabled()) { + cmdbuf.endDebugUtilsLabelEXT(); + } +} + +} // namespace Vulkan::HostPasses diff --git a/gpu/shadps4/video_core/renderer_vulkan/host_passes/hdr_pass.h b/gpu/shadps4/video_core/renderer_vulkan/host_passes/hdr_pass.h new file mode 100644 index 0000000..db4f57f --- /dev/null +++ b/gpu/shadps4/video_core/renderer_vulkan/host_passes/hdr_pass.h @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: HDR output (hdr in bbport.ini). Encodes the swapchain-sized SDR composite (the game's +// frame letterboxed, plus the menu layer) into the HDR swapchain image: HDR10 PQ or scRGB. +// Native mode (hdr_mode=native) brightens the highlights with the frame's gain map +// (vk_hdr_native.h). + +#pragma once + +#include + +#include "common/types.h" +#include "video_core/renderer_vulkan/vk_common.h" + +namespace Vulkan::HostPasses { + +class HdrOutputPass { +public: + /// Push constants of hdr_output.frag. + struct Settings { + float paper_white = 200.0f; ///< nits of SDR white + float peak = 1000.0f; ///< nits the display reaches + float boost = 0.5f; ///< highlight expansion, 0..1 (expand mode and fallback) + u32 flags = 0; ///< FlagScRgb | FlagOverlay | FlagNative | FlagDebug + /// FlagNative: the frame's rectangle in the output (top left corner, pixels) and + /// 1 / its size, mapping output pixels to gain map coordinates. + std::array gain_offset{}; + std::array gain_scale{}; + /// Native mode: the UI's white (times paper white) and colours (0 sRGB .. 1 DCI-P3). + float ui_white = 1.0f; + float ui_vivid = 0.0f; + }; + static constexpr u32 FlagScRgb = 1; ///< scRGB (linear BT.709) output, else HDR10 PQ + static constexpr u32 FlagOverlay = 2; ///< the menu layer is drawn + static constexpr u32 FlagNative = 4; ///< `gain` is this frame's gain map (native mode) + static constexpr u32 FlagDebug = 8; ///< BB_HDR_DEBUG: the gain as a heat map + static constexpr u32 FlagUiFrame = 16; ///< native mode, a frame without a scene: all UI + + /// `output_format`: the HDR swapchain's format. + void Create(vk::Device device, vk::Format output_format); + + bool IsCreated() const { + return static_cast(pipeline); + } + + /// Draws into `output` (layout ColorAttachmentOptimal) of `extent`; `game` and `overlay` + /// (both ShaderReadOnlyOptimal, `extent` big) are read texel for texel, `gain` (any size, + /// in `gain_layout`) is filtered. + void Render(vk::CommandBuffer cmdbuf, vk::ImageView game, vk::ImageView overlay, + vk::ImageView gain, vk::ImageLayout gain_layout, vk::ImageView output, + vk::Extent2D extent, const Settings& settings); + +private: + vk::UniquePipeline pipeline{}; + vk::UniquePipelineLayout pipeline_layout{}; + vk::UniqueDescriptorSetLayout desc_set_layout{}; + vk::UniqueSampler sampler{}; + vk::UniqueSampler gain_sampler{}; ///< bilinear: the map has the display buffer's size +}; + +} // namespace Vulkan::HostPasses diff --git a/gpu/shadps4/video_core/renderer_vulkan/liverpool_to_vk.cpp b/gpu/shadps4/video_core/renderer_vulkan/liverpool_to_vk.cpp index 21a2260..fc52fad 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/liverpool_to_vk.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/liverpool_to_vk.cpp @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright 2024-2026 shadPS4 Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include #include "common/assert.h" #include "common/number_utils.h" #include "video_core/amdgpu/pixel_format.h" @@ -786,10 +787,30 @@ vk::Format TrySurfaceFormat(AmdGpu::DataFormat data_format, AmdGpu::NumberFormat vk::Format SurfaceFormat(AmdGpu::DataFormat data_format, AmdGpu::NumberFormat num_format) { vk::Format result = surface_format_table[GetSurfaceFormatTableIndex(data_format, num_format)]; - bool found = - result != vk::Format::eUndefined || data_format == AmdGpu::DataFormat::FormatInvalid; - ASSERT_MSG(found, "Unknown data_format={} and num_format={}", static_cast(data_format), - static_cast(num_format)); + if (result != vk::Format::eUndefined || data_format == AmdGpu::DataFormat::FormatInvalid) + [[likely]] { + return result; + } + // bbport: a combination the table lacks (a garbage descriptor) ended the game (assertion, + // exit 23). The same data format with another number format keeps the texel size; reported + // a few times. At worst that surface renders wrong. + using AmdGpu::NumberFormat; + for (const NumberFormat other : {NumberFormat::Unorm, NumberFormat::Uint, NumberFormat::Float, + NumberFormat::Snorm, NumberFormat::Sint}) { + result = surface_format_table[GetSurfaceFormatTableIndex(data_format, other)]; + if (result != vk::Format::eUndefined) { + break; + } + } + if (result == vk::Format::eUndefined) { + result = vk::Format::eR8G8B8A8Unorm; + } + static std::atomic notes{0}; + if (notes.fetch_add(1, std::memory_order_relaxed) < 8) { + LOG_ERROR(Render_Vulkan, "Unknown data_format={} and num_format={}, using {}", + static_cast(data_format), static_cast(num_format), + vk::to_string(result)); + } return result; } @@ -1177,8 +1198,15 @@ vk::SampleCountFlagBits RawNumSamples(u32 num_samples) { return vk::SampleCountFlagBits::e8; case 16: return vk::SampleCountFlagBits::e16; - default: - UNREACHABLE(); + default: { + // bbport: not a sample count (a garbage register or sharp): one sample, reported a few + // times; this ended the game (UNREACHABLE, exit 23). + static std::atomic notes{0}; + if (notes.fetch_add(1, std::memory_order_relaxed) < 8) { + LOG_ERROR(Render_Vulkan, "Invalid sample count {}, using 1", num_samples); + } + return vk::SampleCountFlagBits::e1; + } } } diff --git a/gpu/shadps4/video_core/renderer_vulkan/occlusion_model.h b/gpu/shadps4/video_core/renderer_vulkan/occlusion_model.h new file mode 100644 index 0000000..930b227 --- /dev/null +++ b/gpu/shadps4/video_core/renderer_vulkan/occlusion_model.h @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: the PS4 depth-block counter model behind real occlusion results (vk_occlusion.h), +// without Vulkan, so the arithmetic is unit-tested (tests/test_occlusion.cpp). +#pragma once + +#include +#include + +namespace OcclusionModel { + +constexpr std::uint64_t ValidBit = 1ull << 63; +/// What a draw without a usable result adds: plainly visible (the old fake's step), so any +/// begin/end pair around it reads visible, never occluded. +constexpr std::uint64_t Unmeasured = 0x2FFFFFFull; +constexpr std::uint32_t MaxPipes = 16; + +/// DB `k`'s share of the running total: the shares sum to `total` and each grows with it, so the +/// game's sum of end - begin over the DBs is exactly the samples in between. +constexpr std::uint64_t Share(std::uint64_t total, std::uint32_t pipes, std::uint32_t k) { + return total / pipes + (k < total % pipes ? 1 : 0); +} + +enum class Block : std::uint32_t { Occluded = 0, Visible = 1, NotReady = 2 }; + +/// A Gnm block ({begin, end} per DB, 16 bytes apart) as the game reads it. +inline Block ReadBlock(const std::uint64_t* words, std::uint32_t pipes) { + std::uint64_t sum = 0; + for (std::uint32_t k = 0; k < pipes; ++k) { + const std::uint64_t b = words[2 * k], e = words[2 * k + 1]; + if (!(b & ValidBit) || !(e & ValidBit)) { + return Block::NotReady; + } + sum += (e & ~ValidBit) - (b & ~ValidBit); + } + return sum != 0 ? Block::Visible : Block::Occluded; +} + +/// 16.16 factor taking samples counted at `width` x `height` (reduced scene targets) back to the +/// PS4's 1920x1080; never below 1. +constexpr std::uint32_t SampleScale(std::uint32_t width, std::uint32_t height) { + const std::uint64_t pixels = std::uint64_t(width) * height; + constexpr std::uint64_t native = 1920ull * 1080ull; + if (pixels == 0 || pixels >= native) { + return 1u << 16; + } + return static_cast((native << 16) / pixels); +} + +/// One query's contribution: unavailable reads visible; an imprecise device's nonzero result +/// (0/1 on NVIDIA) reads plainly visible; a precise count is scaled, rounding up. +constexpr std::uint64_t Samples(std::uint64_t value, bool available, bool precise, + std::uint32_t scale) { + if (!available) { + return Unmeasured; + } + if (!precise) { + return value ? Unmeasured : 0; + } + return std::max((value * scale + 0xFFFF) >> 16, value); +} + +/// SET_PREDICATION's outcome for the draws up to its clear: unknown draws (a wrong "occluded" +/// makes geometry vanish), else draw when the visibility is the one asked for. +constexpr bool PredicatedDraws(bool visible, bool unknown, bool draw_if_visible) { + return unknown || visible == draw_if_visible; +} + +/// Hysteresis per Gnm block against flicker (lamps and their glare at the edge of occluders: +/// sample counts of small proxies going 0/N/0 with the jittered, reduced scene): a block +/// reports the largest of its last `Depth` measured counts. It reads occluded only after Depth +/// results in a row found no samples, visible as soon as one finds any. Results it has not seen +/// count as `UnknownPast`: visible, with the smallest count (a count-scaled effect stays dark +/// rather than flashing). +struct BlockHistory { + static constexpr std::uint32_t Depth = 3; + static constexpr std::uint64_t UnknownPast = 1; + std::uint64_t previous[Depth - 1] = {UnknownPast, UnknownPast}; + + /// The count to report for the block's new result `measured`, which is remembered. + constexpr std::uint64_t Report(std::uint64_t measured) { + std::uint64_t reported = measured; + for (const std::uint64_t p : previous) { + reported = std::max(reported, p); + } + for (std::uint32_t i = Depth - 2; i > 0; --i) { + previous[i] = previous[i - 1]; + } + previous[0] = measured; + return reported; + } +}; + +} // namespace OcclusionModel diff --git a/gpu/shadps4/video_core/renderer_vulkan/pipeline_compile_queue.h b/gpu/shadps4/video_core/renderer_vulkan/pipeline_compile_queue.h new file mode 100644 index 0000000..766462b --- /dev/null +++ b/gpu/shadps4/video_core/renderer_vulkan/pipeline_compile_queue.h @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: pipelines first seen during play (docs/pipeline_compiles.md). The compile threads' +// job queue, from which a thread that needs a pipeline right away takes its job, the rule for +// how long draws of a pipeline still compiling may be left out, and the statistics of both +// (BB_FRAME_STATS "Pipelines:" line). No Vulkan here: tests/test_pipeline_compile.cpp. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Vulkan { + +/// Compile jobs, first in, first out (the order the draws need them in), each with the object +/// it creates (`owner`). A thread about to wait for that object takes its job when no compile +/// thread has started it yet (Take) and runs it itself, instead of waiting behind the jobs +/// queued before it (20-90 pipelines at once entering an area). +class PipelineJobQueue { +public: + using Job = std::function; + + void Push(const void* owner, Job job) { + { + std::scoped_lock lk{mutex}; + jobs.push_back({owner, std::move(job)}); + } + cv.notify_one(); + } + + /// A compile thread: the oldest job (waits for one). + Job Pop() { + std::unique_lock lk{mutex}; + cv.wait(lk, [this] { return !jobs.empty(); }); + Job job = std::move(jobs.front().job); + jobs.pop_front(); + return job; + } + + /// `owner`'s job, taken out of the queue, when no thread has started it; else empty. + Job Take(const void* owner) { + std::scoped_lock lk{mutex}; + const auto it = + std::find_if(jobs.begin(), jobs.end(), [owner](const Entry& e) { return e.owner == owner; }); + if (it == jobs.end()) { + return {}; + } + Job job = std::move(it->job); + jobs.erase(it); + return job; + } + + [[nodiscard]] std::size_t Size() { + std::scoped_lock lk{mutex}; + return jobs.size(); + } + +private: + struct Entry { + const void* owner; + Job job; + }; + std::mutex mutex; + std::condition_variable cv; + std::deque jobs; +}; + +/// Whether a draw of a pipeline still compiling may be left out now. `first_skip_ns`: when one +/// of its draws was first left out (0: none yet; set here), `budget_ns`: for how long its draws +/// may be left out. Past that they wait for it: a compile that never ends (or a queue that does +/// not move) shows as a stall, not as geometry missing for good. +inline bool WithinSkipBudget(std::uint64_t& first_skip_ns, std::uint64_t now_ns, + std::uint64_t budget_ns) noexcept { + if (first_skip_ns == 0) { + first_skip_ns = now_ns != 0 ? now_ns : 1; + return budget_ns != 0; + } + return now_ns - first_skip_ns < budget_ns; +} + +/// Counted since the last statistics window (BB_FRAME_STATS), exchanged to 0 when printed. +struct PipelineCompileStats { + std::atomic background{0}; ///< pipelines handed to the compile threads + std::atomic taken{0}; ///< compiled by the thread that needed them + std::atomic skipped_draws{0}; ///< draws left out, their pipeline compiling + std::atomic skipped_pipelines{0}; ///< pipelines whose draws were left out + std::atomic late_binds{0}; ///< draws bound on the recording threads + std::atomic waits{0}; ///< threads that waited for a compile + std::atomic wait_ns{0}; + std::atomic longest_wait_ns{0}; + + void NoteWait(std::uint64_t ns) noexcept { + waits.fetch_add(1, std::memory_order_relaxed); + wait_ns.fetch_add(ns, std::memory_order_relaxed); + std::uint64_t longest = longest_wait_ns.load(std::memory_order_relaxed); + while (ns > longest && + !longest_wait_ns.compare_exchange_weak(longest, ns, std::memory_order_relaxed)) { + } + } +}; + +inline PipelineCompileStats pipeline_compile_stats; + +} // namespace Vulkan diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_compute_pipeline.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_compute_pipeline.cpp index a123982..8e8907c 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_compute_pipeline.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_compute_pipeline.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include +#include #include #include "common/serdes.h" @@ -108,6 +109,29 @@ ComputePipeline::ComputePipeline(const Instance& instance, Scheduler& scheduler, .stage = shader_ci, .layout = *pipeline_layout, }; + // bbport (BB_ASYNC_PIPELINES): as graphics pipelines (vk_graphics_pipeline.cpp); the stage + // info only points at its subgroup size and the module, which outlives the pipeline. + if (!preloading && AsyncPipelinesEnabled() && subgroup_size_ci.pNext == nullptr && + shader_ci.pSpecializationInfo == nullptr) { + struct Pending { + vk::PipelineShaderStageRequiredSubgroupSizeCreateInfo subgroup_size; + vk::ComputePipelineCreateInfo info; + }; + auto job = std::make_shared(); + job->subgroup_size = subgroup_size_ci; + job->info = compute_pipeline_ci; + job->info.stage.pNext = shader_ci.pNext ? &job->subgroup_size : nullptr; + ready.store(false, std::memory_order_relaxed); + SubmitPipelineJob(this, [this, job, device, cache = pipeline_cache, name = debug_str] { + auto [result, created] = device.createComputePipelineUnique(cache, job->info); + ASSERT_MSG(result == vk::Result::eSuccess, "Failed to create compute pipeline: {}", + vk::to_string(result)); + pipeline = std::move(created); + SetObjectName(device, *pipeline, "Compute Pipeline {}", name); + MarkReady(); + }); + return; + } auto [pipeline_result, pipe] = instance.GetDevice().createComputePipelineUnique(pipeline_cache, compute_pipeline_ci); if (preloading && pipeline_result != vk::Result::eSuccess) { @@ -122,6 +146,8 @@ ComputePipeline::ComputePipeline(const Instance& instance, Scheduler& scheduler, SetObjectName(device, *pipeline, "Compute Pipeline {}", debug_str); } -ComputePipeline::~ComputePipeline() = default; +ComputePipeline::~ComputePipeline() { + WaitReady(); // bbport: a compile thread may still be creating it +} } // namespace Vulkan diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_draw_pipe.h b/gpu/shadps4/video_core/renderer_vulkan/vk_draw_pipe.h index 0e80933..abd1e0e 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_draw_pipe.h +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_draw_pipe.h @@ -88,6 +88,11 @@ public: wake.fetch_add(1, std::memory_order_seq_cst); wake.notify_one(); } + // bbport: the lookahead thread (BB_DRAWREC_WORKERS) sleeps the same way (WaitForCommit). + if (ahead_sleeping.load(std::memory_order_seq_cst)) { + ahead_wake.fetch_add(1, std::memory_order_seq_cst); + ahead_wake.notify_one(); + } } /// Why stage A drained (statistics): a PM4 opcode, or one of these. @@ -112,18 +117,23 @@ public: const u32 slot = reason < NumReasons ? reason : ReasonRasterizer; ++drains_by_reason[slot]; const u64 start = __rdtsc(); - for (u32 spins = 0; consumed.load(std::memory_order_acquire) != head; ++spins) { - if (spins < 4096) { - __builtin_ia32_pause(); - } else { - std::this_thread::yield(); - } - } + WaitUntil([this] { return consumed.load(std::memory_order_acquire) == head; }, + [this] { return packets; }); const u64 waited = __rdtsc() - start; drain_cycles += waited; cycles_by_reason[slot] += waited; } + /// bbport: whether stage A's waits sleep once they outlast a spin (BB_DRAIN_SLEEP=0: they + /// spin and yield to the end, as before), and how long they spin first (BB_DRAIN_SPIN_US). + static bool SleepEnabled() { + static const bool enabled = [] { + const char* env = std::getenv("BB_DRAIN_SLEEP"); + return !(env && env[0] == '0'); + }(); + return enabled; + } + /// Stage A: the number the next committed packet gets (packets are numbered from 1), and /// whether stage B has run the packet with that number. [[nodiscard]] u64 NextPacket() const noexcept { @@ -141,13 +151,7 @@ public: const u32 slot = reason < NumReasons ? reason : ReasonRasterizer; ++drains_by_reason[slot]; const u64 start = __rdtsc(); - for (u32 spins = 0; !ReachedPacket(number); ++spins) { - if (spins < 4096) { - __builtin_ia32_pause(); - } else { - std::this_thread::yield(); - } - } + WaitUntil([this, number] { return ReachedPacket(number); }, [number] { return number; }); const u64 waited = __rdtsc() - start; drain_cycles += waited; cycles_by_reason[slot] += waited; @@ -168,14 +172,75 @@ public: [[nodiscard]] u64 Consumed() const noexcept { return consumed.load(std::memory_order_acquire); } + /// Stage B, inside the handler: the payload of the packet after the one running when stage A + /// has committed it already (it runs next), else null. + [[nodiscard]] const u8* PeekNext() const noexcept { + const u64 next = running_end; + if (published.load(std::memory_order_acquire) <= next) { + return nullptr; + } + const auto* header = reinterpret_cast(ring.get() + next % Capacity); + return header->size != 0 ? reinterpret_cast(header + 1) : nullptr; + } /// Stage B: bytes of packets committed and not run yet (the work queued for this thread). [[nodiscard]] u64 Backlog() const noexcept { return published.load(std::memory_order_acquire) - consumed.load(std::memory_order_relaxed); } + // bbport BB_DRAWREC_WORKERS: the lookahead thread (Rasterizer::LookaheadLoop) reads committed + // packets between Consumed() and Published(). Stage A never writes there: it waits for room + // below `consumed + Capacity` (WaitForSpace), so a packet at or past `consumed` is intact. + /// The end of the committed packets. + [[nodiscard]] u64 Published() const noexcept { + return published.load(std::memory_order_acquire); + } + /// Packets stage B has run (the number of the packet at Consumed(), counted from 0). + [[nodiscard]] u64 ConsumedPackets() const noexcept { + return consumed_packets.load(std::memory_order_acquire); + } + /// The packet at `position` (Consumed() <= position < Published()): its payload and size, + /// and the position after it. A wrap marker gives no payload and the ring's next lap. + [[nodiscard]] const u8* PacketAt(u64 position, u32& payload, u64& next) const noexcept { + const auto* header = reinterpret_cast(ring.get() + position % Capacity); + if (header->size == 0) { + next = position + (Capacity - position % Capacity); + payload = 0; + return nullptr; + } + next = position + header->size; + payload = header->payload; + return reinterpret_cast(header + 1); + } + /// The lookahead thread: returns once a packet is committed past `position`, or on `stop`. + /// A short spin, then it sleeps until Commit() wakes it (the announce-then-check order of + /// stage B's own sleep: either this thread sees the packet or stage A sees it sleeping). + void WaitForCommit(u64 position, std::stop_token& stop) { + static const auto spin_time = std::chrono::microseconds(20); + const auto spin_until = std::chrono::steady_clock::now() + spin_time; + for (u32 spins = 1;; ++spins) { + if (published.load(std::memory_order_acquire) > position || stop.stop_requested()) { + return; + } + __builtin_ia32_pause(); + if (!(spins & 255) && std::chrono::steady_clock::now() >= spin_until) { + break; + } + } + const u32 seen = ahead_wake.load(std::memory_order_seq_cst); + ahead_sleeping.store(true, std::memory_order_seq_cst); + if (published.load(std::memory_order_seq_cst) <= position && !stop.stop_requested()) { + std::stop_callback on_stop{stop, [this] { + ahead_wake.fetch_add(1, std::memory_order_seq_cst); + ahead_wake.notify_all(); + }}; + ahead_wake.wait(seen, std::memory_order_seq_cst); + } + ahead_sleeping.store(false, std::memory_order_relaxed); + } + /// Statistics (stage A): packets, drains that had to wait, cycles waited, cycles stage B - /// spent running packets. - u64 packets = 0, drains = 0, drain_cycles = 0; + /// spent running packets, waits that went to sleep. + u64 packets = 0, drains = 0, drain_cycles = 0, sleeps = 0; std::atomic busy_cycles{0}; private: @@ -184,19 +249,76 @@ private: u32 payload; ///< bytes written by the producer }; static constexpr u64 Capacity = 16ull << 20; + static constexpr u64 NoWaiter = ~u64{0}; static constexpr u32 Align(u32 size) { return (size + 63) & ~63u; } void WaitForSpace(u64 at, u64 size) { - while (at + size - consumed.load(std::memory_order_acquire) > Capacity) { + if (at + size - consumed.load(std::memory_order_acquire) <= Capacity) { + return; + } + WaitUntil( + [this, at, size] { + return at + size - consumed.load(std::memory_order_acquire) <= Capacity; + }, + [this] { return consumed_packets.load(std::memory_order_relaxed) + 1; }); + } + + /// Stage A: waits until `done()`. bbport: spins (most waits end within microseconds), + /// yields for up to BB_DRAIN_SPIN_US (500), then sleeps until stage B has run packet + /// `target()` (it wakes this thread, Run). A long packet (an image created or uploaded with + /// memory short, a pipeline compile) kept this thread in SwitchToThread for all of it: ~200 + /// ms of kernel time in a 330 ms stall, a core taken from the threads doing the work. + template + void WaitUntil(Done&& done, Target&& target) { + for (u32 spins = 0; spins < 4096; ++spins) { + if (done()) { + return; + } __builtin_ia32_pause(); } + static const auto spin_time = std::chrono::microseconds([] { + const char* env = std::getenv("BB_DRAIN_SPIN_US"); + return env && env[0] ? std::max(0, std::atoi(env)) : 500; + }()); + const auto sleep_after = std::chrono::steady_clock::now() + spin_time; + while (!done()) { + if (!SleepEnabled() || std::chrono::steady_clock::now() < sleep_after) { + std::this_thread::yield(); + continue; + } + // Announced before the check, which stage B's count update and announcement check + // mirror (sequentially consistent both): either this check sees the packet run, or + // stage B sees the target and wakes this thread. + const u64 wait_for = target(); + const u32 seen = drained.load(std::memory_order_seq_cst); + wake_at.store(wait_for, std::memory_order_seq_cst); + const bool asleep = + consumed_packets.load(std::memory_order_seq_cst) < wait_for && !done(); + if (asleep) { + ++sleeps; + drained.wait(seen, std::memory_order_seq_cst); + } + wake_at.store(NoWaiter, std::memory_order_relaxed); + if (!asleep) { + std::this_thread::yield(); // the packet ran, the position follows at once + } + } + } + + /// Stage B: stage A sleeps until the packet just run (WaitUntil). + void WakeStageA() { + if (wake_at.exchange(NoWaiter, std::memory_order_seq_cst) != NoWaiter) { + drained.fetch_add(1, std::memory_order_seq_cst); + drained.notify_all(); + } } void Run(std::stop_token stop) { Common::SetCurrentThreadName("bb:DrawRec"); + BbThreads::MakeCritical(); on_stage_b = true; BbSections::recording_thread = true; stage_b_tid.store(BbPlatform::CurrentThreadId(), std::memory_order_release); @@ -212,7 +334,7 @@ private: if (const char* env = std::getenv("BB_PIPE_SPIN_US")) { return std::max(0, std::atoi(env)); } - return BbThreads::Available() >= 12 ? 200 : 50; + return int(BbThreads::CapSpinUs(BbThreads::Available() >= 12 ? 200 : 50)); }()); const auto spin_until = std::chrono::steady_clock::now() + spin_time; for (u32 spins = 1; available == at; ++spins) { @@ -239,11 +361,18 @@ private: continue; } const u64 start = __rdtsc(); + running_end = at + header->size; handler(context, reinterpret_cast(header + 1), header->payload); busy_cycles.fetch_add(__rdtsc() - start, std::memory_order_relaxed); at += header->size; - consumed_packets.fetch_add(1, std::memory_order_release); + // bbport: the position first, then the count stage A's sleeping waits look at (it + // finds the position past the packet once the count says so), then whether one of + // them waits for this packet: a load and a branch per packet. consumed.store(at, std::memory_order_release); + const u64 run = consumed_packets.fetch_add(1, std::memory_order_seq_cst) + 1; + if (run >= wake_at.load(std::memory_order_seq_cst)) [[unlikely]] { + WakeStageA(); + } } } @@ -256,9 +385,18 @@ private: alignas(64) std::atomic published{0}; alignas(64) std::atomic consumed{0}; std::atomic consumed_packets{0}; ///< packets stage B has run (NextPacket numbers) + u64 running_end = 0; ///< stage B: the end of the packet it runs (PeekNext; its cache line) alignas(64) std::atomic wake{0}; std::atomic sleeping{false}; std::atomic stage_b_tid{0}; + /// bbport: the lookahead thread's sleep (WaitForCommit), woken by Commit like stage B. + std::atomic ahead_sleeping{false}; + std::atomic ahead_wake{0}; + /// bbport: the packet a sleeping wait of stage A needs run (NoWaiter: none), read by stage B + /// after every packet (its own line: written only when stage A goes to sleep), and the word + /// stage A sleeps on (WaitUntil, WakeStageA). + alignas(64) std::atomic wake_at{NoWaiter}; + alignas(64) std::atomic drained{0}; std::jthread thread; }; diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_draw_prep.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_draw_prep.cpp index e71cccf..2bf24a8 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_draw_prep.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_draw_prep.cpp @@ -51,9 +51,15 @@ u32 DefaultWorkerCount() { if (const char* env = std::getenv("BB_PREP_WORKERS")) { return static_cast(std::clamp(std::atoi(env), 0, 16)); } - // Workers are SCHED_IDLE and claim whole buffers, so more of them only use more idle - // cores: half the hardware threads (Steam Deck 4, 16-thread desktop 8). - return std::clamp(BbThreads::Available() / 2, 1, 8); + // Workers are SCHED_IDLE (Windows: THREAD_PRIORITY_IDLE) and claim whole buffers. Below 16 + // hardware threads idle ones still share cores with the render chain (GPU command thread, + // draw recording, Vulkan recording and copy threads) and the game's threads: a quarter of + // them (8 threads 2, 12 threads 3). From 16 on, 3/8 of them, 6 to 8. + const u32 available = BbThreads::Available(); + if (available < 16) { + return std::max(available / 4, 1); + } + return std::clamp(available * 3 / 8, 6, 8); } /// Flattened user data of one submission: chunks never move, so the GPU thread can read a diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_fsr4.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_fsr4.cpp index 995eb38..d2f9c82 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_fsr4.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_fsr4.cpp @@ -38,6 +38,15 @@ bool ReadFile(const std::string& path, std::vector& data) { } FfxFsr4ModelPreset ModelPreset(int preset) { + // Bloodborne PC (after IFreemz/shadPS4-Bloodborne-DLSS-FSR 9b2766e): BB_FSR4_BALANCED=1 runs + // the balanced model for the 2x and 3x presets too; it leaves fewer trails behind moving + // characters than the performance models, at some GPU cost. The render size stays the preset's. + static const bool balanced_low = [] { + const char* v = std::getenv("BB_FSR4_BALANCED"); + return v && v[0] == '1'; + }(); + if (balanced_low && preset >= 3) + return FFX_FSR4_MODEL_PRESET_BALANCED; switch (preset) { case 0: return FFX_FSR4_MODEL_PRESET_NATIVE_AA; diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_gpu_profiler.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_gpu_profiler.cpp index c9e8542..d93a9e3 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_gpu_profiler.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_gpu_profiler.cpp @@ -33,7 +33,7 @@ GpuProfiler::GpuProfiler(const Instance& instance, Scheduler& scheduler_) } void GpuProfiler::WriteTimestamp(u64 key) { - if (used[slice] + 1 >= SliceQueries) { + if (held[slice] || used[slice] + 1 >= SliceQueries) { return; // the frame's slice is full: the rest of the frame goes to the last label } // Outside render passes: radv_CmdWriteTimestamp2 crashed inside some. Marks sit where a @@ -49,7 +49,7 @@ void GpuProfiler::WriteTimestamp(u64 key) { void GpuProfiler::BeginFrame() { // Close the frame: one more timestamp without a label. - if (used[slice] > 0 && used[slice] < SliceQueries) { + if (!held[slice] && used[slice] > 0 && used[slice] < SliceQueries) { scheduler.EndRendering(); const u32 query = slice * SliceQueries + used[slice]++; scheduler.Record([pool = *pool, query](vk::CommandBuffer cmdbuf) { @@ -58,10 +58,17 @@ void GpuProfiler::BeginFrame() { pending[slice] = true; } slice = (slice + 1) % NumSlices; - // The oldest slice: its frame was submitted four frames ago. - if (pending[slice]) { - Collect(slice); + // The oldest slice, from four frames ago. Its results are read without waiting: during a + // load frames pass with no submission, so its timestamps may not have reached the GPU yet + // (waiting for them here stopped the GPU thread for good). Such a slice stays as it is, + // unwritten, until they are there; that frame goes unmeasured. + if (pending[slice] && !Collect(slice)) { + held[slice] = true; + ++skipped_frames; + Print(); + return; } + held[slice] = false; if (used[slice]) { device.resetQueryPool(*pool, slice * SliceQueries, used[slice]); } @@ -71,15 +78,14 @@ void GpuProfiler::BeginFrame() { Print(); } -void GpuProfiler::Collect(u32 which) { +bool GpuProfiler::Collect(u32 which) { const u32 count = used[which]; std::vector stamps(count); - // Four frames on this is complete unless the GPU lags that far: then wait for it. const auto result = device.getQueryPoolResults( *pool, which * SliceQueries, count, count * sizeof(u64), stamps.data(), sizeof(u64), - vk::QueryResultFlagBits::e64 | vk::QueryResultFlagBits::eWait); + vk::QueryResultFlagBits::e64); if (result != vk::Result::eSuccess) { - return; + return false; // eNotReady: not all of them executed yet } for (u32 i = 0; i + 1 < count; ++i) { const double ms = double(stamps[i + 1] - stamps[i]) * period_ns / 1e6; @@ -88,6 +94,7 @@ void GpuProfiler::Collect(u32 which) { ++total.segments; } ++frames; + return true; } void GpuProfiler::Print() { @@ -102,8 +109,10 @@ void GpuProfiler::Print() { for (const auto& [key, total] : sorted) { sum += total.ms; } - std::printf("GPU profile: %.2f ms/frame over %llu frames, %zu labels\n", sum / frames, - static_cast(frames), sorted.size()); + std::printf("GPU profile: %.2f ms/frame over %llu frames, %zu labels (%llu frames not ready, skipped)\n", + sum / frames, static_cast(frames), sorted.size(), + static_cast(skipped_frames)); + skipped_frames = 0; for (size_t i = 0; i < std::min(sorted.size(), 30); ++i) { const auto& [key, total] = sorted[i]; std::printf(" %6.3f ms/frame %5.1f/frame %s\n", total.ms / frames, diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_gpu_profiler.h b/gpu/shadps4/video_core/renderer_vulkan/vk_gpu_profiler.h index 9656a14..2ebe081 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_gpu_profiler.h +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_gpu_profiler.h @@ -58,7 +58,7 @@ public: private: GpuProfiler(const Instance& instance, Scheduler& scheduler); void WriteTimestamp(u64 key); - void Collect(u32 slice); + bool Collect(u32 slice); void Print(); static constexpr u32 NumSlices = 4; @@ -73,6 +73,10 @@ private: std::array, NumSlices> keys; ///< label of each timestamp but the last std::array used{}; std::array pending{}; + /// A slice whose results were not ready when its turn came (its timestamps may sit in a + /// submission not sent yet, during loads): not written again until they are read. + std::array held{}; + u64 skipped_frames = 0; std::unordered_map described; struct Total { double ms = 0; diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_graphics_pipeline.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_graphics_pipeline.cpp index 87381c5..6a003da 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_graphics_pipeline.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_graphics_pipeline.cpp @@ -2,15 +2,27 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include +#include +#include +#include +#include +#include +#include #include +#include +#include #include #include +#include +#include "bbport_threads.h" +#include "common/thread.h" #include "common/serdes.h" #include "common/assert.h" #include "shader_recompiler/backend/spirv/emit_spirv_discard_frag.h" #include "shader_recompiler/backend/spirv/emit_spirv_quad_rect.h" #include "video_core/renderer_vulkan/liverpool_to_vk.h" +#include "video_core/renderer_vulkan/pipeline_compile_queue.h" #include "video_core/renderer_vulkan/vk_graphics_pipeline.h" #include "video_core/renderer_vulkan/vk_instance.h" #include "video_core/renderer_vulkan/vk_object_motion.h" @@ -21,6 +33,101 @@ namespace Vulkan { using Shader::Backend::SPIRV::AuxShaderType; +/// bbport: pipelines first created during play go to compile threads (BB_ASYNC_PIPELINES=0: +/// on the GPU command thread, as before). Pipelines loaded from the cache at startup are +/// always created at once. +bool AsyncPipelinesEnabled() { + static const bool enabled = [] { + const char* env = std::getenv("BB_ASYNC_PIPELINES"); + return !(env && env[0] == '0'); + }(); + return enabled; +} + +namespace { +/// A graphics pipeline's create info with everything it points to owned by the job. +struct PendingPipeline { + vk::GraphicsPipelineCreateInfo info; + boost::container::static_vector stages; + vk::PipelineShaderStageRequiredSubgroupSizeCreateInfo subgroup_size; + VertexInputs bindings; + VertexInputs attributes; + VertexInputs divisors; + vk::PipelineVertexInputDivisorStateCreateInfo divisor_state; + vk::PipelineVertexInputStateCreateInfo vertex_input; + vk::PipelineInputAssemblyStateCreateInfo input_assembly; + vk::PipelineTessellationStateCreateInfo tessellation; + vk::PipelineViewportDepthClipControlCreateInfoEXT clip_control; + vk::PipelineViewportStateCreateInfo viewport; + vk::PipelineRasterizationStateCreateInfo raster; + vk::PipelineRasterizationProvokingVertexStateCreateInfoEXT provoking; + vk::PipelineRasterizationDepthClipStateCreateInfoEXT depth_clip; + vk::PipelineMultisampleStateCreateInfo multisample; + vk::PipelineDepthStencilStateCreateInfo depth_stencil; + std::array attachments; + vk::PipelineColorBlendStateCreateInfo color_blend; + boost::container::static_vector dynamic_states; + vk::PipelineDynamicStateCreateInfo dynamic; + std::array color_formats; + std::array color_samples; + vk::AttachmentSampleCountInfoAMD mixed_samples; + vk::PipelineRenderingCreateInfo rendering; +}; + +/// Compile threads (BB_PIPELINE_THREADS, default a third of the hardware threads, 1-4), first +/// in, first out: the draw that needs a pipeline first gets it first. Never stopped: pipelines +/// destroyed at exit wait for their job (Pipeline::WaitReady). +class PipelineCompiler { +public: + static PipelineCompiler& Get() { + static auto* compiler = new PipelineCompiler(); + return *compiler; + } + + PipelineJobQueue queue; + u32 threads = 0; + +private: + PipelineCompiler() { + threads = std::clamp(BbThreads::Available() / 3, 1, 4); + if (const char* env = std::getenv("BB_PIPELINE_THREADS"); env && env[0]) { + threads = std::clamp(std::atoi(env), 1, 16); + } + for (u32 i = 0; i < threads; ++i) { + std::thread([this, i] { + Common::SetCurrentThreadName(("bb:Pipeline" + std::to_string(i)).c_str()); + for (;;) { + queue.Pop()(); + } + }).detach(); + } + } +}; + +} // namespace + +void SubmitPipelineJob(const void* owner, std::function job) { + pipeline_compile_stats.background.fetch_add(1, std::memory_order_relaxed); + PipelineCompiler::Get().queue.Push(owner, std::move(job)); +} + +std::function TakePipelineJob(const void* owner) { + // bbport: BB_PIPELINE_TAKE=0: a thread that needs a queued pipeline waits for a compile + // thread to get to it, as before. + static const bool enabled = [] { + const char* env = std::getenv("BB_PIPELINE_TAKE"); + return !(env && env[0] == '0'); + }(); + if (!enabled) { + return {}; + } + return PipelineCompiler::Get().queue.Take(owner); +} + +u32 PipelineCompileThreads() { + return PipelineCompiler::Get().threads; +} + static constexpr std::array LogicalStageToStageBit = { vk::ShaderStageFlagBits::eFragment, vk::ShaderStageFlagBits::eTessellationControl, @@ -428,6 +535,105 @@ GraphicsPipeline::GraphicsPipeline( .layout = *pipeline_layout, }; + // bbport (BB_ASYNC_PIPELINES): a pipeline first seen during play is created by a compile + // thread; the draw recording thread waits for it only when it records the bind (Handle). + // Meanwhile the GPU command thread decodes on and starts the next new pipelines, so a burst + // of compiles (area loads: 50-90 at once, 0.5-1.3 s on one thread) runs in parallel. + if (!preloading && AsyncPipelinesEnabled()) { + auto job = std::make_shared(); + auto& j = *job; + bool ok = true; + j.stages.assign(shader_stages.begin(), shader_stages.end()); + j.subgroup_size = subgroup_size_ci; + ok &= subgroup_size_ci.pNext == nullptr; + for (auto& s : j.stages) { + ok &= s.pNext == nullptr || s.pNext == &subgroup_size_ci; + s.pNext = s.pNext ? &j.subgroup_size : nullptr; + } + j.bindings = sdata.vertex_bindings; + j.attributes = sdata.vertex_attributes; + j.divisors = sdata.divisors; + j.divisor_state = divisor_state; + ok &= divisor_state.pNext == nullptr && + divisor_state.pVertexBindingDivisors == sdata.divisors.data(); + j.divisor_state.pVertexBindingDivisors = j.divisors.data(); + j.vertex_input = vertex_input_info; + ok &= vertex_input_info.pNext == nullptr || vertex_input_info.pNext == &divisor_state; + j.vertex_input.pNext = vertex_input_info.pNext ? &j.divisor_state : nullptr; + j.vertex_input.pVertexBindingDescriptions = j.bindings.data(); + j.vertex_input.pVertexAttributeDescriptions = j.attributes.data(); + j.input_assembly = input_assembly; + j.tessellation = tessellation_state; + j.clip_control = clip_control; + j.viewport = viewport_info; + ok &= viewport_info.pNext == nullptr || viewport_info.pNext == &clip_control; + j.viewport.pNext = viewport_info.pNext ? &j.clip_control : nullptr; + // The rasterization chain, linked again in the same order without unlinked parts. + j.raster = raster_chain.get(); + j.provoking = raster_chain.get(); + j.depth_clip = raster_chain.get(); + { + const void** next = &j.raster.pNext; + if (instance.IsProvokingVertexSupported()) { + *next = &j.provoking; + next = &j.provoking.pNext; + } + if (instance.IsDepthClipEnableSupported()) { + *next = &j.depth_clip; + next = &j.depth_clip.pNext; + } + *next = nullptr; + } + j.multisample = sdata.multisampling; + ok &= sdata.multisampling.pNext == nullptr && sdata.multisampling.pSampleMask == nullptr; + j.depth_stencil = depth_stencil_info; + j.attachments = attachments; + j.color_blend = color_blending; + ok &= color_blending.pNext == nullptr; + j.color_blend.pAttachments = j.attachments.data(); + j.dynamic_states.assign(dynamic_states.begin(), dynamic_states.end()); + j.dynamic = dynamic_info; + j.dynamic.pDynamicStates = j.dynamic_states.data(); + j.color_formats = color_formats; + j.color_samples = color_samples; + j.mixed_samples = mixed_samples; + j.mixed_samples.pColorAttachmentSamples = j.color_samples.data(); + j.rendering = pipeline_rendering_ci; + ok &= pipeline_rendering_ci.pNext == nullptr || pipeline_rendering_ci.pNext == &mixed_samples; + j.rendering.pNext = pipeline_rendering_ci.pNext ? &j.mixed_samples : nullptr; + j.rendering.pColorAttachmentFormats = j.color_formats.data(); + j.info = pipeline_info; + ok &= input_assembly.pNext == nullptr && tessellation_state.pNext == nullptr && + clip_control.pNext == nullptr && dynamic_info.pNext == nullptr && + mixed_samples.pNext == nullptr && pipeline_info.pRasterizationState == &raster_chain.get() && + pipeline_info.pMultisampleState == &sdata.multisampling; + j.info.pNext = &j.rendering; + j.info.stageCount = static_cast(j.stages.size()); + j.info.pStages = j.stages.data(); + j.info.pVertexInputState = pipeline_info.pVertexInputState ? &j.vertex_input : nullptr; + j.info.pInputAssemblyState = &j.input_assembly; + j.info.pTessellationState = &j.tessellation; + j.info.pViewportState = &j.viewport; + j.info.pRasterizationState = &j.raster; + j.info.pMultisampleState = &j.multisample; + j.info.pDepthStencilState = pipeline_info.pDepthStencilState ? &j.depth_stencil : nullptr; + j.info.pColorBlendState = &j.color_blend; + j.info.pDynamicState = &j.dynamic; + // Anything this copy does not know how to own (a chain added later): created here. + if (ok) { + ready.store(false, std::memory_order_relaxed); + SubmitPipelineJob(this, [this, job, device, cache = pipeline_cache, name = debug_str] { + auto [result, created] = device.createGraphicsPipelineUnique(cache, job->info); + ASSERT_MSG(result == vk::Result::eSuccess, + "Failed to create graphics pipeline: {}", vk::to_string(result)); + pipeline = std::move(created); + SetObjectName(device, *pipeline, "Graphics Pipeline {}", name); + MarkReady(); + }); + return; + } + } + auto [pipeline_result, pipe] = device.createGraphicsPipelineUnique(pipeline_cache, pipeline_info); if (preloading && pipeline_result != vk::Result::eSuccess) { @@ -442,7 +648,10 @@ GraphicsPipeline::GraphicsPipeline( SetObjectName(device, *pipeline, "Graphics Pipeline {}", debug_str); } -GraphicsPipeline::~GraphicsPipeline() = default; +GraphicsPipeline::~GraphicsPipeline() { + // bbport: the compile thread may still be creating it (its job points at this object). + WaitReady(); +} template void GraphicsPipeline::GetVertexInputs( diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_hdr_native.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_hdr_native.cpp new file mode 100644 index 0000000..571167a --- /dev/null +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_hdr_native.cpp @@ -0,0 +1,635 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: native HDR output (vk_hdr_native.h, hdr_native.comp, docs/hdr.md). + +#include "video_core/renderer_vulkan/vk_hdr_native.h" + +#include +#include +#include +#include +#include + +#include + +#include "bbport_settings.h" +#include "common/assert.h" +#include "video_core/host_shaders/hdr_native_exposure_comp.h" +#include "video_core/host_shaders/hdr_native_gain_comp.h" +#include "video_core/host_shaders/hdr_native_luma_comp.h" +#include "video_core/host_shaders/hdr_native_snapshot_comp.h" +#include "video_core/host_shaders/hdr_native_stats_comp.h" +#include "video_core/renderer_vulkan/vk_gpu_profiler.h" +#include "video_core/renderer_vulkan/vk_instance.h" +#include "video_core/renderer_vulkan/vk_platform.h" +#include "video_core/renderer_vulkan/vk_runtime.h" +#include "video_core/renderer_vulkan/vk_scheduler.h" +#include "video_core/renderer_vulkan/vk_shader_util.h" +#include "video_core/texture_cache/texture_cache.h" + +namespace Vulkan { + +namespace { + +/// Push constants of hdr_native.comp. +struct ExposureParams { + u32 count; + u32 reset; + float min_samples; + float rate; +}; +struct GainParams { + float strength; + float white; + float knee; + float unused; +}; + +/// SDR brightness (linear brightest channel) where native mode starts to brighten: everything +/// darker is the game's SDR picture exactly. Lower than expand mode's 0.8: the gain here is +/// measured, not guessed, and the sky and lit fog sit in the game's shoulder too. +constexpr float Knee = 0.5f; +/// Exposure smoothing per frame (the game's eye adaptation is slower), and the least share of a +/// frame's pixels in the midtones to measure it (a black or white-out frame keeps the last one). +constexpr float ExposureRate = 0.1f; +constexpr u32 MinSampleShare = 200; +/// The Exposure struct of hdr_native.comp. +constexpr vk::DeviceSize StateSize = 4 * sizeof(float); + +constexpr vk::ImageSubresourceRange ColorRange{vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1}; +constexpr auto AllCommands = vk::PipelineStageFlagBits2::eAllCommands; +constexpr auto Compute = vk::PipelineStageFlagBits2::eComputeShader; + +/// Everything before (the game's passes and transitions, this pass's inputs, earlier passes +/// reading what this one writes) finished and visible to the compute shaders. +void BeforeCompute(vk::CommandBuffer cmdbuf) { + const vk::MemoryBarrier2 barrier{ + .srcStageMask = AllCommands, + .srcAccessMask = vk::AccessFlagBits2::eMemoryWrite, + .dstStageMask = Compute, + .dstAccessMask = vk::AccessFlagBits2::eShaderRead | vk::AccessFlagBits2::eShaderWrite, + }; + cmdbuf.pipelineBarrier2({.memoryBarrierCount = 1, .pMemoryBarriers = &barrier}); +} + +/// The compute shaders' writes visible to what follows; later writes to what they read wait. +void AfterCompute(vk::CommandBuffer cmdbuf) { + const vk::MemoryBarrier2 barrier{ + .srcStageMask = Compute, + .srcAccessMask = vk::AccessFlagBits2::eShaderWrite, + .dstStageMask = AllCommands, + .dstAccessMask = vk::AccessFlagBits2::eMemoryRead | vk::AccessFlagBits2::eMemoryWrite, + }; + cmdbuf.pipelineBarrier2({.memoryBarrierCount = 1, .pMemoryBarriers = &barrier}); +} + +/// A port image about to be rewritten whole by a compute shader: its old contents go. +vk::ImageMemoryBarrier2 ToWrite(vk::Image image) { + return { + .srcStageMask = AllCommands, + .srcAccessMask = vk::AccessFlagBits2::eNone, + .dstStageMask = Compute, + .dstAccessMask = vk::AccessFlagBits2::eShaderStorageWrite, + .oldLayout = vk::ImageLayout::eUndefined, + .newLayout = vk::ImageLayout::eGeneral, + .image = image, + .subresourceRange = ColorRange, + }; +} + +vk::DescriptorImageInfo Sampled(vk::ImageView view) { + return {.imageView = view, .imageLayout = vk::ImageLayout::eGeneral}; +} + +vk::WriteDescriptorSet Write(u32 binding, vk::DescriptorType type, + const vk::DescriptorImageInfo* image, + const vk::DescriptorBufferInfo* buffer = nullptr) { + return { + .dstBinding = binding, + .descriptorCount = 1, + .descriptorType = type, + .pImageInfo = image, + .pBufferInfo = buffer, + }; +} + +constexpr auto SampledImage = vk::DescriptorType::eSampledImage; +constexpr auto StorageImage = vk::DescriptorType::eStorageImage; +constexpr auto StorageBuffer = vk::DescriptorType::eStorageBuffer; + +void PrintOnce(bool& printed, const char* text) { + if (!printed) { + printed = true; + std::printf("%s\n", text); + } +} + +} // namespace + +HdrNative::HdrNative(const Instance& instance_, Scheduler& scheduler_, + VideoCore::TextureCache& texture_cache_, Runtime& runtime_) + : instance{instance_}, scheduler{scheduler_}, texture_cache{texture_cache_}, + runtime{runtime_} { + CreatePipelines(); + // The exposure state: four floats the EXPOSURE pass keeps across frames (cleared by the + // first pass that uses it). + const VkBufferCreateInfo buffer_ci{ + .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, + .size = StateSize, + .usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, + }; + const VmaAllocationCreateInfo alloc_ci{.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE}; + VkBuffer buffer{}; + const VkResult result = vmaCreateBuffer(instance.GetAllocator(), &buffer_ci, &alloc_ci, + &buffer, &state.allocation, nullptr); + ASSERT_MSG(result == VK_SUCCESS, "HDR native: exposure buffer allocation failed"); + state.buffer = vk::Buffer{buffer}; + state.size = StateSize; +} + +HdrNative::~HdrNative() { + // The presenter finished the GPU's work before the rasterizer goes. + const auto device = instance.GetDevice(); + for (const auto& entry : view_cache) { + if (entry.view) { + device.destroyImageView(entry.view); + } + } + for (Buffer* buffer : {&partials, &state}) { + if (buffer->buffer) { + vmaDestroyBuffer(instance.GetAllocator(), buffer->buffer, buffer->allocation); + } + } +} + +bool HdrNative::Wanted() { + const auto& s = BbSettings::Get(); + return s.hdr_output.load(std::memory_order_relaxed) != BbSettings::HdrOutputOff && + s.hdr_mode.load(std::memory_order_relaxed) == BbSettings::HdrModeNative; +} + +void HdrNative::CreatePipelines() { + const auto device = instance.GetDevice(); + const auto make = [&](std::initializer_list types, u32 push_size, + std::span code, const char* name, + vk::UniqueDescriptorSetLayout& set_layout, + vk::UniquePipelineLayout& pipeline_layout, vk::UniquePipeline& pipeline) { + std::array bindings{}; + u32 count = 0; + for (const vk::DescriptorType type : types) { + bindings[count] = {.binding = count, + .descriptorType = type, + .descriptorCount = 1, + .stageFlags = vk::ShaderStageFlagBits::eCompute}; + ++count; + } + set_layout = Check<"create HDR native descriptor set layout">( + device.createDescriptorSetLayoutUnique({ + .flags = vk::DescriptorSetLayoutCreateFlagBits::ePushDescriptorKHR, + .bindingCount = count, + .pBindings = bindings.data(), + })); + const vk::PushConstantRange push{ + .stageFlags = vk::ShaderStageFlagBits::eCompute, .offset = 0, .size = push_size}; + pipeline_layout = Check<"create HDR native pipeline layout">( + device.createPipelineLayoutUnique({ + .setLayoutCount = 1, + .pSetLayouts = &*set_layout, + .pushConstantRangeCount = push_size ? 1u : 0u, + .pPushConstantRanges = push_size ? &push : nullptr, + })); + const auto module = CompileSPV(code, device); + ASSERT(module); + SetObjectName(device, module, name); + pipeline = Check<"create HDR native pipeline">(device.createComputePipelineUnique( + {}, vk::ComputePipelineCreateInfo{ + .stage = {.stage = vk::ShaderStageFlagBits::eCompute, + .module = module, + .pName = "main"}, + .layout = *pipeline_layout, + })); + device.destroyShaderModule(module); + }; + make({SampledImage, StorageImage}, 0, HDR_NATIVE_LUMA_COMP, "hdr_native.comp (luma)", + luma_layout, luma_pipeline_layout, luma_pipeline); + make({SampledImage, SampledImage, StorageBuffer, StorageBuffer}, sizeof(u32), + HDR_NATIVE_STATS_COMP, "hdr_native.comp (stats)", stats_layout, stats_pipeline_layout, + stats_pipeline); + make({SampledImage, SampledImage, StorageBuffer, StorageBuffer, StorageImage}, sizeof(u32), + HDR_NATIVE_SNAPSHOT_COMP, "hdr_native.comp (snapshot)", snapshot_layout, + snapshot_pipeline_layout, snapshot_pipeline); + make({StorageBuffer, StorageBuffer}, sizeof(ExposureParams), HDR_NATIVE_EXPOSURE_COMP, + "hdr_native.comp (exposure)", exposure_layout, exposure_pipeline_layout, + exposure_pipeline); + make({SampledImage, SampledImage, SampledImage, StorageBuffer, StorageImage}, + sizeof(GainParams), HDR_NATIVE_GAIN_COMP, "hdr_native.comp (gain)", gain_layout, + gain_pipeline_layout, gain_pipeline); +} + +vk::ImageView HdrNative::CachedView(const VideoCore::Image& image, vk::Format format) { + // As TemporalUpscaler::CachedView: the scene color and the UI target stay the same images + // frame after frame; a view per frame would be created and destroyed for nothing. + const vk::Image handle = image.GetImage(); + ViewEntry* oldest = &view_cache[0]; + for (auto& entry : view_cache) { + if (entry.view && entry.image == handle && entry.uid == image.image_uid && + entry.format == format) { + entry.last_use = ++view_uses; + return entry.view; + } + if (entry.last_use < oldest->last_use) { + oldest = &entry; + } + } + const auto device = instance.GetDevice(); + if (oldest->view) { + scheduler.DeferOperation([device, view = oldest->view] { device.destroyImageView(view); }); + } + // Sampled only: the guest image may also be a storage image, which an sRGB view cannot be. + const vk::ImageViewUsageCreateInfo usage{.usage = vk::ImageUsageFlagBits::eSampled}; + *oldest = { + .image = handle, + .uid = image.image_uid, + .format = format, + .view = Check<"create HDR native view">(device.createImageView({ + .pNext = &usage, + .image = handle, + .viewType = vk::ImageViewType::e2D, + .format = format, + .subresourceRange = ColorRange, + })), + .last_use = ++view_uses, + }; + return oldest->view; +} + +HdrNative::Source HdrNative::Guest(VideoCore::ImageId id, vk::Format format) { + auto& image = texture_cache.GetImage(id); + const vk::ImageView view = CachedView(image, format); + scheduler.EndRendering(); + // NativeAccess (in Transit) resolves a reduced-size scene proxy into the image first, as the + // game's own compute passes reading it do. + runtime.Transit(&image, vk::ImageLayout::eGeneral, Compute, vk::AccessFlagBits2::eShaderRead); + runtime.FlushBarriers(); + return {image.GetImage(), view, image.info.size.width, image.info.size.height}; +} + +void HdrNative::EnsureImage(OwnImage& own, u32 width, u32 height, vk::Format format, + const char* name) { + if (own.image && own.width == width && own.height == height) { + return; + } + if (own.image) { + // Recorded passes may still read the old size's image. + scheduler.DeferOperation( + [image = std::move(own.image), view = std::move(own.view)]() mutable { + view.reset(); + image.Destroy(); + }); + } + const auto device = instance.GetDevice(); + own.image = VideoCore::UniqueImage(device, instance.GetAllocator()); + own.image.Create(vk::ImageCreateInfo{ + .imageType = vk::ImageType::e2D, + .format = format, + .extent = {width, height, 1}, + .mipLevels = 1, + .arrayLayers = 1, + .samples = vk::SampleCountFlagBits::e1, + .tiling = vk::ImageTiling::eOptimal, + .usage = vk::ImageUsageFlagBits::eStorage | vk::ImageUsageFlagBits::eSampled, + .initialLayout = vk::ImageLayout::eUndefined, + }); + SetObjectName(device, vk::Image(own.image), "HDR native {}", name); + own.view = Check<"create HDR native image view">(device.createImageViewUnique({ + .image = vk::Image(own.image), + .viewType = vk::ImageViewType::e2D, + .format = format, + .subresourceRange = ColorRange, + })); + own.width = width; + own.height = height; +} + +void HdrNative::EnsureGainMap(std::unique_ptr& map, u32 width, u32 height) { + if (map && map->width == width && map->height == height) { + return; + } + if (map) { + // The output size changed. A map handed back by the presenter is idle (its frame's + // fence was waited); one made at an earlier display pass may still be written. + scheduler.DeferOperation([old = std::move(map)]() mutable { old.reset(); }); + } + const auto device = instance.GetDevice(); + map = std::make_unique(); + map->image = VideoCore::UniqueImage(device, instance.GetAllocator()); + map->image.Create(vk::ImageCreateInfo{ + .imageType = vk::ImageType::e2D, + .format = vk::Format::eR16Sfloat, + .extent = {width, height, 1}, + .mipLevels = 1, + .arrayLayers = 1, + .samples = vk::SampleCountFlagBits::e1, + .tiling = vk::ImageTiling::eOptimal, + .usage = vk::ImageUsageFlagBits::eStorage | vk::ImageUsageFlagBits::eSampled, + .initialLayout = vk::ImageLayout::eUndefined, + }); + SetObjectName(device, vk::Image(map->image), "HDR native gain map"); + map->view = Check<"create HDR gain map view">(device.createImageViewUnique({ + .image = vk::Image(map->image), + .viewType = vk::ImageViewType::e2D, + .format = vk::Format::eR16Sfloat, + .subresourceRange = ColorRange, + })); + map->width = width; + map->height = height; +} + +void HdrNative::EnsurePartials(u32 groups) { + const vk::DeviceSize size = vk::DeviceSize(std::max(groups, 1u)) * 4 * sizeof(float); + if (partials.buffer && partials.size >= size) { + return; + } + if (partials.buffer) { + scheduler.DeferOperation([allocator = instance.GetAllocator(), old = partials] { + vmaDestroyBuffer(allocator, old.buffer, old.allocation); + }); + } + const VkBufferCreateInfo buffer_ci{ + .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, + .size = size, + .usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + }; + const VmaAllocationCreateInfo alloc_ci{.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE}; + VkBuffer buffer{}; + const VkResult result = vmaCreateBuffer(instance.GetAllocator(), &buffer_ci, &alloc_ci, + &buffer, &partials.allocation, nullptr); + ASSERT_MSG(result == VK_SUCCESS, "HDR native: statistics buffer allocation failed"); + partials.buffer = vk::Buffer{buffer}; + partials.size = size; +} + +void HdrNative::OnSceneColor(VideoCore::ImageId id) { + if (luma_ready || !id || !Wanted()) { + return; + } + const auto& image = texture_cache.GetImage(id); + if (image.info.pixel_format != vk::Format::eR16G16B16A16Sfloat) { + static bool printed = false; + PrintOnce(printed, "HDR native: the scene color is not RGBA16F; native mode falls back"); + return; + } + // A reduced scene's resolve (in Guest) counts as the game's: the post would do it anyway. + const Source scene = Guest(id, vk::Format::eR16G16B16A16Sfloat); + auto* profiler = GpuProfiler::Get(); + const u64 resume = profiler ? profiler->Current() : 0; + if (profiler) { + profiler->Mark(0x4D52'0001ull, [] { return std::string{"HDR native: scene luma"}; }); + } + const u32 width = (scene.width + 1) / 2, height = (scene.height + 1) / 2; + EnsureImage(luma, width, height, vk::Format::eR16G16Sfloat, "scene luma"); + scheduler.RecordCrumb( + {.name = "HDR native scene luma"}, + [scene_view = scene.view, image = vk::Image(luma.image), view = *luma.view, width, height, + pipeline = *luma_pipeline, layout = *luma_pipeline_layout](vk::CommandBuffer cmdbuf) { + BeforeCompute(cmdbuf); + const auto to_write = ToWrite(image); + cmdbuf.pipelineBarrier2( + {.imageMemoryBarrierCount = 1, .pImageMemoryBarriers = &to_write}); + const std::array infos{Sampled(scene_view), Sampled(view)}; + const std::array writes{Write(0, SampledImage, &infos[0]), + Write(1, StorageImage, &infos[1])}; + cmdbuf.bindPipeline(vk::PipelineBindPoint::eCompute, pipeline); + cmdbuf.pushDescriptorSetKHR(vk::PipelineBindPoint::eCompute, layout, 0, writes); + cmdbuf.dispatch((width + 7) / 8, (height + 7) / 8, 1); + AfterCompute(cmdbuf); + }); + if (resume) { + profiler->Resume(resume); + } + luma_ready = true; +} + +void HdrNative::OnUiStart(const Source& frame) { + if (!WantsUiStart()) { + return; + } + ui_started = true; + if (!frame.view || !frame.width || !frame.height || !Wanted()) { + return; + } + EnsureImage(snapshot, frame.width, frame.height, vk::Format::eR8G8B8A8Unorm, + "frame before UI"); + RecordExposure(frame, true); + snapshot_ready = true; + snapshot_source = frame.image; +} + +void HdrNative::RecordExposure(const Source& frame, bool copy) { + auto* profiler = GpuProfiler::Get(); + const u64 resume = profiler ? profiler->Current() : 0; + if (profiler) { + profiler->Mark(0x4D52'0002ull, [] { return std::string{"HDR native: exposure"}; }); + } + const u32 groups_x = (frame.width + 15) / 16, groups_y = (frame.height + 15) / 16; + EnsurePartials(groups_x * groups_y); + const ExposureParams params{ + .count = groups_x * groups_y, + .reset = measured_last_frame ? 0u : 1u, + .min_samples = float(std::max(1024u, frame.width * frame.height / MinSampleShare)), + .rate = ExposureRate, + }; + const bool clear_state = !state_cleared; + state_cleared = true; + scheduler.EndRendering(); + scheduler.RecordCrumb( + {.name = "HDR native exposure"}, + [frame_view = frame.view, luma = *luma.view, snapshot_image = vk::Image(snapshot.image), + snapshot_view = copy ? *snapshot.view : vk::ImageView{}, partials = partials.buffer, + partials_size = partials.size, state = state.buffer, groups_x, groups_y, params, + clear_state, stats = copy ? *snapshot_pipeline : *stats_pipeline, + stats_layout = copy ? *snapshot_pipeline_layout : *stats_pipeline_layout, + exposure = *exposure_pipeline, + exposure_layout = *exposure_pipeline_layout](vk::CommandBuffer cmdbuf) { + if (clear_state) { + cmdbuf.fillBuffer(state, 0, StateSize, 0); + const vk::MemoryBarrier2 cleared{ + .srcStageMask = vk::PipelineStageFlagBits2::eTransfer, + .srcAccessMask = vk::AccessFlagBits2::eTransferWrite, + .dstStageMask = Compute, + .dstAccessMask = vk::AccessFlagBits2::eShaderRead | + vk::AccessFlagBits2::eShaderWrite, + }; + cmdbuf.pipelineBarrier2({.memoryBarrierCount = 1, .pMemoryBarriers = &cleared}); + } + BeforeCompute(cmdbuf); + if (snapshot_view) { + const auto to_write = ToWrite(snapshot_image); + cmdbuf.pipelineBarrier2( + {.imageMemoryBarrierCount = 1, .pImageMemoryBarriers = &to_write}); + } + const std::array infos{Sampled(frame_view), Sampled(luma), Sampled(snapshot_view)}; + const vk::DescriptorBufferInfo partials_info{partials, 0, partials_size}; + const vk::DescriptorBufferInfo state_info{state, 0, StateSize}; + const std::array writes{Write(0, SampledImage, &infos[0]), + Write(1, SampledImage, &infos[1]), + Write(2, StorageBuffer, nullptr, &partials_info), + Write(3, StorageBuffer, nullptr, &state_info), + Write(4, StorageImage, &infos[2])}; + cmdbuf.bindPipeline(vk::PipelineBindPoint::eCompute, stats); + cmdbuf.pushDescriptorSetKHR(vk::PipelineBindPoint::eCompute, stats_layout, 0, + vk::ArrayProxy( + snapshot_view ? 5u : 4u, writes.data())); + cmdbuf.pushConstants(stats_layout, vk::ShaderStageFlagBits::eCompute, 0, + sizeof(params.reset), ¶ms.reset); + cmdbuf.dispatch(groups_x, groups_y, 1); + // The partial sums, added up by one group, which then updates the state the + // groups above read. + const vk::MemoryBarrier2 summed{ + .srcStageMask = Compute, + .srcAccessMask = vk::AccessFlagBits2::eShaderWrite, + .dstStageMask = Compute, + .dstAccessMask = vk::AccessFlagBits2::eShaderRead, + }; + cmdbuf.pipelineBarrier2({.memoryBarrierCount = 1, .pMemoryBarriers = &summed}); + const std::array exposure_writes{Write(0, StorageBuffer, nullptr, &partials_info), + Write(1, StorageBuffer, nullptr, &state_info)}; + cmdbuf.bindPipeline(vk::PipelineBindPoint::eCompute, exposure); + cmdbuf.pushDescriptorSetKHR(vk::PipelineBindPoint::eCompute, exposure_layout, 0, + exposure_writes); + cmdbuf.pushConstants(exposure_layout, vk::ShaderStageFlagBits::eCompute, 0, + sizeof(params), ¶ms); + cmdbuf.dispatch(1, 1, 1); + AfterCompute(cmdbuf); + }); + if (resume) { + profiler->Resume(resume); + } +} + +void HdrNative::RecordGain(const Source& frame, const Source& before_ui, const HdrGainMap& map) { + auto* profiler = GpuProfiler::Get(); + const u64 resume = profiler ? profiler->Current() : 0; + if (profiler) { + profiler->Mark(0x4D52'0003ull, [] { return std::string{"HDR native: gain map"}; }); + } + const auto& s = BbSettings::Get(); + const float paper_white = std::clamp(s.hdr_paper_white.load(), BbSettings::HdrPaperWhiteMin, + BbSettings::HdrPaperWhiteMax); + const float peak = + std::clamp(s.hdr_peak.load(), BbSettings::HdrPeakMin, BbSettings::HdrPeakMax); + // Boost in native mode: the share of the scene's highlight range shown, in log2. Its square + // root, so the shared default (0.25, chosen for expand mode) shows half of it: a lamp the + // scene has 10x brighter than SDR white comes out 3.2x. + const GainParams params{ + .strength = std::sqrt(std::clamp(s.hdr_boost.load(), 0.0f, 1.0f)), + .white = peak / paper_white, + .knee = Knee, + .unused = 0.0f, + }; + scheduler.EndRendering(); + scheduler.RecordCrumb( + {.name = "HDR native gain map"}, + [frame_view = frame.view, before_view = before_ui.view, luma = *luma.view, + state = state.buffer, image = vk::Image(map.image), view = *map.view, width = map.width, + height = map.height, params, pipeline = *gain_pipeline, + layout = *gain_pipeline_layout](vk::CommandBuffer cmdbuf) { + BeforeCompute(cmdbuf); + const auto to_write = ToWrite(image); + cmdbuf.pipelineBarrier2( + {.imageMemoryBarrierCount = 1, .pImageMemoryBarriers = &to_write}); + const std::array infos{Sampled(frame_view), Sampled(before_view), Sampled(luma), + Sampled(view)}; + const vk::DescriptorBufferInfo state_info{state, 0, StateSize}; + const std::array writes{Write(0, SampledImage, &infos[0]), + Write(1, SampledImage, &infos[1]), + Write(2, SampledImage, &infos[2]), + Write(3, StorageBuffer, nullptr, &state_info), + Write(4, StorageImage, &infos[3])}; + cmdbuf.bindPipeline(vk::PipelineBindPoint::eCompute, pipeline); + cmdbuf.pushDescriptorSetKHR(vk::PipelineBindPoint::eCompute, layout, 0, writes); + cmdbuf.pushConstants(layout, vk::ShaderStageFlagBits::eCompute, 0, sizeof(params), + ¶ms); + cmdbuf.dispatch((width + 7) / 8, (height + 7) / 8, 1); + // Read by the HDR encode on the present thread's submission, which waits for this + // frame's work (Presenter::Present); in General layout, sampled. + const vk::ImageMemoryBarrier2 to_read{ + .srcStageMask = Compute, + .srcAccessMask = vk::AccessFlagBits2::eShaderStorageWrite, + .dstStageMask = vk::PipelineStageFlagBits2::eFragmentShader, + .dstAccessMask = vk::AccessFlagBits2::eShaderSampledRead, + .oldLayout = vk::ImageLayout::eGeneral, + .newLayout = vk::ImageLayout::eGeneral, + .image = image, + .subresourceRange = ColorRange, + }; + cmdbuf.pipelineBarrier2( + {.imageMemoryBarrierCount = 1, .pImageMemoryBarriers = &to_read}); + AfterCompute(cmdbuf); + }); + if (resume) { + profiler->Resume(resume); + } +} + +void HdrNative::OnDisplayPass(VAddr address, const Source& frame) { + bool made = false; + bool measured = snapshot_ready; + if (luma_ready && frame.view && frame.width && frame.height && Wanted()) { + // With a UI-free copy the display pass must read the image it was taken from, at the + // same size; without a UI pass this frame (in-engine cutscenes) the frame has no UI and + // is its own copy. A UI pass without a copy (its target not found): no map, the UI + // must not be brightened. + const bool matches = snapshot_ready ? snapshot_source == frame.image && + snapshot.width == frame.width && + snapshot.height == frame.height + : !ui_started; + if (matches) { + if (!snapshot_ready) { + RecordExposure(frame, false); + measured = true; + } + const Source before_ui = + snapshot_ready ? Source{vk::Image(snapshot.image), *snapshot.view, snapshot.width, + snapshot.height} + : frame; + std::scoped_lock lock{slots_mutex}; + auto& slot = slots[address]; + EnsureGainMap(slot.map, frame.width, frame.height); + RecordGain(frame, before_ui, *slot.map); + slot.valid = true; + made = true; + } else { + static bool printed = false; + PrintOnce(printed, "HDR native: no UI-free copy of the image the display pass reads " + "(UI target not found); such frames fall back to expand"); + } + } + if (!made) { + std::scoped_lock lock{slots_mutex}; + if (const auto it = slots.find(address); it != slots.end()) { + it->second.valid = false; + } + } + measured_last_frame = measured; + EndFrame(); +} + +bool HdrNative::TakeGainMap(VAddr address, std::unique_ptr& map) { + std::scoped_lock lock{slots_mutex}; + const auto it = slots.find(address); + if (it == slots.end() || !it->second.valid || !it->second.map) { + return false; + } + std::swap(map, it->second.map); + it->second.valid = false; + return true; +} + +void HdrNative::EndFrame() { + luma_ready = false; + ui_started = false; + snapshot_ready = false; + snapshot_source = vk::Image{}; +} + +} // namespace Vulkan diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_hdr_native.h b/gpu/shadps4/video_core/renderer_vulkan/vk_hdr_native.h new file mode 100644 index 0000000..c607d38 --- /dev/null +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_hdr_native.h @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: native HDR output (hdr_mode=native, docs/hdr.md). The HDR encode (hdr_output.frag) +// shows the game's finished SDR frame, UI included; native mode brightens its highlights by how +// much brighter the game's own linear HDR scene is where the tonemap compressed them, measured +// per frame on the GPU (hdr_native.comp) at three points of Bloodborne's frame that the temporal +// upscaler already finds (vk_temporal_upscaler.h): +// - before the post-processing combine (compute shader 9a9cf8a9, after the upscaler wrote its +// output into the scene color): the scene's brightest channel at half size (OnSceneColor); +// - at the first UI pass (the first Scaleform draw, vertex shader 34e8a281 or another of +// UiComposition::MovieShader; with the scaled presets the upscaled output-size UI image): a +// copy of the tonemapped frame before any UI pixel, and the exposure the game's curve gave the +// midtones (OnUiStart); +// - at the display pass that copies the finished frame into a display buffer (the first pass of +// the next frame): the gain map, 1 below the knee and where the frame differs from the copy +// (UI). It belongs to that display buffer: the presenter takes it when the buffer is flipped, +// which comes a frame later in the command stream, so a frame and its map always match. +// A frame missing any of them (title, menus, loading, movies) has no map: the encode falls back +// to expand mode for it. All passes are recorded on the recording threads (Scheduler::Record), a +// handful of dispatches per frame, nothing per draw; no readback and no wait. + +#pragma once + +#include +#include +#include +#include + +#include "common/types.h" +#include "video_core/renderer_vulkan/vk_common.h" +#include "video_core/texture_cache/image.h" + +namespace VideoCore { +class TextureCache; +} + +namespace Vulkan { + +class Instance; +class Scheduler; +class Runtime; + +/// A display buffer's gain map: R16F at the finished frame's size, General layout; negative +/// where the frame had UI (magnitude 1 there). Handed from the display pass to the presenter's +/// Frame by exchange (HdrNative::TakeGainMap), never copied. +struct HdrGainMap { + VideoCore::UniqueImage image; + vk::UniqueImageView view; + u32 width = 0, height = 0; +}; + +class HdrNative { +public: + HdrNative(const Instance& instance, Scheduler& scheduler, + VideoCore::TextureCache& texture_cache, Runtime& runtime); + ~HdrNative(); + + /// Native mode is in use: an HDR swapchain is presented and hdr_mode is native. Otherwise + /// nothing is recorded. + [[nodiscard]] static bool Wanted(); + + /// An image a pass reads: in General layout by the time the commands run, `view` sampled + /// (UNORM for the display-encoded frames). + struct Source { + vk::Image image; + vk::ImageView view; + u32 width = 0, height = 0; + }; + /// A guest image as a Source: transitioned by the runtime (a reduced-size scene proxy is + /// resolved into it first), viewed as `format`. + Source Guest(VideoCore::ImageId id, vk::Format format); + + /// Before the post-processing combine: the scene color (RGBA16F), upscaled by now. Once a + /// frame. + void OnSceneColor(VideoCore::ImageId scene); + /// The first UI pass is next and this frame's scene was seen: OnUiStart is wanted. + [[nodiscard]] bool WantsUiStart() const noexcept { + return luma_ready && !ui_started; + } + /// The tonemapped frame before the first UI pass (empty: none found). Once a frame. + void OnUiStart(const Source& frame); + /// The display pass copying the finished frame `frame` (empty: none) into the display buffer + /// at `address`: its gain map, or none. Ends the frame. + void OnDisplayPass(VAddr address, const Source& frame); + + /// Presenter, when the display buffer at `address` is flipped: exchanges `map` with the gain + /// map made for it, if it has one (then true). The display buffer's slot gets the old map + /// (`map` may be null): the frame it came from has been presented (its fence waited). + bool TakeGainMap(VAddr address, std::unique_ptr& map); + +private: + struct OwnImage { + VideoCore::UniqueImage image; + vk::UniqueImageView view; + u32 width = 0, height = 0; + }; + struct Buffer { + vk::Buffer buffer{}; + VmaAllocation allocation{}; + vk::DeviceSize size = 0; + }; + struct Slot { + std::unique_ptr map; + bool valid = false; ///< made for the frame the display buffer holds now + }; + + void CreatePipelines(); + /// (Re)creates `image` at `width` x `height`; the old one goes once the GPU is past it. + void EnsureImage(OwnImage& image, u32 width, u32 height, vk::Format format, const char* name); + void EnsureGainMap(std::unique_ptr& map, u32 width, u32 height); + /// Room for `groups` partial sums of the STATS pass. + void EnsurePartials(u32 groups); + /// The exposure statistics of `frame` against this frame's luma (`copy`: also copying it + /// into the snapshot), then the exposure update. + void RecordExposure(const Source& frame, bool copy); + void RecordGain(const Source& frame, const Source& before_ui, const HdrGainMap& map); + vk::ImageView CachedView(const VideoCore::Image& image, vk::Format format); + void EndFrame(); + + const Instance& instance; + Scheduler& scheduler; + VideoCore::TextureCache& texture_cache; + Runtime& runtime; + + vk::UniqueDescriptorSetLayout luma_layout, stats_layout, snapshot_layout, exposure_layout, + gain_layout; + vk::UniquePipelineLayout luma_pipeline_layout, stats_pipeline_layout, + snapshot_pipeline_layout, exposure_pipeline_layout, gain_pipeline_layout; + vk::UniquePipeline luma_pipeline, stats_pipeline, snapshot_pipeline, exposure_pipeline, + gain_pipeline; + + OwnImage luma; ///< RG16F, half the scene's size: the brightest channel's 2x2 max, average + OwnImage snapshot; ///< RGBA8, the tonemapped frame before the UI + Buffer partials; ///< vec2 per STATS group + Buffer state; ///< hdr_native.comp's Exposure, zero at start + bool state_cleared = false; + /// The exposure was measured in the last frame (else the next measurement replaces it). + bool measured_last_frame = false; + + // This frame (from one display pass to the next), on the draw recording thread. + bool luma_ready = false; ///< the scene was reduced + bool ui_started = false; ///< OnUiStart was called + bool snapshot_ready = false; ///< the UI-free copy and the exposure were taken + vk::Image snapshot_source{}; ///< the image they were taken from + + std::mutex slots_mutex; ///< TakeGainMap runs where the flip is handled + std::unordered_map slots; + + struct ViewEntry { + vk::Image image; + u64 uid = 0; + vk::Format format{}; + vk::ImageView view; + u64 last_use = 0; + }; + std::array view_cache{}; + u64 view_uses = 0; +}; + +} // namespace Vulkan diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_instance.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_instance.cpp index 23d52b9..bee2120 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_instance.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_instance.cpp @@ -13,6 +13,7 @@ #include "video_core/renderer_vulkan/liverpool_to_vk.h" #include "bbport_toggles.h" #include "video_core/renderer_vulkan/vk_instance.h" +#include "video_core/renderer_vulkan/vk_occlusion.h" #include "video_core/renderer_vulkan/vk_platform.h" #include @@ -238,7 +239,7 @@ bool Instance::CreateDevice() { return false; } - boost::container::static_vector enabled_extensions; + boost::container::static_vector enabled_extensions; const auto add_extension = [&](std::string_view extension) -> bool { const auto result = std::find_if(available_extensions.begin(), available_extensions.end(), @@ -392,6 +393,19 @@ bool Instance::CreateDevice() { } const bool calibrated_timestamps = TRACY_GPU_ENABLED ? add_extension(VK_EXT_CALIBRATED_TIMESTAMPS_EXTENSION_NAME) : false; + // bbport: HDR output (vk_swapchain.cpp): the display's luminance for the HDR swapchain. + hdr_metadata = add_extension(VK_EXT_HDR_METADATA_EXTENSION_NAME); + // bbport (Windows): BB_EXCLUSIVE_FULLSCREEN=0 keeps the swapchain from ever becoming + // exclusive fullscreen (vk_swapchain.cpp), so a driver cannot switch Windows HDR off for an + // SDR image. Off by default: the driver's exclusive mode has the lowest latency and keeps + // G-SYNC "fullscreen only" working, HDR output keeps HDR on by itself, and the launcher turns + // Windows HDR back on after the game. + const char* exclusive = std::getenv("BB_EXCLUSIVE_FULLSCREEN"); + if (const char* fse = FullScreenExclusiveExtensionName(); + fse && exclusive && exclusive[0] == '0' && + IsInstanceExtensionEnabled(VK_KHR_GET_SURFACE_CAPABILITIES_2_EXTENSION_NAME)) { + full_screen_exclusive = add_extension(fse); + } const auto family_properties = physical_device.getQueueFamilyProperties(); if (family_properties.empty()) { @@ -469,6 +483,8 @@ bool Instance::CreateDevice() { .wideLines = features.wideLines, .multiViewport = features.multiViewport, .samplerAnisotropy = features.samplerAnisotropy, + // bbport: the game asks for exact counts (PERFECT_ZPASS_COUNTS), vk_occlusion. + .occlusionQueryPrecise = features.occlusionQueryPrecise && Occlusion::Wanted(), .vertexPipelineStoresAndAtomics = features.vertexPipelineStoresAndAtomics, .fragmentStoresAndAtomics = features.fragmentStoresAndAtomics, .shaderImageGatherExtended = features.shaderImageGatherExtended, diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_instance.h b/gpu/shadps4/video_core/renderer_vulkan/vk_instance.h index f1f48b6..198095d 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_instance.h +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_instance.h @@ -112,6 +112,16 @@ public: return features.samplerAnisotropy; } + /// bbport: occlusion queries with exact sample counts (vk_occlusion.h). + bool IsOcclusionQueryPreciseSupported() const { + return features.occlusionQueryPrecise; + } + + /// bbport: queries reset on the CPU (vk_occlusion.h). + bool IsHostQueryResetSupported() const { + return vk12_features.hostQueryReset; + } + /// Returns true if depth bounds testing is supported bool IsDepthBoundsSupported() const { return features.depthBounds; @@ -329,6 +339,17 @@ public: return shader_clock && shader_clock_features.shaderSubgroupClock; } + /// bbport: VK_EXT_full_screen_exclusive (Windows) is enabled, with the instance's + /// VK_KHR_get_surface_capabilities2: swapchains are created with exclusive mode disallowed. + bool IsFullScreenExclusiveEnabled() const { + return full_screen_exclusive; + } + + /// bbport: VK_EXT_hdr_metadata is enabled (HDR output). + bool IsHdrMetadataEnabled() const { + return hdr_metadata; + } + /// Returns the vendor ID of the physical device u32 GetVendorID() const { return properties.vendorID; @@ -604,6 +625,8 @@ private: bool compute_shader_derivatives{}; bool mixed_float_dot_product{}; // bbport: VK_VALVE_shader_mixed_float_dot_product (FSR 4.1.1) bool dlss_extensions{}; // bbport: VK_NVX_binary_import + VK_NVX_image_view_handle + bool full_screen_exclusive{}; // bbport: VK_EXT_full_screen_exclusive (Windows) + bool hdr_metadata{}; // bbport: VK_EXT_hdr_metadata bool cooperative_matrix{}; // bbport: VK_KHR_cooperative_matrix (FSR 4.1.1 FP8 variant) bool shader_float8{}; // bbport: VK_EXT_shader_float8 with FP8 matrices (RDNA4) bool supports_memory_budget{}; diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_occlusion.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_occlusion.cpp new file mode 100644 index 0000000..1cdcd2d --- /dev/null +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_occlusion.cpp @@ -0,0 +1,481 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: real occlusion query results (vk_occlusion.h). Design after droogie/bbhost b68ed17. + +#include +#include +#include +#include +#include +#include + +#include "bbport_toggles.h" +#include "core/memory.h" +#include "video_core/renderer_vulkan/occlusion_model.h" +#include "video_core/renderer_vulkan/scene_resolution.h" +#include "video_core/renderer_vulkan/vk_instance.h" +#include "video_core/renderer_vulkan/vk_occlusion.h" +#include "video_core/renderer_vulkan/vk_rasterizer.h" +#include "video_core/renderer_vulkan/vk_scheduler.h" + +extern "C" void runtime_memory_read_backing(uintptr_t address, void* data, uint64_t size); + +namespace Vulkan { + +namespace { +using OcclusionModel::MaxPipes; +using OcclusionModel::Share; +using OcclusionModel::Unmeasured; +using OcclusionModel::ValidBit; + +u64 EnvU64(const char* name, u64 fallback) { + const char* env = std::getenv(name); + return env && *env ? std::strtoull(env, nullptr, 10) : fallback; +} + +/// Reads guest memory through the runtime's backing view: no page protection (a read fault on +/// this thread would wait for the GPU thread), zeroes where nothing is mapped. +void ReadGuest(VAddr address, void* data, u64 size) { + runtime_memory_read_backing(address, data, size); +} + +/// A Gnm block as the game reads it now. +OcclusionModel::Block ReadBlock(VAddr block, u32 pipes) { + std::array words{}; + ReadGuest(block, words.data(), 16ull * pipes); + return OcclusionModel::ReadBlock(words.data(), pipes); +} +} // namespace + +struct Occlusion::Shared { + /// The resolver's state and the pool's life (the destructor takes it too). + std::mutex mutex; + /// Only the queue: the recording side never waits for a resolve in progress. + std::mutex queue_mutex; + bool alive = true; + vk::Device device; + vk::QueryPool pool; + Rasterizer* rasterizer = nullptr; + bool precise = false; + u64 max_lag = 3; + + struct Pending { + u64 tick; ///< the submission holding the dump (and every query before it) + VAddr va; + u64 frame; + u64 serial; + u64 first; ///< the interval's first query, as a running count + u32 count; ///< its queries + u32 pipes; + bool poison; ///< a draw in the interval got no query + }; + std::deque pending; ///< under queue_mutex + std::vector ready; ///< taken from it by the resolver + /// Per query: the sample scale of its draw (16.16; reduced scene targets), written by the + /// recording side before the dump is queued under the mutex. + std::array scale{}; + std::atomic released{0}; ///< queries read and reset (reusable) + std::atomic resolved_serial{0}; ///< dumps processed, in order + + u64 total = 0; ///< the DBs' running count + /// What we last wrote at each dump address: our own value may be overwritten (a block the + /// game reuses), anything else that is not zero is not ours to write over. + std::unordered_map written; + /// Begin dumps (+0) written in this use of their block: the total they wrote, taken by the + /// block's end dump (+8) for the block's result. + std::unordered_map begins; + /// Per block (its +0 address): the hysteresis of its results (OcclusionModel::BlockHistory). + std::unordered_map histories; + std::vector results; + + // Statistics (BB_FRAME_STATS), under the mutex except the recording side's atomics. + u64 dumps = 0, queries = 0, unavailable = 0, poisoned = 0, visible = 0, occluded = 0; + u64 late = 0, foreign = 0, unbacked = 0, written_dumps = 0; + u64 held_visible = 0; ///< blocks that measured nothing but read visible (hysteresis) + u64 late_written = 0; ///< late dumps of known blocks written all the same + std::atomic pool_full{0}, pred_sets{0}, pred_resolved{0}, pred_skipped{0}, + pred_drawn{0}; + std::chrono::steady_clock::time_point window = std::chrono::steady_clock::now(); + u64 window_frames = 0, window_draws = 0; + std::array last{}; + + void Resolve(u64 tick); + void Process(const Pending& dump); + /// The end dump of `block` (its +0 address) is about to be written: hysteresis. + void HoldVisible(VAddr block); + /// False when it did not land (not a query block now, or unmapped). + bool WriteBlock(const Pending& dump); + void Report(); +}; + +bool Occlusion::Wanted() { + static const bool on = [] { + const char* env = std::getenv("BB_OCCLUSION"); + return !(env && env[0] == '0'); + }(); + return on; +} + +Occlusion::Occlusion(const Instance& instance_, Scheduler& scheduler_, Rasterizer& rasterizer_) + : instance{instance_}, scheduler{scheduler_}, rasterizer{rasterizer_} {} + +std::unique_ptr Occlusion::Create(const Instance& instance, Scheduler& scheduler, + Rasterizer& rasterizer) { + if (!Wanted()) { + std::printf("Occlusion: off (BB_OCCLUSION=0): every query the game makes reads visible\n"); + return nullptr; + } + std::unique_ptr occlusion{new Occlusion(instance, scheduler, rasterizer)}; + if (!occlusion->Init()) { + return nullptr; + } + return occlusion; +} + +bool Occlusion::Init() { + // Results are read and the queries reset on the CPU once the GPU is past them: no reset + // commands outside render passes, no waits. + if (!instance.IsHostQueryResetSupported()) { + std::printf("Occlusion: off - the device has no host query reset; every query the game " + "makes reads visible, as before\n"); + return false; + } + const vk::Device device = instance.GetDevice(); + const vk::QueryPoolCreateInfo info = { + .queryType = vk::QueryType::eOcclusion, + .queryCount = PoolSize, + }; + const auto [result, pool] = device.createQueryPool(info); + if (result != vk::Result::eSuccess) { + std::printf("Occlusion: off - no query pool (%s); every query the game makes reads " + "visible, as before\n", + vk::to_string(result).c_str()); + return false; + } + device.resetQueryPool(pool, 0, PoolSize); + precise = instance.IsOcclusionQueryPreciseSupported(); + cap = static_cast(std::clamp(EnvU64("BB_OCCLUSION_CAP", 64), 1, 1024)); + shared = std::make_shared(); + shared->device = device; + shared->pool = pool; + shared->rasterizer = &rasterizer; + shared->precise = precise; + shared->max_lag = EnvU64("BB_OCCLUSION_MAX_LAG", 3); + shared->results.resize(2ull * PoolSize); + std::printf("Occlusion: real sample counts (BB_OCCLUSION=0: the old always-visible fake) - a " + "query on each of the %u draws after a block's begin, results written when the " + "GPU has run them (at most %llu frames late), %s; SET_PREDICATION skips only on " + "known results\n", + cap, static_cast(shared->max_lag), + precise ? "precise" : "not precise (a nonzero count reads plainly visible)"); + return true; +} + +Occlusion::~Occlusion() { + if (!shared) { + return; + } + // A resolve still queued on the scheduler's thread finds it gone and does nothing. + std::scoped_lock lk{shared->mutex}; + shared->alive = false; + shared->device.destroyQueryPool(shared->pool); + shared->pool = nullptr; +} + +Occlusion::DumpTask Occlusion::MakeDump(VAddr va, u32 num_pipes) const noexcept { + return {va, issued, BbStats::submit_done_calls.load(std::memory_order_relaxed), + std::clamp(num_pipes, 1u, MaxPipes), 0}; +} + +void Occlusion::Dump(const DumpTask& task) { + // Every draw stage A decoded since the last dump got a query, or the interval reads visible: + // a draw dropped before recording (pipeline still compiling, filtered, failed) must not + // count as occluded. + const bool poison = + !interval.open || task.issued - interval.issued_at != interval.queries; + const u64 tick = scheduler.CurrentTick(); + const u64 serial = ++dump_serial; + if (block_serial.size() > 65536) { + block_serial.clear(); + } + block_serial[task.va & ~15ull] = serial; + { + std::scoped_lock lk{shared->queue_mutex}; + shared->pending.push_back({tick, task.va, task.frame, serial, interval.first, + interval.queries, task.pipes, poison}); + } + if (tick != registered_tick) { + // One resolve per submission, on the scheduler's thread once the GPU has finished it. + registered_tick = tick; + scheduler.DeferPriorityOperation( + [shared = shared, tick] { shared->Resolve(tick); }); + } + // Gnm's begin dump (+0 of a 16-byte cell) measures the draws up to its end dump; after any + // other dump only the next draw is measured (droogie's caps), the rest read visible. + interval = {true, (task.va & 15) == 0 ? cap : 1u, 0, query_head, task.issued}; + pipes = task.pipes; +} + +Occlusion::DrawQuery Occlusion::AllocateQuery(u32 scene_size) noexcept { + if (query_head - shared->released.load(std::memory_order_acquire) >= PoolSize) { + shared->pool_full.fetch_add(1, std::memory_order_relaxed); + return {}; // unmeasured: reads visible + } + const u32 index = static_cast(query_head % PoolSize); + // A pass into the reduced scene targets counts fewer samples than the PS4's 1920x1080; the + // count is scaled back so a threshold the game applies means the same. + u32 scale = 1u << 16; + if (scene_size) { + const auto size = SceneResolution::Unpack(scene_size); + scale = OcclusionModel::SampleScale(size.width, size.height); + } + shared->scale[index] = scale; + ++query_head; + ++interval.queries; + return {shared->pool, index, + precise ? vk::QueryControlFlags{vk::QueryControlFlagBits::ePrecise} + : vk::QueryControlFlags{}}; +} + +void Occlusion::SetPredication(const PredicationTask& task) { + shared->pred_sets.fetch_add(1, std::memory_order_relaxed); + if (task.op == 0) { + pred_mode = PredMode::None; + pred_visible = pred_unknown = false; + return; + } + if (task.op != 1) { + pred_mode = PredMode::Draw; // primitive-count predication (streamout): not emulated + return; + } + if (!(task.cont && pred_mode != PredMode::None)) { + pred_visible = pred_unknown = false; + } + // Known only when every dump into the block has been resolved: then the block holds this + // use's result (or is not ready). Results come a frame late, so mostly unknown: drawn. + const auto it = block_serial.find(task.block); + if (it != block_serial.end() && + shared->resolved_serial.load(std::memory_order_acquire) >= it->second) { + const auto state = ReadBlock(task.block, pipes); + pred_unknown |= state == OcclusionModel::Block::NotReady; + pred_visible |= state == OcclusionModel::Block::Visible; + if (state != OcclusionModel::Block::NotReady) { + shared->pred_resolved.fetch_add(1, std::memory_order_relaxed); + } + } else { + pred_unknown = true; + } + // A wrong "occluded" makes geometry vanish: unknown draws. + const bool draw = + OcclusionModel::PredicatedDraws(pred_visible, pred_unknown, task.draw_if_visible != 0); + pred_mode = draw ? PredMode::Draw : PredMode::Skip; +} + +bool Occlusion::SkipPredicated() noexcept { + if (pred_mode == PredMode::Skip) { + shared->pred_skipped.fetch_add(1, std::memory_order_relaxed); + return true; + } + shared->pred_drawn.fetch_add(1, std::memory_order_relaxed); + return false; +} + +void Occlusion::Shared::Resolve(u64 tick) { + std::scoped_lock lk{mutex}; + if (!alive) { + return; + } + ready.clear(); + { + std::scoped_lock queue{queue_mutex}; + while (!pending.empty() && pending.front().tick <= tick) { + ready.push_back(pending.front()); + pending.pop_front(); + } + } + for (const Pending& dump : ready) { + Process(dump); + } + Report(); +} + +void Occlusion::Shared::Process(const Pending& dump) { + ++dumps; + bool bad = dump.poison; + u64 samples = 0; + if (dump.count) { + queries += dump.count; + // The ring may wrap inside the interval: at most two ranges. + const u32 first = static_cast(dump.first % PoolSize); + const u32 head_part = std::min(dump.count, PoolSize - first); + const std::array, 2> ranges{{{first, head_part}, + {0, dump.count - head_part}}}; + for (const auto& [start, count] : ranges) { + if (!count) { + continue; + } + // Without waiting: the GPU has finished the submission. A query a driver did not + // resolve (AMD while the window is unfocused, KyoPS4x #220) reads visible. + const vk::Result result = device.getQueryPoolResults( + pool, start, count, 16ull * count, results.data(), 16, + vk::QueryResultFlagBits::e64 | vk::QueryResultFlagBits::eWithAvailability); + for (u32 i = 0; i < count; ++i) { + const bool failed = result != vk::Result::eSuccess && + result != vk::Result::eNotReady; + const bool available = !failed && results[2 * i + 1] != 0; + unavailable += available ? 0 : 1; + // Imprecise results may be 0/1 (NVIDIA): one sample read as "nearly hidden" was + // the likeliest cause of KyoPS4x #219's black world. + samples += OcclusionModel::Samples(results[2 * i], available, precise, + scale[start + i]); + } + device.resetQueryPool(pool, start, count); + } + released.fetch_add(dump.count, std::memory_order_release); + } + if (bad) { + ++poisoned; + samples += Unmeasured; + } + total += samples; + const u64 lag = BbStats::submit_done_calls.load(std::memory_order_relaxed) - dump.frame; + const bool is_begin = (dump.va & 15) == 0; + // A late dump is written into a block we wrote before (it then holds the game's zeroes or + // our last value, checked by WriteBlock) up to twice the lag: the block keeps its last + // result instead of reading not ready. Later, or into memory we never wrote, the game may + // have freed and reused it by now: left as it is (not ready). + if (lag > max_lag && (lag > 2 * max_lag || !written.contains(dump.va))) { + ++late; + if (is_begin) { + begins.erase(dump.va); + } + } else { + late_written += lag > max_lag ? 1 : 0; + if ((dump.va & 15) == 8) { + HoldVisible(dump.va - 8); + } + const bool landed = WriteBlock(dump); + if (is_begin) { + if (landed) { + if (begins.size() > 65536) { + begins.clear(); + } + begins[dump.va] = total; + } else { + begins.erase(dump.va); + } + } + } + resolved_serial.store(dump.serial, std::memory_order_release); +} + +void Occlusion::Shared::HoldVisible(VAddr block) { + // The block's result as the game reads it (end - begin): its begin dump must have landed + // in this use of the block, else the block reads not ready anyway. + const auto begin = begins.find(block); + if (begin == begins.end()) { + return; + } + const u64 measured = total - begin->second; + begins.erase(begin); + if (histories.size() > 65536) { + histories.clear(); + } + const u64 reported = histories[block].Report(measured); + if (reported > measured) { + // Added to the running total: every block keeps reading end - begin. + total += reported - measured; + held_visible += measured == 0 ? 1 : 0; + } +} + +bool Occlusion::Shared::WriteBlock(const Pending& dump) { + std::array words{}; + const u64 extent = 16ull * (dump.pipes - 1) + 8; + ReadGuest(dump.va, words.data(), extent); + const auto own = written.find(dump.va); + for (u32 k = 0; k < dump.pipes; ++k) { + const u64 word = words[2 * k]; + const bool ours = own != written.end() && + word == (Share(own->second, dump.pipes, k) | ValidBit); + if (word != 0 && !ours) { + ++foreign; + return false; // neither the game's zeroes nor our last value: not a query block now + } + } + auto* memory = Core::Memory::Instance(); + for (u32 k = 0; k < dump.pipes; ++k) { + const u64 value = Share(total, dump.pipes, k) | ValidBit; + auto* address = reinterpret_cast(dump.va + 16ull * k); + // Through the backing view (no protection fault on this thread; fails rather than + // writing where nothing is mapped any more). + if (!memory->TryWriteBacking(address, &value, sizeof(value))) { + ++unbacked; + return false; + } + rasterizer->NoteLateCommandWrite(dump.va + 16ull * k, &value, sizeof(value)); + } + ++written_dumps; + if (written.size() > 262144) { + written.clear(); + } + written[dump.va] = total; + // The end dump of a Gnm block (+8): how the pair reads, for the statistics. + if ((dump.va & 15) == 8) { + if (const auto begin = written.find(dump.va - 8); begin != written.end()) { + ++(total != begin->second ? visible : occluded); + } + } + return true; +} + +void Occlusion::Shared::Report() { + static const bool stats = std::getenv("BB_FRAME_STATS") != nullptr; + if (!stats) { + return; + } + const auto now = std::chrono::steady_clock::now(); + const double seconds = std::chrono::duration(now - window).count(); + if (seconds < 5.0) { + return; + } + window = now; + const u64 frames_now = BbStats::submit_done_calls.load(std::memory_order_relaxed); + const u64 draws_now = BbStats::draws.load(std::memory_order_relaxed); + const std::array now_counts{ + dumps, + queries, + visible, + occluded, + poisoned, + unavailable, + late + foreign + unbacked, + pool_full.load(std::memory_order_relaxed), + pred_sets.load(std::memory_order_relaxed), + pred_resolved.load(std::memory_order_relaxed), + pred_skipped.load(std::memory_order_relaxed), + pred_drawn.load(std::memory_order_relaxed), + held_visible, + late_written, + }; + std::array d{}; + const double frames = std::max(1.0, double(frames_now - window_frames)); + for (size_t i = 0; i < d.size(); ++i) { + d[i] = double(now_counts[i] - last[i]) / frames; + } + const double pairs = d[2] + d[3]; + std::printf("Occlusion per frame: %.1f dumps, %.1f queries, objects %.1f visible / %.1f " + "culled (%.0f%%), %.1f intervals read visible (%.1f queries unavailable), %.1f " + "dumps not written (totals: late %llu, reused memory %llu, unmapped %llu), pool " + "full %.1f; SET_PREDICATION %.1f (%.1f known), predicated draws %.1f skipped / " + "%.1f drawn; %.0f draws/frame; held visible %.1f, late written %.1f\n", + d[0], d[1], d[2], d[3], pairs > 0 ? 100.0 * d[3] / pairs : 0.0, d[4], d[5], d[6], + (unsigned long long)late, (unsigned long long)foreign, + (unsigned long long)unbacked, d[7], d[8], d[9], d[10], d[11], + double(draws_now - window_draws) / frames, d[12], d[13]); + std::fflush(stdout); + last = now_counts; + window_frames = frames_now; + window_draws = draws_now; +} + +} // namespace Vulkan diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_occlusion.h b/gpu/shadps4/video_core/renderer_vulkan/vk_occlusion.h new file mode 100644 index 0000000..da1ac39 --- /dev/null +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_occlusion.h @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: real occlusion query results (BB_OCCLUSION, default on; 0: the old fake counts). +// +// The design follows droogie/bbhost commit b68ed17 ("occlusion: the game's own GPU occlusion +// culling works", Kyoskii, after KyoPS4x #217-#220), adapted to this renderer: there the GPU +// writes the counts with a compute shader before the labels; here labels are written when the +// work is recorded (not when the GPU has run it), so the counts are written by the CPU once the +// GPU has finished the submission holding the queries, a frame or so after the game asked. +// +// The PS4 model: each depth block (DB, 8 on a base PS4) keeps a running count of the samples +// that passed; an EVENT_WRITE of PIXEL_PIPE_STAT_DUMP / ZPASS_DONE stores every DB's count, bit +// 63 (valid) set, at the dump's address + 16 * db. Gnm's query block is {begin, end} per DB: +// the game zeroes it, dumps at +0 before the draws it measures and at +8 after, and reads the +// sum of end - begin. Bloodborne tests its culling proxies that way (GX GetData, CPU readback, +// ~180 blocks a frame in a dense view: when the answer is "no samples" it does not submit the +// object's draws at all) and also predicates draws on blocks with SET_PREDICATION (Gnm's +// setZPassPredicationEnable, called from the game at 0x216a79e and 0x2250745). +// +// Here: +// - Stage A counts the draws it decodes and hands each dump to the recording side in order +// (RunInOrder). Draws recorded after a dump get a Vulkan occlusion query each, begun and ended +// around the vkCmdDraw itself (never across a render pass): up to BB_OCCLUSION_CAP (64) after +// a block's begin dump, one after any other dump. An interval holding a draw without a query +// (past the cap, filtered, its pipeline not ready yet, failed) reads plainly visible. +// - At the dump the interval's queries and the block address are queued; once the GPU has +// finished that submission (Scheduler::DeferPriorityOperation, its own thread) the results +// are read without waiting, a running total is kept like the DBs keep theirs, and the dump +// writes it, split over the DBs with the valid bit, into the game's memory. Never a stall: +// no thread waits for a query, the game polls its blocks and takes "not ready" as it would on +// the PS4 (it keeps the object drawn and asks again). +// - Fail-safe towards "visible": an unavailable query, a missing query, an imprecise device's +// nonzero result, all add a large count. A dump over memory that holds neither the game's +// zeroes nor our own last value (freed and reused) is not written, nor one more than +// BB_OCCLUSION_MAX_LAG frames late unless we wrote that block before and it is at most twice +// that late: the block stays not ready. +// - Hysteresis against flicker (lamps, glare): a block reports the largest of its last three +// results (OcclusionModel::BlockHistory): occluded only after three in a row found nothing, +// visible at once. +// - SET_PREDICATION: the draws between a ZPASS set and its clear are skipped only when every +// dump into the block has been resolved and the block reads occluded; otherwise they are +// drawn. The results arrive a frame late, so this rarely skips: GPU conditional rendering +// would need a render pass split per predicated object to copy the result on the GPU and +// would still record every draw (the CPU cost this port is bound by), so it is not done. +// BB_FRAME_STATS counts how many predicated draws there are. +// +// Latency: an object coming into view is drawn again once the game has read the result of a +// query it issued while the object was hidden: one or two frames later than on the PS4 (the +// game itself already lags a frame there). Popping at the edge of occluders is the visible risk. + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "common/types.h" +#include "video_core/renderer_vulkan/vk_common.h" + +namespace Vulkan { + +class Instance; +class Scheduler; +class Rasterizer; + +class Occlusion { +public: + /// BB_OCCLUSION (default on). Off: the fake counts and SET_PREDICATION ignored, as before. + static bool Wanted(); + + /// Null when the device cannot do it (no host query reset, no query pool): the fake then. + static std::unique_ptr Create(const Instance& instance, Scheduler& scheduler, + Rasterizer& rasterizer); + ~Occlusion(); + + /// Stage A (GPU command thread): a draw packet decoded (direct or indirect). + void NoteIssued() noexcept { + ++issued; + } + + /// Stage A: what a dump hands to the recording side. + struct DumpTask { + VAddr va; + u64 issued; ///< draws decoded before the dump + u64 frame; ///< sceGnmSubmitDone calls when it was decoded + u32 pipes; ///< DBs the dump writes + u32 pad; + }; + DumpTask MakeDump(VAddr va, u32 num_pipes) const noexcept; + + struct PredicationTask { + VAddr block; + u32 op; ///< 0 clear, 1 ZPASS, 2 primitive count (not emulated: drawn) + u8 draw_if_visible; + u8 cont; + u16 pad; + }; + /// Stage A: whether a SET_PREDICATION region is open (the submission end clears it). + bool predication_open = false; + + /// Dumps were queued into the submission `current_tick` (not sent to the GPU yet). Read on + /// stage A with the draw pipe drained. + bool HasUnsubmittedDumps(u64 current_tick) const noexcept { + return registered_tick == current_tick; + } + + // ---- the recording side (stage B, or stage A after a drain), in stream order ---- + void Dump(const DumpTask& task); + void SetPredication(const PredicationTask& task); + /// At the start of a draw's recording: true when SET_PREDICATION skips it. + bool SkipDraw() noexcept { + if (pred_mode == PredMode::None) [[likely]] { + return false; + } + return SkipPredicated(); + } + /// The query to wrap the draw about to be recorded in (pool null: none). `scene_size`: the + /// draw's push_data.scene_size (reduced scene targets count fewer samples). + struct DrawQuery { + vk::QueryPool pool{}; + u32 index = 0; + vk::QueryControlFlags flags{}; + }; + DrawQuery BeginDraw(u32 scene_size) noexcept { + if (!interval.open || interval.queries >= interval.cap) [[likely]] { + return {}; + } + return AllocateQuery(scene_size); + } + /// bbport: whether BeginDraw would measure the next draw. A draw the game measures is never + /// left out while its pipeline compiles (Rasterizer::SkipUncompiledDraw): zero samples + /// would read as occluded and its predicated draws would vanish too. + [[nodiscard]] bool Measuring() const noexcept { + return interval.open && interval.queries < interval.cap; + } + +private: + Occlusion(const Instance& instance, Scheduler& scheduler, Rasterizer& rasterizer); + bool Init(); + DrawQuery AllocateQuery(u32 scene_size) noexcept; + bool SkipPredicated() noexcept; + + static constexpr u32 PoolSize = 16384; + + const Instance& instance; + Scheduler& scheduler; + Rasterizer& rasterizer; + bool precise = false; + u32 cap = 64; + u32 pipes = 8; ///< DBs of the last dump (a Neo has 16) + + // Stage A. + u64 issued = 0; + + // The recording side. + struct Interval { + bool open = false; + u32 cap = 0; + u32 queries = 0; ///< queries handed out in it (one per measured draw) + u64 first = 0; ///< its first query, as a running count + u64 issued_at = 0; ///< stage A's draw count at the dump that opened it + } interval; + u64 query_head = 0; ///< queries handed out so far + u64 dump_serial = 0; + u64 registered_tick = ~0ull; + std::unordered_map block_serial; ///< a block's last dump (either half) + enum class PredMode : u8 { None, Skip, Draw }; + PredMode pred_mode = PredMode::None; + bool pred_visible = false, pred_unknown = false; ///< CONTINUE chains OR blocks together + + /// What the resolver thread and the recording side share. + struct Shared; + std::shared_ptr shared; +}; + +} // namespace Vulkan diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_pipeline_common.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_pipeline_common.cpp index 52778fb..6bba722 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_pipeline_common.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_pipeline_common.cpp @@ -1,12 +1,18 @@ // SPDX-FileCopyrightText: Copyright 2024 shadPS4 Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include +#include +#include +#include #include +#include #include #include #include "shader_recompiler/resource.h" +#include "video_core/renderer_vulkan/pipeline_compile_queue.h" #include "video_core/renderer_vulkan/vk_instance.h" #include "video_core/renderer_vulkan/vk_pipeline_cache.h" #include "video_core/renderer_vulkan/vk_pipeline_common.h" @@ -20,7 +26,98 @@ Pipeline::Pipeline(const Instance& instance_, Scheduler& scheduler_, DescriptorH : instance{instance_}, scheduler{scheduler_}, desc_heap{desc_heap_}, profile{profile_}, is_compute{is_compute_} {} -Pipeline::~Pipeline() = default; +Pipeline::~Pipeline() { + // bbport: a compile thread may still be writing `pipeline` (BB_ASYNC_PIPELINES). + WaitReady(); +} + +// bbport: time render threads waited for pipelines created on compile threads; reported with +// the compile time of the GPU thread (BB_FRAME_STATS "shader/pipeline compiles, ... ms"). +extern std::atomic g_bb_compile_ns; + +void Pipeline::WaitReady() const noexcept { + if (ready.load(std::memory_order_acquire)) { + return; + } + const auto start = std::chrono::steady_clock::now(); + // bbport: still queued (the compile threads busy with the pipelines before it): compiled + // here, instead of waiting for them first and then for this one. + if (auto job = TakePipelineJob(this)) { + job(); + pipeline_compile_stats.taken.fetch_add(1, std::memory_order_relaxed); + } + while (!ready.load(std::memory_order_acquire)) { + ready.wait(false, std::memory_order_acquire); + } + const u64 waited = u64(std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count()); + g_bb_compile_ns += waited; + pipeline_compile_stats.NoteWait(waited); +} + +namespace { +bool EnvOff(const char* name) { + const char* env = std::getenv(name); + return env && env[0] == '0'; +} +} // namespace + +u64 SkipUncompiledBudgetNs() { + static const u64 budget = [] { + if (EnvOff("BB_SKIP_UNCOMPILED")) { + return u64{0}; + } + const char* env = std::getenv("BB_SKIP_UNCOMPILED_MS"); + const u64 ms = env && env[0] ? std::strtoull(env, nullptr, 10) : 1000; + return ms * 1'000'000; + }(); + return budget; +} + +bool LatePipelineBindEnabled() { + static const bool enabled = !EnvOff("BB_LATE_PIPELINE_BIND"); + return enabled; +} + +void LogPipelineCompileMode(const Instance& instance, bool late_binds_possible) { + if (!AsyncPipelinesEnabled()) { + std::printf("Pipelines: new ones compiled where the draw needs them (BB_ASYNC_PIPELINES=0)\n"); + return; + } + // VK_EXT_graphics_pipeline_library is reported, not used: shaders reach the port at the draw + // that needs them and a pipeline's shaders share one descriptor set layout, so the libraries + // would be compiled at that draw as well (docs/pipeline_compiles.md). + const auto extensions = instance.GetAvailableExtensions(); + const bool gpl = std::ranges::find(extensions, std::string{ + VK_EXT_GRAPHICS_PIPELINE_LIBRARY_EXTENSION_NAME}) != + extensions.end(); + bool fast_linking = false; + if (gpl) { + const auto chain = instance.GetPhysicalDevice() + .getProperties2(); + fast_linking = chain.get() + .graphicsPipelineLibraryFastLinking; + } + const u64 budget_ms = SkipUncompiledBudgetNs() / 1'000'000; + std::printf("Pipelines: new ones compiled on %u threads; draws of one still compiling: scene " + "geometry %s, other draws %s (BB_SKIP_UNCOMPILED, BB_LATE_PIPELINE_BIND); graphics " + "pipeline library %s, not used\n", + PipelineCompileThreads(), + budget_ms ? fmt::format("left out for up to {} ms", budget_ms).c_str() : "waits", + late_binds_possible && LatePipelineBindEnabled() + ? "bind it on the recording threads (they wait for it)" + : "wait for it on the draw recording thread", + gpl ? (fast_linking ? "offered (fast linking)" : "offered (no fast linking)") + : "not offered"); + std::fflush(stdout); +} + +void Pipeline::MarkReady() noexcept { + ready.store(true, std::memory_order_release); + ready.notify_all(); +} void Pipeline::BindResources(DescriptorWrites& set_writes, const Shader::PushData& push_data, diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_pipeline_common.h b/gpu/shadps4/video_core/renderer_vulkan/vk_pipeline_common.h index ff89b44..45a1560 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_pipeline_common.h +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_pipeline_common.h @@ -3,6 +3,9 @@ #pragma once +#include +#include + #include "shader_recompiler/profile.h" #include "shader_recompiler/runtime_info.h" #include "video_core/renderer_vulkan/vk_common.h" @@ -25,6 +28,29 @@ class Instance; class Scheduler; class DescriptorHeap; +/// bbport: pipelines first created during play are created on compile threads +/// (BB_ASYNC_PIPELINES=0: where they are selected, as before; vk_graphics_pipeline.cpp). +bool AsyncPipelinesEnabled(); +/// Runs `job`, which creates `owner`, on a compile thread (BB_PIPELINE_THREADS), first in, +/// first out. +void SubmitPipelineJob(const void* owner, std::function job); +/// bbport: `owner`'s job when no compile thread has started it yet (the caller runs it), else +/// empty (BB_PIPELINE_TAKE=0: always empty). +std::function TakePipelineJob(const void* owner); +/// The number of compile threads. +u32 PipelineCompileThreads(); + +/// bbport (docs/pipeline_compiles.md): what a draw does with a pipeline still compiling. +/// Scene geometry is left out (BB_SKIP_UNCOMPILED=0: never) for at most this long +/// (BB_SKIP_UNCOMPILED_MS, default 1000); 0 when draws are never left out. +u64 SkipUncompiledBudgetNs(); +/// Other draws and dispatches bind it on the recording threads, which wait for it there +/// (BB_LATE_PIPELINE_BIND=0: the draw recording thread waits, as before). +bool LatePipelineBindEnabled(); +/// One line at start saying which of these run, with whether the device offers +/// VK_EXT_graphics_pipeline_library (not used: see the document). +void LogPipelineCompileMode(const Instance& instance, bool late_binds_possible); + class Pipeline { public: Pipeline(const Instance& instance, Scheduler& scheduler, DescriptorHeap& desc_heap, @@ -32,10 +58,31 @@ public: bool is_compute = false); virtual ~Pipeline(); + /// bbport: a graphics pipeline may still be created on a compile thread + /// (BB_ASYNC_PIPELINES): the first caller that needs the handle waits for it. vk::Pipeline Handle() const noexcept { + if (!ready.load(std::memory_order_acquire)) [[unlikely]] { + WaitReady(); + } return *pipeline; } + /// bbport: false while the pipeline is still being created on a compile thread. + [[nodiscard]] bool IsReady() const noexcept { + return ready.load(std::memory_order_acquire); + } + + /// bbport: the handle once the pipeline is created, else null without waiting (the caller + /// leaves the draw out or binds the pipeline on a recording thread). The same load and + /// branch as Handle() for a created pipeline. + [[nodiscard]] vk::Pipeline HandleIfReady() const noexcept { + return ready.load(std::memory_order_acquire) ? *pipeline : vk::Pipeline{}; + } + + /// bbport: when a draw of this pipeline was first left out while it compiled (0: never). + /// Only the thread recording draws uses it (Rasterizer::SkipUncompiledDraw). + mutable u64 first_skip_ns = 0; + vk::PipelineLayout GetLayout() const noexcept { return *pipeline_layout; } @@ -66,7 +113,14 @@ public: protected: [[nodiscard]] std::string GetDebugString() const; - + /// bbport: blocks until a compile thread has stored `pipeline` (Handle, destructors); runs + /// the job itself when no compile thread has started it (TakePipelineJob). + void WaitReady() const noexcept; + /// bbport: the compile thread stores `pipeline`, then sets this (release) and wakes waiters. + void MarkReady() noexcept; + + /// bbport: cleared by a graphics pipeline whose creation went to a compile thread. + std::atomic ready{true}; const Instance& instance; Scheduler& scheduler; DescriptorHeap& desc_heap; diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_platform.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_platform.cpp index 6b8f6b1..ba9f9e1 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_platform.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_platform.cpp @@ -13,6 +13,8 @@ #define VK_USE_PLATFORM_XLIB_KHR #endif +#include +#include #include #include @@ -32,6 +34,15 @@ namespace Vulkan { static const char* const VALIDATION_LAYER_NAME = "VK_LAYER_KHRONOS_validation"; static const char* const CRASH_DIAGNOSTIC_LAYER_NAME = "VK_LAYER_LUNARG_crash_diagnostic"; +/// bbport: the extensions of the last instance CreateInstance made (IsInstanceExtensionEnabled). +static std::vector enabled_instance_extensions; + +#if defined(VK_USE_PLATFORM_WIN32_KHR) +static const vk::SurfaceFullScreenExclusiveInfoEXT full_screen_exclusive_disallowed{ + .fullScreenExclusive = vk::FullScreenExclusiveEXT::eDisallowed, +}; +#endif + static VKAPI_ATTR VkBool32 VKAPI_CALL DebugUtilsCallback( vk::DebugUtilsMessageSeverityFlagBitsEXT severity, vk::DebugUtilsMessageTypeFlagsEXT type, const vk::DebugUtilsMessengerCallbackDataEXT* callback_data, void* user_data) { @@ -194,10 +205,14 @@ std::vector GetInstanceExtensions(Frontend::WindowSystemType window if (window_type != Frontend::WindowSystemType::Headless) { extensions.push_back(VK_KHR_SURFACE_EXTENSION_NAME); - } - - if (EmulatorSettings.IsHdrAllowed()) { + // bbport: HDR output (hdr in bbport.ini): the surface lists its HDR formats only with + // this extension. Enabled whenever available; the SDR swapchain still picks + // SRGB_NONLINEAR (vk_swapchain.cpp). extensions.push_back(VK_EXT_SWAPCHAIN_COLOR_SPACE_EXTENSION_NAME); +#if defined(VK_USE_PLATFORM_WIN32_KHR) + // bbport: needed by VK_EXT_full_screen_exclusive (device), and the *2 surface queries. + extensions.push_back(VK_KHR_GET_SURFACE_CAPABILITIES_2_EXTENSION_NAME); +#endif } if (enable_debug_utils) { @@ -287,6 +302,7 @@ vk::UniqueInstance CreateInstance(Frontend::WindowSystemType window_type, bool e const auto layers = GetInstanceLayers(enable_validation, enable_crash_diagnostic); const auto extensions = GetLayerExtensions(GetInstanceExtensions(window_type, true), layers); + enabled_instance_extensions.assign(extensions.begin(), extensions.end()); const vk::ApplicationInfo application_info = { .pApplicationName = "shadPS4", @@ -439,4 +455,61 @@ vk::UniqueDebugUtilsMessengerEXT CreateDebugCallback(vk::Instance instance) { return std::move(messenger); } +bool IsInstanceExtensionEnabled(std::string_view name) { + return std::ranges::find(enabled_instance_extensions, name) != + enabled_instance_extensions.end(); +} + +const char* FullScreenExclusiveExtensionName() { +#if defined(VK_USE_PLATFORM_WIN32_KHR) + return VK_EXT_FULL_SCREEN_EXCLUSIVE_EXTENSION_NAME; +#else + return nullptr; +#endif +} + +const void* FullScreenExclusiveDisallowed(bool enabled) { +#if defined(VK_USE_PLATFORM_WIN32_KHR) + return enabled ? &full_screen_exclusive_disallowed : nullptr; +#else + return nullptr; +#endif +} + +vk::ResultValue> GetSurfacePresentModes( + vk::Instance instance, vk::PhysicalDevice physical, vk::SurfaceKHR surface, + bool full_screen_exclusive) { +#if defined(VK_USE_PLATFORM_WIN32_KHR) + // Loaded here: the dispatcher's Win32 entries are only filled in this translation unit. + const auto get_modes = full_screen_exclusive + ? reinterpret_cast( + VULKAN_HPP_DEFAULT_DISPATCHER.vkGetInstanceProcAddr( + static_cast(instance), "vkGetPhysicalDeviceSurfacePresentModes2EXT")) + : nullptr; + if (get_modes) { + const VkPhysicalDeviceSurfaceInfo2KHR info{ + .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SURFACE_INFO_2_KHR, + .pNext = &full_screen_exclusive_disallowed, + .surface = static_cast(surface), + }; + const VkPhysicalDevice device = static_cast(physical); + u32 count = 0; + VkResult result = get_modes(device, &info, &count, nullptr); + std::vector modes; + if (result == VK_SUCCESS) { + modes.resize(count); + result = get_modes(device, &info, &count, + reinterpret_cast(modes.data())); + modes.resize(count); + } + if (result == VK_SUCCESS || result == VK_INCOMPLETE) { + return {vk::Result::eSuccess, std::move(modes)}; + } + LOG_WARNING(Render_Vulkan, "vkGetPhysicalDeviceSurfacePresentModes2EXT failed: {}", + vk::to_string(vk::Result{result})); + } +#endif + return physical.getSurfacePresentModesKHR(surface); +} + } // namespace Vulkan diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_platform.h b/gpu/shadps4/video_core/renderer_vulkan/vk_platform.h index c171d07..cb211e2 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_platform.h +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_platform.h @@ -3,6 +3,8 @@ #pragma once +#include +#include #include #include "common/assert.h" @@ -28,6 +30,25 @@ vk::UniqueInstance CreateInstance(Frontend::WindowSystemType window_type, bool e vk::UniqueDebugUtilsMessengerEXT CreateDebugCallback(vk::Instance instance); +/// bbport: whether CreateInstance enabled this instance extension (the last instance created). +bool IsInstanceExtensionEnabled(std::string_view name); + +/// bbport: VK_EXT_full_screen_exclusive on Windows (the swapchain stays a normal window +/// swapchain: drivers otherwise switch a screen-covering window to exclusive fullscreen, which +/// turned Windows HDR off); nullptr on the other platforms. +const char* FullScreenExclusiveExtensionName(); + +/// bbport: a VkSurfaceFullScreenExclusiveInfoEXT {DISALLOWED} (its pNext null) for the pNext of +/// the swapchain's creation and of the *2 surface queries; nullptr when `enabled` is false or +/// on the other platforms. +const void* FullScreenExclusiveDisallowed(bool enabled); + +/// bbport: the surface's present modes; with `full_screen_exclusive`, as reported for the +/// FullScreenExclusiveDisallowed chain (vkGetPhysicalDeviceSurfacePresentModes2EXT). +vk::ResultValue> GetSurfacePresentModes( + vk::Instance instance, vk::PhysicalDevice physical, vk::SurfaceKHR surface, + bool full_screen_exclusive); + template concept VulkanHandleType = vk::isVulkanHandleType::value; diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_presenter.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_presenter.cpp index 602cb30..9529089 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_presenter.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_presenter.cpp @@ -13,6 +13,7 @@ #include "video_core/renderdoc.h" #include "video_core/renderer_vulkan/vk_platform.h" #include "bbport_overlay.h" +#include "bbport_settings.h" #include "bbport_timeline.h" #include "video_core/renderer_vulkan/vk_temporal_upscaler.h" #include "video_core/renderer_vulkan/vk_presenter.h" @@ -154,6 +155,12 @@ Presenter::Presenter(Frontend::WindowSDL& window_, AmdGpu::Liverpool* liverpool_ fsr_pass.Create(device, instance.GetAllocator(), num_images); pp_pass.Create(device, swapchain.GetSurfaceFormat().format); BbOverlay::Init(instance, swapchain.GetSurfaceFormat().format, num_images); + // bbport: HDR output (hdr in bbport.ini): frames, passes and the menu stay SDR; the encode + // pass writes the HDR swapchain format chosen at start. + if (const vk::Format hdr_format = swapchain.GetHdrFormat().format; + hdr_format != vk::Format::eUndefined) { + hdr_pass.Create(device, hdr_format); + } } @@ -172,6 +179,273 @@ Presenter::~Presenter() { device.destroyImageView(frame.image_view); device.destroyFence(frame.present_done); } + DestroyHdrTarget(hdr_game); + DestroyHdrTarget(hdr_overlay); +} + +void Presenter::CreateHdrTarget(HdrTarget& target, vk::Extent2D extent, const char* name) { + const vk::Device device = instance.GetDevice(); + // The SDR format: the blit's and the menu pipeline's (BbOverlay::Init); sRGB encoded values, + // decoded by the encode pass. Format support is checked by Swapchain::FindPresentFormat. + const vk::ImageCreateInfo image_info = { + .imageType = vk::ImageType::e2D, + .format = swapchain.GetSurfaceFormat().format, + .extent = {extent.width, extent.height, 1}, + .mipLevels = 1, + .arrayLayers = 1, + .samples = vk::SampleCountFlagBits::e1, + .tiling = vk::ImageTiling::eOptimal, + .usage = vk::ImageUsageFlagBits::eColorAttachment | vk::ImageUsageFlagBits::eTransferDst | + vk::ImageUsageFlagBits::eSampled, + .initialLayout = vk::ImageLayout::eUndefined, + }; + const VmaAllocationCreateInfo alloc_info = { + .usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE, + }; + VkImage image{}; + const VkImageCreateInfo unsafe_image_info = static_cast(image_info); + const VkResult result = vmaCreateImage(instance.GetAllocator(), &unsafe_image_info, + &alloc_info, &image, &target.allocation, nullptr); + if (result != VK_SUCCESS) [[unlikely]] { + LOG_CRITICAL(Render_Vulkan, "Failed allocating the HDR output image with error {}", + vk::to_string(vk::Result{result})); + UNREACHABLE(); + } + target.image = vk::Image{image}; + SetObjectName(device, target.image, "HDR output {}", name); + target.view = Check<"create HDR output image view">(device.createImageView({ + .image = target.image, + .viewType = vk::ImageViewType::e2D, + .format = image_info.format, + .subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1}, + })); +} + +void Presenter::DestroyHdrTarget(const HdrTarget& target) { + if (target.view) { + instance.GetDevice().destroyImageView(target.view); + } + if (target.image) { + vmaDestroyImage(instance.GetAllocator(), target.image, target.allocation); + } +} + +void Presenter::EnsureHdrTargets(vk::Extent2D extent) { + if (hdr_game.image && hdr_extent == extent) { + return; + } + if (hdr_game.image) { + // Presents still in flight may read the old size's images: they go once the GPU has + // finished this present's submission (later than every earlier one, same queue). + present_scheduler.DeferOperation( + [this, game = hdr_game, overlay = hdr_overlay] { + DestroyHdrTarget(game); + DestroyHdrTarget(overlay); + }); + } + CreateHdrTarget(hdr_game, extent, "composite"); + CreateHdrTarget(hdr_overlay, extent, "menu layer"); + hdr_extent = extent; +} + +void Presenter::PresentHdr(vk::CommandBuffer cmdbuf, Frame* frame, vk::Extent2D extent) { + EnsureHdrTargets(extent); + const vk::Image swapchain_image = swapchain.Image(); + const bool overlay = BbOverlay::Visible(); + const vk::ImageSubresourceRange color_range{vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1}; + const auto barrier = [&](vk::Image image, vk::ImageLayout old_layout, + vk::ImageLayout new_layout, vk::PipelineStageFlags2 src_stage, + vk::AccessFlags2 src_access, vk::PipelineStageFlags2 dst_stage, + vk::AccessFlags2 dst_access) { + return vk::ImageMemoryBarrier2{ + .srcStageMask = src_stage, + .srcAccessMask = src_access, + .dstStageMask = dst_stage, + .dstAccessMask = dst_access, + .oldLayout = old_layout, + .newLayout = new_layout, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = image, + .subresourceRange = color_range, + }; + }; + const auto barriers = [&](std::span image_barriers) { + cmdbuf.pipelineBarrier2(vk::DependencyInfo{ + .imageMemoryBarrierCount = static_cast(image_barriers.size()), + .pImageMemoryBarriers = image_barriers.data(), + }); + }; + using Stage = vk::PipelineStageFlagBits2; + using Access = vk::AccessFlagBits2; + using Layout = vk::ImageLayout; + + // The composite and the menu layer are rewritten whole: their old contents (read by the + // previous present's encode, same queue: the all-commands source scope orders it) go. + // The frame as in the SDR path: General -> transfer source -> General. + std::array pre{ + barrier(hdr_game.image, Layout::eUndefined, Layout::eTransferDstOptimal, + Stage::eAllCommands, Access::eNone, Stage::eTransfer, Access::eTransferWrite), + barrier(frame->image, Layout::eGeneral, Layout::eTransferSrcOptimal, Stage::eAllCommands, + Access::eColorAttachmentWrite, Stage::eTransfer, Access::eTransferRead), + barrier(hdr_overlay.image, Layout::eUndefined, Layout::eTransferDstOptimal, + Stage::eAllCommands, Access::eNone, Stage::eTransfer, Access::eTransferWrite), + }; + barriers(std::span{pre.data(), overlay ? 3u : 2u}); + const vk::ClearColorValue black{std::array{0.0f, 0.0f, 0.0f, 1.0f}}; + cmdbuf.clearColorImage(hdr_game.image, Layout::eTransferDstOptimal, black, color_range); + if (overlay) { + const vk::ClearColorValue transparent{std::array{0.0f, 0.0f, 0.0f, 0.0f}}; + cmdbuf.clearColorImage(hdr_overlay.image, Layout::eTransferDstOptimal, transparent, + color_range); + } + const vk::MemoryBarrier2 clear_done{ + .srcStageMask = Stage::eTransfer, + .srcAccessMask = Access::eTransferWrite, + .dstStageMask = Stage::eTransfer, + .dstAccessMask = Access::eTransferWrite, + }; + cmdbuf.pipelineBarrier2(vk::DependencyInfo{ + .memoryBarrierCount = 1, + .pMemoryBarriers = &clear_done, + }); + cmdbuf.blitImage(frame->image, Layout::eTransferSrcOptimal, hdr_game.image, + Layout::eTransferDstOptimal, + MakeImageBlitFit(frame->width, frame->height, extent.width, extent.height), + vk::Filter::eLinear); + + std::array post{ + barrier(hdr_game.image, Layout::eTransferDstOptimal, Layout::eShaderReadOnlyOptimal, + Stage::eTransfer, Access::eTransferWrite, Stage::eFragmentShader, + Access::eShaderSampledRead), + barrier(frame->image, Layout::eTransferSrcOptimal, Layout::eGeneral, Stage::eTransfer, + Access::eTransferRead, Stage::eAllCommands, Access::eColorAttachmentWrite), + // The acquire semaphore is waited on at all commands: this transition comes after it. + barrier(swapchain_image, Layout::eUndefined, Layout::eColorAttachmentOptimal, + Stage::eColorAttachmentOutput, Access::eNone, Stage::eColorAttachmentOutput, + Access::eColorAttachmentWrite), + barrier(hdr_overlay.image, Layout::eTransferDstOptimal, Layout::eColorAttachmentOptimal, + Stage::eTransfer, Access::eTransferWrite, Stage::eColorAttachmentOutput, + Access::eColorAttachmentRead | Access::eColorAttachmentWrite), + }; + barriers(std::span{post.data(), overlay ? 4u : 3u}); + + // bbport: the settings menu / FPS counter, at display resolution, into its own layer: the + // encode shows it at paper white over the (possibly expanded) frame. + if (overlay) { + BbOverlay::Render(cmdbuf, hdr_overlay.view, extent); + const std::array menu_done{ + barrier(hdr_overlay.image, Layout::eColorAttachmentOptimal, + Layout::eShaderReadOnlyOptimal, Stage::eColorAttachmentOutput, + Access::eColorAttachmentWrite, Stage::eFragmentShader, + Access::eShaderSampledRead), + }; + barriers(menu_done); + } + + const auto& settings = BbSettings::Get(); + const bool scrgb = + swapchain.GetHdrFormat().colorSpace == vk::ColorSpaceKHR::eExtendedSrgbLinearEXT; + // bbport: native mode (hdr_mode, live): the frame's gain map when it has one (made for the + // frame this display buffer holds, vk_hdr_native.h), else plain SDR at paper white for this + // frame (title, menus, loading, movies: nothing in them is meant to be brighter). + const int mode = std::clamp(settings.hdr_mode.load(), 0, BbSettings::HdrModeCount - 1); + const bool native = mode == BbSettings::HdrModeNative && frame->hdr_native && frame->hdr_gain; + LogHdrState(scrgb, mode, native); + // BB_HDR_DEBUG=1: the gain as a heat map over a grey picture (hdr_output.frag). + static const bool debug = [] { + const char* env = std::getenv("BB_HDR_DEBUG"); + return env && env[0] == '1'; + }(); + // The gain map covers the frame, which the blit above letterboxed into this rectangle. + const vk::Rect2D fit = FitImage(s32(frame->width), s32(frame->height), s32(extent.width), + s32(extent.height)); + const HostPasses::HdrOutputPass::Settings hdr{ + .paper_white = std::clamp(settings.hdr_paper_white.load(), BbSettings::HdrPaperWhiteMin, + BbSettings::HdrPaperWhiteMax), + .peak = std::clamp(settings.hdr_peak.load(), BbSettings::HdrPeakMin, + BbSettings::HdrPeakMax), + .boost = mode == BbSettings::HdrModeNative && !native + ? 0.0f + : std::clamp(settings.hdr_boost.load(), 0.0f, 1.0f), + .flags = (scrgb ? HostPasses::HdrOutputPass::FlagScRgb : 0u) | + (overlay ? HostPasses::HdrOutputPass::FlagOverlay : 0u) | + (native ? HostPasses::HdrOutputPass::FlagNative : 0u) | + (debug ? HostPasses::HdrOutputPass::FlagDebug : 0u) | + (mode == BbSettings::HdrModeNative && !native ? HostPasses::HdrOutputPass::FlagUiFrame : 0u), + .gain_offset = {float(fit.offset.x), float(fit.offset.y)}, + .gain_scale = {1.0f / float(std::max(fit.extent.width, 1u)), + 1.0f / float(std::max(fit.extent.height, 1u))}, + .ui_white = mode == BbSettings::HdrModeNative + ? std::clamp(settings.hdr_ui_brightness.load(), BbSettings::HdrUiBrightnessMin, + BbSettings::HdrUiBrightnessMax) + : 1.0f, + .ui_vivid = mode == BbSettings::HdrModeNative ? std::clamp(settings.hdr_ui_color.load(), 0.0f, 1.0f) : 0.0f, + }; + // bbport: MaxCLL, the brightest the content gets: native mode's highlights reach the peak; + // expand mode's SDR white goes to boost's share of it. It changes with the settings, not + // per frame: a display re-mapping its tone curve on every fallback would pump. + const float expand_white = + std::max(1.0f + (hdr.peak / hdr.paper_white - 1.0f) * hdr.boost, 1.0f); + const float content_peak = mode == BbSettings::HdrModeNative + ? hdr.peak + : std::min(hdr.peak, hdr.paper_white * expand_white); + swapchain.SetHdrMetadata(hdr.peak, content_peak, hdr.paper_white); + if (native) { + // Written on the draw scheduler's queue before this submission's wait (Present). + const std::array gain_ready{ + barrier(vk::Image(frame->hdr_gain->image), Layout::eGeneral, Layout::eGeneral, + Stage::eAllCommands, Access::eMemoryWrite, Stage::eFragmentShader, + Access::eShaderSampledRead), + }; + barriers(gain_ready); + } + hdr_pass.Render(cmdbuf, hdr_game.view, overlay ? hdr_overlay.view : hdr_game.view, + native ? *frame->hdr_gain->view : hdr_game.view, + native ? Layout::eGeneral : Layout::eShaderReadOnlyOptimal, + swapchain.ImageView(), extent, hdr); + + const std::array to_present{ + barrier(swapchain_image, Layout::eColorAttachmentOptimal, Layout::ePresentSrcKHR, + Stage::eColorAttachmentOutput, Access::eColorAttachmentWrite, + Stage::eBottomOfPipe, Access::eNone), + }; + barriers(to_present); +} + +void Presenter::LogHdrState(bool scrgb, int mode, bool native_frame) { + if (mode != hdr_logged_mode) { + hdr_logged_mode = mode; + hdr_native_state = hdr_logged_native = -1; + std::printf("HDR output: %s, mode %s (%s)\n", + scrgb ? "scRGB (FP16)" : "HDR10 (PQ, BT.2020)", BbSettings::HdrModeNames[mode], + mode == BbSettings::HdrModeNative + ? "highlights from the game's HDR scene, plain SDR where a frame has none" + : "highlights expanded from the SDR picture"); + } + if (mode != BbSettings::HdrModeNative) { + return; + } + // Menus, loading screens and movies have no scene: frames come and go without a gain map. + const int state = native_frame ? 1 : 0; + if (state != hdr_native_state) { + hdr_native_state = state; + ++hdr_native_switches; + } + const auto now = std::chrono::steady_clock::now(); + if (hdr_native_state == hdr_logged_native || now < hdr_next_log) { + return; + } + std::printf("HDR native: %s", + state ? "gain maps from the game's scene" + : "no gain map (no scene: title, menu, loading, movie), SDR"); + if (hdr_native_switches > 1) { + std::printf(" (%u switches since the last line)", hdr_native_switches); + } + std::printf("\n"); + hdr_logged_native = state; + hdr_native_switches = 0; + hdr_next_log = now + std::chrono::seconds(5); } bool Presenter::IsVideoOutSurface(const AmdGpu::ColorBuffer& color_buffer) const { @@ -331,6 +605,10 @@ Frame* Presenter::PrepareFrame(const Libraries::VideoOut::BufferAttributeGroup& } Frame* frame = GetRenderFrame(); + // bbport: native HDR output: the gain map made at this display buffer's display pass (for + // the frame it holds, a frame earlier in the command stream) goes with the frame to the + // present thread; the frame's old one, presented (its fence was waited above), goes back. + frame->hdr_native = rasterizer->GetHdrNative().TakeGainMap(cpu_address, frame->hdr_gain); const auto frame_subresources = vk::ImageSubresourceRange{ .aspectMask = vk::ImageAspectFlagBits::eColor, @@ -475,6 +753,7 @@ Frame* Presenter::PrepareFrame(const Libraries::VideoOut::BufferAttributeGroup& Frame* Presenter::PrepareBlankFrame(bool present_thread) { // Request a free presentation frame. Frame* frame = GetRenderFrame(); + frame->hdr_native = false; // bbport: no game frame, no gain map auto& scheduler = present_thread ? present_scheduler : draw_scheduler; scheduler.EndRendering(); @@ -601,7 +880,19 @@ void Presenter::Present(Frame* frame, bool is_reusing_frame, bool is_game_frame) }); } - { + // bbport: HDR output (hdr in bbport.ini) while the swapchain has the HDR format. + const bool hdr_output = swapchain.IsHdrOutput() && hdr_pass.IsCreated(); + BbSettings::Get().hdr_output.store( + !hdr_output ? BbSettings::HdrOutputOff + : swapchain.GetHdrFormat().colorSpace == vk::ColorSpaceKHR::eExtendedSrgbLinearEXT + ? BbSettings::HdrOutputScRgb + : BbSettings::HdrOutputHdr10, + std::memory_order_relaxed); + if (hdr_output) { + const vk::Extent2D extent = swapchain.GetExtent(); + SetExpectedGameSize(s32(extent.width), s32(extent.height)); + PresentHdr(cmdbuf, frame, extent); + } else { const vk::Extent2D extent = swapchain.GetExtent(); SetExpectedGameSize(s32(extent.width), s32(extent.height)); const vk::ImageSubresourceRange color_range{ @@ -714,11 +1005,14 @@ void Presenter::Present(Frame* frame, bool is_reusing_frame, bool is_game_frame) scheduler.Flush(info); // Present to swapchain. + bool presented; { std::scoped_lock submit_lock{Scheduler::submit_mutex}; - if (!swapchain.Present()) { - swapchain.Recreate(window.GetWidth(), window.GetHeight()); - } + presented = swapchain.Present(); + } + // bbport: recreated after the lock: its device wait takes it (Swapchain::WaitIdle). + if (!presented) { + swapchain.Recreate(window.GetWidth(), window.GetHeight()); } free_frame(); diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_presenter.h b/gpu/shadps4/video_core/renderer_vulkan/vk_presenter.h index 0b3dc78..f090799 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_presenter.h +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_presenter.h @@ -5,12 +5,16 @@ #include +#include #include +#include #include #include "core/libraries/videoout/buffer.h" #include "video_core/renderer_vulkan/host_passes/fsr_pass.h" +#include "video_core/renderer_vulkan/host_passes/hdr_pass.h" #include "video_core/renderer_vulkan/host_passes/pp_pass.h" +#include "video_core/renderer_vulkan/vk_hdr_native.h" #include "video_core/renderer_vulkan/vk_instance.h" #include "video_core/renderer_vulkan/vk_runtime.h" #include "video_core/renderer_vulkan/vk_scheduler.h" @@ -38,7 +42,11 @@ struct Frame { u64 ready_tick; bool is_hdr{false}; u8 id{}; - + /// bbport: native HDR output (vk_hdr_native.h): a gain map, exchanged in PrepareFrame for + /// the one made at the display buffer's display pass; `hdr_native` while it belongs to the + /// frame shown (else the HDR encode falls back to expand). + std::unique_ptr hdr_gain; + bool hdr_native{false}; }; enum SchedulerType { @@ -74,6 +82,8 @@ public: return swapchain.HasHDR(); } + /// The game's own HDR request (VideoOut, every flip). bbport: off (Swapchain::HasHDR is + /// false), so it leaves the HDR output (hdr in bbport.ini, PresentHdr) and pp_settings alone. void SetHDR(bool enable) { if (!IsHDRSupported()) { return; @@ -109,6 +119,23 @@ private: void SetExpectedGameSize(s32 width, s32 height); + /// bbport: HDR output. A swapchain-sized image of the SDR format (the frame letterboxed into + /// it, or the menu layer drawn into it), sampled by the HDR encode pass. + struct HdrTarget { + vk::Image image{}; + VmaAllocation allocation{}; + vk::ImageView view{}; + }; + void CreateHdrTarget(HdrTarget& target, vk::Extent2D extent, const char* name); + void DestroyHdrTarget(const HdrTarget& target); + /// The targets at the swapchain's size; the old ones go once the GPU is past them. + void EnsureHdrTargets(vk::Extent2D extent); + /// Records the frame (letterboxed), the menu and the HDR encode into the swapchain image. + void PresentHdr(vk::CommandBuffer cmdbuf, Frame* frame, vk::Extent2D extent); + /// One line when the HDR output's mode starts or changes; in native mode one when frames + /// stop or start having a gain map (at most every 5 s, with the switches in between). + void LogHdrState(bool scrgb, int mode, bool native_frame); + private: float expected_ratio{1920.0 / 1080.0f}; u32 expected_frame_width{1920}; @@ -120,6 +147,14 @@ private: HostPasses::FsrPass::Settings fsr_settings{}; HostPasses::PostProcessingPass::Settings pp_settings{}; HostPasses::PostProcessingPass pp_pass; + HostPasses::HdrOutputPass hdr_pass; ///< bbport: HDR output encode (present thread) + HdrTarget hdr_game{}, hdr_overlay{}; ///< bbport: HDR output's SDR composite and menu layer + vk::Extent2D hdr_extent{}; + int hdr_logged_mode = -1; ///< bbport: LogHdrState (present thread) + int hdr_native_state = -1; ///< native mode: the last frame had a gain map (1) or not (0) + int hdr_logged_native = -1; ///< ... as last logged + u32 hdr_native_switches = 0; + std::chrono::steady_clock::time_point hdr_next_log{}; std::mutex passes_mutex; ///< bbport: the passes are recorded on the recording threads AmdGpu::Liverpool* liverpool; Scheduler draw_scheduler; diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_rasterizer.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_rasterizer.cpp index 5fd360b..4484a75 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_rasterizer.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_rasterizer.cpp @@ -5,7 +5,9 @@ #include #endif #include "bbport_platform.h" +#include #include +#include #include #include #include "video_core/renderer_vulkan/ui_composition.h" @@ -29,7 +31,9 @@ #include "video_core/buffer_cache/buffer.h" #include "video_core/buffer_cache/buffer_cache.h" #include "video_core/renderer_vulkan/liverpool_to_vk.h" +#include "video_core/renderer_vulkan/pipeline_compile_queue.h" #include "video_core/renderer_vulkan/vk_instance.h" +#include "video_core/renderer_vulkan/vk_occlusion.h" #include "video_core/renderer_vulkan/vk_pipeline_cache.h" #include "video_core/renderer_vulkan/vk_rasterizer.h" #include "video_core/renderer_vulkan/vk_gpu_profiler.h" @@ -71,8 +75,10 @@ Rasterizer::Rasterizer(const Instance& instance_, Scheduler& scheduler_, Runtime pipeline_cache.SetObjectMotionEnabled(object_motion->Enabled()); camera_motion = std::make_unique(instance, scheduler, texture_cache, runtime); camera_motion->SetObjectMotion(object_motion.get()); + // bbport: native HDR output's passes run at points of the frame the upscaler finds. + hdr_native = std::make_unique(instance, scheduler, texture_cache, runtime); upscaler = std::make_unique(instance, scheduler, texture_cache, runtime, - *camera_motion, *scene_targets); + *camera_motion, *scene_targets, *hdr_native); if (!EmulatorSettings.IsNullGPU()) { liverpool->BindRasterizer(this); } @@ -80,10 +86,26 @@ Rasterizer::Rasterizer(const Instance& instance_, Scheduler& scheduler_, Runtime memory->SetRasterizer(this); GpuProfiler::Init(instance, scheduler); + occlusion = Occlusion::Create(instance, scheduler, *this); if (DrawPipeWanted()) { constant_ring = std::make_unique(instance, scheduler); draw_pipe = std::make_unique(&RunDrawPacket, this); + // bbport: the lookahead thread (docs/parallel-stageb.md), off unless asked for. + if (LookaheadWanted()) { + lookahead_thread = std::jthread([this](std::stop_token stop) { LookaheadLoop(stop); }); + std::printf("Draw pipe: lookahead thread prefetching for stage B, %u packets ahead " + "(BB_DRAWREC_WORKERS=1, BB_DRAWREC_LOOKAHEAD, BB_DRAWREC_IDLE)\n", + LookaheadWindow()); + } } + // bbport (docs/pipeline_compiles.md): which of the waits for compiles, drains and the texture + // cache's lock run, once at start. + LogPipelineCompileMode(instance, scheduler.IsRecordingDeferred()); + std::printf("Waits: GPU command thread drains %s; write faults on pages no image watches %s " + "(BB_DRAIN_SLEEP, BB_FAULT_SKIP_TEXTURES)\n", + DrawPipe::SleepEnabled() ? "sleep after a spin (stage B wakes them)" : "spin", + FaultSkipsTextureCache() ? "leave the texture cache alone" + : "take the texture cache's lock"); // bbport: this thread joins the texture binding helper before it changes image state. runtime.SetImageAccessHook(&JoinBindHelper, this); scheduler.SetSubmitCallback([this](Vulkan::SubmitInfo& info) { @@ -180,7 +202,21 @@ void Rasterizer::RequestSignalFlush() { self.Flush(); } }, - &none, 0, BbToggle::PipelinedTasks, false); + &none, 0, BbToggle::PipelinedTasks, false, "SignalFlush"); +} + +void Rasterizer::NoteFenceBarrier() { + if (BbStats::enabled) { + BbStats::fence_barriers.fetch_add(1, std::memory_order_relaxed); + } + // Recorded whenever BB_FENCE_ORDER=ordered may be in effect (also while experiment bit 8 is + // off: switched on later, the barriers before are known). + if (!Scheduler::FenceOrderPossible()) { + return; + } + static constexpr u8 none = 0; + RunInOrder([](Rasterizer& self, const u8*) { self.scheduler.NoteSignalBarrier(); }, &none, + 0, BbToggle::PipelinedTasks, false, "FenceBarrier"); } bool Rasterizer::SignalsPublished(u64 position) const { @@ -246,7 +282,7 @@ void Rasterizer::SubmitForSignals() { // fences: here they may not even be recorded yet. static constexpr u8 none = 0; RunInOrder([](Rasterizer& self, const u8*) { self.SubmitForSignalsNow(); }, &none, 0, - BbToggle::PipelinedTasks, false); + BbToggle::PipelinedTasks, false, "SubmitForSignals"); return; } SubmitForSignalsNow(); @@ -462,6 +498,19 @@ bool Rasterizer::FilterDraw() { return true; } +template +bool Rasterizer::RevalidateTarget(Memo& memo, u64 generation) { + // Images were (un)registered since: an exact match still registered is what FindImage + // would find again (TextureCache::IsExactMatch). ~0: invalidated, never revalidated. + if (!memo.exact || memo.generation == ~0ULL || !MemoRevalidation() || + !texture_cache.IsRegisteredImage(memo.image_id, memo.uid)) { + return false; + } + memo.generation = generation; + ++target_lookups_revalidated; + return true; +} + template VideoCore::ImageId Rasterizer::FindTargetMemoized(VideoCore::TextureCache::ImageDesc& desc, LastTarget& last, auto&& make_desc, @@ -476,16 +525,19 @@ VideoCore::ImageId Rasterizer::FindTargetMemoized(VideoCore::TextureCache::Image (append(parts), ...); const u64 generation = texture_cache.RegistryGeneration(); const bool memo_enabled = !BbToggle::Disabled(BbToggle::TextureBindingMemo); - if (memo_enabled && last.generation == generation && last.key_size == key_size && - std::memcmp(last.key.data(), key.data(), key_size) == 0) { + if (memo_enabled && last.key_size == key_size && + std::memcmp(last.key.data(), key.data(), key_size) == 0 && + (last.generation == generation || RevalidateTarget(last, generation))) { texture_cache.MarkFound(last.image_id); return last.image_id; } - const auto remember = [&](VideoCore::ImageId image_id) { + const auto remember = [&](VideoCore::ImageId image_id, bool exact, u64 uid) { last.key = key; last.key_size = key_size; last.generation = generation; last.image_id = image_id; + last.exact = exact; + last.uid = uid; }; u64 hash = 0xCBF29CE484222325ull; for (u32 i = 0; i < key_size; i += 8) { @@ -495,22 +547,30 @@ VideoCore::ImageId Rasterizer::FindTargetMemoized(VideoCore::TextureCache::Image hash ^= hash >> 29; } auto& memo = target_memo[hash % target_memo.size()]; - if (memo.generation == generation && memo.key_size == key_size && - std::memcmp(memo.key.data(), key.data(), key_size) == 0 && memo_enabled) { + if (memo_enabled && memo.key_size == key_size && + std::memcmp(memo.key.data(), key.data(), key_size) == 0 && + (memo.generation == generation || RevalidateTarget(memo, generation))) { desc = memo.desc; texture_cache.MarkFound(memo.image_id); - remember(memo.image_id); + remember(memo.image_id, memo.exact, memo.uid); return memo.image_id; } make_desc(); + const auto range = desc.view_info.range; const VideoCore::ImageId image_id = texture_cache.FindImage(desc); if (generation == texture_cache.RegistryGeneration()) { + // FindImage moved no view into another image's mips or layers, and found the + // registered image of exactly this description. + const bool exact = + desc.view_info.range == range && texture_cache.IsExactMatch(image_id, desc.info); memo.key = key; memo.key_size = key_size; memo.generation = generation; memo.image_id = image_id; + memo.exact = exact; + memo.uid = texture_cache.ImageUid(image_id); memo.desc = desc; - remember(image_id); + remember(image_id, exact, memo.uid); } else { last.generation = ~0ULL; } @@ -821,7 +881,8 @@ void Rasterizer::NoteCommandWriteInOrder(VAddr addr, const void* data, u64 size) std::memcpy(&write, bytes, sizeof(write)); self.NoteCommandWrite(write.addr, bytes + sizeof(write), write.size, true); }, - payload.data(), static_cast(payload.size()), BbToggle::PipelinedTasks, false); + payload.data(), static_cast(payload.size()), BbToggle::PipelinedTasks, false, + "CommandWrite"); } bool Rasterizer::PendingWriteOverlaps(VAddr address, u64 size) { @@ -1097,8 +1158,42 @@ void Rasterizer::RetireSubmission() { } } +namespace { +// BB_SECTIONS: ordered tasks by function (cycles, count), stage B only. +constexpr std::size_t TaskKinds = 16; +std::array task_kinds{}; +std::array task_cycles{}, task_counts{}; +/// bbport: the names RunInOrder's callers give their tasks (written by stage A, read with the +/// statistics: racy, diagnostics only). +std::array, 2 * TaskKinds> task_names{}; + +void NoteTaskName(void* task, const char* name) { + for (auto& entry : task_names) { + if (entry.first == task) { + return; + } + if (!entry.first) { + entry = {task, name}; + return; + } + } +} + +const char* TaskName(void* task) { + for (const auto& entry : task_names) { + if (entry.first == task) { + return entry.second; + } + } + return nullptr; +} +} // namespace + bool Rasterizer::RunInOrder(OrderedTask task, const void* data, u32 size, u64 toggle, - bool reads_guest_memory) { + bool reads_guest_memory, const char* name) { + if (name && BbSections::Enabled()) { + NoteTaskName(reinterpret_cast(task), name); + } if (!UseDrawPipe() || BbToggle::Disabled(toggle)) { DrainDrawPipe(DrawPipe::ReasonRasterizer); task(*this, static_cast(data)); @@ -1115,12 +1210,17 @@ bool Rasterizer::RunInOrder(OrderedTask task, const void* data, u32 size, u64 to return true; } -namespace { -// BB_SECTIONS: ordered tasks by function (cycles, count), stage B only. -constexpr std::size_t TaskKinds = 16; -std::array task_kinds{}; -std::array task_cycles{}, task_counts{}; -} // namespace +bool Rasterizer::NextPacketIsTask(OrderedTask task) const { + if (!draw_pipe || !DrawPipe::OnStageB()) { + return false; + } + const u8* next = draw_pipe->PeekNext(); + if (!next) { + return false; + } + const auto& packet = *reinterpret_cast(next); + return packet.kind == PacketKind::Task && packet.task == task; +} void Rasterizer::RunDrawPacket(void* context, const u8* data, u32 size) { BB_SECTION(Packet); @@ -1275,6 +1375,228 @@ void Rasterizer::RunDrawPacket(void* context, const u8* data, u32 size) { } } +// bbport BB_DRAWREC_WORKERS (docs/parallel-stageb.md): the lookahead thread. Stage B's per-packet +// time is spread over cache misses on the structures a draw consults first (the texture set memo +// slot and its images, the page tracking words and the arena table of each buffer range). This +// thread walks the committed packets a few ahead of stage B and issues prefetches for exactly +// those lines, so that stage B finds them in the shared L3 (or in this core's cache) instead of +// memory. It changes no state and reads only: the packets (committed, below Published, not yet +// overwritten by stage A), their prepared draws (kept alive until stage B passes the packet) and +// the shader infos; every other address is computed and prefetched, never read. A packet stage B +// already passed may point at freed prepared data: that read is covered by the loader's fault +// recovery (BB_RECOVER_SET) and counted. +bool Rasterizer::LookaheadWanted() { + static const bool wanted = [] { + // bbport: on by default on CPUs with 8 or more hardware threads (it takes a core of its + // own while stage B is busy); BB_DRAWREC_WORKERS=0 turns it off, =1 forces it on. + const char* env = std::getenv("BB_DRAWREC_WORKERS"); + if (env && env[0]) { + return env[0] != '0'; + } + return std::thread::hardware_concurrency() >= 8; + }(); + return wanted; +} + +u32 Rasterizer::LookaheadWindow() { + static const u32 window = [] { + const char* env = std::getenv("BB_DRAWREC_LOOKAHEAD"); + const long value = env && env[0] ? std::strtol(env, nullptr, 10) : 8; + return static_cast(std::clamp(value, 1, 256)); + }(); + return window; +} + +void Rasterizer::LookaheadLoop(std::stop_token stop) { + Common::SetCurrentThreadName("bb:DrawAhead"); + if (const char* env = std::getenv("BB_DRAWREC_IDLE"); env && env[0] == '1') { + BbThreads::MakeBackground(); // only idle cores; starved on a busy CPU + } + const u32 window = LookaheadWindow(); + u64 at = 0; // ring position of the next packet to prefetch for + u64 number = 0; // its number in stage B's count (ConsumedPackets) + bool synced = false; + while (!stop.stop_requested()) { + if (BbToggle::Experiment(16)) { + // Paused for an A/B while the game runs: it follows stage B again when resumed. + synced = false; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + continue; + } + const u64 consumed = draw_pipe->Consumed(); + if (!synced || at < consumed) { + if (synced) { + BbStats::lookahead_behind.fetch_add(1, std::memory_order_relaxed); + } + at = consumed; + number = draw_pipe->ConsumedPackets(); + synced = true; + } + if (number > draw_pipe->ConsumedPackets() + window) { + // Far enough ahead: lines fetched now would be gone before stage B gets there. + BbStats::lookahead_waits.fetch_add(1, std::memory_order_relaxed); + const auto until = std::chrono::steady_clock::now() + std::chrono::microseconds(100); + for (u32 spins = 1; number > draw_pipe->ConsumedPackets() + window; ++spins) { + if (stop.stop_requested()) { + return; + } + __builtin_ia32_pause(); + if (!(spins & 255) && std::chrono::steady_clock::now() >= until) { + std::this_thread::yield(); + } + } + continue; + } + if (at >= draw_pipe->Published()) { + draw_pipe->WaitForCommit(at, stop); + continue; + } + u32 payload = 0; + u64 next = at; + if (const u8* data = draw_pipe->PacketAt(at, payload, next)) { + const u64 start = __rdtsc(); + const bool draw = PrefetchPacket(data, payload); + BbStats::lookahead_busy_cycles.fetch_add(__rdtsc() - start, std::memory_order_relaxed); + BbStats::lookahead_packets.fetch_add(1, std::memory_order_relaxed); + if (draw) { + BbStats::lookahead_draws.fetch_add(1, std::memory_order_relaxed); + const u64 run = draw_pipe->ConsumedPackets(); + BbStats::lookahead_lead.fetch_add(number > run ? number - run : 0, + std::memory_order_relaxed); + } + ++number; + } + at = next; + } +} + +bool Rasterizer::PrefetchPacket(const u8* data, u32 size) { + if (*reinterpret_cast(data) != PacketKind::Draw) { + return false; // tasks, dispatches, indirect and special draws bind without prepared sharps + } + // A packet stage B passed meanwhile may point at a retired submission's prepared draws: + // a fault there only ends this packet's prefetch. + BbRecoverBuf recover; + if (BB_RECOVER_SET(recover)) { + runtime_fault_recover = nullptr; + BbStats::lookahead_recovered.fetch_add(1, std::memory_order_relaxed); + return false; + } + runtime_fault_recover = &recover; + const u32 lines = PrefetchDraw(data, size); + runtime_fault_recover = nullptr; + BbStats::lookahead_lines.fetch_add(lines, std::memory_order_relaxed); + return lines != 0; +} + +u32 Rasterizer::PrefetchDraw(const u8* data, u32 size) { + const auto& packet = *reinterpret_cast(data); + if (!packet.prepared || !packet.pipeline || packet.num_stages > Shader::Info::MaxUdSnapshots) { + return 0; + } + u32 lines = 0; + const auto touch = [&lines](const void* address, std::size_t bytes) { + const auto* p = static_cast(address); + for (std::size_t offset = 0; offset < bytes; offset += 64) { + __builtin_prefetch(p + offset); + ++lines; + } + }; + // The layout of PostDraw: register blocks, then per stage its header, ring bindings and + // user data, then (a direct draw) the vertex stream V#s. + const u8* in = data + sizeof(DrawPacket); + in += AlignPacket(packet.num_blocks * sizeof(u16)); + in += packet.num_blocks * AmdGpu::RegDirty::BlockWords * sizeof(u32); + const PreparedDraw& prepared = *packet.prepared; + for (u32 s = 0; s < packet.num_stages; ++s) { + if (in + sizeof(PacketStage) > data + size) { + return lines; + } + const auto& stage = *reinterpret_cast(in); + in += sizeof(PacketStage); + const auto* rings = reinterpret_cast(in); + in += stage.num_ring * sizeof(RingBinding); + in += AlignPacket((stage.ud_size + stage.flat_size) * sizeof(u32)); + if (!stage.info) { + continue; + } + const Shader::Info& info = *stage.info; + const PreparedStage* ps = FindPreparedStage(&prepared, info); + if (!ps) { + continue; + } + // Textures: the memoized set's slot (BindTexturesFromSet), then its images; a slot with + // another key means the full lookup, which starts at the description cache's two ways. + const u32 count = static_cast(info.images.size()); + if (count != 0 && count <= TextureSet::MaxImages && ps->image_hashes) { + bool plain = true; + for (const auto& image : info.images) { + plain &= !image.is_written && + image.mip_fallback_mode == Shader::MipStorageFallbackMode::None; + } + if (plain) { + const bool point_native = scene_targets->Reduced() && PointReadsNative(info, ps); + const u64 key = XXH3_64bits_withSeed(ps->image_hashes, count * sizeof(u64), + reinterpret_cast(&info) ^ + (point_native ? 2 : 0)) | + 1; + const TextureSet& set = texture_sets[key % texture_sets.size()]; + touch(&set, static_cast( + reinterpret_cast(set.entries.data()) - + reinterpret_cast(&set))); + if (__atomic_load_n(&set.key, __ATOMIC_RELAXED) == key) { + touch(set.entries.data(), count * sizeof(TextureSetEntry)); + for (u32 i = 0; i < count; ++i) { + VideoCore::ImageId id{__atomic_load_n(&set.entries[i].id.index, + __ATOMIC_RELAXED)}; + if (id) { + if (const void* image = texture_cache.ImageSlotAddress(id)) { + touch(image, 192); // flags, binding, backing, ticks + } + } + } + } else { + for (u32 i = 0; i < count; ++i) { + const u64 hash = ps->image_hashes[i]; + const auto* ways = &image_desc_cache[(hash % (image_desc_cache.size() / 2)) * 2]; + touch(ways, 64); + touch(ways + 1, 64); + } + } + } + } + // Buffers bound through the cache (BindBuffers): the arena table and the page state. + if (ps->buffer_sharps) { + for (u32 i = 0; i < info.buffers.size() && i < ps->num_buffers; ++i) { + if (info.buffers[i].IsSpecial()) { + continue; + } + bool ring_bound = false; + for (u32 r = 0; r < stage.num_ring; ++r) { + ring_bound |= rings[r].index == i; + } + if (ring_bound) { + continue; // copied into the constant ring by stage A: no cache lookup + } + const auto& vsharp = ps->buffer_sharps[i]; + if (vsharp.base_address != 0 && vsharp.GetSize() != 0) { + lines += buffer_cache.PrefetchBinding(vsharp.base_address, vsharp.GetSize()); + } + } + } + } + // The vertex streams (ResolveVertexBuffers obtains their merged ranges). + if (in + packet.num_vsharps * sizeof(AmdGpu::Buffer) <= data + size) { + const auto* vsharps = reinterpret_cast(in); + for (u32 i = 0; i < packet.num_vsharps; ++i) { + if (vsharps[i].base_address != 0 && vsharps[i].GetSize() != 0) { + lines += buffer_cache.PrefetchBinding(vsharps[i].base_address, vsharps[i].GetSize()); + } + } + } + return lines; +} + void Rasterizer::PrintPipeStats() { static const bool stats = std::getenv("BB_FRAME_STATS") != nullptr; if (!stats || (draw_pipe->packets & 1023) != 0) { @@ -1291,12 +1613,15 @@ void Rasterizer::PrintPipeStats() { const u64 tsc = __rdtsc(); const double cycles = double(tsc - last_tsc); const u64 busy = draw_pipe->busy_cycles.load(std::memory_order_relaxed); - std::printf("Draw pipe: %.0f draws/s pipelined, %.0f drains/s that waited, stage A waited " - "%.1f%%, stage B busy %.1f%%\n", + static u64 last_sleeps = 0; + std::printf("Draw pipe: %.0f draws/s pipelined, %.0f drains/s that waited (%.1f/s slept), " + "stage A waited %.1f%%, stage B busy %.1f%%\n", (draw_pipe->packets - last_packets) / seconds, (draw_pipe->drains - last_drains) / seconds, + (draw_pipe->sleeps - last_sleeps) / seconds, 100.0 * (draw_pipe->drain_cycles - last_drain_cycles) / cycles, 100.0 * (busy - last_busy) / cycles); + last_sleeps = draw_pipe->sleeps; std::printf(" drains by reason/s:"); for (u32 r = 0; r < DrawPipe::NumReasons; ++r) { if (const u64 n = draw_pipe->drains_by_reason[r]) { @@ -1330,20 +1655,36 @@ void Rasterizer::PrintPipeStats() { } last_sections[s] = now_cycles; } + // bbport: the slow paths by shader (program hash): total ms in this window, calls, per call. + const auto print_top = [&](const char* title, BbSections::ByKey& table) { + std::printf("\n %s:", title); + table.Top(8, [&](const BbSections::ByKey::Entry& e) { + const double ms = 1e3 * double(e.cycles) / tsc_hz; + std::printf(" [%016llx %.1f ms %llu x %.1f us]", + static_cast(e.key & ~1ull), ms, + static_cast(e.calls), 1e3 * ms / double(e.calls)); + }); + }; + print_top("full texture lookups by shader", BbSections::tex_full_by_shader); + print_top("dispatches by shader", BbSections::dispatch_by_shader); // Racy reads of stage B's counters: diagnostics only. std::printf("\n stage B tasks (us per frame-second, count/s):"); static std::array last_task_cycles{}, last_task_counts{}; for (std::size_t k = 0; k < TaskKinds && task_kinds[k]; ++k) { char name[96]; + if (const char* known = TaskName(task_kinds[k])) { + std::snprintf(name, sizeof(name), "%s", known); + } else { #ifdef _WIN32 - BbPlatform::DescribeAddress(task_kinds[k], name, sizeof(name)); + BbPlatform::DescribeAddress(task_kinds[k], name, sizeof(name)); #else - Dl_info info{}; - dladdr(task_kinds[k], &info); - std::snprintf(name, sizeof(name), "+0x%llx", - (unsigned long long)(reinterpret_cast(task_kinds[k]) - - reinterpret_cast(info.dli_fbase))); + Dl_info info{}; + dladdr(task_kinds[k], &info); + std::snprintf(name, sizeof(name), "+0x%llx", + (unsigned long long)(reinterpret_cast(task_kinds[k]) - + reinterpret_cast(info.dli_fbase))); #endif + } const u64 c = task_cycles[k], n = task_counts[k]; std::printf(" [%s %.0f us/s %.0f/s]", name, 1e6 * double(c - last_task_cycles[k]) / tsc_hz / seconds, @@ -1352,6 +1693,8 @@ void Rasterizer::PrintPipeStats() { last_task_counts[k] = n; } } + std::printf("\n fences handed over with the next one: %.0f/s", + double(merged_fence_kicks.exchange(0, std::memory_order_relaxed)) / seconds); std::printf("\n stage A waits over 0.5%%:"); for (const auto& [function, lines] : drain_sites) { for (const auto& [line, waited] : lines) { @@ -1361,6 +1704,24 @@ void Rasterizer::PrintPipeStats() { } } std::printf("\n"); + if (lookahead_thread.joinable()) { + // bbport: the lookahead thread (BB_DRAWREC_WORKERS); counters since the last window. + const auto take = [](std::atomic& counter) { + return double(counter.exchange(0, std::memory_order_relaxed)); + }; + const double walked = take(BbStats::lookahead_packets); + const double draws = take(BbStats::lookahead_draws); + const double lines = take(BbStats::lookahead_lines); + const double lead = take(BbStats::lookahead_lead); + const double busy = take(BbStats::lookahead_busy_cycles); + std::printf(" lookahead: %.0f packets/s walked, %.0f draws/s prefetched, %.0f lines/draw, " + "%.1f packets ahead, behind stage B %.1f/s, waited for it %.0f/s, busy %.1f%%, " + "%.0f faults recovered\n", + walked / seconds, draws / seconds, draws > 0 ? lines / draws : 0.0, + draws > 0 ? lead / draws : 0.0, take(BbStats::lookahead_behind) / seconds, + take(BbStats::lookahead_waits) / seconds, 100.0 * busy / cycles, + take(BbStats::lookahead_recovered)); + } drain_sites.clear(); draw_pipe->drains_by_reason = {}; window = now; @@ -1396,6 +1757,9 @@ bool Rasterizer::FilterDrawPasses() const { void Rasterizer::Draw(bool is_indexed, u32 index_offset, const PreparedDraw* prepared) { RENDERER_TRACE; BbStats::draws.fetch_add(1, std::memory_order_relaxed); + if (occlusion) { + occlusion->NoteIssued(); // before anything may drop the draw (vk_occlusion.h) + } // bbport debugging: BB_IMAGE_DUMP_TRIGGER= holding hex guest addresses, one per line; // the images registered there are written to BB_CAPTURE_DIR (TextureCache::DumpImagesAt). if (static const char* trigger = std::getenv("BB_IMAGE_DUMP_TRIGGER"); trigger) { @@ -1452,6 +1816,10 @@ void Rasterizer::Draw(bool is_indexed, u32 index_offset, const PreparedDraw* pre DrawRecord(pipeline, used_prepared, is_indexed, index_offset); } +// BB_PROXY_TRACE (defined with BindTextures). +static bool ProxyTraceOn(); +static void FlushProxyReads(bool reduced, const VideoCore::Image* target); + void Rasterizer::DrawRecord(const GraphicsPipeline* pipeline, const PreparedDraw* used_prepared, bool is_indexed, u32 index_offset) { BB_SECTION(DrawRecord); @@ -1460,6 +1828,10 @@ void Rasterizer::DrawRecord(const GraphicsPipeline* pipeline, const PreparedDraw FrameCapture::Poll(); scheduler.PopPendingOperations(); } + // bbport: under SET_PREDICATION on a block known to read occluded (vk_occlusion.h). + if (occlusion && occlusion->SkipDraw()) { + return; + } // bbport (BB_PREUPLOAD): once a frame, guest GPU memory the CPU wrote and then left alone is // uploaded ahead of its use, BB_PREUPLOAD_MB (16) at most per frame. if (const u32 frame = BbStats::frame_number.load(std::memory_order_relaxed); @@ -1478,6 +1850,10 @@ void Rasterizer::DrawRecord(const GraphicsPipeline* pipeline, const PreparedDraw if (camera_motion->Enabled() && regs.color_buffers[0] && FrameCapture::IsDisplayBuffer(regs.color_buffers[0].Address())) { scene_started = false; + // bbport: native HDR output: the gain map of the frame this pass copies, for this + // display buffer (before OnFrameStart forgets the frame's UI phase). + upscaler->OnDisplayPass(regs.color_buffers[0].Address(), cb_descs[0].first); + previous_scene_depth = camera_motion->Depth(); // the last G-buffer pass's (bbport) camera_motion->OnDisplayPass(cb_descs[0].first); if (upscaler->OnFrameStart()) { object_motion->InvalidateHistory(); @@ -1508,6 +1884,11 @@ void Rasterizer::DrawRecord(const GraphicsPipeline* pipeline, const PreparedDraw return; } const auto state = BeginRendering(pipeline); + if (ProxyTraceOn()) { + const auto target = cb_descs[0].first ? cb_descs[0].first : db_desc.first; + FlushProxyReads(push_data.scene_size != 0, + target ? &texture_cache.GetImage(target) : nullptr); + } if (!inputs_resolved) { ResolveVertexBuffers(pipeline, draw_prepared); @@ -1632,7 +2013,20 @@ void Rasterizer::DrawRecord(const GraphicsPipeline* pipeline, const PreparedDraw const auto& fetch_shader = pipeline->GetFetchShader(); const auto [vertex_offset, instance_offset] = GetDrawOffsets(regs, vs_info, fetch_shader); - const vk::Pipeline handle = pipeline->Handle(); + // bbport (docs/pipeline_compiles.md): a pipeline still compiling does not hold this thread + // (nor the GPU command thread and the guest threads that drain behind it): the draw is left + // out (scene geometry) or a recording thread binds the pipeline once it is there. + // Everything above ran as for a draw (targets, barriers, render pass): only the draw itself + // is missing for those frames. + vk::Pipeline handle = pipeline->HandleIfReady(); + if (!handle) [[unlikely]] { + if (SkipUncompiledDraw(pipeline)) { + ResetBindings(false); + scheduler.KickRecording(); + return; + } + handle = BindWhenCompiled(pipeline, vk::PipelineBindPoint::eGraphics); + } const u32 num_indices = regs.num_indices; const u32 num_instances = regs.num_instances.NumInstances(); const u32 first_vertex = vertex_offset; @@ -1644,13 +2038,25 @@ void Rasterizer::DrawRecord(const GraphicsPipeline* pipeline, const PreparedDraw .program = {vs_info.ProgramBase(), ps_info ? ps_info->ProgramBase() : 0}, .count = {num_indices, num_instances, 0}, }; + // bbport: after a ZPASS dump the draw is measured by an occlusion query around just this + // vkCmdDraw (inside the open render pass; vk_occlusion.h). + const auto query = occlusion ? occlusion->BeginDraw(push_data.scene_size) + : Occlusion::DrawQuery{}; scheduler.RecordCrumb(crumb, [=](vk::CommandBuffer cmdbuf) { - cmdbuf.bindPipeline(vk::PipelineBindPoint::eGraphics, handle); + if (handle) { // null: bound by the command before (BindWhenCompiled) + cmdbuf.bindPipeline(vk::PipelineBindPoint::eGraphics, handle); + } + if (query.pool) { + cmdbuf.beginQuery(query.pool, query.index, query.flags); + } if (is_indexed) { cmdbuf.drawIndexed(num_indices, num_instances, 0, s32(first_vertex), first_instance); } else { cmdbuf.draw(num_indices, num_instances, first_vertex, first_instance); } + if (query.pool) { + cmdbuf.endQuery(query.pool, query.index); + } }); if (FrameCapture::Active()) { const auto* ps = pipeline->GetStages()[u32(Shader::SwStage::Fragment)]; @@ -1676,6 +2082,9 @@ void Rasterizer::DrawIndirect(bool is_indexed, VAddr arg_address, u32 offset, u3 u32 max_count, VAddr count_address, u16 vertex_sgpr_offset, u16 instance_sgpr_offset) { RENDERER_TRACE; + if (occlusion) { + occlusion->NoteIssued(); + } // bbport: like direct draws, handed to the recording thread after the pipeline selection // (else the GPU thread waited here for a whole frame of queued draws). const bool pipelined = UseDrawPipe() && FilterDrawPasses() && @@ -1711,6 +2120,9 @@ void Rasterizer::DrawIndirectRecord(const GraphicsPipeline* pipeline, bool is_in FrameCapture::Poll(); scheduler.PopPendingOperations(); } + if (occlusion && occlusion->SkipDraw()) { + return; + } const VAddr count_address = indirect.count; const u32 stride = indirect.stride; const u32 max_count = indirect.max_count; @@ -1724,6 +2136,11 @@ void Rasterizer::DrawIndirectRecord(const GraphicsPipeline* pipeline, bool is_in return; } const auto state = BeginRendering(pipeline); + if (ProxyTraceOn()) { + const auto target = cb_descs[0].first ? cb_descs[0].first : db_desc.first; + FlushProxyReads(push_data.scene_size != 0, + target ? &texture_cache.GetImage(target) : nullptr); + } BindVertexBuffers(pipeline); if (is_indexed) { @@ -1765,7 +2182,11 @@ void Rasterizer::DrawIndirectRecord(const GraphicsPipeline* pipeline, bool is_in ASSERT(stride == (is_indexed ? sizeof(VkDrawIndexedIndirectCommand) : sizeof(VkDrawIndirectCommand))); - const vk::Pipeline handle = pipeline->Handle(); + // bbport: never left out (what it draws is decided in GPU memory), bound late instead. + vk::Pipeline handle = pipeline->HandleIfReady(); + if (!handle) [[unlikely]] { + handle = BindWhenCompiled(pipeline, vk::PipelineBindPoint::eGraphics); + } const vk::Buffer args = buffer->Handle(); const u64 args_offset = base; const vk::Buffer counts = count_address != 0 ? count_buffer->Handle() : vk::Buffer{}; @@ -1780,8 +2201,15 @@ void Rasterizer::DrawIndirectRecord(const GraphicsPipeline* pipeline, bool is_in .address = indirect.args, .count = {max_count, stride, 0}, }; + const auto query = occlusion ? occlusion->BeginDraw(push_data.scene_size) + : Occlusion::DrawQuery{}; scheduler.RecordCrumb(crumb, [=](vk::CommandBuffer cmdbuf) { - cmdbuf.bindPipeline(vk::PipelineBindPoint::eGraphics, handle); + if (handle) { // null: bound by the command before (BindWhenCompiled) + cmdbuf.bindPipeline(vk::PipelineBindPoint::eGraphics, handle); + } + if (query.pool) { + cmdbuf.beginQuery(query.pool, query.index, query.flags); + } if (is_indexed) { if (counts) { cmdbuf.drawIndexedIndirectCount(args, args_offset, counts, counts_offset, max_count, @@ -1794,6 +2222,9 @@ void Rasterizer::DrawIndirectRecord(const GraphicsPipeline* pipeline, bool is_in } else { cmdbuf.drawIndirect(args, args_offset, max_count, stride); } + if (query.pool) { + cmdbuf.endQuery(query.pool, query.index); + } }); DebugState.IncDrawCall(); @@ -1831,6 +2262,7 @@ void Rasterizer::DispatchRecord(const ComputePipeline* pipeline) { const auto& cs_program = CsRegs(); const auto& cs = pipeline->GetStage(Shader::SwStage::Compute); + BbSections::KeyScope dispatch_by_shader{BbSections::dispatch_by_shader, cs.pgm_hash}; // After the resource binding: its transfers (image uploads, downloads) mark themselves. const auto mark = [&] { if (auto* profiler = GpuProfiler::Get()) { @@ -1865,7 +2297,11 @@ void Rasterizer::DispatchRecord(const ComputePipeline* pipeline) { pipeline->BindResources(set_writes, push_data, {image_infos.data(), image_infos.size()}, {buffer_infos.data(), buffer_infos.size()}); - const vk::Pipeline handle = pipeline->Handle(); + // bbport: dispatches are never left out; a compute pipeline still compiling is bound late. + vk::Pipeline handle = pipeline->HandleIfReady(); + if (!handle) [[unlikely]] { + handle = BindWhenCompiled(pipeline, vk::PipelineBindPoint::eCompute); + } const u32 dim_x = cs_program.dim_x, dim_y = cs_program.dim_y, dim_z = cs_program.dim_z; if (FrameCapture::Active()) { FrameCapture::Dispatch(cs.pgm_hash, dim_x, dim_y, dim_z); @@ -1877,7 +2313,9 @@ void Rasterizer::DispatchRecord(const ComputePipeline* pipeline) { .count = {dim_x, dim_y, dim_z}, }; scheduler.RecordCrumb(crumb, [=](vk::CommandBuffer cmdbuf) { - cmdbuf.bindPipeline(vk::PipelineBindPoint::eCompute, handle); + if (handle) { // null: bound by the command before (BindWhenCompiled) + cmdbuf.bindPipeline(vk::PipelineBindPoint::eCompute, handle); + } cmdbuf.dispatch(dim_x, dim_y, dim_z); }); DebugState.IncDispatch(); @@ -1926,7 +2364,10 @@ void Rasterizer::DispatchIndirectRecord(const ComputePipeline* pipeline, VAddr a pipeline->BindResources(set_writes, push_data, {image_infos.data(), image_infos.size()}, {buffer_infos.data(), buffer_infos.size()}); - const vk::Pipeline handle = pipeline->Handle(); + vk::Pipeline handle = pipeline->HandleIfReady(); + if (!handle) [[unlikely]] { + handle = BindWhenCompiled(pipeline, vk::PipelineBindPoint::eCompute); + } const vk::Buffer args = buffer->Handle(); const u64 args_offset = base; const auto& cs_crumb = pipeline->GetStage(Shader::SwStage::Compute); @@ -1941,7 +2382,9 @@ void Rasterizer::DispatchIndirectRecord(const ComputePipeline* pipeline, VAddr a }; scheduler.RecordCrumb(crumb, [=](vk::CommandBuffer cmdbuf) { Breadcrumbs::CopyArgs(cmdbuf, args, args_offset, args_slot); - cmdbuf.bindPipeline(vk::PipelineBindPoint::eCompute, handle); + if (handle) { // null: bound by the command before (BindWhenCompiled) + cmdbuf.bindPipeline(vk::PipelineBindPoint::eCompute, handle); + } cmdbuf.dispatchIndirect(args, args_offset); }); DebugState.IncDispatch(); @@ -1950,6 +2393,115 @@ void Rasterizer::DispatchIndirectRecord(const ComputePipeline* pipeline, VAddr a scheduler.KickRecording(); } +bool Rasterizer::SkipUncompiledDraw(const GraphicsPipeline* pipeline) { + const u64 budget = SkipUncompiledBudgetNs(); + if (budget == 0) { + return false; + } + // Scene geometry only (the draws the upscaler jitters): drawn with the scene depth, which + // every frame draws again from a clear, and not a full-screen pass. The depth must also have + // been the scene's the frame before: a G-buffer pass of its own (a reflection, drawn now and + // then and kept) is not left out. Shadow maps, half-resolution effects, post-processing and + // UI wait: what they draw may be kept over frames or read as a whole. A draw the game + // measures (occlusion query) waits as well. + const auto& regs = Regs(); + if (!db_desc.first || db_desc.first != camera_motion->Depth() || + db_desc.first != previous_scene_depth || + (regs.num_indices <= 6 && regs.num_instances.NumInstances() <= 1) || + (occlusion && occlusion->Measuring()) || FrameCapture::Active()) { + return false; + } + const bool first = pipeline->first_skip_ns == 0; + const u64 now = u64(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count()); + if (!WithinSkipBudget(pipeline->first_skip_ns, now, budget)) { + return false; + } + pipeline_compile_stats.skipped_draws.fetch_add(1, std::memory_order_relaxed); + if (first) { + pipeline_compile_stats.skipped_pipelines.fetch_add(1, std::memory_order_relaxed); + } + return true; +} + +vk::Pipeline Rasterizer::BindWhenCompiled(const Pipeline* pipeline, + vk::PipelineBindPoint bind_point) { + if (!LatePipelineBindEnabled() || !scheduler.IsRecordingDeferred()) { + return pipeline->Handle(); // waits here (and compiles it here when still queued) + } + // The recording thread that runs this waits for the compile (or compiles it, when still + // queued): the draws after it are recorded meanwhile, and drains of the GPU command thread + // (write faults, readbacks for guest threads) do not wait for the compile. + pipeline_compile_stats.late_binds.fetch_add(1, std::memory_order_relaxed); + scheduler.Record([pipeline, bind_point](vk::CommandBuffer cmdbuf) { + cmdbuf.bindPipeline(bind_point, pipeline->Handle()); + }); + return {}; +} + +bool Rasterizer::OcclusionDump(VAddr va, u32 pipes) { + if (!occlusion || (va & 7) != 0) { + return false; + } + // In order with the draws: the dump closes the interval of draws recorded before it. + const auto task = occlusion->MakeDump(va, pipes); + RunInOrder( + [](Rasterizer& self, const u8* data) { + Occlusion::DumpTask dump; + std::memcpy(&dump, data, sizeof(dump)); + self.occlusion->Dump(dump); + }, + &task, sizeof(task), BbToggle::PipelinedTasks, false, "OcclusionDump"); + return true; +} + +bool Rasterizer::OcclusionPredication(const u32* body) { + if (!occlusion) { + return false; + } + // [0] start address lo (16-byte aligned), [1] address hi[7:0] | PRED_BOOL[8] (1: draw if + // visible) | HINT[12] | PRED_OP[18:16] (0 clear, 1 ZPASS, 2 primitive count) | CONTINUE[31]. + // The game: Gnm's setZPassPredicationEnable(block, hint 0, draw if visible) and Disable. + const Occlusion::PredicationTask task{ + .block = (VAddr(body[1] & 0xff) << 32) | (body[0] & ~0xfu), + .op = (body[1] >> 16) & 7, + .draw_if_visible = u8((body[1] >> 8) & 1), + .cont = u8(body[1] >> 31), + }; + static u32 logged = 0; + if (logged < 4) { + ++logged; + std::printf("Occlusion: SET_PREDICATION block %#llx op %u draw-if-visible %u hint %u " + "continue %u\n", + static_cast(task.block), task.op, task.draw_if_visible, + (body[1] >> 12) & 1, task.cont); + } + occlusion->predication_open = task.op != 0; + RunInOrder( + [](Rasterizer& self, const u8* data) { + Occlusion::PredicationTask set; + std::memcpy(&set, data, sizeof(set)); + self.occlusion->SetPredication(set); + }, + &task, sizeof(task), BbToggle::PipelinedTasks, false, "SetPredication"); + return true; +} + +bool Rasterizer::OcclusionUnsubmitted() const { + return occlusion && occlusion->HasUnsubmittedDumps(scheduler.CurrentTick()); +} + +void Rasterizer::OcclusionSubmissionStart() { + if (!occlusion || !occlusion->predication_open) { + return; + } + // The predicate bit is per packet in the command buffer that set it: a region does not + // carry over into the next submission. + const u32 clear[2] = {0, 0}; + OcclusionPredication(clear); +} + u64 Rasterizer::Flush() { BB_SECTION(Flush); DrainDrawPipe(); @@ -1976,6 +2528,7 @@ void Rasterizer::OnSubmit() { buffer_cache.ProcessFaultBuffer(); } texture_cache.ProcessDownloadImages(); + texture_cache.UpdateKeyPrompts(); texture_cache.RunGarbageCollector(); runtime.TickFrame(); } @@ -1985,8 +2538,16 @@ const PreparedStage* Rasterizer::FindPreparedStage(const Shader::Info& stage) co if (!bind_prepared || BbToggle::Disabled(BbToggle::PreparedResources)) { return nullptr; } - for (u32 i = 0; i < bind_prepared->num_stages; ++i) { - const auto& candidate = bind_prepared->stages[i]; + return FindPreparedStage(bind_prepared, stage); +} + +const PreparedStage* Rasterizer::FindPreparedStage(const PreparedDraw* prepared, + const Shader::Info& stage) { + if (!prepared) { + return nullptr; + } + for (u32 i = 0; i < prepared->num_stages; ++i) { + const auto& candidate = prepared->stages[i]; if (&candidate.program->info == &stage && candidate.num_images == stage.images.size() && candidate.num_samplers == stage.samplers.size() && @@ -2051,10 +2612,10 @@ bool Rasterizer::TexturesBindableOnHelper(const Shader::Info& stage, !IsKnownFormat(data_fmt, num_fmt)) { continue; // null descriptor } - const auto& entry = + auto& entry = prepared ? CachedImageDescEntry(tsharp, image_desc, prepared->image_hashes[image_index]) : CachedImageDescEntry(tsharp, image_desc); - if (entry.found_generation != generation) { + if (!LookupMemoized(entry, generation)) { return false; } VideoCore::ImageId image_id = entry.found_id; @@ -2796,6 +3357,7 @@ void Rasterizer::BindBuffers(const Shader::Info& stage, const PreparedStage* pre bound_buffers.emplace_back(buffer, offset, size, desc.is_written); if (desc.is_written) { // Raw storage-buffer writes can also make an aliased cached image stale. + const VideoCore::InvalidateReason reason{"storage buffer"}; texture_cache.InvalidateMemoryFromGPU(vsharp.base_address, size); } needs_barrier |= runtime.IsBufferAccessed(buffer, offset, size, desc.is_written); @@ -2848,18 +3410,191 @@ Rasterizer::ImageDescCacheEntry& Rasterizer::CachedImageDescEntry(const AmdGpu:: entry.sharp = key; entry.flags = flags; entry.found_generation = ~0ULL; + entry.found_exact = false; entry.view_memo = {}; } return entry; } +bool Rasterizer::MemoRevalidation() { + static const bool on = [] { + const char* env = std::getenv("BB_MEMO_REVALIDATE"); + return !(env && env[0] == '0'); + }(); + return on; +} + +bool Rasterizer::LookupMemoized(ImageDescCacheEntry& entry, u64 generation) { + if (entry.found_generation == generation) { + return true; + } + // An image (un)registered since: an exact match still registered is what FindImage would + // find again (TextureCache::IsExactMatch). + if (!entry.found_exact || entry.found_generation == ~0ULL || !MemoRevalidation() || + !texture_cache.IsRegisteredImage(entry.found_id, entry.found_uid)) { + return false; + } + entry.found_generation = generation; + // A view memoized behind a depth redirect: that image may have been replaced in its slot. + if (entry.view_memo.image_id != entry.found_id) { + entry.view_memo = {}; + } + ++texture_lookups_revalidated; + return true; +} + +void Rasterizer::ReportSlowTextureBind(const Shader::Info& stage, u64 start, + std::span pass1, std::span pass2) { + static const double cycles_per_us = [] { + const auto t0 = std::chrono::steady_clock::now(); + const u64 c0 = __rdtsc(); + while (std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(10)) { + } + const double us = + std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + return double(__rdtsc() - c0) / us; + }(); + static u32 reports = 0; + const double total_us = double(pass2.back() - start) / cycles_per_us; + if (total_us < 300.0 || reports >= 30) { + return; + } + ++reports; + std::printf("Slow texture bind %u: %s %016llx %.0f us, %zu textures, %zu bindings; first pass us:", + reports, stage.sw_stage == Shader::SwStage::Compute ? "cs" : "gfx", + static_cast(stage.pgm_hash), total_us, stage.images.size(), + image_bindings.size()); + for (size_t i = 0; i + 1 < pass1.size(); ++i) { + std::printf(" %.0f", double(pass1[i + 1] - pass1[i]) / cycles_per_us); + } + std::printf("\n"); + for (size_t b = 0; b < image_bindings.size() && b + 1 < pass2.size(); ++b) { + const double us = double(pass2[b + 1] - pass2[b]) / cycles_per_us; + const auto& [id, desc] = image_bindings[b]; + if (!id) { + std::printf(" binding %zu: %.0f us, null\n", b, us); + continue; + } + const auto& image = texture_cache.GetImage(id); + std::printf(" binding %zu: %.0f us; image %u %s %ux%u mips %u layers %u tile %u at %#llx " + "size %u, flags %#x, %s, %s\n", + b, us, id.index, vk::to_string(image.info.pixel_format).c_str(), + image.info.size.width, image.info.size.height, image.info.resources.levels, + image.info.resources.layers, static_cast(image.info.tile_mode), + static_cast(image.info.guest_address), image.info.guest_size, + static_cast(image.flags), + desc->type == VideoCore::TextureCache::BindingType::Storage ? "storage" : "sampled", + image.info.props.is_depth ? "depth" : "color"); + } + std::fflush(stdout); +} + +/// bbport BB_PROXY_TRACE=1 (diagnostics): the first read of each shader and image through a +/// reduced scene proxy, with the shader's sampler filters (0 point, 1 bilinear, 2 aniso point, +/// 3 aniso linear) and the pass reading it (graphics: known once the pass begins, so the reads +/// wait for FlushProxyReads): a point-filtered read of a proxy by a pass at the native size is +/// a nearest-neighbour upscale. +static bool ProxyTraceOn() { + static const bool on = [] { + const char* env = std::getenv("BB_PROXY_TRACE"); + return env && env[0] == '1'; + }(); + return on; +} +struct PendingProxyRead { + u64 shader; + VAddr image; + std::string text; +}; +// Only the draw recording thread binds proxies (never the texture helper). Not thread_local: +// that broke the link of Shader::Info's thread-local snapshots (clang, MinGW). +static std::vector pending_proxy_reads; + +/// kind: 0 compute, 1 reduced pass, 2 native pass (each printed once per shader and image). +static void PrintProxyRead(u64 shader, VAddr image, int kind, const std::string& text, + const std::string& pass) { + static std::mutex mutex; + static std::vector> seen; + std::scoped_lock lock{mutex}; + const auto key = std::make_tuple(shader, image, kind); + if (seen.size() >= 400 || std::find(seen.begin(), seen.end(), key) != seen.end()) { + return; + } + seen.push_back(key); + std::printf("Proxy read: %s; %s\n", text.c_str(), pass.c_str()); +} + +static void TraceProxyRead(const Shader::Info& stage, const VideoCore::Image& image, + SceneResolution::Size proxy) { + if (!ProxyTraceOn()) { + return; + } + std::string filters; + bool point = false; + for (const auto& sampler : stage.samplers) { + const auto sharp = sampler.GetSharp(stage); + const u32 mag = u32(sharp.xy_mag_filter.Value()), min = u32(sharp.xy_min_filter.Value()); + filters += fmt::format(" {}/{}", mag, min); + point |= mag == u32(AmdGpu::Filter::Point) || mag == u32(AmdGpu::Filter::AnisoPoint); + } + const bool compute = stage.sw_stage == Shader::SwStage::Compute; + std::string text = fmt::format("{} {:016x} {} {}x{} from a {}x{} proxy, sampler mag/min:{}{}", + compute ? "cs" : "gfx", stage.pgm_hash, + vk::to_string(image.info.pixel_format), image.info.size.width, + image.info.size.height, proxy.width, proxy.height, filters, + point ? " POINT" : ""); + if (compute) { + PrintProxyRead(stage.pgm_hash, image.info.guest_address, 0, text, "compute (native size)"); + } else { + pending_proxy_reads.push_back({stage.pgm_hash, image.info.guest_address, std::move(text)}); + } +} + +/// The pass of the graphics reads noted since the last call has begun. +static void FlushProxyReads(bool reduced, const VideoCore::Image* target) { + if (pending_proxy_reads.empty()) { + return; + } + const std::string pass = + target ? fmt::format("pass {} {}x{}", reduced ? "reduced" : "NATIVE", + target->info.size.width, target->info.size.height) + : std::string{reduced ? "pass reduced" : "pass NATIVE"}; + for (const auto& read : pending_proxy_reads) { + PrintProxyRead(read.shader, read.image, reduced ? 1 : 2, read.text, pass); + } + pending_proxy_reads.clear(); +} + void Rasterizer::BindTextures(const Shader::Info& stage, const PreparedStage* prepared, Shader::Backend::Bindings& binding, u32& write_index, bool& barrier, bool on_helper) { BB_SECTION(BindTextures); const u32 first_image_idx = image_infos.size(); TextureSet* set_slot = nullptr; - if (!on_helper && BindTexturesFromSet(stage, prepared, first_image_idx, barrier, set_slot)) { + // bbport: a stage the draw-preparation workers did not reach reads its T#s and their + // description hashes here, as PrepareResources does, so it can take the memoized texture set + // too: in heavy scenes ~40% of draws were unprepared and their full lookups were two thirds of + // texture binding. BB_LOCAL_TEXTURE_SETS=0: the full lookup for them, as before. + static const bool local_sets = [] { + const char* env = std::getenv("BB_LOCAL_TEXTURE_SETS"); + return !(env && env[0] == '0'); + }(); + PreparedStage local{}; + std::array local_sharps; + std::array local_hashes; + const PreparedStage* images = prepared; + if (!prepared && !on_helper && local_sets && !stage.images.empty() && + stage.images.size() <= TextureSet::MaxImages) { + for (u32 i = 0; i < stage.images.size(); ++i) { + local_sharps[i] = stage.images[i].GetSharp(stage); + local_hashes[i] = ImageDescHash(local_sharps[i], stage.images[i]); + } + local.image_sharps = local_sharps.data(); + local.image_hashes = local_hashes.data(); + local.num_images = static_cast(stage.images.size()); + images = &local; + } + if (!on_helper && BindTexturesFromSet(stage, images, first_image_idx, barrier, set_slot)) { // Descriptor writes: one per image (no mip arrays in a memoized set). for (u32 i = 0; i < stage.images.size(); ++i) { auto& set_write = set_writes[write_index++]; @@ -2873,6 +3608,14 @@ void Rasterizer::BindTextures(const Shader::Info& stage, const PreparedStage* pr BindSamplers(stage, prepared, binding, write_index); return; } + BB_SECTION(TexFull); + BbSections::Scope empty_scope{stage.images.empty() ? BbSections::TexFullEmpty : BbSections::Count}; + BbSections::KeyScope full_by_shader{BbSections::tex_full_by_shader, stage.pgm_hash}; + if (!stage.images.empty()) { + empty_scope.start = 0; + } else { + full_by_shader.start = 0; + } image_bindings.clear(); image_binding_entries.clear(); image_desc_overflow.clear(); @@ -2882,17 +3625,39 @@ void Rasterizer::BindTextures(const Shader::Info& stage, const PreparedStage* pr boost::container::small_vector image_descriptor_array_sizes; // TextureSetMemo: what this call resolves, to remember when the set qualifies. std::array resolved{}; + std::array resolved_uids{}; bool set_ok = set_slot != nullptr; + // Every lookup an exact match without a depth redirect, every null descriptor a null T# + // (TextureSet::exact). + bool set_exact = set_ok; + const auto note_found = [&](u32 binding_index, VideoCore::ImageId found, u64 uid, + bool exact) { + if (binding_index < resolved.size()) { + resolved[binding_index].found = found; + resolved_uids[binding_index] = uid; + } + set_exact &= exact; + }; // SampleSceneProxies: bindings that may read a reduced scene proxy instead of the native // image (normalized sampling only; see Shader::ImageResource::needs_native). boost::container::small_vector binding_proxy_ok; const bool sample_proxies = !BbToggle::Disabled(BbToggle::SampleSceneProxies) && - !on_helper && scene_targets->Reduced(); + !on_helper && scene_targets->Reduced() && + !PointReadsNative(stage, prepared); + // bbport BB_SECTIONS: a full lookup over 0.3 ms is reported (the first 30) with the time of + // each texture in both passes, to see which image and which step costs it. + const bool time_slow = BbSections::Enabled() && BbSections::recording_thread && + !stage.images.empty(); + boost::container::small_vector pass1_stamps, pass2_stamps; + const u64 full_start = time_slow ? __rdtsc() : 0; for (u32 image_index = 0; image_index < stage.images.size(); ++image_index) { + if (time_slow) { + pass1_stamps.push_back(__rdtsc()); + } const auto& image_desc = stage.images[image_index]; const auto tsharp = - prepared ? prepared->image_sharps[image_index] : image_desc.GetSharp(stage); + images ? images->image_sharps[image_index] : image_desc.GetSharp(stage); // bbport: a hash lookup per texture per draw for a diagnostic only. static const bool warn_meta = std::getenv("BB_WARN_META_TEXTURE") != nullptr; if (warn_meta && texture_cache.IsMeta(tsharp.Address())) { @@ -2916,6 +3681,7 @@ void Rasterizer::BindTextures(const Shader::Info& stage, const PreparedStage* pr "data_format={}, num_format={}", tsharp.Address(), tsharp.pitch, tsharp.width, static_cast(data_fmt), static_cast(num_fmt)); + set_exact = false; // may resolve once the memory is mapped image_bindings.emplace_back(VideoCore::ImageId{}, &image_desc_storage.emplace_back()); image_binding_entries.push_back(nullptr); binding_proxy_ok.push_back(false); @@ -2934,8 +3700,8 @@ void Rasterizer::BindTextures(const Shader::Info& stage, const PreparedStage* pr mip_fallback_mode == Shader::MipStorageFallbackMode::None; auto& desc_entry = - prepared ? CachedImageDescEntry(tsharp, image_desc, prepared->image_hashes[image_index]) - : CachedImageDescEntry(tsharp, image_desc); + images ? CachedImageDescEntry(tsharp, image_desc, images->image_hashes[image_index]) + : CachedImageDescEntry(tsharp, image_desc); // BB_SCENE_DEBUG: proxied scene targets this binding reads at the native size, and why. const auto debug_native = [&](const VideoCore::Image& image) { if (!scene_debug_frame || proxy_candidate || !image.scene_proxy) return; @@ -2951,9 +3717,11 @@ void Rasterizer::BindTextures(const Shader::Info& stage, const PreparedStage* pr // bbport: a plain binding (no mip override) of the same T# resolves to the same image // while no image was registered or unregistered. if (mip_fallback_mode == Shader::MipStorageFallbackMode::None && - desc_entry.found_generation == texture_cache.RegistryGeneration() && - !BbToggle::Disabled(BbToggle::TextureBindingMemo)) { + !BbToggle::Disabled(BbToggle::TextureBindingMemo) && + LookupMemoized(desc_entry, texture_cache.RegistryGeneration())) { desc_entry.pinned = bind_epoch; + note_found(static_cast(image_bindings.size()), desc_entry.found_id, + desc_entry.found_uid, desc_entry.found_exact); auto& [image_id, _] = image_bindings.emplace_back(desc_entry.found_id, &desc_entry.found_desc); image_binding_entries.push_back(&desc_entry); @@ -2963,6 +3731,7 @@ void Rasterizer::BindTextures(const Shader::Info& stage, const PreparedStage* pr if (auto depth_image_id = texture_cache.GetAssociatedDepth(*image)) { image_id = depth_image_id; image = &texture_cache.GetImage(image_id); + set_exact = false; } debug_native(*image); if (image->binding.is_bound) { @@ -2986,21 +3755,32 @@ void Rasterizer::BindTextures(const Shader::Info& stage, const PreparedStage* pr } const u64 generation = texture_cache.RegistryGeneration(); + const u32 binding_index = static_cast(image_bindings.size() - 1); image_id = texture_cache.FindImage(desc); + // FindImage moved no view into another image's mips or layers, and found the + // registered image of exactly this description. + const bool exact = mip_fallback_mode == Shader::MipStorageFallbackMode::None && + desc.view_info.range == desc_entry.desc.view_info.range && + texture_cache.IsExactMatch(image_id, desc.info); + const u64 uid = texture_cache.ImageUid(image_id); if (mip_fallback_mode == Shader::MipStorageFallbackMode::None && generation == texture_cache.RegistryGeneration() && desc_entry.pinned != bind_epoch) { desc_entry.found_generation = generation; desc_entry.found_id = image_id; desc_entry.found_desc = desc; + desc_entry.found_exact = exact; + desc_entry.found_uid = uid; desc_entry.view_memo = {}; } + note_found(binding_index, image_id, uid, exact); auto* image = &texture_cache.GetImage(image_id); if (auto depth_image_id = texture_cache.GetAssociatedDepth(*image)) { // If this image has an associated depth image, it's a stencil attachment. // Redirect the access to the actual depth-stencil buffer. image_id = depth_image_id; image = &texture_cache.GetImage(image_id); + set_exact = false; } debug_native(*image); if (image->binding.is_bound) { @@ -3014,8 +3794,14 @@ void Rasterizer::BindTextures(const Shader::Info& stage, const PreparedStage* pr image_descriptor_array_sizes.push_back(num_bindings); } + if (time_slow) { + pass1_stamps.push_back(__rdtsc()); + } // Second pass to re-bind images that were updated after binding for (u32 binding_index = 0; binding_index < image_bindings.size(); ++binding_index) { + if (time_slow) { + pass2_stamps.push_back(__rdtsc()); + } auto& [image_id, desc_ptr] = image_bindings[binding_index]; auto* memo_entry = image_binding_entries[binding_index]; const auto& desc = *desc_ptr; @@ -3046,10 +3832,12 @@ void Rasterizer::BindTextures(const Shader::Info& stage, const PreparedStage* pr !binding.is_target && !(upscaler->Enabled() && upscaler->RedirectsSampled(image_id))) { if (const auto proxy = scene_targets->SampleProxy(image, desc.view_info)) { + TraceProxyRead(stage, image, scene_targets->ProxySize(image)); image.usage.texture = 1u; image_infos.emplace_back(VK_NULL_HANDLE, proxy->view, proxy->layout); if (set_ok && binding_index < resolved.size()) { - resolved[binding_index] = {image_id, proxy->view, image.backing, + resolved[binding_index] = {image_id, resolved[binding_index].found, + proxy->view, image.backing, desc.view_info.range, true}; } ++proxy_samples; @@ -3098,18 +3886,27 @@ void Rasterizer::BindTextures(const Shader::Info& stage, const PreparedStage* pr } image_infos.emplace_back(VK_NULL_HANDLE, view, layout); if (set_ok && binding_index < resolved.size()) { - resolved[binding_index] = {image_id, *image_view.image_view, image.backing, + resolved[binding_index] = {image_id, resolved[binding_index].found, + *image_view.image_view, image.backing, desc.view_info.range}; set_ok = !is_storage && !binding.force_general && !binding.is_target; } } } + if (time_slow) { + pass2_stamps.push_back(__rdtsc()); + ReportSlowTextureBind(stage, full_start, {pass1_stamps.data(), pass1_stamps.size()}, + {pass2_stamps.data(), pass2_stamps.size()}); + } if (set_ok && image_bindings.size() == stage.images.size()) { - // Remember the set: the same T#s resolve the same way while no image is (un)registered. + // Remember the set: the same T#s resolve the same way while no image is (un)registered, + // and with exact matches while their images stay registered (RevalidateTextureSet). set_slot->generation = texture_cache.RegistryGeneration(); set_slot->scene_generation = scene_targets->Generation(); set_slot->count = static_cast(stage.images.size()); + set_slot->exact = set_exact; std::copy_n(resolved.begin(), set_slot->count, set_slot->entries.begin()); + std::copy_n(resolved_uids.begin(), set_slot->count, set_slot->found_uids.begin()); } else if (set_slot) { set_slot->key = 0; } @@ -3150,7 +3947,9 @@ void Rasterizer::ForceAnisotropy(AmdGpu::Sampler& sharp, bool is_depth) const { static std::atomic seen{0}; const u32 kind = u32(sharp.xy_min_filter.Value()) * 3 * 5 + u32(sharp.mip_filter.Value()) * 5 + u32(sharp.max_aniso.Value()); - if (kind < 64 && !(seen.fetch_or(1ull << kind) & (1ull << kind))) { + // A plain load first: the locked read-modify-write ran for every sampler of every draw. + if (kind < 64 && !(seen.load(std::memory_order_relaxed) & (1ull << kind)) && + !(seen.fetch_or(1ull << kind) & (1ull << kind))) { std::printf("Sampler: game uses min filter %u, mip filter %u, anisotropy %.0fx\n", u32(sharp.xy_min_filter.Value()), u32(sharp.mip_filter.Value()), sharp.MaxAniso()); @@ -3175,15 +3974,80 @@ void Rasterizer::ForceAnisotropy(AmdGpu::Sampler& sharp, bool is_depth) const { sharp.max_aniso.Assign(ratio); } +vk::Sampler Rasterizer::CachedSampler(const AmdGpu::Sampler& sharp, bool is_depth, + float lod_bias) { + static const bool enabled = [] { + const char* env = std::getenv("BB_SAMPLER_MEMO"); + return !(env && env[0] == '0'); + }(); + if (!enabled) { + return texture_cache.GetSampler(sharp, Regs().ta_bc_base, is_depth, lod_bias); + } + static_assert(sizeof(AmdGpu::Sampler) == sizeof(SamplerMemoEntry::sharp)); + std::array words; + std::memcpy(words.data(), &sharp, sizeof(words)); + const u64 variant = u64(is_depth) << 32 | std::bit_cast(lod_bias); + u64 hash = (words[0] ^ std::rotl(words[1], 29) ^ variant) * 0x9E3779B97F4A7C15ull; + hash ^= hash >> 32; + auto& entry = sampler_memo[hash % sampler_memo.size()]; + // Read here, not after GetSampler: only the collector changes them, never while this runs. + const u64 generation = texture_cache.SamplerGeneration(); + const u64 tick = texture_cache.GcTick(); + if (entry.generation == generation && entry.sharp == words && entry.variant == variant) { + if (entry.touched_tick != tick) { + texture_cache.TouchSampler(entry.lru_id); + entry.touched_tick = tick; + } + ++sampler_memo_hits; + return entry.handle; + } + ++sampler_memo_misses; + size_t lru_id = 0; + const vk::Sampler handle = + texture_cache.GetSampler(sharp, Regs().ta_bc_base, is_depth, lod_bias, &lru_id); + entry = {words, variant, handle, lru_id, generation, tick}; + return handle; +} + +// bbport: a pass at the native size (graphics after the upscaler has run, compute) that reads a +// reduced scene proxy through a point-filtered sampler upscales it nearest-neighbour: 17 such +// post-processing passes made effects and the moon's edge blocky at every reduced preset. The +// stage reads the native images instead, resolved from the proxies with linear filtering +// (SceneTargets::Copy). Bilinear samplers keep reading the proxy (the same upscale, no resolve). +// The sampler that reads each image is not known here: any point sampler counts. +// BB_PROXY_POINT_NATIVE=0 (or experiment bit 0): proxies are read regardless, as before. +bool Rasterizer::PointReadsNative(const Shader::Info& stage, const PreparedStage* prepared) const { + static const bool enabled = [] { + const char* env = std::getenv("BB_PROXY_POINT_NATIVE"); + return !(env && env[0] == '0'); + }(); + // Experiment bit 0 of BB_TOGGLE_FILE: proxies read regardless (A/B while the game runs). + if (!enabled || BbToggle::Experiment(1) || + (stage.sw_stage != Shader::SwStage::Compute && upscaler->RasterScaling())) { + return false; + } + for (u32 i = 0; i < stage.samplers.size(); ++i) { + // A local set (BindTextures, unprepared stage) has image sharps only. + const auto sharp = prepared && prepared->sampler_sharps ? prepared->sampler_sharps[i] + : stage.samplers[i].GetSharp(stage); + if (sharp.xy_mag_filter == AmdGpu::Filter::Point || + sharp.xy_mag_filter == AmdGpu::Filter::AnisoPoint) { + return true; + } + } + return false; +} + void Rasterizer::BindSamplers(const Shader::Info& stage, const PreparedStage* prepared, Shader::Backend::Bindings& binding, u32& write_index) { + BB_SECTION(Samplers); for (u32 sampler_index = 0; sampler_index < stage.samplers.size(); ++sampler_index) { const auto& sampler = stage.samplers[sampler_index]; auto ssharp = prepared ? prepared->sampler_sharps[sampler_index] : sampler.GetSharp(stage); ForceAnisotropy(ssharp, sampler.is_depth); - const auto vk_sampler = texture_cache.GetSampler( - ssharp, Regs().ta_bc_base, sampler.is_depth, sampler.is_depth ? 0.0f : sampler_lod_bias); + const auto vk_sampler = CachedSampler(ssharp, sampler.is_depth, + sampler.is_depth ? 0.0f : sampler_lod_bias); image_infos.emplace_back(vk_sampler, VK_NULL_HANDLE, vk::ImageLayout::eGeneral); auto& set_write = set_writes[write_index++]; set_write.dstSet = VK_NULL_HANDLE; @@ -3224,29 +4088,61 @@ Rasterizer::BeginSignature Rasterizer::MakeBeginSignature(const GraphicsPipeline return sig; } +bool Rasterizer::RevalidateTextureSet(TextureSet& set, u64 generation) { + // Images were (un)registered since the set was remembered. Its lookups were exact matches: + // FindImage would find the same images again while each is still registered (uids: slots + // are reused) and has no depth redirect (TextureCache::IsExactMatch). Views and backings + // belong to those images, so the per-image checks of a hit stay as they are. + if (!set.exact || set.generation == ~0ull || !MemoRevalidation()) { + return false; + } + for (u32 i = 0; i < set.count; ++i) { + const auto& entry = set.entries[i]; + if (!entry.id) { + continue; // a null T# + } + if (entry.found != entry.id || + !texture_cache.IsRegisteredImage(entry.found, set.found_uids[i]) || + texture_cache.GetAssociatedDepth(texture_cache.GetImage(entry.found))) { + set.exact = false; + return false; + } + } + set.generation = generation; + ++texture_set_revalidated; + return true; +} + bool Rasterizer::BindTexturesFromSet(const Shader::Info& stage, const PreparedStage* prepared, u32 first_image_idx, bool& barrier, TextureSet*& slot) { + BB_SECTION(TexSet); const u32 count = static_cast(stage.images.size()); if (!prepared || count == 0 || count > TextureSet::MaxImages || BbToggle::Disabled(BbToggle::TextureSetMemo) || FrameCapture::Active()) { + ++texture_set_skip[count == 0 ? 1 : !prepared ? 0 : count > TextureSet::MaxImages ? 2 : 4]; return false; } for (const auto& image : stage.images) { if (image.is_written || image.mip_fallback_mode != Shader::MipStorageFallbackMode::None) { + ++texture_set_skip[3]; return false; } } + // Point-sampling stages in native passes read native images (PointReadsNative): sets of + // their own, whose native entries stand even for images with a current proxy. + const bool point_native = scene_targets->Reduced() && PointReadsNative(stage, prepared); u64 key = XXH3_64bits_withSeed(prepared->image_hashes, count * sizeof(u64), - reinterpret_cast(&stage)); + reinterpret_cast(&stage) ^ (point_native ? 2 : 0)); key |= 1; // 0 marks an empty slot auto& set = texture_sets[key % texture_sets.size()]; slot = &set; const u64 generation = texture_cache.RegistryGeneration(); - const bool match = set.key == key && set.stage == &stage && set.count == count && - set.generation == generation && - set.scene_generation == scene_targets->Generation() && - std::equal(set.hashes.begin(), set.hashes.begin() + count, - prepared->image_hashes); + const bool same_set = set.key == key && set.stage == &stage && set.count == count && + set.scene_generation == scene_targets->Generation() && + std::equal(set.hashes.begin(), set.hashes.begin() + count, + prepared->image_hashes); + const bool match = + same_set && (set.generation == generation || RevalidateTextureSet(set, generation)); if (!match) { const bool generation_miss = set.key == key && set.stage == &stage && set.count == count && set.generation != generation; @@ -3285,10 +4181,12 @@ bool Rasterizer::BindTexturesFromSet(const Shader::Info& stage, const PreparedSt set.stage = &stage; set.count = 0; set.generation = ~0ull; + set.exact = false; std::copy_n(prepared->image_hashes, count, set.hashes.begin()); return false; } // Every image still has the backing its view belongs to and needs no refresh. + BbSections::Scope check_scope{BbSections::TexSetCheck}; for (u32 i = 0; i < count; ++i) { const auto& entry = set.entries[i]; if (!entry.id) { @@ -3303,16 +4201,19 @@ bool Rasterizer::BindTexturesFromSet(const Shader::Info& stage, const PreparedSt (entry.proxy ? !proxies_on || !scene_targets->ProxyCurrent(image, entry.range.base.level, entry.range.extent.levels) - : image.scene_proxy && proxies_on) || + : image.scene_proxy && proxies_on && !point_native) || (upscaler->Enabled() && upscaler->RedirectsSampled(entry.id))) { ++texture_set_why[2]; ++texture_set_misses; set.generation = ~0ull; + set.exact = false; return false; } } + check_scope.End(); ++texture_set_hits; slot = nullptr; + BB_SECTION(TexSetApply); for (u32 i = 0; i < count; ++i) { const auto& entry = set.entries[i]; if (!entry.id) { @@ -3341,26 +4242,76 @@ bool Rasterizer::BindTexturesFromSet(const Shader::Info& stage, const PreparedSt return true; } +void Rasterizer::RememberBeginTargets() { + const auto& ids = begin_memo.signature.ids; + for (u32 i = 0; i < ids.size(); ++i) { + begin_memo.uids[i] = ids[i] ? texture_cache.ImageUid(ids[i]) : 0; + begin_memo.backings[i] = ids[i] ? texture_cache.GetImage(ids[i]).backing : nullptr; + } + begin_memo.scene_generation = scene_targets->Generation(); +} + +bool Rasterizer::RevalidateBeginMemo(const BeginSignature& signature) { + // Images were (un)registered since the memo: it holds when nothing else changed and every + // target is still the same registered image with the same backing (its views, layouts). + if (signature.generation == begin_memo.signature.generation || !MemoRevalidation() || + begin_memo.scene_generation != scene_targets->Generation()) { + return false; + } + BeginSignature same = signature; + same.generation = begin_memo.signature.generation; + if (!(same == begin_memo.signature)) { + return false; + } + for (u32 i = 0; i < signature.ids.size(); ++i) { + const auto id = signature.ids[i]; + if (id && (!texture_cache.IsRegisteredImage(id, begin_memo.uids[i]) || + texture_cache.GetImage(id).backing != begin_memo.backings[i])) { + return false; + } + } + begin_memo.signature.generation = signature.generation; + ++begin_memo_revalidated; + return true; +} + RenderState Rasterizer::BeginRendering(const GraphicsPipeline* pipeline) { BB_SECTION(BeginRendering); // bbport: RenderStateMemo (see BeginMemo). const bool memo_on = !BbToggle::Disabled(BbToggle::RenderStateMemo) && !FrameCapture::Active(); if (BbStats::enabled && ((begin_memo_hits + begin_memo_misses) & 0x3FFFF) == 0x3FFFF) { - std::printf("Render state memo: %llu hits, %llu misses; texture sets: %llu hits, %llu " - "misses\n", + std::printf("Render state memo: %llu hits (%llu revalidated), %llu misses; texture sets: " + "%llu hits (%llu revalidated), %llu misses; lookups revalidated: textures " + "%llu, targets %llu\n", static_cast(begin_memo_hits), + static_cast(begin_memo_revalidated), static_cast(begin_memo_misses), static_cast(texture_set_hits), - static_cast(texture_set_misses)); + static_cast(texture_set_revalidated), + static_cast(texture_set_misses), + static_cast(texture_lookups_revalidated), + static_cast(target_lookups_revalidated)); std::printf(" texture set misses: other key %llu, generation %llu, image check %llu, " "other %llu\n", (unsigned long long)texture_set_why[0], (unsigned long long)texture_set_why[1], (unsigned long long)texture_set_why[2], (unsigned long long)texture_set_why[3]); + std::printf(" texture set skips: unprepared %llu, no images %llu, over 16 images %llu, " + "storage or mip override %llu, memo off %llu\n", + (unsigned long long)texture_set_skip[0], (unsigned long long)texture_set_skip[1], + (unsigned long long)texture_set_skip[2], (unsigned long long)texture_set_skip[3], + (unsigned long long)texture_set_skip[4]); + texture_set_skip = {}; std::printf(" scene proxies sampled directly: %llu bindings\n", static_cast(proxy_samples)); + std::printf(" samplers: %llu memo hits, %llu looked up\n", + static_cast(sampler_memo_hits), + static_cast(sampler_memo_misses)); + sampler_memo_hits = sampler_memo_misses = 0; proxy_samples = 0; texture_set_why = {}; begin_memo_hits = begin_memo_misses = texture_set_hits = texture_set_misses = 0; + begin_memo_revalidated = texture_set_revalidated = texture_lookups_revalidated = 0; + target_lookups_revalidated = 0; } BeginSignature signature; if (memo_on) { @@ -3369,7 +4320,7 @@ RenderState Rasterizer::BeginRendering(const GraphicsPipeline* pipeline) { bool usable = begin_memo.valid && scheduler.IsRenderingWith(begin_memo.state) && !regs.depth_render_control.depth_clear_enable && !regs.depth_render_control.stencil_clear_enable && - signature == begin_memo.signature; + (signature == begin_memo.signature || RevalidateBeginMemo(signature)); for (const auto id : signature.ids) { if (usable && id) { const auto& image = texture_cache.GetImage(id); @@ -3399,6 +4350,7 @@ RenderState Rasterizer::BeginRendering(const GraphicsPipeline* pipeline) { // Recomputed after the full path: it may have started the scene (scene_started). begin_memo = {true, MakeBeginSignature(pipeline), state, push_data.scene_size, target_scale,target_offset}; + RememberBeginTargets(); } } return state; @@ -3766,7 +4718,7 @@ void Rasterizer::DepthStencilCopy(bool is_depth, bool is_stencil) { ScopeMarkerEnd(); } -void Rasterizer::FillBuffer(VAddr address, u32 num_bytes, u32 value, bool is_gds) { +void Rasterizer::FillBuffer(VAddr address, u32 num_bytes, u32 value, bool is_gds, bool waited) { DrainDrawPipe(); ASSERT_MSG(address % 4 == 0 && num_bytes % 4 == 0, "FillBuffer address and size must be a multiple of 4 bytes"); @@ -3778,6 +4730,9 @@ void Rasterizer::FillBuffer(VAddr address, u32 num_bytes, u32 value, bool is_gds // decode order, as before; the VRAM copy follows through write tracking. if (!buffer_cache.IsAnyInPlace(address, num_bytes) && !buffer_cache.IsRegionGpuModified(address, num_bytes)) { + if (!waited) { + WaitBeforeDmaStore(address, num_bytes, 0, 0); + } BbFreeCheck::Check(address, num_bytes, &value, BbFreeCheck::DmaFill); u32* buffer = std::bit_cast(address); std::fill(buffer, buffer + (num_bytes / sizeof(u32)), value); @@ -3807,13 +4762,41 @@ bool Rasterizer::DmaMayWriteOnCpu(VAddr dst, u32 num_bytes) { !buffer_cache.IsRegionGpuModified(dst, num_bytes); } -void Rasterizer::CopyBuffer(VAddr dst, VAddr src, u32 num_bytes, bool dst_gds, bool src_gds) { +bool Rasterizer::DmaWritesOnCpu(VAddr dst, u32 num_bytes, bool fill, VAddr src, bool src_gds) { + static const bool all = [] { + const char* env = std::getenv("BB_DMA_WAITS"); + return env && std::string_view{env} == "all"; + }(); + bool on_cpu = true; + if (!all) { + // FillBuffer's and CopyBuffer's tests for their CPU store. + on_cpu = !buffer_cache.IsAnyInPlace(dst, num_bytes) && + !buffer_cache.IsRegionGpuModified(dst, num_bytes) && + (fill || (!src_gds && !buffer_cache.IsRegionGpuModified(src, num_bytes) && + !texture_cache.FindImageFromRange(src, num_bytes))); + } + if (BbStats::enabled) { + (on_cpu ? BbStats::dma_on_cpu : BbStats::dma_on_gpu).fetch_add(1, std::memory_order_relaxed); + } + return on_cpu; +} + +void Rasterizer::WaitBeforeDmaStore(VAddr dst, u32 num_bytes, VAddr src, u32 src_size) { + WaitHostCopiesFor(dst, num_bytes, BbStats::WaitDma); + WaitDeferredSignalsFor(BbStats::WaitDma, dst, num_bytes, src, src_size); +} + +void Rasterizer::CopyBuffer(VAddr dst, VAddr src, u32 num_bytes, bool dst_gds, bool src_gds, + bool waited) { DrainDrawPipe(); // bbport BB_GUEST_IN_PLACE: see FillBuffer (in place: the GPU, in stream order). if (!dst_gds && !buffer_cache.IsAnyInPlace(dst, num_bytes) && !buffer_cache.IsRegionGpuModified(dst, num_bytes)) { if (!src_gds && !buffer_cache.IsRegionGpuModified(src, num_bytes) && !texture_cache.FindImageFromRange(src, num_bytes)) { + if (!waited) { + WaitBeforeDmaStore(dst, num_bytes, src, num_bytes); + } // Both buffers were not transferred to GPU yet. Can safely copy in host memory. BbFreeCheck::Check(dst, num_bytes, std::bit_cast(src), BbFreeCheck::DmaCopy); std::memcpy(std::bit_cast(dst), std::bit_cast(src), num_bytes); @@ -3824,7 +4807,10 @@ void Rasterizer::CopyBuffer(VAddr dst, VAddr src, u32 num_bytes, bool dst_gds, b return; } } - texture_cache.InvalidateMemoryFromGPU(dst, num_bytes); + { + const VideoCore::InvalidateReason reason{"DMA copy"}; + texture_cache.InvalidateMemoryFromGPU(dst, num_bytes); + } const auto* gds_buffer = buffer_cache.GetGdsBuffer(); const auto [src_buffer, src_offset] = [&] -> std::pair { if (src_gds) { @@ -3866,6 +4852,7 @@ bool Rasterizer::InvalidateMemory(VAddr addr, u64 size, bool assume_locks) { } void Rasterizer::NoteAssetWrite(VAddr addr, u64 size) { + const VideoCore::InvalidateReason reason{"asset or DMA"}; buffer_cache.NoteAssetWrite(addr, size); InvalidateAfterWrite(addr, size); } @@ -3884,6 +4871,7 @@ void Rasterizer::InvalidateAfterWrite(VAddr addr, u64 size) { } void Rasterizer::NoteCommandWrite(VAddr addr, const void* data, u64 size, bool recording_thread) { + const VideoCore::InvalidateReason reason{"command"}; if (VideoCore::WriteTracking() || !IsMapped(addr, size)) { return; // tracking catches it, or the GPU does not use it } @@ -3899,6 +4887,7 @@ void Rasterizer::NoteCommandWrite(VAddr addr, const void* data, u64 size, bool r } void Rasterizer::OnCpuWrite(VAddr addr, u64 size) { + const VideoCore::InvalidateReason reason{"CPU note"}; BbStats::cpu_write_notes.fetch_add(1, std::memory_order_relaxed); BbStats::cpu_write_note_bytes.fetch_add(size, std::memory_order_relaxed); if (IsMapped(addr, size)) { @@ -3907,9 +4896,31 @@ void Rasterizer::OnCpuWrite(VAddr addr, u64 size) { } } +bool Rasterizer::FaultSkipsTextureCache() { + static const bool enabled = [] { + const char* env = std::getenv("BB_FAULT_SKIP_TEXTURES"); + return !(env && env[0] == '0'); + }(); + return enabled; +} + bool Rasterizer::OnWriteFault(VAddr addr, bool assume_locks, u64 guest_rip) { + const VideoCore::InvalidateReason reason{"page fault"}; DrainDrawPipe(); - if (!InvalidateMemory(addr, 8, assume_locks)) { + // bbport: within the faulting page. 8 bytes from a write to the last bytes of a mapping + // reached past its end: IsMapped failed and the fault went unhandled (the game ended). + const u64 size = std::min(8, VideoCore::PageManager::GetNextPageAddr(addr) - addr); + // bbport: a page no tracked image watches (a buffer's page: most faults) needs nothing from + // the texture cache, whose lock the draw recording thread holds while it creates or uploads + // an image: a guest thread writing its constants waited for that (0.6 s with memory short: + // the frame with it). Images learn of CPU writes through their own page protection only + // (WatchedPages). BB_FAULT_SKIP_TEXTURES=0: the lock is taken for every fault, as before. + if (FaultSkipsTextureCache() && !texture_cache.WatchesPages(addr, size)) { + if (!IsMapped(addr, size)) { + return false; + } + buffer_cache.InvalidateMemory(addr, size, assume_locks); + } else if (!InvalidateMemory(addr, size, assume_locks)) { return false; } buffer_cache.ExtendWriteFault(addr, guest_rip); diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_rasterizer.h b/gpu/shadps4/video_core/renderer_vulkan/vk_rasterizer.h index 64f1e31..8a7069a 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_rasterizer.h +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_rasterizer.h @@ -36,6 +36,7 @@ class MemoryManager; namespace Vulkan { class GraphicsPipeline; +class Occlusion; class Runtime; class Rasterizer { @@ -56,6 +57,11 @@ public: return *upscaler; } + /// bbport: native HDR output's gain maps (vk_hdr_native.h), taken by the presenter. + [[nodiscard]] HdrNative& GetHdrNative() noexcept { + return *hdr_native; + } + [[nodiscard]] VideoCore::TextureCache& GetTextureCache() noexcept { return texture_cache; } @@ -72,6 +78,17 @@ public: void DispatchDirect(); void DispatchIndirect(VAddr address, u32 offset, u32 size); + /// bbport (GPU command thread): a ZPASS_DONE dump of `pipes` DBs to `va`, handed to the + /// recording side in order; false when the real counts are off (the caller writes the fake). + bool OcclusionDump(VAddr va, u32 pipes); + /// bbport (GPU command thread): SET_PREDICATION (its two body words); false when off. + bool OcclusionPredication(const u32* body); + /// bbport (GPU command thread): a guest submission starts: a predication region left open + /// by the previous one is closed. + void OcclusionSubmissionStart(); + /// bbport (GPU command thread, drained): dumps wait for the submission being recorded. + bool OcclusionUnsubmitted() const; + void ScopeMarker(fmt::string_view fmt, fmt::format_args args, auto&& func) { if (host_markers_enabled) { ScopeMarkerBegin(fmt::vformat(fmt, args)); @@ -88,11 +105,26 @@ public: void ScopedMarkerInsertColor(const std::string_view& str, const u32 color, bool from_guest = false); - void FillBuffer(VAddr address, u32 num_bytes, u32 value, bool is_gds); - void CopyBuffer(VAddr dst, VAddr src, u32 num_bytes, bool dst_gds, bool src_gds); + /// bbport: `waited` false: the caller (RunDmaData) skipped the waits a CPU store needs, as + /// DmaWritesOnCpu said the GPU writes it; should the store be made on the CPU after all, + /// they are made here first. + void FillBuffer(VAddr address, u32 num_bytes, u32 value, bool is_gds, bool waited = true); + void CopyBuffer(VAddr dst, VAddr src, u32 num_bytes, bool dst_gds, bool src_gds, + bool waited = true); /// bbport: whether FillBuffer/CopyBuffer into guest memory at `dst` may write it on the CPU /// now (otherwise the GPU writes it, in stream order). [[nodiscard]] bool DmaMayWriteOnCpu(VAddr dst, u32 num_bytes); + /// bbport (BB_DMA_WAITS, default: only when needed; =all: every DMA into memory waits as + /// before): whether a DMA into guest memory at `dst` will store with the CPU: the same tests + /// FillBuffer (`fill`) and CopyBuffer make. Recorded for the GPU it writes no guest memory now + /// and lands after the submission, which goes out after the copies and fences before it: it + /// needs neither WaitHostCopiesFor nor WaitDeferredSignals. Stage B owns what it tests. + [[nodiscard]] bool DmaWritesOnCpu(VAddr dst, u32 num_bytes, bool fill, VAddr src, + bool src_gds); + /// bbport: what a DMA stored by the CPU into [dst, dst + num_bytes) waits for first: host + /// copies still reading it, and the deferred fences it must follow (reading [src, src + + /// src_size) for a copy). + void WaitBeforeDmaStore(VAddr dst, u32 num_bytes, VAddr src, u32 src_size); u32 ReadDataFromGds(u32 gsd_offset); bool InvalidateMemory(VAddr addr, u64 size, bool assume_locks = false); /// bbport: data written into GPU memory by a path we hear of (file reads, the game's resource @@ -122,22 +154,23 @@ public: /// over pages the caches watch (no write tracking): what a write fault did. void OnCpuWrite(VAddr addr, u64 size); /// GPU thread, before a write the guest can observe (see Scheduler::WaitHostCopies). - void WaitHostCopies() { + /// `site`: the caller (BbStats::WaitSite, frame statistics). + void WaitHostCopies(u32 site = BbStats::WaitOther) { DrainDrawPipe(); - scheduler.WaitHostCopies(); + scheduler.WaitHostCopies(site); } /// Before a write into [address, address + size): waits only when a host copy may still read /// it (Scheduler::WaitHostCopiesFor). BB_HOST_COPY_WAITS=all: always (the old behaviour). - void WaitHostCopiesFor(VAddr address, u64 size) { + void WaitHostCopiesFor(VAddr address, u64 size, u32 site = BbStats::WaitOther) { DrainDrawPipe(); static const bool all = [] { const char* env = std::getenv("BB_HOST_COPY_WAITS"); return env && std::string_view{env} == "all"; }(); if (all) { - scheduler.WaitHostCopies(); + scheduler.WaitHostCopies(site); } else { - scheduler.WaitHostCopiesFor(address, size); + scheduler.WaitHostCopiesFor(address, size, site); } } /// Notes the guest memory a host copy issued now reads (see Scheduler::NoteHostCopySource). @@ -156,8 +189,10 @@ public: /// Returns true when the task was handed to the recording thread (not run yet). /// `reads_guest_memory`: the task may read guest memory (a CPU write on stage A over memory /// waits for it, WaitForPendingReads); false for fences, flips and signals. + /// `name`: the task in the "stage B tasks" statistics (BB_SECTIONS). bool RunInOrder(OrderedTask task, const void* data, u32 size, - u64 toggle = BbToggle::PipelinedTasks, bool reads_guest_memory = true); + u64 toggle = BbToggle::PipelinedTasks, bool reads_guest_memory = true, + const char* name = nullptr); /// Stage A: the draw pipe position after the last handed-over packet, and whether the /// recording thread has run everything before a position. [[nodiscard]] u64 DrawPipeHead() const { @@ -179,13 +214,31 @@ public: /// Stage A: guest memory the recording thread will read for the next packet (buffer bindings). void NotePendingRead(VAddr address, u64 size); /// Before a guest-visible write: fences deferred earlier are written first. - void WaitDeferredSignals() { - scheduler.WaitDeferredSignals(); + void WaitDeferredSignals(u32 site = BbStats::WaitOther) { + scheduler.WaitDeferredSignals(site); + } + /// bbport: before a CPU write of the command processor (WriteData, DMA): only the deferred + /// fences it must follow (Scheduler::WaitDeferredSignalsFor, BB_FENCE_ORDER). + void WaitDeferredSignalsFor(u32 site, VAddr address, u64 size, VAddr src = 0, + u64 src_size = 0) { + scheduler.WaitDeferredSignalsFor(site, address, size, src, src_size); } + /// bbport (stage A): a WaitRegMem was met by a fence still deferred: the command processor + /// would have waited for it here (Scheduler::NoteSignalBarrier, in order on stage B). + void NoteFenceBarrier(); /// Runs `signal` after the guest memory copies issued so far, without waiting here. - void SignalAfterHostCopies(std::function signal) { - scheduler.SignalAfterHostCopies(std::move(signal)); + /// `kick` false: another one is queued right after (Scheduler::SignalAfterHostCopies). + /// `label`, `label_size`: the guest memory it writes (none: unknown). + template + void SignalAfterHostCopies(Func&& signal, bool kick = true, VAddr label = 0, + u32 label_size = 0) { + scheduler.SignalAfterHostCopies(std::forward(signal), kick, label, label_size); } + /// Stage B, running a packet: whether the next one is already committed and is ordered + /// task `task` (it runs right after this one). + [[nodiscard]] bool NextPacketIsTask(OrderedTask task) const; + /// Stage B: fences handed to the recording threads together with the next one (statistics). + std::atomic merged_fence_kicks{0}; /// bbport BB_HONEST_LABELS=1: runs `signal` once the GPU has finished every command recorded /// before this call (the submission of the current tick), in call order, on a thread that /// waits on the work semaphore: fences the guest sees when the work is done, not when it is @@ -231,6 +284,9 @@ public: /// A guest write hit a protected page. /// `guest_rip`: the guest instruction that wrote (0: unknown), see BufferCache::NoteCpuWrite. bool OnWriteFault(VAddr addr, bool assume_locks, u64 guest_rip = 0); + /// bbport: write faults on pages no tracked image watches skip the texture cache + /// (BB_FAULT_SKIP_TEXTURES=0: they take its lock as before). + static bool FaultSkipsTextureCache(); bool ReadMemory(VAddr addr, u64 size, bool assume_locks = false); void ProcessDownloadImages(); bool IsMapped(VAddr addr, u64 size); @@ -292,8 +348,22 @@ private: u32 scene_size = 0; std::array target_scale{}; std::array target_offset{}; + /// The targets' images as memoized (signature.ids): after other images were + /// (un)registered the memo still holds while each is the same registered image with + /// the same backing (RevalidateBeginMemo). + std::array uids{}; + std::array backings{}; + u64 scene_generation = 0; ///< SceneTargets::Generation (proxy attachments) } begin_memo; u64 begin_memo_hits = 0, begin_memo_misses = 0; + u64 begin_memo_revalidated = 0; ///< hits after a registry generation change + /// Takes begin_memo into `signature`'s registry generation when nothing else differs and + /// its targets are unchanged. + bool RevalidateBeginMemo(const BeginSignature& signature); + void RememberBeginTargets(); + /// bbport: BB_MEMO_REVALIDATE=0 drops memoized texture/target lookups on every image + /// (un)registration as before, instead of revalidating them (IsExactMatch). + static bool MemoRevalidation(); void Resolve(); void DepthStencilCopy(bool is_depth, bool is_stencil); void EliminateFastClear(); @@ -331,6 +401,14 @@ private: const IndirectDraw* indirect = nullptr); /// Everything of a direct dispatch after the pipeline selection (GPU thread or stage B). void DispatchRecord(const ComputePipeline* pipeline); + /// bbport (docs/pipeline_compiles.md): whether the direct draw being recorded, whose + /// pipeline is still compiling, is left out: scene geometry, for a bounded time. + bool SkipUncompiledDraw(const GraphicsPipeline* pipeline); + /// bbport: for a pipeline still compiling, records its bind as a command of its own, which a + /// recording thread runs once the pipeline is there (it waits for it): returns null, the + /// draw or dispatch command binds nothing. Recording on this thread: waits here and returns + /// the handle. + vk::Pipeline BindWhenCompiled(const Pipeline* pipeline, vk::PipelineBindPoint bind_point); /// The compute registers of the dispatch being recorded. const AmdGpu::ComputeProgram& CsRegs() const; static void RunDrawPacket(void* rasterizer, const u8* packet, u32 size); @@ -350,14 +428,38 @@ private: void BindTextures(const Shader::Info& stage, const PreparedStage* prepared, Shader::Backend::Bindings& binding, u32& write_index, bool& barrier, bool on_helper = false); + /// BB_SECTIONS: a full texture lookup over 0.3 ms, per texture (BindTextures' timestamps). + void ReportSlowTextureBind(const Shader::Info& stage, u64 start, std::span pass1, + std::span pass2); bool BindResources(const Pipeline* pipeline); void ForceAnisotropy(AmdGpu::Sampler& sharp, bool is_depth) const; void BindSamplers(const Shader::Info& stage, const PreparedStage* prepared, Shader::Backend::Bindings& binding, u32& write_index); + /// bbport: whether stage reads reduced scene proxies through their native images instead: + /// it has a point-filtered sampler and its pass is at the native size (BindTextures). + bool PointReadsNative(const Shader::Info& stage, const PreparedStage* prepared) const; + /// bbport (SamplerMemo): TextureCache::GetSampler hashed, locked and looked the sampler up + /// for every sampler of every draw. Direct-mapped by the S# (after ForceAnisotropy), the + /// depth flag and the extra bias: what GetSampler returned for them, while no sampler was + /// freed since (SamplerGeneration), touched for the collector once per GC period. Only the + /// thread binding textures uses it (stage B, or the bind helper while stage B waits for it). + /// BB_SAMPLER_MEMO=0: GetSampler every time. + struct SamplerMemoEntry { + std::array sharp{}; + u64 variant = ~0ull; ///< is_depth << 32 | the extra bias's bits + vk::Sampler handle{}; + size_t lru_id = 0; + u64 generation = ~0ull; ///< TextureCache::SamplerGeneration when looked up + u64 touched_tick = ~0ull; ///< TextureCache::GcTick of the last LRU touch + }; + std::array sampler_memo{}; + u64 sampler_memo_hits = 0, sampler_memo_misses = 0; + vk::Sampler CachedSampler(const AmdGpu::Sampler& sharp, bool is_depth, float lod_bias); /// bbport: a stage's resolved textures remembered by its prepared T# hashes /// (TextureSetMemo): a hit only redoes the per-draw effects (binding flags, transitions). struct TextureSetEntry { VideoCore::ImageId id{}; ///< after the depth redirect; null descriptor when invalid + VideoCore::ImageId found{}; ///< what FindImage returned (before the depth redirect) vk::ImageView view; const void* backing = nullptr; VideoCore::SubresourceRange range; @@ -369,18 +471,33 @@ private: u64 generation = ~0ull; u64 scene_generation = 0; ///< SceneTargets::Generation of the proxy views u32 count = 0; + /// Every lookup was an exact match (TextureCache::IsExactMatch) and every null + /// descriptor a null T#: the set survives other images' (un)registration while its + /// images stay registered (RevalidateTextureSet). + bool exact = false; static constexpr u32 MaxImages = 16; ///< larger sets are not memoized std::array hashes{}; std::array entries{}; + std::array found_uids{}; ///< image uids of entries[].found }; std::array texture_sets{}; u64 texture_set_hits = 0, texture_set_misses = 0; std::array texture_set_why{}; ///< misses: other key, generation, image check, new + /// bbport: stages that did not try the set: unprepared, no images, over MaxImages, a storage + /// image or mip override, memo off (toggle, frame capture). + std::array texture_set_skip{}; + /// Hits after a registry generation change: texture sets, and single texture lookups + /// (ImageDescCacheEntry) outside them. + u64 texture_set_revalidated = 0, texture_lookups_revalidated = 0; + bool RevalidateTextureSet(TextureSet& set, u64 generation); /// Returns true when the stage's images were bound from the memo. bool BindTexturesFromSet(const Shader::Info& stage, const PreparedStage* prepared, u32 first_image_idx, bool& barrier, TextureSet*& slot); /// The prepared sharps of `stage` in the current prepared draw, if they fit it. const PreparedStage* FindPreparedStage(const Shader::Info& stage) const; + /// The same for any prepared draw (the lookahead thread). + static const PreparedStage* FindPreparedStage(const PreparedDraw* prepared, + const Shader::Info& stage); /// Whether the helper may bind textures for this pipeline at all (fixed descriptor layout, /// no storage writes that invalidate cached images). bool HelperEligible(const Pipeline* pipeline) const; @@ -428,10 +545,14 @@ private: std::unique_ptr camera_motion; // bbport: motion vectors (docs/upscaler.md) std::unique_ptr scene_targets; bool scene_started = false; + /// bbport: the scene depth of the frame before (SkipUncompiledDraw). + VideoCore::ImageId previous_scene_depth{}; std::unique_ptr object_motion; bool motion_draw = false; u64 motion_geometry{}; ///< vertex-stream identity of the current direct draw bool gbuffer_draw = false; + std::unique_ptr hdr_native; // bbport: native HDR output (docs/hdr.md); before + // the upscaler, which uses it std::unique_ptr upscaler; // bbport: FSR (docs/upscaler.md) std::array draw_jitter{}; ///< viewport offset of the current draw, pixels std::array target_offset{}; @@ -470,6 +591,11 @@ private: // Memoized FindImage for bindings without mip overrides (TextureBindingMemo). u64 found_generation = ~0ULL; VideoCore::ImageId found_id{}; + /// found_id was an exact match (TextureCache::IsExactMatch, found_desc unchanged by + /// FindImage): the memo outlives other images' (un)registration while image found_uid + /// stays registered (LookupMemoized). + bool found_exact = false; + u64 found_uid = 0; VideoCore::TextureCache::ImageDesc found_desc; u64 pinned = 0; ///< bind_epoch of the BindTextures call referencing found_desc u64 last_use = 0; @@ -486,11 +612,15 @@ private: u64 bind_epoch = 0; // bbport: render/depth target lookups memoized by their raw register bytes while image // registrations are unchanged (the descriptions depend only on those registers). + // An exact match (TextureCache::IsExactMatch, description unchanged by FindImage) outlives + // other images' (un)registration while image `uid` stays registered (RevalidateTarget). struct TargetMemo { std::array key{}; u32 key_size = 0; u64 generation = ~0ULL; VideoCore::ImageId image_id{}; + bool exact = false; + u64 uid = 0; VideoCore::TextureCache::ImageDesc desc; }; std::array target_memo{}; @@ -499,10 +629,15 @@ private: struct LastTarget { std::array key{}; u32 key_size = 0; - u64 generation = ~0ULL; + u64 generation = ~0ULL; ///< ~0: invalid (FindImage may have rewritten the description) VideoCore::ImageId image_id{}; + bool exact = false; + u64 uid = 0; }; std::array last_targets{}; ///< CBs, then DB + u64 target_lookups_revalidated = 0; + template + bool RevalidateTarget(Memo& memo, u64 generation); template VideoCore::ImageId FindTargetMemoized(VideoCore::TextureCache::ImageDesc& desc, LastTarget& last, auto&& make_desc, @@ -512,6 +647,9 @@ private: /// `hash` is ImageDescHash(sharp, res), computed by a draw-preparation worker or here. ImageDescCacheEntry& CachedImageDescEntry(const AmdGpu::Image& sharp, const Shader::ImageResource& res, u64 hash); + /// Whether the entry's FindImage memo holds in registry generation `generation`: memoized + /// in it, or an exact match still registered (then taken into it). + bool LookupMemoized(ImageDescCacheEntry& entry, u64 generation); ImageDescCacheEntry& CachedImageDescEntry(const AmdGpu::Image& sharp, const Shader::ImageResource& res) { return CachedImageDescEntry(sharp, res, ImageDescHash(sharp, res)); @@ -650,7 +788,22 @@ private: std::unique_ptr constant_ring; /// Submissions (prepared draws) kept alive until stage B reaches the position. std::deque>> pipe_keepalive; + /// bbport: real occlusion query results (vk_occlusion.h); null with BB_OCCLUSION=0. Before + /// the draw pipe: its thread (which runs the dumps) stops first. + std::unique_ptr occlusion; std::unique_ptr draw_pipe; + /// bbport BB_DRAWREC_WORKERS=1 (docs/parallel-stageb.md): the lookahead thread bb:DrawAhead + /// walks the draw pipe a few packets ahead of stage B and asks the cache for the lines its + /// next draws read first (texture set memo slots, image slots, page tracking words, the + /// arena table). It reads the packets and their prepared draws only and changes nothing: + /// stage B's work and order are as without it. After draw_pipe: it stops before the ring goes. + static bool LookaheadWanted(); + static u32 LookaheadWindow(); + void LookaheadLoop(std::stop_token stop); + /// The prefetches for one packet; false when it holds nothing to prefetch for. + bool PrefetchPacket(const u8* data, u32 size); + u32 PrefetchDraw(const u8* data, u32 size); + std::jthread lookahead_thread; /// BB_HONEST_LABELS (SignalAfterGpu). Last: its thread stops before the scheduler goes. struct GpuSignal { diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_scene_resolution.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_scene_resolution.cpp index ee9ff32..6d2611d 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_scene_resolution.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_scene_resolution.cpp @@ -199,6 +199,19 @@ void SceneTargets::Copy(Entry& e, VideoCore::Image& original, bool to_native) { s32(std::max(1u, original.info.size.height >> e.level)), 1}; const vk::Offset3D reduced{s32(e.image.image_ci.extent.width), s32(e.image.image_ci.extent.height), 1}; + // bbport: a resolve of color is a bilinear upscale where the format filters linearly (never + // integer formats): native-size passes after the upscaler read these images (effects, + // post-processing), and nearest made them blocky. Fills to the proxy and depth/stencil stay + // nearest. BB_SCENE_LINEAR_RESOLVE=0 (or experiment bit 1): nearest throughout, as before. + static const bool linear_resolve = [] { + const char* env = std::getenv("BB_SCENE_LINEAR_RESOLVE"); + return !(env && env[0] == '0'); + }(); + const bool linear = + linear_resolve && !BbToggle::Experiment(2) && to_native && + original.aspect_mask == vk::ImageAspectFlags{vk::ImageAspectFlagBits::eColor} && + bool(Features(original.backing->image.image_ci.format) & + vk::FormatFeatureFlagBits::eSampledImageFilterLinear); for (auto aspect : {vk::ImageAspectFlagBits::eColor, vk::ImageAspectFlagBits::eDepth, vk::ImageAspectFlagBits::eStencil}) { if (!(original.aspect_mask & aspect)) continue; @@ -210,9 +223,10 @@ void SceneTargets::Copy(Entry& e, VideoCore::Image& original, bool to_native) { }; // Nearest preserves depth/stencil and avoids assuming linear blit support for // integer G-buffer formats. FSR reconstructs color at the HDR scene boundary. - scheduler.Record([src,dst,region](vk::CommandBuffer cmd) { + const vk::Filter filter = linear ? vk::Filter::eLinear : vk::Filter::eNearest; + scheduler.Record([src,dst,region,filter](vk::CommandBuffer cmd) { cmd.blitImage(src, vk::ImageLayout::eTransferSrcOptimal, dst, - vk::ImageLayout::eTransferDstOptimal, region, vk::Filter::eNearest); + vk::ImageLayout::eTransferDstOptimal, region, filter); }); } if (to_native) e.state.Resolved(); else e.state.CopiedToProxy(); diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_scheduler.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_scheduler.cpp index 06eadfd..d31ac91 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_scheduler.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_scheduler.cpp @@ -17,6 +17,7 @@ #include "bbport_copy.h" #include "bbport_platform.h" +#include "bbport_sections.h" #include "video_core/renderer_vulkan/vk_gpu_profiler.h" #include "bbport_toggles.h" #include "bbport_wait_trace.h" @@ -322,37 +323,165 @@ std::unique_ptr Scheduler::AcquireChunk() { return chunk; } -void Scheduler::SignalAfterHostCopies(std::function signal) { - // A direct segment queues it too: signals queued before it must not be overtaken. - if (!IsRecordingDeferred() && !direct_segment) { - WaitHostCopies(); - signal(); +namespace { +/// bbport BB_HOST_WAIT_SPIN_US (default 100; 0: the loops as before): the waits for host copies +/// and deferred signals spin with pause for this long before they yield (Sleep(0) on Windows: a +/// system call each, and the core may go to another thread for a while), and help the copy +/// threads one queued task at a time (BbCopy::HelpOne) instead of BbCopy::WaitAsync, which also +/// waits for copies issued after the signal. +u32 HostWaitSpinUs() { + static const u32 us = [] { + const char* env = std::getenv("BB_HOST_WAIT_SPIN_US"); + return env && env[0] ? static_cast(std::max(0, std::atoi(env))) : 100u; + }(); + return us; +} + +/// Waits until `done()`. `help`: runs queued copy tasks meanwhile (the signals wait for them). +template +void WaitUntil(Done&& done, bool help) { + const u32 spin_us = HostWaitSpinUs(); + if (spin_us == 0) { + while (!done()) { + if (help) { + BbCopy::WaitAsync(); + } + std::this_thread::yield(); + } return; } - BbCopy::FlushBatch(); - deferred_signals_issued.fetch_add(1, std::memory_order_relaxed); - RecordOrdered([signal = std::move(signal), done = deferred_signals_done]() mutable { - BbCopy::AfterCopies([signal = std::move(signal), done = std::move(done)] { - signal(); - done->fetch_add(1, std::memory_order_release); - }); - }); - KickRecording(true); + const auto spin_until = std::chrono::steady_clock::now() + std::chrono::microseconds(spin_us); + bool spinning = true; + for (u32 spins = 1; !done(); ++spins) { + if (help && (spins & 63) == 1 && BbCopy::HelpOne()) { + continue; + } + if (!spinning) { + std::this_thread::yield(); + continue; + } + __builtin_ia32_pause(); + // The clock is read every 256 pauses, not per iteration. + if (!(spins & 255) && std::chrono::steady_clock::now() >= spin_until) { + spinning = false; + } + } } -void Scheduler::WaitDeferredSignals() { - const u64 issued = deferred_signals_issued.load(std::memory_order_relaxed); - if (deferred_signals_done->load(std::memory_order_acquire) >= issued || +/// bbport: a wait that blocked, timed into the "host copies" share of the frame statistics and per +/// call site (the "Stage B waits" line). +struct SiteWait { + std::atomic& count; + std::atomic& ns; + std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); + ~SiteWait() { + const u64 spent = static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count()); + BbStats::host_copies_wait_ns.fetch_add(spent, std::memory_order_relaxed); + count.fetch_add(1, std::memory_order_relaxed); + ns.fetch_add(spent, std::memory_order_relaxed); + } +}; + +u32 ClampSite(u32 site) { + return std::min(site, BbStats::WaitOther); +} + +bool FenceOrderEnv() { + static const bool on = [] { + // bbport: ordered unless BB_FENCE_ORDER=all (the opt-out after the beta A/B). + const char* env = std::getenv("BB_FENCE_ORDER"); + const bool ordered = !(env && std::strcmp(env, "all") == 0); + if (ordered) { + std::printf("GPU: WriteData and DMA wait only for the fences the command processor " + "would have written before them (BB_FENCE_ORDER=all turns it off)\n"); + } + return ordered; + }(); + return on; +} +} // namespace + +bool Scheduler::FenceOrderScoped() { + return FenceOrderEnv() || BbToggle::Experiment(8); +} + +bool Scheduler::FenceOrderPossible() { + static const bool possible = FenceOrderEnv() || std::getenv("BB_TOGGLE_FILE") != nullptr; + return possible; +} + +void Scheduler::WaitDeferredSignals(u32 site) { + WaitDeferredSignalsUpTo(deferred_signals_issued.load(std::memory_order_relaxed), site); +} + +void Scheduler::WaitDeferredSignalsUpTo(u64 need, u32 site) { + if (deferred_signals_done->load(std::memory_order_acquire) >= need || BbToggle::Disabled(BbToggle::OrderedGuestWrites)) { return; } - BbStats::WaitTimer timer{BbStats::host_copies_wait_ns}; + site = ClampSite(site); + BbSections::Scope scope{BbSections::WaitSignals}; + SiteWait timer{BbStats::signal_waits[site], BbStats::signal_wait_ns[site]}; KickRecording(true); - while (deferred_signals_done->load(std::memory_order_acquire) < issued) { - // Helps the copy threads the signals wait for. - BbCopy::WaitAsync(); - std::this_thread::yield(); + // Helps the copy threads the signals wait for. + WaitUntil([&] { return deferred_signals_done->load(std::memory_order_acquire) >= need; }, + true); +} + +void Scheduler::WaitDeferredSignalsFor(u32 site, u64 address, u64 size, u64 src, u64 src_size) { + if (!FenceOrderScoped()) { + WaitDeferredSignals(site); + return; + } + const u64 issued = deferred_signals_issued.load(std::memory_order_relaxed); + const u64 done = deferred_signals_done->load(std::memory_order_acquire); + if (done >= issued) { + return; + } + // Signals complete in the order they were issued (BbCopy::AfterCopies keeps it): waiting for + // the newest one needed covers the ones before it. + u64 need = std::min(signal_barrier.load(std::memory_order_relaxed), issued); + need = std::max(need, NewestLabelOverlap(done, address, size)); + if (src_size != 0) { + need = std::max(need, NewestLabelOverlap(done, src, src_size)); + } + if (need <= done) { + BbStats::signal_waits_skipped[ClampSite(site)].fetch_add(1, std::memory_order_relaxed); + return; } + WaitDeferredSignalsUpTo(need, site); +} + +void Scheduler::NoteDeferredLabel(u64 label, u32 label_size, u64 seq) { + if (label_size == 0) { + // Not a label write (GPU idle, an interrupt, a GDS store): nothing may overtake it. + signal_barrier.store(seq, std::memory_order_relaxed); + return; + } + auto& entry = deferred_labels[deferred_label_count++ % DeferredLabels]; + deferred_label_lost = std::max(deferred_label_lost, entry.seq); + entry = {label, label_size, seq}; +} + +u64 Scheduler::NewestLabelOverlap(u64 done, u64 address, u64 size) const { + if (size == 0) { + return 0; + } + // An entry overwritten while its signal may still be pending counts as overlapping. + u64 newest = deferred_label_lost > done ? deferred_label_lost : 0; + const u64 count = std::min(deferred_label_count, DeferredLabels); + for (u64 i = 0; i < count; ++i) { + const auto& entry = deferred_labels[(deferred_label_count - 1 - i) % DeferredLabels]; + if (entry.seq <= done) { + break; // this one and the older ones are written + } + if (entry.address < address + size && address < entry.address + entry.size) { + newest = std::max(newest, entry.seq); + } + } + return newest; } namespace { @@ -386,7 +515,7 @@ void Scheduler::NoteHostCopySource(u64 address, u64 size) { } } -void Scheduler::WaitHostCopiesFor(u64 address, u64 size) { +void Scheduler::WaitHostCopiesFor(u64 address, u64 size, u32 site) { { std::scoped_lock lk{host_copy_sources_mutex}; bool overlap = host_copy_big_seq > host_copy_sources_done; @@ -405,10 +534,10 @@ void Scheduler::WaitHostCopiesFor(u64 address, u64 size) { return; } } - WaitHostCopies(); + WaitHostCopies(site); } -void Scheduler::WaitHostCopies() { +void Scheduler::WaitHostCopies(u32 site) { u64 sources_seen; { std::scoped_lock lk{host_copy_sources_mutex}; @@ -423,13 +552,15 @@ void Scheduler::WaitHostCopies() { s.host_copy_sources_done = std::max(s.host_copy_sources_done, seen); } } forget{*this, sources_seen}; + BbSections::Scope scope{BbSections::WaitCopies}; if (host_copies_done.load(std::memory_order_acquire) < host_copies_issued) { - BbStats::WaitTimer timer{BbStats::host_copies_wait_ns}; + site = ClampSite(site); + SiteWait timer{BbStats::copy_waits[site], BbStats::copy_wait_ns[site]}; BbStats::host_copy_waits.fetch_add(1, std::memory_order_relaxed); KickRecording(true); - while (host_copies_done.load(std::memory_order_acquire) < host_copies_issued) { - std::this_thread::yield(); - } + const u64 issued = host_copies_issued; + WaitUntil( + [&] { return host_copies_done.load(std::memory_order_acquire) >= issued; }, false); } BbStats::WaitTimer timer{BbStats::copy_threads_wait_ns}; BbCopy::WaitAsync(); @@ -449,7 +580,7 @@ void Scheduler::KickRecording(bool force) { MaybeSplit(); } // Batches of tens of KiB keep the queue handoff cheap relative to the work it carries. - if (!force && full_chunks.empty() && record_chunk->Size() < 32 * 1024) { + if (!force && full_chunks.empty() && CurrentChunk().Size() < 32 * 1024) { return; } HandOver(); @@ -461,7 +592,7 @@ void Scheduler::MaybeSplit() { return; } // Cutting inside a render pass closes and reopens it: only after a longer stretch. - const size_t bytes = segment_bytes + record_chunk->Size(); + const size_t bytes = segment_bytes + CurrentChunk().Size(); if (bytes < (is_rendering ? 2 * split_bytes : split_bytes)) { return; } @@ -510,6 +641,11 @@ void Scheduler::HandOver() { if (!commands && !ordered) { return; } + // The chunks that take the current ones' places, taken first (AcquireChunk locks + // recorder_mutex) and swapped in below: record_chunk and ordered_chunk are never null, not + // even for a fault handler recording on this thread meanwhile (deadinside28 10baf9d, #100). + std::unique_ptr next_record = record_chunk->Empty() ? nullptr : AcquireChunk(); + std::unique_ptr next_ordered = ordered_chunk->Empty() ? nullptr : AcquireChunk(); Worker& worker = *workers[current_segment % workers.size()]; bool wake = false; { @@ -521,11 +657,12 @@ void Scheduler::HandOver() { worker.queue.push_back(std::move(chunk)); ++batch->handed[current_segment]; } - if (!record_chunk->Empty()) { - segment_bytes += record_chunk->Size(); - record_chunk->segment = current_segment; - record_chunk->batch = batch; - worker.queue.push_back(std::move(record_chunk)); + if (next_record) { + record_chunk.swap(next_record); // next_record: the chunk handed over + segment_bytes += next_record->Size(); + next_record->segment = current_segment; + next_record->batch = batch; + worker.queue.push_back(std::move(next_record)); ++batch->handed[current_segment]; } worker.queued.store(worker.queue.size(), std::memory_order_release); @@ -542,8 +679,9 @@ void Scheduler::HandOver() { for (auto& chunk : ordered_full) { queue(std::move(chunk)); } - if (!ordered_chunk->Empty()) { - queue(std::move(ordered_chunk)); + if (next_ordered) { + ordered_chunk.swap(next_ordered); // next_ordered: the chunk handed over + queue(std::move(next_ordered)); } ordered_queued.store(ordered_queue.size(), std::memory_order_release); // Any thread may run them; the current segment's thread is woken for them (when it @@ -622,7 +760,7 @@ void Scheduler::EnterDirectMode() { } // A segment nothing was handed to yet is not begun: it becomes the direct one. const bool untouched = - batch->handed[current_segment] == 0 && full_chunks.empty() && record_chunk->Empty(); + batch->handed[current_segment] == 0 && full_chunks.empty() && CurrentChunk().Empty(); if (!untouched) { // Its recording thread ends the segment's command buffer (the pool is that thread's). Record([](vk::CommandBuffer cmdbuf) { Check(cmdbuf.end()); }); @@ -675,6 +813,7 @@ void Scheduler::RecorderThread(std::stop_token stoken, u32 index) { std::snprintf(name, sizeof(name), "bb:VkRecorder%u", index); Common::SetCurrentThreadName(name); } + BbThreads::MakeCritical(); Worker& worker = *workers[index]; std::vector> ordered; const auto has_work = [&] { @@ -687,7 +826,7 @@ void Scheduler::RecorderThread(std::stop_token stoken, u32 index) { // take time from guest threads. The others sleep until a new segment reaches them. if (active_worker.load(std::memory_order_relaxed) == index) { static const auto spin_time = - std::chrono::microseconds(BbThreads::Available() >= 12 ? 200 : 20); + std::chrono::microseconds(BbThreads::CapSpinUs(BbThreads::Available() >= 12 ? 200 : 20)); const auto spin_until = std::chrono::steady_clock::now() + spin_time; for (u32 spins = 1; worker.queued.load(std::memory_order_acquire) == 0 && ordered_queued.load(std::memory_order_acquire) == 0; diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_scheduler.h b/gpu/shadps4/video_core/renderer_vulkan/vk_scheduler.h index a0c6485..1d42432 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_scheduler.h +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_scheduler.h @@ -19,6 +19,7 @@ #include #include +#include "bbport_copy.h" #include "bbport_toggles.h" #include "common/assert.h" #include "common/interval_set.h" @@ -601,6 +602,37 @@ struct SubmissionBatch { class RecordChunk { public: static constexpr size_t Capacity = 128 * 1024; + /// Room kept free in the chunk for the command that consumes reserved data. + static constexpr size_t CommandReserve = 1024; + + /// Makes room for `bytes` of Allocate() data. Groups that fit stay inline (no allocation); + /// a group larger than an empty chunk (e.g. thousands of buffer copies or barriers) gets + /// one uninitialized heap block owned by this chunk, freed when the chunk is recycled, so + /// no span outlives the commands that read it. False: retire this chunk and retry. + /// Ported from AwesomeObserver/bloodborne_pc 54bd756 (Evgeniy Doronin, GPL-2.0-or-later); + /// it replaces an ASSERT that ended the game with exit code 23. + bool ReserveData(size_t bytes) { + const size_t remaining = Capacity - used; + if (remaining < CommandReserve) { + return false; + } + if (spill_remaining >= bytes) { + return true; + } + if (bytes <= remaining - CommandReserve) { + spill_cursor = nullptr; + spill_remaining = 0; + return true; + } + if (bytes <= Capacity - CommandReserve) { + return false; + } + std::unique_ptr block{new std::byte[bytes]}; + spill_cursor = block.get(); + spill_remaining = bytes; + spill_blocks.push_back(std::move(block)); + return true; + } /// Returns false (and leaves `func` untouched) when the chunk has no room. template @@ -625,8 +657,19 @@ public: /// Raw storage in the chunk for a command's variable-length data; null when full. void* Allocate(size_t bytes, size_t align) { + if (spill_cursor) { + void* result = spill_cursor; + size_t remaining = spill_remaining; + if (!std::align(align, bytes, result, remaining)) { + return nullptr; + } + spill_used += spill_remaining - remaining + bytes; + spill_cursor = static_cast(result) + bytes; + spill_remaining = remaining - bytes; + return result; + } const size_t offset = (used + align - 1) & ~(align - 1); - if (offset + bytes > Capacity) { + if (offset > Capacity || bytes > Capacity - offset) { return nullptr; } used = offset + bytes; @@ -643,6 +686,9 @@ public: } first = last = nullptr; used = 0; + spill_cursor = nullptr; + spill_remaining = spill_used = 0; + spill_blocks.clear(); } [[nodiscard]] bool Empty() const noexcept { @@ -650,7 +696,7 @@ public: } [[nodiscard]] size_t Size() const noexcept { - return used; + return used + spill_used; } u32 segment = 0; ///< the command buffer segment the chunk is recorded into (Scheduler) @@ -687,6 +733,9 @@ private: alignas(64) std::byte storage[Capacity]; size_t used = 0; + std::vector> spill_blocks; ///< oversized data (ReserveData) + std::byte* spill_cursor = nullptr; + size_t spill_remaining = 0, spill_used = 0; CommandBase* first{}; CommandBase* last{}; }; @@ -812,9 +861,9 @@ public: func(current_cmdbuf); return; } - if (!record_chunk->Push(std::forward(func))) { + if (!CurrentChunk().Push(std::forward(func))) { RetireChunk(); - const bool pushed = record_chunk->Push(std::forward(func)); + const bool pushed = CurrentChunk().Push(std::forward(func)); ASSERT(pushed); } } @@ -861,12 +910,15 @@ public: /// Makes room for `bytes` of RecordData() plus the command that uses them in the current /// chunk: data and command must share a chunk, which is recycled once executed. void ReserveRecordData(size_t bytes) { + ProducerScope producer{*this, "ReserveRecordData"}; if (!IsRecordingDeferred()) { return; } - ASSERT(bytes + 1024 <= RecordChunk::Capacity); - if (RecordChunk::Capacity - record_chunk->Size() < bytes + 1024) { + // Oversized groups spill into a block owned by the chunk (AwesomeObserver 54bd756). + if (!record_chunk->ReserveData(bytes)) { RetireChunk(); + const bool reserved = record_chunk->ReserveData(bytes); + ASSERT_MSG(reserved, "Cannot reserve {} bytes in an empty recording chunk", bytes); } } @@ -882,6 +934,7 @@ public: const size_t bytes = data.size_bytes(); ReserveRecordData(bytes + alignof(T)); auto* dst = static_cast(record_chunk->Allocate(bytes, alignof(T))); + ASSERT_MSG(dst, "Cannot allocate {} recorded bytes, alignment {}", bytes, alignof(T)); std::memcpy(dst, data.data(), bytes); return {dst, data.size()}; } @@ -898,8 +951,8 @@ public: /// bbport: guest memory copies on the recording thread (small ones, RecordHostCopy) or the /// copy threads (BbCopy::Async) must be done before the guest learns the GPU is past them /// (it may then rewrite the memory, e.g. UI vertices: flickering) and before the - /// submission that reads them. Waits for all. - void WaitHostCopies(); + /// submission that reads them. Waits for all. `site`: the caller (BbStats::WaitSite). + void WaitHostCopies(u32 site = BbStats::WaitOther); /// bbport: guest memory [address, address + size) a host copy issued now will read (the /// source of a deferred upload or rename). WaitHostCopiesFor waits only for these. @@ -908,7 +961,7 @@ public: /// write there (the command processor's WRITE_DATA/DMA) must not change what they copy. /// Writes elsewhere do not wait (the hardware's command processor writes do not wait for /// earlier draws either). - void WaitHostCopiesFor(u64 address, u64 size); + void WaitHostCopiesFor(u64 address, u64 size, u32 site = BbStats::WaitOther); /// Runs `copy` on a recording thread in order with the commands, the other host copies and /// the signals of SignalAfterHostCopies (the threads spin for work anyway, so small copies cost no wakeup); @@ -923,14 +976,66 @@ public: /// bbport: runs `signal` once the guest memory copies issued so far are done, without /// waiting here: the recording thread reaches it after the copies queued before it and hands - /// it to the copy threads' completion (BbCopy::AfterCopies). - void SignalAfterHostCopies(std::function signal); + /// it to the copy threads' completion (BbCopy::AfterCopies). `signal` goes into the ordered + /// chunk as it is (no std::function allocated on this thread: ~450 fences a frame on the + /// draw recording thread). `kick` false: the caller queues another one right after this one, + /// which hands both to the recording threads (KickRecording) at once. + /// `label`, `label_size`: the guest memory the signal writes (an EOP label); none: a signal + /// later guest-visible writes must not overtake at all (WaitDeferredSignalsFor). + template + void SignalAfterHostCopies(Func&& signal, bool kick = true, u64 label = 0, + u32 label_size = 0) { + // A direct segment queues it too: signals queued before it must not be overtaken. + if (!IsRecordingDeferred() && !direct_segment) { + WaitHostCopies(); + signal(); + return; + } + BbCopy::FlushBatch(); + const u64 seq = deferred_signals_issued.fetch_add(1, std::memory_order_relaxed) + 1; + NoteDeferredLabel(label, label_size, seq); + RecordOrdered([signal = std::forward(signal), + done = deferred_signals_done]() mutable { + BbCopy::AfterCopies([signal = std::move(signal), done = std::move(done)]() mutable { + signal(); + done->fetch_add(1, std::memory_order_release); + }); + }); + if (kick) { + KickRecording(true); + } + } /// bbport: before a guest-visible write that is not deferred (WriteData, end-of-shader /// fences, flip): waits until every signal handed to SignalAfterHostCopies ran, so that /// the write cannot overtake an earlier fence. Otherwise the guest, seeing the later value, /// may free memory the earlier fence then writes into (corrupted heap, guest fault). - void WaitDeferredSignals(); + /// `site`: the caller (BbStats::WaitSite). + void WaitDeferredSignals(u32 site = BbStats::WaitOther); + + /// bbport BB_FENCE_ORDER=ordered (or experiment bit 8; default all: WaitDeferredSignals): a + /// CPU write of the command processor (WriteData, DMA) into [address, address + size), + /// reading [src, src + src_size), waits only for the deferred signals the hardware would + /// have written before it: those issued before the last point the command processor waited + /// for a fence still deferred (NoteSignalBarrier), signals without a known label, and the + /// fences writing the same memory. On the PS4 the command processor runs WRITE_DATA when it + /// reaches it and an EOP label lands at the end of the pipe, later, unless a WAIT_REG_MEM on + /// it came in between. + void WaitDeferredSignalsFor(u32 site, u64 address, u64 size, u64 src = 0, u64 src_size = 0); + + /// Records the point where the command processor waited for a fence that is still deferred + /// (a WaitRegMem met through Liverpool::PendingFenceValue): writes after it follow every + /// signal issued so far. + void NoteSignalBarrier() { + signal_barrier.store(deferred_signals_issued.load(std::memory_order_relaxed), + std::memory_order_relaxed); + } + + /// BB_FENCE_ORDER=ordered or experiment bit 8: WaitDeferredSignalsFor waits only for what it + /// must. FenceOrderPossible: it may be switched on while the game runs (BB_TOGGLE_FILE), so the + /// barriers are recorded all along. + [[nodiscard]] static bool FenceOrderScoped(); + [[nodiscard]] static bool FenceOrderPossible(); /// Whether a render pass with exactly this state is open. [[nodiscard]] bool IsRenderingWith(const RenderState& state) const { @@ -1011,12 +1116,32 @@ private: std::unique_ptr AcquireChunk(); - /// Moves the full current chunk aside; a new one takes its place. + /// Moves the full current chunk aside; a new one takes its place (swapped in: never null). void RetireChunk() { ProducerScope producer{*this, "RetireChunk"}; - segment_bytes += record_chunk->Size(); - full_chunks.push_back(std::move(record_chunk)); - record_chunk = AcquireChunk(); + auto full = AcquireChunk(); + record_chunk.swap(full); + if (full) { + segment_bytes += full->Size(); + full_chunks.push_back(std::move(full)); + } + } + + /// The current chunks, never null: replacements are swapped in (RetireChunk, HandOver), so + /// a fault handler that records on this thread while one is replaced still finds one; a + /// null one is replaced here. From deadinside28/bloodborne_pc 10baf9d (issue #100: a read + /// of RecordChunk::Capacity at address 0x20000 on a Windows build). + RecordChunk& CurrentChunk() { + if (!record_chunk) [[unlikely]] { + record_chunk = AcquireChunk(); + } + return *record_chunk; + } + RecordChunk& OrderedChunk() { + if (!ordered_chunk) [[unlikely]] { + ordered_chunk = AcquireChunk(); + } + return *ordered_chunk; } /// Waits for the recording threads, then records on this thread into the current segment's @@ -1058,10 +1183,11 @@ private: return; } auto command = [func = std::forward(func)](vk::CommandBuffer) mutable { func(); }; - if (!ordered_chunk->Push(std::move(command))) { - ordered_full.push_back(std::move(ordered_chunk)); - ordered_chunk = AcquireChunk(); - const bool pushed = ordered_chunk->Push(std::move(command)); + if (!OrderedChunk().Push(std::move(command))) { + auto full = AcquireChunk(); + ordered_chunk.swap(full); + ordered_full.push_back(std::move(full)); + const bool pushed = OrderedChunk().Push(std::move(command)); ASSERT(pushed); } } @@ -1078,6 +1204,14 @@ private: void PriorityPendingOpsThread(std::stop_token stoken); + /// bbport: the label a deferred signal writes (SignalAfterHostCopies), for + /// WaitDeferredSignalsFor; none: everything after it waits for it. + void NoteDeferredLabel(u64 label, u32 label_size, u64 seq); + /// The newest pending signal (sequence above `done`) whose label overlaps the range; 0: none. + [[nodiscard]] u64 NewestLabelOverlap(u64 done, u64 address, u64 size) const; + /// Waits until the first `need` deferred signals ran. + void WaitDeferredSignalsUpTo(u64 need, u32 site); + private: const Instance& instance; u32 crumb_stream = 0; ///< bbport: GPU breadcrumbs (vk_breadcrumbs.h) @@ -1156,6 +1290,19 @@ private: std::atomic producer_where{nullptr}; ///< and where std::shared_ptr> deferred_signals_done = std::make_shared>(0); + /// bbport (WaitDeferredSignalsFor): signals issued before the last command processor wait on a + /// deferred fence, or before the last signal without a label. + std::atomic signal_barrier{0}; + /// Labels of the last deferred signals (the thread issuing them, A or B, as the signals). + struct DeferredLabel { + u64 address = 0; + u64 size = 0; + u64 seq = 0; + }; + static constexpr u32 DeferredLabels = 256; + std::array deferred_labels{}; + u64 deferred_label_count = 0; + u64 deferred_label_lost = 0; ///< the newest signal whose entry was overwritten tracy::VkCtxScope* profiler_scope{}; }; diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_shader_hle.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_shader_hle.cpp index f777aac..aba55a7 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_shader_hle.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_shader_hle.cpp @@ -1,6 +1,13 @@ // SPDX-FileCopyrightText: Copyright 2024 shadPS4 Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include +#include +#include +#include +#include +#include + #include "shader_recompiler/info.h" #include "video_core/renderer_vulkan/vk_rasterizer.h" #include "video_core/renderer_vulkan/vk_runtime.h" @@ -9,6 +16,7 @@ #include "video_core/renderer_vulkan/vk_instance.h" #include "video_core/renderer_vulkan/vk_scheduler.h" #include "video_core/host_shaders/buffer_multi_copy_comp.h" +#include "bbport_sections.h" #include "bbport_toggles.h" #include "video_core/renderer_vulkan/vk_gpu_profiler.h" @@ -135,10 +143,109 @@ static bool MultiCopy(Rasterizer& rasterizer, const VideoCore::Buffer* src, return true; } +namespace { +/// One batch of the copy shader's ranges: the end of its ranges and their bounds. +struct CopyBatch { + u32 end; + vk::DeviceSize src_min, src_max, dst_min, dst_max; +}; + +/// The batches of the greedy merge: consecutive ranges while both their source and their +/// destination bounds stay within `max_distance` bytes. +void FindBatches(std::span copies, vk::DeviceSize max_distance, + std::vector& batches) { + batches.clear(); + for (u32 i = 0; i < copies.size();) { + CopyBatch batch{0, copies[i].srcOffset, copies[i].srcOffset + copies[i].size, + copies[i].dstOffset, copies[i].dstOffset + copies[i].size}; + for (++i; i < copies.size(); ++i) { + const auto& [src_offset, dst_offset, size] = copies[i]; + const auto src_min = std::min(batch.src_min, src_offset); + const auto src_max = std::max(batch.src_max, src_offset + size); + if (src_max - src_min > max_distance) { + break; + } + const auto dst_min = std::min(batch.dst_min, dst_offset); + const auto dst_max = std::max(batch.dst_max, dst_offset + size); + if (dst_max - dst_min > max_distance) { + break; + } + batch = {0, src_min, src_max, dst_min, dst_max}; + } + batch.end = i; + batches.push_back(batch); + } +} + +/// bbport: the guest lists its ranges in its own order and batches merge only neighbours in it, +/// so a shuffled list takes a batch (two ObtainBuffer calls and a dispatch) per few ranges. +/// `grouped` gets them by destination window of 2^window_shift bytes (windows in order of first +/// appearance, the guest's order within each). False, and nothing reordered, when that could +/// change the result: source and destination overlap in guest memory, or a range crosses a +/// window (ranges that may overlap then share a window and keep their order). The hardware runs +/// the ranges as unordered threads of one dispatch anyway. +bool GroupByDestination(std::span copies, u32 window_shift, VAddr src_base, + VAddr dst_base, std::vector& grouped) { + constexpr u32 Slots = 512; + constexpr u32 MaxGroups = 256; + std::array windows; + windows.fill(~0ull); + std::array ids; + std::array starts{}; + thread_local std::vector group_of; + group_of.resize(copies.size()); + u32 groups = 0; + u64 last_window = ~0ull; + u16 last_id = 0; + vk::DeviceSize src_min = ~0ull, src_max = 0, dst_min = ~0ull, dst_max = 0; + for (u32 i = 0; i < copies.size(); ++i) { + const auto& [src_offset, dst_offset, size] = copies[i]; + const u64 window = dst_offset >> window_shift; + if (((dst_offset + size - 1) >> window_shift) != window) { + return false; + } + src_min = std::min(src_min, src_offset); + src_max = std::max(src_max, src_offset + size); + dst_min = std::min(dst_min, dst_offset); + dst_max = std::max(dst_max, dst_offset + size); + if (window != last_window) { + u32 slot = static_cast((window * 0x9E3779B97F4A7C15ull) >> 55); + while (windows[slot] != ~0ull && windows[slot] != window) { + slot = (slot + 1) & (Slots - 1); + } + if (windows[slot] == ~0ull) { + if (groups == MaxGroups) { + return false; + } + windows[slot] = window; + ids[slot] = static_cast(groups++); + } + last_window = window; + last_id = ids[slot]; + } + group_of[i] = last_id; + ++starts[last_id + 1]; + } + if (groups < 2 || + (src_base + src_min < dst_base + dst_max && dst_base + dst_min < src_base + src_max)) { + return false; + } + for (u32 g = 1; g <= groups; ++g) { + starts[g] += starts[g - 1]; + } + grouped.resize(copies.size()); + for (u32 i = 0; i < copies.size(); ++i) { + grouped[starts[group_of[i]]++] = copies[i]; + } + return true; +} +} // namespace + static bool ExecuteCopyShaderHLE(const Shader::Info& info, const AmdGpu::ComputeProgram& cs_program, Rasterizer& rasterizer) { auto& runtime = rasterizer.GetRuntime(); auto& buffer_cache = rasterizer.GetBufferCache(); + BbSections::Scope batch_scope{BbSections::HleBatch}; // Copy shader defines three formatted buffers as inputs: control, source, and destination. const auto ctl_buf_sharp = info.buffers[0].GetSharp(info); @@ -176,44 +283,87 @@ static bool ExecuteCopyShaderHLE(const Shader::Info& info, const AmdGpu::Compute const char* env = std::getenv("BB_COPY_MERGE_KB"); return env ? vk::DeviceSize(std::strtoull(env, nullptr, 10)) * 1024 : vk::DeviceSize(64_KB); }(); - u32 batch_start = 0; - u32 batch_end = 0; - - while (batch_end < copies.size()) { - // Place first copy into the current batch - const auto& copy = copies[batch_start]; - auto src_offset_min = copy.srcOffset; - auto src_offset_max = copy.srcOffset + copy.size; - auto dst_offset_min = copy.dstOffset; - auto dst_offset_max = copy.dstOffset + copy.size; - - for (++batch_end; batch_end < copies.size(); batch_end++) { - // Compute new src and dst bounds if we were to batch this copy - const auto& [src_offset, dst_offset, size] = copies[batch_end]; - auto new_src_offset_min = std::min(src_offset_min, src_offset); - auto new_src_offset_max = std::max(src_offset_max, src_offset + size); - if (new_src_offset_max - new_src_offset_min > MaxDistanceForMerge) { - break; - } - - auto new_dst_offset_min = std::min(dst_offset_min, dst_offset); - auto new_dst_offset_max = std::max(dst_offset_max, dst_offset + size); - if (new_dst_offset_max - new_dst_offset_min > MaxDistanceForMerge) { - break; + static std::vector batches, grouped_batches; + static std::vector grouped; + FindBatches(copies, MaxDistanceForMerge, batches); + // Statistics (BB_FRAME_STATS) and the throttle of the grouping: this runs on one thread at a + // time (the draw recording thread, or the GPU command thread after a drain). + static u64 stat_dispatches = 0, stat_ranges = 0, stat_batches = 0, stat_saved = 0; + static u32 grouping_misses = 0, grouping_pause = 0; + const size_t in_order = batches.size(); + // Worth a try with a few batches or more (each costs a few microseconds, the grouping a few + // nanoseconds per range); paused for a while after attempts that did not help. + if (in_order >= 4 && !BbToggle::Disabled(BbToggle::MultiCopyShader)) { + const u32 window_shift = + static_cast(std::bit_width(std::max(MaxDistanceForMerge, 2)) - 1); + if (grouping_pause) { + --grouping_pause; + } else if (GroupByDestination(copies, window_shift, src_buf_sharp.base_address, + dst_buf_sharp.base_address, grouped) && + (FindBatches(grouped, MaxDistanceForMerge, grouped_batches), + grouped_batches.size() < in_order)) { + copies.swap(grouped); + batches.swap(grouped_batches); + grouping_misses = 0; + } else if (++grouping_misses >= 4) { + grouping_misses = 0; + grouping_pause = 64; + } + } + batch_scope.End(); + // BB_FRAME_STATS: which ObtainBuffer path the batches take (BufferCache::hle_obtain). + using VideoCore::BufferCache; + static std::array path_calls_before{}, path_bytes_before{}; + if (BbStats::enabled) { + ++stat_dispatches; + stat_ranges += copies.size(); + stat_batches += batches.size(); + stat_saved += in_order - batches.size(); + static auto window = std::chrono::steady_clock::now(); + const auto now = std::chrono::steady_clock::now(); + if (now - window >= std::chrono::seconds(5)) { + const double seconds = std::chrono::duration(now - window).count(); + std::printf("HLE copy shader per second: %.0f dispatches, %.0f ranges, %.0f batches " + "(%.0f fewer by destination grouping)\n", + stat_dispatches / seconds, stat_ranges / seconds, stat_batches / seconds, + stat_saved / seconds); + // [source, destination] x [in place, stream, VRAM]: calls/s and KiB per call. + const char* names[6] = {"src in place", "src stream", "src VRAM", + "dst in place", "dst stream", "dst VRAM"}; + std::printf(" HLE copy ObtainBuffer paths:"); + for (u32 i = 0; i < 6; ++i) { + const u64 calls = BufferCache::hle_path_calls[i] - path_calls_before[i]; + const u64 bytes = BufferCache::hle_path_bytes[i] - path_bytes_before[i]; + if (calls) { + std::printf(" %s %.0f/s %.1f KiB,", names[i], calls / seconds, + bytes / 1024.0 / calls); + } } - - // We can batch this copy - src_offset_min = new_src_offset_min; - src_offset_max = new_src_offset_max; - dst_offset_min = new_dst_offset_min; - dst_offset_max = new_dst_offset_max; + std::printf("\n"); + path_calls_before = BufferCache::hle_path_calls; + path_bytes_before = BufferCache::hle_path_bytes; + window = now; + stat_dispatches = stat_ranges = stat_batches = stat_saved = 0; } + } + u32 batch_start = 0; + for (const auto& batch : batches) { + const u32 batch_end = batch.end; + const auto src_offset_min = batch.src_min; + const auto src_offset_max = batch.src_max; + const auto dst_offset_min = batch.dst_min; + const auto dst_offset_max = batch.dst_max; // Obtain buffers for the total source and destination ranges. + BbSections::Scope obtain_scope{BbSections::HleObtain}; + VideoCore::BufferCache::hle_obtain = BbStats::enabled; const auto [src_buf, src_buf_offset] = buffer_cache.ObtainBuffer( src_buf_sharp.base_address + src_offset_min, src_offset_max - src_offset_min, false); const auto [dst_buf, dst_buf_offset] = buffer_cache.ObtainBuffer( dst_buf_sharp.base_address + dst_offset_min, dst_offset_max - dst_offset_min, true); + VideoCore::BufferCache::hle_obtain = false; + obtain_scope.End(); + BB_SECTION(HleCopy); // Apply found buffer base. const auto vk_copies = std::span{copies}.subspan(batch_start, batch_end - batch_start); diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_swapchain.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_swapchain.cpp index ed0b5ca..c7524bf 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_swapchain.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_swapchain.cpp @@ -2,24 +2,55 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include +#include +#include +#include #include +#include "bbport_settings.h" #include "common/assert.h" #include "common/logging/log.h" #include "core/emulator_settings.h" #include "imgui/renderer/imgui_core.h" #include "sdl_window.h" #include "video_core/renderer_vulkan/vk_instance.h" +#include "video_core/renderer_vulkan/vk_platform.h" +#include "video_core/renderer_vulkan/vk_scheduler.h" #include "video_core/renderer_vulkan/vk_swapchain.h" namespace Vulkan { +/// bbport: display events (a lost surface, a failed swapchain) are reported a few times and the +/// presenter skips frames until a new swapchain works; they ended the game (exit 23). +static void NoteDisplayEvent(const char* what, vk::Result result) { + static std::atomic notes{0}; + if (notes.fetch_add(1, std::memory_order_relaxed) < 16) { + std::printf("Display: %s: %s\n", what, vk::to_string(result).c_str()); + } +} + static constexpr vk::SurfaceFormatKHR SURFACE_FORMAT_HDR = { .format = vk::Format::eA2B10G10R10UnormPack32, .colorSpace = vk::ColorSpaceKHR::eHdr10St2084EXT, }; +/// bbport: HDR output formats, in order of preference: HDR10 (BT.2020 primaries, PQ), then +/// scRGB (linear BT.709 in FP16, 1.0 = 80 nits). +static constexpr std::array HDR_OUTPUT_FORMATS = { + SURFACE_FORMAT_HDR, + vk::SurfaceFormatKHR{ + .format = vk::Format::eR16G16B16A16Sfloat, + .colorSpace = vk::ColorSpaceKHR::eExtendedSrgbLinearEXT, + }, +}; + Swapchain::Swapchain(const Instance& instance_, const Frontend::WindowSDL& window_) : instance{instance_}, window{window_}, surface{CreateSurface(instance.GetInstance(), window)} { + // bbport: the swapchain never becomes exclusive fullscreen. A borderless window covering + // the screen was promoted to it by the driver, and Windows HDR switched off with it. + full_screen_exclusive = instance.IsFullScreenExclusiveEnabled(); + if (full_screen_exclusive) { + std::printf("Display: exclusive fullscreen disallowed (VK_EXT_full_screen_exclusive)\n"); + } FindPresentFormat(); FindPresentMode(); @@ -37,9 +68,18 @@ void Swapchain::Create(u32 width_, u32 height_) { height = height_; needs_recreation = false; + // bbport: a lost surface (VK_ERROR_SURFACE_LOST_KHR: a display or driver reset) is made again + // on the same window before a swapchain can be created on it. + if (surface_lost) { + RecreateSurface(); + } + // bbport: a minimised window has a 0x0 surface (issue #31); no swapchain can have that extent. // The old one stays (presents are skipped) until the window is back. - SetSurfaceProperties(); + if (!SetSurfaceProperties()) { + needs_recreation = true; + return; + } if (extent.width == 0 || extent.height == 0) { zero_extent = true; needs_recreation = true; @@ -58,8 +98,22 @@ void Swapchain::Create(u32 width_, u32 height_) { const u32 queue_family_indices_count = exclusive ? 1u : 2u; const vk::SharingMode sharing_mode = exclusive ? vk::SharingMode::eExclusive : vk::SharingMode::eConcurrent; - const auto format = needs_hdr ? SURFACE_FORMAT_HDR : surface_format; + // bbport: HDR output while the display still offers the HDR format chosen at start + // (switching Windows HDR off removes it; the swapchain is SDR until it is back). + hdr_output = false; + if (!needs_hdr && hdr_format.format != vk::Format::eUndefined) { + const auto formats = QuerySurfaceFormats(); + hdr_output = std::ranges::find(formats, hdr_format) != formats.end(); + if (!hdr_output && !hdr_unavailable_logged) { + std::printf("HDR output: the display offers no HDR format now (is Windows HDR on?); " + "SDR until it does\n"); + } + hdr_unavailable_logged = !hdr_output; + } + const auto format = needs_hdr ? SURFACE_FORMAT_HDR : hdr_output ? hdr_format : surface_format; + view_format = format.format; const vk::SwapchainCreateInfoKHR swapchain_info = { + .pNext = FullScreenExclusiveDisallowed(full_screen_exclusive), .surface = surface, .minImageCount = image_count, .imageFormat = format.format, @@ -79,12 +133,82 @@ void Swapchain::Create(u32 width_, u32 height_) { }; auto [swapchain_result, chain] = instance.GetDevice().createSwapchainKHR(swapchain_info); - ASSERT_MSG(swapchain_result == vk::Result::eSuccess, "Failed to create swapchain: {}", - vk::to_string(swapchain_result)); + if (swapchain_result != vk::Result::eSuccess) { + // bbport: no swapchain now (IsPresentable is false): presents are skipped and the next + // one tries again. + NoteDisplayEvent("creating the swapchain failed", swapchain_result); + surface_lost = swapchain_result == vk::Result::eErrorSurfaceLostKHR; + needs_recreation = true; + return; + } swapchain = chain; SetupImages(); RefreshSemaphores(); + // The new chain may have fewer images (AwesomeObserver b3164c1): start the indices over. + frame_index = image_index = 0; + ApplyHdrMetadata(); +} + +void Swapchain::SetHdrMetadata(float max_nits, float content_nits, float frame_average_nits) { + if (max_nits == hdr_max_nits && content_nits == hdr_content_nits && + frame_average_nits == hdr_average_nits) { + return; + } + hdr_max_nits = max_nits; + hdr_content_nits = content_nits; + hdr_average_nits = frame_average_nits; + ApplyHdrMetadata(); +} + +void Swapchain::ApplyHdrMetadata() { + if (!hdr_output || !swapchain || hdr_max_nits <= 0.0f || !instance.IsHdrMetadataEnabled() || + !VULKAN_HPP_DEFAULT_DISPATCHER.vkSetHdrMetadataEXT) { + return; + } + // The picture is the SDR frame: BT.709 primaries, D65 white, up to the peak set. bbport: + // MaxCLL is what the HDR mode can reach (Presenter::PresentHdr): with expand's lower + // ceiling the display has no reason to compress the highlights itself. + const vk::HdrMetadataEXT metadata{ + .displayPrimaryRed = {0.640f, 0.330f}, + .displayPrimaryGreen = {0.300f, 0.600f}, + .displayPrimaryBlue = {0.150f, 0.060f}, + .whitePoint = {0.3127f, 0.3290f}, + .maxLuminance = hdr_max_nits, + .minLuminance = 0.0f, + .maxContentLightLevel = hdr_content_nits > 0.0f ? hdr_content_nits : hdr_max_nits, + .maxFrameAverageLightLevel = hdr_average_nits, + }; + instance.GetDevice().setHdrMetadataEXT(1, &swapchain, &metadata); +} + +std::vector Swapchain::QuerySurfaceFormats() const { + const vk::PhysicalDevice physical = instance.GetPhysicalDevice(); + if (full_screen_exclusive) { + // The same chain as the swapchain's creation (VK_EXT_full_screen_exclusive). + const vk::PhysicalDeviceSurfaceInfo2KHR surface_info{ + .pNext = FullScreenExclusiveDisallowed(true), + .surface = surface, + }; + auto [formats2_result, formats2] = physical.getSurfaceFormats2KHR(surface_info); + if (formats2_result == vk::Result::eSuccess) { + std::vector formats; + formats.reserve(formats2.size()); + for (const auto& format2 : formats2) { + formats.push_back(format2.surfaceFormat); + } + return formats; + } + LOG_WARNING(Render_Vulkan, "Failed to query surface formats (2): {}", + vk::to_string(formats2_result)); + } + auto [formats_result, formats] = physical.getSurfaceFormatsKHR(surface); + if (formats_result != vk::Result::eSuccess) { + LOG_ERROR(Render_Vulkan, "Failed to query surface formats: {}", + vk::to_string(formats_result)); + return {}; + } + return std::move(formats); } void Swapchain::Recreate(u32 width_, u32 height_) { @@ -98,11 +222,7 @@ void Swapchain::SetHDR(bool hdr) { return; } - auto result = instance.GetDevice().waitIdle(); - if (result != vk::Result::eSuccess) { - LOG_WARNING(ImGui, "Failed to wait for Vulkan device idle on mode change: {}", - vk::to_string(result)); - } + WaitIdle(); needs_hdr = hdr; Recreate(width, height); @@ -111,6 +231,10 @@ void Swapchain::SetHDR(bool hdr) { } bool Swapchain::AcquireNextImage() { + if (!swapchain) { + needs_recreation = true; // the last Create failed + return false; + } vk::Device device = instance.GetDevice(); vk::Result result = device.acquireNextImageKHR(swapchain, std::numeric_limits::max(), @@ -120,15 +244,25 @@ bool Swapchain::AcquireNextImage() { case vk::Result::eSuccess: break; case vk::Result::eSuboptimalKHR: + // A suboptimal acquire still owns an image and signals the semaphore: present it in the + // normal submission, then recreate (Present() returns false). Dropping it left the + // semaphore signalled and never waited on. From AwesomeObserver b3164c1 (E. Doronin). + needs_recreation = true; + return true; case vk::Result::eErrorSurfaceLostKHR: + NoteDisplayEvent("acquiring a swapchain image", result); + surface_lost = true; + needs_recreation = true; + break; case vk::Result::eErrorOutOfDateKHR: case vk::Result::eErrorUnknown: needs_recreation = true; break; default: - LOG_CRITICAL(Render_Vulkan, "Swapchain acquire returned unknown result {}", - vk::to_string(result)); - UNREACHABLE(); + // bbport: e.g. a timeout or a lost exclusive fullscreen: recreated like out-of-date + // (this was UNREACHABLE, exit 23). + NoteDisplayEvent("acquiring a swapchain image", result); + needs_recreation = true; break; } @@ -147,6 +281,12 @@ bool Swapchain::Present() { auto result = instance.GetPresentQueue().presentKHR(present_info); if (result == vk::Result::eErrorOutOfDateKHR || result == vk::Result::eSuboptimalKHR) { needs_recreation = true; + } else if (result != vk::Result::eSuccess && result != vk::Result::eErrorDeviceLost) { + // bbport: a lost surface (display or driver reset) or another display event: recreated + // like out-of-date (this was an assertion, exit 23). A lost device still stops below. + NoteDisplayEvent("presenting", result); + surface_lost = result == vk::Result::eErrorSurfaceLostKHR; + needs_recreation = true; } else { ASSERT_MSG(result == vk::Result::eSuccess, "Swapchain presentation failed: {}", vk::to_string(result)); @@ -158,20 +298,13 @@ bool Swapchain::Present() { } void Swapchain::FindPresentFormat() { - const auto [formats_result, formats] = - instance.GetPhysicalDevice().getSurfaceFormatsKHR(surface); - ASSERT_MSG(formats_result == vk::Result::eSuccess, "Failed to query surface formats: {}", - vk::to_string(formats_result)); - - // Check if the device supports HDR formats. Here we care of Rec.2020 PQ only as it is expected - // game output. Other variants as e.g. linear Rec.2020 will require additional color space - // rotation - supports_hdr = - std::find_if(formats.begin(), formats.end(), [](const vk::SurfaceFormatKHR& format) { - return format == SURFACE_FORMAT_HDR; - }) != formats.end(); - // Also make sure that user allowed us to use HDR - supports_hdr &= EmulatorSettings.IsHdrAllowed(); + const auto formats = QuerySurfaceFormats(); + ASSERT_MSG(!formats.empty(), "Failed to query surface formats"); + + // bbport: the game's own HDR request (sceVideoOutConfigureOutputMode with BT.2020 PQ, which + // Bloodborne never makes) stays off: HDR output is the presenter's encode of the SDR + // picture (hdr in bbport.ini, below), and the per-flip SetHDR(false) must not touch it. + supports_hdr = false; // If there is a single undefined surface format, the device doesn't care, so we'll just use // RGBA sRGB. @@ -181,24 +314,62 @@ void Swapchain::FindPresentFormat() { return; } - // Try to find a suitable format. - for (const vk::SurfaceFormatKHR& sformat : formats) { - vk::Format format = sformat.format; - if (format != vk::Format::eR8G8B8A8Unorm && format != vk::Format::eB8G8R8A8Unorm) { - continue; - } + // Try to find a suitable format. bbport: VK_EXT_swapchain_colorspace is always enabled now, + // so the same formats can be listed with other color spaces too: SRGB_NONLINEAR first. + const auto is_sdr_format = [](const vk::SurfaceFormatKHR& sformat) { + return sformat.format == vk::Format::eR8G8B8A8Unorm || + sformat.format == vk::Format::eB8G8R8A8Unorm; + }; + auto found = std::ranges::find_if(formats, [&](const vk::SurfaceFormatKHR& sformat) { + return is_sdr_format(sformat) && sformat.colorSpace == vk::ColorSpaceKHR::eSrgbNonlinear; + }); + if (found == formats.end()) { + found = std::ranges::find_if(formats, is_sdr_format); + } + if (found == formats.end()) { + UNREACHABLE_MSG("Unable to find required swapchain format!"); + } + surface_format.format = found->format; + surface_format.colorSpace = found->colorSpace; - surface_format.format = format; - surface_format.colorSpace = sformat.colorSpace; + // bbport: HDR output (hdr in bbport.ini, BB_HDR), chosen once. + const auto& settings = BbSettings::Get(); + if (!settings.startup_hdr) { return; } - - UNREACHABLE_MSG("Unable to find required swapchain format!"); + for (const vk::SurfaceFormatKHR& candidate : HDR_OUTPUT_FORMATS) { + if (std::ranges::find(formats, candidate) != formats.end()) { + hdr_format = candidate; + break; + } + } + if (hdr_format.format == vk::Format::eUndefined) { + std::printf("HDR output: the display offers no HDR format (is Windows HDR on?)\n"); + return; + } + // The presenter letterboxes the frame into an SDR image of the swapchain's size, draws the + // menu over it, and samples it in the encode pass. + constexpr vk::FormatFeatureFlags composite_features = + vk::FormatFeatureFlagBits::eSampledImage | vk::FormatFeatureFlagBits::eColorAttachment | + vk::FormatFeatureFlagBits::eColorAttachmentBlend | vk::FormatFeatureFlagBits::eBlitDst | + vk::FormatFeatureFlagBits::eTransferDst; + const vk::FormatFeatureFlags features = + instance.GetPhysicalDevice().getFormatProperties(surface_format.format).optimalTilingFeatures; + if ((features & composite_features) != composite_features) { + std::printf("HDR output: %s cannot hold the SDR picture for the encode; staying SDR\n", + vk::to_string(surface_format.format).c_str()); + hdr_format = {}; + return; + } + std::printf("HDR output: %s, paper white %.0f nits, peak %.0f nits, highlight boost %.2f\n", + hdr_format == SURFACE_FORMAT_HDR ? "HDR10 PQ (BT.2020)" : "scRGB (FP16, linear BT.709)", + settings.hdr_paper_white.load(), settings.hdr_peak.load(), + settings.hdr_boost.load()); } void Swapchain::FindPresentMode() { - const auto [modes_result, modes] = - instance.GetPhysicalDevice().getSurfacePresentModesKHR(surface); + const auto [modes_result, modes] = GetSurfacePresentModes( + instance.GetInstance(), instance.GetPhysicalDevice(), surface, full_screen_exclusive); if (modes_result != vk::Result::eSuccess) { LOG_ERROR(Render, "Failed to query available present modes, falling back to Fifo as " "guaranteed supported option."); @@ -228,11 +399,31 @@ void Swapchain::FindPresentMode() { } } -void Swapchain::SetSurfaceProperties() { - const auto [capabilities_result, capabilities] = - instance.GetPhysicalDevice().getSurfaceCapabilitiesKHR(surface); - ASSERT_MSG(capabilities_result == vk::Result::eSuccess, - "Failed to query surface capabilities: {}", vk::to_string(capabilities_result)); +bool Swapchain::SetSurfaceProperties() { + vk::SurfaceCapabilitiesKHR capabilities{}; + vk::Result capabilities_result{}; + if (full_screen_exclusive) { + // The same chain as the swapchain's creation (VK_EXT_full_screen_exclusive). + const vk::PhysicalDeviceSurfaceInfo2KHR surface_info{ + .pNext = FullScreenExclusiveDisallowed(true), + .surface = surface, + }; + const auto [result2, capabilities2] = + instance.GetPhysicalDevice().getSurfaceCapabilities2KHR(surface_info); + capabilities_result = result2; + capabilities = capabilities2.surfaceCapabilities; + } else { + const auto [result1, capabilities1] = + instance.GetPhysicalDevice().getSurfaceCapabilitiesKHR(surface); + capabilities_result = result1; + capabilities = capabilities1; + } + if (capabilities_result != vk::Result::eSuccess) { + // bbport: this was an assertion (exit 23); a lost surface is made again at the next try. + NoteDisplayEvent("querying the surface capabilities failed", capabilities_result); + surface_lost = capabilities_result == vk::Result::eErrorSurfaceLostKHR; + return false; + } extent = capabilities.currentExtent; if (capabilities.currentExtent.width == std::numeric_limits::max()) { @@ -259,23 +450,41 @@ void Swapchain::SetSurfaceProperties() { if (!(capabilities.supportedCompositeAlpha & vk::CompositeAlphaFlagBitsKHR::eOpaque)) { composite_alpha = vk::CompositeAlphaFlagBitsKHR::eInherit; } + return true; } -void Swapchain::Destroy() { - vk::Device device = instance.GetDevice(); - const auto wait_result = device.waitIdle(); +void Swapchain::WaitIdle() { + // bbport: vkDeviceWaitIdle needs every queue of the device externally synchronized: the + // recording threads submit to the same queue under this lock (Scheduler::submit_mutex). + std::scoped_lock submit_lock{Scheduler::submit_mutex}; + const auto wait_result = instance.GetDevice().waitIdle(); if (wait_result != vk::Result::eSuccess) { LOG_WARNING(Render_Vulkan, "Failed to wait for device to become idle: {}", vk::to_string(wait_result)); } +} + +void Swapchain::RecreateSurface() { + Destroy(); // a swapchain goes before its surface + instance.GetInstance().destroySurfaceKHR(surface); + surface = CreateSurface(instance.GetInstance(), window); + surface_lost = false; + std::printf("Display: the window's surface was lost; created again\n"); +} + +void Swapchain::Destroy() { + vk::Device device = instance.GetDevice(); + WaitIdle(); for (auto& image_view : images_view) { device.destroyImageView(image_view); } images_view.clear(); + images.clear(); if (swapchain) { device.destroySwapchainKHR(swapchain); + swapchain = nullptr; // bbport: a failed Create leaves none (IsPresentable) } for (const auto& sem : image_acquired) { @@ -329,7 +538,7 @@ void Swapchain::SetupImages() { auto [im_view_result, im_view] = device.createImageView(vk::ImageViewCreateInfo{ .image = images[i], .viewType = vk::ImageViewType::e2D, - .format = needs_hdr ? SURFACE_FORMAT_HDR.format : surface_format.format, + .format = view_format, // bbport: the format Create chose (SDR, game HDR, HDR output) .subresourceRange = { .aspectMask = vk::ImageAspectFlagBits::eColor, diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_swapchain.h b/gpu/shadps4/video_core/renderer_vulkan/vk_swapchain.h index d310098..1a0e045 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_swapchain.h +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_swapchain.h @@ -97,7 +97,30 @@ public: return needs_hdr; } + /// bbport: HDR output (hdr in bbport.ini): the HDR format chosen at start (format + /// eUndefined: none, SDR only). The frame images, passes and overlay keep the SDR format + /// (GetSurfaceFormat); the presenter encodes into this one. + vk::SurfaceFormatKHR GetHdrFormat() const { + return hdr_format; + } + + /// bbport: the current swapchain has the HDR format (it falls back to SDR while the + /// display offers no HDR format, e.g. Windows HDR switched off). + bool IsHdrOutput() const { + return hdr_output; + } + + /// bbport: VK_EXT_hdr_metadata for the HDR swapchain (kept for the swapchains created later): + /// the display's peak, the brightest content (MaxCLL) and the frame average (MaxFALL). + void SetHdrMetadata(float max_nits, float content_nits, float frame_average_nits); + private: + /// bbport: the surface's formats, queried with the full-screen-exclusive chain when enabled. + std::vector QuerySurfaceFormats() const; + + /// bbport: applies the HDR metadata to the current swapchain. + void ApplyHdrMetadata(); + /// Selects the best available swapchain image format void FindPresentFormat(); @@ -105,7 +128,14 @@ private: void FindPresentMode(); /// Sets the surface properties according to device capabilities - void SetSurfaceProperties(); + /// (bbport: false when they cannot be queried, e.g. a lost surface) + bool SetSurfaceProperties(); + + /// bbport: vkDeviceWaitIdle under Scheduler::submit_mutex (the callers must not hold it). + void WaitIdle(); + + /// bbport: destroys the swapchain and the lost surface, and creates a new surface. + void RecreateSurface(); /// Destroys current swapchain resources void Destroy(); @@ -138,8 +168,16 @@ private: u32 frame_index = 0; bool needs_recreation = true; bool zero_extent = false; ///< bbport: the surface was 0x0 (minimised) at the last Create + bool surface_lost = false; ///< bbport: VK_ERROR_SURFACE_LOST_KHR seen: Create makes a new one bool needs_hdr = false; // The game requested HDR swapchain bool supports_hdr = false; // SC supports HDR output + // bbport: HDR output (GetHdrFormat, IsHdrOutput). + vk::SurfaceFormatKHR hdr_format{vk::Format::eUndefined, vk::ColorSpaceKHR::eSrgbNonlinear}; + bool hdr_output = false; + bool hdr_unavailable_logged = false; + bool full_screen_exclusive = false; ///< VK_EXT_full_screen_exclusive: DISALLOWED chained + float hdr_max_nits = 0.0f, hdr_content_nits = 0.0f, + hdr_average_nits = 0.0f; ///< SetHdrMetadata; 0: not set }; } // namespace Vulkan diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_temporal_upscaler.cpp b/gpu/shadps4/video_core/renderer_vulkan/vk_temporal_upscaler.cpp index a3240be..72f63ff 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_temporal_upscaler.cpp +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_temporal_upscaler.cpp @@ -14,6 +14,7 @@ #include +#include "bbport_sections.h" #include "bbport_settings.h" #include "bbport_toggles.h" #include "ffx_vk_portable.h" @@ -28,6 +29,7 @@ #include "video_core/renderer_vulkan/vk_shader_util.h" #include "video_core/renderer_vulkan/vk_camera_motion.h" #include "video_core/renderer_vulkan/vk_frame_capture.h" +#include "video_core/renderer_vulkan/vk_hdr_native.h" #include "video_core/renderer_vulkan/vk_instance.h" #include "video_core/renderer_vulkan/vk_runtime.h" #include "video_core/renderer_vulkan/vk_scheduler.h" @@ -173,9 +175,11 @@ void DumpFinalFrameIfDue(const Instance& instance, Scheduler& scheduler, vk::Com TemporalUpscaler::TemporalUpscaler(const Instance& instance_, Scheduler& scheduler_, VideoCore::TextureCache& texture_cache_, Runtime& runtime_, - CameraMotion& camera_motion_, SceneTargets& scene_targets_) + CameraMotion& camera_motion_, SceneTargets& scene_targets_, + HdrNative& hdr_native_) : instance{instance_}, scheduler{scheduler_}, texture_cache{texture_cache_}, - runtime{runtime_}, camera_motion{camera_motion_}, scene_targets{scene_targets_} { + runtime{runtime_}, camera_motion{camera_motion_}, scene_targets{scene_targets_}, + hdr_native{hdr_native_} { // Reject unsupported shaders before allocating resources or recording a frame. if (instance.IsDlssCapable()) { dlss = std::make_unique(instance, scheduler); @@ -352,8 +356,8 @@ bool TemporalUpscaler::OnFrameStart() { const int output = settings.output_res.load(); const bool output_changed = !scaled_session && applied_output != output; if (output_changed) { - target_width = BbSettings::OutputWidths[output]; - target_height = BbSettings::OutputHeights[output]; + target_width = BbSettings::OutputWidth(output); + target_height = BbSettings::OutputHeight(output); std::printf("Output resolution: %ux%u (live)\n", target_width, target_height); failed = false; fsr4_failed = false; @@ -411,15 +415,23 @@ bool TemporalUpscaler::OnFrameStart() { } void TemporalUpscaler::OnDispatch(u64 cs_hash) { - if (cs_hash != trigger_hash || done_this_frame || failed || Scaled()) { + if (cs_hash != trigger_hash) { return; } - done_this_frame = true; - if (!scene_color || !camera_motion.Ready() || !Active()) { - reset = true; - return; + if (!done_this_frame && !failed && !Scaled()) { + done_this_frame = true; + if (!scene_color || !camera_motion.Ready() || !Active()) { + reset = true; + } else { + Run(); + } + } + // bbport: native HDR output: the scene color the post-processing starts from, upscaled by + // now at 1080p output; a reduced scene (scaled presets) is resolved into it, as the game's + // own compute passes reading it would. + if (scene_color && HdrNative::Wanted()) { + hdr_native.OnSceneColor(scene_color); } - Run(); } bool TemporalUpscaler::EnsureResources(u32 w, u32 h, u32 ow, u32 oh, bool hdr) { @@ -1106,6 +1118,7 @@ bool TemporalUpscaler::RecordReactive(vk::ImageView color_view) { } void TemporalUpscaler::Run() { + BB_SECTION(Upscaler); if (auto* profiler = GpuProfiler::Get()) { const char* label = BbSettings::Get().upscaler == BbSettings::UpscalerTaa ? "upscaler Run (TAA)" : "upscaler Run (FSR)"; @@ -1195,6 +1208,7 @@ void TemporalUpscaler::Run() { last_frame = now; bool dispatched = false; + BbSections::Scope eval_scope{BbSections::UpscalerEval}; if (BbSettings::Get().upscaler == BbSettings::UpscalerTaa) { RecordTaa(cmdbuf, input_color_view, input_depth_view); dispatched = true; @@ -1274,6 +1288,7 @@ void TemporalUpscaler::Run() { dispatched = true; } } + eval_scope.End(); if (dispatched) { reset = false; dispatched_last_frame = true; @@ -1413,7 +1428,16 @@ float TemporalUpscaler::SceneMipBias() const { if (!Active() || BbSettings::Get().upscaler == BbSettings::UpscalerTaa) return 0.0f; const float render = float(BbSettings::Get().active_render_width.load()); const float output = float(Scaled() ? target_width : 1920u); - return render > 0.0f && render < output ? std::log2(render / output) : 0.0f; + const float scale_bias = render > 0.0f && render < output ? std::log2(render / output) : 0.0f; + // DLSS (DLAA too) wants one more mip level of texture detail than the render/output ratio + // (NVIDIA's DLSS programming guide). From AwesomeObserver/bloodborne_pc b3164c1 + // (Evgeniy Doronin). BB_DLSS_MIP_BIAS=0: the ratio only, as before. + static const bool dlss_extra = [] { + const char* env = std::getenv("BB_DLSS_MIP_BIAS"); + return !env || env[0] != '0'; + }(); + const bool dlss_on = BbSettings::Get().upscaler == BbSettings::UpscalerDlss && UseFsr4(); + return scale_bias - (dlss_extra && dlss_on ? 1.0f : 0.0f); } bool TemporalUpscaler::Scaled() const { @@ -1436,6 +1460,20 @@ void TemporalUpscaler::OnColorTarget(VideoCore::ImageId color) { void TemporalUpscaler::OnDraw(u64 vs_hash, VideoCore::ImageId color, VideoCore::ImageId depth, bool native_viewport) { if (!Scaled() && vs_hash == ui_trigger_vs) done_this_frame = true; + // bbport: native HDR output: the game's tonemapped frame before the first UI pixel (the + // first Scaleform draw of any kind). A stencil-only first pass binds no colour: the last + // frame-size RGBA8 target then. (WantsUiStart first: two flags, false unless native HDR saw + // this frame's scene and no UI yet.) + if (hdr_native.WantsUiStart() && !Scaled() && + (vs_hash == ui_trigger_vs || UiComposition::MovieShader(vs_hash)) && + HdrNative::Wanted()) { + hdr_ui_target = LdrFrame(color) ? color + : LdrFrame(ldr_target) ? ldr_target + : VideoCore::ImageId{}; + hdr_native.OnUiStart(hdr_ui_target + ? hdr_native.Guest(hdr_ui_target, vk::Format::eR8G8B8A8Unorm) + : HdrNative::Source{}); + } if (!Scaled()) { return; } @@ -1469,14 +1507,57 @@ void TemporalUpscaler::OnDraw(u64 vs_hash, VideoCore::ImageId color, case UiComposition::Background::Temporal: RunScaled(); if (ui_phase) { - return; + break; } // A failed FSR dispatch must not force UI text back to the scene resolution. [[fallthrough]]; case UiComposition::Background::Copy: RunUiOnly(color, depth); - return; + break; + } + // bbport: native HDR output: the output-size frame the UI is about to be drawn into. + if (ui_phase && hdr_native.WantsUiStart() && HdrNative::Wanted()) { + hdr_native.OnUiStart(HdrUiImage()); + } +} + +bool TemporalUpscaler::LdrFrame(VideoCore::ImageId id) const { + if (!id) { + return false; + } + const auto& image = texture_cache.GetImage(id); + return (image.info.pixel_format == vk::Format::eR8G8B8A8Unorm || + image.info.pixel_format == vk::Format::eR8G8B8A8Srgb) && + RenderTarget(image.info.size.width, image.info.size.height) && + !FrameCapture::IsDisplayBuffer(image.info.guest_address); +} + +HdrNative::Source TemporalUpscaler::HdrUiImage() { + // The upscaler's own image, in General layout (written by FSR or the background copy, then + // the UI draws): the native passes' barriers order their reads against both. + return {vk::Image(ui_image), + Mirror(vk::Image(ui_image), ui_views, vk::Format::eR8G8B8A8Unorm, {}), ui_width, + ui_height}; +} + +void TemporalUpscaler::OnDisplayPass(VAddr address, VideoCore::ImageId frame) { + HdrNative::Source source{}; + if (HdrNative::Wanted()) { + if (ui_phase) { + // The scaled presets: the display pass is redirected to the output-size UI image. + source = HdrUiImage(); + } else if (!Scaled()) { + // The game's UI target: the one the UI-free copy came from, else (no UI pass) the + // previous draw's target. Another frame-size RGBA8 target there (the display pass + // could read either) is a mismatch: no gain map for this frame. + const VideoCore::ImageId id = hdr_ui_target ? hdr_ui_target : frame; + if (LdrFrame(id) && (!LdrFrame(frame) || frame == id)) { + source = hdr_native.Guest(id, vk::Format::eR8G8B8A8Unorm); + } + } } + hdr_native.OnDisplayPass(address, source); + hdr_ui_target = {}; } void TemporalUpscaler::EnsureUiResources(u32 w, u32 h, vk::Format color, vk::Format depth) { diff --git a/gpu/shadps4/video_core/renderer_vulkan/vk_temporal_upscaler.h b/gpu/shadps4/video_core/renderer_vulkan/vk_temporal_upscaler.h index 5fb3333..7637393 100644 --- a/gpu/shadps4/video_core/renderer_vulkan/vk_temporal_upscaler.h +++ b/gpu/shadps4/video_core/renderer_vulkan/vk_temporal_upscaler.h @@ -22,6 +22,7 @@ #include "video_core/renderer_vulkan/vk_common.h" #include "video_core/renderer_vulkan/vk_dlss.h" #include "video_core/renderer_vulkan/vk_fsr4.h" +#include "video_core/renderer_vulkan/vk_hdr_native.h" #include "video_core/texture_cache/image.h" struct FfxVkPortableUpscaleContext; @@ -47,7 +48,8 @@ class TemporalUpscaler { public: TemporalUpscaler(const Instance& instance, Scheduler& scheduler, VideoCore::TextureCache& texture_cache, Runtime& runtime, - CameraMotion& camera_motion, SceneTargets& scene_targets); + CameraMotion& camera_motion, SceneTargets& scene_targets, + HdrNative& hdr_native); ~TemporalUpscaler(); [[nodiscard]] bool Enabled() const noexcept { @@ -70,6 +72,10 @@ public: /// Start of a frame in the command stream (display pass). bool OnFrameStart(); + /// bbport: native HDR output (vk_hdr_native.h). Right before OnFrameStart: the display pass + /// copies the finished frame into the display buffer at `address`; `frame` is the target + /// the previous draw rendered into. + void OnDisplayPass(VAddr address, VideoCore::ImageId frame); bool RasterScaling() const; /// bbport: LOD bias for scene materials, log2(render / output) while a reduced scene is /// upscaled (0 at native size, for TAA and without an upscaler). @@ -161,6 +167,12 @@ private: return w >= render_width && h >= render_height && w < render_width + 8 && h < render_height + 8; } + /// bbport: native HDR output: the game's tonemapped frame (the UI target): RGBA8 of the + /// render size, not a display buffer. + [[nodiscard]] bool LdrFrame(VideoCore::ImageId id) const; + /// bbport: native HDR output: the output-size UI image as a native HDR source (UNORM view: + /// the display-encoded values, whatever its format). + HdrNative::Source HdrUiImage(); /// The scene's size inside a `w` x `h` render target: the game's viewport. [[nodiscard]] std::array SceneSize(u32 w, u32 h) const; // The scene color is drawn into a reduced SceneTargets proxy (live presets). @@ -187,6 +199,9 @@ private: Runtime& runtime; CameraMotion& camera_motion; SceneTargets& scene_targets; + HdrNative& hdr_native; + /// bbport: native HDR output: the game's UI target this frame's UI-free copy came from. + VideoCore::ImageId hdr_ui_target{}; int applied_preset = -1; int applied_upscaler = -1; bool dispatched_last_frame = false; diff --git a/gpu/shadps4/video_core/texture_cache/image.h b/gpu/shadps4/video_core/texture_cache/image.h index 21f9e6e..95523cc 100644 --- a/gpu/shadps4/video_core/texture_cache/image.h +++ b/gpu/shadps4/video_core/texture_cache/image.h @@ -162,6 +162,8 @@ public: /// bbport: gc tick of the last LRU touch; skips the LRU list (a cache miss) when current. mutable u64 lru_touched_tick = ~0ULL; u64 tick_accessed_last{}; + /// bbport (image write-back GC): gc tick before which a failed write-back is not retried. + u64 writeback_retry_tick = 0; ImageId depth_id{}; u64 depth_uid{}; u64 image_uid{}; @@ -190,6 +192,10 @@ public: }; std::deque backing_images; boost::container::static_vector mip_hashes{}; + /// bbport: mip_hashes still describe guest memory. Set when RefreshImage has hashed every mip; + /// cleared by whatever may write that memory unseen afterwards (an invalidation over its pages, + /// untracking, a store of image data into it). + bool mip_hashes_current = false; u64 lru_id{}; u64 hash{}; diff --git a/gpu/shadps4/video_core/texture_cache/texture_cache.cpp b/gpu/shadps4/video_core/texture_cache/texture_cache.cpp index 98ccdd1..c43bdbf 100644 --- a/gpu/shadps4/video_core/texture_cache/texture_cache.cpp +++ b/gpu/shadps4/video_core/texture_cache/texture_cache.cpp @@ -4,8 +4,11 @@ #include #include +#include "bbport_copy.h" +#include "bbport_sections.h" #include "bbport_toggles.h" #include "bbport_free_check.h" +#include "engine/key_prompts.h" #include "common/assert.h" #include "common/debug.h" #include "common/div_ceil.h" @@ -150,10 +153,116 @@ TextureCache::TextureCache(const Vulkan::Instance& instance_, Vulkan::Scheduler& std::max(std::min(device_local_memory - min_vacancy_critical, min_spacing_critical), DEFAULT_CRITICAL_GC_MEMORY)); trigger_gc_memory = static_cast((device_local_memory - mem_threshold) / 2); + static_pressure_gc_memory = pressure_gc_memory; + static_critical_gc_memory = critical_gc_memory; } +namespace { +/// bbport: discrete GPUs: the pressure mark as a share of the driver's current budget +/// (BB_GC_PRESSURE_PERCENT, default 80; the critical mark 10 points higher). 0: shadPS4's +/// marks only. Those keep 60% of an up to 8 GB budget vacant: on an 8 GB card the pressure +/// mark is ~2.5 GB while the game's VRAM sits at 3.4-4.3 GB (RTX 5060, Central Yharnam), so +/// the collector stayed in pressure mode, evicted hundreds of textures still in use (age 80 +/// submissions, about one frame), uploaded them again and waited for the GPU to write tiled +/// render targets back. The marks never go below shadPS4's. +u64 DiscretePressurePercent() { + static const u64 percent = [] { + const char* env = std::getenv("BB_GC_PRESSURE_PERCENT"); + return env && env[0] ? std::min(std::strtoull(env, nullptr, 10), 85) : 80; + }(); + return percent; +} +/// bbport: under pressure (below the critical mark) an image goes only once unused for this +/// many seconds (BB_GC_PRESSURE_SECONDS, default 1; 0: 80 submissions as before). +u64 PressureIdleSeconds() { + static const u64 seconds = [] { + const char* env = std::getenv("BB_GC_PRESSURE_SECONDS"); + return std::min(env && env[0] ? std::strtoull(env, nullptr, 10) : 1, 10); + }(); + return seconds; +} +/// bbport (GPU hang guard): the collector never frees, nor writes back, an image used in the +/// last this many collector passes, one per guest submission and several a frame +/// (BB_GC_RECENT_FRAMES, default 48; 0: off). The memo hits that bind most +/// images (texture sets, binding and target memos: MarkFound) do not touch the LRU, so an image +/// sampled every frame looked idle: it was freed while in use, re-created and uploaded again, +/// and GPU-written render targets went through a synchronous write-back each time (stalls of +/// 0.3-2 s in area loads before a VK_ERROR_DEVICE_LOST in a full-screen blit). +u64 GcRecentFrames() { + static const u64 frames = [] { + const char* env = std::getenv("BB_GC_RECENT_FRAMES"); + return std::min(env && env[0] ? std::strtoull(env, nullptr, 10) : 48, 60); + }(); + return frames; +} +/// bbport (GPU hang guard): a deleted image's Vulkan objects outlive its unregistration by this +/// many collector passes (submissions), then the GPU's completion of the tick of that pass +/// (BB_IMAGE_FREE_DELAY_FRAMES, default 32; 0: only the GPU tick, as shadPS4). A reference that outlived the registry change +/// (a memo, a proxy, the upscaler) then reads a still valid image instead of freed memory. +u64 ImageFreeDelayFrames() { + static const u64 frames = [] { + const char* env = std::getenv("BB_IMAGE_FREE_DELAY_FRAMES"); + return std::min(env && env[0] ? std::strtoull(env, nullptr, 10) : 32, 240); + }(); + return frames; +} +} // namespace + TextureCache::~TextureCache() = default; +/// bbport BB_REFRESH_TRACE=1 (diagnostics): for images of 4 MiB and up, the writes that mark one +/// dirty (and their kind, InvalidateReason) and its refreshes, with the flags they found and what +/// they did: each combination once, 150 lines at most (loading screens would use up a plain +/// count before the frames that repeat one). +namespace RefreshTrace { +static bool On() { + static const bool on = [] { + const char* env = std::getenv("BB_REFRESH_TRACE"); + return env && env[0] == '1'; + }(); + return on; +} +static bool Large(const Image& image) { + return On() && image.info.guest_size >= (4ull << 20); +} +static bool First(VAddr image, const char* kind, const char* what, u32 flags) { + static std::mutex mutex; + static std::vector> seen; + std::scoped_lock lock{mutex}; + const auto key = std::make_tuple(image, kind, what, flags); + if (seen.size() >= 150 || std::find(seen.begin(), seen.end(), key) != seen.end()) { + return false; + } + seen.push_back(key); + return true; +} +static void Mark(const Image& image, const char* what, VAddr addr, u64 size) { + if (!Large(image) || + !First(image.info.guest_address, invalidate_reason, what, u32(image.flags))) { + return; + } + const auto format = vk::to_string(image.info.pixel_format); + std::printf("Refresh trace: %s write %#llx (+%#llx, %#llx bytes) marks image %#llx %s " + "%ux%u (%#llx bytes, flags %#x) %s\n", + invalidate_reason, static_cast(addr), + static_cast(addr - image.info.guest_address), + static_cast(size), + static_cast(image.info.guest_address), format.c_str(), + image.info.size.width, image.info.size.height, + static_cast(image.info.guest_size), u32(image.flags), what); +} +static void Refresh(const Image& image, u32 flags, const char* outcome) { + if (!Large(image) || !First(image.info.guest_address, "refresh", outcome, flags)) { + return; + } + const auto format = vk::to_string(image.info.pixel_format); + std::printf("Refresh trace: image %#llx %s %ux%u, %u mips, flags %#x: %s\n", + static_cast(image.info.guest_address), format.c_str(), + image.info.size.width, image.info.size.height, image.info.resources.levels, flags, + outcome); +} +} // namespace RefreshTrace + std::string TextureCache::DescribeImagesIn(VAddr addr, u64 size) { std::unique_lock lock{mutex, std::try_to_lock}; // the caller may hold it already if (!lock.owns_lock()) { @@ -197,6 +306,7 @@ void TextureCache::DownloadImageMemory(ImageId image_id, bool sync) { const u32 download_size = image.info.pitch * image.info.size.height * image.info.size.depth * image.info.resources.layers * (image.info.num_bits / 8); ASSERT(download_size <= image.info.guest_size); + image.mip_hashes_current = false; // its data lands in guest memory vk::BufferImageCopy image_copy = { .bufferOffset = 0, .bufferRowLength = image.info.pitch, @@ -267,6 +377,157 @@ void TextureCache::DownloadImageMemory(ImageId image_id, bool sync) { } } +// bbport (image write-back GC). Adapted from IFreemz/shadPS4-Bloodborne-DLSS-FSR, branch exp-1.9: +// a560287 "Reclaim unused images with guarded writeback" (Cainhurst27, credit u/EnglishDave_) and +// 0445949 "VRAM cleanup: don't retry a failed image write-back for 600 frames" (IFreemz). +// Original code: Copyright 2024-2026 shadPS4 Emulator Project, Cainhurst27 and IFreemz; +// licensed GPL-2.0-or-later like this file. +// Differences: the surface is tiled into a host-visible copy of the guest memory and stored with +// TryWriteBacking (as DownloadImageMemory's synchronous path), not into the buffer cache's arena; +// the padding outside the image's extent keeps its guest bytes (TileImageOver); stricter layout +// checks; other images over the range must not hold GPU data, and the clean ones are invalidated. +bool TextureCache::WriteBackTiledImage(ImageId image_id) { + Image& image = slot_images[image_id]; + if (False(image.flags & ImageFlagBits::GpuModified)) { + return true; // guest memory already holds its contents + } + const auto& info = image.info; + if (!info.props.is_tiled || info.guest_address == 0 || info.guest_size == 0 || + !image.backing) { + return false; + } + // Only the plain colour surface is rebuilt: compression metadata, depth/stencil, MSAA, + // block-compressed, volume and thick-tiled layouts stay resident. So does an image whose + // newest contents may be in a reduced-resolution scene proxy (SceneTargets). + const auto& meta = info.meta_info; + if (meta.cmask_addr || meta.fmask_addr || meta.htile_addr || info.stencil_addr || + info.props.is_depth || info.props.has_stencil || info.props.is_block || + info.props.is_volume || info.num_samples != 1 || image.scene_proxy || + image.aspect_mask != vk::ImageAspectFlags{vk::ImageAspectFlagBits::eColor} || + AmdGpu::GetMicroTileThickness(info.array_mode) != 1) { + return false; + } + switch (info.num_bits) { + case 8: + case 16: + case 32: + case 64: + case 96: + case 128: + break; + default: + return false; // no tiling shader for it + } + const u32 bytes_per_texel = info.num_bits / 8; + // The tiling shader runs whole groups of 64 texels over guest_size. + if (info.guest_size % (bytes_per_texel * 64) != 0) { + return false; + } + const u32 levels = info.resources.levels; + const u32 layers = info.resources.layers; + if (levels == 0 || levels > info.mips_layout.size() || layers == 0) { + return false; + } + // The shader numbers texels mip after mip (x, y, then slice); the copies place mip m at its + // offset with rows of `pitch` texels and slices of `height` rows. Both agree, and every byte + // of guest_size is produced, only when the mips are contiguous and each is exactly + // pitch * height * layers texels. + u64 expected_offset = 0; + for (u32 mip = 0; mip < levels; ++mip) { + const auto& mip_info = info.mips_layout[mip]; + if (mip_info.pitch == 0 || mip_info.height == 0 || mip_info.pitch % 8 != 0 || + mip_info.height % 8 != 0 || mip_info.offset != expected_offset || + u64{mip_info.pitch} * mip_info.height * bytes_per_texel * layers != mip_info.size) { + return false; + } + expected_offset += mip_info.size; + } + if (expected_offset != info.guest_size) { + return false; + } + auto* const memory = Core::Memory::Instance(); + const VAddr address = info.guest_address; + const u64 size = info.guest_size; + // All of the range must be mapped: TryWriteBacking may otherwise store part of it. + if (memory->ClampRangeSize(address, size) != size) { + return false; + } + // The buffer cache may hold GPU writes over the range that guest memory has not seen. + if (buffer_cache.IsRegionGpuModified(address, size)) { + return false; + } + // Other images over the range: one holding GPU data would be overwritten in guest memory; + // the clean ones are invalidated once the write-back has landed. + ImageIds overlapping; + bool other_gpu_data = false; + ForEachImageInRegion(address, size, [&](ImageId other_id, Image& other) { + if (other_id == image_id) { + return; + } + if (True(other.flags & ImageFlagBits::GpuModified)) { + other_gpu_data = true; + } + overlapping.push_back(other_id); + }); + if (other_gpu_data) { + return false; + } + + boost::container::small_vector copies; + for (u32 mip = 0; mip < levels; ++mip) { + const auto& mip_info = info.mips_layout[mip]; + const u32 width = std::max(info.size.width >> mip, 1u); + const u32 height = std::max(info.size.height >> mip, 1u); + // The inverse of RefreshImage's upload copies. + copies.push_back({ + .bufferOffset = mip_info.offset, + .bufferRowLength = mip_info.pitch, + .bufferImageHeight = mip_info.height, + .imageSubresource{ + .aspectMask = vk::ImageAspectFlagBits::eColor, + .mipLevel = mip, + .baseArrayLayer = 0, + .layerCount = layers, + }, + .imageOffset = {0, 0, 0}, + .imageExtent = {std::min(mip_info.pitch, width), std::min(mip_info.height, height), 1}, + }); + } + + // A host-visible copy of the guest surface, tiled over on the GPU, then stored back whole. + const auto download = runtime.GetStagingPool().Request( + size, MemoryType::HostCached, std::max(instance.StorageMinAlignment(), 16)); + if (!download.mapped) { + return false; + } + memory->CopySparseMemory(address, download.mapped, size); + download.Flush(); + scheduler.EndRendering(); + tile_manager.TileImageOver(image, copies, download.buffer, download.offset); + runtime.FlushBarriers(); + // Synchronously, while the image still protects its pages (see GarbageCollectImages). + scheduler.Finish(); + download.Invalidate(); + BbFreeCheck::Check(address, size, download.mapped, BbFreeCheck::ImageDownload); + // False when part of the range lost its mapping meanwhile: the image stays (still the + // complete copy), and what was stored is its own data. + const bool written = + memory->TryWriteBacking(std::bit_cast(address), download.mapped, size); + image.mip_hashes_current = false; + // Guest memory changed under the copies made of it, even by a partial store. + if (!WriteTracking()) { + buffer_cache.InvalidateMemory(address, size); + } + // As a CPU write over them would: re-uploaded from guest memory at their next use. + for (const ImageId other_id : overlapping) { + Image& other = slot_images[other_id]; + RefreshTrace::Mark(other, "CPU dirty (stored back image)", address, size); + other.flags |= ImageFlagBits::CpuDirty; + UntrackImage(other_id); + } + return written; +} + void TextureCache::DumpImagesAt(VAddr address, const char* dir) { boost::container::small_vector ids; { @@ -314,12 +575,44 @@ void TextureCache::DumpImagesAt(VAddr address, const char* dir) { } } +/// bbport: a hash of guest memory for the refresh checks. From 1 MiB on, 256 KiB chunks are +/// hashed on the copy threads and then their hashes: a 1080p R32F image the game refreshes every +/// frame (its own compute copy of the depth buffer) cost ~1 ms of hashing on the draw recording +/// thread. Only compared with hashes made here. BB_PARALLEL_IMAGE_HASH=0: one XXH3 as before. +static u64 GuestMemoryHash(const void* data, u64 size) { + static const bool parallel = [] { + const char* env = std::getenv("BB_PARALLEL_IMAGE_HASH"); + return !(env && env[0] == '0'); + }(); + constexpr u64 Chunk = 256 * 1024; + if (!parallel || size < 4 * Chunk || !BbCopy::Enabled()) { + return XXH3_64bits(data, size); + } + const auto* bytes = static_cast(data); + const u64 count = (size + Chunk - 1) / Chunk; + boost::container::small_vector hashes(count); + BbCopy::ParallelFor(count, [&](std::size_t i) { + const u64 offset = i * Chunk; + hashes[i] = XXH3_64bits(bytes + offset, std::min(Chunk, size - offset)); + }); + return XXH3_64bits(hashes.data(), count * sizeof(u64)); +} + +/// bbport: whether a GPU-dirty refresh may keep the mip hashes (RefreshImage). +static bool KeepMipHashes() { + static const bool keep = [] { + const char* env = std::getenv("BB_KEEP_MIP_HASHES"); + return !(env && env[0] == '0'); + }(); + return keep; +} + /// bbport: the guest memory a MaybeCpuDirty check compares, the same at marking and at /// refresh (they hashed different ranges, the whole image and its first 8x8 pixels, so the /// first check never matched). Such an image lies within the faulting page: cheap to hash /// whole, and a CPU write past its first pixels still counts. u64 TextureCache::MaybeDirtyHash(const Image& image) { - return XXH3_64bits(std::bit_cast(image.info.guest_address), image.info.guest_size); + return GuestMemoryHash(std::bit_cast(image.info.guest_address), image.info.guest_size); } void TextureCache::MarkAsMaybeDirty(ImageId image_id, Image& image) { @@ -338,9 +631,11 @@ void TextureCache::InvalidateMemory(VAddr addr, size_t size) { ForEachImageInRegion(pages_start, pages_end - pages_start, [&](ImageId image_id, Image& image) { const auto image_begin = image.info.guest_address; const auto image_end = image.info.guest_address + image.info.guest_size; + image.mip_hashes_current = false; if (image.Overlaps(addr, size)) { // Modified region overlaps image, so the image was definitely accessed by this fault. // Untrack the image, so that the range is unprotected and the guest can write freely. + RefreshTrace::Mark(image, "CPU dirty", addr, size); image.flags |= ImageFlagBits::CpuDirty; PageManager::NoteImageFault(image.info.guest_address, image.info.guest_size, image.info.size.width, image.info.size.height, @@ -365,6 +660,50 @@ void TextureCache::InvalidateMemory(VAddr addr, size_t size) { }); } +bool TextureCache::KeyPromptData(const Image& image, std::vector& data) { + const ImageInfo& info = image.info; + const bool icon = info.guest_size == KeyPrompts::kIconBytes && info.size.width == 32; + const bool hud = info.guest_size == KeyPrompts::kHudBytes && info.size.width == 1024; + if ((!icon && !hud) || info.resources.levels != 1 || info.resources.layers != 1 || + (info.pixel_format != vk::Format::eBc7UnormBlock && + info.pixel_format != vk::Format::eBc7SrgbBlock)) { + return false; + } + const u8* guest = std::bit_cast(info.guest_address); + const int texture = KeyPrompts::Identify(guest, info.guest_size, info.size.width, info.size.height); + if (texture < 0) { + return false; + } + { + std::scoped_lock lock{key_icons_mutex}; + const std::pair where{info.guest_address, info.guest_size}; + if (std::find(key_icons.begin(), key_icons.end(), where) == key_icons.end()) { + // The game loads its icons once; a few reloads elsewhere at most. + if (key_icons.size() >= 256) { + key_icons.erase(key_icons.begin()); + } + key_icons.push_back(where); + } + } + data.assign(guest, guest + info.guest_size); + return KeyPrompts::Draw(texture, data.data(), data.size()); +} + +void TextureCache::UpdateKeyPrompts() { + if (!KeyPrompts::Changed()) { + return; + } + std::vector> icons; + { + std::scoped_lock lock{key_icons_mutex}; + icons = key_icons; + } + // As a CPU write to them would: uploaded again where next drawn. + for (const auto& [addr, size] : icons) { + InvalidateMemory(addr, size); + } +} + void TextureCache::InvalidateMemoryFromGPU(VAddr address, size_t max_size) { std::scoped_lock lock{mutex}; ForEachImageInRegion(address, max_size, [&](ImageId image_id, Image& image) { @@ -374,6 +713,7 @@ void TextureCache::InvalidateMemoryFromGPU(VAddr address, size_t max_size) { return; } // Ensure image is reuploaded when accessed again. + RefreshTrace::Mark(image, "GPU dirty", address, max_size); image.flags |= ImageFlagBits::GpuDirty; }); } @@ -780,6 +1120,18 @@ ImageId TextureCache::FindImage(ImageDesc& desc, bool exact_fmt) { return image_id; } +bool TextureCache::IsExactMatch(ImageId image_id, const ImageInfo& info) const { + if (!image_id || !slot_images.is_allocated(image_id)) { + return false; + } + const Image& image = slot_images[image_id]; + return True(image.flags & ImageFlagBits::Registered) && + image.info.guest_address == info.guest_address && + image.info.guest_size == info.guest_size && image.info.size == info.size && + image.info.pixel_format == info.pixel_format && image.info.type == info.type && + image.info.resources == info.resources && image.info.LayoutKey() == info.LayoutKey(); +} + ImageId TextureCache::FindImageFromRange(VAddr address, size_t size, bool ensure_valid) { ImageIds image_ids; ForEachImageInRegion(address, size, [&](ImageId image_id, Image& image) { @@ -915,12 +1267,14 @@ void TextureCache::RefreshImage(Image& image) { RENDERER_TRACE; TRACE_HINT(fmt::format("{:x}:{:x}", image.info.guest_address, image.info.guest_size)); + const u32 entry_flags = u32(image.flags); if (True(image.flags & ImageFlagBits::MaybeCpuDirty) && False(image.flags & ImageFlagBits::CpuDirty)) { const u64 hash = MaybeDirtyHash(image); if (image.hash == hash) { image.flags &= ~ImageFlagBits::MaybeCpuDirty; + RefreshTrace::Refresh(image, entry_flags, "unchanged (maybe dirty check)"); return; } image.hash = hash; @@ -930,6 +1284,14 @@ void TextureCache::RefreshImage(Image& image) { const u32 num_mips = image.info.resources.levels; const bool is_gpu_modified = True(image.flags & ImageFlagBits::GpuModified); const bool is_gpu_dirty = True(image.flags & ImageFlagBits::GpuDirty); + // bbport: a GPU-dirty refresh uploads the buffer cache's data, and the guest memory the mip + // hashes record is unchanged unless something wrote it since they were taken (which clears + // mip_hashes_current): they are kept instead of hashing it again. The game's compute copy of + // the depth buffer into a 1080p R32F image made that ~1 ms of every frame on the draw + // recording thread. BB_KEEP_MIP_HASHES=0: hashed every time. + constexpr u32 CpuDirtyBits = u32(ImageFlagBits::MaybeCpuDirty) | u32(ImageFlagBits::CpuDirty); + const bool keep_hashes = is_gpu_dirty && image.mip_hashes_current && + (entry_flags & CpuDirtyBits) == 0 && KeepMipHashes(); BbStats::image_upload_bytes.fetch_add(image.info.guest_size, std::memory_order_relaxed); boost::container::small_vector image_copies; @@ -948,7 +1310,11 @@ void TextureCache::RefreshImage(Image& image) { // contents with stale guest memory (a 1x1 exposure texture computed once: the // character creation preview went black after one frame). const u8* mip_addr = std::bit_cast(image.info.guest_address) + mip_offset; - const u64 mip_hash = XXH3_64bits(mip_addr, mip_size); + u64 mip_hash = image.mip_hashes[m]; + if (!keep_hashes) { + BB_SECTION(RefreshHash); + mip_hash = GuestMemoryHash(mip_addr, mip_size); + } if (is_gpu_modified && !is_gpu_dirty && image.mip_hashes[m] == mip_hash) { continue; } @@ -971,26 +1337,49 @@ void TextureCache::RefreshImage(Image& image) { }); } + // Only a tracked image hears of the writes that would outdate them. + image.mip_hashes_current = + image.track_addr == image.guest_begin && image.track_addr_end == image.guest_end; + if (image_copies.empty()) { image.flags &= ~ImageFlagBits::Dirty; + RefreshTrace::Refresh(image, entry_flags, "unchanged guest memory, the GPU's contents kept"); return; } + RefreshTrace::Refresh(image, entry_flags, + !is_gpu_dirty ? "uploaded" + : keep_hashes ? "uploaded from the buffer cache, hashes kept" + : "uploaded from the buffer cache"); scheduler.EndRendering(); - const auto [in_buffer, in_offset] = - buffer_cache.ObtainBufferForImage(image.info.guest_address, image.info.guest_size); + BbSections::Scope obtain_scope{BbSections::RefreshObtain}; + std::pair source; + // bbport: the game's button icons, as the keys they are bound to (engine/key_prompts.h). + std::vector keycaps; + if (KeyPromptData(image, keycaps)) { + auto& stream = buffer_cache.GetStreamBuffer(); + source = {&stream, stream.Copy(keycaps.data(), keycaps.size(), 256)}; + } else { + source = buffer_cache.ObtainBufferForImage(image.info.guest_address, image.info.guest_size); + } + const auto [in_buffer, in_offset] = source; + obtain_scope.End(); + BbSections::Scope detile_scope{BbSections::RefreshDetile}; const auto [buffer, offset] = tile_manager.DetileImage(in_buffer, in_offset, image.info); + detile_scope.End(); for (auto& copy : image_copies) { copy.bufferOffset += offset; } + BB_SECTION(RefreshUpload); runtime.UploadImage(&image, buffer, image_copies); } vk::Sampler TextureCache::GetSampler(const AmdGpu::Sampler& sampler, AmdGpu::BorderColorBuffer border_color_base, - const bool is_depth, float extra_lod_bias) { + const bool is_depth, float extra_lod_bias, + size_t* lru_id) { // Compare and plain uses of one S# need separate samplers; so do extra LOD biases. const u64 hash = HashCombine(HashCombine(XXH3_64bits(&sampler, sizeof(sampler)), is_depth), u64(std::bit_cast(extra_lod_bias))); @@ -1003,6 +1392,9 @@ vk::Sampler TextureCache::GetSampler(const AmdGpu::Sampler& sampler, } else { sampler_lru_cache.Touch(it->second.lru_id, gc_tick); } + if (lru_id) { + *lru_id = it->second.lru_id; + } return it->second.Handle(); } @@ -1051,6 +1443,16 @@ void TextureCache::UnregisterImage(ImageId image_id) { }); } +void TextureCache::WatchPages(VAddr addr, u64 size) { + watched_pages.Add(addr, size, 1); + tracker.UpdatePageWatchers<1>(addr, size); +} + +void TextureCache::UnwatchPages(VAddr addr, u64 size) { + tracker.UpdatePageWatchers(addr, size); + watched_pages.Add(addr, size, -1); +} + void TextureCache::TrackImage(ImageId image_id) { auto& image = slot_images[image_id]; if (!(image.flags & ImageFlagBits::Registered)) { @@ -1066,7 +1468,7 @@ void TextureCache::TrackImage(ImageId image_id) { // Re-track the whole image image.track_addr = image_begin; image.track_addr_end = image_end; - tracker.UpdatePageWatchers<1>(image_begin, image.info.guest_size); + WatchPages(image_begin, image.info.guest_size); } else { if (image_begin < image.track_addr) { TrackImageHead(image_id); @@ -1089,7 +1491,7 @@ void TextureCache::TrackImageHead(ImageId image_id) { ASSERT(image.track_addr != 0 && image_begin < image.track_addr); const auto size = image.track_addr - image_begin; image.track_addr = image_begin; - tracker.UpdatePageWatchers<1>(image_begin, size); + WatchPages(image_begin, size); } void TextureCache::TrackImageTail(ImageId image_id) { @@ -1105,11 +1507,12 @@ void TextureCache::TrackImageTail(ImageId image_id) { const auto addr = image.track_addr_end; const auto size = image_end - image.track_addr_end; image.track_addr_end = image_end; - tracker.UpdatePageWatchers<1>(addr, size); + WatchPages(addr, size); } void TextureCache::UntrackImage(ImageId image_id) { auto& image = slot_images[image_id]; + image.mip_hashes_current = false; // writes go unseen until it is tracked again if (!image.IsTracked()) { return; } @@ -1118,12 +1521,13 @@ void TextureCache::UntrackImage(ImageId image_id) { image.track_addr = 0; image.track_addr_end = 0; if (size != 0) { - tracker.UpdatePageWatchers(addr, size); + UnwatchPages(addr, size); } } void TextureCache::UntrackImageHead(ImageId image_id) { auto& image = slot_images[image_id]; + image.mip_hashes_current = false; const auto image_begin = image.info.guest_address; if (!image.IsTracked() || image_begin < image.track_addr) { return; @@ -1137,11 +1541,12 @@ void TextureCache::UntrackImageHead(ImageId image_id) { // Cehck its hash later. MarkAsMaybeDirty(image_id, image); } - tracker.UpdatePageWatchers(image_begin, size); + UnwatchPages(image_begin, size); } void TextureCache::UntrackImageTail(ImageId image_id) { auto& image = slot_images[image_id]; + image.mip_hashes_current = false; const auto image_end = image.info.guest_address + image.info.guest_size; if (!image.IsTracked() || image.track_addr_end < image_end) { return; @@ -1156,10 +1561,13 @@ void TextureCache::UntrackImageTail(ImageId image_id) { // Cehck its hash later. MarkAsMaybeDirty(image_id, image); } - tracker.UpdatePageWatchers(addr, size); + UnwatchPages(addr, size); } void TextureCache::GarbageCollectImages() { + const u64 second = u64(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count()); if (instance.CanReportMemoryUsage()) { total_used_memory = instance.GetDeviceMemoryUsage(); // bbport: on integrated GPUs (Steam Deck) the usage covers system-memory heaps holding @@ -1179,6 +1587,16 @@ void TextureCache::GarbageCollectImages() { pressure_gc_memory = budget / 100 * 85; critical_gc_memory = budget / 100 * 95; } + } else if (const u64 percent = DiscretePressurePercent(); + percent != 0 && second != gc_budget_second) { + // bbport: discrete GPUs (see DiscretePressurePercent); the budget once a second. + gc_budget_second = second; + if (const u64 budget = instance.GetDeviceMemoryBudgetNow(); budget != 0) { + pressure_gc_memory = std::max(static_pressure_gc_memory, budget / 100 * percent); + critical_gc_memory = + std::max({static_critical_gc_memory, budget / 100 * (percent + 10), + pressure_gc_memory}); + } } } BbStats::gc_used_bytes.store(total_used_memory, std::memory_order_relaxed); @@ -1188,9 +1606,6 @@ void TextureCache::GarbageCollectImages() { // discrete GPU it stays over the trigger, where an age of 16 ticks (~3 frames) evicted every // texture briefly off screen and uploaded it again. Below the pressure mark an image goes // once unused for BB_GC_IDLE_SECONDS (20): the textures of an area left behind. - const u64 second = u64(std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count()); if (second != gc_second) { gc_second = second; BbStats::coarse_second.store(u32(second), std::memory_order_relaxed); @@ -1216,6 +1631,29 @@ void TextureCache::GarbageCollectImages() { u64 below_tick = 0; size_t num_deletions = 0; u32 visited = 0; + // bbport: GPU-written tiled images are written back (re-tiled) and freed under pressure. + // BB_IMAGE_WRITEBACK_GC=0: kept resident instead, as before. + static const bool tiled_writeback = [] { + const char* env = std::getenv("BB_IMAGE_WRITEBACK_GC"); + return !(env && env[0] == '0'); + }(); + // Each write-back waits for the GPU (scheduler.Finish): at most this many a pass, and one + // that failed is not tried again for a while. + static constexpr size_t MaxTiledWritebacks = 4; + static constexpr u64 WritebackRetryTicks = 600; + size_t tiled_writebacks = 0; + // bbport (GPU hang guard): the submission tick GcRecentFrames() frames ago; images accessed + // at or after it are in use. 0: the guard is off. Until that many frames have passed, every + // image counts as recent. + const u64 recent_frames = GcRecentFrames(); + const u64 recent_tick = + recent_frames == 0 + ? 0 + : (gc_tick >= recent_frames + ? std::max(sched_tick_at_gc[(gc_tick - recent_frames) % + sched_tick_at_gc.size()], + 1) + : ~0ull); const auto configure = [&](bool allow_aggressive) { pressured = total_used_memory >= pressure_gc_memory; @@ -1224,6 +1662,9 @@ void TextureCache::GarbageCollectImages() { below_tick = gc_tick - ticks_to_destroy; if (!pressured && !aggresive) { below_tick = std::min(below_tick, idle_tick); + } else if (!aggresive && PressureIdleSeconds() != 0) { + below_tick = std::min(below_tick, gc_tick_at_second[(second - PressureIdleSeconds()) % + gc_tick_at_second.size()]); } num_deletions = aggresive ? 40 : pressured ? 20 : 10; visited = 0; @@ -1237,16 +1678,47 @@ void TextureCache::GarbageCollectImages() { const bool download = image.SafeToDownload(); const bool tiled = image.info.IsTiled(); // bbport: images that cannot go now (GPU-written: their contents exist only here, and - // tiled ones cannot be written back at all) neither use up the deletions nor stay first - // in line. The oldest ten being such render targets stopped the collector for good, and - // VRAM grew with every area visited up to the pressure mark (~10 GB on 16 GB). - if ((tiled && download) || (download && !pressured)) { + // tiled ones only through a write-back that succeeded) neither use up the deletions nor + // stay first in line. The oldest ten being such render targets stopped the collector for + // good, and VRAM grew with every area visited up to the pressure mark (~10 GB on 16 GB). + const auto keep = [&] { lru_cache.Touch(image.lru_id, gc_tick); BbStats::gc_kept_images.fetch_add(1, std::memory_order_relaxed); return false; + }; + if (download && !pressured) { + return keep(); + } + // bbport (GPU hang guard): in use in the last frames (see GcRecentFrames), or by GPU + // work not finished yet: kept, whatever its LRU age says. + if (recent_tick != 0 && (image.tick_accessed_last >= recent_tick || + !scheduler.IsFree(image.tick_accessed_last))) { + ++gc_recent_kept; + return keep(); + } + bool written_back = false; + if (tiled && download) { + // Image write-back GC (after IFreemz/shadPS4-Bloodborne-DLSS-FSR a560287, 0445949): + // freed only once its contents are back in guest memory, re-tiled. + if (!tiled_writeback || gc_tick < image.writeback_retry_tick) { + ++gc_tiled_kept; + return keep(); + } + if (tiled_writebacks >= MaxTiledWritebacks) { + return false; // each waits for the GPU: the next pass, still first in line + } + ++tiled_writebacks; + if (!WriteBackTiledImage(image_id)) { + // Unsupported or failed: likely the same next time; the attempt waited for the GPU. + image.writeback_retry_tick = gc_tick + WritebackRetryTicks; + ++gc_tiled_kept; + return keep(); + } + written_back = true; + ++gc_tiled_written; } --num_deletions; - if (download) { + if (download && !written_back) { // bbport: synchronously, while the image still protects its pages. A deferred // write-back landed after FreeImage had unprotected them, over whatever the game // had meanwhile stored there (e.g. its heap after unloading an area). @@ -1279,17 +1751,20 @@ void TextureCache::GarbageCollectImages() { lru_cache.ForEachItemBelow(below_tick, clean_up); } // bbport: evictions under memory pressure, at most every 5 s (BB_FRAME_STATS or not). - if (pressured || gc_downloads != 0) { + if (pressured || gc_downloads != 0 || gc_tiled_written != 0) { const auto now = std::chrono::steady_clock::now(); if (now - gc_report_time >= std::chrono::seconds(5)) { std::printf("Texture cache: memory pressure, %llu of %llu MiB (critical %llu): " - "%llu images evicted, %llu written back since the last report\n", + "%llu images evicted, %llu written back, %llu tiled written back, " + "%llu tiled kept, %llu in use kept since the last report\n", (unsigned long long)(total_used_memory >> 20), (unsigned long long)(pressure_gc_memory >> 20), (unsigned long long)(critical_gc_memory >> 20), - (unsigned long long)gc_evictions, (unsigned long long)gc_downloads); + (unsigned long long)gc_evictions, (unsigned long long)gc_downloads, + (unsigned long long)gc_tiled_written, (unsigned long long)gc_tiled_kept, + (unsigned long long)gc_recent_kept); gc_report_time = now; - gc_evictions = gc_downloads = 0; + gc_evictions = gc_downloads = gc_tiled_written = gc_tiled_kept = gc_recent_kept = 0; } } } @@ -1320,6 +1795,7 @@ void TextureCache::GarbageCollectSamplers() { const size_t lru_id = samplers.at(hash).lru_id; samplers.erase(hash); sampler_lru_cache.Free(lru_id); + ++sampler_generation; // remembered handles may be gone (SamplerGeneration) return false; }; @@ -1338,6 +1814,10 @@ void TextureCache::RunGarbageCollector() { SCOPE_EXIT { ++gc_tick; }; + // bbport (GPU hang guard): this frame's submission tick (GcRecentFrames), and the images + // deleted long enough ago (ImageFreeDelayFrames). + sched_tick_at_gc[gc_tick % sched_tick_at_gc.size()] = scheduler.CurrentTick(); + FlushImageGraveyard(); GarbageCollectImages(); GarbageCollectSamplers(); @@ -1375,6 +1855,16 @@ void TextureCache::DeleteImage(ImageId image_id) { } } + // bbport (GPU hang guard): kept for ImageFreeDelayFrames() frames first (FlushImageGraveyard). + if (ImageFreeDelayFrames() != 0) { + std::scoped_lock lk{graveyard_mutex}; + image_graveyard.push_back({gc_tick, image_id}); + return; + } + ReclaimImage(image_id); +} + +void TextureCache::ReclaimImage(ImageId image_id) { // Reclaim image and any image views it references. scheduler.DeferOperation([this, image_id] { Image& image = slot_images[image_id]; @@ -1387,4 +1877,14 @@ void TextureCache::DeleteImage(ImageId image_id) { }); } +void TextureCache::FlushImageGraveyard() { + const u64 delay = ImageFreeDelayFrames(); + std::scoped_lock lk{graveyard_mutex}; + while (!image_graveyard.empty() && image_graveyard.front().gc_tick + delay <= gc_tick) { + // Deferred again: freed once the GPU has finished everything recorded until now. + ReclaimImage(image_graveyard.front().image_id); + image_graveyard.pop_front(); + } +} + } // namespace VideoCore diff --git a/gpu/shadps4/video_core/texture_cache/texture_cache.h b/gpu/shadps4/video_core/texture_cache/texture_cache.h index 2e4c529..6ee6764 100644 --- a/gpu/shadps4/video_core/texture_cache/texture_cache.h +++ b/gpu/shadps4/video_core/texture_cache/texture_cache.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -25,6 +26,7 @@ #include "video_core/texture_cache/image_view.h" #include "video_core/texture_cache/sampler.h" #include "video_core/texture_cache/tile_manager.h" +#include "video_core/texture_cache/watched_pages.h" namespace AmdGpu { struct Liverpool; @@ -39,6 +41,19 @@ namespace VideoCore { class BufferCache; class PageManager; +/// bbport BB_REFRESH_TRACE=1 (diagnostics): the kind of write behind an invalidation, named by +/// the rasterizer around its calls, printed with the large images it marks dirty. +inline thread_local const char* invalidate_reason = "other"; +struct InvalidateReason { + const char* previous; + explicit InvalidateReason(const char* reason) : previous{invalidate_reason} { + invalidate_reason = reason; + } + ~InvalidateReason() { + invalidate_reason = previous; + } +}; + class TextureCache { // Default values for garbage collection static constexpr s64 DEFAULT_PRESSURE_GC_MEMORY = 1_GB + 512_MB; @@ -104,6 +119,34 @@ public: slot_images[image_id].tick_accessed_last = scheduler.CurrentTick(); } + /// bbport: whether FindImage's result `image_id` for `info` is an exact match: the + /// registered image with the same address, size, extent, format, type, resources and + /// layout (FindImage's own memo test, with equal resources). Images register once, their + /// descriptions never change and later images come after them in the page table, so a new + /// search finds that image first again for as long as it stays registered: a lookup + /// remembered with it outlives other images' (un)registration (IsRegisteredImage). + [[nodiscard]] bool IsExactMatch(ImageId image_id, const ImageInfo& info) const; + + /// bbport: whether `image_id` is still the registered image `uid` (see IsExactMatch). + [[nodiscard]] bool IsRegisteredImage(ImageId image_id, u64 uid) const { + if (!image_id || !slot_images.is_allocated(image_id)) { + return false; + } + const Image& image = slot_images[image_id]; + return image.image_uid == uid && True(image.flags & ImageFlagBits::Registered); + } + + /// bbport: the unique id of an image (slot ids are reused, these are not). + [[nodiscard]] u64 ImageUid(ImageId image_id) const { + return slot_images[image_id].image_uid; + } + + /// bbport (BB_DRAWREC_WORKERS): where the image with this id would be, for a prefetch from + /// the lookahead thread; the slot may be free or reused, nothing is read through it. + [[nodiscard]] const void* ImageSlotAddress(ImageId image_id) const noexcept { + return slot_images.SlotAddress(image_id); + } + TileManager& GetTileManager() noexcept { return tile_manager; } @@ -111,6 +154,16 @@ public: /// Invalidates any image in the logical page range. void InvalidateMemory(VAddr addr, size_t size); + /// bbport: uploads the game's button icons again when what they show changed (keyboard and + /// mouse prompts, engine/key_prompts.h). Once a frame, without the lock. + void UpdateKeyPrompts(); + + /// bbport: whether a tracked image protects a page of the range; without the lock, from any + /// thread (Rasterizer::OnWriteFault: a write fault elsewhere leaves this cache alone). + [[nodiscard]] bool WatchesPages(VAddr addr, size_t size) const noexcept { + return watched_pages.Any(addr, size); + } + /// Marks an image as dirty if it exists at the provided address. void InvalidateMemoryFromGPU(VAddr address, size_t max_size); @@ -217,7 +270,24 @@ public: /// extra_lod_bias: bbport, added to the S#'s bias (reduced scene rendering). [[nodiscard]] vk::Sampler GetSampler(const AmdGpu::Sampler& sampler, AmdGpu::BorderColorBuffer border_color_base, - bool is_depth, float extra_lod_bias = 0.0f); + bool is_depth, float extra_lod_bias = 0.0f, + size_t* lru_id = nullptr); + + /// bbport (sampler memo of the draw recording thread): a sampler GetSampler returned stays + /// alive while this is unchanged (the collector frees samplers only in RunGarbageCollector, + /// never while textures are bound: the GPU command thread with the draw pipe drained, or the + /// recording thread between packets; it bumps it then), provided the caller touches it once + /// per GC period (TouchSampler) as GetSampler does. + [[nodiscard]] u64 SamplerGeneration() const noexcept { + return sampler_generation; + } + [[nodiscard]] u64 GcTick() const noexcept { + return gc_tick; + } + void TouchSampler(size_t lru_id) { + std::scoped_lock lock{samplers_mutex}; + sampler_lru_cache.Touch(lru_id, gc_tick); + } /// Retrieves the image with the specified id. Image* TryGetImage(ImageId id, u64 uid) { @@ -365,6 +435,12 @@ private: /// Copies image memory back to CPU. void DownloadImageMemory(ImageId image_id, bool sync = false); + /// bbport (image write-back GC): synchronously writes a GPU-modified tiled image back to + /// guest memory, re-tiled, so the collector can free it. False (nothing freed, guest memory + /// untouched or the image still the only complete copy) when the write-back is unsupported + /// for this image or could not be completed. + bool WriteBackTiledImage(ImageId image_id); + /// Thread function for copying downloaded images out to CPU memory. void DownloadedImagesThread(const std::stop_token& token); @@ -387,12 +463,29 @@ private: void UntrackImageHead(ImageId image_id); void UntrackImageTail(ImageId image_id); + /// bbport: the page manager's write watchers of an image's pages, and watched_pages with + /// them: counted before the pages are protected, uncounted after they are unprotected. + void WatchPages(VAddr addr, u64 size); + void UnwatchPages(VAddr addr, u64 size); + void MarkAsMaybeDirty(ImageId image_id, Image& image); static u64 MaybeDirtyHash(const Image& image); + /// bbport: for an image with the game's button icons, its data with keycaps over them in + /// `data`; false when there is none to draw (engine/key_prompts.h). + bool KeyPromptData(const Image& image, std::vector& data); + /// Removes the image and any views/surface metas that reference it. void DeleteImage(ImageId image_id); + /// bbport (GPU hang guard): destroys the image and its views once the GPU is past the + /// current tick (DeleteImage, directly or after BB_IMAGE_FREE_DELAY_FRAMES). + void ReclaimImage(ImageId image_id); + + /// bbport (GPU hang guard): hands images unregistered at least BB_IMAGE_FREE_DELAY_FRAMES + /// frames ago to ReclaimImage (RunGarbageCollector, once a frame). + void FlushImageGraveyard(); + /// Touch the image in the LRU cache. void TouchImage(const Image& image); @@ -420,6 +513,9 @@ private: std::unordered_set download_images; u64 total_used_memory = 0; u64 gc_evictions = 0, gc_downloads = 0; ///< bbport: pressure report + /// bbport (image write-back GC): tiled images written back / kept (write-back refused or + /// failed) since the last pressure report. + u64 gc_tiled_written = 0, gc_tiled_kept = 0; std::chrono::steady_clock::time_point gc_report_time{}; /// bbport: gc_tick at each of the last 64 seconds (GarbageCollectImages). std::array gc_tick_at_second{}; @@ -427,16 +523,37 @@ private: u64 trigger_gc_memory = 0; u64 pressure_gc_memory = 0; u64 critical_gc_memory = 0; + /// bbport: shadPS4's marks from the startup budget, the floor of the discrete-GPU rule + /// (GarbageCollectImages), and the second the driver's budget was last asked for. + u64 static_pressure_gc_memory = 0; + u64 static_critical_gc_memory = 0; + u64 gc_budget_second = ~0ull; u64 total_used_samplers = 0; + u64 sampler_generation = 0; ///< bbport: samplers freed so far (SamplerGeneration) u64 trigger_gc_samplers = 0; u64 pressure_gc_samplers = 0; u64 critical_gc_samplers = 0; u64 gc_tick = 0; + /// bbport (GPU hang guard): scheduler.CurrentTick() at each of the last 64 collector passes + /// (one a frame, indexed by gc_tick), for "used in the last N frames" in submission ticks. + std::array sched_tick_at_gc{}; + /// bbport (GPU hang guard): unregistered images whose Vulkan objects are kept for a few more + /// frames (DeleteImage): the gc_tick they were deleted at. Slots are not reused meanwhile. + struct GraveImage { + u64 gc_tick; + ImageId image_id; + }; + std::deque image_graveyard; + std::mutex graveyard_mutex; + u64 gc_recent_kept = 0; ///< bbport: images kept because used in the last frames Common::LeastRecentlyUsedCache lru_cache; Common::LeastRecentlyUsedCache sampler_lru_cache; const bool readback_linear_images; PageTable page_table; std::mutex mutex; + /// bbport: the pages tracked images protect, readable without `mutex` (WatchesPages); + /// changed with `mutex` held, through WatchPages and UnwatchPages. + WatchedPages watched_pages; // bbport: FindImage results for unchanged image registrations (guarded by `mutex`). struct FindImageCacheEntry { VAddr address = 0; @@ -457,6 +574,9 @@ private: std::array find_image_cache{}; std::atomic registry_generation{0}; std::mutex samplers_mutex; + /// bbport: where the game's button icons are (KeyPromptData), for UpdateKeyPrompts. + std::mutex key_icons_mutex; + std::vector> key_icons; std::mutex download_images_mutex; std::atomic downloads_queued{false}; ///< download_images may be non-empty (checked without the lock) struct MetaDataInfo { diff --git a/gpu/shadps4/video_core/texture_cache/tile_manager.cpp b/gpu/shadps4/video_core/texture_cache/tile_manager.cpp index da02543..8e9d6bb 100644 --- a/gpu/shadps4/video_core/texture_cache/tile_manager.cpp +++ b/gpu/shadps4/video_core/texture_cache/tile_manager.cpp @@ -339,4 +339,82 @@ void TileManager::TileImage(Image& in_image, std::span buff vk::AccessFlagBits2::eShaderWrite); } +// bbport (image write-back GC): TileImage for a write-back of the whole guest surface. Adapted +// from the tiled write-back of IFreemz/shadPS4-Bloodborne-DLSS-FSR (exp-1.9, a560287 by +// Cainhurst27, credit u/EnglishDave_; GPL-2.0-or-later). TileImage tiles a linear staging copy +// of which only the image's extent holds data: the rest (padding rows and columns, beyond a +// render target's hinted size) is whatever the pool held, and tiling writes it into the guest +// surface. Here the linear staging is first detiled from `out_buffer`, which the caller filled +// with the guest memory: bytes the image does not cover go back as they were. +void TileManager::TileImageOver(Image& in_image, std::span buffer_copies, + const VideoCore::Buffer* out_buffer, u64 out_offset) { + const auto& info = in_image.info; + ASSERT(info.props.is_tiled && !buffer_copies.empty() && + buffer_copies.size() <= info.mips_layout.size()); + + TilingInfo params{}; + params.bank_swizzle = info.bank_swizzle; + params.num_slices = info.props.is_volume ? info.size.depth : info.resources.layers; + params.num_mips = static_cast(buffer_copies.size()); + for (u32 mip = 0; mip < params.num_mips; ++mip) { + auto& mip_info = params.mips[mip]; + mip_info = info.mips_layout[mip]; + if (info.props.is_block) { + mip_info.pitch = std::max((mip_info.pitch + 3) / 4, 1U); + mip_info.height = std::max((mip_info.height + 3) / 4, 1U); + } + } + + const vk::DescriptorBufferInfo params_buffer_info{ + .buffer = stream_buffer.Handle(), + .offset = stream_buffer.Copy(¶ms, sizeof(params), instance.UniformMinAlignment()), + .range = sizeof(params), + }; + + const auto staging = runtime.GetStagingPool().Request( + info.guest_size, MemoryType::DeviceLocal, instance.StorageMinAlignment()); + + scheduler.EndRendering(); + runtime.FlushBarriers(); + + const vk::DescriptorBufferInfo tiled_buffer_info{ + .buffer = out_buffer->Handle(), + .offset = out_offset, + .range = info.guest_size, + }; + const vk::DescriptorBufferInfo linear_buffer_info{ + .buffer = staging.buffer->Handle(), + .offset = staging.offset, + .range = info.guest_size, + }; + const auto dim_x = static_cast((info.guest_size / (info.num_bits / 8)) / 64); + + // 1. The guest surface, detiled into the linear staging. + RecordTilingDispatch(GetTilingPipeline(info, false), tiled_buffer_info, linear_buffer_info, + params_buffer_info, dim_x); + runtime.AccessBuffer(out_buffer, out_offset, info.guest_size, + vk::PipelineStageFlagBits2::eComputeShader, + vk::AccessFlagBits2::eShaderRead); + runtime.AccessBuffer(staging.buffer, staging.offset, info.guest_size, + vk::PipelineStageFlagBits2::eComputeShader, + vk::AccessFlagBits2::eShaderWrite); + + // 2. The image over it (DownloadImage waits for the dispatch: a write to the same range). + for (auto& copy : buffer_copies) { + copy.bufferOffset += staging.offset; + } + runtime.DownloadImage(&in_image, staging.buffer, buffer_copies); + runtime.FlushBarriers(); + + // 3. All of it tiled back into the guest surface. + RecordTilingDispatch(GetTilingPipeline(info, true), tiled_buffer_info, linear_buffer_info, + params_buffer_info, dim_x); + runtime.AccessBuffer(staging.buffer, staging.offset, info.guest_size, + vk::PipelineStageFlagBits2::eComputeShader, + vk::AccessFlagBits2::eShaderRead); + runtime.AccessBuffer(out_buffer, out_offset, info.guest_size, + vk::PipelineStageFlagBits2::eComputeShader, + vk::AccessFlagBits2::eShaderWrite); +} + } // namespace VideoCore diff --git a/gpu/shadps4/video_core/texture_cache/tile_manager.h b/gpu/shadps4/video_core/texture_cache/tile_manager.h index 36d0505..0de4773 100644 --- a/gpu/shadps4/video_core/texture_cache/tile_manager.h +++ b/gpu/shadps4/video_core/texture_cache/tile_manager.h @@ -49,6 +49,12 @@ public: void TileImage(Image& in_image, std::span buffer_copies, const VideoCore::Buffer* out_buffer, u64 out_offset); + /// bbport (image write-back GC): tiles a tiled image into `out_buffer`, which holds the guest + /// surface (info.guest_size bytes at out_offset): bytes outside the image's extent keep the + /// value they had there. `buffer_copies`: one per mip, offsets relative to the linear layout. + void TileImageOver(Image& in_image, std::span buffer_copies, + const VideoCore::Buffer* out_buffer, u64 out_offset); + std::pair DetileImage(const VideoCore::Buffer* in_buffer, u64 in_offset, const ImageInfo& info); diff --git a/gpu/shadps4/video_core/texture_cache/watched_pages.h b/gpu/shadps4/video_core/texture_cache/watched_pages.h new file mode 100644 index 0000000..2336643 --- /dev/null +++ b/gpu/shadps4/video_core/texture_cache/watched_pages.h @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: how many tracked images watch each guest page, readable without the texture cache's +// lock (docs/pipeline_compiles.md, "Write faults"). No Vulkan here: tests/test_pipeline_compile.cpp. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace VideoCore { + +/// The pages each tracked image protects (TextureCache::TrackImage and the Untrack functions: +/// the same ranges, counted per page as the page manager counts them), per 4 KiB page. A write +/// fault on a page no tracked image watches (a buffer's page, most faults) needs nothing from +/// the texture cache: the guest thread then does not take its lock, which the draw recording +/// thread holds while it creates an image or uploads one (0.5-0.6 s each with memory short). +/// +/// Writers hold the texture cache's lock (one at a time); readers may be any thread. A reader +/// that races with a track sees the page either way: unwatched, its write lands before the +/// image's upload reads the memory; watched, it takes the lock as before. Counts go up before +/// the pages are protected and down after they are unprotected. +class WatchedPages { +public: + static constexpr std::uint32_t PageBits = 12; ///< the page manager's 4 KiB pages + static constexpr std::uint32_t ChunkPageBits = 16; ///< 256 MiB of guest memory per chunk + static constexpr std::uint32_t AddressBits = 40; ///< guest addresses (page manager) + + /// `delta` (+1/-1) for every page of [address, address + size), as + /// PageManager::UpdatePageWatchers counts them. Writers hold the texture cache's lock. + void Add(std::uint64_t address, std::uint64_t size, int delta) { + const std::uint64_t first = address >> PageBits; + const std::uint64_t end = (address + size + PageSize - 1) >> PageBits; + for (std::uint64_t page = first; page < end; ++page) { + const std::uint64_t chunk = page >> ChunkPageBits; + if (chunk >= NumChunks) { + return; + } + Count* counts = chunks[chunk].load(std::memory_order_relaxed); + if (!counts) { + if (delta < 0) { + continue; + } + owned.push_back(std::make_unique(ChunkPages)); + counts = owned.back().get(); + chunks[chunk].store(counts, std::memory_order_release); + } + Count& count = counts[page & (ChunkPages - 1)]; + const std::uint32_t now = count.load(std::memory_order_relaxed); + // One writer: a plain read-modify-write (no locked instruction per page). + count.store(static_cast(delta < 0 ? (now ? now - 1 : 0) : now + 1), + std::memory_order_release); + } + } + + /// Whether a tracked image watches a page of [address, address + size). Any thread. + [[nodiscard]] bool Any(std::uint64_t address, std::uint64_t size) const noexcept { + const std::uint64_t first = address >> PageBits; + const std::uint64_t end = (address + std::max(size, 1) + PageSize - 1) >> + PageBits; + for (std::uint64_t page = first; page < end; ++page) { + const std::uint64_t chunk = page >> ChunkPageBits; + if (chunk >= NumChunks) { + return true; // outside the table: assume watched (the locked path) + } + const Count* counts = chunks[chunk].load(std::memory_order_acquire); + if (counts && counts[page & (ChunkPages - 1)].load(std::memory_order_acquire) != 0) { + return true; + } + } + return false; + } + +private: + using Count = std::atomic; + static constexpr std::uint64_t PageSize = std::uint64_t{1} << PageBits; + static constexpr std::size_t ChunkPages = std::size_t{1} << ChunkPageBits; + static constexpr std::size_t NumChunks = std::size_t{1} + << (AddressBits - PageBits - ChunkPageBits); + std::array, NumChunks> chunks{}; + std::vector> owned; ///< writers only +}; + +} // namespace VideoCore diff --git a/gpu/shim/bbgpu.cpp b/gpu/shim/bbgpu.cpp index f38aea7..6b80f76 100644 --- a/gpu/shim/bbgpu.cpp +++ b/gpu/shim/bbgpu.cpp @@ -8,6 +8,7 @@ #include #endif #include "bbport_free_check.h" +#include "bbport_sampler.h" #include "bbport_toggles.h" #include "engine/engine_hooks.h" #include @@ -29,6 +30,7 @@ #include "common/elf_info.h" #include "common/logging/log.h" #include "common/rdtsc.h" +#include "common/signal_context.h" #include "core/libraries/kernel/orbis_error.h" #include "core/libraries/libs.h" #include "core/emulator_settings.h" @@ -55,7 +57,9 @@ int32_t* runtime_errno(void); void runtime_memory_set_gpu_hooks(RuntimeGpuRange map, RuntimeGpuRange unmap, RuntimeGpuRange invalidate); void runtime_memory_set_note_write_hook(RuntimeGpuRange note); void runtime_memory_set_cpu_write_hook(RuntimeGpuRange hook); +int runtime_memory_restore_access(void* context, uintptr_t address, int repeated); } +namespace Shader { void RegisterSrtFaultHandler(); } Frontend::WindowSDL* g_window = nullptr; @@ -274,8 +278,29 @@ bool g_window_ready; } // namespace #ifdef BB_PGO_GENERATE +#ifdef __clang__ +// bbport: clang's profile runtime (IR instrumentation, .profraw). __gcov_dump links there too +// but writes only gcov data (none here): the profile was written at a normal exit only. With +// -fprofile-generate= the file is default_%m.profraw and each write merges into it, hence +// the reset after it. +extern "C" int __llvm_profile_write_file(void); +extern "C" void __llvm_profile_reset_counters(void); +static void ProfileDump() { + __llvm_profile_write_file(); +} +static void ProfileReset() { + __llvm_profile_reset_counters(); +} +#else extern "C" void __gcov_dump(void); extern "C" void __gcov_reset(void); +static void ProfileDump() { + __gcov_dump(); +} +static void ProfileReset() { + __gcov_reset(); +} +#endif // Instrumented build (BB_PGO=generate): the game often ends through _exit (watchdog, guest // exit), which skips the profile write at exit. Write every 30 s and reset: the files sum the // intervals. @@ -284,8 +309,8 @@ static void StartProfileWriter() { Common::SetCurrentThreadName("bb:pgo"); for (;;) { std::this_thread::sleep_for(std::chrono::seconds(30)); - __gcov_dump(); - __gcov_reset(); + ProfileDump(); + ProfileReset(); std::printf("PGO: profile written\n"); } }).detach(); @@ -294,9 +319,13 @@ static void StartProfileWriter() { extern "C" int bbgpu_init(const BbGpuConfig* config) { BbSettings::Load(); + // The fault handlers are registered before any guest thread runs: the registry is a std::set + // the fault handler walks without a lock (the SRT walker's was added at the first shader). + Shader::RegisterSrtFaultHandler(); #ifdef BB_PGO_GENERATE StartProfileWriter(); #endif + BbSampler::Start(); // BB_SAMPLE_THREADS: where loading time goes g_sdk_version = config->sdk_version; #ifdef _WIN32 if (config->user_dir && !std::getenv("BB_GPU_USER_DIR")) _putenv_s("BB_GPU_USER_DIR", config->user_dir); @@ -322,6 +351,20 @@ extern "C" int bbgpu_init(const BbGpuConfig* config) { // _Exit skips renderer destructors. Snapshot completed pipeline work and drain its // asynchronous cache writes before terminating; no gameplay queue waits are added. Vulkan::PipelineCache::SaveAllForShutdown(); + // bbport: give the display back before terminating (_Exit skips SDL's cleanup): leave + // fullscreen and hide the window, on this thread, which owns it. It is not destroyed: + // the present thread may still be presenting to its Vulkan surface, and a window + // destroyed under a live swapchain fails the next present or recreate (an assert, + // exit 23). A window call that blocks cannot hold the exit up: the watchdog ends it. + std::thread([] { + std::this_thread::sleep_for(std::chrono::seconds(2)); + std::fflush(stdout); + std::_Exit(0); + }).detach(); + if (SDL_Window* sdl_window = window->GetSDLWindow()) { + SDL_SetWindowFullscreen(sdl_window, false); + SDL_HideWindow(sdl_window); + } std::fflush(stdout); std::_Exit(0); }); @@ -364,6 +407,36 @@ extern "C" uintptr_t bbgpu_resolve(const char* scoped_nid) { return 0; } +namespace { +// bbport: the same access (instruction and address) faulting again within microseconds, many +// times in a row: the tracking claimed the fault but left the page protected, and the thread +// would retry forever. A write the game repeats each frame comes milliseconds apart. +struct RepeatedFault { + u64 rip = 0, address = 0, last_tsc = 0; + u32 count = 0; +}; +thread_local RepeatedFault repeated_fault; + +bool FaultRepeats(void* ucontext, void* address) { + constexpr u64 RepeatCycles = 4'000'000; // ~1 ms + constexpr u32 RepeatLimit = 64; + auto& r = repeated_fault; + const u64 rip = reinterpret_cast(Common::GetRip(ucontext)); + const u64 now = Common::FencedRDTSC(); + if (rip != r.rip || reinterpret_cast(address) != r.address || + now - r.last_tsc > RepeatCycles) { + r = {rip, reinterpret_cast(address), now, 0}; + return false; + } + r.last_tsc = now; + if (++r.count < RepeatLimit) { + return false; + } + r.count = 0; + return true; +} +} // namespace + extern "C" int bbgpu_handle_fault(void* ucontext, void* address) { // BB_LABEL_TRAP (diagnostic): its read-only label pages first. Not passed on to GPU page // tracking (a write fault drains the draw pipe: thousands a second would change the timing @@ -372,9 +445,18 @@ extern "C" int bbgpu_handle_fault(void* ucontext, void* address) { return 1; } if (Core::Signals::Instance()->DispatchAccessViolation(ucontext, address)) { + if (FaultRepeats(ucontext, address)) { + runtime_memory_restore_access(ucontext, reinterpret_cast(address), 1); + } + return 1; + } + if (BbFreeCheck::OnStaleTrapFault(reinterpret_cast(address))) { return 1; } - return BbFreeCheck::OnStaleTrapFault(reinterpret_cast(address)) ? 1 : 0; + // bbport: nobody claimed it. A guest page the guest may access but GPU tracking left + // protected (a stale protection, a page it no longer knows) gets its access back; ending the + // game for it is worse than a GPU copy that may miss a write. Real faults stay unhandled. + return runtime_memory_restore_access(ucontext, reinterpret_cast(address), 0); } extern "C" void bbgpu_patch_image(unsigned char* image, uint64_t size) { @@ -406,11 +488,13 @@ static void KernelServiceThread(std::stop_token stoken) { { std::unique_lock lock{m_asio_req}; cv_asio_req.wait(lock, stoken, [] { return asio_requests != 0; }); + // Taken before the run: a request made while it runs (a timer armed after run() + // found no work) wakes the next one instead of being cleared after it. + asio_requests.exchange(0); } if (stoken.stop_requested()) break; io_context.run(); io_context.restart(); - asio_requests = 0; } } diff --git a/gpu/shim/bbport_copy.cpp b/gpu/shim/bbport_copy.cpp index f9d2884..14d4051 100644 --- a/gpu/shim/bbport_copy.cpp +++ b/gpu/shim/bbport_copy.cpp @@ -96,6 +96,20 @@ public: } } + bool HelpOne() { + Task task; + { + std::scoped_lock lk{mutex}; + if (async_tasks.empty()) { + return false; + } + task = std::move(async_tasks.front()); + async_tasks.pop_front(); + } + RunAsync(task); + return true; + } + void Run(std::size_t count, const std::function& task) { auto job = std::make_shared(); job->task = &task; @@ -186,6 +200,9 @@ private: void Loop(unsigned index) { Common::SetCurrentThreadName(("bb:Copy" + std::to_string(index)).c_str()); + // The GPU command thread waits for these copies (WaitHostCopies, WaitAsync); raised + // only with cores to spare (bulk uploads while streaming, see MakeCritical). + BbThreads::MakeCritical(/*bulk=*/true); u64 seen = 0; std::unique_lock lk{mutex}; while (true) { @@ -294,6 +311,10 @@ void WaitAsync() { GetPool().WaitAll(); } +bool HelpOne() { + return GetPool().Enabled() && GetPool().HelpOne(); +} + void ParallelFor(std::size_t count, const std::function& task) { if (count <= 1 || in_copy_thread || !Enabled()) { for (std::size_t i = 0; i < count; ++i) { diff --git a/gpu/shim/bbport_copy.h b/gpu/shim/bbport_copy.h index 767e9b6..4893d6b 100644 --- a/gpu/shim/bbport_copy.h +++ b/gpu/shim/bbport_copy.h @@ -37,6 +37,11 @@ void FlushBatch(); /// has finished. void WaitAsync(); +/// bbport: runs the oldest task passed to Async() that no copy thread has started yet, on the +/// calling thread. False when there was none. A waiter helps with this instead of WaitAsync(), +/// which also waits for copies issued after the one it needs. +bool HelpOne(); + /// Starts the calling thread's batch and runs `callback` once every task passed to Async() so /// far has finished (at once when none is pending), on whichever thread finishes last. /// Callbacks run in the order they were registered. diff --git a/gpu/shim/bbport_menu_i18n.cpp b/gpu/shim/bbport_menu_i18n.cpp new file mode 100644 index 0000000..77a227a --- /dev/null +++ b/gpu/shim/bbport_menu_i18n.cpp @@ -0,0 +1,1311 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: the in-game menu (bbport_overlay.cpp) in the languages other than English and +// Russian, which MenuText takes at its call sites. Each table maps the English text, exactly as +// passed to MenuText (the effect labels: bbport_settings.h Effects), to its translation; text +// missing here is shown in English. Translations keep the printf specifiers of the English text in +// the same order and the ImGui ID after "###"; FSR, DLSS, TAA, RCAS, SSAO, key and button names, +// preset names and paths stay as they are. The menu font (DejaVu Sans) has every character used. +#include "bbport_menu_i18n.h" + +#include +#include +#include +#include + +namespace BbSettings { + +namespace { + +struct Entry { + const char* english; + const char* text; +}; + +// Spanish (es) +constexpr Entry kSpanish[] = { + {"Hide mouse cursor", + "Ocultar el cursor del ratón"}, + {"Picture", + "Imagen"}, + {"Picture tuning", + "Ajuste de imagen"}, + {"UI brightness", + "Brillo de la interfaz"}, + {"UI colour", + "Color de la interfaz"}, + {"Brightness", + "Brillo"}, + {"Contrast", + "Contraste"}, + {"Saturation", + "Saturación"}, + {"Hue", + "Tono"}, + {"Fog", + "Niebla"}, + {"Vignette", + "Viñeta"}, + {"Ambient occlusion strength", + "Intensidad de la oclusión ambiental"}, + {"Shadow distance", + "Distancia de sombras"}, + {"Game's own picture", + "Imagen original del juego"}, + {"Bloodborne - Graphics (Insert / L3+R3)###bbport_settings", + "Bloodborne - Gráficos (Insert / L3+R3)###bbport_settings"}, + {"%.0f FPS (%.1f ms)", + "%.0f FPS (%.1f ms)"}, + {"Temporal upscaler", + "Escalado temporal"}, + {"Off", + "Desactivado"}, + {"TAA (native anti-aliasing)", + "TAA (antialiasing nativo)"}, + {"Upscaler", + "Escalador"}, + {"— not supported by this GPU", + "— no compatible con esta GPU"}, + {"— in development", + "— en desarrollo"}, + {"FSR 4.1.1 in INT8 mode: the model from AMD's 4.1.1 DLL, reproduced in Vulkan (output " + "matches the DLL). One model for Native through Performance and another for Ultra " + "Performance. Assets: tools/fsr4cap/build_assets.sh (requires the DLL and Proton).", + "FSR 4.1.1 en modo INT8: el modelo de la DLL 4.1.1 de AMD, reproducido en Vulkan (el " + "resultado coincide con la DLL). Un modelo de Native a Performance y otro para Ultra " + "Performance. Recursos: tools/fsr4cap/build_assets.sh (requiere la DLL y Proton)."}, + {"FSR 4 in INT8 mode (v07 model from AMD FidelityFX SDK sources). Higher quality than " + "FSR 3.1, but the pass is more demanding. Changing the preset rebuilds the model (a " + "brief pause). Assets: tools/fetch_fsr4_assets.sh.", + "FSR 4 en modo INT8 (modelo v07 del código fuente de AMD FidelityFX SDK). Mayor calidad " + "que FSR 3.1, pero el pase es más exigente. Cambiar el preajuste reconstruye el modelo " + "(una breve pausa). Recursos: tools/fetch_fsr4_assets.sh."}, + {"FSR 4: auto exposure", + "FSR 4: exposición automática"}, + {"FSR 4: invert jitter sign", + "FSR 4: invertir signo del jitter"}, + {"For diagnosing ghosting: the FSR 4 network normalizes color by exposure and uses it to " + "decide when to discard previous frames. Changes apply immediately, without a restart.", + "Para diagnosticar ghosting: la red de FSR 4 normaliza el color según la exposición y " + "la usa para decidir cuándo descartar fotogramas anteriores. Los cambios se aplican al " + "instante, sin reiniciar."}, + {"Preset", + "Preajuste"}, + {"%s (x%.1f, render %dx%d)", + "%s (x%.1f, renderizado %dx%d)"}, + {"Character motion vectors", + "Vectores de movimiento de personajes"}, + {"Accurate vectors for animated objects: clothing and weapons break up less during " + "movement. The static scene does not get an additional pass. Changes apply after " + "restarting the game.", + "Vectores precisos para objetos animados: la ropa y las armas se deshacen menos en " + "movimiento. La escena estática no recibe un pase adicional. Se aplica tras reiniciar " + "el juego."}, + {"Show motion vectors (debug)", + "Mostrar vectores de movimiento (depuración)"}, + {"Red/green: horizontal/vertical motion (8 pixels = full brightness). Blue: the pixel " + "received an accurate object vector, not just camera motion. The upscaler treats a " + "moving object with no blue or red/green as stationary, causing trails.", + "Rojo/verde: movimiento horizontal/vertical (8 píxeles = brillo máximo). Azul: el píxel " + "recibió un vector de objeto preciso, no solo el movimiento de la cámara. El escalador " + "trata un objeto en movimiento sin azul ni rojo/verde como inmóvil, lo que provoca " + "estelas."}, + {"Output resolution", + "Resolución de salida"}, + {"Size of the final frame and UI. The preset sets the scene size relative to the output: " + "4K Performance = 1920x1080. Applies after restarting the game.", + "Tamaño del fotograma final y de la interfaz. El preajuste fija el tamaño de la escena " + "respecto a la salida: 4K Performance = 1920x1080. Se aplica tras reiniciar el juego."}, + {"The final frame and UI size changes at the next frame boundary. The preset sets the " + "scene size relative to the output: 4K Performance = 1920x1080. Changing the size " + "resets FSR history and may cause a brief pause.", + "El tamaño del fotograma final y de la interfaz cambia en el siguiente fotograma. El " + "preajuste fija el tamaño de la escena respecto a la salida: 4K Performance = " + "1920x1080. Cambiar el tamaño reinicia el historial de FSR y puede causar una breve " + "pausa."}, + {"Auto (based on GPU)", + "Automático (según la GPU)"}, + {"Off (faster)", + "Desactivado (más rápido)"}, + {"On", + "Activado"}, + {"Live resolution changes", + "Cambio de resolución en vivo"}, + {"On: output resolution and preset change without restarting, but game post-processing " + "stays at 1080p, which is noticeably slower on Steam Deck and older GPUs. Off: " + "everything renders at the preset resolution, and changes require a restart. Auto " + "enables this on powerful discrete GPUs. Applies after restarting the game.", + "Activado: la resolución de salida y el preajuste cambian sin reiniciar, pero el " + "posprocesado del juego se queda en 1080p, lo que es notablemente más lento en Steam " + "Deck y GPU antiguas. Desactivado: todo se renderiza a la resolución del preajuste y " + "los cambios requieren reiniciar. Automático lo activa en GPU dedicadas potentes. Se " + "aplica tras reiniciar el juego."}, + {"Game effects (restart required)", + "Efectos del juego (requiere reinicio)"}, + {"Highest (-2)", + "Máximo (-2)"}, + {"Game default", + "Predeterminado del juego"}, + {"Lower (1)", + "Bajo (1)"}, + {"Lowest (2)", + "Mínimo (2)"}, + {"Model detail", + "Detalle de modelos"}, + {"Effects are enabled and disabled by game patches at startup (patches/Bloodborne.xml). " + "Motion blur and shadows from dynamic lights place a significant load on the GPU.", + "Los efectos se activan y desactivan mediante parches del juego al iniciar " + "(patches/Bloodborne.xml). El desenfoque de movimiento y las sombras de luces dinámicas " + "cargan notablemente la GPU."}, + {"Free camera: hold Cross and press L3 (keyboard: Space + Z). Debug menu: left touchpad " + "/ Tab. Requires DbgFont14h.ccm and DbgFont14h.tpf in dvdroot_ps4/font from Nexus mod " + "#253. Right touchpad: Backspace.", + "Cámara libre: mantén Cross y pulsa L3 (teclado: Space + Z). Menú de depuración: " + "touchpad izquierdo / Tab. Requiere DbgFont14h.ccm y DbgFont14h.tpf en dvdroot_ps4/font " + "del mod #253 de Nexus. Touchpad derecho: Backspace."}, + {"Changes apply after restarting the game", + "Los cambios se aplican tras reiniciar el juego"}, + {"Apply and restart game", + "Aplicar y reiniciar el juego"}, + {"Other", + "Otros"}, + {"FPS counter in corner", + "Contador de FPS en la esquina"}, + {"Frame rate", + "Tasa de fotogramas"}, + {"Frame time", + "Tiempo de fotograma"}, + {"Close", + "Cerrar"}, + {"Settings are saved to bbport.ini", + "Los ajustes se guardan en bbport.ini"}, + {"Chromatic aberration", + "Aberración cromática"}, + {"Depth of field (DoF)", + "Profundidad de campo (DoF)"}, + {"Motion blur", + "Desenfoque de movimiento"}, + {"Ambient occlusion (SSAO)", + "Oclusión ambiental (SSAO)"}, + {"Game's own anti-aliasing", + "Antialiasing propio del juego"}, + {"Shadows from dynamic lights", + "Sombras de luces dinámicas"}, + {"Screen-space reflections (not in original game)", + "Reflejos en espacio de pantalla (no estaban en el juego)"}, + {"Skip startup intros", + "Omitir intros al iniciar"}, + {"Free camera (Cross + L3)", + "Cámara libre (Cross + L3)"}, + {"Debug menu (requires font files)", + "Menú de depuración (requiere archivos de fuente)"}, +}; + +// Portuguese, Brazil (pt_BR) +constexpr Entry kPortugueseBrazil[] = { + {"Hide mouse cursor", + "Ocultar o cursor do mouse"}, + {"Picture", + "Imagem"}, + {"Picture tuning", + "Ajuste de imagem"}, + {"UI brightness", + "Brilho da interface"}, + {"UI colour", + "Cor da interface"}, + {"Brightness", + "Brilho"}, + {"Contrast", + "Contraste"}, + {"Saturation", + "Saturação"}, + {"Hue", + "Matiz"}, + {"Fog", + "Névoa"}, + {"Vignette", + "Vinheta"}, + {"Ambient occlusion strength", + "Intensidade da oclusão de ambiente"}, + {"Shadow distance", + "Distância das sombras"}, + {"Game's own picture", + "Imagem original do jogo"}, + {"Bloodborne - Graphics (Insert / L3+R3)###bbport_settings", + "Bloodborne - Gráficos (Insert / L3+R3)###bbport_settings"}, + {"%.0f FPS (%.1f ms)", + "%.0f FPS (%.1f ms)"}, + {"Temporal upscaler", + "Upscaler temporal"}, + {"Off", + "Desligado"}, + {"TAA (native anti-aliasing)", + "TAA (anti-aliasing nativo)"}, + {"Upscaler", + "Upscaler"}, + {"— not supported by this GPU", + "— não suportado por esta GPU"}, + {"— in development", + "— em desenvolvimento"}, + {"FSR 4.1.1 in INT8 mode: the model from AMD's 4.1.1 DLL, reproduced in Vulkan (output " + "matches the DLL). One model for Native through Performance and another for Ultra " + "Performance. Assets: tools/fsr4cap/build_assets.sh (requires the DLL and Proton).", + "FSR 4.1.1 no modo INT8: o modelo da DLL 4.1.1 da AMD, reproduzido em Vulkan (o " + "resultado é igual ao da DLL). Um modelo de Native a Performance e outro para Ultra " + "Performance. Assets: tools/fsr4cap/build_assets.sh (requer a DLL e o Proton)."}, + {"FSR 4 in INT8 mode (v07 model from AMD FidelityFX SDK sources). Higher quality than " + "FSR 3.1, but the pass is more demanding. Changing the preset rebuilds the model (a " + "brief pause). Assets: tools/fetch_fsr4_assets.sh.", + "FSR 4 no modo INT8 (modelo v07 do código-fonte do AMD FidelityFX SDK). Qualidade maior " + "que a do FSR 3.1, mas a passada é mais pesada. Trocar a predefinição reconstrói o " + "modelo (uma breve pausa). Assets: tools/fetch_fsr4_assets.sh."}, + {"FSR 4: auto exposure", + "FSR 4: exposição automática"}, + {"FSR 4: invert jitter sign", + "FSR 4: inverter sinal do jitter"}, + {"For diagnosing ghosting: the FSR 4 network normalizes color by exposure and uses it to " + "decide when to discard previous frames. Changes apply immediately, without a restart.", + "Para diagnosticar ghosting: a rede do FSR 4 normaliza a cor pela exposição e a usa " + "para decidir quando descartar quadros anteriores. As alterações valem na hora, sem " + "reiniciar."}, + {"Preset", + "Predefinição"}, + {"%s (x%.1f, render %dx%d)", + "%s (x%.1f, renderização %dx%d)"}, + {"Character motion vectors", + "Vetores de movimento dos personagens"}, + {"Accurate vectors for animated objects: clothing and weapons break up less during " + "movement. The static scene does not get an additional pass. Changes apply after " + "restarting the game.", + "Vetores precisos para objetos animados: roupas e armas se desfazem menos durante o " + "movimento. A cena estática não recebe uma passada extra. Aplica-se após reiniciar o " + "jogo."}, + {"Show motion vectors (debug)", + "Mostrar vetores de movimento (depuração)"}, + {"Red/green: horizontal/vertical motion (8 pixels = full brightness). Blue: the pixel " + "received an accurate object vector, not just camera motion. The upscaler treats a " + "moving object with no blue or red/green as stationary, causing trails.", + "Vermelho/verde: movimento horizontal/vertical (8 pixels = brilho máximo). Azul: o " + "pixel recebeu um vetor de objeto preciso, não apenas o movimento da câmera. O upscaler " + "trata um objeto em movimento sem azul nem vermelho/verde como parado, o que causa " + "rastros."}, + {"Output resolution", + "Resolução de saída"}, + {"Size of the final frame and UI. The preset sets the scene size relative to the output: " + "4K Performance = 1920x1080. Applies after restarting the game.", + "Tamanho do quadro final e da interface. A predefinição define o tamanho da cena em " + "relação à saída: 4K Performance = 1920x1080. Aplica-se após reiniciar o jogo."}, + {"The final frame and UI size changes at the next frame boundary. The preset sets the " + "scene size relative to the output: 4K Performance = 1920x1080. Changing the size " + "resets FSR history and may cause a brief pause.", + "O tamanho do quadro final e da interface muda no próximo quadro. A predefinição define " + "o tamanho da cena em relação à saída: 4K Performance = 1920x1080. Mudar o tamanho " + "redefine o histórico do FSR e pode causar uma breve pausa."}, + {"Auto (based on GPU)", + "Automático (conforme a GPU)"}, + {"Off (faster)", + "Desligada (mais rápido)"}, + {"On", + "Ligada"}, + {"Live resolution changes", + "Mudança de resolução em tempo real"}, + {"On: output resolution and preset change without restarting, but game post-processing " + "stays at 1080p, which is noticeably slower on Steam Deck and older GPUs. Off: " + "everything renders at the preset resolution, and changes require a restart. Auto " + "enables this on powerful discrete GPUs. Applies after restarting the game.", + "Ligada: a resolução de saída e a predefinição mudam sem reiniciar, mas o " + "pós-processamento do jogo fica em 1080p, o que é bem mais lento no Steam Deck e em " + "GPUs antigas. Desligada: tudo é renderizado na resolução da predefinição, e as " + "mudanças exigem reinício. Automático a liga em GPUs dedicadas potentes. Aplica-se após " + "reiniciar o jogo."}, + {"Game effects (restart required)", + "Efeitos do jogo (requer reinício)"}, + {"Highest (-2)", + "Máximo (-2)"}, + {"Game default", + "Padrão do jogo"}, + {"Lower (1)", + "Baixo (1)"}, + {"Lowest (2)", + "Mínimo (2)"}, + {"Model detail", + "Detalhe dos modelos"}, + {"Effects are enabled and disabled by game patches at startup (patches/Bloodborne.xml). " + "Motion blur and shadows from dynamic lights place a significant load on the GPU.", + "Os efeitos são ligados e desligados por patches do jogo na inicialização " + "(patches/Bloodborne.xml). Desfoque de movimento e sombras de luzes dinâmicas pesam " + "bastante na GPU."}, + {"Free camera: hold Cross and press L3 (keyboard: Space + Z). Debug menu: left touchpad " + "/ Tab. Requires DbgFont14h.ccm and DbgFont14h.tpf in dvdroot_ps4/font from Nexus mod " + "#253. Right touchpad: Backspace.", + "Câmera livre: segure Cross e pressione L3 (teclado: Space + Z). Menu de depuração: " + "touchpad esquerdo / Tab. Requer DbgFont14h.ccm e DbgFont14h.tpf em dvdroot_ps4/font, " + "do mod #253 do Nexus. Touchpad direito: Backspace."}, + {"Changes apply after restarting the game", + "As alterações valem após reiniciar o jogo"}, + {"Apply and restart game", + "Aplicar e reiniciar o jogo"}, + {"Other", + "Outros"}, + {"FPS counter in corner", + "Contador de FPS no canto"}, + {"Frame rate", + "Taxa de quadros"}, + {"Frame time", + "Tempo de quadro"}, + {"Close", + "Fechar"}, + {"Settings are saved to bbport.ini", + "As configurações são salvas em bbport.ini"}, + {"Chromatic aberration", + "Aberração cromática"}, + {"Depth of field (DoF)", + "Profundidade de campo (DoF)"}, + {"Motion blur", + "Desfoque de movimento"}, + {"Ambient occlusion (SSAO)", + "Oclusão de ambiente (SSAO)"}, + {"Game's own anti-aliasing", + "Anti-aliasing próprio do jogo"}, + {"Shadows from dynamic lights", + "Sombras de luzes dinâmicas"}, + {"Screen-space reflections (not in original game)", + "Reflexos em espaço de tela (não existiam no jogo)"}, + {"Skip startup intros", + "Pular vídeos de abertura"}, + {"Free camera (Cross + L3)", + "Câmera livre (Cross + L3)"}, + {"Debug menu (requires font files)", + "Menu de depuração (requer arquivos de fonte)"}, +}; + +// French (fr) +constexpr Entry kFrench[] = { + {"Hide mouse cursor", + "Masquer le curseur de la souris"}, + {"Picture", + "Image"}, + {"Picture tuning", + "Réglage de l'image"}, + {"UI brightness", + "Luminosité de l'interface"}, + {"UI colour", + "Couleurs de l'interface"}, + {"Brightness", + "Luminosité"}, + {"Contrast", + "Contraste"}, + {"Saturation", + "Saturation"}, + {"Hue", + "Teinte"}, + {"Fog", + "Brouillard"}, + {"Vignette", + "Vignettage"}, + {"Ambient occlusion strength", + "Intensité de l'occlusion ambiante"}, + {"Shadow distance", + "Distance des ombres"}, + {"Game's own picture", + "Image d'origine du jeu"}, + {"Bloodborne - Graphics (Insert / L3+R3)###bbport_settings", + "Bloodborne - Graphismes (Insert / L3+R3)###bbport_settings"}, + {"%.0f FPS (%.1f ms)", + "%.0f FPS (%.1f ms)"}, + {"Temporal upscaler", + "Mise à l'échelle temporelle"}, + {"Off", + "Désactivé"}, + {"TAA (native anti-aliasing)", + "TAA (anticrénelage natif)"}, + {"Upscaler", + "Upscaler"}, + {"— not supported by this GPU", + "— non pris en charge par ce GPU"}, + {"— in development", + "— en développement"}, + {"FSR 4.1.1 in INT8 mode: the model from AMD's 4.1.1 DLL, reproduced in Vulkan (output " + "matches the DLL). One model for Native through Performance and another for Ultra " + "Performance. Assets: tools/fsr4cap/build_assets.sh (requires the DLL and Proton).", + "FSR 4.1.1 en mode INT8 : le modèle de la DLL 4.1.1 d'AMD, reproduit en Vulkan " + "(résultat identique à la DLL). Un modèle de Native à Performance et un autre pour " + "Ultra Performance. Ressources : tools/fsr4cap/build_assets.sh (nécessite la DLL et " + "Proton)."}, + {"FSR 4 in INT8 mode (v07 model from AMD FidelityFX SDK sources). Higher quality than " + "FSR 3.1, but the pass is more demanding. Changing the preset rebuilds the model (a " + "brief pause). Assets: tools/fetch_fsr4_assets.sh.", + "FSR 4 en mode INT8 (modèle v07 issu des sources du AMD FidelityFX SDK). Meilleure " + "qualité que FSR 3.1, mais la passe est plus exigeante. Changer de préréglage " + "reconstruit le modèle (brève pause). Ressources : tools/fetch_fsr4_assets.sh."}, + {"FSR 4: auto exposure", + "FSR 4 : exposition automatique"}, + {"FSR 4: invert jitter sign", + "FSR 4 : inverser le signe du jitter"}, + {"For diagnosing ghosting: the FSR 4 network normalizes color by exposure and uses it to " + "decide when to discard previous frames. Changes apply immediately, without a restart.", + "Pour diagnostiquer le ghosting : le réseau FSR 4 normalise la couleur selon " + "l'exposition et s'en sert pour décider quand rejeter les images précédentes. Les " + "changements s'appliquent immédiatement, sans redémarrage."}, + {"Preset", + "Préréglage"}, + {"%s (x%.1f, render %dx%d)", + "%s (x%.1f, rendu %dx%d)"}, + {"Character motion vectors", + "Vecteurs de mouvement des personnages"}, + {"Accurate vectors for animated objects: clothing and weapons break up less during " + "movement. The static scene does not get an additional pass. Changes apply after " + "restarting the game.", + "Vecteurs précis pour les objets animés : les vêtements et les armes se désagrègent " + "moins en mouvement. La scène statique ne reçoit pas de passe supplémentaire. " + "S'applique après le redémarrage du jeu."}, + {"Show motion vectors (debug)", + "Afficher les vecteurs de mouvement (débogage)"}, + {"Red/green: horizontal/vertical motion (8 pixels = full brightness). Blue: the pixel " + "received an accurate object vector, not just camera motion. The upscaler treats a " + "moving object with no blue or red/green as stationary, causing trails.", + "Rouge/vert : mouvement horizontal/vertical (8 pixels = luminosité maximale). Bleu : le " + "pixel a reçu un vecteur d'objet précis, pas seulement le mouvement de la caméra. " + "L'upscaler considère un objet en mouvement sans bleu ni rouge/vert comme immobile, " + "d'où des traînées."}, + {"Output resolution", + "Résolution de sortie"}, + {"Size of the final frame and UI. The preset sets the scene size relative to the output: " + "4K Performance = 1920x1080. Applies after restarting the game.", + "Taille de l'image finale et de l'interface. Le préréglage fixe la taille de la scène " + "par rapport à la sortie : 4K Performance = 1920x1080. S'applique après le redémarrage " + "du jeu."}, + {"The final frame and UI size changes at the next frame boundary. The preset sets the " + "scene size relative to the output: 4K Performance = 1920x1080. Changing the size " + "resets FSR history and may cause a brief pause.", + "La taille de l'image finale et de l'interface change à l'image suivante. Le préréglage " + "fixe la taille de la scène par rapport à la sortie : 4K Performance = 1920x1080. " + "Changer de taille réinitialise l'historique FSR et peut provoquer une brève pause."}, + {"Auto (based on GPU)", + "Auto (selon le GPU)"}, + {"Off (faster)", + "Désactivé (plus rapide)"}, + {"On", + "Activé"}, + {"Live resolution changes", + "Changement de résolution à la volée"}, + {"On: output resolution and preset change without restarting, but game post-processing " + "stays at 1080p, which is noticeably slower on Steam Deck and older GPUs. Off: " + "everything renders at the preset resolution, and changes require a restart. Auto " + "enables this on powerful discrete GPUs. Applies after restarting the game.", + "Activé : la résolution de sortie et le préréglage changent sans redémarrage, mais le " + "post-traitement du jeu reste en 1080p, ce qui est nettement plus lent sur Steam Deck " + "et les GPU anciens. Désactivé : tout est rendu à la résolution du préréglage, et les " + "changements nécessitent un redémarrage. Auto l'active sur les GPU dédiés puissants. " + "S'applique après le redémarrage du jeu."}, + {"Game effects (restart required)", + "Effets du jeu (redémarrage requis)"}, + {"Highest (-2)", + "Maximal (-2)"}, + {"Game default", + "Valeur par défaut du jeu"}, + {"Lower (1)", + "Réduit (1)"}, + {"Lowest (2)", + "Minimal (2)"}, + {"Model detail", + "Détail des modèles"}, + {"Effects are enabled and disabled by game patches at startup (patches/Bloodborne.xml). " + "Motion blur and shadows from dynamic lights place a significant load on the GPU.", + "Les effets sont activés et désactivés par des patchs du jeu au démarrage " + "(patches/Bloodborne.xml). Le flou de mouvement et les ombres des lumières dynamiques " + "sollicitent fortement le GPU."}, + {"Free camera: hold Cross and press L3 (keyboard: Space + Z). Debug menu: left touchpad " + "/ Tab. Requires DbgFont14h.ccm and DbgFont14h.tpf in dvdroot_ps4/font from Nexus mod " + "#253. Right touchpad: Backspace.", + "Caméra libre : maintenez Cross et appuyez sur L3 (clavier : Space + Z). Menu de " + "débogage : pavé tactile gauche / Tab. Nécessite DbgFont14h.ccm et DbgFont14h.tpf dans " + "dvdroot_ps4/font, issus du mod Nexus #253. Pavé tactile droit : Backspace."}, + {"Changes apply after restarting the game", + "Les changements s'appliquent après le redémarrage du jeu"}, + {"Apply and restart game", + "Appliquer et redémarrer le jeu"}, + {"Other", + "Divers"}, + {"FPS counter in corner", + "Compteur FPS dans le coin"}, + {"Frame rate", + "Fréquence d'images"}, + {"Frame time", + "Temps par image"}, + {"Close", + "Fermer"}, + {"Settings are saved to bbport.ini", + "Les réglages sont enregistrés dans bbport.ini"}, + {"Chromatic aberration", + "Aberration chromatique"}, + {"Depth of field (DoF)", + "Profondeur de champ (DoF)"}, + {"Motion blur", + "Flou de mouvement"}, + {"Ambient occlusion (SSAO)", + "Occlusion ambiante (SSAO)"}, + {"Game's own anti-aliasing", + "Anticrénelage propre au jeu"}, + {"Shadows from dynamic lights", + "Ombres des lumières dynamiques"}, + {"Screen-space reflections (not in original game)", + "Réflexions en espace écran (absentes du jeu d'origine)"}, + {"Skip startup intros", + "Passer les intros au démarrage"}, + {"Free camera (Cross + L3)", + "Caméra libre (Cross + L3)"}, + {"Debug menu (requires font files)", + "Menu de débogage (fichiers de police requis)"}, +}; + +// German (de) +constexpr Entry kGerman[] = { + {"Hide mouse cursor", + "Mauszeiger ausblenden"}, + {"Picture", + "Bild"}, + {"Picture tuning", + "Bildanpassung"}, + {"UI brightness", + "Helligkeit der Oberfläche"}, + {"UI colour", + "Farben der Oberfläche"}, + {"Brightness", + "Helligkeit"}, + {"Contrast", + "Kontrast"}, + {"Saturation", + "Sättigung"}, + {"Hue", + "Farbton"}, + {"Fog", + "Nebel"}, + {"Vignette", + "Vignette"}, + {"Ambient occlusion strength", + "Stärke der Umgebungsverdeckung"}, + {"Shadow distance", + "Schattenweite"}, + {"Game's own picture", + "Originalbild des Spiels"}, + {"Bloodborne - Graphics (Insert / L3+R3)###bbport_settings", + "Bloodborne - Grafik (Insert / L3+R3)###bbport_settings"}, + {"%.0f FPS (%.1f ms)", + "%.0f FPS (%.1f ms)"}, + {"Temporal upscaler", + "Temporales Upscaling"}, + {"Off", + "Aus"}, + {"TAA (native anti-aliasing)", + "TAA (native Kantenglättung)"}, + {"Upscaler", + "Upscaler"}, + {"— not supported by this GPU", + "— von dieser GPU nicht unterstützt"}, + {"— in development", + "— in Entwicklung"}, + {"FSR 4.1.1 in INT8 mode: the model from AMD's 4.1.1 DLL, reproduced in Vulkan (output " + "matches the DLL). One model for Native through Performance and another for Ultra " + "Performance. Assets: tools/fsr4cap/build_assets.sh (requires the DLL and Proton).", + "FSR 4.1.1 im INT8-Modus: das Modell aus AMDs 4.1.1-DLL, in Vulkan nachgebildet " + "(Ausgabe identisch mit der DLL). Ein Modell für Native bis Performance und ein " + "weiteres für Ultra Performance. Assets: tools/fsr4cap/build_assets.sh (benötigt die " + "DLL und Proton)."}, + {"FSR 4 in INT8 mode (v07 model from AMD FidelityFX SDK sources). Higher quality than " + "FSR 3.1, but the pass is more demanding. Changing the preset rebuilds the model (a " + "brief pause). Assets: tools/fetch_fsr4_assets.sh.", + "FSR 4 im INT8-Modus (v07-Modell aus den Quellen des AMD FidelityFX SDK). Höhere " + "Qualität als FSR 3.1, aber der Pass ist aufwendiger. Ein Wechsel der Voreinstellung " + "baut das Modell neu auf (kurze Pause). Assets: tools/fetch_fsr4_assets.sh."}, + {"FSR 4: auto exposure", + "FSR 4: Automatische Belichtung"}, + {"FSR 4: invert jitter sign", + "FSR 4: Jitter-Vorzeichen umkehren"}, + {"For diagnosing ghosting: the FSR 4 network normalizes color by exposure and uses it to " + "decide when to discard previous frames. Changes apply immediately, without a restart.", + "Zur Diagnose von Ghosting: Das FSR-4-Netz normalisiert die Farbe anhand der Belichtung " + "und entscheidet damit, wann frühere Frames verworfen werden. Änderungen gelten sofort, " + "ohne Neustart."}, + {"Preset", + "Voreinstellung"}, + {"%s (x%.1f, render %dx%d)", + "%s (x%.1f, Rendering %dx%d)"}, + {"Character motion vectors", + "Bewegungsvektoren für Figuren"}, + {"Accurate vectors for animated objects: clothing and weapons break up less during " + "movement. The static scene does not get an additional pass. Changes apply after " + "restarting the game.", + "Genaue Vektoren für animierte Objekte: Kleidung und Waffen zerfallen bei Bewegung " + "weniger. Die statische Szene erhält keinen zusätzlichen Pass. Gilt nach einem Neustart " + "des Spiels."}, + {"Show motion vectors (debug)", + "Bewegungsvektoren anzeigen (Debug)"}, + {"Red/green: horizontal/vertical motion (8 pixels = full brightness). Blue: the pixel " + "received an accurate object vector, not just camera motion. The upscaler treats a " + "moving object with no blue or red/green as stationary, causing trails.", + "Rot/Grün: horizontale/vertikale Bewegung (8 Pixel = volle Helligkeit). Blau: Das Pixel " + "hat einen genauen Objektvektor erhalten, nicht nur die Kamerabewegung. Ein bewegtes " + "Objekt ohne Blau oder Rot/Grün hält der Upscaler für unbewegt, daher Schlieren."}, + {"Output resolution", + "Ausgabeauflösung"}, + {"Size of the final frame and UI. The preset sets the scene size relative to the output: " + "4K Performance = 1920x1080. Applies after restarting the game.", + "Größe des fertigen Bildes und der Oberfläche. Die Voreinstellung legt die Szenengröße " + "relativ zur Ausgabe fest: 4K Performance = 1920x1080. Gilt nach einem Neustart des " + "Spiels."}, + {"The final frame and UI size changes at the next frame boundary. The preset sets the " + "scene size relative to the output: 4K Performance = 1920x1080. Changing the size " + "resets FSR history and may cause a brief pause.", + "Die Größe des fertigen Bildes und der Oberfläche ändert sich ab dem nächsten Frame. " + "Die Voreinstellung legt die Szenengröße relativ zur Ausgabe fest: 4K Performance = " + "1920x1080. Eine Größenänderung setzt den FSR-Verlauf zurück und kann eine kurze Pause " + "verursachen."}, + {"Auto (based on GPU)", + "Auto (je nach GPU)"}, + {"Off (faster)", + "Aus (schneller)"}, + {"On", + "An"}, + {"Live resolution changes", + "Auflösung live ändern"}, + {"On: output resolution and preset change without restarting, but game post-processing " + "stays at 1080p, which is noticeably slower on Steam Deck and older GPUs. Off: " + "everything renders at the preset resolution, and changes require a restart. Auto " + "enables this on powerful discrete GPUs. Applies after restarting the game.", + "An: Ausgabeauflösung und Voreinstellung ändern sich ohne Neustart, aber die " + "Nachbearbeitung des Spiels bleibt bei 1080p, was auf dem Steam Deck und älteren GPUs " + "spürbar langsamer ist. Aus: Alles wird in der Auflösung der Voreinstellung gerendert, " + "Änderungen erfordern einen Neustart. Auto aktiviert dies auf leistungsstarken " + "dedizierten GPUs. Gilt nach einem Neustart des Spiels."}, + {"Game effects (restart required)", + "Spieleffekte (Neustart erforderlich)"}, + {"Highest (-2)", + "Maximal (-2)"}, + {"Game default", + "Spielstandard"}, + {"Lower (1)", + "Niedriger (1)"}, + {"Lowest (2)", + "Minimal (2)"}, + {"Model detail", + "Modelldetails"}, + {"Effects are enabled and disabled by game patches at startup (patches/Bloodborne.xml). " + "Motion blur and shadows from dynamic lights place a significant load on the GPU.", + "Effekte werden beim Start durch Spiel-Patches ein- und ausgeschaltet " + "(patches/Bloodborne.xml). Bewegungsunschärfe und Schatten dynamischer Lichter belasten " + "die GPU spürbar."}, + {"Free camera: hold Cross and press L3 (keyboard: Space + Z). Debug menu: left touchpad " + "/ Tab. Requires DbgFont14h.ccm and DbgFont14h.tpf in dvdroot_ps4/font from Nexus mod " + "#253. Right touchpad: Backspace.", + "Freie Kamera: Cross halten und L3 drücken (Tastatur: Space + Z). Debug-Menü: linkes " + "Touchpad / Tab. Benötigt DbgFont14h.ccm und DbgFont14h.tpf in dvdroot_ps4/font aus " + "Nexus-Mod #253. Rechtes Touchpad: Backspace."}, + {"Changes apply after restarting the game", + "Änderungen gelten nach einem Neustart des Spiels"}, + {"Apply and restart game", + "Übernehmen und Spiel neu starten"}, + {"Other", + "Sonstiges"}, + {"FPS counter in corner", + "FPS-Anzeige in der Ecke"}, + {"Frame rate", + "Bildrate"}, + {"Frame time", + "Bildzeit"}, + {"Close", + "Schließen"}, + {"Settings are saved to bbport.ini", + "Einstellungen werden in bbport.ini gespeichert"}, + {"Chromatic aberration", + "Chromatische Aberration"}, + {"Depth of field (DoF)", + "Tiefenschärfe (DoF)"}, + {"Motion blur", + "Bewegungsunschärfe"}, + {"Ambient occlusion (SSAO)", + "Umgebungsverdeckung (SSAO)"}, + {"Game's own anti-aliasing", + "Spieleigene Kantenglättung"}, + {"Shadows from dynamic lights", + "Schatten dynamischer Lichter"}, + {"Screen-space reflections (not in original game)", + "Bildschirmraum-Reflexionen (nicht im Originalspiel)"}, + {"Skip startup intros", + "Intros beim Start überspringen"}, + {"Free camera (Cross + L3)", + "Freie Kamera (Cross + L3)"}, + {"Debug menu (requires font files)", + "Debug-Menü (benötigt Schriftdateien)"}, +}; + +// Italian (it) +constexpr Entry kItalian[] = { + {"Hide mouse cursor", + "Nascondi il cursore del mouse"}, + {"Picture", + "Immagine"}, + {"Picture tuning", + "Regolazione immagine"}, + {"UI brightness", + "Luminosità dell'interfaccia"}, + {"UI colour", + "Colori dell'interfaccia"}, + {"Brightness", + "Luminosità"}, + {"Contrast", + "Contrasto"}, + {"Saturation", + "Saturazione"}, + {"Hue", + "Tonalità"}, + {"Fog", + "Nebbia"}, + {"Vignette", + "Vignettatura"}, + {"Ambient occlusion strength", + "Intensità dell'occlusione ambientale"}, + {"Shadow distance", + "Distanza delle ombre"}, + {"Game's own picture", + "Immagine originale del gioco"}, + {"Bloodborne - Graphics (Insert / L3+R3)###bbport_settings", + "Bloodborne - Grafica (Insert / L3+R3)###bbport_settings"}, + {"%.0f FPS (%.1f ms)", + "%.0f FPS (%.1f ms)"}, + {"Temporal upscaler", + "Upscaler temporale"}, + {"Off", + "Disattivato"}, + {"TAA (native anti-aliasing)", + "TAA (anti-aliasing nativo)"}, + {"Upscaler", + "Upscaler"}, + {"— not supported by this GPU", + "— non supportato da questa GPU"}, + {"— in development", + "— in sviluppo"}, + {"FSR 4.1.1 in INT8 mode: the model from AMD's 4.1.1 DLL, reproduced in Vulkan (output " + "matches the DLL). One model for Native through Performance and another for Ultra " + "Performance. Assets: tools/fsr4cap/build_assets.sh (requires the DLL and Proton).", + "FSR 4.1.1 in modalità INT8: il modello della DLL 4.1.1 di AMD, riprodotto in Vulkan " + "(il risultato coincide con la DLL). Un modello da Native a Performance e un altro per " + "Ultra Performance. Risorse: tools/fsr4cap/build_assets.sh (richiede la DLL e Proton)."}, + {"FSR 4 in INT8 mode (v07 model from AMD FidelityFX SDK sources). Higher quality than " + "FSR 3.1, but the pass is more demanding. Changing the preset rebuilds the model (a " + "brief pause). Assets: tools/fetch_fsr4_assets.sh.", + "FSR 4 in modalità INT8 (modello v07 dai sorgenti di AMD FidelityFX SDK). Qualità " + "superiore a FSR 3.1, ma il passaggio è più pesante. Cambiare preset ricostruisce il " + "modello (breve pausa). Risorse: tools/fetch_fsr4_assets.sh."}, + {"FSR 4: auto exposure", + "FSR 4: esposizione automatica"}, + {"FSR 4: invert jitter sign", + "FSR 4: inverti segno del jitter"}, + {"For diagnosing ghosting: the FSR 4 network normalizes color by exposure and uses it to " + "decide when to discard previous frames. Changes apply immediately, without a restart.", + "Per diagnosticare il ghosting: la rete di FSR 4 normalizza il colore in base " + "all'esposizione e la usa per decidere quando scartare i fotogrammi precedenti. Le " + "modifiche si applicano subito, senza riavvio."}, + {"Preset", + "Preset"}, + {"%s (x%.1f, render %dx%d)", + "%s (x%.1f, rendering %dx%d)"}, + {"Character motion vectors", + "Vettori di movimento dei personaggi"}, + {"Accurate vectors for animated objects: clothing and weapons break up less during " + "movement. The static scene does not get an additional pass. Changes apply after " + "restarting the game.", + "Vettori precisi per gli oggetti animati: vestiti e armi si sfaldano meno durante il " + "movimento. La scena statica non riceve un passaggio aggiuntivo. Si applica dopo il " + "riavvio del gioco."}, + {"Show motion vectors (debug)", + "Mostra vettori di movimento (debug)"}, + {"Red/green: horizontal/vertical motion (8 pixels = full brightness). Blue: the pixel " + "received an accurate object vector, not just camera motion. The upscaler treats a " + "moving object with no blue or red/green as stationary, causing trails.", + "Rosso/verde: movimento orizzontale/verticale (8 pixel = luminosità massima). Blu: il " + "pixel ha ricevuto un vettore oggetto preciso, non solo il movimento della telecamera. " + "L'upscaler considera fermo un oggetto in movimento senza blu né rosso/verde, causando " + "scie."}, + {"Output resolution", + "Risoluzione di uscita"}, + {"Size of the final frame and UI. The preset sets the scene size relative to the output: " + "4K Performance = 1920x1080. Applies after restarting the game.", + "Dimensione del fotogramma finale e dell'interfaccia. Il preset imposta la dimensione " + "della scena rispetto all'uscita: 4K Performance = 1920x1080. Si applica dopo il " + "riavvio del gioco."}, + {"The final frame and UI size changes at the next frame boundary. The preset sets the " + "scene size relative to the output: 4K Performance = 1920x1080. Changing the size " + "resets FSR history and may cause a brief pause.", + "La dimensione del fotogramma finale e dell'interfaccia cambia dal fotogramma " + "successivo. Il preset imposta la dimensione della scena rispetto all'uscita: 4K " + "Performance = 1920x1080. Cambiare dimensione azzera la cronologia di FSR e può causare " + "una breve pausa."}, + {"Auto (based on GPU)", + "Auto (in base alla GPU)"}, + {"Off (faster)", + "Disattivato (più veloce)"}, + {"On", + "Attivato"}, + {"Live resolution changes", + "Cambio di risoluzione al volo"}, + {"On: output resolution and preset change without restarting, but game post-processing " + "stays at 1080p, which is noticeably slower on Steam Deck and older GPUs. Off: " + "everything renders at the preset resolution, and changes require a restart. Auto " + "enables this on powerful discrete GPUs. Applies after restarting the game.", + "Attivato: risoluzione di uscita e preset cambiano senza riavvio, ma la " + "post-elaborazione del gioco resta a 1080p, cosa nettamente più lenta su Steam Deck e " + "GPU meno recenti. Disattivato: tutto viene renderizzato alla risoluzione del preset e " + "le modifiche richiedono un riavvio. Auto lo attiva sulle GPU dedicate potenti. Si " + "applica dopo il riavvio del gioco."}, + {"Game effects (restart required)", + "Effetti di gioco (richiede riavvio)"}, + {"Highest (-2)", + "Massimo (-2)"}, + {"Game default", + "Predefinito del gioco"}, + {"Lower (1)", + "Basso (1)"}, + {"Lowest (2)", + "Minimo (2)"}, + {"Model detail", + "Dettaglio modelli"}, + {"Effects are enabled and disabled by game patches at startup (patches/Bloodborne.xml). " + "Motion blur and shadows from dynamic lights place a significant load on the GPU.", + "Gli effetti vengono attivati e disattivati da patch del gioco all'avvio " + "(patches/Bloodborne.xml). Sfocatura di movimento e ombre delle luci dinamiche pesano " + "molto sulla GPU."}, + {"Free camera: hold Cross and press L3 (keyboard: Space + Z). Debug menu: left touchpad " + "/ Tab. Requires DbgFont14h.ccm and DbgFont14h.tpf in dvdroot_ps4/font from Nexus mod " + "#253. Right touchpad: Backspace.", + "Telecamera libera: tieni premuto Cross e premi L3 (tastiera: Space + Z). Menu di " + "debug: touchpad sinistro / Tab. Richiede DbgFont14h.ccm e DbgFont14h.tpf in " + "dvdroot_ps4/font dalla mod Nexus #253. Touchpad destro: Backspace."}, + {"Changes apply after restarting the game", + "Le modifiche si applicano dopo il riavvio del gioco"}, + {"Apply and restart game", + "Applica e riavvia il gioco"}, + {"Other", + "Altro"}, + {"FPS counter in corner", + "Contatore FPS nell'angolo"}, + {"Frame rate", + "Frequenza fotogrammi"}, + {"Frame time", + "Tempo per fotogramma"}, + {"Close", + "Chiudi"}, + {"Settings are saved to bbport.ini", + "Le impostazioni sono salvate in bbport.ini"}, + {"Chromatic aberration", + "Aberrazione cromatica"}, + {"Depth of field (DoF)", + "Profondità di campo (DoF)"}, + {"Motion blur", + "Sfocatura di movimento"}, + {"Ambient occlusion (SSAO)", + "Occlusione ambientale (SSAO)"}, + {"Game's own anti-aliasing", + "Anti-aliasing del gioco"}, + {"Shadows from dynamic lights", + "Ombre delle luci dinamiche"}, + {"Screen-space reflections (not in original game)", + "Riflessi in screen space (assenti nel gioco originale)"}, + {"Skip startup intros", + "Salta i filmati iniziali"}, + {"Free camera (Cross + L3)", + "Telecamera libera (Cross + L3)"}, + {"Debug menu (requires font files)", + "Menu di debug (richiede file dei font)"}, +}; + +// Polish (pl) +constexpr Entry kPolish[] = { + {"Hide mouse cursor", + "Ukryj kursor myszy"}, + {"Picture", + "Obraz"}, + {"Picture tuning", + "Strojenie obrazu"}, + {"UI brightness", + "Jasność interfejsu"}, + {"UI colour", + "Kolory interfejsu"}, + {"Brightness", + "Jasność"}, + {"Contrast", + "Kontrast"}, + {"Saturation", + "Nasycenie"}, + {"Hue", + "Odcień"}, + {"Fog", + "Mgła"}, + {"Vignette", + "Winieta"}, + {"Ambient occlusion strength", + "Siła okluzji otoczenia"}, + {"Shadow distance", + "Zasięg cieni"}, + {"Game's own picture", + "Oryginalny obraz gry"}, + {"Bloodborne - Graphics (Insert / L3+R3)###bbport_settings", + "Bloodborne - Grafika (Insert / L3+R3)###bbport_settings"}, + {"%.0f FPS (%.1f ms)", + "%.0f FPS (%.1f ms)"}, + {"Temporal upscaler", + "Skalowanie czasowe"}, + {"Off", + "Wyłączone"}, + {"TAA (native anti-aliasing)", + "TAA (natywny antyaliasing)"}, + {"Upscaler", + "Upscaler"}, + {"— not supported by this GPU", + "— nieobsługiwane przez ten GPU"}, + {"— in development", + "— w przygotowaniu"}, + {"FSR 4.1.1 in INT8 mode: the model from AMD's 4.1.1 DLL, reproduced in Vulkan (output " + "matches the DLL). One model for Native through Performance and another for Ultra " + "Performance. Assets: tools/fsr4cap/build_assets.sh (requires the DLL and Proton).", + "FSR 4.1.1 w trybie INT8: model z biblioteki DLL AMD 4.1.1 odtworzony w Vulkanie (wynik " + "zgodny z DLL). Jeden model od Native do Performance i osobny dla Ultra Performance. " + "Zasoby: tools/fsr4cap/build_assets.sh (wymaga DLL i Protona)."}, + {"FSR 4 in INT8 mode (v07 model from AMD FidelityFX SDK sources). Higher quality than " + "FSR 3.1, but the pass is more demanding. Changing the preset rebuilds the model (a " + "brief pause). Assets: tools/fetch_fsr4_assets.sh.", + "FSR 4 w trybie INT8 (model v07 ze źródeł AMD FidelityFX SDK). Wyższa jakość niż FSR " + "3.1, ale przebieg jest bardziej wymagający. Zmiana profilu przebudowuje model (krótka " + "pauza). Zasoby: tools/fetch_fsr4_assets.sh."}, + {"FSR 4: auto exposure", + "FSR 4: automatyczna ekspozycja"}, + {"FSR 4: invert jitter sign", + "FSR 4: odwróć znak jittera"}, + {"For diagnosing ghosting: the FSR 4 network normalizes color by exposure and uses it to " + "decide when to discard previous frames. Changes apply immediately, without a restart.", + "Do diagnozowania smużenia (ghosting): sieć FSR 4 normalizuje kolor według ekspozycji i " + "na tej podstawie decyduje, kiedy odrzucić poprzednie klatki. Zmiany działają od razu, " + "bez restartu."}, + {"Preset", + "Profil"}, + {"%s (x%.1f, render %dx%d)", + "%s (x%.1f, renderowanie %dx%d)"}, + {"Character motion vectors", + "Wektory ruchu postaci"}, + {"Accurate vectors for animated objects: clothing and weapons break up less during " + "movement. The static scene does not get an additional pass. Changes apply after " + "restarting the game.", + "Dokładne wektory dla animowanych obiektów: ubrania i broń mniej się rozpadają w ruchu. " + "Statyczna scena nie dostaje dodatkowego przebiegu. Działa po ponownym uruchomieniu gry."}, + {"Show motion vectors (debug)", + "Pokaż wektory ruchu (debug)"}, + {"Red/green: horizontal/vertical motion (8 pixels = full brightness). Blue: the pixel " + "received an accurate object vector, not just camera motion. The upscaler treats a " + "moving object with no blue or red/green as stationary, causing trails.", + "Czerwony/zielony: ruch w poziomie/pionie (8 pikseli = pełna jasność). Niebieski: " + "piksel otrzymał dokładny wektor obiektu, a nie tylko ruch kamery. Poruszający się " + "obiekt bez niebieskiego ani czerwonego/zielonego upscaler uznaje za nieruchomy, stąd " + "smugi."}, + {"Output resolution", + "Rozdzielczość wyjściowa"}, + {"Size of the final frame and UI. The preset sets the scene size relative to the output: " + "4K Performance = 1920x1080. Applies after restarting the game.", + "Rozmiar gotowej klatki i interfejsu. Profil ustala rozmiar sceny względem wyjścia: 4K " + "Performance = 1920x1080. Działa po ponownym uruchomieniu gry."}, + {"The final frame and UI size changes at the next frame boundary. The preset sets the " + "scene size relative to the output: 4K Performance = 1920x1080. Changing the size " + "resets FSR history and may cause a brief pause.", + "Rozmiar gotowej klatki i interfejsu zmienia się od następnej klatki. Profil ustala " + "rozmiar sceny względem wyjścia: 4K Performance = 1920x1080. Zmiana rozmiaru resetuje " + "historię FSR i może spowodować krótką pauzę."}, + {"Auto (based on GPU)", + "Auto (zależnie od GPU)"}, + {"Off (faster)", + "Wyłączona (szybciej)"}, + {"On", + "Włączona"}, + {"Live resolution changes", + "Zmiana rozdzielczości na żywo"}, + {"On: output resolution and preset change without restarting, but game post-processing " + "stays at 1080p, which is noticeably slower on Steam Deck and older GPUs. Off: " + "everything renders at the preset resolution, and changes require a restart. Auto " + "enables this on powerful discrete GPUs. Applies after restarting the game.", + "Włączona: rozdzielczość wyjściowa i profil zmieniają się bez restartu, ale " + "postprocessing gry zostaje w 1080p, co na Steam Decku i starszych GPU jest wyraźnie " + "wolniejsze. Wyłączona: wszystko jest renderowane w rozdzielczości profilu, a zmiany " + "wymagają restartu. Auto włącza ją na mocnych dedykowanych GPU. Działa po ponownym " + "uruchomieniu gry."}, + {"Game effects (restart required)", + "Efekty gry (wymagany restart)"}, + {"Highest (-2)", + "Najwyższa (-2)"}, + {"Game default", + "Jak w grze"}, + {"Lower (1)", + "Niższa (1)"}, + {"Lowest (2)", + "Najniższa (2)"}, + {"Model detail", + "Szczegółowość modeli"}, + {"Effects are enabled and disabled by game patches at startup (patches/Bloodborne.xml). " + "Motion blur and shadows from dynamic lights place a significant load on the GPU.", + "Efekty są włączane i wyłączane przez łatki gry przy uruchomieniu " + "(patches/Bloodborne.xml). Rozmycie ruchu i cienie od dynamicznych świateł wyraźnie " + "obciążają GPU."}, + {"Free camera: hold Cross and press L3 (keyboard: Space + Z). Debug menu: left touchpad " + "/ Tab. Requires DbgFont14h.ccm and DbgFont14h.tpf in dvdroot_ps4/font from Nexus mod " + "#253. Right touchpad: Backspace.", + "Wolna kamera: przytrzymaj Cross i naciśnij L3 (klawiatura: Space + Z). Menu " + "debugowania: lewy touchpad / Tab. Wymaga DbgFont14h.ccm i DbgFont14h.tpf w " + "dvdroot_ps4/font z moda Nexus #253. Prawy touchpad: Backspace."}, + {"Changes apply after restarting the game", + "Zmiany zadziałają po ponownym uruchomieniu gry"}, + {"Apply and restart game", + "Zastosuj i uruchom grę ponownie"}, + {"Other", + "Inne"}, + {"FPS counter in corner", + "Licznik FPS w rogu"}, + {"Frame rate", + "Liczba klatek na sekundę"}, + {"Frame time", + "Czas klatki"}, + {"Close", + "Zamknij"}, + {"Settings are saved to bbport.ini", + "Ustawienia są zapisywane w bbport.ini"}, + {"Chromatic aberration", + "Aberracja chromatyczna"}, + {"Depth of field (DoF)", + "Głębia ostrości (DoF)"}, + {"Motion blur", + "Rozmycie ruchu"}, + {"Ambient occlusion (SSAO)", + "Okluzja otoczenia (SSAO)"}, + {"Game's own anti-aliasing", + "Własny antyaliasing gry"}, + {"Shadows from dynamic lights", + "Cienie od dynamicznych świateł"}, + {"Screen-space reflections (not in original game)", + "Odbicia w przestrzeni ekranu (nie było ich w grze)"}, + {"Skip startup intros", + "Pomiń intro przy uruchomieniu"}, + {"Free camera (Cross + L3)", + "Wolna kamera (Cross + L3)"}, + {"Debug menu (requires font files)", + "Menu debugowania (wymaga plików czcionek)"}, +}; + +// Turkish (tr) +constexpr Entry kTurkish[] = { + {"Hide mouse cursor", + "Fare imlecini gizle"}, + {"Picture", + "Görüntü"}, + {"Picture tuning", + "Görüntü ayarı"}, + {"UI brightness", + "Arayüz parlaklığı"}, + {"UI colour", + "Arayüz renkleri"}, + {"Brightness", + "Parlaklık"}, + {"Contrast", + "Kontrast"}, + {"Saturation", + "Doygunluk"}, + {"Hue", + "Renk tonu"}, + {"Fog", + "Sis"}, + {"Vignette", + "Vinyet"}, + {"Ambient occlusion strength", + "Ortam kapatma gücü"}, + {"Shadow distance", + "Gölge mesafesi"}, + {"Game's own picture", + "Oyunun kendi görüntüsü"}, + {"Bloodborne - Graphics (Insert / L3+R3)###bbport_settings", + "Bloodborne - Grafik (Insert / L3+R3)###bbport_settings"}, + {"%.0f FPS (%.1f ms)", + "%.0f FPS (%.1f ms)"}, + {"Temporal upscaler", + "Zamansal ölçekleyici"}, + {"Off", + "Kapalı"}, + {"TAA (native anti-aliasing)", + "TAA (yerel kenar yumuşatma)"}, + {"Upscaler", + "Ölçekleyici"}, + {"— not supported by this GPU", + "— bu GPU tarafından desteklenmiyor"}, + {"— in development", + "— geliştiriliyor"}, + {"FSR 4.1.1 in INT8 mode: the model from AMD's 4.1.1 DLL, reproduced in Vulkan (output " + "matches the DLL). One model for Native through Performance and another for Ultra " + "Performance. Assets: tools/fsr4cap/build_assets.sh (requires the DLL and Proton).", + "INT8 modunda FSR 4.1.1: AMD'nin 4.1.1 DLL'indeki model, Vulkan'da yeniden üretildi " + "(çıktı DLL ile aynı). Native'den Performance'a kadar bir model, Ultra Performance için " + "ayrı bir model. Dosyalar: tools/fsr4cap/build_assets.sh (DLL ve Proton gerekir)."}, + {"FSR 4 in INT8 mode (v07 model from AMD FidelityFX SDK sources). Higher quality than " + "FSR 3.1, but the pass is more demanding. Changing the preset rebuilds the model (a " + "brief pause). Assets: tools/fetch_fsr4_assets.sh.", + "INT8 modunda FSR 4 (AMD FidelityFX SDK kaynaklarından v07 modeli). FSR 3.1'den daha " + "yüksek kalite, ancak geçiş daha ağır. Ön ayarı değiştirmek modeli yeniden oluşturur " + "(kısa bir duraklama). Dosyalar: tools/fetch_fsr4_assets.sh."}, + {"FSR 4: auto exposure", + "FSR 4: otomatik pozlama"}, + {"FSR 4: invert jitter sign", + "FSR 4: jitter işaretini ters çevir"}, + {"For diagnosing ghosting: the FSR 4 network normalizes color by exposure and uses it to " + "decide when to discard previous frames. Changes apply immediately, without a restart.", + "Hayalet görüntü (ghosting) teşhisi için: FSR 4 ağı rengi pozlamaya göre normalleştirir " + "ve önceki kareleri ne zaman atacağına buna göre karar verir. Değişiklikler yeniden " + "başlatmadan hemen uygulanır."}, + {"Preset", + "Ön ayar"}, + {"%s (x%.1f, render %dx%d)", + "%s (x%.1f, işleme %dx%d)"}, + {"Character motion vectors", + "Karakter hareket vektörleri"}, + {"Accurate vectors for animated objects: clothing and weapons break up less during " + "movement. The static scene does not get an additional pass. Changes apply after " + "restarting the game.", + "Animasyonlu nesneler için hassas vektörler: kıyafetler ve silahlar hareket sırasında " + "daha az dağılır. Statik sahne ek bir geçiş almaz. Oyun yeniden başlatıldıktan sonra " + "uygulanır."}, + {"Show motion vectors (debug)", + "Hareket vektörlerini göster (hata ayıklama)"}, + {"Red/green: horizontal/vertical motion (8 pixels = full brightness). Blue: the pixel " + "received an accurate object vector, not just camera motion. The upscaler treats a " + "moving object with no blue or red/green as stationary, causing trails.", + "Kırmızı/yeşil: yatay/dikey hareket (8 piksel = tam parlaklık). Mavi: piksel yalnızca " + "kamera hareketini değil, hassas bir nesne vektörünü aldı. Ölçekleyici, mavisi ya da " + "kırmızısı/yeşili olmayan hareketli bir nesneyi sabit sayar; bu da iz bırakmaya yol " + "açar."}, + {"Output resolution", + "Çıkış çözünürlüğü"}, + {"Size of the final frame and UI. The preset sets the scene size relative to the output: " + "4K Performance = 1920x1080. Applies after restarting the game.", + "Son karenin ve arayüzün boyutu. Ön ayar, sahne boyutunu çıkışa göre belirler: 4K " + "Performance = 1920x1080. Oyun yeniden başlatıldıktan sonra uygulanır."}, + {"The final frame and UI size changes at the next frame boundary. The preset sets the " + "scene size relative to the output: 4K Performance = 1920x1080. Changing the size " + "resets FSR history and may cause a brief pause.", + "Son karenin ve arayüzün boyutu bir sonraki karede değişir. Ön ayar, sahne boyutunu " + "çıkışa göre belirler: 4K Performance = 1920x1080. Boyutu değiştirmek FSR geçmişini " + "sıfırlar ve kısa bir duraklamaya neden olabilir."}, + {"Auto (based on GPU)", + "Otomatik (GPU'ya göre)"}, + {"Off (faster)", + "Kapalı (daha hızlı)"}, + {"On", + "Açık"}, + {"Live resolution changes", + "Anlık çözünürlük değişimi"}, + {"On: output resolution and preset change without restarting, but game post-processing " + "stays at 1080p, which is noticeably slower on Steam Deck and older GPUs. Off: " + "everything renders at the preset resolution, and changes require a restart. Auto " + "enables this on powerful discrete GPUs. Applies after restarting the game.", + "Açık: çıkış çözünürlüğü ve ön ayar yeniden başlatmadan değişir, ancak oyunun son " + "işlemesi 1080p'de kalır; bu, Steam Deck'te ve eski GPU'larda belirgin şekilde daha " + "yavaştır. Kapalı: her şey ön ayarın çözünürlüğünde işlenir ve değişiklikler yeniden " + "başlatma gerektirir. Otomatik, bunu güçlü harici GPU'larda açar. Oyun yeniden " + "başlatıldıktan sonra uygulanır."}, + {"Game effects (restart required)", + "Oyun efektleri (yeniden başlatma gerekir)"}, + {"Highest (-2)", + "En yüksek (-2)"}, + {"Game default", + "Oyun varsayılanı"}, + {"Lower (1)", + "Düşük (1)"}, + {"Lowest (2)", + "En düşük (2)"}, + {"Model detail", + "Model detayı"}, + {"Effects are enabled and disabled by game patches at startup (patches/Bloodborne.xml). " + "Motion blur and shadows from dynamic lights place a significant load on the GPU.", + "Efektler, başlangıçta oyun yamalarıyla açılıp kapatılır (patches/Bloodborne.xml). " + "Hareket bulanıklığı ve dinamik ışıkların gölgeleri GPU'ya belirgin bir yük bindirir."}, + {"Free camera: hold Cross and press L3 (keyboard: Space + Z). Debug menu: left touchpad " + "/ Tab. Requires DbgFont14h.ccm and DbgFont14h.tpf in dvdroot_ps4/font from Nexus mod " + "#253. Right touchpad: Backspace.", + "Serbest kamera: Cross'u basılı tutup L3'e basın (klavye: Space + Z). Hata ayıklama " + "menüsü: sol touchpad / Tab. Nexus mod #253'ten dvdroot_ps4/font içine DbgFont14h.ccm " + "ve DbgFont14h.tpf gerekir. Sağ touchpad: Backspace."}, + {"Changes apply after restarting the game", + "Değişiklikler oyun yeniden başlatıldıktan sonra uygulanır"}, + {"Apply and restart game", + "Uygula ve oyunu yeniden başlat"}, + {"Other", + "Diğer"}, + {"FPS counter in corner", + "Köşede FPS sayacı"}, + {"Frame rate", + "Kare hızı"}, + {"Frame time", + "Kare süresi"}, + {"Close", + "Kapat"}, + {"Settings are saved to bbport.ini", + "Ayarlar bbport.ini dosyasına kaydedilir"}, + {"Chromatic aberration", + "Renk sapması"}, + {"Depth of field (DoF)", + "Alan derinliği (DoF)"}, + {"Motion blur", + "Hareket bulanıklığı"}, + {"Ambient occlusion (SSAO)", + "Ortam perdelemesi (SSAO)"}, + {"Game's own anti-aliasing", + "Oyunun kendi kenar yumuşatması"}, + {"Shadows from dynamic lights", + "Dinamik ışıkların gölgeleri"}, + {"Screen-space reflections (not in original game)", + "Ekran alanı yansımaları (orijinal oyunda yoktu)"}, + {"Skip startup intros", + "Açılış videolarını atla"}, + {"Free camera (Cross + L3)", + "Serbest kamera (Cross + L3)"}, + {"Debug menu (requires font files)", + "Hata ayıklama menüsü (yazı tipi dosyaları gerekir)"}, +}; + +using Table = std::unordered_map; + +template +Table Build(const Entry (&entries)[N]) { + Table table; + table.reserve(N); + for (const Entry& entry : entries) { + table.emplace(entry.english, entry.text); + } + return table; +} + +} // namespace + +const char* MenuTranslation(MenuLanguage language, const char* english) { + // Built once (thread-safe static initialization); looked up for every string every frame. + static const std::array tables = { + Build(kSpanish), Build(kPortugueseBrazil), Build(kFrench), Build(kGerman), + Build(kItalian), Build(kPolish), Build(kTurkish), + }; + std::size_t index = 0; + switch (language) { + case MenuLanguage::Spanish: index = 0; break; + case MenuLanguage::PortugueseBrazil: index = 1; break; + case MenuLanguage::French: index = 2; break; + case MenuLanguage::German: index = 3; break; + case MenuLanguage::Italian: index = 4; break; + case MenuLanguage::Polish: index = 5; break; + case MenuLanguage::Turkish: index = 6; break; + default: return english; + } + const Table& table = tables[index]; + const auto it = table.find(english); + return it != table.end() ? it->second : english; +} + +} // namespace BbSettings diff --git a/gpu/shim/bbport_menu_i18n.h b/gpu/shim/bbport_menu_i18n.h new file mode 100644 index 0000000..693ca50 --- /dev/null +++ b/gpu/shim/bbport_menu_i18n.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: the in-game menu's text in the languages other than English and Russian +// (bbport_menu_i18n.cpp), for MenuText (bbport_settings.h). +#pragma once + +#include "bbport_settings.h" + +namespace BbSettings { + +/// The translation of a menu text, looked up by its English text; the English text itself when the +/// table has none (or for English and Russian, which MenuText answers itself). Static storage. +const char* MenuTranslation(MenuLanguage language, const char* english); + +} // namespace BbSettings diff --git a/gpu/shim/bbport_overlay.cpp b/gpu/shim/bbport_overlay.cpp index 83e5538..9fbf7a9 100644 --- a/gpu/shim/bbport_overlay.cpp +++ b/gpu/shim/bbport_overlay.cpp @@ -12,12 +12,15 @@ #include #include "bbport_settings.h" +#include "engine/cheats.h" +#include "engine/graphics.h" #include "imgui.h" #include "imgui_impl_vulkan.h" #include "video_core/renderer_vulkan/vk_instance.h" #include "video_core/renderer_vulkan/vk_scheduler.h" -// DejaVu Sans (Cyrillic), embedded (third_party/fonts, Bitstream Vera license). +// DejaVu Sans (Latin with Latin Extended, Greek, Cyrillic), embedded (third_party/fonts, Bitstream +// Vera license). #ifdef _WIN32 // PE/COFF assemblers have no .hidden/.previous: the compiler embeds the file (#embed, a GCC // extension in C++). @@ -156,10 +159,44 @@ void Checkbox(const char* label, std::atomic& value) { Store(value, v, changed); } -void Slider(const char* label, std::atomic& value, float lo, float hi) { +bool Slider(const char* label, std::atomic& value, float lo, float hi, + const char* format = "%.2f", ImGuiSliderFlags flags = ImGuiSliderFlags_None) { float v = value; - const bool changed = ImGui::SliderFloat(label, &v, lo, hi, "%.2f"); + const bool changed = ImGui::SliderFloat(label, &v, lo, hi, format, flags); Store(value, v, changed); + return changed; +} + +// The amounts Custom starts from (the last one chosen in this session). +int custom_vials = 20, custom_bullets = 20; + +// A cheat (engine/cheats.cpp): Off / Infinite / Custom, and the amount for Custom; live. +void Cheat(const char* label, std::atomic& value, BbEngine::Cheats::Which which, int& custom) { + ImGui::PushID(label); + const char* modes[] = {BbSettings::MenuText("Off", "Выкл."), + BbSettings::MenuText("Infinite", "Бесконечно"), + BbSettings::MenuText("Custom", "Своё количество")}; + const int current = value; + int mode = current == BbSettings::CheatOff ? 0 : current == BbSettings::CheatInfinite ? 1 : 2; + if (current > 0) { + custom = current; + } + if (ImGui::Combo(label, &mode, modes, 3)) { + Store(value, mode == 0 ? BbSettings::CheatOff : mode == 1 ? BbSettings::CheatInfinite : custom, true); + } + if (mode == 2) { + int amount = std::clamp(custom, 1, BbSettings::CheatMaxAmount); + const bool changed = ImGui::SliderInt(BbSettings::MenuText("Amount", "Количество"), &amount, 1, + BbSettings::CheatMaxAmount, "%d", ImGuiSliderFlags_AlwaysClamp); + if (changed) { + custom = std::clamp(amount, 1, BbSettings::CheatMaxAmount); + Store(value, custom, true); + } + } + if (const char* why = BbEngine::Cheats::Unavailable(which)) { + ImGui::TextDisabled("%s: %s", BbSettings::MenuText("Unavailable", "Недоступно"), why); + } + ImGui::PopID(); } void Hint(const char* text) { @@ -173,6 +210,208 @@ void Hint(const char* text) { } } +void CheatUnavailable(BbEngine::Cheats::Which which) { + if (const char* why = BbEngine::Cheats::Unavailable(which)) { + ImGui::TextDisabled("%s: %s", BbSettings::MenuText("Unavailable", "Недоступно"), why); + } +} + +// An on/off cheat (engine/cheats.cpp); live. +void CheatToggle(const char* label, std::atomic& value, BbEngine::Cheats::Which which, const char* hint) { + ImGui::PushID(label); + Checkbox(label, value); + Hint(hint); + CheatUnavailable(which); + ImGui::PopID(); +} + +// A cheat with a few levels, the first the game's own; live. +template +void CheatLevels(const char* label, std::atomic& value, const int (&levels)[N], const char* const (&names)[N], + BbEngine::Cheats::Which which, const char* hint) { + ImGui::PushID(label); + const int current = value; + int index = 0; + for (std::size_t i = 0; i < N; ++i) { + if (levels[i] == current) { + index = int(i); + } + } + if (ImGui::Combo(label, &index, names, int(N))) { + Store(value, levels[index], true); + } + Hint(hint); + CheatUnavailable(which); + ImGui::PopID(); +} + +// Adds Blood Echoes / Insight to the character in the world (applied by the engine's frame hook). +void CheatStats() { + const bool off = BbEngine::Cheats::Unavailable(BbEngine::Cheats::Echoes) != nullptr; + ImGui::BeginDisabled(off); + ImGui::TextUnformatted(BbSettings::MenuText("Blood Echoes", "Отголоски крови")); + for (int amount : {1000, 10000, 100000}) { + char label[32]; + std::snprintf(label, sizeof(label), "+%d,%03d", amount / 1000, 0); + ImGui::SameLine(); + if (ImGui::Button(label)) { + BbEngine::Cheats::AddEchoes(amount); + } + } + ImGui::SameLine(); + if (ImGui::Button(BbSettings::MenuText("+1 Insight", "+1 озарение"))) { + BbEngine::Cheats::AddInsight(1); + } + ImGui::EndDisabled(); + Hint(BbSettings::MenuText( + "Added to the character in the world, up to the game's limits (999,999,999 Blood Echoes, 99 Insight). " + "They are saved with your character: turning cheats off does not take them back.", + "Добавляются персонажу в игре, до пределов игры (999 999 999 отголосков, 99 озарения). Они " + "сохраняются вместе с персонажем: выключение читов их не отнимает.")); + CheatUnavailable(BbEngine::Cheats::Echoes); +} + +// HDR output (bbport.ini hdr*): on/off on restart; paper white, peak and boost live. +void HdrSection() { + auto& s = BbSettings::Get(); + ImGui::SeparatorText(BbSettings::MenuText("HDR output", "Вывод HDR")); + Checkbox(BbSettings::MenuText("HDR output (restart required)", "Вывод HDR (после перезапуска)"), + s.hdr); + Hint(BbSettings::MenuText( + "Shows the game's SDR picture on an HDR display without washed-out colors, with optional " + "highlight expansion. Windows HDR must be on (Settings > Display > Use HDR). Turning it " + "on or off applies after restarting the game.", + "Показывает SDR-картинку игры на HDR-дисплее без блёклых цветов, с необязательным " + "расширением светлых участков. В Windows должен быть включён HDR (Параметры > Дисплей > " + "Использовать HDR). Включение и выключение применяются после перезапуска игры.")); + const int output = s.hdr_output.load(); + if (output == BbSettings::HdrOutputOff) { + if (s.startup_hdr) { + ImGui::PushTextWrapPos(); + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.3f, 1.0f), "%s", + BbSettings::MenuText( + "Not active: the display offers no HDR format (is Windows HDR on?)", + "Не активен: дисплей не предлагает HDR-формат (включён ли HDR " + "в Windows?)")); + ImGui::PopTextWrapPos(); + } + return; + } + ImGui::TextDisabled("%s", output == BbSettings::HdrOutputScRgb + ? BbSettings::MenuText("Active: scRGB (FP16)", "Активен: scRGB (FP16)") + : BbSettings::MenuText("Active: HDR10 (PQ, BT.2020)", + "Активен: HDR10 (PQ, BT.2020)")); + // bbport: hdr_mode, live (docs/hdr.md). + const char* modes[BbSettings::HdrModeCount] = { + BbSettings::MenuText("Native (from the game's HDR scene)", "Нативный (из HDR-сцены игры)"), + BbSettings::MenuText("Expand (from the SDR picture)", "Расширение (из SDR-картинки)"), + }; + int mode = std::clamp(s.hdr_mode.load(), 0, BbSettings::HdrModeCount - 1); + const bool mode_changed = ImGui::Combo(BbSettings::MenuText("HDR mode", "Режим HDR"), &mode, + modes, BbSettings::HdrModeCount); + Store(s.hdr_mode, mode, mode_changed); + Hint(BbSettings::MenuText( + "Native: the brightness of lamps, fire, the moon and other highlights comes from the " + "game's own HDR scene, before its tonemap clipped it to SDR white. Midtones, colours and " + "the UI stay as in the SDR picture. Menus, loading screens and movies (no scene) use " + "Expand. Expand: the SDR picture's brightest parts are stretched towards the peak.", + "Нативный: яркость ламп, огня, луны и других светлых мест берётся из собственной " + "HDR-сцены игры, до того как тонмаппинг обрезал её до белого SDR. Средние тона, цвета и " + "интерфейс остаются как в SDR-картинке. В меню, на загрузках и в видеороликах (без сцены) " + "работает «Расширение». Расширение: самые яркие места SDR-картинки растягиваются к пику.")); + Slider(BbSettings::MenuText("Paper white (nits)", "Белый SDR (нит)"), s.hdr_paper_white, + BbSettings::HdrPaperWhiteMin, BbSettings::HdrPaperWhiteMax, "%.0f", + ImGuiSliderFlags_AlwaysClamp); + Slider(BbSettings::MenuText("Peak brightness (nits)", "Пиковая яркость (нит)"), s.hdr_peak, + BbSettings::HdrPeakMin, BbSettings::HdrPeakMax, "%.0f", + ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic); + Slider(BbSettings::MenuText("Highlight boost", "Усиление светлых участков"), s.hdr_boost, 0.0f, + 1.0f, "%.2f", ImGuiSliderFlags_AlwaysClamp); + if (mode == BbSettings::HdrModeNative) { + Slider(BbSettings::MenuText("UI brightness", "Яркость интерфейса"), s.hdr_ui_brightness, + BbSettings::HdrUiBrightnessMin, BbSettings::HdrUiBrightnessMax, "%.2f", ImGuiSliderFlags_AlwaysClamp); + Slider(BbSettings::MenuText("UI colour", "Цвета интерфейса"), s.hdr_ui_color, 0.0f, 1.0f, "%.2f", + ImGuiSliderFlags_AlwaysClamp); + Hint(BbSettings::MenuText( + "Native mode only: the game's HUD, menus, subtitles and loading screens. UI brightness: their " + "white, times paper white. UI colour: 0 shows their exact sRGB colours, 1 the wider colours a " + "wide-gamut monitor shows SDR with (the usual look; exact sRGB can look washed out next to " + "it). Live.", + "Только в нативном режиме: интерфейс игры, меню, субтитры и экраны загрузки. Яркость " + "интерфейса: его белый, умноженный на белый SDR. Цвета интерфейса: 0 — точные цвета sRGB, " + "1 — более насыщенные, как на мониторе с широким охватом в SDR (привычный вид; точный sRGB " + "рядом с ним может казаться блёклым). Меняются сразу.")); + } + Hint(BbSettings::MenuText( + "Paper white: how bright the game's white (and this menu) is; about 200 suits a dim room. " + "Peak: your display's peak brightness (its HDR specs, or the Windows HDR Calibration app). " + "Highlight boost: 0 shows the SDR picture as it is; higher values let the brightest parts " + "(lamps, fire, the moon) rise towards the peak. In Native mode it is how much of the " + "scene's own highlight range is shown: 0.25 about half, 1 all of it (up to the peak). " + "Changes apply live. Ctrl+click a slider to type an exact value.", + "Белый SDR: яркость белого в игре (и этого меню); около 200 подходит для неяркой комнаты. " + "Пиковая яркость: максимум вашего дисплея (в его характеристиках HDR или в приложении " + "«Калибровка HDR» Windows). Усиление светлых участков: 0 — SDR-картинка как есть; больше — " + "самые яркие места (лампы, огонь, луна) поднимаются к пику. В нативном режиме — какая " + "доля собственного диапазона светлых мест сцены показывается: 0.25 — около половины, " + "1 — весь (до пика). Меняются сразу. Ctrl+клик по ползунку — ввести точное значение.")); +} + +// The game's own picture for each area (engine/graphics.cpp), live: Picture tuning too. +void PictureSection() { + auto& s = BbSettings::Get(); + ImGui::SeparatorText(BbSettings::MenuText("Picture", "Изображение")); + const bool live = BbEngine::Graphics::Enabled(); + if (!live) { + ImGui::TextDisabled("%s", BbSettings::MenuText("Unavailable: the game's code differs from version 1.09.", + "Недоступно: код игры отличается от версии 1.09.")); + } + ImGui::BeginDisabled(!live); + Checkbox(BbSettings::MenuText("Picture tuning", "Настройка изображения"), s.graphics_controls); + // A moved setting turns Picture tuning on: it gates them all. + const bool vignette = s.graphics_vignette; + // The useful range on the slider; Ctrl+click types any value (bbport_settings.cpp keeps it + // within wide limits). + bool moved = Slider(BbSettings::MenuText("Brightness", "Яркость"), s.graphics_brightness, 0.0f, 3.0f); + moved |= Slider(BbSettings::MenuText("Contrast", "Контраст"), s.graphics_contrast, 0.8f, 1.2f, "%.3f"); + moved |= Slider(BbSettings::MenuText("Saturation", "Насыщенность"), s.graphics_saturation, 0.0f, 3.0f); + moved |= Slider(BbSettings::MenuText("Hue", "Оттенок"), s.graphics_hue, -180.0f, 180.0f, "%.0f"); + moved |= Slider(BbSettings::MenuText("Fog", "Туман"), s.graphics_fog, 0.0f, 3.0f); + moved |= Slider("Bloom", s.graphics_bloom, 0.0f, 3.0f); + Checkbox(BbSettings::MenuText("Vignette", "Виньетка"), s.graphics_vignette); + moved |= vignette != s.graphics_vignette; + moved |= Slider(BbSettings::MenuText("Ambient occlusion strength", "Сила затенения (AO)"), s.graphics_ao_strength, + 0.0f, 3.0f); + moved |= Slider(BbSettings::MenuText("Shadow distance", "Дальность теней"), s.graphics_shadow_scale, 0.5f, 4.0f); + if (moved && !s.graphics_controls) { + Store(s.graphics_controls, true, true); + } + if (ImGui::Button(BbSettings::MenuText("Game's own picture", "Как в игре"))) { + Store(s.graphics_brightness, 1.0f, true); + Store(s.graphics_contrast, 1.0f, true); + Store(s.graphics_saturation, 1.0f, true); + Store(s.graphics_hue, 0.0f, true); + Store(s.graphics_fog, 1.0f, true); + Store(s.graphics_bloom, 1.0f, true); + Store(s.graphics_vignette, true, true); + Store(s.graphics_ao_strength, 1.0f, true); + Store(s.graphics_shadow_scale, 1.0f, true); + } + ImGui::EndDisabled(); + Hint(BbSettings::MenuText( + "Changes apply live, Picture tuning too, to the game's own colour, fog and lighting of each area " + "(the HUD and menus keep theirs). Moving one turns Picture tuning on; off: the game's own picture. 1.00 and Hue 0 are the game's. Fog 0 clears the haze over distant streets. The " + "same settings are in the game's System menu (PC Graphics, PC Effects, PC Colour) and the " + "launcher. Ctrl+click a slider to type any value, beyond its ends too (brightness, saturation and fog " + "up to 5, contrast 0.5-1.5, hue -180-180, bloom up to 10, shadow distance 0.25-8).", + "Меняются сразу, как и сама настройка: собственные цвет, туман и освещение каждой области " + "(интерфейс и меню не меняются). Выкл. — картинка игры. 1.00 и оттенок 0 — как в игре. Туман 0 убирает дымку над дальними улицами. Те же " + "настройки есть в системном меню игры (PC Graphics, PC Effects, PC Colour) и в лаунчере. " + "Ctrl+клик по ползунку — ввести любое значение, в том числе за его краями (яркость, " + "насыщенность и туман до 5, контраст 0.5-1.5, оттенок -180-180, bloom до 10, дальность теней " + "0.25-8).")); +} + void Menu() { auto& s = BbSettings::Get(); const ImGuiViewport* viewport = ImGui::GetMainViewport(); @@ -185,6 +424,18 @@ void Menu() { pos.y = viewport->WorkPos.y + std::clamp(s.menu_y * viewport->WorkSize.y, 0.0f, std::max(viewport->WorkSize.y - margin, 0.0f)); } + // The contents are taller than a 1080p screen: the window ends above the screen's bottom edge + // wherever its top is, so its scroll bar and resize corner stay in reach, and it opens as tall + // as that allows (the contents' own height when they fit). + static int last_frame = -2; + static float window_top = 0.0f; + const float min_height = 240.0f * base_scale; + const float bottom = viewport->WorkPos.y + viewport->WorkSize.y - 8.0f * base_scale; + pos.y = std::min(pos.y, std::max(viewport->WorkPos.y, bottom - min_height)); + const bool appearing = ImGui::GetFrameCount() != last_frame + 1; + const float top = appearing ? pos.y : window_top; + ImGui::SetNextWindowSizeConstraints(ImVec2(360.0f * base_scale, min_height), + ImVec2(FLT_MAX, std::max(min_height, bottom - top))); ImGui::SetNextWindowPos(pos, ImGuiCond_Appearing); ImGui::SetNextWindowSize(ImVec2(620.0f * base_scale, 0.0f), ImGuiCond_Appearing); bool keep_open = true; @@ -195,6 +446,8 @@ void Menu() { ImGui::End(); return; } + last_frame = ImGui::GetFrameCount(); + window_top = ImGui::GetWindowPos().y; // Moved: remembered (saved with the settings when the menu closes). if (!ImGui::IsWindowAppearing() && viewport->WorkSize.x > 0.0f && viewport->WorkSize.y > 0.0f) { const ImVec2 at = ImGui::GetWindowPos(); @@ -206,12 +459,15 @@ void Menu() { dirty = true; } } - // Always readable, even when the rest of the menu is in Russian. - static const char* languages[] = {"English", "Русский"}; - int language = s.menu_language == BbSettings::MenuLanguage::Russian ? 1 : 0; - if (ImGui::Combo("Language", &language, languages, 2)) { + // Always readable, whatever language the rest of the menu is in: each language by its own + // name, in MenuLanguage order. + static const char* languages[] = {"English", "Русский", "Español", "Português (Brasil)", + "Français", "Deutsch", "Italiano", "Polski", "Türkçe"}; + static_assert(std::size(languages) == BbSettings::MenuLanguageCount); + int language = int(s.menu_language.load()); + if (ImGui::Combo("Language", &language, languages, BbSettings::MenuLanguageCount)) { s.menu_language = - language == 1 ? BbSettings::MenuLanguage::Russian : BbSettings::MenuLanguage::English; + BbSettings::MenuLanguage(std::clamp(language, 0, BbSettings::MenuLanguageCount - 1)); BbSettings::Save(); } ImGui::Text(BbSettings::MenuText("%.0f FPS (%.1f ms)", "%.0f FPS (%.1f мс)"), @@ -303,8 +559,8 @@ void Menu() { label, sizeof(label), BbSettings::MenuText("%s (x%.1f, render %dx%d)", "%s (x%.1f, рендер %dx%d)"), BbSettings::PresetName(i), scale, - int(std::lround(BbSettings::OutputWidths[output] / scale / 2) * 2), - int(std::lround(BbSettings::OutputHeights[output] / scale / 2) * 2)); + int(std::lround(BbSettings::OutputWidth(output) / scale / 2) * 2), + int(std::lround(BbSettings::OutputHeight(output) / scale / 2) * 2)); if (ImGui::Selectable(label, i == preset)) { Store(s.preset, i, true); } @@ -389,7 +645,8 @@ void Menu() { ImGui::SeparatorText(BbSettings::MenuText("Output resolution", "Разрешение вывода")); static const char* outputs[] = {"1280 x 720", "1920 x 1080", "2560 x 1440", "3840 x 2160", - "2560 x 1080", "3440 x 1440", "3840 x 1600", "3840 x 1080", "5120 x 1440", "5120 x 2160"}; + "2560 x 1080", "3440 x 1440", "3840 x 1600", "3840 x 1080", "5120 x 1440", "5120 x 2160", + "7680 x 2160", "Display (auto)"}; static_assert(std::size(outputs)==BbSettings::OutputCount); int output = s.output_res; if (ImGui::BeginCombo(BbSettings::MenuText("Output resolution", "Разрешение вывода"), @@ -439,6 +696,8 @@ void Menu() { "остаётся в 1080p — на Steam Deck и старых видеокартах это заметно медленнее. " "Выключена: всё рисуется в разрешении пресета, смена — через перезапуск. Авто включает " "её на мощных дискретных видеокартах. Применяется после перезапуска игры.")); + HdrSection(); + PictureSection(); ImGui::SeparatorText(BbSettings::MenuText("Game effects (restart required)", "Эффекты игры (после перезапуска)")); const char* lods[] = {BbSettings::MenuText("Highest (-2)", "Максимальная (-2)"), @@ -478,7 +737,8 @@ void Menu() { bool restart = s.object_motion != s.startup_object_motion || s.model_lod != s.startup_model_lod || - s.live_resolution != s.startup_live_resolution || BbSettings::ResolutionNeedsRestart(); + s.live_resolution != s.startup_live_resolution || s.hdr != s.startup_hdr || + BbSettings::ResolutionNeedsRestart(); for (int e = 0; e < BbSettings::EffectCount; ++e) { restart |= s.effects[e] != s.startup_effects[e]; } @@ -499,13 +759,114 @@ void Menu() { Slider("Camera distance scale", s.camera_distance_scale, 0.5f, 1.5f); Slider("Camera height scale", s.camera_height_scale, 0.5f, 1.5f); + ImGui::SeparatorText(BbSettings::MenuText("Cheats", "Читы")); + Cheat(BbSettings::MenuText("Blood Vials", "Флаконы крови"), s.cheat_vials, BbEngine::Cheats::Vials, + custom_vials); + Cheat(BbSettings::MenuText("Quicksilver Bullets", "Ртутные пули"), s.cheat_bullets, + BbEngine::Cheats::Bullets, custom_bullets); + Hint(BbSettings::MenuText( + "Changes apply live. Infinite: kept full at the normal carry limit, even from none. Custom: the count is " + "refilled to the amount and never lowered. Off restores the game's item data and keeps the " + "counts you have. While a cheat is on, the counts it gives are saved with your character. " + "Do not play online with cheats on.", + "Применяются сразу. Бесконечно: запас всегда полный, даже если предметов не было. Своё " + "количество: запас пополняется до него и никогда не уменьшается. Выкл. возвращает данные " + "предметов игры, а имеющееся количество остаётся. Пока чит включён, полученные предметы " + "сохраняются вместе с персонажем. Не играйте по сети с включёнными читами.")); + CheatToggle(BbSettings::MenuText("Infinite stamina", "Бесконечная выносливость"), s.cheat_stamina, + BbEngine::Cheats::Stamina, + BbSettings::MenuText("Your stamina stays full (enemies are not affected). Off: nothing to restore.", + "Ваша выносливость всегда полная (на врагов не действует). Выкл.: " + "восстанавливать нечего.")); + CheatToggle(BbSettings::MenuText("Stealth", "Скрытность"), s.cheat_stealth, BbEngine::Cheats::Stealth, + BbSettings::MenuText("Enemies neither see nor hear you: the game's debug flags Player Hide and " + "Player Silence. Off puts the flags back as they were.", + "Враги вас не видят и не слышат: отладочные флаги игры Player Hide и Player " + "Silence. Выкл. возвращает флаги как были.")); + { + static const char* const ranges[] = {"Off", "150%", "200%", "300%"}; + const char* const names[] = {BbSettings::MenuText(ranges[0], "Выкл."), ranges[1], ranges[2], ranges[3]}; + CheatLevels(BbSettings::MenuText("Firearm range", "Дальность огнестрела"), s.cheat_gun_range, + BbSettings::CheatGunRanges, names, BbEngine::Cheats::GunRange, + BbSettings::MenuText( + "Bullets of the pistols, rifles, blunderbusses, cannons, Gatling Gun and the trick " + "weapons' guns fly farther. Not Rosmarinus, the Flamesprayer, arcane or hunter tools. " + "Off restores the game's bullet data.", + "Пули пистолетов, ружей, мушкетонов, пушек, пулемёта и огнестрела трансформируемого " + "оружия летят дальше. Не Розмарин, огнемёт, тайные и охотничьи инструменты. Выкл. " + "возвращает данные пуль игры.")); + } + CheatToggle(BbSettings::MenuText("Keep Blood Echoes on death", "Не терять отголоски при смерти"), + s.cheat_keep_echoes, BbEngine::Cheats::KeepEchoes, + BbSettings::MenuText("Dying keeps your Blood Echoes; the bloodstain holds none. Off restores the " + "game's code. Echoes you kept stay in your save.", + "При смерти отголоски остаются у вас, в пятне крови их нет. Выкл. " + "возвращает код игры. Сохранённые отголоски остаются в сохранении.")); + { + static const char* const multipliers[] = {"x1", "x2", "x5", "x10"}; + const char* const names[] = {BbSettings::MenuText("x1 (off)", "x1 (выкл.)"), multipliers[1], multipliers[2], + multipliers[3]}; + CheatLevels(BbSettings::MenuText("Blood Echo multiplier", "Множитель отголосков"), s.cheat_echo_multiplier, + BbSettings::CheatEchoMultipliers, names, BbEngine::Cheats::EchoMultiplier, + BbSettings::MenuText( + "Blood Echoes from enemies and bosses times this. An enemy already loaded may give the " + "normal amount until the area loads again. x1 restores the game's data; echoes gained " + "stay in your save.", + "Отголоски за врагов и боссов умножаются. Уже загруженный враг может дать обычное " + "количество, пока область не загрузится заново. x1 возвращает данные игры; полученные " + "отголоски остаются в сохранении.")); + } + CheatToggle(BbSettings::MenuText("No weapon wear", "Оружие не изнашивается"), s.cheat_no_durability, + BbEngine::Cheats::NoDurability, + BbSettings::MenuText("Equipped weapons are kept at full durability. Off stops it; durability is " + "saved as it is.", + "Экипированное оружие всегда с полной прочностью. Выкл. прекращает это; " + "прочность сохраняется как есть.")); + CheatToggle(BbSettings::MenuText("Infinite consumables", "Бесконечные расходники"), s.cheat_consumables, + BbEngine::Cheats::Consumables, + BbSettings::MenuText( + "Molotovs, knives, pebbles, oil urns, antidotes, sedatives, elixirs, papers, Numbing Mist, " + "Shining Coins and similar tools are not used up. Not Blood Vials or Quicksilver Bullets " + "(above), key items, materials, or Insight and Blood Echo items. Off restores the item data.", + "Коктейли Молотова, ножи, камешки, масло, антидоты, успокоительное, эликсиры, бумага, " + "онемляющий туман, блестящие монеты и подобные инструменты не расходуются. Не флаконы крови и " + "ртутные пули (выше), не ключевые предметы, материалы, предметы озарения и отголосков. Выкл. " + "возвращает данные предметов.")); + CheatStats(); + ImGui::SeparatorText(BbSettings::MenuText("Other", "Прочее")); Checkbox(BbSettings::MenuText("FPS counter in corner", "Счётчик FPS в углу"), s.show_fps); + if (s.show_fps) { + ImGui::Indent(); + Checkbox(BbSettings::MenuText("Frame rate", "Частота кадров"), s.show_fps_rate); + Checkbox(BbSettings::MenuText("Frame time", "Время кадра"), s.show_fps_frame_time); + Checkbox(BbSettings::MenuText("Upscaler", "Апскейлер"), s.show_fps_upscaler); + ImGui::Unindent(); + } + Checkbox(BbSettings::MenuText("Hide mouse cursor", "Скрывать курсор мыши"), s.hide_cursor); + Hint(BbSettings::MenuText("Hidden 2 seconds after the mouse stops moving; moving it brings it back.", + "Скрывается через 2 секунды после остановки мыши; движение возвращает его.")); ImGui::Spacing(); if (ImGui::Button(BbSettings::MenuText("Close", "Закрыть"))) { keep_open = false; } + // Quit (issue #21): the same clean exit as closing the window, so a controller player need not + // force-close the game. Asks once more before quitting; the game's own save is untouched. + static bool quit_armed = false; + ImGui::SameLine(); + if (ImGui::Button(quit_armed ? BbSettings::MenuText("Press again to quit", "Нажмите ещё раз") + : BbSettings::MenuText("Quit game", "Выйти из игры"))) { + if (quit_armed) { + SDL_Event quit{}; + quit.type = SDL_EVENT_QUIT; + SDL_PushEvent(&quit); + } + quit_armed = !quit_armed; + } + if (quit_armed && !ImGui::IsItemHovered() && !ImGui::IsItemFocused()) { + quit_armed = false; + } ImGui::SameLine(); ImGui::TextDisabled("%s", BbSettings::MenuText("Settings are saved to bbport.ini", "Настройки сохраняются в bbport.ini")); @@ -516,6 +877,24 @@ void Menu() { } void FpsCounter() { + // What it shows (the menu's checkboxes under the counter); nothing chosen: no window. + const auto& s = BbSettings::Get(); + char text[96]; + int n = 0; + if (s.show_fps_rate) { + n += std::snprintf(text + n, sizeof(text) - n, "%.0f FPS", + frame_ms_avg > 0.0f ? 1000.0f / frame_ms_avg : 0.0f); + } + if (s.show_fps_frame_time) { + n += std::snprintf(text + n, sizeof(text) - n, "%s%.1f ms", n ? " " : "", frame_ms_avg); + } + if (s.show_fps_upscaler) { + n += std::snprintf(text + n, sizeof(text) - n, "%s%s", n ? " " : "", + BbSettings::UpscalerName(s.upscaler)); + } + if (n == 0) { + return; + } const ImGuiViewport* viewport = ImGui::GetMainViewport(); const float pad = 12.0f * base_scale; ImGui::SetNextWindowPos( @@ -526,9 +905,7 @@ void FpsCounter() { ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoFocusOnAppearing); - const auto& s = BbSettings::Get(); - ImGui::Text("%.0f FPS %.1f ms %s", frame_ms_avg > 0.0f ? 1000.0f / frame_ms_avg : 0.0f, - frame_ms_avg, BbSettings::UpscalerName(s.upscaler)); + ImGui::TextUnformatted(text); ImGui::End(); } @@ -587,6 +964,9 @@ void Init(const Vulkan::Instance& instance, vk::Format format, u32 image_count) style.GrabRounding = 4.0f; style.Colors[ImGuiCol_WindowBg].w = 0.92f; + // No GlyphRanges: the Vulkan backend has ImGuiBackendFlags_RendererHasTextures (ImGui 1.92), so + // the atlas loads each glyph of the font when first drawn (Cyrillic, ñ ç ü ß ą ł ş ğ ı, —). + // Ranges would only limit that. ImFontConfig font_config; font_config.FontDataOwnedByAtlas = false; io.Fonts->AddFontFromMemoryTTF(const_cast(bb_font_ttf), diff --git a/gpu/shim/bbport_platform.cpp b/gpu/shim/bbport_platform.cpp index 8f2e888..52f794d 100644 --- a/gpu/shim/bbport_platform.cpp +++ b/gpu/shim/bbport_platform.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +37,25 @@ bool GetUsage(bool thread, Usage& out) { return true; } +bool GetMemory(Memory& out) { + PROCESS_MEMORY_COUNTERS_EX counters{}; + MEMORYSTATUSEX status{}; + status.dwLength = sizeof(status); + if (!GetProcessMemoryInfo(GetCurrentProcess(), + reinterpret_cast(&counters), + sizeof(counters)) || + !GlobalMemoryStatusEx(&status)) { + return false; + } + out.private_bytes = counters.PrivateUsage; + out.working_set = counters.WorkingSetSize; + out.peak_working_set = counters.PeakWorkingSetSize; + out.free_phys = status.ullAvailPhys; + out.total_phys = status.ullTotalPhys; + out.commit_left = status.ullAvailPageFile; + return true; +} + std::uint32_t CurrentThreadId() { return static_cast(GetCurrentThreadId()); } @@ -98,5 +118,30 @@ void MakeBackground() { SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_IDLE); } +// The game runs dozens of threads at normal priority (cloth, character, Havok, job pools); on +// 12 or fewer hardware threads Windows gives a ready thread of equal priority a core only when +// another one's quantum ends (tens of ms), so a render-chain thread queued behind them delays +// the frame. Its idle spins are bounded (BB_GPU_SPIN_US, BB_PIPE_SPIN_US, recording: 200 us) +// before it sleeps, so the higher priority takes no lasting time from the game's threads. +namespace { +bool Raising() { + static const bool enabled = [] { + const char* env = std::getenv("BB_GPU_THREAD_PRIORITY"); + return !(env && env[0] == '0'); + }(); + return enabled; +} +} // namespace + +void MakeCritical(bool bulk) { + if (Raising() && (!bulk || Available() >= 16)) { + SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_ABOVE_NORMAL); + } +} + +unsigned CapSpinUs(unsigned wanted) { + return Raising() && Available() < 16 ? std::min(wanted, 50u) : wanted; +} + } // namespace BbThreads #endif diff --git a/gpu/shim/bbport_platform.h b/gpu/shim/bbport_platform.h index 2a1ad78..cd531da 100644 --- a/gpu/shim/bbport_platform.h +++ b/gpu/shim/bbport_platform.h @@ -18,6 +18,12 @@ namespace BbPlatform { +/// RAM of the game and of the PC (bytes). +struct Memory { + std::uint64_t private_bytes = 0, working_set = 0, peak_working_set = 0; + std::uint64_t free_phys = 0, total_phys = 0, commit_left = 0; +}; + struct Usage { std::uint64_t user_us = 0, sys_us = 0, invol_switches = 0, vol_switches = 0, minor_faults = 0; }; @@ -26,6 +32,7 @@ struct Usage { /// CPU time and scheduling counters of the calling thread (thread = true) or of the process. /// Windows has no per-thread context switch or fault counters: those stay 0 for threads. bool GetUsage(bool thread, Usage& out); +bool GetMemory(Memory& out); /// OS id of the calling thread. std::uint32_t CurrentThreadId(); /// A CPU clock of the calling thread that other threads can read (-1: unavailable): a @@ -38,6 +45,10 @@ void DescribeAddress(const void* address, char* out, std::size_t size); std::size_t CaptureStack(void** frames, std::size_t count); bool ReadProcessMemory(const void* source, void* destination, std::size_t size); #else +inline bool GetMemory(Memory&) { + return false; +} + inline bool GetUsage(bool thread, Usage& out) { rusage usage{}; if (getrusage(thread ? RUSAGE_THREAD : RUSAGE_SELF, &usage) != 0) { diff --git a/gpu/shim/bbport_sampler.cpp b/gpu/shim/bbport_sampler.cpp new file mode 100644 index 0000000..a32fa8f --- /dev/null +++ b/gpu/shim/bbport_sampler.cpp @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: BB_SAMPLE_THREADS= - where those threads run, for loading-time work (Windows; the +// Linux runtime has BB_SAMPLE_THREAD in runtime_kernel.c). Names are comma-separated prefixes of +// thread descriptions (guest threads carry theirs; "main" is the loader thread the game's main loop +// runs on), and "load" is main, GXRenderThread, the game's Core.Res.* file loaders and the engine's +// GPU command and draw recording threads. +// About every 2 ms each is suspended, its instruction pointer read and resumed; samples in host +// code are kept with the first return address into the game on the stack. Every 5 s, per thread: +// the share in game code, in each host module (waiting shows as ntdll/KernelBase), and the +// hottest sites. +#include "bbport_sampler.h" + +#ifdef _WIN32 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "bbport_platform.h" + +#ifndef CREATE_WAITABLE_TIMER_HIGH_RESOLUTION +#define CREATE_WAITABLE_TIMER_HIGH_RESOLUTION 0x00000002 +#endif + +namespace BbSampler { +namespace { +constexpr unsigned long long kGuestLo = 0x800000000ull, kGuestHi = 0x810000000ull; + +/// A sample: the instruction (guest offset when in game code) and, in host code, the first guest +/// return address on the stack (offset; 0 when none). +using Site = std::pair; + +struct Target { + HANDLE handle = nullptr; + std::string name; + unsigned long long samples = 0; + std::map sites; +}; + +std::vector Wanted() { + std::vector names; + const char* env = std::getenv("BB_SAMPLE_THREADS"); + std::string list = env ? env : ""; + if (list == "load") list = "main,GXRenderThread,Core.Res.,shadPS4:GpuCommandProcessor,bb:DrawRec,bb:VkRecorder"; + size_t start = 0; + for (;;) { + const size_t comma = list.find(',', start); + std::string name = list.substr(start, comma == std::string::npos ? std::string::npos : comma - start); + if (!name.empty()) names.push_back(name); + if (comma == std::string::npos) break; + start = comma + 1; + } + return names; +} + +std::string Describe(HANDLE thread) { + PWSTR wide = nullptr; + std::string name; + if (SUCCEEDED(GetThreadDescription(thread, &wide)) && wide) { + char text[128] = {}; + WideCharToMultiByte(CP_UTF8, 0, wide, -1, text, sizeof(text) - 1, nullptr, nullptr); + name = text; + LocalFree(wide); + } + return name; +} + +constexpr DWORD kAccess = THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_QUERY_LIMITED_INFORMATION; + +// The process's threads whose description starts with one of `wanted`. +void Refresh(const std::vector& wanted, std::map& targets) { + HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); + if (snapshot == INVALID_HANDLE_VALUE) return; + const DWORD pid = GetCurrentProcessId(), self = GetCurrentThreadId(); + THREADENTRY32 entry{}; + entry.dwSize = sizeof(entry); + std::vector seen; + for (BOOL ok = Thread32First(snapshot, &entry); ok; ok = Thread32Next(snapshot, &entry)) { + if (entry.th32OwnerProcessID != pid || entry.th32ThreadID == self) continue; + seen.push_back(entry.th32ThreadID); + HANDLE thread = OpenThread(kAccess, FALSE, entry.th32ThreadID); + if (!thread) continue; + bool want = false; + if (!targets.count(entry.th32ThreadID)) { + const std::string name = Describe(thread); + for (const auto& w : wanted) want |= !name.empty() && name.rfind(w, 0) == 0; + if (want) targets[entry.th32ThreadID] = Target{thread, name}; + } + if (!want) CloseHandle(thread); + } + CloseHandle(snapshot); + for (auto it = targets.begin(); it != targets.end();) { // threads that ended + if (std::find(seen.begin(), seen.end(), it->first) == seen.end()) { + CloseHandle(it->second.handle); + it = targets.erase(it); + } else { + ++it; + } + } +} + +/// bbport: the engine's own code range: a sample in a system DLL (a wait, a protection change) +/// on an engine thread is kept with the first return address into it, the engine function that +/// called the system (marked with kHostCaller). +constexpr unsigned long long kHostCaller = 1ull << 63; +std::pair EngineCode() { + static const auto range = [] { + const auto base = reinterpret_cast(GetModuleHandleW(nullptr)); + const auto* dos = reinterpret_cast(base); + const auto* nt = reinterpret_cast(base + dos->e_lfanew); + const unsigned long long start = base + nt->OptionalHeader.BaseOfCode; + return std::pair{start, start + nt->OptionalHeader.SizeOfCode}; + }(); + return range; +} + +bool Sample(Target& t, Site* site) { + if (SuspendThread(t.handle) == static_cast(-1)) return false; + CONTEXT context{}; + context.ContextFlags = CONTEXT_CONTROL; + unsigned long long rip = 0, caller = 0; + if (GetThreadContext(t.handle, &context)) { + rip = context.Rip; + if (rip < kGuestLo || rip >= kGuestHi) { + // Read while the thread is suspended: its stack can't change under us. + unsigned long long stack[192] = {}; + if (BbPlatform::ReadProcessMemory(reinterpret_cast(context.Rsp), stack, sizeof(stack))) { + for (const auto value : stack) { + if (value >= kGuestLo && value < kGuestHi) { + caller = value - kGuestLo; + break; + } + } + const auto [code, code_end] = EngineCode(); + if (!caller && (rip < code || rip >= code_end)) { + for (const auto value : stack) { + if (value >= code && value < code_end) { + caller = value | kHostCaller; + break; + } + } + } + } + } + } + ResumeThread(t.handle); + if (!rip) return false; + *site = rip >= kGuestLo && rip < kGuestHi ? Site{rip - kGuestLo, ~0ull} : Site{rip, caller}; + return true; +} + +std::string Module(unsigned long long address, bool with_offset) { + char text[512]; + BbPlatform::DescribeAddress(reinterpret_cast(address), text, sizeof(text)); + std::string s = text; + const size_t slash = s.find_last_of("\\/"); + if (slash != std::string::npos) s = s.substr(slash + 1); + if (!with_offset) { + const size_t plus = s.find('+'); + if (plus != std::string::npos) s = s.substr(0, plus); + } + return s; +} + +void Report(std::map& targets) { + // BB_SAMPLE_TOP=N: the N hottest sites instead of 8 (a flat profile, e.g. the draw recording + // thread, symbolized afterwards from the executable's debug info). + static const size_t top = [] { + const char* env = std::getenv("BB_SAMPLE_TOP"); + return env ? std::max(1, std::strtoull(env, nullptr, 10)) : size_t{8}; + }(); + for (auto& [id, t] : targets) { + if (!t.samples) continue; + std::map modules; + unsigned long long guest = 0; + for (const auto& [site, count] : t.sites) { + if (site.second == ~0ull) guest += count; + else modules[Module(site.first, false)] += count; + } + std::vector> hot(t.sites.begin(), t.sites.end()); + std::sort(hot.begin(), hot.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); + std::vector> mods(modules.begin(), modules.end()); + std::sort(mods.begin(), mods.end(), [](const auto& a, const auto& b) { return a.second > b.second; }); + std::printf("Samples %s (%llu): game code %.0f%%", t.name.c_str(), t.samples, 100.0 * guest / t.samples); + for (size_t i = 0; i < mods.size() && i < 5; ++i) + std::printf(", %s %.0f%%", mods[i].first.c_str(), 100.0 * mods[i].second / t.samples); + std::printf("; hottest:"); + for (size_t i = 0; i < hot.size() && i < top; ++i) { + const auto& [site, count] = hot[i]; + const double share = 100.0 * count / t.samples; + const int digits = top > 8 ? 2 : 0; // long lists are mostly well under 1% + if (site.second == ~0ull) + std::printf(" +%#llx %.*f%%", site.first, digits, share); + else if (site.second & kHostCaller) + std::printf(" %s (from %s) %.*f%%", Module(site.first, true).c_str(), + Module(site.second & ~kHostCaller, true).c_str(), digits, share); + else + std::printf(" %s (from +%#llx) %.*f%%", Module(site.first, true).c_str(), site.second, + digits, share); + } + std::printf("\n"); + t.samples = 0; + t.sites.clear(); + } + std::fflush(stdout); +} + +void Run(std::vector wanted) { + std::map targets; + // A high-resolution timer: ~2 ms waits without changing the system timer resolution. + HANDLE timer = CreateWaitableTimerExW(nullptr, nullptr, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS); + auto last_refresh = std::chrono::steady_clock::now() - std::chrono::seconds(10); + auto last_report = std::chrono::steady_clock::now(); + std::printf("Sampler: BB_SAMPLE_THREADS=%s, about every 2 ms, reported every 5 s\n", std::getenv("BB_SAMPLE_THREADS")); + for (;;) { + const auto now = std::chrono::steady_clock::now(); + if (now - last_refresh >= std::chrono::seconds(2)) { + Refresh(wanted, targets); + last_refresh = now; + } + for (auto& [id, t] : targets) { + Site site; + if (Sample(t, &site)) { + ++t.samples; + ++t.sites[site]; + } + } + if (now - last_report >= std::chrono::seconds(5)) { + Report(targets); + last_report = now; + } + if (timer) { + LARGE_INTEGER due; + due.QuadPart = -20000; // 2 ms, relative, in 100 ns units + if (SetWaitableTimer(timer, &due, 0, nullptr, nullptr, FALSE)) WaitForSingleObject(timer, INFINITE); + } else { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + } +} +} // namespace + +void Start() { + static bool started = false; + if (started) return; + auto wanted = Wanted(); + if (wanted.empty()) return; + started = true; + std::thread(Run, std::move(wanted)).detach(); +} +} // namespace BbSampler +#else +namespace BbSampler { +void Start() {} +} // namespace BbSampler +#endif diff --git a/gpu/shim/bbport_sampler.h b/gpu/shim/bbport_sampler.h new file mode 100644 index 0000000..afaaaa1 --- /dev/null +++ b/gpu/shim/bbport_sampler.h @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// bbport: BB_SAMPLE_THREADS - where chosen threads spend their time, sampled. See bbport_sampler.cpp. +#pragma once + +namespace BbSampler { +/// Starts the sampler thread when BB_SAMPLE_THREADS is set (Windows); nothing otherwise. +void Start(); +} // namespace BbSampler diff --git a/gpu/shim/bbport_sections.h b/gpu/shim/bbport_sections.h index f88cc52..5563f99 100644 --- a/gpu/shim/bbport_sections.h +++ b/gpu/shim/bbport_sections.h @@ -3,6 +3,7 @@ // inclusive (nested parts count in their parent too), printed with the draw pipe statistics. #pragma once +#include #include #include #include @@ -34,6 +35,28 @@ enum Id : std::uint32_t { EnsureResident, ObtainStream, ///< ObtainBuffer's copy of a small read-only buffer into the stream buffer ObtainVram, ///< ObtainBuffer's VRAM path (SynchronizeMemory) + TexSet, ///< BindTexturesFromSet: the memoized set, lookup to descriptors + TexSetCheck, ///< its check that every image of the set is still current + TexSetApply, ///< its per-image bookkeeping and layout transitions + TexFull, ///< BindTextures when the set did not apply: the full lookup + Samplers, ///< BindSamplers + VramImage, ///< SynchronizeMemoryFromImage (texel buffers over image memory) + VramWritten, ///< ObtainBuffer's bookkeeping for a GPU-written buffer + TexFullEmpty, ///< BindTextures for a stage without images + RefreshHash, ///< RefreshImage: guest memory hashes + RefreshObtain, ///< RefreshImage: ObtainBufferForImage (the buffer side of the source) + RefreshDetile, ///< RefreshImage: DetileImage + RefreshUpload, ///< RefreshImage: UploadImage (recording the copy) + Upscaler, ///< TemporalUpscaler::Run: the upscaler's passes + UpscalerEval, ///< its FSR or DLSS call + HleBatch, ///< copy shader HLE: reading the ranges, batching and grouping them + HleObtain, ///< copy shader HLE: ObtainBuffer for the batches' sources and destinations + HleCopy, ///< copy shader HLE: recording the copies (MultiCopy or CopyBuffer) + VramTrack, ///< SynchronizeMemory: the memory tracker's walk over the range (with uploads) + VramUpload, ///< SynchronizeMemory: staging and recording the uploads it found + Protect, ///< page protection changes made on this thread (VirtualProtect) + WaitCopies, ///< Scheduler::WaitHostCopies: host copies not done yet + WaitSignals, ///< Scheduler::WaitDeferredSignals: fences deferred to the recording threads Count, }; inline constexpr const char* Names[Count] = { @@ -41,29 +64,95 @@ inline constexpr const char* Names[Count] = { "buffers", "textures", "begin render", "vertex", "index", "emit vertex", "dynamic", "dispatch", "HLE shader", "ObtainBuffer", "flush", "motion", "descriptors", "kick", "preupload", - "EnsureResident", "stream copy", "VRAM path", + "EnsureResident", "stream copy", "VRAM path", "tex set", "tex set check", "tex set apply", + "tex full", "samplers", "VRAM image sync", "VRAM written", "tex full empty", + "refresh hash", "refresh obtain", "refresh detile", "refresh upload", "upscaler", + "upscaler eval", "HLE batch", "HLE obtain", "HLE copy", "VRAM track", "VRAM upload", + "protect", "wait copies", "wait fences", }; inline std::array, Count> cycles{}; +/// BB_SECTIONS, read once at startup (a plain global: no initialization guard per scope). +inline const bool enabled = [] { + const char* env = std::getenv("BB_SECTIONS"); + return env && env[0] == '1'; +}(); inline bool Enabled() { - static const bool on = [] { - const char* env = std::getenv("BB_SECTIONS"); - return env && env[0] == '1'; - }(); - return on; + return enabled; } /// Set on the draw recording thread (DrawPipe::Run): only its time is counted. inline thread_local bool recording_thread = false; +/// Off (the default) a scope costs one predictable branch on `enabled` and one on `start` at +/// its end: no thread-local access, no rdtsc, no atomics. struct Scope { - explicit Scope(Id id_) : id{id_}, start{Enabled() && recording_thread ? __rdtsc() : 0} {} + explicit Scope(Id id_) : id{id_} { + if (enabled) [[unlikely]] { + start = recording_thread ? __rdtsc() : 0; + } + } ~Scope() { - if (start) { + End(); + } + /// Ends the scope before its block does (counted once). + void End() { + if (start) [[unlikely]] { cycles[id].fetch_add(__rdtsc() - start, std::memory_order_relaxed); + start = 0; } } Id id; - std::uint64_t start; + std::uint64_t start = 0; +}; +/// Where a section's time goes, per key (a shader's program hash): open addressing over 1024 +/// slots, written by the draw recording thread only; read and cleared with the statistics +/// (racy, diagnostics only). +struct ByKey { + struct Entry { + std::uint64_t key = 0, cycles = 0, calls = 0; + }; + std::array entries{}; + void Add(std::uint64_t key, std::uint64_t spent) { + key |= 1; // 0 marks a free slot + for (std::size_t i = 0; i < entries.size(); ++i) { + auto& e = entries[(key + i) % entries.size()]; + if (e.key == key || e.key == 0) { + e.key = key; + e.cycles += spent; + ++e.calls; + return; + } + } + } + /// The `n` keys with the most time, most first; the table starts over. + template + void Top(std::size_t n, Print&& print) { + std::array copy = entries; + entries = {}; + std::sort(copy.begin(), copy.end(), + [](const Entry& a, const Entry& b) { return a.cycles > b.cycles; }); + for (std::size_t i = 0; i < n && copy[i].key; ++i) { + print(copy[i]); + } + } +}; +inline ByKey tex_full_by_shader, dispatch_by_shader; + +/// Times its block into `table` under `key` (BB_SECTIONS=1, draw recording thread). +struct KeyScope { + KeyScope(ByKey& table_, std::uint64_t key_) : table{table_}, key{key_} { + if (enabled) [[unlikely]] { + start = recording_thread ? __rdtsc() : 0; + } + } + ~KeyScope() { + if (start) [[unlikely]] { + table.Add(key, __rdtsc() - start); + } + } + ByKey& table; + std::uint64_t key; + std::uint64_t start = 0; }; } // namespace BbSections diff --git a/gpu/shim/bbport_settings.cpp b/gpu/shim/bbport_settings.cpp index cf4fbd8..bb303b4 100644 --- a/gpu/shim/bbport_settings.cpp +++ b/gpu/shim/bbport_settings.cpp @@ -6,9 +6,11 @@ #endif // SPDX-License-Identifier: GPL-2.0-or-later #include "bbport_settings.h" +#include "bbport_menu_i18n.h" #include "input/ini_bindings.h" #include +#include #include #include #include @@ -31,13 +33,62 @@ float Clamp(float v, float lo, float hi) { return std::clamp(v, lo, hi); } +// cheat_vials / cheat_bullets: off, infinite or an amount 1..CheatMaxAmount; anything else is off. +int CheatValue(const std::string& key, const std::string& value) { + if (value == "off" || value == "0") { + return CheatOff; + } + if (value == "infinite") { + return CheatInfinite; + } + char* end = nullptr; + const long amount = std::strtol(value.c_str(), &end, 10); + if (!value.empty() && end && *end == '\0' && amount >= 1 && amount <= CheatMaxAmount) { + return int(amount); + } + std::printf("Settings: unknown %s '%s' (expected off, infinite or 1-%d); off\n", key.c_str(), + value.c_str(), CheatMaxAmount); + return CheatOff; +} + +std::string CheatText(int value) { + return value == CheatInfinite ? "infinite" + : value >= 1 && value <= CheatMaxAmount ? std::to_string(value) + : "off"; +} + +// cheat_gun_range: off or one of CheatGunRanges (percent); cheat_echo_multiplier: one of +// CheatEchoMultipliers. Anything else is the game's own (off / 1). +template +int CheatChoice(const std::string& key, const std::string& value, const int (&choices)[N], const char* expected) { + if (value == "off") { + return choices[0]; + } + char* end = nullptr; + const long v = std::strtol(value.c_str(), &end, 10); + if (!value.empty() && end && *end == '\0' && std::find(choices, choices + N, int(v)) != choices + N) { + return int(v); + } + std::printf("Settings: unknown %s '%s' (expected %s); %d\n", key.c_str(), value.c_str(), expected, choices[0]); + return choices[0]; +} + void Set(Values& v, const std::string& key, const std::string& value) { const float f = float(std::atof(value.c_str())); const int i = std::atoi(value.c_str()); if (key == "menu_language") { - if (value == "en") v.menu_language = MenuLanguage::English; - else if (value == "ru") v.menu_language = MenuLanguage::Russian; - else std::printf("Settings: unknown menu_language '%s' (expected en or ru)\n", value.c_str()); + bool known = false; + for (int l = 0; l < MenuLanguageCount; ++l) { + if (value == MenuLanguageCodes[l]) { + v.menu_language = MenuLanguage(l); + known = true; + } + } + if (!known) { + std::printf("Settings: unknown menu_language '%s' (expected en, ru, es, pt_BR, fr, de, it, pl " + "or tr)\n", + value.c_str()); + } } else if (key == "upscaler") { for (int u = 0; u < UpscalerCount; ++u) { if (value == UpscalerName(u)) { @@ -66,6 +117,14 @@ void Set(Values& v, const std::string& key, const std::string& value) { v.debug_view = std::clamp(i, 0, DebugViewCount - 1); } else if (key == "show_fps") { v.show_fps = i != 0; + } else if (key == "show_fps_rate") { + v.show_fps_rate = i != 0; + } else if (key == "show_fps_frame_time") { + v.show_fps_frame_time = i != 0; + } else if (key == "show_fps_upscaler") { + v.show_fps_upscaler = i != 0; + } else if (key == "hide_cursor") { + v.hide_cursor = i != 0; } else if (key == "menu_pos") { float x = -1.0f, y = -1.0f; if (std::sscanf(value.c_str(), "%f,%f", &x, &y) == 2 && x >= 0.0f && x <= 1.0f && y >= 0.0f && @@ -91,6 +150,30 @@ void Set(Values& v, const std::string& key, const std::string& value) { v.mouse_invert_y=i!=0; } else if (key == "mouse_auto_rotation") { v.mouse_auto_rotation=i!=0; + } else if (key == "button_prompts") { + int mode = -1; + for (int m = 0; m < PromptsCount; ++m) { + if (value == ButtonPromptsNames[m]) { + mode = m; + } + } + if (mode < 0) { + std::printf("Settings: unknown button_prompts '%s' (expected auto, keyboard or controller); auto\n", + value.c_str()); + } + v.button_prompts = mode < 0 ? PromptsAuto : mode; + } else if (key == "pad_icons") { + int mode = -1; + for (int m = 0; m < PadIconsCount; ++m) { + if (value == PadIconsNames[m]) { + mode = m; + } + } + if (mode < 0) { + std::printf("Settings: unknown pad_icons '%s' (expected auto, playstation or xbox); auto\n", + value.c_str()); + } + v.pad_icons = mode < 0 ? PadIconsAuto : mode; } else if (key == "mouse_sensitivity") { v.mouse_sensitivity=std::clamp(i,0,10); } else if (key == "camera_controls") { @@ -109,22 +192,77 @@ void Set(Values& v, const std::string& key, const std::string& value) { v.graphics_controls=i!=0; } else if (key == "graphics_vignette") { v.graphics_vignette=i!=0; + // Wide limits: the menu's sliders show the useful range, Ctrl+click types any value up to these. } else if (key == "graphics_ao_strength") { - v.graphics_ao_strength=Clamp(std::isfinite(f)?f:1.0f,0.0f,2.0f); + v.graphics_ao_strength=Clamp(std::isfinite(f)?f:1.0f,0.0f,5.0f); } else if (key == "graphics_shadow_scale") { - v.graphics_shadow_scale=Clamp(std::isfinite(f)?f:1.0f,1.0f,3.0f); + v.graphics_shadow_scale=Clamp(std::isfinite(f)?f:1.0f,0.25f,8.0f); } else if (key == "graphics_bloom") { - v.graphics_bloom=Clamp(std::isfinite(f)?f:1.0f,0.0f,1.0f); + v.graphics_bloom=Clamp(std::isfinite(f)?f:1.0f,0.0f,10.0f); } else if (key == "graphics_saturation") { - v.graphics_saturation=Clamp(std::isfinite(f)?f:1.0f,0.0f,2.0f); + v.graphics_saturation=Clamp(std::isfinite(f)?f:1.0f,0.0f,5.0f); + } else if (key == "graphics_fog") { + v.graphics_fog=Clamp(std::isfinite(f)?f:1.0f,0.0f,5.0f); + } else if (key == "graphics_brightness") { + v.graphics_brightness=Clamp(std::isfinite(f)?f:1.0f,0.0f,5.0f); + } else if (key == "graphics_contrast") { + v.graphics_contrast=Clamp(std::isfinite(f)?f:1.0f,0.5f,1.5f); + } else if (key == "graphics_hue") { + v.graphics_hue=Clamp(std::isfinite(f)?f:0.0f,-180.0f,180.0f); } else if (key == "model_lod") { v.model_lod = std::clamp(i, -2, 2); } else if (key == "fullscreen") { v.fullscreen = i != 0; + } else if (key == "hdr") { + v.hdr = i != 0; + } else if (key == "hdr_paper_white") { + v.hdr_paper_white = Clamp(std::isfinite(f) ? f : HdrPaperWhiteDefault, HdrPaperWhiteMin, + HdrPaperWhiteMax); + } else if (key == "hdr_peak") { + v.hdr_peak = Clamp(std::isfinite(f) ? f : HdrPeakDefault, HdrPeakMin, HdrPeakMax); + } else if (key == "hdr_boost") { + v.hdr_boost = Clamp(std::isfinite(f) ? f : HdrBoostDefault, 0.0f, 1.0f); + } else if (key == "hdr_ui_brightness") { + v.hdr_ui_brightness = Clamp(std::isfinite(f) ? f : 1.0f, HdrUiBrightnessMin, HdrUiBrightnessMax); + } else if (key == "hdr_ui_color") { + v.hdr_ui_color = Clamp(std::isfinite(f) ? f : HdrUiColorDefault, 0.0f, 1.0f); + } else if (key == "hdr_mode") { + int mode = -1; + for (int m = 0; m < HdrModeCount; ++m) { + if (value == HdrModeNames[m]) { + mode = m; + } + } + if (mode < 0) { + std::printf("Settings: unknown hdr_mode '%s' (expected native or expand); native\n", + value.c_str()); + } + v.hdr_mode = mode < 0 ? HdrModeNative : mode; } else if (key == "live_resolution") { v.live_resolution = value == "auto" ? -1 : std::clamp(i, 0, 1); + } else if (key == "cheat_vials") { + v.cheat_vials = CheatValue(key, value); + } else if (key == "cheat_bullets") { + v.cheat_bullets = CheatValue(key, value); + } else if (key == "cheat_stamina") { + v.cheat_stamina = i != 0; + } else if (key == "cheat_stealth") { + v.cheat_stealth = i != 0; + } else if (key == "cheat_keep_echoes") { + v.cheat_keep_echoes = i != 0; + } else if (key == "cheat_no_durability") { + v.cheat_no_durability = i != 0; + } else if (key == "cheat_consumables") { + v.cheat_consumables = i != 0; + } else if (key == "cheat_gun_range") { + v.cheat_gun_range = CheatChoice(key, value, CheatGunRanges, "off, 150, 200 or 300"); + } else if (key == "cheat_echo_multiplier") { + v.cheat_echo_multiplier = CheatChoice(key, value, CheatEchoMultipliers, "1, 2, 5 or 10"); } else if (key == "output_res") { - for (int r = 0; r < OutputCount; ++r) { + if (value == "auto") { + v.output_res = OutputAuto; + } + for (int r = 0; r < OutputAuto; ++r) { if (value == std::to_string(OutputWidths[r]) + "x" + std::to_string(OutputHeights[r])) { v.output_res = r; } @@ -138,15 +276,65 @@ void Set(Values& v, const std::string& key, const std::string& value) { } } +// The menu's language for the launcher's code (BB_MENU_LANGUAGE: en, ru, pt_BR...) or a locale +// (ru_RU.UTF-8, de_DE...): its first two letters; Portuguese only as pt_BR. Else English. +MenuLanguage LanguageOfLocale(const char* lang) { + if (!lang || !lang[0] || !lang[1]) { + return MenuLanguage::English; + } + const auto lower = [lang](int i) { return char(std::tolower(static_cast(lang[i]))); }; + if (lower(0) == 'p' && lower(1) == 't') { + return (lang[2] == '_' || lang[2] == '-') && lower(3) == 'b' && lower(4) == 'r' + ? MenuLanguage::PortugueseBrazil + : MenuLanguage::English; + } + for (int l = 0; l < MenuLanguageCount; ++l) { + const char* code = MenuLanguageCodes[l]; + if (code[2] == '\0' && lower(0) == code[0] && lower(1) == code[1]) { + return MenuLanguage(l); + } + } + return MenuLanguage::English; +} + } // namespace +// The display's size for output_res = auto: BB_DISPLAY_SIZE ("WxH", from run_windows.py), even +// (the game has half-resolution buffers); 1920x1080 without it. +static std::pair DisplaySize() { + static const std::pair size = [] { + int w = 0, h = 0; + const char* env = std::getenv("BB_DISPLAY_SIZE"); + if (env && std::sscanf(env, "%dx%d", &w, &h) == 2 && w >= 640 && h >= 360 && w <= 16384 && + h <= 8640) { + return std::pair{w & ~1, h & ~1}; + } + return std::pair{1920, 1080}; + }(); + return size; +} + +int OutputWidth(int index) { + index = std::clamp(index, 0, OutputCount - 1); + return index == OutputAuto ? DisplaySize().first : OutputWidths[index]; +} + +int OutputHeight(int index) { + index = std::clamp(index, 0, OutputCount - 1); + return index == OutputAuto ? DisplaySize().second : OutputHeights[index]; +} + Values& Get() { static Values values; return values; } const char* MenuText(const char* english, const char* russian) { - return Get().menu_language == MenuLanguage::Russian ? russian : english; + switch (const MenuLanguage language = Get().menu_language) { + case MenuLanguage::English: return english; + case MenuLanguage::Russian: return russian; + default: return MenuTranslation(language, english); + } } void Load() { @@ -166,9 +354,7 @@ void Load() { } } } - v.menu_language = lang && (lang[0] == 'r' || lang[0] == 'R') && (lang[1] == 'u' || lang[1] == 'U') - ? MenuLanguage::Russian - : MenuLanguage::English; + v.menu_language = LanguageOfLocale(lang); } if (FILE* file = std::fopen(Path(), "r")) { char line[256]; @@ -196,6 +382,9 @@ void Load() { {"BB_REACTIVE", "reactive"}, {"BB_REACTIVE_SCALE", "reactive_scale"}, {"BB_REACTIVE_THRESHOLD", "reactive_threshold"}, {"BB_REACTIVE_MAX", "reactive_max"}, {"BB_UPSCALE_PRESET", "preset"}, {"BB_OBJECT_MOTION", "object_motion"}, + {"BB_HDR", "hdr"}, {"BB_HDR_PAPER_WHITE", "hdr_paper_white"}, + {"BB_HDR_PEAK", "hdr_peak"}, {"BB_HDR_BOOST", "hdr_boost"}, + {"BB_HDR_MODE", "hdr_mode"}, }; for (const auto& [env, key] : env_keys) { if (const char* value = std::getenv(env)) { @@ -211,6 +400,7 @@ void Load() { v.startup_model_lod = v.model_lod; v.startup_output_res = v.output_res; v.startup_live_resolution = v.live_resolution; + v.startup_hdr = v.hdr; } void ConfigureUpscalerSupport(bool fsr4, bool fsr411, bool dlss) { @@ -264,7 +454,7 @@ void Save() { return std::string(text); }; const auto flag = [](bool value) { return std::string(value ? "1" : "0"); }; - put("menu_language", v.menu_language == MenuLanguage::Russian ? "ru" : "en"); + put("menu_language", MenuLanguageCodes[int(v.menu_language.load())]); put("upscaler", UpscalerName(v.upscaler)); put("preset", std::to_string(v.preset.load())); put("sharpen", flag(v.sharpen)); @@ -277,6 +467,10 @@ void Save() { put("reactive_max", fixed(v.reactive_max, 2)); put("debug_view", std::to_string(v.debug_view.load())); put("show_fps", flag(v.show_fps)); + put("show_fps_rate", flag(v.show_fps_rate)); + put("show_fps_frame_time", flag(v.show_fps_frame_time)); + put("show_fps_upscaler", flag(v.show_fps_upscaler)); + put("hide_cursor", flag(v.hide_cursor)); put("fsr4_auto_exposure", flag(v.fsr4_auto_exposure)); put("fsr4_invert_jitter", flag(v.fsr4_invert_jitter)); // Read by patches.py at start. @@ -284,12 +478,23 @@ void Save() { put(Effects[e].key, flag(v.effects[e])); } put("fullscreen", flag(v.fullscreen)); + put("hdr", flag(v.hdr)); + put("hdr_paper_white", fixed(v.hdr_paper_white, 0)); + put("hdr_peak", fixed(v.hdr_peak, 0)); + put("hdr_boost", fixed(v.hdr_boost, 2)); + put("hdr_ui_brightness", fixed(v.hdr_ui_brightness, 2)); + put("hdr_ui_color", fixed(v.hdr_ui_color, 2)); + put("hdr_mode", HdrModeNames[std::clamp(v.hdr_mode.load(), 0, HdrModeCount - 1)]); put("graphics_controls",flag(v.graphics_controls)); put("graphics_vignette",flag(v.graphics_vignette)); put("graphics_ao_strength",fixed(v.graphics_ao_strength,2)); put("graphics_shadow_scale",fixed(v.graphics_shadow_scale,2)); put("graphics_bloom",fixed(v.graphics_bloom,2)); put("graphics_saturation",fixed(v.graphics_saturation,2)); + put("graphics_fog",fixed(v.graphics_fog,2)); + put("graphics_brightness",fixed(v.graphics_brightness,2)); + put("graphics_contrast",fixed(v.graphics_contrast,2)); + put("graphics_hue",fixed(v.graphics_hue,0)); put("camera_controls", flag(v.camera_controls)); put("widescreen",flag(v.widescreen)); put("pc_controls",flag(v.pc_controls)); @@ -299,14 +504,26 @@ void Save() { put("mouse_invert_y",flag(v.mouse_invert_y)); put("mouse_auto_rotation",flag(v.mouse_auto_rotation)); put("mouse_sensitivity",std::to_string(v.mouse_sensitivity.load())); + put("button_prompts", ButtonPromptsNames[std::clamp(v.button_prompts.load(), 0, PromptsCount - 1)]); + put("pad_icons", PadIconsNames[std::clamp(v.pad_icons.load(), 0, PadIconsCount - 1)]); put("change_appearance", flag(v.change_appearance)); put("rebirth", flag(v.rebirth)); put("camera_fov_scale", fixed(v.camera_fov_scale, 2)); put("camera_distance_scale", fixed(v.camera_distance_scale, 2)); put("camera_height_scale", fixed(v.camera_height_scale, 2)); put("model_lod", std::to_string(v.model_lod.load())); - put("output_res", std::to_string(OutputWidths[v.output_res]) + "x" + - std::to_string(OutputHeights[v.output_res])); + put("cheat_vials", CheatText(v.cheat_vials)); + put("cheat_bullets", CheatText(v.cheat_bullets)); + put("cheat_stamina", flag(v.cheat_stamina)); + put("cheat_stealth", flag(v.cheat_stealth)); + put("cheat_gun_range", v.cheat_gun_range == 0 ? "off" : std::to_string(v.cheat_gun_range.load())); + put("cheat_keep_echoes", flag(v.cheat_keep_echoes)); + put("cheat_echo_multiplier", std::to_string(v.cheat_echo_multiplier.load())); + put("cheat_no_durability", flag(v.cheat_no_durability)); + put("cheat_consumables", flag(v.cheat_consumables)); + put("output_res", v.output_res == OutputAuto ? std::string("auto") + : std::to_string(OutputWidths[v.output_res]) + "x" + + std::to_string(OutputHeights[v.output_res])); // Read by run.sh at start. put("live_resolution", v.live_resolution < 0 ? "auto" : flag(v.live_resolution != 0)); if (v.menu_x >= 0.0f && v.menu_y >= 0.0f) { diff --git a/gpu/shim/bbport_settings.h b/gpu/shim/bbport_settings.h index c0e8a94..6faf11c 100644 --- a/gpu/shim/bbport_settings.h +++ b/gpu/shim/bbport_settings.h @@ -22,7 +22,14 @@ inline bool IsFrameUpscaler(int upscaler) { } enum Preset : int { NativeAA = 0, Quality, Balanced, Performance, UltraPerformance, PresetCount }; enum DebugView : int { DebugNone = 0, DebugReactive = 1, DebugMotion = 2, DebugViewCount }; -enum class MenuLanguage { English, Russian }; +/// The menu's language, in the Language selector's order (bbport_overlay.cpp). +enum class MenuLanguage { + English, Russian, Spanish, PortugueseBrazil, French, German, Italian, Polish, Turkish +}; +/// menu_language in bbport.ini, by MenuLanguage; the launcher's BB_MENU_LANGUAGE uses the same codes. +inline constexpr const char* MenuLanguageCodes[] = {"en", "ru", "es", "pt_BR", "fr", "de", "it", "pl", "tr"}; +inline constexpr int MenuLanguageCount = int(sizeof(MenuLanguageCodes) / sizeof(MenuLanguageCodes[0])); +static_assert(MenuLanguageCount == int(MenuLanguage::Turkish) + 1); /// Game effects switched by the community patches at start (patches.py EFFECTS): ini key, /// menu label, default (the game's own behaviour). @@ -33,11 +40,13 @@ struct Effect { bool default_on; }; inline constexpr Effect Effects[] = { - {"effect_chromatic_aberration", "Chromatic aberration", "Хроматическая аберрация", true}, - {"effect_dof", "Depth of field (DoF)", "Глубина резкости (DoF)", true}, - {"effect_motion_blur", "Motion blur", "Размытие в движении", true}, + // bbport: chromatic aberration, depth of field, motion blur and the game's own anti-aliasing start + // off - a clearer picture, and the upscalers anti-alias. + {"effect_chromatic_aberration", "Chromatic aberration", "Хроматическая аберрация", false}, + {"effect_dof", "Depth of field (DoF)", "Глубина резкости (DoF)", false}, + {"effect_motion_blur", "Motion blur", "Размытие в движении", false}, {"effect_ssao", "Ambient occlusion (SSAO)", "Затенение SSAO", true}, - {"effect_game_aa", "Game's own anti-aliasing", "Собственное сглаживание игры", true}, + {"effect_game_aa", "Game's own anti-aliasing", "Собственное сглаживание игры", false}, {"effect_dynamic_shadows", "Shadows from dynamic lights", "Тени от динамических источников", true}, {"effect_ssr", "Screen-space reflections (not in original game)", "Отражения SSR (не было в игре)", false}, {"skip_intro", "Skip startup intros", "Пропуск заставок при запуске", false}, @@ -45,11 +54,52 @@ inline constexpr Effect Effects[] = { {"debug_menu", "Debug menu (requires font files)", "Debug menu (нужны файлы шрифтов)", false}, }; inline constexpr int EffectCount = int(sizeof(Effects) / sizeof(Effects[0])); -/// Live output resolutions: the upscaler's output and the UI host targets. -inline constexpr int OutputWidths[] = {1280,1920,2560,3840,2560,3440,3840,3840,5120,5120}; -inline constexpr int OutputHeights[] = {720,1080,1440,2160,1080,1440,1600,1080,1440,2160}; -inline constexpr int OutputCount = 10; +/// Live output resolutions: the upscaler's output and the UI host targets. The last is the +/// display's own size (output_res = auto): BB_DISPLAY_SIZE, which run_windows.py reads from +/// Windows at start, else 1920x1080. Use OutputWidth/OutputHeight, which know it. +inline constexpr int OutputWidths[] = {1280,1920,2560,3840,2560,3440,3840,3840,5120,5120,7680,0}; +inline constexpr int OutputHeights[] = {720,1080,1440,2160,1080,1440,1600,1080,1440,2160,2160,0}; +inline constexpr int OutputCount = 12; +inline constexpr int OutputAuto = 11; inline constexpr int OutputDefault = 1; ///< 1920x1080, the game's own size +static_assert(sizeof(OutputWidths) / sizeof(OutputWidths[0]) == OutputCount && + sizeof(OutputHeights) / sizeof(OutputHeights[0]) == OutputCount); +int OutputWidth(int index); +int OutputHeight(int index); +/// Live cheats (engine/cheats.cpp): cheat_vials / cheat_bullets in bbport.ini are "off", +/// "infinite" or an amount kept in the inventory, 1..CheatMaxAmount. +inline constexpr int CheatOff = 0, CheatInfinite = -1, CheatMaxAmount = 99; +/// cheat_gun_range: "off" (0) or the firearm bullets' range in percent. +inline constexpr int CheatGunRanges[] = {0, 150, 200, 300}; +/// cheat_echo_multiplier: Blood Echoes from kills times this (1: the game's own). +inline constexpr int CheatEchoMultipliers[] = {1, 2, 5, 10}; +/// HDR output (hdr_paper_white / hdr_peak in nits, hdr_boost 0..1): defaults and ranges. +inline constexpr float HdrPaperWhiteDefault = 200.0f, HdrPaperWhiteMin = 80.0f, HdrPaperWhiteMax = 500.0f; +inline constexpr float HdrPeakDefault = 1000.0f, HdrPeakMin = 400.0f, HdrPeakMax = 10000.0f; +inline constexpr float HdrBoostDefault = 0.25f; +/// Native mode: the game's UI at hdr_ui_brightness times paper white, and hdr_ui_color of the way +/// from sRGB's colours (0) to a wide-gamut SDR monitor's (1, DCI-P3). +inline constexpr float HdrUiBrightnessMin = 0.5f, HdrUiBrightnessMax = 2.0f, HdrUiColorDefault = 1.0f; +/// The swapchain's HDR signal (Values::hdr_output). +enum HdrOutput : int { HdrOutputOff = 0, HdrOutputHdr10 = 1, HdrOutputScRgb = 2 }; +/// hdr_mode in bbport.ini (docs/hdr.md). Native: the highlights' brightness from the game's own +/// HDR scene (vk_hdr_native.h), expand where a frame has none (menus, loading, movies). Expand: +/// the SDR picture's highlights stretched towards the peak. +enum HdrMode : int { HdrModeNative = 0, HdrModeExpand = 1, HdrModeCount }; +inline constexpr const char* HdrModeNames[] = {"native", "expand"}; +static_assert(sizeof(HdrModeNames) / sizeof(HdrModeNames[0]) == HdrModeCount); + +/// button_prompts in bbport.ini: the game's button icons drawn as the bound keys while the keyboard +/// or mouse is in use (auto), always (keyboard), or never (controller). engine/key_prompts.h. +enum ButtonPrompts : int { PromptsAuto = 0, PromptsKeyboard = 1, PromptsController = 2, PromptsCount }; +inline constexpr const char* ButtonPromptsNames[] = {"auto", "keyboard", "controller"}; +static_assert(sizeof(ButtonPromptsNames) / sizeof(ButtonPromptsNames[0]) == PromptsCount); + +/// pad_icons in bbport.ini: the game's controller icons as they are (PlayStation's) or as an Xbox +/// controller's buttons; auto follows the gamepad in use. engine/key_prompts.h. +enum PadIcons : int { PadIconsAuto = 0, PadIconsPlayStation = 1, PadIconsXbox = 2, PadIconsCount }; +inline constexpr const char* PadIconsNames[] = {"auto", "playstation", "xbox"}; +static_assert(sizeof(PadIconsNames) / sizeof(PadIconsNames[0]) == PadIconsCount); struct Values { std::atomic menu_language{MenuLanguage::English}; @@ -65,6 +115,12 @@ struct Values { std::atomic reactive_max{0.9f}; std::atomic debug_view{DebugNone}; std::atomic show_fps{false}; + /// What the FPS counter shows: the frame rate, the frame time, the upscaler. + std::atomic show_fps_rate{true}; + std::atomic show_fps_frame_time{true}; + std::atomic show_fps_upscaler{true}; + /// The mouse cursor hidden over the game 2 s after the mouse last moved (window.cpp). + std::atomic hide_cursor{true}; /// The settings menu's position (fraction of the screen), -1 until it is moved. std::atomic menu_x{-1.0f}, menu_y{-1.0f}; // FSR 4 checks (menu): the provider's auto exposure, the jitter sign it is given. @@ -77,14 +133,40 @@ struct Values { std::atomic widescreen{true},pc_controls{true},mouse_camera{true},mouse_menu{true}; std::atomic mouse_invert_x{false},mouse_invert_y{false},mouse_auto_rotation{false}; std::atomic mouse_sensitivity{5}; + std::atomic button_prompts{PromptsAuto}; ///< ButtonPrompts; live + std::atomic pad_icons{PadIconsAuto}; ///< PadIcons; live std::atomic change_appearance{false}, rebirth{false}; // next launch, engine features std::atomic graphics_controls{false}, graphics_vignette{true}; std::atomic graphics_ao_strength{1},graphics_shadow_scale{1},graphics_bloom{1},graphics_saturation{1}; + /// Fog opacity (0..1 of the area's), the tone map's brightness (ColorV) and contrast (Contrust) + /// scales and a hue offset in degrees (ColorH); 1, 1, 1 and 0 leave the area's own. + std::atomic graphics_fog{1},graphics_brightness{1},graphics_contrast{1},graphics_hue{0}; std::atomic camera_fov_scale{1.0f}, camera_distance_scale{1.0f}, camera_height_scale{1.0f}; std::atomic model_lod{0}; ///< -2 highest .. 2 lowest, 0 the game's std::atomic output_res{OutputDefault}; ///< index into OutputWidths + /// CheatOff, CheatInfinite or the amount to keep (1..CheatMaxAmount); live. + std::atomic cheat_vials{CheatOff}, cheat_bullets{CheatOff}; + /// The other cheats (0/1 in bbport.ini); live. + std::atomic cheat_stamina{false}, cheat_stealth{false}, cheat_keep_echoes{false}, + cheat_no_durability{false}, cheat_consumables{false}; + /// One of CheatGunRanges / CheatEchoMultipliers; live. + std::atomic cheat_gun_range{0}, cheat_echo_multiplier{1}; /// Borderless fullscreen window at the desktop size (F11 toggles; BB_FULLSCREEN overrides). std::atomic fullscreen{false}; + /// HDR output (hdr, BB_HDR): the game's SDR picture shown in an HDR swapchain when the + /// display offers one (Windows HDR on). Applied at start (startup_hdr). + std::atomic hdr{false}; + /// Nits of the SDR picture's white, the display's peak, and the highlight expansion (0: SDR + /// white stays at paper white, 1: up to the peak); live (BB_HDR_PAPER_WHITE, BB_HDR_PEAK, + /// BB_HDR_BOOST). + std::atomic hdr_paper_white{HdrPaperWhiteDefault}, hdr_peak{HdrPeakDefault}, + hdr_boost{HdrBoostDefault}; + std::atomic hdr_ui_brightness{1.0f}, hdr_ui_color{HdrUiColorDefault}; + /// HdrMode (hdr_mode, BB_HDR_MODE); live. In native mode boost is the share of the scene's + /// highlight range shown: sqrt(boost) of it in log2 (0.25: half, 1: all of it). + std::atomic hdr_mode{HdrModeNative}; + /// Set by the renderer: the HDR output in use (HdrOutput). + std::atomic hdr_output{HdrOutputOff}; /// Live resolution and preset changes (run.sh): 0 off by default (startup patch, fastest /// on the Steam Deck and older GPUs), -1 auto (strong discrete GPUs), 1 on. On restart. std::atomic live_resolution{0}; @@ -100,10 +182,12 @@ struct Values { int startup_model_lod = 0; int startup_output_res = OutputDefault; int startup_live_resolution = 0; + bool startup_hdr = false; }; Values& Get(); -/// Localized overlay text; the Language selector itself stays in English. +/// Localized overlay text; the Language selector itself stays in English. The other languages +/// look the English text up (bbport_menu_i18n.cpp) and show it when they have no translation. const char* MenuText(const char* english, const char* russian); /// Reads the file, then the environment overrides. Called once at start. diff --git a/gpu/shim/bbport_threads.h b/gpu/shim/bbport_threads.h index 50d0422..8336b8d 100644 --- a/gpu/shim/bbport_threads.h +++ b/gpu/shim/bbport_threads.h @@ -19,7 +19,19 @@ namespace BbThreads { // Windows (bbport_platform.cpp): the process affinity mask; THREAD_PRIORITY_IDLE. unsigned Available(); void MakeBackground(); +/// The serial render chain (GPU command thread, draw recording, Vulkan recording) runs above +/// the game's own threads (THREAD_PRIORITY_ABOVE_NORMAL; BB_GPU_THREAD_PRIORITY=0: normal). +/// `bulk`: threads doing large copies (texture uploads while an area streams in) are raised +/// only with 16 or more hardware threads; with fewer they took the cores the game's own +/// threads needed exactly then. +void MakeCritical(bool bulk = false); +/// An idle spin of a raised thread before it sleeps, in microseconds: `wanted`, at most 50 +/// with fewer than 16 hardware threads (a raised thread spinning keeps a game thread waiting). +unsigned CapSpinUs(unsigned wanted); #else +/// Linux: unchanged (normal priority; raising it needs privileges). +inline void MakeCritical(bool = false) {} +inline unsigned CapSpinUs(unsigned wanted) { return wanted; } /// Hardware threads available to the process. inline unsigned Available() { cpu_set_t set; diff --git a/gpu/shim/bbport_toggles.h b/gpu/shim/bbport_toggles.h index 46a4b7b..e3e3ccb 100644 --- a/gpu/shim/bbport_toggles.h +++ b/gpu/shim/bbport_toggles.h @@ -226,4 +226,51 @@ inline std::atomic gpu_signal_faults{0}; /// Protection changes: calls and pages, those removing write access (TLB shootdowns) apart. inline std::atomic protect_calls{0}, protect_pages{0}, protect_revoke_calls{0}, protect_revoke_pages{0}; +/// bbport (BB_FRAME_STATS): protection changes by what they are for. On the draw recording thread +/// (stage B): pages watched again after an upload, texture tracking, the rest (unwatching). Other +/// threads together; bb:Protect (BB_DEFER_PROTECT) apart. Calls and ns. +enum ProtectKind : unsigned { + ProtectUpload, + ProtectTexture, + ProtectOther, + ProtectDeferred, + ProtectKinds, +}; +inline std::array, ProtectKinds> stage_b_protect_calls{}, + stage_b_protect_ns{}; +inline std::atomic other_protect_calls{0}, other_protect_ns{0}; +/// BB_DEFER_PROTECT: pages whose write protection was deferred, protection changes that applied +/// them (calls, ns), flushes a copy had to make itself (bb:Protect behind), deferred pages whose +/// watch was dropped before they were protected (no system call at all). +inline std::atomic deferred_protect_pages{0}, deferred_protect_calls{0}, + deferred_protect_ns{0}, copy_flushes{0}, deferred_cancelled_pages{0}; +/// Where host copies and deferred fences are waited for (Scheduler::WaitHostCopies, +/// WaitDeferredSignals): waits that blocked, their ns, and fence waits found not needed +/// (BB_FENCE_ORDER=ordered). +enum WaitSite : unsigned { + WaitWriteData, + WaitDma, + WaitFlip, + WaitEos, + WaitEop, + WaitOther, + WaitSites, +}; +inline constexpr const char* WaitSiteNames[WaitSites] = {"WriteData", "DMA", "flip", "EOS", "EOP", + "other"}; +inline std::array, WaitSites> signal_waits{}, signal_wait_ns{}, + signal_waits_skipped{}, copy_waits{}, copy_wait_ns{}; +/// WaitRegMem met by a fence still deferred (a point the command processor would wait at). +inline std::atomic fence_barriers{0}; +/// DmaData into guest memory: stored by the CPU, or recorded for the GPU (no waits, BB_DMA_WAITS). +inline std::atomic dma_on_cpu{0}, dma_on_gpu{0}; +/// bbport BB_DRAWREC_WORKERS=1 (docs/parallel-stageb.md): the lookahead thread bb:DrawAhead walks +/// the draw pipe ahead of the draw recording thread and asks the cache for what its next draws will +/// read. Packets walked, draws it could prefetch for, cache lines requested, times it fell behind +/// stage B (and caught up), times it waited for stage B (BB_DRAWREC_LOOKAHEAD packets ahead), the +/// lead in packets summed over prefetched draws, cycles spent prefetching, faults recovered from +/// (a packet stage B had already passed). Experiment bit 16 of BB_TOGGLE_FILE pauses it (A/B). +inline std::atomic lookahead_packets{0}, lookahead_draws{0}, lookahead_lines{0}, + lookahead_behind{0}, lookahead_waits{0}, lookahead_lead{0}, lookahead_busy_cycles{0}, + lookahead_recovered{0}; } // namespace BbStats diff --git a/gpu/shim/core/emulator_settings.h b/gpu/shim/core/emulator_settings.h index f176cc1..cee7291 100644 --- a/gpu/shim/core/emulator_settings.h +++ b/gpu/shim/core/emulator_settings.h @@ -36,12 +36,8 @@ public: u32 GetVblankFrequency() { static const u32 value = [] { const long hz = Number("BB_VBLANK_HZ", 60); -#ifdef _WIN32 - // Direct runtime launches must be safe too, even with legacy uncapped env vars. - return hz == 30 || hz == 60 || hz == 90 ? u32(hz) : 60u; -#else - return hz > 0 ? u32(hz) : 480u; -#endif + // Bloodborne PC: Windows runs uncapped too (0 or above 120: 480 Hz), as on Linux. + return hz > 0 && hz <= 120 ? u32(hz) : 480u; }(); return value; } @@ -49,24 +45,17 @@ public: bool IsUncappedVblank() { static const bool value = [] { const long hz = Number("BB_VBLANK_HZ", 60); -#ifdef _WIN32 - (void)hz; - return false; -#else return hz <= 0 || hz > 120; -#endif }(); return value; } /// Frames per second the present thread lets through; 0 = no limit (BB_FPS_LIMIT). u32 GetFrameLimit() { static const u32 value = [] { -#ifdef _WIN32 - const long fps = Number("BB_FPS_LIMIT", GetInstance()->GetVblankFrequency()); - return fps == 30 || fps == 60 || fps == 90 ? u32(fps) : 60u; -#else - return u32(std::max(0L, Number("BB_FPS_LIMIT", 0))); -#endif + // Bloodborne PC: any limit the launcher asks for (0 = none); without one, the fixed + // presets still limit to their own rate. + const u32 vblank = GetInstance()->GetVblankFrequency(); + return u32(std::max(0L, Number("BB_FPS_LIMIT", vblank <= 120 ? long(vblank) : 0L))); }(); return value; } @@ -74,7 +63,6 @@ public: bool IsDirectMemoryAccessEnabled() { static const auto value = Flag("BB_DIRECT_MEMORY_ACCESS", false); return value; } bool IsDumpShaders() { static const auto value = Flag("BB_DUMP_SHADERS", false); return value; } bool IsFsrEnabled() { static const auto value = Flag("BB_FSR1", false); return value; } - bool IsHdrAllowed() { static const auto value = Flag("BB_HDR", false); return value; } bool IsNullGPU() { static const auto value = Flag("BB_NULL_GPU", false); return value; } bool IsPatchShaders() { return false; } bool IsPipelineCacheArchived() { return false; } diff --git a/gpu/shim/engine/change_appearance.cpp b/gpu/shim/engine/change_appearance.cpp index eff8103..f9db012 100644 --- a/gpu/shim/engine/change_appearance.cpp +++ b/gpu/shim/engine/change_appearance.cpp @@ -9,6 +9,9 @@ #include "rebirth.h" #include "menu_memory.h" #include "graphics.h" +#include "cheats.h" +#include "online_fixes.h" +#include "trophy_sync.h" #include "bbport_settings.h" #include "bbport_platform.h" #include @@ -159,6 +162,8 @@ std::int64_t __attribute__((sysv_abi)) FrameHook(std::uint64_t, const std::uint6 BbInput::Tick(); Camera::Tick(); Widescreen::Tick(); + Cheats::Tick(); + TrophySync::Tick(); return 0; // The established FPS++ implementation still runs, byte for byte. } } // namespace @@ -168,7 +173,9 @@ void Install(std::uint8_t* image, std::size_t size) { const char* identity = std::getenv("BB_ENGINE_IMAGE_SHA256"); const bool mirror = asset && *asset; const bool respec=std::getenv("BB_REBIRTH_ASSET"); - if (!mirror && !respec && !BbSettings::Get().camera_controls && !BbSettings::Get().graphics_controls && !BbSettings::Get().pc_controls && !BbSettings::Get().widescreen) return; + // Cheats (cheats.cpp) are switched live from the overlay, so a verified executable always + // gets the frame hook; run_windows.py verifies it for any engine feature or cheat. + if (!identity && !mirror && !respec && !BbSettings::Get().camera_controls && !BbSettings::Get().graphics_controls && !BbSettings::Get().pc_controls && !BbSettings::Get().widescreen) return; if (!identity || std::strcmp(identity, "071df19c8880086d97182dbc057bc8cb37badaca57d9112683836b24a0444c0a")) { Log("mirror disabled: executable identity was not verified"); return; } @@ -180,7 +187,6 @@ void Install(std::uint8_t* image, std::size_t size) { !std::filesystem::equivalent(resolved, std::filesystem::path(original) / "dvdroot_ps4/map/mapstudio/m21_00_00_00.msb.dcx", error))) { Log("mirror disabled: a user mod owns the Dream layout"); asset = nullptr; - if (!respec && !BbSettings::Get().camera_controls && !BbSettings::Get().graphics_controls && !BbSettings::Get().pc_controls && !BbSettings::Get().widescreen) return; } Hook hooks[2]; if (!Prepare(hooks[0], image, size, 0x1c1cce0, kStepPrologue, StepHook) || @@ -204,7 +210,9 @@ void Install(std::uint8_t* image, std::size_t size) { Log("mirror disabled: native editor memory budget differs"); } const bool rebirth=menus && Rebirth::Install(image,size); - if (!g_frame_on && !camera && !rebirth && !menus && !graphics && !wide && !input) { + const bool cheats=Cheats::Install(image,size); + const bool online=OnlineFixes::Install(image,size); + if (!g_frame_on && !camera && !rebirth && !menus && !graphics && !wide && !input && !cheats && !online) { for (auto& hook : hooks) Discard(hook); Log("engine enhancements disabled: no compatible feature could be prepared"); return; } diff --git a/gpu/shim/engine/cheats.cpp b/gpu/shim/engine/cheats.cpp new file mode 100644 index 0000000..8ecdcf5 --- /dev/null +++ b/gpu/shim/engine/cheats.cpp @@ -0,0 +1,1037 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Live cheats: Blood Vials (goods 1000) and Quicksilver Bullets (goods 900), each off, infinite +// or kept at an amount (bbport.ini cheat_vials / cheat_bullets, the overlay's Cheats section). +// +// The inventory helpers are the game's own, the ones its debug menu's "fill to the maximum" +// (sub_18d3dc0) calls, as droogie/bbhost's boss_rush plugin found and used them +// (plugins/boss_rush/boss_rush.cpp refill(), GPL-3.0); the param row layouts are bbhost's +// include/bbhost/params.hpp (EQUIP_PARAM_GOODS_ST, EQUIP_PARAM_WEAPON_ST). Credit: droogie. +// Every address and offset here was read back from our 1.09 image (071df19c...); Install checks +// the code bytes that show them again before anything is enabled. +// +// Save data: a cheat changes param bytes in memory only, from a snapshot of their original values, +// and writes those values back when it is turned off. The counts it raises are inventory, which +// the game saves: cheats affect the save (the counts) only while they are on, and turning one off +// leaves the counts as they are (above the normal carry cap, the game lowers them to the cap the +// next time the count changes). +// +// The other cheats (second half of this file) follow the same rules: each changes only bytes it +// took a snapshot of first (param rows, two static debug flags, one code range) and writes the +// originals back when turned off; or it uses the game's own data and helpers from the frame hook +// (stamina, durability, echoes and insight), which leaves nothing to restore. Echoes, insight and +// durability are save data and stay as they are when a cheat is turned off. +#include "cheats.h" +#include "engine_hooks.h" +#include "engine_state.h" +#include "bbport_settings.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace BbEngine::Cheats { +namespace { +using Clock = std::chrono::steady_clock; + +// Game functions (BN addresses: image base 0x400000). +constexpr std::uint64_t kGoodsRow = 0x231e3d0; // (&{id, pad, row}, goods id): the goods row +constexpr std::uint64_t kGoodsCap = 0x231e7e0; // (&{id, pad, row}): most one can carry +constexpr std::uint64_t kInventorySlot = 0x18d9f60; // (EquipInventoryData*, &{0x40000000|id, 0, 0, 0}) +constexpr std::uint64_t kAddItem = 0x18cdfd0; // (EquipGameData*, type, id, count, 1, 1, [stack] 0) +constexpr std::uint32_t kGoods = 0x40000000; // the item type of goods +// PlayerGameData (GameDataMan 0x593b130 +0x08, engine_state player_record) + 0x1d0 is EquipGameData, +// which holds EquipInventoryData at +0x158: the first *(+0x24) of its 16-byte entries at *(+0x58), +// the rest at *(+0x48), the slot bound *(+0x88); an entry's item at +0, its quantity at +8. +constexpr std::uint64_t kEquipGameData = 0x1d0, kInventory = 0x158; +constexpr std::uint64_t kInvFirst = 0x24, kInvRest = 0x48, kInvHead = 0x58, kInvBound = 0x88; +// EQUIP_PARAM_GOODS_ST: int16 maxNum at 0x36; bitfield bytes 0x42-0x47: isConsume (0x44 bit 7), +// useBulletMaxNum (0x46 bit 6), useHpCureMaxNum (0x46 bit 7). With either use* bit the carry cap is +// the live one (runes), else the row's maxNum (sub_231e7e0). +constexpr std::size_t kGoodsRowSize = 0x6c, kMaxNum = 0x36, kConsumeByte = 0x44, kCapByte = 0x46; +constexpr std::uint8_t kIsConsume = 0x80, kUseBulletMaxNum = 0x40, kUseHpCureMaxNum = 0x80; +// EQUIP_PARAM_WEAPON_ST: int8 bulletConsumeNum at 0x13a, read from the row at each shot. Weapons +// without a firearm hold -1 there (694 of the 1090 rows in 1.09, every melee weapon); 1, 2, 10 +// and 12 are the costs of the guns, trick-weapon guns and arcane tools. +constexpr std::size_t kWeaponRowSize = 0x13c, kBulletConsumeNum = 0x13a; +constexpr std::uint32_t kBloodVial = 1000, kQuicksilverBullets = 900; +constexpr auto kDebounce = std::chrono::milliseconds(400); // a slider being dragged applies once +constexpr auto kInterval = std::chrono::milliseconds(250); // refills: at most four times a second +constexpr auto kSettle = std::chrono::milliseconds(1500); // in the world this long before acting +constexpr const char* kNotVerified = "the engine's executable was not verified"; + +// The code that shows the functions, their arguments and the layouts above (our image's bytes). +constexpr std::uint8_t kSigRow[] = {0x55,0x48,0x89,0xe5,0x41,0x57,0x41,0x56,0x41,0x54,0x53,0x41,0x89,0xf4, + 0x49,0x89,0xfe,0x31,0xc9,0x45,0x85,0xe4,0x0f,0x88,0xf2,0x00,0x00,0x00}; +constexpr std::uint8_t kSigCap[] = {0x55,0x48,0x89,0xe5,0x53,0x48,0x83,0xec,0x28,0x48,0x8b,0x1d,0xf0,0x53, + 0x4c,0x03,0x48,0x8b,0x03,0x48,0x89,0x45,0xf0,0x48,0x8b,0x4f,0x08,0xb8,0x63,0x00,0x00,0x00,0x48,0x85, + 0xc9,0x0f,0x84,0x8c,0x00,0x00,0x00,0x8b,0x51,0x42,0x0f,0xb7,0x41,0x46}; +constexpr std::uint8_t kSigCapMaxNum[] = {0x0f,0xbf,0x49,0x36,0x85,0xc9,0xb8,0x63,0x00,0x00,0x00,0x0f,0x4f, + 0xc1}; +constexpr std::uint8_t kSigSlot[] = {0x8b,0x06,0x83,0xf8,0xff,0x0f,0x84,0xa6,0x00,0x00,0x00,0x89,0xc1,0x48, + 0x69,0xc9,0x15,0x3f,0xef,0x03,0x48,0xc1,0xe9,0x20}; +constexpr std::uint8_t kSigAdd[] = {0x55,0x48,0x89,0xe5,0x41,0x57,0x41,0x56,0x41,0x55,0x41,0x54,0x53,0x48, + 0x81,0xec,0x18,0x01,0x00,0x00,0x41,0x89,0xcd,0x41,0x89,0xd4,0x49,0x89,0xff,0x48,0x8b,0x1d,0xec,0x5b, + 0xf1,0x03,0x48,0x8b,0x03,0x48,0x89,0x45,0xd0,0x41,0xc7,0x87,0x9c,0x02}; +// sub_18cdfd0, goods: the row (sub_231e3d0), then the inventory at EquipGameData + 0x158. +constexpr std::uint8_t kSigAddInventory[] = {0xe8,0x22,0x02,0xa5,0x00,0x4d,0x8d,0xb7,0x58,0x01,0x00,0x00, + 0xbe,0x00,0x00,0x00,0x40,0x4c,0x89,0xf7,0x44,0x89,0xe2,0xe8,0xbb,0xbc,0x00,0x00}; +// Using goods: the quantity changes only when the row's isConsume bit (0x42 dword, 0x800000) is set. +constexpr std::uint8_t kSigConsume[] = {0xe8,0x0b,0xd6,0xa4,0x00,0x48,0x89,0xde,0x48,0x8b,0x45,0xa0,0x48, + 0x85,0xc0,0x0f,0x84,0xc5,0x02,0x00,0x00,0x8b,0x40,0x42,0xa9,0x00,0x00,0x80,0x00,0x0f,0x84,0xb7,0x02, + 0x00,0x00}; +// The fire check reads bulletConsumeNum (movsx r13d, byte [row+0x13a]) from the weapon row. +constexpr std::uint8_t kSigBullets[] = {0xe8,0xcb,0xf8,0x62,0x00,0x48,0x8b,0x45,0x98,0x45,0x31,0xe4,0x41, + 0xbd,0x00,0x00,0x00,0x00,0x48,0x85,0xc0,0x74,0x08,0x44,0x0f,0xbe,0xa8,0x3a,0x01,0x00,0x00}; +// A game call of sub_18cdfd0: rdi = *(*0x593b130 + 8) + 0x1d0. +constexpr std::uint8_t kSigEquip[] = {0x48,0x8d,0x05,0x3a,0xab,0x22,0x04,0x48,0x8b,0x00,0x4c,0x8b,0x68,0x08, + 0x49,0x8d,0xbd,0xd0,0x01,0x00,0x00}; +// The debug menu's fill: slot (sub_18d9f60), bound +0x88, first +0x24, +0x58 / +0x48, quantity +8. +constexpr std::uint8_t kSigEntry[] = {0xe8,0x9d,0x5a,0x00,0x00,0xc7,0x85,0x34,0xfe,0xff,0xff,0x00,0x00,0x00, + 0x00,0x85,0xc0,0x78,0x4f,0x41,0x8b,0x8c,0x24,0xe0,0x01,0x00,0x00,0xff,0xc1,0x39,0xc1,0x76,0x41,0x89, + 0xc1,0x41,0x2b,0x8c,0x24,0x7c,0x01,0x00,0x00,0x73,0x10,0x89,0xc1,0x48,0xc1,0xe1,0x04,0x49,0x03,0x8c, + 0x24,0xb0,0x01,0x00,0x00,0xeb,0x0c,0x48,0xc1,0xe1,0x04,0x49,0x03,0x8c,0x24,0xa0,0x01,0x00,0x00,0x83, + 0x39,0x00,0x74,0x14,0x48,0x85,0xc9,0x74,0x0f,0x8b,0x41,0x08,0x89,0x85,0x34,0xfe,0xff,0xff}; +struct Signature { std::uint64_t bn; std::span bytes; }; +constexpr Signature kSignatures[] = { + {kGoodsRow, kSigRow}, {kGoodsCap, kSigCap}, {0x231e887, kSigCapMaxNum}, {kInventorySlot, kSigSlot}, + {kAddItem, kSigAdd}, {0x18ce1a9, kSigAddInventory}, {0x18d0dc0, kSigConsume}, {0x1cfa1d0, kSigBullets}, + {0x17105ef, kSigEquip}, {0x18d44be, kSigEntry}, +}; + +std::uint64_t g_slide = 0; +std::uint8_t* g_image = nullptr; +std::size_t g_image_size = 0; +bool g_installed = false; +// The hunter in the world for kSettle: same record and map block since then (checked every kInterval). +std::uint64_t g_record = 0; +std::uint32_t g_block = 0; +Clock::time_point g_since{}, g_check{}; +bool g_present = false, g_settled = false; +std::uint64_t Slot(std::uint64_t bn) { return g_slide + (bn - kPreferredGuestSlide); } +template bool Read(std::uint64_t at, T* out) { return at && host_read_safe(reinterpret_cast(at), out, sizeof(T)); } +// Guest heap and data: above the null page, below 1 TiB (the guest-visible range), readable. +bool Plausible(std::uint64_t p) { std::uint64_t v = 0; return p >= 0x10000 && p < (std::uint64_t(1) << 40) && Read(p, &v); } +// Our image's bytes at a BN address match `bytes` (checked at Install, before guest code runs). +bool Matches(std::uint64_t bn, std::span bytes) { + const std::size_t at = bn - kPreferredGuestSlide; + return g_image && at <= g_image_size && bytes.size() <= g_image_size - at && + !std::memcmp(g_image + at, bytes.data(), bytes.size()); +} + +// A param byte a cheat may change: its original value (the snapshot) and what is there now. +struct Byte { std::uint64_t at; std::uint8_t original, now; }; +struct Cheat { + const char* name; // the log's: "vials", "bullets" + std::uint32_t goods; + std::uint8_t cap_bit; // useHpCureMaxNum / useBulletMaxNum + std::atomic unavailable{kNotVerified}; + bool disabled = false; + int active = BbSettings::CheatOff, pending = BbSettings::CheatOff; + Clock::time_point pending_since{}; + // The snapshot: taken once from the original values, kept until the game reloads the table. + bool snapped = false, weapons = false; + std::uint64_t goods_cap = 0, goods_file = 0, weapon_cap = 0, weapon_file = 0; + std::vector bytes; // [0..1] maxNum, [2] isConsume byte, [3] cap byte, then weapons + int base_cap = 0; // the carry cap while the row's cap bytes are original + int floor = -1; // infinite: the count it keeps if the param change does not hold + bool floor_logged = false; + int failures = 0; +}; +Cheat g_cheats[2] = {{"vials", kBloodVial, kUseHpCureMaxNum}, {"bullets", kQuicksilverBullets, kUseBulletMaxNum}}; + +struct Handle { std::int32_t id, pad; std::uint64_t row; }; +std::uint64_t GuestRow(std::uint32_t id) { + Handle h{-1, 0, 0}; + Call(Slot(kGoodsRow), &h, id); + return h.row; +} +int GuestCap(std::uint32_t id) { + Handle h{-1, 0, 0}; + Call(Slot(kGoodsRow), &h, id); + return h.row ? static_cast(Call(Slot(kGoodsCap), &h)) : -1; +} +// The count held (0 when the item is not in the inventory); false if the inventory looks wrong. +bool Count(std::uint64_t equip, std::uint32_t id, int* have) { + const std::uint64_t inv = equip + kInventory; + std::uint32_t bound = 0, first = 0; + std::uint64_t head = 0, rest = 0; + if (!Read(inv + kInvBound, &bound) || !Read(inv + kInvFirst, &first) || !Read(inv + kInvHead, &head) || + !Read(inv + kInvRest, &rest) || bound > 65536 || first > bound + 1 || (!Plausible(head) && !Plausible(rest))) + return false; + std::uint32_t item[4] = {kGoods | id, 0, 0, 0}; + const auto slot = static_cast(Call(Slot(kInventorySlot), inv, item)); + *have = 0; + if (slot < 0 || static_cast(slot) >= bound + 1) return true; + const auto index = static_cast(slot); + if (!Plausible(index < first ? head : rest)) return false; + const std::uint64_t entry = index < first ? head + std::uint64_t(index) * 16 : rest + std::uint64_t(index - first) * 16; + std::uint32_t e[4] = {}; + if (!Read(entry, &e)) return false; + if (e[0]) *have = static_cast(e[2]); + return *have >= 0 && *have <= 9999; +} + +// The game reloaded the table (a new file): the snapshot's addresses are no longer its rows. +bool SameTables(const Cheat& c) { + return params_table_file(c.goods_cap) == c.goods_file && (!c.weapons || params_table_file(c.weapon_cap) == c.weapon_file); +} + +// Writes back each original value that is still the one this cheat wrote; never writes elsewhere. +void RestoreSafe(Cheat& c) { + if (!c.snapped) return; + if (!SameTables(c)) { c.snapped = false; c.bytes.clear(); return; } + for (Byte& b : c.bytes) { + std::uint8_t current = 0; + if (b.now != b.original && Read(b.at, ¤t) && current == b.now) { + std::memcpy(reinterpret_cast(b.at), &b.original, 1); + b.now = b.original; + } + } +} + +void Disable(Cheat& c, const char* why) { + if (c.disabled) return; + c.disabled = true; + RestoreSafe(c); + c.unavailable.store(why); + std::printf("Cheats: %s unavailable: %s\n", c.name, why); +} + +// Reads one table's records and the rows' bytes (one read each); checks the rows' size. +bool LoadTable(const char* table, std::size_t row_size, ParamTable* t, std::vector* records, + std::vector* data, std::uint64_t* lo) { + if (!params_table(table, t) || !params_records(*t, records) || records->size() < 2 || + (*records)[1].data - (*records)[0].data != row_size) return false; + std::uint64_t first = ~std::uint64_t(0), last = 0; + for (const ParamRecord& r : *records) { first = std::min(first, r.data); last = std::max(last, r.data); } + if (last - first > 16 * 1024 * 1024) return false; + data->resize(last - first + row_size); + *lo = first; + return host_read_safe(reinterpret_cast(first), data->data(), data->size()); +} + +// The snapshot: the goods row's bytes (vials, bullets) and, for bullets, each weapon row's +// bulletConsumeNum that is not 0. Checked against the game's own row lookup and plausible values. +const char* Snapshot(Cheat& c) { + ParamTable goods; + std::vector records; + std::vector data; + std::uint64_t lo = 0; + if (!LoadTable("EquipParamGoods", kGoodsRowSize, &goods, &records, &data, &lo)) + return "EquipParamGoods not found or its rows are not 0x6c bytes"; + const auto it = std::find_if(records.begin(), records.end(), [&](const ParamRecord& r) { return r.id == c.goods; }); + if (it == records.end()) return "goods row missing"; + if (GuestRow(c.goods) != it->data) return "goods row differs from the game's own lookup"; + const std::uint8_t* row = data.data() + (it->data - lo); + std::int16_t max_num = 0; + std::memcpy(&max_num, row + kMaxNum, 2); + if (max_num < 1 || max_num > 999) return "goods row maxNum implausible"; + if (c.goods == kBloodVial && !(row[kConsumeByte] & kIsConsume)) return "Blood Vial row is not consumable"; + c.bytes.clear(); + for (std::size_t off : {kMaxNum, kMaxNum + 1, kConsumeByte, kCapByte}) + c.bytes.push_back({it->data + off, row[off], row[off]}); + c.goods_cap = goods.cap; + c.goods_file = goods.file; + c.weapons = false; + if (c.goods == kQuicksilverBullets) { + ParamTable weapons; + std::vector found; + const char* problem = nullptr; + if (!LoadTable("EquipParamWeapon", kWeaponRowSize, &weapons, &records, &data, &lo)) { + problem = "EquipParamWeapon not found or its rows are not 0x13c bytes"; + } else { + // -1 marks rows without a cost (left alone); any other negative value, or one above + // 64, would mean the offset is wrong for this table. + std::size_t none = 0; + for (const ParamRecord& r : records) { + const auto v = static_cast(data[r.data - lo + kBulletConsumeNum]); + if (v > 64 || v < -1) { problem = "a weapon's bulletConsumeNum is out of range"; break; } + if (v < 0) { ++none; continue; } + if (v) found.push_back({r.data + kBulletConsumeNum, std::uint8_t(v), std::uint8_t(v)}); + } + if (!problem && found.empty()) problem = "no weapon uses bullets"; + if (!problem) + std::printf("Cheats: bullets: %zu weapons cost bullets, %zu without a cost (-1) left alone\n", + found.size(), none); + } + if (problem) { + // The weapon rows stay untouched; infinite bullets keep the count by refilling instead. + std::printf("Cheats: bullets: weapon params not used (%s); infinite bullets refill instead\n", problem); + } else { + c.weapons = true; + c.weapon_cap = weapons.cap; + c.weapon_file = weapons.file; + c.bytes.insert(c.bytes.end(), found.begin(), found.end()); + } + } + c.snapped = true; + return nullptr; +} + +bool CapBytesOriginal(const Cheat& c) { + return c.bytes[0].now == c.bytes[0].original && c.bytes[1].now == c.bytes[1].original && + c.bytes[3].now == c.bytes[3].original; +} + +// The bytes a mode wants: infinite clears isConsume (vials) or every weapon's bulletConsumeNum +// (bullets); an amount above the carry cap raises the row's maxNum to it and makes the cap the +// row's own (clears useHpCureMaxNum / useBulletMaxNum). Everything else: the original values. +std::vector Wanted(const Cheat& c, int mode) { + std::vector want(c.bytes.size()); + for (std::size_t i = 0; i < c.bytes.size(); ++i) want[i] = c.bytes[i].original; + if (mode == BbSettings::CheatInfinite) { + if (c.goods == kBloodVial) want[2] = std::uint8_t(c.bytes[2].original & ~kIsConsume); + for (std::size_t i = 4; i < c.bytes.size(); ++i) want[i] = 0; + } else if (mode > 0 && mode > c.base_cap) { + want[0] = std::uint8_t(mode & 0xff); + want[1] = std::uint8_t(mode >> 8); + want[3] = std::uint8_t(c.bytes[3].original & ~c.cap_bit); + } + return want; +} + +// Brings the bytes to `want`: every byte to change is checked first (still what this cheat left +// there), then written. A byte changed by anything else stops the cheat. +bool Reconcile(Cheat& c, const std::vector& want) { + for (std::size_t i = 0; i < c.bytes.size(); ++i) { + std::uint8_t current = 0; + if (want[i] != c.bytes[i].now && (!Read(c.bytes[i].at, ¤t) || current != c.bytes[i].now)) { + Disable(c, "a param byte changed outside the cheat"); + return false; + } + } + for (std::size_t i = 0; i < c.bytes.size(); ++i) { + if (want[i] == c.bytes[i].now) continue; + std::memcpy(reinterpret_cast(c.bytes[i].at), &want[i], 1); + c.bytes[i].now = want[i]; + } + return true; +} + +// A changed setting, after the debounce. +void Switch(Cheat& c, int mode) { + c.active = mode; + c.floor = -1; + c.failures = 0; + g_check = Clock::time_point{}; // the new mode applies at the next frame + if (mode == BbSettings::CheatOff) { + if (c.snapped && SameTables(c)) Reconcile(c, Wanted(c, mode)); + else { c.snapped = false; c.bytes.clear(); } + std::printf("Cheats: %s off, params restored\n", c.name); + } else if (mode == BbSettings::CheatInfinite) { + std::printf("Cheats: infinite %s on\n", c.name); + } else { + std::printf("Cheats: %s kept at %d\n", c.name, mode); + } +} + +// In the world, settled: params to the mode, then the count (at most every kInterval). +void Run(Cheat& c, std::uint64_t equip) { + if (c.snapped && !SameTables(c)) { + std::printf("Cheats: %s: the game reloaded its params; applying again\n", c.name); + c.snapped = false; + c.bytes.clear(); + } + if (!c.snapped) { + if (const char* why = Snapshot(c)) { Disable(c, why); return; } + } + if (CapBytesOriginal(c)) { + c.base_cap = GuestCap(c.goods); + if (c.base_cap < 1 || c.base_cap > 999) { Disable(c, "carry cap implausible"); return; } + } + if (!Reconcile(c, Wanted(c, c.active))) return; + int have = 0; + if (!Count(equip, c.goods, &have)) { Disable(c, "inventory not readable"); return; } + const int cap = GuestCap(c.goods); + if (cap < 1 || cap > 999) { Disable(c, "carry cap implausible"); return; } + int target = 0; + if (c.active > 0) { + target = std::min(c.active, cap); // never lowers a count above it + } else { + // Infinite: kept full (the carry cap), from none too. Where the param change applies, + // using one does not lower the count at all; otherwise it is refilled. + if (c.floor == cap && have < cap && !c.floor_logged) { + c.floor_logged = true; + std::printf("Cheats: %s dropped while infinite; keeping the count by refilling\n", c.name); + } + c.floor = have; + target = cap; + } + if (have >= target) { c.failures = 0; return; } + Call(Slot(kAddItem), equip, kGoods, c.goods, target - have, 1, 1, 0); + int after = have; + if (Count(equip, c.goods, &after) && after > have) c.failures = 0; + else if (++c.failures >= 3) Disable(c, "refill had no effect"); +} + +// --------------------------------------------------------------------------------------------- +// The other cheats. +// +// Stamina: the hunter's ChrIns is WorldChrMan (0x593e878) +0x60; its module container (+0x3b0) +// holds the data module at +0x20 (droogie/bbhost world_chr.cpp, rebirth.cpp heal()), where the +// stamina spending code keeps stamina at +0x134 between -50 and the maximum at +0x138 +// (kSigStaminaClamp, one of ten such sites; each first tests the debug menu's "All No Stamina +// Consume" flag 0x593e88a, which would spare every character, so the cheat does not use it). +constexpr std::uint64_t kWorldChrMan = 0x593e878; +constexpr std::uint64_t kPlayerIns = 0x60, kModules = 0x3b0, kDataModule = 0x20, kStamina = 0x134, kMaxStamina = 0x138; +constexpr std::uint8_t kSigStaminaLoad[] = {0x49,0x8b,0x8c,0x24,0xb0,0x03,0x00,0x00,0x4c,0x8b,0x79,0x20,0x41,0x8b, + 0x9f,0x34,0x01,0x00,0x00}; +constexpr std::uint8_t kSigStaminaClamp[] = {0x48,0x8d,0x05,0xe1,0x83,0xc8,0x03,0x80,0x38,0x00,0x75,0x1f,0xb8,0xce, + 0xff,0xff,0xff,0x83,0xfb,0xce,0x7c,0x0e,0x41,0x8b,0x87,0x38,0x01,0x00,0x00,0x39,0xd8,0x0f,0x4e,0xd8,0x89, + 0xd8,0x41,0x89,0x87,0x34,0x01,0x00,0x00}; +// Stealth: the static ChrDbg flag bytes the debug menu's "Player Hide" and "Player Silence" toggle +// (the menu builder passes their addresses with those labels, kSigStealthMenu / kSigSilentMenu), +// read live by the game (kSigStealthUse / kSigSilentUse); Kyo's XML patches write the same bytes. +// 0x593e888 next to them is "Player No Dead" and is never touched. +constexpr std::uint64_t kPlayerHide = 0x593e88e, kPlayerSilence = 0x593e88f; +constexpr std::uint8_t kSigStealthMenu[] = {0x48,0x8d,0x35,0xfc,0x48,0x06,0x03,0x48,0x8d,0x15,0xc9,0xd3,0xc1}; +constexpr std::uint8_t kSigSilentMenu[] = {0x48,0x8d,0x35,0xda,0x48,0x06,0x03,0x48,0x8d,0x15,0x88,0xd3,0xc1}; +constexpr std::uint8_t kSigStealthUse[] = {0x48,0x8d,0x05,0xac,0x93,0xae,0x03,0x8a,0x00,0x88,0x43,0x64}; +constexpr std::uint8_t kSigSilentUse[] = {0x48,0x8d,0x05,0xc5,0x95,0xae,0x03,0x80,0x38,0x00}; +// Echoes and insight: PlayerGameData +0x94 (capped at 999,999,999 by the game's own add, kSigEchoes) +// and +0x84 (capped at 99, kSigInsight); engine_state's player_stat "echoes" / "insight". +constexpr std::int64_t kEchoCap = 999999999, kInsightCap = 99; +constexpr std::uint8_t kSigInsight[] = {0x44,0x03,0xb8,0x84,0x00,0x00,0x00,0x78,0x0d,0x41,0x83,0xff,0x63,0xb9,0x63, + 0x00,0x00,0x00,0x41,0x0f,0x4e,0xcf,0x89,0x88,0x84,0x00,0x00,0x00}; +constexpr std::uint8_t kSigEchoes[] = {0x48,0x8b,0x93,0xc0,0x03,0x00,0x00,0x8b,0xb2,0x94,0x00,0x00,0x00,0x31,0xc9, + 0x89,0xf7,0x01,0xc7,0x78,0x0e,0x81,0xff,0xff,0xc9,0x9a,0x3b,0xb9,0xff,0xc9,0x9a,0x3b,0x0f,0x4e,0xcf,0x89, + 0x8a,0x94,0x00,0x00,0x00}; +// Keeping echoes on death: the blood mark commit (droogie/bbhost death_events.hpp +// BLOOD_MARK_COMMIT_FN 0x17d2910, which the death events call with capture=1) adds the carried +// echoes (+0x94) and a pending amount into r12d, stores r12d as the mark's echoes (+0x38) and +// zeroes the carried echoes. The patch keeps the carried echoes and leaves only the pending part +// in the mark (r12d -= carried), so picking the bloodstain up cannot give them a second time. +constexpr std::uint64_t kBloodMarkSite = 0x17d2bd2; +constexpr std::uint8_t kSigBloodMark[] = {0x45,0x8b,0xa5,0x94,0x00,0x00,0x00,0x48,0x8d,0x05,0x78,0x85,0x16,0x04, + 0x48,0x8b,0x00,0x48,0x85,0xc0,0x75,0x2b,0x48,0x8d,0x3d,0xca,0x87,0x56,0x03,0x48,0x8d,0x15,0x17,0x88,0x56, + 0x03,0x48,0x8d,0x0d,0xfe,0x85,0x56,0x03,0xbe,0xb1,0x00,0x00,0x00,0x31,0xc0,0xe8,0xf7,0x29,0xce,0x00,0x48, + 0x8d,0x05,0x48,0x85,0x16,0x04,0x48,0x8b,0x00,0x48,0x8b,0x40,0x08,0x44,0x03,0x60,0x28,0xc7,0x40,0x28,0x00, + 0x00,0x00,0x00,0x44,0x89,0x63,0x38,0x41,0xc7,0x85,0x94,0x00,0x00,0x00,0x00,0x00,0x00,0x00}; +// mov [rbx+0x38], r12d; mov dword [r13+0x94], 0 -> sub r12d, [r13+0x94]; mov [rbx+0x38], r12d; nop4 +constexpr std::uint8_t kBloodMarkOriginal[] = {0x44,0x89,0x63,0x38,0x41,0xc7,0x85,0x94,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00}; +constexpr std::uint8_t kBloodMarkKeep[] = {0x45,0x2b,0xa5,0x94,0x00,0x00,0x00,0x44,0x89,0x63,0x38,0x0f,0x1f,0x40, + 0x00}; +static_assert(sizeof(kBloodMarkOriginal) == sizeof(kBloodMarkKeep)); +// Durability: sub_18d24e0 (EquipGameData*, slot 0-7, change) adds the change to the durability of +// the weapon in an equipment slot, clamped to 0..EquipParamWeapon durabilityMax (+0xbe); the +// game's PlayerIns calls it with rdi = PlayerGameData + 0x1d0 (kSigDurabilityCall). A slot's item +// handle is EquipGameData +0x94 + 4 * slot, its id from sub_1e89070 (&{handle, 0, 0, -1}) at +0x10. +constexpr std::uint64_t kChangeDurability = 0x18d24e0, kGaitemLookup = 0x1e89070, kEquipHandles = 0x94; +constexpr int kDurabilitySlots = 8; +constexpr std::size_t kDurabilityMax = 0xbe; +constexpr std::uint8_t kSigDurability[] = {0x55,0x48,0x89,0xe5,0x41,0x57,0x41,0x56,0x41,0x55,0x41,0x54,0x53,0x48, + 0x83,0xec,0x68,0x41,0x89,0xf5,0x48,0x89,0xfb,0x4c,0x8b,0x25,0xe2,0x16,0xf1,0x03,0x49,0x8b,0x04,0x24,0x48, + 0x89,0x45,0xd0,0x41,0x83,0xfd,0x07,0x0f,0x87,0xaf,0x00,0x00,0x00,0x89,0x55,0x8c,0x4d,0x63,0xf5,0x42,0x8b, + 0x84,0xb3,0x94,0x00,0x00,0x00,0x89,0x45,0x90,0x48,0xc7,0x45,0x98,0x00,0x00,0x00,0x00,0xc7,0x45,0xa0,0xff, + 0xff,0xff,0xff,0x48,0x8d,0x7d,0x90,0xe8,0x37,0x6b,0x5b,0x00,0x8b,0x45,0xa0,0x25,0xff,0xff,0xff,0x0f,0x3d, + 0xff,0xff,0xff,0x0f,0x41,0xbf,0xff,0xff,0xff,0xff,0x44,0x0f,0x45,0xf8,0x41,0x83,0xff,0xff}; +constexpr std::uint8_t kSigDurabilityClamp[] = {0x44,0x01,0xf3,0x48,0x8b,0x4d,0x98,0x31,0xd2,0xb8,0x00,0x00,0x00, + 0x00,0x48,0x85,0xc9,0x74,0x07,0x0f,0xb7,0x81,0xbe,0x00,0x00,0x00,0x85,0xdb,0x78,0x07,0x39,0xc3,0x0f,0x4f, + 0xd8,0x89,0xda,0x48,0x8b,0x7d,0x80,0x48,0x81,0xc7,0x58,0x01,0x00,0x00,0x8b,0xb5,0x7c,0xff,0xff,0xff,0xe8, + 0x58,0x71,0x00,0x00}; +constexpr std::uint8_t kSigDurabilityCall[] = {0x49,0x8b,0xbf,0xc0,0x03,0x00,0x00,0x48,0x81,0xc7,0xd0,0x01,0x00,0x00, + 0x89,0xde,0x44,0x89,0xe2,0xe8,0xfd,0x69,0xbd,0xff}; +// Firearm range: the guns' BehaviorParam_PC rows (variationId = the weapons' behaviorVariationId: +// 600 Blunderbuss, 610 Ludwig's Rifle, 1000 Rifle Spear, 1010 Reiterpallasch, 1400 Hunter Pistol +// and Evelyn, 1410 Repeating Pistol, 1500 Cannon, 3300 Gatling Gun, 3500 Church Cannon, 3600 +// Piercing Rifle) with refType 1 name a Bullet row; each shot is a chain of Bullet rows (HitBulletID, +// the next segment). The moving segments' life (+0x10) and dist (+0x14, where gravity and +// acceleration change) are scaled. Rows any other player behaviour reaches (spells, hunter tools, +// the shared non-moving 77) are left alone. 1.09: 45 rows, none shared with enemies. +constexpr std::uint32_t kFirearmVariations[] = {600, 610, 1000, 1010, 1400, 1410, 1500, 3300, 3500, 3600}; +constexpr std::size_t kBehaviorRowSize = 0x20, kBehaviorVariation = 0x0, kBehaviorRefType = 0x9, kBehaviorRefId = 0xc; +constexpr std::size_t kBulletRowSize = 0xa0, kBulletLife = 0x10, kBulletDist = 0x14, kBulletVelocity = 0x28, + kBulletNext = 0x68; +// Echo multiplier: NpcParam getSoul (+0x28, an enemy's echoes; 11025 at most in 1.09) and +// GameAreaParam bonusSoul_single / _multi (+0x0 / +0x4, a boss's; 230000 at most). +constexpr std::size_t kNpcRowSize = 0x184, kGetSoul = 0x28, kAreaRowSize = 0x20; +// Consumables: the stackable usable goods whose isConsume bit is cleared (the game then does not +// lower the count, as for the Hunter's Mark). Not the Blood Vials (1000) and Quicksilver Bullets +// (900), which have their own cheats; not keys, materials, Blood Echo and Insight items (Coldblood, +// Madman's Knowledge, Great One's Wisdom, Blood Dreg), the Bold Hunter's Mark, Vermin, one-of +// items (runes, gems, Umbilical Cords) or the unnamed rows. +constexpr std::uint32_t kConsumables[] = { + 1100, 1101, // Antidote, Sedative + 1110, 1120, // Beast Blood Pellet, Blue Elixir + 1200, 1201, 1250, 1251, // Molotov Cocktail, Delayed Molotov, Rope / Delayed Rope Molotov + 1210, 1240, 1230, // Poison Knife, Throwing Knife, Pebble + 1220, 1260, 1270, // Oil Urn, Pungent Blood Cocktail, Numbing Mist + 1300, 1320, 1330, // Fire Paper, Bolt Paper, Bone Marrow Ash + 1410, // Shining Coins + 2030, 2090, // Lead Elixir, Shaman Bone Blade +}; +constexpr std::size_t kGoodsType = 0x3a, kRefCategory = 0x3b; + +// The bytes one of these cheats changes: each original (the snapshot) and what is there now, and +// the param tables they are in (a reloaded table voids the snapshot). +struct Patch { + std::vector bytes; + std::vector tables; + bool snapped = false, code = false; +}; +bool Current(const Patch& p) { + for (const ParamTable& t : p.tables) + if (params_table_file(t.cap) != t.file) return false; + return true; +} +void Forget(Patch& p) { p.snapped = false; p.bytes.clear(); p.tables.clear(); } +std::vector Originals(const Patch& p) { + std::vector v(p.bytes.size()); + for (std::size_t i = 0; i < v.size(); ++i) v[i] = p.bytes[i].original; + return v; +} +// Brings the bytes to `want` after checking every byte to change still holds what this cheat +// left there; false (nothing written) if one does not. Code (one contiguous range) goes through +// WriteCode, data and params are written directly. +bool Write(Patch& p, const std::vector& want) { + // Consecutive bytes are read in one go (the echo multiplier changes about 20000). + bool change = false; + std::vector run; + for (std::size_t i = 0, j = 0; i < p.bytes.size(); i = j) { + bool differs = want[i] != p.bytes[i].now; + for (j = i + 1; j < p.bytes.size() && p.bytes[j].at == p.bytes[j - 1].at + 1; ++j) + differs = differs || want[j] != p.bytes[j].now; + if (!differs) continue; + run.resize(j - i); + if (!host_read_safe(reinterpret_cast(p.bytes[i].at), run.data(), run.size())) return false; + for (std::size_t k = i; k < j; ++k) + if (want[k] != p.bytes[k].now && run[k - i] != p.bytes[k].now) return false; + change = true; + } + if (!change) return true; + if (p.code) { + if (!WriteCode(reinterpret_cast(p.bytes[0].at), want.data(), want.size())) return false; + for (std::size_t i = 0; i < p.bytes.size(); ++i) p.bytes[i].now = want[i]; + return true; + } + for (std::size_t i = 0; i < p.bytes.size(); ++i) { + if (want[i] == p.bytes[i].now) continue; + std::memcpy(reinterpret_cast(p.bytes[i].at), &want[i], 1); + p.bytes[i].now = want[i]; + } + return true; +} +// Writes back the originals where they are still what this cheat wrote. +void RestoreSafe(Patch& p) { + if (!p.snapped) return; + if (!Current(p)) { Forget(p); return; } + if (!Write(p, Originals(p)) && !p.code) { + // Something else changed a byte: put back only the ones still ours, byte by byte. Code is + // left as it is (half an instruction sequence would be worse than either). + for (Byte& b : p.bytes) { + std::uint8_t current = 0; + if (b.now == b.original || !Read(b.at, ¤t) || current != b.now) continue; + std::memcpy(reinterpret_cast(b.at), &b.original, 1); + b.now = b.original; + } + } +} + +enum Kind { Refill, Flags, Params, Code }; +struct Extra { + Which which; + Kind kind; + const char* name; // the log's and the reasons': "stamina", "firearm range" + std::atomic unavailable{kNotVerified}; + bool disabled = false; + int active = 0, pending = 0; + Clock::time_point pending_since{}; + Patch patch; + int failures = 0; +}; +Extra g_extras[] = { + {Stamina, Refill, "stamina"}, {Stealth, Flags, "stealth"}, {GunRange, Params, "firearm range"}, + {KeepEchoes, Code, "keep echoes"}, {EchoMultiplier, Params, "echo multiplier"}, + {NoDurability, Refill, "durability"}, {Consumables, Params, "consumables"}, +}; +Extra& ExtraOf(Which which) { + for (Extra& e : g_extras) + if (e.which == which) return e; + return g_extras[0]; +} +std::atomic g_echoes_unavailable{kNotVerified}; +std::atomic g_add_echoes{0}, g_add_insight{0}; + +void Disable(Extra& e, const char* why) { + if (e.disabled) return; + e.disabled = true; + RestoreSafe(e.patch); + e.unavailable.store(why); + std::printf("Cheats: %s unavailable: %s\n", e.name, why); +} + +int SettingOf(const Extra& e, const BbSettings::Values& s) { + switch (e.which) { + case Stamina: return s.cheat_stamina.load(std::memory_order_relaxed); + case Stealth: return s.cheat_stealth.load(std::memory_order_relaxed); + case KeepEchoes: return s.cheat_keep_echoes.load(std::memory_order_relaxed); + case NoDurability: return s.cheat_no_durability.load(std::memory_order_relaxed); + case Consumables: return s.cheat_consumables.load(std::memory_order_relaxed); + case GunRange: { + const int v = s.cheat_gun_range.load(std::memory_order_relaxed); + return std::find(std::begin(BbSettings::CheatGunRanges), std::end(BbSettings::CheatGunRanges), v) != + std::end(BbSettings::CheatGunRanges) ? v : 0; + } + case EchoMultiplier: { + // Off is 0 here (x1 changes nothing). + const int v = s.cheat_echo_multiplier.load(std::memory_order_relaxed); + return v > 1 && std::find(std::begin(BbSettings::CheatEchoMultipliers), + std::end(BbSettings::CheatEchoMultipliers), v) != + std::end(BbSettings::CheatEchoMultipliers) ? v : 0; + } + default: return 0; + } +} + +// Param snapshots (taken in the world, the first time the cheat runs and after a table reload). +const char* SnapshotGunRange(Patch& p) { + ParamTable behavior, bullets; + std::vector brecords, records; + std::vector bdata, data; + std::uint64_t blo = 0, lo = 0; + if (!LoadTable("BehaviorParam_PC", kBehaviorRowSize, &behavior, &brecords, &bdata, &blo)) + return "BehaviorParam_PC not found or its rows are not 0x20 bytes"; + if (!LoadTable("Bullet", kBulletRowSize, &bullets, &records, &data, &lo)) + return "Bullet not found or its rows are not 0xa0 bytes"; + const auto row = [&](std::int32_t id) -> const ParamRecord* { + const auto it = std::find_if(records.begin(), records.end(), [&](const ParamRecord& r) { return std::int32_t(r.id) == id; }); + return it == records.end() ? nullptr : &*it; + }; + // Every Bullet row a behaviour reaches through its chain (at most 8 segments). + const auto reach = [&](bool firearm, std::vector* out, std::size_t* variations) { + std::vector seen; + for (const ParamRecord& b : brecords) { + const std::uint8_t* r = bdata.data() + (b.data - blo); + std::uint32_t variation = 0; + std::int32_t id = 0; + std::memcpy(&variation, r + kBehaviorVariation, 4); + std::memcpy(&id, r + kBehaviorRefId, 4); + const bool gun = std::find(std::begin(kFirearmVariations), std::end(kFirearmVariations), variation) != + std::end(kFirearmVariations); + if (r[kBehaviorRefType] != 1 || gun != firearm) continue; + if (gun && variations && std::find(seen.begin(), seen.end(), variation) == seen.end()) { + seen.push_back(variation); + ++*variations; + } + for (int depth = 0; depth < 8 && id > 0; ++depth) { + const ParamRecord* bullet = row(id); + if (!bullet || std::find(out->begin(), out->end(), bullet->data) != out->end()) break; + out->push_back(bullet->data); + std::memcpy(&id, data.data() + (bullet->data - lo) + kBulletNext, 4); + } + } + }; + std::vector guns, others; + std::size_t variations = 0; + reach(true, &guns, &variations); + reach(false, &others, nullptr); + if (variations != std::size(kFirearmVariations)) return "a firearm's behaviour rows are missing"; + p.bytes.clear(); + std::size_t rows = 0; + for (std::uint64_t at : guns) { + if (std::find(others.begin(), others.end(), at) != others.end()) continue; + const std::uint8_t* r = data.data() + (at - lo); + float life = 0, dist = 0, velocity = 0; + std::memcpy(&life, r + kBulletLife, 4); + std::memcpy(&dist, r + kBulletDist, 4); + std::memcpy(&velocity, r + kBulletVelocity, 4); + if (!std::isfinite(velocity) || velocity <= 0) continue; // spawners and blasts stay as they are + if (velocity > 200 || !std::isfinite(life) || life <= 0 || life > 5 || !std::isfinite(dist) || dist < 0 || + dist > 50) + return "a firearm bullet's life, range or speed is implausible"; + for (std::size_t off : {kBulletLife, kBulletDist}) + for (std::size_t k = 0; k < 4; ++k) p.bytes.push_back({at + off + k, r[off + k], r[off + k]}); + ++rows; + } + if (rows < 20 || rows > 120) return "unexpected number of firearm bullet rows"; + p.tables = {behavior, bullets}; + std::printf("Cheats: firearm range: %zu bullet rows of %zu firearm behaviours\n", rows, variations); + return nullptr; +} + +const char* SnapshotEchoes(Patch& p) { + p.bytes.clear(); + p.tables.clear(); + std::size_t rows = 0; + struct Field { const char* table; std::size_t size; std::size_t offsets[2]; std::size_t count; std::uint32_t most; }; + const Field fields[] = {{"NpcParam", kNpcRowSize, {kGetSoul, 0}, 1, 1000000}, + {"GameAreaParam", kAreaRowSize, {0x0, 0x4}, 2, 10000000}}; + for (const Field& f : fields) { + ParamTable t; + std::vector records; + std::vector data; + std::uint64_t lo = 0; + if (!LoadTable(f.table, f.size, &t, &records, &data, &lo)) + return f.size == kNpcRowSize ? "NpcParam not found or its rows are not 0x184 bytes" + : "GameAreaParam not found or its rows are not 0x20 bytes"; + for (const ParamRecord& r : records) { + for (std::size_t k = 0; k < f.count; ++k) { + const std::uint8_t* v = data.data() + (r.data - lo) + f.offsets[k]; + std::uint32_t echoes = 0; + std::memcpy(&echoes, v, 4); + if (!echoes) continue; + // x10 must stay far from the int32 the game adds it as. + if (echoes > f.most) return "an enemy's Blood Echoes are implausible"; + for (std::size_t b = 0; b < 4; ++b) p.bytes.push_back({r.data + f.offsets[k] + b, v[b], v[b]}); + ++rows; + } + } + p.tables.push_back(t); + } + if (rows < 100) return "too few enemies give Blood Echoes"; + std::printf("Cheats: echo multiplier: %zu enemy and boss echo values\n", rows); + return nullptr; +} + +const char* SnapshotConsumables(Patch& p) { + ParamTable goods; + std::vector records; + std::vector data; + std::uint64_t lo = 0; + if (!LoadTable("EquipParamGoods", kGoodsRowSize, &goods, &records, &data, &lo)) + return "EquipParamGoods not found or its rows are not 0x6c bytes"; + p.bytes.clear(); + for (std::uint32_t id : kConsumables) { + const auto it = std::find_if(records.begin(), records.end(), [&](const ParamRecord& r) { return r.id == id; }); + if (it == records.end()) return "a consumable's goods row is missing"; + const std::uint8_t* row = data.data() + (it->data - lo); + std::int16_t max_num = 0; + std::memcpy(&max_num, row + kMaxNum, 2); + // An ordinary (type 0) stackable item that is used up and uses an effect or an attack. + if (row[kGoodsType] != 0 || max_num < 2 || max_num > 99 || !(row[kConsumeByte] & kIsConsume) || + (row[kRefCategory] != 1 && row[kRefCategory] != 2)) + return "a consumable's goods row is not a stackable consumable"; + p.bytes.push_back({it->data + kConsumeByte, row[kConsumeByte], row[kConsumeByte]}); + } + p.tables = {goods}; + return nullptr; +} + +// The bytes a mode wants; mode 0: the originals. +std::vector WantedOf(const Extra& e, int mode) { + std::vector want = Originals(e.patch); + if (!mode) return want; + switch (e.which) { + case Stealth: std::fill(want.begin(), want.end(), std::uint8_t(1)); break; + case KeepEchoes: std::memcpy(want.data(), kBloodMarkKeep, sizeof(kBloodMarkKeep)); break; + case Consumables: for (std::uint8_t& b : want) b = std::uint8_t(b & ~kIsConsume); break; + case GunRange: + for (std::size_t i = 0; i + 4 <= want.size(); i += 4) { + float v = 0; + std::memcpy(&v, want.data() + i, 4); + v = v * float(mode) / 100.0f; + std::memcpy(want.data() + i, &v, 4); + } + break; + case EchoMultiplier: + for (std::size_t i = 0; i + 4 <= want.size(); i += 4) { + std::uint32_t v = 0; + std::memcpy(&v, want.data() + i, 4); + v *= std::uint32_t(mode); + std::memcpy(want.data() + i, &v, 4); + } + break; + default: break; + } + return want; +} + +void LogSwitch(const Extra& e, int mode) { + switch (e.which) { + case Stamina: std::printf("Cheats: infinite stamina %s\n", mode ? "on" : "off"); break; + case Stealth: std::printf(mode ? "Cheats: stealth on (Player Hide, Player Silence)\n" : "Cheats: stealth off, flags restored\n"); break; + case GunRange: + if (mode) std::printf("Cheats: firearm range %d%%\n", mode); + else std::printf("Cheats: firearm range off, params restored\n"); + break; + case KeepEchoes: std::printf(mode ? "Cheats: keep Blood Echoes on death on\n" : "Cheats: keep Blood Echoes on death off, code restored\n"); break; + case EchoMultiplier: + if (mode) std::printf("Cheats: echo multiplier x%d\n", mode); + else std::printf("Cheats: echo multiplier off, params restored\n"); + break; + case NoDurability: std::printf("Cheats: weapon durability kept full %s\n", mode ? "on" : "off"); break; + case Consumables: std::printf(mode ? "Cheats: infinite consumables on\n" : "Cheats: infinite consumables off, params restored\n"); break; + default: break; + } +} + +// A changed setting, after the debounce. Flags and code change at once (they need no character); +// params change in the world (Run), and are restored here. +void Switch(Extra& e, int mode) { + e.active = mode; + e.failures = 0; + g_check = Clock::time_point{}; + LogSwitch(e, mode); + if (e.kind == Flags && mode && !e.patch.snapped) { + e.patch.bytes.clear(); + for (std::uint64_t bn : {kPlayerHide, kPlayerSilence}) { + std::uint8_t v = 0; + if (!Read(Slot(bn), &v) || v > 1) { Disable(e, "a debug flag holds an unexpected value"); return; } + e.patch.bytes.push_back({Slot(bn), v, v}); + } + e.patch.snapped = true; + } + if (e.kind == Flags || e.kind == Code || !mode) { + if (!e.patch.snapped) return; + if (!Current(e.patch)) { Forget(e.patch); return; } + if (!Write(e.patch, WantedOf(e, mode))) + Disable(e, e.kind == Code ? "the game code changed outside the cheat" : "a byte changed outside the cheat"); + // Flags are taken again next time: the debug menu or a patch may have set them meanwhile. + if (e.kind == Flags && !mode) Forget(e.patch); + } +} + +// The hunter's data module, or 0: checked each time (the chain changes on every load). +std::uint64_t DataModule() { + std::uint64_t man = 0, chr = 0, modules = 0, data = 0; + if (!Read(Slot(kWorldChrMan), &man) || !Plausible(man) || !Read(man + kPlayerIns, &chr) || !Plausible(chr) || + !Read(chr + kModules, &modules) || !Plausible(modules) || !Read(modules + kDataModule, &data) || !Plausible(data)) + return 0; + return data; +} + +// Every frame while settled: stamina back to its maximum (the game spends it within a frame). +void RefillStamina() { + const std::uint64_t data = DataModule(); + std::int32_t value[2] = {}; + if (!data || !Read(data + kStamina, &value) || value[1] < 1 || value[1] > 2000 || value[0] < -50 || + value[0] > value[1] || value[0] == value[1]) + return; + std::memcpy(reinterpret_cast(data + kStamina), &value[1], 4); +} + +// Each weapon's durabilityMax by id, read once per table file (the slots are checked four times +// a second). +ParamTable g_weapons; +std::vector> g_durability; +bool LoadDurability() { + if (!g_durability.empty() && params_table_file(g_weapons.cap) == g_weapons.file) return true; + g_durability.clear(); + std::vector records; + std::vector data; + std::uint64_t lo = 0; + if (!LoadTable("EquipParamWeapon", kWeaponRowSize, &g_weapons, &records, &data, &lo)) return false; + for (const ParamRecord& r : records) { + std::uint16_t most = 0; + std::memcpy(&most, data.data() + (r.data - lo) + kDurabilityMax, 2); + g_durability.push_back({r.id, most}); + } + std::sort(g_durability.begin(), g_durability.end()); + return !g_durability.empty(); +} + +// The equipped weapons' durability raised to their maximum with the game's own change (a large +// increase, clamped by it); slots whose weapon has no durability (fists) are left alone. +void RefillDurability(Extra& e, std::uint64_t equip) { + if (!LoadDurability()) { Disable(e, "EquipParamWeapon not found or its rows are not 0x13c bytes"); return; } + int touched = 0; + for (int slot = 0; slot < kDurabilitySlots; ++slot) { + std::uint32_t handle = 0; + if (!Read(equip + kEquipHandles + 4 * std::uint64_t(slot), &handle)) { Disable(e, "equipment not readable"); return; } + if (!handle || handle == 0xffffffffu) continue; + struct { std::uint32_t handle, pad; std::uint64_t item; std::uint32_t id, pad2; } gaitem{handle, 0, 0, 0xffffffffu, 0}; + Call(Slot(kGaitemLookup), &gaitem); + // Weapons are item type 0 (the top four bits); armour (1) and the rest are left alone. + const std::uint32_t id = gaitem.id; + if (id == 0xffffffffu || (id & 0xfffffff) == 0xfffffff || id >> 28) continue; + const auto it = std::lower_bound(g_durability.begin(), g_durability.end(), std::make_pair(id, std::uint16_t(0))); + if (it == g_durability.end() || it->first != id || !it->second || it->second > 2000) continue; + Call(Slot(kChangeDurability), equip, slot, int(it->second)); + ++touched; + } + if (!e.failures) { + e.failures = 1; // logged once per switch + std::printf("Cheats: durability: %d equipped weapons kept at their maximum\n", touched); + } +} + +// In the world, settled. +void Run(Extra& e, std::uint64_t record) { + if (e.kind == Refill) { + if (e.which == NoDurability) RefillDurability(e, record + kEquipGameData); + return; + } + if (e.kind != Params) return; + if (e.patch.snapped && !Current(e.patch)) { + std::printf("Cheats: %s: the game reloaded its params; applying again\n", e.name); + Forget(e.patch); + } + if (!e.patch.snapped) { + const char* why = e.which == GunRange ? SnapshotGunRange(e.patch) + : e.which == EchoMultiplier ? SnapshotEchoes(e.patch) + : SnapshotConsumables(e.patch); + if (why) { Forget(e.patch); Disable(e, why); return; } + e.patch.snapped = true; + } + if (!Write(e.patch, WantedOf(e, e.active))) Disable(e, "a param byte changed outside the cheat"); +} + +// The overlay's queued echoes / insight, in the world. +void AddStats() { + const long long echoes = g_add_echoes.exchange(0), insight = g_add_insight.exchange(0); + std::int64_t v = 0; + if (echoes > 0) { + if (!player_stat_get("echoes", &v) || v < 0 || v > kEchoCap) { + std::printf("Cheats: Blood Echoes not readable; nothing added\n"); + } else { + const std::int64_t now = std::min(kEchoCap, v + echoes); + player_stat_set("echoes", now); + std::printf("Cheats: +%lld Blood Echoes (%lld held)\n", static_cast(now - v), static_cast(now)); + } + } + if (insight > 0) { + if (!player_stat_get("insight", &v) || v < 0 || v > kInsightCap) { + std::printf("Cheats: Insight not readable; nothing added\n"); + } else { + const std::int64_t now = std::min(kInsightCap, v + insight); + player_stat_set("insight", now); + std::printf("Cheats: +%lld Insight (%lld held)\n", static_cast(now - v), static_cast(now)); + } + } +} + +// Install: each cheat's own code checks; one that differs disables only that cheat. +void InstallExtras() { + const auto check = [](Which which, std::initializer_list signatures) { + for (const Signature& s : signatures) { + if (!Matches(s.bn, s.bytes)) { + Extra& e = ExtraOf(which); + e.unavailable.store("engine code differs from the verified 1.09 image"); + std::printf("Cheats: %s unavailable: engine code differs at 0x%llx\n", e.name, + static_cast(s.bn)); + return false; + } + } + ExtraOf(which).unavailable.store(nullptr); + return true; + }; + check(Stamina, {{0x1cb6451, kSigStaminaLoad}, {0x1cb64a2, kSigStaminaClamp}}); + check(Stealth, {{0x1d214b7, kSigStealthMenu}, {0x1d214f9, kSigSilentMenu}, {0x1e554db, kSigStealthUse}, + {0x1e552c3, kSigSilentUse}}); + if (check(KeepEchoes, {{0x17d2b82, kSigBloodMark}})) { + Patch& p = ExtraOf(KeepEchoes).patch; + p.code = true; + for (std::size_t i = 0; i < sizeof(kBloodMarkOriginal); ++i) + p.bytes.push_back({Slot(kBloodMarkSite) + i, kBloodMarkOriginal[i], kBloodMarkOriginal[i]}); + p.snapped = true; + } + check(NoDurability, {{kChangeDurability, kSigDurability}, {0x18d265d, kSigDurabilityClamp}, + {0x1cfbacb, kSigDurabilityCall}}); + // Params only, read and checked in the world; the consumables need the isConsume test. + check(GunRange, {}); + check(EchoMultiplier, {}); + check(Consumables, {{0x18d0dc0, kSigConsume}}); + const bool stats = Matches(0x1cfc567, kSigInsight) && Matches(0x1cfc6a4, kSigEchoes); + g_echoes_unavailable.store(stats ? nullptr : "engine code differs from the verified 1.09 image"); + if (!stats) std::printf("Cheats: add echoes / insight unavailable: engine code differs\n"); +} +} // namespace + +bool Install(std::uint8_t* image, std::size_t size) { + g_image = image; + g_image_size = size; + g_slide = reinterpret_cast(image); + bool vials = true; + for (const Signature& s : kSignatures) { + if (!Matches(s.bn, s.bytes)) { + for (Cheat& c : g_cheats) { + c.unavailable.store("engine code differs from the verified 1.09 image"); + std::printf("Cheats: %s unavailable: engine code differs at 0x%llx\n", c.name, + static_cast(s.bn)); + c.disabled = true; + } + vials = false; + break; + } + } + if (vials) + for (Cheat& c : g_cheats) c.unavailable.store(nullptr); + InstallExtras(); + g_installed = true; + bool any = vials || !g_echoes_unavailable.load(); + for (Extra& e : g_extras) { + if (e.unavailable.load()) e.disabled = true; + any = any || !e.disabled; + } + g_image = nullptr; // the checks are done; nothing reads the image through it again + return any; +} + +const char* Unavailable(Which which) { + if (which == Vials || which == Bullets) return g_cheats[which == Bullets ? 1 : 0].unavailable.load(); + if (which == Echoes) return g_echoes_unavailable.load(); + if (which >= WhichCount) return kNotVerified; + return ExtraOf(which).unavailable.load(); +} + +void AddEchoes(int amount) { + if (amount > 0 && g_installed && !g_echoes_unavailable.load()) g_add_echoes.fetch_add(std::min(amount, int(kEchoCap))); +} +void AddInsight(int amount) { + if (amount > 0 && g_installed && !g_echoes_unavailable.load()) g_add_insight.fetch_add(std::min(amount, int(kInsightCap))); +} + +void Tick() { + if (!g_installed) return; + const auto& s = BbSettings::Get(); + const int settings[2] = {s.cheat_vials.load(std::memory_order_relaxed), s.cheat_bullets.load(std::memory_order_relaxed)}; + bool idle = g_add_echoes.load(std::memory_order_relaxed) <= 0 && g_add_insight.load(std::memory_order_relaxed) <= 0; + for (int i = 0; i < 2; ++i) { + const Cheat& c = g_cheats[i]; + idle = idle && (c.disabled || (settings[i] == BbSettings::CheatOff && c.pending == BbSettings::CheatOff && + c.active == BbSettings::CheatOff)); + } + for (const Extra& e : g_extras) idle = idle && (e.disabled || (!SettingOf(e, s) && !e.pending && !e.active)); + if (idle) return; // all off: nothing read, nothing called + const auto now = Clock::now(); + for (int i = 0; i < 2; ++i) { + Cheat& c = g_cheats[i]; + if (c.disabled) continue; + int value = settings[i]; + if (value != BbSettings::CheatInfinite && (value < 0 || value > BbSettings::CheatMaxAmount)) value = BbSettings::CheatOff; + if (value != c.pending) { c.pending = value; c.pending_since = now; } + if (c.pending != c.active && now - c.pending_since >= kDebounce) Switch(c, c.pending); + } + for (Extra& e : g_extras) { + if (e.disabled) continue; + const int value = SettingOf(e, s); + if (value != e.pending) { e.pending = value; e.pending_since = now; } + if (e.pending != e.active && now - e.pending_since >= kDebounce) Switch(e, e.pending); + } + Extra& stamina = ExtraOf(Stamina); + if (g_settled && !stamina.disabled && stamina.active) RefillStamina(); + if (now < g_check) return; + g_check = now + kInterval; + // Acts only with a character loaded and in the world (not at the title, not while loading). + const std::uint64_t record = player_record(); + std::uint32_t block = 0; + const bool present = Plausible(record) && world_player_block(&block); + if (!present || !g_present || record != g_record || block != g_block) { + g_present = present; + g_record = record; + g_block = block; + g_since = now; + g_settled = false; + for (Cheat& c : g_cheats) c.floor = -1; + if (!present) { + const bool echoes = g_add_echoes.exchange(0) > 0, insight = g_add_insight.exchange(0) > 0; + if (echoes || insight) std::printf("Cheats: no character in the world; Blood Echoes / Insight not added\n"); + } + return; + } + if (now - g_since < kSettle) return; + g_settled = true; + for (Cheat& c : g_cheats) { + if (!c.disabled && c.active != BbSettings::CheatOff) Run(c, record + kEquipGameData); + } + for (Extra& e : g_extras) { + if (!e.disabled && e.active) Run(e, record); + } + if (!g_echoes_unavailable.load()) AddStats(); +} +} // namespace BbEngine::Cheats diff --git a/gpu/shim/engine/cheats.h b/gpu/shim/engine/cheats.h new file mode 100644 index 0000000..1c7cb8b --- /dev/null +++ b/gpu/shim/engine/cheats.h @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Live cheats switched from bbport.ini (cheat_*) and the in-game overlay: Blood Vials and +// Quicksilver Bullets (infinite or kept at an amount), stamina, stealth, firearm range, keeping +// Blood Echoes on death, an echo multiplier, weapon durability, consumables; and the overlay's +// add Blood Echoes / Insight actions. See cheats.cpp. +#pragma once +#include +#include + +namespace BbEngine::Cheats { +enum Which : int { + Vials = 0, Bullets, Stamina, Stealth, GunRange, KeepEchoes, EchoMultiplier, NoDurability, Consumables, + Echoes, ///< the overlay's add Blood Echoes / Insight actions + WhichCount +}; +/// Checks the game code the cheats rely on. Loader start, after the executable is verified. +bool Install(std::uint8_t* image, std::size_t size); +/// The main thread's frame hook (change_appearance.cpp FrameHook): the only place cheats act. +void Tick(); +/// Why a cheat cannot run, for the overlay (static text), or nullptr while it can. +const char* Unavailable(Which which); +/// Queues Blood Echoes / Insight to add; applied by Tick with a character in the world, clamped to +/// the game's limits (999,999,999 echoes, 99 insight). Any thread. +void AddEchoes(int amount); +void AddInsight(int amount); +} // namespace BbEngine::Cheats diff --git a/gpu/shim/engine/engine_hooks.cpp b/gpu/shim/engine/engine_hooks.cpp index 8d8698a..1b32e71 100644 --- a/gpu/shim/engine/engine_hooks.cpp +++ b/gpu/shim/engine/engine_hooks.cpp @@ -142,6 +142,39 @@ bool Prepare(Hook& hook, std::uint8_t* image, std::size_t size, std::size_t offs return true; } +std::uint8_t* TailStub(Callback callback, std::uint64_t resume) { + if (!callback || !resume) return nullptr; +#ifdef _WIN32 + auto* page = static_cast(VirtualAlloc(nullptr, 4096, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)); + if (!page) return nullptr; +#else + auto* page = static_cast(mmap(nullptr, 4096, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0)); + if (page == MAP_FAILED) return nullptr; +#endif + EmitPrologueStub(page, 0, reinterpret_cast(callback), resume, nullptr, 0, false); +#ifdef _WIN32 + DWORD previous = 0; + if (!VirtualProtect(page, 4096, PAGE_EXECUTE_READ, &previous) || + !FlushInstructionCache(GetCurrentProcess(), page, 4096)) { + FreeStub(page); return nullptr; + } +#else + if (mprotect(page, 4096, PROT_READ | PROT_EXEC)) { FreeStub(page); return nullptr; } + __builtin___clear_cache(reinterpret_cast(page), reinterpret_cast(page + 4096)); +#endif + return page; +} + +void FreeStub(std::uint8_t* stub) { + if (!stub) return; +#ifdef _WIN32 + VirtualFree(stub, 0, MEM_RELEASE); +#else + munmap(stub, 4096); +#endif +} + void Discard(Hook& hook) { if (hook.stub) { #ifdef _WIN32 diff --git a/gpu/shim/engine/engine_hooks.h b/gpu/shim/engine/engine_hooks.h index a88772c..15c4726 100644 --- a/gpu/shim/engine/engine_hooks.h +++ b/gpu/shim/engine/engine_hooks.h @@ -17,6 +17,10 @@ struct Hook { bool Prepare(Hook& hook, std::uint8_t* image, std::size_t size, std::size_t offset, std::span expected, Callback callback); void Discard(Hook& hook); +// A stub that calls `callback` and then jumps to `resume` (no displaced bytes): for a tail call +// redirected to it. Null when no page could be had. +std::uint8_t* TailStub(Callback callback, std::uint64_t resume); +void FreeStub(std::uint8_t* stub); void Commit(std::span hooks); bool WriteCode(void* address, const void* bytes, std::size_t size); @@ -27,11 +31,19 @@ template std::int64_t Argument(T value) { else return static_cast(value); } template std::int64_t Call(std::uint64_t fn, A... args) { - static_assert(sizeof...(A) <= 6); - using GuestFn = std::int64_t (__attribute__((sysv_abi)) *)(std::int64_t, std::int64_t, - std::int64_t, std::int64_t, std::int64_t, std::int64_t); - std::int64_t values[7] = {0, Argument(args)...}; - return reinterpret_cast(fn)(values[1], values[2], values[3], values[4], values[5], values[6]); + static_assert(sizeof...(A) <= 7); + std::int64_t values[8] = {0, Argument(args)...}; + if constexpr (sizeof...(A) <= 6) { + using GuestFn = std::int64_t (__attribute__((sysv_abi)) *)(std::int64_t, std::int64_t, + std::int64_t, std::int64_t, std::int64_t, std::int64_t); + return reinterpret_cast(fn)(values[1], values[2], values[3], values[4], values[5], values[6]); + } else { + // The seventh argument goes on the stack ([rsp] at the call, [rbp+0x10] in the callee). + using GuestFn7 = std::int64_t (__attribute__((sysv_abi)) *)(std::int64_t, std::int64_t, + std::int64_t, std::int64_t, std::int64_t, std::int64_t, std::int64_t); + return reinterpret_cast(fn)(values[1], values[2], values[3], values[4], values[5], values[6], + values[7]); + } } void Install(std::uint8_t* image, std::size_t size); } // namespace BbEngine diff --git a/gpu/shim/engine/engine_state.cpp b/gpu/shim/engine/engine_state.cpp index 090e05d..766fb5e 100644 --- a/gpu/shim/engine/engine_state.cpp +++ b/gpu/shim/engine/engine_state.cpp @@ -14,7 +14,7 @@ template bool Read(std::uint64_t at,T* out) { return at && host_read std::uint64_t Pointer(std::uint64_t at) { std::uint64_t p=0;Read(at,&p);return p; } std::uint64_t Slot(std::uint64_t bn) { return slide+(bn-kPreferredGuestSlide); } struct Stat { const char* name; std::uint32_t offset; }; -constexpr Stat stats[]={{"hp",0x14},{"vitality",0x40},{"endurance",0x48},{"strength",0x58},{"skill",0x60},{"bloodtinge",0x68},{"arcane",0x70},{"level",0x90},{"echoes",0x94}}; +constexpr Stat stats[]={{"hp",0x14},{"vitality",0x40},{"endurance",0x48},{"strength",0x58},{"skill",0x60},{"bloodtinge",0x68},{"arcane",0x70},{"insight",0x84},{"level",0x90},{"echoes",0x94}}; std::uint64_t Record() { auto m=Pointer(Slot(0x593b130)); return m?Pointer(m+8):0; } struct Watch { std::uint64_t type=0;const char16_t* name=nullptr;std::atomic made{0}; }; Watch watches[8]; int count; @@ -23,6 +23,7 @@ struct Safe { template bool operator()(std::uint64_t at,T* out) cons bool host_read_safe(const void* p,void* out,std::size_t n) { return p && BbPlatform::ReadProcessMemory(p,out,n); } void host_log(const char* format,...) { std::fputs("Engine: ",stdout);va_list a;va_start(a,format);std::vprintf(format,a);va_end(a);std::putchar('\n'); } void StateInitialize(std::uint64_t image) { slide=image; } +std::uint64_t player_record() { return Record(); } bool player_stat_get(const char* name,std::int64_t* value) { auto record=Record(); if (!record) return false; for (const auto& s:stats) if (!std::strcmp(name,s.name)) { std::int32_t v=0;if (!Read(record+s.offset,&v)) return false;*value=v;return true; } @@ -86,6 +87,44 @@ void* params_row(const char* wanted,std::uint32_t id,std::size_t* size) { } return nullptr; } +// The same walk as params_row, keeping the first table of that name for row-by-row access. +bool params_table(const char* wanted,ParamTable* out) { + const auto repo=Pointer(Slot(0x5940340)); if (!repo || !wanted || !out) return false; + for (unsigned s=0;s<63;++s) { + const auto entry=repo+0x70+s*0x48;std::int32_t n=0;if (!Read(entry,&n) || n<=0) continue; + for (unsigned k=0;k(n) && k<8;++k) { + const auto cap=Pointer(entry+8+8*k);std::uint64_t capacity=0;if (!cap || !Read(cap+0x30,&capacity)) continue; + const auto name=capacity<8?cap+0x18:Pointer(cap+0x18); + bool same=true; unsigned c=0; + for (;c<64;++c) { char16_t ch=0;if (!Read(name+2*c,&ch) || ch!=static_cast(wanted[c])) { same=false;break; }if (!ch) break; } + if (!same || c==64) continue; + ParamTable t;t.cap=cap;t.file=params_table_file(cap); + if (!t.file || !Read(t.file+0xa,&t.rows) || !t.rows || !Read(t.file+0x2d,&t.format) || !Read(t.file+0x2e,&t.flags)) continue; + *out=t;return true; + } + } + return false; +} +std::uint64_t params_table_file(std::uint64_t cap) { const auto holder=cap?Pointer(cap+0x70):0;return holder?Pointer(holder+0x70):0; } +// Every record in one read (the records params_row reads one at a time): id and row address. +bool params_records(const ParamTable& t,std::vector* out) { + if (!t.file || !t.rows || !out) return false; + const bool wide=t.format>3 && (t.flags&2); + const std::size_t stride=wide?0x18:0xc; + std::vector bytes(stride*t.rows); + if (!host_read_safe(reinterpret_cast(t.file+(t.format==2?0x34:0x40)),bytes.data(),bytes.size())) return false; + out->clear();out->reserve(t.rows); + for (std::size_t i=0;i64*1024*1024) return false; + out->push_back({id,t.file+offset}); + } + return true; +} int menu_steps_watch(std::uint64_t type,const char16_t* name) { if (count>=8 || !name) return -1;const int w=count++;watches[w].type=type;watches[w].name=name;return w; } diff --git a/gpu/shim/engine/engine_state.h b/gpu/shim/engine/engine_state.h index a2bc45d..ad7e742 100644 --- a/gpu/shim/engine/engine_state.h +++ b/gpu/shim/engine/engine_state.h @@ -4,12 +4,22 @@ #pragma once #include "engine_hooks.h" #include "bbport_platform.h" +#include namespace BbEngine { constexpr std::uint64_t kPreferredGuestSlide=0x400000; bool host_read_safe(const void* p,void* out,std::size_t n); void host_log(const char*,...); void StateInitialize(std::uint64_t image); void* params_row(const char* table,std::uint32_t id,std::size_t* size); +// A loaded param table (the repository's ResCap and its file) and its records, for walking every +// row. The file changes if the game reloads the table: params_table_file(cap) reads it again. +struct ParamTable { std::uint64_t cap=0,file=0;std::uint16_t rows=0;std::uint8_t format=0,flags=0; }; +bool params_table(const char* table,ParamTable* out); +std::uint64_t params_table_file(std::uint64_t cap); +struct ParamRecord { std::uint32_t id;std::uint64_t data; }; +bool params_records(const ParamTable& table,std::vector* out); +// GameDataMan's local player record (PlayerGameData), or 0 while there is none. +std::uint64_t player_record(); bool player_stat_get(const char* name,std::int64_t* value); bool player_stat_set(const char* name,std::int64_t value); bool player_origin(int* origin); diff --git a/gpu/shim/engine/graphics.cpp b/gpu/shim/engine/graphics.cpp index c1e5ac1..67c8822 100644 --- a/gpu/shim/engine/graphics.cpp +++ b/gpu/shim/engine/graphics.cpp @@ -4,6 +4,7 @@ #include "graphics.h" #include "engine_state.h" #include "bbport_settings.h" +#include #include #include #include @@ -24,6 +25,13 @@ const std::uint8_t kYebisRecordPrologue[] = {0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x54, 0x53, 0x48, 0x83, 0xec, 0x28, 0x4d, 0x89, 0xcd}; constexpr std::uint64_t kPostBlock = 0x59406e0; constexpr std::size_t kSaturation = 0xc8, kVignettePower = 0xd0, kGlareLuminance = 0xdc; +// bbport: the rest of the tone map's colour correction. sub_269e990 copies the blended tone map +// (a stack block in the area GPARAM's group order: ToneMapExposure, Gamma, ToneType, +// ToneMappingFactor, FilmicContrast, ColorH, ColorS, ColorV, Contrust, ...) field by field into +// this block at 0x269fa5c-0x269fb8c: +0x98 exposure, +0x9c gamma, +0xa8 filmic contrast, +// +0xc4 hue, +0xc8 saturation, +0xcc value, +0x248 contrast. Across the 86 areas hue is 0-8.7 +// (degrees), value 0.2-4 and contrast 0.983-1.069 - steep, so its setting stays within 5%. +constexpr std::size_t kHue = 0xc4, kValue = 0xcc, kContrast = 0x248; constexpr std::size_t kYebisHeld = 0x2710; // on the YEBIS object: the block was not refreshed std::uint8_t* g_post_block = nullptr; @@ -51,8 +59,29 @@ const std::uint8_t kShadowSetupPrologue[] = {0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, constexpr std::size_t kShadowBlock = 0x80, kShadowHeld = 0x297c; // on the renderer constexpr std::size_t kShadowScaled[] = {0x38, 0x3c, 0x44, 0x48}; +// bbport: fog, after bbhost graphics_patch.cpp (droogie, later than 7c790536). The fog manager +// (graphics manager +0x5888) keeps one 0x60-byte block per fog the area defines, in the form the +// shaders read; floats 0, 8 and 12 are its opacities (the fog colour's alpha times the area's +// fogAlpha, and the height fog's). sub_26da690 rebuilds the table from the area's parameters +// every time and ends with a tail call - `jmp sub_122e6b0` at 0x26daadf, the table in rdx - that +// copies it to the GPU, so the scaling goes between the two. Another exit path follows that jump +// at once, so the jump is pointed at the 13 bytes of padding after the function, which jump on +// to the stub; sub_122e6b0 is shared with two other managers' uploads, which is why the jump. +constexpr std::uint64_t kGraphicsManager = 0x5940dd8, kFogUploadJump = 0x26daadf, kFogPadding = 0x26daaf3, + kTableUpload = 0x122e6b0; +constexpr std::size_t kFogManager = 0x5888, kFogMap = 0x248, kFogTable = 0x250, kFogCount = 0x258, kFogBlock = 0x60; +constexpr int kFogOpacity[] = {0, 8, 12}; +const std::uint8_t kFogPaddingBytes[] = {0x66, 0x66, 0x66, 0x66, 0x2e, 0x0f, 0x1f, 0x84, 0, 0, 0, 0, 0}; +std::uint8_t* g_graphics_manager = nullptr; // the pointer's slot in the image + template bool Read(std::uint64_t at,T* out) { return at && host_read_safe(reinterpret_cast(at),out,sizeof(T)); } template void Write(std::uint64_t at,const T& value) { std::memcpy(reinterpret_cast(at),&value,sizeof(T)); } +// bbport: the hooks are installed whatever Picture tuning is (graphics_controls), which they +// read every call: off, each returns at once and the game's own values stand - so the switch +// and every setting are live. +bool On() { return BbSettings::Get().graphics_controls.load(std::memory_order_relaxed); } +// SSAO and AA are effects with their own settings (Picture tuning doesn't gate them): written +// every view, so switching Picture tuning off leaves nothing forced behind. GUEST_ABI std::int64_t RenderView(std::uint64_t,const std::uint64_t* saved) { const auto r=saved[0];std::uint64_t caps=0; if (!r || !Read(r+kCapabilities,&caps)) return 0; @@ -62,6 +91,7 @@ GUEST_ABI std::int64_t RenderView(std::uint64_t,const std::uint64_t* saved) { return 0; } GUEST_ABI std::int64_t AoDraw(std::uint64_t,const std::uint64_t* saved) { + if (!On()) return 0; const auto object=saved[5];const float k=BbSettings::Get().graphics_ao_strength; std::uint8_t held=1;float v=0,o=0; if (!object || k==1 || !Read(object+kSsaoHeld,&held) || held || @@ -70,6 +100,7 @@ GUEST_ABI std::int64_t AoDraw(std::uint64_t,const std::uint64_t* saved) { return 0; } GUEST_ABI std::int64_t ShadowSetup(std::uint64_t,const std::uint64_t* saved) { + if (!On()) return 0; const auto block=saved[4];const float k=BbSettings::Get().graphics_shadow_scale; std::uint8_t held=1; if (!block || k==1 || !Read(block+kShadowHeld-kShadowBlock,&held) || held) return 0; @@ -77,20 +108,42 @@ GUEST_ABI std::int64_t ShadowSetup(std::uint64_t,const std::uint64_t* saved) { return 0; } GUEST_ABI std::int64_t YebisRecord(std::uint64_t,const std::uint64_t* saved) { + if (!On()) return 0; const auto object=saved[5];std::uint8_t held=1; if (!object || !g_post_block || !Read(object+kYebisHeld,&held) || held) return 0; const auto& s=BbSettings::Get(); const auto block=reinterpret_cast(g_post_block); - float glare=0,saturation=0,power=0; - if (s.graphics_bloom!=1 && Read(block+kGlareLuminance,&glare) && std::isfinite(glare)) Write(block+kGlareLuminance,glare*s.graphics_bloom); - if (s.graphics_saturation!=1 && Read(block+kSaturation,&saturation) && std::isfinite(saturation)) Write(block+kSaturation,saturation*s.graphics_saturation); + float glare=0,saturation=0,power=0,hue=0,value=0,contrast=0; + // Typed values (Ctrl+click) are kept within bbport_settings.cpp's limits here too. + const float bloom=std::clamp(s.graphics_bloom.load(),0.0f,10.0f),sat=std::clamp(s.graphics_saturation.load(),0.0f,5.0f); + const float hue_offset=std::clamp(s.graphics_hue.load(),-180.0f,180.0f),bright=std::clamp(s.graphics_brightness.load(),0.0f,5.0f); + const float contrast_scale=std::clamp(s.graphics_contrast.load(),0.5f,1.5f); + if (bloom!=1 && Read(block+kGlareLuminance,&glare) && std::isfinite(glare)) Write(block+kGlareLuminance,glare*bloom); + if (sat!=1 && Read(block+kSaturation,&saturation) && std::isfinite(saturation)) Write(block+kSaturation,saturation*sat); if (!s.graphics_vignette && Read(block+kVignettePower,&power)) Write(block+kVignettePower,0.0f); + if (hue_offset!=0 && Read(block+kHue,&hue) && std::isfinite(hue)) Write(block+kHue,hue+hue_offset); + if (bright!=1 && Read(block+kValue,&value) && std::isfinite(value)) Write(block+kValue,value*bright); + if (contrast_scale!=1 && Read(block+kContrast,&contrast) && std::isfinite(contrast)) Write(block+kContrast,contrast*contrast_scale); + return 0; +} +// At the fog table's upload: rdi the id map, rdx the table, both checked against the fog +// manager before anything is written. +GUEST_ABI std::int64_t FogUpload(std::uint64_t,const std::uint64_t* saved) { + const float k=std::clamp(BbSettings::Get().graphics_fog.load(),0.0f,5.0f); + std::uint64_t manager=0,fog=0,map=0,table=0;std::uint32_t count=0; + if (!On() || k==1 || !std::isfinite(k) || !Read(reinterpret_cast(g_graphics_manager),&manager) || + !Read(manager+kFogManager,&fog) || !Read(fog+kFogMap,&map) || !Read(fog+kFogTable,&table) || + !Read(fog+kFogCount,&count) || map!=saved[5] || table!=saved[3] || !table || !count || count>4096) return 0; + auto* blocks=reinterpret_cast(table); + for (std::uint32_t i=0;i1 ? std::min(opacity*k,std::max(opacity,1.0f)) : opacity*k; // more fog: up to opaque + } return 0; } } bool Enabled() { return enabled; } bool Install(std::uint8_t* image,std::size_t size) { - if (!BbSettings::Get().graphics_controls) return false; Hook hooks[4]; if (size<0x55406e0+0x370 || !Prepare(hooks[0],image,size,kRenderView-0x400000,kRenderViewPrologue,RenderView) || !Prepare(hooks[1],image,size,kYebisRecord-0x400000,kYebisRecordPrologue,YebisRecord) || @@ -100,8 +153,23 @@ bool Install(std::uint8_t* image,std::size_t size) { host_log("live graphics disabled: engine bytes differ; all original paths retained");return false; } g_post_block=image+0x55406e0; + // Fog: the jump and the padding checked, the stub made, before anything is written. + const std::size_t jump=kFogUploadJump-0x400000,padding=kFogPadding-0x400000,upload=kTableUpload-0x400000; + std::uint8_t* fog=nullptr;std::int32_t rel=0; + if (size>padding+sizeof(kFogPaddingBytes)) std::memcpy(&rel,image+jump+1,4); + if (size>padding+sizeof(kFogPaddingBytes) && image[jump]==0xe9 && static_cast(jump+5)+rel==static_cast(upload) && + !std::memcmp(image+padding,kFogPaddingBytes,sizeof(kFogPaddingBytes))) + fog=TailStub(FogUpload,reinterpret_cast(image+upload)); Commit(hooks);enabled=true; - host_log("bbhost live graphics enabled: AA, SSAO, AO strength, shadow distance, bloom, saturation and vignette"); + if (fog) { + g_graphics_manager=image+(kGraphicsManager-0x400000); + std::uint8_t* p=image+padding;*p++=0x49;*p++=0xbb; // movabs r11, stub; jmp r11 + const auto target=reinterpret_cast(fog);std::memcpy(p,&target,8);p+=8;*p++=0x41;*p++=0xff;*p=0xe3; + const std::int32_t to_padding=static_cast(padding-(jump+5));std::memcpy(image+jump+1,&to_padding,4); + } + host_log("bbhost live graphics enabled (Picture tuning %s, live): AA, SSAO, AO strength, shadow distance, bloom, " + "saturation, vignette, brightness, contrast, hue%s",On() ? "on" : "off", + fog ? " and fog" : " (fog: engine bytes differ, left alone)"); return true; } } diff --git a/gpu/shim/engine/key_prompts.cpp b/gpu/shim/engine/key_prompts.cpp new file mode 100644 index 0000000..dfde0a2 --- /dev/null +++ b/gpu/shim/engine/key_prompts.cpp @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "engine/key_prompts.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "bbport_settings.h" +#include "engine/keycap_art.h" +#include "input/bindings.h" +#include "input/compat.h" +#include "input/input.h" + +namespace KeyPrompts { +namespace { + +static_assert(kIconBytes == Keycap::kBytes); + +// The game's icons, as on the US disc: FNV-1a 64 of each one's 0x400 bytes. The names follow the +// Japanese convention: KG_OK draws Circle and KG_Cancel Cross. +enum Icon : int { + kCross, kL1, kL2, kL3, kLeftStick, kDpadDown, kDpadLeftRight, kDpadUp, kDpadUpDown, kDpad, + kCircle, kR1, kR2, kR3, kRightStick, kSquare, kTriangle, kOptions, kTouchLeft, kTouchRight, + kIconCount, +}; +constexpr std::uint64_t kHashes[kIconCount] = { + 0x2310f7c71b939e0bull, // KG_Cancel + 0xdf0f0104031e5f67ull, // KG_L1 + 0xfadfdba7de6c76b0ull, // KG_L2 + 0xd210779fe3f51673ull, // KG_L3 + 0xcc75c8c3bfbf5b35ull, // KG_LS + 0xce0de7ced6567e92ull, // KG_L_D + 0x294394b7ad99f129ull, // KG_L_LR + 0xaa338448add2c9f0ull, // KG_L_U + 0xad221c010b1f4101ull, // KG_L_UD + 0x57d112cbe3da49b1ull, // KG_L_UDLR + 0xed95e3b0221d51d1ull, // KG_OK + 0x665da1b194608b8aull, // KG_R1 + 0x73f2b5572f49f886ull, // KG_R2 + 0xe0848ecdcdd5582dull, // KG_R3 + 0x03b41e07b779abf7ull, // KG_RS + 0x51f55dd737e72648ull, // KG_R_L + 0xeb8fef4557aea451ull, // KG_R_U + 0xadd716f21d77cfdfull, // KG_Start + 0xce3869fe20faa939ull, // KG_TP_L + 0x9376eea6c514d1fbull, // KG_TP_R +}; + +constexpr std::uint32_t kPadCross = 0x4000, kPadCircle = 0x2000; + +std::uint64_t Fnv(const void* data, std::size_t size) { + std::uint64_t h = 0xcbf29ce484222325ull; + for (std::size_t i = 0; i < size; ++i) { + h = (h ^ static_cast(data)[i]) * 0x100000001b3ull; + } + return h; +} + +struct State { + bool keys = false; // keycaps shown + bool menu = false; // a menu owns the screen (menu_pointer_in_menu) + std::uint32_t confirm = 0; // the pad button that confirms there + bool mouse_camera = false; + std::uint64_t serial = 0; // host_bindings_serial + bool xbox = false; // the controller icons as an Xbox controller's buttons + bool operator==(const State&) const = default; +}; + +// Chosen, or (auto) while the gamepad in use is not a PlayStation one: a controller in DirectInput +// mode (an 8BitDo in D-input) reports as a generic gamepad, not as an Xbox one, and kept the +// PlayStation icons. No gamepad: the game's own. +bool XboxIcons() { + switch (BbSettings::Get().pad_icons.load(std::memory_order_relaxed)) { + case BbSettings::PadIconsXbox: return true; + case BbSettings::PadIconsPlayStation: return false; + default: { + const int type = runtime_pad_type(); + return type != SDL_GAMEPAD_TYPE_UNKNOWN && type != SDL_GAMEPAD_TYPE_PS3 && + type != SDL_GAMEPAD_TYPE_PS4 && type != SDL_GAMEPAD_TYPE_PS5; + } + } +} + +State Current() { + State s; + const auto& settings = BbSettings::Get(); + const int mode = settings.button_prompts.load(std::memory_order_relaxed); + s.keys = settings.pc_controls && (mode == BbSettings::PromptsKeyboard || + (mode == BbSettings::PromptsAuto && + host_input_device() == InputDevice::KeyboardMouse)); + if (s.keys) { + s.menu = menu_pointer_in_menu(); + s.confirm = menu_confirm_button(); + s.mouse_camera = settings.mouse_camera; + s.serial = host_bindings_serial(); + } else { + s.xbox = XboxIcons(); + } + return s; +} + +// What a cap says for a key: short, since it is at most five letters on a 32-pixel icon. +std::string KeyLabel(int scancode) { + const char* name = SDL_GetScancodeName(static_cast(scancode)); + if (!name || !*name) { + return "?"; + } + static constexpr std::pair kShort[] = { + {"Left Shift", "Shift"}, {"Right Shift", "Shift"}, {"Left Ctrl", "Ctrl"}, {"Right Ctrl", "Ctrl"}, + {"Left Alt", "Alt"}, {"Right Alt", "Alt"}, {"Left GUI", "Win"}, {"Right GUI", "Win"}, + {"Return", "Enter"}, {"Keypad Enter", "Enter"}, {"Escape", "Esc"}, {"Backspace", "Bksp"}, + {"CapsLock", "Caps"}, {"Delete", "Del"}, {"Insert", "Ins"}, {"PageUp", "PgUp"}, + {"PageDown", "PgDn"}, {"PrintScreen", "PrtSc"}, {"ScrollLock", "ScrLk"}, {"Numlock", "NumLk"}, + {"`", "~"}, // a lone backtick is a speck + }; + for (const auto& [from, to] : kShort) { + if (from == name) { + return to; + } + } + std::string label = name; + if (label.rfind("Keypad ", 0) == 0) { + label = "Num" + label.substr(7); + } + return label.size() > 5 ? label.substr(0, 5) : label; +} + +// One cap of several. +std::string ActionLabel(int action) { + if (const int key = host_binding_key(action)) { + return KeyLabel(key); + } + static constexpr const char* kMouse[] = {"-", "LMB", "RMB", "MMB", "M4", "M5", "WhUp", "WhDn"}; + const int m = host_binding_mouse(action); + return kMouse[m >= 0 && m <= 7 ? m : 0]; +} + +// The key bound to an action, else its mouse button; nothing when it has neither (the game's +// own icon stays). +std::optional ActionSpec(int action) { + using Keycap::Spec; + if (const int key = host_binding_key(action)) { + return Spec{Spec::Key, {KeyLabel(key)}}; + } + const int m = host_binding_mouse(action); + if (m == 4 || m == 5) { + return Spec{Spec::Key, {m == 4 ? "M4" : "M5"}}; + } + if (m >= 1 && m <= 7) { + return Spec{Spec::Mouse, {}, m}; + } + return std::nullopt; +} + +Keycap::Spec Caps(std::initializer_list actions) { + Keycap::Spec spec{Keycap::Spec::Keys, {}}; + for (const int action : actions) { + spec.labels.push_back(ActionLabel(action)); + } + return spec; +} + +// The gamepad button bound to the icon's PS4 button (runtime_pad.c), as an Xbox controller's. +// The d-pad and the sticks keep the game's icons, which fit any controller. +std::optional PadSpec(int icon) { + using namespace Keycap; + static constexpr struct { + Icon icon; + std::uint32_t bit; // scePad's + } kButtons[] = { + {kCross, kPadCross}, {kCircle, kPadCircle}, {kSquare, 0x8000}, {kTriangle, 0x1000}, + {kL1, 0x400}, {kR1, 0x800}, {kL2, 0x100}, {kR2, 0x200}, + {kL3, 0x2}, {kR3, 0x4}, {kOptions, 0x8}, {kTouchLeft, 0x100000}, + {kTouchRight, 0x100000}, + }; + const auto it = std::find_if(std::begin(kButtons), std::end(kButtons), [&](const auto& b) { return b.icon == icon; }); + if (it == std::end(kButtons)) { + return std::nullopt; + } + int button; + switch (runtime_pad_button(it->bit, icon == kTouchRight)) { + case SDL_GAMEPAD_BUTTON_SOUTH: button = PadA; break; + case SDL_GAMEPAD_BUTTON_EAST: button = PadB; break; + case SDL_GAMEPAD_BUTTON_WEST: button = PadX; break; + case SDL_GAMEPAD_BUTTON_NORTH: button = PadY; break; + case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: button = PadLB; break; + case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: button = PadRB; break; + case SDL_GAMEPAD_BUTTON_COUNT: button = PadLT; break; + case SDL_GAMEPAD_BUTTON_COUNT + 1: button = PadRT; break; + case SDL_GAMEPAD_BUTTON_LEFT_STICK: button = PadLS; break; + case SDL_GAMEPAD_BUTTON_RIGHT_STICK: button = PadRS; break; + case SDL_GAMEPAD_BUTTON_BACK: button = PadView; break; + case SDL_GAMEPAD_BUTTON_START: button = PadMenu; break; + case SDL_GAMEPAD_BUTTON_MISC1: button = PadShare; break; + case SDL_GAMEPAD_BUTTON_DPAD_UP: button = PadUp; break; + case SDL_GAMEPAD_BUTTON_DPAD_DOWN: button = PadDown; break; + case SDL_GAMEPAD_BUTTON_DPAD_LEFT: button = PadLeft; break; + case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: button = PadRight; break; + case SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1: button = PadP1; break; + case SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2: button = PadP2; break; + case SDL_GAMEPAD_BUTTON_LEFT_PADDLE1: button = PadP3; break; + case SDL_GAMEPAD_BUTTON_LEFT_PADDLE2: button = PadP4; break; + case SDL_GAMEPAD_BUTTON_TOUCHPAD: button = PadTouchpad; break; + case SDL_GAMEPAD_BUTTON_GUIDE: button = PadGuide; break; + default: return std::nullopt; // unbound + } + return Spec{Spec::Pad, {}, button}; +} + +std::optional IconSpec(int icon, const State& s) { + using Keycap::Spec; + if (!s.keys) { + return s.xbox ? PadSpec(icon) : std::nullopt; + } + // In a menu the face buttons that confirm and go back are Menu Confirm's and Menu Back's + // keys, and the d-pad is the arrow keys (input.cpp). + if (s.menu) { + switch (icon) { + case kCross: return ActionSpec(s.confirm == kPadCross ? kBindConfirm : kBindBack); + case kCircle: return ActionSpec(s.confirm == kPadCircle ? kBindConfirm : kBindBack); + case kDpadUp: return Spec{Spec::Key, {"Up"}}; + case kDpadDown: return Spec{Spec::Key, {"Down"}}; + case kDpadLeftRight: return Spec{Spec::Keys, {"Left", "Right"}}; + case kDpadUpDown: return Spec{Spec::Keys, {"Up", "Down"}}; + case kDpad: return Spec{Spec::Keys, {"Up", "Left", "Down", "Right"}}; + default: break; + } + } + switch (icon) { + case kCross: return ActionSpec(kBindInteract); + case kCircle: return ActionSpec(kBindRoll); + case kTriangle: return ActionSpec(kBindBloodVial); + case kSquare: return ActionSpec(kBindUseItem); + case kR1: return ActionSpec(kBindAttack); + case kR2: return ActionSpec(kBindStrong); + case kL1: return ActionSpec(kBindTransform); + case kL2: return ActionSpec(kBindFirearm); + case kR3: return ActionSpec(kBindLockOn); + case kL3: return ActionSpec(kBindL3); + case kOptions: return ActionSpec(kBindMenu); + case kTouchLeft: return ActionSpec(kBindGestures); + case kTouchRight: return ActionSpec(kBindEffects); + case kDpadUp: return ActionSpec(kBindDpadUp); + case kDpadDown: return ActionSpec(kBindSwitchItem); + case kDpadLeftRight: return Caps({kBindSwitchLeft, kBindSwitchRight}); + case kDpadUpDown: return Caps({kBindDpadUp, kBindSwitchItem}); + case kDpad: return Caps({kBindDpadUp, kBindSwitchLeft, kBindSwitchItem, kBindSwitchRight}); + case kLeftStick: return Caps({kBindMoveF, kBindMoveL, kBindMoveB, kBindMoveR}); + case kRightStick: + if (s.mouse_camera) { + return Spec{Spec::Mouse, {}, 0}; + } + return Caps({kBindLookU, kBindLookL, kBindLookD, kBindLookR}); + default: return std::nullopt; + } +} + +std::mutex g_mutex; +State g_state; +bool g_have_state = false; +std::atomic g_logged{false}; + +// With g_mutex held. +void SetState(const State& now) { + static int shown = -1; // the controller icons last logged: 0 the game's, 1 Xbox + if (!now.keys && shown != int(now.xbox)) { + shown = int(now.xbox); + host_log("button prompts: %s controller icons", now.xbox ? "Xbox" : "the game's (PlayStation)"); + } + g_state = now; + g_have_state = true; +} +// Drawn keycaps by what they are for and Spec::Id(), kept for the session (a few dozen at most). +// Never destroyed, so no late upload outlives it. +auto& g_drawn = *new std::unordered_map>; + +// The HUD sheet (MENU_Common_00091, 1024x512 BC7, one mip, PS4 thin tiled): its Circle, Cross +// and L3, 26 pixels each with transparent blocks between them. Each is redrawn over its own +// 7x8 blocks; the sheet is recognised by those blocks alone (FNV-1a 64 of them in this order), +// so a mod's changes elsewhere on it stay. +constexpr int kHud = kIconCount; +constexpr std::uint64_t kHudHash = 0x08d886c6323fa447ull; +constexpr std::uint32_t kHudWidth = 1024, kHudHeight = 512; +constexpr int kHudBlockY = 30, kHudBlocksX = 7, kHudBlocksY = 8; // y 120..151 +constexpr struct { + Icon icon; + int block_x; // the first block column + int left; // the button's left edge in it +} kHudButtons[] = {{kCircle, 165, 1}, {kCross, 173, 2}, {kL3, 181, 2}}; +constexpr int kHudTop = 3; // the buttons' top edge in the first block row + +// Where block (bx, by) of the sheet is: tiles of 8x8 blocks, a row of them after another, each +// in Morton order (x in the even bits). +std::size_t HudBlock(int bx, int by) { + const int tile = (by >> 3) * int(kHudWidth / 32) + (bx >> 3); + int morton = 0; + for (int k = 0; k < 3; ++k) { + morton |= ((bx >> k) & 1) << (2 * k) | ((by >> k) & 1) << (2 * k + 1); + } + return std::size_t(tile * 64 + morton) * 16; +} + +std::uint64_t HudHash(const std::uint8_t* data) { + std::uint64_t h = 0xcbf29ce484222325ull; + for (const auto& button : kHudButtons) { + for (int by = kHudBlockY; by < kHudBlockY + kHudBlocksY; ++by) { + for (int bx = button.block_x; bx < button.block_x + kHudBlocksX; ++bx) { + const std::uint8_t* b = data + HudBlock(bx, by); + for (int i = 0; i < 16; ++i) { + h = (h ^ b[i]) * 0x100000001b3ull; + } + } + } + } + return h; +} + +// With g_mutex held. A KG_* icon (kIconBytes, tiled) or a HUD button's blocks (row by row). +const std::vector& Drawn(const Keycap::Spec& spec, int hud_left) { + const std::string id = (hud_left < 0 ? std::string("kg|") : "hud" + std::to_string(hud_left) + '|') + spec.Id(); + auto it = g_drawn.find(id); + if (it != g_drawn.end()) { + return it->second; + } + std::vector out; + if (hud_left < 0) { + std::uint8_t rgba[Keycap::kSize * Keycap::kSize * 4]; + Keycap::Draw(spec, rgba); + out.resize(kIconBytes); + Keycap::Encode(rgba, out.data()); + } else { + constexpr int size = 26, w = kHudBlocksX * 4, h = kHudBlocksY * 4; + std::uint8_t cap[size * size * 4]; + Keycap::Draw(spec, cap, size); + std::vector region(std::size_t(w) * h * 4, 0); + for (int y = 0; y < size; ++y) { + std::memcpy(®ion[(std::size_t(y + kHudTop) * w + hud_left) * 4], &cap[y * size * 4], size * 4); + } + out.resize(std::size_t(kHudBlocksX) * kHudBlocksY * 16); + Keycap::EncodeBlocks(region.data(), w, h, out.data()); + } + return g_drawn.emplace(id, std::move(out)).first->second; +} + +void LogFound() { + if (!g_logged.exchange(true)) { + const auto& settings = BbSettings::Get(); + host_log("button prompts: the game's button icons found (button_prompts = %s, pad_icons = %s)", + BbSettings::ButtonPromptsNames[std::clamp(settings.button_prompts.load(), 0, BbSettings::PromptsCount - 1)], + BbSettings::PadIconsNames[std::clamp(settings.pad_icons.load(), 0, BbSettings::PadIconsCount - 1)]); + } +} + +} // namespace + +int Identify(const void* data, std::size_t size, std::uint32_t width, std::uint32_t height) { + if (!data) { + return -1; + } + if (size == kIconBytes && width == 32 && height == 32) { + const std::uint64_t hash = Fnv(data, size); + for (int icon = 0; icon < kIconCount; ++icon) { + if (kHashes[icon] == hash) { + LogFound(); + return icon; + } + } + } else if (size == kHudBytes && width == kHudWidth && height == kHudHeight && + HudHash(static_cast(data)) == kHudHash) { + LogFound(); + return kHud; + } + return -1; +} + +bool Draw(int texture, std::uint8_t* data, std::size_t size) { + if (texture < 0 || texture > kHud || size != (texture == kHud ? kHudBytes : kIconBytes)) { + return false; + } + std::scoped_lock lock{g_mutex}; + if (!g_have_state) { + SetState(Current()); + } + if (!g_state.keys && !g_state.xbox) { + return false; + } + if (texture != kHud) { + const auto spec = IconSpec(texture, g_state); + if (!spec) { + return false; + } + std::memcpy(data, Drawn(*spec, -1).data(), kIconBytes); + return true; + } + bool drawn = false; + for (const auto& button : kHudButtons) { + const auto spec = IconSpec(button.icon, g_state); + if (!spec) { + continue; + } + const std::uint8_t* blocks = Drawn(*spec, button.left).data(); + for (int by = 0; by < kHudBlocksY; ++by) { + for (int bx = 0; bx < kHudBlocksX; ++bx) { + std::memcpy(data + HudBlock(button.block_x + bx, kHudBlockY + by), blocks, 16); + blocks += 16; + } + } + drawn = true; + } + return drawn; +} + +bool Changed() { + const State now = Current(); + std::scoped_lock lock{g_mutex}; + if (!g_have_state) { + SetState(now); + return false; + } + if (now == g_state) { + return false; + } + SetState(now); + return true; +} + +} // namespace KeyPrompts diff --git a/gpu/shim/engine/key_prompts.h b/gpu/shim/engine/key_prompts.h new file mode 100644 index 0000000..0a77be4 --- /dev/null +++ b/gpu/shim/engine/key_prompts.h @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +// Button prompts for the keyboard and mouse. The game draws its button prompts from two places in +// menu/common.tpf: 20 icons of their own (KG_*: 32x32 BC7 textures - the key guides, the +// tutorial notes, the menus) and three 26-pixel buttons on a HUD sheet (MENU_Common_00091: +// Circle, Cross and L3 - the world prompts such as "Examine" and the dialog boxes). While the +// keyboard or mouse is in use the texture cache uploads keycaps over them (engine/keycap_art.h): +// the key, or the mouse button, bound to what that pad button does there - in a menu, Menu +// Confirm and Menu Back for the face buttons that confirm and go back. The pad, or +// button_prompts = controller, brings the controller's icons back: the game's own (PlayStation), +// or with a controller other than a PlayStation one in use (pad_icons = auto) or pad_icons = xbox, the Xbox button bound +// to each PS4 button (src/runtime_pad.c), drawn the same way. Nothing in the game's memory +// changes. + +#include +#include + +namespace KeyPrompts { + +constexpr std::size_t kIconBytes = 0x400, kHudBytes = 0x80000; + +/// Which of the game's textures with button icons this is, or -1. +int Identify(const void* data, std::size_t size, std::uint32_t width, std::uint32_t height); + +/// Draws the keycaps or Xbox buttons over `data`, a copy of that texture's data about to be +/// uploaded (the game's format and tiling). False, and `data` unchanged, while the game's own +/// icons show. +bool Draw(int texture, std::uint8_t* data, std::size_t size); + +/// Once a frame (Rasterizer::OnSubmit): whether what the icons show changed since the last call +/// (the device or gamepad in use, a menu opened or closed, a binding, the settings), so that the +/// texture cache uploads them again. +bool Changed(); + +} // namespace KeyPrompts diff --git a/gpu/shim/engine/keycap_art.cpp b/gpu/shim/engine/keycap_art.cpp new file mode 100644 index 0000000..d91da06 --- /dev/null +++ b/gpu/shim/engine/keycap_art.cpp @@ -0,0 +1,808 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "engine/keycap_art.h" + +#include +#include +#include +#include +#include + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-function" +#define STBTT_STATIC +#define STB_TRUETYPE_IMPLEMENTATION +#include "imstb_truetype.h" +#pragma GCC diagnostic pop + +// Roboto Medium (third_party/imgui/misc/fonts, Apache License 2.0), embedded as the overlay embeds +// its font (bbport_overlay.cpp). +#ifdef _WIN32 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wpedantic" +alignas(16) static const unsigned char bb_keycap_font[] = { +#embed BB_KEYCAP_FONT_PATH +}; +#pragma GCC diagnostic pop +#else +asm(".section .rodata\n" + ".balign 16\n" + ".hidden bb_keycap_font\n" + ".global bb_keycap_font\n" + "bb_keycap_font:\n" + ".incbin \"" BB_KEYCAP_FONT_PATH "\"\n" + ".previous\n"); +extern "C" const unsigned char bb_keycap_font[]; +#endif + +namespace Keycap { + +std::string Spec::Id() const { + std::string id(1, char('0' + kind)); + for (const auto& label : labels) { + id += '|'; + id += label; + } + return id + '|' + std::to_string(button); +} + +namespace { + +// Drawn at 4x4 samples a pixel; a sample is in or out of a shape, or covered by a glyph as +// stb_truetype rasterizes it. +constexpr int S = 4; + +struct Color { + float r, g, b, a; +}; +constexpr Color Rgb(std::uint32_t rgb, float a = 1.0f) { + return {float((rgb >> 16) & 0xff) / 255.0f, float((rgb >> 8) & 0xff) / 255.0f, + float(rgb & 0xff) / 255.0f, a}; +} +// From KG_R1: the face, the rims (light along the top, dark along the bottom and the right), +// the line under the top rim, the shadow under the cap, the lettering. +constexpr Color kFace = Rgb(0x2f2f2f), kRimTop = Rgb(0x7a7a7a), kRimSide = Rgb(0x4a4a4a), + kRimDark = Rgb(0x1e1e1e), kUnderRim = Rgb(0x262626), kShadow = Rgb(0, 0.42f), + kInk = Rgb(0xc3a171), kOutline = Rgb(0x8a8a8a); + +// Shapes are laid out on 32 units; a canvas shows them at its own size, `scale` pixels a unit +// from (ox, oy) - the game's 26-pixel HUD buttons show a window around the square cap. +struct Canvas { + int size, n; // pixels, samples + float scale, ox, oy; + std::vector px; // premultiplied + + Canvas(int size_, float scale_, float ox_, float oy_) + : size(size_), n(size_ * S), scale(scale_), ox(ox_), oy(oy_), px(std::size_t(n) * n * 4, 0.0f) {} + + void Blend(int x, int y, Color c, float coverage) { + const float a = c.a * std::min(coverage, 1.0f); + if (a <= 0.0f) { + return; + } + float* p = &px[(std::size_t(y) * n + x) * 4]; + p[0] = c.r * a + p[0] * (1.0f - a); + p[1] = c.g * a + p[1] * (1.0f - a); + p[2] = c.b * a + p[2] * (1.0f - a); + p[3] = a + p[3] * (1.0f - a); + } +}; + +// Samples per unit: how sharp shape edges are (Paint sets it for its canvas). +thread_local float t_samples = S; + +// `coverage(x, y)` at each sample, in units. +template +void Paint(Canvas& c, Color color, Coverage coverage) { + t_samples = S * c.scale; + for (int y = 0; y < c.n; ++y) { + for (int x = 0; x < c.n; ++x) { + const float cover = coverage(((float(x) + 0.5f) / S - c.ox) / c.scale, + ((float(y) + 0.5f) / S - c.oy) / c.scale); + if (cover > 0.0f) { + c.Blend(x, y, color, cover); + } + } + } +} + +struct Box { + float x0, y0, x1, y1, r; // r: the corners' radius +}; + +float Distance(const Box& b, float x, float y) { + const float hx = (b.x1 - b.x0) / 2 - b.r, hy = (b.y1 - b.y0) / 2 - b.r; + const float qx = std::abs(x - (b.x0 + b.x1) / 2) - hx, qy = std::abs(y - (b.y0 + b.y1) / 2) - hy; + return std::hypot(std::max(qx, 0.0f), std::max(qy, 0.0f)) + std::min(std::max(qx, qy), 0.0f) - b.r; +} + +float Inside(const Box& b, float x, float y) { + return std::clamp(0.5f - Distance(b, x, y) * t_samples, 0.0f, 1.0f); +} + +Box Inset(Box b, float left, float top, float right, float bottom) { + b.x0 += left; + b.y0 += top; + b.x1 -= right; + b.y1 -= bottom; + b.r = std::max(b.r - std::max(left, top), 0.5f); + return b; +} + +Box Offset(Box b, float dx, float dy) { + b.x0 += dx; + b.x1 += dx; + b.y0 += dy; + b.y1 += dy; + return b; +} + +void DrawCap(Canvas& c, const Box& cap) { + const Box shadow = Offset(Inset(cap, 0.3f, 0.0f, 0.3f, 0.0f), 0.0f, 1.1f); + Paint(c, kShadow, [&](float x, float y) { return Inside(shadow, x, y); }); + Paint(c, kRimDark, [&](float x, float y) { return Inside(cap, x, y); }); + const Box side = Inset(cap, 0.0f, 0.0f, 0.8f, 1.0f); + Paint(c, kRimSide, [&](float x, float y) { return Inside(side, x, y); }); + Paint(c, kRimTop, [&](float x, float y) { return y < cap.y0 + 1.0f ? Inside(side, x, y) : 0.0f; }); + const Box under = Inset(cap, 0.9f, 1.0f, 0.9f, 1.0f); + Paint(c, kUnderRim, [&](float x, float y) { return Inside(under, x, y); }); + const Box face = Inset(cap, 0.9f, 1.8f, 0.9f, 1.0f); + Paint(c, kFace, [&](float x, float y) { return Inside(face, x, y); }); +} + +// An arrow `size` pixels long centred on (cx, cy): 0 up, 1 right, 2 down, 3 left. +void DrawArrow(Canvas& c, float cx, float cy, float size, int dir, Color color) { + const float head = size * 0.5f, half_head = size * 0.45f, half_shaft = size * 0.14f; + Paint(c, color, [&](float x, float y) { + // u across the arrow, v from its tip (0) to its tail (size). + float u = x - cx, v = y - cy; + switch (dir) { + case 1: std::swap(u, v); v = -v; break; + case 2: v = -v; break; + case 3: std::swap(u, v); break; + default: break; + } + v += size / 2; + if (v < 0.0f || v > size) { + return 0.0f; + } + if (v <= head) { + return std::abs(u) <= half_head * v / head ? 1.0f : 0.0f; + } + return std::abs(u) <= half_shaft ? 1.0f : 0.0f; + }); +} + +int ArrowDir(const std::string& label) { + return label == "Up" ? 0 : label == "Right" ? 1 : label == "Down" ? 2 : label == "Left" ? 3 : -1; +} + +std::vector Utf8(const std::string& s) { + std::vector out; + for (std::size_t i = 0; i < s.size();) { + const auto b = static_cast(s[i]); + const int len = b < 0x80 ? 1 : b >> 5 == 6 ? 2 : b >> 4 == 14 ? 3 : b >> 3 == 30 ? 4 : 1; + int cp = len == 1 ? b : b & (0x7f >> len); + for (int k = 1; k < len && i + k < s.size(); ++k) { + cp = (cp << 6) | (static_cast(s[i + k]) & 0x3f); + } + out.push_back(cp); + i += len; + } + return out; +} + +struct Font { + stbtt_fontinfo info{}; + bool ok = false; + float cap_height = 1.0f; // font units + + Font() { + ok = stbtt_InitFont(&info, bb_keycap_font, stbtt_GetFontOffsetForIndex(bb_keycap_font, 0)) != 0; + int x0, y0, x1, y1; + if (ok && stbtt_GetCodepointBox(&info, 'H', &x0, &y0, &x1, &y1)) { + cap_height = float(y1); + } + } +}; + +const Font& GetFont() { + static const Font font; + return font; +} + +// `text` centred on (cx, cy), its capitals `cap` pixels tall, smaller if it is wider than `width`. +void DrawText(Canvas& c, const std::string& text, float cx, float cy, float width, float cap, Color color = kInk) { + const Font& font = GetFont(); + const auto cps = Utf8(text); + if (!font.ok || cps.empty()) { + return; + } + // The ink's extent at the font's own scale. + float pen = 0.0f, left = 1e9f, right = -1e9f, top = -1e9f, bottom = 1e9f; + bool capitals = false; + std::vector at(cps.size()); + for (std::size_t i = 0; i < cps.size(); ++i) { + int advance, bearing, x0, y0, x1, y1; + stbtt_GetCodepointHMetrics(&font.info, cps[i], &advance, &bearing); + at[i] = pen; + if (stbtt_GetCodepointBox(&font.info, cps[i], &x0, &y0, &x1, &y1)) { + left = std::min(left, pen + float(x0)); + right = std::max(right, pen + float(x1)); + bottom = std::min(bottom, float(y0)); + top = std::max(top, float(y1)); + } + capitals |= (cps[i] >= 'A' && cps[i] <= 'Z') || (cps[i] >= '0' && cps[i] <= '9'); + pen += float(advance); + if (i + 1 < cps.size()) { + pen += float(stbtt_GetCodepointKernAdvance(&font.info, cps[i], cps[i + 1])); + } + } + if (right <= left) { + return; + } + const float scale = std::min(cap / font.cap_height, width / (right - left)); + // Capitals sit centred; text without any (punctuation) centres its own ink. + const float baseline = capitals ? cy + font.cap_height * scale / 2 : cy + (top + bottom) / 2 * scale; + const float origin = cx - (left + right) / 2 * scale; + + const int n = c.n; + std::vector ink(std::size_t(n) * n, 0.0f); + const float hs = scale * c.scale * S; + std::vector glyph; + for (std::size_t i = 0; i < cps.size(); ++i) { + const float gx = (c.ox + (origin + at[i] * scale) * c.scale) * S; + const float gy = (c.oy + baseline * c.scale) * S; + const float fx = std::floor(gx), fy = std::floor(gy); + int x0, y0, x1, y1; + stbtt_GetCodepointBitmapBoxSubpixel(&font.info, cps[i], hs, hs, gx - fx, gy - fy, &x0, &y0, &x1, &y1); + const int w = x1 - x0, h = y1 - y0; + if (w <= 0 || h <= 0) { + continue; + } + glyph.assign(std::size_t(w) * h, 0); + stbtt_MakeCodepointBitmapSubpixel(&font.info, glyph.data(), w, h, w, hs, hs, gx - fx, gy - fy, cps[i]); + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + const int px = int(fx) + x0 + x, py = int(fy) + y0 + y; + if (px >= 0 && px < n && py >= 0 && py < n) { + float& v = ink[std::size_t(py) * n + px]; + v = std::min(1.0f, v + float(glyph[y * w + x]) / 255.0f); + } + } + } + } + // A little heavier, as the game's lettering: each sample takes its left neighbour's ink. + for (int y = 0; y < n; ++y) { + float* row = &ink[std::size_t(y) * n]; + for (int x = n - 1; x > 0; --x) { + row[x] = std::max(row[x], row[x - 1]); + } + } + for (int y = 0; y < n; ++y) { + for (int x = 0; x < n; ++x) { + c.Blend(x, y, color, ink[std::size_t(y) * n + x]); + } + } +} + +// A cap with a label: an arrow or text. +void DrawLabel(Canvas& c, const Box& cap, const std::string& label, float cap_px) { + DrawCap(c, cap); + const float cx = (cap.x0 + cap.x1) / 2, cy = (cap.y0 + cap.y1) / 2 + 0.3f; + if (const int dir = ArrowDir(label); dir >= 0) { + DrawArrow(c, cx, cy, cap_px * 1.05f, dir, kInk); + return; + } + const float w = cap.x1 - cap.x0; + DrawText(c, label, cx, cy, w - std::min(5.0f, w * 0.2f), cap_px); +} + +void DrawMouse(Canvas& c, int button) { + const Box body{7.0f, 2.0f, 25.0f, 29.0f, 8.5f}; + const Box shadow = Offset(body, 0.0f, 1.1f); + Paint(c, kShadow, [&](float x, float y) { return Inside(shadow, x, y); }); + Paint(c, kOutline, [&](float x, float y) { return Inside(body, x, y); }); + const Box face = Inset(body, 1.3f, 1.3f, 1.3f, 1.3f); + Paint(c, kFace, [&](float x, float y) { return Inside(face, x, y); }); + const float cx = 16.0f, split = 13.0f; + if (button == 1 || button == 2) { + Paint(c, kInk, [&](float x, float y) { + const bool side = button == 1 ? x < cx - 0.6f : x > cx + 0.6f; + return side && y < split - 0.6f ? Inside(face, x, y) : 0.0f; + }); + } + // The buttons' seams. + Paint(c, kOutline, [&](float x, float y) { + const bool seam = (std::abs(x - cx) <= 0.6f && y < split) || std::abs(y - split) <= 0.6f; + return seam ? Inside(face, x, y) : 0.0f; + }); + const bool wheel = button == 3 || button == 6 || button == 7; + const Box wheel_box{cx - 2.0f, 4.5f, cx + 2.0f, 11.5f, 2.0f}; + Paint(c, wheel ? kInk : kOutline, [&](float x, float y) { return Inside(wheel_box, x, y); }); + if (button == 6 || button == 7) { + DrawArrow(c, 28.5f, 8.0f, 6.5f, button == 6 ? 0 : 2, kInk); + } else if (button == 0) { + // The camera: the mouse moved. + DrawArrow(c, 3.5f, 17.0f, 6.0f, 3, kInk); + DrawArrow(c, 28.5f, 17.0f, 6.0f, 1, kInk); + } +} + +// The round cap of the game's face buttons and sticks (KG_Cancel, KG_L3), and the wide one of +// its shoulder buttons (KG_L1). +constexpr Box kRoundCap{3.7f, 3.2f, 28.3f, 27.8f, 12.3f}; +constexpr Box kWideCap{0.5f, 6.0f, 31.5f, 26.0f, 4.0f}; +// A, B, X and Y in their colours, as bright as the game's own symbols. +constexpr Color kPadFace[] = {Rgb(0x5bbf3f), Rgb(0xe2423a), Rgb(0x3a7bf0), Rgb(0xf1c232)}; +constexpr const char* kPadNames[] = {"A", "B", "X", "Y", "LB", "RB", "LT", "RT", + "LS", "RS", "", "", "", "", "", "", + "", "P1", "P2", "P3", "P4", "Pad", "Home"}; +static_assert(sizeof(kPadNames) / sizeof(kPadNames[0]) == PadGuide + 1); + +// The shoulder buttons, the triggers and the names are on a wide cap, the rest round. +bool PadWide(int button) { + return (button >= PadLB && button <= PadRT) || button >= PadP1; +} + +// An outlined box: `line` units thick, its inside the cap's face. +void DrawFrame(Canvas& c, const Box& box, float line) { + Paint(c, kInk, [&](float x, float y) { return Inside(box, x, y); }); + const Box inside = Inset(box, line, line, line, line); + Paint(c, kFace, [&](float x, float y) { return Inside(inside, x, y); }); +} + +void DrawPad(Canvas& c, int button) { + button = std::clamp(button, 0, int(PadGuide)); + if (PadWide(button)) { + DrawLabel(c, kWideCap, kPadNames[button], button <= PadRT ? 12.5f : 10.0f); + return; + } + DrawCap(c, kRoundCap); + const float cx = 16.0f, cy = 15.8f; + switch (button) { + case PadA: + case PadB: + case PadX: + case PadY: + DrawText(c, kPadNames[button], cx, cy, 20.0f, 13.0f, kPadFace[button]); + break; + case PadLS: + case PadRS: + DrawText(c, kPadNames[button], cx, cy, 19.0f, 11.5f); + break; + case PadView: + // Two windows, one over the other. + DrawFrame(c, {9.0f, 9.5f, 18.5f, 17.0f, 1.5f}, 1.5f); + DrawFrame(c, {13.5f, 14.0f, 23.0f, 21.5f, 1.5f}, 1.5f); + break; + case PadMenu: + for (const float y : {11.0f, 15.5f, 20.0f}) { + const Box line{10.0f, y - 0.9f, 22.0f, y + 0.9f, 0.9f}; + Paint(c, kInk, [&](float px, float py) { return Inside(line, px, py); }); + } + break; + case PadShare: { + // An arrow out of a tray. + DrawArrow(c, cx, 13.0f, 9.0f, 0, kInk); + const Box tray[] = {{10.0f, 15.5f, 11.8f, 21.5f, 0.6f}, {20.2f, 15.5f, 22.0f, 21.5f, 0.6f}, + {10.0f, 19.7f, 22.0f, 21.5f, 0.6f}}; + for (const Box& b : tray) { + Paint(c, kInk, [&](float x, float y) { return Inside(b, x, y); }); + } + break; + } + default: { // the d-pad: up, down, left, right + constexpr int kDir[] = {0, 2, 3, 1}; + DrawArrow(c, cx, cy, 12.0f, kDir[std::clamp(button - int(PadUp), 0, 3)], kInk); + break; + } + } +} + +void Resolve(const Canvas& c, std::uint8_t* rgba) { + for (int y = 0; y < c.size; ++y) { + for (int x = 0; x < c.size; ++x) { + for (int ch = 0; ch < 4; ++ch) { + float sum = 0.0f; + for (int sy = 0; sy < S; ++sy) { + for (int sx = 0; sx < S; ++sx) { + sum += c.px[((std::size_t(y) * S + sy) * c.n + x * S + sx) * 4 + ch]; + } + } + const float v = std::clamp(sum / (S * S), 0.0f, 1.0f); + rgba[(y * c.size + x) * 4 + ch] = std::uint8_t(std::lround(v * 255.0f)); + } + } + } +} + +// BC7 modes 4, 5 and 6, the ones the game's icons use: one subset, colour and alpha either +// on one line (6) or on two (4 and 5, rotation 0). Each is fitted along the block's principal +// axis, then refined by least squares for the indices it got; the block keeps whichever mode +// came closest. +namespace bc7 { + +constexpr int kW2[4] = {0, 21, 43, 64}; +constexpr int kW3[8] = {0, 9, 18, 27, 37, 46, 55, 64}; +constexpr int kW4[16] = {0, 4, 9, 13, 17, 21, 26, 30, 34, 38, 43, 47, 51, 55, 60, 64}; + +const int* Weights(int bits) { + return bits == 2 ? kW2 : bits == 3 ? kW3 : kW4; +} +int Interp(int a, int b, int w) { + return ((64 - w) * a + w * b + 32) >> 6; +} +int Expand(int v, int bits) { + v <<= 8 - bits; + return v | (v >> bits); +} + +struct Block { + int px[16][4]; +}; + +// Channels [c0, c1) on one line between two endpoints. +struct LineSpec { + int c0, c1; + int index_bits; + int endpoint_bits; + bool pbits; // mode 6: 7 bits and a bit shared by the endpoint's channels +}; + +struct Line { + int stored[2][4] = {}; + int pbit[2] = {}; + int value[2][4] = {}; + int index[16] = {}; + long long err = LLONG_MAX; +}; + +void Quantize(const LineSpec& s, const float e[4], int stored[4], int& pbit, int value[4]) { + if (s.pbits) { + float best = 1e30f; + for (int p = 0; p < 2; ++p) { + int st[4] = {}, val[4] = {}; + float err = 0.0f; + for (int c = s.c0; c < s.c1; ++c) { + st[c] = std::clamp(int(std::lround((e[c] - float(p)) / 2.0f)), 0, 127); + val[c] = st[c] * 2 + p; + err += (float(val[c]) - e[c]) * (float(val[c]) - e[c]); + } + if (err < best) { + best = err; + pbit = p; + std::memcpy(stored, st, sizeof(st)); + std::memcpy(value, val, sizeof(val)); + } + } + return; + } + const int top = (1 << s.endpoint_bits) - 1; + for (int c = s.c0; c < s.c1; ++c) { + const int guess = std::clamp(int(std::lround(e[c] * float(top) / 255.0f)), 0, top); + int best_v = guess; + float best = 1e30f; + for (int v = std::max(guess - 1, 0); v <= std::min(guess + 1, top); ++v) { + const float d = std::abs(float(Expand(v, s.endpoint_bits)) - e[c]); + if (d < best) { + best = d; + best_v = v; + } + } + stored[c] = best_v; + value[c] = Expand(best_v, s.endpoint_bits); + } +} + +void Assign(const LineSpec& s, const Block& b, Line& line) { + const int* w = Weights(s.index_bits); + const int n = 1 << s.index_bits; + line.err = 0; + for (int i = 0; i < 16; ++i) { + long long best = LLONG_MAX; + for (int k = 0; k < n; ++k) { + long long e = 0; + for (int c = s.c0; c < s.c1; ++c) { + const int d = Interp(line.value[0][c], line.value[1][c], w[k]) - b.px[i][c]; + e += d * d; + } + if (e < best) { + best = e; + line.index[i] = k; + } + } + line.err += best; + } +} + +Line Fit(const LineSpec& s, const Block& b) { + float mean[4] = {}; + for (int i = 0; i < 16; ++i) { + for (int c = s.c0; c < s.c1; ++c) { + mean[c] += float(b.px[i][c]) / 16.0f; + } + } + float cov[4][4] = {}; + for (int i = 0; i < 16; ++i) { + for (int c = s.c0; c < s.c1; ++c) { + for (int d = s.c0; d < s.c1; ++d) { + cov[c][d] += (float(b.px[i][c]) - mean[c]) * (float(b.px[i][d]) - mean[d]); + } + } + } + float axis[4] = {1.0f, 1.0f, 1.0f, 1.0f}; + for (int it = 0; it < 12; ++it) { + float next[4] = {}; + float len = 0.0f; + for (int c = s.c0; c < s.c1; ++c) { + for (int d = s.c0; d < s.c1; ++d) { + next[c] += cov[c][d] * axis[d]; + } + len += next[c] * next[c]; + } + if (len < 1e-12f) { + break; + } + len = std::sqrt(len); + for (int c = s.c0; c < s.c1; ++c) { + axis[c] = next[c] / len; + } + } + float lo = 1e30f, hi = -1e30f; + for (int i = 0; i < 16; ++i) { + float t = 0.0f; + for (int c = s.c0; c < s.c1; ++c) { + t += (float(b.px[i][c]) - mean[c]) * axis[c]; + } + lo = std::min(lo, t); + hi = std::max(hi, t); + } + float e[2][4] = {}; + for (int c = s.c0; c < s.c1; ++c) { + e[0][c] = std::clamp(mean[c] + axis[c] * lo, 0.0f, 255.0f); + e[1][c] = std::clamp(mean[c] + axis[c] * hi, 0.0f, 255.0f); + } + + Line best; + const int* w = Weights(s.index_bits); + for (int iter = 0; iter < 6; ++iter) { + Line line; + Quantize(s, e[0], line.stored[0], line.pbit[0], line.value[0]); + Quantize(s, e[1], line.stored[1], line.pbit[1], line.value[1]); + Assign(s, b, line); + if (line.err < best.err) { + best = line; + } + if (best.err == 0) { + break; + } + float aa = 0.0f, ab = 0.0f, bb = 0.0f, d0[4] = {}, d1[4] = {}; + for (int i = 0; i < 16; ++i) { + const float t = float(w[line.index[i]]) / 64.0f; + aa += (1.0f - t) * (1.0f - t); + ab += (1.0f - t) * t; + bb += t * t; + for (int c = s.c0; c < s.c1; ++c) { + d0[c] += (1.0f - t) * float(b.px[i][c]); + d1[c] += t * float(b.px[i][c]); + } + } + const float det = aa * bb - ab * ab; + if (std::abs(det) < 1e-6f) { + break; + } + for (int c = s.c0; c < s.c1; ++c) { + e[0][c] = std::clamp((bb * d0[c] - ab * d1[c]) / det, 0.0f, 255.0f); + e[1][c] = std::clamp((aa * d1[c] - ab * d0[c]) / det, 0.0f, 255.0f); + } + } + return best; +} + +// The first index's top bit is not stored: it has to be 0. +void Anchor(Line& line, int index_bits) { + const int n = 1 << index_bits; + if (line.index[0] < n / 2) { + return; + } + for (int k = 0; k < 4; ++k) { + std::swap(line.stored[0][k], line.stored[1][k]); + std::swap(line.value[0][k], line.value[1][k]); + } + std::swap(line.pbit[0], line.pbit[1]); + for (int& i : line.index) { + i = n - 1 - i; + } +} + +struct Bits { + std::uint64_t w[2] = {}; + int pos = 0; + void Put(std::uint32_t v, int n) { + for (int i = 0; i < n; ++i, ++pos) { + if ((v >> i) & 1) { + w[pos >> 6] |= 1ull << (pos & 63); + } + } + } +}; + +struct Encoded { + long long err = LLONG_MAX; + Bits bits; +}; + +Encoded Mode6(const Block& b) { + const LineSpec s{0, 4, 4, 7, true}; + Line l = Fit(s, b); + Anchor(l, 4); + Encoded out; + out.err = l.err; + out.bits.Put(1u << 6, 7); + for (int c = 0; c < 4; ++c) { + for (int k = 0; k < 2; ++k) { + out.bits.Put(l.stored[k][c], 7); + } + } + out.bits.Put(l.pbit[0], 1); + out.bits.Put(l.pbit[1], 1); + for (int i = 0; i < 16; ++i) { + out.bits.Put(l.index[i], i == 0 ? 3 : 4); + } + return out; +} + +Encoded Mode5(const Block& b) { + const LineSpec cs{0, 3, 2, 7, false}, as{3, 4, 2, 8, false}; + Line lc = Fit(cs, b), la = Fit(as, b); + Anchor(lc, 2); + Anchor(la, 2); + Encoded out; + out.err = lc.err + la.err; + out.bits.Put(1u << 5, 6); + out.bits.Put(0, 2); // rotation + for (int c = 0; c < 3; ++c) { + for (int k = 0; k < 2; ++k) { + out.bits.Put(lc.stored[k][c], 7); + } + } + for (int k = 0; k < 2; ++k) { + out.bits.Put(la.stored[k][3], 8); + } + for (int i = 0; i < 16; ++i) { + out.bits.Put(lc.index[i], i == 0 ? 1 : 2); + } + for (int i = 0; i < 16; ++i) { + out.bits.Put(la.index[i], i == 0 ? 1 : 2); + } + return out; +} + +// index_mode 0: colour on the 2-bit indices, alpha on the 3-bit ones; 1 the other way round. +Encoded Mode4(const Block& b, int index_mode) { + const int color_bits = index_mode ? 3 : 2, alpha_bits = index_mode ? 2 : 3; + const LineSpec cs{0, 3, color_bits, 5, false}, as{3, 4, alpha_bits, 6, false}; + Line lc = Fit(cs, b), la = Fit(as, b); + Anchor(lc, color_bits); + Anchor(la, alpha_bits); + Encoded out; + out.err = lc.err + la.err; + out.bits.Put(1u << 4, 5); + out.bits.Put(0, 2); // rotation + out.bits.Put(index_mode, 1); + for (int c = 0; c < 3; ++c) { + for (int k = 0; k < 2; ++k) { + out.bits.Put(lc.stored[k][c], 5); + } + } + for (int k = 0; k < 2; ++k) { + out.bits.Put(la.stored[k][3], 6); + } + const Line& two = index_mode ? la : lc; + const Line& three = index_mode ? lc : la; + for (int i = 0; i < 16; ++i) { + out.bits.Put(two.index[i], i == 0 ? 1 : 2); + } + for (int i = 0; i < 16; ++i) { + out.bits.Put(three.index[i], i == 0 ? 2 : 3); + } + return out; +} + +void EncodeBlock(const Block& b, std::uint8_t out[16]) { + Encoded best = Mode6(b); + for (Encoded e : {Mode5(b), Mode4(b, 0), Mode4(b, 1)}) { + if (e.err < best.err) { + best = e; + } + } + std::memcpy(out, best.bits.w, 16); +} + +} // namespace bc7 + +} // namespace + +void Draw(const Spec& spec, std::uint8_t* rgba, int size) { + // A square cap fills a smaller button: the 27 units around it at that size. Other layouts + // scale down whole. + const std::string first = spec.labels.empty() ? std::string() : spec.labels[0]; + const bool square = spec.kind == Spec::Pad ? !PadWide(spec.button) + : spec.kind == Spec::Key && (Utf8(first).size() <= 2 || ArrowDir(first) >= 0); + float scale = float(size) / kSize, origin = 0.0f; + if (size != kSize && square) { + scale = float(size) / 27.0f; + origin = -2.5f * scale; + } + Canvas c(size, scale, origin, origin); + switch (spec.kind) { + case Spec::Mouse: + DrawMouse(c, spec.button); + break; + case Spec::Pad: + DrawPad(c, spec.button); + break; + case Spec::Keys: + if (spec.labels.size() >= 4) { + // An inverted T: up; left, down, right. + DrawLabel(c, {10.75f, 0.5f, 21.25f, 14.5f, 2.5f}, spec.labels[0], 7.5f); + DrawLabel(c, {0.0f, 16.0f, 10.5f, 30.0f, 2.5f}, spec.labels[1], 7.5f); + DrawLabel(c, {10.75f, 16.0f, 21.25f, 30.0f, 2.5f}, spec.labels[2], 7.5f); + DrawLabel(c, {21.5f, 16.0f, 32.0f, 30.0f, 2.5f}, spec.labels[3], 7.5f); + } else if (spec.labels.size() >= 2) { + DrawLabel(c, {0.0f, 6.0f, 15.5f, 26.0f, 3.0f}, spec.labels[0], 9.5f); + DrawLabel(c, {16.5f, 6.0f, 32.0f, 26.0f, 3.0f}, spec.labels[1], 9.5f); + } + break; + case Spec::Key: + default: { + const std::string label = spec.labels.empty() ? std::string() : spec.labels[0]; + const std::size_t glyphs = Utf8(label).size(); + if (glyphs <= 2 || ArrowDir(label) >= 0) { + DrawLabel(c, {3.0f, 2.5f, 29.0f, 28.0f, 4.5f}, label, glyphs <= 1 ? 13.0f : 11.0f); + } else { + // Long names on a wide cap, as KG_R1 is. + DrawLabel(c, {0.5f, 6.0f, 31.5f, 26.0f, 4.0f}, label, 10.0f); + } + break; + } + } + Resolve(c, rgba); +} + +void EncodeBlocks(const std::uint8_t* rgba, int width, int height, std::uint8_t* out) { + for (int by = 0; by < height / 4; ++by) { + for (int bx = 0; bx < width / 4; ++bx) { + bc7::Block block; + for (int p = 0; p < 16; ++p) { + const int x = bx * 4 + p % 4, y = by * 4 + p / 4; + for (int ch = 0; ch < 4; ++ch) { + block.px[p][ch] = rgba[(y * width + x) * 4 + ch]; + } + } + bc7::EncodeBlock(block, out); + out += 16; + } + } +} + +void Encode(const std::uint8_t rgba[kSize * kSize * 4], std::uint8_t out[kBytes]) { + // Block i of the tile is at x = i's even bits, y = its odd bits. + for (int i = 0; i < 64; ++i) { + int bx = 0, by = 0; + for (int k = 0; k < 3; ++k) { + bx |= ((i >> (2 * k)) & 1) << k; + by |= ((i >> (2 * k + 1)) & 1) << k; + } + bc7::Block block; + for (int p = 0; p < 16; ++p) { + const int x = bx * 4 + p % 4, y = by * 4 + p / 4; + for (int ch = 0; ch < 4; ++ch) { + block.px[p][ch] = rgba[(y * kSize + x) * 4 + ch]; + } + } + bc7::EncodeBlock(block, out + i * 16); + } +} + +} // namespace Keycap diff --git a/gpu/shim/engine/keycap_art.h b/gpu/shim/engine/keycap_art.h new file mode 100644 index 0000000..77a036f --- /dev/null +++ b/gpu/shim/engine/keycap_art.h @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +// Keyboard, mouse and Xbox controller button icons in the style of the game's own +// (menu/common.tpf's KG_*: a dark cap with a light top rim and a shadow under it, tan lettering, +// the face buttons round with a coloured symbol), drawn here, 32x32, and stored the way those +// are: BC7 in the PS4's thin tiling, one tile of 8x8 4x4-blocks in Morton order, 0x400 bytes. +// engine/key_prompts.cpp shows them in place of the game's icons. + +#include +#include +#include +#include + +namespace Keycap { + +constexpr int kSize = 32; +constexpr std::size_t kBytes = 0x400; + +/// An Xbox controller's buttons (Spec::Pad). The paddles, the touchpad and the guide button, +/// which a binding can name too, show as their names. +enum PadButton : int { + PadA, PadB, PadX, PadY, PadLB, PadRB, PadLT, PadRT, PadLS, PadRS, PadView, PadMenu, PadShare, + PadUp, PadDown, PadLeft, PadRight, PadP1, PadP2, PadP3, PadP4, PadTouchpad, PadGuide, +}; + +struct Spec { + enum Kind : std::uint8_t { + Key, // one cap: labels[0] + Keys, // two caps side by side, or four as an inverted T (up, left, down, right) + Mouse, // the mouse, `button` lit + Pad, // an Xbox controller's button: `button` a PadButton + }; + Kind kind = Key; + /// Cap texts, UTF-8. "Up", "Down", "Left" and "Right" are drawn as arrows. + std::vector labels; + /// Mouse: 0 none (the mouse itself, for the camera), 1 left, 2 right, 3 middle, 6 and 7 the + /// wheel up and down. Pad: a PadButton. + int button = 0; + + /// One string per distinct icon (the caches' key). + std::string Id() const; +}; + +/// RGBA8, `size` pixels square, row-major, the colour premultiplied by alpha as the game's icons +/// are. 32 for the KG_* icons; the HUD's buttons are 26. +void Draw(const Spec& spec, std::uint8_t* rgba, int size = kSize); +/// BC7, PS4 thin tiled: kBytes. +void Encode(const std::uint8_t rgba[kSize * kSize * 4], std::uint8_t out[kBytes]); +/// BC7 blocks of a region (`width` and `height` multiples of 4), row by row, 16 bytes each. +void EncodeBlocks(const std::uint8_t* rgba, int width, int height, std::uint8_t* out); + +} // namespace Keycap diff --git a/gpu/shim/engine/online_fixes.cpp b/gpu/shim/engine/online_fixes.cpp new file mode 100644 index 0000000..af312b6 --- /dev/null +++ b/gpu/shim/engine/online_fixes.cpp @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Game-side fixes for playing online, adapted from droogie/bbhost src/engine/chara_id.cpp and +// src/engine/sos_signs.cpp (566184a); see licenses/BBHOST-PORTS.md. Binary Ninja addresses +// (image base 0x400000), as the other engine hooks. +#include "online_fixes.h" +#include "engine_state.h" +#include +#include +#include +#include +#include + +namespace BbEngine::OnlineFixes { +namespace { +#define GUEST_ABI __attribute__((sysv_abi)) + +// The network login's character step (0x2043ee0): when GameDataMan's local PlayerGameData +// (0x593b130 -> +8) holds a character id at +0x690 it hands it to FrpgNetMan and goes on; when it +// holds 0 it asks the server (sync_chara_id), whose first PublishCharacterIdList entry the +// callback stores with no check - 0x8000000000000000 when the list is empty, as The Hunter's Dream +// answered until 2026-10-10. The save keeps that, and the character never asks again: so it is +// put back to 0 before the step runs, and the step asks. +constexpr std::uint64_t kCharaStep = 0x2043ee0; +// push rbp; mov rbp, rsp; push r15; push r14; push r12; push rbx; sub rsp, 0x40 +const std::uint8_t kCharaStepPrologue[] = {0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56, + 0x41, 0x54, 0x53, 0x48, 0x83, 0xec, 0x40}; +constexpr std::uint64_t kGameDataMan = 0x593b130; +constexpr std::size_t kPlayerGameData = 0x8, kCharaId = 0x690; +constexpr std::uint64_t kUnset = 0x8000000000000000ull; + +// A summon sign entry waits this long for its host (.rodata 180.0f, read by the sign code). +constexpr std::uint64_t kSignTimeout = 0x4d27a5c; +constexpr float kGameSignTimeout = 180.0f; + +std::uint8_t* g_game_data_man = nullptr; // the pointer's slot in the image +std::atomic g_cleared{0}; + +template bool Read(std::uint64_t at, T* out) { + return at && host_read_safe(reinterpret_cast(at), out, sizeof(T)); +} + +GUEST_ABI std::int64_t CharaStep(std::uint64_t, const std::uint64_t*) { + std::uint64_t man = 0, data = 0, id = 0; + if (!Read(reinterpret_cast(g_game_data_man), &man) || !Read(man + kPlayerGameData, &data) || + !Read(data + kCharaId, &id)) return 0; + if (id == kUnset) { + const std::uint64_t none = 0; + std::memcpy(reinterpret_cast(data + kCharaId), &none, sizeof(none)); + if (g_cleared.fetch_add(1) < 4) + host_log("online: this character has no id from the server (0x8000000000000000); asking for one"); + } else if (id) { + static std::atomic said{false}; + if (!said.exchange(true)) host_log("online: character id %llu", static_cast(id)); + } + return 0; // the step runs as it would +} +} // namespace + +bool Install(std::uint8_t* image, std::size_t size) { + const char* online = std::getenv("BB_ONLINE"); + if (!online || online[0] != '1') return false; + bool any = false; + Hook hook; + if (Prepare(hook, image, size, kCharaStep - 0x400000, kCharaStepPrologue, CharaStep)) { + g_game_data_man = image + (kGameDataMan - 0x400000); + Commit(std::span{&hook, 1}); + any = true; + } else { + host_log("online: character id fix off: the login's character step differs from 1.09"); + } + // BB_SIGN_TIMEOUT seconds (the launcher's; 0 or unset keeps the game's 180). + const char* timeout = std::getenv("BB_SIGN_TIMEOUT"); + const long seconds = timeout ? std::strtol(timeout, nullptr, 10) : 0; + const std::size_t at = kSignTimeout - 0x400000; + float value = 0; + if (seconds > 0 && seconds != 180 && size >= at + sizeof(value)) { + std::memcpy(&value, image + at, sizeof(value)); + if (value == kGameSignTimeout) { + value = static_cast(std::min(seconds, 600L)); + std::memcpy(image + at, &value, sizeof(value)); // the loader still owns the image writable + host_log("online: a summon sign entry times out after %.0f s (the game's 180)", value); + any = true; + } else { + host_log("online: sign timeout left alone: 0x%llx is not 180.0", static_cast(kSignTimeout)); + } + } + return any; +} +} // namespace BbEngine::OnlineFixes diff --git a/gpu/shim/engine/online_fixes.h b/gpu/shim/engine/online_fixes.h new file mode 100644 index 0000000..1b042f6 --- /dev/null +++ b/gpu/shim/engine/online_fixes.h @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Game-side fixes for playing online (BB_ONLINE=1), after droogie/bbhost src/engine/chara_id.cpp +// and sos_signs.cpp: a character the server once gave no id gets asked for one again, and a +// summon sign entry times out sooner. See online_fixes.cpp. +#pragma once +#include +#include + +namespace BbEngine::OnlineFixes { +/// Loader start, after the executable is verified: nothing unless online. Commits its own hook. +bool Install(std::uint8_t* image, std::size_t size); +} // namespace BbEngine::OnlineFixes diff --git a/gpu/shim/engine/option_menu.cpp b/gpu/shim/engine/option_menu.cpp index cf652fe..b455025 100644 --- a/gpu/shim/engine/option_menu.cpp +++ b/gpu/shim/engine/option_menu.cpp @@ -606,15 +606,29 @@ Slider camera[] = { bool seeded=false; bool camera_page=false,graphics_page=false,input_page=false; Slider graphics_sliders[]={{nullptr,117007,0,0.2f,5},{nullptr,117006,1,0.2f,0}}; -Slider effect_sliders[]={{nullptr,120000,0,0.1f,10},{nullptr,120002,0,0.2f,5}}; +Slider effect_sliders[]={{nullptr,120000,0,0.1f,10}}; +Slider fog_slider[]={{nullptr,117008,0,0.1f,10}}; // after the output list: the list stays row 4 +// Brightness, contrast, saturation and hue (bbport): 5 is the area's own. +Slider color_sliders[]={{nullptr,125000,0.5f,0.1f,5},{nullptr,125001,0.95f,0.01f,5},{nullptr,125002,0,0.2f,5}, + {nullptr,125003,-20.0f,4.0f,5}}; struct StartupToggle { std::atomic* setting=nullptr; std::uint32_t id; std::uint8_t value=0,last=0; }; +StartupToggle color_toggle[]={{nullptr,125004}}; // Picture tuning, live +bool color_seeded=false; StartupToggle enhancements[]={{nullptr,124001},{nullptr,124002},{nullptr,124004}}; bool enhancements_seeded=false; StartupToggle graphics_toggles[]={{nullptr,117001},{nullptr,117000}}; StartupToggle effect_toggles[]={{nullptr,117005},{nullptr,117002},{nullptr,117003},{nullptr,120001}}; bool graphics_seeded=false,effects_seeded=false; std::int32_t output_resolution=1,last_resolution=1; -const Choice outputs[]={{0,117010},{1,117012},{2,117013},{3,117015},{4,117040},{5,117041},{6,117042},{7,117043},{8,117044},{9,117045}}; +const Choice outputs[]={{0,117010},{1,117012},{2,117013},{3,117015},{4,117040},{5,117041},{6,117042},{7,117043},{8,117044},{9,117045},{10,117046},{11,117047}}; +static_assert(sizeof(outputs)/sizeof(outputs[0])==BbSettings::OutputCount); +// What "Defaults" puts a toggle back to: an effect's own default (bbport_settings.h Effects: CA, +// DoF, motion blur and the game's AA are off), on for the others (vignette). +std::uint8_t DefaultOf(const std::atomic* setting) { + for (int e=0;e void SeedSliders(Rows& rows) { for (auto& s:rows) s.value=s.last=static_cast(std::clamp(std::lround((s.setting->load()-s.base)/s.step),0l,10l)); } @@ -657,21 +671,32 @@ GUEST_ABI void GraphicsHandler(void* dialog,void* params) { host_log("pc-options: PCGraphics opened, dialog=%p params=%p",dialog,params); alignas(16) std::uint8_t scratch[0x100]{}; hle_call_guest(guest_fn(kScratchInit),&scratch); - for (auto& t:graphics_toggles) { t.value=t.last=t.setting->load()?1:0;add_toggle_row(dialog,&t.value,t.id,1); } + for (auto& t:graphics_toggles) { t.value=t.last=t.setting->load()?1:0;add_toggle_row(dialog,&t.value,t.id,DefaultOf(t.setting)); } SeedSliders(graphics_sliders); for (auto& s:graphics_sliders) add_slider_row(dialog,&s.value,s.id,s.def); output_resolution=last_resolution=BbSettings::Get().output_res; add_list_row(dialog,&output_resolution,117004,outputs,BbSettings::OutputCount,BbSettings::OutputDefault); - graphics_seeded=true;log_rows("PCGraphics",dialog,5,5); + SeedSliders(fog_slider); + for (auto& s:fog_slider) add_slider_row(dialog,&s.value,s.id,s.def); + graphics_seeded=true;log_rows("PCGraphics",dialog,6,6); } GUEST_ABI void EffectsHandler(void* dialog,void* params) { host_log("pc-options: PCEffects opened, dialog=%p params=%p",dialog,params); alignas(16) std::uint8_t scratch[0x100]{}; hle_call_guest(guest_fn(kScratchInit),&scratch); - for (auto& t:effect_toggles) { t.value=t.last=t.setting->load()?1:0;add_toggle_row(dialog,&t.value,t.id,1); } + for (auto& t:effect_toggles) { t.value=t.last=t.setting->load()?1:0;add_toggle_row(dialog,&t.value,t.id,DefaultOf(t.setting)); } SeedSliders(effect_sliders); for (auto& s:effect_sliders) add_slider_row(dialog,&s.value,s.id,s.def); - effects_seeded=true;log_rows("PCEffects",dialog,6,6); + effects_seeded=true;log_rows("PCEffects",dialog,5,5); +} +GUEST_ABI void ColorHandler(void* dialog,void* params) { + host_log("pc-options: PCColour opened, dialog=%p params=%p",dialog,params); + alignas(16) std::uint8_t scratch[0x100]{}; + hle_call_guest(guest_fn(kScratchInit),&scratch); + for (auto& t:color_toggle) { t.value=t.last=t.setting->load()?1:0;add_toggle_row(dialog,&t.value,t.id,0); } + SeedSliders(color_sliders); + for (auto& s:color_sliders) add_slider_row(dialog,&s.value,s.id,s.def); + color_seeded=true;log_rows("PCColour",dialog,5,5); } GUEST_ABI std::int64_t OpenGraphics(void* root,void* params) { return open_named(root,params,"PCGraphics",reinterpret_cast(&GraphicsHandler),4); @@ -687,9 +712,22 @@ GUEST_ABI void* OpenEffectsRow(void* out,void* ctx) { GuestFunction f(kOpenerFunctorVTable,reinterpret_cast(&OpenEffects)); hle_call_guest(guest_fn(kOpenSectionFlow),out,ctx,f.buf);return out; } +GUEST_ABI std::int64_t OpenColor(void* root,void* params) { + return open_named(root,params,"PCColour",reinterpret_cast(&ColorHandler),-1); +} +GUEST_ABI void* OpenColorRow(void* out,void* ctx) { + GuestFunction f(kOpenerFunctorVTable,reinterpret_cast(&OpenColor)); + hle_call_guest(guest_fn(kOpenSectionFlow),out,ctx,f.buf);return out; +} StartupToggle controls[]={{nullptr,118000},{nullptr,118002},{nullptr,118003},{nullptr,116002},{nullptr,118005}}; std::uint8_t sensitivity=5,last_sensitivity=5;bool controls_seeded=false; +// Button Prompts (engine/key_prompts.h): BbSettings::ButtonPrompts. +std::int32_t prompts=0,last_prompts=0; +const Choice prompt_choices[]={{BbSettings::PromptsAuto,118010},{BbSettings::PromptsKeyboard,118011},{BbSettings::PromptsController,118012}}; +// Controller Icons: BbSettings::PadIcons. +std::int32_t pad_icons=0,last_pad_icons=0; +const Choice pad_icon_choices[]={{BbSettings::PadIconsAuto,118013},{BbSettings::PadIconsPlayStation,118014},{BbSettings::PadIconsXbox,118015}}; GUEST_ABI void ControlsHandler(void* dialog,void*) { alignas(16) std::uint8_t scratch[0x100]{};hle_call_guest(guest_fn(kScratchInit),&scratch); auto& s=BbSettings::Get();sensitivity=last_sensitivity=s.mouse_sensitivity; @@ -697,9 +735,13 @@ GUEST_ABI void ControlsHandler(void* dialog,void*) { add_toggle_row(dialog,&controls[0].value,118000,1); add_slider_row(dialog,&sensitivity,118001,5); for(int i=1;i<5;i++)add_toggle_row(dialog,&controls[i].value,controls[i].id,i==3?1:0); - controls_seeded=true;log_rows("PCControls",dialog,6,6); + prompts=last_prompts=s.button_prompts; + add_list_row(dialog,&prompts,118006,prompt_choices,BbSettings::PromptsCount,BbSettings::PromptsAuto); + pad_icons=last_pad_icons=s.pad_icons; + add_list_row(dialog,&pad_icons,118007,pad_icon_choices,BbSettings::PadIconsCount,BbSettings::PadIconsAuto); + controls_seeded=true;log_rows("PCControls",dialog,8,8); } -GUEST_ABI std::int64_t OpenControls(void* root,void* params) {return open_named(root,params,"PCControls",reinterpret_cast(&ControlsHandler),-1);} +GUEST_ABI std::int64_t OpenControls(void* root,void* params) {return open_named(root,params,"PCControls",reinterpret_cast(&ControlsHandler),-2);} GUEST_ABI std::int64_t OpenKeys(void* root,void* params) {return open_named(root,params,"PCKeys",reinterpret_cast(&pc_keys_handler),-2);} GUEST_ABI void* OpenControlsRow(void* out,void* ctx) {GuestFunction f(kOpenerFunctorVTable,reinterpret_cast(&OpenControls));hle_call_guest(guest_fn(kOpenSectionFlow),out,ctx,f.buf);return out;} GUEST_ABI void* OpenKeysRow(void* out,void* ctx) {GuestFunction f(kOpenerFunctorVTable,reinterpret_cast(&OpenKeys));hle_call_guest(guest_fn(kOpenSectionFlow),out,ctx,f.buf);return out;} @@ -712,6 +754,8 @@ GUEST_ABI void* SystemFinalize(void* out,void* builder,void* arg3) { hle_call_guest(guest_fn(kAddCommandRow),builder,c.buf,f.buf,&flag); } { Captions c(110010);GuestFunction f(kRowFunctorVTable,reinterpret_cast(&OpenEffectsRow)); hle_call_guest(guest_fn(kAddCommandRow),builder,c.buf,f.buf,&flag); } + { Captions c(110014);GuestFunction f(kRowFunctorVTable,reinterpret_cast(&OpenColorRow)); + hle_call_guest(guest_fn(kAddCommandRow),builder,c.buf,f.buf,&flag); } } if(input_page) { {Captions c(110008);GuestFunction f(kRowFunctorVTable,reinterpret_cast(&OpenControlsRow));hle_call_guest(guest_fn(kAddCommandRow),builder,c.buf,f.buf,&flag);} @@ -720,7 +764,7 @@ GUEST_ABI void* SystemFinalize(void* out,void* builder,void* arg3) { if (camera_page) { Captions c(110011);GuestFunction f(kRowFunctorVTable,reinterpret_cast(&OpenCameraRow)); hle_call_guest(guest_fn(kAddCommandRow),builder,c.buf,f.buf,&flag); } hle_call_guest(guest_fn(kSystemFinalize),out,builder,arg3); - host_log("pc-options: System list includes PC Enhancements%s%s",graphics_page?", PC Graphics / PC Effects":"",camera_page?", PC Camera":""); + host_log("pc-options: System list includes PC Enhancements%s%s",graphics_page?", PC Graphics / PC Effects / PC Colour":"",camera_page?", PC Camera":""); return out; } } // namespace @@ -783,7 +827,10 @@ bool Install(std::uint8_t* image,std::size_t size) { graphics_sliders[0].setting=&settings.graphics_ao_strength;graphics_sliders[1].setting=&settings.graphics_shadow_scale; effect_toggles[0].setting=&settings.effects[2];effect_toggles[1].setting=&settings.effects[1]; effect_toggles[2].setting=&settings.effects[0];effect_toggles[3].setting=&settings.graphics_vignette; - effect_sliders[0].setting=&settings.graphics_bloom;effect_sliders[1].setting=&settings.graphics_saturation; + effect_sliders[0].setting=&settings.graphics_bloom;fog_slider[0].setting=&settings.graphics_fog; + color_sliders[0].setting=&settings.graphics_brightness;color_sliders[1].setting=&settings.graphics_contrast; + color_sliders[2].setting=&settings.graphics_saturation;color_sliders[3].setting=&settings.graphics_hue; + color_toggle[0].setting=&settings.graphics_controls; enhancements[0].setting=&settings.change_appearance; enhancements[1].setting=&settings.rebirth; enhancements[2].setting=&settings.camera_controls; @@ -797,7 +844,7 @@ bool Install(std::uint8_t* image,std::size_t size) { std::memcpy(p,&target,8); p+=8; *p++=0x41; *p++=0xff; *p=0xe3; const std::int32_t to_pad=pad-(call+5); std::memcpy(image+call+1,&to_pad,4); g_installed=true; - host_log("pc-options: native System pages installed (%u local assets): Enhancements%s%s",installed,graphics_page?", Graphics / Effects":"",camera_page?", Camera":""); + host_log("pc-options: native System pages installed (%u local assets): Enhancements%s%s",installed,graphics_page?", Graphics / Effects / Colour":"",camera_page?", Camera":""); return true; } bool ClickDecides(std::uint8_t* list, int index) { @@ -850,17 +897,31 @@ void Tick() { changed=true; } auto push_sliders=[&](auto& rows,bool active) { - if (!active) return; - for (auto& s:rows) if (s.value!=s.last) { s.last=std::min(s.value,10);*s.setting=s.base+s.last*s.step;changed=true; } + bool moved=false; + if (!active) return moved; + for (auto& s:rows) if (s.value!=s.last) { s.last=std::min(s.value,10);*s.setting=s.base+s.last*s.step;changed=moved=true; } + return moved; }; auto push_toggles=[&](auto& rows,bool active) { - if (!active) return; - for (auto& t:rows) if (t.value!=t.last) { t.last=t.value;*t.setting=t.value!=0;changed=true; } + bool moved=false; + if (!active) return moved; + for (auto& t:rows) if (t.value!=t.last) { t.last=t.value;*t.setting=t.value!=0;changed=moved=true; } + return moved; }; - push_sliders(graphics_sliders,graphics_seeded);push_sliders(effect_sliders,effects_seeded); + // Picture tuning gates the picture settings (engine/graphics.cpp), live: moving one of them + // turns it on, and its own switch is on PC Colour. + push_toggles(color_toggle,color_seeded); + bool picture=push_sliders(graphics_sliders,graphics_seeded);picture|=push_sliders(effect_sliders,effects_seeded); + picture|=push_sliders(fog_slider,graphics_seeded);picture|=push_sliders(color_sliders,color_seeded); push_toggles(controls,controls_seeded); if(controls_seeded && sensitivity!=last_sensitivity){last_sensitivity=std::min(sensitivity,10);BbSettings::Get().mouse_sensitivity=last_sensitivity;changed=true;} - push_toggles(graphics_toggles,graphics_seeded);push_toggles(effect_toggles,effects_seeded); + if(controls_seeded && prompts!=last_prompts){last_prompts=std::clamp(prompts,0,BbSettings::PromptsCount-1);BbSettings::Get().button_prompts=last_prompts;changed=true;} + if(controls_seeded && pad_icons!=last_pad_icons){last_pad_icons=std::clamp(pad_icons,0,BbSettings::PadIconsCount-1);BbSettings::Get().pad_icons=last_pad_icons;changed=true;} + picture|=push_toggles(graphics_toggles,graphics_seeded);picture|=push_toggles(effect_toggles,effects_seeded); + if (picture && !BbSettings::Get().graphics_controls) { + BbSettings::Get().graphics_controls=true; + for (auto& t:color_toggle) t.value=t.last=1; + } if (graphics_seeded && output_resolution!=last_resolution) { last_resolution=std::clamp(output_resolution,0,BbSettings::OutputCount-1); BbSettings::Get().output_res=last_resolution;changed=true; diff --git a/gpu/shim/engine/trophy_sync.cpp b/gpu/shim/engine/trophy_sync.cpp new file mode 100644 index 0000000..705692f --- /dev/null +++ b/gpu/shim/engine/trophy_sync.cpp @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// The game unlocks a trophy only as it happens: a save imported from a PS4 (or played on another +// port) never reports the bosses it had already defeated. Their defeat flags - set by the game +// when a boss dies, and kept in the save - are read once a character is in the world, and each +// one set is reported as earned in this save ("Runtime: trophy N earned in this save"), which the +// launcher records without a notification per trophy. Defeat flags from droogie/bbhost's boss +// rush table (plugins/boss_rush/boss_rush.cpp, 566184a), taken from the game's own data; trophy +// ids are the game's (TROP.SFM order, the launcher's Core/Trophies.cs). +#include "trophy_sync.h" +#include "engine_state.h" +#include +#include +#include +#include + +namespace BbEngine::TrophySync { +namespace { +struct Boss { + int trophy; + std::uint32_t defeat_flag; +}; +constexpr Boss kBosses[] = { + {21, 12411700}, // Cleric Beast + {14, 12411800}, // Father Gascoigne + {22, 12301800}, // Blood-starved Beast + {15, 12401800}, // Vicar Amelia + {23, 12201800}, // The Witch of Hemwick + {24, 12301700}, // Darkbeast Paarl + {16, 12701800}, // Shadow of Yharnam + {17, 13201800}, // Rom, the Vacuous Spider + {18, 12801800}, // The One Reborn + {26, 12501800}, // Martyr Logarius + {25, 13301800}, // Amygdala + {27, 12421700}, // Celestial Emissary + {28, 12421800}, // Ebrietas, Daughter of the Cosmos + {20, 12601800}, // Mergo's Wet Nurse + {36, 13401800}, // Ludwig, the Holy Blade + {38, 13501850}, // Living Failures + {37, 13501800}, // Lady Maria of the Astral Clocktower + {39, 13401850}, // Laurence, the First Vicar + {35, 13601800}, // Orphan of Kos +}; +constexpr int kBossCount = int(sizeof(kBosses) / sizeof(kBosses[0])); + +std::bitset g_reported; +std::chrono::steady_clock::time_point g_next{}; +std::uint64_t g_read_record = 0; // the character whose save was read (0: read the next one) +} // namespace + +void Tick() { + if (g_reported.all()) return; + // A few seconds apart: the flags come with the save, before the world appears. + const auto now = std::chrono::steady_clock::now(); + if (now < g_next) return; + g_next = now + std::chrono::seconds(3); + // Once per character in the world: the flags its save brought. Read again after a load + // (out of the world, or another character), not polled - a boss killed now sets its flag + // before the game unlocks its trophy, which must keep its own notification. + const std::uint64_t record = player_record(); + if (!record || !world_player_block(nullptr)) { + g_read_record = 0; + return; + } + if (record == g_read_record) return; + g_read_record = record; + for (int i = 0; i < kBossCount; ++i) { + bool defeated = false; + if (g_reported.test(i) || !event_flag_get(kBosses[i].defeat_flag, &defeated) || !defeated) continue; + g_reported.set(i); + std::printf("Runtime: trophy %d earned in this save\n", kBosses[i].trophy); + } +} +} // namespace BbEngine::TrophySync diff --git a/gpu/shim/engine/trophy_sync.h b/gpu/shim/engine/trophy_sync.h new file mode 100644 index 0000000..ffaf36b --- /dev/null +++ b/gpu/shim/engine/trophy_sync.h @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Trophies a save has already earned (bosses it has defeated) are reported as earned, so an +// imported save, or one from another port, counts what it had. See trophy_sync.cpp. +#pragma once + +namespace BbEngine::TrophySync { +/// The main thread's frame hook (change_appearance.cpp FrameHook). +void Tick(); +} // namespace BbEngine::TrophySync diff --git a/gpu/shim/engine/widescreen.cpp b/gpu/shim/engine/widescreen.cpp index 62aad48..1041de6 100644 --- a/gpu/shim/engine/widescreen.cpp +++ b/gpu/shim/engine/widescreen.cpp @@ -87,7 +87,7 @@ void Tick() { if(!installed)return; const auto& settings=BbSettings::Get(); const auto index=std::clamp(BbSettings::ResolutionNeedsRestart()?settings.startup_output_res:settings.output_res.load(),0,BbSettings::OutputCount-1); - const float want=settings.widescreen?float(BbSettings::OutputWidths[index])/BbSettings::OutputHeights[index]:original_aspect; + const float want=settings.widescreen?float(BbSettings::OutputWidth(index))/BbSettings::OutputHeight(index):original_aspect; static float applied=0;if(want==applied)return; previous_aspect=aspect.load();aspect=want; const float rx=std::max(1.0f,want/original_aspect),ry=std::max(1.0f,original_aspect/want); diff --git a/gpu/shim/input/compat.h b/gpu/shim/input/compat.h index deefd94..1d12ce5 100644 --- a/gpu/shim/input/compat.h +++ b/gpu/shim/input/compat.h @@ -57,6 +57,7 @@ void menu_pointer_click(); uint32_t menu_pointer_take_press(); uint64_t menu_pointer_tick(); bool menu_pointer_in_menu(); +bool menu_pointer_navigating(); uint32_t menu_region_confirm_button(); uint32_t menu_confirm_button(); uint32_t menu_back_button(); diff --git a/gpu/shim/input/input.cpp b/gpu/shim/input/input.cpp index 30fdc4f..7630a3d 100644 --- a/gpu/shim/input/input.cpp +++ b/gpu/shim/input/input.cpp @@ -54,7 +54,7 @@ bool host_mouse_stage_position(float& x,float& y) { // Presenter fits the output; Scaleform fits its native 1920x1080 stage. const auto& s=BbSettings::Get(); const int idx=std::clamp(BbSettings::ResolutionNeedsRestart()?s.startup_output_res:s.output_res.load(),0,BbSettings::OutputCount-1); - const float ow=BbSettings::OutputWidths[idx],oh=BbSettings::OutputHeights[idx]; + const float ow=float(BbSettings::OutputWidth(idx)),oh=float(BbSettings::OutputHeight(idx)); const float present=std::min(window_w/ow,window_h/oh); if(!s.widescreen){ x=(mouse_x-(window_w-ow*present)/2)/present*1920/ow; @@ -162,8 +162,10 @@ extern "C" int bbgpu_pc_input(BbPcPad* d,const bool* keys,int nkeys,int gamepad_ if(p.lx==128)p.lx=127;if(p.ly==128)p.ly=127; if(p.rx==128)p.rx=127;if(p.ry==128)p.ry=127; } - // Separate immutable menu navigation from gameplay keys. - if(menu && keys) { + // Separate immutable menu navigation from gameplay keys (only while a menu has the screen: + // menu_pointer_navigating). + const bool navigating=menu && menu_pointer_navigating(); + if(navigating && keys) { for(int i=kBindLookU;i<=kBindLookR;i++)held[i]=false; if(keys[SDL_SCANCODE_UP]||held[kBindMoveF])p.buttons|=0x10; if(keys[SDL_SCANCODE_DOWN]||held[kBindMoveB])p.buttons|=0x40; @@ -178,7 +180,7 @@ extern "C" int bbgpu_pc_input(BbPcPad* d,const bool* keys,int nkeys,int gamepad_ if(s.mouse_menu){ if(edges&1)menu_pointer_click(); if(edges&SDL_BUTTON_RMASK)p.buttons|=menu_back_button(); - if(scroll>0)p.buttons|=0x10;if(scroll<0)p.buttons|=0x40; + if(navigating && scroll>0)p.buttons|=0x10;if(navigating && scroll<0)p.buttons|=0x40; p.buttons|=menu_pointer_take_press(); } }else { diff --git a/gpu/shim/input/input.h b/gpu/shim/input/input.h index d4f3a31..5716e3f 100644 --- a/gpu/shim/input/input.h +++ b/gpu/shim/input/input.h @@ -23,6 +23,12 @@ typedef struct { struct { uint16_t x,y;uint8_t id; } touch[2]; } BbPcPad; int bbgpu_pc_input(BbPcPad*,const bool* keys,int nkeys,int gamepad_present); +/* src/runtime_pad.c, for the game's button icons (engine/key_prompts.cpp): the gamepad in use's + * SDL_GamepadType (SDL_GAMEPAD_TYPE_UNKNOWN without one), and the gamepad button bound to a PS4 + * button (its scePad bit; the touchpad's right side with right_side): an SDL_GamepadButton, + * SDL_GAMEPAD_BUTTON_COUNT and +1 for the left and right trigger, -1 for none. */ +int runtime_pad_type(void); +int runtime_pad_button(uint32_t ps_button,int right_side); #ifdef __cplusplus } #endif diff --git a/gpu/shim/input/menu_pointer.cpp b/gpu/shim/input/menu_pointer.cpp index 93a179a..a5116da 100644 --- a/gpu/shim/input/menu_pointer.cpp +++ b/gpu/shim/input/menu_pointer.cpp @@ -858,3 +858,14 @@ bool menu_pointer_in_menu() { // few frames means none has. return menu_open() || now_ms() - g_focus_ms.load(std::memory_order_relaxed) < 400; } + +bool menu_pointer_navigating() { + // bbport: whether the keys that move the character navigate a menu as the d-pad (input.cpp): + // the front end, the flow's open menu, or a list with focus in the last few frames. Not the + // pointer's 400 ms after a list lost focus: W pressed right after closing the inventory was a + // held d-pad up in the world, which turns HP into Quicksilver Bullets. + if (front_end()) { + return true; + } + return menu_open() || now_ms() - g_focus_ms.load(std::memory_order_relaxed) < 70; +} diff --git a/gpu/shim/window.cpp b/gpu/shim/window.cpp index f475c39..aa552ab 100644 --- a/gpu/shim/window.cpp +++ b/gpu/shim/window.cpp @@ -169,6 +169,18 @@ bool WindowSDL::PollEvents() { UpdateTextTitle(); continue; } + // The controller finishes the text dialog too: Cross (South) accepts, Circle (East) + // cancels. From Supermedo/bloodborne_pc 1d34202. + if (text_active && event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && + (event.gbutton.button == SDL_GAMEPAD_BUTTON_SOUTH || + event.gbutton.button == SDL_GAMEPAD_BUTTON_EAST)) { + std::scoped_lock lock{text_mutex}; + text_state = event.gbutton.button == SDL_GAMEPAD_BUTTON_SOUTH ? 1 : 2; + text_active = false; + SDL_StopTextInput(window); + UpdateTextTitle(); + continue; + } if (BbOverlay::HandleEvent(event)) { continue; } @@ -196,26 +208,25 @@ bool WindowSDL::PollEvents() { } } BbInput::Pump(window,text_active); - if (!BbInput::Enabled()) UpdateCursor(); + UpdateCursor(); return is_open; } -// Issue #3: the OS cursor over the game. Hidden in fullscreen, and in a window after 3 s without -// moving the mouse; always shown while the settings menu is open. +// Issue #3: the OS cursor over the game. bbport: with hide_cursor (Settings, on by default) it +// goes 2 s after the mouse last moved and comes back when it moves - a controller player never +// sees it, a mouse player in the menus has it while using it - and it is always shown while the +// settings menu is open. The mouse camera (relative mode) hides it itself (input/input.cpp). void WindowSDL::UpdateCursor() { -#ifdef _WIN32 - // Keep the established Windows cursor behaviour while the 0.4 loading/input - // regression is isolated. The upstream auto-hide path was validated on Linux. - return; -#else - const bool fullscreen = (SDL_GetWindowFlags(window) & SDL_WINDOW_FULLSCREEN) != 0; - const bool hide = !BbOverlay::MenuOpen() && - (fullscreen || SDL_GetTicks() - last_mouse_motion_ms > 3000); + if (SDL_GetWindowRelativeMouseMode(window)) { + cursor_hidden = false; // input.cpp shows it again when the mouse camera ends + return; + } + const bool hide = BbSettings::Get().hide_cursor && !BbOverlay::MenuOpen() && + SDL_GetTicks() - last_mouse_motion_ms > 2000; if (hide != cursor_hidden) { cursor_hidden = hide; hide ? SDL_HideCursor() : SDL_ShowCursor(); } -#endif } } // namespace Frontend diff --git a/gpu/win_compat.h b/gpu/win_compat.h new file mode 100644 index 0000000..662d6f4 --- /dev/null +++ b/gpu/win_compat.h @@ -0,0 +1,11 @@ +// Windows CLANG64 build: headers some vendored sources (FSR 3 SDK) expect to arrive +// transitively but newer libc++ no longer includes (_countof, placement new, log2). +#pragma once +#include +#include +#include +#include +#include +#ifndef _countof +#define _countof(a) (sizeof(a) / sizeof((a)[0])) +#endif diff --git a/licenses/BBHOST-PORTS.md b/licenses/BBHOST-PORTS.md index ba73307..485c11f 100644 --- a/licenses/BBHOST-PORTS.md +++ b/licenses/BBHOST-PORTS.md @@ -53,3 +53,12 @@ vertical FOV and uses this port's output size. Vulkan UI composition adds unifor centered fitting, including stencil masks and window-space vertices. The existing presenter already letterboxes. The existing renderer, temporal resources, synchronization, shader and motion-vector paths remain in use. + +Online and picture sources: +https://github.com/droogie/bbhost/tree/566184a74f071166299bb656c4af479e5bbe2771 + +engine/online_fixes.cpp adapts src/engine/chara_id.cpp (the login's character step clears the +empty-list character id) and src/engine/sos_signs.cpp (the summon sign timeout). The fog control in +engine/graphics.cpp adapts the fog table upload hook of src/engine/graphics_patch.cpp, redirected +through the function's padding; the play-log redirect in scripts/online.py follows src/hle/http.cpp. The boss defeat flags in +engine/trophy_sync.cpp are from the boss rush table of plugins/boss_rush/boss_rush.cpp. diff --git a/licenses/SHADNET-PORT.md b/licenses/SHADNET-PORT.md index 4fbbd84..7f7cce2 100644 --- a/licenses/SHADNET-PORT.md +++ b/licenses/SHADNET-PORT.md @@ -2,7 +2,8 @@ The network-only source in `gpu/bbnet/` is adapted from https://github.com/Supermedo/bloodborne_pc/tree/e0761196535bac06e5d5beb1c43da5124a06dd4c -(the `windows` branch inspected on 2026-10-09). Original RPCS3, shadPS4 and +(the `windows` branch inspected on 2026-10-09). The co-op signaling fix (shadNet server framing, UPnP +mapping) is from https://github.com/Supermedo/bloodborne_pc/commit/004d2c3792110371b419f2b4c34324552644c03d. Original RPCS3, shadPS4 and shadp2p notices remain in the files. These portions are GPL-2.0-or-later. `third_party/httplib/httplib.h` and `third_party/wepoll/` retain their embedded third-party license notices. diff --git a/patches/Bloodborne-1.09-sites.json b/patches/Bloodborne-1.09-sites.json new file mode 100644 index 0000000..2677d7e --- /dev/null +++ b/patches/Bloodborne-1.09-sites.json @@ -0,0 +1,3411 @@ +{ +"1080p Light Grid (READ NOTES)": [ +1833407850, +3909983120 +], +"1280x800 Light Grid For SteamDeck (READ NOTES)": [ +1833407850, +3909983120 +], +"1440p Light Grid (READ NOTES)": [ +1833407850, +3909983120 +], +"30 FPS++": [ +2323127071, +2631118384, +2388195768, +1647839111, +3479810198, +3104824291, +3596559473, +4084567069, +1042991265, +4078859149, +1390600732, +2833541427, +2833541427, +1189330515, +54321702, +2061523586, +3105031303, +3105031303, +2879826793, +2879826793, +2879826793, +2879826793, +1627956100, +1627956100, +1627956100, +370119442, +370119442, +370119442, +2150128256, +3433886301, +3433886301, +3433886301, +3786298538, +4132207062, +988182856, +543216688, +1696784233, +1696784233, +1832774844, +3545907812, +3545907812, +3545907812, +3795698027, +3424469690, +4014592400, +49369776, +2981173530, +2768625435, +4243730363, +3787538431, +894062881, +4158520656, +1589567198, +887835720, +453833892, +759777354, +4087591940, +2856385493, +1737012036, +1472077940, +1622860868, +2102284411, +3832870339, +558161692, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +49369776, +2185502769, +2319345656, +3034846778, +2070632800, +3475978548, +2594264895, +1468257426, +1468257426, +1468257426, +1468257426, +558161692, +3523407757, +558161692, +3523407757, +558161692, +3523407757, +558161692, +3523407757, +3464669836, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +558161692, +2368389495, +4184371333, +3465309164, +3329911327, +571615212, +1199063381, +1140457901, +4128687841, +554730246, +2174648374, +3520565598, +2454845616, +822666861, +170791278, +4108283363 +], +"4k Light Grid (READ NOTES)": [ +1833407850, +3909983120 +], +"50% Text scale": [ +2896259690 +], +"60 FPS++": [ +49369776, +49369776, +49369776, +3685112572, +1620668763, +1620668763, +49369776, +1620668763, +49369776, +49369776, +49369776, +3685112572, +1089557024, +1089557024, +49369776, +2616722186, +1620668763, +2616722186, +1620668763, +1620668763, +3760546069, +3760546069, +209058557, +209058557, +49369776, +1620668763, +4138166084, +1324957560, +3504607969, +379203374, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +2323127071, +2631118384, +2388195768, +1647839111, +3479810198, +3104824291, +3596559473, +4084567069, +1042991265, +4078859149, +1390600732, +2833541427, +2833541427, +1189330515, +54321702, +2061523586, +3105031303, +3105031303, +2879826793, +2879826793, +2879826793, +2879826793, +1627956100, +1627956100, +1627956100, +370119442, +370119442, +370119442, +2150128256, +3433886301, +3433886301, +3433886301, +3786298538, +4132207062, +988182856, +543216688, +1696784233, +1696784233, +1832774844, +3545907812, +3545907812, +3545907812, +3795698027, +3424469690, +4014592400, +49369776, +2981173530, +2768625435, +4243730363, +3787538431, +894062881, +4158520656, +1589567198, +887835720, +453833892, +759777354, +4087591940, +2856385493, +1737012036, +1472077940, +1622860868, +2102284411, +3832870339, +558161692, +49369776, +2185502769, +2319345656, +3034846778, +2070632800, +3475978548, +1468257426, +1468257426, +1468257426, +1468257426, +558161692, +3523407757, +558161692, +3523407757, +558161692, +3523407757, +558161692, +3523407757, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +558161692, +2368389495, +4184371333, +3465309164, +3329911327, +571615212, +1199063381, +1140457901, +4128687841, +554730246, +2174648374, +3520565598, +2454845616, +822666861, +170791278, +4108283363, +198859238, +2789939855, +1683950521, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836 +], +"60FPS (no deltatime)": [ +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +2185502769, +2319345656, +3034846778, +2070632800, +3475978548, +3105031303, +3105031303, +2879826793, +2879826793, +2879826793, +2879826793, +1627956100, +1627956100, +1627956100, +370119442, +370119442, +370119442, +2150128256, +3433886301, +3433886301, +3433886301, +4132207062, +988182856, +4014592400, +49369776, +2981173530, +2768625435, +4243730363, +3787538431, +894062881, +4158520656, +1589567198, +887835720, +453833892, +759777354, +4087591940, +2856385493, +1737012036, +1472077940, +1622860868, +2102284411, +3832870339, +558161692, +3523407757, +558161692, +3523407757, +558161692, +3523407757, +558161692, +3523407757, +1468257426, +1468257426, +1468257426, +1468257426, +3615271895, +1921564001, +1834692469, +2594264895, +1132461680, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836 +], +"90 FPS++": [ +49369776, +49369776, +49369776, +3685112572, +1620668763, +1620668763, +49369776, +1620668763, +49369776, +49369776, +49369776, +3685112572, +1089557024, +1089557024, +49369776, +2616722186, +1620668763, +2616722186, +1620668763, +1620668763, +3760546069, +3760546069, +209058557, +209058557, +49369776, +1620668763, +4138166084, +1324957560, +3504607969, +379203374, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +2323127071, +2631118384, +2388195768, +1647839111, +3479810198, +3104824291, +3596559473, +4084567069, +1042991265, +4078859149, +1390600732, +2833541427, +2833541427, +1189330515, +54321702, +2061523586, +3105031303, +3105031303, +2879826793, +2879826793, +2879826793, +2879826793, +1627956100, +1627956100, +1627956100, +370119442, +370119442, +370119442, +2150128256, +3433886301, +3433886301, +3433886301, +3786298538, +4132207062, +988182856, +543216688, +1696784233, +1696784233, +1832774844, +3545907812, +3545907812, +3545907812, +3795698027, +3424469690, +4014592400, +49369776, +2981173530, +2768625435, +4243730363, +3787538431, +894062881, +4158520656, +1589567198, +887835720, +453833892, +759777354, +4087591940, +2856385493, +1737012036, +1472077940, +1622860868, +2102284411, +3832870339, +558161692, +49369776, +2185502769, +2319345656, +3034846778, +2070632800, +3475978548, +2594264895, +1468257426, +1468257426, +1468257426, +1468257426, +558161692, +3523407757, +558161692, +3523407757, +558161692, +3523407757, +558161692, +3523407757, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +558161692, +2368389495, +4184371333, +3465309164, +3329911327, +571615212, +1199063381, +1140457901, +4128687841, +554730246, +2174648374, +3520565598, +2454845616, +822666861, +170791278, +4108283363, +198859238, +2789939855, +1683950521, +3323510029, +3849980644, +3359755065, +3323510029, +1365703029, +3359755065, +3323510029, +960032351, +3359755065, +3323510029, +277943102, +3359755065, +3323510029, +1802675284, +3589725021, +3323510029, +108720932, +4080004081, +87579910, +3323510029, +3292184665, +2659628423, +2207897948 +], +"Bookmark and Capture outputs": [ +2507524724 +], +"DS1-like physics": [ +2553991758, +4187955813 +], +"Disable AA": [ +2768625435 +], +"Disable Camera Auto Rotation via Movement": [ +1494140327, +2367928749, +2099036775, +2831130978 +], +"Disable Chromatic Aberration": [ +231079769 +], +"Disable DoF": [ +3262761870 +], +"Disable Dynamic Light Shadows (perf increase)": [ +2768625435 +], +"Disable HTTP Requests": [ +99719601, +1520453793, +2659905730 +], +"Disable Motion Blur (perf increase)": [ +2768625435 +], +"Disable Rally (HP Regain)": [ +256133923 +], +"Disable SSAO": [ +2768625435 +], +"Enable Screen Space Reflections (READ NOTE)": [ +2768625435, +476252946, +4175998531, +2768625435, +3291218802, +1092217162, +1475942187, +755860989, +3633208317, +3138266090 +], +"Enemy Control": [ +2348046655, +4001439880, +2133803301, +2627779088, +4219540862, +4117690478, +2839758423, +2928193127, +271732364, +2209200936, +1962484073, +2223699683, +701488212, +3359900920, +2422313004, +251269265, +2717569709, +422881821, +1156840085, +4042002489, +3306607773, +4243596435, +2263661271, +1670989236, +649709233, +4191432335, +4025089277, +466199896, +4219540862, +612577837, +940039887, +3416684465, +861162503, +3709380648, +274079208, +76027086, +1669445742, +965094710, +1823668865, +1705509861, +1654762297, +2006839597, +1888770545, +2039390357, +4143916141, +324782227, +3827681309, +122293523, +1132461680, +1670267065, +4147670891, +2815226633, +1787849139, +441766216, +260297347, +4140569388, +1705440918, +2203780451, +1458400714, +3520537339, +1588995290, +4025030967, +2627459138, +2515288312, +3630933581, +1384333726, +776218152, +1708651215, +558161692, +1308269081, +3164042178, +1250401604, +2986066573, +1025094507, +807273966, +726732388, +2799210778, +3551678323, +1035323296, +3478036536, +2271397716, +1608459888, +1889197862, +2519675055, +3901073461, +3343547936, +3304706874, +1910772811, +1793438000, +4167143407, +1595146749, +3962621002, +657789295, +1811154800, +4147670891, +2815226633, +1787849139, +441766216, +3030983028, +4286209799, +1705440918, +2203780451, +1458400714, +3520537339, +341875554, +1054692487, +852752771, +1015915437, +2271934001, +3083712860, +1995773914, +2109901690, +3244778451, +153446963, +2475831252, +3365668125, +2330846604, +4039682554, +4030339888, +2520432317, +586015381, +141506709, +1539691286, +1396124840, +4049174643, +1095932680, +3827681309, +122293523, +1132461680, +1104745215 +], +"FMOD Crash Fix": [ +2380436099, +2383232344, +1889526452 +], +"Increased Graphics Heap Sizes": [ +1600478565, +2627329111 +], +"Increased camera distance": [ +1441988340, +2768625435, +990295465, +990295465 +], +"Intel 12th Gen+ SFX workaround": [ +3372436214 +], +"Intel Black Tonemap Fix": [ +2594600918 +], +"Lower object/corpse physics": [ +2553991758, +4187955813 +], +"Model LOD -2 (Highest)": [ +2397452532 +], +"Model LOD 1 (Lower)": [ +2397452532 +], +"Model LOD 2 (Lowest)": [ +2397452532 +], +"No Rally Decay": [ +2816594313, +2896259690, +3685112572, +740153734, +3523407757, +3523407757 +], +"Optimal 1080p": [ +3377627042, +332572558, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757, +1145682728, +607605785, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825 +], +"Player No Dead (Read note)": [ +558161692 +], +"Player Silent (Read note)": [ +3523407757 +], +"Player Stealth (Read note)": [ +3523407757 +], +"Resolution Patch 1280x720 (16:9)": [ +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757 +], +"Resolution Patch 1280x800 (16:10)": [ +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +125750626, +998058419, +2323038359, +998058419, +908541777, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757, +1145682728, +607605785, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825 +], +"Resolution Patch 1280x960 (4:3)": [ +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +125750626, +998058419, +2323038359, +998058419, +908541777, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757, +1145682728, +607605785, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825 +], +"Resolution Patch 1440x1080 (4:3)": [ +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +125750626, +998058419, +2323038359, +998058419, +908541777, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757, +1145682728, +607605785, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825 +], +"Resolution Patch 1440x810 (16:9)": [ +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757 +], +"Resolution Patch 1600x900 (16:9)": [ +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757 +], +"Resolution Patch 1920x1200 (16:10)": [ +1600478565, +2627329111, +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +3795222784, +796140818, +2772849967, +1613309634, +3247145901, +125750626, +998058419, +2323038359, +998058419, +908541777, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757, +4020356546, +2474901431, +449436934, +2971635099, +1145682728, +607605785, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825 +], +"Resolution Patch 2560x1080 (21:9)": [ +1600478565, +2627329111, +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +125750626, +998058419, +2323038359, +998058419, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757, +1145682728, +607605785, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825 +], +"Resolution Patch 2560x1440 (16:9)": [ +1600478565, +2627329111, +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757 +], +"Resolution Patch 2560x1600 (16:10)": [ +1600478565, +2627329111, +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +125750626, +998058419, +2323038359, +998058419, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757, +1145682728, +607605785, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825 +], +"Resolution Patch 3440x1440 (21:9)": [ +1600478565, +2627329111, +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +125750626, +998058419, +2323038359, +998058419, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757, +607605785, +1145682728, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825 +], +"Resolution Patch 3840x1080 (32:9)": [ +1600478565, +2627329111, +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +125750626, +998058419, +2323038359, +998058419, +908541777, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757, +1145682728, +607605785, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825 +], +"Resolution Patch 3840x2160 (16:9)": [ +1600478565, +2627329111, +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757 +], +"Resolution Patch 5120x1440 (32:9)": [ +1600478565, +2627329111, +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +125750626, +998058419, +2323038359, +998058419, +908541777, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757, +1145682728, +607605785, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825 +], +"Resolution Patch 5120x2160 (21:9)": [ +1600478565, +2627329111, +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +125750626, +998058419, +2323038359, +998058419, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757, +1145682728, +607605785, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825, +2535649825 +], +"Resolution Patch 640x360 (16:9)": [ +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757 +], +"Resolution Patch 960x540 (16:9)": [ +3377627042, +332572558, +2230965167, +4020630861, +1405308833, +3523407757, +1707097249, +3953094915, +3523407757, +749304812, +1405308833, +3523407757, +3715318310, +3953094915, +2883475241, +1940714468, +2537545575, +3523407757, +2798432768, +899125761, +3523407757, +3615307657, +899125761, +3523407757, +4221872386, +496673276, +3523407757, +315034679, +2711914081, +3523407757, +818223758, +3457771013, +3523407757, +460406812, +496673276, +3523407757, +4118749488, +2711914081, +3523407757, +3490771856, +3457771013, +3523407757, +568510690, +2147493970, +498629140, +1940714468, +4066548370, +3523407757, +4161948430, +176691153, +3523407757, +3047172694, +1079945321, +3523407757, +1709630865, +2147493970, +498629140, +2077168961, +4066548370, +2883475241, +214367839, +176691153, +3523407757, +1098161927, +1079945321, +3523407757 +], +"Restore Debug Camera": [ +2304322506, +1705440918, +2280313934, +1886856828, +2589836330, +1664056054, +3823700792, +2306292255, +2724731650, +2094237127, +2406151311, +2608834294, +3102650229, +2914027920, +571309257, +1440658252, +977631847, +2852464175, +1181144073, +40040826, +3554254475, +655174618, +3554254475, +1342839628, +42927773, +953835697, +1892146901, +2348266574, +2902154454, +1287368257, +2905507042, +2968610011, +3698495994, +1640307124, +3885949918, +2515944998, +720701117, +4232672440, +1923614845, +696144303, +3009948698, +183209246, +224370561, +1284969656, +2878577526, +3254253109, +1948513343, +3964364234, +2332322733, +150801554, +2636008817, +3943211511, +1284969656, +1373015224, +3171858556, +2400671704, +1576740256, +2268293872, +3190111935, +3171858556, +2880253942, +598372217, +1241566177, +211158994, +3143011063, +2734521908, +3619558496, +4056584649, +2602250783, +3970155022, +3192513588, +1218558070, +1566826810, +2983428588, +2286785277, +2983428588, +3741907169, +168805463, +2805862398, +2960007635, +3324180253, +1104745215, +3933218927, +1530289871, +463393078, +1104745215, +3307977100, +4294901760, +761896822, +1505515367, +4278190080, +2852464175, +1091244048, +2724731650, +3966539862, +1281784366, +1382516806, +1259060791, +2852464175, +1181144073, +1259060791, +2852464175, +1091244048, +4239843852, +828499103, +2094237127, +1483155041, +4108050209, +1505515367, +4278190080, +2852464175, +1091244048, +2724731650, +1969605100, +996231864, +1382516806, +1259060791, +2852464175, +1181144073, +1259060791, +3664761504, +2825434405, +1010288, +3707901625, +2825434405, +3580832660, +3993045852, +2852464175, +1091244048, +4239843852, +81022053, +2212294583, +3135200373, +2909332022, +1181144073, +2607524032, +4278190080, +3110715001, +3916222277, +2262612132, +3568589458, +81022053, +3916222277, +3664761504, +1181144073, +534112542, +4067256894, +3219065702, +2852464175, +1091244048, +3219065702, +3010007134, +2852464175, +616286678, +1007455905, +2212294583, +4278190080, +4275794182, +325317158, +3523407757, +3523407757, +3523407757, +2212294583, +1231433021, +2094237127, +1231433021, +3916222277, +3775001192, +3523407757, +2909332022, +1181144073, +2648514015, +1874795921, +3523407757, +3523407757, +3523407757, +2852464175, +3607279774, +2796764439, +2094237127, +3523407757, +3523407757, +3523407757, +1281784366, +3523407757, +3523407757, +3523407757, +2852464175, +3607279774, +2796764439, +1913130485, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +1993550816, +3607279774, +2796764439, +1422998873, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +2684717064, +2796764439, +1633981859, +3523407757, +3523407757, +3523407757, +2768625435, +3607279774, +2796764439, +3523407757, +2768625435, +3523407757, +3523407757, +2768625435, +3523407757, +3523407757, +3523407757, +3607279774, +2796764439, +3580832660, +2768625435, +3523407757, +3523407757, +852952723, +3523407757, +3523407757, +3523407757, +3607279774, +2796764439, +3705235391, +2768625435, +3523407757, +3523407757, +3523407757, +3523407757, +2181537457, +3554254475, +3607279774, +2796764439, +3686048678, +2768625435, +3523407757, +3523407757, +3523407757, +3523407757, +3916222277, +3554254475, +2852464175, +3607279774, +2796764439, +3484811241, +2768625435, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +3664761504, +2825434405, +4024072794, +3523407757, +3707901625, +2825434405, +3580832660, +3993045852, +2852464175, +1091244048, +4239843852, +81022053, +2212294583, +3135200373, +2909332022, +1181144073, +2607524032, +4278190080, +3110715001, +3916222277, +2262612132, +3568589458, +81022053, +3916222277, +2909332022, +2825434405, +4067256894, +1069182125, +4067256894, +3219065702, +2852464175, +1091244048, +3219065702, +856455061, +2852464175, +616286678, +1007455905, +2212294583, +4278190080, +4275794182, +325317158, +3523407757, +3523407757, +3523407757, +2212294583, +1231433021, +2094237127, +2547889144, +3916222277, +3775001192, +3523407757, +2909332022, +1181144073, +2648514015, +2766056989, +2768625435, +3523407757, +3523407757, +2852464175, +3607279774, +2796764439, +4194326291, +2768625435, +3523407757, +3523407757, +1281784366, +3523407757, +3523407757, +3523407757, +2852464175, +3607279774, +2796764439, +4108050209, +2768625435, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +1993550816, +3607279774, +2796764439, +3916222277, +2768625435, +3523407757, +3523407757, +3523407757, +3523407757, +2684717064, +2796764439, +2852464175, +2768625435, +3523407757, +3523407757, +2768625435, +3607279774, +2796764439, +3110715001, +2768625435, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +3607279774, +2796764439, +3187964512, +2768625435, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +3607279774, +2796764439, +3081909835, +2768625435, +3523407757, +3523407757, +2768625435, +3523407757, +3523407757, +3523407757, +2684717064, +2796764439, +2967466578, +2768625435, +3523407757, +3523407757, +3523407757, +2684717064, +2796764439, +3352871620, +2768625435, +3523407757, +3523407757, +3523407757, +2852464175, +3607279774, +2796764439, +2679148245, +2768625435, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +3607279774, +2796764439, +2439710439, +2768625435, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +3523407757, +3664761504, +1339070498, +4278190080, +2238339752, +1010288, +2909332022, +1091244048, +2648514015, +1203610895, +3523407757, +3523407757, +3523407757, +2212294583, +3135200373, +1993550816, +3554254475, +2796764439, +1684325040, +3523407757, +2238339752, +1171273221, +2909332022, +1181144073, +4278190080, +2094237127, +794826487, +282398762, +2969862996, +2768625435, +2852464175, +1181144073, +2808630289, +2909332022, +1181144073, +3966539862, +2909332022, +1181144073, +2282172566, +2094237127, +3580832660, +4110462503, +1309403428, +3523407757, +3664761504, +1339070498, +2262612132, +2238339752, +1010288, +2212294583, +3135200373, +2852464175, +1220369467, +3496967303, +3245242331, +2768625435, +3523407757, +3523407757, +1993550816, +3554254475, +2796764439, +325317158, +3523407757, +2238339752, +1171273221, +2909332022, +1181144073, +4057696306, +2094237127, +3803648100, +2393998729, +2969862996, +2768625435, +2852464175, +1181144073, +2808630289, +2852464175, +1181144073, +3295551688, +2909332022, +1181144073, +2262612132, +2094237127, +755860989, +2181537457, +1309403428, +3523407757, +2852464175, +2825434405, +2724731650, +3422756325, +2724731650, +81022053, +1259060791, +2852464175, +2825434405, +3523407757, +2852464175, +1661535913, +3568589458, +1422998873, +4067256894, +1119744540, +2852464175, +2796764439, +1309403428, +3081909835, +784033777, +3554254475, +2967466578, +3554254475, +3352871620, +3554254475, +1590793086, +3554254475, +701932520, +3352871620, +3496967303, +2094237127, +1091244048, +3815530850, +2969862996, +2768625435, +571309257, +1119744540, +1594548856, +2766056989, +3523407757, +3372436214, +2852464175, +1181144073, +40040826, +543223747, +3110715001, +2852464175, +1181144073, +4167943193, +2094237127, +565944005, +3523407757, +3523407757, +3611228824, +1116374495, +2768625435 +], +"Restore Debug Menu (READ NOTES)": [ +2238339752, +4120538232, +6315783, +1981559909, +1331292427, +1466425173, +3372436214, +3523407757, +1557497520, +805826031 +], +"Sensitive Analog Input (easier to run)": [ +2896259690 +], +"Skip Intro": [ +316887025, +316887025, +316887025 +], +"Sprint Fix (High FPS)": [ +487178217 +], +"Uncap FPS++": [ +4078859149, +1390600732, +2833541427, +2833541427, +1189330515, +54321702, +3105031303, +3105031303, +2879826793, +2879826793, +2879826793, +2879826793, +2061523586, +1627956100, +1627956100, +1627956100, +370119442, +370119442, +370119442, +2150128256, +3433886301, +3433886301, +3433886301, +3786298538, +3464669836, +3464669836, +737843265, +2896259690, +2896259690, +2896259690, +2896259690, +2896259690, +4132207062, +988182856, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +2185502769, +2319345656, +3034846778, +2070632800, +3475978548, +2909859693, +2631118384, +2323127071, +92922331, +2039770993, +1115816987, +4212624557, +1104745215, +2255673703, +901431946, +2768625435, +3654225759, +2718676303, +4088589409, +3705235391, +2322244508, +2985312887, +1498818383, +3201515988, +3273652256, +2651470142, +3169574030, +1196573059, +1259060791, +2094237127, +3898889988, +2388195768, +1647839111, +3479810198, +627095760, +2265434530, +1007455905, +4275794182, +1852075159, +3523407757, +3523407757, +3523407757, +2212294583, +1231433021, +3596559473, +4084567069, +3736401077, +4026595636, +1007455905, +798129053, +1281318153, +1647839111, +3895848633, +4088798008, +733688034, +1007455905, +4275794182, +1852075159, +3523407757, +3523407757, +3523407757, +2212294583, +1231433021, +2094237127, +2648514015, +1510651243, +1455205971, +3523407757, +3707901625, +130729692, +491394942, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3185849905, +2094237127, +3940166985, +2933784634, +2154433979, +3523407757, +544722670, +558161692, +1160256024, +2896259690, +3464669836, +558161692, +2368389495, +4184371333, +3465309164, +3329911327, +571615212, +1199063381, +1140457901, +4128687841, +554730246, +2174648374, +3520565598, +2454845616, +822666861, +170791278, +4108283363, +1683950521, +198859238, +3323510029, +3849980644, +3359755065, +3323510029, +1365703029, +3359755065, +3323510029, +960032351, +3359755065, +3323510029, +277943102, +3359755065, +3323510029, +1802675284, +3589725021, +3323510029, +108720932, +4080004081, +87579910, +2207897948, +2659628423, +3323510029, +3292184665, +49369776, +49369776, +49369776, +3685112572, +1620668763, +1620668763, +49369776, +1620668763, +49369776, +49369776, +49369776, +3685112572, +1089557024, +1089557024, +49369776, +2616722186, +1620668763, +2616722186, +1620668763, +1620668763, +3760546069, +3760546069, +209058557, +209058557, +49369776, +1620668763, +2981173530, +2768625435, +4243730363, +3787538431, +894062881, +4158520656, +1589567198, +887835720, +453833892, +759777354, +4087591940, +2856385493, +1737012036, +1472077940, +1622860868, +2102284411, +3832870339, +1468257426, +1468257426, +1468257426, +1468257426, +3464669836, +3464669836, +3464669836, +3464669836, +874729383, +874729383, +3464669836, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +4138166084, +1324957560, +874729383, +3464669836, +874729383, +874729383, +874729383, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +3464669836, +874729383, +874729383, +3464669836, +874729383, +874729383, +3464669836, +2789939855, +874729383, +3545907812, +3545907812, +3545907812, +3795698027, +3424469690, +558161692, +874729383, +874729383, +3504607969, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +874729383, +49369776, +4014592400, +49369776, +3464669836 +], +"Unlock Game Region": [ +2786556550, +3395220681, +3395220681 +] +} diff --git a/patches/redzone.json b/patches/redzone.json new file mode 100644 index 0000000..e38ac14 --- /dev/null +++ b/patches/redzone.json @@ -0,0 +1 @@ +{"eboot":"Bloodborne 01.09","text_sha256":"da9c1e6e6de298a07739a6d0bde7f6fa58198a0051b779dc241cbe9e9b6720ad","functions":153,"skipped":["0x3214c0 (jump table)","0x3bdcd0 (jump table)","0x40fc00 (jump table)","0x413b00 (jump table)","0x4c4d00 (jump table)","0x4c53a0 (jump table)","0x4c6ff0 (jump table)","0x5a71b0 (jump table)","0x781b60 (jump table)","0x787c30 (jump table)","0xd56840 (jump table)","0xd72690 (jump table)","0xd72a20 (jump table)","0xd72df0 (jump table)","0xd73310 (jump table)","0xd893a0 (jump table)","0xd8baf0 (jump table)","0xd8ed10 (jump table)","0xd99fd0 (jump table)","0xd9b6e0 (jump table)","0xd9c070 (jump table)","0xdc5b30 (jump table)","0xdc6700 (jump table)","0xf584c0 (jump table)","0xf5a5d0 (jump table)","0xffc220 (jump table)","0x15cb1f0 (jump table)","0x176f950 (jump table)","0x18dc900 (jump table)","0x210a700 (jump table)","0x2879e20 (jump table)"],"uncovered_stores":["0x6d5c5","0x1373e9","0x1373f0","0x1374b4","0x1374c4","0x137618","0x1376d6","0x1376dd","0x3b7031","0x3d10a8","0x3d157b","0x40b401","0x6db371","0x810356","0x810383","0xc62843","0xd4e640","0xd74582","0xdca513","0xe51128","0x17e44d6","0x17e44de","0x1a0d484","0x2b82e38","0x2b82e9d","0x2b82f9e","0x2b8300d","0x2b833b9","0x2b834fb","0x2b83e0a","0x2b83e4c","0x2b83fc0","0x2b84482","0x2b84627","0x2b85042","0x2b851a2","0x2b8532f","0x2b853ef","0x2b855b6","0x2b855dd","0x2b856e7","0x2b858a5"],"sites":[[82112,"6642c704692800",[-7]],[82135,"6642c704697d00",[-7]],[82156,"6642c704695d00",[-7]],[82174,"6642c704697b00",[-7]],[82192,"6642c704693c00",[-7]],[82210,"6642c704695b00",[-7]],[82797,"6642c704692900",[-7]],[82821,"6646893c69",[-5]],[82831,"468914a9488b4c24d042c6042900",[-4,5,-5]],[82897,"6642c704693e00",[-7]],[83032,"6644897c7d00418d343a8934bb41c6043800",[-6,4,-3,-5]],[83168,"48c741280000000048c7412000000000c7010000803fc741140000803fc741280000803f48c70001000000",[-8,-8,-6,-7,-7,-7]],[83235,"c7010000803f48c7410c0000000048c7410400000000c741140000803fc7411800000000c7411c0000000048c70001000000",[-6,-8,-8,-7,-7,-7,-7]],[491848,"488947184c896720488b4424e8488947284c8957304c8977388b4424e48947408b4424f48947448b4424f88947488b4424fc89474c8857504c894f08",[-4,-4,5,-4,-4,-4,4,-3,4,-3,4,-3,4,-3,-3,-4]],[801876,"418881d0000000498d99d3000000418a81d3000000418881d1000000418a81d4000000418881d2000000418a81d5000000418881d3000000",[-7,7,7,-7,7,-7,7,-7]],[802845,"6641c781d40000000000",[-10]],[802870,"418881d6000000418a91d8000000418891d7000000",[-7,7,-7]],[803166,"41c681d500000001",[-8]],[803257,"41c681d400000000",[-8]],[803378,"6641c781d40000000000",[-10]],[803444,"41c681d400000001",[-8]],[803541,"41c681d500000000",[-8]],[803556,"418881d900000084d2490f44da8a032401418881da000000",[-7,2,4,2,2,-7]],[803770,"418881da000000",[-7]],[847056,"4c897cd5004c8974d50866c744d512000066894cd5104c897cd5184c8964d52066c744d52a000066448954d52883c3026641895c8512",[-5,-5,-7,-5,-5,-5,-7,-6,3,-6]],[875519,"88084803442498",[-2,5]],[875655,"41880c284401d6",[-4,3]],[876019,"41880c2801f8",[-4,2]],[876411,"438814a84c89e2c1ed1443886ca801c1e91443884ca802c1e814438844a803",[-4,3,3,-5,3,-5,3,-5]],[876667,"43881498c1ee0a4388749801c1ed0a43886c9802c1e90a43884c9803",[-4,3,-5,3,-5,3,-5]],[876922,"8848fdc1ed0a408868fec1eb0a8858ff4c89eb4d89c5c1ea0a8810",[-3,3,-4,3,-3,3,3,3,-2]],[1140937,"881948ffc1",[-2,3]],[1152153,"488987f8000000",[-7]],[1152517,"66899ff8000000668987fa00000066898ffc00000066448987fe00000085d20f9fc20fb6d2ffc2899700010000",[-7,-7,-7,-8,2,3,3,2,-6]],[1186680,"6689047198",[-4,1]],[1186803,"6641890c68",[-5]],[1186920,"66890479ffc6",[-4,2]],[1186937,"66428b0461668901",[5,-3]],[1274660,"c706ff3f0000c702ff3f0000c70101c0ffff41c70001c0ffff",[-6,-6,-6,-7]],[1274830,"488b5424f0893a",[5,-2]],[1274846,"488b5424f88902",[5,-2]],[1275033,"488b5424f0892a",[5,-2]],[1275049,"488b5424f8893a",[5,-2]],[1275389,"488b7c24f0891f",[5,-2]],[1275405,"488b7c24f8892f",[5,-2]],[1275423,"41892866666666662e0f1f840000000000",[-3,14]],[1275579,"488b5424f0891a",[5,-2]],[1275595,"488b5424f8893a",[5,-2]],[3744672,"48c741280000000048c7412000000000c7010000803fc741140000803fc741280000803f48c70001000000",[-8,-8,-6,-7,-7,-7]],[3744739,"c7010000803f48c7410c0000000048c7410400000000c741140000803fc7411800000000c7411c0000000048c70001000000",[-6,-8,-8,-7,-7,-7,-7]],[3831463,"89b7a0000000",[-6]],[3854612,"884bfe8a4c03ff884bff8a0c03880b",[-3,4,-3,3,-2]],[3854673,"4288443604",[-5]],[3854705,"4288443605",[-5]],[3855203,"880b48ffc3",[-2,3]],[3855490,"880e48ffc6",[-2,3]],[3855735,"880b48ffc3",[-2,3]],[3855827,"884efe8a4bff884eff8a0b880e",[-3,3,-3,2,-2]],[3855888,"41884e0183f802",[-4,3]],[3855901,"418846024983c602",[-4,4]],[3856092,"41884d0049ffc5",[-4,3]],[3856304,"41884e0149ffc6",[-4,3]],[3856331,"c7000b000000",[-6]],[3856346,"48894730488b442448c7001d000000",[-4,5,-6]],[3856411,"48890f488977184c39da4929d34183c30544895f084d39ce4d29f14181c10101000044894f20488b4c24484889414844894150",[-3,-4,3,3,4,-4,3,3,7,-4,5,-4,-4]],[3856484,"48894f30488b4c2448c7011d000000",[-4,5,-6]],[3856519,"48894730488b442448c7001d000000",[-4,5,-6]],[3877551,"8816418d14194863d2418a14148856014101f94963d14989c1418a1414885602410fb656018d042a4898418a04048846038d041a4898418a040488460401fa4863c2418a0404884605410fb6008d14284863d2418a141488118d14184863d2418a141488510101f84898418a0404884102410fb6400101c501c301f84c8b5c24e84863d5418a14148851034863d3418a1414885104488b5424f04898418a0404884105",[-2,4,3,4,-3,3,3,3,4,-3,5,3,2,4,-3,3,2,4,-3,2,3,4,-3,4,3,3,4,-2,3,3,4,-3,2,2,4,-3,5,2,2,2,5,3,4,-3,3,4,-3,5,2,4,-3]],[3877873,"41881e8d3c064863ff418a1c3c41885e0101d64863f6418a1c3441885e02410fb63001f14863c9418a0c0c41884d0001f04898418a04044188450101d64863c6418a040441884502",[-3,3,3,4,-4,2,3,4,-4,4,2,3,4,-4,2,2,4,-4,2,3,4,-4]],[3878245,"88018d042f4898418a040088410101d74863c7418a0400884102410fb647014101c501c501d04963d54d89d5418a14108851034863d5418a14108851044898418a0400884105",[-2,3,2,4,-3,2,3,4,-3,5,3,2,2,3,3,4,-3,3,4,-3,2,4,-3]],[3878439,"88450001f24863c2418a04008845014863c1418a0400884502",[-3,2,3,4,-3,3,4,-3]],[3888698,"41882c2449ffc4",[-4,3]],[3889035,"880348ffc3",[-2,3]],[3889071,"89484c488b4424e0",[-3,5]],[3895457,"45897c2404",[-5]],[3895569,"4189442408",[-5]],[3895681,"45895c240c",[-5]],[3895791,"41897c2410",[-5]],[3895903,"41895c2414",[-5]],[3895974,"4989742418",[-5]],[3896463,"488b4424c04c897820",[5,-4]],[3908607,"8808498b0cf649030cef48c1e9104401c94863c9418a0c0c88480145030cab4963c9418a0c0c884802",[-2,4,4,4,3,3,4,-3,4,3,4,-3]],[3909372,"884d00498b0cf249030cd748c1e9104489c629ce4863ce4c89f6418a0c0c884d01442b04964963c8418a0c0c884d028a08884d03",[-3,4,4,4,3,2,3,3,4,-3,4,3,4,-3,2,-3]],[3966739,"8b5424fc891785f6",[4,-2,2]],[3966807,"4489670431d2",[-4,2]],[3966875,"8957084585ed",[-3,3]],[3966956,"89770c4585f6",[-3,3]],[3967023,"89571031c9",[-3,2]],[3967097,"8957144585d2",[-3,3]],[3967173,"894f1831c9",[-3,2]],[3967251,"894f1c4529c8",[-3,3]],[3967267,"c7472000000000",[-7]],[3967532,"c5fb2cc1894720",[4,-3]],[4001062,"c6041e00c6441eff00",[-4,-5]],[4001088,"c6041effc6441effff",[-4,-5]],[4001117,"44885c1efe418a0a884c1efd418a08884c1efc418a4d00884c1efb8a4c16fb884c1efa4883c0fa8a4c16fa884c1ef9",[-5,3,-4,3,-4,4,-4,4,-4,4,4,-4]],[4001336,"42886c16ff",[-5]],[4001443,"4088308a118850ff8a51ff8850fe8a51fe4883c1fd8850fd",[-3,2,-3,3,-3,3,4,-3]],[4001475,"41c646100641c6461204418a4e11c0e10241884e13",[-5,-5,4,3,-4]],[4001591,"88442eff4c0f44c3",[-4,4]],[4001702,"885c0eff490f44c1",[-4,4]],[4001721,"41c646110841c64613084d897e08",[-5,-5,-4]],[4001791,"66c7444efe0000",[-7]],[4001808,"66c7444efeffff",[-7]],[4001819,"885c4efd8a5c0efe885c4efc",[-4,4,-4]],[4001886,"40886c0eff8a5c16ff885c0efe",[-5,4,-4]],[4001910,"41c646100441c6461202418a4e1100c941884e13",[-5,-5,4,2,-4]],[4002274,"41881e85ff",[-3,2]],[4002288,"41884e014983c602",[-4,4]],[4002496,"40882e4585d2",[-3,3]],[4002511,"884e014883c602",[-3,4]],[4002673,"880240887201",[-2,-4]],[4002687,"8842028a41074883c108884203",[-3,3,4,-3]],[4003059,"882e884e01",[-2,-3]],[4003075,"8846028a42074883c208884603",[-3,3,4,-3]],[4003147,"884a12806210fd8a4211f6e1884213",[-3,-4,3,2,-3]],[4239500,"88042f48ffc5",[-3,3]],[4239730,"c6842f900000001c",[-8]],[4239740,"c6842f9000000016",[-8]],[4239753,"c6842f900000001a",[-8]],[4239792,"889cafe903000089cb83e33f81cb80000000889cafea030000",[-7,2,3,6,-7]],[4239846,"889cafe9030000ba06060000c4e268f7d981cb80000000889cafea03000089cb83e33f81cb80000000889cafeb030000",[-7,5,5,6,-7,2,3,6,-7]],[4239927,"889cafe9030000ba0c060000c4e268f7d981cb80000000889cafea0300004181ca80000000448894afeb03000089cb83e33f81cb80000000889cafec030000",[-7,5,5,6,-7,7,-8,2,3,6,-7]],[4240004,"889cafe803000066898c6fe8010000",[-7,-8]],[4240042,"c6842f9000000001",[-8]],[4240084,"889c2f90000000c684afe80300000066c7846fe80100000000",[-7,-8,-10]],[4240158,"889c2f90000000c684afe803000001888cafe903000083f90119db09cb66899c6fe8010000",[-7,-8,-7,3,2,2,-8]],[4240220,"c6842f9000000000",[-8]],[4240240,"66c7846fe8010000ffff66c784afe80300000100",[-10,-10]],[4240275,"4c8987e0010000488997d8010000",[-7,-7]],[4240301,"4889879001000048898798010000488987a0010000",[-7,-7,-7]],[4240329,"488987a8010000488987b0010000488987b8010000",[-7,-7,-7]],[4240357,"488987c0010000488987c8010000488987d0010000",[-7,-7,-7]],[4240396,"48898780000000",[-7]],[4483835,"49893385c9",[-3,2]],[4483872,"48891e4889de",[-3,3]],[4483910,"4889304c8b36",[-3,3]],[4483971,"48c70600000000",[-7]],[4559675,"49893385c9",[-3,2]],[4559712,"48891e4889de",[-3,3]],[4559750,"4889304c8b36",[-3,3]],[4559811,"48c70600000000",[-7]],[5205924,"49894cd428",[-5]],[5205949,"488954f128",[-5]],[5205965,"894e0839fa",[-3,2]],[5205990,"48897c02f8488b4c0a0848890c02",[-5,5,-4]],[5206013,"897e084439cf",[-3,3]],[5206048,"48893c29488b5411084889542908",[-4,5,-5]],[5946509,"4889014d8930",[-3,-3]],[6673920,"c70200000000",[-6]],[6673967,"66c787ec0000000000",[-9]],[6674072,"66428934026689afe00000008b97d000000089ce29d6c1ee056689b7ec000000c7410c0000003f",[-5,-7,6,2,2,3,-7,-7]],[6674174,"89510c410fb7510c488bb7d000000089cd29f5c1ed0548c1e2054c8d0416668b54161e66413b550c0f94c20fb6d26641896c501c418b550c66448b87e00000000fb7f2488bafd00000004889f248c1e2056644894415006689b7e0000000",[-3,5,7,2,2,3,4,4,5,5,3,3,-6,4,8,3,7,3,4,-6,-7]],[6674478,"6642890c3a664489b7e0000000ff8fe40000008b8f38010000ffc9898f38010000",[-5,-8,-6,6,2,-6]],[6674534,"4889341a488b4c0a0848894c1a08",[-4,5,-5]],[6674569,"6644895c0a1e4181cb00000080498b09448919",[-6,7,3,-3]],[6681849,"66c745000000",[-6]],[6681943,"6689140e66458944dd10418b0e89c229cac1ea0566895500c7400c0000003f",[-4,-6,3,2,2,3,-4,-7]],[6682013,"89480c0fb74f0c498b1689c629d6c1ee0548c1e105488d3c0a668b4c0a1e66413b4c240c0f94c10fb6c96689744f1c418b4c240c4d8d64dd1066418b54dd100fb7c9498b364889cf48c1e7056689143e6641894cdd10",[-3,4,3,2,2,3,4,4,5,6,3,3,-5,5,5,6,3,3,3,4,-4,-6]],[6682280,"66428904396645891c2441ff4cdd14488b4424f84ac704080000000042c744080800000000",[-5,-5,-5,5,-8,-9]],[6754962,"c70100000000",[-6]],[6755018,"c787ec00000000000000",[-10]],[6755127,"8934118987e00000004c2b8fd000000049c1e9044169c1abaaaaaa8987ec000000c744292000000000",[-3,-6,7,4,7,-6,-8]],[6755207,"89742920488bb7d00000004929f149c1e9044569c9abaaaaaa8b12488d145248c1e204488d1c168b5416283b100f94c20fb6d244894c93244863008b97e0000000488bb7d0000000488d1c4048c1e30489141e8987e0000000",[-4,7,3,4,7,2,4,4,4,4,2,3,3,-5,3,6,7,4,4,-3,-6]],[6755485,"428904394489a7e0000000ff8fe40000008b8738010000ffc8898738010000",[-4,-7,-6,6,2,-6]],[6755538,"4a891431488b4401084a89443108",[-4,5,-5]],[6755580,"44895c01284181c800000080498b01448900",[-5,7,3,-3]],[6762656,"41c70000000000",[-7]],[6762767,"893c13458954c110492b3648c1ee0469d6abaaaaaa418910c7442b2000000000",[-3,-5,3,4,6,-3,-8]],[6762839,"4489642b20498b3e4829fe48c1ee044469e6abaaaaaa418b30488d347648c1e6044c8d04378b743728413b320f94c20fb6d245896490244963124d8d64c110458b44c110498b3e488d345248c1e60444890437418954c110",[-5,3,3,4,7,3,4,4,4,4,3,3,3,-5,3,5,5,3,4,4,-4,-5]],[6763095,"4289143e45892c2441ff4cc114488b4424f84ac704180000000042c744180800000000",[-4,-4,-5,5,-8,-9]],[7076760,"41c701eeff7f7f",[-7]],[7160982,"894d004883c504",[-3,4]],[7188414,"44880689caffc239c20f94c20fb6d2448d4c0a01468a540ce8448856018d4c0a0239c10f94c10fb6c9428d4c09014863c9448a4c0ce844884e0269d00404040481c200010203c5f96ec2c5f970c000c5f96f0fc4e27100c0c5f1efc9c5f82ec118d288c1c0e1074408c1880ec0e00624804408d0884601c0e2074408ca885602",[-3,2,2,2,3,3,5,5,-4,4,2,3,3,5,3,5,-4,6,6,4,5,4,5,4,4,2,2,3,3,-2,3,2,3,-3,3,3,-3]],[7188600,"8906c5f96f0f",[-2,4]],[7189311,"890ec4c1585cc0",[-2,5]],[7189592,"44880689d7c1e70409cf40887e01c1ea048856024489c9c1e90844884e03c1e0044180e240410fb6d209c809d0884604",[-3,2,3,2,-4,3,-3,3,3,-4,3,4,4,2,2,-3]],[7189872,"66893e83e002c1e00e09d06689460266894e04",[-3,3,3,2,-4,-4]],[8250810,"6641897a18664189721a664189521c6641894a1e41c7421000000000",[-5,-5,-5,-5,-8]],[8454026,"c747080100ffff",[-7]],[8454044,"48890f4805d8000000488947104889771848895720",[-3,6,-4,-4,-4]],[8454916,"81fac00000000f9407",[6,-3]],[8454954,"668378fe010f9407",[5,-3]],[8455001,"c6070031c0",[-3,2]],[8590929,"c7070000003fc74704cdcccc3dc747080ad7233cc7470c17b7d138c5fa115f10c5fa114f14c5d9dfcac5f85dc5c5f172f11dc5f857c1c747180ad7a33cc7471c0ad7233cc747200ad7233cc5fa114724c5fa115f28c7472c205cc23fc74730acc52737c6473400",[-6,-7,-7,-7,5,5,4,4,5,4,-7,-7,-7,5,5,-7,-7,-4]],[8675913,"885f640fb6c3",[-3,3]],[8864244,"418909488b0e8b4c810441894904488b0e8b4c810841894908488b5424d0488b0a8b0c8141890b488b0a8b4c810441894b04488b0a8b4c810841894b08",[-3,3,4,-4,3,4,-4,5,3,3,-3,3,4,-4,3,4,-4]],[8864335,"890f4883c704",[-2,4]],[8864374,"890f4883c704",[-2,4]],[8864451,"458904a94531c0",[-4,3]],[8864509,"895608488b4424d0895008",[-3,5,-3]],[8864546,"01c841894008",[2,-4]],[8864578,"01c841894108",[2,-4]],[9066258,"66896cb8104a8b6c9a0848896cb8084a8b2c9a48892cb866428b6c9a1266896cb812",[-5,5,-5,4,-4,6,-5]],[9066307,"428b7c9a14893b",[5,-2]],[9066498,"42892ca349ffc4",[-4,3]],[9149205,"41c70300000000",[-7]],[9150122,"458913b001",[-3,2]],[11435941,"6641c7412c0000",[-7]],[11436044,"664289043b6641896920418b41104489c129c1c1e905664189492c41c7400c0000003f",[-5,-5,4,3,2,3,-5,-8]],[11436141,"4189400c410fb744240c498b49104489c329cbc1eb0548c1e005488d2c01668b44011e66413b460c0f94c00fb6c066895c451c418b460c66418b49200fb7e8498b59104889e848c1e00566890c036641896920",[-4,6,4,3,2,3,4,4,5,5,3,3,-5,4,5,3,4,3,4,-4,-5]],[11436425,"6642890411664189512041ff4924c7460cffffff3f",[-5,-5,-4,-7]],[11751432,"42896c3f14c1e60a09c109f141c1e0054409c142894c3714",[-5,3,2,2,4,3,-5]],[11798967,"44890497440b5c24f48d148d010000004863d2488b3b44891c974409d08d148d020000004863d2488b3b89049709f58d048d030000004898488b0b892c81",[-4,5,7,3,3,-4,3,7,3,3,-3,2,7,2,3,-3]],[12015853,"4288442f0c",[-5]],[12016445,"4488570a44884f0b",[-4,-4]],[12016479,"66c747080000c6470d00c6470c00",[-6,-4,-4]],[12016666,"884f0c66c747080000",[-3,-6]],[12908279,"8932d3e309d8",[-2,2,2]],[12986607,"09d842890436",[2,-4]],[12987438,"42893c164401d8",[-4,3]],[13944443,"4889475048635720",[-4,4]],[13944491,"488948ce480fbe68fe",[-4,5]],[13944531,"4c8968ee488d2c09",[-4,4]],[13944544,"4c8968e6480fbe68ff",[-4,5]],[13944570,"4c8968d64a8d5c29034883e3fc488958de",[-4,5,4,-4]],[13944630,"4c896f404c6357184c895424c04b8d44d500498d5402034883e2fc4531db48894738",[-4,4,5,5,5,4,3,-4]],[13944704,"4b8954dd00488d6a434883e5fc488b4424f04c8b68404f8b64dd0049896c2408",[-5,4,4,5,4,5,-5]],[13945021,"4d89342485db",[-4,2]],[13945344,"4d8934244883ca03",[-4,4]],[13945363,"4d897424108d48024863c94883ca03488d144a4883e2fc4989542418488d4c11034883e1fc49894c24284898488d4c08034883e1fc49894c2420",[-5,3,3,4,4,4,-5,5,4,-5,2,5,4,-5]],[13945447,"488950484863481c",[-4,4]],[13945488,"488902480fbe6a24488d4428034883e0fc48894208",[-3,5,5,4,-4]],[13945523,"4889473048635714",[-4,4]],[13945577,"4889084883c903",[-3,4]],[13945602,"488948e84801f1",[-4,3]],[13945613,"488948f0488d4c5903",[-4,5]],[13945636,"48894f2831c0",[-4,2]],[13952349,"0f44f8897e08",[3,-3]],[13952367,"894604450fb75c100141ffcb44891e",[-3,6,3,-3]],[13952511,"89561044896608897e04",[-3,-4,-3]],[13952592,"894e0c41ffc344891e410882d4020000",[-3,3,-3,-7]],[13952621,"4489660cc6461401",[-4,-4]],[13952640,"410fbe040089460c",[5,-3]],[13952813,"018634030000",[-6]],[13952828,"4489b6300300004b8d0476488b4c24c00fb7440101ffc8894424f889862c030000",[-7,4,5,5,2,4,-6]],[13953136,"89863c0300004489ae3403000044898e30030000",[-6,-7,-7]],[13953489,"44899e38030000",[-7]],[13953508,"808ed402000001",[-7]],[13953524,"898638030000ff862c030000",[-6,-6]],[13953686,"44899e38030000c6864003000001",[-7,-7]],[13953737,"89be38030000c6864003000001",[-6,-7]],[13953894,"89be38030000",[-6]],[13953921,"898638030000",[-6]],[13953964,"89be38030000",[-6]],[14107802,"894604488b5424f0410fb73c50893e",[-3,5,5,-2]],[14107931,"89561044897e088d4301894604",[-3,-4,3,-3]],[14107984,"44037e1044897e08",[4,-4]],[14107996,"44897e0cffc7893e",[-4,2,-2]],[14108004,"450893d4020000",[-7]],[14108024,"44897e0cc6461401",[-4,-4]],[14165658,"c787240100000000803f48c787280100000000000066c787200100000000",[-10,-11,-9]],[14166080,"c704ab00000000c704ae00000000c704a900000000",[-7,-7,-7]],[14166121,"66c787220100000000",[-9]],[14173076,"66898720010000",[-7]],[14244481,"41c744240800000000",[-9]],[14244709,"89074883c504",[-2,4]],[14245553,"41c7430800000000",[-8]],[14245781,"89074883c504",[-2,4]],[14448392,"48890141ffc3",[-3,3]],[14448982,"4889014883c708",[-3,4]],[14752224,"66892f4c01f7",[-3,3]],[14752448,"6644890f4883c204",[-4,4]],[14752672,"6689174983c404",[-3,4]],[14752832,"6689374801ef",[-3,3]],[14798507,"8901488b5424a0",[-2,5]],[14798549,"c70202000000",[-6]],[14798562,"c70201000000",[-6]],[14798570,"c70200000000",[-6]],[14798587,"89078d0c40",[-2,3]],[14829735,"4429571c448b7c24e84529d744897f20c7473800000000",[-4,5,3,-4,-7]],[14830054,"44897f2001471c",[-4,-3]],[14830095,"44897f2044015f1c",[-4,-4]],[14830143,"488974d500",[-5]],[14830155,"488b4710488903",[4,-3]],[14998862,"48890a48c70001000000",[-3,-7]],[14998910,"48890a48c70001000000",[-3,-7]],[14998958,"48890a48c70001000000",[-3,-7]],[15002734,"48890a48c70001000000",[-3,-7]],[15002782,"48890a48c70001000000",[-3,-7]],[15002830,"48890a48c70001000000",[-3,-7]],[15011967,"c7471c00000000",[-7]],[15012108,"448903c1e918",[-3,3]],[15012153,"66890243894cd90c",[-3,-5]],[15012171,"4489571c49ffc6",[-4,3]],[15360414,"48890a48c70001000000",[-3,-7]],[15360462,"48890a48c70001000000",[-3,-7]],[15360510,"48890a48c70001000000",[-3,-7]],[17331523,"668907896f04895f0883e60fc1e60681e1000000034d85c94c894f1044885f1944887f1ab8000000030f44c109f066c74702000089470c",[-3,-3,-3,3,3,6,3,-4,-4,-4,5,3,2,-6,-3]],[17332990,"892c878b0289049f8b4604c1e01005000001002500007f000dff0380be418d6801428d1c850000000089041f8b4204418d58028904af8b4604c1e01005000002002500007f000dff0380be418d680389049f8b4208418d58048904af8b4604c1e01005000003002500007f000dff0380be418d680589049f8b420c8904af",[-3,2,-3,3,3,5,5,5,4,8,-3,3,4,-3,3,3,5,5,5,4,-3,3,4,-3,3,3,5,5,5,4,-3,3,-3]],[17333286,"892cb783c20289049f",[-3,3,-3]],[17333350,"c7049700008cbf4183c002c7048f002080be8b4e3085c90f45c142890487",[-7,4,-7,3,2,3,-4]],[17374157,"48c70700000000",[-7]],[17374361,"4489571444894f184489671c4489472044897f24",[-4,-4,-4,-4,-4]],[17374576,"6689440f284488440f2a",[-5,-5]],[17374629,"8901478a1477400fb6cd460fb664860389c841f6f24183e401468d24a5040000004b8d1c4948c1e3044489641f44ba0100000048d3e241feca4120ea44896c1f40450fb6ca4889541f28",[-2,4,4,6,2,3,4,8,4,4,-5,5,3,3,3,-5,4,-5]],[17374720,"89441f3089e8",[-4,2]],[17374743,"89441f34430fb6447701b90f0000004429c9440fafc844894c1f38ba01000000d3e289541f3c896c1f4c",[-4,6,5,3,4,-5,5,2,-4,-4]],[17374792,"428b0cf1894c1f48",[4,-4]],[17374811,"894c1f500fafc589441f54",[-4,3,-4]],[17374891,"44092a89c988440d00",[-3,2,-4]],[17374927,"8911488d4c6d0048c1e10488440f2a",[-2,5,4,-4]],[17374954,"6689440f28",[-5]],[17544244,"c70000000000",[-6]],[17544566,"890e428b4c228c894e04",[-2,5,-3]],[17544599,"488b4c2438ff01",[5,-2]],[17544628,"890e428b4c22ac894e04",[-2,5,-3]],[17544649,"ff010f1f440000",[-2,5]],[17544678,"890e428b4c22cc894e04",[-2,5,-3]],[17544699,"ff010f1f00",[-2,3]],[17544753,"890e428b4c22ec894e04",[-2,5,-3]],[17544784,"ff0166666666662e0f1f840000000000",[-2,14]],[17545097,"89088b4a0c894804",[-2,3,-3]],[17545127,"488b4c2438ff01",[5,-2]],[17545162,"89088b4a0c894804",[-2,3,-3]],[17545176,"488b4c2438ff0190",[5,-2,1]],[17545212,"89088b4a0c894804",[-2,3,-3]],[17545231,"ff016666666666662e0f1f840000000000",[-2,15]],[17545299,"89088b4a0c894804",[-2,3,-3]],[17545328,"ff0066666666662e0f1f840000000000",[-2,14]],[17546944,"41c70000000000",[-7]],[17547221,"8908428b4c278c894804",[-2,5,-3]],[17547270,"488b4c24e8ff01",[5,-2]],[17547299,"8908428b4c27ac894804",[-2,5,-3]],[17547320,"ff01660f1f440000",[-2,6]],[17547350,"8908428b4c27cc894804",[-2,5,-3]],[17547371,"ff010f1f00",[-2,3]],[17547398,"8908428b4c27ec894804",[-2,5,-3]],[17547419,"ff010f1f00",[-2,3]],[17739891,"48c740ec0000803f",[-8]],[17742067,"b9000080bf488948fc",[5,-4]],[18389510,"41896cc304",[-5]],[18389547,"438b6cc304896cc204",[5,-4]],[18389576,"66895cc202",[-5]],[18389770,"6644892cc16644896cc102",[-5,-6]],[18406684,"890a488b4c24f88901",[-2,5,-2]],[18407632,"45892cb64439de",[-4,3]],[18408046,"478904964539e2",[-4,3]],[18602273,"2971284139f1",[-3,3]],[18602281,"41c7070000000066c7441004ffff66c7441002ffff6641c706ffff66c744100e000048c744100600000000",[-7,-7,-7,-6,-7,-9]],[18602739,"0fb700458927",[3,-3]],[18602865,"66c7442910ffff66c7442914ff7f66c7442912ff7f66c744291c0000c744291800000000",[-7,-7,-7,-7,-8]],[18603098,"66448910488b442410668938488b442408668910488b4424b066448920488b442428668918488b442420668908488b442418668930488b4424a866448928",[-4,5,-3,5,-3,5,-4,5,-3,5,-3,5,-3,5,-4]],[18885146,"448997a0000000",[-7]],[18885639,"898fa40000008b8e880000008b96980000004101cfc1e204c1e10401c101d189ca29c28997a8000000",[-6,6,6,3,3,3,2,2,2,2,-6]],[18885735,"c74774ffff0000c5fa1147784489bf840000008b96a8000000899788000000898f8c000000c5f857c0c5fa2ac1c5fa2ac8c5fa5ec1c5fa11879000000044898f94000000",[-7,5,-7,6,-6,-6,4,4,4,4,8,-7]],[18958500,"4889461048c7041a00000000ff0e",[-4,-8,-2]],[18958552,"4889561048c704e800000000ff0e",[-4,-8,-2]],[18959208,"4889441d00",[-5]],[18959243,"488944f500ff02",[-5,-2]],[18959382,"48894268c57a115270",[-4,5]],[18959492,"48c7041600000000",[-8]],[18959510,"48c7401000000000c70000000000",[-8,-6]],[18991407,"488902488b6e0848896a0848c7421000000000bbffffffff48895a18",[-3,4,-4,-8,5,-4]],[18992062,"48891ac5f82818",[-3,4]],[18992462,"48895a08c4c1782818",[-4,5]],[21419066,"89690449c70000000000",[-3,-7]],[21419088,"488946104c8b56204c0356184c39d04889d8490f47c148894628",[-4,4,4,3,3,4,-4]],[21419119,"66c700000066c740020000c7400400000000",[-5,-6,-7]],[21419193,"4489084883c3054883e3fc488d4b024c39d1490f47d866892b488d69024c39d54889c8490f47c066448930488d41034c39d0490f47e844887d00488d69044c39d5490f47c04488204883c1054c39d148894e10490f47e844886d00488b4e104883c1034883e1fc488d410448894610488b6e2048036e184839e8490f47c84531c98911",[-3,4,4,4,3,4,-3,4,3,3,4,-4,4,3,4,-4,4,3,4,-3,4,3,-4,4,-4,4,4,4,4,-4,4,4,3,4,3,-2]],[21419348,"89410449c70000000000",[-3,-7]],[21419401,"89790449c70000000000",[-3,-7]],[21419423,"48894e10488b7e2048037e1831d24839f9480f46d048895628",[-4,4,4,2,3,4,-4]],[21419453,"66c702ffff66c742020000c74204000000008d420829c1894a0449c70000000000",[-5,-6,-7,3,2,-3,-7]],[23062804,"43890cbe428934a0",[-4,-4]],[24003445,"4489049f44892c90",[-4,-4]],[25052075,"488b5c24f048890b",[5,-3]],[25052311,"488b4c24d08911",[5,-2]],[25052360,"450f47e5448921",[4,-3]],[25052418,"890ac5f057cbc5fa2ad0c5f25ccac5fa2cc1488b4c24388901",[-2,4,4,4,4,5,-2]],[25132816,"4489388b16895004",[-3,2,-3]],[25896964,"80670dbfc74708ffffffff",[-4,-7]],[27317407,"c7471c00000000c7472800000000c7473000000000",[-7,-7,-7]],[31357660,"c680dc00000001",[-7]],[31357708,"88465048ffc2",[-3,3]],[31358509,"448820c4e37914c50141886c3626c4e1f97edec4e37914c50241886c1e26",[-3,6,-5,5,6,-5]],[31358575,"41887c2e26c4e1f97edfc4e37914c50441886c0e26",[-5,5,6,-5]],[31358632,"41884c2e26c4c1f97eddc4e37914c50641886c0626",[-5,5,6,-5]],[31358689,"45885c2e26c4c1f97edbc4e37914c0084188441626",[-5,5,6,-5]],[31358756,"4588541626c4c1f97edac4e37914c30a41885c2e26",[-5,5,6,-5]],[31358823,"45884c1e26c4e1f97edbc4e37914c10c41884c1626",[-5,5,6,-5]],[31358890,"41884c1626c5f9d65c24e0c4e37914c20e4188543626",[-5,6,6,-5]],[31358953,"41884c3626c4c1f97edfc4e37d19c001c4e37914c6004188743e26",[-5,5,6,6,-5]],[31359017,"4188543e26c4c1f97edcc4e37914c70241887c0e26c4c3f916dd014b8d3c4048c1e7044b8d145b48c1e204488d044048c1e0044b8d0c5248c1e104488d6c6d0048c1e504488d1c5b48c1e304c4e37914c6034188743e26c4e37914c6044188741626c4e37914c2054188540626c4e37914c0064188440e26c4e37914c0074188442e26c4e37914c0084188441e26c4c37914c009c4e37914c10ac4e37914c20bc4e37914c60cc4e37914c70dc4e37914c50ec4e37914c30f488b4424e8488d044048c1e0044588440626488b4424e0488d044048c1e00441884c0626488b4424d8488d044048c1e0044188540626488b5424f0488b4c24c04b8d047f48c1e00441887406264b8d044948c1e00441887c06264b8d046448c1e00441886c06264b8d446d0048c1e00441885c0626",[-5,5,6,-5,6,4,4,4,4,4,4,4,4,5,4,4,4,6,-5,6,-5,6,-5,6,-5,6,-5,6,-5,6,6,6,6,6,6,6,5,4,4,-5,5,4,4,-5,5,4,4,-5,5,5,4,4,-5,4,4,-5,4,4,-5,5,4,-5]],[31359398,"8811ffc04883c130",[-2,2,4]],[33729484,"4489560489c1c1f91fc1e91d01c1c1f9034429d2bf0200000029c701cf39fa0f4dfaf7d139cf0f4ecf4401d18d14014489cf29c74439ca0f4ef985ff410f48f8897e08",[-4,2,3,3,2,3,3,5,2,2,2,3,2,2,3,3,3,3,2,3,3,2,4,-3]],[33729552,"48c7460400000000",[-8]],[34656568,"49890948c70600000000498932",[-3,-7,-3]],[34656612,"44885a0a8a4e09",[-4,3]],[34656619,"80e1fd884e09",[3,-3]],[34656656,"48890a488b4c24f8488b572048894a28",[-3,5,4,-4]],[35755701,"48895c3d008b5c0e0c895c3d0c8b4c0e08894c3d08418b4c2404894c3d14418b0c24894c3d10418b4b04894c3d1c418b0b894c3d18418b4a04894c3d24418b0a894c3d20498b0948894c3d28",[-5,4,-4,4,-4,5,-4,4,-4,4,-4,3,-4,4,-4,3,-4,3,-5]],[35756094,"4589440b04",[-5]],[35756215,"898190000000",[-6]],[35969198,"488981a80600008b91f006000083fa05be050000000f42f289b1f00600008b91f406000083fa06be060000000f47f289b1f40600004c8b41084981487800002000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-8]],[35969317,"4883c044f0480938",[4,-4]],[35969335,"49898070c10000",[-7]],[35969343,"4889b1a80600008b81f006000083f805ba050000000f42d08991f00600008b81f406000083f806ba060000000f47d08991f40600004c8b41084981487800002000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-8]],[35969461,"4883c644f048093e",[4,-4]],[35969479,"49898070c10000",[-7]],[35969500,"4889b0b00600008b88f006000083f906ba060000000f42d18990f00600008b88f406000083f907ba070000000f47d18990f40600004c8b40084981487800004000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-8]],[35969621,"4883c644f048093e",[4,-4]],[35969639,"49898078c10000",[-7]],[35969665,"4989b1a8060000418b89f006000083f905ba050000000f42d1418991f0060000418b89f406000083f906ba060000000f47d1418991f40600004d8b41084981487800002000",[-7,7,3,5,3,-7,7,3,5,3,-7,4,-8]],[35969797,"4883c644f0480916",[4,-4]],[35969815,"49898070c10000488b4c24f0498989e0060000418b81f006000083f80cba0c0000000f42d0418991f0060000418b81f406000083f80dba0d0000000f47d0418991f40600004d8b41084981487800000010",[-7,5,-7,7,3,5,3,-7,7,3,5,3,-7,4,-8]],[35969957,"4883c144f0480939",[4,-4]],[35969975,"498980a8c10000",[-7]],[35970001,"4989b1b0060000418b89f006000083f906ba060000000f42d1418991f0060000418b89f406000083f907ba070000000f47d1418991f40600004d8b41084981487800004000",[-7,7,3,5,3,-7,7,3,5,3,-7,4,-8]],[35970133,"4883c644f0480916",[4,-4]],[35970151,"49898078c10000488b4c24f0498989e8060000418b81f006000083f80dba0d0000000f42d0418991f0060000418b81f406000083f80eba0e0000000f47d0418991f40600004d8b41084981487800000020",[-7,5,-7,7,3,5,3,-7,7,3,5,3,-7,4,-8]],[35970293,"4883c144f0480939",[4,-4]],[35970311,"498980b0c10000",[-7]],[36508816,"895cd7308d1c18895cd7348944d7388b4424f08944d73c448944d74044885cd744",[-4,3,-4,-4,4,-4,-5,-5]],[36508890,"894cc7308974c734448944c738418b4b18894cc73c418b4b1c894cc740418a4b20884cc744",[-4,-4,-5,4,-4,4,-4,4,-4]],[36508994,"8954cf308974cf348944cf38418b43188944cf3c418b431c8944cf40418a43208844cf44",[-4,-4,-4,4,-4,4,-4,4,-4]],[36509082,"8954c73044894cc7344129d144894cc738",[-4,-5,3,-5]],[36509108,"8954c73c418b561c8954c740",[-4,4,-4]],[36509136,"488954c73c",[-5]],[36509143,"8854c744ffc189c82b8730060000898f30060000",[-4,2,2,6,-6]],[36756548,"48898680070000488b4e08488981b8c10000488b4710488986500d0000488b4e08488981a0d00000488b4720488986680a0000488b4e0848898198d50000",[-7,4,-7,4,-7,4,-7,4,-7,4,-7]],[36756672,"48898e280a00008bbe600a000083ff07b8070000000f42c78986600a00008b86640a000083f80abf0a0000000f47f889be640a0000488b46084881887819000080030000c5f9d680e0d00000488990e8d00000488988f0d00000498b00488b8050020000488b0048898680040000c5f857c0c5f8118688040000c78698040000ffffffff488b4e0848898100bc0000c5f8118198bc0000c781b0bc0000ffffffff498b00488b8050020000488b80f801000048898658020000488b4e0848898170b90000498b00488b8050020000488b80d801000048898610020000c686180200000048898108bc0000c681b4bc000000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-11,8,-7,-7,3,7,3,-7,4,8,-10,4,-7,8,-10,3,7,7,-7,4,-7,3,7,7,-7,-7,-7,-7]],[36756932,"48898680070000488b4e08488981b8c10000488b4710488986500d0000488b4e08488981a0d00000488b4740488986680a0000488b4e0848898198d50000",[-7,4,-7,4,-7,4,-7,4,-7,4,-7]],[36757056,"48898e280a00008bbe600a000083ff07b8070000000f42c78986600a00008b86640a000083f80abf0a0000000f47f889be640a0000488b46084881887819000080030000c5f9d680e0d00000488990e8d00000488988f0d00000498b00488b8050020000488b0048898680040000c5f857c0c5f8118688040000c78698040000ffffffff488b4e0848898100bc0000c5f8118198bc0000c781b0bc0000ffffffff498b00488b8050020000488b80f801000048898658020000488b4e0848898170b90000498b00488b8050020000488b80d801000048898610020000c686180200000048898108bc0000c681b4bc000000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-11,8,-7,-7,3,7,3,-7,4,8,-10,4,-7,8,-10,3,7,7,-7,4,-7,3,7,7,-7,-7,-7,-7]],[36757316,"48898680070000488b4e08488981b8c10000488b4710488986500d0000488b4e08488981a0d00000488b4730488986680a0000488b4e0848898198d50000",[-7,4,-7,4,-7,4,-7,4,-7,4,-7]],[36757440,"48898e280a00008bbe600a000083ff07b8070000000f42c78986600a00008b86640a000083f80abf0a0000000f47f889be640a0000488b46084881887819000080030000c5f9d680e0d00000488990e8d00000488988f0d00000498b00488b8050020000488b0048898680040000c5f857c0c5f8118688040000c78698040000ffffffff488b4e0848898100bc0000c5f8118198bc0000c781b0bc0000ffffffff498b00488b8050020000488b80f801000048898658020000488b4e0848898170b90000498b00488b8050020000488b80d801000048898610020000c686180200000048898108bc0000c681b4bc000000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-11,8,-7,-7,3,7,3,-7,4,8,-10,4,-7,8,-10,3,7,7,-7,4,-7,3,7,7,-7,-7,-7,-7]],[36757700,"48898680070000488b4e08488981b8c10000488b4710488986500d0000488b4e08488981a0d00000488b4748488986680a0000488b4e0848898198d50000",[-7,4,-7,4,-7,4,-7,4,-7,4,-7]],[36757824,"48898e280a00008bbe600a000083ff07b8070000000f42c78986600a00008b86640a000083f80abf0a0000000f47f889be640a0000488b46084881887819000080030000c5f9d680e0d00000488990e8d00000488988f0d00000498b00488b8050020000488b0048898680040000c5f857c0c5f8118688040000c78698040000ffffffff488b4e0848898100bc0000c5f8118198bc0000c781b0bc0000ffffffff498b00488b8050020000488b80f801000048898658020000488b4e0848898170b90000498b00488b8050020000488b80d801000048898610020000c686180200000048898108bc0000c681b4bc000000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-11,8,-7,-7,3,7,3,-7,4,8,-10,4,-7,8,-10,3,7,7,-7,4,-7,3,7,7,-7,-7,-7,-7]],[36758084,"48898680070000488b4e08488981b8c10000488b4710488986500d0000488b4e08488981a0d00000488b4750488986680a0000488b4e0848898198d50000",[-7,4,-7,4,-7,4,-7,4,-7,4,-7]],[36758208,"48898e280a00008bbe600a000083ff07b8070000000f42c78986600a00008b86640a000083f80abf0a0000000f47f889be640a0000488b46084881887819000080030000c5f9d680e0d00000488990e8d00000488988f0d00000498b00488b8050020000488b0048898680040000c5f857c0c5f8118688040000c78698040000ffffffff488b4e0848898100bc0000c5f8118198bc0000c781b0bc0000ffffffff498b00488b8050020000488b80f801000048898658020000488b4e0848898170b90000498b00488b8050020000488b80d801000048898610020000c686180200000048898108bc0000c681b4bc000000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-11,8,-7,-7,3,7,3,-7,4,8,-10,4,-7,8,-10,3,7,7,-7,4,-7,3,7,7,-7,-7,-7,-7]],[36758468,"48898680070000488b4e08488981b8c10000488b4710488986500d0000488b4e08488981a0d00000488b4758488986680a0000488b4e0848898198d50000",[-7,4,-7,4,-7,4,-7,4,-7,4,-7]],[36758592,"48898e280a00008bbe600a000083ff07b8070000000f42c78986600a00008b86640a000083f80abf0a0000000f47f889be640a0000488b46084881887819000080030000c5f9d680e0d00000488990e8d00000488988f0d00000498b00488b8050020000488b401848898680040000c5f857c0c5f8118688040000c78698040000ffffffff488b4e0848898100bc0000c5f8118198bc0000c781b0bc0000ffffffff498b00488b8050020000488b80f801000048898658020000488b4e0848898170b90000498b00488b8050020000488b80d801000048898610020000c686180200000048898108bc0000c681b4bc000000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-11,8,-7,-7,3,7,4,-7,4,8,-10,4,-7,8,-10,3,7,7,-7,4,-7,3,7,7,-7,-7,-7,-7]],[36758852,"48898680070000488b4e08488981b8c10000488b4710488986500d0000488b4e08488981a0d00000488b4760488986680a0000488b4e0848898198d50000",[-7,4,-7,4,-7,4,-7,4,-7,4,-7]],[36758976,"48898e280a00008bbe600a000083ff07b8070000000f42c78986600a00008b86640a000083f80abf0a0000000f47f889be640a0000488b46084881887819000080030000c5f9d680e0d00000488990e8d00000488988f0d00000498b00488b8050020000488b400848898680040000c5f857c0c5f8118688040000c78698040000ffffffff488b4e0848898100bc0000c5f8118198bc0000c781b0bc0000ffffffff498b00488b8050020000488b80f801000048898658020000488b4e0848898170b90000498b00488b8050020000488b80d801000048898610020000c686180200000048898108bc0000c681b4bc000000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-11,8,-7,-7,3,7,4,-7,4,8,-10,4,-7,8,-10,3,7,7,-7,4,-7,3,7,7,-7,-7,-7,-7]],[36759236,"48898680070000488b4e08488981b8c10000488b4710488986500d0000488b4e08488981a0d00000488b8780000000488986680a0000488b4e0848898198d50000",[-7,4,-7,4,-7,4,-7,7,-7,4,-7]],[36759363,"48898e280a00008bbe600a000083ff07b8070000000f42c78986600a00008b86640a000083f80abf0a0000000f47f889be640a0000488b46084881887819000080030000c5f9d680e0d00000488990e8d00000488988f0d00000498b00488b8050020000488b0048898680040000c5f857c0c5f8118688040000c78698040000ffffffff488b4e0848898100bc0000c5f8118198bc0000c781b0bc0000ffffffff498b00488b8050020000488b80f801000048898658020000488b4e0848898170b90000498b00488b8050020000488b80d801000048898610020000c686180200000048898108bc0000c681b4bc000000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-11,8,-7,-7,3,7,3,-7,4,8,-10,4,-7,8,-10,3,7,7,-7,4,-7,3,7,7,-7,-7,-7,-7]],[36759620,"48898680070000488b4e08488981b8c10000488b4710488986500d0000488b4e08488981a0d00000488b8788000000488986680a0000488b4e0848898198d50000",[-7,4,-7,4,-7,4,-7,7,-7,4,-7]],[36759747,"48898e280a00008bbe600a000083ff07b8070000000f42c78986600a00008b86640a000083f80abf0a0000000f47f889be640a0000488b46084881887819000080030000c5f9d680e0d00000488990e8d00000488988f0d00000498b00488b8050020000488b0048898680040000c5f857c0c5f8118688040000c78698040000ffffffff488b4e0848898100bc0000c5f8118198bc0000c781b0bc0000ffffffff498b00488b8050020000488b80f801000048898658020000488b4e0848898170b90000498b00488b8050020000488b80d801000048898610020000c686180200000048898108bc0000c681b4bc000000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-11,8,-7,-7,3,7,3,-7,4,8,-10,4,-7,8,-10,3,7,7,-7,4,-7,3,7,7,-7,-7,-7,-7]],[36760004,"48898680070000488b4e08488981b8c10000488b4710488986500d0000488b4e08488981a0d00000488b8790000000488986680a0000488b4e0848898198d50000",[-7,4,-7,4,-7,4,-7,7,-7,4,-7]],[36760131,"48898e280a00008bbe600a000083ff07b8070000000f42c78986600a00008b86640a000083f80abf0a0000000f47f889be640a0000488b46084881887819000080030000c5f9d680e0d00000488990e8d00000488988f0d00000498b00488b8050020000488b0048898680040000c5f857c0c5f8118688040000c78698040000ffffffff488b4e0848898100bc0000c5f8118198bc0000c781b0bc0000ffffffff498b00488b8050020000488b80f801000048898658020000488b4e0848898170b90000498b00488b8050020000488b80d801000048898610020000c686180200000048898108bc0000c681b4bc000000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-11,8,-7,-7,3,7,3,-7,4,8,-10,4,-7,8,-10,3,7,7,-7,4,-7,3,7,7,-7,-7,-7,-7]],[36760388,"48898680070000488b4e08488981b8c1000048c786500d000000000000488b460848c780a0d0000000000000488b8798000000488986680a0000488b4e0848898198d50000",[-7,4,-7,-11,4,-11,7,-7,4,-7]],[36760519,"48898e280a00008bbe600a000083ff07b8070000000f42c78986600a00008b86640a000083f80abf0a0000000f47f889be640a0000488b46084881887819000080030000c5f9d680e0d00000488990e8d00000488988f0d00000498b00488b8050020000488b0048898680040000c5f857c0c5f8118688040000c78698040000ffffffff488b4e0848898100bc0000c5f8118198bc0000c781b0bc0000ffffffff498b00488b8050020000488b80f801000048898658020000488b4e0848898170b90000498b00488b8050020000488b80d801000048898610020000c686180200000048898108bc0000c681b4bc000000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-11,8,-7,-7,3,7,3,-7,4,8,-10,4,-7,8,-10,3,7,7,-7,4,-7,3,7,7,-7,-7,-7,-7]],[36760772,"48898680070000488b4e08488981b8c10000488b4710488986500d0000488b4e08488981a0d00000488b4778488986680a0000488b4e0848898198d50000",[-7,4,-7,4,-7,4,-7,4,-7,4,-7]],[36760896,"48898e280a00008bbe600a000083ff07b8070000000f42c78986600a00008b86640a000083f80abf0a0000000f47f889be640a0000488b46084881887819000080030000c5f9d680e0d00000488990e8d00000488988f0d00000498b00488b8050020000488b0048898680040000c5f857c0c5f8118688040000c78698040000ffffffff488b4e0848898100bc0000c5f8118198bc0000c781b0bc0000ffffffff498b00488b8050020000488b801802000048898658020000488b4e0848898170b90000498b00488b8050020000488b80d801000048898610020000c686180200000048898108bc0000c681b4bc000000",[-7,6,3,5,3,-6,6,3,5,3,-6,4,-11,8,-7,-7,3,7,3,-7,4,8,-10,4,-7,8,-10,3,7,7,-7,4,-7,3,7,7,-7,-7,-7,-7]],[37320542,"c7070000803f48c7470400000000c5fa11570cc7471000000000c747140000803fc7471800000000c5fa11471c48c7472000000000c747280000803f",[-6,-8,5,-7,-7,-7,5,-8,-7]],[39433236,"88572c418b02",[-3,3]],[39433435,"88572c418b01",[-3,3]],[39433634,"88572c418b02",[-3,3]],[39434201,"88572c80f201",[-3,3]],[39910781,"49891048890ec746080100ffff48890748c747100000000048c7471800000000c7472000000000",[-3,-3,-7,-3,-8,-8,-7]],[39983191,"89b7a0000000",[-6]],[39984420,"884bfe8a4c03ff884bff8a0c03880b",[-3,4,-3,3,-2]],[39984485,"4288443604",[-5]],[39984513,"4288443605",[-5]],[39984995,"880b48ffc3",[-2,3]],[39985285,"880e48ffc6",[-2,3]],[39985533,"880b48ffc3",[-2,3]],[39985635,"884efe8a4bff884eff8a0b880e",[-3,3,-3,2,-2]],[39985691,"41884e0183f802",[-4,3]],[39985704,"418846024983c602",[-4,4]],[39985895,"41884d0049ffc5",[-4,3]],[39986096,"41884e0149ffc6",[-4,3]],[39986123,"c7000b000000",[-6]],[39986138,"48894730488b442448c7001b000000",[-4,5,-6]],[39986203,"48890f488977184c39da4929d34183c30544895f084d39ce4d29f14181c10101000044894f20488b4c24484889414844894150",[-3,-4,3,3,4,-4,3,3,7,-4,5,-4,-4]],[39986276,"48894f30488b4c2448c7011b000000",[-4,5,-6]],[39986311,"48894730488b442448c7001b000000",[-4,5,-6]],[41289299,"83c640408830",[3,-3]],[41289306,"8b4c24fc894801",[4,-3]],[41289425,"488b4c24f848894801",[5,-4]],[41289537,"8b4c24fc894801",[4,-3]],[42063698,"c5fa114f0cc6471001",[5,-4]],[42234798,"881688760189d1c1e910884e02c1ea188856034c89ca88560488760589d1c1e910884e06c1ea18885607885e08887e0989d9c1e910884e0ac1eb18885e0b88460c88660d89c1c1e910884e0ec1e81888460f",[-2,-3,2,3,-3,3,-3,3,-3,-3,2,3,-3,3,-3,-3,-3,2,3,-3,3,-3,-3,-3,2,3,-3,3,-3]],[42238549,"881a887a0189d8c1e810884202c1eb18885a034889f3885a04887a0589d8c1e810884206c1eb18885a074889eb885a08887a0989d8c1e81088420ac1eb18885a0b884a0c886a0d89c8c1e81088420ec1e918884a0f",[-2,-3,2,3,-3,3,-3,3,-3,-3,2,3,-3,3,-3,3,-3,-3,2,3,-3,3,-3,-3,-3,2,3,-3,3,-3]],[42242443,"4189360fb64707c1e0180fb65706c1e21009c20fb64705c1e00809d00fb66f0409c541896e040fb6470bc1e0180fb64f0ac1e11009c10fb64709c1e00809c8440fb65f084109c345895e08",[-3,4,3,4,3,2,4,3,2,4,2,-4,4,3,4,3,2,4,3,2,5,3,-4]],[42243253,"4189460c31c84889c6c1e9184133948f000c0000895424dc89f1c1e8080fb6c0c1e9100fb6c9418b9489000400004133148189f0c1e8184133948100080000400fb6c641339481000c0000413352100fb6c289d1c1e9080fb6c9458ba48f000400004533248789d0c1e8100fb6c04533a487000800004131d5c1ea184533a497000c00004489e84489eac1e8080fb6c0c1ea100fb6d2418b8c910004000041330c814489e8c1e81841338c8100080000410fb6c541338c81000c000041334a140fb6c189cac1ea080fb6d2458b9c970004000045331c8789c8c1e8100fb6c045339c87000800004131c8c1e91845339c8f000c00004489c04489c1c1e8080fb6c0c1e9100fb6c9418b948900040000413314814489c0c1e8184133948100080000410fb6c041339481000c0000413352180fb6c289d1c1e9080fb6c9418bbc8f0004000041333c8789d0c1e8100fb6c04133bc870008000031d548896c24f8c1ea184133bc97000c000089e889eac1e8080fb6c0c1ea100fb6d2418b8c910004000041330c8189e8c1e81841338c8100080000400fb6c541338c81000c000041334a1c0fb6c189cac1ea080fb6d2418bac970004000041332c8789c8c1e8100fb6c04133ac870008000031ce48897424d0c1e9184133ac8f000c000089f089f1c1e8080fb6c0c1e9100fb6c9418b9489000400004133148189f0c1e8184133948100080000400fb6c641339481000c0000413352200fb6c289d1c1e9080fb6c9418b8c8f0004000041330c8789d0c1e8100fb6c041338c87000800004131d5c1ea1841338c97000c00004489e8c1e8080fb6c0453304814489e8c1e8100fb6c045338481000400004489e8c1e8184533848100080000410fb6c545338481000c00008b4424f4418946108b5424f041895614448b4c24e845894e18448b7c24ec45897e1c8b7424e04133761089f04189762031d04189d2418946244431c889c2418946284431fa4589f94189562c31de4431d389da4189763041895e3431c2418956388b5424dc31f289de4189d74431ce4189763c418956404131df45897e4431d04189464831c64189764c4431e389de4431e24189565041895e5431c6418976584531cc4589665c4131d345895e604489da31da418956644431d84189466889c24431e24189566c4131fb45895e7031df41897e7489fa31c24189567889fa4431e24189567c4431dd4189ae8000000089ee31fe4189b68400000031e84189868800000031c24189968c00000031cd4189ae9000000031cf4189be9400000031c74189be980000004431e141898e9c000000",[-4,2,3,3,8,4,2,3,3,3,3,8,4,2,3,8,4,8,4,3,2,3,3,8,4,2,3,3,8,3,3,8,3,3,3,3,3,3,8,4,3,3,8,4,8,4,3,2,3,3,8,4,2,3,3,8,3,3,8,3,3,3,3,3,3,8,4,3,3,8,4,8,4,3,2,3,3,8,4,2,3,3,8,2,5,3,8,2,2,3,3,3,3,8,4,2,3,8,4,8,4,3,2,3,3,8,4,2,3,3,8,2,5,3,8,2,2,3,3,3,3,8,4,2,3,8,4,8,4,3,2,3,3,8,4,2,3,3,8,3,3,8,3,3,3,4,3,3,3,8,3,3,8,4,8,4,-4,4,-4,5,-4,5,-4,4,4,2,-4,2,3,-4,3,2,-4,3,3,-4,2,3,2,-4,-4,2,-4,4,2,2,3,3,-4,-4,3,-4,2,-4,2,-4,3,2,3,-4,-4,2,-4,3,-4,3,-4,3,2,-4,3,-4,2,3,-4,3,-4,2,-4,2,2,-4,2,3,-4,3,-7,2,2,-7,2,-7,2,-7,2,-7,2,-7,2,-7,3,-7]],[42244229,"418986a00000004431c141898ea40000004589aea8000000458986ac00000041c786f00000000a000000",[-7,3,-7,-7,-7,-11]],[42244333,"4589130fb64707c1e0180fb65706c1e21009c20fb64705c1e00809d00fb64f0409c1894c24f041894b044189cc0fb6470bc1e0180fb6570ac1e21009c20fb64709c1e00809d0440fb647084109c0458943080fb6470fc1e0180fb6570ec1e21009c20fb6470dc1e00809d00fb64f0c09c141894b0c",[-3,4,3,4,3,2,4,3,2,4,2,4,-4,3,4,3,4,3,2,4,3,2,5,3,-4,4,3,4,3,2,4,3,2,4,2,-4]],[42244532,"418953100fb64717",[-4,4]],[42245857,"418943148b4424e8418943188b4424e44189431c8b4424e0418943208b6c24dc41896b24448b6424d8458963288b4c24d441894b2c8b5424d04133531841895330458b731c89d089d74431f0418943344133432089c24189433831ea4189533c4431e24189d4418953404131cc45896344448b6424cc4431e74531f44589e641897b484589634c4131c6458973504589e64131ee4489f64589735431d6418973584131fd4489ee4431f141894b5c45896b604431e6418973644431e8418943684131c64589736c4189c64131d64489f64589737031ce418973744131dc4489e64131dd45896b784589637c31c64189b38000000031eb41899b840000004131de4589b3880000004531e94189de4489ce4131ce4589b38c00000045898b900000004431e64189b3940000004431c889c64189839800000031de4189b39c0000004431ca418993a00000004131d64589b3a40000004531c145898ba80000004531e0458983ac0000004431c0418983b00000004431c341899bb400000031d341899bb80000004131c8458983bc000000",[-4,4,-4,4,-4,4,-4,4,-4,5,-4,4,-4,4,4,-4,4,2,2,3,-4,4,2,-4,2,-4,3,3,-4,3,-4,5,3,3,3,-4,-4,3,-4,3,3,3,-4,2,-4,3,3,3,-4,-4,3,-4,3,-4,3,-4,3,3,3,-4,2,-4,3,3,3,-4,-4,2,-7,2,-7,3,-7,3,3,3,3,-7,-7,3,-7,3,2,-7,2,-7,3,-7,3,-7,3,-7,3,-7,3,-7,3,-7,2,-7,3,-7]],[42246275,"418983c00000004531fa458993c4000000488b4424f84131c2458993c8000000488b4424c04131c74589bbcc00000041c783f00000000c000000",[-7,3,-7,5,3,-7,5,3,-7,-11]],[42246394,"44890e0fb64707c1e0180fb65706c1e21009c20fb64705c1e00809d0440fb657044109c2448956040fb6470bc1e0180fb6570ac1e21009c20fb64709c1e00809d0440fb647084109c0448946080fb6470fc1e0180fb6570ec1e21009c20fb6470dc1e00809d00fb64f0c09c1894c24ac894e0c",[-3,4,3,4,3,2,4,3,2,5,3,-4,4,3,4,3,2,4,3,2,5,3,-4,4,3,4,3,2,4,3,2,4,2,4,-3]],[42246591,"895610440fb65f174489d9c1e1180fb6571689d3c1e31009cb0fb64f1589cdc1e50809dd0fb65f1489d809e84189c5418b8c8f0004000041330c9f41338c970008000043338c9f000c0000894e140fb6471b89c1c1e1180fb6571a89d3c1e31009cb0fb64f1989cdc1e50809dd0fb65f184189db4109eb418b8c8f0004000041330c9f41338c97000800000fb6c041338c87000c0000894e18",[-3,5,3,3,4,2,3,2,4,2,3,2,4,2,2,3,8,4,8,8,-3,4,2,3,4,2,3,2,4,2,3,2,4,3,3,8,4,8,3,8,-3]],[42248674,"89461c8b4424e08946208b4424dc8946248b4424d88946288b4424d489462c8b4424d08946308b4424cc8946348b4424c88946388b4424c489463c8b4424c033462089464033462489464433462889464833462c89464c8b4424bc33463089465033463489465433463889465833463c89465c8b4424b833464089466033464489466433464889466833464c89466c44336e5044896e7044336e5444896e7444336e5844896e7844336e5c44896e7c443376604489b680000000443376644489b684000000443376684489b6880000004433766c4489b68c00000044335e7044899e9000000044335e7444899e9400000044335e7844899e9800000044335e7c44899e9c00000044338680000000448986a000000044338684000000448986a400000044338688000000448986a80000004433868c000000448986ac00000033be9000000089beb000000033be9400000089beb400000033be9800000089beb800000033be9c00000089bebc000000338ea0000000898ec0000000338ea4000000898ec4000000338ea8000000898ec8000000338eac000000898ecc00000033aeb000000089aed000000033aeb400000089aed400000033aeb800000089aed800000033aebc00000089aedc000000",[-3,4,-3,4,-3,4,-3,4,-3,4,-3,4,-3,4,-3,4,-3,4,3,-3,3,-3,3,-3,3,-3,4,3,-3,3,-3,3,-3,3,-3,4,3,-3,3,-3,3,-3,3,-3,4,-4,4,-4,4,-4,4,-4,4,-7,4,-7,4,-7,4,-7,4,-7,4,-7,4,-7,4,-7,7,-7,7,-7,7,-7,7,-7,6,-6,6,-6,6,-6,6,-6,6,-6,6,-6,6,-6,6,-6,6,-6,6,-6,6,-6,6,-6]],[42249147,"8996e0000000488b4424f831d08986e4000000488b4424f031c28996e80000004431d28996ec000000c786f00000000e000000",[-6,5,2,-6,5,2,-6,3,-6,-10]],[42465123,"48890ec5f82807",[-3,4]],[42465500,"48891ec5f82807",[-3,4]],[42720618,"44897ae4448952e8448942ec448972f0c5fa1142f4c5fa114af8c5fa1152fc890a",[-4,-4,-4,-4,5,5,5,-2]],[42722108,"894fc48957c8895fcc8947d0c58a5cc0c5b25cc9c5aa5cd2c5fa1147d4c5fa114fd8c5fa1157dc8947e0c5da5c842484000000c4c1525ccfc4c1625cd0c5fa1147e4c5fa114fe8c5fa1157ec44896ff0c57a115ff4c57a1167f8c57a116ffc44892f",[-3,-3,-3,-3,4,4,4,5,5,5,-3,9,5,5,5,5,5,-4,5,5,5,-3]],[42723634,"448942d4897ad8896adcc742e0000000008942e4c57a114ae8c57a1152ecc57a1172f044896af4",[-4,-3,-3,-7,-3,5,5,5,-4]],[42724393,"448952f0448972f444897af844894afc893a",[-4,-4,-4,-4,-2]],[42790398,"8946a48b4424088946a88b4424048946ac44894eb0448946b4894eb8448956bc8b5424248956c08b4424148946c48b4424b88946c848c746d40000000048c746cc00000000448966dc44897ee044896ee444894ee8448946ec894ef0448956f48956f88b4c2420894efc890648c7460c0000000048c7460400000000",[-3,4,-3,4,-3,-4,-4,-3,-4,4,-3,4,-3,4,-3,-8,-8,-4,-4,-4,-4,-4,-3,-4,-3,4,-3,-2,-8,-8]],[42792142,"894f908b4c2404894f948b0c24894f984489479c8947a08957a444896fa88b5c2418895fac8b4c240c894fb08b4c24b4894fb448c747c00000000048c747b800000000448957c844895fcc44894fd0448947d48947d88957dc44896fe0895fe48b4424148947e8894fec48c747f80000000048c747f000000000",[-3,4,-3,3,-3,-4,-3,-3,-4,4,-3,4,-3,4,-3,-8,-8,-4,-4,-4,-4,-3,-3,-4,-3,4,-3,-3,-8,-8]],[42793742,"8946c48b4424fc8946c88b4424f88946cc448946d08b4424048946d48b4424f48946d8896edc8b4424ac8946e0897ee444894ee8c746ec00000000c746f00000000044897ef4448966f844896efc8b4424b08906",[-3,4,-3,4,-3,-4,4,-3,4,-3,-3,4,-3,-3,-4,-7,-7,-4,-4,-4,4,-2]],[42795229,"8942c48b4424088942c88b4424048942cc448952d08b4424108942d444895ad844894adc8b4424b88942e0448942e4894ae8c742ec00000000c742f0000000008b4424148942f48972f88b04248942fc8b4424bc8902",[-3,4,-3,4,-3,-4,4,-3,-4,-4,4,-3,-4,-3,-7,-7,4,-3,-3,3,-3,4,-2]],[43042281,"c70600000000488b4710488b4f2848ffc8482b47184801c831d248f7f1483dffff0000b9ffff00000f4ec841890a",[-6,4,4,3,4,3,2,3,6,5,3,-3]],[43046505,"c706000000000fb7470c0fb74f0e0fb7771248ffc84829c84801f0489948f7fe483dffff0000b9ffff00000f4ec841890a",[-6,4,4,4,3,3,3,2,3,6,5,3,-3]],[43049993,"c706000000000fb6470c0fb64f0d0fb6770f48ffc84829c84801f0489948f7fe483dffff0000b9ffff00000f4ec841890a",[-6,4,4,4,3,3,3,2,3,6,5,3,-3]],[43052777,"c70600000000488b4710488b4f2848ffc8482b47184801c8489948f7f9483dffff0000b9ffff00000f4ec841890a",[-6,4,4,3,4,3,2,3,6,5,3,-3]],[43059881,"c706000000004863470c48634f104863771848ffc84829c84801f0489948f7fe483dffff0000b9ffff00000f4ec841890a",[-6,4,4,4,3,3,3,2,3,6,5,3,-3]],[43067081,"c70600000000480fbf470c480fbf4f0e480fbf771248ffc84829c84801f0489948f7fe483dffff0000b9ffff00000f4ec841890a",[-6,5,5,5,3,3,3,2,3,6,5,3,-3]],[43074249,"c70600000000480fbe470c480fbe4f0d480fbe770f48ffc84829c84801f0489948f7fe483dffff0000b9ffff00000f4ec841890a",[-6,5,5,5,3,3,3,2,3,6,5,3,-3]],[43084297,"c706000000008b470c8b4f108b771848ffc84829c84801f0489948f7fe483dffff0000b9ffff00000f4ec841890a",[-6,3,3,3,3,3,3,2,3,6,5,3,-3]],[43960583,"c57a7f17c5d96cd5c5fa7f5710c5f96dc18d049500000000c5fa7f0407c5d96dc5c5fa7f4407108d04d500000000c5b96cc3c5fa7f0407c5c96cc7c5fa7f440710c5b96dc36bc20cc5fa7f0407c5c96dc7c5fa7f44071089d0c1e004c4c1216cc1c5fa7f0407c4c1196cc5c5fa7f440710c4c1216dc16bc214c5fa7f0407c4c1196dc5c5fa7f4407106bc218c5f96f4c24e8c5f96f5424d8c5f16cc2c5fa7f0407c4c1096cc7c5fa7f440710c5f16dc26bc21cc5fa7f0407c4c1096dc7c5fa7f440710",[-4,4,-5,4,7,-5,4,-6,7,4,-5,4,-6,4,3,-5,4,-6,2,3,5,-5,5,-6,5,3,-5,5,-6,3,6,6,4,-5,5,-6,4,3,-5,5,-6]],[43962463,"c5fa7f1fc5f96dc4c5fa7f0417c5f16cc5c4a17a7f042fc5f16dc5c4a17a7f041fc5e96cc6c5fa7f040fc5e96dc6c4a17a7f0437c5b96cc7c4a17a7f0417c5b96dc7c4a17a7f0407c4a17970440e40d8c4a179704c0e50d8c4a17970540e60d8c4217970440e70d8c4a17970a40ec0000000d8c4a17970ac0ed0000000d8c4a17970b40ee0000000d8c4a17970bc0ef0000000d8c5f96cdcc5fa7f1c1fc5f96dc4c5fa7f042fc5f16cc5c4a17a7f043fc5f16dc5488b4424d0c5fa7f0407c5e96cc6488b4424e8c5fa7f0407c5e96dc6488b4424d8c5fa7f0407c5b96cc7488b4424e0c5fa7f0407c5b96dc7c4a17a7f0427",[-4,4,-5,4,-6,4,-6,4,-5,4,-6,4,-6,4,-6,8,8,8,8,11,11,11,11,4,-5,4,-5,4,-6,4,5,-5,4,5,-5,4,5,-5,4,5,-5,4,-6]],[43962983,"c5fa7f17c5d96cd5c5fa7f5710c5f96dc1c5fa7f0417c5d96dc5c5fa7f441710c5b96cc34c8b6424d8c4a17a7f4427f0c5c96cc7c4a17a7f0427c5b96dc34c8b6424f0c4a17a7f4427f0c5c96dc7c4a17a7f0427c4a1796f441640c4a1796f4c1650c421796f441660c4a1796f5c1670c4a1796fa41640010000c4a1796fac1650010000c4a1796fb41660010000c4a1796fbc1670010000c5f96cd1c5fa7f141fc5d96cd5c5fa7f541f10c5f96dc1c4a17a7f041fc5d96dc5c4a17a7f441f10c5b96cc3c4a17a7f4437f0c5c96cc7c4a17a7f0437c5b96dc3c4a17a7f440ff0c5c96dc7c4a17a7f040fc4a1796f841680000000c4a1796f8c1690000000c421796f8416a0000000c4a1796f9c16b0000000c4a1796fa41680010000c4a1796fac1690010000c4a1796fb416a0010000c4a1796fbc16b0010000c5f96cd14c8b6424e0c4a17a7f5427f0c5d96cd5c4a17a7f1427c5f96dc1c5fa7f440ff0c5d96dc5c5fa7f040fc5b96cc3c4a17a7f043fc5c96cc7c4a17a7f443f10c5b96dc3c4a17a7f0407c5c96dc7c4a17a7f440710c4a1796f8416c0000000c4a1796f8c16d0000000c421796f8416e0000000c4a1796f9c16f0000000c4a1796fa416c0010000c4a1796fac16d0010000c4a1796fb416e0010000c4a1796fbc16f0010000c5f96cd1c5fa7f542ff0c5d96cd5c5fa7f142fc5f96dc1c5fa7f4407f0c5d96dc5c5fa7f0407c5b96cc3c4a17a7f042fc5c96cc7c4a17a7f442f10c5b96dc34c8b6424e8c4a17a7f0427c5c96dc7c4a17a7f442710",[-4,4,-5,4,-5,4,-6,4,5,-7,4,-6,4,5,-7,4,-6,7,7,7,7,10,10,10,10,4,-5,4,-6,4,-6,4,-7,4,-7,4,-6,4,-7,4,-6,10,10,10,10,10,10,10,10,4,5,-7,4,-6,4,-6,4,-5,4,-6,4,-7,4,-6,4,-7,10,10,10,10,10,10,10,10,4,-6,4,-5,4,-6,4,-5,4,-6,4,-7,4,5,-6,4,-7]],[45624704,"41c70200000000",[-7]],[45624869,"66890866428b4c22c666894802",[-3,6,-4]],[45624917,"66890866428b4c22d666894802",[-3,6,-4]],[45624936,"41ff020f1f440000",[-3,5]],[45624970,"66890866428b4c22e666894802",[-3,6,-4]],[45625014,"66890866428b4c22f666894802",[-3,6,-4]],[45625033,"41ff020f1f4000",[-3,4]],[45625229,"668928668b6a0666896802",[-3,4,-4]],[45625277,"668928668b6a0666896802",[-3,4,-4]],[45625294,"41ff026666666666662e0f1f840000000000",[-3,15]],[45625340,"668928668b6a0666896802",[-3,4,-4]],[45625384,"668928668b6a0666896802",[-3,4,-4]],[45625401,"41ff020f1f4000",[-3,4]],[45625994,"41c70100000000",[-7]],[45626279,"668908668b4c9a0666894802",[-3,5,-4]],[45626323,"668908668b4c9a1666894802",[-3,5,-4]],[45626341,"41ff010f1f840000000000",[-3,8]],[45626375,"668908668b4c9a2666894802",[-3,5,-4]],[45626393,"41ff010f1f4000",[-3,4]],[45626423,"668908668b4c9a3666894802",[-3,5,-4]],[45626441,"41ff010f1f4000",[-3,4]],[45626601,"66448910668b5a0666895802",[-4,4,-4]],[45626646,"668918668b5a0666895802",[-3,4,-4]],[45626663,"41ff01660f1f440000",[-3,6]],[45626696,"668918668b5a0666895802",[-3,4,-4]],[45626713,"41ff010f1f4000",[-3,4]],[45626744,"668918668b5a0666895802",[-3,4,-4]],[45626761,"41ff010f1f4000",[-3,4]],[45628736,"41c70200000000",[-7]],[45628910,"8918420fb74c22cec1e110420fb75c22c609cb895804",[-2,6,3,6,2,-3]],[45628976,"8928420fb74c22dec1e110420fb76c22d609cd896804",[-2,6,3,6,2,-3]],[45629043,"8928420fb74c22eec1e110420fb76c22e609cd896804",[-2,6,3,6,2,-3]],[45629071,"41ff0266666666662e0f1f840000000000",[-3,14]],[45629119,"8928420fb74c22fec1e110420fb76c22f609cd896804",[-2,6,3,6,2,-3]],[45629147,"41ff026690",[-3,2]],[45629352,"89180fb76a0ec1e5100fb75a0609eb895804",[-2,4,3,4,2,-3]],[45629418,"89180fb76a0ec1e5100fb75a0609eb895804",[-2,4,3,4,2,-3]],[45629442,"41ff0266662e0f1f840000000000",[-3,11]],[45629495,"89180fb76a0ec1e5100fb75a0609eb895804",[-2,4,3,4,2,-3]],[45629519,"41ff0266666666662e0f1f840000000000",[-3,14]],[45629571,"89180fb76a0ec1e5100fb75a0609eb895804",[-2,4,3,4,2,-3]],[45629595,"41ff026690",[-3,2]],[45630250,"41c70100000000",[-7]],[45630565,"8908440fb7549ace41c1e2100fb74c9ac64409d1894804",[-2,6,4,5,3,-3]],[45630632,"8908440fb7549ade41c1e2100fb74c9ad64409d1894804",[-2,6,4,5,3,-3]],[45630661,"41ff010f1f840000000000",[-3,8]],[45630707,"8908440fb7549aee41c1e2100fb74c9ae64409d1894804",[-2,6,4,5,3,-3]],[45630736,"41ff01666666662e0f1f840000000000",[-3,13]],[45630784,"89280fb74c9afec1e1100fb76c9af609cd896804",[-2,5,3,5,2,-3]],[45630810,"41ff010f1f00",[-3,3]],[45630984,"4489204989cc440fb7520e41c1e2100fb74a064409d1894804",[-3,3,5,4,4,3,-3]],[45631054,"8908440fb7520e41c1e2100fb74a064409d1894804",[-2,5,4,4,3,-3]],[45631081,"41ff010f1f4000",[-3,4]],[45631124,"8908440fb7520e41c1e2100fb74a064409d1894804",[-2,5,4,4,3,-3]],[45631151,"41ff0166666666662e0f1f840000000000",[-3,14]],[45631201,"89180fb74a0ec1e1100fb75a0609cb895804",[-2,4,3,4,2,-3]],[45631225,"41ff010f1f4000",[-3,4]],[45633413,"c70600000000",[-6]],[45633586,"8908428b4c2aa0894804",[-2,5,-3]],[45633624,"8908428b4c2ac0894804",[-2,5,-3]],[45633640,"ff06660f1f440000",[-2,6]],[45633668,"8908428b4c2ae0894804",[-2,5,-3]],[45633684,"ff06662e0f1f840000000000",[-2,10]],[45633716,"8908428b0c2a894804",[-2,4,-3]],[45633731,"ff0666662e0f1f840000000000",[-2,11]],[45633940,"89088b4a10894804",[-2,3,-3]],[45633978,"89088b4a10894804",[-2,3,-3]],[45633992,"ff06660f1f440000",[-2,6]],[45634022,"89088b4a10894804",[-2,3,-3]],[45634036,"ff06662e0f1f840000000000",[-2,10]],[45634069,"89088b4a10894804",[-2,3,-3]],[45634083,"ff0666662e0f1f840000000000",[-2,11]],[45634203,"c70100000000",[-6]],[45634335,"8928428b6c3ae0896804",[-2,5,-3]],[45634373,"8928428b2c3a896804",[-2,4,-3]],[45634388,"ff01662e0f1f840000000000",[-2,10]],[45634529,"89188b5a10895804",[-2,3,-3]],[45634566,"89288b6a10896804",[-2,3,-3]],[45634580,"ff01662e0f1f840000000000",[-2,10]],[45634682,"41c70100000000",[-7]],[45634984,"89088b0cda894804",[-2,3,-3]],[45635022,"89088b4cda20894804",[-2,4,-3]],[45635061,"89088b4cda40894804",[-2,4,-3]],[45635076,"41ff01660f1f840000000000",[-3,9]],[45635109,"89088b4cda60894804",[-2,4,-3]],[45635124,"41ff01660f1f840000000000",[-3,9]],[45635288,"4489108b5a10895804",[-3,3,-3]],[45635328,"89188b5a10895804",[-2,3,-3]],[45635342,"41ff016666666666662e0f1f840000000000",[-3,15]],[45635382,"89188b5a10895804",[-2,3,-3]],[45635396,"41ff01660f1f840000000000",[-3,9]],[45635430,"89188b5a10895804",[-2,3,-3]],[45635444,"41ff01660f1f840000000000",[-3,9]],[45635558,"41c70000000000",[-7]],[45635733,"8908428b4c27a0894804",[-2,5,-3]],[45635774,"8908428b4c27c0894804",[-2,5,-3]],[45635790,"41ff006666666666662e0f1f840000000000",[-3,15]],[45635830,"8908428b4c27e0894804",[-2,5,-3]],[45635846,"41ff000f1f8000000000",[-3,7]],[45635878,"8908428b0c27894804",[-2,4,-3]],[45635893,"41ff000f1f840000000000",[-3,8]]]} diff --git a/scripts/link_modules.py b/scripts/link_modules.py index b0a76bd..1d289fb 100644 --- a/scripts/link_modules.py +++ b/scripts/link_modules.py @@ -10,9 +10,11 @@ libSceLibcInternal are served by libc.prx's same-NID exports: the internal library is not shipped with the game and exposes the same functions. Output: out/boot-linked.bin (format BBPROBE5) and out/link.json. """ +import bisect import collections import hashlib import json +import os from pathlib import Path import struct from prepare import parse_self, span, unpack @@ -20,6 +22,9 @@ from link_libc import encode_id DEFAULT_MODULES = ('libc.prx', 'libSceFios2.prx') FS_LOAD = bytes.fromhex('64488b042500000000') # mov rax, fs:[0] +RED_ZONE_SITES = Path(__file__).resolve().parent.parent / 'patches' / 'redzone.json' +LEA_DOWN = bytes.fromhex('488d642480') # lea rsp, [rsp-0x80] +LEA_UP = bytes.fromhex('488da42480000000') # lea rsp, [rsp+0x80] def module(path): @@ -89,7 +94,40 @@ def patch_fs_loads(image, ph, base): return patched -def link(game, out, module_names=DEFAULT_MODULES): +def protect_red_zones(image, sites, base, skip=()): + """Run guest stores of red-zone leaf functions with rsp 128 bytes lower (Windows). + + Where Windows saves extended state in the compact XSAVE layout (Intel 12th gen and later), + an exception's frame holds the AVX registers up to 0x28 bytes below rsp, over the System V + red zone where leaf functions keep live locals (PS4 and Linux leave those 128 bytes alone). + A store that faults on a page the GPU tracker write-protected is resumed with the locals + overwritten: the attack-trail writer at 0x28ce7b0 then loads NULL from [rsp-0x60]. Each site + (tools/redzone_sites.py: offset, original bytes, instruction lengths, negative = store) + jumps to a copy at `base` where the stores run below the red zone. Sites whose bytes + differ or that overlap a relocation (`skip`: sorted targets) stay as they are. Returns + the copies and the number of sites redirected.""" + code = bytearray() + applied = 0 + for offset, original, lengths in sites: + original = bytes.fromhex(original) + end = offset + len(original) + near = bisect.bisect_right(skip, offset - 8) + if image[offset:end] != original or (near < len(skip) and skip[near] < end): + continue + here = base + len(code) + pos = offset + for length in lengths: + body = image[pos:pos + abs(length)] + code += LEA_DOWN + body + LEA_UP if length < 0 else body + pos += abs(length) + code += b'\xe9' + struct.pack('gs patched={fs_patched}') + f'fs->gs patched={fs_patched}, red-zone-safe stores at {red_zone_sites} sites') if __name__ == '__main__': diff --git a/scripts/mods.py b/scripts/mods.py index d6818eb..ceca08e 100644 --- a/scripts/mods.py +++ b/scripts/mods.py @@ -19,6 +19,10 @@ GAME_FOLDERS = {'action', 'chr', 'event', 'facegen', 'font', 'map', 'menu', 'mov 'obj', 'other', 'param', 'paramdef', 'parts', 'remo', 'script', 'sfx', 'shader', 'sound'} +# Every retail serial (all run the same 1.09 game): mods wrap their files in any of them. +SERIALS = ('CUSA00207', 'CUSA00208', 'CUSA00299', 'CUSA00900', 'CUSA01363', 'CUSA03014', + 'CUSA03023', 'CUSA03173', 'CUSA03179') + def child(folder, name): """`name` inside `folder`, matching an existing entry case-insensitively (mods made on @@ -33,13 +37,14 @@ def child(folder, name): def content_root(folder): """(root, prefix): where a mod's files are and the game path they go to, or None. - Accepted layouts: /dvdroot_ps4, /app0/dvdroot_ps4, /CUSA03173/dvdroot_ps4, - one wrapper folder around any of these (an archive extracted into a folder of its name), and - the game's folders without dvdroot_ps4 (/chr, /parts, ...).""" + Accepted layouts: /dvdroot_ps4, /app0/dvdroot_ps4, //dvdroot_ps4 + (CUSA03173 or another retail serial), one wrapper folder around any of these (an archive + extracted into a folder of its name), and the game's folders without dvdroot_ps4 (/chr, + /parts, ...).""" folder = Path(folder) if not folder.is_dir(): return None - for wrapper in ('', 'app0', 'CUSA03173'): + for wrapper in ('', 'app0') + SERIALS: base = child(folder, wrapper) if wrapper else folder dvdroot = child(base, 'dvdroot_ps4') if dvdroot.is_dir(): diff --git a/scripts/online.py b/scripts/online.py index fdbf8c4..d806a53 100644 --- a/scripts/online.py +++ b/scripts/online.py @@ -6,8 +6,22 @@ from pathlib import Path from urllib.parse import urlsplit GAME_STATUS_ORIGIN = 'https://ss4.scej-network.jp:20443' +# Messages, bloodstains and ghosts: Bloodborne's own game server. shadNet servers only sign in and +# match players (co-op); the public one has no game server, so The Hunter's Dream is the default. +HUNTERS_DREAM = 'https://thehuntersdream.com' +# The game PUTs its play logs to FromSoftware's S3 buckets (bucket names nobody here owns); the game +# server takes them on its API port instead (PUT //, plain HTTP), as bbhost sends them +# (droogie/bbhost src/hle/http.cpp). +PLAY_LOG_HOSTS = ('bb-playlog-prod.s3.amazonaws.com', 'bb-playlog-test.s3.amazonaws.com') +GAME_API_PORT = 18671 +# P2P (co-op) port when none is set: the PS4's own, so UPnP and firewall rules stay the same +# between games. +DEFAULT_P2P_PORT = 3658 +# Seconds a summon sign entry waits for its host (the game's 180; engine/online_fixes.cpp). +DEFAULT_SIGN_TIMEOUT = 30 ONLINE_ENV = ('BB_ONLINE', 'BB_SHADNET_SERVER', 'BB_SHADNET_WEBAPI', 'BB_SHADNET_NPID', - 'BB_SHADNET_PASSWORD', 'BB_SHADNET_TOKEN', 'BB_UPNP', 'SHADPS4_P2P_PORT', 'SHADPS4_HTTP_HOST_OVERRIDES_JSON') + 'BB_SHADNET_PASSWORD', 'BB_SHADNET_TOKEN', 'BB_UPNP', 'SHADPS4_P2P_PORT', 'SHADPS4_HTTP_HOST_OVERRIDES_JSON', + 'BB_SIGN_TIMEOUT', 'SHADPS4_P2P_PORT_DEFAULT') def read_settings(path): settings = {} @@ -19,6 +33,19 @@ def read_settings(path): settings[key.strip()] = value.strip() return settings +def game_server(text): + """The game server as typed: empty is The Hunter's Dream, a bare name gets https://. A + server without a port keeps the game's own (20443).""" + text = (text or '').strip() + if not text: + return HUNTERS_DREAM + if '://' not in text: + text = 'https://' + text + # Earlier versions defaulted to the shadNet Web API, which has no game server. + if urlsplit(text).hostname == 'srv.shadps4.net': + return HUNTERS_DREAM + return text + def http_base(text): if not text or any(ord(c) < 32 for c in text): raise ValueError('Online requires an explicit WebAPI / game API URL') @@ -29,20 +56,90 @@ def http_base(text): raise ValueError('Invalid online API port') return text.rstrip('/') +# The Hunter's Dream co-op pool (online_pool=huntersdream), with bbhost's players: bbbridge +# (MaskZeus, GPL-3.0-or-later, bbbridge/ in the package) stands in for the shadNet server on this PC +# and talks to The Hunter's Dream the way bbhost does. Its account (Discord sign-in) and settings +# live in network/bbbridge/, which updates keep. +BRIDGE_HOST = '127.0.0.1' +BRIDGE_PORT = 31313 +BRIDGE_WEBAPI = f'http://{BRIDGE_HOST}:31315' +# The game's own server traffic, through the bridge's proxies (bbbridge src/setup.cpp). +BRIDGE_OVERRIDES = { + GAME_STATUS_ORIGIN: f'http://{BRIDGE_HOST}:31316', + 'https://thehuntersdream.com:20443': f'http://{BRIDGE_HOST}:31316', + 'https://thehuntersdream.com:18671': f'http://{BRIDGE_HOST}:31317', + 'http://thehuntersdream.com:18671': f'http://{BRIDGE_HOST}:31317', + 'https://bb-playlog-test.s3.amazonaws.com:443': f'http://{BRIDGE_HOST}:31317', + 'https://bb-playlog-prod.s3.amazonaws.com:443': f'http://{BRIDGE_HOST}:31317', + 'https://thehuntersdream.com:18671/summon_messenger': BRIDGE_WEBAPI, + 'http://thehuntersdream.com:18671/summon_messenger': BRIDGE_WEBAPI, +} + +def bridge_folder(data): + """network/bbbridge with bbbridge's settings file pointing at its account file there. + Returns (folder, settings file).""" + folder = Path(data) / 'network' / 'bbbridge' + folder.mkdir(parents=True, exist_ok=True) + config = folder / 'bbbridge_config.json' + wanted = {'state_file': str((folder / 'bbbridge_state.json').resolve())} + try: + current = json.loads(config.read_text(encoding='utf-8')) + except (OSError, ValueError): + current = {} + if not isinstance(current, dict): + current = {} + if any(current.get(k) != v for k, v in wanted.items()): + config.write_text(json.dumps({**current, **wanted}, indent=2) + '\n', encoding='utf-8') + return folder, config + +def bridge_accounts(data): + """The Hunter's Dream accounts signed in on this PC (bbbridge's account file).""" + try: + state = json.loads((Path(data) / 'network/bbbridge/bbbridge_state.json').read_text(encoding='utf-8')) + return list(state.get('accounts', {})) + except (OSError, ValueError, AttributeError): + return [] + +def configure_hunters_dream(settings, data, env): + accounts = bridge_accounts(data) + if not accounts: + raise ValueError("The Hunter's Dream: sign in with Discord on the launcher's Online page first") + wanted = settings.get('online_thd_account', '') + destination = Path(data) / 'network/host_overrides.json' + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_suffix('.json.tmp') + temporary.write_text(json.dumps(BRIDGE_OVERRIDES, indent=2), encoding='utf-8') + os.replace(temporary, destination) + env.update(BB_ONLINE='1', BB_BRIDGE='1', BB_SHADNET_SERVER=f'{BRIDGE_HOST}:{BRIDGE_PORT}', + BB_SHADNET_WEBAPI=BRIDGE_WEBAPI, + BB_SHADNET_NPID=wanted if wanted in accounts else accounts[0], + # The game signs in with a password; the bridge never reads it. + BB_SHADNET_PASSWORD='bbbridge', + BB_UPNP='1' if settings.get('online_upnp') == '1' else '0', + SHADPS4_HTTP_HOST_OVERRIDES_JSON=str(destination.resolve()), + # The bridge sends the other players' traffic to this port: no fallback to another. + SHADPS4_P2P_PORT=str(DEFAULT_P2P_PORT), SHADPS4_P2P_PORT_DEFAULT='0') + timeout = settings.get('online_sign_timeout', '').strip() + env['BB_SIGN_TIMEOUT'] = str(min(max(int(timeout), 0), 600)) if timeout.isdigit() else str(DEFAULT_SIGN_TIMEOUT) + return destination + def configure_online(settings, data, env): + env.pop('BB_BRIDGE', None) if settings.get('online_enabled') != '1' or settings.get('online_connected', '1') != '1': for key in ONLINE_ENV: env.pop(key, None) env['BB_ONLINE'] = '0' return None + if settings.get('online_pool') == 'huntersdream': + return configure_hunters_dream(settings, data, env) host = settings.get('online_host', '').strip().strip('[]') if not host or any(c.isspace() or ord(c) < 32 for c in host) or any(c in host for c in '/@?#:'): raise ValueError('Enter a shadNet server before enabling online') port = int(settings.get('online_port', '')) if not 1 <= port <= 65535: raise ValueError('shadNet TCP port must be between 1 and 65535') - webapi = http_base(settings.get('online_webapi', '')) - game_api = http_base(settings.get('online_game_api', '') or webapi) + webapi = http_base(settings.get('online_webapi', '').strip() or f'http://{host}:31315') + game_api = http_base(game_server(settings.get('online_game_api', ''))) if urlsplit(webapi).path not in ('', '/') or urlsplit(game_api).path not in ('', '/'): raise ValueError('WebAPI and game API require a server origin without an extra path') username = settings.get('online_username', '') @@ -52,19 +149,24 @@ def configure_online(settings, data, env): raise ValueError('Sign in with the account password in the launcher') p2p = settings.get('online_p2p_port', '').strip() if p2p and not 1 <= int(p2p) <= 65535: - raise ValueError('Local P2P port must be between 1 and 65535 or empty for automatic') + raise ValueError('Local P2P port must be between 1 and 65535, or empty for the default (3658)') # Runtime cache only. Never read or overwrite another application's JSON, # and never route private-server traffic through a public server by default. destination = Path(data) / 'network/host_overrides.json' destination.parent.mkdir(parents=True, exist_ok=True) temporary = destination.with_suffix('.json.tmp') - temporary.write_text(json.dumps({GAME_STATUS_ORIGIN: game_api}, indent=2), encoding='utf-8') + overrides = {GAME_STATUS_ORIGIN: game_api} + overrides.update({h: f'http://{urlsplit(game_api).hostname}:{GAME_API_PORT}' for h in PLAY_LOG_HOSTS}) + temporary.write_text(json.dumps(overrides, indent=2), encoding='utf-8') os.replace(temporary, destination) env.update(BB_ONLINE='1', BB_SHADNET_SERVER=(f'[{host}]' if ':' in host else host) + f':{port}', BB_SHADNET_WEBAPI=webapi, BB_SHADNET_NPID=username, BB_UPNP='1' if settings.get('online_upnp') == '1' else '0', SHADPS4_HTTP_HOST_OVERRIDES_JSON=str(destination.resolve())) - env.pop('SHADPS4_P2P_PORT', None) - if p2p: - env['SHADPS4_P2P_PORT'] = str(int(p2p)) + env['SHADPS4_P2P_PORT'] = str(int(p2p)) if p2p else str(DEFAULT_P2P_PORT) + # The default may be taken or reserved on this PC: the engine falls back to a free port then. + # A port the player set is kept as it is. + env['SHADPS4_P2P_PORT_DEFAULT'] = '0' if p2p else '1' + timeout = settings.get('online_sign_timeout', '').strip() + env['BB_SIGN_TIMEOUT'] = str(min(max(int(timeout), 0), 600)) if timeout.isdigit() else str(DEFAULT_SIGN_TIMEOUT) return destination diff --git a/scripts/patches.py b/scripts/patches.py index da1e52e..eaeb424 100644 --- a/scripts/patches.py +++ b/scripts/patches.py @@ -10,6 +10,7 @@ import os import struct import sys import xml.etree.ElementTree as ET +import zlib from pathlib import Path EBOOT_BASE=0x400000 @@ -18,6 +19,54 @@ EBOOT_BASE=0x400000 # half speed (the game's wall detector measured distance per frame, a 30 FPS rule). FPS_PRESETS={'30':[],'60':['60 FPS++'],'90':['90 FPS++','Sprint Fix (High FPS)'], 'uncap':['Uncap FPS++','Sprint Fix (High FPS)']} +# 120 and 144: the 90 FPS++ patch with its frame-rate values for that rate. The uncapped patch (a +# different design) deadlocked the game's model jobs at the end of Vicar Amelia's intro at any +# frame rate, 60 included; 60 and 90 FPS++ (one design) did not. Their values follow the rate +# from the game's own 30: frame times 1/fps, frame counts fps/6 and fps/3, timings 3/fps, a +# thread's sleep 990000/fps us, the limiter's longest sleep just under a frame, and speed scales +# 60/fps and fps/60 (two vectors the patch keeps over a debug string at 0x4d3bd00), and the frame +# time the character update steps its stamina by and hands on, 1.125/fps (the patch fixes it at +# 0.0125 for 90: kept at that at 120 and 144, the character ran 1.5 and 1.8 times as fast there and +# fell that much faster). +SCALED_FPS={'120':120,'144':144} +SCALED_BASE='90 FPS++' + + +def scaled_rate_values(fps): + """Patch address -> bytes: the 90 FPS++ values that depend on the frame rate, for `fps`.""" + f=lambda *v: b''.join(struct.pack('0 and h>0: return (w,h) except ValueError: pass @@ -202,8 +267,101 @@ def compile_patches(xml, names, app_version, segments): return writes +# bbport: the standard 1.09 executable's bytes at each built-in patch's write sites, as CRC-32s +# (patches/Bloodborne-1.09-sites.json, made by --write-site-table from a verified eboot.elf). On an +# executable that differs at its sites a patch writes into other code (60/90 FPS++ crashed at start +# on one, GitHub issue 3), so such a patch is left out and the log names where it differs. +def site_table_path(xml): + return Path(xml).with_name('Bloodborne-1.09-sites.json') + + +def original_bytes(elf, offset, length): + """The executable's bytes at image offset `offset` (zero past a segment's file data).""" + phoff,=struct.unpack_from('>10} KiB each (the game\'s 256)') size=render_size(read_settings(a.settings),a.render_res) if a.render_res else None # The UI keeps the game's 1920x1080 coordinates even for a larger output: the port draws # it into the output-size image with a viewport scaled by output / 1920 @@ -302,14 +521,24 @@ def main(): if size: writes+=resolution_writes(a.xml,size,a.app_version,segments,ui) if size[0]*size[1]>OUTPUT_SIZE[0]*OUTPUT_SIZE[1]: - heap='Increased Graphics Heap Sizes' + heap=HEAP_PATCH writes+=compile_patches(a.xml,[heap],a.app_version,segments) names.append(heap) print(f'Patches: scene {size[0]}x{size[1]}; UI {ui[0]}x{ui[1]}') if a.patches_dir: # After the built-in ones: an external patch of the same bytes wins. + out=output_size(read_settings(a.settings)) + if a.output_res: + out=tuple(int(v) for v in a.output_res.lower().split('x')) + resolution=(resolution_footprint(a.xml,a.app_version,segments),out) if out!=OUTPUT_SIZE else None writes+=compile_external(external_selection(external_patches(a.patches_dir,a.app_version,a.xml), - a.patches_config),segments) + a.patches_config),segments,resolution) + # Whether a patch enlarges the game's heaps (the built-in one, or a resolution patch above + # 1080p from a patches/ file): run_windows.py then gives the game the larger memory pool. + heap_bytes={offset+i for offset,data in compile_patches(a.xml,[HEAP_PATCH],a.app_version,segments) + for i in range(len(data))} + heaps=any(offset+i in heap_bytes for offset,data in writes for i in range(len(data))) + (a.out/'patches-info.json').write_text(json.dumps({'heap_patch':heaps})+'\n',encoding='utf-8') # BBPATCH2: the patch base, so the loader can rebase pointers the patches write into # relocated slots (60/90 FPS++ replace function pointers). blob=struct.pack('<8sQQ',b'BBPATCH2',EBOOT_BASE,len(writes)) diff --git a/scripts/prepare.py b/scripts/prepare.py index 9860c61..f46e297 100644 --- a/scripts/prepare.py +++ b/scripts/prepare.py @@ -25,9 +25,37 @@ def unpack(fmt, data, offset): return struct.unpack(fmt, span(data, offset, struct.calcsize(fmt))) +def parse_elf(data): + """An eboot.bin already unwrapped to a plain ELF (some dumping tools and other ports write + it so): the same result as parse_self, every segment present in the file.""" + header = unpack('<16sHHIQQQIHHHHHH', data, 0) + if header[0][:7] != b'\x7fELF\x02\x01\x01' or header[2] != 62: + raise ValueError("expected little-endian x86-64 ELF") + if header[9] != 56 or not 0 < header[10] < 256: + raise ValueError("unsupported program headers") + ph = [dict(zip(('type', 'flags', 'offset', 'vaddr', 'paddr', 'filesz', 'memsz', 'align'), + unpack(' 512 * 1024 * 1024: + raise ValueError("probe image exceeds 512 MiB limit") + elf = bytearray(end) + present = min(end, len(data)) + elf[:present] = data[:present] + missing = [] + for i, p in enumerate(ph): + if p['filesz'] and p['offset'] + p['filesz'] > len(data): + missing.append(i) + if p['type'] in (1, 2, 0x61000000, 0x61000010): + raise ValueError(f"required segment {i} is unavailable") + return elf, header, ph, [], missing + + def parse_self(data): + if span(data, 0, 4) == b'\x7fELF': + return parse_elf(data) if span(data, 0, 4) != b'O\x15=\x1d': - raise ValueError("expected PS4 SELF") + raise ValueError("expected a PS4 executable (SELF or ELF)") count, = unpack('= 640 and h >= 360 else None + except (AttributeError, OSError): + return None + + +def large_scene(render): + """Whether a scene of `render` ('WxH') needs the 9152 MiB pool: above 1920x1080.""" + w, h = (int(v) for v in render.split('x')) + return w * h > 1920 * 1080 + + def run(arguments, capture=False, check=True, env=None): result = subprocess.run( [str(a) for a in arguments], @@ -78,6 +99,52 @@ def build(): run([bash, '-lc', 'bash build.sh'], env=env) +# The image the engine boots (prepare.py, link_libc.py, link_modules.py, content_profile.py): +# about 300 MB written and 235,000 relocations applied, the same every launch for the same game. +# It is made again only when what it is made from changes - the game's executable, modules and +# parameters, the content SKU, these scripts - and reused otherwise (out/prepared.json). +PREPARE_SCRIPTS = ('prepare.py', 'link_libc.py', 'link_modules.py', 'content_profile.py', 'game_check.py') +PREPARED = ('boot.bin', 'eboot.elf', 'entry.bin', 'analysis.json', 'boot-libc.bin', 'libc-link.json', + 'boot-linked.bin', 'link.json', 'content.bin', 'content-profile.json') + + +def prepare_key(game, sku): + digest = hashlib.sha256() + modules = sorted((game / 'sce_module').iterdir()) if (game / 'sce_module').is_dir() else [] + # patches/redzone.json: the stores link_modules.py moves below the red zone (Intel 12th gen+). + for path in [game / 'eboot.bin', game / 'sce_sys/param.sfo', *modules, *(SCRIPTS / s for s in PREPARE_SCRIPTS), + ROOT / 'patches' / 'redzone.json']: + digest.update(path.name.encode('utf-8') + b'\0') + if path.is_file(): + with path.open('rb') as f: + while chunk := f.read(1 << 20): + digest.update(chunk) + digest.update(b'\0') + digest.update(f'sku={sku};check={os.environ.get("BB_SKIP_GAME_CHECK", "")}'.encode('utf-8')) + return digest.hexdigest() + + +def prepare_image(game, out): + """The boot image for `game` in `out`: reused when unchanged, else made by the scripts.""" + started = time.monotonic() + sku = os.environ.get('BB_CONTENT_SKU', 'full') + stamp = out / 'prepared.json' + key = prepare_key(game, sku) + try: + reusable = json.loads(stamp.read_text(encoding='utf-8')).get('key') == key + except (OSError, ValueError, AttributeError): + reusable = False + if reusable and all((out / name).is_file() for name in PREPARED): + print(f'Boot image unchanged: reused ({time.monotonic() - started:.1f} s)', flush=True) + return + stamp.unlink(missing_ok=True) # a run stopped half way leaves no stamp behind + for script, extra in (('prepare.py', []), ('link_libc.py', []), ('link_modules.py', []), + ('content_profile.py', ['--sku', sku])): + run([PYTHON, SCRIPTS / script, game, '--out', out, *extra]) + stamp.write_text(json.dumps({'key': key}) + '\n', encoding='utf-8') + print(f'Boot image prepared in {time.monotonic() - started:.1f} s (reused on later launches)', flush=True) + + def settings_value(config, key): if not config.is_file(): return None @@ -95,6 +162,18 @@ def configure_frame_rate(env): legacy uncapped launch cannot saturate the desktop during menus or loading. """ fps = env.get('BB_FPS', '60') + # Bloodborne PC: 120 and 144 are the 90 FPS++ patch with its frame-rate values for that rate + # (patches.py SCALED_FPS; the uncapped patch froze Vicar Amelia's intro at any rate). 144 Hz + # is above the vblank rates the engine paces on (it runs 480 Hz then); the limit holds 144. + if fps in ('120', '144'): + env.update(BB_FPS=fps, BB_FPS_LIMIT=fps, BB_VBLANK_HZ=fps) + return fps + # Other rates above 90 use the uncapped patch (with the sprint fix) and the frame limiter; + # "uncap" runs unlimited unless BB_FPS_LIMIT asks for a cap. + if fps == 'uncap' or (fps.isdigit() and int(fps) > 90): + limit = fps if fps.isdigit() else env.get('BB_FPS_LIMIT', '0') + env.update(BB_FPS='uncap', BB_FPS_LIMIT=limit if limit.isdigit() else '0', BB_VBLANK_HZ='0') + return 'uncap' if fps not in ('30', '60', '90'): print(f'Unsupported Windows FPS preset {fps!r}; using 60 FPS.', flush=True) fps = '60' @@ -102,6 +181,56 @@ def configure_frame_rate(env): return fps +def bridge_listening(): + import socket + from online import BRIDGE_HOST, BRIDGE_PORT + try: + socket.create_connection((BRIDGE_HOST, BRIDGE_PORT), timeout=0.5).close() + return True + except OSError: + return False + + +def start_bridge(data): + """The Hunter's Dream pool: starts bbbridge-cli (bbbridge/, MaskZeus, GPL-3.0-or-later) for + this launch and returns it, or None when a bridge already listens (bbbridge's own app).""" + from online import bridge_folder + if bridge_listening(): + print("Co-op: The Hunter's Dream through the bbbridge already running", flush=True) + return None + cli = ROOT / 'bbbridge' / 'bbbridge-cli.exe' + if not cli.is_file(): + sys.exit(f"The Hunter's Dream: {cli} is missing (reinstall the package).") + folder, config = bridge_folder(data) + log = folder / 'bbbridge.log' + process = subprocess.Popen([str(cli), '--config', str(config), '--log', str(log)], cwd=folder, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW) + for _ in range(100): + if bridge_listening(): + print(f"Co-op: The Hunter's Dream as {os.environ.get('BB_SHADNET_NPID')}, through bbbridge " + f'(log: {log})', flush=True) + return process + if process.poll() is not None: + try: + tail = ''.join(log.read_text(encoding='utf-8', errors='replace').splitlines(True)[-5:]) + except OSError: + tail = '' + sys.exit(f'bbbridge stopped at start (exit code {process.returncode}):\n{tail}') + time.sleep(0.1) + process.terminate() + sys.exit("bbbridge did not start listening within 10 s (see network/bbbridge/bbbridge.log).") + + +def stop_bridge(process): + if process and process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + + def main(): arguments = sys.argv[1:] game = os.environ.get('BB_GAME_DIR') @@ -138,39 +267,45 @@ def main(): '--config', os.environ.get('BB_MODS_CONFIG', data / 'mods.json'), '--enabled', os.environ.get('BB_MODS_ENABLED', '1')], capture=True)) overlay = merged if merged.resolve() != original else None + bridge = None try: - for script, extra in (('prepare.py', []), ('link_libc.py', []), ('link_modules.py', []), - ('content_profile.py', ['--sku', os.environ.get('BB_CONTENT_SKU', 'full')])): - run([PYTHON, SCRIPTS / script, merged, '--out', out, *extra]) + prepare_image(merged, out) prebuilt = os.environ.get('BB_PREBUILT') == '1' if not prebuilt: build() # Engine features are prepared locally and only mounted after runtime byte checks. for key in ('BB_DREAM_MIRROR_ASSET', 'BB_ENGINE_IMAGE_SHA256', 'BB_PC_MENU_ASSETS', 'BB_REBIRTH_ASSET'): os.environ.pop(key, None) - if (any(settings_value(config, key) == '1' for key in ('change_appearance', 'camera_controls', 'rebirth', 'graphics_controls')) - or any(settings_value(config, key) != '0' for key in ('pc_controls', 'widescreen'))): - try: - from engine_assets import prepare_mirror, verify_executable - os.environ['BB_ENGINE_IMAGE_SHA256'] = verify_executable(out / 'eboot.elf') - if settings_value(config, 'change_appearance') == '1': - asset = prepare_mirror(merged, out / 'eboot.elf', data / 'engine-assets') - os.environ['BB_DREAM_MIRROR_ASSET'] = str(asset) - print(f'Engine: Dream mirror prepared locally: {asset}', flush=True) - if (any(settings_value(config, key) == '1' for key in ('change_appearance', 'camera_controls', 'rebirth', 'graphics_controls')) - or any(settings_value(config, key) != '0' for key in ('pc_controls', 'widescreen'))): - from engine_assets import prepare_menu_assets - try: - menus = prepare_menu_assets(merged, out / 'eboot.elf', data / 'engine-assets', - out / 'bb-engine-assets.exe', rebirth=settings_value(config, 'rebirth') == '1') - os.environ['BB_PC_MENU_ASSETS'] = str(menus) - print(f'Engine: native PC menus prepared locally: {menus}', flush=True) - if settings_value(config, 'rebirth') == '1': - os.environ['BB_REBIRTH_ASSET'] = str(menus / 'dvdroot_ps4/script/talk/m24_02_00_00.talkesdbnd.dcx') - except (OSError, ValueError) as error: - print(f'Engine: native PC menus disabled: {error}', flush=True) - except (OSError, ValueError, IndexError, struct.error, zlib.error) as error: - print(f'Engine: enhancement preparation skipped: {error}', flush=True) + # bbport: the engine's own features - picture tuning, cheats, trophies from a save, the + # online fixes - all check the executable first, so it is always verified (~0.1 s). + try: + from engine_assets import prepare_mirror, verify_executable + os.environ['BB_ENGINE_IMAGE_SHA256'] = verify_executable(out / 'eboot.elf') + if settings_value(config, 'change_appearance') == '1': + asset = prepare_mirror(merged, out / 'eboot.elf', data / 'engine-assets') + os.environ['BB_DREAM_MIRROR_ASSET'] = str(asset) + print(f'Engine: Dream mirror prepared locally: {asset}', flush=True) + if (any(settings_value(config, key) == '1' for key in ('change_appearance', 'camera_controls', 'rebirth', 'graphics_controls')) + or any(settings_value(config, key) != '0' for key in ('pc_controls', 'widescreen'))): + from engine_assets import prepare_menu_assets + try: + menus = prepare_menu_assets(merged, out / 'eboot.elf', data / 'engine-assets', + out / 'bb-engine-assets.exe', rebirth=settings_value(config, 'rebirth') == '1') + os.environ['BB_PC_MENU_ASSETS'] = str(menus) + print(f'Engine: native PC menus prepared locally: {menus}', flush=True) + if settings_value(config, 'rebirth') == '1': + os.environ['BB_REBIRTH_ASSET'] = str(menus / 'dvdroot_ps4/script/talk/m24_02_00_00.talkesdbnd.dcx') + except (OSError, ValueError) as error: + print(f'Engine: native PC menus disabled: {error}', flush=True) + except (OSError, ValueError, IndexError, struct.error, zlib.error) as error: + print(f'Engine: enhancement preparation skipped: {error}', flush=True) + # output_res = auto: the display's own size, for the scene size below and the engine. + if not os.environ.get('BB_DISPLAY_SIZE'): + size = display_size() + if size: + os.environ['BB_DISPLAY_SIZE'] = f'{size[0]}x{size[1]}' + if (settings_value(config, 'output_res') or '').lower() == 'auto': + print(f'Output resolution: the display\'s own, {size[0]}x{size[1]}', flush=True) # Sizes chosen below for the previous launch are recomputed after an in-game restart. if os.environ.get('BB_AUTO_RENDER_RES') == '1': for key in ('BB_RENDER_RES', 'BB_OUTPUT_RES', 'BB_AUTO_RENDER_RES'): @@ -192,14 +327,30 @@ def main(): print(f'Output {scaled_output}: live resolution changes (live_resolution=0: startup patch)') elif scaled_output: os.environ.update(BB_RENDER_RES=scaled_render, BB_OUTPUT_RES=scaled_output, BB_AUTO_RENDER_RES='1') - os.environ.setdefault('BB_DMEM_MB', '9152') - print(f'Output {scaled_output}: scene {scaled_render}, direct memory {os.environ["BB_DMEM_MB"]} MiB ' + # The extra 4 GiB is for scenes above 1080p only: Windows counts the whole pool + # against RAM plus page file from the start, used or not. The scene, not the output: + # the output exists only on the host side (the upscaler fills it), and the game's UI + # stays 1920x1080. A 1440p output with a Quality upscaler (a 1706x960 scene) asked for + # 10 GB at start and failed on PCs with 16 GB (Windows error 1450). + if large_scene(scaled_render): + os.environ.setdefault('BB_DMEM_MB', '9152') + dmem = os.environ.get('BB_DMEM_MB', '5056') + print(f'Output {scaled_output}: scene {scaled_render}, direct memory {dmem} MiB ' '(live_resolution=1: live changes)') run([PYTHON, SCRIPTS / 'patches.py', '--out', out, '--fps', fps, '--extra', os.environ.get('BB_PATCHES', ''), '--settings', config, '--game-dir', merged, '--render-res', os.environ.get('BB_RENDER_RES', ''), '--output-res', os.environ.get('BB_OUTPUT_RES', ''), '--patches-dir', os.environ.get('BB_PATCHES_DIR', data / 'patches'), '--patches-config', os.environ.get('BB_PATCHES_CONFIG', data / 'patches.json')]) + # bbport: a patch that enlarges the game's heaps (a resolution patch above 1080p chosen in + # the patches list, with a 1080p output here) overflows the 5056 MiB pool at start. + try: + heaps = json.loads((out / 'patches-info.json').read_text(encoding='utf-8')).get('heap_patch') + except (OSError, ValueError): + heaps = False + if heaps and 'BB_DMEM_MB' not in os.environ: + os.environ['BB_DMEM_MB'] = '9152' + print("Patches: the game's heaps are enlarged; direct memory 9152 MiB", flush=True) probe = ROOT / os.environ.get('BB_PROBE', out / 'bb-probe.exe') command = [probe, out / 'boot-linked.bin', '--content-profile', out / 'content.bin', '--patches', out / 'patches.bin', '--app0', merged, @@ -208,6 +359,10 @@ def main(): # MSYS2's DLLs (libc++, SDL3, FFmpeg, ...). System32 is searched before PATH, so the # Vulkan loader stays the one installed with the GPU driver. os.environ['PATH'] = os.pathsep.join([str(msys_root() / 'clang64/bin'), os.environ.get('PATH', '')]) + # Started last, after the slow steps: an in-game restart runs this script again while the + # previous one is still stopping its bridge. + if os.environ.get('BB_BRIDGE') == '1': + bridge = start_bridge(data) print('Starting:', ' '.join(shlex.quote(str(c)) for c in command), flush=True) try: status = subprocess.call( @@ -219,8 +374,11 @@ def main(): except KeyboardInterrupt: status = 130 print(f'bb-probe exited with code {status}', flush=True) - return status + # Windows crash codes (0xC0000005) do not fit sys.exit: pass them on as the signed + # 32-bit value the launcher reads, not -1. + return status - (1 << 32) if status >= (1 << 31) else status finally: + stop_bridge(bridge) if overlay: remove_overlay(overlay) diff --git a/src/host_sync.h b/src/host_sync.h index bab54c6..7c90dfb 100644 --- a/src/host_sync.h +++ b/src/host_sync.h @@ -67,6 +67,10 @@ typedef uint64_t HostThreadId; static inline HostThreadId host_thread_id(void) { return GetCurrentThreadId(); } static inline int host_thread_equal(HostThreadId a, HostThreadId b) { return a == b; } static inline void host_yield(void) { SwitchToThread(); } +/* The calling thread above the others (1) or back at normal priority (0). */ +static inline void host_thread_set_high_priority(int high) { + SetThreadPriority(GetCurrentThread(), high ? THREAD_PRIORITY_HIGHEST : THREAD_PRIORITY_NORMAL); +} typedef HANDLE HostThread; #else #include @@ -105,6 +109,8 @@ typedef pthread_t HostThreadId; static inline HostThreadId host_thread_id(void) { return pthread_self(); } static inline int host_thread_equal(HostThreadId a, HostThreadId b) { return pthread_equal(a, b); } static inline void host_yield(void) { sched_yield(); } +/* Raising a thread's priority needs privileges on Linux: left to the scheduler. */ +static inline void host_thread_set_high_priority(int high) { (void)high; } typedef pthread_t HostThread; #endif diff --git a/src/probe.c b/src/probe.c index b0e7c32..1100542 100644 --- a/src/probe.c +++ b/src/probe.c @@ -8,11 +8,13 @@ #include #include "runtime.h" #include "gpu/bbgpu.h" +#include "respawn_patch.h" #if !defined(__x86_64__) || !defined(__GNUC__) #error This prototype requires x86-64 GCC or Clang (including MinGW). #endif #ifdef _WIN32 #include +#include #include #include #include @@ -436,6 +438,145 @@ static int fatal(DWORD code) { return 0; } } +/* A fault in host code, before any handler of its own runs. On a guest thread the handler + * search has to cross guest frames, which have no unwind data: Windows then ends the process + * with the fault's code and the unhandled filter never runs, so this note is the only trace. + * Every one on guest threads; the first few elsewhere (a driver may fault on purpose). No stdio + * locks (the faulting thread may hold them). */ +static void note_host_fault(EXCEPTION_POINTERS *e) { + static volatile LONG notes; + EXCEPTION_RECORD *r = e->ExceptionRecord; + if (r->ExceptionCode == EXCEPTION_BREAKPOINT) return; + if (!runtime_thread_has_record() && InterlockedIncrement(¬es) > 4) return; + char line[1024], where[512], thread_name[64] = ""; + describe(where, sizeof(where), (uintptr_t)e->ContextRecord->Rip); + PWSTR description = NULL; + if (SUCCEEDED(GetThreadDescription(GetCurrentThread(), &description)) && description) { + WideCharToMultiByte(CP_UTF8, 0, description, -1, thread_name, sizeof(thread_name), NULL, NULL); + LocalFree(description); + } + int n = snprintf(line, sizeof(line), "Host fault 0x%08lx at %s", r->ExceptionCode, where); + if (r->ExceptionCode == EXCEPTION_ACCESS_VIOLATION && r->NumberParameters >= 2) + n += snprintf(line + n, sizeof(line) - n, ", %s address %p", + r->ExceptionInformation[0] == 1 ? "writing" : r->ExceptionInformation[0] == 8 ? "executing" : "reading", + (void *)r->ExceptionInformation[1]); + n += snprintf(line + n, sizeof(line) - n, " (thread %lu %s; if the game closes now, this is why)\n", + GetCurrentThreadId(), thread_name); + HANDLE out = GetStdHandle(STD_ERROR_HANDLE); + DWORD written; + WriteFile(out, line, (DWORD)strlen(line), &written, NULL); + /* The host call chain: this handler, the dispatcher, then the faulting frames. */ + void *frames[32]; + USHORT depth = RtlCaptureStackBackTrace(0, 32, frames, NULL); + for (USHORT i = 0; i < depth; ++i) { + describe(where, sizeof(where), (uintptr_t)frames[i]); + snprintf(line, sizeof(line), " host #%u %s\n", i, where); + WriteFile(out, line, (DWORD)strlen(line), &written, NULL); + } +} +/* The engine's own folder (out\: bb-probe.exe and the CLANG64 DLLs) as a mapped file name. */ +static char own_dir[MAX_PATH]; +static void find_own_dir(void) { + if (!GetMappedFileNameA(GetCurrentProcess(), (void *)find_own_dir, own_dir, sizeof(own_dir))) own_dir[0] = 0; + char *slash = strrchr(own_dir, '\\'); + if (slash) slash[1] = 0; +} +/* Whether Windows' handler search can get past this host fault. It walks the frames by their + * unwind data; at a guest frame (none) it gives up and ends the process with the fault's code, + * without the unhandled filter. The same walk here: a frame with a handler of its own on the + * way (a driver's or Windows' __try) may still handle it; the engine's own frames only have the + * C++ personality routine, which leaves faults alone. A fault during the walk (a broken stack): + * not decided, the search goes on. */ +static int host_fault_dead_end(const CONTEXT *fault) { + if (!own_dir[0]) return 0; + CONTEXT c = *fault; + RuntimeRecoverBuf recover; + if (RUNTIME_RECOVER_SET(recover)) return 0; + runtime_fault_recover = &recover; + int dead_end = 0; + for (int depth = 0; depth < 64 && c.Rip; ++depth) { + if (guest_address((uintptr_t)c.Rip)) { dead_end = 1; break; } + DWORD64 base = 0; + RUNTIME_FUNCTION *function = RtlLookupFunctionEntry(c.Rip, &base, NULL); + if (!function) { c.Rip = *(DWORD64 *)c.Rsp; c.Rsp += 8; continue; } /* leaf */ + void *handler_data; + DWORD64 frame; + PEXCEPTION_ROUTINE handler = RtlVirtualUnwind(UNW_FLAG_EHANDLER, base, c.Rip, function, &c, &handler_data, &frame, NULL); + char path[MAX_PATH]; + if (handler && !(GetMappedFileNameA(GetCurrentProcess(), (void *)handler, path, sizeof(path)) && + !_strnicmp(path, own_dir, strlen(own_dir)))) break; + } + runtime_fault_recover = NULL; + return dead_end; +} +/* Crash dump (logs\crash.dmp, beside the session log) and the unhandled filter's upkeep, on a + * helper thread. MiniDumpWriteDump cannot walk the thread that calls it reliably: the crashing + * thread wakes this one and waits for it, bounded (a lock the dump needs may be held by the + * crashed thread). dbghelp is loaded only then. Every second meanwhile the unhandled filter is + * set again: overlays (Steam, RTSS, Discord) replace it, and theirs end the process silently. */ +static LONG WINAPI unhandled_filter(EXCEPTION_POINTERS *e); +static HANDLE crash_dump_request, crash_dump_done; +static EXCEPTION_POINTERS *volatile crash_dump_exception; +static volatile DWORD crash_dump_thread_id; +static volatile LONG crash_dump_written; +static char crash_dump_dir[MAX_PATH], crash_dump_path[MAX_PATH]; +typedef BOOL (WINAPI *MiniDumpWriteDumpFn)(HANDLE, DWORD, HANDLE, MINIDUMP_TYPE, PMINIDUMP_EXCEPTION_INFORMATION, + PMINIDUMP_USER_STREAM_INFORMATION, PMINIDUMP_CALLBACK_INFORMATION); +static unsigned __stdcall crash_helper_thread(void *unused) { + (void)unused; + for (DWORD waited; (waited = WaitForSingleObject(crash_dump_request, 1000)) != WAIT_OBJECT_0;) { + if (waited != WAIT_TIMEOUT) { SetEvent(crash_dump_done); return 0; } + LPTOP_LEVEL_EXCEPTION_FILTER previous = SetUnhandledExceptionFilter(unhandled_filter); + static int noted; + if (previous != unhandled_filter && !noted) { + noted = 1; + fprintf(stderr, "Crash reports: another module replaced the unhandled exception filter (an overlay?); set again\n"); + } + } + if (crash_dump_dir[0]) CreateDirectoryA(crash_dump_dir, NULL); + HMODULE dbghelp = LoadLibraryW(L"dbghelp.dll"); + MiniDumpWriteDumpFn write_dump = dbghelp ? (MiniDumpWriteDumpFn)(void *)GetProcAddress(dbghelp, "MiniDumpWriteDump") : NULL; + HANDLE file = write_dump ? CreateFileA(crash_dump_path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL) + : INVALID_HANDLE_VALUE; + if (file != INVALID_HANDLE_VALUE) { + MINIDUMP_EXCEPTION_INFORMATION exception = {crash_dump_thread_id, crash_dump_exception, FALSE}; + const MINIDUMP_TYPE type = (MINIDUMP_TYPE)(MiniDumpNormal | MiniDumpWithThreadInfo | MiniDumpWithIndirectlyReferencedMemory); + if (write_dump(GetCurrentProcess(), GetCurrentProcessId(), file, type, &exception, NULL, NULL)) crash_dump_written = 1; + CloseHandle(file); + } + SetEvent(crash_dump_done); + return 0; +} +static void start_crash_helper(void) { + /* The session log's folder (BB_LOG_FILE, set by the launcher), else logs\ here. */ + const char *log = getenv("BB_LOG_FILE"); + const char *slash = log ? strrchr(log, '\\') : NULL, *other = log ? strrchr(log, '/') : NULL; + if (other > slash) slash = other; + if (slash && (size_t)(slash - log) + sizeof("\\crash.dmp") < sizeof(crash_dump_path)) { + snprintf(crash_dump_path, sizeof(crash_dump_path), "%.*s\\crash.dmp", (int)(slash - log), log); + } else { + snprintf(crash_dump_dir, sizeof(crash_dump_dir), "logs"); + snprintf(crash_dump_path, sizeof(crash_dump_path), "logs\\crash.dmp"); + } + crash_dump_request = CreateEventW(NULL, FALSE, FALSE, NULL); + crash_dump_done = CreateEventW(NULL, TRUE, FALSE, NULL); + HANDLE thread = crash_dump_request && crash_dump_done ? (HANDLE)_beginthreadex(NULL, 0, crash_helper_thread, NULL, 0, NULL) : NULL; + if (thread) CloseHandle(thread); + else crash_dump_request = NULL; +} +/* Called once (report_exception lets one thread through), before terminate(). */ +static void write_crash_dump(EXCEPTION_POINTERS *e) { + if (!crash_dump_request) return; + crash_dump_exception = e; + crash_dump_thread_id = GetCurrentThreadId(); + SetEvent(crash_dump_request); + const DWORD waited = WaitForSingleObject(crash_dump_done, 30000); + char line[MAX_PATH + 64]; + snprintf(line, sizeof(line), waited == WAIT_OBJECT_0 && crash_dump_written ? "Crash dump: %s\n" : "Crash dump: not written (%s)\n", + crash_dump_path); + DWORD written; + WriteFile(GetStdHandle(STD_ERROR_HANDLE), line, (DWORD)strlen(line), &written, NULL); +} static LONG CALLBACK vectored_handler(EXCEPTION_POINTERS *e) { EXCEPTION_RECORD *r = e->ExceptionRecord; if (r->ExceptionCode == EXCEPTION_SINGLE_STEP && hw_watch_file && (e->ContextRecord->Dr6 & 1)) { @@ -458,13 +599,19 @@ static LONG CALLBACK vectored_handler(EXCEPTION_POINTERS *e) { return EXCEPTION_CONTINUE_EXECUTION; } } - /* Host code may handle its own exceptions (drivers probe memory under __try). */ - if (!fatal(r->ExceptionCode) || !guest_address((uintptr_t)e->ContextRecord->Rip)) return EXCEPTION_CONTINUE_SEARCH; + if (!fatal(r->ExceptionCode)) return EXCEPTION_CONTINUE_SEARCH; + /* Host code may handle its own exceptions (drivers probe memory under __try): only noted. */ + if (!guest_address((uintptr_t)e->ContextRecord->Rip) && !host_fault_dead_end(e->ContextRecord)) { + note_host_fault(e); + return EXCEPTION_CONTINUE_SEARCH; + } report_exception(e); + write_crash_dump(e); terminate(128 + 11); } static LONG WINAPI unhandled_filter(EXCEPTION_POINTERS *e) { report_exception(e); + write_crash_dump(e); terminate(128 + 11); } /* link_modules.py rewrote the guest's `mov rax, fs:[0]` into `mov rax, gs:[0]`. GS is the TEB @@ -666,7 +813,11 @@ void runtime_restart(void) { static int loader_main(int argc, char **argv); static int main_argc; static char **main_argv; -static unsigned __stdcall loader_thread(void *unused) { (void)unused; return (unsigned)loader_main(main_argc, main_argv); } +static unsigned __stdcall loader_thread(void *unused) { + (void)unused; + runtime_win_set_thread_name("main"); /* the game's main loop (BB_SAMPLE_THREADS=main) */ + return (unsigned)loader_main(main_argc, main_argv); +} int main(int argc, char **argv) { timeBeginPeriod(1); /* 1 ms waits for timed locks and sleeps outside runtime_sleep_ns */ /* No EcoQoS: Windows 11 otherwise moves the threads of an unfocused window to efficiency @@ -674,8 +825,10 @@ int main(int argc, char **argv) { PROCESS_POWER_THROTTLING_STATE throttling = {PROCESS_POWER_THROTTLING_CURRENT_VERSION, PROCESS_POWER_THROTTLING_EXECUTION_SPEED, 0}; SetProcessInformation(GetCurrentProcess(), ProcessPowerThrottling, &throttling, sizeof(throttling)); + find_own_dir(); AddVectoredExceptionHandler(1, vectored_handler); SetUnhandledExceptionFilter(unhandled_filter); + start_crash_helper(); start_dump_thread(); start_hw_watch(); main_argc = argc; main_argv = argv; @@ -903,6 +1056,20 @@ int main(int argc, char **argv) { memcpy(image + relocs[i].target, &value, 8); } if (patch_file) apply_patches(patch_file, segments, ns, relocs, nr); + /* AwesomeObserver 149972b: no 12-second minimum loading time after death. */ + if (!cpu_only) { + const char *respawn_fix = getenv("BB_RESPAWN_DELAY_FIX"); + if (respawn_fix && !strcmp(respawn_fix, "0")) { + puts("Respawn loading: original minimum delay requested (BB_RESPAWN_DELAY_FIX=0)"); + } else { + int patched = bb_patch_respawn_delay(image, (size_t)size); + if (patched == BB_RESPAWN_APPLIED || patched == BB_RESPAWN_ALREADY_APPLIED) + printf("Respawn loading: 12-second minimum delay removed%s; readiness checks preserved\n", + patched == BB_RESPAWN_ALREADY_APPLIED ? " (already patched)" : ""); + else + puts("Respawn loading: unsupported or modified instructions; minimum-delay fix skipped"); + } + } #ifdef _WIN32 printf("Guest thread pointer reads: %" PRIu64 " use TEB TLS slot %u\n", patch_tls_reads(segments, ns), runtime_win_tls_slot()); #endif diff --git a/src/respawn_patch.h b/src/respawn_patch.h new file mode 100644 index 0000000..c73a186 --- /dev/null +++ b/src/respawn_patch.h @@ -0,0 +1,53 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later + * Removes the 12-second minimum loading time after the player dies (CUSA03173 1.09). + * Ported from AwesomeObserver/bloodborne_pc 149972b, "Remove death-triggered minimum + * loading delay on Windows" (Evgeniy Doronin). + * + * The player-death flag initializes a minimum loading countdown to 12.0 seconds; the + * world-loading step waits on it independently of character readiness. Only that + * initialization changes (vmovss xmm0,[12.0] -> vxorps xmm0,xmm0,xmm0 + nop, 8 bytes). + * The character readiness branch, the resource-loading steps and the 15-second multiplayer + * timeout stay intact. Nothing is written unless the whole init block, the readiness branch + * and the 12.0 constant match byte for byte. Offsets refer to the loaded image, not to + * SELF/ELF file offsets. BB_RESPAWN_DELAY_FIX=0 keeps the original delay (probe.c). + */ +#ifndef BB_RESPAWN_PATCH_H +#define BB_RESPAWN_PATCH_H + +#include +#include + +enum { BB_RESPAWN_UNSUPPORTED, BB_RESPAWN_APPLIED, BB_RESPAWN_ALREADY_APPLIED }; + +static inline int bb_patch_respawn_delay(unsigned char *image, size_t size) { + static const unsigned char init[] = { + 0x41,0x80,0xbc,0x24,0xe4,0x00,0x00,0x00,0x00, /* cmp byte [r12+0xe4],0 */ + 0x74,0x0a, /* je +10 */ + 0xc5,0xfa,0x10,0x05,0x5c,0xf5,0xfe,0x02, /* vmovss xmm0,[rip+..] (12.0) */ + 0xeb,0x04,0xc5,0xf8,0x57,0xc0, /* jmp +4; vxorps xmm0,xmm0,xmm0 */ + 0xc4,0xc1,0x7a,0x11,0x84,0x24,0x78,0x02,0x00,0x00, /* countdown = xmm0 */ + 0x41,0xc7,0x84,0x24,0x7c,0x02,0x00,0x00,0x00,0x00,0x70,0x41 /* 15 s timeout */ + }; + static const unsigned char ready[] = { + 0xc5,0xf8,0x2e,0xca,0x0f,0x87,0x53,0x06,0x00,0x00, + 0x45,0x84,0xff,0x0f,0x84,0x4a,0x06,0x00,0x00 + }; + static const unsigned char twelve[] = {0x00,0x00,0x40,0x41}; + static const unsigned char zero[] = {0xc5,0xf8,0x57,0xc0,0x0f,0x1f,0x40,0x00}; + const size_t init_at = 0x1938b59, instruction_at = 0x1938b64; + const size_t twelve_at = 0x49280c8, ready_at = 0x193a581; + unsigned char expected[sizeof(init)]; + if (!image || size < twelve_at + sizeof(twelve)) return BB_RESPAWN_UNSUPPORTED; + if (memcmp(image + twelve_at, twelve, sizeof(twelve)) || + memcmp(image + ready_at, ready, sizeof(ready))) return BB_RESPAWN_UNSUPPORTED; + if (!memcmp(image + init_at, init, sizeof(init))) { + memcpy(image + instruction_at, zero, sizeof(zero)); + return BB_RESPAWN_APPLIED; + } + memcpy(expected, init, sizeof(init)); + memcpy(expected + (instruction_at - init_at), zero, sizeof(zero)); + return !memcmp(image + init_at, expected, sizeof(expected)) ? + BB_RESPAWN_ALREADY_APPLIED : BB_RESPAWN_UNSUPPORTED; +} + +#endif diff --git a/src/runtime.h b/src/runtime.h index 4ae7643..035ae4d 100644 --- a/src/runtime.h +++ b/src/runtime.h @@ -36,6 +36,11 @@ void runtime_mutex_report(void); uintptr_t runtime_memory_resolve(const char *name); void runtime_memory_report(void); int runtime_memory_is_mapped(uintptr_t address, uint64_t size); +/* Guest memory whose guest protection allows writing, all of the range. */ +int runtime_memory_guest_writable(uintptr_t address, uint64_t size); +/* Fault handler, last resort: gives a page GPU tracking left protected the guest's protection back + * (1: retry the access; 0: a real fault). context: the handler's (EXCEPTION_POINTERS, ucontext). */ +int runtime_memory_restore_access(void *context, uintptr_t address, int repeated); /* The CPU is about to write the range outside guest code (a file read): tells the GPU side. */ void runtime_memory_note_write(uintptr_t address, uint64_t size); void runtime_memory_note_cpu_write(uintptr_t address, uint64_t size); @@ -57,6 +62,8 @@ uintptr_t runtime_thread_resolve(const char *name); void runtime_thread_report(void); void runtime_set_main_tls(const void *data, uint64_t filesz, uint64_t memsz, uint64_t align); void runtime_thread_attach_main(void); +/* The calling thread runs guest code (it has a guest thread record). */ +int runtime_thread_has_record(void); int32_t *runtime_errno(void); uintptr_t runtime_sema_resolve(const char *name); void runtime_sema_report(void); diff --git a/src/runtime_ajm.c b/src/runtime_ajm.c index b36f136..d8be9be 100644 --- a/src/runtime_ajm.c +++ b/src/runtime_ajm.c @@ -313,9 +313,12 @@ static void run_job(Context *ctx, uint32_t id, Job *job) { const unsigned char *input=NULL; if (job->input_count==1) input=job->inputs[0].address; else if (job->input_count>1) { - joined=malloc(in_size ? in_size : 1); - uint64_t at=0; - for (int i=0;iinput_count;++i) { memcpy(joined+at,job->inputs[i].address,job->inputs[i].size); at+=job->inputs[i].size; } + /* The sizes come from the game: a bad job must not take the engine down. */ + joined=in_size<=(UINT64_C(64)<<20) ? malloc(in_size ? in_size : 1) : NULL; + if (joined) { + uint64_t at=0; + for (int i=0;iinput_count;++i) { memcpy(joined+at,job->inputs[i].address,job->inputs[i].size); at+=job->inputs[i].size; } + } else { in_size=0; flags_result|=RESULT_INVALID_PARAMETER; } input=joined; } Output out={job->outputs,job->output_count,0,0}; diff --git a/src/runtime_audio.c b/src/runtime_audio.c index 2563dfa..d673185 100644 --- a/src/runtime_audio.c +++ b/src/runtime_audio.c @@ -3,9 +3,16 @@ * reads 256-frame slots out of its mixer's 512-frame ring, and returning in * bursts (whenever the device pulls a quantum) lets it overtake the mixer and * play stale halves - a click every 10.7 ms. The lowest SDL queue level of - * each window is steered to two buffers (silence prefill on underrun, ±3% - * cadence corrections), which also absorbs device clock drift. Without an audio device (headless runs, BB_AUDIO=none) - * ports only follow the clock. */ + * each window is steered to two buffers (silence prefill on underrun, cadence + * corrections), which also absorbs device clock drift. Without an audio device (headless runs, BB_AUDIO=none) + * ports only follow the clock. + * bbport: some devices take noticeably less than 48 kHz of what they are given (89-91% seen on + * one PC): the correction is proportional (up to 25% slower) instead of a fixed 3%, which let + * the queue - the sound's delay - grow by a second every minute, and a queue far over the + * target is dropped back to it at once. + * bbport: the device's own thread (SDL's mixer) is watched too: a postmix callback counts what it + * writes and when (BB_AUDIO_STATS) - a thread that comes late is a gap in the sound - and + * BB_AUDIO_THREAD_PRIORITY raises it when the system left it at normal priority. */ #define _GNU_SOURCE #include "runtime.h" #include @@ -40,6 +47,8 @@ typedef struct { int stats; uint64_t stat_start_ns, stat_last_ns, stat_max_gap_ns; /* BB_AUDIO_STATS */ unsigned stat_starved, stat_buffers; int stat_min_queued; + int stat_start_queued; uint64_t stat_bytes; /* the device's rate: what it took of what was given */ + unsigned resets; /* queues dropped back to the target */ } Port; typedef struct { uint16_t output; uint8_t channel, reserved; int16_t volume; uint16_t reroute; uint64_t flag, reserved64[2]; } PortState; _Static_assert(sizeof(PortState)==32,"AudioOut port state layout"); @@ -59,6 +68,45 @@ static int sdl_audio(void) { } return sdl_ready; } +/* As runtime_thread.c reads it: off only when it starts with 0. */ +static int audio_priority(void) { const char *e=getenv("BB_AUDIO_THREAD_PRIORITY"); return !(e && e[0]=='0'); } +/* The device thread, seen from the first port's postmix callback: written frames, mixes and + the longest wait between two, read and reset by that port's statistics. */ +static struct { uint64_t last_ns, max_gap_ns, frames; unsigned calls; int freq, seen; } device_thread; +static Port *device_port; +static void SDLCALL device_mixed(void *userdata, const SDL_AudioSpec *spec, float *buffer, int buflen) { + (void)userdata; (void)buffer; + const uint64_t now=now_ns(), last=__atomic_load_n(&device_thread.last_ns,__ATOMIC_RELAXED); + if (last && now-last>__atomic_load_n(&device_thread.max_gap_ns,__ATOMIC_RELAXED)) + __atomic_store_n(&device_thread.max_gap_ns,now-last,__ATOMIC_RELAXED); + __atomic_store_n(&device_thread.last_ns,now,__ATOMIC_RELAXED); + __atomic_add_fetch(&device_thread.calls,1u,__ATOMIC_RELAXED); + __atomic_add_fetch(&device_thread.frames,(uint64_t)buflen/((uint64_t)spec->channels*sizeof(float)),__ATOMIC_RELAXED); + if (!device_thread.seen) { + device_thread.seen=1; +#ifdef _WIN32 + /* WASAPI asks Windows for "Pro Audio" scheduling (MMCSS); where that is missing the + thread runs at normal priority, behind the game's busy threads. */ + const int before=GetThreadPriority(GetCurrentThread()); + const int raise=beforestream); + SDL_AudioSpec spec; int frames=0; + if (SDL_GetAudioDeviceFormat(id,&spec,&frames)) { + device_thread.freq=spec.freq; + printf("Runtime: audio output %s: %d Hz, %d channels, %d frames per write (%.1f ms; the game: 48000 Hz, converted)\n", + SDL_GetCurrentAudioDriver(),spec.freq,spec.channels,frames,spec.freq ? 1000.0*frames/spec.freq : 0.0); + } + if ((p->stats || audio_priority()) && !SDL_SetAudioPostmixCallback(id,device_mixed,NULL)) + fprintf(stderr,"Runtime: audio postmix callback failed (%s)\n",SDL_GetError()); +} static int port_range(int type, int *first, int *last) { switch (type) { case 0: *first=0; *last=7; return 1; /* main */ @@ -112,6 +160,7 @@ static ABI int32_t audio_open(int32_t user, int32_t type, int32_t index, uint32_ else fprintf(stderr,"Runtime: SDL audio stream failed (%s); port %d uses the timer sink\n",SDL_GetError(),id); } p->stats=getenv("BB_AUDIO_STATS")!=NULL; + if (p->stream) watch_device(p); const char *dump=getenv("BB_AUDIO_DUMP"); if (dump) { char path[4096]; @@ -162,19 +211,35 @@ static int32_t output_port(int32_t handle, const void *data, int pace) { if (p->dump) fwrite(converted,1,bytes,p->dump); if (p->stats) { uint64_t now=now_ns(); - if (!p->stat_start_ns) { p->stat_start_ns=now; p->stat_min_queued=1<<30; } + if (!p->stat_start_ns) { + p->stat_start_ns=now; p->stat_min_queued=1<<30; + p->stat_start_queued=p->stream ? SDL_GetAudioStreamQueued(p->stream) : 0; p->stat_bytes=0; + } else if (now-p->stat_last_ns>p->stat_max_gap_ns) p->stat_max_gap_ns=now-p->stat_last_ns; - p->stat_last_ns=now; ++p->stat_buffers; + p->stat_last_ns=now; ++p->stat_buffers; p->stat_bytes+=bytes; if (p->stream) { int queued=SDL_GetAudioStreamQueued(p->stream); if (queuedstat_min_queued) p->stat_min_queued=queued; if (queued==0) ++p->stat_starved; } if (now-p->stat_start_ns>=5000000000u) { - printf("Audio stats port %d: %u buffers/5s (expected %u), max gap %.2f ms, min queued %d bytes, starved %u\n", + /* What the device took in this window, against 48 kHz (100%: it keeps up). */ + const int queued_now=p->stream ? SDL_GetAudioStreamQueued(p->stream) : 0; + const double taken=(double)p->stat_bytes-(double)(queued_now-p->stat_start_queued); + const double nominal=(double)(now-p->stat_start_ns)/1e9*48000.0*p->channels*p->sample_bytes; + printf("Audio stats port %d: %u buffers/5s (expected %u), max gap %.2f ms, min queued %d bytes, starved %u, " + "device took %.1f%% of 48 kHz, queue resets %u\n", (int)(p-ports),p->stat_buffers,(unsigned)(5u*48000u/(unsigned)p->frames),p->stat_max_gap_ns/1e6, - p->stat_min_queued,p->stat_starved); + p->stat_min_queued,p->stat_starved,p->stream ? 100.0*taken/nominal : 0.0,p->resets); + if (p==device_port && device_thread.freq) { + const unsigned calls=__atomic_exchange_n(&device_thread.calls,0u,__ATOMIC_RELAXED); + const uint64_t frames=__atomic_exchange_n(&device_thread.frames,(uint64_t)0,__ATOMIC_RELAXED); + const uint64_t gap=__atomic_exchange_n(&device_thread.max_gap_ns,(uint64_t)0,__ATOMIC_RELAXED); + printf("Audio device thread: %u writes/5s, %.1f%% of %d Hz, max gap %.2f ms\n",calls, + 100.0*(double)frames/((double)(now-p->stat_start_ns)/1e9*device_thread.freq),device_thread.freq,gap/1e6); + } p->stat_start_ns=now; p->stat_max_gap_ns=0; p->stat_starved=0; p->stat_buffers=0; p->stat_min_queued=1<<30; + p->stat_start_queued=queued_now; p->stat_bytes=0; } } if (pace) { @@ -186,15 +251,31 @@ static int32_t output_port(int32_t handle, const void *data, int pace) { if (p->stream) { /* The device drains the queue in quanta (21 ms on PipeWire), so the level is a sawtooth: its minimum over ~32 buffers is what gets controlled. */ + static const unsigned char silence[2048*8*4]; int low=2*(int)bytes, queued=SDL_GetAudioStreamQueued(p->stream); if (queued<(int)bytes) { - static const unsigned char silence[2048*8*4]; SDL_PutAudioStreamData(p->stream,silence,low-queued<(int)bytes ? low-queued : (int)bytes); queued=low; } + /* Far over the target (a device taking less than the correction covers, or a burst): + the delay would only grow. Back to the target at once: one skip, not seconds of lag. */ + if (queued>low+32*(int)bytes) { + SDL_ClearAudioStream(p->stream); + SDL_PutAudioStreamData(p->stream,silence,(int)bytes); + SDL_PutAudioStreamData(p->stream,silence,(int)bytes); + queued=low; p->window_count=0; + if (++p->resets<=4) + printf("Runtime: audio port %d: the device took far less than it was given; queue dropped back to %d bytes\n", + (int)(p-ports),low); + } if (!p->window_count || queuedwindow_min) p->window_min=queued; if (++p->window_count==32) { - p->adjust_ns=p->window_min>low+2*(int)bytes ? (int64_t)(period/32) : p->window_minwindow_min-(low+2*(int)bytes); + int64_t adjust=excess>0 ? (int64_t)period*excess/(32*(int64_t)bytes) : p->window_min(int64_t)(period/4)) adjust=(int64_t)(period/4); + p->adjust_ns=adjust; p->window_count=0; } p->next_deadline_ns+=(uint64_t)p->adjust_ns; diff --git a/src/runtime_content.c b/src/runtime_content.c index 59a683b..93185af 100644 --- a/src/runtime_content.c +++ b/src/runtime_content.c @@ -1,11 +1,16 @@ /* Offline AppContent provider for the configured base-game profile. - * No package/DLC mounting, downloads, entitlement or license emulation. */ + * No package/DLC mounting, downloads, entitlement or license emulation; dumped add-ons are + * reported installed (see find_addons). */ #include "runtime.h" +#include +#include #include #include #include +#include #define INVALID_ID ((int32_t)0x805a1000) #define NOT_LOADED ((int32_t)0x805a1001) +#define NOT_INITIALIZED ((int32_t)0x80d90001) #define PARAMETER ((int32_t)0x80d90002) #define BUSY ((int32_t)0x80d90003) static unsigned refs, initialized, configured; @@ -58,23 +63,129 @@ static ABI int32_t content_init(const unsigned char *init,unsigned char *boot) { puts("Runtime: AppContent initialized; offline base-game profile, no mounted add-ons"); return 0; } -static void require_initialized(void) { - require_loaded(); - if (!initialized) { fputs("STOP: AppContent query before initialization\n",stderr); exit(21); } +/* bbport: queries before initialization (or after the module's unload) fail as on the console + * instead of ending the game (exit 21). */ +static int32_t require_initialized(void) { + require_provider(); + if (!refs || !initialized) { fputs("Runtime: AppContent query before initialization\n",stderr); return NOT_INITIALIZED; } + return 0; } static ABI int32_t param_int(uint32_t id,int32_t *out) { - require_initialized(); + int32_t result=require_initialized(); + if (result) return result; if (id>4 || !out) return PARAMETER; memcpy(out,¶meters[id],4); ++queries; printf("Runtime: AppContent parameter %u = %d\n",id,*out); return 0; } +/* bbport: add-ons. The base game (not the Game of the Year edition) learns from this list which + * add-ons were bought; it always answered none, so The Old Hunters was missing on base-game + * dumps. Its data ships with the 1.09 update: a dumped add-on is only its sce_sys. Reported + * installed: the add-on folders in BB_DLC_DIR (the launcher's DLC folder, or one add-on folder + * itself) and in /addcont// (shadPS4's layout). The entitlement label is the + * end of the add-on's CONTENT_ID (sce_sys/param.sfo, category "ac"), else the folder's name. */ +#define LABEL_SIZE 17 +#define MAX_ADDONS 64 +#define ADDON_INSTALLED 4u +/* SceAppContentAddcontInfo: the entitlement label (16 characters and NUL, padding), status. */ +typedef struct { char label[LABEL_SIZE]; char padding[3]; uint32_t status; } AddonInfo; +static char addon_labels[MAX_ADDONS][LABEL_SIZE]; +static uint32_t addon_count; +static int addons_found; +static uint32_t le32(const unsigned char *p) { + return (uint32_t)p[0]|(uint32_t)p[1]<<8|(uint32_t)p[2]<<16|(uint32_t)p[3]<<24; +} +/* The string `key` of a param.sfo into out (size bytes): 1 when found. */ +static int sfo_string(const char *path,const char *key,char *out,size_t size) { + FILE *f=fopen(path,"rb"); + if (!f) return 0; + unsigned char *sfo=malloc(65536); + size_t n=sfo ? fread(sfo,1,65536,f) : 0; + fclose(f); + int found=0; + if (n>=20 && !memcmp(sfo,"\0PSF",4)) { + const uint32_t keys=le32(sfo+8), data=le32(sfo+12), count=le32(sfo+16); + for (uint32_t i=0;i<count && 20+16*((size_t)i+1)<=n && !found;++i) { + const unsigned char *e=sfo+20+16*(size_t)i; + const size_t k=(size_t)keys+(e[0]|e[1]<<8), v=(size_t)data+le32(e+12), len=le32(e+4); + if (k>=n || !memchr(sfo+k,0,n-k) || strcmp((const char *)sfo+k,key)) continue; + if (v>=n || len>n-v || !size) break; + const size_t m=len<size ? len : size-1; + memcpy(out,sfo+v,m); out[m]=0; found=1; + } + } + free(sfo); + return found; +} +/* The add-on folder `path` (named `name`); need_sfo: only with an add-on's param.sfo. */ +static void add_addon(const char *path,const char *name,int need_sfo) { + char sfo[4096], category[16]="", id[64]="", label[64]=""; + if (snprintf(sfo,sizeof(sfo),"%s/sce_sys/param.sfo",path)>=(int)sizeof(sfo)) return; + if (sfo_string(sfo,"CATEGORY",category,sizeof(category))) { + if (strcmp(category,"ac")) return; /* a game, not an add-on */ + if (sfo_string(sfo,"CONTENT_ID",id,sizeof(id))) { + const char *dash=strrchr(id,'-'); + snprintf(label,sizeof(label),"%s",dash ? dash+1 : ""); + } + } else if (need_sfo) return; + if (!label[0]) snprintf(label,sizeof(label),"%s",name); + const size_t n=strlen(label); + if (!n || n>=LABEL_SIZE || addon_count>=MAX_ADDONS) return; + for (size_t i=0;i<n;++i) if (!isalnum((unsigned char)label[i])) return; + for (uint32_t i=0;i<addon_count;++i) if (!strcmp(addon_labels[i],label)) return; + memcpy(addon_labels[addon_count++],label,n+1); + printf("Runtime: add-on %s reported installed (%s)\n",label,path); +} +static void scan_addons(const char *folder) { + DIR *dir=opendir(folder); + for (struct dirent *e; dir && (e=readdir(dir));) { + char path[4096]; + struct stat st; + if (e->d_name[0]=='.' || snprintf(path,sizeof(path),"%s/%s",folder,e->d_name)>=(int)sizeof(path) || + stat(path,&st) || !S_ISDIR(st.st_mode)) continue; + add_addon(path,e->d_name,0); + } + if (dir) closedir(dir); +} +static void find_addons(void) { + if (addons_found) return; + addons_found=1; + const char *dlc=getenv("BB_DLC_DIR"); + if (dlc && *dlc) { + add_addon(dlc,"",1); + scan_addons(dlc); + } + char root[4096]; + DIR *titles=snprintf(root,sizeof(root),"%s/addcont",runtime_file_user_dir())<(int)sizeof(root) ? opendir(root) : NULL; + for (struct dirent *t; titles && (t=readdir(titles));) { + char title[4096]; + if (t->d_name[0]=='.' || snprintf(title,sizeof(title),"%s/%s",root,t->d_name)>=(int)sizeof(title)) continue; + scan_addons(title); + } + if (titles) closedir(titles); + if (dlc && *dlc && !addon_count) printf("Runtime: DLC folder %s: no add-ons found\n",dlc); +} static ABI int32_t addon_list(uint32_t service,void *list,uint32_t capacity,uint32_t *hits) { - require_initialized(); - if (service) { fputs("STOP: nonzero AppContent service label unsupported\n",stderr); exit(21); } + int32_t result=require_initialized(); + if (result) return result; + /* bbport: other service labels have no add-ons (this ended the game, exit 21). */ + static unsigned noted; + if (service && !noted++) fprintf(stderr,"Runtime: AppContent service label %u: no add-ons\n",service); if ((!capacity || !list) && !hits) return PARAMETER; - /* No add-ons are mounted by this provider. Never touch unused list slots. */ - if (hits) *hits=0; + find_addons(); + /* Without a list the count; with one at most capacity entries. Never touch unused slots. */ + uint32_t count=service ? 0 : addon_count; + if (list && capacity) { + if (count>capacity) count=capacity; + for (uint32_t i=0;i<count;++i) { + AddonInfo info; + memset(&info,0,sizeof(info)); + memcpy(info.label,addon_labels[i],strlen(addon_labels[i])); + info.status=ADDON_INSTALLED; + memcpy((unsigned char *)list+(size_t)i*sizeof(info),&info,sizeof(info)); + } + } + if (hits) *hits=count; ++lists; return 0; } uintptr_t runtime_content_resolve(const char *name) { diff --git a/src/runtime_file.c b/src/runtime_file.c index 6e10375..efb8985 100644 --- a/src/runtime_file.c +++ b/src/runtime_file.c @@ -272,12 +272,40 @@ static void touch_for_write(void *buffer,uint64_t size) { *b=*b; } } +/* bbport: the kernel's copy into guest memory the guest may write failed: the GPU thread + * protected a page again between touch_for_write and the copy (EFAULT; Windows: ERROR_NOACCESS). + * The data goes through a host buffer and memcpy, whose faults the handler resolves as for guest + * code. offset<0: from the descriptor's position (a failed read leaves it where it was). */ +static ssize_t read_bounced(int h,void *buffer,uint64_t size,int64_t offset) { + enum { BOUNCE=1<<20 }; + unsigned char *bounce=malloc(size<BOUNCE ? size : BOUNCE); + if (!bounce) { errno=ENOMEM; return -1; } + uint64_t done=0; + while (done<size) { + const uint64_t chunk=size-done<BOUNCE ? size-done : BOUNCE; + const ssize_t n=offset<0 ? read(h,bounce,chunk) : pread(h,bounce,chunk,offset+(int64_t)done); + if (n<0) { + if (done) break; + int e=errno; free(bounce); errno=e; + return -1; + } + memcpy((unsigned char *)buffer+done,bounce,(size_t)n); + done+=(uint64_t)n; + if ((uint64_t)n<chunk) break; + } + free(bounce); + static unsigned notes; + if (__atomic_fetch_add(¬es,1,__ATOMIC_RELAXED)<4) + printf("Runtime: a file read into guest memory failed (a page protected again); read through a host buffer\n"); + return (ssize_t)done; +} static int64_t do_read(int fd,void *buffer,uint64_t size) { int h=host_fd(fd); if (h<0) return get(fd) ? -EISDIR : -EBADF; runtime_memory_note_write((uintptr_t)buffer,size); touch_for_write(buffer,size); ssize_t n=read(h,buffer,size); + if (n<0 && size && runtime_memory_guest_writable((uintptr_t)buffer,size)) n=read_bounced(h,buffer,size,-1); if (n<0) { if (audio_trace()) printf("Audio trace: read(fd %d, %llu) failed, errno %d\n",fd,(unsigned long long)size,errno); return -errno; } if (n>0) runtime_memory_note_write((uintptr_t)buffer,(uint64_t)n); /* and once the data is there */ __atomic_add_fetch(&reads,1,__ATOMIC_RELAXED); __atomic_add_fetch(&bytes_read,(uint64_t)n,__ATOMIC_RELAXED); @@ -289,6 +317,7 @@ static int64_t do_pread(int fd,void *buffer,uint64_t size,int64_t offset) { runtime_memory_note_write((uintptr_t)buffer,size); touch_for_write(buffer,size); ssize_t n=pread(h,buffer,size,offset); + if (n<0 && size && offset>=0 && runtime_memory_guest_writable((uintptr_t)buffer,size)) n=read_bounced(h,buffer,size,offset); if (n<0) { if (audio_trace()) printf("Audio trace: pread(fd %d, %llu @%lld) failed, errno %d\n",fd,(unsigned long long)size,(long long)offset,errno); return -errno; } if (n>0) runtime_memory_note_write((uintptr_t)buffer,(uint64_t)n); /* and once the data is there */ __atomic_add_fetch(&reads,1,__ATOMIC_RELAXED); __atomic_add_fetch(&bytes_read,(uint64_t)n,__ATOMIC_RELAXED); diff --git a/src/runtime_host.c b/src/runtime_host.c index 73f3762..f783d21 100644 --- a/src/runtime_host.c +++ b/src/runtime_host.c @@ -76,29 +76,70 @@ uint64_t host_realtime_ns(void) { return (ticks - UINT64_C(116444736000000000)) * 100; } static __thread HANDLE sleep_timer; -/* High-resolution waitable timers (Windows 10 1803+) wake within ~0.5 ms; the rest of a - * short wait is spun, so frame pacing and audio deadlines stay precise. */ -void host_sleep_until_ns(uint64_t deadline) { - enum { SPIN_NS = 600000 }; +static __thread int sleep_timer_high_resolution; +enum { PRECISE_SPIN_NS = 600000, ADAPTIVE_MARGIN_NS = 100000 }; +/* How late the high-resolution timer wakes (ns), a running average over ~16 waits, measured on + * every timer wait. The adaptive spin (SPIN_ADAPTIVE) is that plus a margin: a fixed short + * spin woke the game's threads late whenever the timer was later than it (~20 sleeps a frame + * on the game's critical threads), a fixed long one burns a core. */ +static int64_t timer_late_ns = 300000; +#define SPIN_ADAPTIVE UINT64_MAX +/* High-resolution waitable timers (Windows 10 1803+) wake within ~0.5 ms; the last spin_ns of + * a wait is spun (all of it without such a timer). */ +static void sleep_until_spinning(uint64_t deadline, uint64_t spin_ns) { uint64_t now = host_monotonic_ns(); if (now >= deadline) return; - if (deadline - now > SPIN_NS) { - if (!sleep_timer) { - sleep_timer = CreateWaitableTimerExW(NULL, NULL, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS); - if (!sleep_timer) sleep_timer = CreateWaitableTimerExW(NULL, NULL, 0, TIMER_ALL_ACCESS); - } + if (!sleep_timer) { + sleep_timer = CreateWaitableTimerExW(NULL, NULL, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS); + sleep_timer_high_resolution = sleep_timer != NULL; + if (!sleep_timer) sleep_timer = CreateWaitableTimerExW(NULL, NULL, 0, TIMER_ALL_ACCESS); + } + if (spin_ns == SPIN_ADAPTIVE) { + int64_t late = __atomic_load_n(&timer_late_ns, __ATOMIC_RELAXED); + spin_ns = (uint64_t)(late > 0 ? late : 0) + ADAPTIVE_MARGIN_NS; + if (spin_ns > PRECISE_SPIN_NS) spin_ns = PRECISE_SPIN_NS; + } + if (!sleep_timer_high_resolution && spin_ns < PRECISE_SPIN_NS) spin_ns = PRECISE_SPIN_NS; + if (deadline - now > spin_ns) { + const uint64_t due_at = deadline - spin_ns; LARGE_INTEGER due; - due.QuadPart = -(LONGLONG)((deadline - now - SPIN_NS) / 100); - if (sleep_timer && due.QuadPart < 0 && SetWaitableTimer(sleep_timer, &due, 0, NULL, NULL, FALSE)) + due.QuadPart = -(LONGLONG)((due_at - now) / 100); + if (sleep_timer && due.QuadPart < 0 && SetWaitableTimer(sleep_timer, &due, 0, NULL, NULL, FALSE)) { WaitForSingleObject(sleep_timer, INFINITE); - else + if (sleep_timer_high_resolution) { + const uint64_t woke = host_monotonic_ns(); + int64_t sample = woke > due_at ? (int64_t)(woke - due_at) : 0; + if (sample > 2000000) sample = 2000000; + const int64_t old = __atomic_load_n(&timer_late_ns, __ATOMIC_RELAXED); + __atomic_store_n(&timer_late_ns, old + (sample - old) / 16, __ATOMIC_RELAXED); + } + } else { Sleep((DWORD)((deadline - now) / 1000000)); + } } while (host_monotonic_ns() < deadline) YieldProcessor(); } +/* Frame pacing and audio deadlines: precise. */ +void host_sleep_until_ns(uint64_t deadline) { sleep_until_spinning(deadline, PRECISE_SPIN_NS); } +/* The game's own sleeps (sceKernelUsleep, nanosleep: ~20-26 per frame, ~4 ms each in Central + * Yharnam). By default the spin tail follows the timer (SPIN_ADAPTIVE: as late as it wakes, + * plus 0.1 ms; ~0.2-0.4 ms instead of 0.6), so a sleep neither burns a fixed 0.6 ms of a core + * nor overruns. BB_SLEEP_SPIN_US sets a fixed tail (600: as before). */ +static uint64_t guest_sleep_spin_ns(void) { + static uint64_t spin = 0; + uint64_t value = __atomic_load_n(&spin, __ATOMIC_RELAXED); + if (value == 0) { + const char *env = getenv("BB_SLEEP_SPIN_US"); + value = env && env[0] ? strtoull(env, NULL, 10) * 1000 : SPIN_ADAPTIVE; + if (value != SPIN_ADAPTIVE && value > PRECISE_SPIN_NS) value = PRECISE_SPIN_NS; + if (value == 0) value = 1; + __atomic_store_n(&spin, value, __ATOMIC_RELAXED); + } + return value; +} void host_sleep_ns(uint64_t ns) { if (!ns) { SwitchToThread(); return; } - host_sleep_until_ns(host_monotonic_ns() + ns); + sleep_until_spinning(host_monotonic_ns() + ns, guest_sleep_spin_ns()); } #else #include <pthread.h> diff --git a/src/runtime_kernel.c b/src/runtime_kernel.c index 21a81d0..0204150 100644 --- a/src/runtime_kernel.c +++ b/src/runtime_kernel.c @@ -250,10 +250,14 @@ static ABI int32_t guest_sysctl(const int32_t *name,uint32_t namelen,void *old,u if (old) { if (*oldlen<4) return fail_posix(ENOMEM); memcpy(old,&value,4); } *oldlen=4; return 0; } - fprintf(stderr,"STOP: unsupported sysctl mib"); - for (uint32_t i=0;i<namelen && i<8;++i) fprintf(stderr," %d",name[i]); - fputc('\n',stderr); - exit(21); + /* bbport: unknown names fail as on FreeBSD (ENOENT) instead of ending the game (exit 21). */ + static unsigned notes; + if (__atomic_fetch_add(¬es,1,__ATOMIC_RELAXED)<8) { + fprintf(stderr,"Runtime: unsupported sysctl mib"); + for (uint32_t i=0;i<namelen && i<8;++i) fprintf(stderr," %d",name[i]); + fputs(" (ENOENT)\n",stderr); + } + return fail_posix(ENOENT); } /* ---- pthread_once and thread-specific data ---- */ diff --git a/src/runtime_memory.c b/src/runtime_memory.c index be5a034..7fd70a4 100644 --- a/src/runtime_memory.c +++ b/src/runtime_memory.c @@ -18,6 +18,7 @@ #else #include <fcntl.h> #include <sys/mman.h> +#include <ucontext.h> #include <unistd.h> #endif /* sceKernelGetDirectMemorySize on retail PS4: 5056 MiB. BB_DMEM_MB raises it (the resolution @@ -124,7 +125,8 @@ static GpuRange hook_map, hook_unmap, hook_invalidate; static GpuRange hook_note_write; /* bbport: data written into GPU memory (runtime_memory_note_write) */ enum { HOOK_MAP, HOOK_UNMAP, HOOK_INVALIDATE }; typedef struct { int kind; uintptr_t address; uint64_t size; } PendingHook; -static PendingHook pending[64]; static size_t pending_count; +/* Grows as needed (a batch map or an unmap across many mappings queues one each). */ +static PendingHook *pending; static size_t pending_count, pending_capacity; static size_t allocations, maps, flexible_maps, protects, queries; static uint64_t live_bytes, flexible_bytes; @@ -270,7 +272,12 @@ static void zero_phys(uint64_t phys, uint64_t size) { punch(phys,size); } static void queue_hook(int kind, uintptr_t address, uint64_t size) { GpuRange hook = kind==HOOK_MAP ? hook_map : kind==HOOK_UNMAP ? hook_unmap : hook_invalidate; if (!hook) return; - if (pending_count==sizeof(pending)/sizeof(*pending)) { fputs("STOP: GPU hook queue overflow\n",stderr); exit(21); } + if (pending_count==pending_capacity) { + size_t capacity=pending_capacity ? pending_capacity*2 : 64; + PendingHook *next=realloc(pending,capacity*sizeof(*pending)); + if (!next) { fputs("STOP: GPU hook queue overflow\n",stderr); exit(21); } + pending=next; pending_capacity=capacity; + } pending[pending_count++]=(PendingHook){kind,address,size}; } /* Runs queued GPU notifications; callers invoke it after dropping the lock. */ @@ -427,6 +434,17 @@ static int direct_range_allocated(uint64_t phys, uint64_t size) { } static ABI uint64_t direct_size(void) { return POOL_SIZE; } +/* bbport: the game doesn't check a failed allocation (it writes through the null result a moment + * later, "writing address 0"), so the log says why. A patch that enlarges the game's heaps + * ("Increased Graphics Heap Sizes", the resolution patches above 1080p) needs the larger pool. */ +static void report_pool_full(uint64_t size, uint64_t used) { + static unsigned reported; + if (__atomic_fetch_add(&reported,1,__ATOMIC_RELAXED)>=3) return; + printf("Runtime: direct allocation of %" PRIu64 " MiB failed: the game's memory pool is full (%" PRIu64 " of %" PRIu64 + " MiB in use); a patch that enlarges the game's heaps needs BB_DMEM_MB=9152\n", + size>>20, used>>20, (uint64_t)POOL_SIZE>>20); + fflush(stdout); +} static ABI int32_t direct_allocate(int64_t low, int64_t high, uint64_t size, uint64_t alignment, int type, int64_t *out) { if (!alignment) alignment = PAGE; @@ -440,7 +458,12 @@ static ABI int32_t direct_allocate(int64_t low, int64_t high, uint64_t size, int slot = -1; for (int i=0; i<LIMIT; ++i) if (!blocks[i].used) { slot=i; break; } for (;;) { - if (slot<0 || start > end || size > end-start) { write_unlock(); return NO_SPACE; } + if (slot<0 || start > end || size > end-start) { + const uint64_t used = live_bytes; + write_unlock(); + if ((uint64_t)high >= POOL_SIZE) report_pool_full(size, used); + return NO_SPACE; + } int overlap = 0; for (int i=0; i<LIMIT; ++i) { Block b = blocks[i]; @@ -503,10 +526,14 @@ static ABI int32_t direct_unmap(void *address, uint64_t size) { /* The GPU forgets the range before it disappears (as shadPS4's early GPU unmap). */ if (hook_unmap && !((uintptr_t)address%PAGE) && size) hook_unmap((uintptr_t)address,align_up(size,PAGE)); write_lock(); - size_t before=pending_count; + size_t before=pending_count, kept=before; int32_t result=unmap_locked((uintptr_t)address,size); - pending_count=before; /* already notified above */ + /* The unmaps were notified above; the invalidations of view pieces remapped around the + * range (restore_remnant: their GPU protection is gone) still go out. */ + for (size_t i=before;i<pending_count;++i) if (pending[i].kind!=HOOK_UNMAP) pending[kept++]=pending[i]; + pending_count=kept; write_unlock(); + flush_hooks(); return result; } static ABI int32_t direct_release(uint64_t start, uint64_t size) { @@ -1008,6 +1035,80 @@ void runtime_memory_gpu_protect(uintptr_t address, uint64_t size, int read, int } read_unlock(); } +/* bbport: the fault handler's last resort (bbgpu_handle_fault). An access the guest's own + * protection allows faulted on a page GPU tracking protected, and the tracking did not resolve + * it (repeated: it claimed the fault, yet the same access keeps faulting; else: no record of the + * page). An unhandled fault ends the game without a trace, so the page gets the guest's + * protection back: the GPU may miss writes there until it protects the page again. No locks + * beyond the table's (nested per thread) and no allocation: this runs in the fault handler. */ +int runtime_memory_restore_access(void *context, uintptr_t address, int repeated) { + int is_write; +#ifdef _WIN32 + const ULONG_PTR kind=((EXCEPTION_POINTERS *)context)->ExceptionRecord->ExceptionInformation[0]; + if (kind==8) return 0; /* execution: never a tracking fault */ + is_write=kind==1; +#else + const greg_t error=((ucontext_t *)context)->uc_mcontext.gregs[REG_ERR]; + if (error & 0x10) return 0; /* instruction fetch */ + is_write=(error & 2)!=0; +#endif + const uintptr_t page=address&~(uintptr_t)4095; + int restored=0, ok=0; + read_lock(); + const size_t i=vma_index(address); + if (i<vma_count && vmas[i].start<=address && vmas[i].kind!=KIND_RESERVED) { + const int guest=host_prot(vmas[i].prot); + if (guest & (is_write ? PROT_WRITE : PROT_READ)) { +#ifdef _WIN32 + MEMORY_BASIC_INFORMATION info; + if (VirtualQuery((void *)page,&info,sizeof(info)) && info.State==MEM_COMMIT) { + const DWORD mode=info.Protect & 0xff; + const int allowed = is_write ? (mode==PAGE_READWRITE || mode==PAGE_EXECUTE_READWRITE) + : (mode!=PAGE_NOACCESS && mode!=PAGE_EXECUTE); + /* Already allowed: changed back meanwhile (the tracking, another thread). */ + restored=!allowed && !win_mem_protect(page,page+4096,guest); + ok=allowed || restored; + } +#else + restored=ok=!mprotect((void *)page,4096,guest); +#endif + } + } + read_unlock(); + if (restored) { + static _Atomic unsigned notes; + const unsigned n=atomic_fetch_add(¬es,1)+1; + if (n<=8 || !(n&(n-1))) { /* the first ones, then at powers of two */ + char line[256]; + int length=snprintf(line,sizeof(line), + "Runtime: %s fault at %#llx: guest page left protected by GPU tracking (%s); " + "access restored (%u so far)\n", is_write ? "write" : "read", (unsigned long long)address, + repeated ? "the fault repeats" : "not handled", n); + if (length<0) length=0; + if (length>=(int)sizeof(line)) length=(int)sizeof(line)-1; +#ifdef _WIN32 + DWORD written; + WriteFile(GetStdHandle(STD_ERROR_HANDLE),line,(DWORD)length,&written,NULL); +#else + { ssize_t written_=write(2,line,(size_t)length); (void)written_; } +#endif + } + } + return ok; +} +/* bbport: whether the guest may write all of [address, address+size) (its own protection). */ +int runtime_memory_guest_writable(uintptr_t address, uint64_t size) { + if (!size) return 0; + int ok=1; + read_lock(); + uintptr_t at=address; + for (size_t i=vma_index(address); at<address+size; ++i) { + if (i==vma_count || vmas[i].start>at || vmas[i].kind==KIND_RESERVED || !(host_prot(vmas[i].prot) & PROT_WRITE)) { ok=0; break; } + at=vmas[i].end; + } + read_unlock(); + return ok; +} /* Host view of a guest range, for the GPU backend: the range must be mapped. */ int runtime_memory_is_mapped(uintptr_t address, uint64_t size) { const CachedRegion *c=(runtime_disabled_optimizations & 1) ? NULL : cached_region(address); diff --git a/src/runtime_pad.c b/src/runtime_pad.c index fac0ac7..b70f6a1 100644 --- a/src/runtime_pad.c +++ b/src/runtime_pad.c @@ -60,6 +60,7 @@ static int initialized, opened, sdl_ready; static SDL_Gamepad *gamepad; static size_t reads; static uint8_t connected_count; +static int gamepad_type; /* runtime_pad_type: read without the lock */ static uint64_t now_us(void) { return host_monotonic_ns()/1000u; } static uint8_t axis(int16_t v) { int x=(v+32768)>>8; return (uint8_t)(x<0 ? 0 : x>255 ? 255 : x); } @@ -86,6 +87,52 @@ static int is_preferred(SDL_JoystickID id, const char *want) { const char *name=SDL_GetGamepadNameForID(id); return !SDL_strcasecmp(guid,want) || (name && SDL_strcasestr(name,want)); } +/* bbport: several gamepads at once (also one pad behind a 2.4 GHz receiver that shows as two): + * without a chosen one, the pad being used takes over, checked five times a second. */ +#define MAX_OTHER_PADS 4 +static SDL_Gamepad *other_pads[MAX_OTHER_PADS]; +static SDL_JoystickID other_pad_ids[MAX_OTHER_PADS]; +static int pad_active(SDL_Gamepad *p) { + for (int b=0;b<SDL_GAMEPAD_BUTTON_COUNT;++b) if (SDL_GetGamepadButton(p,(SDL_GamepadButton)b)) return 1; + for (int a=0;a<SDL_GAMEPAD_AXIS_COUNT;++a) { + const int v=SDL_GetGamepadAxis(p,(SDL_GamepadAxis)a); + if (v>20000 || v<-20000) return 1; + } + return 0; +} +static void follow_active_pad(void) { + static uint64_t last; + const uint64_t now=now_us(); + if (now-last<200000) return; + last=now; + for (int s=0;s<MAX_OTHER_PADS;++s) + if (other_pads[s] && !SDL_GamepadConnected(other_pads[s])) { SDL_CloseGamepad(other_pads[s]); other_pads[s]=NULL; } + int count=0; + SDL_JoystickID *ids=SDL_GetGamepads(&count); + if (!ids) return; + for (int i=0;count>1 && i<count;++i) { + if (gamepad && SDL_GetGamepadID(gamepad)==ids[i]) continue; + int slot=-1, free_slot=-1; + for (int s=0;s<MAX_OTHER_PADS;++s) { + if (other_pads[s] && other_pad_ids[s]==ids[i]) slot=s; + else if (!other_pads[s] && free_slot<0) free_slot=s; + } + if (slot<0 && free_slot>=0 && (other_pads[free_slot]=SDL_OpenGamepad(ids[i]))) { + other_pad_ids[free_slot]=ids[i]; + slot=free_slot; + } + if (slot>=0 && pad_active(other_pads[slot])) { + SDL_Gamepad *previous=gamepad; + gamepad=other_pads[slot]; + other_pads[slot]=previous; + other_pad_ids[slot]=previous ? SDL_GetGamepadID(previous) : 0; + printf("Runtime: gamepad in use: %s\n",SDL_GetGamepadName(gamepad)); + break; + } + } + SDL_free(ids); +} + /* The chosen gamepad, else the first while it is not connected (checked again every second, it * is taken as soon as it connects); called under lock. */ static SDL_Gamepad *current_gamepad(void) { @@ -117,6 +164,14 @@ static SDL_Gamepad *current_gamepad(void) { } SDL_free(ids); } + if (!want) follow_active_pad(); + static SDL_JoystickID typed; + const SDL_JoystickID id=gamepad ? SDL_GetGamepadID(gamepad) : 0; + if (id!=typed) { + typed=id; + __atomic_store_n(&gamepad_type,gamepad ? (int)SDL_GetGamepadType(gamepad) : (int)SDL_GAMEPAD_TYPE_UNKNOWN, + __ATOMIC_RELAXED); + } return gamepad; } /* bbport (frame stats): the game's libc heap, read every 5 s from a game thread (it reads the pad) @@ -168,6 +223,10 @@ enum { PAD_LEFT_TRIGGER=SDL_GAMEPAD_BUTTON_COUNT, PAD_RIGHT_TRIGGER }; /* trigge typedef struct { int key_count, pad_count; SDL_Scancode keys[MAX_BIND]; int pad[MAX_BIND]; } Binding; static Binding bindings[IN_COUNT]; static int bindings_ready; +/* bbport: stick options (bbport.ini): pad_swap_sticks=1 moves with the right stick and looks with + * the left; pad_invert_look_x / pad_invert_look_y=1 invert the camera stick; pad_deadzone=N + * (percent, 0-50) ignores small stick movement (worn sticks that drift). */ +static int pad_swap_sticks, pad_invert_look_x, pad_invert_look_y, pad_deadzone; static void bind_defaults(void) { static const struct { int input; SDL_Scancode key; } keys[]={ @@ -216,6 +275,14 @@ static void load_bindings(void) { while (fgets(line,sizeof line,f)) { const int keyboard=!strncmp(line,"key.",4), pad=!strncmp(line,"pad.",4); char *eq=strchr(line,'='); + if (!strncmp(line,"pad_",4) && eq) { + const int value=atoi(eq+1); + if (!strncmp(line,"pad_swap_sticks=",16)) pad_swap_sticks=value!=0; + else if (!strncmp(line,"pad_invert_look_x=",18)) pad_invert_look_x=value!=0; + else if (!strncmp(line,"pad_invert_look_y=",18)) pad_invert_look_y=value!=0; + else if (!strncmp(line,"pad_deadzone=",13)) pad_deadzone=value<0 ? 0 : value>50 ? 50 : value; + continue; + } if ((!keyboard && !pad) || !eq) continue; *eq=0; int input=-1; @@ -296,6 +363,17 @@ static void sample_host(PadData *d) { } d->left_x=axis(SDL_GetGamepadAxis(g,SDL_GAMEPAD_AXIS_LEFTX)); d->left_y=axis(SDL_GetGamepadAxis(g,SDL_GAMEPAD_AXIS_LEFTY)); d->right_x=axis(SDL_GetGamepadAxis(g,SDL_GAMEPAD_AXIS_RIGHTX)); d->right_y=axis(SDL_GetGamepadAxis(g,SDL_GAMEPAD_AXIS_RIGHTY)); + if (pad_deadzone) { + const int dz=pad_deadzone*128/100; + uint8_t *sticks[4]={&d->left_x,&d->left_y,&d->right_x,&d->right_y}; + for (int s=0;s<4;++s) if (*sticks[s]-128<dz && 128-*sticks[s]<dz) *sticks[s]=128; + } + if (pad_swap_sticks) { + uint8_t t=d->left_x; d->left_x=d->right_x; d->right_x=t; + t=d->left_y; d->left_y=d->right_y; d->right_y=t; + } + if (pad_invert_look_x) d->right_x=(uint8_t)(255-d->right_x); + if (pad_invert_look_y) d->right_y=(uint8_t)(255-d->right_y); if (SDL_GetNumGamepadTouchpads(g)>0) { const int fingers=SDL_GetNumGamepadTouchpadFingers(g,0); for (int finger=0;finger<fingers && d->touch_count<2;++finger) { @@ -456,13 +534,21 @@ static void touch_ids(PadData *d) { else if (!since) since=d->timestamp; d->touch_held_time=d->touch_count ? (uint32_t)(d->timestamp-since) : 0; } +/* After the menu or the text dialog closes, buttons still held (the Cross that accepted a + * name, the button that closed the menu) stay hidden until all are released: the game would + * take them as a new press. From Supermedo/bloodborne_pc 1d34202 (hold_after_capture). */ +static int hold_after_capture; static void sample(PadData *d) { sample_host(d); - if (bbgpu_overlay_captures_input()) return; + if (bbgpu_overlay_captures_input()) { hold_after_capture=1; return; } record_sample(d); read_inject(); replay_sample(d); d->buttons|=injected.buttons; + if (hold_after_capture) { + if (d->buttons) { d->buttons=0; d->l2=d->r2=0; } + else hold_after_capture=0; + } if (injected.touch_side>=0) touch_click(d,injected.touch_side); else if ((d->buttons & BTN_TOUCHPAD) && !d->touch_count) touch_click(d,0); if (injected.buttons & BTN_L2) d->l2=255; @@ -538,5 +624,27 @@ static const RuntimeExport exports[]={ {"scePadSetAngularVelocityDeadbandState",pad_ok_handle_flag}, {"scePadSetTiltCorrectionState",pad_ok_handle_flag}, {"scePadSetMotionSensorState",pad_ok_handle_flag}, }; +int runtime_pad_type(void) { return __atomic_load_n(&gamepad_type,__ATOMIC_RELAXED); } +/* The first bound button the gamepad in use has (Touchpad: Back, then a DualShock's touchpad). */ +int runtime_pad_button(uint32_t ps_button, int right_side) { + int input=-1, found=-1; + if (ps_button==BTN_TOUCHPAD) input=right_side ? IN_TOUCHPAD_RIGHT : IN_TOUCHPAD; + else for (int i=IN_CROSS;i<=IN_RIGHT && input<0;++i) if (input_buttons[i]==ps_button) input=i; + if (input<0) return -1; + host_lock(&lock); + if (!bindings_ready) { load_bindings(); bindings_ready=1; } + const Binding *b=&bindings[input]; + for (int i=0;i<b->pad_count && found<0;++i) { + const int p=b->pad[i]; + const int has=!gamepad ? 1 + : p==PAD_LEFT_TRIGGER ? SDL_GamepadHasAxis(gamepad,SDL_GAMEPAD_AXIS_LEFT_TRIGGER) + : p==PAD_RIGHT_TRIGGER ? SDL_GamepadHasAxis(gamepad,SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) + : SDL_GamepadHasButton(gamepad,(SDL_GamepadButton)p); + if (has) found=p; + } + if (found<0 && b->pad_count) found=b->pad[0]; + host_unlock(&lock); + return found; +} uintptr_t runtime_pad_resolve(const char *name) { return RUNTIME_LOOKUP(exports,name); } void runtime_pad_report(void) { printf("Runtime: pad reads=%zu, gamepad=%s\n",reads,gamepad ? SDL_GetGamepadName(gamepad) : "none"); } diff --git a/src/runtime_savedata.c b/src/runtime_savedata.c index 99b3049..e49901f 100644 --- a/src/runtime_savedata.c +++ b/src/runtime_savedata.c @@ -74,7 +74,7 @@ static size_t mounts_done, memory_writes; * (e.g. the player's weapon sounds) never play. It is set before the game reads the save. * BB_SOUND_HACK=0 leaves saves untouched. */ static void bloodborne_sound_hack(void) { - static const char *const ids[]={"CUSA00207","CUSA00208","CUSA00299","CUSA00900","CUSA01363","CUSA03014","CUSA03023","CUSA03173"}; + static const char *const ids[]={"CUSA00207","CUSA00208","CUSA00299","CUSA00900","CUSA01363","CUSA03014","CUSA03023","CUSA03173","CUSA03179"}; const char *env=getenv("BB_SOUND_HACK"); if (env && env[0]=='0') return; int bloodborne=0; @@ -284,7 +284,11 @@ static ABI int32_t save_search(const SearchCond *cond, SearchResult *result) { size_t n=strlen(e->d_name); if (e->d_name[0]=='.' || n>=32 || (n>8 && !strcmp(e->d_name+n-8,".sce_sys"))) continue; if (cond->dir && cond->dir->data[0] && !like(e->d_name,cond->dir->data)) continue; - if (count==capacity) { capacity=capacity ? capacity*2 : 16; entries=realloc(entries,capacity*sizeof(*entries)); } + if (count==capacity) { + Entry *grown=realloc(entries,(capacity ? capacity*2 : 16)*sizeof(*entries)); + if (!grown) break; + entries=grown; capacity=capacity ? capacity*2 : 16; + } snprintf(entries[count].name,32,"%s",e->d_name); char meta[680]; snprintf(meta,sizeof(meta),"%s/%s.sce_sys",base,e->d_name); read_param(meta,&entries[count].param); diff --git a/src/runtime_services.c b/src/runtime_services.c index 5f31782..42c1225 100644 --- a/src/runtime_services.c +++ b/src/runtime_services.c @@ -87,7 +87,9 @@ static ABI int32_t system_param(int32_t id,int32_t *value) { case 5: *value=0; break; /* summer time */ case 7: *value=0; break; /* parental level off */ case 1000: *value=1; break; /* enter button = cross */ - default: fprintf(stderr,"STOP: unsupported system parameter %d\n",id); exit(21); + default: /* bbport: the parameter error instead of ending the game (exit 21) */ + fprintf(stderr,"Runtime: unsupported system parameter %d\n",id); + return SYSTEM_PARAMETER; } return 0; } @@ -262,10 +264,13 @@ static ABI int32_t ime_init(const ImeParam *param, const void *extended) { utf16_to_utf8(param->title,128,prompt,sizeof(prompt)); const char *preset=getenv("BB_IME_TEXT"); if (preset) { ime_complete(0,preset); return 0; } - if (!bbgpu_text_input_begin(initial,prompt[0] ? prompt : "Text")) { - const char *name=getenv("BB_USER_NAME"); + /* An empty field starts with the player name (launcher) or "Hunter", so a controller alone + * can accept it: there is no on-screen keyboard (Supermedo/bloodborne_pc 1d34202). */ + const char *name=getenv("BB_USER_NAME"); + const char *start=initial[0] ? initial : name && *name ? name : "Hunter"; + if (!bbgpu_text_input_begin(start,prompt[0] ? prompt : "Text")) { ime_complete(0,name ? name : initial[0] ? initial : "Hunter"); - } else printf("Runtime: ImeDialog opened: type in the game window, Enter to confirm, Esc to cancel\n"); + } else printf("Runtime: ImeDialog opened: type in the game window; Enter or Cross accepts, Esc or Circle cancels\n"); return 0; } static ABI int32_t ime_status(void) { diff --git a/src/runtime_thread.c b/src/runtime_thread.c index 65883c0..e46c2a7 100644 --- a/src/runtime_thread.c +++ b/src/runtime_thread.c @@ -3,7 +3,8 @@ * `mov rax, fs:[0]` into `mov rax, gs:[0]`, so GS base = guest TCB while glibc * keeps FS. On Windows GS is the TEB: the loader points the instruction at a TEB * TLS slot that holds the guest TCB instead. - * Priorities/affinity are recorded, not enforced by a PS4 scheduler. */ + * Priorities/affinity are recorded, not enforced by a PS4 scheduler; only the audio range of + * priorities raises the host thread's priority (apply_priority). */ #define _GNU_SOURCE #include "runtime.h" #include <stdio.h> @@ -130,6 +131,8 @@ void runtime_thread_attach_main(void) { GuestThread *t=runtime_thread_current(); snprintf(t->name,sizeof(t->name),"main"); } +/* No record is created here: the fault handler asks (probe.c). */ +int runtime_thread_has_record(void) { return current!=NULL; } static GuestThread *find_thread(void *handle) { GuestThread *found=NULL; host_lock(&lock); @@ -244,10 +247,26 @@ static void set_host_name(const char *name) { pthread_setname_np(pthread_self(),host); #endif } +/* bbport: PS4 priorities run from 256 (highest) to 767. Bloodborne starts FMOD's output, mixer, + * stream and file threads at 256-265 and every other thread at 700. At the host priority of the + * ~60 game threads (and below the render threads with BB_GPU_THREAD_PRIORITY), a busy CPU ran + * FMOD's mixer late: late and stuttering sound on CPUs with slower cores. That range runs above + * them; it is mostly asleep. BB_AUDIO_THREAD_PRIORITY=0: all at normal priority, as before. */ +#define HIGH_PRIO_LAST 300 +static void apply_priority(int prio) { + static int enabled=-1; + if (enabled<0) { const char *e=getenv("BB_AUDIO_THREAD_PRIORITY"); enabled=!(e && e[0]=='0'); } + if (enabled) host_thread_set_high_priority(prio<=HIGH_PRIO_LAST); + static int noted; + if (prio<=HIGH_PRIO_LAST && !__atomic_exchange_n(¬ed,1,__ATOMIC_RELAXED)) + printf("Runtime: audio threads (priority %d-%d) %s\n",256,HIGH_PRIO_LAST, + enabled ? "above the game's other threads" : "at normal priority (BB_AUDIO_THREAD_PRIORITY=0)"); +} static void *host_start(void *p) { GuestThread *t=p; attach(t); set_host_name(t->name); + if (t->attr.prio<=HIGH_PRIO_LAST) apply_priority(t->attr.prio); if (!EXIT_SET(t->exit_jump)) t->result=t->entry(t->argument); runtime_thread_keys_cleanup(); host_lock(&lock); t->finished=1; ++exited; host_unlock(&lock); @@ -323,6 +342,8 @@ static ABI int32_t thread_get_prio(GuestThread *t,int *prio) { } static ABI int32_t thread_set_prio(GuestThread *t,int prio) { if (!find_thread(t)) return ERR(3); + /* Only the calling thread's own host priority can change here (others' handles may be gone). */ + if (t==current && (prio<=HIGH_PRIO_LAST)!=(t->attr.prio<=HIGH_PRIO_LAST)) apply_priority(prio); t->attr.prio=prio; return 0; } static ABI int32_t thread_set_affinity(GuestThread *t,uint64_t mask) { diff --git a/tests/test_content.c b/tests/test_content.c index a2136ca..ff79682 100644 --- a/tests/test_content.c +++ b/tests/test_content.c @@ -1,6 +1,7 @@ #include "runtime.h" #include <assert.h> #include <stdint.h> +#include <stdlib.h> #include <string.h> #include <stdio.h> typedef int32_t (ABI *Module)(uint16_t); @@ -8,6 +9,11 @@ typedef int32_t (ABI *Init)(const void *,void *); typedef int32_t (ABI *Param)(uint32_t,int32_t *); typedef int32_t (ABI *List)(uint32_t,void *,uint32_t,uint32_t *); #define GET(t,n) ((t)runtime_content_resolve(n)) +/* The runtime's user folder (runtime_file.c): TEST_USER_DIR, else one without add-ons. */ +const char *runtime_file_user_dir(void) { + const char *dir=getenv("TEST_USER_DIR"); + return dir ? dir : "no-such-user-dir"; +} int main(int argc,char **argv) { Module load=GET(Module,"g8cM39EUZ6o#M#N"); Module loaded=GET(Module,"fMP5NHUOaMk#M#N"); @@ -20,6 +26,22 @@ int main(int argc,char **argv) { if (argc>1 && !strcmp(argv[1],"--missing")) { load(0xb4); return 99; } const uint32_t profile[]={1,13,0x80000000,0,7}; runtime_content_configure(profile); + if (argc>1 && !strcmp(argv[1],"--addons")) { + /* BB_DLC_DIR and TEST_USER_DIR set by the caller (tests/test_content.py). */ + assert(load(0xb4)==0); + unsigned char initial[32]={0}, boot[40]; + assert(init(initial,boot)==0); + struct { char label[17]; char padding[3]; uint32_t status; } entries[3]; + memset(entries,0xaa,sizeof(entries)); + uint32_t hits=0; + assert(list(0,NULL,0,&hits)==0); + printf("hits %u\n",hits); + assert(list(0,entries,2,&hits)==0 && hits<=2); + for (uint32_t i=0;i<hits;++i) printf("addon %s %u\n",entries[i].label,entries[i].status); + const unsigned char *rest=(const unsigned char *)&entries[hits]; + for (size_t i=0;i<sizeof(entries[0])*(3-hits);++i) assert(rest[i]==0xaa); + return 0; + } if (argc>1 && !strcmp(argv[1],"--unknown")) { /* Other system modules are host-provided: reference counted, independent. */ assert((uint32_t)loaded(0xb5)==0x805a1001); diff --git a/tests/test_content.py b/tests/test_content.py index c151e2a..5f72fc3 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -36,5 +36,31 @@ class ContentTests(unittest.TestCase): r=self.run_case('--missing');self.assertEqual(r.returncode,21) self.assertIn('needs --content-profile',r.stderr) + def test_dumped_addons_are_reported_installed(self): + # The Old Hunters on base-game dumps: add-on folders in BB_DLC_DIR and <user>/addcont/<title id>, + # each label once (from param.sfo's CONTENT_ID, else the folder's name); a game is not one. + def sfo(entries): + keys=data=index=b'' + for k,v in entries: + kb,vb=k.encode()+b'\0',v.encode()+b'\0' + index+=struct.pack('<HHIII',len(keys),0x0204,len(vb),len(vb),len(data)); keys+=kb; data+=vb + start=20+len(index) + return b'\0PSF'+struct.pack('<IIII',0x101,start,start+len(keys),len(entries))+index+keys+data + with tempfile.TemporaryDirectory() as tmp: + p=Path(tmp) + for folder,entries in [('dlc/SPEXPANSIONDLC03',None), + ('dlc/oldhunters',[('CATEGORY','ac'),('CONTENT_ID','UP9000-CUSA00207_00-SPEXPANSIONDLC03')]), + ('dlc/game',[('CATEGORY','gd'),('CONTENT_ID','UP9000-CUSA00207_00-BLOODBORNE0000000')]), + ('dlc/dvdroot_ps4',None),('user/addcont/CUSA00207/SOUNDTRACK01',None)]: + (p/folder/'sce_sys').mkdir(parents=True) + if entries: (p/folder/'sce_sys/param.sfo').write_bytes(sfo(entries)) + r=subprocess.run([str(EXE.resolve()),'--addons'],capture_output=True,text=True,timeout=5, + env={'BB_DLC_DIR':str(p/'dlc'),'TEST_USER_DIR':str(p/'user')}) + self.assertEqual(r.returncode,0,r.stdout+r.stderr) + self.assertIn('hits 2',r.stdout) + self.assertIn('addon SPEXPANSIONDLC03 4',r.stdout) + self.assertIn('addon SOUNDTRACK01 4',r.stdout) + r=self.run_case('--addons');self.assertIn('hits 0',r.stdout) + def test_other_modules_are_counted_independently(self): r=self.run_case('--unknown');self.assertEqual(r.returncode,0,r.stdout+r.stderr) diff --git a/tests/test_engine_hooks.cpp b/tests/test_engine_hooks.cpp index 50e085f..316ab02 100644 --- a/tests/test_engine_hooks.cpp +++ b/tests/test_engine_hooks.cpp @@ -67,10 +67,32 @@ int main() { assert(reinterpret_cast<Floating>(image)(1.5,2.5) == 4.0); assert(floats[0] == 1.5 && floats[1] == 2.5); BbEngine::Discard(hook); + + // A seventh argument on the stack, read as the game reads it ([rbp+0x10]): + // push rbp; mov rbp,rsp; mov rax,[rbp+0x10]; add rax,r9; pop rbp; ret + const std::uint8_t seventh[] = {0x55,0x48,0x89,0xe5,0x48,0x8b,0x45,0x10,0x4c,0x01,0xc8,0x5d,0xc3}; + std::memcpy(image + 0x100, seventh, sizeof seventh); + assert(BbEngine::Call(reinterpret_cast<std::uint64_t>(image + 0x100), 1,2,3,4,5,6,70) == 76); + + // A redirected tail call (the fog table's upload): its jmp rel32 goes to padding that jumps on + // to a stub, which calls the host and then the jump's target with the arguments intact. + calls = 0; + std::memcpy(image + 16, sum, sizeof sum); + auto* stub = BbEngine::TailStub(Observe, reinterpret_cast<std::uint64_t>(image + 16)); + assert(stub); + std::uint8_t* p = image + 0x210; *p++ = 0x49; *p++ = 0xbb; // movabs r11, stub; jmp r11 + const auto target = reinterpret_cast<std::uint64_t>(stub); + std::memcpy(p, &target, 8); p += 8; *p++ = 0x41; *p++ = 0xff; *p = 0xe3; + image[0x200] = 0xe9; + const std::int32_t rel = 0x210 - (0x200 + 5); + std::memcpy(image + 0x201, &rel, 4); + assert(BbEngine::Call(reinterpret_cast<std::uint64_t>(image + 0x200), 1,2,3,4,5,6) == 21); + assert(calls == 1 && arguments == (std::array<std::uint64_t,6>{1,2,3,4,5,6})); + BbEngine::FreeStub(stub); #ifdef _WIN32 VirtualFree(image, 0, MEM_RELEASE); #else munmap(image, 4096); #endif - std::puts("PASS: guest ABI arguments, XMM preservation, return slot, byte/range guards and staged hook installation"); + std::puts("PASS: guest ABI arguments (seven with a stack argument), XMM preservation, return slot, byte/range guards, staged hook installation and a redirected tail call"); } diff --git a/tests/test_gpu_runtime_stubs.cpp b/tests/test_gpu_runtime_stubs.cpp index 75eca24..45ea45d 100644 --- a/tests/test_gpu_runtime_stubs.cpp +++ b/tests/test_gpu_runtime_stubs.cpp @@ -42,6 +42,7 @@ void runtime_memory_note_write(uintptr_t, uint64_t) { std::abort(); } void runtime_memory_set_note_write_hook(void (*)(uintptr_t, uint64_t)) {} void runtime_memory_set_cpu_write_hook(void (*)(uintptr_t, uint64_t)) {} void runtime_memory_set_write_watch(uintptr_t, uint64_t, int) { std::abort(); } +int runtime_memory_restore_access(void*, uintptr_t, int) { return 0; } void runtime_memory_set_guest_chunk_allocator(int (*)(uint64_t, uint64_t)) {} void runtime_memory_set_guest_chunk_whole(int) {} int runtime_memory_vma_info(uintptr_t, int*, int*, uintptr_t*) { std::abort(); } diff --git a/tests/test_hdr_native.cpp b/tests/test_hdr_native.cpp new file mode 100644 index 0000000..d15f7f5 --- /dev/null +++ b/tests/test_hdr_native.cpp @@ -0,0 +1,848 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Runs native HDR output's shaders (hdr_native.comp, docs/hdr.md) on a synthetic frame: a scene +// with midtones, a highlight 8x brighter than SDR white, NaN/inf texels, bloom (SDR brighter than +// the scene) and UI drawn after the snapshot, tonemapped by a known curve. Checks the half-size +// luma, the exposure estimate (and its resistance to bloom), and the gain map: exactly 1 below +// the knee and on UI, the scene's ratio (to the power of the strength, into the peak) on the +// highlight. Plain Vulkan 1.3 compute; Lavapipe works. +#include <vulkan/vulkan.h> + +#include <algorithm> +#include <array> +#include <cassert> +#include <cmath> +#include <cstdio> +#include <cstdlib> +#include <cstring> +#include <limits> +#include <vector> + +#include "video_core/host_shaders/hdr_native_exposure_comp.h" +#include "video_core/host_shaders/hdr_native_gain_comp.h" +#include "video_core/host_shaders/hdr_native_luma_comp.h" +#include "video_core/host_shaders/hdr_native_snapshot_comp.h" +#include "video_core/host_shaders/hdr_native_stats_comp.h" + +namespace { + +#define CHECK(call) \ + do { \ + const VkResult result_ = (call); \ + if (result_ != VK_SUCCESS) { \ + std::fprintf(stderr, "%s failed: %d\n", #call, int(result_)); \ + std::exit(1); \ + } \ + } while (0) + +constexpr uint32_t W = 64, H = 32; // the frame (the UI target) +constexpr uint32_t LW = W / 2, LH = H / 2; // the luma +constexpr float Exposure = 0.25f; // the game's exposure in the synthetic tonemap +constexpr float Knee = 0.5f; // vk_hdr_native.cpp + +uint16_t ToHalf(float value) { + if (std::isnan(value)) { + return 0x7e00; + } + if (std::isinf(value)) { + return value > 0 ? 0x7c00 : 0xfc00; + } + uint32_t bits; + std::memcpy(&bits, &value, 4); + const uint32_t sign = (bits >> 16) & 0x8000; + const int exponent = int((bits >> 23) & 0xff) - 127 + 15; + uint32_t mantissa = bits & 0x7fffff; + if (exponent >= 31) { + return uint16_t(sign | 0x7c00); + } + if (exponent <= 0) { + if (exponent < -10) { + return uint16_t(sign); + } + mantissa |= 0x800000; + const int shift = 14 - exponent; + return uint16_t(sign | ((mantissa + (1u << (shift - 1))) >> shift)); + } + const uint32_t rounded = (uint32_t(exponent) << 10 | mantissa >> 13) + ((mantissa >> 12) & 1); + return uint16_t(sign | rounded); +} + +float FromHalf(uint16_t v) { + const int exponent = (v >> 10) & 31; + const float mantissa = float(v & 1023); + const float sign = v & 0x8000 ? -1.0f : 1.0f; + if (exponent == 31) { + return mantissa ? std::numeric_limits<float>::quiet_NaN() + : sign * std::numeric_limits<float>::infinity(); + } + return sign * (exponent == 0 ? std::ldexp(mantissa, -24) + : std::ldexp(1.0f + mantissa / 1024.0f, exponent - 15)); +} + +// The synthetic game's tonemap: the exposure, linear up to 0.5, then a shoulder into SDR white; +// display-encoded with 2.2 into 8 bits. +float Curve(float x) { + return x <= 0.5f ? x : 0.5f + 0.5f * (1.0f - std::exp(-(x - 0.5f) / 0.5f)); +} +uint8_t Encode(float linear) { + return uint8_t(std::lround(std::pow(std::clamp(linear, 0.0f, 1.0f), 1.0f / 2.2f) * 255.0f)); +} + +struct Vk { + VkInstance instance{}; + VkPhysicalDevice physical{}; + VkDevice device{}; + VkQueue queue{}; + uint32_t family = 0; + VkCommandPool pool{}; + VkCommandBuffer cmd{}; + VkPhysicalDeviceMemoryProperties memory{}; + + uint32_t MemoryType(uint32_t bits, VkMemoryPropertyFlags flags) const { + for (uint32_t i = 0; i < memory.memoryTypeCount; ++i) { + if ((bits & (1u << i)) && (memory.memoryTypes[i].propertyFlags & flags) == flags) { + return i; + } + } + std::fprintf(stderr, "no memory type\n"); + std::exit(1); + } +}; + +struct Image { + VkImage image{}; + VkDeviceMemory memory{}; + VkImageView view{}; + VkFormat format{}; + uint32_t width = 0, height = 0, bytes = 0; +}; + +struct Buffer { + VkBuffer buffer{}; + VkDeviceMemory memory{}; + void* mapped = nullptr; + VkDeviceSize size = 0; +}; + +Image MakeImage(const Vk& vk, VkFormat format, uint32_t width, uint32_t height, uint32_t bytes) { + Image image{.format = format, .width = width, .height = height, .bytes = bytes}; + const VkImageCreateInfo info{ + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, + .imageType = VK_IMAGE_TYPE_2D, + .format = format, + .extent = {width, height, 1}, + .mipLevels = 1, + .arrayLayers = 1, + .samples = VK_SAMPLE_COUNT_1_BIT, + .tiling = VK_IMAGE_TILING_OPTIMAL, + .usage = VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED, + }; + CHECK(vkCreateImage(vk.device, &info, nullptr, &image.image)); + VkMemoryRequirements requirements; + vkGetImageMemoryRequirements(vk.device, image.image, &requirements); + const VkMemoryAllocateInfo alloc{ + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .allocationSize = requirements.size, + .memoryTypeIndex = + vk.MemoryType(requirements.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT), + }; + CHECK(vkAllocateMemory(vk.device, &alloc, nullptr, &image.memory)); + CHECK(vkBindImageMemory(vk.device, image.image, image.memory, 0)); + const VkImageViewCreateInfo view{ + .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, + .image = image.image, + .viewType = VK_IMAGE_VIEW_TYPE_2D, + .format = format, + .subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}, + }; + CHECK(vkCreateImageView(vk.device, &view, nullptr, &image.view)); + return image; +} + +Buffer MakeBuffer(const Vk& vk, VkDeviceSize size, VkBufferUsageFlags usage, bool host = true) { + Buffer buffer{.size = size}; + const VkBufferCreateInfo info{ + .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, + .size = size, + .usage = usage, + }; + CHECK(vkCreateBuffer(vk.device, &info, nullptr, &buffer.buffer)); + VkMemoryRequirements requirements; + vkGetBufferMemoryRequirements(vk.device, buffer.buffer, &requirements); + const VkMemoryAllocateInfo alloc{ + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .allocationSize = requirements.size, + .memoryTypeIndex = vk.MemoryType(requirements.memoryTypeBits, + host ? VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT + : VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT), + }; + CHECK(vkAllocateMemory(vk.device, &alloc, nullptr, &buffer.memory)); + CHECK(vkBindBufferMemory(vk.device, buffer.buffer, buffer.memory, 0)); + if (host) { + CHECK(vkMapMemory(vk.device, buffer.memory, 0, VK_WHOLE_SIZE, 0, &buffer.mapped)); + } + return buffer; +} + +struct Pass { + VkDescriptorSetLayout set_layout{}; + VkPipelineLayout layout{}; + VkPipeline pipeline{}; + VkDescriptorSet set{}; + std::vector<VkDescriptorType> types; +}; + +Pass MakePass(const Vk& vk, VkDescriptorPool pool, std::vector<VkDescriptorType> types, + uint32_t push_size, const uint32_t* code, size_t code_size) { + Pass pass{.types = types}; + std::vector<VkDescriptorSetLayoutBinding> bindings; + for (uint32_t i = 0; i < types.size(); ++i) { + bindings.push_back({i, types[i], 1, VK_SHADER_STAGE_COMPUTE_BIT, nullptr}); + } + const VkDescriptorSetLayoutCreateInfo set_info{ + .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO, + .bindingCount = uint32_t(bindings.size()), + .pBindings = bindings.data(), + }; + CHECK(vkCreateDescriptorSetLayout(vk.device, &set_info, nullptr, &pass.set_layout)); + const VkPushConstantRange push{VK_SHADER_STAGE_COMPUTE_BIT, 0, push_size}; + const VkPipelineLayoutCreateInfo layout_info{ + .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, + .setLayoutCount = 1, + .pSetLayouts = &pass.set_layout, + .pushConstantRangeCount = push_size ? 1u : 0u, + .pPushConstantRanges = push_size ? &push : nullptr, + }; + CHECK(vkCreatePipelineLayout(vk.device, &layout_info, nullptr, &pass.layout)); + const VkShaderModuleCreateInfo module_info{ + .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, + .codeSize = code_size, + .pCode = code, + }; + VkShaderModule module; + CHECK(vkCreateShaderModule(vk.device, &module_info, nullptr, &module)); + const VkComputePipelineCreateInfo pipeline_info{ + .sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO, + .stage = {.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, + .stage = VK_SHADER_STAGE_COMPUTE_BIT, + .module = module, + .pName = "main"}, + .layout = pass.layout, + }; + CHECK(vkCreateComputePipelines(vk.device, VK_NULL_HANDLE, 1, &pipeline_info, nullptr, + &pass.pipeline)); + vkDestroyShaderModule(vk.device, module, nullptr); + const VkDescriptorSetAllocateInfo alloc{ + .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO, + .descriptorPool = pool, + .descriptorSetCount = 1, + .pSetLayouts = &pass.set_layout, + }; + CHECK(vkAllocateDescriptorSets(vk.device, &alloc, &pass.set)); + return pass; +} + +// A resource of a pass: an image (sampled or storage, General layout) or a whole buffer. +struct Resource { + const Image* image = nullptr; + const Buffer* buffer = nullptr; +}; + +void Bind(const Vk& vk, const Pass& pass, std::vector<Resource> resources) { + std::vector<VkDescriptorImageInfo> images(resources.size()); + std::vector<VkDescriptorBufferInfo> buffers(resources.size()); + std::vector<VkWriteDescriptorSet> writes; + for (uint32_t i = 0; i < resources.size(); ++i) { + VkWriteDescriptorSet write{ + .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, + .dstSet = pass.set, + .dstBinding = i, + .descriptorCount = 1, + .descriptorType = pass.types[i], + }; + if (resources[i].image) { + images[i] = {VK_NULL_HANDLE, resources[i].image->view, VK_IMAGE_LAYOUT_GENERAL}; + write.pImageInfo = &images[i]; + } else { + buffers[i] = {resources[i].buffer->buffer, 0, VK_WHOLE_SIZE}; + write.pBufferInfo = &buffers[i]; + } + writes.push_back(write); + } + vkUpdateDescriptorSets(vk.device, uint32_t(writes.size()), writes.data(), 0, nullptr); +} + +void Barrier(VkCommandBuffer cmd) { + const VkMemoryBarrier barrier{ + .sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER, + .srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT, + .dstAccessMask = VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT, + }; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 1, &barrier, 0, nullptr, 0, + nullptr); +} + +void Dispatch(VkCommandBuffer cmd, const Pass& pass, const void* push, uint32_t push_size, + uint32_t x, uint32_t y) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pass.pipeline); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pass.layout, 0, 1, &pass.set, 0, + nullptr); + if (push_size) { + vkCmdPushConstants(cmd, pass.layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, push_size, push); + } + vkCmdDispatch(cmd, x, y, 1); + Barrier(cmd); +} + +void Begin(const Vk& vk) { + CHECK(vkResetCommandBuffer(vk.cmd, 0)); + const VkCommandBufferBeginInfo begin{ + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, + .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, + }; + CHECK(vkBeginCommandBuffer(vk.cmd, &begin)); +} + +void Submit(const Vk& vk) { + CHECK(vkEndCommandBuffer(vk.cmd)); + const VkSubmitInfo submit{ + .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, + .commandBufferCount = 1, + .pCommandBuffers = &vk.cmd, + }; + CHECK(vkQueueSubmit(vk.queue, 1, &submit, VK_NULL_HANDLE)); + CHECK(vkQueueWaitIdle(vk.queue)); +} + +void Upload(const Vk& vk, const Buffer& staging, const Image& image, const void* data) { + std::memcpy(staging.mapped, data, size_t(image.width) * image.height * image.bytes); + Begin(vk); + const VkBufferImageCopy region{ + .imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, + .imageExtent = {image.width, image.height, 1}, + }; + vkCmdCopyBufferToImage(vk.cmd, staging.buffer, image.image, VK_IMAGE_LAYOUT_GENERAL, 1, + ®ion); + Barrier(vk.cmd); + Submit(vk); +} + +template <typename T> +std::vector<T> Download(const Vk& vk, const Buffer& staging, const Image& image) { + Begin(vk); + const VkBufferImageCopy region{ + .imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, + .imageExtent = {image.width, image.height, 1}, + }; + vkCmdCopyImageToBuffer(vk.cmd, image.image, VK_IMAGE_LAYOUT_GENERAL, staging.buffer, 1, + ®ion); + Barrier(vk.cmd); + Submit(vk); + std::vector<T> out(size_t(image.width) * image.height * image.bytes / sizeof(T)); + std::memcpy(out.data(), staging.mapped, out.size() * sizeof(T)); + return out; +} + +Vk Init() { + Vk vk; + const VkApplicationInfo app{ + .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, + .pApplicationName = "hdr-native-shader-test", + .apiVersion = VK_API_VERSION_1_3, + }; + const VkInstanceCreateInfo instance_info{ + .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, + .pApplicationInfo = &app, + }; + CHECK(vkCreateInstance(&instance_info, nullptr, &vk.instance)); + uint32_t count = 0; + CHECK(vkEnumeratePhysicalDevices(vk.instance, &count, nullptr)); + std::vector<VkPhysicalDevice> devices(count); + CHECK(vkEnumeratePhysicalDevices(vk.instance, &count, devices.data())); + // BB_TEST_GPU=<index> picks a device; else the first that has Vulkan 1.3. + const char* wanted = std::getenv("BB_TEST_GPU"); + for (uint32_t i = 0; i < count && !vk.physical; ++i) { + VkPhysicalDeviceProperties properties; + vkGetPhysicalDeviceProperties(devices[i], &properties); + if ((wanted && uint32_t(std::atoi(wanted)) != i) || + properties.apiVersion < VK_API_VERSION_1_3) { + continue; + } + vk.physical = devices[i]; + std::printf("HDR native shaders: %s\n", properties.deviceName); + } + assert(vk.physical); + vkGetPhysicalDeviceMemoryProperties(vk.physical, &vk.memory); + uint32_t families = 0; + vkGetPhysicalDeviceQueueFamilyProperties(vk.physical, &families, nullptr); + std::vector<VkQueueFamilyProperties> family_properties(families); + vkGetPhysicalDeviceQueueFamilyProperties(vk.physical, &families, family_properties.data()); + while (!(family_properties[vk.family].queueFlags & VK_QUEUE_COMPUTE_BIT)) { + ++vk.family; + } + VkPhysicalDeviceFeatures supported; + vkGetPhysicalDeviceFeatures(vk.physical, &supported); + assert(supported.shaderStorageImageExtendedFormats); // rg16f / r16f storage + const VkPhysicalDeviceFeatures features{.shaderStorageImageExtendedFormats = VK_TRUE}; + const float priority = 1.0f; + const VkDeviceQueueCreateInfo queue_info{ + .sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO, + .queueFamilyIndex = vk.family, + .queueCount = 1, + .pQueuePriorities = &priority, + }; + const VkDeviceCreateInfo device_info{ + .sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO, + .queueCreateInfoCount = 1, + .pQueueCreateInfos = &queue_info, + .pEnabledFeatures = &features, + }; + CHECK(vkCreateDevice(vk.physical, &device_info, nullptr, &vk.device)); + vkGetDeviceQueue(vk.device, vk.family, 0, &vk.queue); + const VkCommandPoolCreateInfo pool_info{ + .sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, + .flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, + .queueFamilyIndex = vk.family, + }; + CHECK(vkCreateCommandPool(vk.device, &pool_info, nullptr, &vk.pool)); + const VkCommandBufferAllocateInfo cmd_info{ + .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, + .commandPool = vk.pool, + .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, + .commandBufferCount = 1, + }; + CHECK(vkAllocateCommandBuffers(vk.device, &cmd_info, &vk.cmd)); + return vk; +} + +struct ExposureState { + float log_scale, valid, samples, frames; +}; + +// --bench [width height]: GPU time of one frame's passes (luma, snapshot + statistics, +// exposure, gain map; barriers between them as in the game) on noise-filled images. +void Bench(const Vk& vk, const Pass& luma, const Pass& snapshot, const Pass& exposure, + const Pass& gain, uint32_t width, uint32_t height) { + const Image scene = MakeImage(vk, VK_FORMAT_R16G16B16A16_SFLOAT, width, height, 8); + const Image luma_image = + MakeImage(vk, VK_FORMAT_R16G16_SFLOAT, (width + 1) / 2, (height + 1) / 2, 4); + const Image target = MakeImage(vk, VK_FORMAT_R8G8B8A8_UNORM, width, height, 4); + const Image copy = MakeImage(vk, VK_FORMAT_R8G8B8A8_UNORM, width, height, 4); + const Image gain_map = MakeImage(vk, VK_FORMAT_R16_SFLOAT, width, height, 2); + const uint32_t groups_x = (width + 15) / 16, groups_y = (height + 15) / 16; + // In video memory, as the game's (VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE). + const Buffer partials = + MakeBuffer(vk, groups_x * groups_y * 16, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, false); + const Buffer state = MakeBuffer(vk, sizeof(ExposureState), + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | + VK_BUFFER_USAGE_TRANSFER_DST_BIT, + false); + const Buffer staging = MakeBuffer(vk, VkDeviceSize(width) * height * 8, + VK_BUFFER_USAGE_TRANSFER_SRC_BIT); + Begin(vk); + vkCmdFillBuffer(vk.cmd, state.buffer, 0, VK_WHOLE_SIZE, 0); + Barrier(vk.cmd); + std::vector<VkImageMemoryBarrier> to_general; + for (const Image* image : {&scene, &luma_image, &target, ©, &gain_map}) { + to_general.push_back({.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .dstAccessMask = VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT, + .oldLayout = VK_IMAGE_LAYOUT_UNDEFINED, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = image->image, + .subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}}); + } + vkCmdPipelineBarrier(vk.cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0, nullptr, + uint32_t(to_general.size()), to_general.data()); + Submit(vk); + // Noise, so that no compression of uniform surfaces makes the reads cheaper than in game. + uint32_t seed = 12345; + const auto next = [&seed] { + seed = seed * 1664525u + 1013904223u; + return seed >> 8; + }; + std::vector<uint16_t> scene_data(size_t(width) * height * 4); + for (auto& v : scene_data) { + v = ToHalf(float(next() % 4096) / 512.0f); + } + Upload(vk, staging, scene, scene_data.data()); + std::vector<uint8_t> frame_data(size_t(width) * height * 4); + for (auto& v : frame_data) { + v = uint8_t(next()); + } + Upload(vk, staging, target, frame_data.data()); + Bind(vk, luma, {{&scene}, {&luma_image}}); + Bind(vk, snapshot, {{&target}, {&luma_image}, {nullptr, &partials}, {nullptr, &state}, + {©}}); + Bind(vk, exposure, {{nullptr, &partials}, {nullptr, &state}}); + Bind(vk, gain, {{&target}, {©}, {&luma_image}, {nullptr, &state}, {&gain_map}}); + VkQueryPool queries; + const VkQueryPoolCreateInfo query_info{ + .sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO, + .queryType = VK_QUERY_TYPE_TIMESTAMP, + .queryCount = 2, + }; + CHECK(vkCreateQueryPool(vk.device, &query_info, nullptr, &queries)); + VkPhysicalDeviceProperties properties; + vkGetPhysicalDeviceProperties(vk.physical, &properties); + const uint32_t reset = 0; + const struct { + uint32_t count, reset; + float min_samples, rate; + } exposure_params{groups_x * groups_y, 0, 1024.0f, 0.1f}; + const struct { + float strength, white, knee, unused; + } gain_params{0.5f, 5.0f, Knee, 0.0f}; + std::vector<double> times; + for (int run = 0; run < 120; ++run) { + Begin(vk); + vkCmdResetQueryPool(vk.cmd, queries, 0, 2); + vkCmdWriteTimestamp(vk.cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, queries, 0); + Dispatch(vk.cmd, luma, nullptr, 0, (luma_image.width + 7) / 8, (luma_image.height + 7) / 8); + Dispatch(vk.cmd, snapshot, &reset, 4, groups_x, groups_y); + Dispatch(vk.cmd, exposure, &exposure_params, sizeof(exposure_params), 1, 1); + Dispatch(vk.cmd, gain, &gain_params, sizeof(gain_params), (width + 7) / 8, + (height + 7) / 8); + vkCmdWriteTimestamp(vk.cmd, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, queries, 1); + Submit(vk); + std::array<uint64_t, 2> stamps{}; + CHECK(vkGetQueryPoolResults(vk.device, queries, 0, 2, sizeof(stamps), stamps.data(), 8, + VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT)); + if (run >= 20) { // warm-up (clocks, caches) + times.push_back(double(stamps[1] - stamps[0]) * properties.limits.timestampPeriod / + 1e6); + } + } + std::sort(times.begin(), times.end()); + std::printf("HDR native shaders: %ux%u, all passes: median %.3f ms, min %.3f ms, p90 %.3f ms\n", + width, height, times[times.size() / 2], times.front(), + times[times.size() * 9 / 10]); + vkDestroyQueryPool(vk.device, queries, nullptr); +} + +} // namespace + +int main(int argc, char** argv) { + Vk vk = Init(); + VkDescriptorPool pool; + const std::array<VkDescriptorPoolSize, 3> sizes{{ + {VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 16}, + {VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 8}, + {VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 16}, + }}; + const VkDescriptorPoolCreateInfo pool_info{ + .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO, + .maxSets = 8, + .poolSizeCount = uint32_t(sizes.size()), + .pPoolSizes = sizes.data(), + }; + CHECK(vkCreateDescriptorPool(vk.device, &pool_info, nullptr, &pool)); + constexpr auto Sampled = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE; + constexpr auto Storage = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; + constexpr auto Ssbo = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + // The same layouts as HdrNative::CreatePipelines. + const Pass luma = MakePass(vk, pool, {Sampled, Storage}, 0, HDR_NATIVE_LUMA_COMP, + sizeof(HDR_NATIVE_LUMA_COMP)); + const Pass stats = MakePass(vk, pool, {Sampled, Sampled, Ssbo, Ssbo}, 4, + HDR_NATIVE_STATS_COMP, sizeof(HDR_NATIVE_STATS_COMP)); + const Pass snapshot = MakePass(vk, pool, {Sampled, Sampled, Ssbo, Ssbo, Storage}, 4, + HDR_NATIVE_SNAPSHOT_COMP, sizeof(HDR_NATIVE_SNAPSHOT_COMP)); + const Pass exposure = MakePass(vk, pool, {Ssbo, Ssbo}, 16, HDR_NATIVE_EXPOSURE_COMP, + sizeof(HDR_NATIVE_EXPOSURE_COMP)); + const Pass gain = MakePass(vk, pool, {Sampled, Sampled, Sampled, Ssbo, Storage}, 16, + HDR_NATIVE_GAIN_COMP, sizeof(HDR_NATIVE_GAIN_COMP)); + if (argc > 1 && std::strcmp(argv[1], "--bench") == 0) { + const uint32_t width = argc > 3 ? uint32_t(std::atoi(argv[2])) : 1920; + const uint32_t height = argc > 3 ? uint32_t(std::atoi(argv[3])) : 1080; + Bench(vk, luma, snapshot, exposure, gain, width, height); + vkDeviceWaitIdle(vk.device); + return 0; + } + + const Image scene = MakeImage(vk, VK_FORMAT_R16G16B16A16_SFLOAT, W, H, 8); + const Image luma_image = MakeImage(vk, VK_FORMAT_R16G16_SFLOAT, LW, LH, 4); + const Image target = MakeImage(vk, VK_FORMAT_R8G8B8A8_UNORM, W, H, 4); // the UI target + const Image copy = MakeImage(vk, VK_FORMAT_R8G8B8A8_UNORM, W, H, 4); + const Image gain_map = MakeImage(vk, VK_FORMAT_R16_SFLOAT, W, H, 2); + const uint32_t groups_x = (W + 15) / 16, groups_y = (H + 15) / 16; + const Buffer partials = MakeBuffer(vk, groups_x * groups_y * 16, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + const Buffer state = MakeBuffer(vk, sizeof(ExposureState), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + const Buffer staging = MakeBuffer(vk, W * H * 8, VK_BUFFER_USAGE_TRANSFER_SRC_BIT | + VK_BUFFER_USAGE_TRANSFER_DST_BIT); + std::memset(state.mapped, 0, sizeof(ExposureState)); + Begin(vk); + std::vector<VkImageMemoryBarrier> to_general; + for (const Image* image : {&scene, &luma_image, &target, ©, &gain_map}) { + to_general.push_back({.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, + .dstAccessMask = VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT, + .oldLayout = VK_IMAGE_LAYOUT_UNDEFINED, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, + .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .image = image->image, + .subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}}); + } + vkCmdPipelineBarrier(vk.cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0, nullptr, + uint32_t(to_general.size()), to_general.data()); + Submit(vk); + + // The scene (linear HDR, the game's units) and what the game makes of it. + enum Kind { Mid, Light, Bloom, Glow, Dark }; + std::vector<Kind> kinds(W * H); + std::vector<float> scene_rgb(W * H * 3); + std::vector<uint16_t> scene_half(W * H * 4); + std::vector<uint8_t> before_ui(W * H * 4); + for (uint32_t y = 0; y < H; ++y) { + for (uint32_t x = 0; x < W; ++x) { + const uint32_t p = y * W + x; + // Midtones on the SDR scale from 0.03 to 0.4 (a gradient), a warm light at 8x SDR + // white (an 8x8 block), around it bloom (added after the scene color: a dark scene + // under a brighter SDR frame): bright next to the light, in the midtones further out. + Kind kind = Mid; + float level = (0.03f + 0.37f * float(x) / float(W - 1)) / Exposure; + std::array<float, 3> tint{1.0f, 1.0f, 1.0f}; + if (x >= 40 && x < 48 && y >= 8 && y < 16) { + kind = Light; + level = 8.0f / Exposure; + tint = {1.0f, 0.8f, 0.5f}; + } else if (x >= 38 && x < 50 && y >= 6 && y < 18) { + kind = Bloom; + level = 0.02f / Exposure; + } else if (x >= 36 && x < 52 && y >= 4 && y < 20) { + kind = Glow; + level = 0.02f / Exposure; + } else if (y >= 28) { + kind = Dark; + level = 0.001f; + } + kinds[p] = kind; + for (int c = 0; c < 3; ++c) { + scene_rgb[p * 3 + c] = level * tint[c]; + scene_half[p * 4 + c] = ToHalf(level * tint[c]); + const float sdr = kind == Bloom ? 0.8f + : kind == Glow ? 0.3f + : Curve(Exposure * level * tint[c]); + before_ui[p * 4 + c] = Encode(sdr); + } + scene_half[p * 4 + 3] = ToHalf(1.0f); + before_ui[p * 4 + 3] = 255; + } + } + // Texels the game's effects can leave: NaN (must not spread) and inf (very bright). + std::vector<uint16_t> poisoned = scene_half; + const uint32_t nan_pixel = 2 * W + 2, inf_pixel = 2 * W + 6; + poisoned[nan_pixel * 4] = ToHalf(std::numeric_limits<float>::quiet_NaN()); + poisoned[inf_pixel * 4 + 1] = ToHalf(std::numeric_limits<float>::infinity()); + Upload(vk, staging, scene, poisoned.data()); + + // LUMA: the brightest channel, 2x2 max (r) and that of the 2x2 average (g), half size. + const auto reduce = [&] { + Bind(vk, luma, {{&scene}, {&luma_image}}); + Begin(vk); + Dispatch(vk.cmd, luma, nullptr, 0, (LW + 7) / 8, (LH + 7) / 8); + Submit(vk); + }; + reduce(); + const auto luma_out = Download<uint16_t>(vk, staging, luma_image); + for (uint32_t y = 0; y < LH; ++y) { + for (uint32_t x = 0; x < LW; ++x) { + float peak = 0.0f; + std::array<float, 3> sum{}; + for (uint32_t dy = 0; dy < 2; ++dy) { + for (uint32_t dx = 0; dx < 2; ++dx) { + const uint32_t p = (y * 2 + dy) * W + x * 2 + dx; + for (int c = 0; c < 3; ++c) { + float v = FromHalf(poisoned[p * 4 + c]); + v = std::isnan(v) ? 0.0f : std::min(v, 65504.0f); + peak = std::max(peak, v); + sum[c] += v; + } + } + } + const float average = std::max({sum[0], sum[1], sum[2]}) * 0.25f; + const float r = FromHalf(luma_out[(y * LW + x) * 2]); + const float g = FromHalf(luma_out[(y * LW + x) * 2 + 1]); + assert(std::isfinite(r) && std::isfinite(g)); + assert(std::abs(r - peak) <= peak * 2e-3f + 1e-6f); + assert(std::abs(g - average) <= average * 2e-3f + 1e-6f); + } + } + assert(FromHalf(luma_out[(1 * LW + 3) * 2]) == 65504.0f); // the inf texel's block + std::printf("HDR native shaders: luma (2x2 max and average, NaN, inf) PASS\n"); + // The rest on the scene without them (an inf block would be an outlier of its own). + Upload(vk, staging, scene, scene_half.data()); + reduce(); + + // SNAPSHOT + EXPOSURE at the first UI pass: the copy is exact, the exposure the game's. + const auto measure = [&](const Image& frame, bool copy_frame, uint32_t reset) { + const Pass& pass = copy_frame ? snapshot : stats; + std::vector<Resource> resources{{&frame}, {&luma_image}, {nullptr, &partials}, + {nullptr, &state}}; + if (copy_frame) { + resources.push_back({©}); + } + Bind(vk, pass, resources); + Bind(vk, exposure, {{nullptr, &partials}, {nullptr, &state}}); + const struct { + uint32_t count, reset; + float min_samples, rate; + } params{groups_x * groups_y, reset, 64.0f, 0.1f}; + Begin(vk); + Dispatch(vk.cmd, pass, &reset, 4, groups_x, groups_y); + Dispatch(vk.cmd, exposure, ¶ms, sizeof(params), 1, 1); + Submit(vk); + ExposureState result; + std::memcpy(&result, state.mapped, sizeof(result)); + return result; + }; + // Without bloom first (the scene's own SDR values there). + std::vector<uint8_t> no_bloom = before_ui; + for (uint32_t p = 0; p < W * H; ++p) { + if (kinds[p] == Bloom || kinds[p] == Glow) { + for (int c = 0; c < 3; ++c) { + no_bloom[p * 4 + c] = Encode(Curve(Exposure * scene_rgb[p * 3 + c])); + } + } + } + Upload(vk, staging, target, no_bloom.data()); + ExposureState measured = measure(target, true, 1); + std::printf("HDR native shaders: exposure %.4f stops (expected %.4f), %.0f samples\n", + measured.log_scale, std::log2(Exposure), measured.samples); + assert(measured.valid == 1.0f && measured.samples > 500.0f); + assert(std::abs(measured.log_scale - std::log2(Exposure)) < 0.03f); + const auto copied = Download<uint8_t>(vk, staging, copy); + assert(copied == no_bloom); // bit for bit + std::printf("HDR native shaders: snapshot copy and exposure PASS\n"); + + // Bloom: SDR midtones lit up around the light where the scene is dark (the glow ring, ~4 + // stops above the exposure). The first frame (no previous exposure) is pulled up; frames + // after weigh the outliers down. + std::memset(state.mapped, 0, sizeof(ExposureState)); + Upload(vk, staging, target, before_ui.data()); + measured = measure(target, false, 1); + const float first_error = std::abs(measured.log_scale - std::log2(Exposure)); + for (int frame = 0; frame < 60; ++frame) { + measured = measure(target, false, 0); + } + const float settled_error = std::abs(measured.log_scale - std::log2(Exposure)); + std::printf("HDR native shaders: with bloom %.3f stops off at first, %.3f after 60 frames\n", + first_error, settled_error); + assert(first_error > 0.1f && settled_error < 0.5f * first_error && settled_error < 0.15f); + // A frame with nothing to measure keeps the exposure; after a reset it has none. + std::vector<uint8_t> black(W * H * 4, 0); + Upload(vk, staging, target, black.data()); + const ExposureState kept = measure(target, false, 0); + assert(kept.valid == 1.0f && kept.log_scale == measured.log_scale && kept.samples == 0.0f); + const ExposureState dropped = measure(target, false, 1); + assert(dropped.valid == 0.0f); + std::printf("HDR native shaders: bloom-resistant exposure, empty frames PASS\n"); + + // GAIN at the display pass: the frame as the first UI pass saw it, then UI drawn over it. + Upload(vk, staging, target, no_bloom.data()); + measured = measure(target, true, 1); + std::vector<uint8_t> finished = no_bloom; + const auto ui = [](uint32_t x, uint32_t y) { return x >= 42 && x < 60 && y >= 22 && y < 26; }; + for (uint32_t y = 0; y < H; ++y) { + for (uint32_t x = 0; x < W; ++x) { + if (ui(x, y)) { + const uint32_t p = y * W + x; + finished[p * 4] = 250; + finished[p * 4 + 1] = 250; + finished[p * 4 + 2] = uint8_t(finished[p * 4 + 2] ^ 0x5a); + } + } + } + // Also UI over the light: HUD over a lamp stays at paper white. + for (uint32_t x = 40; x < 44; ++x) { + finished[(12 * W + x) * 4] ^= 0x08; + } + Upload(vk, staging, target, finished.data()); + const float white = 1000.0f / 200.0f; // the default peak / paper white + for (const float strength : {1.0f, 0.5f}) { + const struct { + float strength, white, knee, unused; + } params{strength, white, Knee, 0.0f}; + Bind(vk, gain, {{&target}, {©}, {&luma_image}, {nullptr, &state}, {&gain_map}}); + Begin(vk); + Dispatch(vk.cmd, gain, ¶ms, sizeof(params), (W + 7) / 8, (H + 7) / 8); + Submit(vk); + const auto map = Download<uint16_t>(vk, staging, gain_map); + const float scale = std::exp2(measured.log_scale); + float light_gain = 0.0f; + for (uint32_t y = 0; y < H; ++y) { + for (uint32_t x = 0; x < W; ++x) { + const uint32_t p = y * W + x; + const float g = FromHalf(map[p]); + const bool on_ui = ui(x, y) || (y == 12 && x >= 40 && x < 44); + float sdr = 0.0f; + for (int c = 0; c < 3; ++c) { + sdr = std::max(sdr, std::pow(no_bloom[p * 4 + c] / 255.0f, 2.2f)); + } + if (on_ui) { + assert(g == -1.0f); // UI: gain 1, marked for the debug view + } else if (sdr <= Knee) { + assert(g == 1.0f); // midtones: the SDR picture exactly + } else { + assert(g >= 1.0f && sdr * g <= white * 1.001f); + } + if (kinds[p] == Light && !on_ui && x > 41 && x < 46 && y > 9 && y < 14) { + // Inside the light (its 4x4 footprint in the luma is all light): the + // scene's ratio to the SDR white, to the power of the strength, into the + // peak (unchanged up to halfway, then towards the peak). + const float ratio = scale * 8.0f / Exposure / sdr; + float target_value = sdr * std::pow(ratio, strength); + const float start = 0.5f * (1.0f + white); + if (target_value > start) { + target_value = start + (white - start) * + (1.0f - std::exp(-(target_value - start) / + (white - start))); + } + const float expected = target_value / sdr; + if (std::abs(g - expected) > expected * 0.01f) { + std::fprintf(stderr, "gain %.4f, expected %.4f at %u,%u\n", g, expected, + x, y); + } + assert(std::abs(g - expected) <= expected * 0.01f); + light_gain = g; + } + } + } + std::printf("HDR native shaders: gain map (strength %.2f: light x%.3f) PASS\n", strength, + light_gain); + } + // No scene brighter than the SDR frame (bloom ring): no gain. + { + Upload(vk, staging, target, before_ui.data()); + measured = measure(target, true, 1); + const struct { + float strength, white, knee, unused; + } params{1.0f, white, Knee, 0.0f}; + Bind(vk, gain, {{&target}, {©}, {&luma_image}, {nullptr, &state}, {&gain_map}}); + Begin(vk); + Dispatch(vk.cmd, gain, ¶ms, sizeof(params), (W + 7) / 8, (H + 7) / 8); + Submit(vk); + const auto map = Download<uint16_t>(vk, staging, gain_map); + uint32_t checked = 0; + for (uint32_t y = 0; y < H; ++y) { + for (uint32_t x = 0; x < W; ++x) { + // Bright bloom (above the knee) outside the light's luma footprint (the 2x2 + // luma texels around reach 2 scene pixels further). + if (kinds[y * W + x] == Bloom && (x <= 38 || x >= 49 || y <= 6 || y >= 17)) { + assert(FromHalf(map[y * W + x]) == 1.0f); + ++checked; + } + } + } + assert(checked >= 40); + std::printf("HDR native shaders: bloom (SDR brighter than the scene) keeps gain 1 PASS\n"); + } + vkDeviceWaitIdle(vk.device); + std::printf("HDR native shaders: PASS\n"); + return 0; +} diff --git a/tests/test_mods.py b/tests/test_mods.py index 795f2f7..d8d75c0 100644 --- a/tests/test_mods.py +++ b/tests/test_mods.py @@ -114,14 +114,16 @@ class ModTests(unittest.TestCase): def test_wrapped_and_bare_layouts(self): layouts = {'Title': 'CUSA03173/dvdroot_ps4/chr', 'Archive': 'Archive v1.2/dvdroot_ps4/chr', - 'Bare': 'chr', 'Nested': 'Nested/app0/dvdroot_ps4/chr'} + 'Bare': 'chr', 'Nested': 'Nested/app0/dvdroot_ps4/chr', + 'Us': 'CUSA03179/dvdroot_ps4/chr'} for name, path in layouts.items(): folder = self.moddir / name / path folder.mkdir(parents=True) (folder / 'a.dcx').write_bytes(name.encode()) (self.moddir / 'Archive/readme.txt').write_text('notes') + (self.moddir / 'Us/screenshots').mkdir() # beside the serial folder: not one wrapper (self.moddir / 'Junk/stuff').mkdir(parents=True) - self.assertEqual(mods.discover(self.moddir), ['Archive', 'Bare', 'Nested', 'Title']) + self.assertEqual(mods.discover(self.moddir), ['Archive', 'Bare', 'Nested', 'Title', 'Us']) for name in layouts: with self.subTest(name=name): result = mods.build_overlay(self.game, self.root / 'out', diff --git a/tests/test_occlusion.cpp b/tests/test_occlusion.cpp new file mode 100644 index 0000000..89dd2e5 --- /dev/null +++ b/tests/test_occlusion.cpp @@ -0,0 +1,136 @@ +// The depth-block counter model behind real occlusion results (occlusion_model.h). +#include <cassert> +#include <cstdint> +#include <cstdio> + +#include "gpu/shadps4/video_core/renderer_vulkan/occlusion_model.h" + +using namespace OcclusionModel; + +namespace { +// The words two dumps write into a block: the begin dump at +0, the end dump at +8. +void Dump(std::uint64_t* block, std::uint32_t pipes, std::uint64_t total, bool end) { + for (std::uint32_t k = 0; k < pipes; ++k) { + block[2 * k + (end ? 1 : 0)] = Share(total, pipes, k) | ValidBit; + } +} + +std::uint64_t SumOfDifferences(const std::uint64_t* block, std::uint32_t pipes) { + std::uint64_t sum = 0; + for (std::uint32_t k = 0; k < pipes; ++k) { + sum += (block[2 * k + 1] & ~ValidBit) - (block[2 * k] & ~ValidBit); + } + return sum; +} +} // namespace + +int main() { + // The shares add up to the total, and every DB's share grows with it: the game's sum of + // end - begin is the samples drawn in between, whatever the split. + for (std::uint32_t pipes : {8u, 16u}) { + for (std::uint64_t total = 0; total < 200; ++total) { + std::uint64_t sum = 0; + for (std::uint32_t k = 0; k < pipes; ++k) { + sum += Share(total, pipes, k); + assert(Share(total + 1, pipes, k) >= Share(total, pipes, k)); + } + assert(sum == total); + } + for (std::uint64_t begin : {0ull, 7ull, 123456789ull}) { + for (std::uint64_t samples : {0ull, 1ull, 5ull, 4000ull}) { + std::uint64_t block[2 * MaxPipes] = {}; + assert(ReadBlock(block, pipes) == Block::NotReady); // zeroed by the game + Dump(block, pipes, begin, false); + assert(ReadBlock(block, pipes) == Block::NotReady); // only the begin landed + Dump(block, pipes, begin + samples, true); + assert(SumOfDifferences(block, pipes) == samples); + assert(ReadBlock(block, pipes) == (samples ? Block::Visible : Block::Occluded)); + } + } + } + + // Anything not measured reads visible, never occluded. + assert(Samples(0, false, true, 1u << 16) == Unmeasured); + assert(Samples(0, false, false, 1u << 16) == Unmeasured); + assert(Samples(1, true, false, 1u << 16) == Unmeasured); // imprecise 0/1: plainly visible + assert(Samples(0, true, false, 1u << 16) == 0); + assert(Samples(0, true, true, 4u << 16) == 0); // occluded stays occluded + assert(Samples(10, true, true, 1u << 16) == 10); + assert(Samples(1, true, true, (1u << 16) + 1) == 2); // rounds up, never down + + // Reduced scene targets count fewer samples: scaled back to 1920x1080, never below 1. + assert(SampleScale(1920, 1080) == 1u << 16); + assert(SampleScale(3840, 2160) == 1u << 16); + assert(SampleScale(960, 540) == 4u << 16); + assert(SampleScale(0, 0) == 1u << 16); + assert(Samples(250, true, true, SampleScale(960, 540)) == 1000); + + // SET_PREDICATION: unknown draws; otherwise draws when visibility matches the request. + assert(PredicatedDraws(false, true, true)); + assert(PredicatedDraws(false, true, false)); + assert(PredicatedDraws(true, false, true)); + assert(!PredicatedDraws(false, false, true)); + assert(PredicatedDraws(false, false, false)); + assert(!PredicatedDraws(true, false, false)); + + // Hysteresis: the largest of the last three results; occluded only after three results in + // a row found nothing, visible at once; an unseen past reads visible with the least count. + { + BlockHistory fresh; + assert(fresh.Report(0) == BlockHistory::UnknownPast); + assert(fresh.Report(0) == BlockHistory::UnknownPast); + assert(fresh.Report(0) == 0); + assert(fresh.Report(0) == 0); + assert(fresh.Report(9) == 9); // visible at once + assert(fresh.Report(0) == 9); + assert(fresh.Report(0) == 9); + assert(fresh.Report(0) == 0); + } + { + // A lamp flickering 0/N/0 under jitter reads steadily visible. + BlockHistory lamp; + const std::uint64_t measured[] = {40, 0, 35, 0, 0, 41, 0, 38}; + for (const std::uint64_t m : measured) { + assert(lamp.Report(m) >= 35); + } + // The largest of the last three: a drop shows after two more results. + BlockHistory fading; + fading.Report(100); + fading.Report(100); + fading.Report(100); + assert(fading.Report(10) == 100); + assert(fading.Report(10) == 100); + assert(fading.Report(10) == 10); + // Unmeasured (fail-safe visible) counts too. + BlockHistory unknown; + unknown.Report(0); + unknown.Report(0); + assert(unknown.Report(Unmeasured) == Unmeasured); + assert(unknown.Report(0) == Unmeasured); + } + { + // As the resolver writes it: the held count is added to the running total between the + // begin and the end dump, so the game reads the held result from the block. + for (std::uint32_t pipes : {8u, 16u}) { + BlockHistory history; + std::uint64_t total = 1000; + const std::uint64_t measured[] = {12, 0, 0, 0, 5}; + const Block expected[] = {Block::Visible, Block::Visible, Block::Visible, + Block::Occluded, Block::Visible}; + for (std::size_t i = 0; i < sizeof(measured) / sizeof(measured[0]); ++i) { + std::uint64_t block[2 * MaxPipes] = {}; + Dump(block, pipes, total, false); + const std::uint64_t begin = total; + total += measured[i]; + const std::uint64_t reported = history.Report(total - begin); + total += reported - (total - begin); + Dump(block, pipes, total, true); + assert(SumOfDifferences(block, pipes) == reported); + assert(ReadBlock(block, pipes) == expected[i]); + } + } + } + + std::printf("occlusion model: ok\n"); + return 0; +} diff --git a/tests/test_online.py b/tests/test_online.py index bf0d998..f34e29c 100644 --- a/tests/test_online.py +++ b/tests/test_online.py @@ -6,7 +6,12 @@ import tempfile import unittest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts')) -from online import configure_online, http_base, ONLINE_ENV, GAME_STATUS_ORIGIN +from online import (configure_online, game_server, http_base, ONLINE_ENV, GAME_STATUS_ORIGIN, HUNTERS_DREAM, + PLAY_LOG_HOSTS, DEFAULT_P2P_PORT, DEFAULT_SIGN_TIMEOUT) + + +def play_logs(host): + return {h: f'http://{host}:18671' for h in PLAY_LOG_HOSTS} class OnlineProfileTests(unittest.TestCase): @@ -28,30 +33,50 @@ class OnlineProfileTests(unittest.TestCase): self.assertEqual(env, {'BB_ONLINE':'0','UNRELATED':'keep'}) self.assertFalse((self.data/'network').exists()) - def test_custom_ports_preserved_and_only_game_origin_redirected(self): + def test_custom_ports_preserved_and_only_game_origin_and_play_logs_redirected(self): self.settings['online_game_api'] = 'https://game.example:31443' self.settings['online_p2p_port'] = '32000' path = configure_online(self.settings, self.data, self.env) - self.assertEqual(json.loads(path.read_text()), {GAME_STATUS_ORIGIN:'https://game.example:31443'}) + self.assertEqual(json.loads(path.read_text()), + {GAME_STATUS_ORIGIN:'https://game.example:31443', **play_logs('game.example')}) self.assertEqual(self.env['BB_SHADNET_SERVER'], 'server.example:31413') self.assertEqual(self.env['BB_SHADNET_WEBAPI'], 'http://server.example:31415') self.assertEqual(self.env['SHADPS4_P2P_PORT'], '32000') + self.assertEqual(self.env['SHADPS4_P2P_PORT_DEFAULT'], '0') self.assertEqual(self.env['SHADPS4_HTTP_HOST_OVERRIDES_JSON'], str(path.resolve())) self.assertEqual(self.env['UNRELATED'], 'keep') self.assertNotIn('synthetic-test-password', path.read_text()) self.assertFalse(path.with_suffix('.json.tmp').exists()) - def test_same_api_fallback_and_automatic_port(self): + def test_game_server_defaults_and_pinned_port(self): self.env['SHADPS4_P2P_PORT'] = 'inherited' path = configure_online(self.settings, self.data, self.env) - self.assertEqual(json.loads(path.read_text()), {GAME_STATUS_ORIGIN:self.settings['online_webapi']}) - self.assertNotIn('SHADPS4_P2P_PORT', self.env) + self.assertEqual(json.loads(path.read_text()), {GAME_STATUS_ORIGIN:HUNTERS_DREAM, **play_logs('thehuntersdream.com')}) + self.assertEqual(self.env['SHADPS4_P2P_PORT'], str(DEFAULT_P2P_PORT)) + self.assertEqual(self.env['SHADPS4_P2P_PORT_DEFAULT'], '1') + self.assertEqual(self.env['BB_SIGN_TIMEOUT'], str(DEFAULT_SIGN_TIMEOUT)) + + def test_sign_timeout_setting(self): + for typed, expected in (('0', '0'), ('180', '180'), ('9999', '600'), ('abc', '30'), ('', '30')): + self.settings['online_sign_timeout'] = typed + configure_online(self.settings, self.data, self.env) + self.assertEqual(self.env['BB_SIGN_TIMEOUT'], expected) + + def test_game_server_bare_name_and_old_shadnet_default(self): + self.assertEqual(game_server('thehuntersdream.com'), 'https://thehuntersdream.com') + self.assertEqual(game_server('http://srv.shadps4.net:31315'), HUNTERS_DREAM) + self.assertEqual(game_server('http://game.example:8080'), 'http://game.example:8080') + + def test_empty_webapi_uses_server_port_31315(self): + self.settings['online_webapi'] = '' + configure_online(self.settings, self.data, self.env) + self.assertEqual(self.env['BB_SHADNET_WEBAPI'], 'http://server.example:31315') def test_invalid_profile_fails_before_writing_cache(self): for key, values in { 'online_host':['', 'http://server.example', 'foo bar', '::1'], 'online_port':['0','65536','not-a-port'], - 'online_webapi':['','ftp://server.example','http://user:pass@server.example','http://server.example:0','http://server.example/api'], + 'online_webapi':['ftp://server.example','http://user:pass@server.example','http://server.example:0','http://server.example/api'], 'online_username':['','has space','x'*17,'é'*9], 'online_p2p_port':['0','65536','bad']}.items(): for value in values: @@ -64,6 +89,28 @@ class OnlineProfileTests(unittest.TestCase): with self.assertRaises(ValueError): configure_online(self.settings,self.data,{}) self.assertFalse((self.data/'network').exists()) + def test_hunters_dream_pool_goes_through_the_local_bridge(self): + settings = dict(online_enabled='1', online_connected='1', online_pool='huntersdream') + with self.assertRaises(ValueError): # not signed in with Discord yet + configure_online(settings, self.data, {}) + state = self.data / 'network/bbbridge/bbbridge_state.json' + state.parent.mkdir(parents=True) + state.write_text(json.dumps({'accounts': {'Hunter': {'token': 'x'}, 'Other': {}}}), encoding='utf-8') + env = {'UNRELATED': 'keep'} + path = configure_online(dict(settings, online_thd_account='Other'), self.data, env) + self.assertEqual(env['BB_SHADNET_SERVER'], '127.0.0.1:31313') + self.assertEqual(env['BB_SHADNET_NPID'], 'Other') + self.assertEqual((env['BB_BRIDGE'], env['SHADPS4_P2P_PORT'], env['SHADPS4_P2P_PORT_DEFAULT']), + ('1', str(DEFAULT_P2P_PORT), '0')) + overrides = json.loads(Path(path).read_text(encoding='utf-8')) + self.assertEqual(overrides[GAME_STATUS_ORIGIN], 'http://127.0.0.1:31316') + # An account name no longer signed in: the first one there is. + configure_online(dict(settings, online_thd_account='Gone'), self.data, env) + self.assertEqual(env['BB_SHADNET_NPID'], 'Hunter') + # Back to shadNet: the bridge flag goes. + configure_online(self.settings, self.data, env) + self.assertNotIn('BB_BRIDGE', env) + def test_url_does_not_drop_port(self): self.assertEqual(http_base('https://server.example:34443/api/'),'https://server.example:34443/api') for url in ('http://server.example?x=1','http://server.example#x','http://server.example\n'): diff --git a/tests/test_patches.py b/tests/test_patches.py index b65009f..e60c0cb 100644 --- a/tests/test_patches.py +++ b/tests/test_patches.py @@ -1,4 +1,5 @@ from paths import ROOT +import json import struct import tempfile import unittest @@ -7,7 +8,7 @@ from pathlib import Path import patches -from patches import (EBOOT_BASE, OUTPUT_SIZE, RESOLUTION_TEMPLATE, SCENE_HEIGHT, +from patches import (DEFAULT_OFF, EBOOT_BASE, OUTPUT_SIZE, RESOLUTION_TEMPLATE, SCENE_HEIGHT, SCENE_WIDTH, UI_HEIGHT, UI_WIDTH, compile_patches, render_size, resolution_writes, scaled_sizes, effect_patches, validate_patch_requirements, external_patches, external_selection, @@ -91,7 +92,8 @@ class NativeUiTests(unittest.TestCase): class DebugPatchTests(unittest.TestCase): def test_camera_patch_is_optional_and_compatible_with_fps_and_debug_menu(self): - self.assertEqual(effect_patches({'debug_camera': '0', 'debug_menu': '0'}), []) + on = {key: '1' for key in DEFAULT_OFF} + self.assertEqual(effect_patches({'debug_camera': '0', 'debug_menu': '0', **on}), []) camera = effect_patches({'debug_camera': '1'}) writes = compile_patches(XML, camera, '01.09', SEGMENTS) self.assertGreater(len(writes), 0) @@ -102,6 +104,11 @@ class DebugPatchTests(unittest.TestCase): if offset+i in camera_bytes: self.assertEqual(camera_bytes[offset+i], byte, patch) + def test_missing_effect_keys_follow_the_engine_defaults(self): + self.assertEqual(effect_patches({}), ['Disable Chromatic Aberration', 'Disable DoF', + 'Disable Motion Blur (perf increase)', 'Disable AA']) + self.assertNotIn('Disable DoF', effect_patches({'effect_dof': '1'})) + def test_debug_menu_checks_both_fonts_but_camera_does_not_need_them(self): names = effect_patches({'debug_menu': '1'}) with tempfile.TemporaryDirectory() as directory: @@ -163,6 +170,13 @@ class ExternalPatchTests(unittest.TestCase): def test_built_in_file_is_not_external(self): self.assertEqual(external_patches(XML.parent), []) + def test_1_09_is_01_09(self): + self.assertTrue(patches.same_app_version('1.09', '01.09')) + self.assertTrue(patches.same_app_version(' 01.09 ', '01.09')) + self.assertFalse(patches.same_app_version('1.00', '01.09')) + self.assertFalse(patches.same_app_version(None, '01.09')) + self.assertFalse(patches.same_app_version('v1.09', '01.09')) + class IntelTonemapTests(unittest.TestCase): def cpuinfo(self, vendor): @@ -185,5 +199,153 @@ class IntelTonemapTests(unittest.TestCase): self.assertIn(patches.INTEL_TONEMAP, names) +def fake_elf(code): + """A one-segment ELF: `code` as file data, then as much zero memory (bss).""" + phoff = 64 + elf = bytearray(phoff + 56) + bytes(code) + struct.pack_into('<Q', elf, 0x20, phoff) + struct.pack_into('<HH', elf, 0x36, 56, 1) + struct.pack_into('<IIQQQQQQ', elf, phoff, 1, 5, phoff + 56, 0, 0, len(code), 2 * len(code), 0x4000) + return bytes(elf) + + +class SiteCheckTests(unittest.TestCase): + XML = """<Patch> + <Metadata Name="Fast A" AppVer="01.09"><PatchList><Line Type="bytes" Address="0x400010" Value="9090"/></PatchList></Metadata> + <Metadata Name="Fast B" AppVer="01.09"><PatchList><Line Type="bytes" Address="0x400090" Value="01"/></PatchList></Metadata> + <Metadata Name="Other" AppVer="01.09"><PatchList><Line Type="bytes" Address="0x400020" Value="eb"/></PatchList></Metadata> +</Patch>""" + + def test_a_differing_patch_and_its_group_are_left_out(self): + code = bytes(range(128)) + elf = fake_elf(code) + segments = patches.eboot_segments(elf) + with tempfile.TemporaryDirectory() as directory: + xml = Path(directory) / 'Bloodborne.xml' + xml.write_text(self.XML) + table = {name: patches.site_crcs(elf, patches.compile_patches(xml, [name], '01.09', segments)) + for name in ('Fast A', 'Fast B', 'Other')} + patches.site_table_path(xml).write_text(json.dumps(table)) + names = ['Fast A', 'Fast B', 'Other'] + self.assertEqual(patches.checked_patches(xml, names, '01.09', segments, elf, ('Fast A', 'Fast B')), names) + changed = bytearray(elf) + changed[64 + 56 + 0x11] ^= 0xff # one byte under "Fast A" + self.assertEqual(patches.checked_patches(xml, names, '01.09', segments, bytes(changed), ('Fast A', 'Fast B')), + ['Other']) + self.assertEqual(patches.checked_patches(xml, names, '01.09', segments, bytes(changed)), ['Fast B', 'Other']) + + def test_bss_sites_read_as_zero_and_no_table_checks_nothing(self): + elf = fake_elf(bytes([0x11]) * 16) + self.assertEqual(patches.original_bytes(elf, 14, 4), bytes([0x11, 0x11, 0, 0])) + with tempfile.TemporaryDirectory() as directory: + xml = Path(directory) / 'Bloodborne.xml' + self.assertEqual(patches.checked_patches(xml, ['Other'], '01.09', [], elf), ['Other']) + + def test_shipped_table_covers_the_fps_presets_and_effects(self): + xml = ROOT / 'patches/Bloodborne.xml' + table = json.loads(patches.site_table_path(xml).read_text(encoding='utf-8')) + names = {name for preset in patches.FPS_PRESETS.values() for name in preset} + names |= {name for off, on in patches.EFFECTS.values() for name in (off, on) if name} + for name in sorted(names): + writes = patches.compile_patches(xml, [name], '01.09', [(0, 1 << 40)]) + self.assertEqual(len(table[name]), len(writes), name) + + def test_the_verified_executable_matches_the_table(self): + elf_path = ROOT / 'out/eboot.elf' + if not elf_path.is_file(): + self.skipTest('no prepared eboot.elf') + elf = elf_path.read_bytes() + from engine_assets import EBOOT_SHA, sha + if sha(elf) not in EBOOT_SHA: + self.skipTest('out/eboot.elf is not the verified 1.09 executable') + xml = ROOT / 'patches/Bloodborne.xml' + names = list(json.loads(patches.site_table_path(xml).read_text(encoding='utf-8'))) + segments = patches.eboot_segments(elf) + self.assertEqual(patches.checked_patches(xml, names, '01.09', segments, elf), names) + + +class ScaledRateTests(unittest.TestCase): + WIDE = [(0, 1 << 40)] + + def test_the_formulas_rebuild_90_fps(self): + # At 90 every frame-rate value is the patch's own, but for the speed scale's load, which + # reads the patch's 60/fps vector instead of the game's own 2/3 constant. + writes = patches.compile_patches(XML, [patches.SCALED_BASE], '01.09', self.WIDE) + scaled = patches.rate_scaled(writes, 90) + differ = [o + EBOOT_BASE for (o, a), (_, b) in zip(writes, scaled) if a != b] + self.assertEqual(differ, [0x0B6FBD4]) + values = patches.scaled_rate_values(90) + load = 0x76fbd8 + struct.unpack('<i', values[0x0B6FBD4])[0] + self.assertEqual(load, 0x4D3BD00 - EBOOT_BASE) + self.assertEqual(values[0x4D3BD00][:4], struct.pack('<f', 60 / 90)) + + def test_120_and_144_values(self): + for fps in (120, 144): + with self.subTest(fps=fps): + values = patches.scaled_rate_values(fps) + self.assertEqual(values[0x2434840][5:], struct.pack('<f', 1 / fps)) + self.assertEqual(values[0x4D3BD00], struct.pack('<4f', *[60 / fps] * 4)) + self.assertEqual(values[0x4D3BD10], struct.pack('<4f', *[fps / 60] * 4)) + self.assertEqual(struct.unpack('<i', values[0x1929676])[0], fps // 6) + self.assertEqual(struct.unpack('<I', values[0x25629F0][1:])[0], 990000 // fps) + # The character's frame time keeps 90's pace (0.0125 there). + self.assertEqual(values[0x1CBDB42][6:10], struct.pack('<f', 0.0125 * 90 / fps)) + writes = patches.compile_patches(XML, [patches.SCALED_BASE], '01.09', self.WIDE) + self.assertEqual(len(patches.rate_scaled(writes, fps)), len(writes)) + + +class RenderPoolTests(unittest.TestCase): + def test_the_pools_grow_only_over_the_standard_code(self): + code = bytearray(patches.RENDER_POOL_SITES[-1] + 16) + for site in patches.RENDER_POOL_SITES: + code[site:site + 5] = patches.RENDER_POOL_CODE + self.assertEqual(patches.render_pool_writes(fake_elf(bytes(code))), + [(site + 1, struct.pack('<I', 0x100000)) for site in patches.RENDER_POOL_SITES]) + code[patches.RENDER_POOL_SITES[1] + 3] = 0x08 + self.assertEqual(patches.render_pool_writes(fake_elf(bytes(code))), []) + + def test_the_verified_executable_has_the_pool_code(self): + elf_path = ROOT / 'out/eboot.elf' + if not elf_path.is_file(): + self.skipTest('no prepared eboot.elf') + elf = elf_path.read_bytes() + from engine_assets import EBOOT_SHA, sha + if sha(elf) not in EBOOT_SHA: + self.skipTest('out/eboot.elf is not the verified 1.09 executable') + self.assertEqual(len(patches.render_pool_writes(elf)), 2) + + +class HeapPoolTests(unittest.TestCase): + """An external resolution patch above 1080p enlarges the game's heaps with a 1080p output: + patches-info.json tells run_windows.py to give the game the larger memory pool.""" + + def run_patches(self, directory, external): + import shutil, subprocess, sys + root = Path(directory) + (root / 'xml').mkdir() + shutil.copy(ROOT / 'patches/Bloodborne.xml', root / 'xml/Bloodborne.xml') + (root / 'out').mkdir() + elf = bytearray(fake_elf(bytes(0x100))) + struct.pack_into('<Q', elf, 64 + 40, 0x6000000) # memsz: the whole image, as zeros + (root / 'out/eboot.elf').write_bytes(bytes(elf)) + (root / 'ext').mkdir() + if external: + shutil.copy(ROOT / 'patches/Bloodborne.xml', root / 'ext/community.xml') + (root / 'patches.json').write_text(json.dumps( + {'enabled': ['community.xml/Resolution Patch 2560x1080 (21:9)']})) + (root / 'bbport.ini').write_text('') + subprocess.run([sys.executable, str(ROOT / 'scripts/patches.py'), '--xml', str(root / 'xml/Bloodborne.xml'), + '--out', str(root / 'out'), '--fps', '30', '--settings', str(root / 'bbport.ini'), + '--game-dir', str(root), '--patches-dir', str(root / 'ext'), + '--patches-config', str(root / 'patches.json')], check=True, capture_output=True) + return json.loads((root / 'out/patches-info.json').read_text())['heap_patch'] + + def test_an_external_resolution_patch_asks_for_the_larger_pool(self): + with tempfile.TemporaryDirectory() as directory: + self.assertTrue(self.run_patches(directory, True)) + with tempfile.TemporaryDirectory() as directory: + self.assertFalse(self.run_patches(directory, False)) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_pipeline_compile.cpp b/tests/test_pipeline_compile.cpp new file mode 100644 index 0000000..b14cae2 --- /dev/null +++ b/tests/test_pipeline_compile.cpp @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Pipeline compiles off the frame path (docs/pipeline_compiles.md): the compile job queue a +// waiting thread takes its job from, the bound on leaving draws out, the texture cache's +// lock-free page watch counts, and the draw pipe's sleeping waits (no Vulkan device). +#include <atomic> +#include <cassert> +#include <chrono> +#include <cstdio> +#include <cstdlib> +#include <cstring> +#include <memory> +#include <thread> +#include <vector> + +#include "bbport_platform.h" +#include "video_core/renderer_vulkan/pipeline_compile_queue.h" +#include "video_core/renderer_vulkan/vk_draw_pipe.h" +#include "video_core/texture_cache/watched_pages.h" + +using namespace std::chrono_literals; + +namespace { + +void TestJobQueue() { + Vulkan::PipelineJobQueue queue; + int a = 0, b = 0, c = 0; + std::vector<char> order; + queue.Push(&a, [&] { order.push_back('a'); }); + queue.Push(&b, [&] { order.push_back('b'); }); + queue.Push(&c, [&] { order.push_back('c'); }); + assert(queue.Size() == 3); + // A thread that needs b takes it out of the queue and runs it itself. + auto job = queue.Take(&b); + assert(job); + job(); + assert(!queue.Take(&b)); // gone: a second waiter waits for the first one's result + assert(queue.Size() == 2); + // The compile threads go on first in, first out with the rest. + queue.Pop()(); + queue.Pop()(); + assert((order == std::vector<char>{'b', 'a', 'c'})); + assert(!queue.Take(&a) && queue.Size() == 0); + + // A compile thread and takers at once: every job runs exactly once. + constexpr int Jobs = 2000; + std::vector<int> owners(Jobs); + std::vector<std::atomic<int>> runs(Jobs); + for (int i = 0; i < Jobs; ++i) { + queue.Push(&owners[i], [&, i] { + runs[i].fetch_add(1); + std::this_thread::yield(); + }); + } + std::atomic<bool> stop{false}; + std::thread worker([&] { + while (!stop.load()) { + queue.Pop()(); + } + }); + for (int i = Jobs - 1; i >= 0; i -= 3) { + if (auto j = queue.Take(&owners[i])) { + j(); + } + } + // Last in the queue: the compile thread has run every job left before it. + queue.Push(nullptr, [&] { stop.store(true); }); + worker.join(); + assert(queue.Size() == 0); + for (int i = 0; i < Jobs; ++i) { + assert(runs[i].load() == 1); + } +} + +void TestSkipBudget() { + using Vulkan::WithinSkipBudget; + constexpr std::uint64_t Ms = 1'000'000; + std::uint64_t first = 0; + assert(WithinSkipBudget(first, 5 * Ms, 1000 * Ms) && first == 5 * Ms); + assert(WithinSkipBudget(first, 500 * Ms, 1000 * Ms) && first == 5 * Ms); + assert(WithinSkipBudget(first, 1004 * Ms, 1000 * Ms)); + assert(!WithinSkipBudget(first, 1005 * Ms, 1000 * Ms)); // waits from then on + assert(!WithinSkipBudget(first, 9000 * Ms, 1000 * Ms)); + std::uint64_t never = 0; + assert(!WithinSkipBudget(never, 7 * Ms, 0)); // BB_SKIP_UNCOMPILED=0 + std::uint64_t at_zero = 0; + assert(WithinSkipBudget(at_zero, 0, 10) && at_zero != 0); +} + +void TestWatchedPages() { + auto pages = std::make_unique<VideoCore::WatchedPages>(); + constexpr std::uint64_t P = 4096; + const std::uint64_t base = 0x2'4000'0000ull; // another chunk than below + assert(!pages->Any(base, 8)); + // An image over three pages, not page aligned (TrackImage: the whole image). + const std::uint64_t begin = base + 0x800, end = base + 2 * P + 0x100; + pages->Add(begin, end - begin, 1); + assert(pages->Any(base, 8) && pages->Any(base + P, 8) && pages->Any(base + 2 * P + 0xff0, 8)); + assert(!pages->Any(base + 3 * P, 8) && !pages->Any(base - 8, 8)); + // UntrackImageHead: [begin, next page), then UntrackImageTail: [page of end, end): the + // counts follow the page manager's (each sub-range's own pages). + pages->Add(begin, base + P - begin, -1); + assert(!pages->Any(base + 0x900, 8) && pages->Any(base + P, 8)); + pages->Add(base + 2 * P, end - (base + 2 * P), -1); + assert(!pages->Any(base + 2 * P + 8, 8) && pages->Any(base + P + 8, 8)); + // A second image sharing the middle page keeps it watched after the first one goes. + pages->Add(base + P + 0x200, 0x100, 1); + pages->Add(base + P, P, -1); // UntrackImage of what is left of the first + assert(pages->Any(base + P, 8)); + pages->Add(base + P + 0x200, 0x100, -1); + assert(!pages->Any(base, 3 * P)); + // A range across pages, and a page-aligned tail of size 0 (no page), as the page manager. + pages->Add(base + 5 * P, 0, -1); + assert(!pages->Any(base + 5 * P, 1)); + // Addresses past the table count as watched (the locked path). + assert(pages->Any(std::uint64_t{1} << 41, 8)); + // Untracking more than tracked stays at zero instead of wrapping. + pages->Add(base + 9 * P, P, -1); + assert(!pages->Any(base + 9 * P, 8)); +} + +// The draw pipe's stage A waits: packets that run long (a pipeline compile, an image created +// with memory short) must not keep stage A spinning, and a wake-up must never be lost. +std::atomic<int> long_packets{0}; +std::atomic<std::uint64_t> handled{0}; +void Handler(void*, const u8* packet, u32 size) { + u32 sleep_ms = 0; + std::memcpy(&sleep_ms, packet, sizeof(sleep_ms)); + if (sleep_ms) { + long_packets.fetch_add(1); + std::this_thread::sleep_for(std::chrono::milliseconds(sleep_ms)); + } + assert(size == sizeof(u32)); + handled.fetch_add(1, std::memory_order_relaxed); +} + +void Post(Vulkan::DrawPipe& pipe, u32 sleep_ms) { + u8* out = pipe.Begin(sizeof(u32)); + std::memcpy(out, &sleep_ms, sizeof(sleep_ms)); + pipe.Commit(sizeof(u32)); +} + +std::uint64_t ThreadCpuUs() { + BbPlatform::Usage usage; + return BbPlatform::GetUsage(true, usage) ? usage.user_us + usage.sys_us : 0; +} + +void TestDrawPipeWaits() { + Vulkan::DrawPipe pipe(&Handler, nullptr); + // A drain behind a 300 ms packet: stage A sleeps (stage B wakes it), using little CPU. + Post(pipe, 0); + Post(pipe, 300); + const std::uint64_t cpu_before = ThreadCpuUs(); + const auto start = std::chrono::steady_clock::now(); + pipe.Drain(); + const auto waited = std::chrono::steady_clock::now() - start; + const std::uint64_t cpu_us = ThreadCpuUs() - cpu_before; + assert(pipe.Idle() && handled.load() == 2); + assert(waited >= 250ms); + std::printf("drain behind a 300 ms packet: waited %lld ms, %llu us on the CPU, %llu sleeps\n", + static_cast<long long>( + std::chrono::duration_cast<std::chrono::milliseconds>(waited).count()), + static_cast<unsigned long long>(cpu_us), + static_cast<unsigned long long>(pipe.sleeps)); + if (Vulkan::DrawPipe::SleepEnabled()) { + assert(pipe.sleeps >= 1); + // Spin (4096 pauses) and yield for 0.5 ms, then asleep: far below the 300 ms. + assert(cpu_us < 60'000); + } + + // WaitForPacket for one in the middle of long ones. + const u64 number = pipe.NextPacket() + 1; + Post(pipe, 20); + Post(pipe, 20); + Post(pipe, 200); + pipe.WaitForPacket(number, Vulkan::DrawPipe::ReasonConstRam); + assert(pipe.ReachedPacket(number)); + pipe.Drain(); + + // Many short packets with drains and packet waits in between, a watchdog for a lost + // wake-up (stage A asleep with nothing left for stage B to run: a hang). + std::atomic<bool> finished{false}; + std::thread watchdog([&] { + std::uint64_t last = handled.load(); + for (int quiet = 0; !finished.load(); std::this_thread::sleep_for(100ms)) { + const std::uint64_t now = handled.load(); + quiet = now == last ? quiet + 1 : 0; + last = now; + if (quiet > 100) { + std::fprintf(stderr, "draw pipe wait hung (lost wake-up)\n"); + std::abort(); + } + } + }); + std::uint64_t rng = 0x9e3779b97f4a7c15ull; + for (int i = 0; i < 20000; ++i) { + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + Post(pipe, (rng & 1023) == 0 ? 2 : 0); + switch (rng >> 61) { + case 0: + pipe.Drain(); + assert(pipe.Idle()); + break; + case 1: + pipe.WaitForPacket(pipe.NextPacket() - 1, Vulkan::DrawPipe::ReasonRasterizer); + break; + default: + break; + } + } + pipe.Drain(); + finished.store(true); + watchdog.join(); + assert(pipe.Idle() && handled.load() == pipe.packets); + std::printf("%llu packets, %llu drains that waited, %llu slept, %d long packets\n", + static_cast<unsigned long long>(pipe.packets), + static_cast<unsigned long long>(pipe.drains), + static_cast<unsigned long long>(pipe.sleeps), long_packets.load()); +} + +} // namespace + +int main() { + TestJobQueue(); + TestSkipBudget(); + TestWatchedPages(); + TestDrawPipeWaits(); + std::printf("pipeline compile tests passed\n"); + return 0; +} diff --git a/tests/test_redzone.py b/tests/test_redzone.py new file mode 100644 index 0000000..b7ddf6c --- /dev/null +++ b/tests/test_redzone.py @@ -0,0 +1,136 @@ +"""Red-zone-safe trampolines for guest stores (link_modules.protect_red_zones). + +Where Windows saves extended state in the compact XSAVE layout (Intel 12th gen and later), a +fault's frame holds the AVX registers up to 0x28 bytes below rsp, over the System V red zone, so +a guest leaf function whose store faults on a GPU-tracked page loses its locals (the +attack-trail crash at 0x28ce9b5). The Windows test runs that sequence for real: a vectored +handler unprotects the page as the GPU tracker does. +""" +from paths import ROOT +import ctypes +import json +import os +import struct +import unittest +import xml.etree.ElementTree as ET +from link_modules import LEA_DOWN, LEA_UP, protect_red_zones + +SENTINEL = 0x1122334455667788 +STORE = 30 # offset of the faulting store in leaf() + + +def leaf(page): + """vcmptrueps ymm0 (AVX state in use); mov rax,SENTINEL; mov [rsp-0x40],rax; mov rcx,page; + mov dword [rcx],1; mov rax,[rsp-0x40]; vzeroupper; ret""" + code = (bytes.fromhex('c5fcc2c00f') + b'\x48\xb8' + struct.pack('<Q', SENTINEL) + + bytes.fromhex('48894424c0') + b'\x48\xb9' + struct.pack('<Q', page) + + bytes.fromhex('c70101000000') + bytes.fromhex('488b4424c0' 'c5f877' 'c3')) + return code, [[STORE, 'c70101000000', [-6]]] + + +class TrampolineBytes(unittest.TestCase): + def test_site_jumps_to_trampoline_that_lowers_rsp_around_the_store(self): + image = bytearray(leaf(0)[0]) + code, applied = protect_red_zones(image, leaf(0)[1], 0x100) + self.assertEqual(applied, 1) + self.assertEqual(image[STORE:STORE + 5], b'\xe9' + struct.pack('<i', 0x100 - (STORE + 5))) + self.assertEqual(image[STORE + 5], 0xcc) + body = LEA_DOWN + bytes.fromhex('c70101000000') + LEA_UP + self.assertEqual(code[:len(body)], body) + back = 0x100 + len(body) + 5 + self.assertEqual(code[len(body):len(body) + 5], b'\xe9' + struct.pack('<i', STORE + 6 - back)) + + def test_other_instructions_of_a_site_are_copied_unchanged(self): + image = bytearray(bytes.fromhex('8b442404' '8946a4' 'c3')) # mov eax,[rsp+4]; mov [rsi-0x5c],eax + code, applied = protect_red_zones(image, [[0, '8b4424048946a4', [4, -3]]], 0x40) + self.assertEqual(applied, 1) + self.assertTrue(code.startswith(bytes.fromhex('8b442404') + LEA_DOWN + bytes.fromhex('8946a4') + LEA_UP)) + + def test_changed_bytes_or_relocations_leave_the_site_alone(self): + image = bytearray(leaf(0)[0]) + self.assertEqual(protect_red_zones(image, [[STORE, 'c70102000000', [-6]]], 0x100), (b'', 0)) + self.assertEqual(protect_red_zones(image, leaf(0)[1], 0x100, skip=[STORE + 3]), (b'', 0)) + self.assertEqual(image, leaf(0)[0]) + + +class SiteTable(unittest.TestCase): + sites = json.loads((ROOT / 'patches' / 'redzone.json').read_text())['sites'] + + def test_attack_trail_stores_are_covered(self): + # 0x28ce7b0 writes trail vertices with live locals at [rsp-0x6c, rsp); 0x28ce9b5 then + # loaded a NULL pointer from the overwritten [rsp-0x60]. + covered = set() + for offset, original, lengths in self.sites: + pos = offset + for length in lengths: + if length < 0: + covered.add(pos) + pos += abs(length) + for store in (0x28cedfe, 0x28cee33, 0x28cee68, 0x28cee72): + self.assertIn(store, covered) + + def test_sites_do_not_overlap_community_patches(self): + lines = ET.parse(ROOT / 'patches' / 'Bloodborne.xml').iter('Line') + patched = sorted(int(line.get('Address'), 16) - 0x400000 for line in lines) + for offset, original, _ in self.sites: + end = offset + len(original) // 2 + self.assertFalse(any(offset - 8 < p < end for p in patched), hex(offset)) + + +@unittest.skipUnless(os.name == 'nt', 'Windows exception dispatch') +class WindowsFault(unittest.TestCase): + """A store faults on a read-only page; the handler makes it writable and resumes.""" + + def run_leaf(self, protect): + k32 = ctypes.WinDLL('kernel32', use_last_error=True) + k32.VirtualAlloc.restype = ctypes.c_void_p + k32.VirtualAlloc.argtypes = (ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint32, ctypes.c_uint32) + k32.VirtualProtect.argtypes = (ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32)) + k32.VirtualFree.argtypes = (ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint32) + k32.AddVectoredExceptionHandler.restype = ctypes.c_void_p + k32.RemoveVectoredExceptionHandler.argtypes = (ctypes.c_void_p,) + page = k32.VirtualAlloc(None, 4096, 0x3000, 0x02) # MEM_COMMIT|MEM_RESERVE, PAGE_READONLY + text = k32.VirtualAlloc(None, 4096, 0x3000, 0x40) # PAGE_EXECUTE_READWRITE + code, sites = leaf(page) + image = bytearray(code) + if protect: + trampolines, applied = protect_red_zones(image, sites, 0x100) + self.assertEqual(applied, 1) + image += bytes(0x100 - len(image)) + trampolines + ctypes.memmove(text, bytes(image), len(image)) + + class Record(ctypes.Structure): + _fields_ = [('code', ctypes.c_uint32), ('flags', ctypes.c_uint32), ('next', ctypes.c_void_p), + ('address', ctypes.c_void_p), ('count', ctypes.c_uint32), + ('information', ctypes.c_size_t * 15)] + + class Pointers(ctypes.Structure): + _fields_ = [('record', ctypes.POINTER(Record)), ('context', ctypes.c_void_p)] + + @ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.POINTER(Pointers)) + def handler(info): + r = info.contents.record.contents + if r.code != 0xC0000005 or not page <= r.information[1] < page + 4096: + return 0 # EXCEPTION_CONTINUE_SEARCH + k32.VirtualProtect(page, 4096, 0x04, ctypes.byref(ctypes.c_uint32())) + return -1 # EXCEPTION_CONTINUE_EXECUTION + + cookie = k32.AddVectoredExceptionHandler(1, handler) + try: + result = ctypes.CFUNCTYPE(ctypes.c_uint64)(text)() + finally: + k32.RemoveVectoredExceptionHandler(cookie) + k32.VirtualFree(text, 0, 0x8000) + k32.VirtualFree(page, 0, 0x8000) + return result + + def test_exception_frame_overwrites_the_red_zone(self): + if self.run_leaf(protect=False) == SENTINEL: + self.skipTest('this CPU keeps the red zone: its AVX state is saved further down') + + def test_trampoline_keeps_the_red_zone(self): + self.assertEqual(self.run_leaf(protect=True), SENTINEL) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_settings.cpp b/tests/test_settings.cpp index 593fed4..2087d68 100644 --- a/tests/test_settings.cpp +++ b/tests/test_settings.cpp @@ -3,9 +3,9 @@ #include <cassert> #include <cstdio> #include <cstdlib> -#include <fstream> -#include <filesystem> -#include <chrono> +#include <fstream> +#include <filesystem> +#include <chrono> #include <sstream> #include <string> #include "gpu/shim/bbport_settings.h" @@ -18,18 +18,18 @@ static std::string Read(const char* path) { } int main() { - const auto temporary = std::filesystem::temp_directory_path() / - ("bbport-settings-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); - const std::string filename = temporary.string(); - const char* path = filename.c_str(); + const auto temporary = std::filesystem::temp_directory_path() / + ("bbport-settings-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + const std::string filename = temporary.string(); + const char* path = filename.c_str(); const char ini[] = "# launcher\nupscaler=fsr3\nkey.cross=X, Space\npad.circle=a\nshow_fps=0\n" "fullscreen_hint=1\n"; - { std::ofstream file(path); file << ini; assert(file.good()); } -#ifdef _WIN32 - _putenv_s("BB_CONFIG", path); -#else - setenv("BB_CONFIG", path, 1); -#endif + { std::ofstream file(path); file << ini; assert(file.good()); } +#ifdef _WIN32 + _putenv_s("BB_CONFIG", path); +#else + setenv("BB_CONFIG", path, 1); +#endif BbSettings::Load(); auto& s = BbSettings::Get(); @@ -37,8 +37,8 @@ int main() { s.show_fps = true; s.upscaler = BbSettings::UpscalerFsr411; s.menu_x = 0.625f; - s.menu_y = 0.125f; - s.fullscreen = true; + s.menu_y = 0.125f; + s.fullscreen = true; BbSettings::Save(); const std::string saved = Read(path); @@ -47,43 +47,159 @@ int main() { assert(saved.find("# launcher\nupscaler=fsr411\nkey.cross=X, Space\npad.circle=a\nshow_fps=1\n" "fullscreen_hint=1\n") == 0); assert(saved.find("menu_pos=0.6250,0.1250\n") != std::string::npos); - assert(saved.find("upscaler=fsr3") == std::string::npos); - assert(saved.find("fullscreen=1\n") != std::string::npos); + assert(saved.find("upscaler=fsr3") == std::string::npos); + assert(saved.find("fullscreen=1\n") != std::string::npos); s.menu_x = -1.0f; s.menu_y = -1.0f; BbSettings::Load(); - assert(s.menu_x == 0.625f && s.menu_y == 0.125f && s.upscaler == BbSettings::UpscalerFsr411); - // Replacing an existing file works on Windows too, and must not erase key bindings. - s.upscaler = BbSettings::UpscalerDlss; - BbSettings::Save(); - assert(Read(path).find("upscaler=dlss\n") != std::string::npos); - assert(Read(path).find("key.cross=X, Space\n") != std::string::npos); - BbSettings::ConfigureUpscalerSupport(false, false, false); - assert(s.upscaler == BbSettings::UpscalerFsr3 && !s.dlss_supported); - s.upscaler = BbSettings::UpscalerDlss; - BbSettings::ConfigureUpscalerSupport(false, false, true); - assert(s.upscaler == BbSettings::UpscalerDlss && s.dlss_supported); - s.camera_controls = true; - s.camera_fov_scale = 1.25f; - s.camera_distance_scale = 0.80f; - s.camera_height_scale = 1.10f; - BbSettings::Save(); - s.camera_controls = false; - s.camera_fov_scale = s.camera_distance_scale = s.camera_height_scale = 1.0f; - BbSettings::Load(); - assert(s.camera_controls && s.camera_fov_scale == 1.25f); - assert(s.camera_distance_scale == 0.80f && s.camera_height_scale == 1.10f); - s.change_appearance=true;s.rebirth=true;s.graphics_controls=true; - s.graphics_bloom=0.7f;s.graphics_shadow_scale=1.4f; - BbSettings::Save(); - s.rebirth=false;s.graphics_bloom=1.0f; - BbSettings::Load(); - assert(s.change_appearance && s.rebirth && s.graphics_controls && s.graphics_bloom==0.7f && s.graphics_shadow_scale==1.4f); - // Edited configs must not feed infinite/NaN camera transforms to the engine. - { std::ofstream file(path); file << "camera_fov_scale=nan\ncamera_distance_scale=-10\ncamera_height_scale=inf\n"; } - BbSettings::Load(); - assert(s.camera_fov_scale == 1.0f && s.camera_distance_scale == 0.5f && s.camera_height_scale == 1.0f); - std::filesystem::remove(temporary); + assert(s.menu_x == 0.625f && s.menu_y == 0.125f && s.upscaler == BbSettings::UpscalerFsr411); + // Replacing an existing file works on Windows too, and must not erase key bindings. + s.upscaler = BbSettings::UpscalerDlss; + BbSettings::Save(); + assert(Read(path).find("upscaler=dlss\n") != std::string::npos); + assert(Read(path).find("key.cross=X, Space\n") != std::string::npos); + BbSettings::ConfigureUpscalerSupport(false, false, false); + assert(s.upscaler == BbSettings::UpscalerFsr3 && !s.dlss_supported); + s.upscaler = BbSettings::UpscalerDlss; + BbSettings::ConfigureUpscalerSupport(false, false, true); + assert(s.upscaler == BbSettings::UpscalerDlss && s.dlss_supported); + s.camera_controls = true; + s.camera_fov_scale = 1.25f; + s.camera_distance_scale = 0.80f; + s.camera_height_scale = 1.10f; + BbSettings::Save(); + s.camera_controls = false; + s.camera_fov_scale = s.camera_distance_scale = s.camera_height_scale = 1.0f; + BbSettings::Load(); + assert(s.camera_controls && s.camera_fov_scale == 1.25f); + assert(s.camera_distance_scale == 0.80f && s.camera_height_scale == 1.10f); + s.change_appearance=true;s.rebirth=true;s.graphics_controls=true; + s.graphics_bloom=0.7f;s.graphics_shadow_scale=1.4f; + BbSettings::Save(); + s.rebirth=false;s.graphics_bloom=1.0f; + BbSettings::Load(); + assert(s.change_appearance && s.rebirth && s.graphics_controls && s.graphics_bloom==0.7f && s.graphics_shadow_scale==1.4f); + // Edited configs must not feed infinite/NaN camera transforms to the engine. + { std::ofstream file(path); file << "camera_fov_scale=nan\ncamera_distance_scale=-10\ncamera_height_scale=inf\n"; } + BbSettings::Load(); + assert(s.camera_fov_scale == 1.0f && s.camera_distance_scale == 0.5f && s.camera_height_scale == 1.0f); + // Menu languages: the code is read and written back; the added languages translate by the + // English text and fall back to it; Russian keeps its own text. + { std::ofstream file(path); file << "menu_language=pt_BR\n"; } + BbSettings::Load(); + assert(s.menu_language == BbSettings::MenuLanguage::PortugueseBrazil); + assert(std::string(BbSettings::MenuText("Close", "Закрыть")) == "Fechar"); + assert(std::string(BbSettings::MenuText("Not in the tables", "x")) == "Not in the tables"); + BbSettings::Save(); + assert(Read(path).find("menu_language=pt_BR\n") != std::string::npos); + s.menu_language = BbSettings::MenuLanguage::Turkish; + assert(std::string(BbSettings::MenuText("Close", "Закрыть")) == "Kapat"); + s.menu_language = BbSettings::MenuLanguage::Russian; + assert(std::string(BbSettings::MenuText("Close", "Закрыть")) == "Закрыть"); + // Cheats: off, infinite or an amount 1-99, written back as read; anything else is off. + { std::ofstream file(path); file << "cheat_vials=infinite\ncheat_bullets=50\n"; } + BbSettings::Load(); + assert(s.cheat_vials == BbSettings::CheatInfinite && s.cheat_bullets == 50); + BbSettings::Save(); + assert(Read(path).find("cheat_vials=infinite\n") != std::string::npos); + assert(Read(path).find("cheat_bullets=50\n") != std::string::npos); + { std::ofstream file(path); file << "cheat_vials=100\ncheat_bullets=lots\n"; } + BbSettings::Load(); + assert(s.cheat_vials == BbSettings::CheatOff && s.cheat_bullets == BbSettings::CheatOff); + BbSettings::Save(); + assert(Read(path).find("cheat_vials=off\n") != std::string::npos); + // The other cheats: 0/1 switches, firearm range off/150/200/300, echo multiplier 1/2/5/10; + // written back as read, anything else the game's own. + assert(Read(path).find("cheat_gun_range=off\n") != std::string::npos); + assert(Read(path).find("cheat_echo_multiplier=1\n") != std::string::npos); + assert(Read(path).find("cheat_stamina=0\n") != std::string::npos); + { + std::ofstream file(path); + file << "cheat_stamina=1\ncheat_stealth=1\ncheat_gun_range=200\ncheat_keep_echoes=1\n" + "cheat_echo_multiplier=5\ncheat_no_durability=1\ncheat_consumables=1\n"; + } + BbSettings::Load(); + assert(s.cheat_stamina && s.cheat_stealth && s.cheat_keep_echoes && s.cheat_no_durability && s.cheat_consumables); + assert(s.cheat_gun_range == 200 && s.cheat_echo_multiplier == 5); + BbSettings::Save(); + for (const char* line : {"cheat_stamina=1\n", "cheat_stealth=1\n", "cheat_gun_range=200\n", "cheat_keep_echoes=1\n", + "cheat_echo_multiplier=5\n", "cheat_no_durability=1\n", "cheat_consumables=1\n"}) { + assert(Read(path).find(line) != std::string::npos); + } + { + std::ofstream file(path); + file << "cheat_stamina=0\ncheat_stealth=0\ncheat_gun_range=250\ncheat_keep_echoes=0\n" + "cheat_echo_multiplier=3\ncheat_no_durability=0\ncheat_consumables=0\n"; + } + BbSettings::Load(); + assert(!s.cheat_stamina && !s.cheat_stealth && !s.cheat_keep_echoes && !s.cheat_no_durability && !s.cheat_consumables); + assert(s.cheat_gun_range == 0 && s.cheat_echo_multiplier == 1); + { std::ofstream file(path); file << "cheat_gun_range=300\ncheat_echo_multiplier=10\n"; } + BbSettings::Load(); + assert(s.cheat_gun_range == 300 && s.cheat_echo_multiplier == 10); + { std::ofstream file(path); file << "cheat_gun_range=off\ncheat_echo_multiplier=x2\n"; } + BbSettings::Load(); + assert(s.cheat_gun_range == 0 && s.cheat_echo_multiplier == 1); + BbSettings::Save(); + assert(Read(path).find("cheat_gun_range=off\n") != std::string::npos); + assert(Read(path).find("cheat_echo_multiplier=1\n") != std::string::npos); + // HDR output: hdr 0/1 (applied at start), paper white 80-500 and peak 400-10000 nits, boost + // 0-1; written back as read; anything else clamped or the default. + assert(Read(path).find("hdr=0\n") != std::string::npos); + assert(Read(path).find("hdr_paper_white=200\n") != std::string::npos); + assert(Read(path).find("hdr_peak=1000\n") != std::string::npos); + assert(Read(path).find("hdr_boost=0.25\n") != std::string::npos); + // hdr_mode: native (the default: highlights from the game's HDR scene) or expand (from the + // SDR picture); live, so it is not a startup setting. + assert(s.hdr_mode == BbSettings::HdrModeNative); + assert(Read(path).find("hdr_mode=native\n") != std::string::npos); + { std::ofstream file(path); file << "hdr=1\nhdr_paper_white=250\nhdr_peak=20000\nhdr_boost=0.75\n" + "hdr_mode=expand\n"; } + BbSettings::Load(); + assert(s.hdr && s.startup_hdr && s.hdr_paper_white == 250.0f && s.hdr_peak == 10000.0f && + s.hdr_boost == 0.75f && s.hdr_mode == BbSettings::HdrModeExpand); + BbSettings::Save(); + for (const char* line : {"hdr=1\n", "hdr_paper_white=250\n", "hdr_peak=10000\n", "hdr_boost=0.75\n", + "hdr_mode=expand\n"}) { + assert(Read(path).find(line) != std::string::npos); + } + assert(Read(path).find("hdr_mode=native\n") == std::string::npos); + { std::ofstream file(path); file << "hdr=0\nhdr_paper_white=10\nhdr_peak=nan\nhdr_boost=-1\n" + "hdr_mode=bright\n"; } + BbSettings::Load(); + assert(!s.hdr && !s.startup_hdr && s.hdr_paper_white == 80.0f && s.hdr_peak == 1000.0f && + s.hdr_boost == 0.0f && s.hdr_mode == BbSettings::HdrModeNative); + { std::ofstream file(path); file << "hdr_mode=expand\n"; } + BbSettings::Load(); + assert(s.hdr_mode == BbSettings::HdrModeExpand); + { std::ofstream file(path); file << "hdr_mode=native\n"; } + BbSettings::Load(); + assert(s.hdr_mode == BbSettings::HdrModeNative); + // BB_HDR* override the file. +#ifdef _WIN32 + _putenv_s("BB_HDR", "1"); + _putenv_s("BB_HDR_PEAK", "600"); + _putenv_s("BB_HDR_MODE", "expand"); +#else + setenv("BB_HDR", "1", 1); + setenv("BB_HDR_PEAK", "600", 1); + setenv("BB_HDR_MODE", "expand", 1); +#endif + BbSettings::Load(); + assert(s.hdr && s.startup_hdr && s.hdr_peak == 600.0f && s.hdr_paper_white == 80.0f && + s.hdr_mode == BbSettings::HdrModeExpand); +#ifdef _WIN32 + _putenv_s("BB_HDR", ""); + _putenv_s("BB_HDR_PEAK", ""); + _putenv_s("BB_HDR_MODE", ""); +#else + unsetenv("BB_HDR"); + unsetenv("BB_HDR_PEAK"); + unsetenv("BB_HDR_MODE"); +#endif + BbSettings::Load(); + assert(s.hdr_mode == BbSettings::HdrModeNative); + std::filesystem::remove(temporary); std::puts("PASS: settings save keeps other keys, menu position"); } diff --git a/tests/test_windows_frame_rate.py b/tests/test_windows_frame_rate.py index 49eb6c3..89c9786 100644 --- a/tests/test_windows_frame_rate.py +++ b/tests/test_windows_frame_rate.py @@ -1,6 +1,6 @@ from paths import ROOT import unittest -from run_windows import configure_frame_rate +from run_windows import configure_frame_rate, large_scene class WindowsFrameRateTests(unittest.TestCase): @@ -12,8 +12,8 @@ class WindowsFrameRateTests(unittest.TestCase): self.assertEqual(env['BB_FPS_LIMIT'], fps) self.assertEqual(env['BB_VBLANK_HZ'], '90' if fps == '90' else '60') - def test_default_and_retired_presets_are_capped_at_60(self): - for value in (None, 'uncap', 'Unlimited', '', '0', '480', 'bad'): + def test_default_and_unknown_presets_are_capped_at_60(self): + for value in (None, 'Unlimited', '', '0', 'bad'): with self.subTest(value=value): env = {'BB_VBLANK_HZ': '0', 'BB_FPS_LIMIT': '0'} if value is not None: @@ -23,6 +23,46 @@ class WindowsFrameRateTests(unittest.TestCase): self.assertEqual(env['BB_FPS_LIMIT'], '60') self.assertEqual(env['BB_VBLANK_HZ'], '60') + def test_120_and_144_are_their_own_presets(self): + # The launcher's 120 and 144: the 90 FPS++ patch scaled to the rate (patches.py). + for fps in ('120', '144'): + with self.subTest(fps=fps): + env = {'BB_FPS': fps, 'BB_FPS_LIMIT': '0', 'BB_VBLANK_HZ': '0'} + self.assertEqual(configure_frame_rate(env), fps) + self.assertEqual(env['BB_FPS'], fps) + self.assertEqual(env['BB_FPS_LIMIT'], fps) + self.assertEqual(env['BB_VBLANK_HZ'], fps) + + def test_other_high_rates_use_the_uncapped_patch_with_a_limit(self): + for fps in ('165', '480'): + with self.subTest(fps=fps): + env = {'BB_FPS': fps, 'BB_FPS_LIMIT': '0', 'BB_VBLANK_HZ': '60'} + self.assertEqual(configure_frame_rate(env), 'uncap') + self.assertEqual(env['BB_FPS'], 'uncap') + self.assertEqual(env['BB_FPS_LIMIT'], fps) + self.assertEqual(env['BB_VBLANK_HZ'], '0') + + def test_uncapped_keeps_its_limit(self): + # "Uncapped": no limit unless BB_FPS_LIMIT asks for one. + for inherited, expected in (('0', '0'), ('165', '165'), ('bad', '0')): + with self.subTest(limit=inherited): + env = {'BB_FPS': 'uncap', 'BB_FPS_LIMIT': inherited, 'BB_VBLANK_HZ': '60'} + self.assertEqual(configure_frame_rate(env), 'uncap') + self.assertEqual(env['BB_FPS_LIMIT'], expected) + self.assertEqual(env['BB_VBLANK_HZ'], '0') + if __name__ == '__main__': unittest.main() + + +class MemoryPoolTests(unittest.TestCase): + def test_the_large_pool_follows_the_scene_not_the_output(self): + # 1440p Quality (1706x960) and 4K Performance (1916x1078) scenes fit the 5056 MiB pool; + # 1440p native or 4K Quality scenes need 9152 MiB. + self.assertFalse(large_scene('1706x960')) + self.assertFalse(large_scene('1916x1078')) + self.assertFalse(large_scene('1920x1080')) + self.assertTrue(large_scene('2560x1440')) + self.assertTrue(large_scene('2258x1270')) + self.assertTrue(large_scene('3840x2160')) diff --git a/tools/engine/menu_assets.cpp b/tools/engine/menu_assets.cpp index d51b554..a7ece9f 100644 --- a/tools/engine/menu_assets.cpp +++ b/tools/engine/menu_assets.cpp @@ -31,7 +31,7 @@ using Bytes = std::vector<std::uint8_t>; // Bumped whenever a generator or the recipe changes, so installs remake their // assets instead of keeping the last generation. -constexpr int kGeneratorVersion = 8; // 8: PC Enhancements' three rows; 7: the PC Enhancements section; 6: event text (group 30) for the altar's rebirth +constexpr int kGeneratorVersion = 9; // 9: PC Colour, Fog on PC Graphics; 8: 8: PC Enhancements' three rows; 7: the PC Enhancements section; 6: event text (group 30) for the altar's rebirth const char kMessagesTsv[] = #include "pc_option_messages.inc" @@ -765,7 +765,7 @@ Bytes add_list_slot(const Bytes& b, const std::string& list) { return out; } -// The movie: eight sections, then the System list's scroll bar. PCDeck is +// The movie: nine sections, then the System list's scroll bar. PCDeck is // opened on a Steam Deck only (engine/option_menu.cpp), but is always made. Bytes build_option_movie(const Bytes& src) { Bytes m = src; @@ -781,13 +781,14 @@ Bytes build_option_movie(const Bytes& src) { m = add_option_section(m, a); }; section("PCSetting", 116020, 383, 6, 0, {}); - section("PCGraphics", 117020, 384, 5, 0, {"Item_5_0"}); - section("PCControls", 118020, 385, 6, 0, {}); + section("PCGraphics", 117020, 384, 6, 0, {}); + section("PCControls", 118020, 385, std::nullopt, 2, {}); section("PCKeys", 119020, 386, std::nullopt, 2, {}); - section("PCEffects", 120020, 387, 6, 0, {}); + section("PCEffects", 120020, 387, 5, 0, {"Item_5_0"}); section("PCCamera", 121020, 388, 3, 0, {"Item_3_0", "Item_4_0", "Item_5_0"}); section("PCDeck", 123020, 389, 2, 0, {"Item_2_0", "Item_3_0", "Item_4_0", "Item_5_0"}); section("PCEnhance", 124020, 390, 3, 0, {"Item_3_0", "Item_4_0", "Item_5_0"}); + section("PCColour", 125020, 391, 5, 0, {"Item_5_0"}); return add_scrollbar(m, "Top", 1290, 354.5); } diff --git a/tools/engine/pc_option_messages.tsv b/tools/engine/pc_option_messages.tsv index 2199381..8054a0c 100644 --- a/tools/engine/pc_option_messages.tsv +++ b/tools/engine/pc_option_messages.tsv @@ -7,7 +7,7 @@ # The port takes the next free blocks - 116000 PC Settings, 117000 PC # Graphics, 118000 PC Controls, 119000 Key Bindings, 120000 PC Effects, 121000 # PC Camera, 122000 the title menu's Quit Game, 123000 Steam Deck (shown on a -# Deck only), 124000 PC Enhancements - and 110006..110013 for the System rows +# Deck only), 124000 PC Enhancements, 125000 PC Colour - and 110006..110014 for the System rows # that open them. All are unused in the shipped menu.msgbnd. (Motion blur, # depth of field and chromatic aberration moved to PC Effects with their # 117000 ids.) @@ -27,12 +27,13 @@ # id menu text (group 0xc8) line help (group 0xc9) 110006 PC Settings Options added by the PC port. 110007 PC Graphics Graphics options added by the PC port. -110008 PC Controls The mouse: camera, sensitivity and the pointer. +110008 PC Controls The mouse: camera, sensitivity and the pointer; button prompts and controller icons. 110009 Key Bindings Keyboard and mouse bindings. 110010 PC Effects Post-processing: blur, bloom, vignette and lens effects. 110011 PC Camera The follow camera: field of view, distance and height. 110012 Steam Deck Frame rate and model detail for the Steam Deck. 110013 PC Enhancements Enable the mirror, rebirth and PC Camera controls. Each needs a restart. +110014 PC Colour Brightness, contrast, saturation and hue of the picture. 116000 Vertical Sync Wait for the display. Off can tear. 116001 Fullscreen Borderless, at the desktop resolution. 116002 Mouse in Menus Move and click to select. @@ -59,7 +60,12 @@ 120020 PC Effects 120000 Bloom The glow around bright light. 10 is the game's own. 120001 Vignette The darkened frame edges some areas use. -120002 Saturation Colour. 5 is the game's own; 0 is black and white. +125020 PC Colour +125004 Picture Tuning Applies PC Graphics, PC Effects and PC Colour live. Off: the game's own picture. Moving one of their settings turns it on. +125000 Brightness How bright the picture is. 5 is the game's own. +125001 Contrast The difference between dark and bright. 5 is the game's own. +125002 Saturation Colour. 5 is the game's own; 0 is black and white. +125003 Hue Shifts every colour. 5 is the game's own. 121020 PC Camera 121000 Camera Distance How far behind the character the camera sits. 5 is the game's own. 121001 Camera Height How high above the character the camera looks. 5 is the game's own. @@ -120,9 +126,19 @@ 14000336 Undo Rebirth 118005 Camera Auto Rotation Allow the camera to turn automatically while using the mouse. -117040 2560x1080 -117041 3440x1440 -117042 3840x1600 -117043 3840x1080 -117044 5120x1440 -117045 5120x2160 +118006 Button Prompts The button icons in menus and prompts. Automatic shows keys while the keyboard or mouse is in use. +118010 Automatic +118011 Keyboard +118012 Controller +118007 Controller Icons The controller's buttons in menus and prompts. Automatic shows Xbox buttons while an Xbox controller is in use. +118013 Automatic +118014 PlayStation +118015 Xbox +117040 2560x1080 (21:9) +117041 3440x1440 (21:9) +117042 3840x1600 (24:10) +117043 3840x1080 (32:9) +117044 5120x1440 (32:9) +117045 5120x2160 (21:9) +117046 7680x2160 (32:9) +117047 Display (auto) diff --git a/tools/redzone_sites.py b/tools/redzone_sites.py new file mode 100644 index 0000000..a3e6357 --- /dev/null +++ b/tools/redzone_sites.py @@ -0,0 +1,177 @@ +"""List the eboot stores that run below the red zone on Windows: patches/redzone.json. + +Where Windows saves extended state in the compact XSAVE layout (Intel 12th gen and later), an +exception's frame holds the AVX registers up to 0x28 bytes below rsp, over the System V red +zone, so a leaf function's store that faults on a GPU-tracked page and is resumed loses the +locals the function keeps there (link_modules.protect_red_zones). A site is a run of a red-zone +leaf's instructions, at least 5 bytes for a jmp, holding its stores through non-stack pointers; +nothing inside it may be a branch target, rip-relative, segment-prefixed or change rsp. +Functions with jump tables or community patches (patches/Bloodborne.xml) are left out. + +Developer tool (pip install capstone): python tools/redzone_sites.py <game dir with eboot.bin> +""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import struct +import sys +import xml.etree.ElementTree as ET +import capstone +from capstone import x86 + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / 'scripts')) +from prepare import parse_self # noqa: E402 + +RSP, RBP, RIP = x86.X86_REG_RSP, x86.X86_REG_RBP, x86.X86_REG_RIP +STACK = {RSP, x86.X86_REG_ESP, x86.X86_REG_SP, x86.X86_REG_SPL} +FLOW = {capstone.CS_GRP_JUMP, capstone.CS_GRP_CALL, capstone.CS_GRP_RET, capstone.CS_GRP_INT, capstone.CS_GRP_IRET} +NO_ACCESS = ('lea', 'nop', 'prefetch') +RED_ZONE_RSP = re.compile(rb'[\x44\x4c\x54\x5c\x64\x6c\x74\x7c]\x24[\x80-\xff]', re.S) # [rsp-disp8] +FRAME_POINTER = bytes.fromhex('4889e5') # mov rbp, rsp + + +def load(game): + elf, _, ph, _, _ = parse_self((game / 'eboot.bin').read_bytes()) + loads = [p for p in ph if p['type'] == 1] + image = bytearray(max(p['vaddr'] + p['memsz'] for p in loads)) + for p in loads: + image[p['vaddr']:p['vaddr'] + p['filesz']] = elf[p['offset']:p['offset'] + p['filesz']] + text = next(p for p in loads if p['flags'] & 1) + hdr = next(p for p in ph if p['type'] == 0x6474e550)['vaddr'] + if image[hdr + 2] != 0x03 or image[hdr + 3] != 0x3b: + raise ValueError('unexpected .eh_frame_hdr encoding') + count, = struct.unpack_from('<I', image, hdr + 8) + starts = sorted({hdr + struct.unpack_from('<i', image, hdr + 12 + 8 * i)[0] for i in range(count)}) + end = text['vaddr'] + text['filesz'] + return bytes(image), [s for s in starts if text['vaddr'] <= s < end] + [end] + + +def patched_addresses(): + lines = ET.parse(ROOT / 'patches' / 'Bloodborne.xml').iter('Line') + return sorted(int(line.get('Address'), 16) - 0x400000 for line in lines) + + +def stack_layout(insns): + """rsp after the prologue and rbp as a frame pointer, both relative to the entry rsp.""" + rsp, rbp = 0, None + for i in insns: + ops = i.operands + if i.mnemonic == 'push': + rsp -= 8 + elif i.mnemonic == 'sub' and ops[0].type == x86.X86_OP_REG and ops[0].reg == RSP and ops[1].type == x86.X86_OP_IMM: + rsp -= ops[1].imm + elif i.mnemonic == 'mov' and ops[0].type == x86.X86_OP_REG and ops[0].reg == RBP and \ + ops[1].type == x86.X86_OP_REG and ops[1].reg == RSP: + rbp = rsp + else: + break + return rsp, rbp + + +def movable(i): + _, written = i.regs_access() + return not FLOW & set(i.groups) and not STACK & set(written) and i.prefix[1] not in (0x64, 0x65) and \ + not any(op.type == x86.X86_OP_MEM and op.mem.base == RIP for op in i.operands) + + +def analyse(md, image, start, end): + """(sites, uncovered stores) of one function; None when it keeps nothing below rsp.""" + insns = [] + for i in md.disasm(image[start:end], start): + if i.group(capstone.CS_GRP_CALL): + return None # not a leaf: no red zone + insns.append(i) + if not insns: + return None + body_rsp, rbp = stack_layout(insns) + targets, red_zone, stores = set(), False, [] + for i in insns: + if i.group(capstone.CS_GRP_JUMP): + op = i.operands[0] + if op.type == x86.X86_OP_IMM: + targets.add(op.imm) + elif not (op.type == x86.X86_OP_MEM and op.mem.base == RIP): + return [], ['jump table'] # targets unknown: left alone + for op in i.operands: + if op.type != x86.X86_OP_MEM: + continue + if op.mem.base == RSP and op.mem.disp < 0: + red_zone = True + if rbp is not None and op.mem.base == RBP and rbp + op.mem.disp < body_rsp: + red_zone = True + if op.access & capstone.CS_AC_WRITE and not i.mnemonic.startswith(NO_ACCESS) and \ + op.mem.base not in (RSP, RIP) and not (rbp is not None and op.mem.base == RBP): + stores.append(i) + if not red_zone or not stores: + return None + # Runs of movable instructions; a branch target starts a new run. + runs, run = [], [] + for i in insns: + if not movable(i) or (i.address in targets and run): + if run: + runs.append(run) + run = [i] if movable(i) else [] + continue + run.append(i) + if run: + runs.append(run) + store_at = {i.address for i in stores} + sites = [] + for run in runs: + marked = [k for k, i in enumerate(run) if i.address in store_at] + if not marked: + continue + first, last = marked[0], marked[-1] + while sum(i.size for i in run[first:last + 1]) < 5 and (last + 1 < len(run) or first > 0): + if last + 1 < len(run): + last += 1 + else: + first -= 1 + span = run[first:last + 1] + if sum(i.size for i in span) >= 5: + sites.append([span[0].address, b''.join(i.bytes for i in span).hex(), + [-i.size if i.address in store_at else i.size for i in span]]) + covered = {a for s in sites for a in range(s[0], s[0] + len(s[1]) // 2)} + return sites, [f'{i.address:#x}' for i in stores if i.address not in covered] + + +def main(): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument('game', type=Path) + p.add_argument('-o', '--out', type=Path, default=ROOT / 'patches' / 'redzone.json') + a = p.parse_args() + image, starts = load(a.game) + patched = patched_addresses() + md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64) + md.detail = True + sites, functions, skipped, uncovered = [], 0, [], [] + for start, end in zip(starts, starts[1:]): + code = image[start:end] + if not (RED_ZONE_RSP.search(code) or FRAME_POINTER in code): + continue + result = analyse(md, image, start, end) + if result is None: + continue + if any(start <= q < end for q in patched): + skipped.append(f'{start:#x} (community patch)') + continue + found, missing = result + if missing == ['jump table']: + skipped.append(f'{start:#x} (jump table)') + continue + functions += 1 + sites += found + uncovered += missing + a.out.write_text(json.dumps(dict( + eboot='Bloodborne 01.09', text_sha256=hashlib.sha256(image[starts[0]:starts[-1]]).hexdigest(), + functions=functions, skipped=skipped, uncovered_stores=uncovered, + sites=sites), separators=(',', ':')) + '\n', encoding='ascii') + print(f'{len(sites)} sites in {functions} red-zone leaf functions; {len(uncovered)} stores uncovered; ' + f'{len(skipped)} functions skipped -> {a.out}') + + +if __name__ == '__main__': + main()