mirror of
https://github.com/valentderah/totk-pc-recomp.git
synced 2026-11-11 01:41:02 +00:00
Compare commits
No files matched your search
@@ -26,6 +26,7 @@ Report problems as issues, following [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
- 16:10 (Steam Deck, handhelds), 3:2 (Surface, Framework), 21:9 and 32:9 (ultrawide) game resolutions, with the camera and the interface in that shape
|
||||
- Anisotropic texture filtering up to 16x, for sharp ground into the distance
|
||||
- In-game settings menu (F1) and performance overlay (F10)
|
||||
- Play time across runs on the Game tab of the settings menu, kept in `playtime.txt` next to the saves
|
||||
- Discord Rich Presence ("Playing TotK Recomp" with the play time), off by default, own application id supported
|
||||
- Setup and in-game menu in English, Russian, Brazilian Portuguese and Simplified Chinese
|
||||
- Keyboard, mouse and gamepad with remapping, rumble and gyro
|
||||
@@ -34,7 +35,8 @@ Report problems as issues, following [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
- Shaders prepared at install instead of during play
|
||||
- Mods made for emulators: unpack a mod into the `mods` folder next to `totk.exe`; switch and order them in the menu. RomFS
|
||||
mods, TKMM's merged output and ExeFS code patches (`.ips`, `.pchtxt`) work; the game rebuilds itself for code patches
|
||||
([docs/mods.md](docs/mods.md))
|
||||
([docs/mods.md](docs/mods.md)). HD texture packs work: the port grows the texture heap a pack asks for by itself. Tested
|
||||
with the HDPacks.com 4K Texture Pack v1.0 on 1.4.3 and 1.2.1; other packs come with no guarantee
|
||||
- Atmosphère cheats: put a cheat file into a mod and switch its cheats in the menu ([docs/cheats.md](docs/cheats.md))
|
||||
|
||||
## Planned
|
||||
|
||||
@@ -10,6 +10,7 @@ Code copied or adapted from another project is listed here with its source, the
|
||||
| MacNativeGames TotK host runtime: dynamic linking, identity check, module permissions, guest memory and SVCs, threads, IPC, audio renderer, system services, RomFS and save storage, boot sequence, the NVN proc name list | https://github.com/ly05010419/MacNativeGames @ `b75be8cfa704df3701e86130c8ced268e79d6b11` (`games/totk/runtime/`, `games/totk/scripts/prepare_runtime.py`), Copyright (c) 2026 MacNativeGames contributors | MIT | `runtime/loader.c`, `runtime/memory.c`; `runtime/kernel.c`, `runtime/svc.c`, `runtime/ipc.c`, `runtime/ipc.h`, `runtime/audren.c`, `runtime/audren.h`, `runtime/audio_mixer.c`, `runtime/audio_effects.c`, `runtime/audio_effects.h`, `runtime/audio_reverb.c`, `runtime/audio_reverb.h` (SDL3 output replaces CoreAudio), `runtime/ov_system.c` (also `shared_font.c`: the BFTTF layout behind nn::pl), `runtime/ov_input.c` (also `hid_feedback.c`: vibration and six-axis handles and calls), `runtime/motion_fusion.c` (six-axis fusion of `motion_fusion.c`), `runtime/ov_media.c` (hardware Opus decoder over libopus), `runtime/swkbd_win.c` (`swkbd_host.c`: ShowKeyboardArg offsets, UTF-8/UTF-16 conversion, disabled-key filter, result codes; Win32 window replaces the macOS text bar), `runtime/ov_gfx.c`, `runtime/romfs.c`, `runtime/romfs_path.h`, `runtime/fs_host.c`, `runtime/fs.h`, `runtime/boot.c`; `runtime/gfx_window.c`; `tools/gen_nvn_procs.py`, `runtime/nvn_procs.inc` (`NVN/names.txt` and the `NVN_EXTRA` names of `totk_nvn_ext.c`) |
|
||||
| MacNativeGames TotK frame-rate patches: 60 fps present-interval controller, real-time frame step and its present sampling, hair and cloth clock, trampoline slow-motion check (instruction words and 1.2.1 sites), player position chain; quick-menu capture background RomFS overlay | https://github.com/ly05010419/MacNativeGames @ `ebe621de99bfc48f3c42f2d2aa73e04fbcc6eae2` (`games/totk/runtime/game_patches.c`, `game_version.h`, `world_survey.c`, `games/totk/scripts/make_romfs_patches.py`), Copyright (c) 2026 MacNativeGames contributors | MIT | `runtime/frame_rate.c`, `recomp/patches-v121.txt`, `tools/make_romfs_patches.py` |
|
||||
| MacNativeGames TotK 1.4.3 fade fix: the four distance / LOD and pre-actor fade sites, their instruction words and the floor of the fade step at 1 | https://github.com/ly05010419/MacNativeGames @ `3e9e7f67fdfdd3404134dcfa0f24969a9425a33f` (`games/totk/runtime/game_patches_v143.c` patch_fade_increment), Copyright (c) 2026 MacNativeGames contributors | MIT | `recomp/patches-v143.txt` (set fade; the floor without flags, clz >> 5, as in pull request #78) |
|
||||
| MacNativeGames TotK 1.2.1 cursor and aim steps: the map cursor, vertical Ultrahand aim and vertical gyro aim sites, their instruction words and the step scaled by the frame ratio | https://github.com/ly05010419/MacNativeGames @ `363a6eb50434387f7d623349934bcf1e910be48f` (`games/totk/runtime/game_patches.c` patch_cursor_speed), Copyright (c) 2026 MacNativeGames contributors | MIT | `recomp/patches-v121.txt` (set cursor) |
|
||||
| MacNativeGames TotK aspect ratio: the camera and viewport sites that hand the perspective projection its aspect ratio, and the UI layout edits for a canvas wider than 16:9 | https://github.com/ly05010419/MacNativeGames @ `933b6a0d1438de160208a0a13eb5e8d7a2cb8094` (`games/totk/runtime/aspect_patch.h`, `aspect_math.h`, `game_patches_v143.c`, `games/totk/scripts/ultrawide_layout.py`), Copyright (c) 2026 MacNativeGames contributors | MIT | `recomp/patches-v143.txt` (set aspect), `tools/ultrawide_layout.py`, `tools/make_romfs_patches.py` (--aspect) |
|
||||
| MacNativeGames TotK button icons: the Xbox controller's shoulder buttons, triggers, Menu, View and d-pad drawn with letters from the game's own text fonts, the .bfotf key, the button guide's images and their block-linear layout, the resource size table raised for the archives that grew | https://github.com/ly05010419/MacNativeGames @ `933b6a0d1438de160208a0a13eb5e8d7a2cb8094` (`games/totk/scripts/make_button_icons.py`), Copyright (c) 2026 MacNativeGames contributors | MIT | `tools/button_icons.py`, `tools/make_romfs_patches.py` (--prompts) |
|
||||
| RenoDX TotK mod (game mod by souperman9, tonemapper by Musa, RenoDX framework by ShortFuse, RenoVK fork by Ritsu): the approach of the HDR tonemap (the game's 1 - e^-x curve kept below a pivot and continued along its tangent above it, the saturation grade applied to the colour scaled into 0..1) and the observation that the passes after the tonemap keep values above 1. No code copied; the shader is our own | https://github.com/clshortfuse/renodx @ `b6f87272ad1bfdc0e6444910a93606815944a085` (`src/games/totk/tonemap.slang`, `addon.cpp`), Copyright (c) 2025 Carlos Lopez Jr. | MIT | `runtime/shaders/hdr_tonemap.frag`, `runtime/vk_hdr.c` |
|
||||
@@ -103,3 +104,4 @@ folder's `licenses/` and in each component's own folder.
|
||||
| `totk_xlate` (NativeAOT linux-x64 build of `tools/xlate/`, linked with the folder's clang, lld and sysroot) | built by `tools/build_xlate.py --llvm --sysroot` | MIT | `bin/totk_xlate` |
|
||||
| AppImage type-2 runtime (static; includes libfuse and squashfuse), the AppImage's first part; appimagetool packs the folder and is not distributed | https://github.com/AppImage/type2-runtime | MIT; libfuse LGPL-2.1; squashfuse BSD-2-Clause | the AppImage header |
|
||||
| MacNativeGames shader variant list | as in the installer release row above | MIT | `data/shader_list.tsv` |
|
||||
| Weeping of the Empire menu cursor: eui's focus box read through ScreenMgr, BoxCursorMgr and the screens' node lists, AnimButton's hit test, the stale-hit-pane fallback to the parts panes, the three writes of moveBoxCursor, the structure offsets (1.0.0; the ScreenMgr instance placed in 1.4.3 by us) | https://github.com/daniel-madrid-07/weeping-of-the-empire @ `df0586cdc5702ca19bbebfa59697e0b6980f6237` (`engine/runtime/totk_menu_cursor.cpp`), Copyright (c) 2026 daniel-madrid-07 | MIT | `runtime/menu_cursor.c` |
|
||||
@@ -84,12 +84,16 @@ Every guest thread is a host thread. TotK assumes Horizon's scheduler: three app
|
||||
- NVN front end from MacNativeGames (`nvn_api_*.c`, `nvn_obj.h`, texture layout and deswizzle). The game records command buffers through it; nothing reaches the GPU until the game submits.
|
||||
- Vulkan 1.3 back end in place of MacNativeGames' Metal one. At `QueueSubmitCommands` the recorded NVN commands are replayed into Vulkan command buffers (`vk_exec*.c`).
|
||||
- Memory pools. Apple's unified memory let MacNativeGames use the game's pools zero-copy. The back end imports the pools as host memory (`VK_EXT_external_memory_host`). On a discrete GPU, pools bound as buffers are mirrored in device-local memory and the pages the CPU wrote (write watch, above) are uploaded before a replay; idle pool regions are made read-only so the write-watch queries skip them (`vk_memory.c`). GPU-rendered textures are written back when the CPU reads them: on a pool invalidate, on a CPU copy, and for small targets once the CPU waits for a fence (`vk_texture.c`).
|
||||
- Replay thread. `TOTK_REPLAY_ASYNC=1` moves the replay off the submitting thread onto a queue at most one frame behind the game; reads of GPU results wait for the newest writer instead of draining the queue (`vk_backend.c`). It is on by default: where the game waits for the GPU it overlaps the replay with the game threads (a GTX 1650 in the Depths, issue #88), elsewhere it is neutral; it adds up to a frame of latency, and `replay_async = 0` turns it off.
|
||||
- Replay thread. `TOTK_REPLAY_ASYNC=1` moves the replay off the submitting thread onto a queue at most one frame behind the game; reads of GPU results wait for the newest writer instead of draining the queue (`vk_backend.c`). It is off by default. Where the game waits for the GPU it overlaps the replay with the game threads (a GTX 1650 in the Depths, issue #88), a gain measured only there; while it was the default, a GTX 1660 Ti at the 60 fps cap with the GPU at 26 % reported uneven motion (#659). It adds up to a frame of latency; `replay_async = 1` turns it on.
|
||||
- Recorder thread. The Vulkan calls the replay makes into its command buffer are made by a thread of their own (`vk_rec.c`, after Savage Wind Recomp and LAS Recomp): each `vkCmd*` copies its arguments into a packet of a ring and the recorder makes the calls in order; calls into other command buffers are made at once; the recorder also ends the command buffer, and the submit waits until it has. In the town on an RX 9070 the replay takes 17% less time on the submitting thread. It is off by default on AMD GPUs, where the driver loses the device or hangs the GPU at startup with it (`TOTK_VK_RECORDER=1` forces it on, `=0` makes every call on the submitting thread).
|
||||
- Uniform snapshot. The game rewrites per-object uniform blocks on other threads soon after it submits the frame
|
||||
that reads them, while the replay still records it. At the start of a submission's replay every uniform range it
|
||||
binds from CPU-written pools is copied, and the draws bind the copy (`vk_usnap.c`, after MacNativeGames'
|
||||
`TOTK_UBO_SNAPSHOT`); without it, late draws read another object's matrix.
|
||||
- Uniform updates. `nvnCommandBufferUpdateUniformBuffer` between the draws of a pass is replayed as an overlay: the
|
||||
bytes go to scratch memory, later bindings of the range bind them, and the copies into the game's buffers are
|
||||
recorded behind one barrier when the pass ends (`vk_exec_draw.c` `op_update_ub`, `TOTK_UB_OVERLAY`). A copy at the
|
||||
update itself would end the pass with a full barrier, which the terrain paid before most of its draws.
|
||||
- Driver data. The NVN driver keeps a per-stage constant bank (c0) that shaders read for things like point size, dispatch size and base instance. The back end fills it the way the driver does.
|
||||
- Shaders: the Ryujinx translator 1.3.2 with MacNativeGames' patches and ours (`tools/xlate/patches/`), built with .NET 10 NativeAOT as `totk_xlate.exe` and run as a separate process. Its SPIR-V goes to Vulkan directly. `tools/prepare_shaders.py` and `totk.exe --prepare-shaders` translate and compile the variants of MacNativeGames' shared list ahead of play (29,587 for 1.4.3); the rest are translated on first use and cached on disk.
|
||||
- Pipelines: `VK_EXT_graphics_pipeline_library` where the driver has it. Shader stages are compiled ahead into libraries; a draw that meets a new state links them at once (fast link), and an optimised pipeline replaces the linked one when a background thread finishes it. Pipeline keys seen in play are saved and built at boot. A draw whose pipeline is not ready is skipped when one of its pass's targets was rendered in the previous frame, and waits otherwise, so one-shot passes are never lost; a full-screen draw is skipped as well rather than waiting (`TOTK_PIPE_SKIP`); the stage libraries a waiting draw needs are built on idle workers in parallel (`TOTK_PIPE_WAIT`). Without the extension, a missing pipeline is built on a worker and the draw waits or is skipped by the same rule.
|
||||
|
||||
+41
-2
@@ -115,7 +115,15 @@ of them.
|
||||
`SDL3.dll`, `libiconv-2.dll` and `libopus-0.dll` next to the exe.
|
||||
For Xbox button prompts, run `make_romfs_patches.py` twice more, with `--prompts xbox` and `--prompts xbox-label`:
|
||||
they write `build\<ver>\romfs-patches-xbox` and `romfs-patches-xbox-label` ([controls.md](controls.md), "Xbox
|
||||
button prompts"), which `build_game.py` copies next to `totk.exe` like `romfs-patches`.
|
||||
button prompts"), which `build_game.py` copies next to `totk.exe` like `romfs-patches`. `--prompts pc` writes
|
||||
`romfs-patches-pc`, the keyboard and mouse prompts ([controls.md](controls.md), "PC button prompts").
|
||||
|
||||
A build from source has no layouts for other shapes than 16:9. At an `output_res` such as 5120x1440 the game then
|
||||
draws the largest 16:9 size inside it (2560x1440) and the present adds bars, instead of a wide camera over a
|
||||
stretched HUD (`runtime/resolution.c`, `shape_fallback`). For the full shape make `romfs-patches-<w>x<h>` once per
|
||||
size: `make_romfs_patches.py --version <ver> --aspect <w>x<h> --out build\<ver>\romfs-patches-<w>x<h>` (the sizes
|
||||
the installer makes are `ASPECT_SIZES` in `installer/steps.py`); `build_game.py` copies every `romfs-patches*`
|
||||
folder next to `totk.exe`.
|
||||
|
||||
6. Prepare the shaders (once per build folder and translator). It needs `refs\MacNativeGames` (`tools\fetch_refs.sh`)
|
||||
for the shared shader list:
|
||||
@@ -150,6 +158,33 @@ your machines without MSYS2, Python or .NET. `romfs.bin` stays out unless you pa
|
||||
`romfs\` next to the exe or point `TOTK_ROMFS` at its folder. Run `totk.exe --prepare-shaders` once in the copied
|
||||
folder. The folder holds code and data derived from your game: for your own machines only.
|
||||
|
||||
### Profile-guided build
|
||||
|
||||
`build_game.py --pgo <file>.profdata` compiles the generated code at -O2 with an execution profile, and the runtime
|
||||
with it too: hot paths laid out straight, hot calls inlined, code that never ran compiled for size. On 1.4.3 the fight
|
||||
bench ran 12-15% faster per frame and a fight outside the training about 6%. The installer builds with
|
||||
`data/pgo-<ver>.profdata` of the release when the update has one, else at -O1.
|
||||
|
||||
A profile only fits the generated C it was trained on: functions are matched by name and control-flow hash, so a
|
||||
function whose C changes loses its counts without a warning. Retrain after a lifter or generator change, from a clean
|
||||
`recomp/`. The instrumented build needs llvm-mingw, as MSYS2's clang has no profile runtime:
|
||||
|
||||
```powershell
|
||||
$llvm = "toolchain\llvm-mingw-<date>\llvm-mingw-<date>-ucrt-x86_64"
|
||||
.venv\Scripts\python tools\build_game.py --version v143 --out build\v143-pgogen --toolchain $llvm --pgo gen
|
||||
```
|
||||
|
||||
Play `build\v143-pgogen\totk.exe` through varied scenes (fights, towns, riding, building, the Depths, menus) for 20-30
|
||||
minutes; it is slower than a normal build. It writes `build\v143-pgogen\pgo\*.profraw` every minute and at exit.
|
||||
Scripted runs from saves (`tools/run_game.py`) add to the same file. Then:
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\python tools\pgo.py merge build\v143-pgogen --version v143
|
||||
```
|
||||
|
||||
writes `build\pgo\pgo-v143.profdata` and its record (`.json`: the `recomp/` tree it fits). `make_release.py` ships every
|
||||
profile of `build\pgo` in `data\` and warns when a record names another `recomp/` tree than the one released.
|
||||
|
||||
## Linux (in progress)
|
||||
|
||||
The runtime builds and runs on x86-64 Linux with a window, input and menu on SDL3 (`*_sdl.c`) in place of the Win32
|
||||
@@ -202,7 +237,11 @@ Before a commit:
|
||||
|
||||
A change to the button prompts (`tools/make_romfs_patches.py --prompts`, `tools/button_icons.py`) passes
|
||||
`.venv-py314\Scripts\python tests\romfs_prompts_test.py`, which runs the tool on a RomFS it makes itself (no game
|
||||
files); a change to the settings passes `totk.exe --probe-cfg`.
|
||||
files); a change to the settings passes `totk.exe --probe-cfg` and `python tests\settings_doc_check.py`, which fails
|
||||
when a setting is described neither in `totk.ini.example` nor in [settings.md](settings.md). A change to the mods' ExeFS patches
|
||||
(`tools/exefs_patches.py`, `runtime/mods.c`, the `folded.txt` of `recomp/gen.py`) passes
|
||||
`.venv\Scripts\python tests\exefs_patches_test.py`, which makes its own modules and runs `gen.py` on one with a jump
|
||||
table (no game files), and `totk.exe --probe-mods`.
|
||||
|
||||
The lifter (`recomp/`) and the CPU headers (`runtime/cpu.h`, `runtime/simd.h`) are checked against Unicorn. Every lifter
|
||||
change passes these, all run with `.venv`:
|
||||
|
||||
+114
-5
@@ -27,12 +27,13 @@ The log names the layout in effect (`input: gamepad layout ...`); the menu shows
|
||||
| D-pad left, up, right, down | D-pad | 1 2 3 4 | quick-select |
|
||||
| Plus | Start | Tab | menu, inventory |
|
||||
| Minus | Back | M | map |
|
||||
| Right stick shaken left and right | shake the right stick | G | detach a fused part from what Ultrahand holds |
|
||||
|
||||
Esc is unbound in the game; it opens the settings menu and releases the mouse cursor (see Mouse camera). Z and X duplicate ZL and ZR so menu pages turn with two neighbouring keys; the menu tabs (L, R) are Q and R. The mouse wheel is unbound. The bindings are the tables of `input_map` in `runtime/input_map.c`: the keys (platform-neutral key ids) and one gamepad table per layout (gamepad inputs by position); `runtime/input_win.c` translates Win32 virtual keys and SDL buttons.
|
||||
Esc is unbound in the game; it opens the settings menu and releases the mouse cursor (see Mouse camera). Z and X duplicate ZL and ZR so menu pages turn with two neighbouring keys; the menu tabs (L, R) are Q and R. The mouse wheel zooms the Purah Pad's map and is unbound elsewhere. The bindings are the tables of `input_map` in `runtime/input_map.c`: the keys (platform-neutral key ids) and one gamepad table per layout (gamepad inputs by position); `runtime/input_win.c` translates Win32 virtual keys and SDL buttons.
|
||||
|
||||
## Xbox button prompts
|
||||
|
||||
`TOTK_PROMPTS` (`prompts`, the *Button prompts* row on the Controls tab, read at start) picks the controller whose buttons the game's prompts show: `nintendo` (default) keeps the game's own, `xbox` shows an Xbox controller's.
|
||||
`TOTK_PROMPTS` (`prompts`, the *Button prompts* row on the Controls tab, read at start) picks the controller whose buttons the game's prompts show: `nintendo` (default) keeps the game's own, `xbox` shows an Xbox controller's, `pc` the keyboard and mouse (PC button prompts, below).
|
||||
|
||||
- L, R, ZL, ZR, Plus and Minus become LB, RB, LT, RT, Menu and View, and the d-pad prompts a cross with an arrow for each direction meant. The letters on the keys are cut out in the game's own text font.
|
||||
- A, B, X and Y name the label of the button the pad layout presses, so each prompt names the button to press. `pad_layout = nintendo` plays an Xbox pad by position: the right button, labelled B, does Switch A (interact, confirm), and the bottom one, labelled A, does Switch B (run, cancel), so the prompts swap A with B and X with Y. `xbox` and `auto` put Switch A on the button labelled A, and the letters stay.
|
||||
@@ -56,6 +57,24 @@ The game reads its UI archive and fonts once at boot, so the prompts change at t
|
||||
|
||||
The Switch prompts stay, with a note under *Button prompts* and a `RomFS overlay: ...` line in the log, when the folder is missing (an install made by an older setup, or one where the setup could not prepare it and showed a warning; running the setup again on the install folder writes it), when it fails its check, with a game resolution of another shape than 16:9 (its own UI layout wins; 16:10 and 21:9), and when the `romfs_mods` folder replaces one of its files: `romfs-patches` then loads in its place, where two overlays replacing one file would stop the game. In a source build, `.venv-py314\Scripts\python tools\make_romfs_patches.py --version <ver> --prompts xbox` (and `--prompts xbox-label`) writes `build\<ver>\romfs-patches-xbox` (`-label`); it needs fontTools from `tools/requirements-py314.txt`. `--xbox-prompts` is the older name of `--prompts xbox`.
|
||||
|
||||
## PC button prompts
|
||||
|
||||
`prompts = pc` (*PC* on the *Button prompts* row) shows the key or mouse button the default keyboard bindings press each Switch button with, whatever the pad layout. The game loads `romfs-patches-pc` in place of `romfs-patches`, written by `tools/make_romfs_patches.py --prompts pc` (`button_icons.py`, `pc_icons`), which the setup runs next to the Xbox ones:
|
||||
|
||||
| Switch | Prompt |
|
||||
|---|---|
|
||||
| A, B, X, Y | E, Shift, Space, left click |
|
||||
| L, R, ZL, ZR | Q, R, right click, F |
|
||||
| Plus, Minus | Tab, M |
|
||||
| D-pad | 1 (left), 2 (up), 3 (right), 4 (down), as a cross or the keys of the directions meant |
|
||||
| Left stick, its press | W A S D (or the keys of the directions meant), Ctrl |
|
||||
| Right stick, its press | the mouse, with an arrow for each direction meant, C |
|
||||
|
||||
- The glyphs keep the width of the game's own, so the right-aligned guides do not run into their text. A key is a rounded square with its label cut out in the game's own text fonts; a mouse button is a mouse with that button filled and the others hollow.
|
||||
- The button guide in play draws every button from images of the Common archive (`Nt_KeyTexA/B/X/Y/L/ZL_00`, `Nt_KeyTexLSt_00` and `_01`, the stick tilted and pressed): all of them are painted with the same icons. The letters of the action prompt's diamond are one character of the Normal font each: E, an arrow for Shift, a bar for Space, L and R for the mouse buttons.
|
||||
- Like the Xbox prompts they follow the defaults and not the bindings. `--binds <totk.ini>` draws the first key of each `bind_` line of that file instead, for a folder made by hand.
|
||||
- At a game resolution of another shape than 16:9 the game loads `romfs-patches-pc-<w>x<h>` in place of `romfs-patches-<w>x<h>` when it is there: the same prompts with that shape's UI layout, `make_romfs_patches.py --prompts pc --aspect <w>x<h> --out <install>\romfs-patches-pc-<w>x<h>`. The setup does not write those, so without one the Switch prompts stay (*off at a game resolution not 16:9*).
|
||||
|
||||
## Window keys
|
||||
|
||||
| Key | Gamepad | Action |
|
||||
@@ -68,9 +87,9 @@ The Minus+Plus combo never reaches the game. In the menu the arrow keys, the D-p
|
||||
|
||||
## Remapping
|
||||
|
||||
The *Controls* tab of the settings menu lists, under the button layout, every Switch button and the eight stick directions the keys drive (movement and camera), each with two key cells and a controller cell. Select a cell, press A or Enter, then press the new key, mouse button or controller button; Esc or five seconds without a press cancel, and a controller button cancels a key cell. Delete, Backspace or X clears the selected cell. A key another action has moves to the new one (the description names the action it left); a controller button swaps with the one it replaces. A remapped controller button is physical: from then on the face buttons (A B X Y) stay where they are in both layouts, until the buttons are put back as the layout in use has them or *Reset all buttons* is chosen. F1, F8, F10, F11 and Esc cannot be bound (the menu, the flight recorder and the window keep them). Punctuation keys are named by their place on a US keyboard; the menu shows the character the keyboard layout in use puts there.
|
||||
The *Controls* tab of the settings menu lists, under the button layout, every Switch button, the eight stick directions the keys drive (movement and camera) and the shake key, each with two key cells and a controller cell. Select a cell, press A or Enter, then press the new key, mouse button or controller button; Esc or five seconds without a press cancel, and a controller button cancels a key cell. Delete, Backspace or X clears the selected cell. A key another action has moves to the new one (the description names the action it left); a controller button swaps with the one it replaces. A remapped controller button is physical: from then on the face buttons (A B X Y) stay where they are in both layouts, until the buttons are put back as the layout in use has them or *Reset all buttons* is chosen. F1, F8, F10, F11 and Esc cannot be bound (the menu, the flight recorder and the window keep them). Punctuation keys are named by their place on a US keyboard; the menu shows the character the keyboard layout in use puts there.
|
||||
|
||||
The tab writes `totk.ini` lines, one per Switch button or stick direction: `bind_<button> = <entry>, <entry>, ...`. The button is `A B X Y L R ZL ZR PLUS MINUS LEFT UP RIGHT DOWN LCLICK RCLICK`, or a stick direction `LSTICK_LEFT LSTICK_UP LSTICK_RIGHT LSTICK_DOWN` (movement, W A S D by default) and `RSTICK_...` (camera, the arrow keys). An entry is `pad:<name>` or `key:<name>`:
|
||||
The tab writes `totk.ini` lines, one per Switch button or stick direction: `bind_<button> = <entry>, <entry>, ...`. The button is `A B X Y L R ZL ZR PLUS MINUS LEFT UP RIGHT DOWN LCLICK RCLICK`, or a stick direction `LSTICK_LEFT LSTICK_UP LSTICK_RIGHT LSTICK_DOWN` (movement, W A S D by default) and `RSTICK_...` (camera, the arrow keys), or `SHAKE` (keys only, G by default). An entry is `pad:<name>` or `key:<name>`:
|
||||
|
||||
- pad names: `south east west north l r zl zr start back lstick rstick dpad_left dpad_up dpad_right dpad_down lpaddle1 rpaddle1 lpaddle2 rpaddle2` (`zl`, `zr` are the triggers; the paddles are the back buttons of an Xbox Elite, DualSense Edge or Steam Controller, upper pair 1, lower pair 2, and no Switch button has them by default)
|
||||
- key names: `a` to `z`, `0` to `9`, `f2` to `f7`, `f9`, `f12`, `num0` to `num9`, `space tab enter backspace lshift rshift lctrl rctrl lalt capslock insert delete home end pageup pagedown up down left right`, `num_add num_sub num_mul num_div num_decimal`, `minus equals lbracket rbracket semicolon apostrophe comma period slash backslash grave`, `mouse_l mouse_r mouse_m mouse_x1 mouse_x2`
|
||||
@@ -90,7 +109,85 @@ bind_Y = pad:north, key:mouse_l
|
||||
|
||||
## Mouse camera
|
||||
|
||||
Mouse motion (raw input) becomes right-stick deflection. Each presented frame takes the counts moved since the last frame, divides by the frame time and sets the deflection to `counts per second * TOTK_MOUSE_SENS / 3000` of full, held for that frame, so the turn rate does not depend on the frame rate. This is not mouselook: the game's stick curve and dead zone still apply, so very slow movements do nothing and a fast flick saturates. The mouse deflection competes with the gamepad and arrow keys by the larger-deflection rule.
|
||||
`mouse_mode = direct` (the default) turns the camera by the raw mouse motion on games 1.4.3 and 1.2.1, through hooks in the game's camera code (`runtime/mouse_direct.c`, the `mouse_direct` sites of `recomp/patches-v143.txt` and `recomp/patches-v121.txt`). None of them changes a step of the gamepad alone. The free-orbit camera receives raw-count
|
||||
angular increments at its final spherical-angle output: 0.08 degrees per count on both axes at sensitivity 1 (about
|
||||
14.3 cm a turn at 800 DPI), with no time multiplier, curve or carry into later frames. The controller's persistent pitch/yaw move with
|
||||
the output. Mouse input is removed from its stick path first, so the camera's later vertical signed-square curve and
|
||||
velocity filters cannot process it a second time.
|
||||
The aiming camera (bow, throwing), the Purah Pad camera, the scope, the ability camera (Ultrahand, Fuse, Ascend,
|
||||
Recall, Autobuild), the horse camera, lock-on, the bow drawn while locked on, climbing (walls and objects), the
|
||||
waterfall's turn, shield surfing, the wakeboard and the dialogue cameras take the mouse the same way, at the same
|
||||
degrees per count: each turns by
|
||||
the stick times its own speeds and the frame time and adds that to a base angle, so the load of the stick it turns by
|
||||
gets the gamepad's part only and the load of that base angle gets the mouse's degrees (`camera_load`, two sites per
|
||||
axis per camera). Their speeds, time step, slow motion (bullet time) and, for the Purah Pad camera and the abilities,
|
||||
the eased curve their pitch moves along do not touch the mouse; their own limits, collision and blends apply to the
|
||||
result. Past the pitch limit of the scope or the ability camera the stick moves the camera's distance; the mouse's
|
||||
motion past the limit keeps doing that through the stick. Recall's cursor is the ability camera's offset mode: the
|
||||
camera keeps a direction, the stick moves the cursor across the screen (ReverseRecorderCursorSpeed, within
|
||||
ReverseRecorderCursorRange) and the camera follows it by up to ReverseRecorderCameraRange (10 degrees across, 7.8 up).
|
||||
The mouse moves the cursor so that its aim, the camera's follow plus the cursor's angle from the screen's centre (the
|
||||
offset taken as the screen's half width or height at the camera's 50 degree view and aspect ratio), turns as many
|
||||
degrees a count as the camera does elsewhere. The follow range is read from the game each step (until it has been read
|
||||
once, the mouse is stick input there). The stick loads that only test for input keep the mouse in the stick. A step with mouse motion that no
|
||||
direct hook took is logged (`mouse: step N with mouse motion took the stick path`, the first 30, then one in 600, with
|
||||
the player's state), so the cameras still on stick input show in `logs/totk.log`.
|
||||
Lock-on keeps pointing the camera at the target; the stick, and so the mouse, adds an offset around that direction,
|
||||
which the game limits and lets return: the mouse's degrees are the step's turn the game adds to the offset and judges
|
||||
the return by.
|
||||
Shield surfing and the wakeboard turn their pitch by the square of the stick's y and move a second camera parameter
|
||||
with it: their stick y keeps the mouse (that parameter and the camera's state follow it as before), the pitch gets
|
||||
the degrees, and the pitch turn the game made from y is cut to the gamepad's share (p |p| / t |t|). Their stick x, and
|
||||
the dialogue camera's (which is also its test for input), give a tiny value in the mouse's direction when the
|
||||
gamepad's axis is at rest, so the camera still sees input without a stick turn.
|
||||
Two inputs stay stick input: the waterfall's vertical axis moves the camera's height, not an angle, and the horse's
|
||||
lock-on without a target (HorseLockOnEmpty) turns the stick's position into an offset that returns when the stick
|
||||
does, which a mouse has no position for.
|
||||
Those receive input after
|
||||
the radial dead zone, unit-circle clamp and stick response exponent: x is 0.0343 stick units a count over the frame
|
||||
ratio (the stick turns yaw 70 x / 30 degrees a step) and y 70 / 51 times that, linear like their pitch (51 y / 30
|
||||
degrees a step), so both axes turn by the same amount for the same motion. `stick_sens` (which scales the game's
|
||||
stick turn rates) is divided out. While the player uses an ability and the ability camera's hooks do not take the
|
||||
mouse, its part is scaled by 1 / 0.8 and 70 / (1.5 x 51), the ability camera's own stick scales.
|
||||
The player's verbs are read every 50 ms from the player actor (the player root's GOT slot main+0x3969918, the
|
||||
component array +0x228, its AS component +0x158 and the verbs of its five slots). Ultrahand has a separate hook after
|
||||
its manipulation input's power curves; it gets 0.01 stick units a count over the frame ratio, at most three full tilts
|
||||
a step.
|
||||
`mouse_aim_scale` (0.1 to 3, default 1) multiplies the mouse's motion while the bow is drawn (the verb UseBow or
|
||||
DrawnBow with ZR held). The game's camera inversion and gamepad response remain in effect. `mouse_sens` scales raw
|
||||
counts, with the same meaning as in 0.3.10 (the free camera turns 0.08 degrees a count at 1, and now every other camera
|
||||
does too); its scale differs from the stick-emulation mode.
|
||||
The Controls tab switches between *Direct* and *Stick emulation* (`mouse_mode = stick`).
|
||||
The orbit camera keeps its desired pitch at the final angular output, including stationary frames, so a later game
|
||||
filter cannot recenter it. The persistent filter state accounts for the camera's current vertical offset instead of
|
||||
accumulating corrections. A gamepad vertical input releases that mouse target to the game's own. The game's
|
||||
lock-on, recenter and override (aiming) drive the camera themselves: the mouse stops holding without restoring its
|
||||
earlier target, and when they end (or after the camera went 100 ms without a step) it holds the pitch the camera has
|
||||
then, so the camera does not spring back to a pitch from before the aim or to the game's default after a shot.
|
||||
While the ability wheel is open (L held and its screen, ShortCutMainItem_00 or ShortCut_00, has the focus), mouse
|
||||
counts move its virtual cursor, which stays where it was when the mouse stops. L or the D-pad held with no wheel or
|
||||
strip on screen (L soothes the horse, turns menu tabs) leaves the mouse on the camera; with the menu cursor off
|
||||
(`menu_cursor = 0`) the screens are not read and the buttons alone decide.
|
||||
The horizontal quick lists (D-pad up/left/right: materials, shields and weapons) use separate 32 ms right-stick steps,
|
||||
with at least 32 ms of neutral between them. At sensitivity 1, 32 horizontal counts request a step; a large flick
|
||||
cannot queue continued scrolling after the mouse stops. Vertical movement is ignored in these lists. Switching or
|
||||
releasing a selector resets its cursor/step state without sending the last movement to the camera. Selector input is
|
||||
not consumed by the direct camera or Ultrahand hooks.
|
||||
To detach a fused part from what Ultrahand holds, shake the mouse left and right, or press G (`bind_SHAKE`). A press
|
||||
swings the right stick left and right for 0.8 s, holding the key keeps it swinging. With `mouse_mode = direct` a shake
|
||||
of the mouse while Ultrahand holds an object swings the right stick with it: once the swings (10 degrees of camera turn
|
||||
or more each, 125 counts at sensitivity 1) change direction twice within 0.35 s, the stick goes full tilt the
|
||||
way the mouse moves, each side held at least two frames so the game reads it, and goes neutral as soon as the mouse
|
||||
rests. Only your own swings reach the stick, so nothing keeps swinging after you stop; shake until the part comes off.
|
||||
The camera hooks ignore that swing (the mouse turns the camera in degrees already). Turning a held object back and
|
||||
forth at an ordinary pace is slower and does not count. The arrow keys or the gamepad's right stick shaken by hand
|
||||
work too.
|
||||
A step of the gamepad alone, with no mouse motion and no pitch the mouse holds, reaches the camera exactly as without
|
||||
the hooks.
|
||||
The mapping below is still used when the camera-input hook is unavailable or the game disables camera input.
|
||||
Game 1.1.0 has no hooks and keeps stick emulation whatever `mouse_mode` says. On 1.2.1 `mouse_aim_scale` has no effect (the bow-in-hand state is not read there).
|
||||
|
||||
In stick-emulation mode, mouse motion (raw input) becomes right-stick deflection. Each presented frame takes the counts moved since the last frame, divides by the frame time and sets the deflection to `counts per second * TOTK_MOUSE_SENS / 3000` of full, held for that frame, so the turn rate does not depend on the frame rate. This is not mouselook: the game's stick curve and dead zone still apply, so very slow movements do nothing and a fast flick saturates. The mouse deflection competes with the gamepad and arrow keys by the larger-deflection rule.
|
||||
|
||||
`TOTK_MOUSE_SENS` (0 to 10, default 1; higher turns faster, 0 turns the mouse camera and the cursor capture off). While the window is the foreground window the cursor is hidden and confined to the client area. It is released on focus loss, minimise, while F11 changes the window, during a size or move loop and while a text entry is open. Esc releases it by hand: the cursor shows, the mouse camera and mouse buttons stop, and the window can be moved, minimised or closed. A mouse click inside the client area captures it again; that click does not reach the game. Activating the window by a click on its title bar or border does not capture, so it can be dragged or closed right away; Alt+Tab or a click in the client area captures as before. `TOTK_INPUT=none` disables the capture too.
|
||||
|
||||
@@ -98,6 +195,12 @@ Mouse motion (raw input) becomes right-stick deflection. Each presented frame ta
|
||||
|
||||
The stick direction bits (`StickLLeft` to `StickRDown`) are derived from the sticks at 50% deflection. A gamepad plugged in later is picked up within one second; with none connected the log says `input: no gamepad`.
|
||||
|
||||
## Mouse in the game's menus
|
||||
|
||||
On game 1.4.3, while one of the game's menus has its focus box (the frame the d-pad moves: the title, the save list, the inventory and its System tab, Options, shops, dialogue choices), the cursor is released from the mouse camera and shown in the middle of the window. A focus gap of up to 300 ms (a tab switch, a popup opening: the game drops the focus box for a frame or two) keeps the pointer where it is instead of capturing and centring it again. The item under the pointer takes the focus as if the d-pad had moved there, with the game's highlight, sound and description; a left click is A on it, a right click is B. The keys and the pad keep working, and the focus moves only when the pointer moves. This happens only while the keyboard and mouse are the device used last: after a gamepad button press or a stick pushed past half way the menus are as on the console, with no pointer, until the mouse moves or a key is pressed (`input_kbm_last` in `runtime/input.c`). The ability wheel, the d-pad strips, Autobuild and the Purah Pad's camera stay gameplay: the mouse keeps the camera there. The map has no focus box; it is found in `eui::ScreenMgr`'s list of open screens (every 100 ms: the Purah Pad's frame `AppMenuOverlay_00` with `AppMap_00` and the menu background `AppMenuBG_00`), and there the mouse moves the map's cursor as the left stick does, with the cursor still captured, and the scroll wheel zooms: each notch is a full-tilt push of the right stick (the map's zoom) for 100 ms with 50 ms of neutral after it, up (in) for a turn away from you; a fast scroll's notches wait their turn (at most four), a turn the other way drops them. The Scope opens the same map screen without the menu background and keeps the mouse camera. When the menu closes the cursor is captured again.
|
||||
|
||||
`runtime/menu_cursor.c` reads eui's screens at each pad read and moves the focus with the writes `eui::BoxCursorMgr::moveBoxCursor` makes, so no game code is called. `menu_cursor = 0` (`TOTK_MENU_CURSOR=0`, live; the *Mouse in the game's menus* row of the Controls tab) turns it off; `TOTK_MENU_CURSOR_LOG=1` logs every screen it reads (by default the first six), with the layout size, the node count and the hit panes it uses.
|
||||
|
||||
## Nintendo Switch 2 controllers
|
||||
|
||||
Windows does not present the Switch 2 Pro Controller as a gamepad SDL can open (the log says `input: no gamepad`), so it needs a bridge program that wakes it and creates a virtual gamepad. The DS4Windows fork by hbashton ([github.com/hbashton/DS4Windows](https://github.com/hbashton/DS4Windows), tested with release VIIPERRC4.5.1, a pre-release) with the output set to an emulated DualSense gives buttons, rumble, gyro and accelerometer, and reads the sticks through the controller's calibration (they rest at 0 to 2%). The game reads its virtual DualSense directly; the log then shows `input: gamepad "DualSense Wireless Controller" (rumble, gyro and accelerometer, ...)` and `input: motion sensors on`. Its release notes also list Joy-Con 2 support, which is untested here.
|
||||
@@ -111,14 +214,20 @@ Bridges that output a ViGEm DualShock 4 (ProCon2Windows, connectyourpro as teste
|
||||
```
|
||||
<frame> <button> [hold_frames] # A B X Y L R ZL ZR PLUS MINUS LEFT UP RIGHT DOWN LCLICK RCLICK; hold defaults to 4
|
||||
<frame> LSTICK|RSTICK <x> <y> [hold_frames] # -32767..32767, up is +y
|
||||
<frame> MOUSE <dx> <dy> # direct-mode raw counts, once; down is +dy
|
||||
@<ms> <button> [hold_ms] # timed on the world clock
|
||||
@<ms> LSTICK|RSTICK <x> <y> [hold_ms]
|
||||
@<ms> MOUSE <dx> <dy>
|
||||
```
|
||||
|
||||
`<frame>` counts 60 Hz ticks of game time since the first frame (presented frames at 60 fps), so the title menus get the presses at the same game time at any frame limit. `@<ms>` counts milliseconds of the world clock: game time stepped since the end of the first save load (the player leaves the title screen's origin), so a timed event lands at the same game time at any frame rate and load length. A later load, such as fast travel, still shifts what follows. In a script with timed lines the untimed ones stop when the world clock starts, as the load ends at another tick in each run. Each event logs once (`input: script frame N tick T: A`). `tools/input/load_save.txt` loads the first save from the title screen.
|
||||
|
||||
`TOTK_INPUT_RECORD=<file>` (`input_record` in `totk.ini`, or Input recording in the menu) writes the merged input in the same format, one line per button press and per stick segment: events before the world clock starts by tick, the rest timed; a button or stick held when the world clock starts is cut there and goes on as a timed event. The file replays as a script, which makes it the repro to attach to a bug report.
|
||||
|
||||
In direct mouse mode the recording also writes one `MOUSE` event per consumed raw displacement. These are one-shot
|
||||
displacements rather than held stick segments. They replay without cursor capture, including with `input = none`;
|
||||
the game must be accepting camera input when the event reaches its camera update.
|
||||
|
||||
`TOTK_TEST_PAD=<lx>,<ly>,<rx>,<ry>` attaches an SDL virtual gamepad at start whose sticks rest at these raw SDL axis values (-32768 to 32767, down is +y), so a drifting or held stick can be tested without a controller: the values go through the gamepad mapping and `TOTK_STICK_DEADZONE` like a real pad's, and `TOTK_INPUT_RECORD` shows what reached the game.
|
||||
|
||||
## RenderDoc capture (blocked)
|
||||
|
||||
+25
-11
@@ -38,7 +38,10 @@ The install folder (`main.c` `portable_paths()` reads this layout; it stays port
|
||||
|
||||
The player runs a newer installer on the same folder. It reads `totk-install.json`, keeps `romfs/`, `exefs/`,
|
||||
`shared-fonts/`, `profile/` and `totk.ini` after checking their hashes, rebuilds `totk.exe` and re-prepares the
|
||||
shaders. A newer release that changes nothing game-derived skips the rebuild.
|
||||
shaders. A newer release that changes nothing game-derived skips the rebuild. The window starts with the last install folder
|
||||
and the base, update, keys and firmware paths it ran with (paths only, never the keys file's contents), kept in
|
||||
`%LOCALAPPDATA%\TotK Recomp\setup.json` (`$XDG_STATE_HOME/totk-recomp/setup.json` elsewhere). Entries that no longer exist
|
||||
stay empty. A reinstall or a switch to another game version starts filled in.
|
||||
|
||||
Given an update file other than the recorded one (1.1.0, then 1.2.1, then 1.4.3), the installer extracts and builds again from it
|
||||
and keeps `profile/`. Saves carry over both ways between 1.2.1 and 1.4.3: 1.4.3 converts a 1.2.1 save on load, and 1.2.1
|
||||
@@ -57,7 +60,9 @@ loads the saves 1.4.3 wrote.
|
||||
4. **Lift** the ExeFS to C (`recomp/gen.py`, `recomp/patches-v121.txt` or `patches-v143.txt`) into a temp folder next
|
||||
to the installer. The code cheats of the mods' cheat files and the mods' ExeFS patch files become patch sets first
|
||||
(`tools/cheats.py`, `tools/exefs_patches.py`, status in `build-cache/cheats.status` and `exefs.status`); a file the
|
||||
tools refuse costs those cheats or patches, not the install.
|
||||
tools refuse costs those cheats or patches, not the install. The patch files' writes into main's data go to
|
||||
`build-cache/exefs.data`, which the game stores into main at start; after the lift they are checked against the
|
||||
bytes the lifted code read as constants (`<gen>/main/folded.txt`, its jump tables).
|
||||
5. **Compile** the generated C, `modules.c`, `romfs.c`, `patches.c` and `ov_zstd.c` (they bake the per-install layout, RomFS
|
||||
hashes and the game version's patch page) and link
|
||||
them with the prebuilt runtime library for the host's level (`libtotkrt.a` or `libtotkrt-v2.a`) into `totk.exe`
|
||||
@@ -67,9 +72,10 @@ loads the saves 1.4.3 wrote.
|
||||
`totk.exe --prepare-shaders`) in the install folder, then fold the shader cache's loose files (four per shader, about
|
||||
117,000) into `shader-cache/shader-cache.pack` (`tools/pack_shader_cache.py`). totk.exe reads the pack first and
|
||||
writes the translations it makes in play as loose files; running the tool again with the game closed folds those in.
|
||||
The patches are written three times: `romfs-patches`, and `romfs-patches-xbox` and `romfs-patches-xbox-label`
|
||||
The patches are written four times: `romfs-patches`, `romfs-patches-xbox` and `romfs-patches-xbox-label`
|
||||
(`--prompts`: Xbox button prompts for `prompts = xbox`, the first for `pad_layout = nintendo`, the second for `xbox`
|
||||
and `auto`). The two Xbox folders are optional: when one cannot be made, the setup warns and finishes.
|
||||
and `auto`), and `romfs-patches-pc` (keyboard and mouse prompts for `prompts = pc`). The three prompt folders are
|
||||
optional: when one cannot be made, the setup warns and finishes.
|
||||
When the graphics driver crashes while building the shaders, `prepare_shaders.py` runs that part again without the
|
||||
variants it was building ([building.md](building.md), step 6), and the setup finishes with a warning that names how
|
||||
many were left out.
|
||||
@@ -128,9 +134,12 @@ python -m installer --base FILE --update FILE --keys FILE --lang CODE --dest DIR
|
||||
English as the game language, because Chinese game text needs the Switch shared fonts, which the setup extracts only
|
||||
from a firmware folder. A folder with
|
||||
`totk-install.json` needs only the folder. `--selftest [--shots DIR]` builds every screen in every language and
|
||||
closes itself.
|
||||
- `tests/installer_gui_test.py`: event model, form, catalogs, cancel, the self-closing window and one end-to-end run
|
||||
of the CLI in `--verify` mode (needs `build/install-test` and `build/extracted/v121`; see its docstring).
|
||||
closes itself. The window is not stretched on a display whose scale is the system DPI: `installer/dpi.py` makes the
|
||||
process system DPI aware before Tk starts, Tk then draws the fonts at the display's scale and `dpi.px` sizes the
|
||||
paddings and wrap widths with it; the launcher does the same for its error box.
|
||||
- `tests/installer_gui_test.py`: event model, form, catalogs, cancel, the display scale, the self-closing window and
|
||||
one end-to-end run of the CLI in `--verify` mode (needs `build/install-test` and `build/extracted/v121`; see its
|
||||
docstring).
|
||||
- `tests/installer_test.py`: bookkeeping with fake steps. `tests/installer_e2e.py`: a dev-mode run with the files of
|
||||
`config.local.toml`.
|
||||
|
||||
@@ -147,15 +156,17 @@ llvm/ llvm-mingw, x86_64-only subset (Apache-2.0 with LLVM excep
|
||||
runtime/ libtotkrt.a, libtotkrt-v2.a and the headers the generated code and modules.c/romfs.c/patches.c
|
||||
include
|
||||
bin/ totk_xlate.exe, hactool.exe, SDL3.dll, libopus-0.dll, ninja.exe
|
||||
data/ shader_list.tsv (MacNativeGames, MIT)
|
||||
data/ shader_list.tsv (MacNativeGames, MIT); pgo-<ver>.profdata + .json, the execution profile an
|
||||
update's code and the runtime libraries compile with (building.md, "Profile-guided build")
|
||||
THIRD_PARTY_NOTICES.md
|
||||
totk.ini.example, VERSION the default ini; the release string (the repository's VERSION plus the commit)
|
||||
```
|
||||
|
||||
- SDL3 comes from the upstream release, not MSYS2: MSYS2's DLL imports the LGPL libiconv.
|
||||
- Every bundled third-party piece gets a row in `THIRD_PARTY_NOTICES.md`.
|
||||
- The release contains nothing game-derived; `make_release.py` runs `tools/check_content.py` over the folder before it
|
||||
finishes.
|
||||
- The release contains no code, data, text or assets of the game; `make_release.py` runs `tools/check_content.py` over
|
||||
the folder before it finishes. The execution profiles name lifted functions by address (`f_<address>`) and hold
|
||||
branch counts and control-flow hashes of the generated C.
|
||||
|
||||
## Switch system fonts
|
||||
|
||||
@@ -209,7 +220,8 @@ bin/ totk_xlate, hactool, ninja, libSDL3.so.0
|
||||
|
||||
## Constraints
|
||||
|
||||
- The installer steps contain no Win32 calls: Linux runs the same steps with its own compiler ("Linux release").
|
||||
- The installer steps contain no Win32 calls; only the window's DPI awareness (`installer/dpi.py`) is Windows-only, and
|
||||
it does nothing elsewhere. Linux runs the same steps with its own compiler ("Linux release").
|
||||
- The keys file path is passed to the tools; key values never appear in the UI, logs or files.
|
||||
- The window is a front end over the CLI (`python -m installer --base ... --update ... --keys ... --lang ... --dest
|
||||
...`), which prints one JSON progress event per line. Tests and CI use the CLI.
|
||||
@@ -233,3 +245,5 @@ bin/ totk_xlate, hactool, ninja, libSDL3.so.0
|
||||
## Later
|
||||
|
||||
- Re-warm the driver cache on start after a move, GPU or driver change.
|
||||
- Per-monitor DPI in the setup window: Tk 9 has none, so a display whose scale differs from the primary one still gets
|
||||
the window stretched.
|
||||
+1
-1
@@ -88,7 +88,7 @@ Source shorthands:
|
||||
| 3.9 | **Integer vertex inputs read as raw bits** (bone indices `R8UI`). | Float-declared inputs convert integers. | NXN Mario: stretched characters; re-translated with `uint` inputs. Ours: attribute types passed to `QueryGraphicsState` (`Program.cs`). | NXN |
|
||||
| 3.10 | **BFE / BFI fields past bit 31** (as PTX `bfe` / `bfi`): a field that runs past bit 31 is cut there; one that starts past it extracts 0 (signed: bit 31 repeated) and inserts nothing. TotK 1.4.3's Depths terrain vertex shaders (42 variants) take the position from a register, `((2y + x) * 8) & 0xff` with 5-bit x and y, so up to 248. | `OpBitField*Extract` / `OpBitFieldInsert` undefined when offset or offset + count exceeds 32; AMD's bitfield instructions take offset and width modulo 32. | Ryujinx passes the fields through. UltraTears saw the Depths walls and ground missing on the Steam Deck and a Linux RX 570 (RADV). Ours: clamped where the field is not a constant inside the word (translator patch 0012). RX 9070 on Windows drew the Depths the same before and after. | PTX ISA `bfe`, `bfi` |
|
||||
| 3.11 | **SHF by 0** gives the unshifted word (C for `SHF.L`, A for `SHF.R`). | Ryujinx's funnel shifts the other word by 32: undefined in SPIR-V; AMD shifts take the low 5 bits and give A \| C. | yuzu fixed Depths terrain gaps in its funnel shift (PR 10459). Ours: a select on a zero amount (translator patch 0013). In 1.4.3 every register SHF pairs its word with 0, so the result was right already. | — |
|
||||
| 3.12 | **Registers read before any write** on a path (paths the structurizer adds included) have no defined value in the game's code; UltraTears reports 0 from the NVIDIA driver. | An `OpVariable` without an initializer loads an undefined value; Mesa folds undefined values freely. | Ours: every function-local variable starts at 0 (`OpConstantNull`, translator patch 0014). | — |
|
||||
| 3.12 | **Registers read before any write** on a path (paths the structurizer adds included) have no defined value in the game's code; UltraTears reports 0 from the NVIDIA driver. | An `OpVariable` without an initializer loads an undefined value; Mesa folds undefined values freely. | Ours: on Mesa drivers (RADV, ANV, NVK; `vk_caps_driver`) every function-local variable starts at 0 (`OpConstantNull`, job flag `zero_locals`, translator patch 0016; `TOTK_ZERO_LOCALS` overrides). Elsewhere the load stays undefined: a zero start on every driver (the old patch 0014) made the whole picture shake by about 0.9 output pixels a frame with DLSS or DLAA on an RTX 4080 SUPER (#612). It also moved the SPIR-V hash of the game's FXAA shader out of `FXAA_FS` (`vk_dlaa.c` `vk_dlaa_skip_draw`), the likely cause of the shake; that path is not traced. | — |
|
||||
|
||||
## 4. Memory model
|
||||
|
||||
|
||||
+23
-1
@@ -48,7 +48,10 @@ Two mods that edit the same game file do not combine: the higher one wins the wh
|
||||
(`.tkcl` files) go the same way.
|
||||
|
||||
The port merges the resource size table on its own: at every start with mods on, it combines the tables the mods ship
|
||||
and raises the entries for files a mod made larger or added. A separate RESTBL mod is not needed.
|
||||
and raises the entries for files a mod made larger or added, the files inside a mod's packs (`Pack\**\*.pack.zs`)
|
||||
included. A separate RESTBL mod is not needed. The table tells the game how much memory each file needs: a file that
|
||||
outgrew its entry crashes the game when it loads, which can be long after the start, when the game first needs it. The
|
||||
log line `RomFS: resource size table: ...` says how many entries were raised and names the first files inside packs.
|
||||
|
||||
## Code patches (ExeFS)
|
||||
|
||||
@@ -68,6 +71,10 @@ The rebuild needs the setup folder the game was installed from. When that folder
|
||||
A code patch over one of the port's own patches (the 60 FPS patch, the resolution patch and the other entries under
|
||||
**Built into the port**) turns that built-in patch off while the mod is on. The mod's row names it.
|
||||
|
||||
A patch file may also change the game's data (constants and tables in its `.rodata` and `.data`). Those bytes are
|
||||
stored into the loaded game at start, before it runs. When two mods write the same bytes, the higher one wins,
|
||||
as with files.
|
||||
|
||||
## Cheats
|
||||
|
||||
A cheat file is part of a mod: `mods\<mod>\cheats\<build id>.txt`, switched cheat by cheat on the Cheats tab. Cheats
|
||||
@@ -84,6 +91,21 @@ The port keys a mod by its folder name. A renamed folder counts as a new mod:
|
||||
|
||||
Rename mod folders before you set them up in the menu, not after.
|
||||
|
||||
## HD texture packs
|
||||
|
||||
Texture packs made for the emulators replace the game's textures with larger ones, so the heap the world's textures
|
||||
stream into must grow. Such a pack grows it in its own `System/HeapSize/Product.Nin_NX_NVN.HeapDef.byml.zs`, sized for
|
||||
the emulators' 8 GB memory option. The port reads that file at start: the texture heap and the game's memory pool grow
|
||||
by the pack's amount, and the game gets the port's own heap table with that growth (the log says
|
||||
`resolution: mod <name> ships a HeapDef with TextureHeap ...`). Nothing needs to be set. For a pack without such a file
|
||||
that still loops the panic Blood Moon, set `tex_heap_extra_mb` under `[mods]` in `totk.ini` (0 to 4096 MiB).
|
||||
|
||||
Tested: **HDPacks.com Tears of the Kingdom 4K Texture Pack v1.0** (`4kTextures`, 10,407 textures, 6.5 GB) on game 1.4.3
|
||||
and 1.2.1, RTX 4080 SUPER: it loads into the game from a save and runs at the same frame rate, with about 3.3 GB more of
|
||||
textures in memory (4.5 GB of game memory pools against 1.2 GB without it). Other packs are not tested and come with no
|
||||
guarantee. Such a pack needs a lot of video memory; on a GPU with less than 8 GB, `astc_bc = 1` under `[performance]`
|
||||
keeps its textures at a quarter of the size.
|
||||
|
||||
## What does not work
|
||||
|
||||
| On the Mods tab | Why | What to do |
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# Settings reference
|
||||
|
||||
`totk.ini.example` describes the settings for players. This page lists the rest: diagnostics, test hooks, paths and experiments. Set one as `name = value` in `totk.ini`, or as the environment variable `TOTK_NAME`, which wins over the file. Remove the line to go back to the default.
|
||||
|
||||
## Safety
|
||||
|
||||
- **Safe**: changes only what the row says, with no effect on stability or saves.
|
||||
- **Caution**: can slow the game, grow files, or change the picture, and undoes cleanly when removed.
|
||||
- **Risky**: can crash or hang the game, or break the picture or game logic, while it is set.
|
||||
- **Save risk**: can damage or lose saves, or point the game at other game or save files.
|
||||
- **Unknown**: the code did not settle it.
|
||||
|
||||
Many rows below are developer tools that only act together with another setting or a command-line mode (for example a trace mode or the DLSS path). The row says so where the code does. The rows come from reading the code, and most effects have not been checked in a run.
|
||||
|
||||
`tests/settings_doc_check.py` fails when a name in `known[]` (`runtime/cfg.c`) is described neither here nor in `totk.ini.example`, or when a default here differs from the code.
|
||||
|
||||
## Diagnostics and logging
|
||||
|
||||
| Setting | What it does | Effect of changing it | Safety |
|
||||
| --- | --- | --- | --- |
|
||||
| `astc_time` | `1` times the GPU dispatches of each ASTC texture decode and logs the time per block footprint and the largest call. | Adds GPU timestamp queries around every decode; if the queue has no timestamps or no `hostQueryReset`, the log says the decodes are not timed and nothing is measured. | Caution |
|
||||
| `bind_stats` | `1` counts bindings and descriptors per draw of each stage and how much of the bound state changes between draws. | Adds per-draw bookkeeping to the render thread and extra log output; frames get slower. | Caution |
|
||||
| `contact_debug` | `1` draws the contact-shadow factor in grey in place of the picture. | The frame shows a grey debug image while it is set; has no effect when contact shadows are off. | Caution |
|
||||
| `core_diag` | `1` records why ready guest threads wait for a virtual core and for how long, with a histogram every 60 presents. | Turns on host-call and SVC depth tracking and writes many log lines (one per present over 25 ms); frames get slower. | Caution |
|
||||
| `menu_cursor_log` | `1` logs what the menu cursor code reads at every screen change. | Only adds log lines. | Safe |
|
||||
| `perf_slow_ms` (default `50`) | Frames at least this many milliseconds long get a `perf: slow frame` log line with the time blocked per cause. | Changes how many frames are logged; a small value fills the log. | Safe |
|
||||
| `replay_check` | `1` reads every texture read-back a second time after a full drain of the replay queue and compares the two. | Stalls the replay queue at every read-back, so frames get much slower; mismatches are only counted and logged. | Caution |
|
||||
| `resolve_check` | `1` compares every cached pool lookup with the locked lookup and stops on a difference. | Slows every pool lookup; the game stops if the two ever differ. | Caution |
|
||||
| `speed_probe` | `1` adds the player's position and ground speed to the frame-rate line that `TOTK_TRACE=perf` prints every 120 presents. | Only adds log text; has no effect without `TOTK_TRACE=perf`. | Safe |
|
||||
|
||||
## Profiling and measurement
|
||||
|
||||
| Setting | What it does | Effect of changing it | Safety |
|
||||
| --- | --- | --- | --- |
|
||||
| `ab` | Splits game time into blocks of this many seconds in the order A B B A and lets settings written as `ab` take effect only in the B blocks. | Settings that accept `ab` flip on and off every few seconds, so frame times and the picture change with the block; without such a setting it does nothing. | Caution |
|
||||
| `crit` | `<first>-<last>` frame range (with `TOTK_TRACE=sched`) that records every state change and wake of the guest threads, then writes a file at the last frame. | Recording adds overhead to every thread switch in the range and writes one file; `tools/crit_path.py` reads it. | Caution |
|
||||
| `crit_out` (default `crit.bin`) | Path of the file that `crit` writes. | Only moves the file; nothing is written without `crit`. | Safe |
|
||||
| `profile` | Path of a sampling profile file; the profiler suspends threads to unwind their stacks. | Frames get slower while it samples and the profile file grows; `tools/prof_report.py` reads it. | Caution |
|
||||
| `profile_chains` | `1` keeps call chains of every thread in the profile. | Larger profile file and more work per sample; has no effect without `profile`. | Caution |
|
||||
| `profile_frames` | `<lo>-<hi>` samples only while presents lo to hi-1 are pending; `w<lo>-<hi>` uses world-clock seconds. | Limits the profile to a window; has no effect without `profile`. | Safe |
|
||||
| `profile_main` | `1` samples only the guest main thread. | Narrows the profile; has no effect without `profile`. | Safe |
|
||||
| `profile_render` | `1` samples only the render thread and keeps its call chains. | Narrows the profile and makes it larger; has no effect without `profile`. | Caution |
|
||||
|
||||
## Capture and frame dumps
|
||||
|
||||
| Setting | What it does | Effect of changing it | Safety |
|
||||
| --- | --- | --- | --- |
|
||||
| `dump` | Frame number to dump: every render pass target and storage image of that frame is copied out as PNG, with `passes.txt` and `images.txt`. | Writes many PNG files and shader hashes to disk and slows the dumped frame; size is capped by the dump code. | Caution |
|
||||
| `dump_buf` | `<addr>:<size>[:<off>:<stride>:<w>:<h>]` snapshots one GPU buffer range after every logged pass of a dump. | Writes `buf_<pass>.bin` files and `bufs.txt`; has no effect without `dump`. | Caution |
|
||||
| `dump_dir` (default `dump`) | Folder that receives the frame dump files. | Only moves the output. | Safe |
|
||||
| `dump_log` | Frame number; logs the frames from there up to the dumped frame into `passes.txt`, with PNGs only of targets up to 64K texels. | Writes a longer `passes.txt` and more PNGs; has no effect without `dump`. | Caution |
|
||||
| `flight_at` | Comma-separated frame numbers that trigger a flight-recorder dump. | Writes a dump folder when each frame is reached; has no effect unless the flight recorder (`TOTK_FLIGHT=1`) is on. | Caution |
|
||||
| `flight_dir` | Folder for flight-recorder dumps; the default is `flight` beside the folder of `totk.exe`. | Only moves the output. | Safe |
|
||||
| `flight_frames` (default `600`) | Number of presents the flight recorder keeps. | A larger ring keeps more history and more thumbnails in memory; has no effect unless `TOTK_FLIGHT=1`. | Caution |
|
||||
| `flight_thumbs` (default `1`) | `0` turns off the 320x180 thumbnail the flight recorder saves for each present. | Dumps hold only `frames.tsv`; thumbnails cost a GPU blit per present when on. | Safe |
|
||||
| `rdoc` | Comma-separated frame numbers to capture with RenderDoc, which is loaded before Vulkan starts. | The game runs under RenderDoc, with its capture options set to keep `VK_EXT_external_memory_host` available and to reference all resources, and captures are written to disk. | Caution |
|
||||
| `rdoc_dir` | Output folder for RenderDoc captures (the default is `build/captures`). | Only moves the output. | Safe |
|
||||
| `rdoc_dll` | Path of the RenderDoc library, for when it is not found by default. | A wrong path logs that the library cannot be loaded and no capture is taken. | Safe |
|
||||
| `shots` | `<lo>-<hi>:<step>` saves every step-th present in the range as `f<n>.png`, scaled to 480x270; `w<lo>-<hi>:<step>` takes them by world-clock seconds. | Writes one PNG per step and adds a readback of the window texture; the pictures clip highlights in HDR. | Caution |
|
||||
| `shots_dir` (default `.`) | Folder for the `shots` pictures; it is created when needed. | Only moves the output. | Safe |
|
||||
| `shots_full` | Set to any value to save `shots` at full size instead of 480x270. | Files get far larger and writing them takes longer; has no effect without `shots`. | Caution |
|
||||
|
||||
## Automated tests and test hooks
|
||||
|
||||
| Setting | What it does | Effect of changing it | Safety |
|
||||
| --- | --- | --- | --- |
|
||||
| `vram_limit_mib` | Pretends the device-local memory budget is at most this many MiB, for the paths that react to the budget (`tex_vram_pressure`, mirror refusal); the driver knows nothing of it. | Lets a large card drive those paths; a low value drops released textures and refuses mirrors that would have fit. | Caution |
|
||||
| `background_window` | `1` keeps the window behind the user's windows, never fullscreen at start, and F11 keeps its place; SDL windows ignore it. | The window does not come to the front or flash, so it is easy to miss; meant for unattended runs. | Caution |
|
||||
| `gpu` | `vulkan` or `null`: the graphics back end; `null` has no GPU, completes every submit at once and returns zeros for texture reads. | `null` shows no picture; any other value stops the start with an error. | Caution |
|
||||
| `input` | `none` reports no input from the keyboard, mouse or controllers. | The game gets no controller input and the menu cursor stops following the mouse; `input_script` still applies. | Caution |
|
||||
| `input_script` | Path of a file with lines `<frame> <button> [hold]` or `<frame> LSTICK or RSTICK <x> <y> [hold]` that are ORed into the real input. | The game presses those buttons by itself; a file that cannot be opened is logged and ignored. | Caution |
|
||||
| `menu_test` | `<frame>[:<frames per page>]` opens the menu at that frame, shows each tab for 30 frames (or the given count), saves one screenshot per tab and closes the menu. | The menu opens by itself and screenshots are written. | Caution |
|
||||
| `menu_test_dir` | Folder for the `menu_test` screenshots; the default is the working directory. | Only moves the output. | Safe |
|
||||
| `menu_test_set` | `<frame>:<key>=<value>[,...][;...]` changes settings live at that frame, with or without the menu open; `SAVE=1` also writes `totk.ini`. | Settings change during play without input, and `SAVE=1` rewrites the file. | Caution |
|
||||
| `probe_movie` | Text that picks the movie for `totk.exe --probe-movie`: the first file whose path contains it. | Only used by the movie decoder probe; ignored in normal play. | Safe |
|
||||
| `probe_movie_shot` | Path of a `.ppm` file that the movie probe writes the middle frame to. | Only used by the movie decoder probe. | Safe |
|
||||
| `swkbd_answer` | `<text>`, `<cancel>` or `<initial>`: answers the on-screen keyboard (for example a horse name) without opening a window. | The game gets that answer every time it asks for text. | Caution |
|
||||
| `swkbd_test` | `ok` or `cancel` opens the keyboard window and presses OK or Cancel on the first timer tick. | The keyboard window closes by itself. | Caution |
|
||||
| `test_pad` | `<lx>,<ly>,<rx>,<ry>` attaches a virtual gamepad whose sticks rest at these raw SDL axis values (-32768 to 32767). | The game sees a controller with that drift or deflection; a malformed value is logged and ignored. | Caution |
|
||||
| `test_prepare_shaders` | `1` translates every recorded shader on all usable CPUs during start. | Start takes longer and fills the shader cache folder; the CPU is fully used while it runs. | Caution |
|
||||
|
||||
## Input
|
||||
|
||||
| Setting | What it does | Effect of changing it | Safety |
|
||||
| --- | --- | --- | --- |
|
||||
| `bind_<button>` | Remaps one Switch button or stick direction to `pad:<name>` or `key:<name>` entries; covers `bind_a`, `bind_b`, `bind_x`, `bind_y`, `bind_l`, `bind_r`, `bind_zl`, `bind_zr`, `bind_plus`, `bind_minus`, `bind_left`, `bind_up`, `bind_right`, `bind_down`, `bind_lclick`, `bind_rclick`, `bind_lstick_left`, `bind_lstick_up`, `bind_lstick_right`, `bind_lstick_down`, `bind_rstick_left`, `bind_rstick_up`, `bind_rstick_right`, `bind_rstick_down` and `bind_shake`. | A line replaces every default of that button; button prompts in the game still show the default layout. The Controls tab of the menu writes these lines; `docs/controls.md` lists the entry names. | Safe |
|
||||
|
||||
## Paths and files
|
||||
|
||||
| Setting | What it does | Effect of changing it | Safety |
|
||||
| --- | --- | --- | --- |
|
||||
| `amiibo_dir` | Folder the Amiibo tab starts in; the tab saves the folder you last browsed here. | Only changes which folder the tab lists first. | Safe |
|
||||
| `exefs` | Folder of the game's ExeFS (with `main.npdm`); a relative path is relative to `totk.exe`'s folder. | Without it, an `exefs` folder next to `totk.exe` is used, else the path set at build time. The game code is compiled into `totk.exe`, and the loader checks the files here by SHA-256, so files from another build or game version stop the start with a message. | Caution |
|
||||
| `mods_dir` (default `mods`) | Folder that holds one subfolder per player mod, beside `totk.exe`. | Mods found there replace game files; see `docs/mods.md`. | Caution |
|
||||
| `mods_disabled` | Names of mods that are switched off, separated by `\|`. | Listed mods are not applied; the Mods tab writes this line. | Safe |
|
||||
| `mods_order` | Mod names, top first, separated by `\|`; the top mod wins a file that two mods replace. | Changes which mod's file the game gets; unlisted mods go below the listed ones, by name. | Safe |
|
||||
| `pipeline_cache` | Path of the Vulkan pipeline cache file (default `pipeline-cache.bin` beside `totk.exe`). | Moves the cache; a new path starts empty and pipelines are built again. | Caution |
|
||||
| `profile_dir` | Folder of the game profile, which holds the user account id that save data is named after; the default is a `profile` folder beside `totk.exe`, else the build-time path. | Another folder means a different set of saves. | Save risk |
|
||||
| `romfs` | Folder of the game's RomFS (with `romfs.bin`); a relative path is relative to `totk.exe`'s folder. | Without it, a `romfs` folder next to `totk.exe` is used, else the build-time path; a missing image stops the start with a message. | Save risk |
|
||||
| `romfs_index` | Path of `romfs-index.bin`, which `tools/romfs_index.py` wrote; the default is beside `totk.exe`. | The index is checked against the image by SHA-256 and size; a mismatch stops the start. | Caution |
|
||||
| `romfs_mmap` | `1` maps the RomFS image into memory instead of reading it with positional reads. | Changes how the image is read; the log reports when the mapped path is on. | Caution |
|
||||
| `romfs_mods` | Folder of an overlay with a `manifest.tsv` of sizes and SHA-256 hashes; its files are served in place of the originals. | A missing file, size or hash mismatch, or a changed original stops the start; player mods win over this overlay. | Save risk |
|
||||
| `romfs_patches` | Folder of an overlay with a `manifest.tsv`, served in place of the original files; the default is `romfs-patches` beside the index. | Same checks as `romfs_mods`; while it is set the Xbox and PC button prompts are off, and the menu says so. | Save risk |
|
||||
| `romfs_proof_dir` | Folder for the `.romfs-verified-v1` proof file of a full verification; the default is beside the index. | Only moves one small file. | Safe |
|
||||
| `romfs_verify` | `full` hashes the whole RomFS image at start; `force` hashes it again even when a proof file exists. | Start takes many minutes for a 17 GB image; a proof file is written so later starts skip the hash. | Caution |
|
||||
| `shader_cache` | Folder of the translated shader cache (default `shader-cache` beside `totk.exe`). | A new folder starts empty, so shaders are translated again during play. | Caution |
|
||||
| `shared_fonts` | Folder of the extracted system fonts; the default is `shared-fonts` beside `totk.exe`, else beside the RomFS. | Simplified Chinese, Traditional Chinese and Korean need these fonts: without them the game exits before its first frame in those languages. Other languages do not use them. | Caution |
|
||||
|
||||
## Shader translation
|
||||
|
||||
| Setting | What it does | Effect of changing it | Safety |
|
||||
| --- | --- | --- | --- |
|
||||
| `xlate` | Path of the shader translator program; the default is `totk_xlate` beside `totk.exe`, else under `build/xlate`. | Shaders are translated by that program; if it is missing the log says so and graphics shaders cannot be translated. | Risky |
|
||||
| `xlate_id` | Text or 64 hex digits that identify the translator build in shader cache keys; the default comes from an `.id` file beside the translator, else a hash of the program. | A value that two different builds share lets the cache serve shaders translated by the other build. | Caution |
|
||||
| `xlate_keep` (default `0`) | `1` keeps the translation job of a last-frame variant as `<key>.job` with its code. | Writes extra files to the shader cache folder; meant for running the translator by hand. | Caution |
|
||||
| `xlate_procs` (default `2`) | Number of translator processes allowed during play. | More processes translate new shaders sooner and use more CPU and memory; values below 1 count as 1. | Caution |
|
||||
|
||||
## Graphics experiments
|
||||
|
||||
| Setting | What it does | Effect of changing it | Safety |
|
||||
| --- | --- | --- | --- |
|
||||
| `block_views_3d` (default `0`) | `1` gives 3D block-compressed images block views too. | On some AMD cards a GPU write past the end of the image's memory loses the Vulkan device, and the game stops. | Risky |
|
||||
| `feedback` | `0` samples a target that an earlier draw of the same pass wrote with no barrier; `restart` ends and restarts the pass behind a full barrier; otherwise the feedback-loop layout is used when the GPU supports it. | `0` brings back the old behaviour that the barrier fixed, so the picture can break in passes that read a target they draw into; `restart` costs extra passes. | Risky |
|
||||
| `flush_span` (default `0`) | `1` copies one span from the lowest to the highest flushed address of a pool, as an older version did. | Copies far more data to the GPU in crowded towns: an estimated 30-50 ms of GPU time per large copy over PCIe 3.0 x8. | Caution |
|
||||
| `freeze_idle` (default `60`) | Number of submissions a 2 MiB region stays unwritten before it is made read-only; `0` turns the freeze off. | A larger value keeps more regions writable and costs more write tracking; a write to a frozen region costs about 60 microseconds. | Caution |
|
||||
| `gpl_draw_build` (default `1`) | `0` leaves the missing shader libraries of a waiting draw to its background job instead of building them in parallel at once. | Draws whose pipeline is not ready are skipped meanwhile, until the job finishes. | Caution |
|
||||
| `lib_prebuild_keep` (default `0`) | Number of recorded graphics shader library variants that stay in memory after the boot prebuild. | Each kept library costs 140-460 KB of memory; the driver cache already makes a rebuild cheap. | Caution |
|
||||
| `minimal_layout` (default `1`) | `0` skips the compare against one-block tiles when a texture's memory layout is chosen. | The code calls it a negative control for fresh sessions; the comment does not say what the picture does. | Unknown |
|
||||
| `mirror_gap` (default `2`) | Hot pools at most this many 2 MiB regions apart share one write-watch query. | Changes the number of queries per submission; negative values count as 0. | Caution |
|
||||
| `movie_host` (default `1`) | `0` leaves every movie decoder to the game's own library. | The code comment says that library aborts at the first movie, so the game stops at the first cutscene. | Risky |
|
||||
| `movie_pace` (default `1`) | `0` hands every decoded movie frame over at the next present instead of when it is due. | Movies play at the game's frame rate rather than their own, so their speed no longer matches the sound. | Caution |
|
||||
| `pcss_k` (default `0.5`) | Depth-to-penumbra factor of soft shadows at 100 percent, for tuning. | Changes how wide soft shadows blur; has no effect while `pcss` is `0`. | Caution |
|
||||
| `pipe_sync` | Any value other than `0` or empty stops graphics pipelines from being built in the background. | Pipelines are built on the render path, which causes stutter when new effects appear. | Caution |
|
||||
| `push_stage` | `fs` pushes the descriptor set from the fragment stage instead of the vertex stage. | The log names the stage in use; the code does not say what changes in the picture. | Unknown |
|
||||
| `rec_end` (default `1`) | `0` lets the submitting thread begin, end and submit a recorded part again, as older versions did. | On some AMD cards the GPU hangs or the scene turns green and then white when a submission is split. | Risky |
|
||||
| `tex_vram_pressure` (default `90`) | Percent of the device-local memory budget above which released textures are dropped, 0 to 100; `0` is off. First those the game has overwritten with a later texture and left alone for 30 s, then those that can be uploaded again. | A low value drops textures sooner and re-uploads them; `0` keeps them and lets the card spill into system memory. Also a slider in the menu. | Caution |
|
||||
| `txd_transpose` (default `1`) | `0` turns off the swap of gradient components for gradient texture samples. | Terrain textures turned across the screen show crawling patches again. | Caution |
|
||||
| `ub_overlay` (default `1`) | `0` copies each uniform update inside a pass into its buffer at once, ending the pass with a full barrier; `ab` alternates with the `ab` blocks. | With `0` scenes with many updated blocks (the terrain, the sky islands) take longer on the GPU; `TOTK_TRACE=perf` prints how many updates stayed in their pass. | Caution |
|
||||
| `ubo_snapshot` (default `1`) | `0` binds live memory for uniform blocks; `ab` alternates with the `ab` blocks. | Without snapshots late draws read another object's matrix, so objects can be drawn in the wrong place. | Risky |
|
||||
| `vk_no_float64` (default `0`) | `1` clears the 64-bit float feature, so the translator lowers FP64 to FP32. | Shaders change, so the shader cache entries for it are made again; precision of FP64 shaders drops. | Caution |
|
||||
| `vk_ssbo_align` (default unset) | Power of two that storage buffer offsets are treated as needing, to test the unaligned path. | The translator and the draw code round storage offsets down to it; shaders are translated again. | Caution |
|
||||
| `vk_subgroup` (default `32`) | Subgroup size to pin where the driver allows it. | A value other than 32 changes what the translator is told about the GPU, and shaders are translated again. | Caution |
|
||||
| `vram_headroom` (default `1`) | `0` turns off the spare half-block of video memory that a background thread keeps per memory type. | Texture allocations on the render thread can wait for a new block again. | Caution |
|
||||
| `vs_outs` | `all` declares all 32 vertex shader output locations; otherwise only the union of the paired fragment inputs. | Larger vertex shaders and new pipelines; unwritten outputs read the same as before. | Caution |
|
||||
| `wb_exact` (default `1`) | `0` decides write-back of GPU-written textures by page only. | A target that shares a page with data the CPU writes can go unwritten, so a name label next to the reticle can blink. | Caution |
|
||||
| `wb_merge` (default `1`) | `0` copies GPU-written ranges back as plain copies instead of writing word by word the words that changed. | In mirror memory mode the GPU's copy can overwrite words the CPU wrote, which breaks effects such as falling rain and waterfall splashes. | Caution |
|
||||
|
||||
## Motion vectors and DLSS
|
||||
|
||||
| Setting | What it does | Effect of changing it | Safety |
|
||||
| --- | --- | --- | --- |
|
||||
| `dlaa_jitter` (default `1`) | `0` turns off the camera jitter fed to DLSS. | DLSS gets no jitter between frames; a test setting. | Caution |
|
||||
| `dlaa_maskdebug` (default `0`) | `1` shows the reactive mask in place of the picture. | The frame shows a debug image while it is set. | Caution |
|
||||
| `dlaa_mv` (default `1`) | `0` sends zero motion vectors to DLSS. | DLSS sees no motion, so moving content can smear; a test setting. | Caution |
|
||||
| `dlaa_mvdebug` (default `0`) | `1` shows the motion vectors in place of the picture, x in red and y in green. | The frame shows a debug image while it is set. | Caution |
|
||||
| `dlaa_survey` (default `0`) | `1` logs what is drawn into the light target that DLSS reads. | Adds log output and per-draw work. | Caution |
|
||||
| `dlaa_times` (default `0`) | `1` times the DLSS passes on the GPU. | Adds GPU timestamps and log lines. | Caution |
|
||||
| `dlss_reactive` (default `0`) | `1` builds a reactive mask for DLSS where it ran. | Changes how DLSS blends moving areas; a test setting. | Caution |
|
||||
| `mv_check` | Frame interval; every that many DLSS frames copies the light target and motion images to host memory and logs how well the vectors explain the picture. | Costs a GPU-to-host copy and a comparison on the completion thread at each interval. | Caution |
|
||||
| `mv_survey` (default `0`) | `1` notes the uniform blocks of each scene draw and compares them with those of earlier frames. | Adds per-draw work and log output. | Caution |
|
||||
| `mvobj_all` (default `0`) | `1` also lists draws for object motion that read no object blocks (terrain and grass). | More draws are checked and listed, which costs time. | Caution |
|
||||
| `mvobj_banks` (default `0`) | `1` logs which words of the other uniform banks changed for each scene draw. | Adds log output and per-draw work. | Caution |
|
||||
| `mvobj_equal` (default `0`) | `1` uses an EQUAL depth compare for the object motion pass, for comparison. | Changes which pixels get object vectors; the code comment names the default as the one that hides objects behind nearer geometry. | Caution |
|
||||
| `mvobj_exact` | Hex bank mask; only draws whose position uses exactly these banks get object motion. | Fewer draws get object vectors, so their motion falls back to the camera's. | Caution |
|
||||
| `mvobj_exec` (default `1`) | `0` lists object motion draws but never records them. | Objects get camera motion only. | Caution |
|
||||
| `mvobj_noc4` (default `0`) | `1` draws again positions that have no scene camera bank. | Those draws get this frame's view, so they can show wrong motion. | Caution |
|
||||
| `mvobj_only` | Hex bank mask; only draws whose position reads no bank outside the mask get object motion. | Fewer draws get object vectors. | Caution |
|
||||
| `mvobj_times` (default `0`) | `1` adds timings to the object-motion summary. | The clock reads themselves cost frame time. | Caution |
|
||||
| `mvprev_check` (default `0`) | `1` verifies the stored first bytes of each previous-frame table entry. | The code comment measures 3.6 ms per frame. | Caution |
|
||||
| `mvprev_grow` (default `1`) | `0` keeps only three banks in the previous-frame tables. | Object motion that depends on the other banks is no longer tracked. | Caution |
|
||||
| `mvprev_skip` | Hex bank mask of banks left out of the previous-frame copy, replacing the default. | Different banks are tracked, which changes object motion. | Caution |
|
||||
| `mvprev_trust` | Hex bank mask of banks whose found previous-frame copy is taken without the check. | A copy that does not match is used anyway, which can give wrong motion. | Caution |
|
||||
|
||||
## Scheduling and host
|
||||
|
||||
| Setting | What it does | Effect of changing it | Safety |
|
||||
| --- | --- | --- | --- |
|
||||
| `app_pool` | Size in bytes of the guest application memory pool; accepts decimal or `0x` hex. | The default depends on the resolution settings; `0` stops the start with a message, and too small a pool leaves the game without memory. | Risky |
|
||||
| `audio_off` | Any value mixes audio without opening an output device. | The game runs in silence and reports two channels; same as `audio = none`. | Caution |
|
||||
| `one_per_core` (default `0`) | `1` keeps the process on the first logical processor of each physical core; `ab` switches it with the `ab` blocks. | Busy threads stop sharing a core with a sibling thread, which can help or hurt on the CPU; it only applies when the CPU has hyperthreads. | Caution |
|
||||
| `reexec` | Counter of address-space retries that the runtime sets itself when it starts the process again. | The counter is cleared when the window is recreated; a value of 20 or more stops the start with a fatal error. | Risky |
|
||||
| `restart_wait` | Set by the runtime when it restarts itself; names the old process by id and creation time so the new process waits for it to exit. | Meant for the runtime's own use; the code does not describe a manual value. | Unknown |
|
||||
| `spin` (default `1`) | `0` makes a guest thread that polls memory keep its virtual core. | Polling threads can starve the thread whose item they wait for, so frames can stall. | Caution |
|
||||
| `spin_handoff_us` (default `50`) | Microseconds a polling wait lasts before it gives its virtual core to a waiting thread. | A shorter value hands cores over sooner; a longer one keeps them longer. | Caution |
|
||||
|
||||
## Generated-code patches
|
||||
|
||||
These names are code patch sets built into the generated game code (`recomp/patches-*.txt`). `rt_patches_init` reads the environment name at start, then the resolution and mouse code switches some sets itself.
|
||||
|
||||
| Setting | What it does | Effect of changing it | Safety |
|
||||
| --- | --- | --- | --- |
|
||||
| `aspect_patch` (default `0`) | Patch set that feeds the camera the output's aspect ratio when it is not 16:9. | The resolution code switches this set on or off itself at start, so a value here is replaced; the code does not settle what a manual value does. | Unknown |
|
||||
| `fov_patch` (default `0`) | Patch set that scales the camera's vertical view angle by the field-of-view setting. | The resolution code switches this set itself whenever the field of view is not 100 percent; a manual value is replaced. | Unknown |
|
||||
| `mouse_direct_patch` (default `1`) | Patch set of the hooks in the game's camera code behind `mouse_mode = direct` on game 1.4.3. | `0` stops `mouse_mode = direct` from taking effect; other mouse modes are unaffected. | Safe |
|
||||
| `patch_output` (default `0`) | Patch set that makes the game use the configured output size for its swapchain and display configuration. | The resolution code switches this set whenever the output differs from the base size; a manual value is replaced. | Unknown |
|
||||
| `patch_scene` (default `0`) | Patch set that makes the game use the configured scene render size. | The resolution code switches this set from the render size setting; a manual value is replaced. | Unknown |
|
||||
@@ -0,0 +1,109 @@
|
||||
"""The display scale of the installer window: process DPI awareness and the pixel sizes that follow it.
|
||||
|
||||
Windows draws a DPI-unaware process at 96 DPI and stretches its windows to the display's scale, which is what made the
|
||||
setup window blurry at 125% and above. gui.py calls enable() before it imports tkinter, so the process is system DPI
|
||||
aware before its first window exists; Tk then reads the display's DPI when it starts (`tk scaling` is 2.667 at 200%)
|
||||
and every font size in points comes out at the right size. The pixel sizes of the layout have no such scaling of their
|
||||
own, so gui.py passes them through px().
|
||||
|
||||
System DPI awareness, not per-monitor: Tk 9.0.4, the one the release bundles, has no per-monitor support (its DLL
|
||||
imports no DPI-awareness function), so it draws everything at the system DPI. Per-monitor awareness would leave the
|
||||
window crisp on a display at another scale but with the primary display's font sizes; system awareness draws the
|
||||
primary display exactly and lets Windows stretch the window only on a display whose scale differs from it, as before.
|
||||
|
||||
On Linux the window stays 1:1 (px returns n as it is): Tk and the desktop scale what they draw.
|
||||
"""
|
||||
import ctypes
|
||||
import os
|
||||
|
||||
DPI_96 = 96 # the scale Windows calls 100%
|
||||
DPI_AWARENESS_CONTEXT_SYSTEM_AWARE = -2
|
||||
PROCESS_SYSTEM_DPI_AWARE = 1 # SetProcessDpiAwareness' value for the same thing (Windows 8.1)
|
||||
LOGPIXELSX = 88 # GetDeviceCaps' horizontal DPI, the only source before Windows 10 1607
|
||||
LOAD_LIBRARY_SEARCH_SYSTEM32 = 0x00000800 # ctypes.WinDLL's winmode: System32 only, never the installer's folder
|
||||
|
||||
_scale = None
|
||||
|
||||
|
||||
def enable():
|
||||
"""Make the process system DPI aware, then return the display scale (1.0 at 96 DPI). Must run before the first
|
||||
window; a later call only returns the scale."""
|
||||
global _scale
|
||||
if _scale is None:
|
||||
dpi = _system_dpi() if os.name == 'nt' else DPI_96
|
||||
_scale = dpi / DPI_96
|
||||
return _scale
|
||||
|
||||
|
||||
def scale():
|
||||
"""The display scale: 1.0 at 100%, 2.0 at 200%."""
|
||||
return enable()
|
||||
|
||||
|
||||
def px(n):
|
||||
"""n pixels at 100% as this display's pixels: 14 stays 14 at 100% and becomes 28 at 200%."""
|
||||
return int(round(n * enable()))
|
||||
|
||||
|
||||
def awareness():
|
||||
"""What Windows says the process is: 0 unaware, 1 system aware, 2 per-monitor aware."""
|
||||
if os.name != 'nt':
|
||||
return 0
|
||||
try: # Windows 10 1607
|
||||
user32 = ctypes.WinDLL('user32')
|
||||
user32.GetThreadDpiAwarenessContext.restype = ctypes.c_void_p
|
||||
user32.GetAwarenessFromDpiAwarenessContext.argtypes = [ctypes.c_void_p]
|
||||
return int(user32.GetAwarenessFromDpiAwarenessContext(user32.GetThreadDpiAwarenessContext()))
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try: # Windows 8.1
|
||||
value = ctypes.c_int()
|
||||
shcore = ctypes.WinDLL('shcore', winmode=LOAD_LIBRARY_SEARCH_SYSTEM32)
|
||||
shcore.GetProcessDpiAwareness.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)]
|
||||
if shcore.GetProcessDpiAwareness(None, ctypes.byref(value)) == 0:
|
||||
return value.value
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try: # Windows Vista
|
||||
return 1 if ctypes.WinDLL('user32').IsProcessDPIAware() else 0
|
||||
except (AttributeError, OSError):
|
||||
return 0
|
||||
|
||||
|
||||
def _system_dpi():
|
||||
"""Make the process system DPI aware and return the display DPI. Each call that this Windows version has is tried;
|
||||
one failing means the process already is aware (a manifest, or the call before it) and changes nothing."""
|
||||
try:
|
||||
user32 = ctypes.WinDLL('user32', use_last_error=True)
|
||||
except OSError:
|
||||
return DPI_96
|
||||
try: # Windows 10 1607, and the only way to ask for the system context explicitly
|
||||
user32.SetProcessDpiAwarenessContext.argtypes = [ctypes.c_void_p]
|
||||
user32.SetProcessDpiAwarenessContext.restype = ctypes.c_bool
|
||||
user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE))
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try: # Windows 8.1
|
||||
ctypes.WinDLL('shcore', winmode=LOAD_LIBRARY_SEARCH_SYSTEM32).SetProcessDpiAwareness(PROCESS_SYSTEM_DPI_AWARE)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try: # Windows Vista
|
||||
user32.SetProcessDPIAware()
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try: # Windows 10 1607
|
||||
return int(user32.GetDpiForSystem()) or DPI_96
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try: # before that
|
||||
gdi32 = ctypes.WinDLL('gdi32')
|
||||
user32.GetDC.restype = ctypes.c_void_p
|
||||
user32.GetDC.argtypes = [ctypes.c_void_p]
|
||||
user32.ReleaseDC.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
|
||||
gdi32.GetDeviceCaps.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
||||
dc = user32.GetDC(None)
|
||||
dpi = gdi32.GetDeviceCaps(ctypes.c_void_p(dc), LOGPIXELSX)
|
||||
user32.ReleaseDC(None, ctypes.c_void_p(dc))
|
||||
return dpi or DPI_96
|
||||
except (AttributeError, OSError):
|
||||
return DPI_96
|
||||
+124
-48
@@ -3,7 +3,8 @@
|
||||
A front end over the CLI (docs/installer.md): it collects the game files, the keys file path, the optional firmware
|
||||
folder, the game language and the install folder, runs `python -m installer ...` as a subprocess and shows its JSON
|
||||
events. Decisions live in guilogic.py; this file only draws. The window text is in
|
||||
installer/text/<en|ru|pt-BR|zh-Hans>.json under "ui.*" and "launcher.*" ids.
|
||||
installer/text/<en|ru|pt-BR|zh-Hans>.json under "ui.*" and "launcher.*" ids. On Windows the window follows the
|
||||
display's DPI (installer/dpi.py): fonts and paddings grow with the scale, so Windows does not stretch it.
|
||||
|
||||
--selftest [--shots DIR] build every screen in every language and close by itself (PNG per screen with --shots)
|
||||
--lang en|ru|pt-BR|zh-Hans window language (default: the Windows display language)
|
||||
@@ -15,9 +16,18 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tkinter as tk
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from . import dpi
|
||||
|
||||
# Windows fixes the DPI awareness of a process when its first window is created, and Tk reads the display's DPI when it
|
||||
# starts: both happen inside the first tkinter.Tk(), so this call comes before tkinter is imported at all.
|
||||
dpi.enable()
|
||||
|
||||
import tkinter as tk # noqa: E402 (after dpi.enable, see above)
|
||||
from tkinter import filedialog, messagebox, ttk # noqa: E402
|
||||
|
||||
try:
|
||||
from PIL import ImageGrab # only the screenshots of --selftest --shots need it; the release has no PIL
|
||||
@@ -26,13 +36,28 @@ except ImportError:
|
||||
|
||||
from . import ini
|
||||
from . import layout as layouts
|
||||
from . import recent
|
||||
from .guilogic import (DONE, FAILED, PENDING, RUNNING, SKIPPED, UI_LANGUAGE_LABELS, UI_LANGUAGES, Catalog, Form, Model,
|
||||
Process, amount, default_game_language, describe_event, parse_line, windows_ui_language)
|
||||
|
||||
POLL_MS = 100
|
||||
GLYPH = {PENDING: '\u25cb', RUNNING: '\u25b6', DONE: '\u2714', SKIPPED: '\u2013', FAILED: '\u2716'}
|
||||
COLOR = {PENDING: '#808080', RUNNING: '#1a5fb4', DONE: '#26a269', SKIPPED: '#808080', FAILED: '#c01c28'}
|
||||
PAD = 14
|
||||
# The window's pixels at 100%: Tk scales the fonts by itself (tk scaling), these through installer/dpi.py.
|
||||
PAD = dpi.px(14) # padding of the window and its frames
|
||||
WRAP = dpi.px(620) # wrap width of the long labels
|
||||
|
||||
RECENT_WAIT = 1.5 # seconds the window waits for the recorded paths
|
||||
|
||||
|
||||
def load_recent(wait=RECENT_WAIT):
|
||||
"""recent.load() in a daemon thread, waited for at most `wait` seconds: a recorded path on a disconnected network
|
||||
share can block its stat call for a long time. Nothing is prefilled when it has not returned by then."""
|
||||
out = {}
|
||||
thread = threading.Thread(target=lambda: out.update(recent.load()), daemon=True)
|
||||
thread.start()
|
||||
thread.join(wait)
|
||||
return {} if thread.is_alive() else out
|
||||
|
||||
|
||||
def fix_tcl_paths():
|
||||
@@ -57,6 +82,11 @@ class App:
|
||||
self.bound = [] # (widget, option, id, params) retranslated on a language switch
|
||||
self.vars = {n: tk.StringVar() for n in ('base', 'update', 'keys', 'firmware', 'dest')}
|
||||
self.vars['lang'] = tk.StringVar()
|
||||
self.recorded = {} # base/update paths prefilled from the recorded ones, by field
|
||||
self.touched = set() # fields the user picked or typed
|
||||
self.programmatic = False
|
||||
for name in ('base', 'update'):
|
||||
self.vars[name].trace_add('write', lambda *_, n=name: self.touched.add(n) if not self.programmatic else None)
|
||||
self.lang_code = default_game_language(ui_lang)
|
||||
self.screen = None
|
||||
self.fitted = None # the (width, height) fit last gave the window
|
||||
@@ -74,7 +104,7 @@ class App:
|
||||
self.mono_font = 'Consolas' if os.name == 'nt' else 'DejaVu Sans Mono'
|
||||
style = ttk.Style(root)
|
||||
style.configure('Title.TLabel', font=(self.ui_font, 15, 'bold'))
|
||||
style.configure('Notice.TLabel', background='#fff3cd', foreground='#664d03', padding=8)
|
||||
style.configure('Notice.TLabel', background='#fff3cd', foreground='#664d03', padding=dpi.px(8))
|
||||
style.configure('Err.TLabel', foreground='#c01c28')
|
||||
style.configure('Version.TLabel', foreground='#808080')
|
||||
|
||||
@@ -84,7 +114,7 @@ class App:
|
||||
self.title.pack(side='left')
|
||||
version = ttk.Label(top, style='Version.TLabel')
|
||||
self.bind_text(version, 'text', 'ui.version', version=layout.release)
|
||||
version.pack(side='left', padx=(10, 0), pady=(6, 0))
|
||||
version.pack(side='left', padx=(dpi.px(10), 0), pady=(dpi.px(6), 0))
|
||||
sw = ttk.Frame(top)
|
||||
sw.pack(side='right')
|
||||
self.ui_var = tk.StringVar(value=self.cat.lang) # the window language code; the box shows its name
|
||||
@@ -103,6 +133,15 @@ class App:
|
||||
self.bind_text(self.title, 'text', 'ui.rebuild_title')
|
||||
self.vars['dest'].set(str(rebuild))
|
||||
root.after(POLL_MS, self.start)
|
||||
else:
|
||||
# recorded sources fill only empty fields; dest goes last, its trace runs refresh_form (update hint and
|
||||
# recorded language). In update mode filled files are harmless: Extract.current hashes one only when its
|
||||
# size or mtime differ from the recorded ones.
|
||||
last = load_recent()
|
||||
self.recorded = {n: last[n] for n in ('base', 'update') if n in last and not self.vars[n].get().strip()}
|
||||
for name in ('base', 'update', 'keys', 'firmware', 'dest'):
|
||||
if name in last and not self.vars[name].get().strip():
|
||||
self.set_prefill(name, last[name])
|
||||
|
||||
# ---- text -----------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -185,19 +224,20 @@ class App:
|
||||
self.root.geometry(f'{max(w, self.root.winfo_width())}x{max(h, self.root.winfo_height())}')
|
||||
|
||||
def notice(self, parent):
|
||||
w = ttk.Label(parent, style='Notice.TLabel', wraplength=620, justify='left')
|
||||
w = ttk.Label(parent, style='Notice.TLabel', wraplength=WRAP, justify='left')
|
||||
self.bind_text(w, 'text', 'ui.notice')
|
||||
return w
|
||||
|
||||
def build_form(self):
|
||||
f = ttk.Frame(self.body)
|
||||
gap = dpi.px(4) # between the form's rows
|
||||
bar = ttk.Frame(f) # packed first so pack shrinks the text above it, never the buttons
|
||||
bar.pack(side='bottom', fill='x', pady=(12, 0))
|
||||
bar.pack(side='bottom', fill='x', pady=(dpi.px(12), 0))
|
||||
self.start_button = self.button(bar, 'ui.start', self.start)
|
||||
self.start_button.pack(side='right')
|
||||
self.form_intro = self.label(f, 'ui.intro', wraplength=620, justify='left')
|
||||
self.form_intro.pack(anchor='w', pady=(0, 8))
|
||||
self.notice(f).pack(fill='x', pady=(0, 10))
|
||||
self.form_intro = self.label(f, 'ui.intro', wraplength=WRAP, justify='left')
|
||||
self.form_intro.pack(anchor='w', pady=(0, dpi.px(8)))
|
||||
self.notice(f).pack(fill='x', pady=(0, dpi.px(10)))
|
||||
grid = ttk.Frame(f)
|
||||
grid.pack(fill='x')
|
||||
grid.columnconfigure(1, weight=1)
|
||||
@@ -206,44 +246,44 @@ class App:
|
||||
self.form_rows = {}
|
||||
for i, (name, label_id, pick_id) in enumerate(rows):
|
||||
lab = self.label(grid, label_id)
|
||||
lab.grid(row=i, column=0, sticky='w', pady=4, padx=(0, 10))
|
||||
ttk.Entry(grid, textvariable=self.vars[name]).grid(row=i, column=1, sticky='ew', pady=4)
|
||||
lab.grid(row=i, column=0, sticky='w', pady=gap, padx=(0, dpi.px(10)))
|
||||
ttk.Entry(grid, textvariable=self.vars[name]).grid(row=i, column=1, sticky='ew', pady=gap)
|
||||
b = self.button(grid, 'ui.browse', lambda n=name, p=pick_id: self.pick_file(n, p))
|
||||
b.grid(row=i, column=2, padx=(8, 0))
|
||||
b.grid(row=i, column=2, padx=(dpi.px(8), 0))
|
||||
self.form_rows[name] = lab
|
||||
i = len(rows)
|
||||
self.label(grid, 'ui.firmware').grid(row=i, column=0, sticky='w', pady=4)
|
||||
ttk.Entry(grid, textvariable=self.vars['firmware']).grid(row=i, column=1, sticky='ew', pady=4)
|
||||
self.button(grid, 'ui.browse', self.pick_firmware).grid(row=i, column=2, padx=(8, 0))
|
||||
self.label(grid, 'ui.firmware').grid(row=i, column=0, sticky='w', pady=gap)
|
||||
ttk.Entry(grid, textvariable=self.vars['firmware']).grid(row=i, column=1, sticky='ew', pady=gap)
|
||||
self.button(grid, 'ui.browse', self.pick_firmware).grid(row=i, column=2, padx=(dpi.px(8), 0))
|
||||
i += 1
|
||||
self.label(grid, 'ui.game_language').grid(row=i, column=0, sticky='w', pady=4)
|
||||
self.label(grid, 'ui.game_language').grid(row=i, column=0, sticky='w', pady=gap)
|
||||
self.lang_box = ttk.Combobox(grid, textvariable=self.vars['lang'], state='readonly', width=30)
|
||||
self.lang_box.grid(row=i, column=1, sticky='w', pady=4)
|
||||
self.lang_box.grid(row=i, column=1, sticky='w', pady=gap)
|
||||
self.lang_touched = False
|
||||
self.lang_box.bind('<<ComboboxSelected>>', lambda _e: setattr(self, 'lang_touched', True))
|
||||
self.fill_languages()
|
||||
self.label(grid, 'ui.dest').grid(row=i + 1, column=0, sticky='w', pady=4)
|
||||
ttk.Entry(grid, textvariable=self.vars['dest']).grid(row=i + 1, column=1, sticky='ew', pady=4)
|
||||
self.button(grid, 'ui.browse', self.pick_dest).grid(row=i + 1, column=2, padx=(8, 0))
|
||||
self.label(f, 'ui.compressed_help', wraplength=620, justify='left').pack(anchor='w', pady=(8, 0))
|
||||
self.label(f, 'ui.firmware_help', wraplength=620, justify='left').pack(anchor='w', pady=(8, 0))
|
||||
self.label(grid, 'ui.dest').grid(row=i + 1, column=0, sticky='w', pady=gap)
|
||||
ttk.Entry(grid, textvariable=self.vars['dest']).grid(row=i + 1, column=1, sticky='ew', pady=gap)
|
||||
self.button(grid, 'ui.browse', self.pick_dest).grid(row=i + 1, column=2, padx=(dpi.px(8), 0))
|
||||
self.label(f, 'ui.compressed_help', wraplength=WRAP, justify='left').pack(anchor='w', pady=(dpi.px(8), 0))
|
||||
self.label(f, 'ui.firmware_help', wraplength=WRAP, justify='left').pack(anchor='w', pady=(dpi.px(8), 0))
|
||||
# On by default and named with its source and licence: the player sees what is downloaded and can untick it
|
||||
self.dlss_var = tk.BooleanVar(value=True)
|
||||
dlss = ttk.Checkbutton(f, variable=self.dlss_var)
|
||||
self.bind_text(dlss, 'text', 'ui.dlss')
|
||||
dlss.pack(anchor='w', pady=(8, 0))
|
||||
self.label(f, 'ui.dlss_help', wraplength=620, justify='left').pack(anchor='w', padx=(22, 0))
|
||||
self.update_hint = ttk.Label(f, wraplength=620, justify='left')
|
||||
self.update_hint.pack(anchor='w', pady=(8, 0))
|
||||
self.form_error = ttk.Label(f, style='Err.TLabel', wraplength=620, justify='left')
|
||||
self.form_error.pack(anchor='w', pady=(8, 0))
|
||||
self.label(f, 'ui.keys_private', wraplength=620, justify='left').pack(anchor='w', pady=(8, 0))
|
||||
dlss.pack(anchor='w', pady=(dpi.px(8), 0))
|
||||
self.label(f, 'ui.dlss_help', wraplength=WRAP, justify='left').pack(anchor='w', padx=(dpi.px(22), 0))
|
||||
self.update_hint = ttk.Label(f, wraplength=WRAP, justify='left')
|
||||
self.update_hint.pack(anchor='w', pady=(dpi.px(8), 0))
|
||||
self.form_error = ttk.Label(f, style='Err.TLabel', wraplength=WRAP, justify='left')
|
||||
self.form_error.pack(anchor='w', pady=(dpi.px(8), 0))
|
||||
self.label(f, 'ui.keys_private', wraplength=WRAP, justify='left').pack(anchor='w', pady=(dpi.px(8), 0))
|
||||
return f
|
||||
|
||||
def build_run(self):
|
||||
f = ttk.Frame(self.body)
|
||||
bar = ttk.Frame(f)
|
||||
bar.pack(side='bottom', fill='x', pady=(8, 0))
|
||||
bar.pack(side='bottom', fill='x', pady=(dpi.px(8), 0))
|
||||
self.cancel_button = self.button(bar, 'ui.cancel', self.cancel)
|
||||
self.cancel_button.pack(side='right')
|
||||
self.log_toggle = self.button(bar, 'ui.show_log', self.toggle_log)
|
||||
@@ -252,14 +292,22 @@ class App:
|
||||
self.run_header.pack(anchor='w')
|
||||
self.step_rows = {}
|
||||
self.steps_box = ttk.Frame(f)
|
||||
self.steps_box.pack(fill='x', pady=(8, 8))
|
||||
self.phase_label = ttk.Label(f, wraplength=620, justify='left')
|
||||
self.steps_box.pack(fill='x', pady=dpi.px(8))
|
||||
self.phase_label = ttk.Label(f, wraplength=WRAP, justify='left')
|
||||
self.phase_label.pack(anchor='w')
|
||||
self.bar = ttk.Progressbar(f, mode='determinate', maximum=1000)
|
||||
self.bar.pack(fill='x', pady=(4, 6))
|
||||
self.space_label = ttk.Label(f, wraplength=620, justify='left')
|
||||
bar_holder = ttk.Frame(f)
|
||||
bar_holder.pack(fill='x', pady=(dpi.px(4), dpi.px(6)))
|
||||
self.bar = ttk.Progressbar(bar_holder, mode='determinate', maximum=1000)
|
||||
self.bar.pack(fill='both', expand=True)
|
||||
if os.name == 'nt' and ttk.Style(f).theme_use() == 'vista':
|
||||
# The vista theme draws the bar 22 px high at every DPI and has no thickness option: the holder gives it the
|
||||
# height it would have at 100%, scaled with the display. Every other theme draws its own height, and the
|
||||
# holder follows it.
|
||||
bar_holder.pack_propagate(False)
|
||||
bar_holder.configure(height=dpi.px(22))
|
||||
self.space_label = ttk.Label(f, wraplength=WRAP, justify='left')
|
||||
self.space_label.pack(anchor='w')
|
||||
self.warn_label = ttk.Label(f, wraplength=620, justify='left', foreground='#9a6700')
|
||||
self.warn_label = ttk.Label(f, wraplength=WRAP, justify='left', foreground='#9a6700')
|
||||
self.warn_label.pack(anchor='w')
|
||||
self.log_frame = ttk.Frame(f)
|
||||
self.log = tk.Text(self.log_frame, height=10, wrap='word', state='disabled', font=(self.mono_font, 9))
|
||||
@@ -273,22 +321,22 @@ class App:
|
||||
def build_finish(self):
|
||||
f = ttk.Frame(self.body)
|
||||
bar = ttk.Frame(f)
|
||||
bar.pack(side='bottom', fill='x', pady=(12, 0))
|
||||
bar.pack(side='bottom', fill='x', pady=(dpi.px(12), 0))
|
||||
self.button(bar, 'ui.close', self.on_close).pack(side='right')
|
||||
self.open_button = self.button(bar, 'ui.open_folder', self.open_folder)
|
||||
self.open_button.pack(side='right', padx=(0, 8))
|
||||
self.open_button.pack(side='right', padx=(0, dpi.px(8)))
|
||||
self.back_button = self.button(bar, 'ui.back', lambda: self.show('form'))
|
||||
self.back_button.pack(side='left')
|
||||
if self.rebuild: # a failed or cancelled rebuild: the game starts with the code it has
|
||||
self.button(bar, 'ui.start_game', self.start_game).pack(side='right', padx=(0, 8))
|
||||
self.finish_title = ttk.Label(f, style='Title.TLabel', wraplength=620, justify='left')
|
||||
self.button(bar, 'ui.start_game', self.start_game).pack(side='right', padx=(0, dpi.px(8)))
|
||||
self.finish_title = ttk.Label(f, style='Title.TLabel', wraplength=WRAP, justify='left')
|
||||
self.finish_title.pack(anchor='w')
|
||||
self.finish_text = ttk.Label(f, wraplength=620, justify='left')
|
||||
self.finish_text.pack(anchor='w', pady=(8, 8))
|
||||
self.finish_text = ttk.Label(f, wraplength=WRAP, justify='left')
|
||||
self.finish_text.pack(anchor='w', pady=dpi.px(8))
|
||||
self.finish_steps = ttk.Frame(f)
|
||||
self.finish_steps.pack(fill='x', pady=(0, 8))
|
||||
self.finish_steps.pack(fill='x', pady=(0, dpi.px(8)))
|
||||
self.finish_notice = self.notice(f)
|
||||
self.finish_notice.pack(fill='x', pady=(8, 0))
|
||||
self.finish_notice.pack(fill='x', pady=(dpi.px(8), 0))
|
||||
return f
|
||||
|
||||
# ---- form -----------------------------------------------------------------------------------------------------
|
||||
@@ -298,9 +346,35 @@ class App:
|
||||
firmware=self.vars['firmware'].get(), lang=self.selected_lang(), dest=self.vars['dest'].get(),
|
||||
dlss=self.dlss_var.get())
|
||||
|
||||
def set_prefill(self, name, value):
|
||||
self.programmatic = True
|
||||
try:
|
||||
self.vars[name].set(value)
|
||||
finally:
|
||||
self.programmatic = False
|
||||
|
||||
def match_prefill(self, state):
|
||||
"""Prefilled base/update stay only while the chosen install recorded the same file (name and size, one stat);
|
||||
another install would otherwise extract again from them. Fields the user touched are left alone."""
|
||||
for name, value in self.recorded.items():
|
||||
if name in self.touched:
|
||||
continue
|
||||
keep = True
|
||||
if state is not None:
|
||||
known = (state.get('inputs') or {}).get(name)
|
||||
try:
|
||||
keep = (isinstance(known, dict) and Path(value).name == known.get('name')
|
||||
and Path(value).stat().st_size == known.get('size'))
|
||||
except (OSError, ValueError):
|
||||
keep = False
|
||||
want = value if keep else ''
|
||||
if self.vars[name].get() != want:
|
||||
self.set_prefill(name, want)
|
||||
|
||||
def refresh_form(self):
|
||||
form = self.form()
|
||||
state = form.installed_state()
|
||||
self.match_prefill(state)
|
||||
if state is not None:
|
||||
self.update_hint.configure(text=self.cat('ui.update_mode'))
|
||||
recorded = ini.canonical_language(state.get('lang'))
|
||||
@@ -339,6 +413,7 @@ class App:
|
||||
self.fit()
|
||||
return
|
||||
self.form_error.configure(text='')
|
||||
recent.save(form.dest, {name: getattr(form, name) for name in recent.SOURCES})
|
||||
# in a repository the Python running this window: the tools' embeddable Python there has no ._pth entry for
|
||||
# the repository, so `-m installer` would not find the package
|
||||
python = Path(sys.executable) if self.layout.dev else self.layout.python
|
||||
@@ -365,7 +440,7 @@ class App:
|
||||
self.step_rows = {}
|
||||
for s in steps:
|
||||
row = ttk.Frame(self.steps_box)
|
||||
row.pack(fill='x', pady=1)
|
||||
row.pack(fill='x', pady=dpi.px(1))
|
||||
glyph = ttk.Label(row, width=3)
|
||||
glyph.pack(side='left')
|
||||
name = ttk.Label(row)
|
||||
@@ -450,7 +525,7 @@ class App:
|
||||
def toggle_log(self):
|
||||
self.log_open = not self.log_open
|
||||
if self.log_open:
|
||||
self.log_frame.pack(fill='both', expand=True, pady=(8, 0))
|
||||
self.log_frame.pack(fill='both', expand=True, pady=(dpi.px(8), 0))
|
||||
else:
|
||||
self.log_frame.pack_forget()
|
||||
self.bound = [b for b in self.bound if b[0] is not self.log_toggle]
|
||||
@@ -587,6 +662,7 @@ def grab(root, path):
|
||||
def selftest(layout, shots=None):
|
||||
"""Every screen in every language; the window closes itself. Returns 0 when nothing raised."""
|
||||
fix_tcl_paths()
|
||||
os.environ[recent.ENV] = os.path.join(tempfile.mkdtemp(prefix='totk-selftest-'), 'setup.json')
|
||||
root = tk.Tk()
|
||||
root.geometry('+60+40')
|
||||
app = App(root, 'en', layout)
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
No console window. The two message strings come from the catalog (installer/text/<lang>.json, ids launcher.title and
|
||||
launcher.python_missing); tools/make_release.py writes them into launcher_text.h before it compiles this file.
|
||||
Build: llvm/bin/llvm-windres -I runtime -O coff launcher.rc -o launcher_rc.o, then
|
||||
llvm/bin/clang -O2 -mwindows launcher.c launcher_rc.o -o TotK-Setup.exe */
|
||||
llvm/bin/clang -O2 -mwindows launcher.c launcher_rc.o -o TotK-Setup.exe
|
||||
|
||||
System DPI aware, like the window's Python process (installer/dpi.py): Windows draws the error box of fail() at the
|
||||
display's scale instead of stretching it, which it would at 125% and above. */
|
||||
#include <windows.h>
|
||||
#include <wchar.h>
|
||||
|
||||
@@ -38,6 +41,7 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR command_line, i
|
||||
STARTUPINFOW si = { sizeof si };
|
||||
PROCESS_INFORMATION pi;
|
||||
(void)instance; (void)previous; (void)command_line; (void)show;
|
||||
SetProcessDPIAware(); /* before the message box exists; the call fails harmlessly when a manifest set it */
|
||||
if (n == 0 || n >= PATH_CHARS) { fail(); return 1; }
|
||||
while (n > 0 && folder[n - 1] != L'\\') n--;
|
||||
folder[n > 0 ? n - 1 : 0] = 0;
|
||||
|
||||
+13
-2
@@ -12,6 +12,8 @@ Release (docs/installer.md "Release layout"), root = the unpacked folder:
|
||||
include/ + lib/ of Vulkan, SDL3, Opus, libvpx and VMA (build_game.py --deps)
|
||||
bin/ totk_xlate.exe (+ totk_xlate.id), hactool.exe, ninja.exe, SDL3.dll, libopus-0.dll
|
||||
data/shader_list.tsv the MacNativeGames shader list
|
||||
data/pgo-<v>.profdata the execution profile the update's code is compiled with (tools/pgo.py; Windows only);
|
||||
without one the game is built at -O1
|
||||
totk.ini.example
|
||||
VERSION the release version, one line
|
||||
temp/ the default temp folder (created)
|
||||
@@ -24,7 +26,7 @@ Dev mode, root = a repository checkout (no python/python.exe): build/py314-embed
|
||||
Python the release ships, with the same packages), else this Python; toolchain/llvm-mingw-*/ and toolchain/deps-mingw;
|
||||
runtime/ as the sources with build/install-rtlib/libtotkrt.a and libtotkrt-v2.a (tools/build_game.py --make-runtime-lib
|
||||
--out build/install-rtlib, once per --cpu); MSYS2 UCRT64 ninja.exe; toolchain/hactool; build/xlate/totk_xlate.exe; the shader list from
|
||||
refs/MacNativeGames; build/install-temp. The version is VERSION with -dev and the commit: 0.1.0-dev+973c626. On Linux,
|
||||
refs/MacNativeGames; build/pgo/pgo-<v>.profdata when there is one; build/install-temp. The version is VERSION with -dev and the commit: 0.1.0-dev+973c626. On Linux,
|
||||
dev mode takes clang, lld and ninja from PATH and the system's libraries (no deps folder, no SDL3 copy).
|
||||
"""
|
||||
import os
|
||||
@@ -66,6 +68,7 @@ class Layout:
|
||||
temp: Path | None = None
|
||||
sysroot: Path | None = None # Linux release: what the game links against, instead of the host's
|
||||
runtime_lib_v2: Path | None = None # libtotkrt-v2.a: the runtime for CPUs without AVX2 (build_game.py --cpu v2)
|
||||
pgo_dir: Path | None = None # pgo-<v>.profdata (tools/pgo.py); None: no profiles (Linux)
|
||||
|
||||
@property
|
||||
def clang(self):
|
||||
@@ -79,6 +82,12 @@ class Layout:
|
||||
return lib
|
||||
return None if self.dev else lib
|
||||
|
||||
def pgo(self, version):
|
||||
"""The execution profile build_game.py --pgo compiles the update's code with, None without one (the game is
|
||||
then built at -O1)."""
|
||||
p = self.pgo_dir / f'pgo-{version_tag(version)}.profdata' if self.pgo_dir else None
|
||||
return p if p and p.is_file() else None
|
||||
|
||||
def patches(self, version):
|
||||
"""The code patch table for recomp/gen.py."""
|
||||
return self.recomp / f'patches-{version_tag(version)}.txt'
|
||||
@@ -129,6 +138,7 @@ def release_layout(root):
|
||||
xlate=root / 'bin' / ('totk_xlate' + EXE),
|
||||
dlls=(root / 'bin' / 'SDL3.dll', root / 'bin' / 'libopus-0.dll') if WIN else (root / 'bin' / 'libSDL3.so.0',),
|
||||
shader_list=root / 'data' / 'shader_list.tsv', ini_example=root / 'totk.ini.example',
|
||||
pgo_dir=root / 'data' if WIN else None,
|
||||
# Linux: None, the install folder's .setup-temp (installer/cli.py): the AppImage is mounted read-only, and on
|
||||
# a Steam Deck /home is often too full for the generated C while the install folder is on the SD card
|
||||
temp=root / 'temp' if WIN else None,
|
||||
@@ -154,7 +164,8 @@ def dev_layout(root):
|
||||
ninja=UCRT / ('ninja' + EXE), hactool=root / 'toolchain' / 'hactool' / ('hactool' + EXE),
|
||||
xlate=root / 'build' / 'xlate' / ('totk_xlate' + EXE),
|
||||
dlls=tuple(sorted((deps / 'bin').glob('*.dll'))),
|
||||
shader_list=None, ini_example=root / 'totk.ini.example', temp=root / 'build' / 'install-temp')
|
||||
shader_list=None, ini_example=root / 'totk.ini.example', temp=root / 'build' / 'install-temp',
|
||||
pgo_dir=root / 'build' / 'pgo')
|
||||
|
||||
|
||||
def dev_commit(root):
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""The last install folder and source paths, in a per-user file, so an update, a reinstall or a switch to another game
|
||||
version starts with the form filled in.
|
||||
|
||||
{"format": 1, "dest": ..., "base": ..., "update": ..., "keys": ..., "firmware": ...}
|
||||
|
||||
Absolute paths only. The keys entry is the path of the keys file, never its contents.
|
||||
|
||||
Windows: %LOCALAPPDATA%\TotK Recomp\setup.json; elsewhere $XDG_STATE_HOME/totk-recomp/setup.json (default
|
||||
~/.local/state/totk-recomp/setup.json). TOTK_SETUP_RECENT names another file (tests, --selftest). Nothing else is
|
||||
stored: the language comes from the install's own totk-install.json. A file of format 0 (only "dest") still loads.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import state
|
||||
|
||||
FORMAT = 1
|
||||
ENV = 'TOTK_SETUP_RECENT'
|
||||
FILES = ('base', 'update', 'keys') # recorded when they are files
|
||||
SOURCES = FILES + ('firmware',)
|
||||
FIELDS = ('dest',) + SOURCES
|
||||
|
||||
|
||||
def path():
|
||||
override = os.environ.get(ENV)
|
||||
if override:
|
||||
return Path(override)
|
||||
if sys.platform == 'win32':
|
||||
base = os.environ.get('LOCALAPPDATA') or str(Path.home() / 'AppData' / 'Local')
|
||||
return Path(base) / 'TotK Recomp' / 'setup.json'
|
||||
base = os.environ.get('XDG_STATE_HOME') or str(Path.home() / '.local' / 'state')
|
||||
return Path(base) / 'totk-recomp' / 'setup.json'
|
||||
|
||||
|
||||
def usable(name, value):
|
||||
"""value when it is a non-empty absolute path of the right kind for the field, else None. One stat call per
|
||||
path, no file is opened or hashed; a path the OS rejects (embedded NUL, unreachable share) counts as missing."""
|
||||
if not isinstance(value, str) or not value or not os.path.isabs(value):
|
||||
return None
|
||||
try:
|
||||
p = Path(value)
|
||||
if name == 'dest':
|
||||
ok = (p / state.NAME).is_file()
|
||||
elif name == 'firmware':
|
||||
ok = p.is_dir()
|
||||
else:
|
||||
ok = p.is_file()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return value if ok else None
|
||||
|
||||
|
||||
def read():
|
||||
try:
|
||||
data = json.loads(path().read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def load():
|
||||
"""The recorded entries that still exist, by field name. Never raises."""
|
||||
data = read()
|
||||
return {name: data[name] for name in FIELDS if usable(name, data.get(name))}
|
||||
|
||||
|
||||
def save(dest, sources=None):
|
||||
"""Records dest and the non-empty, existing entries of sources (base, update, keys, firmware); an entry left out
|
||||
or empty keeps its saved value. Atomic; a failure to write is ignored, the window works without it."""
|
||||
target = path()
|
||||
tmp = target.with_name(target.name + '.tmp')
|
||||
data = {name: v for name, v in read().items() if name in FIELDS and isinstance(v, str)}
|
||||
data['dest'] = os.path.abspath(dest)
|
||||
for name, value in (sources or {}).items():
|
||||
if name in SOURCES and isinstance(value, str) and value.strip():
|
||||
value = os.path.abspath(value.strip())
|
||||
if usable(name, value):
|
||||
data[name] = value
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp.write_text(json.dumps({'format': FORMAT, **data}), encoding='utf-8')
|
||||
os.replace(tmp, target)
|
||||
except OSError:
|
||||
pass
|
||||
+51
-21
@@ -7,7 +7,8 @@ recomp/gen.py, tools/build_game.py, tools/make_romfs_patches.py and tools/prepar
|
||||
with the bundled clang (hostcheck.c).
|
||||
|
||||
Install folder: exefs/, romfs/ (romfs.bin, index.json), profile/, shared-fonts/ (Chinese and Korean text), totk.exe
|
||||
and its DLLs, nvngx_dlss.dll (with --dlss), romfs-index.bin, romfs-patches/, romfs-patches-xbox/, romfs-patches-xbox-label/, totk_xlate.exe, the
|
||||
and its DLLs, nvngx_dlss.dll (with --dlss), romfs-index.bin, romfs-patches/, romfs-patches-xbox/,
|
||||
romfs-patches-xbox-label/, romfs-patches-pc/, totk_xlate.exe, the
|
||||
shader state of totk.exe --prepare-shaders, totk.ini, totk-install.json.
|
||||
Temp folder (<temp>/<dest name>-<hash>): check/ (ExeFS of both inputs, the host check), unpacked/ (the NSP of an NSZ
|
||||
or XCZ input until the extraction is done), gen/ (C), build/ (objects), logs/. The cleanup keeps gen/, build/ and
|
||||
@@ -15,9 +16,10 @@ logs/: the next run lifts into the same gen/, where recomp/gen.py rewrites only
|
||||
in build/ recompiles only those. A change of the mods' code cheats then costs the lift (about a minute) and the chunks
|
||||
it touches, not a full compile.
|
||||
|
||||
Install folder build-cache/: cheats.status (tools/cheats.py), exefs.status (tools/exefs_patches.py) and rebuild.txt, the command that starts this setup in
|
||||
its rebuild mode on the install folder (installer/gui.py --rebuild), one argument per line; totk.exe runs it at start
|
||||
when an enabled mod has code cheats the build lacks (runtime/cheats.c).
|
||||
Install folder build-cache/: cheats.status (tools/cheats.py), exefs.status and exefs.data (tools/exefs_patches.py) and
|
||||
rebuild.txt, the command that starts this setup in its rebuild mode on the install folder (installer/gui.py --rebuild),
|
||||
one argument per line; totk.exe runs it at start when an enabled mod has code cheats the build lacks
|
||||
(runtime/cheats.c).
|
||||
"""
|
||||
import hashlib
|
||||
import http.client
|
||||
@@ -854,6 +856,12 @@ def exefs_status(ctx):
|
||||
return ctx.dest / 'build-cache' / 'exefs.status'
|
||||
|
||||
|
||||
def exefs_data(ctx):
|
||||
"""The data writes of the mods' ExeFS patch files (tools/exefs_patches.py --data), which runtime/mods.c stores into
|
||||
main before the game runs."""
|
||||
return ctx.dest / 'build-cache' / 'exefs.data'
|
||||
|
||||
|
||||
def cheats_status(ctx):
|
||||
"""The build's cheat status file (tools/cheats.py --status), which runtime/cheats.c reads: in build-cache, not in a
|
||||
mod's own cheats folder, which the player (or TKMM's merge) may empty."""
|
||||
@@ -868,7 +876,7 @@ class Lift(Step):
|
||||
# fingerprint takes a file's name and content only; the paths under mods/ go in too, because tools/cheats.py
|
||||
# and tools/exefs_patches.py name each patch set after its mod folder, so a renamed folder needs a lift
|
||||
files = cheat_files(ctx) + exefs_files(ctx)
|
||||
return fingerprint('lift-4', ctx.keys['extract'], ctx.layout.recomp, ctx.layout.patches(game_version(ctx)),
|
||||
return fingerprint('lift-5', ctx.keys['extract'], ctx.layout.recomp, ctx.layout.patches(game_version(ctx)),
|
||||
ctx.layout.tools / 'cheats.py', ctx.layout.tools / 'exefs_patches.py', *files,
|
||||
*(f.relative_to(ctx.dest / 'mods').as_posix() for f in files))
|
||||
|
||||
@@ -889,36 +897,54 @@ class Lift(Step):
|
||||
if m:
|
||||
rep.progress('lift', 'emit', int(m.group(1)), int(m.group(2)), 'chunks')
|
||||
|
||||
# The code cheats of the mods' cheat files and the mods' ExeFS patch files become patch sets (tools/cheats.py,
|
||||
# tools/exefs_patches.py); their status files tell the runtime what the build has. Both are the player's
|
||||
# downloads, so files the tools refuse cost those cheats or patches, not the install: the game is built
|
||||
# without them and the setup warns.
|
||||
# The code cheats of the mods' cheat files and the mods' ExeFS patch files become patch sets and data writes
|
||||
# (tools/cheats.py, tools/exefs_patches.py); their status files tell the runtime what the build has. Both are
|
||||
# the player's downloads, so files the tools refuse cost those cheats or patches, not the install: the game is
|
||||
# built without them and the setup warns.
|
||||
plain = layout.patches(game_version(ctx))
|
||||
stages = [('cheats.py', 'patches-cheats.txt', cheats_status(ctx), cheat_files(ctx), 'warn.cheats_skipped'),
|
||||
('exefs_patches.py', 'patches-exefs.txt', exefs_status(ctx), exefs_files(ctx), 'warn.exefs_skipped')]
|
||||
# (tool, patch file it writes, its outputs for the runtime: the status first, then the data file, the files it
|
||||
# reads, the warning when it fails)
|
||||
stages = [('cheats.py', 'patches-cheats.txt', [cheats_status(ctx)], cheat_files(ctx), 'warn.cheats_skipped'),
|
||||
('exefs_patches.py', 'patches-exefs.txt', [exefs_status(ctx), exefs_data(ctx)], exefs_files(ctx),
|
||||
'warn.exefs_skipped')]
|
||||
patches, skipped = plain, {}
|
||||
for tool, out, status, inputs, warn in stages:
|
||||
status.unlink(missing_ok=True) # no status: the runtime treats every such file as not built
|
||||
|
||||
def drop(outputs): # no status: the runtime treats every such file as not built
|
||||
for f in outputs:
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
for tool, out, outputs, inputs, warn in stages:
|
||||
drop(outputs)
|
||||
if not inputs:
|
||||
continue
|
||||
code, log = run_tool(ctx, 'lift', layout.tools / tool,
|
||||
['--exefs', ctx.dest / 'exefs', '--mods', ctx.dest / 'mods', '--patches', patches,
|
||||
'--out', ctx.temp / out, '--status', status])
|
||||
'--out', ctx.temp / out, '--status', outputs[0],
|
||||
*(['--data', outputs[1]] if len(outputs) > 1 else [])])
|
||||
if code:
|
||||
status.unlink(missing_ok=True)
|
||||
drop(outputs)
|
||||
skipped[warn] = log
|
||||
else:
|
||||
patches = ctx.temp / out
|
||||
args = ['--exefs', ctx.dest / 'exefs', '--out', gen]
|
||||
code, log = run_tool(ctx, 'lift', layout.recomp / 'gen.py', args + ['--patches', patches], parse)
|
||||
if code and patches != plain:
|
||||
for tool, out, status, inputs, warn in stages:
|
||||
for tool, out, outputs, inputs, warn in stages:
|
||||
if inputs and warn not in skipped:
|
||||
status.unlink(missing_ok=True)
|
||||
drop(outputs)
|
||||
skipped[warn] = log
|
||||
code, log = run_tool(ctx, 'lift', layout.recomp / 'gen.py', args + ['--patches', plain], parse)
|
||||
if code:
|
||||
raise InstallError('lift.failed', code=code, log=str(log))
|
||||
# The mods' data writes must stay off the bytes the lifted code reads as constants (its jump tables), which only
|
||||
# the lift knows: gen.py lists them in folded.txt, and a file that writes them becomes unsupported
|
||||
if exefs_data(ctx).is_file():
|
||||
code, log = run_tool(ctx, 'lift', layout.tools / 'exefs_patches.py',
|
||||
['--exefs', ctx.dest / 'exefs', '--status', exefs_status(ctx), '--data',
|
||||
exefs_data(ctx), '--folded', gen / 'main' / 'folded.txt'])
|
||||
if code:
|
||||
drop([exefs_status(ctx), exefs_data(ctx)])
|
||||
skipped.setdefault('warn.exefs_skipped', log)
|
||||
for warn, log in skipped.items():
|
||||
rep.warning(warn, log=str(log))
|
||||
(gen / STAMP).write_text(ctx.keys['lift'] + '\n')
|
||||
@@ -944,7 +970,8 @@ class Compile(Step):
|
||||
id = 'compile'
|
||||
|
||||
def key(self, ctx):
|
||||
return fingerprint('compile-3', ctx.keys['lift'], runtime_key(ctx))
|
||||
return fingerprint('compile-3', ctx.keys['lift'], runtime_key(ctx),
|
||||
ctx.layout.pgo(game_version(ctx)) or 'no profile')
|
||||
|
||||
def current(self, ctx, record):
|
||||
exe = ctx.dest / ('totk' + EXE)
|
||||
@@ -970,10 +997,12 @@ class Compile(Step):
|
||||
rep.progress('compile', 'compile', force=True)
|
||||
cpu = getattr(ctx, 'cpu', 'v3')
|
||||
lib = layout.runtime_lib_for(cpu)
|
||||
pgo = layout.pgo(game_version(ctx))
|
||||
code, log = run_tool(ctx, 'compile', layout.tools / 'build_game.py', [
|
||||
'--gen', ctx.temp / 'gen', '--out', build, '--toolchain', layout.llvm, '--cpu', cpu,
|
||||
*(['--deps', layout.deps] if layout.deps else []), *(['--sysroot', layout.sysroot] if layout.sysroot else []),
|
||||
'--runtime', layout.runtime, *(['--runtime-lib', lib] if lib else ()), '--ninja', layout.ninja,
|
||||
'--runtime', layout.runtime, *(['--runtime-lib', lib] if lib else ()), *(['--pgo', pgo] if pgo else ()),
|
||||
'--ninja', layout.ninja,
|
||||
'--version', version_tag(game_version(ctx)), '--expected', layout.expected(game_version(ctx)),
|
||||
'--exefs', dest / 'exefs', '--romfs', dest / 'romfs',
|
||||
'--profile', dest / 'profile'], parse)
|
||||
@@ -1067,9 +1096,10 @@ class Prepare(Step):
|
||||
# romfs-patches-xbox for the Nintendo pad layout, romfs-patches-xbox-label for xbox and auto (runtime/input_map.c
|
||||
# input_prompts_folder). Both are written on every install, so the menu choices work without another run of
|
||||
# the setup. Optional: when one fails, the game keeps the Switch prompts with that layout and the menu says the
|
||||
# files are missing, so the setup warns once and goes on.
|
||||
# files are missing, so the setup warns once and goes on. romfs-patches-pc likewise, with keyboard and mouse
|
||||
# prompts for prompts = pc (16:9 only: another shape takes make_romfs_patches.py --prompts pc --aspect).
|
||||
failed = None
|
||||
for style in ('xbox', 'xbox-label'):
|
||||
for style in ('xbox', 'xbox-label', 'pc'):
|
||||
out = dest / f'romfs-patches-{style}'
|
||||
remove(out)
|
||||
code, log = run_tool(ctx, f'prepare-{style}', layout.tools / 'make_romfs_patches.py',
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"warn.npdm_differs": "main.npdm differs from the reference {version} dump; only the code modules are used.",
|
||||
"warn.cheats_skipped": "The cheats of the mods folder that change the game's code could not be built; the game installs without them. Details: {log}",
|
||||
"warn.exefs_skipped": "The mods' ExeFS patches (.ips, .pchtxt) could not be built; the game installs without them. Details: {log}",
|
||||
"warn.xbox_prompts_failed": "The Xbox button prompts could not be prepared (exit code {code}); the game keeps the Switch prompts. Details: {log}",
|
||||
"warn.xbox_prompts_failed": "The Xbox or PC button prompts could not be prepared (exit code {code}); the game keeps the Switch prompts. Details: {log}",
|
||||
"warn.shaders_skipped": "The graphics driver crashed while building some shaders, so {count} were left out of the preparation. The game builds them when it first needs them, and the driver may crash there too; a driver update may fix it. Details: {log}",
|
||||
"warn.kernel_old": "Linux {version} lacks the write tracking the game relies on (6.7 or later); the game may run very slowly. Updating the system fixes this.",
|
||||
"warn.dlss_kept": "A DLSS library is already in the install folder ({path}); the setup keeps it.",
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"warn.cheats_skipped": "Não foi possível compilar os cheats da pasta de mods que alteram o código do jogo; o jogo é instalado sem eles. Detalhes: {log}",
|
||||
"warn.exefs_skipped": "Não foi possível compilar os patches ExeFS dos mods (.ips, .pchtxt); o jogo é instalado sem eles. Detalhes: {log}",
|
||||
"warn.npdm_differs": "O main.npdm difere do dump de referência da versão {version}; só os módulos de código são usados.",
|
||||
"warn.xbox_prompts_failed": "Não foi possível preparar os ícones de botão do Xbox (código de saída {code}); o jogo mantém os ícones do Switch. Detalhes: {log}",
|
||||
"warn.xbox_prompts_failed": "Não foi possível preparar os ícones de botão do Xbox ou do PC (código de saída {code}); o jogo mantém os ícones do Switch. Detalhes: {log}",
|
||||
"warn.shaders_skipped": "O driver gráfico travou ao compilar alguns shaders, então {count} ficaram fora da preparação. O jogo os compila quando precisar deles pela primeira vez, e o driver pode travar ali também; uma atualização do driver pode resolver. Detalhes: {log}",
|
||||
"warn.kernel_old": "O Linux {version} não tem o rastreamento de escrita de que o jogo depende (6.7 ou mais novo); o jogo pode rodar muito devagar. Atualizar o sistema resolve.",
|
||||
"warn.dlss_kept": "Já existe uma biblioteca DLSS na pasta de instalação ({path}); a instalação a mantém.",
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"warn.npdm_differs": "main.npdm отличается от эталонного дампа {version}; используются только модули с кодом.",
|
||||
"warn.cheats_skipped": "Не удалось собрать читы из папки модов, меняющие код игры; игра установится без них. Подробности: {log}",
|
||||
"warn.exefs_skipped": "Не удалось собрать ExeFS-патчи модов (.ips, .pchtxt); игра установится без них. Подробности: {log}",
|
||||
"warn.xbox_prompts_failed": "Не удалось подготовить подсказки кнопок Xbox (код выхода {code}); игра оставит подсказки Switch. Подробности: {log}",
|
||||
"warn.xbox_prompts_failed": "Не удалось подготовить подсказки кнопок Xbox или PC (код выхода {code}); игра оставит подсказки Switch. Подробности: {log}",
|
||||
"warn.shaders_skipped": "Графический драйвер аварийно завершился при сборке части шейдеров, поэтому {count} из них пропущены при подготовке. Игра соберёт их, когда они впервые понадобятся, и драйвер может упасть и там; обновление драйвера может это исправить. Подробности: {log}",
|
||||
"warn.kernel_old": "В Linux {version} нет отслеживания записи, на которое опирается игра (нужна версия 6.7 или новее); игра может работать очень медленно. Обновление системы это исправит.",
|
||||
"warn.dlss_kept": "В папке установки уже есть библиотека DLSS ({path}); установка её оставляет.",
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"warn.cheats_skipped": "无法构建 mods 文件夹中修改游戏代码的金手指;游戏将不含它们进行安装。详情:{log}",
|
||||
"warn.exefs_skipped": "无法构建模组的 ExeFS 补丁(.ips、.pchtxt);游戏将不含它们进行安装。详情:{log}",
|
||||
"warn.npdm_differs": "main.npdm 与参考的 {version} 导出不同;只使用其中的代码模块。",
|
||||
"warn.xbox_prompts_failed": "无法准备 Xbox 按键提示(退出代码 {code});游戏将继续使用 Switch 按键提示。详情:{log}",
|
||||
"warn.xbox_prompts_failed": "无法准备 Xbox 或 PC 按键提示(退出代码 {code});游戏将继续使用 Switch 按键提示。详情:{log}",
|
||||
"warn.shaders_skipped": "显卡驱动在编译部分着色器时崩溃,因此有 {count} 个着色器未被预先准备。游戏会在首次需要时编译它们,驱动在那时也可能崩溃;更新驱动可能会解决。详情:{log}",
|
||||
"warn.kernel_old": "Linux {version} 缺少游戏依赖的写入跟踪功能(需要 6.7 或更新版本);游戏可能运行得很慢。更新系统即可解决。",
|
||||
"warn.dlss_kept": "安装文件夹中已有 DLSS 库({path});安装程序将保留它。",
|
||||
|
||||
+41
-2
@@ -9,6 +9,9 @@ Output, under --out:
|
||||
<mod>/<mod>_NNNN.c one `void f_<addr>(Cpu *c)` per guest function, about --chunk-bytes of C per file
|
||||
<mod>/<mod>_table.c sorted <mod>_fn_addrs[] / <mod>_fn_ptrs[] / <mod>_fn_count for rt_dispatch
|
||||
<mod>/manifest.json chunk list, function count, statistics and timings
|
||||
<mod>/folded.txt the module's bytes the generated code took as constants: what the jump tables read at
|
||||
lift time, as module offsets [start, end); tools/exefs_patches.py keeps a mod's data
|
||||
writes off them
|
||||
patches.h code patch sets for the runtime (recomp/patches.py; no sets without --patches)
|
||||
|
||||
Function discovery runs in this process, vectorised over the module's .text words. Function starts are the module
|
||||
@@ -555,7 +558,8 @@ def _retry(fn, *args):
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ workers
|
||||
|
||||
_W = {} # worker state: 'mods' {name: (path, base)}, 'starts' {name: sorted array}, 'emitters' {name: Emitter}
|
||||
_W = {} # worker state: 'mods' {name: (path, base)}, 'starts' {name: sorted array}, 'emitters' {name: Emitter},
|
||||
# 'reads' {name: [(start, end)]} module bytes read since the last chunk
|
||||
|
||||
|
||||
def _worker_init(mods, starts, table, opts, jobs):
|
||||
@@ -565,6 +569,7 @@ def _worker_init(mods, starts, table, opts, jobs):
|
||||
_W['patches'] = table
|
||||
_W['opts'] = opts # Emitter keyword arguments
|
||||
_W['emitters'] = {}
|
||||
_W['reads'] = {}
|
||||
|
||||
|
||||
def _emitter(name):
|
||||
@@ -572,12 +577,31 @@ def _emitter(name):
|
||||
if e is None:
|
||||
path, base = _W['mods'][name]
|
||||
mod = Module(name, path, base, 'c')
|
||||
# Module.read is how the jump tables (recomp/jumptables.py _eval) read .text and .rodata at lift time: the
|
||||
# bytes it returns become part of the generated code, so <mod>/folded.txt lists them
|
||||
read, reads = mod.read, _W['reads'].setdefault(name, [])
|
||||
|
||||
def recorded(a, n, signed=False):
|
||||
reads.append((a - base, a - base + n))
|
||||
return read(a, n, signed=signed)
|
||||
mod.read = recorded
|
||||
e = _W['emitters'][name] = Emitter(mod, set(_W['starts'][name].tolist()),
|
||||
_W['patches'].for_module(name, base),
|
||||
spins=_W['patches'].spins_for(name, base), **_W['opts'])
|
||||
return e
|
||||
|
||||
|
||||
def merge_ranges(ranges):
|
||||
"""Sorted [start, end) ranges with overlapping and touching ones joined."""
|
||||
out = []
|
||||
for s, e in sorted(ranges):
|
||||
if out and s <= out[-1][1]:
|
||||
out[-1][1] = max(out[-1][1], e)
|
||||
else:
|
||||
out.append([s, e])
|
||||
return out
|
||||
|
||||
|
||||
def _emit_chunk(task):
|
||||
"""Worker: write one chunk. Returns its statistics."""
|
||||
name, number, path, fns = task
|
||||
@@ -595,9 +619,12 @@ def _emit_chunk(task):
|
||||
written = write_if_changed(Path(path), text)
|
||||
except OSError as ex:
|
||||
written, error = False, f'{path}: {ex}'
|
||||
reads = _W['reads'].get(name, [])
|
||||
folded = merge_ranges(reads)
|
||||
reads.clear()
|
||||
return {'module': name, 'number': number, 'file': Path(path).name, 'functions': len(fns), 'bytes': len(text),
|
||||
'written': written, 'error': error, 'seconds': time.perf_counter() - t0, 'stats': dict(e.stats),
|
||||
'unsupported': dict(e.unsupported)}
|
||||
'unsupported': dict(e.unsupported), 'folded': folded}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ driver
|
||||
@@ -613,6 +640,14 @@ def table_text(name, addrs):
|
||||
return ''.join(lines)
|
||||
|
||||
|
||||
def folded_text(name, ranges):
|
||||
"""<mod>/folded.txt: the module offsets [start, end) of the bytes the jump tables read at lift time, in hex."""
|
||||
lines = [f'# {GENERATED_MARKER}: {name} bytes the generated code took as constants (jump tables), module offsets '
|
||||
f'[start, end), generated by recomp/gen.py from game code. Do not edit or commit.']
|
||||
lines += [f'{s:x} {e:x}' for s, e in ranges]
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
|
||||
def remove_stale(moddir, name, nchunks, errors):
|
||||
"""Delete chunks an earlier run wrote beyond this run's count, and temporary files an interrupted run left."""
|
||||
pat = re.compile(re.escape(name) + r'_(\d{4})\.c')
|
||||
@@ -714,6 +749,7 @@ def main():
|
||||
# Largest chunks first, so the pool does not end on a long tail.
|
||||
tasks.sort(key=lambda t: -t[0])
|
||||
results = {name: [] for name in info}
|
||||
folded = {name: [] for name in info}
|
||||
done_at, errors = {}, []
|
||||
t_emit = time.perf_counter()
|
||||
ctx = mp.get_context('spawn')
|
||||
@@ -724,9 +760,11 @@ def main():
|
||||
print(f'emitting with {jobs} workers ({why})', file=sys.stderr, flush=True)
|
||||
try:
|
||||
# Chunk writes are atomic and idempotent, so a chunk lost with a failed pool is simply emitted again.
|
||||
# Its folded bytes come with its result: a chunk emitted again reads them again in its new worker.
|
||||
with ctx.Pool(jobs, initializer=_worker_init, initargs=(paths, starts_abs, table, opts, jobs)) as pool:
|
||||
for r in pool.imap_unordered(_emit_chunk, pending, chunksize=1):
|
||||
results[r['module']].append(r)
|
||||
folded[r['module']] += r['folded']
|
||||
if r['error']:
|
||||
errors.append(r['error'])
|
||||
done_at[r['module']] = time.perf_counter() - t_emit
|
||||
@@ -753,6 +791,7 @@ def main():
|
||||
unsupported.update(r['unsupported'])
|
||||
t = time.perf_counter()
|
||||
table_written = write_if_changed(moddir / f'{name}_table.c', table_text(name, d['emitted']))
|
||||
write_if_changed(moddir / 'folded.txt', folded_text(name, merge_ranges(folded[name])))
|
||||
removed = remove_stale(moddir, name, d['chunks'], errors)
|
||||
manifest = {
|
||||
'module': name,
|
||||
|
||||
@@ -55,6 +55,16 @@ site cloth main 8222f0 9401d7cf @page:x8 bd400104 1e240842 1e240863 9401d7cf #
|
||||
# 100; compare +0x28 * +0x38 (30 / time scale) instead, the same value at 30 Hz and independent of the frame rate.
|
||||
site bounce main bda798 bd402900 bd402900 bd403901 1e210800 # ldr s0, [x8, #0x28]; ldr s1, [x8, #0x38]; fmul s0, s0, s1
|
||||
|
||||
# Cursor and aim steps (upstream game_patches.c patch_cursor_speed, 363a6eb). These updates read the game's 30 Hz frame
|
||||
# ratio for their smoothing but leave it out of the step itself, so the map cursor and the vertical Ultrahand aim moved
|
||||
# 2x as fast at 60 fps and 4x at 120 as at the shipping 30 fps (issue #44). Each site multiplies the step by that ratio,
|
||||
# so the speed is the shipping one at any frame rate. x9 and s16 are free: the next read of x9 follows a write, the
|
||||
# function does not touch s16, and x20 keeps the update's frame object for the whole function.
|
||||
set cursor TOTK_HIGHFPS_FIX 1
|
||||
site cursor main ec3d9c bd400361 f94002e9 bd400930 1e300800 bd400361 # map pan speed s0 *= [[x23] + 8], then ldr s1, [x27]
|
||||
site cursor main 1d48d64 1e2a198c 1e2a198c bd400a90 1e30098c # fdiv s12, s12, s10, then s12 *= [x20 + 8]: vertical Ultrahand aim
|
||||
site cursor main 1d484e4 1e200923 1e200923 bd400a90 1e300863 # fmul s3, s9, s0, then s3 *= [x20 + 8]: vertical gyro aim (Recall)
|
||||
|
||||
# Output size (upstream games/totk/runtime/game_patches.c patch_output_res; runtime/resolution.c switches the set on).
|
||||
# The swapchain size nnMain writes before NvInitializeGraphics comes from page +48 ({w, h} as one 64-bit value), and
|
||||
# the output size the renderer lays out its canvas, crop and UI in (main+0xcc3f00, read from the graphics parameters
|
||||
@@ -93,6 +103,104 @@ set pooloff TOTK_POOL_OFFSET_PATCH 1
|
||||
site pooloff main ad43f0 93407f22 2a1903e2 # sxtw x2, w25 -> mov w2, w25 (SetStorage offset)
|
||||
site pooloff main ad440c 8b39c101 8b394101 # add x1, x8, w25, sxtw -> add x1, x8, w25, uxtw (packaged data source)
|
||||
|
||||
# Direct mouse camera (runtime/mouse_direct.c; the same hooks as patches-v143.txt, mapped by enclosing function to the
|
||||
# 1.2.1 main, each original word checked). The camera input update (main+0xd18088, 1.4.3 0x874c1c) after its dead zone,
|
||||
# clamp and StickExponent keeps x in s11 (1.4.3 s9); the host callback performs the displaced store and adds raw mouse
|
||||
# counts. The free orbit (main+0xae28b8) keeps its final angles at [sp+0x3c] / [sp+0x40], Ultrahand's curve results
|
||||
# (main+0x1768ebc) are s9 and s15. The host functions are at page +0xfd0 .. +0xf80; the page is main+0x472b000.
|
||||
set mouse_direct TOTK_MOUSE_DIRECT_PATCH 1
|
||||
site mouse_direct main d18140 bd04228b 94e84fa4 # camera input: str s11, [x20, #0x420]
|
||||
site mouse_direct main d181fc 6d4123e9 94e84f71 # camera input activity: ldp d9, d8, [sp, #0x10]
|
||||
site mouse_direct main ae28e8 f9400408 94f125b2 # free orbit begin: ldr x8, [x0, #8]
|
||||
site mouse_direct main ae355c bd403fed 94f12291 # free orbit angles: ldr s13, [sp, #0x3c]
|
||||
site mouse_direct main 176949c b9001be9 94bf0abd # Ultrahand input: str w9, [sp, #0x18]
|
||||
# The aiming, scope, ability, horse and Purah Pad cameras: per axis, the stick load and the angle the turn is added to,
|
||||
# each a bl to page +0xf80 told apart by the return address (runtime/mouse_direct.c load_sites_121). Where 1.2.1 adds
|
||||
# the turn in a shared helper (main+0x1bb70e8, 0x1bd0d00: *p = wrap(*p + s0)), the hook is on the turn the helper takes
|
||||
# (Horse) or on the store of the helper's result (Aiming's pitch target). Every enclosing function saves x30 in its
|
||||
# prologue and reads it only to return.
|
||||
site mouse_direct main 1d34338 bd4426c1 94a7df12 # Aiming: ldr s1, [x22, #0x424] (stick y)
|
||||
site mouse_direct main 1d34374 bd015660 94a7df03 # Aiming: str s0, [x19, #0x154] (pitch target: +0x15c + turn from a helper)
|
||||
site mouse_direct main 1d34584 bd4422c1 94a7de7f # Aiming: ldr s1, [x22, #0x420] (stick x)
|
||||
site mouse_direct main 1d345d0 bc5b83a1 94a7de6c # Aiming: ldur s1, [x29, #-0x48] (yaw)
|
||||
site mouse_direct main 1d35fd4 bd4422e0 94a7d7eb # AimingTelescope: ldr s0, [x23, #0x420] (stick x)
|
||||
site mouse_direct main 1d35fe0 bd4426e9 94a7d7e8 # AimingTelescope: ldr s9, [x23, #0x424] (stick y)
|
||||
site mouse_direct main 1d36280 bd416260 94a7d740 # AimingTelescope: ldr s0, [x19, #0x160] (pitch)
|
||||
site mouse_direct main 1d36224 bd405be1 94a7d757 # AimingTelescope: ldr s1, [sp, #0x58] (yaw)
|
||||
site mouse_direct main 1d48478 bd4422cf 94a78ec2 # SpecialPower: ldr s15, [x22, #0x420] (stick x)
|
||||
site mouse_direct main 1d48480 bd4426cc 94a78ec0 # SpecialPower: ldr s12, [x22, #0x424] (stick y)
|
||||
site mouse_direct main 1d48bb4 bd416262 94a78cf3 # SpecialPower: ldr s2, [x19, #0x160] (pitch)
|
||||
site mouse_direct main 1d48b38 bc5a83a1 94a78d12 # SpecialPower: ldur s1, [x29, #-0x58] (yaw)
|
||||
site mouse_direct main 1d4070c bd442288 94a7ae1d # Horse: ldr s8, [x20, #0x420] (stick x)
|
||||
site mouse_direct main 1d40740 bd401be0 94a7ae10 # Horse: ldr s0, [sp, #0x18] (yaw turn, which a helper adds to +0x68)
|
||||
site mouse_direct main 1d408f8 bd4426a8 94a7ada2 # Horse: ldr s8, [x21, #0x424] (stick y)
|
||||
site mouse_direct main 1d409ac bd406661 94a7ad75 # Horse: ldr s1, [x19, #0x64] (pitch)
|
||||
site mouse_direct main 1d48ccc bd4422c9 94a78cad # SpecialPower offset mode: ldr s9, [x22, #0x420] (stick x)
|
||||
site mouse_direct main 1d48cf0 bd41da61 94a78ca4 # SpecialPower offset mode: ldr s1, [x19, #0x1d8] (offset x)
|
||||
site mouse_direct main 1d48cfc bd4426c9 94a78ca1 # SpecialPower offset mode: ldr s9, [x22, #0x424] (stick y)
|
||||
site mouse_direct main 1d48d18 bd41de61 94a78c9a # SpecialPower offset mode: ldr s1, [x19, #0x1dc] (offset y)
|
||||
site mouse_direct main 1d485f8 bd400400 94a78e62 # SpecialPower: ldr s0, [x0, #0x4] (offset scale, read only)
|
||||
site mouse_direct main 1d37b44 bd4426ad 94a7d10f # CameraFinder: ldr s13, [x21, #0x424] (stick y)
|
||||
site mouse_direct main 1d37b68 bd405261 94a7d106 # CameraFinder: ldr s1, [x19, #0x50] (pitch)
|
||||
site mouse_direct main 1d37bcc bd4422ad 94a7d0ed # CameraFinder: ldr s13, [x21, #0x420] (stick x)
|
||||
site mouse_direct main 1d37bf4 bd405be1 94a7d0e3 # CameraFinder: ldr s1, [sp, #0x58] (yaw)
|
||||
# The other player cameras. ClimbObj, EventMultiTalk and EventTalkManual load an angle with a pre-indexed load whose
|
||||
# write-back the code after it needs; the mov before it adds the offset instead and the hook loads [base] (no register
|
||||
# between the two words, no branch into them). Wakeboard, ShieldSurfing and Tail read the stick twice, once for its
|
||||
# length and once for its angle: the first pair gives the same values without degrees of its own (REPEAT).
|
||||
site mouse_direct main 1d38e08 bd442301 94a7cc5e # ClimbObj: ldr s1, [x24, #0x420]
|
||||
site mouse_direct main 1d38e10 bd409261 94a7cc5c # ClimbObj: ldr s1, [x19, #0x90] (yaw)
|
||||
site mouse_direct main 1d38dac bd442701 94a7cc75 # ClimbObj: ldr s1, [x24, #0x424]
|
||||
site mouse_direct main 1d38da4 aa1303f6 91022276 # ClimbObj: mov x22, x19 -> add x22, x19, #0x88
|
||||
site mouse_direct main 1d38db8 bc488ec1 94a7cc72 # ClimbObj: ldr s1, [x22, #0x88]! (pitch), as ldr s1, [x22]
|
||||
site mouse_direct main 1d50738 bd4422c0 94a76e12 # Wakeboard: ldr s0, [x22, #0x420] (x for the length, REPEAT)
|
||||
site mouse_direct main 1d50740 bd4426c1 94a76e10 # Wakeboard: ldr s1, [x22, #0x424] (y for the length, REPEAT)
|
||||
site mouse_direct main 1d50768 bd4426c0 94a76e06 # Wakeboard: ldr s0, [x22, #0x424] (stick y, kept)
|
||||
site mouse_direct main 1d5076c bd4422c1 94a76e05 # Wakeboard: ldr s1, [x22, #0x420]
|
||||
site mouse_direct main 1d50e5c bd40da60 94a76c49 # Wakeboard: ldr s0, [x19, #0xd8] (pitch)
|
||||
site mouse_direct main 1d508bc bd401fa0 94a76db1 # Wakeboard: ldr s0, [x29, #0x1c] (pitch turn, cut to the pad)
|
||||
site mouse_direct main 1d50f20 bd403bec 94a76c18 # Wakeboard: ldr s12, [sp, #0x38] (yaw)
|
||||
site mouse_direct main 1d51e10 bd4422cd 94a7685c # WaterfallClimb: ldr s13, [x22, #0x420]
|
||||
site mouse_direct main 1d51fb8 b9403be8 94a767f2 # WaterfallClimb: ldr w8, [sp, #0x38] (yaw)
|
||||
site mouse_direct main 1d51f48 bd4426c9 94a7680e # WaterfallClimb: ldr s9, [x22, #0x424]
|
||||
site mouse_direct main 1d4a11c bd442280 94a78799 # Tail: ldr s0, [x20, #0x420] (x for the length, REPEAT)
|
||||
site mouse_direct main 1d4a124 bd442681 94a78797 # Tail: ldr s1, [x20, #0x424] (y for the length, REPEAT)
|
||||
site mouse_direct main 1d4a14c bd442680 94a7878d # Tail (climbing): ldr s0, [x20, #0x424] (stick y)
|
||||
site mouse_direct main 1d4a150 bd442281 94a7878c # Tail: ldr s1, [x20, #0x420]
|
||||
site mouse_direct main 1d4a4a0 bd40c260 94a786b8 # Tail (climbing): ldr s0, [x19, #0xc0] (yaw)
|
||||
site mouse_direct main 1d4a514 bd40b661 94a7869b # Tail (climbing): ldr s1, [x19, #0xb4] (pitch)
|
||||
site mouse_direct main 1d4c930 bd442320 94a77d94 # LockOn / Target: ldr s0, [x25, #0x420]
|
||||
site mouse_direct main 1d4c958 bd442321 94a77d8a # LockOn / Target: ldr s1, [x25, #0x420] (stick x)
|
||||
site mouse_direct main 1d4c97c bd402be0 94a77d81 # LockOn / Target: ldr s0, [sp, #0x28] (yaw turn)
|
||||
site mouse_direct main 1d4c98c bd402be9 94a77d7d # LockOn / Target: ldr s9, [sp, #0x28] (yaw turn)
|
||||
site mouse_direct main 1d4cd5c bd442721 94a77c89 # LockOn / Target: ldr s1, [x25, #0x424] (stick y)
|
||||
site mouse_direct main 1d4cd80 bd402be0 94a77c80 # LockOn / Target: ldr s0, [sp, #0x28] (pitch turn)
|
||||
site mouse_direct main 1d4cd90 bd402beb 94a77c7c # LockOn / Target: ldr s11, [sp, #0x28] (pitch turn)
|
||||
site mouse_direct main 1d443e8 bd4426a9 94a79ee6 # LockOnAimingAt: ldr s9, [x21, #0x424] (stick y)
|
||||
site mouse_direct main 1d44430 bd419a61 94a79ed4 # LockOnAimingAt: ldr s1, [x19, #0x198] (pitch offset)
|
||||
site mouse_direct main 1d44444 bd4422a9 94a79ecf # LockOnAimingAt: ldr s9, [x21, #0x420] (stick x)
|
||||
site mouse_direct main 1d44490 bd419e61 94a79ebc # LockOnAimingAt: ldr s1, [x19, #0x19c] (yaw offset)
|
||||
site mouse_direct main 1d418dc bd44234b 94a7a9a9 # HorseLockOnEmpty: ldr s11, [x26, #0x420]
|
||||
site mouse_direct main 1d3a484 bd44230b 94a7c6bf # EventMultiTalk: ldr s11, [x24, #0x420]
|
||||
site mouse_direct main 1d3a3f8 bd44270b 94a7c6e2 # EventMultiTalk: ldr s11, [x24, #0x424] (stick y)
|
||||
site mouse_direct main 1d3a418 aa1303f7 91017277 # EventMultiTalk: mov x23, x19 -> add x23, x19, #0x5c
|
||||
site mouse_direct main 1d3a420 bc45cee1 94a7c6d8 # EventMultiTalk: ldr s1, [x23, #0x5c]! (pitch), as ldr s1, [x23]
|
||||
site mouse_direct main 1d3a4dc aa1303f6 91019276 # EventMultiTalk: mov x22, x19 -> add x22, x19, #0x64
|
||||
site mouse_direct main 1d3a4e4 bc464ec1 94a7c6a7 # EventMultiTalk: ldr s1, [x22, #0x64]! (yaw), as ldr s1, [x22]
|
||||
site mouse_direct main 1d3e2b0 bd442729 94a7b734 # EventTalkManual: ldr s9, [x25, #0x424] (stick y)
|
||||
site mouse_direct main 1d3e2d4 aa1303f8 9101d278 # EventTalkManual: mov x24, x19 -> add x24, x19, #0x74
|
||||
site mouse_direct main 1d3e2d8 bc474f01 94a7b72a # EventTalkManual: ldr s1, [x24, #0x74]! (pitch), as ldr s1, [x24]
|
||||
site mouse_direct main 1d3e33c bd442329 94a7b711 # EventTalkManual: ldr s9, [x25, #0x420] (stick x)
|
||||
site mouse_direct main 1d3e360 aa1303f6 9101f276 # EventTalkManual: mov x22, x19 -> add x22, x19, #0x7c
|
||||
site mouse_direct main 1d3e364 bc47cec1 94a7b707 # EventTalkManual: ldr s1, [x22, #0x7c]! (yaw), as ldr s1, [x22]
|
||||
site mouse_direct main 1d46108 bd4422e0 94a7979e # ShieldSurfing: ldr s0, [x23, #0x420] (x for the length, REPEAT)
|
||||
site mouse_direct main 1d46110 bd4426e1 94a7979c # ShieldSurfing: ldr s1, [x23, #0x424] (y for the length, REPEAT)
|
||||
site mouse_direct main 1d46138 bd4426e0 94a79792 # ShieldSurfing: ldr s0, [x23, #0x424] (stick y, kept)
|
||||
site mouse_direct main 1d4613c bd4422e1 94a79791 # ShieldSurfing: ldr s1, [x23, #0x420] (stick x)
|
||||
site mouse_direct main 1d46788 bd40de60 94a795fe # ShieldSurfing: ldr s0, [x19, #0xdc] (pitch)
|
||||
site mouse_direct main 1d46910 bd401fa0 94a7959c # ShieldSurfing: ldr s0, [x29, #0x1c] (pitch turn, cut to the pad)
|
||||
site mouse_direct main 1d467b4 bd403bec 94a795f3 # ShieldSurfing: ldr s12, [sp, #0x38] (yaw)
|
||||
|
||||
# Spin waits, from tools/spin_waits.py --exefs build/extracted/v121/exefs --patches recomp/patches-v121.txt: loops that only
|
||||
# another thread can end; their back-edges pass rt_spin_gate (runtime/kernel.c), TOTK_SPIN=0 keeps the core.
|
||||
spin main ac600 ac620 ac630 # 13 instructions; loads
|
||||
|
||||
@@ -103,6 +103,92 @@ set fov TOTK_FOV_PATCH 0
|
||||
site fov main 628c3c bd413e60 f81f0fea @page:x10 bd413e60 bd403d48 1e280800 f84107ea # ldr s0, [x19, #0x13c] (camera update)
|
||||
site fov main b4342c bd4066c0 f81f0fea @page:x10 bd4066c0 bd403d48 1e280800 f84107ea # ldr s0, [x22, #0x64] (camera setup)
|
||||
|
||||
# The camera input update, after its stick dead zone, unit-radius clamp and StickExponent. The host callback
|
||||
# performs the displaced store and adds raw mouse counts to s9/s10 (runtime/mouse_direct.c camera_input).
|
||||
# Its address is page +0xfd0; the page is main+0x3adf000 on this verified build.
|
||||
set mouse_direct TOTK_MOUSE_DIRECT_PATCH 1
|
||||
site mouse_direct main 874cb4 bd042289 94c9acc7
|
||||
site mouse_direct main 874d7c 6d40a3e9 94c9ac91
|
||||
site mouse_direct main 18e444 f9400408 94e546db
|
||||
site mouse_direct main 18f108 bd4047ec 94e543a6
|
||||
site mouse_direct main 1d59460 b9004be8 94761acc
|
||||
# The aiming, scope, ability, horse and Purah Pad camera cameras: per axis, the float load the stick turn is computed
|
||||
# from (the gamepad's part only) and the load of the angle it is added to (plus the mouse's degrees), each a bl to page
|
||||
# +0xf80, told apart by the return address (runtime/mouse_direct.c camera_load, load_sites). Every enclosing function
|
||||
# saves x30 in its prologue, so the bl's link register write is harmless.
|
||||
site mouse_direct main 23beb94 bd4426c1 945c84fb # Aiming: ldr s1, [x22, #0x424] (stick y)
|
||||
site mouse_direct main 23bebc8 bd415e61 945c84ee # Aiming: ldr s1, [x19, #0x15c] (pitch)
|
||||
site mouse_direct main 23bee2c bd4422c1 945c8455 # Aiming: ldr s1, [x22, #0x420] (stick x)
|
||||
site mouse_direct main 23beeec bc5b83a1 945c8425 # Aiming: ldur s1, [x29, #-0x48] (yaw)
|
||||
site mouse_direct main 23c0a10 bd4422e0 945c7d5c # AimingTelescope: ldr s0, [x23, #0x420] (stick x)
|
||||
site mouse_direct main 23c0a1c bd4426eb 945c7d59 # AimingTelescope: ldr s11, [x23, #0x424] (stick y)
|
||||
site mouse_direct main 23c0b00 bd416260 945c7d20 # AimingTelescope: ldr s0, [x19, #0x160] (pitch)
|
||||
site mouse_direct main 23c0e80 bd405be1 945c7c40 # AimingTelescope: ldr s1, [sp, #0x58] (yaw)
|
||||
site mouse_direct main 23d2eec bd4422e8 945c3425 # SpecialPower: ldr s8, [x23, #0x420] (stick x)
|
||||
site mouse_direct main 23d2ef4 bd4426ee 945c3423 # SpecialPower: ldr s14, [x23, #0x424] (stick y)
|
||||
site mouse_direct main 23d3158 bd416260 945c338a # SpecialPower: ldr s0, [x19, #0x160] (pitch)
|
||||
site mouse_direct main 23d34cc bc5b03a1 945c32ad # SpecialPower: ldur s1, [x29, #-0x50] (yaw)
|
||||
site mouse_direct main 23cb274 bd442288 945c5343 # Horse: ldr s8, [x20, #0x420] (stick x)
|
||||
site mouse_direct main 23cb2a0 bd406a61 945c5338 # Horse: ldr s1, [x19, #0x68] (yaw)
|
||||
site mouse_direct main 23cb3f8 bd4426a8 945c52e2 # Horse: ldr s8, [x21, #0x424] (stick y)
|
||||
site mouse_direct main 23cb424 bd406661 945c52d7 # Horse: ldr s1, [x19, #0x64] (pitch)
|
||||
site mouse_direct main 23d2f30 bd4422e8 945c3414 # SpecialPower offset mode: ldr s8, [x23, #0x420] (stick x)
|
||||
site mouse_direct main 23d2f5c bd41da62 945c3409 # SpecialPower offset mode: ldr s2, [x19, #0x1d8] (offset x)
|
||||
site mouse_direct main 23d2f6c bd4426e8 945c3405 # SpecialPower offset mode: ldr s8, [x23, #0x424] (stick y)
|
||||
site mouse_direct main 23d2f98 bd41de62 945c33fa # SpecialPower offset mode: ldr s2, [x19, #0x1dc] (offset y)
|
||||
site mouse_direct main 23d3210 bd400400 945c335c # SpecialPower: ldr s0, [x0, #0x4] (offset scale, read only)
|
||||
site mouse_direct main 23c2890 bd4426ad 945c75bc # CameraFinder: ldr s13, [x21, #0x424] (stick y)
|
||||
site mouse_direct main 23c28b4 bd405261 945c75b3 # CameraFinder: ldr s1, [x19, #0x50] (pitch)
|
||||
site mouse_direct main 23c2934 bd4422ad 945c7593 # CameraFinder: ldr s13, [x21, #0x420] (stick x)
|
||||
site mouse_direct main 23c2964 bd404be1 945c7587 # CameraFinder: ldr s1, [sp, #0x48] (yaw)
|
||||
# The other player cameras (runtime/mouse_direct.c load_sites; a ROLE_WATCH load stays the game's and only names its
|
||||
# mode in the log when the mouse was in it):
|
||||
site mouse_direct main 23c3dd0 bd442301 945c706c # ClimbObj: ldr s1, [x24, #0x420]
|
||||
site mouse_direct main 23c3dd8 bd409261 945c706a # ClimbObj: ldr s1, [x19, #0x90] (yaw)
|
||||
site mouse_direct main 23c3d64 bd442701 945c7087 # ClimbObj: ldr s1, [x24, #0x424]
|
||||
# ClimbObj's pitch is a pre-indexed load, ldr s1, [x22, #0x88]!, whose write-back the code after it needs (ldp and
|
||||
# str through x22). A host call cannot write x22: the lifter keeps X19-X29 in locals across a call. So the mov before
|
||||
# it adds the 0x88 instead (x22 = x19 + 0x88) and the hook loads [x22] with no write-back.
|
||||
site mouse_direct main 23c3d5c aa1303f6 91022276 # ClimbObj: mov x22, x19 -> add x22, x19, #0x88
|
||||
site mouse_direct main 23c3d70 bc488ec1 945c7084 # ClimbObj: ldr s1, [x22, #0x88]! (pitch), as ldr s1, [x22]
|
||||
site mouse_direct main 23da694 bd4422c1 945c163b # Wakeboard: ldr s1, [x22, #0x420]
|
||||
site mouse_direct main 23da698 bd4426c0 945c163a # Wakeboard: ldr s0, [x22, #0x424] (stick y, kept)
|
||||
site mouse_direct main 23da840 bd40da60 945c15d0 # Wakeboard: ldr s0, [x19, #0xd8] (pitch)
|
||||
site mouse_direct main 23da894 bd4017e0 945c15bb # Wakeboard: ldr s0, [sp, #0x14] (pitch turn, cut to the pad)
|
||||
site mouse_direct main 23daed8 bd403fe9 945c142a # Wakeboard: ldr s9, [sp, #0x3c] (yaw)
|
||||
site mouse_direct main 23dced8 bd4422cd 945c0c2a # WaterfallClimb: ldr s13, [x22, #0x420]
|
||||
site mouse_direct main 23dd070 b85cc3a8 945c0bc4 # WaterfallClimb: ldur w8, [x29, #-0x34] (yaw)
|
||||
site mouse_direct main 23dd008 bd4426c9 945c0bde # WaterfallClimb: ldr s9, [x22, #0x424]
|
||||
site mouse_direct main 23d4b14 bd44228c 945c2d1b # Tail: ldr s12, [x20, #0x420]
|
||||
site mouse_direct main 23d4b1c bd44268d 945c2d19 # Tail (climbing): ldr s13, [x20, #0x424] (stick y)
|
||||
site mouse_direct main 23d4f34 bd40c260 945c2c13 # Tail (climbing): ldr s0, [x19, #0xc0] (yaw)
|
||||
site mouse_direct main 23d5028 bd40b661 945c2bd6 # Tail (climbing): ldr s1, [x19, #0xb4] (pitch)
|
||||
site mouse_direct main 23d756c bd442300 945c2285 # LockOn / Target: ldr s0, [x24, #0x420]
|
||||
site mouse_direct main 23d7594 bd442301 945c227b # LockOn / Target: ldr s1, [x24, #0x420] (stick x)
|
||||
site mouse_direct main 23d75b4 bd4023e0 945c2273 # LockOn / Target: ldr s0, [sp, #0x20] (yaw turn)
|
||||
site mouse_direct main 23d75c4 bd4023e9 945c226f # LockOn / Target: ldr s9, [sp, #0x20] (yaw turn)
|
||||
site mouse_direct main 23d7998 bd442701 945c217a # LockOn / Target: ldr s1, [x24, #0x424] (stick y)
|
||||
site mouse_direct main 23d79b8 bd4023e0 945c2172 # LockOn / Target: ldr s0, [sp, #0x20] (pitch turn)
|
||||
site mouse_direct main 23d79c8 bd4023ea 945c216e # LockOn / Target: ldr s10, [sp, #0x20] (pitch turn)
|
||||
site mouse_direct main 23cecb4 bd4426a9 945c44b3 # LockOnAimingAt: ldr s9, [x21, #0x424] (stick y)
|
||||
site mouse_direct main 23cecf8 bd419a61 945c44a2 # LockOnAimingAt: ldr s1, [x19, #0x198] (pitch offset)
|
||||
site mouse_direct main 23ced0c bd4422a9 945c449d # LockOnAimingAt: ldr s9, [x21, #0x420] (stick x)
|
||||
site mouse_direct main 23ced4c bd419e61 945c448d # LockOnAimingAt: ldr s1, [x19, #0x19c] (yaw offset)
|
||||
site mouse_direct main 23cc4b4 bd44232b 945c4eb3 # HorseLockOnEmpty: ldr s11, [x25, #0x420]
|
||||
site mouse_direct main 23c54d4 bd4422eb 945c6aab # EventMultiTalk: ldr s11, [x23, #0x420]
|
||||
site mouse_direct main 23c542c bd4426eb 945c6ad5 # EventMultiTalk: ldr s11, [x23, #0x424] (stick y)
|
||||
site mouse_direct main 23c546c bd405e61 945c6ac5 # EventMultiTalk: ldr s1, [x19, #0x5c] (pitch)
|
||||
site mouse_direct main 23c5554 bd406661 945c6a8b # EventMultiTalk: ldr s1, [x19, #0x64] (yaw)
|
||||
site mouse_direct main 23c90ac bd442709 945c5bb5 # EventTalkManual: ldr s9, [x24, #0x424] (stick y)
|
||||
site mouse_direct main 23c90d8 bd407661 945c5baa # EventTalkManual: ldr s1, [x19, #0x74] (pitch)
|
||||
site mouse_direct main 23c9140 bd442309 945c5b90 # EventTalkManual: ldr s9, [x24, #0x420] (stick x)
|
||||
site mouse_direct main 23c9170 bd407e61 945c5b84 # EventTalkManual: ldr s1, [x19, #0x7c] (yaw)
|
||||
site mouse_direct main 23d0100 bd4422c1 945c3fa0 # ShieldSurfing: ldr s1, [x22, #0x420] (stick x)
|
||||
site mouse_direct main 23d0104 bd4426c0 945c3f9f # ShieldSurfing: ldr s0, [x22, #0x424] (stick y, kept)
|
||||
site mouse_direct main 23d02ac bd40de60 945c3f35 # ShieldSurfing: ldr s0, [x19, #0xdc] (pitch)
|
||||
site mouse_direct main 23d0a34 bd4013e0 945c3d53 # ShieldSurfing: ldr s0, [sp, #0x10] (pitch turn, cut to the pad)
|
||||
site mouse_direct main 23d087c bd403fe9 945c3dc1 # ShieldSurfing: ldr s9, [sp, #0x3c] (yaw)
|
||||
|
||||
# The game's zstd decoder (BSD zstd, statically linked into main) through the host's libzstd (runtime/ov_zstd.c):
|
||||
# every call of ZSTD_decompressMultiFrame (main+0x3a1f0: dctx, dst, capacity, src, size, dict, dict size, ddict) and of
|
||||
# its wrapper ZSTD_decompress_usingDDict (main+0x3a1e0, which tail-branches to it) goes to a host function on the patch
|
||||
|
||||
@@ -307,6 +307,7 @@ void rt_frame_presented(const tex_t *t, uint64_t n) {
|
||||
fflush(stderr);
|
||||
fs_save_flush();
|
||||
vk_pipeline_save(); // no-op without the Vulkan back end
|
||||
playtime_save();
|
||||
prof_write();
|
||||
fflush(stderr);
|
||||
plat_exit(0);
|
||||
|
||||
+70
-20
@@ -7,6 +7,7 @@
|
||||
// cfg_get handed out stays valid) and cfg_save writes only the changed keys back, keeping the rest of the file.
|
||||
// Readers take no lock: an entry is complete before n_entries counts it, and value pointers are swapped atomically.
|
||||
#include <ctype.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -28,26 +29,29 @@ static PlatMutex set_lock = PLAT_MUTEX_INIT; // cfg_set, cfg_reset, cfg_save
|
||||
static int save_ok = 1; // the last cfg_save_to wrote (or had nothing to write)
|
||||
|
||||
// Every name the runtime reads (and the patch sets in generated/), without the prefix. A file key outside this list is
|
||||
// logged as unknown. A new variable goes here too, or its ini key logs a harmless "unknown key" line.
|
||||
// logged as unknown. A new variable goes here too, or its ini key logs a harmless "unknown key" line, and gets a
|
||||
// description in totk.ini.example or docs/settings.md (tests/settings_doc_check.py checks both).
|
||||
static const char *const known[] = {
|
||||
"AB", "AMIIBO_DIR", "ANISO", "APP_POOL", "ASPECT", "ASPECT_PATCH", "ASSUME_FOCUS", "ASTC", "ASTC_BC", "ASTC_TIME", "ASYNC_PREEMPT", "AUDIO", "AUDIO_CHANNELS", "AUDIO_OFF", "AUDIO_PREFILL_MS",
|
||||
"BACKGROUND_WINDOW", "BIND_A", "BIND_B", "BIND_DOWN", "BIND_L", "BIND_LCLICK", "BIND_LEFT", "BIND_LSTICK_DOWN", "BIND_LSTICK_LEFT",
|
||||
"BIND_LSTICK_RIGHT", "BIND_LSTICK_UP", "BIND_MINUS",
|
||||
"BIND_PLUS", "BIND_R", "BIND_RCLICK", "BIND_RIGHT", "BIND_RSTICK_DOWN", "BIND_RSTICK_LEFT", "BIND_RSTICK_RIGHT", "BIND_RSTICK_UP",
|
||||
"BIND_SHAKE",
|
||||
"BIND_SKIP", "BIND_STATS", "BIND_UP", "BIND_X", "BIND_Y",
|
||||
"BIND_ZL", "BIND_ZR", "BLOCK_VIEWS_3D", "CLOTH_REALTIME", "COLOR_BRIGHTNESS", "COLOR_CONTRAST", "COLOR_GAMMA",
|
||||
"COLOR_PRESET", "COLOR_SATURATION", "CONTACT_DEBUG", "CONTACT_SHADOWS", "CORE_DIAG", "CRIT", "CRIT_OUT",
|
||||
"DESC_TEMPLATE", "DEVICE_PROFILE", "DISCORD_APP_ID", "DISCORD_IMAGE", "DISCORD_RPC", "DLAA", "DLAA_JITTER", "DLAA_MV", "DLAA_MASKDEBUG", "DLAA_MVDEBUG", "DLAA_OBJMV", "DLAA_SURVEY", "DLAA_TIMES", "DLSS", "DLSS_CHAR_MASK", "DLSS_PRESET", "DLSS_REACTIVE", "DUMP", "DUMP_BUF", "DUMP_DIR",
|
||||
"DUMP_LOG", "EXEFS", "FEEDBACK", "FIX_C0", "FIX_ZERO", "FLIGHT", "FLIGHT_AT", "FLIGHT_DIR", "FLIGHT_FRAMES",
|
||||
"FLIGHT_SPIKE_MS", "FLIGHT_THUMBS", "FOV", "FOV_PATCH", "FPS60", "FPS_LIMIT", "FREEZE_IDLE", "FREE_CORES",
|
||||
"FLIGHT_SPIKE_MS", "FLIGHT_THUMBS", "FLUSH_SPAN", "FOV", "FOV_PATCH", "FPS60", "FPS_LIMIT", "FRAME_QUEUE",
|
||||
"FREEZE_IDLE", "FREE_CORES",
|
||||
"FULLSCREEN", "FXAA", "GPL", "GPL_DRAW_BUILD", "GPU", "GPU_INDEX", "GPU_NAME", "HDR", "HDR_GAME_NITS", "HDR_HUE",
|
||||
"HDR_PEAK_NITS", "HDR_UI_NITS", "HIGHFPS_FIX", "INPUT", "INPUT_RECORD", "INPUT_SCRIPT", "LANG", "LIB_PREBUILD",
|
||||
"LIB_PREBUILD_KEEP", "LIB_PREBUILD_THREADS", "LOCALE", "LOD_GUARD", "LSTICK_DEADZONE", "MEMORY", "MENU_LANG", "MENU_TEST",
|
||||
"MENU_TEST_DIR", "MENU_TEST_SET", "MINIMAL_LAYOUT", "MIRROR_GAP", "MODS_DIR", "MODS_DISABLED", "MODS_ORDER", "MODS_REBUILD", "MOTION", "MOUSE_SENS", "MOVIE_HOST", "MOVIE_PACE",
|
||||
"MENU_CURSOR", "MENU_CURSOR_LOG", "MENU_TEST_DIR", "MENU_TEST_SET", "MINIMAL_LAYOUT", "MIRROR_GAP", "MODS_DIR", "MODS_DISABLED", "MODS_ORDER", "MODS_REBUILD", "MOTION", "MOUSE_AIM_SCALE", "MOUSE_MODE", "MOUSE_DIRECT_PATCH", "MOUSE_SENS", "MOVIE_ASPECT", "MOVIE_HOST", "MOVIE_PACE",
|
||||
"MVOBJ_ALL", "MVOBJ_BANKS", "MVOBJ_EQUAL", "MVOBJ_EXACT", "MVOBJ_EXEC", "MVOBJ_NOC4", "MVOBJ_ONLY", "MVOBJ_TIMES", "MVPREV_CHECK", "MVPREV_GROW", "MVPREV_SKIP", "MVPREV_TRUST", "MV_CHECK",
|
||||
"MV_SURVEY",
|
||||
"ONE_PER_CORE", "OPERATION_MODE", "OUTPUT_RES", "PAD_LAYOUT", "PASS_CRUMBS", "PATCH_OUTPUT", "PATCH_SCENE", "PCSS", "PCSS_K", "PERF_OVERLAY", "PERF_SLOW_MS", "PIPELINE_CACHE", "PIPE_MEMO", "PIPE_PREBUILD", "PIPE_SKIP", "PIPE_SYNC", "PIPE_WAIT",
|
||||
"PREWARM_XLATE", "PROBE_MOVIE", "PROBE_MOVIE_SHOT", "PROFILE", "PROFILE_CHAINS", "PROFILE_DIR", "PROFILE_FRAMES",
|
||||
"PREWARM_XLATE", "PROBE_MOVIE", "PROBE_MOVIE_BENCH", "PROBE_MOVIE_SHOT", "PROFILE", "PROFILE_CHAINS", "PROFILE_DIR", "PROFILE_FRAMES",
|
||||
"PROFILE_MAIN", "PROFILE_RENDER", "PROMPTS", "PUSH_SKIP", "PUSH_STAGE", "RDOC", "RDOC_DIR", "RDOC_DLL", "REALTIME", "REC_END", "REEXEC",
|
||||
"RENDER_SCALE", "REPLAY_ASYNC",
|
||||
"REPLAY_CHECK", "RESOLVE_CHECK", "RESTART_WAIT", "ROMFS", "ROMFS_INDEX", "ROMFS_MMAP", "ROMFS_MODS", "ROMFS_PATCHES",
|
||||
@@ -55,19 +59,19 @@ static const char *const known[] = {
|
||||
"SHADER_MEMO", "SHADOW_RANGE", "SHADOW_RES", "SHADOW_SCALE", "SHARED_FONTS", "SHARPNESS", "SHOTS", "SHOTS_DIR",
|
||||
"SHOTS_FULL", "SPEED_PROBE", "SPIN", "SPIN_HANDOFF_US", "SPLIT_DRAWS", "SPLIT_US", "STICK_DEADZONE", "STICK_SENS",
|
||||
"SWKBD_ANSWER",
|
||||
"SWKBD_TEST", "TEST_PAD", "TEST_PREPARE_SHADERS", "TEX_ALIAS", "TEX_MEMO", "TEX_VRAM_PRESSURE", "TIMER_1MS", "TRACE", "TXD_TRANSPOSE", "UBO_SNAPSHOT", "UPSCALER", "VK_DYN_A2C", "VK_DYN_VERTEX", "VK_NO_FLOAT64",
|
||||
"VK_PRESENT", "VK_RECORDER", "VK_SSBO_ALIGN", "VK_SUBGROUP", "VK_SUBGROUP_PIN", "VK_VALIDATION", "VOLUME", "VRAM_HEADROOM", "VS_OUTS",
|
||||
"WALK_KEY", "WALK_SCALE", "WB_EXACT", "WB_FENCE_LIMIT", "WINDOW_SIZE", "XLATE", "XLATE_ID", "XLATE_KEEP", "XLATE_PROCS",
|
||||
"SWKBD_TEST", "TEST_PAD", "TEST_PREPARE_SHADERS", "TEX_ALIAS", "TEX_HEAP_EXTRA_MB", "TEX_MEMO", "TEX_VRAM_PRESSURE", "TIMER_1MS", "TRACE", "TXD_TRANSPOSE", "UBO_SNAPSHOT", "UB_OVERLAY", "UPSCALER", "VB_USED", "VK_DYN_A2C", "VK_DYN_VERTEX", "VK_NO_FLOAT64",
|
||||
"VK_PRESENT", "VK_RECORDER", "VK_SSBO_ALIGN", "VK_SUBGROUP", "VK_SUBGROUP_PIN", "VK_VALIDATION", "VOLUME", "VRAM_HEADROOM", "VRAM_LIMIT_MIB", "VS_OUTS",
|
||||
"WALK_KEY", "WALK_SCALE", "WB_EXACT", "WB_FENCE_LIMIT", "WB_MERGE", "WINDOW_SIZE", "XLATE", "XLATE_ID", "XLATE_KEEP", "XLATE_PROCS",
|
||||
"ZSTD_CHECK", "ZSTD_NATIVE",
|
||||
};
|
||||
|
||||
// The totk.ini section a key cfg_set may write goes to when the file lacks it (totk.ini.example's sections).
|
||||
static const struct { const char *key, *section; } sections[] = {
|
||||
{"FPS_LIMIT", "frame rate"}, {"FPS60", "frame rate"},
|
||||
{"FPS_LIMIT", "frame rate"}, {"FPS60", "frame rate"}, {"FRAME_QUEUE", "frame rate"},
|
||||
{"REALTIME", "frame rate"}, {"CLOTH_REALTIME", "frame rate"}, {"HIGHFPS_FIX", "frame rate"},
|
||||
{"FULLSCREEN", "display"}, {"WINDOW_SIZE", "display"}, {"PERF_OVERLAY", "display"}, {"VK_PRESENT", "display"},
|
||||
{"GPU_INDEX", "display"}, {"OUTPUT_RES", "display"}, {"RENDER_SCALE", "display"}, {"UPSCALER", "display"},
|
||||
{"SHARPNESS", "display"}, {"ASPECT", "display"}, {"FOV", "display"}, {"ANISO", "display"},
|
||||
{"SHARPNESS", "display"}, {"ASPECT", "display"}, {"MOVIE_ASPECT", "display"}, {"FOV", "display"}, {"ANISO", "display"},
|
||||
{"COLOR_PRESET", "display"},
|
||||
{"COLOR_BRIGHTNESS", "display"}, {"COLOR_CONTRAST", "display"}, {"COLOR_SATURATION", "display"},
|
||||
{"COLOR_GAMMA", "display"}, {"HDR", "display"}, {"HDR_PEAK_NITS", "display"}, {"HDR_GAME_NITS", "display"},
|
||||
@@ -78,28 +82,29 @@ static const struct { const char *key, *section; } sections[] = {
|
||||
{"SHADOW_SCALE", "display"}, {"SHADOW_RANGE", "display"}, {"CONTACT_SHADOWS", "display"},
|
||||
{"PCSS", "display"},
|
||||
{"MODS_DIR", "mods"}, {"MODS_ORDER", "mods"}, {"MODS_DISABLED", "mods"}, {"MODS_REBUILD", "mods"},
|
||||
{"TEX_HEAP_EXTRA_MB", "mods"},
|
||||
{"MENU_LANG", "language"}, {"LANG", "language"}, {"OPERATION_MODE", "language"},
|
||||
{"VOLUME", "audio"}, {"AUDIO_CHANNELS", "audio"},
|
||||
{"MOTION", "input"}, {"RUMBLE_STRENGTH", "input"}, {"RUMBLE_CURVE", "input"}, {"RUMBLE_TRIGGERS", "input"},
|
||||
{"PAD_LAYOUT", "input"}, {"PROMPTS", "input"}, {"STICK_DEADZONE", "input"}, {"LSTICK_DEADZONE", "input"}, {"RSTICK_DEADZONE", "input"},
|
||||
{"STICK_SENS", "input"},
|
||||
{"MOUSE_SENS", "input"}, {"WALK_KEY", "input"}, {"WALK_SCALE", "input"}, {"INPUT_RECORD", "input"},
|
||||
{"MENU_CURSOR", "input"}, {"MOUSE_MODE", "input"}, {"MOUSE_SENS", "input"}, {"MOUSE_AIM_SCALE", "input"}, {"WALK_KEY", "input"}, {"WALK_SCALE", "input"}, {"INPUT_RECORD", "input"},
|
||||
{"BIND_A", "input"}, {"BIND_B", "input"}, {"BIND_X", "input"}, {"BIND_Y", "input"}, {"BIND_LCLICK", "input"},
|
||||
{"BIND_RCLICK", "input"}, {"BIND_L", "input"}, {"BIND_R", "input"}, {"BIND_ZL", "input"}, {"BIND_ZR", "input"},
|
||||
{"BIND_PLUS", "input"}, {"BIND_MINUS", "input"}, {"BIND_LEFT", "input"}, {"BIND_UP", "input"},
|
||||
{"BIND_RIGHT", "input"}, {"BIND_DOWN", "input"}, {"BIND_LSTICK_LEFT", "input"}, {"BIND_LSTICK_UP", "input"},
|
||||
{"BIND_LSTICK_RIGHT", "input"}, {"BIND_LSTICK_DOWN", "input"}, {"BIND_RSTICK_LEFT", "input"},
|
||||
{"BIND_RSTICK_UP", "input"}, {"BIND_RSTICK_RIGHT", "input"}, {"BIND_RSTICK_DOWN", "input"},
|
||||
{"BIND_RSTICK_UP", "input"}, {"BIND_RSTICK_RIGHT", "input"}, {"BIND_RSTICK_DOWN", "input"}, {"BIND_SHAKE", "input"},
|
||||
{"ASYNC_PREEMPT", "performance"}, {"REPLAY_ASYNC", "performance"}, {"FREE_CORES", "performance"},
|
||||
{"TIMER_1MS", "performance"}, {"PIPE_WAIT", "performance"}, {"PIPE_SKIP", "performance"},
|
||||
{"LIB_PREBUILD", "performance"}, {"LIB_PREBUILD_THREADS", "performance"}, {"PIPE_PREBUILD", "performance"},
|
||||
{"PREWARM_XLATE", "performance"}, {"GPL", "performance"}, {"ASTC", "performance"}, {"MEMORY", "performance"},
|
||||
{"TEX_ALIAS", "performance"}, {"TEX_VRAM_PRESSURE", "performance"}, {"ASTC_BC", "performance"}, {"LOD_GUARD", "performance"}, {"FIX_C0", "performance"}, {"FIX_ZERO", "performance"},
|
||||
{"BIND_SKIP", "performance"}, {"SHADER_MEMO", "performance"}, {"PIPE_MEMO", "performance"}, {"TEX_MEMO", "performance"}, {"DESC_TEMPLATE", "performance"},
|
||||
{"BIND_SKIP", "performance"}, {"SHADER_MEMO", "performance"}, {"PIPE_MEMO", "performance"}, {"TEX_MEMO", "performance"}, {"VB_USED", "performance"}, {"DESC_TEMPLATE", "performance"},
|
||||
{"PUSH_SKIP", "performance"}, {"SPLIT_DRAWS", "performance"}, {"SPLIT_US", "performance"}, {"VK_VALIDATION", "performance"},
|
||||
{"VK_DYN_VERTEX", "performance"}, {"VK_DYN_A2C", "performance"}, {"VK_SUBGROUP_PIN", "performance"},
|
||||
{"TRACE", "diagnostics"}, {"FLIGHT", "diagnostics"}, {"PASS_CRUMBS", "diagnostics"}, {"FLIGHT_SPIKE_MS", "diagnostics"},
|
||||
{"WB_FENCE_LIMIT", "diagnostics"}, {"WB_EXACT", "diagnostics"},
|
||||
{"WB_FENCE_LIMIT", "diagnostics"}, {"WB_MERGE", "diagnostics"}, {"WB_EXACT", "diagnostics"},
|
||||
{"MV_SURVEY", "diagnostics"},
|
||||
};
|
||||
|
||||
@@ -118,7 +123,7 @@ static const CfgMeta metas[] = {
|
||||
{"DLAA", "0", "0|1", RESTART}, {"DLSS", "dlaa", "dlaa|quality|balanced|performance|ultra_performance", LIVE},
|
||||
{"DLSS_PRESET", "default", "default|k|j", LIVE},
|
||||
{"DLSS_CHAR_MASK", "65", "0|35|65|100", LIVE}, {"FXAA", "1", "0|1", LIVE},
|
||||
{"ASPECT", "fit", "fit|stretch", LIVE}, {"FOV", "100", "70..140", LIVE},
|
||||
{"ASPECT", "fit", "fit|stretch", LIVE}, {"MOVIE_ASPECT", "keep", "keep|stretch", LIVE}, {"FOV", "100", "70..140", LIVE},
|
||||
{"ANISO", "game", "game|2|4|8|16", RESTART}, // vk_texture.c sampler_features
|
||||
// colour adjustment (vk_color.c); the preset is the menu's, the four values are what the present uses
|
||||
{"COLOR_PRESET", "default", "default|vivid|clarity|custom", LIVE}, {"COLOR_BRIGHTNESS", "0", "-30..30", LIVE},
|
||||
@@ -133,7 +138,7 @@ static const CfgMeta metas[] = {
|
||||
{"SHADOW_RANGE", "100", "50..400", RESTART}, {"CONTACT_SHADOWS", "0", "0..100", RESTART},
|
||||
{"PCSS", "0", "0..200", RESTART},
|
||||
// Game
|
||||
{"FPS_LIMIT", "60", "30|40|45|60|90|120|144|display", LIVE}, {"FPS60", "1", "0|1", LIVE},
|
||||
{"FPS_LIMIT", "60", "30|40|45|60|90|120|144|display", LIVE}, {"FPS60", "1", "0|1", LIVE}, {"FRAME_QUEUE", "0", "0|1", LIVE},
|
||||
{"REALTIME", "1", "0|1", RESTART}, {"CLOTH_REALTIME", "1", "0|1", RESTART},
|
||||
{"MODS_REBUILD", "auto", "auto|off", RESTART},
|
||||
{"HIGHFPS_FIX", "1", "0|1", RESTART},
|
||||
@@ -144,10 +149,11 @@ static const CfgMeta metas[] = {
|
||||
{"VOLUME", "100", "0..100", LIVE}, {"AUDIO_CHANNELS", "stereo", "auto|stereo|5.1", RESTART},
|
||||
{"MOTION", "1", "0|1", LIVE}, {"RUMBLE_STRENGTH", "130", "0..300", LIVE}, {"RUMBLE_CURVE", "0.6", "0.2..1.5", LIVE},
|
||||
{"RUMBLE_TRIGGERS", "0", "0|1", LIVE}, {"PAD_LAYOUT", "auto", "auto|nintendo|xbox", LIVE},
|
||||
{"PROMPTS", "nintendo", "nintendo|xbox", RESTART},
|
||||
{"PROMPTS", "nintendo", "nintendo|xbox|pc", RESTART},
|
||||
{"STICK_DEADZONE", "8", "0..50", LIVE}, {"STICK_SENS", "1", "0.1..3", RESTART},
|
||||
{"LSTICK_DEADZONE", "", "0..50", LIVE}, {"RSTICK_DEADZONE", "", "0..50", LIVE},
|
||||
{"MOUSE_SENS", "1", "0..10", LIVE},
|
||||
{"MOUSE_MODE", "direct", "stick|direct", LIVE},
|
||||
{"MOUSE_SENS", "1", "0..10", LIVE}, {"MOUSE_AIM_SCALE", "1", "0.1..3", LIVE}, {"MENU_CURSOR", "1", "0|1", LIVE},
|
||||
{"WALK_KEY", "none", NULL, LIVE}, {"WALK_SCALE", "0.51", "0.2..0.9", LIVE},
|
||||
{"MENU_LANG", "", "en|ru|pt-BR|zh-Hans", LIVE},
|
||||
{"AMIIBO_DIR", "", NULL, LIVE},
|
||||
@@ -156,7 +162,7 @@ static const CfgMeta metas[] = {
|
||||
{"DISCORD_APP_ID", "1567232599846293504", NULL, LIVE},
|
||||
{"DISCORD_IMAGE", "totk_512x512", NULL, LIVE},
|
||||
// Advanced: scheduling
|
||||
{"ASYNC_PREEMPT", "1", "0|1|2", RESTART}, {"REPLAY_ASYNC", "1", "0|1", RESTART},
|
||||
{"ASYNC_PREEMPT", "1", "0|1|2", RESTART}, {"REPLAY_ASYNC", "0", "0|1", RESTART},
|
||||
{"FREE_CORES", "0", "0|1", RESTART}, {"TIMER_1MS", "1", "0|1", RESTART},
|
||||
// shaders and pipelines
|
||||
{"PIPE_WAIT", "default", "default|draw", RESTART}, {"PIPE_SKIP", "fullscreen", "strict|any|fullscreen", RESTART},
|
||||
@@ -168,6 +174,7 @@ static const CfgMeta metas[] = {
|
||||
// render fixes
|
||||
{"LOD_GUARD", "1", "0|1", RESTART}, {"FIX_C0", "1", "0|1", RESTART}, {"FIX_ZERO", "1", "0|1", RESTART},
|
||||
{"BIND_SKIP", "1", "0|1", RESTART}, {"SHADER_MEMO", "1", "0|1", RESTART}, {"PIPE_MEMO", "1", "0|1", RESTART}, {"TEX_MEMO", "1", "0|1", RESTART},
|
||||
{"VB_USED", "1", "0|1", RESTART},
|
||||
{"DESC_TEMPLATE", "1", "0|1", RESTART},
|
||||
{"PUSH_SKIP", "1", "0|1", RESTART}, {"SPLIT_DRAWS", "64", "0..1000", RESTART},
|
||||
{"SPLIT_US", "1000", "0..20000", RESTART},
|
||||
@@ -176,7 +183,7 @@ static const CfgMeta metas[] = {
|
||||
{"VK_DYN_A2C", "", "0|1", RESTART}, {"VK_SUBGROUP_PIN", "1", "0|1", RESTART},
|
||||
// diagnostics
|
||||
{"TRACE", "", NULL, RESTART}, {"FLIGHT", "0", "0|1", RESTART}, {"FLIGHT_SPIKE_MS", "100", "0..60000", RESTART},
|
||||
{"WB_FENCE_LIMIT", "65536", "0..1073741824", RESTART}, {"WB_EXACT", "1", "0|1", RESTART},
|
||||
{"WB_FENCE_LIMIT", "65536", "0..1073741824", RESTART}, {"WB_MERGE", "1", "0|1", RESTART}, {"WB_EXACT", "1", "0|1", RESTART},
|
||||
{"PASS_CRUMBS", "", "0|1", RESTART}, // vk_crumbs.c pass marks; unset: VK_AMD_buffer_marker only
|
||||
{"INPUT_RECORD", "", NULL, RESTART},
|
||||
};
|
||||
@@ -325,9 +332,51 @@ static const char *raw_get(const char *name) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// ---- the screen's shape (desktop): a primary display of another shape than 16:9 gives output_res a default of that
|
||||
// shape while neither the environment nor totk.ini sets it, so the first start fills the screen instead of a 16:9
|
||||
// picture between black bars. The sizes are the ones the installer lays the HUD out for (installer/steps.py
|
||||
// ASPECT_SIZES, ui/menu.cpp row_output_res): of the display's shape within 1%, the smallest at least 1080 high that fits
|
||||
// the display, else the largest that fits. A 16:9 display, an unknown one or one of another shape keeps 1920x1080.
|
||||
// Xbox or keyboard prompts keep 1920x1080 too: the setup builds their folders for 16:9 only (romfs.c overlay_init), so
|
||||
// a default of another shape would trade the prompts the player chose for the Switch ones.
|
||||
static const unsigned SHAPED[][2] = {
|
||||
{2560, 1080}, {3440, 1440}, {3840, 1600}, {5120, 2160}, // 21:9
|
||||
{3840, 1080}, {5120, 1440}, // 32:9
|
||||
{1280, 800}, {1680, 1050}, {1920, 1200}, {2560, 1600}, // 16:10
|
||||
{1920, 1280}, {2160, 1440}, {2256, 1504}, {2880, 1920}, {3000, 2000}, // 3:2
|
||||
};
|
||||
static char screen_res[16]; // set once by cfg_init, "" for none
|
||||
|
||||
static int switch_prompts(void) {
|
||||
const char *p = raw_get(PREFIX "PROMPTS"), *l = raw_get(PREFIX "PAD_LAYOUT");
|
||||
return (!p || !strcasecmp(p, "nintendo")) && !(l && !strcasecmp(l, "xbox_prompts"));
|
||||
}
|
||||
|
||||
static void screen_default(void) {
|
||||
int sw, sh;
|
||||
if (!plat_screen_size(&sw, &sh)) return;
|
||||
double r = (double)sw / sh;
|
||||
if (fabs(r / (16.0 / 9.0) - 1) <= 0.01) return;
|
||||
int best = -1, fit = -1;
|
||||
for (int i = 0; i < (int)(sizeof SHAPED / sizeof *SHAPED); i++) {
|
||||
unsigned w = SHAPED[i][0], h = SHAPED[i][1];
|
||||
if (fabs((double)w / h / r - 1) > 0.01 || w > (unsigned)sw || h > (unsigned)sh) continue;
|
||||
if (fit < 0 || w > SHAPED[fit][0]) fit = i; // the largest that fits
|
||||
if (h >= 1080 && (best < 0 || w < SHAPED[best][0])) best = i;
|
||||
}
|
||||
if (best < 0) best = fit;
|
||||
if (best < 0) return;
|
||||
snprintf(screen_res, sizeof screen_res, "%ux%u", SHAPED[best][0], SHAPED[best][1]);
|
||||
if (!raw_get(PREFIX "OUTPUT_RES") && switch_prompts())
|
||||
rt_log("cfg: the display is %dx%d: output_res defaults to %s, its shape (totk.ini output_res sets another)", sw,
|
||||
sh, screen_res);
|
||||
}
|
||||
|
||||
const char *cfg_get(const char *name) {
|
||||
const char *v = raw_get(name);
|
||||
if (v || strncmp(name, PREFIX, PREFIX_LEN) || !profile_of()) return v;
|
||||
if (v || strncmp(name, PREFIX, PREFIX_LEN)) return v;
|
||||
if (!profile_of())
|
||||
return screen_res[0] && !strcasecmp(name + PREFIX_LEN, "OUTPUT_RES") && switch_prompts() ? screen_res : NULL;
|
||||
for (size_t i = 0; i < sizeof DECK / sizeof *DECK; i++)
|
||||
if (!strcasecmp(DECK[i].key, name + PREFIX_LEN)) return DECK[i].value;
|
||||
return NULL;
|
||||
@@ -702,6 +751,7 @@ static void load_ini(void) {
|
||||
|
||||
void cfg_init(void) {
|
||||
load_ini();
|
||||
if (!profile_of()) screen_default();
|
||||
if (profile_of())
|
||||
rt_log("cfg: device profile Steam Deck: defaults for the keys totk.ini does not set (TOTK_DEVICE_PROFILE=desktop "
|
||||
"turns it off)");
|
||||
|
||||
@@ -234,6 +234,10 @@ int probe_cfg(void) {
|
||||
CHECK(input_map_buttons(dn) == (1ull << B_ZR | 1ull << B_PLUS | 1ull << B_MINUS | 1ull << B_STICK_L | 1ull << B_RIGHT),
|
||||
"keys: X ZR, Tab Plus, M Minus, Left Ctrl stick click, 3 D-pad right");
|
||||
memset(dn, 0, sizeof dn);
|
||||
dn[KEY_G] = 1;
|
||||
CHECK(input_map_buttons(dn) == 0 && input_map_held(dn, B_SHAKE) && !input_map_held(dn, B_A),
|
||||
"keys: G shakes and presses no button");
|
||||
memset(dn, 0, sizeof dn);
|
||||
dn[KEY_W] = 1;
|
||||
input_map_stick(dn, 0, st);
|
||||
CHECK(st[0] == 0 && st[1] == 32767, "keys: W is left stick up");
|
||||
@@ -301,6 +305,11 @@ int probe_cfg(void) {
|
||||
CHECK(input_prompts_pick(NULL, "auto") == PROMPTS_NINTENDO && input_prompts_pick("", "xbox") == PROMPTS_NINTENDO &&
|
||||
input_prompts_pick("Xbox", "nintendo") == PROMPTS_XBOX && input_prompts_pick("nintendo", "xbox") == PROMPTS_NINTENDO,
|
||||
"prompts: the game's own unless set to Xbox");
|
||||
CHECK(input_prompts_pick("PC", "nintendo") == PROMPTS_PC && input_prompts_pick("pc", "xbox_prompts") == PROMPTS_PC &&
|
||||
EQ(input_prompts_folder(PROMPTS_PC, "nintendo"), "romfs-patches-pc") &&
|
||||
EQ(input_prompts_folder(PROMPTS_PC, "xbox"), "romfs-patches-pc") &&
|
||||
EQ(input_prompts_folder(PROMPTS_PC, NULL), "romfs-patches-pc"),
|
||||
"prompts: pc shows the keyboard and mouse whatever the pad layout");
|
||||
CHECK(input_prompts_pick(NULL, "xbox_prompts") == PROMPTS_XBOX && input_prompts_pick("nintendo", "xbox_prompts") == PROMPTS_NINTENDO,
|
||||
"prompts: 0.1.9's pad_layout = xbox_prompts means Xbox while prompts is unset");
|
||||
CHECK(!input_prompts_folder(PROMPTS_NINTENDO, "nintendo") && !input_prompts_folder(PROMPTS_NINTENDO, "xbox"),
|
||||
|
||||
+2
-1
@@ -41,7 +41,8 @@ size_t romfs_overlay_count(void);
|
||||
int romfs_patches_loaded(void); // a romfs-patches manifest gave at least one file
|
||||
// The button prompts (input_prompts_folder): the folder loaded at start, NULL for the game's own prompts; whether a
|
||||
// folder had a manifest at start; and why the folder the settings asked for at start was not loaded (ROMFS_PROMPTS_OK
|
||||
// for any other folder: it was not asked for).
|
||||
// for any other folder: it was not asked for). romfs-patches-pc goes by that name also where romfs-patches-pc-<w>x<h>
|
||||
// stands in for it (romfs.c overlay_init).
|
||||
enum { ROMFS_PROMPTS_OK, ROMFS_PROMPTS_MISSING, ROMFS_PROMPTS_FAILED, ROMFS_PROMPTS_SHAPE, ROMFS_PROMPTS_MODS,
|
||||
ROMFS_PROMPTS_PATCHES };
|
||||
const char *romfs_prompts(void);
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@ uint64_t gfx_vsync_ticks(void); // ticks since start
|
||||
uint64_t gfx_vsync_time_ns(uint64_t tick); // kern_now_ns() time of a tick (pacing)
|
||||
double gfx_window_refresh_hz(void); // refresh rate of the window's monitor; 0 unknown
|
||||
int gfx_window_focused(void);
|
||||
int gfx_picture_rect(float out[4]); // where the last present put the picture, fractions of the swapchain (x, y, w, h); 0 before one
|
||||
|
||||
// ---- shader service client (gfx_shader.c)
|
||||
enum { GFX_STAGE_VS, GFX_STAGE_TCS, GFX_STAGE_TES, GFX_STAGE_GS, GFX_STAGE_FS, GFX_STAGE_CS };
|
||||
@@ -52,7 +53,7 @@ const GfxShader *gfx_shader_await(int stage, const uint8_t *control, const uint8
|
||||
uint64_t gfx_shader_async_done(void); // translations finished by the background worker
|
||||
void gfx_shader_counts(uint64_t *keys, uint64_t *translated); // distinct keys in memory, translations done (TOTK_TRACE=perf)
|
||||
// Translator answers for this device (vk_caps.c): shaderFloat64, storage offset rounding, subgroup size per vs/fs/cs.
|
||||
void gfx_shader_set_host(int float64, uint32_t sbuf_align, const uint32_t subgroup[3], uint32_t gather_bits);
|
||||
void gfx_shader_set_host(int float64, uint32_t sbuf_align, const uint32_t subgroup[3], uint32_t gather_bits, int zero_locals);
|
||||
const uint8_t *gfx_shader_key(const GfxShader *s); // the 32-byte cache key (stable while the translator is)
|
||||
const uint8_t *gfx_shader_input_id(const GfxShader *s); // the 32-byte input id (stable across translators)
|
||||
// By input id from memory or disk; xlate 1 also translates it from its stored inputs. NULL if absent.
|
||||
|
||||
@@ -100,6 +100,7 @@ static void null_replay(uint64_t handle) {
|
||||
}
|
||||
|
||||
static uint64_t null_submit(const uint64_t *handles, int n) {
|
||||
nvn_pins_sweep(nvn_submit_count(), NULL, 0); // no replay state outlives a submission here
|
||||
for (int i = 0; i < n; i++) null_replay(handles[i]);
|
||||
return __atomic_add_fetch(&fence, 1, __ATOMIC_ACQ_REL);
|
||||
}
|
||||
|
||||
+25
-3
@@ -72,6 +72,7 @@ void gfx_window_closed(void) {
|
||||
rt_log("gfx: window closed; %s", restart ? "restarting" : "exiting");
|
||||
fs_save_flush();
|
||||
vk_pipeline_save();
|
||||
playtime_save();
|
||||
prof_write(); // plat_exit runs no atexit handlers
|
||||
if (restart) {
|
||||
plat_setenv("TOTK_REEXEC", NULL); // the new process counts its own address-space retries (memory.c)
|
||||
@@ -295,8 +296,7 @@ static void free_capture(void) {
|
||||
g_cap_valid = 0;
|
||||
}
|
||||
|
||||
// Returns 1 with a usable swapchain, 0 when the window has no area (minimised). Called with g_pl held.
|
||||
static int create_swapchain(void) {
|
||||
static int build_swapchain(void) {
|
||||
flip_locked(); // the acquired image goes out before its swapchain is replaced
|
||||
VkCtx *c = vk_ctx();
|
||||
VkSurfaceCapabilitiesKHR caps;
|
||||
@@ -364,10 +364,19 @@ static int create_swapchain(void) {
|
||||
}
|
||||
free_capture();
|
||||
vk_queue_unlock();
|
||||
plat_xchg32(&g_dirty, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Returns 1 with a usable swapchain, 0 when the window has no area (minimised). Called with g_pl held. The stale mark
|
||||
// goes before the surface is read: a resize during the build keeps it. The switch to fullscreen at start lands there
|
||||
// on the window thread, and the driver does not report the swapchain as out of date.
|
||||
static int create_swapchain(void) {
|
||||
plat_xchg32(&g_dirty, 0);
|
||||
if (build_swapchain()) return 1;
|
||||
plat_xchg32(&g_dirty, 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void sync_barrier(VkCommandBuffer cb, VkImage img, VkImageLayout from, VkImageLayout to, VkPipelineStageFlags2 s0,
|
||||
VkAccessFlags2 a0, VkPipelineStageFlags2 s1, VkAccessFlags2 a1) {
|
||||
VkImageMemoryBarrier2 b = {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2, .srcStageMask = s0, .srcAccessMask = a0,
|
||||
@@ -543,6 +552,15 @@ static void save_capture(const char *path) {
|
||||
// TOTK_ASPECT=stretch, else (fit, the default) the largest one of the picture's own aspect ratio, centred (a 16:9 picture
|
||||
// on the Steam Deck's 1280x800 screen gets bars of 40 rows above and below; a 16:9 picture in a 21:9 window, bars at the
|
||||
// sides).
|
||||
// Where the last present put the picture, as fractions of the swapchain (x, y, w, h): menu_cursor.c's pointer
|
||||
static float g_pic[4];
|
||||
static volatile int32_t g_pic_ok;
|
||||
int gfx_picture_rect(float out[4]) {
|
||||
if (!__atomic_load_n(&g_pic_ok, __ATOMIC_ACQUIRE)) return 0;
|
||||
memcpy(out, g_pic, sizeof g_pic);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void gfx_fit_rect(int cw, int ch, int dw, int dh, int out[4]) {
|
||||
out[0] = out[1] = 0, out[2] = dw, out[3] = dh;
|
||||
if (cw <= 0 || ch <= 0 || !strcmp(cfg_value("TOTK_ASPECT"), "stretch")) return;
|
||||
@@ -616,6 +634,10 @@ static void present_image(VkImage src, VkImageLayout layout, VkFormat fmt, int w
|
||||
const int rect[4] = {cx, cy, cw, ch};
|
||||
int to[4]; // where the picture goes in the swapchain image: {x, y, w, h}
|
||||
gfx_fit_rect(cw, ch, (int)g_sc_ext.width, (int)g_sc_ext.height, to);
|
||||
if (g_sc_ext.width && g_sc_ext.height) {
|
||||
for (int i = 0; i < 4; i++) g_pic[i] = (float)to[i] / (float)(i & 1 ? g_sc_ext.height : g_sc_ext.width);
|
||||
__atomic_store_n(&g_pic_ok, 1, __ATOMIC_RELEASE);
|
||||
}
|
||||
if (to[2] != (int)g_sc_ext.width || to[3] != (int)g_sc_ext.height) { // black bars beside or above and below it
|
||||
VkClearColorValue black = {.float32 = {0, 0, 0, 1}};
|
||||
VkImageSubresourceRange all = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
|
||||
@@ -119,7 +119,7 @@ static struct { // translations in fl
|
||||
static char cache_dir[PATH_CAP];
|
||||
static char xlate_exe[PATH_CAP];
|
||||
static uint8_t xlate_id[32]; // translator identity (see the keys at the top)
|
||||
static int host_f64 = 1, host_set; // gfx_shader_set_host
|
||||
static int host_f64 = 1, host_zero_locals, host_set; // gfx_shader_set_host
|
||||
static uint32_t host_sbuf, host_sg[3] = {32, 32, 32}, host_gather;
|
||||
static PlatOnce paths_once = PLAT_ONCE_INIT;
|
||||
|
||||
@@ -313,18 +313,19 @@ static void init_paths(void *param) {
|
||||
static void paths(void) { plat_once(&paths_once, init_paths, NULL); }
|
||||
|
||||
// Host answers (vk_caps.c, at device creation, before any translation). Answers other than the defaults (Float64,
|
||||
// exact storage offsets, 32 lanes, no gather bias) are mixed into the translator identity, so they get their own cache
|
||||
// keys.
|
||||
void gfx_shader_set_host(int float64, uint32_t sbuf_align, const uint32_t subgroup[3], uint32_t gather_bits) {
|
||||
// exact storage offsets, 32 lanes, no gather bias, undefined locals) are mixed into the translator identity, so they
|
||||
// get their own cache keys.
|
||||
void gfx_shader_set_host(int float64, uint32_t sbuf_align, const uint32_t subgroup[3], uint32_t gather_bits, int zero_locals) {
|
||||
paths();
|
||||
if (host_set++) return;
|
||||
host_f64 = float64, host_sbuf = sbuf_align, host_gather = gather_bits;
|
||||
host_f64 = float64, host_sbuf = sbuf_align, host_gather = gather_bits, host_zero_locals = zero_locals;
|
||||
memcpy(host_sg, subgroup, sizeof host_sg);
|
||||
if (float64 && !sbuf_align && subgroup[0] == 32 && subgroup[1] == 32 && subgroup[2] == 32 && !gather_bits) return;
|
||||
if (float64 && !sbuf_align && subgroup[0] == 32 && subgroup[1] == 32 && subgroup[2] == 32 && !gather_bits && !zero_locals) return;
|
||||
uint8_t buf[32 + 96];
|
||||
int n = snprintf((char *)buf + 32, 96, "TKHO f64=%d sbuf=%u sg=%u,%u,%u", float64, sbuf_align, subgroup[0], subgroup[1],
|
||||
subgroup[2]);
|
||||
if (gather_bits) n += snprintf((char *)buf + 32 + n, (size_t)(96 - n), " gather=%u", gather_bits);
|
||||
if (zero_locals) n += snprintf((char *)buf + 32 + n, (size_t)(96 - n), " zero_locals");
|
||||
memcpy(buf, xlate_id, 32);
|
||||
rt_sha256(buf, 32 + (size_t)n, xlate_id);
|
||||
rt_log("gfx_shader: host options %s: own shader cache keys", (char *)buf + 37);
|
||||
@@ -1174,6 +1175,7 @@ static size_t build_request(char *o0, const char *hex, const char *stage, const
|
||||
if (host_sbuf) o += sprintf(o, ",\"sbuf_align\":%u", host_sbuf);
|
||||
if (host_sg[si] != 32) o += sprintf(o, ",\"subgroup\":%u", host_sg[si]);
|
||||
if (host_gather) o += sprintf(o, ",\"gather_bias\":%u", host_gather);
|
||||
if (host_zero_locals) o += sprintf(o, ",\"zero_locals\":true");
|
||||
o += sprintf(o, "},\"out\":\"");
|
||||
json_esc(&o, cache_dir);
|
||||
o += sprintf(o, "\"}");
|
||||
|
||||
@@ -333,10 +333,12 @@ static void window_main(void *arg) {
|
||||
outer_size(g_hwnd, cw, ch, &ow, &oh);
|
||||
SetWindowPos(g_hwnd, HWND_BOTTOM, 0, 0, ow, oh, SWP_NOMOVE | SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||||
if (!background) {
|
||||
// Pending before the raise: the activation it gets is the first WM_ACTIVATEAPP, which takes the fullscreen
|
||||
// setting; set after it, fullscreen waited for the next activation and the game started in a window
|
||||
g_fs_pending = cfg_fullscreen();
|
||||
raise_window(g_hwnd); // a frontend's player may have no way to reach a window left behind
|
||||
FLASHWINFO fw = {.cbSize = sizeof fw, .hwnd = g_hwnd, .dwFlags = FLASHW_TRAY | FLASHW_TIMERNOFG};
|
||||
FlashWindowEx(&fw);
|
||||
g_fs_pending = cfg_fullscreen();
|
||||
}
|
||||
note_applied();
|
||||
refresh_rate_update(g_hwnd, 1);
|
||||
|
||||
@@ -167,6 +167,9 @@ void mem_thaw(uint64_t a, uint64_t n); // makes the frozen r
|
||||
void mem_freeze_stats(uint64_t *frozen, uint64_t *freezes, uint64_t *fault_thaws, uint64_t *other_thaws);
|
||||
// regions frozen now; freezes and thaws since the last call
|
||||
void mem_kernel_free(void *p, uint64_t size); // TLR/TLS reuse after thread exit
|
||||
int mem_guest_readable(uint64_t a, uint64_t n); // [a, a+n) can be read without a fault: inside a loaded
|
||||
// module or the committed heap answered from the layout,
|
||||
// anything else by plat_mem_readable (an OS query)
|
||||
|
||||
// ---- modules (loader.c)
|
||||
typedef struct {
|
||||
@@ -257,6 +260,12 @@ void cam_pack_served(void); // romfs.c serves the
|
||||
// ---- game resolution (resolution.c): TOTK_OUTPUT_RES, TOTK_RENDER_SCALE
|
||||
uint64_t res_heap_extra_bytes(void); // added to the application pool (mem_init), 2 MiB steps
|
||||
uint64_t res_heap_extra_kib(void); // added to the HeapDef texture heap and its parent; 0 = none
|
||||
uint64_t res_tex_heap_extra_bytes(void); // TOTK_TEX_HEAP_EXTRA_MB: added to the application pool and
|
||||
// the heap cap (mem_init), 2 MiB steps; 0 = none
|
||||
int res_heapdef_tex_grown(const void *in, size_t n); // the HeapDef table (.byml.zs) grows TextureHeap over the
|
||||
// shipped size (an HD texture pack's)
|
||||
int res_heapdef_plain(const void *in, size_t n); // a readable HeapDef table whose TextureHeap is not
|
||||
// grown (a content mod's)
|
||||
int res_heapdef_build(const void *in, size_t n, void **out, size_t *out_n); // the HeapDef table (.byml.zs) with
|
||||
// the grown heaps, malloc'd; 0 = cannot (logged)
|
||||
void res_heapdef_served(void); // romfs.c serves the grown table
|
||||
@@ -269,6 +278,8 @@ void res_sizes(unsigned out[2], unsigned scene[2]); // the output and scene
|
||||
// until res_init switched the sets on)
|
||||
int res_is_16x9(unsigned w, unsigned h); // within 1% of 16:9 (the test res_init and romfs.c share)
|
||||
int res_wants_wide_overlay(const unsigned out[2]); // not 16:9 on a game version with the camera aspect patch
|
||||
int romfs_shaped_ready(unsigned w, unsigned h); // romfs-patches-<w>x<h> has a manifest (romfs.c; works
|
||||
// before fs_init)
|
||||
void res_wanted(unsigned out[2]); // the output size TOTK_OUTPUT_RES asks for
|
||||
void res_fov_live(void); // TOTK_FOV to the camera (set fov); on a settings change
|
||||
// ---- code patches (patches.c) and the frame step (frame_rate.c)
|
||||
@@ -313,6 +324,10 @@ int probe_cfg(void); // cfg_probe.c
|
||||
int probe_dpi(void); // dpi_probe_win.c (Windows only)
|
||||
int probe_mods(void); // mods_probe.c
|
||||
void discord_init(void); // discord_rpc.c: the Rich Presence thread, game runs only
|
||||
void playtime_init(void); // playtime.c: the play time thread, game runs only
|
||||
void playtime_save(void); // playtime.c: writes <profile>/playtime.txt; at exit
|
||||
int playtime_get(uint64_t *total_s, uint64_t *session_s); // playtime.c: all runs and this one; 0 without a profile
|
||||
int probe_playtime(void); // playtime.c: the file, the ticks and the save
|
||||
int probe_discord(void); // discord_rpc.c
|
||||
int probe_loader(void), probe_kernel(void), probe_ipc(void), probe_services(void), probe_fs(void);
|
||||
int probe_gfx_abi(void); // gfx_abi_probe.c
|
||||
|
||||
+24
-5
@@ -35,7 +35,7 @@ typedef struct {
|
||||
const char *name;
|
||||
GuestFn fn;
|
||||
uint64_t original; // observers: the guest definition rt_original calls
|
||||
uint64_t calls;
|
||||
uint64_t calls; // with TOTK_TRACE=host only (hostfn_report)
|
||||
int blocking; // runs with the virtual core given up (hostfn_blocking)
|
||||
} Slot;
|
||||
static Slot slots[MAX_HOSTFN];
|
||||
@@ -44,6 +44,21 @@ static RtHostEntry added[MAX_ADDED];
|
||||
static int nadded;
|
||||
static int sealed;
|
||||
|
||||
// The call count (hostfn_calls): one counter per calling thread, each on its own cache line, written only by that
|
||||
// thread. A locked add on a per-function counter beside the read-mostly slot fields made the game threads share and
|
||||
// bounce those lines on every host call (1.4.3 town: about 1% of the game threads' time in hostfn_entry).
|
||||
#define MAX_COUNTERS 4096
|
||||
typedef struct { _Alignas(64) uint64_t n; } Counter;
|
||||
static Counter counters[MAX_COUNTERS];
|
||||
static uint32_t ncounters;
|
||||
static uint64_t shared_calls; // the threads past MAX_COUNTERS, counted with an atomic add
|
||||
static __thread uint64_t *thread_calls;
|
||||
|
||||
__attribute__((noinline)) static uint64_t *counter_new(void) {
|
||||
uint32_t i = __atomic_fetch_add(&ncounters, 1, __ATOMIC_RELAXED);
|
||||
return thread_calls = i < MAX_COUNTERS ? &counters[i].n : &shared_calls;
|
||||
}
|
||||
|
||||
static GuestFn table_find(const RtHostEntry *start, const RtHostEntry *stop, const char *name) {
|
||||
__asm__("" : "+r"(start), "+r"(stop)); // separate objects: keep the compiler from reasoning about them
|
||||
for (const RtHostEntry *p = start; p < stop; p++)
|
||||
@@ -146,8 +161,11 @@ static void hostfn_entry(Cpu *c) {
|
||||
uint64_t i = (c->pc - RT_HOSTFN_BASE) >> 4;
|
||||
if (__builtin_expect((c->pc & 15) || i >= (uint64_t)nslots, 0)) bad_entry(c);
|
||||
Slot *s = &slots[i];
|
||||
uint64_t n = __atomic_fetch_add(&s->calls, 1, __ATOMIC_RELAXED);
|
||||
if (__builtin_expect(!n && (rt_trace & RT_TRACE_HOSTCALL), 0)) {
|
||||
uint64_t *k = thread_calls;
|
||||
if (__builtin_expect(!k, 0)) k = counter_new();
|
||||
if (__builtin_expect(k == &shared_calls, 0)) __atomic_fetch_add(k, 1, __ATOMIC_RELAXED);
|
||||
else __atomic_store_n(k, *k + 1, __ATOMIC_RELAXED);
|
||||
if (__builtin_expect(rt_trace & RT_TRACE_HOSTCALL, 0) && !__atomic_fetch_add(&s->calls, 1, __ATOMIC_RELAXED)) {
|
||||
char lr[96];
|
||||
rt_log("host call %s from %s", s->name, ldr_describe(c->x[30] - 4, lr, sizeof lr));
|
||||
}
|
||||
@@ -178,8 +196,9 @@ void hostfn_seal(void) {
|
||||
}
|
||||
|
||||
uint64_t hostfn_calls(void) {
|
||||
uint64_t n = 0;
|
||||
for (int i = 0; i < nslots; i++) n += __atomic_load_n(&slots[i].calls, __ATOMIC_RELAXED);
|
||||
uint32_t m = __atomic_load_n(&ncounters, __ATOMIC_RELAXED);
|
||||
uint64_t n = __atomic_load_n(&shared_calls, __ATOMIC_RELAXED);
|
||||
for (uint32_t i = 0; i < m && i < MAX_COUNTERS; i++) n += __atomic_load_n(&counters[i].n, __ATOMIC_RELAXED);
|
||||
return n;
|
||||
}
|
||||
|
||||
|
||||
+154
-13
@@ -26,6 +26,13 @@ enum { STICK_MAX = 32767, STICK_DIR = 16384, TRIGGER_ON = 16384, RESCAN_MS = 100
|
||||
|
||||
static PlatMutex lock = PLAT_MUTEX_INIT;
|
||||
static int state; // 0 not started, 1 running, -1 disabled or SDL failed
|
||||
static volatile int32_t g_block, g_menu; // text entry / host menu: 0 off, 1 open, 2 waiting for release
|
||||
// The device the player used last (input_kbm_last): 1 keyboard and mouse, 0 the gamepad. A pad counts once a button is
|
||||
// down or a stick is past half way (a drifting stick never switches), the mouse once it moved MOUSE_USED raw counts
|
||||
// within one poll (a slow deliberate move does; a desk bump does not), the keyboard with any key or mouse button.
|
||||
enum { PAD_USED = 16384, MOUSE_USED = 6 };
|
||||
static volatile int32_t g_kbm_last = 1;
|
||||
int input_kbm_last(void) { return __atomic_load_n(&g_kbm_last, __ATOMIC_RELAXED); }
|
||||
static SDL_Gamepad *pad;
|
||||
static uint64_t next_scan;
|
||||
static int pad_rumble; // the open gamepad has rumble motors
|
||||
@@ -66,6 +73,7 @@ static const struct { const char *name; int bit; } script_buttons[] = {
|
||||
static ScriptEv *script_ev;
|
||||
static int script_n, script_state; // 0 not loaded, 1 loaded
|
||||
static int script_mixed; // the script has timed lines: its untimed ones end where the world clock starts
|
||||
static int32_t script_mouse[2]; // one-shot raw counts, consumed by the camera hook; under lock
|
||||
|
||||
static void script_load(void) {
|
||||
script_state = 1;
|
||||
@@ -88,10 +96,11 @@ static void script_load(void) {
|
||||
ScriptEv e = {.at = (uint64_t)(at * unit + 0.5), .hold = timed ? 66667 : 4, .bit = -1, .timed = timed};
|
||||
long a, b;
|
||||
double c;
|
||||
if (!strcmp(name, "LSTICK") || !strcmp(name, "RSTICK")) {
|
||||
if (!strcmp(name, "MOUSE") || !strcmp(name, "LSTICK") || !strcmp(name, "RSTICK")) {
|
||||
int n = sscanf(line + off, "%ld %ld %lf", &a, &b, &c);
|
||||
if (n < 2) { rt_log("input: script line %d: %s needs x y [hold]", ln, name); continue; }
|
||||
e.stick = name[0] == 'L' ? 1 : 2; e.x = (int32_t)a; e.y = (int32_t)b;
|
||||
e.stick = name[0] == 'M' ? 3 : name[0] == 'L' ? 1 : 2; e.x = (int32_t)a; e.y = (int32_t)b;
|
||||
if (e.stick == 3) e.hold = 1; // a displacement, never a held speed
|
||||
if (n >= 3 && c > 0) e.hold = (uint64_t)(c * unit + 0.5);
|
||||
snprintf(e.text, sizeof e.text, "%s %d %d", name, e.x, e.y);
|
||||
} else {
|
||||
@@ -123,12 +132,17 @@ static void script_apply(uint64_t *b, int32_t sl[2], int32_t sr[2]) {
|
||||
if (e->timed ? w < 0 : script_mixed && w >= 0) continue;
|
||||
uint64_t now = e->timed ? (uint64_t)w : tick;
|
||||
if (now < e->at || (now >= e->at + e->hold && (e->logged || !e->timed))) continue;
|
||||
if (e->stick == 3 && e->logged) continue;
|
||||
if (!e->logged) {
|
||||
e->logged = 1;
|
||||
if (e->timed) rt_log("input: script frame %llu at %.1f ms: %s", (unsigned long long)fr, w / 1000.0, e->text);
|
||||
else rt_log("input: script frame %llu tick %llu: %s", (unsigned long long)fr, (unsigned long long)tick, e->text);
|
||||
}
|
||||
if (e->stick) { int32_t *s = e->stick == 1 ? sl : sr; s[0] = e->x; s[1] = e->y; }
|
||||
if (e->stick == 3) {
|
||||
int64_t x = (int64_t)script_mouse[0] + e->x, y = (int64_t)script_mouse[1] + e->y;
|
||||
script_mouse[0] = (int32_t)(x < INT32_MIN ? INT32_MIN : x > INT32_MAX ? INT32_MAX : x);
|
||||
script_mouse[1] = (int32_t)(y < INT32_MIN ? INT32_MIN : y > INT32_MAX ? INT32_MAX : y);
|
||||
} else if (e->stick) { int32_t *s = e->stick == 1 ? sl : sr; s[0] = e->x; s[1] = e->y; }
|
||||
else *b |= 1ull << e->bit;
|
||||
}
|
||||
}
|
||||
@@ -223,6 +237,30 @@ static void rec_apply(uint64_t b, const int32_t sl[2], const int32_t sr[2]) {
|
||||
if (wrote) fflush(rec);
|
||||
}
|
||||
|
||||
void input_direct_mouse_sample(int enabled, int32_t *dx, int32_t *dy) {
|
||||
plat_mutex_lock(&lock);
|
||||
enabled = enabled && !g_block && !g_menu;
|
||||
*dx = script_mouse[0]; *dy = script_mouse[1];
|
||||
script_mouse[0] = script_mouse[1] = 0;
|
||||
if (!enabled) { *dx = *dy = 0; }
|
||||
else if (ikb_captured() && ikb_foreground() && input_cursor_allowed()) {
|
||||
int32_t x, y;
|
||||
ikb_mouse_take(&x, &y);
|
||||
int64_t sx = (int64_t)*dx + x, sy = (int64_t)*dy + y;
|
||||
*dx = (int32_t)(sx < INT32_MIN ? INT32_MIN : sx > INT32_MAX ? INT32_MAX : sx);
|
||||
*dy = (int32_t)(sy < INT32_MIN ? INT32_MIN : sy > INT32_MAX ? INT32_MAX : sy);
|
||||
}
|
||||
if (enabled && (*dx || *dy) && rec_state == 1) {
|
||||
char what[80];
|
||||
snprintf(what, sizeof what, "MOUSE %d %d", *dx, *dy);
|
||||
int64_t w = frame_step_world_us();
|
||||
uint64_t tick = frame_step_ticks();
|
||||
rec_line(tick, w, tick + 1, w, what);
|
||||
fflush(rec);
|
||||
}
|
||||
plat_mutex_unlock(&lock);
|
||||
}
|
||||
|
||||
static float mouse_sens = -1; // TOTK_MOUSE_SENS, 0 = mouse camera off; live (cfg_version)
|
||||
static unsigned mouse_sens_seen;
|
||||
|
||||
@@ -282,15 +320,41 @@ static void walk_get(void) {
|
||||
}
|
||||
}
|
||||
|
||||
// The mouse camera as a right-stick sample, held for the whole presented frame. Under `lock`. The sample is divided by
|
||||
// the camera speed factor the game runs with (camera_speed.c, cam_speed_applied), which multiplies every right-stick
|
||||
// turn rate, so TOTK_MOUSE_SENS alone sets the mouse's speed; below 1 the sample stays within the stick's range, so the
|
||||
// mouse's top speed drops with the factor.
|
||||
static void mouse_camera(int32_t out[2]) {
|
||||
static int mouse_selector;
|
||||
int input_direct_mouse_selector_active(void) { return __atomic_load_n(&mouse_selector, __ATOMIC_RELAXED) != 0; }
|
||||
|
||||
// Selector 1 is a radial wheel, 2..4 are distinct horizontal quick lists; only a wheel retains a virtual cursor.
|
||||
// Under `lock`.
|
||||
static void mouse_camera(int32_t out[2], int selector) {
|
||||
static uint64_t frame = ~0ull, t0;
|
||||
static int32_t held[2];
|
||||
static int was_selector;
|
||||
static float cursor[2];
|
||||
static InputMouseRow row;
|
||||
uint64_t now = plat_now_ns();
|
||||
int32_t dx, dy;
|
||||
if (!input_direct_mouse_enabled() || !ikb_captured()) selector = 0;
|
||||
__atomic_store_n(&mouse_selector, selector, __ATOMIC_RELAXED);
|
||||
if (selector) {
|
||||
if (was_selector != selector) cursor[0] = cursor[1] = 0, row = (InputMouseRow){0};
|
||||
was_selector = selector;
|
||||
ikb_mouse_take(&dx, &dy);
|
||||
if (selector == 1) input_direct_mouse_radial(dx, dy, mouse_sens_get(), cursor, out);
|
||||
else input_direct_mouse_row(dx, mouse_sens_get(), now, &row, out);
|
||||
held[0] = held[1] = 0; frame = ~0ull;
|
||||
return;
|
||||
}
|
||||
if (was_selector) {
|
||||
was_selector = 0; cursor[0] = cursor[1] = 0; row = (InputMouseRow){0};
|
||||
ikb_mouse_take(&dx, &dy); // release the wheel without sending its last movement to the camera
|
||||
held[0] = held[1] = out[0] = out[1] = 0; frame = ~0ull;
|
||||
return;
|
||||
}
|
||||
if (input_direct_mouse_active()) {
|
||||
held[0] = held[1] = out[0] = out[1] = 0;
|
||||
frame = ~0ull;
|
||||
return; // the camera update takes the raw counts itself, once, outside the HID stick path
|
||||
}
|
||||
if (!ikb_captured()) {
|
||||
ikb_mouse_take(&dx, &dy);
|
||||
held[0] = held[1] = 0;
|
||||
@@ -459,6 +523,53 @@ static void pick(int32_t *x, int32_t *y, int32_t kx, int32_t ky) {
|
||||
if (b > a) { *x = kx; *y = ky; }
|
||||
}
|
||||
|
||||
// The shake key (B_SHAKE): the game detaches a part fused to what Ultrahand holds when the right stick swings left and
|
||||
// right a few times. A press swings it for SHAKE_NS, holding keeps it swinging; each side lasts SHAKE_SIDE_NS, two
|
||||
// frames at the game's 30 fps, so every side is read even at 20 fps. Returns the stick x, 0 while not shaking.
|
||||
// Under lock.
|
||||
#define SHAKE_NS 800000000ull
|
||||
#define SHAKE_SIDE_NS 66666667ull
|
||||
static int32_t shake_stick(int held) {
|
||||
static int was;
|
||||
static uint64_t from, until;
|
||||
uint64_t now = plat_now_ns();
|
||||
if (held && !was && now >= until) from = now;
|
||||
if (held && !was) until = now + SHAKE_NS;
|
||||
if (held && until < now) until = now;
|
||||
was = held;
|
||||
if (now >= until && !held) return 0;
|
||||
return (now - from) / SHAKE_SIDE_NS % 2 ? STICK_MAX : -STICK_MAX;
|
||||
}
|
||||
|
||||
// The scroll wheel on the Purah Pad's map, which zooms with the right stick's y (up: in): each notch is a full-tilt
|
||||
// pulse of MAP_ZOOM_PULSE_NS on that stick with MAP_ZOOM_GAP_NS of neutral after it, so a step of the zoom and a
|
||||
// held zoom both see one notch as one push. A fast scroll's notches wait their turn (at most MAP_ZOOM_QUEUE), a turn the
|
||||
// other way drops them. Returns the stick y for this poll, 0 between pulses and off the map. Under lock.
|
||||
#define MAP_ZOOM_PULSE_NS 100000000ull
|
||||
#define MAP_ZOOM_GAP_NS 50000000ull
|
||||
#define MAP_ZOOM_QUEUE 4
|
||||
static int32_t map_zoom_stick(int32_t notches, int on_map) {
|
||||
static int32_t queued, dir;
|
||||
static uint64_t until; // the end of the pulse (dir != 0) or of the neutral after it
|
||||
uint64_t now = plat_now_ns();
|
||||
if (!on_map) { queued = dir = 0, until = 0; return 0; }
|
||||
if (notches) {
|
||||
if (queued && (notches > 0) != (queued > 0)) queued = 0;
|
||||
queued += notches;
|
||||
queued = queued > MAP_ZOOM_QUEUE ? MAP_ZOOM_QUEUE : queued < -MAP_ZOOM_QUEUE ? -MAP_ZOOM_QUEUE : queued;
|
||||
}
|
||||
if (dir) {
|
||||
if (now < until) return dir * STICK_MAX;
|
||||
dir = 0, until = now + MAP_ZOOM_GAP_NS;
|
||||
return 0;
|
||||
}
|
||||
if (now < until || !queued) return 0;
|
||||
dir = queued > 0 ? 1 : -1;
|
||||
queued -= dir;
|
||||
until = now + MAP_ZOOM_PULSE_NS;
|
||||
return dir * STICK_MAX;
|
||||
}
|
||||
|
||||
// Under lock, state 1: the open gamepad's inputs by position (pd[PAD_*] != 0 held); 0 without a connected gamepad.
|
||||
static int pad_physical(uint8_t pd[PAD_COUNT]) {
|
||||
if ((!pad || !SDL_GamepadConnected(pad)) && plat_now_ns() / 1000000u >= next_scan) { next_scan = plat_now_ns() / 1000000u + RESCAN_MS; scan_pads(); }
|
||||
@@ -497,6 +608,8 @@ int input_pad_stick_percent(double pct[2]) {
|
||||
return ok;
|
||||
}
|
||||
|
||||
static uint64_t g_buttons_last; // the merged buttons of the last poll (input_buttons_last)
|
||||
|
||||
static void poll_merged(uint64_t *buttons, int32_t *lx, int32_t *ly, int32_t *rx, int32_t *ry) {
|
||||
uint64_t b = 0;
|
||||
int32_t sl[2] = {0, 0}, sr[2] = {0, 0};
|
||||
@@ -505,6 +618,8 @@ static void poll_merged(uint64_t *buttons, int32_t *lx, int32_t *ly, int32_t *rx
|
||||
if (state > 0) {
|
||||
binds_refresh();
|
||||
if (state == 1) pad_read(&b, sl, sr);
|
||||
int pad_used = b || abs(sl[0]) > PAD_USED || abs(sl[1]) > PAD_USED || abs(sr[0]) > PAD_USED ||
|
||||
abs(sr[1]) > PAD_USED, kbm_used = 0;
|
||||
if (ikb_foreground()) {
|
||||
uint8_t down[KEY_COUNT] = {0};
|
||||
// Mouse buttons count only while the cursor is captured, and a button already held when the capture began
|
||||
@@ -514,6 +629,7 @@ static void poll_merged(uint64_t *buttons, int32_t *lx, int32_t *ly, int32_t *rx
|
||||
int cap = ikb_captured();
|
||||
for (int k = 1; k < KEY_COUNT; k++) {
|
||||
down[k] = ikb_down(k);
|
||||
kbm_used |= down[k];
|
||||
if (k < KEY_MOUSE_L || k > KEY_MOUSE_X2) continue;
|
||||
if (cap && !was_captured) mouse_ignore[k] = down[k];
|
||||
if (!down[k]) mouse_ignore[k] = 0;
|
||||
@@ -530,9 +646,32 @@ static void poll_merged(uint64_t *buttons, int32_t *lx, int32_t *ly, int32_t *rx
|
||||
input_map_stick(down, 1, k);
|
||||
if (walk) k[0] = (int32_t)(k[0] * walk_scale), k[1] = (int32_t)(k[1] * walk_scale);
|
||||
pick(&sr[0], &sr[1], k[0], k[1]);
|
||||
mouse_camera(k);
|
||||
pick(&sr[0], &sr[1], k[0], k[1]);
|
||||
int selector = b & (1ull << B_L) ? 1 : b & (1ull << B_UP) ? 2 :
|
||||
b & (1ull << B_LEFT) ? 3 : b & (1ull << B_RIGHT) ? 4 : 0;
|
||||
// Only while the wheel or a strip is on screen (menu_cursor.c reads the focused screen): L also soothes the
|
||||
// horse and turns menu tabs, the D-pad does other things too, and then the mouse stays on the camera.
|
||||
// Before the screens were read (the menu cursor off) the buttons alone decide, as before.
|
||||
if (selector && menu_cursor_shortcut() == 0) selector = 0;
|
||||
mouse_camera(k, selector);
|
||||
// On the map the left stick moves the cursor and the right one zooms: the mouse moves the cursor there,
|
||||
// the scroll wheel zooms (map_zoom_stick). Elsewhere the wheel is unbound; its notches are dropped.
|
||||
int map = menu_cursor_map();
|
||||
if (map) pick(&sl[0], &sl[1], k[0], k[1]);
|
||||
else pick(&sr[0], &sr[1], k[0], k[1]);
|
||||
int32_t zoom = map_zoom_stick(ikb_wheel_take(), map);
|
||||
if (zoom) pick(&sr[0], &sr[1], 0, zoom);
|
||||
int32_t shake = shake_stick(input_map_held(down, B_SHAKE));
|
||||
if (shake) sr[0] = shake, sr[1] = 0;
|
||||
// A mouse shake (mouse_direct.c) moves the stick with the mouse, side by side, and lets go when it rests
|
||||
else if (input_direct_mouse_shake()) sr[0] = input_direct_mouse_shake() * STICK_MAX, sr[1] = 0;
|
||||
static uint32_t travel_seen;
|
||||
uint32_t travel = ikb_mouse_travel();
|
||||
if (travel - travel_seen >= MOUSE_USED) kbm_used = 1;
|
||||
travel_seen = travel;
|
||||
}
|
||||
// A press or a full-way stick on the pad wins over keys held in the same poll: the player picked it up
|
||||
if (pad_used) __atomic_store_n(&g_kbm_last, 0, __ATOMIC_RELAXED);
|
||||
else if (kbm_used) __atomic_store_n(&g_kbm_last, 1, __ATOMIC_RELAXED);
|
||||
}
|
||||
script_apply(&b, sl, sr);
|
||||
rec_apply(b, sl, sr);
|
||||
@@ -548,14 +687,15 @@ static void poll_merged(uint64_t *buttons, int32_t *lx, int32_t *ly, int32_t *rx
|
||||
if (overlay) menu_perf_cycle();
|
||||
stick_bits(&b, sl[0], sl[1], B_SL_LEFT);
|
||||
stick_bits(&b, sr[0], sr[1], B_SR_LEFT);
|
||||
__atomic_store_n(&g_buttons_last, b, __ATOMIC_RELAXED);
|
||||
*buttons = b; *lx = sl[0]; *ly = sl[1]; *rx = sr[0]; *ry = sr[1];
|
||||
}
|
||||
|
||||
uint64_t input_buttons_last(void) { return __atomic_load_n(&g_buttons_last, __ATOMIC_RELAXED); }
|
||||
|
||||
// Text entry (swkbd_*.c): while it is open the game reads no input, as behind the keyboard applet; after it closes
|
||||
// the game reads none until every button and stick direction is released, so the press that closed it stays there.
|
||||
// g_block: 0 off, 1 text entry open, 2 closed, waiting for the release. The cursor is released while it is open.
|
||||
static volatile int32_t g_block;
|
||||
static volatile int32_t g_menu; // the in-game menu, the same way (input_menu)
|
||||
// When g_block and g_menu last went to 2 (plat_now_ns). The wait for the release ends after RELEASE_WAIT_NS even with
|
||||
// something held: an input that never reads as released (a button another program holds down, a key whose release
|
||||
// went to another window, a stick that rests outside its dead zone) would otherwise keep the game deaf until restart
|
||||
@@ -565,7 +705,7 @@ static volatile uint64_t g_block_closed, g_menu_closed;
|
||||
|
||||
int input_cursor_allowed(void) {
|
||||
const char *in = cfg_get("TOTK_INPUT");
|
||||
return g_block != 1 && g_menu != 1 && mouse_sens_get() > 0 && !(in && !strcmp(in, "none"));
|
||||
return g_block != 1 && g_menu != 1 && mouse_sens_get() > 0 && !(in && !strcmp(in, "none")) && !menu_cursor_pointer();
|
||||
}
|
||||
|
||||
void input_block(int on) {
|
||||
@@ -603,6 +743,7 @@ static int blocked(volatile int32_t *flag, volatile uint64_t *closed, const char
|
||||
|
||||
void input_poll(uint64_t *buttons, int32_t *lx, int32_t *ly, int32_t *rx, int32_t *ry) {
|
||||
poll_merged(buttons, lx, ly, rx, ry);
|
||||
if (!g_block && !g_menu) menu_cursor_poll(buttons);
|
||||
const int32_t axes[4] = {*lx, *ly, *rx, *ry};
|
||||
int text = blocked(&g_block, &g_block_closed, "text entry", *buttons, axes),
|
||||
menu = blocked(&g_menu, &g_menu_closed, "menu", *buttons, axes);
|
||||
|
||||
@@ -6,6 +6,35 @@
|
||||
// Down=15) and the stick directions (StickLLeft..Down 16-19, StickRLeft..Down 20-23, derived from the sticks).
|
||||
// Sticks are -32767..32767, up is +y. TOTK_INPUT=none reports no input. Callable from any thread.
|
||||
void input_poll(uint64_t *buttons, int32_t *lx, int32_t *ly, int32_t *rx, int32_t *ry);
|
||||
// 1 when keyboard and mouse are the device the player used last, 0 when the gamepad is: what is shown for pointing
|
||||
// (menu_cursor.c shows the pointer in the game's menus only for the keyboard and mouse)
|
||||
int input_kbm_last(void);
|
||||
// Direct mouse input is consumed by the game's camera-input update, after its stick response. While that update
|
||||
// accepts camera input, the HID right stick contains only the controller and keyboard. Selectors route mouse input
|
||||
// separately: a persistent radial cursor for the ability wheel, discrete steps for horizontal quick lists.
|
||||
int input_direct_mouse_active(void);
|
||||
int input_direct_mouse_enabled(void);
|
||||
// A radial selector uses a persistent virtual cursor while its opening button is held.
|
||||
void input_direct_mouse_radial(int32_t dx, int32_t dy, float sensitivity, float cursor[2], int32_t out[2]);
|
||||
// Horizontal quick selectors receive short, separated steps instead of a latched radial-cursor direction.
|
||||
typedef struct { float distance; uint64_t pulse_until, neutral_until; int direction; } InputMouseRow;
|
||||
void input_direct_mouse_row(int32_t dx, float sensitivity, uint64_t now, InputMouseRow *row, int32_t out[2]);
|
||||
int input_direct_mouse_selector_active(void);
|
||||
// Consume direct mouse counts, including one-shot MOUSE events from an input script. enabled=0 discards scripted
|
||||
// counts while leaving the real mouse for menu input. Live counts require cursor capture and foreground focus.
|
||||
void input_direct_mouse_sample(int enabled, int32_t *dx, int32_t *dy);
|
||||
// The buttons (B_* bits) of the last poll, keys and gamepad merged; any thread (mouse_direct.c: ZR for the bow's scale)
|
||||
uint64_t input_buttons_last(void);
|
||||
// mouse_direct.c: the right stick's side a mouse shake gives while Ultrahand holds an object (-1 / +1), 0 while the
|
||||
// mouse does not shake; input.c puts it on the stick
|
||||
int input_direct_mouse_shake(void);
|
||||
// menu_cursor.c: the L ability wheel or a D-pad strip has the focus: 1 / 0, -1 with the menu cursor off (not read)
|
||||
int menu_cursor_shortcut(void);
|
||||
// menu_cursor.c: the pointer in the game's menus (1.4.3). poll: once per pad read, adds a click's A or B; pointer: a
|
||||
// menu has the pointer, so the mouse camera's capture is released
|
||||
void menu_cursor_poll(uint64_t *buttons);
|
||||
int menu_cursor_pointer(void);
|
||||
int menu_cursor_map(void); // the Purah Pad's map is open: the mouse's stick is the left one (its cursor)
|
||||
// The same for host UI (the text entry): never blocked. input_block(1) while a text entry is open: input_poll then
|
||||
// reports nothing; input_block(0) when it closes: nothing until all buttons and stick directions are released.
|
||||
void input_poll_ui(uint64_t *buttons, int32_t *lx, int32_t *ly, int32_t *rx, int32_t *ry);
|
||||
|
||||
+13
-5
@@ -20,7 +20,7 @@
|
||||
{KEY_SPACE, B_X}, {KEY_LSHIFT, B_B}, {KEY_E, B_A}, {KEY_Q, B_L}, {KEY_R, B_R}, \
|
||||
{KEY_LCTRL, B_STICK_L}, {KEY_C, B_STICK_R}, \
|
||||
{KEY_1, B_LEFT}, {KEY_2, B_UP}, {KEY_3, B_RIGHT}, {KEY_4, B_DOWN}, \
|
||||
{KEY_TAB, B_PLUS}, {KEY_M, B_MINUS}
|
||||
{KEY_TAB, B_PLUS}, {KEY_M, B_MINUS}, {KEY_G, B_SHAKE}
|
||||
|
||||
// By position: south is Switch B, east A, west Y, north X (Nintendo layout); the Xbox layout swaps A with B and X with Y
|
||||
// (south A, as the Xbox labels; cross A on a PlayStation pad). The triggers are ZL and ZR.
|
||||
@@ -31,7 +31,7 @@
|
||||
{PAD_ZR, B_ZR}
|
||||
#define NINTENDO_PADS DEFAULT_PADS(B_B, B_A, B_Y, B_X)
|
||||
#define XBOX_PADS DEFAULT_PADS(B_A, B_B, B_X, B_Y)
|
||||
enum { DEFAULT_KEY_COUNT = 26, DEFAULT_PAD_COUNT = 16 };
|
||||
enum { DEFAULT_KEY_COUNT = 27, DEFAULT_PAD_COUNT = 16 };
|
||||
_Static_assert(sizeof (Binding[]){DEFAULT_KEYS} / sizeof (Binding) == DEFAULT_KEY_COUNT, "DEFAULT_KEY_COUNT");
|
||||
|
||||
static const InputMap defaults = {{DEFAULT_KEYS}, DEFAULT_KEY_COUNT, {{NINTENDO_PADS}, {XBOX_PADS}},
|
||||
@@ -41,7 +41,7 @@ InputMap input_map = {{DEFAULT_KEYS}, DEFAULT_KEY_COUNT, {{NINTENDO_PADS}, {XBOX
|
||||
|
||||
static const char *const action_names[ACTION_COUNT] = {
|
||||
"A", "B", "X", "Y", "LCLICK", "RCLICK", "L", "R", "ZL", "ZR", "PLUS", "MINUS", "LEFT", "UP", "RIGHT", "DOWN",
|
||||
"LSTICK_LEFT", "LSTICK_UP", "LSTICK_RIGHT", "LSTICK_DOWN", "RSTICK_LEFT", "RSTICK_UP", "RSTICK_RIGHT", "RSTICK_DOWN"};
|
||||
"LSTICK_LEFT", "LSTICK_UP", "LSTICK_RIGHT", "LSTICK_DOWN", "RSTICK_LEFT", "RSTICK_UP", "RSTICK_RIGHT", "RSTICK_DOWN", "SHAKE"};
|
||||
static const char *const pad_names[PAD_COUNT] = {
|
||||
NULL, "south", "east", "west", "north", "lstick", "rstick", "l", "r", "start", "back",
|
||||
"dpad_left", "dpad_up", "dpad_right", "dpad_down", "zl", "zr", "lpaddle1", "rpaddle1", "lpaddle2", "rpaddle2"};
|
||||
@@ -143,7 +143,7 @@ int input_map_bind(const char *button, const char *list) {
|
||||
}
|
||||
if (!ok) {
|
||||
bad++;
|
||||
rt_log("input: bind %s: ignoring '%s' (expected pad:<name> or key:<name>; stick directions take keys only)",
|
||||
rt_log("input: bind %s: ignoring '%s' (expected pad:<name> or key:<name>; stick directions and SHAKE take keys only)",
|
||||
button, tok);
|
||||
}
|
||||
}
|
||||
@@ -289,11 +289,13 @@ int input_pad_layout_pick(const char *setting, int east_labelled_a) {
|
||||
}
|
||||
|
||||
int input_prompts_pick(const char *prompts, const char *pad_layout) {
|
||||
if (prompts && *prompts) return !strcasecmp(prompts, "xbox") ? PROMPTS_XBOX : PROMPTS_NINTENDO;
|
||||
if (prompts && *prompts)
|
||||
return !strcasecmp(prompts, "xbox") ? PROMPTS_XBOX : !strcasecmp(prompts, "pc") ? PROMPTS_PC : PROMPTS_NINTENDO;
|
||||
return pad_layout && !strcasecmp(pad_layout, "xbox_prompts") ? PROMPTS_XBOX : PROMPTS_NINTENDO;
|
||||
}
|
||||
|
||||
const char *input_prompts_folder(int prompts, const char *pad_layout) {
|
||||
if (prompts == PROMPTS_PC) return "romfs-patches-pc";
|
||||
if (prompts != PROMPTS_XBOX) return NULL;
|
||||
// The folders are read at start, before a pad is open: auto counts as Xbox, as for a pad without Nintendo labels.
|
||||
// A Nintendo-labelled pad under auto plays by label too (its A is on the button labelled A), so it matches as well.
|
||||
@@ -315,6 +317,12 @@ uint64_t input_map_buttons(const uint8_t down[KEY_COUNT]) {
|
||||
return b;
|
||||
}
|
||||
|
||||
int input_map_held(const uint8_t down[KEY_COUNT], int action) {
|
||||
for (int i = 0; i < input_map.nkeys; i++)
|
||||
if (input_map.keys[i].action == action && down[input_map.keys[i].key]) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void input_map_stick(const uint8_t down[KEY_COUNT], int stick, int32_t out[2]) {
|
||||
int first = stick ? B_SR_LEFT : B_SL_LEFT, dir[4] = {0, 0, 0, 0}; // left, up, right, down
|
||||
for (int i = 0; i < input_map.nkeys; i++) {
|
||||
|
||||
+16
-9
@@ -7,11 +7,13 @@
|
||||
#include <stdint.h>
|
||||
|
||||
// Actions are the bit numbers of libnx HidNpadButton (input.h); 16..23 are the stick directions, which keys drive as
|
||||
// stick deflection.
|
||||
// stick deflection. B_SHAKE is a key of its own, never a button bit: a press shakes the right stick left and right for
|
||||
// the game (input.c shake_stick), which detaches a fused part held with Ultrahand. A pad shakes its own stick.
|
||||
enum {
|
||||
B_A = 0, B_B, B_X, B_Y, B_STICK_L, B_STICK_R, B_L, B_R, B_ZL, B_ZR, B_PLUS, B_MINUS, B_LEFT, B_UP, B_RIGHT, B_DOWN,
|
||||
B_SL_LEFT = 16, B_SL_UP, B_SL_RIGHT, B_SL_DOWN, B_SR_LEFT, B_SR_UP, B_SR_RIGHT, B_SR_DOWN,
|
||||
ACTION_COUNT = 24
|
||||
B_SHAKE = 24,
|
||||
ACTION_COUNT = 25
|
||||
};
|
||||
|
||||
// Keys and mouse buttons. F1, F10, F11 and Esc belong to the menu and the window, F8 to the flight recorder: no ids.
|
||||
@@ -67,14 +69,14 @@ extern InputMap input_map;
|
||||
void input_map_reset(void);
|
||||
// The script and totk.ini names, any case when parsed; NULL for an id without one. Actions: A B X Y LCLICK RCLICK L R ZL
|
||||
// ZR PLUS MINUS LEFT UP RIGHT DOWN, then LSTICK_LEFT LSTICK_UP LSTICK_RIGHT LSTICK_DOWN and the same for RSTICK (the
|
||||
// stick directions the keys drive). Keys: a-z 0-9 space tab lshift ... (key_names in input_map.c). Pads: south east ...
|
||||
// stick directions the keys drive), then SHAKE. Keys: a-z 0-9 space tab lshift ... (key_names in input_map.c). Pads: south east ...
|
||||
const char *input_action_name(int action);
|
||||
const char *input_key_name(int key);
|
||||
const char *input_pad_name(int pad);
|
||||
// Replaces every binding of the action `button` (an action name) with `list`: comma separated "pad:<name>" and
|
||||
// "key:<name>" entries, "key:default" / "pad:default" for that part's defaults, or "none" for no input. A part the list
|
||||
// names neither way is left unbound. Pad names are physical buttons, so pad entries go into the gamepad table of every
|
||||
// layout; pad:default keeps each layout's own button. Stick directions take keys only. Invalid entries are logged and
|
||||
// layout; pad:default keeps each layout's own button. Stick directions and SHAKE take keys only. Invalid entries are logged and
|
||||
// skipped; when none is valid the action keeps its bindings. 1 when applied.
|
||||
int input_map_bind(const char *button, const char *list);
|
||||
// Applies the TOTK_BIND_<action> settings (cfg_get) over the defaults: at start, and again when one of them changes.
|
||||
@@ -87,21 +89,26 @@ int input_pad_layout_pick(const char *setting, int east_labelled_a);
|
||||
|
||||
// Button prompts (TOTK_PROMPTS): the controller whose buttons the game's prompts show. Nintendo: the game's own. Xbox:
|
||||
// LB RB LT RT, Menu, View and a d-pad cross for the other buttons, and the face buttons named by the label of the
|
||||
// button the pad layout presses (runtime/romfs.c overlay_init, tools/make_romfs_patches.py --prompts).
|
||||
enum { PROMPTS_NINTENDO, PROMPTS_XBOX };
|
||||
// The prompts for a TOTK_PROMPTS value ("nintendo" or "xbox", any case). Unset or empty: Xbox while the pad layout
|
||||
// setting is "xbox_prompts", else Nintendo.
|
||||
// button the pad layout presses (runtime/romfs.c overlay_init, tools/make_romfs_patches.py --prompts). PC: the key or
|
||||
// mouse button the keyboard defaults press each button with, the mouse for the right stick.
|
||||
enum { PROMPTS_NINTENDO, PROMPTS_XBOX, PROMPTS_PC };
|
||||
// The prompts for a TOTK_PROMPTS value ("nintendo", "xbox" or "pc", any case). Unset or empty: Xbox while the pad
|
||||
// layout setting is "xbox_prompts", else Nintendo.
|
||||
int input_prompts_pick(const char *prompts, const char *pad_layout);
|
||||
// The folder next to romfs-index.bin that holds those prompts for a TOTK_PAD_LAYOUT value, NULL for the game's own. The
|
||||
// Nintendo layout plays an Xbox pad by position, so its folder names the face buttons the other way round
|
||||
// (romfs-patches-xbox: the right button does A and is labelled B); xbox and auto put every Switch button on the button
|
||||
// labelled with its letter (auto picks the layout per pad by its labels), so they share romfs-patches-xbox-label.
|
||||
// labelled with its letter (auto picks the layout per pad by its labels), so they share romfs-patches-xbox-label. The PC
|
||||
// prompts follow no pad: romfs-patches-pc whatever the layout (romfs.c overlay_init serves romfs-patches-pc-<w>x<h>
|
||||
// under that name at a game resolution of another shape).
|
||||
const char *input_prompts_folder(int prompts, const char *pad_layout);
|
||||
// down[pad] != 0 for the gamepad inputs held. Returns the Switch button bits (0..15) the layout's pad table presses.
|
||||
uint64_t input_map_pad_buttons(const uint8_t down[PAD_COUNT], int layout);
|
||||
|
||||
// down[key] != 0 for the keys held. Returns the Switch button bits (0..15) the table presses.
|
||||
uint64_t input_map_buttons(const uint8_t down[KEY_COUNT]);
|
||||
// 1 when a key of action is held.
|
||||
int input_map_held(const uint8_t down[KEY_COUNT], int action);
|
||||
// out = x, y of the stick (0 left, 1 right) from its four direction actions; up is +y; a diagonal is scaled to the
|
||||
// unit circle.
|
||||
void input_map_stick(const uint8_t down[KEY_COUNT], int stick, int32_t out[2]);
|
||||
|
||||
+113
-4
@@ -1,11 +1,13 @@
|
||||
// The SDL3 back end of the keyboard and the mouse (input_wsys.h; input.c merges them with the gamepad): SDL scancodes to
|
||||
// the key ids of input_map.h, the mouse camera's relative motion and the cursor capture (SDL's relative mouse mode).
|
||||
// Key and button state comes from the window's events (window thread) and is read by any thread. Keys and their map:
|
||||
// docs/controls.md.
|
||||
// the key ids of input_map.h, the mouse camera's relative motion, the cursor capture (SDL's relative mouse mode), the
|
||||
// pointer's place in the window (the game's menus, menu_cursor.c) and the mouse travel (the last-device switch,
|
||||
// input_kbm_last). Key and button state comes from the window's events (window thread) and is read by any thread. Keys
|
||||
// and their map: docs/controls.md.
|
||||
//
|
||||
// The capture follows the Win32 back end's rules (input_win.c): captured while the window has the focus, the mouse
|
||||
// camera is on and no text entry or menu is open; Esc releases it until a click in the window (and closing the menu
|
||||
// opened by that Esc gives it back); losing the focus releases it until the window has the focus again.
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
@@ -21,8 +23,18 @@ static SDL_Window *win;
|
||||
static volatile uint8_t key_held[KEY_COUNT];
|
||||
static volatile int32_t esc_held, f8_held, focused;
|
||||
static volatile int32_t mouse_dx, mouse_dy; // relative motion since the last ikb_mouse_take, whole counts
|
||||
static volatile int32_t mouse_wheel; // wheel turned while captured, in 1/120 notches (ikb_wheel_take)
|
||||
static float rem_x, rem_y; // window thread: the fractions SDL reported beyond them
|
||||
static volatile int32_t mouse_travel; // raw counts moved in total, captured or not (ikb_mouse_travel)
|
||||
static float travel_rem; // window thread: the fraction of a count beyond mouse_travel
|
||||
static volatile int32_t captured; // relative mouse mode is on
|
||||
// The pointer's place in the window, as fractions of its size packed 32.32 fixed point (x in the high word), written
|
||||
// by the window thread from the mouse events and read by the game thread (menu_cursor.c through ikb_pointer).
|
||||
// ptr_have is 0 whenever the pointer is not over the window, as the Win32 back end's client-rect test.
|
||||
static volatile uint64_t ptr_pos;
|
||||
static volatile int32_t ptr_have;
|
||||
static volatile int32_t warp_pending; // ikb_pointer_set asked the window thread to move the pointer
|
||||
static float warp_nx, warp_ny; // its target, read by the window thread once warp_pending is seen
|
||||
enum { CUR_REL = 1, CUR_ESC = 2 }; // as input_win.c: released by Esc or a click elsewhere; by Esc
|
||||
static volatile int32_t cur_rel, cur_suspend, cur_pending;
|
||||
static uint32_t wake_event; // SDL_RegisterEvents: wakes the window thread for a cursor change
|
||||
@@ -133,6 +145,39 @@ static void cursor_apply(void) {
|
||||
plat_xchg32(&captured, want);
|
||||
}
|
||||
|
||||
// The pointer's place in the window (the window thread): x and y are SDL window units, the same space the mouse
|
||||
// events report, so dividing by the window's size makes fractions of it whatever the pixel density is. At and past
|
||||
// the edges the fraction is clamped to 0..1, where a letterboxed picture covers no item anyway.
|
||||
static void pointer_store(float x, float y) {
|
||||
if (!win) return;
|
||||
int w = 0, h = 0;
|
||||
SDL_GetWindowSize(win, &w, &h);
|
||||
if (w < 1 || h < 1) return;
|
||||
double fx = x / w, fy = y / h;
|
||||
if (!(fx >= 0)) fx = 0; // also NaN
|
||||
else if (fx > 1) fx = 1;
|
||||
if (!(fy >= 0)) fy = 0;
|
||||
else if (fy > 1) fy = 1;
|
||||
__atomic_store_n(&ptr_pos, (uint64_t)(uint32_t)(fx * 4294967295.0) << 32 | (uint32_t)(fy * 4294967295.0),
|
||||
__ATOMIC_RELEASE);
|
||||
__atomic_store_n(&ptr_have, 1, __ATOMIC_RELEASE);
|
||||
}
|
||||
|
||||
// A warp asked for by ikb_pointer_set, done here as every window call: on the window thread, and after the cursor
|
||||
// change of the same menu opening has been applied. SDL's warp makes a motion event where it works (X11); where it
|
||||
// does not (some Wayland compositors) the stored place stands, so the item under the pointer is the place asked for
|
||||
// even if the visible cursor stayed behind.
|
||||
static void pointer_apply(void) {
|
||||
if (!plat_xchg32(&warp_pending, 0)) return;
|
||||
if (!win || !focused) return;
|
||||
int w = 0, h = 0;
|
||||
SDL_GetWindowSize(win, &w, &h);
|
||||
if (w < 1 || h < 1) return;
|
||||
float x = warp_nx * w, y = warp_ny * h;
|
||||
SDL_WarpMouseInWindow(win, x, y);
|
||||
pointer_store(x, y);
|
||||
}
|
||||
|
||||
// Any thread: the window thread applies the change at its next event.
|
||||
void ikb_cursor_update(void) {
|
||||
plat_xchg32(&cur_pending, 1);
|
||||
@@ -158,11 +203,13 @@ void input_sdl_window(SDL_Window *w) {
|
||||
win = w;
|
||||
wake_event = SDL_RegisterEvents(1);
|
||||
plat_xchg32(&focused, (SDL_GetWindowFlags(w) & SDL_WINDOW_INPUT_FOCUS) != 0);
|
||||
__atomic_store_n(&ptr_have, 0, __ATOMIC_RELEASE); // the pointer's place is unknown until it enters or moves
|
||||
cursor_apply();
|
||||
}
|
||||
|
||||
void input_sdl_idle(void) {
|
||||
if (cur_pending) cursor_apply();
|
||||
pointer_apply();
|
||||
}
|
||||
|
||||
void input_sdl_event(const SDL_Event *e) {
|
||||
@@ -186,9 +233,19 @@ void input_sdl_event(const SDL_Event *e) {
|
||||
int k = mouse_key(e->button.button);
|
||||
if (k) key_held[k] = (uint8_t)down;
|
||||
if (down && (cur_rel & CUR_REL)) plat_xchg32(&cur_rel, 0); // a click in the window captures again
|
||||
if (!captured) pointer_store(e->button.x, e->button.y); // a click can come without a motion first
|
||||
break;
|
||||
}
|
||||
case SDL_EVENT_MOUSE_MOTION:
|
||||
case SDL_EVENT_MOUSE_MOTION: {
|
||||
// The travel counts any motion, captured or not, as the Win32 back end's raw input does: it is what makes a
|
||||
// moved mouse the device used last, so the menu pointer appears without a click or a key.
|
||||
travel_rem += fabsf(e->motion.xrel) + fabsf(e->motion.yrel);
|
||||
int32_t t = (int32_t)travel_rem;
|
||||
if (t) {
|
||||
travel_rem -= t;
|
||||
plat_add32(&mouse_travel, t);
|
||||
}
|
||||
if (!captured) pointer_store(e->motion.x, e->motion.y); // in relative mode x and y are not the pointer's
|
||||
if (captured) {
|
||||
rem_x += e->motion.xrel;
|
||||
rem_y += e->motion.yrel;
|
||||
@@ -198,6 +255,22 @@ void input_sdl_event(const SDL_Event *e) {
|
||||
if (dy) plat_add32(&mouse_dy, dy);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SDL_EVENT_WINDOW_MOUSE_ENTER: { // entering without moving yet: the place is where the enter found the pointer
|
||||
float x, y;
|
||||
SDL_GetMouseState(&x, &y); // the window with mouse focus is this one during its own enter event
|
||||
pointer_store(x, y);
|
||||
break;
|
||||
}
|
||||
case SDL_EVENT_WINDOW_MOUSE_LEAVE:
|
||||
__atomic_store_n(&ptr_have, 0, __ATOMIC_RELEASE);
|
||||
break;
|
||||
case SDL_EVENT_MOUSE_WHEEL: // notches, + away from the player (a flipped wheel reports the other sign)
|
||||
if (captured) {
|
||||
float y = e->wheel.direction == SDL_MOUSEWHEEL_FLIPPED ? -e->wheel.y : e->wheel.y;
|
||||
plat_add32(&mouse_wheel, (int32_t)(y * 120));
|
||||
}
|
||||
break;
|
||||
case SDL_EVENT_WINDOW_FOCUS_GAINED:
|
||||
plat_xchg32(&focused, 1);
|
||||
break;
|
||||
@@ -207,9 +280,11 @@ void input_sdl_event(const SDL_Event *e) {
|
||||
memset((void *)key_held, 0, sizeof key_held); // the key-ups go elsewhere
|
||||
plat_xchg32(&esc_held, 0);
|
||||
plat_xchg32(&f8_held, 0);
|
||||
__atomic_store_n(&ptr_have, 0, __ATOMIC_RELEASE); // the pointer left with the focus, leave or not
|
||||
break;
|
||||
}
|
||||
cursor_apply();
|
||||
pointer_apply(); // after cursor_apply: a menu opening uncaptures first, then the pointer moves to its middle
|
||||
}
|
||||
|
||||
// ---- input_wsys.h
|
||||
@@ -218,7 +293,41 @@ int ikb_down(int key) { return key > 0 && key < KEY_COUNT && key_held[key]; }
|
||||
int ikb_escape(void) { return esc_held != 0; }
|
||||
int ikb_captured(void) { return captured != 0; }
|
||||
|
||||
// The pointer's place in the window as fractions of its size (menu_cursor.c maps them onto the picture the present
|
||||
// drew). 0 whenever the pointer is not over this window or the window has not the focus, as the Win32 back end's
|
||||
// GetCursorPos test.
|
||||
int ikb_pointer(float *nx, float *ny) {
|
||||
if (!focused || !__atomic_load_n(&ptr_have, __ATOMIC_ACQUIRE)) return 0;
|
||||
uint64_t p = __atomic_load_n(&ptr_pos, __ATOMIC_ACQUIRE);
|
||||
*nx = (float)(uint32_t)(p >> 32) / 4294967295.0f;
|
||||
*ny = (float)(uint32_t)p / 4294967295.0f;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Moves the pointer there (menu_cursor.c: to the middle when a menu opens). The window thread warps at its next
|
||||
// event, as SDL asks for its video thread; until then the warp target stands as the pointer's place.
|
||||
void ikb_pointer_set(float nx, float ny) {
|
||||
if (!(nx >= 0 && nx <= 1) || !(ny >= 0 && ny <= 1)) return;
|
||||
warp_nx = nx, warp_ny = ny;
|
||||
__atomic_store_n(&warp_pending, 1, __ATOMIC_RELEASE);
|
||||
if (wake_event) {
|
||||
SDL_Event e = {.user = {.type = wake_event}};
|
||||
SDL_PushEvent(&e);
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t ikb_mouse_travel(void) { return (uint32_t)__atomic_load_n(&mouse_travel, __ATOMIC_RELAXED); }
|
||||
|
||||
void ikb_mouse_take(int32_t *dx, int32_t *dy) {
|
||||
*dx = plat_xchg32(&mouse_dx, 0);
|
||||
*dy = plat_xchg32(&mouse_dy, 0);
|
||||
}
|
||||
|
||||
int32_t ikb_wheel_take(void) {
|
||||
int32_t o, n;
|
||||
do {
|
||||
o = mouse_wheel;
|
||||
n = o % 120; // the part of a notch stays for the next call
|
||||
} while (!__atomic_compare_exchange_n(&mouse_wheel, &o, n, 0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST));
|
||||
return o / 120;
|
||||
}
|
||||
+42
-3
@@ -83,6 +83,8 @@ uint32_t input_key_char(int key) {
|
||||
|
||||
// ---- mouse camera: raw motion accumulated by the window thread, one stick sample per presented frame
|
||||
static volatile LONG mouse_dx, mouse_dy; // raw counts since the last frame sample
|
||||
static volatile LONG mouse_travel; // raw counts moved in total, captured or not (ikb_mouse_travel)
|
||||
static volatile LONG mouse_wheel; // wheel turned while captured, in WHEEL_DELTA units (ikb_wheel_take)
|
||||
static volatile LONG cur_captured; // cursor hidden and confined to the client area
|
||||
static volatile LONG cur_active; // WM_ACTIVATEAPP: the window's application is active
|
||||
// Bits: CUR_REL, Esc or activation by a click on the title bar or border: until a client click; CUR_ESC, set with CUR_REL
|
||||
@@ -137,11 +139,18 @@ int input_window_msg(HWND h, UINT m, WPARAM w, LPARAM l, LRESULT *res) {
|
||||
case WM_INPUT: {
|
||||
RAWINPUT ri;
|
||||
UINT sz = sizeof ri;
|
||||
if (cur_captured && GetRawInputData((HRAWINPUT)l, RID_INPUT, &ri, &sz, sizeof(RAWINPUTHEADER)) != (UINT)-1 &&
|
||||
if (GetRawInputData((HRAWINPUT)l, RID_INPUT, &ri, &sz, sizeof(RAWINPUTHEADER)) != (UINT)-1 &&
|
||||
ri.header.dwType == RIM_TYPEMOUSE && !(ri.data.mouse.usFlags & MOUSE_MOVE_ABSOLUTE)) {
|
||||
InterlockedAdd(&mouse_dx, ri.data.mouse.lLastX);
|
||||
InterlockedAdd(&mouse_dy, ri.data.mouse.lLastY);
|
||||
LONG x = ri.data.mouse.lLastX, y = ri.data.mouse.lLastY;
|
||||
InterlockedAdd(&mouse_travel, (x < 0 ? -x : x) + (y < 0 ? -y : y));
|
||||
if (cur_captured) {
|
||||
InterlockedAdd(&mouse_dx, x);
|
||||
InterlockedAdd(&mouse_dy, y);
|
||||
}
|
||||
}
|
||||
// The wheel comes in the same message (usButtonData, signed, + away from the player)
|
||||
if (cur_captured && ri.header.dwType == RIM_TYPEMOUSE && (ri.data.mouse.usButtonFlags & RI_MOUSE_WHEEL))
|
||||
InterlockedAdd(&mouse_wheel, (SHORT)ri.data.mouse.usButtonData);
|
||||
break; // DefWindowProc must see WM_INPUT for cleanup
|
||||
}
|
||||
case WM_SETCURSOR:
|
||||
@@ -203,11 +212,41 @@ int ikb_escape(void) { return key_down(VK_ESCAPE); }
|
||||
|
||||
int ikb_captured(void) { return cur_captured != 0; }
|
||||
|
||||
int ikb_pointer(float *nx, float *ny) {
|
||||
HWND h = gfx_window_handle();
|
||||
RECT r;
|
||||
POINT p;
|
||||
if (!h || GetForegroundWindow() != h || !GetClientRect(h, &r) || r.right < 1 || r.bottom < 1 || !GetCursorPos(&p) ||
|
||||
!ScreenToClient(h, &p))
|
||||
return 0;
|
||||
*nx = (float)p.x / (float)r.right, *ny = (float)p.y / (float)r.bottom;
|
||||
return p.x >= 0 && p.y >= 0 && p.x < r.right && p.y < r.bottom;
|
||||
}
|
||||
|
||||
void ikb_pointer_set(float nx, float ny) {
|
||||
HWND h = gfx_window_handle();
|
||||
RECT r;
|
||||
if (!h || GetForegroundWindow() != h || !GetClientRect(h, &r)) return;
|
||||
POINT p = {(LONG)(nx * r.right), (LONG)(ny * r.bottom)};
|
||||
if (ClientToScreen(h, &p)) SetCursorPos(p.x, p.y);
|
||||
}
|
||||
|
||||
uint32_t ikb_mouse_travel(void) { return (uint32_t)mouse_travel; }
|
||||
|
||||
void ikb_mouse_take(int32_t *dx, int32_t *dy) {
|
||||
*dx = InterlockedExchange(&mouse_dx, 0);
|
||||
*dy = InterlockedExchange(&mouse_dy, 0);
|
||||
}
|
||||
|
||||
int32_t ikb_wheel_take(void) {
|
||||
LONG o, n;
|
||||
do {
|
||||
o = mouse_wheel;
|
||||
n = o % WHEEL_DELTA; // the part of a notch stays for the next call
|
||||
} while (InterlockedCompareExchange(&mouse_wheel, n, o) != o);
|
||||
return (int32_t)(o / WHEEL_DELTA);
|
||||
}
|
||||
|
||||
void ikb_cursor_update(void) { cursor_update(gfx_window_handle()); }
|
||||
|
||||
void ikb_menu_closed(void) { cur_rel_update(rel_menu_closed); }
|
||||
@@ -7,8 +7,14 @@
|
||||
int ikb_foreground(void); // the game window is in front: keys count
|
||||
int ikb_down(int key); // key id (input_map.h KEY_*) held, mouse buttons included
|
||||
int ikb_escape(void); // Esc held (no key id: it belongs to the menu)
|
||||
// The pointer in the client area, 0..1 from its top left; 0 when the window is not in front or the pointer is outside
|
||||
int ikb_pointer(float *nx, float *ny);
|
||||
void ikb_pointer_set(float nx, float ny); // moves the pointer there (the window in front only)
|
||||
int ikb_captured(void); // the cursor is captured for the mouse camera
|
||||
void ikb_mouse_take(int32_t *dx, int32_t *dy); // raw mouse motion since the last call, in counts
|
||||
uint32_t ikb_mouse_travel(void); // raw counts the mouse moved in total (|dx| + |dy|), captured or not
|
||||
int32_t ikb_wheel_take(void); // whole wheel notches turned while captured since the last call (+ away
|
||||
// from the player); a high-resolution wheel's part of a notch is kept
|
||||
void ikb_cursor_update(void); // what input_cursor_allowed says may have changed
|
||||
void ikb_menu_closed(void); // the menu closed: a capture that Esc gave up comes back
|
||||
|
||||
|
||||
+12
-3
@@ -2,10 +2,12 @@
|
||||
//
|
||||
// totk.exe [--exefs DIR] [--romfs DIR] [--profile DIR] <mode>
|
||||
// --selftest layout, dispatch table and host-function registry; needs no game files
|
||||
// --probe-mods the mods folder scan, order and switches (mods.c); needs no game files
|
||||
// --probe-mods the mods folder scan, order and switches (mods.c), the size table entries of the members of
|
||||
// mods' packs (restbl_probe.c); needs no game files
|
||||
// --probe-loader load and relocate the modules, check them against the measured 1.2.1 values
|
||||
// --probe-kernel, --probe-ipc, --probe-services, --probe-fs host probes
|
||||
// --probe-discord the Rich Presence IPC exchange; skips without a Discord client; no game files
|
||||
// --probe-playtime the play time file, its ticks and saves (playtime.c); needs no game files
|
||||
// --probe-dpi the process DPI awareness (per-monitor v2) and the primary monitor's size; no game files
|
||||
// --probe-gfx-abi NVN object layouts against nvn.h's reserved sizes; needs no game files
|
||||
// --probe-nvn, --probe-nvn-tex, --probe-window, --probe-shader, --probe-vkmem, --probe-vkexec graphics probes
|
||||
@@ -395,7 +397,9 @@ static void check_memory(void) {
|
||||
CHECK(q.state == 0 && q.base == g->alias.base && q.size == g->alias.size, "QueryMemory on the free alias region");
|
||||
CHECK(mem_map_physical(a, 0x1000) == 0 && LD64(a + 0x10) == 0 && mem_unmap_physical(a, 0x1000) == 0,
|
||||
"remapped physical memory is not zeroed");
|
||||
CHECK(mem_map_physical(a, 0x100000000ull) == 0x10801, "MapPhysicalMemory beyond the pool is not LimitReached");
|
||||
// past the pool whatever its size (TOTK_APP_POOL, TOTK_TEX_HEAP_EXTRA_MB): 4 GiB fits a grown one
|
||||
CHECK(mem_map_physical(a, (g->pool + 0x100000) & ~0xfffffull) == 0x10801,
|
||||
"MapPhysicalMemory beyond the pool is not LimitReached");
|
||||
mem_query(RT_HOSTFN_BASE + 0x40, &q);
|
||||
CHECK(q.state == 0x10 && q.base == RT_HOSTFN_BASE, "QueryMemory on the host-function window");
|
||||
mem_query(g->alias.base + g->alias.size + 0x1000, &q);
|
||||
@@ -462,7 +466,7 @@ static int probe_crash(int null) {
|
||||
|
||||
static int usage(const char *argv0) {
|
||||
fprintf(stderr, "usage: %s [--exefs DIR] [--romfs DIR] [--profile DIR] [--selftest | --probe-loader | "
|
||||
"--probe-cfg | --probe-dpi | --probe-mods | --probe-kernel | --probe-ipc | --probe-services | --probe-fs | --probe-gfx-abi | "
|
||||
"--probe-cfg | --probe-dpi | --probe-mods | --probe-playtime | --probe-kernel | --probe-ipc | --probe-services | --probe-fs | --probe-gfx-abi | "
|
||||
"--probe-nvn | --probe-nvn-tex | --probe-window | --probe-shader | --probe-vkmem | "
|
||||
"--probe-vkexec | --probe-astc | --probe-movie | --probe-crash-call | --probe-crash-null | --probe-discord | "
|
||||
"--prepare-shaders | --warm-shaders | --boot |--seconds S [--shot PNG]]\n", argv0);
|
||||
@@ -503,6 +507,9 @@ static void portable_paths(RtConfig *c) {
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
mem_init(); // first: claim the guest address space before anything else can land there; reads totk.ini
|
||||
// The build that wrote the log, at its top: an attached log from another install or version shows at a glance.
|
||||
rt_log("version: TotK Recomp %s, game %d.%d.%d", rt_port_version, rt_game_version / 100, rt_game_version / 10 % 10,
|
||||
rt_game_version % 10);
|
||||
// A restart from the settings menu (gfx_present.c gfx_window_closed) starts this process while the one it replaces
|
||||
// is still exiting: its log, RomFS and saves are free once it is gone.
|
||||
if (plat_restart_wait(30000) < 0) rt_log("restart: the previous process still runs after 30 s; starting anyway");
|
||||
@@ -549,6 +556,7 @@ int main(int argc, char **argv) {
|
||||
if (!mode) {
|
||||
cfg.boot = cfg.seconds > 0; // --seconds starts the game like --boot, with its checkpoints
|
||||
discord_init(); // the Rich Presence thread (discord_rpc.c); it reads totk.ini itself and idles while off
|
||||
playtime_init(); // the play time thread (playtime.c): the Game tab's play time across runs
|
||||
rt_boot();
|
||||
return 0;
|
||||
}
|
||||
@@ -562,6 +570,7 @@ int main(int argc, char **argv) {
|
||||
#endif
|
||||
if (!strcmp(mode, "--probe-mods")) return probe_mods();
|
||||
if (!strcmp(mode, "--probe-discord")) return probe_discord();
|
||||
if (!strcmp(mode, "--probe-playtime")) return probe_playtime();
|
||||
if (!strcmp(mode, "--probe-kernel")) return probe_kernel();
|
||||
if (!strcmp(mode, "--probe-ipc")) return probe_ipc();
|
||||
if (!strcmp(mode, "--probe-services")) return probe_services();
|
||||
|
||||
+50
-13
@@ -20,11 +20,11 @@
|
||||
#define PAGE 0x1000ull
|
||||
#define GiB (1ull << 30)
|
||||
#define LOW_SEARCH_FROM 0x100000000ull
|
||||
#define LOW_SIZE (6 * GiB)
|
||||
#define LOW_BASE (6 * GiB) // the stack and heap block without a texture heap extra
|
||||
#define STACK_OFF 0x10000000ull
|
||||
#define STACK_SIZE 0x10000000ull
|
||||
#define HEAP_OFF (STACK_OFF + STACK_SIZE + 0x10000000ull)
|
||||
#define HEAP_CAP (4 * GiB)
|
||||
#define HEAP_CAP (4 * GiB) // without TOTK_TEX_HEAP_EXTRA_MB, which raises it by its own size (mem_init)
|
||||
#define ALIAS_SIZE (64 * GiB)
|
||||
#define ALIAS_ALIGN (4 * GiB)
|
||||
#define ALIAS_PAGES (ALIAS_SIZE / PAGE)
|
||||
@@ -48,6 +48,9 @@ enum { ATTR_PERMISSION_LOCKED = 16 };
|
||||
|
||||
static RtGuestLayout L;
|
||||
static uint64_t mods_lo, mods_hi; // fixed module window
|
||||
// The stack and heap block: LOW_BASE, plus the texture heap extra (tex_heap_extra_mb or a mod's HeapDef,
|
||||
// res_tex_heap_extra_bytes) rounded up to a GiB, so only a player who grows the texture heap reserves more (mem_init).
|
||||
static uint64_t low_size = LOW_BASE;
|
||||
static PlatMutex lock = PLAT_MUTEX_INIT;
|
||||
static uint64_t heap_size, alias_bytes;
|
||||
static uint8_t *alias_pages; // 1 per mapped 4 KiB page of the alias region
|
||||
@@ -78,7 +81,7 @@ static volatile int64_t frz_count[4]; // freezes; thaws by a write fault, by m
|
||||
|
||||
static int watched(uint64_t addr, uint64_t size) {
|
||||
const RtGuestLayout *g = &L;
|
||||
return (addr >= g->aslr.base && size <= LOW_SIZE && addr - g->aslr.base <= LOW_SIZE - size) ||
|
||||
return (addr >= g->aslr.base && size <= low_size && addr - g->aslr.base <= low_size - size) ||
|
||||
(addr >= g->alias.base && size <= ALIAS_SIZE && addr - g->alias.base <= ALIAS_SIZE - size);
|
||||
}
|
||||
|
||||
@@ -221,10 +224,15 @@ void mem_init(void) {
|
||||
|
||||
// Stack and heap block, then the alias region above it, both searched below the module window.
|
||||
uint64_t low = 0, alias = 0;
|
||||
for (uint64_t at = LOW_SEARCH_FROM; at + LOW_SIZE <= mods_lo && !low; at += GiB)
|
||||
if (reserve_at(at, LOW_SIZE, 1)) low = at;
|
||||
if (!low) reexec_or_fail("a 6 GiB stack and heap block below 2^39");
|
||||
for (uint64_t at = align_up(low + LOW_SIZE, ALIAS_ALIGN); at + ALIAS_SIZE <= mods_lo && !alias; at += ALIAS_ALIGN)
|
||||
low_size = LOW_BASE + align_up(res_tex_heap_extra_bytes(), GiB);
|
||||
for (uint64_t at = LOW_SEARCH_FROM; at + low_size <= mods_lo && !low; at += GiB)
|
||||
if (reserve_at(at, low_size, 1)) low = at;
|
||||
if (!low) {
|
||||
char what[64];
|
||||
snprintf(what, sizeof what, "a %llu GiB stack and heap block below 2^39", (unsigned long long)(low_size / GiB));
|
||||
reexec_or_fail(what);
|
||||
}
|
||||
for (uint64_t at = align_up(low + low_size, ALIAS_ALIGN); at + ALIAS_SIZE <= mods_lo && !alias; at += ALIAS_ALIGN)
|
||||
if (reserve_at(at, ALIAS_SIZE, 1)) alias = at;
|
||||
if (!alias) reexec_or_fail("a 64 GiB alias region below 2^39");
|
||||
|
||||
@@ -238,11 +246,11 @@ void mem_init(void) {
|
||||
L = (RtGuestLayout){
|
||||
.aslr = {low, RT_ADDRESS_LIMIT - low},
|
||||
.stack = {low + STACK_OFF, STACK_SIZE},
|
||||
.heap = {low + HEAP_OFF, HEAP_CAP},
|
||||
.heap = {low + HEAP_OFF, HEAP_CAP + res_tex_heap_extra_bytes()},
|
||||
.alias = {alias, ALIAS_SIZE},
|
||||
.kernel = {RT_KERNEL_BASE, RT_KERNEL_SIZE},
|
||||
.hostfn = {RT_HOSTFN_BASE, RT_HOSTFN_SIZE},
|
||||
.pool = pool ? strtoull(pool, NULL, 0) : DEFAULT_POOL + res_heap_extra_bytes(), // resolution.c
|
||||
.pool = pool ? strtoull(pool, NULL, 0) : DEFAULT_POOL + res_heap_extra_bytes() + res_tex_heap_extra_bytes(),
|
||||
};
|
||||
if (!L.pool) rt_fatal("memory: TOTK_APP_POOL is 0");
|
||||
}
|
||||
@@ -276,7 +284,7 @@ static int inside(uint64_t a, uint64_t n, RtRange r) { return a >= r.base && n <
|
||||
// ---- 0x01 SetHeapSize
|
||||
uint32_t mem_set_heap_size(uint64_t size, uint64_t *addr) {
|
||||
plat_mutex_lock(&lock);
|
||||
if (size > HEAP_CAP || size % 0x200000 || size + image_bytes() > L.pool) {
|
||||
if (size > L.heap.size || size % 0x200000 || size + image_bytes() > L.pool) {
|
||||
plat_mutex_unlock(&lock);
|
||||
return R_INVALID_STATE;
|
||||
}
|
||||
@@ -288,6 +296,35 @@ uint32_t mem_set_heap_size(uint64_t size, uint64_t *addr) {
|
||||
return R_OK;
|
||||
}
|
||||
|
||||
// The runtime's readers of game objects (menu_cursor.c, mouse_direct.c) check every pointer before they follow it. An
|
||||
// OS query (plat_mem_readable: VirtualQuery) costs tens of microseconds in this process, and a reader that checks a
|
||||
// few dozen pointers at a time stalls the game thread for milliseconds. The game's objects live in the committed heap
|
||||
// and its globals in the modules' images, which are readable for as long as they are mapped: those answer from the
|
||||
// layout. Any other range asks the OS. The heap only shrinks when the game asks for it (mem_set_heap_size), not while
|
||||
// it runs.
|
||||
int mem_guest_readable(uint64_t a, uint64_t n) {
|
||||
static uint64_t fast, slow;
|
||||
if (a + n < a) return 0;
|
||||
uint64_t hs = __atomic_load_n(&heap_size, __ATOMIC_ACQUIRE);
|
||||
int ok = a >= L.heap.base && a + n <= L.heap.base + hs;
|
||||
for (int i = 0; !ok && i < ldr_count(); i++) {
|
||||
const RtLoaded *m = ldr_module(i);
|
||||
ok = a >= m->base && a + n <= m->base + m->size;
|
||||
}
|
||||
if (ok) {
|
||||
if (__atomic_add_fetch(&fast, 1, __ATOMIC_RELAXED) == 100000)
|
||||
rt_log("memory: 100000 guest reads checked by the layout, %llu by the OS",
|
||||
(unsigned long long)__atomic_load_n(&slow, __ATOMIC_RELAXED));
|
||||
} else {
|
||||
ok = plat_mem_readable(a, n);
|
||||
uint64_t s = __atomic_add_fetch(&slow, 1, __ATOMIC_RELAXED);
|
||||
if (!(s & (s - 1)) && s >= 1024) // 1024, 2048, 4096...: how often the layout could not answer
|
||||
rt_log("memory: guest reads checked by the layout %llu, by the OS %llu",
|
||||
(unsigned long long)__atomic_load_n(&fast, __ATOMIC_RELAXED), (unsigned long long)s);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ---- module data permissions (0x02, 0x03) and module pages for QueryMemory
|
||||
static int module_index(uint64_t a) {
|
||||
for (int i = 0; i < ldr_count(); i++) {
|
||||
@@ -477,7 +514,7 @@ static void taken_gap(uint64_t a, uint64_t *base, uint64_t *size) {
|
||||
uint64_t lo = 0, hi = RT_ADDRESS_LIMIT;
|
||||
RtRange known[3 + MOD_COUNT + 2];
|
||||
int n = 0;
|
||||
known[n++] = (RtRange){L.aslr.base, LOW_SIZE};
|
||||
known[n++] = (RtRange){L.aslr.base, low_size};
|
||||
known[n++] = L.alias;
|
||||
known[n++] = L.kernel;
|
||||
known[n++] = L.hostfn;
|
||||
@@ -520,14 +557,14 @@ uint32_t mem_query(uint64_t a, RtMemoryInfo *out) {
|
||||
r.base = 0;
|
||||
r.size = low;
|
||||
r.state = ST_FREE;
|
||||
} else if (a < low + LOW_SIZE) {
|
||||
} else if (a < low + low_size) {
|
||||
uint64_t heap = L.heap.base;
|
||||
if (a >= heap && a < heap + heap_size) {
|
||||
r = (RtMemoryInfo){.base = heap, .size = heap_size, .state = ST_NORMAL, .perm = PERM_RW};
|
||||
} else if (a < heap) {
|
||||
r = (RtMemoryInfo){.base = low, .size = heap - low, .state = ST_FREE};
|
||||
} else {
|
||||
r = (RtMemoryInfo){.base = heap + heap_size, .size = low + LOW_SIZE - heap - heap_size, .state = ST_FREE};
|
||||
r = (RtMemoryInfo){.base = heap + heap_size, .size = low + low_size - heap - heap_size, .state = ST_FREE};
|
||||
}
|
||||
} else if (a - L.alias.base < L.alias.size) {
|
||||
uint64_t p = (a - L.alias.base) / PAGE;
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
// The mouse pointer in TotK 1.4.3's own menus (the title, the inventory, the System tab, Options, shops, dialogue
|
||||
// choices): while a menu screen has the game's focus box (eui's BoxCursor, the frame the d-pad moves), the Windows
|
||||
// pointer is released from the mouse camera and shown, the item under it takes the focus as if the d-pad had moved
|
||||
// there (highlight, sound and description included), a left click on an item is A on it and a right click is B.
|
||||
// Nothing is drawn and no game code is called: at each pad read (ov_input.c GetNpadStates, the game thread) the open
|
||||
// screens are read and the focus is moved with the three writes eui::BoxCursorMgr::moveBoxCursor makes, so the game's
|
||||
// own BoxCursorControl moves the box at its next update.
|
||||
//
|
||||
// Adapted from Weeping of the Empire's engine/runtime/totk_menu_cursor.cpp @ df0586cdc5702ca19bbebfa59697e0b6980f6237
|
||||
// (https://github.com/daniel-madrid-07/weeping-of-the-empire), Copyright (c) 2026 daniel-madrid-07, MIT License: the
|
||||
// focus read, the hit test, the stale-hit-pane fallback and the focus writes, which it does for 1.0.0.
|
||||
// Placed in 1.4.3 by hand:
|
||||
// - eui::ScreenMgr::sInstance: 1.0.0 main+0x4640010, 1.4.3 main+0x3acf960 (its createInstance main+0xc613d4 and
|
||||
// constructor main+0xc61450 match 1.0.0's main+0x119c5cc / +0x119c648 instruction for instruction, the object
|
||||
// 0x450 bytes with the same field stores, so its layout is 1.0.0's);
|
||||
// - the screen's reserved node (+0x150) and request bits (+0x175, bit 2) and its draw target byte (+0x16c): the same
|
||||
// stores at 1.4.3 main+0x87903c.
|
||||
// The other fields are 1.0.0's; menu_cursor logs what it reads the first time a menu opens (TOTK_MENU_CURSOR_LOG=1:
|
||||
// every screen change), so a field that moved shows as a menu with no nodes or boxes off the layout.
|
||||
//
|
||||
// Only while keyboard and mouse are the device used last (input_kbm_last): a gamepad player sees the menus unchanged.
|
||||
// TOTK_MENU_CURSOR (live, default 1): 0 leaves the menus to the keys and the pad.
|
||||
#include <math.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cfg.h"
|
||||
#include "gfx.h"
|
||||
#include "host.h"
|
||||
#include "input.h"
|
||||
#include "input_map.h"
|
||||
#include "input_wsys.h"
|
||||
#include "layout.h"
|
||||
#include "platform/platform.h"
|
||||
|
||||
#define SCREEN_MGR_V143 0x3acf960ull // eui::ScreenMgr::sInstance
|
||||
#define MAIN_SIZE 0x3adf000ull // main's image (code, data, bss) in 1.4.3
|
||||
|
||||
// eui::ScreenMgr, BoxCursorMgr, BoxCursorControl
|
||||
enum { SM_BOX_MGR = 0x3f8, BM_CONTROLS = 0x28, CTL_ACTIVE = 0x58, CTL_RESERVED = 0x60 };
|
||||
// eui::Screen
|
||||
enum { SCR_LAYOUT = 0x28, SCR_DRAW_INFO = 0x60, SCR_NODES = 0x80, SCR_RESERVED = 0x150, SCR_STATE = 0x16e,
|
||||
SCR_REQUEST = 0x175, SCR_OPENED = 2 };
|
||||
// eui::BoxCursorNode, AnimButton
|
||||
enum { NODE_BUTTON = 0x18, NODE_SCREEN = 0x20, BTN_LAYOUT = 0x20, BTN_FLAGS = 0x40, BTN_HIT_PANE = 0x58 };
|
||||
#define BTN_USABLE 0x20010u // both set: active and enabled (ButtonGroup::Update's test)
|
||||
// nn::ui2d::DrawInfo: projection (4 x 4) and view (3 x 4); nn::ui2d::Layout: root pane, size, name
|
||||
enum { DI_PROJ = 0x10, DI_VIEW = 0x50, LYT_ROOT = 0x18, LYT_SIZE = 0x28, LYT_NAME = 0x30 };
|
||||
// nn::ui2d::Pane
|
||||
enum { PANE_PARENT = 0x18, PANE_SIZE = 0x50, PANE_FLAG = 0x58, PANE_ALPHA = 0x59, PANE_BASE = 0x5b, PANE_MTX = 0x70 };
|
||||
|
||||
static uint64_t main_lo, main_hi;
|
||||
|
||||
static int ok_at(uint64_t p, uint64_t n) {
|
||||
return p >= 0x10000 && p < (1ull << 47) && (p & 7) == 0 && mem_guest_readable(p, n);
|
||||
}
|
||||
static uint64_t rd64(uint64_t a) { uint64_t v; memcpy(&v, (const void *)(uintptr_t)a, 8); return v; }
|
||||
static uint32_t rd32(uint64_t a) { uint32_t v; memcpy(&v, (const void *)(uintptr_t)a, 4); return v; }
|
||||
static uint8_t rd8(uint64_t a) { return *(const volatile uint8_t *)(uintptr_t)a; }
|
||||
static float rdf(uint64_t a) { float v; memcpy(&v, (const void *)(uintptr_t)a, 4); return v; }
|
||||
// An object of the game: readable, its vtable in main's image
|
||||
static int game_object(uint64_t p, uint64_t n) {
|
||||
if (!ok_at(p, n)) return 0;
|
||||
uint64_t vt = rd64(p);
|
||||
return vt >= main_lo && vt < main_hi;
|
||||
}
|
||||
|
||||
// ---- panes (AnimButton::HitTest's rules: 1.0.0 main+0x222ea40, +0x223b090)
|
||||
static int pane_shown(uint64_t pane) {
|
||||
if (!ok_at(pane, PANE_MTX + 0x20)) return 0;
|
||||
if (!(rd8(pane + PANE_FLAG) & 1) || !rd8(pane + PANE_ALPHA)) return 0;
|
||||
int depth = 0;
|
||||
for (uint64_t p = rd64(pane + PANE_PARENT); p; p = rd64(p + PANE_PARENT)) {
|
||||
if (!ok_at(p, PANE_MTX) || ++depth > 64) return 0;
|
||||
uint8_t f = rd8(p + PANE_FLAG);
|
||||
if (!(f & 1) || (!rd8(p + PANE_ALPHA) && (f & 2))) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
// The pane's box in layout coordinates (origin at the centre, y up): centre and half size
|
||||
static void pane_box(uint64_t pane, float *cx, float *cy, float *hw, float *hh) {
|
||||
float w = rdf(pane + PANE_SIZE), h = rdf(pane + PANE_SIZE + 4);
|
||||
float m00 = rdf(pane + PANE_MTX), m11 = rdf(pane + PANE_MTX + 0x14);
|
||||
uint8_t base = rd8(pane + PANE_BASE), bh = base & 3, bv = base >> 2 & 3;
|
||||
*hw = fabsf(w * m00) * 0.5f, *hh = fabsf(h * m11) * 0.5f;
|
||||
*cx = rdf(pane + PANE_MTX + 0xc), *cy = rdf(pane + PANE_MTX + 0x1c);
|
||||
if (bh == 1) *cx += *hw; else if (bh == 2) *cx -= *hw; // the translation is the left / right edge
|
||||
if (bv == 1) *cy -= *hh; else if (bv == 2) *cy += *hh; // ... the top / bottom edge
|
||||
}
|
||||
|
||||
// The screen's draw transform, for a screen drawn away from its centred layout space (scale and translation only)
|
||||
typedef struct { int ok; float v[12], p[16]; } Xf;
|
||||
static Xf screen_xf(uint64_t scr) {
|
||||
Xf x = {0};
|
||||
uint64_t di = rd64(scr + SCR_DRAW_INFO);
|
||||
if (!ok_at(di, DI_VIEW + sizeof x.v)) return x;
|
||||
memcpy(x.p, (const void *)(uintptr_t)(di + DI_PROJ), sizeof x.p);
|
||||
memcpy(x.v, (const void *)(uintptr_t)(di + DI_VIEW), sizeof x.v);
|
||||
for (int i = 0; i < 16; i++) if (!isfinite(x.p[i])) return x;
|
||||
for (int i = 0; i < 12; i++) if (!isfinite(x.v[i])) return x;
|
||||
x.ok = x.p[0] != 0 && x.p[5] != 0 && fabsf(x.p[1]) < 1e-5f && fabsf(x.p[4]) < 1e-5f && fabsf(x.v[1]) < 1e-4f &&
|
||||
fabsf(x.v[4]) < 1e-4f;
|
||||
return x;
|
||||
}
|
||||
static int to_ndc(const Xf *t, float x, float y, float *nx, float *ny) {
|
||||
float vx = t->v[0] * x + t->v[1] * y + t->v[3], vy = t->v[4] * x + t->v[5] * y + t->v[7],
|
||||
vz = t->v[8] * x + t->v[9] * y + t->v[11];
|
||||
float cx = t->p[0] * vx + t->p[1] * vy + t->p[2] * vz + t->p[3];
|
||||
float cy = t->p[4] * vx + t->p[5] * vy + t->p[6] * vz + t->p[7];
|
||||
float cw = t->p[12] * vx + t->p[13] * vy + t->p[14] * vz + t->p[15];
|
||||
if (!(fabsf(cw) > 1e-6f)) return 0;
|
||||
*nx = cx / cw, *ny = cy / cw;
|
||||
return 1;
|
||||
}
|
||||
// The transform maps the layout as the plain layout test does (centre to 0,0, corner to 1,1): it adds nothing
|
||||
static int xf_plain(const Xf *t, float lw, float lh) {
|
||||
float ax, ay, bx, by;
|
||||
if (!to_ndc(t, 0, 0, &ax, &ay) || !to_ndc(t, lw / 2, lh / 2, &bx, &by)) return 1;
|
||||
return fabsf(ax) < 0.01f && fabsf(ay) < 0.01f && fabsf(bx - 1) < 0.01f && fabsf(by - 1) < 0.01f;
|
||||
}
|
||||
|
||||
// The pointer: layout coordinates (x, y) and device coordinates (nx, ny), and the transform when it is not plain
|
||||
typedef struct { float x, y, nx, ny; const Xf *t; } Pt;
|
||||
static int pane_hit(uint64_t pane, const Pt *p) {
|
||||
if (!pane_shown(pane)) return 0;
|
||||
float cx, cy, hw, hh;
|
||||
pane_box(pane, &cx, &cy, &hw, &hh);
|
||||
if (!p->t) return p->x >= cx - hw && p->x <= cx + hw && p->y >= cy - hh && p->y <= cy + hh;
|
||||
float ax, ay, bx, by;
|
||||
if (!to_ndc(p->t, cx - hw, cy - hh, &ax, &ay) || !to_ndc(p->t, cx + hw, cy + hh, &bx, &by)) return 0;
|
||||
return p->nx >= fminf(ax, bx) && p->nx <= fmaxf(ax, bx) && p->ny >= fminf(ay, by) && p->ny <= fmaxf(ay, by);
|
||||
}
|
||||
|
||||
// ---- screens and nodes
|
||||
typedef struct { uint64_t control, node, screen; } Focus;
|
||||
static uint64_t screen_mgr(void) {
|
||||
uint64_t slot = MOD_MAIN_BASE + SCREEN_MGR_V143, sm = rd64(slot);
|
||||
return game_object(sm, SM_BOX_MGR + 8) ? sm : 0;
|
||||
}
|
||||
// The focused node of an open screen, from the draw targets' box cursors (all 0: none, as in gameplay)
|
||||
static Focus focus_now(void) {
|
||||
Focus none = {0};
|
||||
uint64_t sm = screen_mgr(), bm = sm ? rd64(sm + SM_BOX_MGR) : 0;
|
||||
if (!ok_at(bm, BM_CONTROLS + 16)) return none;
|
||||
for (int i = 0; i < 2; i++) {
|
||||
uint64_t ctl = rd64(bm + BM_CONTROLS + 8ull * i);
|
||||
if (!ok_at(ctl, CTL_RESERVED + 8)) continue;
|
||||
uint64_t node = rd64(ctl + CTL_ACTIVE);
|
||||
if (!ok_at(node, NODE_SCREEN + 8)) continue;
|
||||
uint64_t scr = rd64(node + NODE_SCREEN);
|
||||
if (!game_object(scr, SCR_REQUEST + 1) || rd8(scr + SCR_STATE) != SCR_OPENED) continue;
|
||||
return (Focus){ctl, node, scr};
|
||||
}
|
||||
return none;
|
||||
}
|
||||
static void layout_name(uint64_t scr, char *out, size_t n) {
|
||||
snprintf(out, n, "?");
|
||||
uint64_t lyt = rd64(scr + SCR_LAYOUT), name = ok_at(lyt, LYT_NAME + 8) ? rd64(lyt + LYT_NAME) : 0;
|
||||
if (name < 0x10000 || name >= (1ull << 47) || !mem_guest_readable(name, n)) return;
|
||||
memcpy(out, (const void *)(uintptr_t)name, n - 1);
|
||||
out[n - 1] = 0;
|
||||
for (char *q = out; *q; q++) if (*q < 0x20 || *q > 0x7e) { *q = 0; break; }
|
||||
}
|
||||
// The open screens: eui::ScreenMgr's screen array, count at +0x8 and its pointers at +0x10 (1.0.0's layout, which the
|
||||
// 1.4.3 constructor keeps). The map has no focus box, so it is found here: the Purah Pad's frame (AppMenuOverlay_00)
|
||||
// with AppMap_00 and the menu's background AppMenuBG_00 open. The Scope opens the frame over AppMap_00 without that
|
||||
// background (Weeping of the Empire's engine/runtime/totk_menu_cursor.cpp census tells them apart the same way).
|
||||
// TOTK_MENU_CURSOR_LOG=1 logs the open screens whenever the set changes.
|
||||
enum { SM_COUNT = 0x8, SM_SCREENS = 0x10 };
|
||||
static int map_open(void) {
|
||||
uint64_t sm = screen_mgr();
|
||||
if (!sm) return 0;
|
||||
int32_t n = (int32_t)rd32(sm + SM_COUNT);
|
||||
uint64_t arr = rd64(sm + SM_SCREENS);
|
||||
if (n <= 0 || n > 1024 || !ok_at(arr, 8ull * (uint64_t)n)) return 0;
|
||||
const char *e = cfg_get("TOTK_MENU_CURSOR_LOG");
|
||||
int verbose = e && !strcmp(e, "1"), pad = 0, map = 0, bg = 0;
|
||||
char list[1024];
|
||||
size_t len = 0;
|
||||
uint64_t h = 1469598103934665603ull;
|
||||
for (int32_t i = 0; i < n; i++) {
|
||||
uint64_t scr = rd64(arr + 8ull * (uint64_t)i);
|
||||
if (!game_object(scr, SCR_REQUEST + 1) || rd8(scr + SCR_STATE) != SCR_OPENED) continue;
|
||||
char nm[48];
|
||||
layout_name(scr, nm, sizeof nm);
|
||||
pad |= !strcmp(nm, "AppMenuOverlay_00");
|
||||
map |= !strcmp(nm, "AppMap_00");
|
||||
bg |= !strcmp(nm, "AppMenuBG_00");
|
||||
if (!verbose) continue;
|
||||
for (const char *c = nm; *c; c++) h = (h ^ (uint8_t)*c) * 1099511628211ull;
|
||||
if (len < sizeof list - 64) len += (size_t)snprintf(list + len, sizeof list - len, "%s%s", len ? " " : "", nm);
|
||||
}
|
||||
static uint64_t last;
|
||||
static int lines;
|
||||
if (verbose && h != last && lines < 200) last = h, lines++, rt_log("menu_cursor: open screens: %s", list);
|
||||
return pad && map && bg;
|
||||
}
|
||||
static void layout_size(uint64_t scr, float *lw, float *lh) {
|
||||
*lw = 1920, *lh = 1080;
|
||||
uint64_t lyt = rd64(scr + SCR_LAYOUT);
|
||||
if (!ok_at(lyt, LYT_SIZE + 8)) return;
|
||||
float w = rdf(lyt + LYT_SIZE), h = rdf(lyt + LYT_SIZE + 4);
|
||||
if (w >= 64 && w <= 8192 && h >= 64 && h <= 8192) *lw = w, *lh = h;
|
||||
}
|
||||
// The screen's focusable nodes: an sead OffsetList, head {prev, next} at +0x80, node-to-link offset at +0x94
|
||||
#define MAX_NODES 512
|
||||
static int nodes_of(uint64_t scr, uint64_t *node, uint64_t *btn) {
|
||||
uint64_t head = scr + SCR_NODES;
|
||||
int32_t off = (int32_t)rd32(head + 0x14), n = 0;
|
||||
if (off < 0 || off > 0x40) return 0;
|
||||
for (uint64_t link = rd64(head + 8); link != head && n < MAX_NODES; link = rd64(link + 8)) {
|
||||
if (!ok_at(link, 16)) break;
|
||||
uint64_t nd = link - (uint64_t)off;
|
||||
if (!ok_at(nd, NODE_SCREEN + 8)) continue;
|
||||
uint64_t b = rd64(nd + NODE_BUTTON);
|
||||
if (!ok_at(b, BTN_HIT_PANE + 8)) continue;
|
||||
node[n] = nd, btn[n] = b, n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
static int usable(uint64_t btn) { return !(~rd32(btn + BTN_FLAGS) & BTN_USABLE); }
|
||||
static uint64_t root_pane(uint64_t btn) {
|
||||
uint64_t blyt = rd64(btn + BTN_LAYOUT);
|
||||
return ok_at(blyt, LYT_ROOT + 8) ? rd64(blyt + LYT_ROOT) : 0;
|
||||
}
|
||||
// Every usable node's shown hit pane lies off the visible layout: the hit panes are stale (TotK never uses touch;
|
||||
// the dialogue choices' sit far from where the choices are drawn), so the buttons' own parts panes stand for them
|
||||
static int stale_hit_panes(int n, const uint64_t *btn, float lw, float lh, const Xf *t) {
|
||||
int seen = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (!usable(btn[i])) continue;
|
||||
uint64_t pane = rd64(btn[i] + BTN_HIT_PANE);
|
||||
if (!ok_at(pane, PANE_MTX + 0x20) || !pane_shown(pane)) continue;
|
||||
float cx, cy, hw, hh, vx, vy;
|
||||
pane_box(pane, &cx, &cy, &hw, &hh);
|
||||
int in = t ? to_ndc(t, cx, cy, &vx, &vy) && fabsf(vx) <= 1 && fabsf(vy) <= 1
|
||||
: fabsf(cx) <= lw / 2 && fabsf(cy) <= lh / 2;
|
||||
seen++;
|
||||
if (in) return 0;
|
||||
}
|
||||
return seen > 0;
|
||||
}
|
||||
// The usable node under the pointer, 0: none
|
||||
static uint64_t node_at(int n, const uint64_t *node, const uint64_t *btn, const Pt *p, float lw, float lh) {
|
||||
if (!stale_hit_panes(n, btn, lw, lh, p->t)) {
|
||||
for (int i = 0; i < n; i++)
|
||||
if (usable(btn[i]) && pane_hit(rd64(btn[i] + BTN_HIT_PANE), p)) return node[i];
|
||||
return 0;
|
||||
}
|
||||
uint64_t found = 0;
|
||||
float best = 1e30f;
|
||||
for (int i = 0; i < n; i++) { // parts panes can be several items wide: the nearest centre
|
||||
uint64_t pane = root_pane(btn[i]);
|
||||
if (!usable(btn[i]) || !pane_hit(pane, p)) continue;
|
||||
float cx, cy, hw, hh, d;
|
||||
pane_box(pane, &cx, &cy, &hw, &hh);
|
||||
d = (cx - p->x) * (cx - p->x) + (cy - p->y) * (cy - p->y);
|
||||
if (d < best) best = d, found = node[i];
|
||||
}
|
||||
return found;
|
||||
}
|
||||
// The focus to node: the writes moveBoxCursor makes. The request bit is a byte eui also writes, so it is raised
|
||||
// again until the focus is there.
|
||||
static void move_focus(const Focus *f, uint64_t node) {
|
||||
memcpy((void *)(uintptr_t)(f->control + CTL_RESERVED), &node, 8);
|
||||
memcpy((void *)(uintptr_t)(f->screen + SCR_RESERVED), &node, 8);
|
||||
__atomic_fetch_or((uint8_t *)(uintptr_t)(f->screen + SCR_REQUEST), (uint8_t)4, __ATOMIC_SEQ_CST);
|
||||
}
|
||||
|
||||
// Screens with a focus box that are gameplay, not menus: the L ability wheel, the D-pad strips, Autobuild's list, the
|
||||
// Purah Pad's camera and the map (the stick moves its own cursor there)
|
||||
static int gameplay_screen(const char *nm) {
|
||||
static const char *const names[] = {"ShortCutMainItem_00", "ShortCut_00", "AutoBuilder_00", "CameraPointer_00",
|
||||
"AppMap_00"};
|
||||
for (size_t i = 0; i < sizeof names / sizeof *names; i++)
|
||||
if (!strcmp(nm, names[i])) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static volatile int32_t g_pointer; // a menu has the pointer: input_cursor_allowed releases the capture
|
||||
static volatile int32_t g_map; // the Purah Pad's map is open (not the Scope): the mouse moves its cursor
|
||||
|
||||
int menu_cursor_pointer(void) { return __atomic_load_n(&g_pointer, __ATOMIC_RELAXED); }
|
||||
int menu_cursor_map(void) { return __atomic_load_n(&g_map, __ATOMIC_RELAXED); }
|
||||
|
||||
static int enabled(void) {
|
||||
if (!RT_V143) return 0;
|
||||
const char *v = cfg_get("TOTK_MENU_CURSOR"), *in = cfg_get("TOTK_INPUT");
|
||||
return !(v && !strcmp(v, "0")) && !(in && !strcmp(in, "none"));
|
||||
}
|
||||
|
||||
static void set_pointer(int on) {
|
||||
if (plat_xchg32(&g_pointer, on) == on) return;
|
||||
ikb_cursor_update();
|
||||
if (on) ikb_pointer_set(0.5f, 0.5f); // the pointer appears in the middle, not where the capture left it
|
||||
}
|
||||
|
||||
// The pointer on the game's picture, 0..1 from its top left (the present's fit, letterbox or pillarbox)
|
||||
static int picture_pos(float *px, float *py) {
|
||||
float nx, ny, r[4];
|
||||
if (!ikb_pointer(&nx, &ny) || !gfx_picture_rect(r) || r[2] <= 0 || r[3] <= 0) return 0;
|
||||
*px = (nx - r[0]) / r[2], *py = (ny - r[1]) / r[3];
|
||||
return *px >= 0 && *px <= 1 && *py >= 0 && *py <= 1;
|
||||
}
|
||||
|
||||
static void log_screen(const Focus *f, const char *nm, int n, float lw, float lh, const Xf *t, int stale) {
|
||||
static uint64_t last;
|
||||
static int lines;
|
||||
const char *e = cfg_get("TOTK_MENU_CURSOR_LOG");
|
||||
int verbose = e && !strcmp(e, "1");
|
||||
if (f->screen == last || (!verbose && lines >= 6)) return;
|
||||
last = f->screen, lines++;
|
||||
rt_log("menu_cursor: screen %s (layout %gx%g, %d nodes, %s hit panes, draw transform %s)", nm, lw, lh, n,
|
||||
stale ? "stale" : "own", t ? "scaled" : "plain");
|
||||
}
|
||||
|
||||
// Once per pad read on the game thread: the pointer's hover and clicks, as pad buttons (A, B) for this read
|
||||
void menu_cursor_poll(uint64_t *buttons) {
|
||||
static int inited, pending_a, held_l, held_r;
|
||||
static uint64_t pending_node, pending_until, hold_until, hold_bit, menu_seen;
|
||||
static float last_px = -1, last_py = -1;
|
||||
if (!inited) {
|
||||
inited = 1;
|
||||
main_lo = MOD_MAIN_BASE, main_hi = MOD_MAIN_BASE + MAIN_SIZE;
|
||||
if (RT_V143) rt_log("menu_cursor: on for 1.4.3 (TOTK_MENU_CURSOR=0 turns it off)");
|
||||
}
|
||||
if (!enabled()) { set_pointer(0); __atomic_store_n(&g_map, 0, __ATOMIC_RELAXED); return; }
|
||||
Focus f = focus_now();
|
||||
char nm[48] = "";
|
||||
if (f.screen) layout_name(f.screen, nm, sizeof nm);
|
||||
// The open screens are scanned every 100 ms, not at every pad read: each screen costs memory queries
|
||||
uint64_t now = plat_now_ms();
|
||||
static uint64_t map_at;
|
||||
static int map, map_logged;
|
||||
if (!map_at || now - map_at >= 100) map_at = now, map = map_open();
|
||||
if (map && !map_logged) map_logged = 1, rt_log("menu_cursor: the map is open: the mouse moves its cursor");
|
||||
__atomic_store_n(&g_map, map, __ATOMIC_RELAXED);
|
||||
// With the gamepad as the last device the menus stay as they are on the console: no pointer, the capture kept.
|
||||
// Moving the mouse (or a key) makes keyboard and mouse the last device and the pointer comes.
|
||||
int menu = f.screen && !gameplay_screen(nm) && ikb_foreground() && input_kbm_last();
|
||||
if (menu) menu_seen = now;
|
||||
// A menu's focus gap (a tab switch, a popup opening: no focus box for a frame or two) keeps the pointer where it
|
||||
// is, for up to 300 ms: releasing it there captured it again and centred it when the next tab took the focus
|
||||
int gap = !menu && !f.screen && menu_seen && now - menu_seen < 300 && ikb_foreground() && input_kbm_last();
|
||||
set_pointer(menu || gap);
|
||||
int l = ikb_down(KEY_MOUSE_L), r = ikb_down(KEY_MOUSE_R);
|
||||
int click_l = l && !held_l, click_r = r && !held_r;
|
||||
held_l = l, held_r = r;
|
||||
if (now < hold_until) { // a click's button, held for 60 ms so the game sees one press whatever its read rate
|
||||
*buttons |= hold_bit;
|
||||
return;
|
||||
}
|
||||
if (gap) return; // a click's focus request waits for the next screen
|
||||
if (!menu) { pending_a = 0; last_px = last_py = -1; return; }
|
||||
if (pending_a) { // the focus asked for by a click: A once it is there
|
||||
if (rd64(f.control + CTL_ACTIVE) == pending_node) {
|
||||
pending_a = 0, hold_until = now + 60, hold_bit = 1ull << B_A;
|
||||
*buttons |= hold_bit;
|
||||
return;
|
||||
}
|
||||
if (now > pending_until) pending_a = 0;
|
||||
else move_focus(&f, pending_node);
|
||||
return;
|
||||
}
|
||||
if (click_r) { hold_until = now + 60, hold_bit = 1ull << B_B; *buttons |= hold_bit; return; }
|
||||
float px, py;
|
||||
if (!picture_pos(&px, &py)) return;
|
||||
int moved = fabsf(px - last_px) > 0.001f || fabsf(py - last_py) > 0.001f;
|
||||
if (!moved && !click_l) return;
|
||||
last_px = px, last_py = py;
|
||||
float lw, lh;
|
||||
layout_size(f.screen, &lw, &lh);
|
||||
Xf xf = screen_xf(f.screen);
|
||||
Pt p = {(px - 0.5f) * lw, (0.5f - py) * lh, px * 2 - 1, 1 - py * 2, NULL};
|
||||
if (xf.ok && !xf_plain(&xf, lw, lh)) p.t = &xf;
|
||||
static uint64_t node[MAX_NODES], btn[MAX_NODES];
|
||||
int n = nodes_of(f.screen, node, btn);
|
||||
log_screen(&f, nm, n, lw, lh, p.t, n && stale_hit_panes(n, btn, lw, lh, p.t));
|
||||
uint64_t at = n ? node_at(n, node, btn, &p, lw, lh) : 0;
|
||||
if (!at) return;
|
||||
if (at != f.node) move_focus(&f, at);
|
||||
if (click_l) {
|
||||
if (at == f.node) { hold_until = now + 60, hold_bit = 1ull << B_A; *buttons |= hold_bit; }
|
||||
else pending_a = 1, pending_node = at, pending_until = now + 300;
|
||||
}
|
||||
}
|
||||
|
||||
// The L ability wheel or a D-pad strip (materials, weapons, shields, bows) has the focus: 1 / 0, -1 with the menu
|
||||
// cursor off (the screens are not read). input.c asks while L or the D-pad is held, before it gives the mouse to their
|
||||
// selectors: L also soothes the horse and turns menu tabs, the D-pad does other things too.
|
||||
int menu_cursor_shortcut(void) {
|
||||
if (!enabled()) return -1;
|
||||
Focus f = focus_now();
|
||||
char nm[48] = "";
|
||||
if (f.screen) layout_name(f.screen, nm, sizeof nm);
|
||||
return f.screen && (!strcmp(nm, "ShortCutMainItem_00") || !strcmp(nm, "ShortCut_00"));
|
||||
}
|
||||
+248
-45
@@ -15,7 +15,9 @@
|
||||
// when its build id is main's (an IPS file's name, a pchtxt file's @nsobid line); others are another version's and
|
||||
// counted. The setup builds every mod's files into patch sets and writes build-cache/exefs.status next to totk.exe
|
||||
// (tools/exefs_patches.py has the format): a file whose line is missing or has another content hash waits for the
|
||||
// rebuild at start (cheats.c), and its mod stays off until then.
|
||||
// rebuild at start (cheats.c), and its mod stays off until then. A file's writes into main's data (.rodata, .data,
|
||||
// .bss) are lines of build-cache/exefs.data, stored into the loaded main before the game runs (mods_exefs_data), as
|
||||
// emulators apply a patch file to the module before its dynamic linker runs.
|
||||
#include <ctype.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -170,22 +172,26 @@ static int same_build(const char *hex, size_t n) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// build-cache/exefs.status next to totk.exe, read once; "" when there is none.
|
||||
static const char *exefs_status(void) {
|
||||
static char *text;
|
||||
if (text) return text;
|
||||
// build-cache/<name> next to totk.exe, read once into *text; "" when there is none (mods_probe_cache sets the text).
|
||||
static char *status_text, *data_text;
|
||||
static const char *build_cache(const char *name, char **text) {
|
||||
if (*text) return *text;
|
||||
char path[2100];
|
||||
uint64_t n = 0;
|
||||
if (plat_exe_path(path, sizeof path)) {
|
||||
char *cut = strrchr(path, '/'), *back = strrchr(path, '\\');
|
||||
if (back > cut) cut = back;
|
||||
if (cut) snprintf(cut + 1, sizeof path - (size_t)(cut + 1 - path), "build-cache/exefs.status");
|
||||
text = (char *)plat_file_read(path, &n);
|
||||
if (cut && snprintf(cut + 1, sizeof path - (size_t)(cut + 1 - path), "build-cache/%s", name) <
|
||||
(int)(sizeof path - (size_t)(cut + 1 - path)))
|
||||
*text = (char *)plat_file_read(path, &n);
|
||||
}
|
||||
if (!text) text = calloc(1, 1);
|
||||
return text;
|
||||
if (!*text) *text = calloc(1, 1);
|
||||
return *text;
|
||||
}
|
||||
|
||||
static const char *exefs_status(void) { return build_cache("exefs.status", &status_text); }
|
||||
static const char *exefs_data(void) { return build_cache("exefs.data", &data_text); }
|
||||
|
||||
// The status line of key: its kind, built-in sets and note, NUL-terminated copies; 0 when none.
|
||||
static int status_line(const char *key, const char *content, char *kind, char *over, char *note) {
|
||||
char want[20];
|
||||
@@ -194,8 +200,9 @@ static int status_line(const char *key, const char *content, char *kind, char *o
|
||||
const char *end = strchr(line, '\n');
|
||||
size_t len = end ? (size_t)(end - line) : strlen(line);
|
||||
if (len > 18 && !memcmp(line, want, 18)) {
|
||||
char copy[600];
|
||||
snprintf(copy, sizeof copy, "%.*s", (int)(len < sizeof copy ? len : sizeof copy - 1), line + 18);
|
||||
char copy[600]; // the line after the two hashes, without the next line
|
||||
size_t rest = len - 18;
|
||||
snprintf(copy, sizeof copy, "%.*s", (int)(rest < sizeof copy ? rest : sizeof copy - 1), line + 18);
|
||||
char *t1 = strchr(copy, '\t'), *t2 = t1 ? strchr(t1 + 1, '\t') : NULL;
|
||||
if (!t1 || !t2) return 0;
|
||||
*t1 = *t2 = 0;
|
||||
@@ -269,9 +276,14 @@ static int exefs_file_cb(const char *leaf, int kind, uint64_t size, void *ctx) {
|
||||
m->sets[m->nsets++] = (uint32_t)strtoul(key, NULL, 16);
|
||||
size_t k = strlen(m->overrides);
|
||||
if (*over) snprintf(m->overrides + k, sizeof m->overrides - k, "%s%s", k ? "," : "", over);
|
||||
} else if (!strcmp(kind_s, "data") && m->ndata < (int)(sizeof m->data / sizeof *m->data)) {
|
||||
m->data[m->ndata++] = (uint32_t)strtoul(key, NULL, 16);
|
||||
} else if (!strcmp(kind_s, "other")) {
|
||||
m->other_version++;
|
||||
}
|
||||
unsigned long bytes; // a code or data file's data writes: exefs.data must hold them all (mods_exefs_data)
|
||||
if ((!strcmp(kind_s, "code") || !strcmp(kind_s, "data")) && sscanf(note, "data %lu", &bytes) == 1)
|
||||
m->data_bytes += (uint32_t)bytes;
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -326,34 +338,62 @@ static int scan_cb(const char *name, int kind, uint64_t size, void *ctx) {
|
||||
if (m->cheats[0]) plat_fs_list(m->cheats, cheats_cb, &m->kind);
|
||||
int unsupported = exefs_check(m, path);
|
||||
// a mod whose code part the port cannot use is off as a whole: its RomFS files may expect that code
|
||||
m->problem = strchr(m->name, '|') ? MOD_BAD_NAME
|
||||
: m->kind & MOD_SUBSDK ? MOD_PLUGIN
|
||||
: unsupported ? MOD_EXEFS_UNSUPPORTED
|
||||
: m->pending ? MOD_EXEFS_LATER
|
||||
: m->nsets || m->kind & (MOD_ROMFS | MOD_CHEATS) ? MOD_OK
|
||||
: m->kind & MOD_EXEFS ? MOD_EXEFS_OTHER
|
||||
: MOD_EMPTY;
|
||||
m->problem = strchr(m->name, '|') ? MOD_BAD_NAME
|
||||
: m->kind & MOD_SUBSDK ? MOD_PLUGIN
|
||||
: unsupported ? MOD_EXEFS_UNSUPPORTED
|
||||
: m->pending ? MOD_EXEFS_LATER
|
||||
: m->nsets || m->ndata || m->kind & (MOD_ROMFS | MOD_CHEATS) ? MOD_OK
|
||||
: m->kind & MOD_EXEFS ? MOD_EXEFS_OTHER
|
||||
: MOD_EMPTY;
|
||||
m->enabled = !listed(disabled, m->name);
|
||||
nmods++;
|
||||
return 1;
|
||||
}
|
||||
|
||||
void mods_scan(void) {
|
||||
static int done;
|
||||
if (done) return;
|
||||
done = 1;
|
||||
// The mods folder into out: TOTK_MODS_DIR, else <dir of totk.exe>/mods. 0 when the exe's path is unknown.
|
||||
static int mods_folder(char *out, size_t cap) {
|
||||
const char *d = cfg_get("TOTK_MODS_DIR");
|
||||
if (d && *d) {
|
||||
snprintf(dir, sizeof dir, "%s", d);
|
||||
} else {
|
||||
char exe[2048];
|
||||
if (!plat_exe_path(exe, sizeof exe)) return;
|
||||
char *cut = strrchr(exe, '/'), *back = strrchr(exe, '\\');
|
||||
if (back > cut) cut = back;
|
||||
if (!cut) return;
|
||||
*cut = 0;
|
||||
snprintf(dir, sizeof dir, "%s/mods", exe);
|
||||
}
|
||||
if (d && *d) return snprintf(out, cap, "%s", d) < (int)cap;
|
||||
char exe[2048];
|
||||
if (!plat_exe_path(exe, sizeof exe)) return 0;
|
||||
char *cut = strrchr(exe, '/'), *back = strrchr(exe, '\\');
|
||||
if (back > cut) cut = back;
|
||||
if (!cut) return 0;
|
||||
*cut = 0;
|
||||
return snprintf(out, cap, "%s/mods", exe) < (int)cap;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const char *folder, *disabled;
|
||||
void (*cb)(const char *name, const char *romfs, void *ctx);
|
||||
void *ctx;
|
||||
} Peek;
|
||||
|
||||
static int peek_cb(const char *name, int kind, uint64_t size, void *ctx) {
|
||||
(void)size;
|
||||
const Peek *pk = ctx;
|
||||
if (kind != PLAT_FS_DIR || listed(pk->disabled, name)) return 1;
|
||||
char path[2100], romfs[2048];
|
||||
if (snprintf(path, sizeof path, "%s/%s", pk->folder, name) >= (int)sizeof path) return 1;
|
||||
find_root(path, "romfs", romfs, sizeof romfs);
|
||||
if (!romfs[0]) find_root(path, "romfslite", romfs, sizeof romfs);
|
||||
if (romfs[0]) pk->cb(name, romfs, pk->ctx);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void mods_peek_romfs(void (*cb)(const char *name, const char *romfs, void *ctx), void *ctx) {
|
||||
char folder[2048];
|
||||
if (!mods_folder(folder, sizeof folder) || !plat_dir_exists(folder)) return;
|
||||
Peek pk = {folder, cfg_get("TOTK_MODS_DISABLED"), cb, ctx};
|
||||
plat_fs_list(folder, peek_cb, &pk);
|
||||
}
|
||||
|
||||
static int scanned;
|
||||
|
||||
void mods_scan(void) {
|
||||
if (scanned) return;
|
||||
scanned = 1;
|
||||
if (!mods_folder(dir, sizeof dir)) return;
|
||||
if (!plat_dir_exists(dir)) {
|
||||
rt_log("mods: no folder %s", dir);
|
||||
return;
|
||||
@@ -376,18 +416,171 @@ void mods_scan(void) {
|
||||
rt_log("mods: %d folders in %s", nmods, dir);
|
||||
}
|
||||
|
||||
void mods_exefs_switch(void) {
|
||||
mods_scan();
|
||||
for (int i = 0; i < nmods; i++) {
|
||||
const ModInfo *m = &mods[i];
|
||||
if (!start_enabled[i] || m->problem != MOD_OK || !m->nsets) continue;
|
||||
for (int k = 0; k < m->nsets; k++) {
|
||||
char name[32];
|
||||
snprintf(name, sizeof name, "mod_%08x", m->sets[k]);
|
||||
int set = rt_patch_set(name);
|
||||
if (set >= 0) rt_patch_switch(set, 1);
|
||||
else rt_log("mods: %s: patch set %s is not in this build", m->name, name);
|
||||
// ---- ExeFS data writes (build-cache/exefs.data; tools/exefs_patches.py data_line has the format)
|
||||
|
||||
// 1 when key names one of m's usable ExeFS patch files: one with a patch set, or one with data writes only.
|
||||
static int owns(const ModInfo *m, uint32_t key) {
|
||||
for (int k = 0; k < m->nsets; k++)
|
||||
if (m->sets[k] == key) return 1;
|
||||
for (int k = 0; k < m->ndata; k++)
|
||||
if (m->data[k] == key) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int hex_digit(char c) {
|
||||
return c >= '0' && c <= '9' ? c - '0'
|
||||
: c >= 'a' && c <= 'f' ? c - 'a' + 10
|
||||
: c >= 'A' && c <= 'F' ? c - 'A' + 10
|
||||
: -1;
|
||||
}
|
||||
|
||||
// One line: "<file hash>\t<content hash>\t<offset in main>\t<bytes>[\tbase]", all hex. 0 when malformed.
|
||||
typedef struct { char key[9], content[9]; uint64_t off; uint8_t bytes[256]; int n, base; } DataRun;
|
||||
static int parse_run(const char *line, size_t len, DataRun *r) {
|
||||
char copy[1100], *f[6];
|
||||
if (len >= sizeof copy) return 0;
|
||||
memcpy(copy, line, len);
|
||||
copy[len] = 0;
|
||||
int nf = 0;
|
||||
for (char *p = copy; p && nf < 6; nf++) {
|
||||
f[nf] = p;
|
||||
p = strchr(p, '\t');
|
||||
if (p) *p++ = 0;
|
||||
}
|
||||
if (nf < 4 || nf > 5 || strlen(f[0]) != 8 || strlen(f[1]) != 8) return 0;
|
||||
memcpy(r->key, f[0], 9);
|
||||
memcpy(r->content, f[1], 9);
|
||||
char *end;
|
||||
r->off = strtoull(f[2], &end, 16);
|
||||
size_t hex = strlen(f[3]);
|
||||
if (!*f[2] || *end || !hex || hex % 2 || hex / 2 > sizeof r->bytes) return 0;
|
||||
r->n = (int)(hex / 2);
|
||||
for (int i = 0; i < r->n; i++) {
|
||||
int hi = hex_digit(f[3][2 * i]), lo = hex_digit(f[3][2 * i + 1]);
|
||||
if (hi < 0 || lo < 0) return 0;
|
||||
r->bytes[i] = (uint8_t)(hi << 4 | lo);
|
||||
}
|
||||
r->base = nf == 5;
|
||||
return nf == 4 || (!strcmp(f[4], "base") && r->n == 8);
|
||||
}
|
||||
|
||||
// Stores n bytes into the loaded main at a: a read-only page (.rodata, loader.c seal) is writable for its part of the
|
||||
// copy only, as cheats.c wr_main does. 0 when a page is neither readable nor writable.
|
||||
static int store_main(uint64_t a, const uint8_t *from, int n) {
|
||||
while (n > 0) {
|
||||
uint64_t page = a & ~0xFFFull, end;
|
||||
int k = (int)(page + 0x1000 - a);
|
||||
if (k > n) k = n;
|
||||
int prot = plat_mem_state(page, page + 0x1000, &end);
|
||||
if (prot != PLAT_PROT_READ && prot != PLAT_PROT_RW) return 0;
|
||||
if (prot == PLAT_PROT_READ && !plat_mem_protect(page, 0x1000, PLAT_PROT_RW)) return 0;
|
||||
memcpy((void *)(uintptr_t)a, from, (size_t)k);
|
||||
if (prot == PLAT_PROT_READ) plat_mem_protect(page, 0x1000, PLAT_PROT_READ);
|
||||
a += (uint64_t)k;
|
||||
from += k;
|
||||
n -= k;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
long mods_exefs_data(const ModInfo *m, uint64_t base, uint64_t size, uint64_t code_end,
|
||||
int (*store)(uint64_t addr, const uint8_t *bytes, int n)) {
|
||||
if (!store) store = store_main;
|
||||
long total = 0, found = 0;
|
||||
for (int pass = 0; pass < 2; pass++) { // pass 0 checks every line, pass 1 stores
|
||||
// exefs.data must hold every byte the status lines count: one missing or cut short would leave the code
|
||||
// patches on without their data
|
||||
if (pass && found != (long)m->data_bytes) {
|
||||
rt_log("mods: %s: build-cache/exefs.data holds %ld of the %u bytes its ExeFS patch files write", m->name,
|
||||
found, m->data_bytes);
|
||||
return -1;
|
||||
}
|
||||
for (const char *line = exefs_data(), *next; *line; line = next) {
|
||||
const char *nl = strchr(line, '\n');
|
||||
size_t len = nl ? (size_t)(nl - line) : strlen(line);
|
||||
next = nl ? nl + 1 : line + len;
|
||||
if (len && line[len - 1] == '\r') len--;
|
||||
char key[9];
|
||||
snprintf(key, sizeof key, "%.*s", (int)(len < 8 ? len : 8), line);
|
||||
if (!len || *line == '#' || !owns(m, (uint32_t)strtoul(key, NULL, 16))) continue;
|
||||
DataRun r;
|
||||
char kind[16], over[256], note[256];
|
||||
// a line whose file the status does not list as built (another content) or that leaves main's data: the
|
||||
// two build-cache files and this build disagree
|
||||
if (!parse_run(line, len, &r) || !status_line(r.key, r.content, kind, over, note) ||
|
||||
(strcmp(kind, "code") && strcmp(kind, "data")) || r.off < code_end || r.off > size ||
|
||||
(uint64_t)r.n > size - r.off) {
|
||||
rt_log("mods: %s: a line of build-cache/exefs.data does not fit this build: %.*s", m->name, (int)len,
|
||||
line);
|
||||
return -1;
|
||||
}
|
||||
if (!pass) {
|
||||
found += r.n;
|
||||
continue;
|
||||
}
|
||||
if (r.base) { // a DT_RELR word holds its addend; the dynamic linker adds the module's base
|
||||
uint64_t w;
|
||||
memcpy(&w, r.bytes, 8);
|
||||
w += base;
|
||||
memcpy(r.bytes, &w, 8);
|
||||
}
|
||||
if (!store(base + r.off, r.bytes, r.n)) {
|
||||
rt_log("mods: %s: cannot write main+%#llx", m->name, (unsigned long long)r.off);
|
||||
return -1;
|
||||
}
|
||||
total += r.n;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
static char *dup_text(const char *s) {
|
||||
if (!s) return NULL;
|
||||
size_t n = strlen(s) + 1;
|
||||
char *d = malloc(n);
|
||||
if (d) memcpy(d, s, n);
|
||||
return d;
|
||||
}
|
||||
|
||||
void mods_probe_cache(const char *status, const char *data) {
|
||||
free(status_text);
|
||||
free(data_text);
|
||||
status_text = dup_text(status);
|
||||
data_text = dup_text(data);
|
||||
}
|
||||
|
||||
void mods_rescan(void) {
|
||||
free(mods);
|
||||
mods = NULL;
|
||||
nmods = 0;
|
||||
scanned = 0;
|
||||
}
|
||||
|
||||
// Switches the patch set mod_<key> on; 0 when this build lacks it (logged).
|
||||
static int set_on(const ModInfo *m, uint32_t key) {
|
||||
char name[32];
|
||||
snprintf(name, sizeof name, "mod_%08x", key);
|
||||
int set = rt_patch_set(name);
|
||||
if (set >= 0) rt_patch_switch(set, 1);
|
||||
else rt_log("mods: %s: patch set %s is not in this build", m->name, name);
|
||||
return set >= 0;
|
||||
}
|
||||
|
||||
void mods_exefs_apply(uint64_t base, uint64_t size, uint64_t code_end,
|
||||
int (*store)(uint64_t addr, const uint8_t *bytes, int n)) {
|
||||
// bottom first: the top mod's data writes land last and win, as its RomFS files do. Patch sets are only switches
|
||||
// (rt_patch_switch), so their order does not matter.
|
||||
for (int i = nmods - 1; i >= 0; i--) {
|
||||
const ModInfo *m = &mods[i];
|
||||
if (!start_enabled[i] || m->problem != MOD_OK || !(m->nsets || m->ndata)) continue;
|
||||
// the data first: a mod whose data writes cannot all be stored keeps its code patches off too, as half of a
|
||||
// patch file is worse than none
|
||||
long stored = size ? mods_exefs_data(m, base, size, code_end, store) : 0;
|
||||
if (stored < 0) {
|
||||
rt_log("mods: %s: its ExeFS patch files stay off: their data writes could not all be stored", m->name);
|
||||
continue;
|
||||
}
|
||||
for (int k = 0; k < m->nsets; k++) set_on(m, m->sets[k]);
|
||||
char over[256];
|
||||
snprintf(over, sizeof over, "%s", m->overrides);
|
||||
for (char *s = strtok(over, ","); s; s = strtok(NULL, ",")) {
|
||||
@@ -395,10 +588,20 @@ void mods_exefs_switch(void) {
|
||||
if (set >= 0) rt_patch_switch(set, 0);
|
||||
rt_log("mods: %s replaces the port's patch set %s; it stays off", m->name, s);
|
||||
}
|
||||
rt_log("mods: %s: %d ExeFS patch files on", m->name, m->nsets);
|
||||
rt_log("mods: %s: %d ExeFS patch files on, %ld bytes of main's data stored", m->name, m->nsets + m->ndata,
|
||||
stored);
|
||||
}
|
||||
}
|
||||
|
||||
void mods_exefs_switch(void) {
|
||||
mods_scan();
|
||||
const RtLoaded *main_mod = NULL;
|
||||
for (int i = 0; ldr_module(i); i++)
|
||||
if (!strcmp(ldr_module(i)->name, "main")) main_mod = ldr_module(i);
|
||||
if (main_mod) mods_exefs_apply(main_mod->base, main_mod->size, main_mod->text_end, NULL);
|
||||
else mods_exefs_apply(0, 0, 0, NULL);
|
||||
}
|
||||
|
||||
int mods_exefs_pending(uint32_t *key) {
|
||||
int n = 0;
|
||||
for (int i = 0; i < nmods; i++) {
|
||||
|
||||
+27
-2
@@ -2,7 +2,8 @@
|
||||
// them: <mod>/romfs, <mod>/exefs and <mod>/cheats, the Atmosphere layout (atmosphere/contents/<title id>/romfs,
|
||||
// atmosphere/exefs_patches/<name>/*.ips) or TKMM's merged output (romfs or romfslite, exefs, cheats). ExeFS patch
|
||||
// files (.ips, .pchtxt) for the game's main are patch sets the setup builds into the game (tools/exefs_patches.py),
|
||||
// switched with their mod (mods_exefs_switch). The list is scanned once at start (romfs.c overlay_init);
|
||||
// switched with their mod (mods_exefs_switch), and data writes stored into main before the game runs (mods_exefs_data).
|
||||
// The list is scanned once at start (romfs.c overlay_init);
|
||||
// its order and switches live in totk.ini [mods] (TOTK_MODS_ORDER: names top first, separated by '|';
|
||||
// TOTK_MODS_DISABLED: names switched off). The top mod wins a file two mods replace.
|
||||
#pragma once
|
||||
@@ -36,10 +37,17 @@ typedef struct {
|
||||
uint32_t pending, pending_key; // ExeFS patch files the build lacks; FNV-1a of their names and contents
|
||||
int nsets;
|
||||
uint32_t sets[16]; // the built patch sets mod_<hash> of its ExeFS patch files
|
||||
int ndata;
|
||||
uint32_t data[16]; // the hashes of its ExeFS patch files with data writes only (no set)
|
||||
uint32_t data_bytes; // the bytes its usable ExeFS patch files write into main's data, as their
|
||||
// status lines count them ("data <bytes>")
|
||||
char overrides[256]; // the port's own patch sets its patches replace, comma separated
|
||||
} ModInfo;
|
||||
|
||||
void mods_scan(void); // once; later calls return at once
|
||||
// Every switched-on mod folder's RomFS root, without the rest of the scan (no ExeFS checks, no order): for mem_init,
|
||||
// which runs before the paths mods_scan needs are set (resolution.c mod_tex_heap_kib). cb gets the name and root.
|
||||
void mods_peek_romfs(void (*cb)(const char *name, const char *romfs, void *ctx), void *ctx);
|
||||
const char *mods_dir(void); // the folder scanned (default: <dir of totk.exe>/mods)
|
||||
int mods_count(void);
|
||||
ModInfo *mods_at(int i); // 0 = top of the order; NULL out of range
|
||||
@@ -48,8 +56,25 @@ ModInfo *mods_at(int i); // 0 = top of the order; NULL out of range
|
||||
void mods_set_enabled(int i, int on);
|
||||
void mods_move(int i, int delta); // -1 up, +1 down; nothing at the ends
|
||||
int mods_changed(void); // 1 when the order or a switch differs from the start
|
||||
// After rt_patches_init: the patch sets of the enabled mods' ExeFS patches on, the port's own sets they replace off.
|
||||
// After ldr_load and rt_patches_init, before any game code: the patch sets of the enabled mods' ExeFS patches on, the
|
||||
// port's own sets they replace off, and their data writes stored into main (mods_exefs_data).
|
||||
void mods_exefs_switch(void);
|
||||
// What mods_exefs_switch does with main's image at base (size bytes, code below code_end; size 0: no data stored), for
|
||||
// every enabled mod from the bottom one up, so that the top mod's data writes win where two mods write the same bytes.
|
||||
// store as for mods_exefs_data.
|
||||
void mods_exefs_apply(uint64_t base, uint64_t size, uint64_t code_end,
|
||||
int (*store)(uint64_t addr, const uint8_t *bytes, int n));
|
||||
// Stores the data writes of mod m's ExeFS patch files (build-cache/exefs.data) into main's image at base: size bytes,
|
||||
// code below code_end. A DT_RELR word gets base added, as the dynamic linker adds it. Every line is checked before the
|
||||
// first store: one that falls outside [code_end, size) or does not belong to a usable file of m keeps them all out.
|
||||
// store writes n bytes at a guest address, 1 on success; NULL: into the loaded main, whose .rodata is read-only.
|
||||
// Returns the bytes stored, -1 when a line kept them out or a store failed. --probe-mods passes a buffer and memcpy.
|
||||
long mods_exefs_data(const ModInfo *m, uint64_t base, uint64_t size, uint64_t code_end,
|
||||
int (*store)(uint64_t addr, const uint8_t *bytes, int n));
|
||||
// --probe-mods: the texts of build-cache/exefs.status and exefs.data instead of the files (NULL: read the file), and a
|
||||
// fresh scan at the next mods_scan.
|
||||
void mods_probe_cache(const char *status, const char *data);
|
||||
void mods_rescan(void);
|
||||
// The ExeFS patch files of the enabled mods that the build lacks, their FNV-1a mixed into *key (cheats.c starts the
|
||||
// rebuild for them).
|
||||
int mods_exefs_pending(uint32_t *key);
|
||||
|
||||
+93
-3
@@ -1,5 +1,6 @@
|
||||
// --probe-mods: the mods folder scan, its order and switches (mods.c). Builds a mods folder of its own next to totk.exe,
|
||||
// scans it and removes it. Needs no game files.
|
||||
// scans it and removes it. Also the resource size table's entries of the members of the mods' packs (restbl_probe.c).
|
||||
// Needs no game files.
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
@@ -7,6 +8,7 @@
|
||||
#include "host.h"
|
||||
#include "mods.h"
|
||||
#include "platform/platform.h"
|
||||
#include "restbl.h"
|
||||
|
||||
static int fails;
|
||||
#define CHECK(c, what) do { if (!(c)) { fails++; fprintf(stderr, "FAIL mods: %s\n", what); } } while (0)
|
||||
@@ -37,6 +39,93 @@ static int remove_cb(const char *name, int kind, uint64_t size, void *ctx) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
static uint32_t fnv(const char *p, size_t n) {
|
||||
uint32_t h = 0x811C9DC5u;
|
||||
for (size_t i = 0; i < n; i++) h = (h ^ (uint8_t)p[i]) * 0x01000193u;
|
||||
return h;
|
||||
}
|
||||
|
||||
static ModInfo *by_name(const char *name) {
|
||||
for (int i = 0; mods_at(i); i++)
|
||||
if (!strcmp(mods_at(i)->name, name)) return mods_at(i);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// mods_exefs_data's store into a buffer standing in for main
|
||||
static int store_buffer(uint64_t a, const uint8_t *p, int n) {
|
||||
memcpy((void *)(uintptr_t)a, p, (size_t)n);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// A second scan of a folder of its own: data writes of ExeFS patch files (build-cache/exefs.data, given as text).
|
||||
static void probe_data(const char *root) {
|
||||
remove_tree(root);
|
||||
plat_file_mkdir_p(root);
|
||||
const char *bid = *mods_main_build_id() ? mods_main_build_id() : "0";
|
||||
char n_ips[160], p_ips[160], q_ips[160], x_ips[160], y_ips[160];
|
||||
snprintf(n_ips, sizeof n_ips, "N/exefs/%s.ips", bid);
|
||||
snprintf(p_ips, sizeof p_ips, "P/exefs/%s.ips", bid);
|
||||
snprintf(q_ips, sizeof q_ips, "Q/exefs/%s.ips", bid);
|
||||
snprintf(x_ips, sizeof x_ips, "X/exefs/%s.ips", bid);
|
||||
snprintf(y_ips, sizeof y_ips, "Y/exefs/%s.ips", bid);
|
||||
const char *files[] = {n_ips, p_ips, q_ips, x_ips, y_ips};
|
||||
for (size_t i = 0; i < sizeof files / sizeof *files; i++) put(root, files[i]);
|
||||
uint32_t kn = fnv(n_ips, strlen(n_ips)), kp = fnv(p_ips, strlen(p_ips)), kq = fnv(q_ips, strlen(q_ips));
|
||||
uint32_t kx = fnv(x_ips, strlen(x_ips)), ky = fnv(y_ips, strlen(y_ips));
|
||||
uint32_t c = fnv("probe", 5);
|
||||
char status[512], data[768];
|
||||
snprintf(status, sizeof status,
|
||||
"%08X\t%08X\tdata\t-\tdata 11\n%08X\t%08X\tdata\t-\tdata 1\n%08X\t%08X\tdata\t-\tdata 4\n"
|
||||
"%08X\t%08X\tdata\t-\tdata 1\n%08X\t%08X\tdata\t-\tdata 1\n",
|
||||
kn, c, kp, c, kq, c, kx, c, ky, c);
|
||||
snprintf(data, sizeof data,
|
||||
"# file hash, content hash, offset in main, bytes[, base]\n"
|
||||
"%08X\t%08X\t10\t414243\n" // N: three bytes of data
|
||||
"%08X\t%08X\t18\t0800000000000000\tbase\r\n" // N: a DT_RELR word, 8 plus main's base (a CRLF line)
|
||||
"DEADBEEF\t%08X\t20\t99\n" // another file's: not N's
|
||||
"%08X\t%08X\t4\t77\n" // P: below main's data
|
||||
"%08X\t%08X\t30\t66\n" // Q: 1 of the 4 bytes its status counts
|
||||
"%08X\t%08X\t28\tBB\n" // X: the byte Y writes too
|
||||
"%08X\t%08X\t28\tAA\n", // Y, above X in the order: its byte stays
|
||||
kn, c, kn, c, c, kp, c, kq, c, kx, c, ky, c);
|
||||
mods_probe_cache(status, data);
|
||||
cfg_set("TOTK_MODS_DIR", root);
|
||||
cfg_set("TOTK_MODS_ORDER", "Y"); // Y on top, the rest by name: Y N P Q X
|
||||
cfg_set("TOTK_MODS_DISABLED", "");
|
||||
mods_rescan();
|
||||
mods_scan();
|
||||
ModInfo *n = by_name("N"), *p = by_name("P"), *q = by_name("Q");
|
||||
CHECK(n && p && q && by_name("X") && by_name("Y"), "second scan: five mods");
|
||||
if (n && p && q) {
|
||||
CHECK(n->kind == MOD_EXEFS && n->problem == MOD_OK && n->ndata == 1 && !n->nsets,
|
||||
"an ExeFS patch file with data writes only makes a usable mod");
|
||||
uint8_t img[0x40];
|
||||
memset(img, 0, sizeof img);
|
||||
uint64_t base = (uint64_t)(uintptr_t)img, word;
|
||||
long stored = mods_exefs_data(n, base, sizeof img, 8, store_buffer);
|
||||
memcpy(&word, img + 0x18, 8);
|
||||
CHECK(stored == 11 && !memcmp(img + 0x10, "ABC", 3) && word == base + 8 && !img[0x20],
|
||||
"data writes: bytes as they are, a DT_RELR word rebased, other files' lines left");
|
||||
memset(img, 0x5A, sizeof img);
|
||||
int untouched = 1;
|
||||
CHECK(mods_exefs_data(p, base, sizeof img, 8, store_buffer) == -1, "a line below main's data keeps all out");
|
||||
CHECK(n->data_bytes == 11 && q->data_bytes == 4, "the status lines count the data bytes");
|
||||
CHECK(mods_exefs_data(q, base, sizeof img, 8, store_buffer) == -1,
|
||||
"a data file holding fewer bytes than the status counts keeps all out");
|
||||
for (size_t i = 0; i < sizeof img; i++) untouched &= img[i] == 0x5A;
|
||||
CHECK(untouched, "nothing stored when a line does not fit or bytes are missing");
|
||||
// every enabled mod as at start: X and Y write the same byte, and Y, the higher one, wins as its RomFS files
|
||||
// would; P and Q stay out as above
|
||||
memset(img, 0, sizeof img);
|
||||
CHECK(EQ(mods_at(0)->name, "Y") && EQ(mods_at(4)->name, "X"), "mods_order puts Y on top and X at the bottom");
|
||||
mods_exefs_apply(base, sizeof img, 8, store_buffer);
|
||||
CHECK(img[0x28] == 0xAA, "two mods write the same byte: the top mod's byte stays");
|
||||
CHECK(!memcmp(img + 0x10, "ABC", 3) && !img[0x30], "the other mods' data as each one stores it alone");
|
||||
}
|
||||
mods_probe_cache(NULL, NULL);
|
||||
remove_tree(root);
|
||||
}
|
||||
|
||||
// s ends in tail, either path separator matching either.
|
||||
static int ends_with(const char *s, const char *tail) {
|
||||
size_t n = strlen(s), t = strlen(tail);
|
||||
@@ -124,7 +213,8 @@ int probe_mods(void) {
|
||||
CHECK(EQ(cfg_get("TOTK_MODS_DISABLED"), "A"), "switching A off writes it");
|
||||
CHECK(mods_changed(), "changes seen");
|
||||
}
|
||||
remove_tree(root);
|
||||
probe_data(root);
|
||||
printf("mods: %s\n", fails ? "FAIL" : "ok");
|
||||
return fails ? 1 : 0;
|
||||
int table = restbl_probe();
|
||||
return fails || table ? 1 : 0;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+9
-1
@@ -29,10 +29,18 @@ typedef struct {
|
||||
enum { MOVIE_EV_INPUT, MOVIE_EV_OUTPUT, MOVIE_EV_FORMAT, MOVIE_EV_ERROR }; // DecoderEvents: EventType * per event
|
||||
enum { MOVIE_TYPE_VP9 = 3, MOVIE_MODE_BUFFER = 1, MOVIE_FORMAT_NV12 = 4, MOVIE_FLAG_EOS = 4 };
|
||||
|
||||
typedef struct { uint64_t frames, decode_ns, max_ns, errors, dropped, handed; } MovieStats;
|
||||
typedef struct { uint64_t frames, decode_ns, max_ns, errors, dropped, handed, fit_ns; } MovieStats;
|
||||
// fit_ns: the part of decode_ns spent writing the NV12 frame
|
||||
|
||||
void movie_set_guest(const MovieGuest *g); // probe: stand-ins instead of the modules
|
||||
void movie_set_clock(uint64_t (*clock)(void)); // probe: a fake clock (ns) for the pacing; NULL = real
|
||||
void movie_set_pace(int on); // probe: 0 hands every decoded frame over at the next tick
|
||||
int movie_stats(uint64_t obj, MovieStats *out); // counters of the decoder at guest object obj; 0 if none
|
||||
int movie_quiet(uint64_t obj); // 1 when its worker is idle and cannot take more input now
|
||||
|
||||
// Keeping a movie's shape on an output of another shape (ov_movie.c): the picture is shrunk inside the frame, with
|
||||
// black (Y 16, U = V 128) around it. movie_fit_new returns NULL when the shapes agree within 1%: copy the frame.
|
||||
typedef struct MovieFit MovieFit;
|
||||
MovieFit *movie_fit_new(int w, int h, unsigned out_w, unsigned out_h);
|
||||
void movie_fit_apply(const MovieFit *f, const uint8_t *const plane[3], const int stride[3], uint8_t *dst);
|
||||
void movie_fit_free(MovieFit *f);
|
||||
@@ -9,6 +9,7 @@
|
||||
// Without the RomFS (no game data) it prints why and exits 0. TOTK_PROBE_MOVIE_SHOT=<file.ppm> saves the middle frame.
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
@@ -267,7 +268,104 @@ static int paced(uint64_t obj, const uint8_t *data, const Track *t, int limit, u
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The shape-keeping resampler (ov_movie.c movie_fit_new / movie_fit_apply) on a synthetic 1920x1080 I420 picture, which
|
||||
// needs no game data. The planes are gradients, which a box filter keeps linear, so every output sample has an expected
|
||||
// value: luma rises left to right and top to bottom, U rises left to right, V falls top to bottom. A 16:9 output must give
|
||||
// no plan; the others must give bars (Y 16, U = V 128) that are symmetric, a picture of the movie's shape once the game
|
||||
// stretches the frame, and samples of the picture where the gradients say. TOTK_PROBE_MOVIE_BENCH=1 also times the frame.
|
||||
#define FIT_CHECK(cond, ...) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
printf("probe-movie: FAILED: "); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
rc = 1; \
|
||||
goto done; \
|
||||
} \
|
||||
} while (0)
|
||||
static int check_fit(void) {
|
||||
enum { W = 1920, H = 1080, CW = W / 2, CH = H / 2 };
|
||||
int rc = 0;
|
||||
MovieFit *f = NULL;
|
||||
uint8_t *y = malloc((size_t)W * H), *u = malloc((size_t)CW * CH), *v = malloc((size_t)CW * CH);
|
||||
uint8_t *out = malloc((size_t)W * H * 3 / 2);
|
||||
FIT_CHECK(y && u && v && out, "fit test buffers");
|
||||
for (int j = 0; j < H; j++)
|
||||
for (int i = 0; i < W; i++) y[j * W + i] = (uint8_t)(32 + 150.0 * (i + 0.5) / W + 60.0 * (j + 0.5) / H + 0.5);
|
||||
for (int j = 0; j < CH; j++)
|
||||
for (int i = 0; i < CW; i++) {
|
||||
u[j * CW + i] = (uint8_t)(40 + 150.0 * (i + 0.5) / CW + 0.5);
|
||||
v[j * CW + i] = (uint8_t)(220 - 150.0 * (j + 0.5) / CH + 0.5);
|
||||
}
|
||||
const uint8_t *plane[3] = {y, u, v};
|
||||
const int stride[3] = {W, CW, CW};
|
||||
FIT_CHECK(!movie_fit_new(W, H, 1920, 1080) && !movie_fit_new(W, H, 2560, 1440), "16:9 outputs need no plan");
|
||||
static const unsigned outs[][2] = {{5120, 1440}, {3440, 1440}, {1280, 800}, {1920, 1200}};
|
||||
for (int k = 0; k < 4; k++) {
|
||||
unsigned ow = outs[k][0], oh = outs[k][1];
|
||||
f = movie_fit_new(W, H, ow, oh);
|
||||
FIT_CHECK(f, "%ux%u: no plan", ow, oh);
|
||||
memset(out, 0xAA, (size_t)W * H * 3 / 2); // a byte nothing else produces: every pixel must be written
|
||||
movie_fit_apply(f, plane, stride, out);
|
||||
const uint8_t *uv = out + (size_t)W * H;
|
||||
int l = 0, r = W - 1, t = 0, b = H - 1; // the picture's extent: where luma leaves 16, on the middle row and column
|
||||
while (l < W && out[(H / 2) * W + l] == 16) l++;
|
||||
while (r >= 0 && out[(H / 2) * W + r] == 16) r--;
|
||||
while (t < H && out[t * W + W / 2] == 16) t++;
|
||||
while (b >= 0 && out[b * W + W / 2] == 16) b--;
|
||||
int pw = r - l + 1, ph = b - t + 1;
|
||||
FIT_CHECK(pw > 2 && ph > 2, "%ux%u: no picture found", ow, oh);
|
||||
FIT_CHECK(abs(l - (W - 1 - r)) <= 2 && abs(t - (H - 1 - b)) <= 2,
|
||||
"%ux%u: bars not symmetric: left %d right %d, top %d bottom %d", ow, oh, l, W - 1 - r, t, H - 1 - b);
|
||||
// On screen the picture covers pw/W of the width and ph/H of the height of an ow x oh canvas; 1 = the movie's shape.
|
||||
double shape = ((double)pw / W * ow) / ((double)ph / H * oh) / ((double)W / H);
|
||||
FIT_CHECK(fabs(shape - 1) < 0.01, "%ux%u: picture %dx%d shows at %.3f of the movie's shape", ow, oh, pw, ph, shape);
|
||||
FIT_CHECK(l == 0 || ph == H, "%ux%u: sideways bars with a squeezed height", ow, oh);
|
||||
for (int j = 0; j < H; j++) // everything outside the picture is black in luma and chroma, left and right alike
|
||||
for (int i = 0; i < W; i++)
|
||||
if (j < t || j > b || i < l || i > r)
|
||||
FIT_CHECK(out[j * W + i] == 16, "%ux%u: luma (%d,%d) is %d, not black", ow, oh, i, j, out[j * W + i]);
|
||||
for (int j = 0; j < CH; j++)
|
||||
for (int i = 0; i < CW; i++) {
|
||||
int outside = 2 * j + 1 < t || 2 * j > b || 2 * i + 1 < l || 2 * i > r;
|
||||
const uint8_t *c = uv + (size_t)j * W + 2 * i;
|
||||
if (outside) FIT_CHECK(c[0] == 128 && c[1] == 128, "%ux%u: chroma (%d,%d) is %d/%d, not black", ow, oh, i, j,
|
||||
c[0], c[1]);
|
||||
}
|
||||
static const double fr[] = {0.1, 0.35, 0.5, 0.8, 0.95}; // samples of the picture, as fractions of it
|
||||
for (int a = 0; a < 5; a++)
|
||||
for (int c = 0; c < 5; c++) {
|
||||
int px = (int)(pw * fr[a]), py = (int)(ph * fr[c]); // picture pixel
|
||||
double sx = (px + 0.5) * W / pw, sy = (py + 0.5) * H / ph; // where it comes from in the source
|
||||
double ey = 32 + 150.0 * sx / W + 60.0 * sy / H;
|
||||
int got = out[(t + py) * W + l + px];
|
||||
FIT_CHECK(fabs(got - ey) <= 2, "%ux%u: luma at picture (%d,%d) is %d, want %.1f", ow, oh, px, py, got, ey);
|
||||
int cx = px / 2, cy = py / 2, cpw = pw / 2, cph = ph / 2;
|
||||
double sux = (cx + 0.5) * CW / cpw, svy = (cy + 0.5) * CH / cph;
|
||||
const uint8_t *q = uv + (size_t)(t / 2 + cy) * W + 2 * (l / 2 + cx);
|
||||
double eu = 40 + 150.0 * sux / CW, ev = 220 - 150.0 * svy / CH;
|
||||
FIT_CHECK(fabs(q[0] - eu) <= 3 && fabs(q[1] - ev) <= 3,
|
||||
"%ux%u: chroma at picture (%d,%d) is U %d V %d, want U %.1f V %.1f", ow, oh, cx, cy, q[0], q[1], eu,
|
||||
ev);
|
||||
}
|
||||
if (cfg_get("TOTK_PROBE_MOVIE_BENCH")) {
|
||||
uint64_t t0 = plat_now_ns();
|
||||
for (int i = 0; i < 100; i++) movie_fit_apply(f, plane, stride, out);
|
||||
printf("probe-movie: fit %ux%u: %.2f ms per frame\n", ow, oh, (plat_now_ns() - t0) / 1e8);
|
||||
}
|
||||
printf("probe-movie: fit %ux%u: picture %dx%d at (%d,%d) of the frame, bars Y 16 / UV 128, gradients as expected\n",
|
||||
ow, oh, pw, ph, l, t);
|
||||
movie_fit_free(f);
|
||||
f = NULL;
|
||||
}
|
||||
done:
|
||||
movie_fit_free(f);
|
||||
free(y), free(u), free(v), free(out);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int probe_movie(void) {
|
||||
if (check_fit()) return 1;
|
||||
const char *dir = rt_config()->romfs;
|
||||
char image[PLAT_PATH_MAX];
|
||||
snprintf(image, sizeof image, "%s/romfs.bin", dir);
|
||||
|
||||
+10
-1
@@ -75,7 +75,8 @@ uint64_t nvn_tex_view_offset(const tex_t *t, const view_t *v);
|
||||
// A command handle (what QueueSubmitCommands receives) points at a host copy of one recording: a flat sequence of
|
||||
// 8-byte-aligned records {op_hdr, payload}, OP_BEGIN first and OP_END last. Called recordings are inlined, so a copy
|
||||
// holds no OP_CALL or OP_JUMP. Texture pointers in payloads (p_set_rt, p_copy_b2t, p_copy_t2t, p_clear_tex) are
|
||||
// immutable host copies that live for the run, one per tex_t.serial. Deferred binds (p_bind_handles.src != 0) hold the
|
||||
// immutable host copies ("pins"), one per tex_t.serial, that live while a recording copy names them and a while after
|
||||
// (nvn_pins_sweep). Deferred binds (p_bind_handles.src != 0) hold the
|
||||
// handles read at submit (nvn_submit_prepare). Payloads of the ops nvn_obj.h does not define:
|
||||
typedef struct { volatile uint32_t *val; uint32_t mode, value; } p_signal_event; // OP_SIGNAL_EVENT; mode: NVNeventSignalMode
|
||||
typedef struct { uint64_t type, addr; } p_report_counter; // OP_REPORT_COUNTER: NVNcounterType, NVNcounterData address
|
||||
@@ -99,6 +100,14 @@ int nvn_handle_live(uint64_t handle); // 1 while the handle
|
||||
// not added again)
|
||||
uint64_t nvn_handle_bytes(uint64_t handle); // bytes of the copy, OP_BEGIN to OP_END; 0 if not live
|
||||
uint64_t nvn_submit_count(void); // QueueSubmitCommands calls so far
|
||||
// Submits a texture pin outlives its last tex_pin and the release of the last recording copy naming it: about 20 s at
|
||||
// one submit a frame (MacNativeGames frees after two 600-submit windows).
|
||||
#define NVN_PIN_AGE 1200
|
||||
// Frees the texture pins no recording copy has named for NVN_PIN_AGE submits (nvn_api_cmd.c, "texture pins"), except keep[0..nkeep):
|
||||
// the replay's current render targets, which carry over to the next submission. Called between submissions by the
|
||||
// thread that replays them; now = nvn_submit_count().
|
||||
void nvn_pins_sweep(uint64_t now, const tex_t *const *keep, int nkeep);
|
||||
void nvn_pins_stats(uint64_t *live, uint64_t *freed); // pins that exist, pins freed so far
|
||||
const char *nvn_op_name(uint32_t op); // "DRAW_IDX", ...; "?" past OP_COUNT_
|
||||
// MemoryPoolInvalidateMappedRange: the CPU is about to read pool storage the GPU wrote. Weak no-op in nvn_ext.c; a back
|
||||
// end that renders into host-side copies defines it and writes them back.
|
||||
|
||||
+305
-27
@@ -4,7 +4,7 @@
|
||||
// games/totk/third_party/d2r_gfx/gfx/nvn/hle_nvn.c @ b75be8cf (MIT, Copyright (c) 2026 jhaizhou; origin d2r-mobile),
|
||||
// lines 647-1062, 1261-1286, 1486-1760 and 1975-1990, and the *Deferred and memory-callback entry points of
|
||||
// totk_nvn_ext.c in the same directory. Metal, mach, the translation thread, Apple diagnostics, the per-frame
|
||||
// statistics and the long-session sweeps (copy marks in control memory, texture pin sweep) dropped.
|
||||
// statistics and the copy marks in control memory dropped; the texture pin sweep replaced by counting (texture pins).
|
||||
//
|
||||
// Records are written into a growable host buffer per command buffer, never into the game's command memory: games hand
|
||||
// the same command memory to several command buffers recording at once (budgeted for real NVN encodings), and our
|
||||
@@ -17,6 +17,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cfg.h"
|
||||
#include "gfx.h"
|
||||
#include "host.h"
|
||||
#include "nvn_api.h"
|
||||
@@ -140,10 +141,13 @@ static void pool_drain(void) {
|
||||
// system-managed page file, so the same request succeeds a moment later, and a stall of some frames is better than
|
||||
// ending the game. Frees the pooled buffers first. NULL only after about 30 s without memory; the caller ends the game.
|
||||
// Never called with a lock held.
|
||||
static void big_drain(void);
|
||||
|
||||
static void *realloc_wait(void *old, size_t n, const char *what) {
|
||||
void *p = realloc(old, n);
|
||||
if (p) return p;
|
||||
pool_drain();
|
||||
big_drain();
|
||||
static uint32_t reports;
|
||||
int report = __atomic_fetch_add(&reports, 1, __ATOMIC_RELAXED) < 8;
|
||||
if (report) {
|
||||
@@ -235,30 +239,169 @@ static void copy_view(view_t *dst, uint8_t *has, const NVNtextureView *v) {
|
||||
// ---------- texture pins
|
||||
// Recordings refer to textures by pointer, but the game may finalize or reuse an NVNtexture after recording and before
|
||||
// the submit (NVN itself captured only GPU state). Records therefore point at an immutable host copy per texture
|
||||
// initialization (tex_t.serial). The copies are kept for the whole run (MacNativeGames sweeps them for long sessions).
|
||||
static PlatMutex pin_lock = PLAT_MUTEX_INIT;
|
||||
static nvn_hmap pins = {.klen = 8}; // serial -> tex_t copy
|
||||
// initialization (tex_t.serial). The game initializes its transient targets again every frame, about 29 new serials a
|
||||
// frame on 1.4.3, so copies kept for the run grew the heap by about 44 MiB per minute of play, standing still too, until
|
||||
// the commit limit ended the game after a few hours. A copy is freed once nothing can read it any more:
|
||||
// - refs counts the recording copies whose ops name it: live ones, released ones a queued submission still reads, and
|
||||
// per-submission clones (pin_refs, at flatten_recording and wherever such a copy is freed or cloned);
|
||||
// - a recording in progress holds pins no copy counts yet, through its own tex_pin calls and through ops inlined from a
|
||||
// copy that was released since: a pin goes only NVN_PIN_AGE submits after its last tex_pin and after refs fell to 0;
|
||||
// - the replay's current render targets carry over to later submissions and are compared by pointer (vk_exec.c set_rt),
|
||||
// so a freed address reused by a new pin would read as the same target: nvn_pins_sweep runs between submissions on
|
||||
// the thread that replays them and keeps the pins it is given.
|
||||
// MacNativeGames hit the same growth (3.16 million copies, 405 MB after 27 minutes) and marks pins by walking every live
|
||||
// recording each window (hle_nvn.c pin_scan); counting at copy and release walks each copy's ops once more, when it is
|
||||
// freed. TOTK_PIN_SWEEP=0 keeps every copy.
|
||||
typedef struct {
|
||||
tex_t t; // first: ops hold pins as tex_t pointers
|
||||
uint64_t last_use; // nsubmits at the last tex_pin or the last drop of refs
|
||||
uint32_t refs;
|
||||
uint8_t queued; // in pin_q
|
||||
} pin_t;
|
||||
_Static_assert(offsetof(pin_t, t) == 0, "ops hold pins as tex_t pointers");
|
||||
typedef struct { pin_t *p; uint64_t at; } pin_due;
|
||||
static PlatMutex pin_lock = PLAT_MUTEX_INIT; // lock order: copy_lock, then pin_lock
|
||||
static nvn_hmap pins = {.klen = 8}; // serial -> pin_t
|
||||
static pin_due *pin_q; // pins with no counted copy, oldest first: [pin_qh, pin_qn)
|
||||
static size_t pin_qh, pin_qn, pin_qcap;
|
||||
static uint64_t pins_live, pins_freed;
|
||||
static uint64_t nsubmits; // QueueSubmitCommands calls so far
|
||||
|
||||
static void pin_queue(pin_t *p, uint64_t at) { // pin_lock held
|
||||
if (p->queued) return;
|
||||
if (pin_qh && pin_qh * 2 >= pin_qn) {
|
||||
memmove(pin_q, pin_q + pin_qh, sizeof *pin_q * (pin_qn - pin_qh));
|
||||
pin_qn -= pin_qh, pin_qh = 0;
|
||||
}
|
||||
if (pin_qn == pin_qcap) {
|
||||
pin_qcap = pin_qcap ? pin_qcap * 2 : 4096;
|
||||
if (!(pin_q = realloc(pin_q, sizeof *pin_q * pin_qcap))) rt_fatal("nvn: out of memory (texture pin queue)");
|
||||
}
|
||||
pin_q[pin_qn++] = (pin_due){p, at};
|
||||
p->queued = 1;
|
||||
}
|
||||
|
||||
static const tex_t *tex_pin(const NVNtexture *nt) {
|
||||
const tex_t *t = (const tex_t *)nt;
|
||||
if (!t) return NULL;
|
||||
uint64_t serial = t->serial;
|
||||
if (!serial) rt_fatal("nvn: a recording references the uninitialized texture object %p", (const void *)t);
|
||||
plat_mutex_lock_shared(&pin_lock);
|
||||
const tex_t *r = nvn_hm_get(&pins, &serial);
|
||||
uint64_t now = __atomic_load_n(&nsubmits, __ATOMIC_RELAXED);
|
||||
plat_mutex_lock_shared(&pin_lock); // nvn_pins_sweep frees under the exclusive lock
|
||||
pin_t *r = nvn_hm_get(&pins, &serial);
|
||||
if (r) __atomic_store_n(&r->last_use, now, __ATOMIC_RELAXED);
|
||||
plat_mutex_unlock_shared(&pin_lock);
|
||||
if (r) return r;
|
||||
if (r) return &r->t;
|
||||
plat_mutex_lock(&pin_lock);
|
||||
r = nvn_hm_get(&pins, &serial);
|
||||
if (!r) {
|
||||
tex_t *copy = malloc(sizeof *copy);
|
||||
if (!copy) rt_fatal("nvn: texture pin copy");
|
||||
*copy = *t;
|
||||
nvn_hm_put(&pins, &serial, copy);
|
||||
r = copy;
|
||||
if (!(r = malloc(sizeof *r))) rt_fatal("nvn: texture pin copy");
|
||||
*r = (pin_t){.t = *t};
|
||||
nvn_hm_put(&pins, &serial, r);
|
||||
pins_live++;
|
||||
pin_queue(r, now); // freed like any other if no copy ever names it
|
||||
}
|
||||
__atomic_store_n(&r->last_use, now, __ATOMIC_RELAXED);
|
||||
plat_mutex_unlock(&pin_lock);
|
||||
return &r->t;
|
||||
}
|
||||
|
||||
// The pins an op's payload names, into out (at most 9); returns how many.
|
||||
static int op_pins(uint32_t op, const void *a, const tex_t *out[9]) {
|
||||
int k = 0;
|
||||
switch (op) {
|
||||
case OP_SET_RT: {
|
||||
const p_set_rt *r = a;
|
||||
int n = r->n < 0 ? 0 : r->n > 8 ? 8 : r->n;
|
||||
for (int i = 0; i < n; i++)
|
||||
if (r->color[i]) out[k++] = r->color[i];
|
||||
if (r->depth) out[k++] = r->depth;
|
||||
break;
|
||||
}
|
||||
case OP_COPY_B2T: case OP_COPY_T2B:
|
||||
if (((const p_copy_b2t *)a)->tex) out[k++] = ((const p_copy_b2t *)a)->tex;
|
||||
break;
|
||||
case OP_CLEAR_TEX:
|
||||
if (((const p_clear_tex *)a)->tex) out[k++] = ((const p_clear_tex *)a)->tex;
|
||||
break;
|
||||
case OP_COPY_T2T: {
|
||||
const p_copy_t2t *c = a;
|
||||
if (c->src) out[k++] = c->src;
|
||||
if (c->dst) out[k++] = c->dst;
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
return k;
|
||||
}
|
||||
|
||||
static void pin_ref(const tex_t *t, int delta, uint64_t now) {
|
||||
pin_t *p = (pin_t *)(uintptr_t)t;
|
||||
if (delta > 0) {
|
||||
__atomic_add_fetch(&p->refs, 1, __ATOMIC_RELAXED);
|
||||
return;
|
||||
}
|
||||
__atomic_store_n(&p->last_use, now, __ATOMIC_RELAXED);
|
||||
if (__atomic_sub_fetch(&p->refs, 1, __ATOMIC_ACQ_REL)) return;
|
||||
plat_mutex_lock(&pin_lock);
|
||||
pin_queue(p, now);
|
||||
plat_mutex_unlock(&pin_lock);
|
||||
}
|
||||
|
||||
// Counts (delta 1) or uncounts (-1) the pins named by the copy at handle, OP_BEGIN to OP_END; returns how many.
|
||||
static uint32_t pin_refs(const uint8_t *copy, int delta) {
|
||||
uint64_t now = __atomic_load_n(&nsubmits, __ATOMIC_RELAXED);
|
||||
uint32_t n = 0;
|
||||
for (const op_hdr *h = nvn_op_first((uintptr_t)copy); h->op != OP_END; h = nvn_op_next(h)) {
|
||||
const tex_t *p[9];
|
||||
int k = op_pins(h->op, nvn_op_payload(h), p);
|
||||
for (int i = 0; i < k; i++) pin_ref(p[i], delta, now);
|
||||
n += (uint32_t)k;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
void nvn_pins_sweep(uint64_t now, const tex_t *const *keep, int nkeep) {
|
||||
if (now < NVN_PIN_AGE) return;
|
||||
static int on = -1;
|
||||
if (on < 0) {
|
||||
const char *s = cfg_get("TOTK_PIN_SWEEP");
|
||||
on = !(s && !strcmp(s, "0"));
|
||||
if (!on) rt_log("nvn: TOTK_PIN_SWEEP=0: texture pin copies are kept for the run (the heap grows in long sessions)");
|
||||
}
|
||||
if (!on) return;
|
||||
static uint64_t logged;
|
||||
plat_mutex_lock(&pin_lock);
|
||||
for (int budget = 1024; pin_qh < pin_qn && budget; budget--) {
|
||||
pin_due d = pin_q[pin_qh];
|
||||
if (d.at > now - NVN_PIN_AGE) break;
|
||||
pin_qh++;
|
||||
pin_t *p = d.p;
|
||||
p->queued = 0;
|
||||
if (__atomic_load_n(&p->refs, __ATOMIC_ACQUIRE)) continue; // queued again when the count falls to 0
|
||||
uint64_t last = __atomic_load_n(&p->last_use, __ATOMIC_RELAXED);
|
||||
int kept = 0;
|
||||
for (int i = 0; i < nkeep; i++) kept |= keep[i] == &p->t;
|
||||
if (kept || last > now - NVN_PIN_AGE) {
|
||||
pin_queue(p, kept ? now : last);
|
||||
continue;
|
||||
}
|
||||
nvn_hm_del(&pins, &p->t.serial);
|
||||
free(p);
|
||||
pins_live--, pins_freed++;
|
||||
}
|
||||
if (now / 7200 != logged) {
|
||||
logged = now / 7200;
|
||||
rt_log("nvn: texture pins at submit %llu: %llu live, %llu waiting, %llu freed so far", (unsigned long long)now,
|
||||
(unsigned long long)pins_live, (unsigned long long)(pin_qn - pin_qh), (unsigned long long)pins_freed);
|
||||
}
|
||||
plat_mutex_unlock(&pin_lock);
|
||||
return r;
|
||||
}
|
||||
|
||||
void nvn_pins_stats(uint64_t *live, uint64_t *freed) {
|
||||
plat_mutex_lock_shared(&pin_lock);
|
||||
*live = pins_live, *freed = pins_freed;
|
||||
plat_mutex_unlock_shared(&pin_lock);
|
||||
}
|
||||
|
||||
// ---------- recording copies
|
||||
@@ -266,14 +409,17 @@ static const tex_t *tex_pin(const NVNtexture *nt) {
|
||||
// adding control memory again releases the copies recorded there without walking every live copy. 4 KiB pages cost
|
||||
// about 500 lookups per AddControlMemory under the exclusive lock (1.4.3 field: 53 calls a frame, 2 MiB ranges, 3.7
|
||||
// copies per page), and the recording threads waited on it.
|
||||
typedef struct { uint64_t magic, bytes, made_at, used_at, deferred; } copy_prefix; // deferred: 0 unknown, 1 none, 2 some
|
||||
// deferred: 0 unknown, 1 none, 2 some; pins: texture pins its ops name, counted in their refs; pin_v: those pins, in
|
||||
// record order, or NULL (then pin_refs walks the records); cap: bytes of the block from the large-block cache
|
||||
// (copy_block), 0 for a plain heap block
|
||||
typedef struct { uint64_t magic, bytes, made_at, used_at, deferred, pins, cap; const tex_t **pin_v; } copy_prefix;
|
||||
_Static_assert(sizeof(copy_prefix) % 8 == 0, "records stay 8-byte aligned");
|
||||
typedef struct { uintptr_t lo, hi; uint8_t *copy; } cmd_copy;
|
||||
typedef struct { cmd_copy *v; uint32_t n, cap; } copy_bucket;
|
||||
static PlatMutex copy_lock = PLAT_MUTEX_INIT; // guards the two maps, the counters and every prefix
|
||||
static nvn_hmap copy_pages = {.klen = 8}; // page -> copy_bucket
|
||||
static nvn_hmap copy_live = {.klen = 8}; // handle -> copy_prefix of a live copy
|
||||
static uint64_t ncopies, copy_bytes, copies_made, nsubmits;
|
||||
static uint64_t ncopies, copy_bytes, copies_made;
|
||||
// Asynchronous replay: copies released by the game, and per-submission clones, that a queued submission still reads.
|
||||
// Freed once gfx_replayed_submits() reaches their used_at. Empty with synchronous replay.
|
||||
static copy_prefix **zombies;
|
||||
@@ -288,10 +434,117 @@ static void zombie_add(copy_prefix *pf) { // copy_lock held
|
||||
zombies[nzombies++] = pf;
|
||||
}
|
||||
|
||||
static void zombie_sweep(uint64_t replayed) { // copy_lock held
|
||||
// Blocks of large copies are kept for the next ones. 1.4.3 makes two copies of 0.5-2 MiB a frame (the per-frame
|
||||
// recordings that inline many others); above the heap's direct-allocation threshold (about 512 KiB) every malloc mapped
|
||||
// fresh zero-filled pages and every free unmapped them, so the copy ran at half the speed of a small one and the free
|
||||
// held copy_lock for tens of microseconds. A block is reused for a copy that fills at least half of it.
|
||||
#define BIG_MIN ((size_t)256 << 10) // smaller blocks come from the heap and go back to it
|
||||
#define BIG_MAX ((size_t)16 << 20) // a larger block is never kept
|
||||
#define BIG_SLOTS 8
|
||||
static PlatMutex big_lock = PLAT_MUTEX_INIT; // lock order: copy_lock, then big_lock
|
||||
static struct { copy_prefix *p; size_t cap; } big[BIG_SLOTS];
|
||||
static uint32_t nbig;
|
||||
|
||||
static void big_drain(void) {
|
||||
plat_mutex_lock(&big_lock);
|
||||
while (nbig) free(big[--nbig].p);
|
||||
plat_mutex_unlock(&big_lock);
|
||||
}
|
||||
|
||||
// A block for a copy of n bytes after its prefix; prefix.cap says where it goes back (copy_block_free). wait: a failed
|
||||
// allocation waits for memory (realloc_wait), which is never done with a lock held; else it ends the game at once.
|
||||
static copy_prefix *copy_block(size_t n, int wait, const char *what) {
|
||||
size_t total = sizeof(copy_prefix) + n, cap = 0;
|
||||
copy_prefix *pf = NULL;
|
||||
if (total >= BIG_MIN && total <= BIG_MAX) {
|
||||
plat_mutex_lock(&big_lock);
|
||||
uint32_t best = BIG_SLOTS;
|
||||
for (uint32_t i = 0; i < nbig; i++)
|
||||
if (big[i].cap >= total && big[i].cap / 2 <= total && (best == BIG_SLOTS || big[i].cap < big[best].cap))
|
||||
best = i;
|
||||
if (best < BIG_SLOTS) {
|
||||
pf = big[best].p;
|
||||
cap = big[best].cap;
|
||||
big[best] = big[--nbig];
|
||||
}
|
||||
plat_mutex_unlock(&big_lock);
|
||||
if (!pf) cap = (total + total / 8 + 0xffff) & ~(size_t)0xffff; // room for the next frame's slightly larger one
|
||||
}
|
||||
if (!pf) pf = wait ? realloc_wait(NULL, cap ? cap : total, what) : malloc(cap ? cap : total);
|
||||
if (!pf) rt_fatal("nvn: %s allocation (%zu bytes)", what, cap ? cap : total);
|
||||
pf->cap = cap;
|
||||
return pf;
|
||||
}
|
||||
|
||||
static void copy_block_free(copy_prefix *pf) {
|
||||
size_t cap = pf->cap;
|
||||
if (cap) {
|
||||
plat_mutex_lock(&big_lock);
|
||||
if (nbig < BIG_SLOTS) {
|
||||
big[nbig].p = pf;
|
||||
big[nbig++].cap = cap;
|
||||
pf = NULL;
|
||||
} else {
|
||||
uint32_t small = 0;
|
||||
for (uint32_t i = 1; i < nbig; i++)
|
||||
if (big[i].cap < big[small].cap) small = i;
|
||||
if (big[small].cap < cap) {
|
||||
copy_prefix *drop = big[small].p;
|
||||
big[small].p = pf;
|
||||
big[small].cap = cap;
|
||||
pf = drop;
|
||||
}
|
||||
}
|
||||
plat_mutex_unlock(&big_lock);
|
||||
}
|
||||
free(pf);
|
||||
}
|
||||
|
||||
// Counts (delta 1) or uncounts (-1) the pins a copy names: from its list, without walking its records. A copy of a
|
||||
// per-frame recording is 0.5-2 MiB of records naming a few pins; walking them again at every free cost about 40 us.
|
||||
static void copy_pins_ref(const copy_prefix *pf, int delta) {
|
||||
if (!pf->pins) return;
|
||||
if (!pf->pin_v) {
|
||||
pin_refs((const uint8_t *)(pf + 1), delta);
|
||||
return;
|
||||
}
|
||||
uint64_t now = __atomic_load_n(&nsubmits, __ATOMIC_RELAXED);
|
||||
for (uint64_t i = 0; i < pf->pins; i++) pin_ref(pf->pin_v[i], delta, now);
|
||||
}
|
||||
|
||||
// A copy in no map and no zombie list: nothing else can reach it, so copy_lock need not be held.
|
||||
static void copy_free(copy_prefix *pf) {
|
||||
copy_pins_ref(pf, -1);
|
||||
free((void *)pf->pin_v);
|
||||
copy_block_free(pf);
|
||||
}
|
||||
|
||||
// Copies retired under copy_lock and freed after it is released: the frees, the unpinning walk over their records and
|
||||
// the large blocks kept the lock exclusive for most of release_copies, while the game's other recording threads waited
|
||||
// for it in copy_touch (1.4.3 town: three threads, 0.6 ms a frame of waiting).
|
||||
typedef struct { copy_prefix **v; uint32_t n, cap; } dead_list;
|
||||
static __thread dead_list dead;
|
||||
|
||||
static void dead_add(copy_prefix *pf) {
|
||||
if (dead.n == dead.cap) {
|
||||
uint32_t cap = dead.cap ? dead.cap * 2 : 256;
|
||||
copy_prefix **v = realloc(dead.v, sizeof *v * cap);
|
||||
if (!v) { copy_free(pf); return; } // out of memory: free it here, under the lock, as before
|
||||
dead.v = v;
|
||||
dead.cap = cap;
|
||||
}
|
||||
dead.v[dead.n++] = pf;
|
||||
}
|
||||
|
||||
static void dead_free(void) { // copy_lock not held
|
||||
for (uint32_t i = 0; i < dead.n; i++) copy_free(dead.v[i]);
|
||||
dead.n = 0;
|
||||
}
|
||||
|
||||
static void zombie_sweep(uint64_t replayed) { // copy_lock held; the swept copies go to the dead list
|
||||
for (uint32_t i = 0; i < nzombies;) {
|
||||
if (zombies[i]->used_at <= replayed) {
|
||||
free(zombies[i]);
|
||||
dead_add(zombies[i]);
|
||||
zombies[i] = zombies[--nzombies];
|
||||
} else {
|
||||
i++;
|
||||
@@ -331,25 +584,44 @@ static uint64_t copy_touch(uint64_t handle, uint64_t now, const char *what) {
|
||||
}
|
||||
|
||||
// The recording [p, p + n), OP_BEGIN to OP_END (the recording buffer is one block: recording emits no OP_JUMP), copied
|
||||
// into one host block of its exact size. One allocation and one copy: growing the copy record by record cost the
|
||||
// game's main thread about 1 ms a frame in reallocations and small copies (1.4.3, field).
|
||||
// into one host block (copy_block: its exact size, or a kept large block). One allocation and one copy: growing the
|
||||
// copy record by record cost the game's main thread about 1 ms a frame in reallocations and small copies (1.4.3,
|
||||
// field). The pins the records name are counted and listed (copy_prefix.pin_v) on the way.
|
||||
static uint8_t *flatten_recording(const uint8_t *p, size_t n) {
|
||||
uint8_t *out = realloc_wait(NULL, sizeof(copy_prefix) + n, "command copy");
|
||||
if (!out) rt_fatal("nvn: command copy allocation");
|
||||
copy_prefix *block = copy_block(n, 1, "command copy");
|
||||
uint8_t *out = (uint8_t *)block;
|
||||
memcpy(out + sizeof(copy_prefix), p, n);
|
||||
static __thread struct { const tex_t **v; uint64_t cap; } named; // the pins of this recording, reused
|
||||
uint64_t pins_named = 0;
|
||||
int listed = 1; // 0 after a failed growth: the copy goes without a list
|
||||
for (size_t at = 0;;) { // the records stay checked: a corrupt one stops here, not in the replay
|
||||
op_hdr h;
|
||||
memcpy(&h, p + at, sizeof h);
|
||||
if (h.size < 8 || (h.size & 7) || h.op >= OP_COUNT_ || h.op == OP_JUMP || h.op == OP_CALL || at + h.size > n ||
|
||||
(!at && h.op != OP_BEGIN))
|
||||
rt_fatal("nvn: corrupt recording at %p (op %u size %u)", (const void *)(p + at), h.op, h.size);
|
||||
const tex_t *pinned[9];
|
||||
int k = op_pins(h.op, out + sizeof(copy_prefix) + at + 8, pinned);
|
||||
for (int i = 0; i < k; i++) pin_ref(pinned[i], 1, 0);
|
||||
if (k && listed && pins_named + (uint64_t)k > named.cap) {
|
||||
uint64_t cap = named.cap ? named.cap * 2 : 64;
|
||||
while (cap < pins_named + (uint64_t)k) cap *= 2;
|
||||
const tex_t **v = realloc((void *)named.v, sizeof *v * cap);
|
||||
if (v) named.v = v, named.cap = cap;
|
||||
else listed = 0;
|
||||
}
|
||||
if (k && listed) memcpy((void *)(named.v + pins_named), pinned, sizeof *pinned * (size_t)k);
|
||||
pins_named += (uint64_t)k;
|
||||
at += h.size;
|
||||
if (h.op == OP_END) {
|
||||
if (at != n) rt_fatal("nvn: recording at %p ends at %zu of %zu bytes", (const void *)p, at, n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
*(copy_prefix *)out = (copy_prefix){COPY_MAGIC, n, 0, 0};
|
||||
const tex_t **pin_v = NULL;
|
||||
if (pins_named && listed && (pin_v = malloc(sizeof *pin_v * pins_named)))
|
||||
memcpy((void *)pin_v, (const void *)named.v, sizeof *pin_v * pins_named);
|
||||
*block = (copy_prefix){.magic = COPY_MAGIC, .bytes = n, .pins = pins_named, .cap = block->cap, .pin_v = pin_v};
|
||||
return out + sizeof(copy_prefix);
|
||||
}
|
||||
|
||||
@@ -399,7 +671,7 @@ static void release_copies(uintptr_t lo, uintptr_t hi) {
|
||||
copy_bytes -= pf->bytes;
|
||||
pf->magic = 0;
|
||||
if (pf->used_at > replayed) zombie_add(pf); // a queued submission still reads it
|
||||
else free(pf);
|
||||
else dead_add(pf);
|
||||
b->v[i] = b->v[--b->n];
|
||||
}
|
||||
if (!b->n) {
|
||||
@@ -409,6 +681,7 @@ static void release_copies(uintptr_t lo, uintptr_t hi) {
|
||||
}
|
||||
}
|
||||
plat_mutex_unlock(©_lock);
|
||||
dead_free();
|
||||
}
|
||||
|
||||
// ---------- CommandBuffer: object and memory
|
||||
@@ -1031,10 +1304,14 @@ void nvn_submit_prepare_async(const uint64_t *handles, uint64_t *out, int n) {
|
||||
}
|
||||
out[i] = handles[i];
|
||||
if (pf->used_at > replayed && pf->used_at != now && pf->deferred != 1) {
|
||||
size_t sz = sizeof *pf + pf->bytes;
|
||||
copy_prefix *cl = malloc(sz);
|
||||
if (!cl) rt_fatal("nvn: out of memory (recording clone, %zu bytes)", sz);
|
||||
memcpy(cl, pf, sz);
|
||||
copy_prefix *cl = copy_block(pf->bytes, 0, "recording clone"); // copy_lock held
|
||||
size_t cap = cl->cap;
|
||||
memcpy(cl, pf, sizeof *pf + pf->bytes);
|
||||
cl->cap = cap;
|
||||
cl->pin_v = NULL;
|
||||
if (pf->pin_v && (cl->pin_v = malloc(sizeof *cl->pin_v * pf->pins)))
|
||||
memcpy((void *)cl->pin_v, (const void *)pf->pin_v, sizeof *cl->pin_v * pf->pins);
|
||||
copy_pins_ref(cl, 1);
|
||||
cl->magic = 0; // not a live handle: the game never sees it
|
||||
cl->used_at = now;
|
||||
zombie_add(cl);
|
||||
@@ -1045,6 +1322,7 @@ void nvn_submit_prepare_async(const uint64_t *handles, uint64_t *out, int n) {
|
||||
}
|
||||
}
|
||||
plat_mutex_unlock(©_lock);
|
||||
dead_free();
|
||||
for (int i = 0; i < n; i++) {
|
||||
int nd = resolve_deferred((const uint8_t *)(uintptr_t)out[i], 0);
|
||||
if (out[i] == handles[i]) ((copy_prefix *)(uintptr_t)out[i] - 1)->deferred = nd ? 2 : 1;
|
||||
|
||||
+11
-5
@@ -11,6 +11,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cfg.h"
|
||||
#include "gfx.h"
|
||||
#include "host.h"
|
||||
#include "nvn_api.h"
|
||||
@@ -667,8 +668,12 @@ static void sleep_until(uint64_t deadline) {
|
||||
}
|
||||
|
||||
// NVNwindowAcquireTextureResult nvnWindowAcquireTexture(NVNwindow *w, NVNsync *s, int *index): blocks here, not in the
|
||||
// present, until the image's previous present plus one period has passed (the game often presents with a lock held).
|
||||
// That is the time the next frame goes to the display, so the image is free as soon as that time is reached.
|
||||
// present, until the image is free (the game often presents with a lock held). Without the frame queue that is the
|
||||
// image's previous present plus one period, when the next frame goes to the display, so a frame gets exactly one period
|
||||
// from acquire to present. Its present is a copy already on the queue by its display time, so with the frame queue
|
||||
// (TOTK_FRAME_QUEUE=1) the image is free from that time on: the game draws the next frame while the previous one waits
|
||||
// for its turn, and a frame that takes up to one more period still makes its slot, for up to one frame more latency.
|
||||
// Town at 4K with DLAA at 60 on an RTX 5070: frames over 17.5 ms 1-8 per 30 s with the queue against 25-33 without.
|
||||
NVN_API(nvnWindowAcquireTexture) {
|
||||
win_t *o = arg_p(c, 0);
|
||||
sync_t *s = arg_p(c, 1);
|
||||
@@ -693,8 +698,9 @@ NVN_API(nvnWindowAcquireTexture) {
|
||||
|
||||
// void nvnQueuePresentTexture(NVNqueue *q, NVNwindow *w, int index): queues the copy and returns at once. The display
|
||||
// time comes from the pacer: the back end's present thread holds the host present until then (back-edge pacing, as the
|
||||
// Switch's display flips at vblank), so the present cadence does not follow the replay time; one period later the
|
||||
// image is free for the next acquire. Added latency: the slack of the frame (period minus its work), as on the Switch.
|
||||
// Switch's display flips at vblank), so the present cadence does not follow the replay time; one period later (at that
|
||||
// time with the frame queue) the image is free for the next acquire. Added latency: the slack of the frame (period minus
|
||||
// its work), as on the Switch, plus up to a period with the frame queue.
|
||||
NVN_API(nvnQueuePresentTexture) {
|
||||
win_t *o = arg_p(c, 1);
|
||||
int idx = (int)arg_x(c, 2);
|
||||
@@ -709,7 +715,7 @@ NVN_API(nvnQueuePresentTexture) {
|
||||
uint64_t slot = now + pace(&o->pace, now, period);
|
||||
gfx_backend()->present(t, o->crop, slot);
|
||||
o->ticket[idx] = o->presents;
|
||||
o->avail[idx] = slot + period;
|
||||
o->avail[idx] = cfg_bool("TOTK_FRAME_QUEUE") ? slot : slot + period;
|
||||
frame_step_present(slot, o->interval); // frame_rate.c: the real-time step follows the display times
|
||||
rt_frame_presented(t, o->presents);
|
||||
}
|
||||
@@ -694,6 +694,55 @@ static void parse_all(uint64_t h) {
|
||||
n_ops = OP_COUNT_ - OP_SIGNAL_EVENT;
|
||||
}
|
||||
|
||||
// A recording naming tex3 as its render target; *pin: the texture pin in its SET_RT payload.
|
||||
static uint64_t record_target(uint8_t *ctl, const tex_t **pin) {
|
||||
const NVNtexture *colors[1] = {&tex3};
|
||||
begin(&cmd2, ctl, 0x3a000);
|
||||
CALL(nvnCommandBufferSetRenderTargets, U(&cmd2), 1, U(colors), 0, 0, 0);
|
||||
uint64_t h = CALL(nvnCommandBufferEndRecording, U(&cmd2));
|
||||
*pin = NULL;
|
||||
for (const op_hdr *o = nvn_op_first(h); o->op != OP_END; o = nvn_op_next(o))
|
||||
if (o->op == OP_SET_RT) *pin = ((const p_set_rt *)nvn_op_payload(o))->color[0];
|
||||
return h;
|
||||
}
|
||||
|
||||
// Texture pins are freed NVN_PIN_AGE submits after the last recording copy naming them is released, unless the replay
|
||||
// keeps them as its current render targets; a texture named again gets a new pin.
|
||||
static void probe_pins(void) {
|
||||
uint8_t *ctl = mem_kernel_alloc(0x1000, 16);
|
||||
set_tex(NVN_FORMAT_RGBA8, 16, 16, 1, 0x20000);
|
||||
CHECK((uint8_t)CALL(nvnTextureInitialize, U(&tex3), U(&texb)) == 1, "pin probe texture");
|
||||
uint64_t serial = ((const tex_t *)(const void *)&tex3)->serial;
|
||||
uint64_t live0, freed0, live, freed;
|
||||
nvn_pins_stats(&live0, &freed0);
|
||||
const tex_t *pin;
|
||||
uint64_t h = record_target(ctl, &pin);
|
||||
nvn_pins_stats(&live, &freed);
|
||||
CHECK(pin && pin->serial == serial && live == live0 + 1, "a recording naming a new texture makes one pin");
|
||||
uint64_t s = nvn_submit_count();
|
||||
nvn_pins_sweep(s + 10 * NVN_PIN_AGE, NULL, 0);
|
||||
nvn_pins_stats(&live, &freed);
|
||||
CHECK(live == live0 + 1 && freed == freed0 && nvn_handle_live(h), "a pin a live recording names stays");
|
||||
CALL(nvnCommandBufferAddControlMemory, U(&cmd2), U(ctl), 0x1000); // retires h: the pin has no copy left
|
||||
CHECK(!nvn_handle_live(h), "the pin's recording is retired");
|
||||
nvn_pins_sweep(s + NVN_PIN_AGE - 1, NULL, 0);
|
||||
nvn_pins_stats(&live, &freed);
|
||||
CHECK(live == live0 + 1 && freed == freed0, "a pin stays NVN_PIN_AGE submits after its last copy went");
|
||||
nvn_pins_sweep(s + NVN_PIN_AGE, &pin, 1);
|
||||
nvn_pins_stats(&live, &freed);
|
||||
CHECK(live == live0 + 1 && freed == freed0, "the replay's current render target is kept");
|
||||
nvn_pins_sweep(s + 2 * NVN_PIN_AGE, NULL, 0);
|
||||
nvn_pins_stats(&live, &freed);
|
||||
CHECK(live == live0 && freed == freed0 + 1, "an unnamed, unkept pin is freed (live %llu of %llu, freed %llu of %llu)",
|
||||
(unsigned long long)live, (unsigned long long)live0, (unsigned long long)freed, (unsigned long long)freed0 + 1);
|
||||
h = record_target(ctl, &pin);
|
||||
nvn_pins_stats(&live, &freed);
|
||||
CHECK(pin && pin->serial == serial && live == live0 + 1, "naming the texture again makes a new pin");
|
||||
CALL(nvnCommandBufferAddControlMemory, U(&cmd2), U(ctl), 0x1000);
|
||||
CALL(nvnCommandBufferFinalize, U(&cmd2));
|
||||
CALL(nvnTextureFinalize, U(&tex3));
|
||||
}
|
||||
|
||||
static void probe_commands(void) {
|
||||
ctlmem1 = mem_kernel_alloc(0x1000, 16);
|
||||
ctlmem2 = mem_kernel_alloc(0x1000, 16);
|
||||
@@ -722,6 +771,7 @@ static void probe_commands(void) {
|
||||
CALL(nvnCommandBufferAddControlMemory, U(&cmd2), U(ctlmem2), 0x1000);
|
||||
CHECK(!nvn_handle_live(h2) && nvn_handle_live(h), "reusing control memory retires only the handles recorded in it");
|
||||
CALL(nvnCommandBufferFinalize, U(&cmd2));
|
||||
probe_pins();
|
||||
n_objects += 5;
|
||||
}
|
||||
|
||||
|
||||
+196
-4
@@ -73,6 +73,7 @@ typedef struct {
|
||||
unsigned owed; // events to signal on the next guest thread (bit per MOVIE_EV_*)
|
||||
uint64_t t0; // host time of the first frame handed over (pacing)
|
||||
int64_t pts0; // its presentation time (us)
|
||||
MovieFit *fit; // picture shrunk inside the frame; NULL = the shapes agree, copy
|
||||
MovieStats st;
|
||||
PlatMutex mu;
|
||||
PlatCond work, idle;
|
||||
@@ -207,6 +208,173 @@ static void to_nv12(const vpx_image_t *img, uint8_t *dst, int w, int h) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Keeping the movie's shape. The game draws a decoded frame over the whole canvas, so on an output that is not 16:9
|
||||
// (output_res in totk.ini) the 1920x1080 movies would be stretched. The frame keeps its size; the picture is shrunk
|
||||
// inside it and the rest is black, so that after the game's stretch the picture has the movie's own shape again: a wider
|
||||
// output gets a squeezed picture with black at the left and right, a taller one (16:10) black above and below.
|
||||
// The shrink is a box filter (each output pixel averages the source pixels it covers, weights in 1/4096), separable, with
|
||||
// the per-column and per-row source spans and weights built once per movie (movie_fit_new), so the frame loop only
|
||||
// multiplies and adds.
|
||||
enum { FIT_ONE = 4096, FIT_Y_BLACK = 16, FIT_UV_BLACK = 128 }; // black of limited-range video: Y 16, U = V 128
|
||||
typedef struct { int n, taps; int *start; uint16_t *wt; } FitAxis; // output i: taps weights from source start[i]
|
||||
struct MovieFit {
|
||||
int w, h, x0, y0, pw, ph; // frame, and the picture's corner and size in it (all even)
|
||||
FitAxis lx, ly, cx, cy; // luma, chroma; columns, rows; n = 0 where the axis is kept
|
||||
uint8_t *tmp; // horizontally shrunk rows, before the vertical pass
|
||||
uint32_t *acc; // one row of the vertical sum
|
||||
};
|
||||
|
||||
// Box filter from src_n to dst_n (dst_n < src_n): output i covers [i, i + 1) * src_n / dst_n of the source.
|
||||
static int axis_build(FitAxis *a, int src_n, int dst_n) {
|
||||
double s = (double)src_n / dst_n;
|
||||
a->n = dst_n;
|
||||
a->taps = (int)s + 2 > src_n ? src_n : (int)s + 2;
|
||||
a->start = malloc((size_t)dst_n * sizeof *a->start);
|
||||
a->wt = malloc((size_t)dst_n * (size_t)a->taps * sizeof *a->wt);
|
||||
if (!a->start || !a->wt) return 0;
|
||||
for (int i = 0; i < dst_n; i++) {
|
||||
double lo = i * s, hi = (i + 1) * s;
|
||||
int st = (int)lo;
|
||||
if (st > src_n - a->taps) st = src_n - a->taps;
|
||||
a->start[i] = st;
|
||||
int sum = 0, big = 0;
|
||||
uint16_t *w = a->wt + (size_t)i * a->taps;
|
||||
for (int t = 0; t < a->taps; t++) {
|
||||
double ov = (st + t + 1 < hi ? st + t + 1 : hi) - (st + t > lo ? st + t : lo);
|
||||
w[t] = ov > 0 ? (uint16_t)(ov / s * FIT_ONE + 0.5) : 0;
|
||||
sum += w[t];
|
||||
if (w[t] > w[big]) big = t;
|
||||
}
|
||||
w[big] = (uint16_t)(w[big] + FIT_ONE - sum); // the weights add up to exactly 1
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
static void axis_free(FitAxis *a) {
|
||||
free(a->start);
|
||||
free(a->wt);
|
||||
}
|
||||
|
||||
void movie_fit_free(MovieFit *f) {
|
||||
if (!f) return;
|
||||
axis_free(&f->lx), axis_free(&f->ly), axis_free(&f->cx), axis_free(&f->cy);
|
||||
free(f->tmp);
|
||||
free(f->acc);
|
||||
free(f);
|
||||
}
|
||||
|
||||
// The plan for a w x h movie shown on an ow x oh output; NULL when the shapes agree within 1% (the frame is copied).
|
||||
MovieFit *movie_fit_new(int w, int h, unsigned ow, unsigned oh) {
|
||||
if (w < 16 || h < 16 || (w | h) & 1 || !ow || !oh) return NULL;
|
||||
double movie = (double)w / h, out = (double)ow / oh, r = out / movie;
|
||||
if (r > 0.99 && r < 1.01) return NULL;
|
||||
MovieFit *f = calloc(1, sizeof *f);
|
||||
if (!f) {
|
||||
rt_log("movie: out of memory for the picture-shape tables; this movie is stretched");
|
||||
return NULL;
|
||||
}
|
||||
f->w = w, f->h = h, f->pw = w, f->ph = h;
|
||||
if (r > 1) f->pw = (int)(w / r / 2 + 0.5) * 2; // wider output: squeeze sideways
|
||||
else f->ph = (int)(h * r / 2 + 0.5) * 2; // taller output: squeeze vertically
|
||||
if (f->pw < 2) f->pw = 2;
|
||||
if (f->ph < 2) f->ph = 2;
|
||||
f->x0 = (w - f->pw) / 2 & ~1;
|
||||
f->y0 = (h - f->ph) / 2 & ~1;
|
||||
int ok = 1;
|
||||
if (f->pw < w) ok = axis_build(&f->lx, w, f->pw) && axis_build(&f->cx, w / 2, f->pw / 2);
|
||||
if (f->ph < h) ok = ok && axis_build(&f->ly, h, f->ph) && axis_build(&f->cy, h / 2, f->ph / 2);
|
||||
f->tmp = malloc((size_t)f->pw * h);
|
||||
f->acc = malloc((size_t)f->pw * sizeof *f->acc);
|
||||
if (!ok || !f->tmp || !f->acc) {
|
||||
rt_log("movie: out of memory for the picture-shape tables; this movie is stretched");
|
||||
movie_fit_free(f);
|
||||
return NULL;
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
// One output row of the horizontal pass: dst gets the shrunk src row (dstep 2 writes every other byte, for U and V).
|
||||
// The tap count is a constant in the 3 and 4 tap copies (what 2x and below squeezes need), so the compiler unrolls the sum.
|
||||
static inline __attribute__((always_inline)) void fit_row_taps(const FitAxis *a, const uint8_t *src, uint8_t *dst,
|
||||
int dstep, int taps) {
|
||||
for (int i = 0; i < a->n; i++) {
|
||||
const uint8_t *p = src + a->start[i];
|
||||
const uint16_t *w = a->wt + (size_t)i * taps;
|
||||
uint32_t v = FIT_ONE / 2;
|
||||
for (int t = 0; t < taps; t++) v += w[t] * p[t];
|
||||
dst[(size_t)i * dstep] = (uint8_t)(v / FIT_ONE);
|
||||
}
|
||||
}
|
||||
static void fit_row_h(const FitAxis *a, const uint8_t *src, uint8_t *dst, int dstep) {
|
||||
if (a->taps == 3) fit_row_taps(a, src, dst, dstep, 3);
|
||||
else if (a->taps == 4) fit_row_taps(a, src, dst, dstep, 4);
|
||||
else fit_row_taps(a, src, dst, dstep, a->taps);
|
||||
}
|
||||
|
||||
// One plane (sw x sh) shrunk into the pw x ph picture whose corner (ox, oy) is in dst; ax/ay NULL = that axis is kept.
|
||||
static void fit_plane(const MovieFit *f, const uint8_t *src, int ss, int sw, int sh, uint8_t *dst, int ds, int dstep,
|
||||
int ox, int oy, const FitAxis *ax, const FitAxis *ay, int pw, int ph) {
|
||||
uint8_t *base = dst + (size_t)oy * ds + (size_t)ox * dstep;
|
||||
if (!ay) { // sideways only: straight into the frame
|
||||
for (int y = 0; y < sh; y++) {
|
||||
const uint8_t *row = src + (size_t)y * ss;
|
||||
uint8_t *o = base + (size_t)y * ds;
|
||||
if (ax) fit_row_h(ax, row, o, dstep);
|
||||
else if (dstep == 1) memcpy(o, row, (size_t)sw);
|
||||
else for (int x = 0; x < sw; x++) o[(size_t)x * dstep] = row[x];
|
||||
}
|
||||
return;
|
||||
}
|
||||
const uint8_t *in = src;
|
||||
int is = ss;
|
||||
if (ax) {
|
||||
for (int y = 0; y < sh; y++) fit_row_h(ax, src + (size_t)y * ss, f->tmp + (size_t)y * pw, 1);
|
||||
in = f->tmp, is = pw;
|
||||
}
|
||||
for (int y = 0; y < ph; y++) {
|
||||
const uint16_t *w = ay->wt + (size_t)y * ay->taps;
|
||||
const uint8_t *r0 = in + (size_t)ay->start[y] * is;
|
||||
uint8_t *o = base + (size_t)y * ds;
|
||||
if (ay->taps == 3) { // the common case, summed in registers
|
||||
const uint8_t *r1 = r0 + is, *r2 = r1 + is;
|
||||
for (int x = 0; x < pw; x++)
|
||||
o[(size_t)x * dstep] = (uint8_t)((FIT_ONE / 2 + w[0] * r0[x] + w[1] * r1[x] + w[2] * r2[x]) / FIT_ONE);
|
||||
continue;
|
||||
}
|
||||
memset(f->acc, 0, (size_t)pw * sizeof *f->acc);
|
||||
for (int t = 0; t < ay->taps; t++) {
|
||||
const uint8_t *row = r0 + (size_t)t * is;
|
||||
uint32_t k = w[t];
|
||||
for (int x = 0; x < pw; x++) f->acc[x] += k * row[x];
|
||||
}
|
||||
for (int x = 0; x < pw; x++) o[(size_t)x * dstep] = (uint8_t)((f->acc[x] + FIT_ONE / 2) / FIT_ONE);
|
||||
}
|
||||
}
|
||||
|
||||
// I420 planes -> packed NV12 with the picture shrunk inside black bars: w x h of Y, then w x h/2 of interleaved U, V.
|
||||
void movie_fit_apply(const MovieFit *f, const uint8_t *const plane[3], const int stride[3], uint8_t *dst) {
|
||||
int w = f->w, h = f->h, cw = w / 2, ch = h / 2;
|
||||
uint8_t *uv = dst + (size_t)w * h;
|
||||
// Black only where the picture does not go: whole rows above and below it, the strips left and right of it.
|
||||
int rx = f->x0 + f->pw, ry = f->y0 + f->ph;
|
||||
memset(dst, FIT_Y_BLACK, (size_t)f->y0 * w);
|
||||
memset(dst + (size_t)ry * w, FIT_Y_BLACK, (size_t)(h - ry) * w);
|
||||
memset(uv, FIT_UV_BLACK, (size_t)(f->y0 / 2) * w);
|
||||
memset(uv + (size_t)(ry / 2) * w, FIT_UV_BLACK, (size_t)(ch - ry / 2) * w);
|
||||
for (int y = f->y0; y < ry; y++) {
|
||||
memset(dst + (size_t)y * w, FIT_Y_BLACK, (size_t)f->x0);
|
||||
memset(dst + (size_t)y * w + rx, FIT_Y_BLACK, (size_t)(w - rx));
|
||||
}
|
||||
for (int y = f->y0 / 2; y < ry / 2; y++) {
|
||||
memset(uv + (size_t)y * w, FIT_UV_BLACK, (size_t)f->x0);
|
||||
memset(uv + (size_t)y * w + rx, FIT_UV_BLACK, (size_t)(w - rx));
|
||||
}
|
||||
fit_plane(f, plane[0], stride[0], w, h, dst, w, 1, f->x0, f->y0, f->lx.n ? &f->lx : NULL, f->ly.n ? &f->ly : NULL,
|
||||
f->pw, f->ph);
|
||||
for (int c = 0; c < 2; c++)
|
||||
fit_plane(f, plane[1 + c], stride[1 + c], cw, ch, uv + c, w, 2, f->x0 / 2, f->y0 / 2,
|
||||
f->cx.n ? &f->cx : NULL, f->cy.n ? &f->cy : NULL, f->pw / 2, f->ph / 2);
|
||||
}
|
||||
|
||||
static int free_out(const Movie *m) {
|
||||
for (int i = 0; i < OUT_BUFS; i++)
|
||||
if (!m->out_held[i]) return i;
|
||||
@@ -250,7 +418,9 @@ static void worker(void *arg) {
|
||||
slot = free_out(m);
|
||||
m->out_held[slot] = 1; // reserved: in no queue, so the game cannot release it
|
||||
}
|
||||
uint64_t gen = m->gen, dt = 0;
|
||||
uint64_t gen = m->gen, dt = 0, fit_ns = 0;
|
||||
// A plan made for another frame size (a start that found the worker busy) is not used: it would overrun dst.
|
||||
const MovieFit *fit = m->fit && m->fit->w == m->w && m->fit->h == m->h ? m->fit : NULL;
|
||||
int got = 0, w = m->w, h = m->h;
|
||||
uint8_t *dst = slot >= 0 ? m->out[slot] : NULL;
|
||||
vpx_codec_err_t e = VPX_CODEC_OK;
|
||||
@@ -265,7 +435,12 @@ static void worker(void *arg) {
|
||||
const vpx_image_t *img;
|
||||
while ((img = vpx_codec_get_frame(&m->vpx, &it)) != NULL) {
|
||||
if (img->fmt != VPX_IMG_FMT_I420 || (int)img->d_w != w || (int)img->d_h != h) bad = "layout";
|
||||
else if (!got) to_nv12(img, dst, w, h);
|
||||
else if (!got) {
|
||||
uint64_t t1 = kern_now_ns();
|
||||
if (fit) movie_fit_apply(fit, (const uint8_t *const *)img->planes, img->stride, dst);
|
||||
else to_nv12(img, dst, w, h);
|
||||
fit_ns = kern_now_ns() - t1;
|
||||
}
|
||||
got++;
|
||||
}
|
||||
}
|
||||
@@ -279,6 +454,7 @@ static void worker(void *arg) {
|
||||
m->owed |= 1u << MOVIE_EV_INPUT;
|
||||
if (p.size > 0) {
|
||||
m->st.decode_ns += dt;
|
||||
m->st.fit_ns += fit_ns;
|
||||
if (dt > m->st.max_ns) m->st.max_ns = dt;
|
||||
if (bad) rt_fatal("movie: VP9 frame layout differs from the configured %dx%d I420", w, h);
|
||||
if (e != VPX_CODEC_OK || !got) {
|
||||
@@ -302,8 +478,10 @@ static void worker(void *arg) {
|
||||
push_decoded(m, slot, 0, p.time, frame_bytes(m));
|
||||
m->st.frames++;
|
||||
if (m->st.frames % 600 == 0)
|
||||
rt_log("movie: %llu frames, decode %.2f ms per frame (max %.2f)", (unsigned long long)m->st.frames,
|
||||
m->st.decode_ns / 1e6 / (double)m->st.frames, m->st.max_ns / 1e6);
|
||||
rt_log("movie: %llu frames, decode %.2f ms per frame (max %.2f), of it %.2f ms %s",
|
||||
(unsigned long long)m->st.frames, m->st.decode_ns / 1e6 / (double)m->st.frames,
|
||||
m->st.max_ns / 1e6, m->st.fit_ns / 1e6 / (double)m->st.frames,
|
||||
m->fit ? "fitting the picture" : "converting");
|
||||
}
|
||||
}
|
||||
if (p.flags & MOVIE_FLAG_EOS && !m->eos_in) {
|
||||
@@ -355,6 +533,8 @@ static void release_codec(Movie *m) { // m->mu held, worker quiesced
|
||||
free(m->out[i]);
|
||||
m->out[i] = NULL;
|
||||
}
|
||||
movie_fit_free(m->fit);
|
||||
m->fit = NULL;
|
||||
m->configured = m->started = 0;
|
||||
reset_queues(m);
|
||||
}
|
||||
@@ -597,6 +777,18 @@ static void ov_start(Cpu *c) {
|
||||
plat_mutex_lock(&m->mu);
|
||||
int st = ST_NOT_CONFIGURED;
|
||||
if (m->configured) {
|
||||
if (!m->started && !m->busy) { // the worker is idle: it reads m->fit only when it takes an input
|
||||
movie_fit_free(m->fit);
|
||||
m->fit = NULL;
|
||||
unsigned out[2];
|
||||
res_wanted(out);
|
||||
if (strcmp(cfg_value("TOTK_MOVIE_ASPECT"), "stretch")) {
|
||||
m->fit = movie_fit_new(m->w, m->h, out[0], out[1]);
|
||||
if (m->fit)
|
||||
rt_log("movie: %dx%d picture kept in shape on the %ux%u output (%dx%d inside the frame)", m->w,
|
||||
m->h, out[0], out[1], m->fit->pw, m->fit->ph);
|
||||
}
|
||||
}
|
||||
m->started = 1;
|
||||
m->owed |= 1u << MOVIE_EV_INPUT;
|
||||
plat_cond_signal(&m->work);
|
||||
|
||||
@@ -270,7 +270,8 @@ int plat_intr_resume(PlatIntr *t, uint64_t to) {
|
||||
// ---- fibers: a stack switch of our own. glibc's swapcontext saves and restores the signal mask with a system call
|
||||
// on every switch, and the game switches fibers often. fiber_swap saves the callee-saved registers, the MXCSR and the
|
||||
// x87 control word on the current stack, stores the stack pointer in *save and loads the other one. A new fiber's
|
||||
// stack is laid out as fiber_swap leaves one, returning into fiber_entry, which calls fn(arg) (r12 and r13).
|
||||
// stack is laid out as fiber_swap leaves one, returning into fiber_entry, which realigns the stack and calls fn(arg)
|
||||
// (r12 and r13).
|
||||
struct PlatFiber {
|
||||
void *sp;
|
||||
void *stack; // mapped stack (NULL for a thread's own context)
|
||||
@@ -312,6 +313,11 @@ __asm__(".text\n"
|
||||
".type plat_fiber_entry, @function\n"
|
||||
".p2align 4\n"
|
||||
"plat_fiber_entry:\n"
|
||||
// fiber_swap's ret lands here with rsp = top - 8, as at the entry of a called function. The call below must
|
||||
// be made with rsp 16-byte aligned so that fn starts with rsp = 8 mod 16, as the System V ABI demands; without
|
||||
// the realignment every 16-byte local of fn is off by 8, which the VEX instructions of a v3 build tolerate
|
||||
// but the SSE ones of a v2 or k10 build (movdqa, pcmpeqb with a memory operand) fault on.
|
||||
" andq $-16, %rsp\n"
|
||||
" movq %r13, %rdi\n"
|
||||
" callq *%r12\n"
|
||||
" ud2\n"
|
||||
@@ -993,6 +999,12 @@ int plat_mem_read(uint64_t addr, void *out, size_t n) {
|
||||
return process_vm_readv(getpid(), &local, 1, &remote, 1, 0) == (ssize_t)n;
|
||||
}
|
||||
|
||||
// Not known before the SDL window exists; the Steam Deck has its own profile (cfg.c)
|
||||
int plat_screen_size(int *w, int *h) {
|
||||
(void)w, (void)h;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int plat_mem_readable(uint64_t addr, uint64_t n) {
|
||||
uint8_t b;
|
||||
uint64_t end = addr + (n ? n : 1);
|
||||
@@ -1275,7 +1287,16 @@ int plat_stderr_path(char *out, size_t cap) {
|
||||
|
||||
// ---- process
|
||||
|
||||
#ifdef TOTK_PGO_GEN
|
||||
// compiler-rt's profile runtime (an instrumented build, tools/build_game.py --pgo gen). It writes its counters from an
|
||||
// atexit handler, which _exit skips; __llvm_profile_dump writes them once and makes later writes no-ops.
|
||||
int __llvm_profile_dump(void);
|
||||
#endif
|
||||
|
||||
void plat_exit(int code) {
|
||||
#ifdef TOTK_PGO_GEN
|
||||
__llvm_profile_dump();
|
||||
#endif
|
||||
fflush(stdout);
|
||||
fflush(stderr);
|
||||
_exit(code);
|
||||
|
||||
@@ -240,6 +240,7 @@ typedef struct {
|
||||
} PlatCrash;
|
||||
void plat_crash_hook(void (*fn)(const PlatCrash *c));
|
||||
int plat_mem_read(uint64_t addr, void *out, size_t n); // copies n bytes, 0 instead of faulting on unmapped memory
|
||||
int plat_screen_size(int *w, int *h); // the primary display's current mode in pixels; 0 unknown
|
||||
int plat_mem_readable(uint64_t addr, uint64_t n); // every page of [addr, addr + n) can be read; no fault
|
||||
void *plat_aligned_alloc(size_t size, size_t align); // free with plat_aligned_free; NULL on failure
|
||||
void plat_aligned_free(void *p);
|
||||
|
||||
@@ -775,6 +775,14 @@ int plat_mem_read(uint64_t addr, void *out, size_t n) {
|
||||
return ReadProcessMemory(GetCurrentProcess(), (const void *)(uintptr_t)addr, out, n, &got) && got == n;
|
||||
}
|
||||
|
||||
// The display mode's own size: physical pixels whatever the process's DPI awareness and the display scale
|
||||
int plat_screen_size(int *w, int *h) {
|
||||
DEVMODEW dm = {.dmSize = sizeof dm};
|
||||
if (!EnumDisplaySettingsW(NULL, ENUM_CURRENT_SETTINGS, &dm) || !dm.dmPelsWidth || !dm.dmPelsHeight) return 0;
|
||||
*w = (int)dm.dmPelsWidth, *h = (int)dm.dmPelsHeight;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int plat_mem_readable(uint64_t addr, uint64_t n) {
|
||||
for (uint64_t a = addr, end = addr + n; a < end || a == addr;) {
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
@@ -791,7 +799,33 @@ void plat_aligned_free(void *p) { _aligned_free(p); }
|
||||
|
||||
// ---- process
|
||||
|
||||
#ifdef TOTK_PGO_GEN
|
||||
// compiler-rt's profile runtime (an instrumented build, tools/build_game.py --pgo gen). It writes its counters from an
|
||||
// atexit handler, which ExitProcess skips; __llvm_profile_dump writes them once and makes later writes no-ops.
|
||||
int __llvm_profile_dump(void);
|
||||
int __llvm_profile_write_file(void);
|
||||
void __llvm_profile_reset_counters(void);
|
||||
|
||||
// A long played session must not lose its counts to a crash or a reboot: every minute the counters are added to the
|
||||
// .profraw (the file merges, -fprofile-generate's %m) and zeroed. Counts taken between the write and the reset are lost.
|
||||
static DWORD WINAPI pgo_flush_thread(void *arg) {
|
||||
(void)arg;
|
||||
for (;;) {
|
||||
Sleep(60 * 1000);
|
||||
if (__llvm_profile_write_file() == 0) __llvm_profile_reset_counters();
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void pgo_flush_start(void) {
|
||||
HANDLE h = CreateThread(NULL, 0, pgo_flush_thread, NULL, 0, NULL);
|
||||
if (h) CloseHandle(h);
|
||||
}
|
||||
#endif
|
||||
|
||||
void plat_exit(int code) {
|
||||
#ifdef TOTK_PGO_GEN
|
||||
__llvm_profile_dump();
|
||||
#endif
|
||||
fflush(stdout);
|
||||
fflush(stderr);
|
||||
ExitProcess((UINT)code);
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// Play time: how long the player has spent in the game, across runs, shown on the Game tab of the settings menu
|
||||
// (ui/menu.cpp tab_game). The game itself keeps no play time a player can see, and the Switch's own play log is not
|
||||
// there to read, so the port counts it.
|
||||
//
|
||||
// One host thread (it never holds a virtual core, as discord_rpc.c's) wakes every second and adds the time since its
|
||||
// last wake when the window has the focus (gfx_window_focused) and the game presented at least one frame meanwhile
|
||||
// (gfx_stats). So the shader prewarm before the first frame, a window left in the background and a hung game do not
|
||||
// count; loading screens and the game's own menus do, as they present frames. One wake adds at most TICK_CAP_MS, so a
|
||||
// machine that slept with the game open adds two seconds, not the night.
|
||||
//
|
||||
// The total lives in <profile>/playtime.txt, next to account.uid and the saves, so it stays with them when the
|
||||
// install is updated or moved. It is plain text, "total_seconds <n>", written atomically (plat_file_write_atomic)
|
||||
// every SAVE_EVERY_MS while it grows and once more when the game exits (playtime_save from boot.c, svc.c and
|
||||
// gfx_present.c, next to prof_write); a crash loses at most SAVE_EVERY_MS. A file that does not read as that is kept
|
||||
// as playtime.txt.bad and the count starts again from zero. A scripted run (--seconds) reads the total and adds nothing.
|
||||
#include <inttypes.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "gfx.h"
|
||||
#include "host.h"
|
||||
#include "platform/platform.h"
|
||||
|
||||
#define TICK_MS 1000
|
||||
#define TICK_CAP_MS 2000
|
||||
#define SAVE_EVERY_MS 30000
|
||||
#define TOTAL_CAP_S (100ull * 365 * 24 * 3600) // a hundred years: anything larger is a damaged file
|
||||
|
||||
static PlatMutex lock = PLAT_MUTEX_INIT; // the totals and the file
|
||||
static uint64_t total_ms, session_ms, saved_ms;
|
||||
static int loaded; // 1 once the file was read (or found missing), -1 when there is no profile
|
||||
static char path[1100];
|
||||
|
||||
// The seconds of a playtime.txt: "total_seconds <n>", a line end or nothing after it, a UTF-8 byte order mark before it
|
||||
// (an editor may add one). 1 when the file reads so and n is at most TOTAL_CAP_S.
|
||||
static int parse(const uint8_t *b, uint64_t n, uint64_t *seconds) {
|
||||
if (n >= 3 && !memcmp(b, "\xEF\xBB\xBF", 3)) b += 3, n -= 3;
|
||||
char text[64] = {0};
|
||||
memcpy(text, b, n < sizeof text - 1 ? n : sizeof text - 1);
|
||||
unsigned long long s = 0;
|
||||
char tail = 0;
|
||||
if (sscanf(text, "total_seconds %llu%c", &s, &tail) < 1 || (tail && tail != '\n' && tail != '\r') ||
|
||||
s > TOTAL_CAP_S)
|
||||
return 0;
|
||||
*seconds = s;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Holding lock. dir: the profile directory.
|
||||
static void load(const char *dir) {
|
||||
if (loaded) return;
|
||||
if (!dir || !*dir || snprintf(path, sizeof path, "%s/playtime.txt", dir) >= (int)sizeof path) {
|
||||
loaded = -1;
|
||||
rt_log("playtime: no profile directory; play time is not counted");
|
||||
return;
|
||||
}
|
||||
loaded = 1;
|
||||
uint64_t n = 0, s = 0;
|
||||
uint8_t *b = plat_file_read(path, &n);
|
||||
if (!b) {
|
||||
rt_log("playtime: no %s yet; counting from zero", path);
|
||||
return;
|
||||
}
|
||||
if (parse(b, n, &s)) {
|
||||
free(b);
|
||||
total_ms = saved_ms = s * 1000;
|
||||
rt_log("playtime: %llu h %02llu min played before this run (%s)", (unsigned long long)(s / 3600),
|
||||
(unsigned long long)(s / 60 % 60), path);
|
||||
return;
|
||||
}
|
||||
char bad[1110];
|
||||
snprintf(bad, sizeof bad, "%s.bad", path);
|
||||
int kept = plat_file_write_atomic(bad, b, (size_t)n); // the whole file, for the player to recover the number
|
||||
free(b);
|
||||
rt_log("playtime: %s does not read as \"total_seconds <n>\"; %s, counting from zero", path,
|
||||
kept ? "kept as playtime.txt.bad" : "could not keep a copy");
|
||||
}
|
||||
|
||||
// Holding lock.
|
||||
static void save_locked(void) {
|
||||
if (loaded != 1 || total_ms / 1000 == saved_ms / 1000) return;
|
||||
char text[48];
|
||||
int n = snprintf(text, sizeof text, "total_seconds %" PRIu64 "\n", total_ms / 1000);
|
||||
if (plat_file_write_atomic(path, text, (size_t)n)) saved_ms = total_ms;
|
||||
else rt_log("playtime: cannot write %s", path);
|
||||
}
|
||||
|
||||
void playtime_save(void) {
|
||||
plat_mutex_lock(&lock);
|
||||
save_locked();
|
||||
plat_mutex_unlock(&lock);
|
||||
}
|
||||
|
||||
int playtime_get(uint64_t *total_s, uint64_t *session_s) {
|
||||
plat_mutex_lock(&lock);
|
||||
int ok = loaded == 1;
|
||||
*total_s = total_ms / 1000;
|
||||
*session_s = session_ms / 1000;
|
||||
plat_mutex_unlock(&lock);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Holding lock: the time since the last tick, d ms, when the game was played meanwhile; the file every SAVE_EVERY_MS.
|
||||
static void tick_locked(uint64_t now, uint64_t d, int playing, uint64_t *last_save) {
|
||||
if (d > TICK_CAP_MS) d = TICK_CAP_MS;
|
||||
if (playing) total_ms += d, session_ms += d;
|
||||
if (now - *last_save >= SAVE_EVERY_MS) save_locked(), *last_save = now;
|
||||
}
|
||||
|
||||
static void playtime_main(void *arg) {
|
||||
(void)arg;
|
||||
plat_thread_priority(PLAT_PRIO_BELOW_NORMAL);
|
||||
uint64_t last = plat_now_ms(), last_save = last, frames = gfx_stats().frames;
|
||||
for (;;) {
|
||||
plat_sleep_ms(TICK_MS);
|
||||
uint64_t now = plat_now_ms(), f = gfx_stats().frames;
|
||||
int playing = f != frames && gfx_window_focused();
|
||||
plat_mutex_lock(&lock);
|
||||
tick_locked(now, now - last, playing, &last_save);
|
||||
plat_mutex_unlock(&lock);
|
||||
last = now, frames = f;
|
||||
}
|
||||
}
|
||||
|
||||
// The file is read at once, so the Game tab shows the total from the first frame. A scripted run (--seconds: benches,
|
||||
// menu shots, input scripts) shows it but adds nothing: those runs are not play.
|
||||
void playtime_init(void) {
|
||||
static int32_t started;
|
||||
if (plat_xchg32(&started, 1)) return;
|
||||
plat_mutex_lock(&lock);
|
||||
load(rt_config()->profile);
|
||||
int on = loaded == 1;
|
||||
plat_mutex_unlock(&lock);
|
||||
if (!on) return;
|
||||
if (rt_config()->seconds > 0) {
|
||||
rt_log("playtime: a scripted run (--seconds); play time is shown, not counted");
|
||||
return;
|
||||
}
|
||||
if (!plat_thread_spawn(playtime_main, NULL, "playtime", 0))
|
||||
rt_log("playtime: cannot start its thread; play time is not counted");
|
||||
}
|
||||
|
||||
// --probe-playtime: the file's parsing, a missing, a valid and a damaged file, the periodic save and the tick's cap, in
|
||||
// a directory next to totk.exe. Needs no game files.
|
||||
static int fails;
|
||||
#define CHECK(c, what) do { if (!(c)) { fails++; fprintf(stderr, "FAIL playtime: %s\n", what); } } while (0)
|
||||
|
||||
static void reset(void) { total_ms = session_ms = saved_ms = 0, loaded = 0, path[0] = 0; }
|
||||
|
||||
static int file_is(const char *p, const char *want) {
|
||||
uint64_t n = 0;
|
||||
uint8_t *b = plat_file_read(p, &n);
|
||||
int ok = b && n == strlen(want) && !memcmp(b, want, n);
|
||||
free(b);
|
||||
return ok;
|
||||
}
|
||||
|
||||
int probe_playtime(void) {
|
||||
uint64_t s = 0;
|
||||
CHECK(parse((const uint8_t *)"total_seconds 42\n", 17, &s) && s == 42, "a line with LF");
|
||||
CHECK(parse((const uint8_t *)"total_seconds 42\r\n", 18, &s) && s == 42, "a line with CRLF");
|
||||
CHECK(parse((const uint8_t *)"total_seconds 42", 16, &s) && s == 42, "no line end");
|
||||
CHECK(parse((const uint8_t *)"\xEF\xBB\xBFtotal_seconds 7\n", 19, &s) && s == 7, "a byte order mark");
|
||||
CHECK(!parse((const uint8_t *)"total_seconds 42x", 17, &s), "a character after the number");
|
||||
CHECK(!parse((const uint8_t *)"total_seconds -1\n", 17, &s), "a negative number");
|
||||
CHECK(!parse((const uint8_t *)"total_seconds 3153600001\n", 25, &s), "over a hundred years");
|
||||
CHECK(!parse((const uint8_t *)"seconds 42\n", 11, &s), "another key");
|
||||
CHECK(!parse((const uint8_t *)"", 0, &s), "an empty file");
|
||||
|
||||
char dir[1024];
|
||||
if (!plat_exe_path(dir, sizeof dir)) return 1;
|
||||
char *cut = plat_last_sep(dir);
|
||||
if (!cut) return 1;
|
||||
strcpy(cut + 1, "playtime-probe");
|
||||
plat_file_mkdir_p(dir);
|
||||
char file[1100], bad[1110];
|
||||
snprintf(file, sizeof file, "%s/playtime.txt", dir);
|
||||
snprintf(bad, sizeof bad, "%s.bad", file);
|
||||
plat_file_delete(file);
|
||||
plat_file_delete(bad);
|
||||
|
||||
uint64_t total, session, last_save = 0;
|
||||
reset();
|
||||
load(dir);
|
||||
CHECK(playtime_get(&total, &session) && total == 0, "a missing file counts from zero");
|
||||
tick_locked(1000, 1000, 1, &last_save);
|
||||
tick_locked(2000, 1000, 0, &last_save);
|
||||
CHECK(plat_file_size(file) < 0, "nothing is written before SAVE_EVERY_MS");
|
||||
tick_locked(2000 + 3600000, 3600000, 1, &last_save); // a machine that slept: one tick adds TICK_CAP_MS, and saves
|
||||
playtime_get(&total, &session);
|
||||
CHECK(total == 3 && session == 3, "played ticks count, an idle one does not, a long one is capped");
|
||||
CHECK(file_is(file, "total_seconds 3\n"), "the save after SAVE_EVERY_MS writes the total");
|
||||
|
||||
reset();
|
||||
load(dir);
|
||||
playtime_get(&total, &session);
|
||||
CHECK(total == 3 && session == 0, "the next run reads the total back");
|
||||
|
||||
static const char damaged[] = "total_seconds twelve\n";
|
||||
plat_file_write_atomic(file, damaged, sizeof damaged - 1);
|
||||
reset();
|
||||
load(dir);
|
||||
playtime_get(&total, &session);
|
||||
CHECK(total == 0, "a damaged file counts from zero");
|
||||
CHECK(file_is(bad, damaged), "a damaged file is kept whole as playtime.txt.bad");
|
||||
|
||||
plat_file_delete(file);
|
||||
plat_file_delete(bad);
|
||||
reset();
|
||||
fprintf(stderr, "playtime: %s (%d failures)\n", fails ? "FAILED" : "ok", fails);
|
||||
return fails ? 1 : 0;
|
||||
}
|
||||
+164
-15
@@ -39,6 +39,7 @@
|
||||
|
||||
#include "cfg.h"
|
||||
#include "host.h"
|
||||
#include "mods.h"
|
||||
#include "platform/platform.h"
|
||||
|
||||
#define BASE_W 1920
|
||||
@@ -46,6 +47,7 @@
|
||||
#define SCENE_W 1600
|
||||
#define SCENE_H 900
|
||||
#define DTA_KIB 113664u // DynamicTextureAllocatorMem, shipped
|
||||
#define TEX_HEAP_KIB 506880u // TextureHeap, shipped (1.2.1 and 1.4.3): what a mod's own HeapDef is measured against
|
||||
#define SCENE_MAX 4096 // per side, as MacNativeGames' TOTK_RENDER_RES
|
||||
#define SHADOW_BASE 960 // the sun shadow map's base size (its cascades: a D16 array of 4 layers)
|
||||
|
||||
@@ -120,6 +122,25 @@ static double dlss_share(const char *m) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A game resolution that is not 16:9 needs the HUD's layout for its shape (romfs-patches-<w>x<h>, romfs.c
|
||||
// overlay_init), which the installer makes but a developer build only has after tools/make_romfs_patches.py --aspect.
|
||||
// Without it the camera would widen while the 16:9 HUD stretched across the picture, so the game draws the largest
|
||||
// 16:9 size that fits inside the asked one and the camera aspect patch stays off (res_init); gfx_present.c shows the
|
||||
// picture with bars (present setting aspect). Not when TOTK_ROMFS_PATCHES names the overlay itself or the game version
|
||||
// has no camera aspect patch. TOTK_OUTPUT_RES is restart-only (cfg.c, RESTART), so this one decision holds all run.
|
||||
static void shape_fallback(void) {
|
||||
const char *own = cfg_get("TOTK_ROMFS_PATCHES");
|
||||
unsigned out[2] = {R.ow, R.oh};
|
||||
if (own || !res_wants_wide_overlay(out) || romfs_shaped_ready(R.ow, R.oh)) return;
|
||||
unsigned h = R.oh, w = R.ow;
|
||||
if ((uint64_t)w * 9 <= (uint64_t)h * 16) h = (unsigned)((uint64_t)w * 9 / 16) & ~1u;
|
||||
w = (unsigned)((uint64_t)h * 16 / 9) & ~1u;
|
||||
rt_log("resolution: no romfs-patches-%ux%u for the HUD at this shape; the game draws %ux%u (16:9) and the present "
|
||||
"adds bars. Make the layout with: tools/make_romfs_patches.py --version <v121|v143> --aspect %ux%u --out "
|
||||
"build/<version>/romfs-patches-%ux%u", R.ow, R.oh, w, h, R.ow, R.oh, R.ow, R.oh);
|
||||
R.ow = w, R.oh = h;
|
||||
}
|
||||
|
||||
static void decide(void) {
|
||||
if (R.decided) return;
|
||||
R.decided = 1;
|
||||
@@ -131,6 +152,7 @@ static void decide(void) {
|
||||
if (sscanf(o, "%ux%u%c", &w, &h, &tail) == 2 && w >= 1280 && h >= 720 && w <= 7680 && h <= 4320) {
|
||||
R.ow = w & ~1u;
|
||||
R.oh = h & ~1u;
|
||||
shape_fallback();
|
||||
} else {
|
||||
rt_log("resolution: TOTK_OUTPUT_RES=%s is not <width>x<height> in 1280x720..7680x4320; 1920x1080", o);
|
||||
}
|
||||
@@ -189,6 +211,33 @@ uint64_t res_heap_extra_kib(void) {
|
||||
|
||||
uint64_t res_heap_extra_bytes(void) { return ((res_heap_extra_kib() << 10) + 0x1fffff) & ~0x1fffffull; }
|
||||
|
||||
// TOTK_TEX_HEAP_EXTRA_MB (0 to 4096): more room in "TextureHeap" and its parent "ゲームグラフィックス", the heap the
|
||||
// world's textures stream into. HD texture packs made for emulators with 8 GB of DRAM grow it by about 3.3 GiB in their
|
||||
// own HeapDef; without the room the game runs out and starts its panic Blood Moon over and over. The pool and the heap
|
||||
// cap grow by the same amount (memory.c mem_init).
|
||||
static uint64_t mod_tex_heap_kib(void);
|
||||
|
||||
static uint64_t cfg_tex_heap_kib(void) {
|
||||
const char *v = cfg_get("TOTK_TEX_HEAP_EXTRA_MB");
|
||||
if (!v || !*v) return 0;
|
||||
char *end;
|
||||
long mb = strtol(v, &end, 10);
|
||||
if (end == v || *end || mb < 0 || mb > 4096) {
|
||||
static int logged;
|
||||
if (!logged++) rt_log("resolution: TOTK_TEX_HEAP_EXTRA_MB=%s is not 0..4096; 0", v);
|
||||
return 0;
|
||||
}
|
||||
return (uint64_t)mb << 10;
|
||||
}
|
||||
|
||||
// The larger of TOTK_TEX_HEAP_EXTRA_MB and what an enabled mod's own HeapDef adds to the texture heap (mod_tex_heap_kib).
|
||||
static uint64_t tex_heap_extra_kib(void) {
|
||||
uint64_t c = cfg_tex_heap_kib(), m = mod_tex_heap_kib();
|
||||
return c > m ? c : m;
|
||||
}
|
||||
|
||||
uint64_t res_tex_heap_extra_bytes(void) { return ((tex_heap_extra_kib() << 10) + 0x1fffff) & ~0x1fffffull; }
|
||||
|
||||
// ---- the HeapDef table. BYML version 2..7, little endian ("YB"): a hash node is {u8 0xC1, u24 count} and count
|
||||
// entries {u24 key index, u8 type, u32 value}; an int (type 0xD1) is stored in the entry, so a size changes in place.
|
||||
typedef struct { uint8_t *p; size_t n; uint32_t keys; } Byml;
|
||||
@@ -226,28 +275,103 @@ static size_t heap_size_field(const Byml *b, uint32_t heaps, const char *heap) {
|
||||
return at && u32_at(b, at, &node) ? field(b, node, "Size", 0xd1) : 0;
|
||||
}
|
||||
|
||||
int res_heapdef_build(const void *in, size_t n, void **out, size_t *out_n) {
|
||||
uint64_t extra = res_heap_extra_kib();
|
||||
*out = NULL;
|
||||
if (!extra) return 0;
|
||||
// A HeapDef in b's place (zstd frame in[0..n) with its content size, no dictionary): b.p malloc'd and *heaps its
|
||||
// "Heaps" node; 0 when it is not one (b.p freed). why: what the log line says the caller keeps then.
|
||||
static int heapdef_open(const void *in, size_t n, Byml *b, uint32_t *heaps, const char *why) {
|
||||
b->p = NULL;
|
||||
if (ZSTD_getDictID_fromFrame(in, n)) {
|
||||
rt_log("resolution: the HeapDef table needs a zstd dictionary; the game keeps its own resolution");
|
||||
rt_log("resolution: the HeapDef table needs a zstd dictionary; %s", why);
|
||||
return 0;
|
||||
}
|
||||
unsigned long long raw = ZSTD_getFrameContentSize(in, n);
|
||||
if (raw == ZSTD_CONTENTSIZE_UNKNOWN || raw == ZSTD_CONTENTSIZE_ERROR || raw > (64u << 20)) {
|
||||
rt_log("resolution: the HeapDef table is not a zstd frame with a size; the game keeps its own resolution");
|
||||
rt_log("resolution: the HeapDef table is not a zstd frame with a size; %s", why);
|
||||
return 0;
|
||||
}
|
||||
Byml b = {malloc((size_t)raw), (size_t)raw, 0};
|
||||
if (!b.p) return 0;
|
||||
size_t got = ZSTD_decompress(b.p, b.n, in, n);
|
||||
uint32_t root, heaps = 0;
|
||||
*b = (Byml){malloc((size_t)raw), (size_t)raw, 0};
|
||||
if (!b->p) return 0;
|
||||
size_t got = ZSTD_decompress(b->p, b->n, in, n);
|
||||
uint32_t root;
|
||||
uint16_t ver = 0;
|
||||
if (!ZSTD_isError(got) && got == b.n && b.n >= 16) memcpy(&ver, b.p + 2, 2);
|
||||
size_t heaps_at = 0, dta_at = 0, gfx_at = 0;
|
||||
if (ver >= 2 && ver <= 7 && !memcmp(b.p, "YB", 2) && u32_at(&b, 4, &b.keys) && u32_at(&b, 12, &root) &&
|
||||
(heaps_at = field(&b, root, "Heaps", 0xc1)) && u32_at(&b, heaps_at, &heaps)) {
|
||||
if (!ZSTD_isError(got) && got == b->n && b->n >= 16) memcpy(&ver, b->p + 2, 2);
|
||||
size_t heaps_at = 0;
|
||||
if (ver >= 2 && ver <= 7 && !memcmp(b->p, "YB", 2) && u32_at(b, 4, &b->keys) && u32_at(b, 12, &root) &&
|
||||
(heaps_at = field(b, root, "Heaps", 0xc1)) && u32_at(b, heaps_at, heaps))
|
||||
return 1;
|
||||
free(b->p);
|
||||
b->p = NULL;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// HD texture packs made for the emulators ship their own HeapDef with "TextureHeap" and its parent grown (HDPacks.com's
|
||||
// 4K pack for TotK: by 3,419,000 KiB, 3.3 GiB, for the emulators' 8 GB memory option). Served as it is, the game asks
|
||||
// for that room and the application pool has none: it crashed at start (issue #673). So before mem_init sizes the pool,
|
||||
// every switched-on mod's HeapDef is read (mods.c mods_peek_romfs: mods_scan needs paths main sets later) and the
|
||||
// largest TextureHeap growth over the shipped size counts as TOTK_TEX_HEAP_EXTRA_MB (the larger of the two, at most
|
||||
// 4 GiB). romfs.c heapdef_overlay then serves another table grown by that amount in the pack's place: the table of the
|
||||
// highest switched-on mod below it whose texture heap is not grown, else the game's own, so the other heaps keep the
|
||||
// sizes the content that runs needs.
|
||||
#define HEAPDEF_REL "System/HeapSize/Product.Nin_NX_NVN.HeapDef.byml.zs"
|
||||
static struct { int done; uint64_t kib; } MT;
|
||||
|
||||
// "TextureHeap" of the HeapDef table in[0..n) in KiB; 0 when the table cannot be read or has no such size (the failure
|
||||
// is logged with why).
|
||||
static int32_t heapdef_tex_kib(const void *in, size_t n, const char *why) {
|
||||
Byml b;
|
||||
uint32_t heaps;
|
||||
size_t th_at = 0;
|
||||
int32_t th = 0;
|
||||
if (!heapdef_open(in, n, &b, &heaps, why)) return 0;
|
||||
if ((th_at = heap_size_field(&b, heaps, "TextureHeap"))) memcpy(&th, b.p + th_at, 4);
|
||||
else rt_log("resolution: the HeapDef table has no TextureHeap size; %s", why);
|
||||
free(b.p);
|
||||
return th;
|
||||
}
|
||||
|
||||
static void mod_heapdef(const char *name, const char *romfs, void *ctx) {
|
||||
(void)ctx;
|
||||
char path[2300], why[400];
|
||||
if (snprintf(path, sizeof path, "%s/%s", romfs, HEAPDEF_REL) >= (int)sizeof path) return;
|
||||
uint64_t n = 0;
|
||||
uint8_t *in = plat_file_read(path, &n);
|
||||
if (!in) return;
|
||||
snprintf(why, sizeof why, "mod %s: its texture heap growth is left out", name);
|
||||
int32_t th = heapdef_tex_kib(in, (size_t)n, why);
|
||||
free(in);
|
||||
if (!th) return;
|
||||
int64_t grow = (int64_t)th - (int64_t)TEX_HEAP_KIB;
|
||||
if (grow > (int64_t)(4096u << 10)) grow = 4096u << 10;
|
||||
if (grow > 0 && (uint64_t)grow > MT.kib) MT.kib = (uint64_t)grow;
|
||||
rt_log("resolution: mod %s ships a HeapDef with TextureHeap %d KiB (the game's %u KiB)%s", name, th, TEX_HEAP_KIB,
|
||||
grow > 0 ? ": the port grows the texture heap and the pool by the difference" : "");
|
||||
}
|
||||
|
||||
static uint64_t mod_tex_heap_kib(void) {
|
||||
if (!MT.done) MT.done = 1, mods_peek_romfs(mod_heapdef, NULL);
|
||||
return MT.kib;
|
||||
}
|
||||
|
||||
// Only such a table is swapped for the game's own (romfs.c heapdef_overlay): a mod table that changes other heaps
|
||||
// keeps them. A table it cannot read stays the base (logged).
|
||||
int res_heapdef_tex_grown(const void *in, size_t n) {
|
||||
return heapdef_tex_kib(in, n, "the mod's table stays the base") > (int32_t)TEX_HEAP_KIB;
|
||||
}
|
||||
|
||||
// A table that can stand in for an HD pack's (romfs.c heapdef_overlay): readable, with TextureHeap at most the shipped
|
||||
// size. A content mod's table (The Second Quest grows RSDB and UI for its own files) is one.
|
||||
int res_heapdef_plain(const void *in, size_t n) {
|
||||
int32_t th = heapdef_tex_kib(in, n, "it does not stand in for the texture pack's table");
|
||||
return th > 0 && th <= (int32_t)TEX_HEAP_KIB;
|
||||
}
|
||||
|
||||
int res_heapdef_build(const void *in, size_t n, void **out, size_t *out_n) {
|
||||
uint64_t extra = res_heap_extra_kib(), tex = tex_heap_extra_kib();
|
||||
*out = NULL;
|
||||
if (!extra && !tex) return 0;
|
||||
Byml b;
|
||||
uint32_t heaps = 0;
|
||||
size_t dta_at = 0, gfx_at = 0;
|
||||
if (heapdef_open(in, n, &b, &heaps, "the game keeps its own resolution")) {
|
||||
dta_at = heap_size_field(&b, heaps, "DynamicTextureAllocatorMem");
|
||||
gfx_at = heap_size_field(&b, heaps, "Graphics");
|
||||
}
|
||||
@@ -263,6 +387,26 @@ int res_heapdef_build(const void *in, size_t n, void **out, size_t *out_n) {
|
||||
int32_t dta2 = dta + (int32_t)extra, gfx2 = gfx + (int32_t)extra;
|
||||
memcpy(b.p + dta_at, &dta2, 4);
|
||||
memcpy(b.p + gfx_at, &gfx2, 4);
|
||||
int32_t th = 0, th2 = 0, gg = 0, gg2 = 0;
|
||||
if (tex) { // TOTK_TEX_HEAP_EXTRA_MB: the world texture heap and its parent grow by the same amount
|
||||
static const char parent[] = "\xe3\x82\xb2\xe3\x83\xbc\xe3\x83\xa0\xe3\x82\xb0\xe3\x83\xa9\xe3\x83\x95"
|
||||
"\xe3\x82\xa3\xe3\x83\x83\xe3\x82\xaf\xe3\x82\xb9"; // ゲームグラフィックス
|
||||
size_t th_at = heap_size_field(&b, heaps, "TextureHeap"), gg_at = heap_size_field(&b, heaps, parent);
|
||||
if (th_at && gg_at) {
|
||||
memcpy(&th, b.p + th_at, 4);
|
||||
memcpy(&gg, b.p + gg_at, 4);
|
||||
th2 = th + (int32_t)tex, gg2 = gg + (int32_t)tex;
|
||||
memcpy(b.p + th_at, &th2, 4);
|
||||
memcpy(b.p + gg_at, &gg2, 4);
|
||||
} else {
|
||||
rt_log("resolution: the HeapDef table has no TextureHeap sizes; TOTK_TEX_HEAP_EXTRA_MB left out");
|
||||
tex = 0;
|
||||
if (!extra) {
|
||||
free(b.p);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
size_t cap = ZSTD_compressBound(b.n);
|
||||
void *z = malloc(cap);
|
||||
size_t zn = z ? ZSTD_compress(z, cap, b.p, b.n, 9) : 0;
|
||||
@@ -273,7 +417,12 @@ int res_heapdef_build(const void *in, size_t n, void **out, size_t *out_n) {
|
||||
}
|
||||
*out = z;
|
||||
*out_n = zn;
|
||||
rt_log("resolution: HeapDef DynamicTextureAllocatorMem %d -> %d KiB, Graphics %d -> %d KiB", dta, dta2, gfx, gfx2);
|
||||
if (extra)
|
||||
rt_log("resolution: HeapDef DynamicTextureAllocatorMem %d -> %d KiB, Graphics %d -> %d KiB", dta, dta2, gfx,
|
||||
gfx2);
|
||||
if (tex)
|
||||
rt_log("resolution: HeapDef TextureHeap %d -> %d KiB and its parent %d -> %d KiB (TOTK_TEX_HEAP_EXTRA_MB or a "
|
||||
"mod's HeapDef)", th, th2, gg, gg2);
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -234,3 +234,102 @@ uint32_t restbl_grow(const char *key, uint32_t entry, uint64_t old, uint64_t new
|
||||
uint64_t e = entry + ((new_size - old) * f + 999) / 1000;
|
||||
return clamp32((e + 31) & ~(uint64_t)31);
|
||||
}
|
||||
|
||||
// ---- packs
|
||||
// SARC: "SARC", u16 header size, u16 byte order mark 0xfeff, u32 archive size, u32 data offset, u16 version, u16 pad;
|
||||
// SFAT: "SFAT", u16 header size, u16 node count, u32 hash key, then per node {u32 name hash, u32 attributes (bit 24: it
|
||||
// has a name, bits 0-23: the name's offset in SFNT / 4), u32 data start, u32 data end} with the data offsets relative
|
||||
// to the archive's data offset; SFNT: "SFNT", u16 header size, u16 pad, then the NUL-terminated names.
|
||||
static uint32_t rd16(const uint8_t *p) { return p[0] | p[1] << 8; }
|
||||
|
||||
int restbl_sarc_each(const uint8_t *p, size_t n, RestblMember *each, void *ctx) {
|
||||
if (n < 0x14 || memcmp(p, "SARC", 4) || rd16(p + 6) != 0xfeff) return 0;
|
||||
size_t head = rd16(p + 4), data = rd32(p + 12);
|
||||
if (head + 12 > n || memcmp(p + head, "SFAT", 4) || data > n) return 0;
|
||||
size_t count = rd16(p + head + 6), nodes = head + rd16(p + head + 4), names = nodes + count * 16;
|
||||
if (names + 8 > n || memcmp(p + names, "SFNT", 4) || rd16(p + names + 4) < 8) return 0;
|
||||
names += rd16(p + names + 4);
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
const uint8_t *e = p + nodes + i * 16;
|
||||
uint32_t attr = rd32(e + 4), lo = rd32(e + 8), hi = rd32(e + 12);
|
||||
if (!(attr >> 24)) continue; // without a name: nothing the game looks up by path
|
||||
size_t s = names + (size_t)(attr & 0xffffff) * 4;
|
||||
if (s >= n || !memchr(p + s, 0, n - s) || lo > hi || hi > n - data) return 0;
|
||||
each((const char *)p + s, p + data + lo, hi - lo, ctx);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The decompressed size a zstd frame p[0..n) records (Frame_Content_Size), or 4 times n when it records none, as
|
||||
// romfs.c content_size takes a loose file's.
|
||||
static uint64_t frame_content_size(const uint8_t *p, size_t n) {
|
||||
static const uint8_t did_bytes[4] = {0, 1, 2, 4};
|
||||
if (n < 6 || rd32(p) != 0xFD2FB528u) return n;
|
||||
uint8_t fhd = p[4];
|
||||
int single = fhd >> 5 & 1, fcs = fhd >> 6;
|
||||
size_t at = 5 + !single + did_bytes[fhd & 3], len = fcs ? 1u << fcs : single;
|
||||
if (!len || at + len > n) return (uint64_t)n * 4;
|
||||
uint64_t v = 0;
|
||||
for (size_t i = 0; i < len; i++) v |= (uint64_t)p[at + i] << 8 * i;
|
||||
return len == 2 ? v + 256 : v;
|
||||
}
|
||||
|
||||
typedef struct { const char *name; uint64_t size; } Member;
|
||||
typedef struct { Member *m; size_t n, cap; int full; } Members;
|
||||
|
||||
static int ends_with(const char *s, const char *tail) {
|
||||
size_t n = strlen(s), k = strlen(tail);
|
||||
return n >= k && !strcmp(s + n - k, tail);
|
||||
}
|
||||
|
||||
// A member's size as the game unpacks it: a .zs member's content size, else its bytes (a .mc member's compressed ones).
|
||||
static void member_add(const char *name, const uint8_t *data, size_t size, void *ctx) {
|
||||
Members *ms = ctx;
|
||||
if (ms->n == ms->cap) {
|
||||
size_t cap = ms->cap ? ms->cap * 2 : 64;
|
||||
Member *grown = realloc(ms->m, cap * sizeof *grown);
|
||||
if (!grown) {
|
||||
ms->full = 1;
|
||||
return;
|
||||
}
|
||||
ms->m = grown, ms->cap = cap;
|
||||
}
|
||||
ms->m[ms->n++] = (Member){name, ends_with(name, ".zs") ? frame_content_size(data, size) : size};
|
||||
}
|
||||
|
||||
static int by_name(const void *a, const void *b) {
|
||||
return strcmp(((const Member *)a)->name, ((const Member *)b)->name);
|
||||
}
|
||||
|
||||
int restbl_pack_raise(Restbl *t, const Restbl *base, const uint8_t *mod, size_t mod_n, const uint8_t *orig,
|
||||
size_t orig_n, RestblNote *note, void *ctx) {
|
||||
Members m = {0}, o = {0};
|
||||
int raised = -1;
|
||||
if (!restbl_sarc_each(mod, mod_n, member_add, &m) || m.full ||
|
||||
(orig && (!restbl_sarc_each(orig, orig_n, member_add, &o) || o.full)))
|
||||
goto done;
|
||||
if (o.n) qsort(o.m, o.n, sizeof *o.m, by_name);
|
||||
raised = 0;
|
||||
for (size_t i = 0; i < m.n; i++) {
|
||||
char key[1024];
|
||||
uint32_t entry, before = 0, want;
|
||||
restbl_key(m.m[i].name, key, sizeof key);
|
||||
int mc = ends_with(m.m[i].name, ".mc");
|
||||
const Member *was = o.n ? bsearch(&m.m[i], o.m, o.n, sizeof *o.m, by_name) : NULL;
|
||||
if (was) {
|
||||
if (!restbl_get(base, key, &entry)) continue; // the game sizes it without the table (streamed)
|
||||
want = restbl_grow(key, entry, was->size, m.m[i].size, mc);
|
||||
} else { // new to this pack: no copy of the game's to grow from
|
||||
want = restbl_estimate(key, mc ? m.m[i].size * 8 : m.m[i].size);
|
||||
}
|
||||
restbl_get(t, key, &before);
|
||||
if (want && restbl_raise(t, key, want)) {
|
||||
raised++;
|
||||
if (note) note(key, before, want, ctx);
|
||||
}
|
||||
}
|
||||
done:
|
||||
free(m.m);
|
||||
free(o.m);
|
||||
return raised;
|
||||
}
|
||||
@@ -31,3 +31,20 @@ uint32_t restbl_estimate(const char *key, uint64_t size);
|
||||
// times the type's factor, never lowered. scaled: the sizes are compressed ones (a mesh codec .mc file), whose entry
|
||||
// grows in proportion.
|
||||
uint32_t restbl_grow(const char *key, uint32_t entry, uint64_t old, uint64_t new_size, int scaled);
|
||||
|
||||
// The members of the SARC archive p[0..n): each(name, data, size, ctx) for every named member, in the archive's order.
|
||||
// 0 when p is not a SARC archive or a member lies outside it.
|
||||
typedef void RestblMember(const char *name, const uint8_t *data, size_t size, void *ctx);
|
||||
int restbl_sarc_each(const uint8_t *p, size_t n, RestblMember *each, void *ctx);
|
||||
|
||||
// A pack (Pack/**/*.pack.zs, decompressed: a SARC archive) whose members the game loads as resources of their own, each
|
||||
// sized by its own entry. The entries of a mod's pack `mod` raised in t against the game's table `base` and the game's
|
||||
// copy of the pack `orig` (NULL for a pack the game lacks): a member that grew by restbl_grow, a member the game's copy
|
||||
// lacks by restbl_estimate, the others left as they are. note(key, entry before, entry after, ctx) for each raised
|
||||
// entry. The number raised; -1 when either pack is not a SARC archive.
|
||||
typedef void RestblNote(const char *key, uint32_t was, uint32_t now, void *ctx);
|
||||
int restbl_pack_raise(Restbl *t, const Restbl *base, const uint8_t *mod, size_t mod_n, const uint8_t *orig,
|
||||
size_t orig_n, RestblNote *note, void *ctx);
|
||||
|
||||
// restbl_probe.c, part of --probe-mods: the number of failed checks.
|
||||
int restbl_probe(void);
|
||||
@@ -0,0 +1,133 @@
|
||||
// The entries of the members of the mods' packs (restbl.c restbl_pack_raise), on archives and tables of its own; part
|
||||
// of --probe-mods (mods_probe.c). Needs no game files.
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "restbl.h"
|
||||
|
||||
static int fails;
|
||||
#define CHECK(c, what) do { if (!(c)) { fails++; fprintf(stderr, "FAIL restbl: %s\n", what); } } while (0)
|
||||
|
||||
// A member of a probe archive: its name and size; a .zs member is a zstd frame header recording `content` bytes:
|
||||
// fcs 4, a frame with a window descriptor and a 4-byte field; 2, a single-segment frame with a 2-byte field holding
|
||||
// content - 256.
|
||||
typedef struct { const char *name; size_t size, content; int fcs; } Member;
|
||||
|
||||
static void wr16(uint8_t *p, unsigned v) { p[0] = (uint8_t)v, p[1] = (uint8_t)(v >> 8); }
|
||||
static void wr32(uint8_t *p, uint32_t v) { wr16(p, v & 0xffff), wr16(p + 2, v >> 16); }
|
||||
|
||||
// A SARC archive of the members m[0..n); unnamed: the index of a member without a name, -1 for none. malloc'd.
|
||||
static uint8_t *sarc(const Member *m, int n, int unnamed, size_t *out_n) {
|
||||
size_t names = 0, data = 0;
|
||||
for (int i = 0; i < n; i++) names += (strlen(m[i].name) + 4) & ~(size_t)3, data += (m[i].size + 7) & ~(size_t)7;
|
||||
size_t sfat = 0x14, sfnt = sfat + 12 + 16 * (size_t)n, start = (sfnt + 8 + names + 15) & ~(size_t)15;
|
||||
size_t total = start + data;
|
||||
uint8_t *p = calloc(1, total);
|
||||
if (!p) return NULL;
|
||||
memcpy(p, "SARC", 4), wr16(p + 4, 0x14), wr16(p + 6, 0xfeff);
|
||||
wr32(p + 8, (uint32_t)total), wr32(p + 12, (uint32_t)start);
|
||||
memcpy(p + sfat, "SFAT", 4), wr16(p + sfat + 4, 12), wr16(p + sfat + 6, (unsigned)n), wr32(p + sfat + 8, 0x65);
|
||||
memcpy(p + sfnt, "SFNT", 4), wr16(p + sfnt + 4, 8);
|
||||
size_t name_at = 0, data_at = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
uint8_t *node = p + sfat + 12 + 16 * (size_t)i, *d = p + start + data_at;
|
||||
wr32(node + 4, i == unnamed ? 0 : 0x01000000u | (uint32_t)(name_at / 4));
|
||||
wr32(node + 8, (uint32_t)data_at), wr32(node + 12, (uint32_t)(data_at + m[i].size));
|
||||
memcpy(p + sfnt + 8 + name_at, m[i].name, strlen(m[i].name));
|
||||
memset(d, 0x5a, m[i].size);
|
||||
if (m[i].fcs == 4) wr32(d, 0xFD2FB528u), d[4] = 0x80, d[5] = 0x58, wr32(d + 6, (uint32_t)m[i].content);
|
||||
if (m[i].fcs == 2) wr32(d, 0xFD2FB528u), d[4] = 0x60, wr16(d + 5, (unsigned)(m[i].content - 256));
|
||||
name_at += (strlen(m[i].name) + 4) & ~(size_t)3;
|
||||
data_at += (m[i].size + 7) & ~(size_t)7;
|
||||
}
|
||||
*out_n = total;
|
||||
return p;
|
||||
}
|
||||
|
||||
// An empty table with the entries keys[i] = sizes[i].
|
||||
static Restbl *table(const char *const *keys, const uint32_t *sizes, int n) {
|
||||
uint8_t head[22] = "RESTBL";
|
||||
wr32(head + 6, 1), wr32(head + 10, 160), wr32(head + 14, 0), wr32(head + 18, 0);
|
||||
Restbl *t = restbl_parse(head, sizeof head);
|
||||
for (int i = 0; t && i < n; i++) restbl_raise(t, keys[i], sizes[i]);
|
||||
return t;
|
||||
}
|
||||
|
||||
static uint32_t get(const Restbl *t, const char *key) {
|
||||
uint32_t v = 0;
|
||||
return restbl_get(t, key, &v) ? v : 0;
|
||||
}
|
||||
|
||||
static int visited;
|
||||
static void count(const char *name, const uint8_t *data, size_t size, void *ctx) {
|
||||
(void)name, (void)data, (void)size, (void)ctx;
|
||||
visited++;
|
||||
}
|
||||
|
||||
static int notes;
|
||||
static void note(const char *key, uint32_t was, uint32_t now, void *ctx) {
|
||||
(void)key, (void)ctx;
|
||||
notes += now > was;
|
||||
}
|
||||
|
||||
int restbl_probe(void) {
|
||||
fails = 0;
|
||||
static const char *const keys[] = {"AI/Grow.root.ainb", "AI/Same.root.ainb", "AI/Shrink.root.ainb",
|
||||
"Component/Shared.bgyml", "Model/Mesh.bfres", "AI/Zs.ainb", "AI/Zs2.ainb"};
|
||||
static const uint32_t sizes[] = {13512, 5000, 6000, 9000, 30000, 4400, 3500};
|
||||
// The game's copy: an .ainb member a mod grows (by 288 bytes, past its entry's slack), one it keeps, one it
|
||||
// shrinks, one the table does not size, a mesh codec member and two compressed ones.
|
||||
static const Member orig[] = {{"AI/Grow.root.ainb", 13088, 0, 0}, {"AI/Same.root.ainb", 4600, 0, 0},
|
||||
{"AI/Shrink.root.ainb", 5600, 0, 0}, {"AI/NotSized.ainb", 100, 0, 0},
|
||||
{"Model/Mesh.bfres.mc", 1000, 0, 0}, {"AI/Zs.ainb.zs", 64, 4000, 4},
|
||||
{"AI/Zs2.ainb.zs", 64, 3900, 2}};
|
||||
// The mod's copy: those, plus members new to the pack (one the game sizes elsewhere, one it lacks, a streamed one).
|
||||
static const Member mod[] = {{"AI/Grow.root.ainb", 13376, 0, 0}, {"AI/Same.root.ainb", 4600, 0, 0},
|
||||
{"AI/Shrink.root.ainb", 5000, 0, 0}, {"AI/NotSized.ainb", 300, 0, 0},
|
||||
{"Component/New/X.bgyml", 1000, 0, 0}, {"Component/Shared.bgyml", 2000, 0, 0},
|
||||
{"Sound/Stream.bwav", 500, 0, 0}, {"Model/Mesh.bfres.mc", 1500, 0, 0},
|
||||
{"AI/Zs.ainb.zs", 64, 4500, 4}, {"AI/Zs2.ainb.zs", 64, 4200, 2}};
|
||||
size_t on = 0, mn = 0;
|
||||
uint8_t *op = sarc(orig, 7, -1, &on), *mp = sarc(mod, 10, 4, &mn); // the mod's New/X has no name: left alone
|
||||
Restbl *base = table(keys, sizes, 7), *t = table(keys, sizes, 7);
|
||||
CHECK(op && mp && base && t, "probe archives and tables");
|
||||
if (op && mp && base && t) {
|
||||
visited = 0;
|
||||
CHECK(restbl_sarc_each(mp, mn, count, NULL) && visited == 9, "SARC members: every named one");
|
||||
int r = restbl_pack_raise(t, base, mp, mn, op, on, note, NULL);
|
||||
CHECK(r == 5 && notes == 5, "five entries raised, each noted");
|
||||
CHECK(get(t, "AI/Grow.root.ainb") == 13824, "a member that grew: its entry plus the growth, 32-byte steps");
|
||||
CHECK(get(t, "AI/Same.root.ainb") == 5000 && get(t, "AI/Shrink.root.ainb") == 6000,
|
||||
"members that kept or lost bytes keep their entries");
|
||||
CHECK(!get(t, "AI/NotSized.ainb"), "a member the game sizes without the table gets no entry");
|
||||
CHECK(!get(t, "Component/New/X.bgyml"), "a member without a name is not looked up");
|
||||
CHECK(get(t, "Component/Shared.bgyml") == 16384, "a member new to the pack: the estimate of its type");
|
||||
CHECK(!get(t, "Sound/Stream.bwav"), "a streamed member new to the pack gets no entry");
|
||||
CHECK(get(t, "Model/Mesh.bfres") == 45000, "a mesh codec member's entry grows in proportion");
|
||||
CHECK(get(t, "AI/Zs.ainb") == 4928 && get(t, "AI/Zs2.ainb") == 3808,
|
||||
"a compressed member: the growth of the size its frame records (4- and 2-byte fields)");
|
||||
notes = 0;
|
||||
CHECK(restbl_pack_raise(t, base, mp, mn, op, on, note, NULL) == 0 && !notes, "a second pass raises nothing");
|
||||
Restbl *shipped = table(keys, (const uint32_t[]){20000, 5000, 6000, 9000, 30000, 4400, 3500}, 7);
|
||||
CHECK(shipped && restbl_pack_raise(shipped, base, mp, mn, op, on, NULL, NULL) == 4 &&
|
||||
get(shipped, "AI/Grow.root.ainb") == 20000,
|
||||
"an entry a mod's own table raised further stays");
|
||||
restbl_free(shipped);
|
||||
Restbl *added = table(keys, sizes, 7);
|
||||
CHECK(added && restbl_pack_raise(added, base, mp, mn, NULL, 0, NULL, NULL) == 7 &&
|
||||
get(added, "AI/Grow.root.ainb") == 24576 && get(added, "AI/NotSized.ainb") == 12288 &&
|
||||
get(added, "AI/Zs2.ainb") == 16384 && get(added, "Model/Mesh.bfres") == 30000,
|
||||
"a pack the game lacks: every member gets the estimate of its type, an entry above it stays");
|
||||
restbl_free(added);
|
||||
CHECK(restbl_pack_raise(t, base, (const uint8_t *)"SARC", 4, NULL, 0, NULL, NULL) == -1 &&
|
||||
restbl_pack_raise(t, base, mp, mn, mp, 0x20, NULL, NULL) == -1,
|
||||
"an archive that is not one, or whose game copy is cut short, raises nothing");
|
||||
}
|
||||
free(op);
|
||||
free(mp);
|
||||
restbl_free(base);
|
||||
restbl_free(t);
|
||||
printf("restbl: %s\n", fails ? "FAIL" : "ok");
|
||||
return fails;
|
||||
}
|
||||
+228
-26
@@ -24,8 +24,8 @@
|
||||
// matches a RomFS path only without case is served under the RomFS spelling.
|
||||
//
|
||||
// The resource size table: with a player mod enabled, the game's table is rebuilt at start with the tables the mods
|
||||
// ship merged in and the entries of the mods' files raised to what they need (restbl_overlay), and served from
|
||||
// <profile>/mods/ in place of the game's or a mod's table.
|
||||
// ship merged in and the entries of the mods' files raised to what they need (restbl_overlay), the members of their
|
||||
// packs too (restbl_packs), and served from <profile>/mods/ in place of the game's or a mod's table.
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -533,6 +533,22 @@ static void added_init(void) {
|
||||
rt_log("RomFS overlay: %zu files added to the RomFS", nadded);
|
||||
}
|
||||
|
||||
// RomFS file `rel` as the image holds it, whatever overlays replace it. malloc'd; NULL when it is missing, unreadable
|
||||
// or 16 MiB or more (logged with `keeps`, what the game does without it).
|
||||
static void *read_original(const char *rel, size_t *n, const char *keeps) {
|
||||
uint64_t i = lower(rel);
|
||||
if (i >= file_count || strcmp(entry_name(entry(i)), rel)) return NULL;
|
||||
uint64_t size = entry(i).size;
|
||||
void *in = size && size < (16u << 20) ? malloc((size_t)size) : NULL;
|
||||
if (!in || read_at(image, in, (uint32_t)size, entry(i).offset) != (int64_t)size) {
|
||||
free(in);
|
||||
rt_log("RomFS: cannot read %s; %s", rel, keeps);
|
||||
return NULL;
|
||||
}
|
||||
*n = (size_t)size;
|
||||
return in;
|
||||
}
|
||||
|
||||
// RomFS file `rel` as the game would read it: from an overlay that replaces it (*base), else from the image. malloc'd;
|
||||
// NULL when it is missing, unreadable or 16 MiB or more (logged with `keeps`, what the game does without it).
|
||||
static void *read_served(const char *rel, Overlay **base, size_t *n, const char *keeps) {
|
||||
@@ -621,10 +637,122 @@ static Restbl *restbl_unpack(const void *z, size_t n) {
|
||||
return t;
|
||||
}
|
||||
|
||||
// ---- the members of the mods' packs (restbl_pack_raise). The game loads each member of a pack (Pack/**/*.pack.zs, a
|
||||
// SARC archive compressed with a dictionary of Pack/ZsDic.pack.zs) as a resource of its own, in a heap sized by the
|
||||
// member's own entry, so a mod that changes a member and ships no table for it (or an older one) leaves the game's
|
||||
// entry. A member that outgrew its entry's slack loads, then its heap runs out: TotK 1.4.3 writes through a null
|
||||
// pointer at main+0x21794c when the 0x68-byte object an .ainb member builds after its file no longer fits.
|
||||
#define PACK_MAX (256u << 20) // the largest pack taken, decompressed (AI.Global.Product.140.pack: 24 MiB)
|
||||
typedef struct { ZSTD_DDict *dict[4]; unsigned id[4]; int n; ZSTD_DCtx *dctx; } Packs;
|
||||
|
||||
// The bytes of zstd frame z[0..n), with the dictionary of ps its header names; malloc'd. NULL when it is not such a
|
||||
// frame with a recorded size of at most PACK_MAX.
|
||||
static uint8_t *pack_unpack(Packs *ps, const void *z, size_t n, size_t *out_n) {
|
||||
unsigned long long raw = ZSTD_getFrameContentSize(z, n);
|
||||
unsigned id = ZSTD_getDictID_fromFrame(z, n);
|
||||
ZSTD_DDict *dd = NULL;
|
||||
for (int i = 0; i < ps->n; i++)
|
||||
if (ps->id[i] == id) dd = ps->dict[i];
|
||||
if (raw == ZSTD_CONTENTSIZE_UNKNOWN || raw == ZSTD_CONTENTSIZE_ERROR || raw > PACK_MAX || (id && !dd) || !ps->dctx)
|
||||
return NULL;
|
||||
uint8_t *d = malloc(raw ? (size_t)raw : 1);
|
||||
size_t got = !d ? 0 : dd ? ZSTD_decompress_usingDDict(ps->dctx, d, (size_t)raw, z, n, dd)
|
||||
: ZSTD_decompressDCtx(ps->dctx, d, (size_t)raw, z, n);
|
||||
if (!d || ZSTD_isError(got) || got != raw) {
|
||||
free(d);
|
||||
return NULL;
|
||||
}
|
||||
*out_n = (size_t)raw;
|
||||
return d;
|
||||
}
|
||||
|
||||
static void pack_dict_add(const char *name, const uint8_t *data, size_t size, void *ctx) {
|
||||
Packs *ps = ctx;
|
||||
ZSTD_DDict *dd = ps->n < 4 && ends_with(name, ".zsdic") ? ZSTD_createDDict(data, size) : NULL; // a copy
|
||||
if (!dd) return;
|
||||
ps->dict[ps->n] = dd;
|
||||
ps->id[ps->n++] = ZSTD_getDictID_fromDDict(dd);
|
||||
}
|
||||
|
||||
// The dictionaries of Pack/ZsDic.pack.zs as served (zs.zsdic, pack.zsdic, bcett.byml.zsdic): a SARC compressed
|
||||
// without one. 0 when it holds none.
|
||||
static int packs_open(Packs *ps) {
|
||||
Overlay *base;
|
||||
size_t n = 0, rn = 0;
|
||||
ps->dctx = ZSTD_createDCtx();
|
||||
void *z = read_served("Pack/ZsDic.pack.zs", &base, &n, "the members of the mods' packs keep their entries");
|
||||
uint8_t *raw = z ? pack_unpack(ps, z, n, &rn) : NULL;
|
||||
int ok = raw && restbl_sarc_each(raw, rn, pack_dict_add, ps) && ps->n;
|
||||
free(z);
|
||||
free(raw);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static void packs_close(Packs *ps) {
|
||||
for (int i = 0; i < ps->n; i++) ZSTD_freeDDict(ps->dict[i]);
|
||||
ZSTD_freeDCtx(ps->dctx);
|
||||
}
|
||||
|
||||
// Overlay o (a pack) and the game's copy of it, decompressed; NULL when o cannot be read or unpacked. *orig stays NULL
|
||||
// for a pack the game lacks or whose copy cannot be read.
|
||||
static uint8_t *pack_pair(Packs *ps, const Overlay *o, size_t *n, uint8_t **orig, size_t *orig_n) {
|
||||
uint64_t zn = 0;
|
||||
void *z = plat_file_read(o->path, &zn);
|
||||
uint8_t *mod = z ? pack_unpack(ps, z, (size_t)zn, n) : NULL;
|
||||
free(z);
|
||||
*orig = NULL;
|
||||
if (!mod || o->index == ADDED) return mod;
|
||||
Entry e = entry(o->index);
|
||||
void *g = e.size < READ_CHUNK ? malloc(e.size ? (size_t)e.size : 1) : NULL;
|
||||
if (g && read_at(image, g, (uint32_t)e.size, e.offset) == (int64_t)e.size)
|
||||
*orig = pack_unpack(ps, g, (size_t)e.size, orig_n);
|
||||
free(g);
|
||||
return mod;
|
||||
}
|
||||
|
||||
typedef struct { const char *pack, *mod; int lines; } PackNote;
|
||||
|
||||
static void pack_note(const char *key, uint32_t was, uint32_t now, void *ctx) {
|
||||
PackNote *pn = ctx;
|
||||
if (pn->lines++ < 16)
|
||||
rt_log("RomFS: resource size table: %s in %s (%s): entry %u -> %u", key, pn->pack, pn->mod, was, now);
|
||||
}
|
||||
|
||||
// The entries of the members of every pack a mod serves, raised in out against the game's table base; the number
|
||||
// raised, *packs the number of packs with one.
|
||||
static int restbl_packs(Restbl *out, const Restbl *base, int *packs, int *lines) {
|
||||
Packs ps = {0};
|
||||
int raised = 0, opened = 0, bad = 0;
|
||||
*packs = 0;
|
||||
for (size_t k = 0; k < noverlay; k++) {
|
||||
const Overlay *o = &overlays[k];
|
||||
if ((!o->mod && o->prio != 1) || !ends_with(o->name, ".pack.zs")) continue;
|
||||
if (!opened++ && !packs_open(&ps)) {
|
||||
rt_log("RomFS: no zstd dictionary in Pack/ZsDic.pack.zs; the members of mods' packs keep their entries");
|
||||
break;
|
||||
}
|
||||
// A pack whose game copy cannot be read counts as new: each member gets an estimate, which only raises entries.
|
||||
size_t n = 0, orig_n = 0;
|
||||
uint8_t *orig, *mod = pack_pair(&ps, o, &n, &orig, &orig_n);
|
||||
PackNote pn = {o->name, owner_name(o), *lines};
|
||||
int r = mod ? restbl_pack_raise(out, base, mod, n, orig, orig_n, pack_note, &pn) : -1;
|
||||
if (r < 0 && bad++ < 4) rt_log("RomFS: %s (%s) is not a pack this build reads; its members keep their entries",
|
||||
o->name, owner_name(o));
|
||||
*lines = pn.lines;
|
||||
raised += r > 0 ? r : 0;
|
||||
*packs += r > 0;
|
||||
free(mod);
|
||||
free(orig);
|
||||
}
|
||||
packs_close(&ps);
|
||||
return raised;
|
||||
}
|
||||
|
||||
// With a player mod (or the TOTK_ROMFS_MODS manifest) serving files: the game's table with every shipped table of this
|
||||
// version merged in (an entry larger than the game's wins), the entries a mod's table for another version gives for
|
||||
// that mod's files, and each served mod file's entry raised to what its size needs (restbl_grow for a file the game
|
||||
// has, restbl_estimate for an added one). Never lowers the game's entries.
|
||||
// has, restbl_estimate for an added one), the members of the packs mods serve too (restbl_packs). Never lowers the
|
||||
// game's entries.
|
||||
static void restbl_overlay(void) {
|
||||
int player = 0;
|
||||
for (size_t k = 0; k < noverlay; k++) player |= overlays[k].mod || overlays[k].prio == 1;
|
||||
@@ -691,8 +819,10 @@ static void restbl_overlay(void) {
|
||||
}
|
||||
raised += want && restbl_raise(out, key, want);
|
||||
}
|
||||
int packs = 0, lines = 0, members = restbl_packs(out, base, &packs, &lines);
|
||||
if (lines > 16) rt_log("RomFS: resource size table: %d more entries of pack members raised", lines - 16);
|
||||
restbl_free(base);
|
||||
if (!merged && !raised && !served) {
|
||||
if (!merged && !raised && !members && !served) {
|
||||
restbl_free(out);
|
||||
rt_log("RomFS: resource size table: the mods' files fit the game's entries (%.0f ms)", (now_s() - t0) * 1000);
|
||||
return;
|
||||
@@ -711,20 +841,59 @@ static void restbl_overlay(void) {
|
||||
}
|
||||
if (serve_generated(restbl_rel, served, z, zn, "mods", "merged resource sizes", keeps))
|
||||
rt_log("RomFS: resource size table: %d tables merged (%d entries), %d entries raised for mod files (%d "
|
||||
"added files estimated), %.0f ms", tables, merged, raised, estimated, (now_s() - t0) * 1000);
|
||||
"added files estimated), %d for members of %d mod packs, %.0f ms", tables, merged, raised, estimated,
|
||||
members, packs, (now_s() - t0) * 1000);
|
||||
}
|
||||
|
||||
// The HeapDef table a bigger game resolution needs (resolution.c): its texture heap and that heap's parent grow. The
|
||||
// base is an overlay that replaces the table (a mod's heap sizes stay), else the RomFS original; the grown table is
|
||||
// written to <profile>/resolution/ and served in the base's place.
|
||||
// The HeapDef table a bigger game resolution or TOTK_TEX_HEAP_EXTRA_MB needs (resolution.c): its texture heaps and
|
||||
// their parents grow. The base is an overlay that replaces the table (a mod's heap sizes stay), else the RomFS
|
||||
// original; the grown table is written to <profile>/resolution/ and served in the base's place. A served mod table
|
||||
// whose own TextureHeap is grown (an HD texture pack's, resolution.c res_heapdef_tex_grown) is not the base: its
|
||||
// growth is already in the amount (resolution.c mod_tex_heap_kib). In its place the table of the highest switched-on
|
||||
// mod below it that changes other heaps only grows by the amount (plain_mod_heapdef: with the pack above The Second
|
||||
// Quest, the default order for HDPacks.com's "4kTextures", the game's own sizes for RSDB and UI were too small for
|
||||
// that mod's files and the game crashed loading a save), else the RomFS original; either is served in the pack's
|
||||
// place. A mod table that changes other heaps only and is served itself stays the base.
|
||||
#define HEAPDEF "System/HeapSize/Product.Nin_NX_NVN.HeapDef.byml.zs"
|
||||
|
||||
// The HeapDef table of the highest switched-on mod that changes other heaps only (malloc'd, *n, *who its name); NULL
|
||||
// when no mod has one. Mods above the served table ship none, so the first found is the highest below it.
|
||||
static void *plain_mod_heapdef(size_t *n, const char **who) {
|
||||
for (int i = 0; i < mods_count(); i++) {
|
||||
const ModInfo *m = mods_at(i);
|
||||
char path[2300];
|
||||
if (!m->enabled || !m->romfs[0] ||
|
||||
snprintf(path, sizeof path, "%s/%s", m->romfs, HEAPDEF) >= (int)sizeof path)
|
||||
continue;
|
||||
uint64_t fn = 0;
|
||||
uint8_t *f = plat_file_read(path, &fn);
|
||||
if (!f) continue;
|
||||
if (fn < (16u << 20) && res_heapdef_plain(f, (size_t)fn)) {
|
||||
*n = (size_t)fn;
|
||||
*who = m->name;
|
||||
return f;
|
||||
}
|
||||
free(f);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
static void heapdef_overlay(void) {
|
||||
if (!res_heap_extra_kib()) return;
|
||||
if (!res_heap_extra_kib() && !res_tex_heap_extra_bytes()) return;
|
||||
static const char keeps[] = "the game keeps its own resolution";
|
||||
Overlay *base;
|
||||
size_t n;
|
||||
void *in = read_served(HEAPDEF, &base, &n, keeps), *out = NULL;
|
||||
size_t out_n = 0;
|
||||
if (in && base && base->mod && res_heapdef_tex_grown(in, n)) {
|
||||
free(in);
|
||||
const char *who = NULL;
|
||||
if ((in = plain_mod_heapdef(&n, &who)))
|
||||
rt_log("RomFS: %s: the HeapDef of %s with the texture heap growth in place of the mod's table",
|
||||
mods_at(base->mod - 1)->name, who);
|
||||
else if ((in = read_original(HEAPDEF, &n, keeps)))
|
||||
rt_log("RomFS: %s: the game's own HeapDef with the mod's texture heap growth in place of the mod's table",
|
||||
mods_at(base->mod - 1)->name);
|
||||
}
|
||||
int ok = in && res_heapdef_build(in, n, &out, &out_n);
|
||||
free(in);
|
||||
if (ok && serve_generated(HEAPDEF, base, out, out_n, "resolution", "grown heaps", keeps)) res_heapdef_served();
|
||||
@@ -750,14 +919,17 @@ static void camera_overlay(void) {
|
||||
// The button prompts' folders (input_prompts_folder) and whether each had a manifest next to romfs-index.bin at start;
|
||||
// the one the settings asked for at start, the one overlay_init loaded (NULL: the game's own prompts) and why the first
|
||||
// did not load (ROMFS_PROMPTS_*). The menu compares them with the settings in effect (ui/menu.cpp prompts_note).
|
||||
static struct { const char *name; int ready; } prompts_folders[2];
|
||||
// romfs-patches-pc keeps that name, but at a game resolution of another shape than 16:9 its files come from pc_dir,
|
||||
// romfs-patches-pc-<w>x<h> (overlay_init).
|
||||
static struct { const char *name; int ready; } prompts_folders[3];
|
||||
static const char *prompts_wanted, *prompts_loaded;
|
||||
static int prompts_off;
|
||||
static char pc_dir[64];
|
||||
|
||||
const char *romfs_prompts(void) { return prompts_loaded; }
|
||||
|
||||
int romfs_prompts_ready(const char *folder) {
|
||||
for (int i = 0; i < 2; i++)
|
||||
for (int i = 0; i < 3; i++)
|
||||
if (folder && prompts_folders[i].name && !strcmp(prompts_folders[i].name, folder)) return prompts_folders[i].ready;
|
||||
return 0;
|
||||
}
|
||||
@@ -798,6 +970,9 @@ static void overlay_init(void) {
|
||||
// keeps the Switch prompts. So does a RomFS mod (the TOTK_ROMFS_MODS manifest or a player mod) that replaces one of
|
||||
// the prompts' fonts: romfs-patches loads in their place, so the mod's file is not served beside prompts files made
|
||||
// for the game's own. A mod's resource size table is no clash: restbl_overlay merges it with the prompts' one.
|
||||
// prompts = pc takes romfs-patches-pc whatever the pad layout, and at a game resolution of another shape
|
||||
// romfs-patches-pc-<w>x<h> (make_romfs_patches.py --prompts pc --aspect): it carries that shape's UI layout too, so
|
||||
// it replaces romfs-patches-<w>x<h> as romfs-patches-pc replaces romfs-patches.
|
||||
char def[2100], path[2100];
|
||||
const char *patches = cfg_get("TOTK_ROMFS_PATCHES"), *mods = cfg_get("TOTK_ROMFS_MODS");
|
||||
int wide = 0;
|
||||
@@ -813,34 +988,46 @@ static void overlay_init(void) {
|
||||
"(tools/make_romfs_patches.py --aspect %ux%u)", out[0], out[1], out[0], out[1]);
|
||||
}
|
||||
}
|
||||
static const char *const layouts[2] = {"nintendo", "xbox"}; // one of each folder
|
||||
for (int i = 0; i < 2; i++) {
|
||||
prompts_folders[i].name = input_prompts_folder(PROMPTS_XBOX, layouts[i]);
|
||||
static const char *const layouts[2] = {"nintendo", "xbox"}; // one of each Xbox folder, then the PC one
|
||||
for (int i = 0; i < 3; i++) {
|
||||
const char *name = i < 2 ? input_prompts_folder(PROMPTS_XBOX, layouts[i]) : input_prompts_folder(PROMPTS_PC, 0);
|
||||
const char *dir = name;
|
||||
if (i == 2) {
|
||||
if (wide) snprintf(pc_dir, sizeof pc_dir, "%s-%ux%u", name, out[0], out[1]);
|
||||
else snprintf(pc_dir, sizeof pc_dir, "%s", name);
|
||||
dir = pc_dir;
|
||||
}
|
||||
prompts_folders[i].name = name;
|
||||
prompts_folders[i].ready =
|
||||
snprintf(path, sizeof path, "%s/%s/manifest.tsv", index_dir, prompts_folders[i].name) < (int)sizeof path &&
|
||||
plat_file_exists(path);
|
||||
snprintf(path, sizeof path, "%s/%s/manifest.tsv", index_dir, dir) < (int)sizeof path && plat_file_exists(path);
|
||||
}
|
||||
const char *layout = cfg_value("TOTK_PAD_LAYOUT");
|
||||
prompts_wanted = input_prompts_folder(input_prompts_pick(cfg_get("TOTK_PROMPTS"), layout), layout);
|
||||
int pick = input_prompts_pick(cfg_get("TOTK_PROMPTS"), layout), shaped = wide && pick == PROMPTS_PC;
|
||||
const char *style = pick == PROMPTS_PC ? "keyboard and mouse" : "Xbox";
|
||||
prompts_wanted = input_prompts_folder(pick, layout);
|
||||
prompts_off = !prompts_wanted ? ROMFS_PROMPTS_OK
|
||||
: wide ? ROMFS_PROMPTS_SHAPE
|
||||
: patches ? ROMFS_PROMPTS_PATCHES
|
||||
: !romfs_prompts_ready(prompts_wanted) ? ROMFS_PROMPTS_MISSING
|
||||
: wide && !shaped ? ROMFS_PROMPTS_SHAPE
|
||||
: patches && !wide ? ROMFS_PROMPTS_PATCHES
|
||||
: !romfs_prompts_ready(prompts_wanted) ? (shaped ? ROMFS_PROMPTS_SHAPE : ROMFS_PROMPTS_MISSING)
|
||||
: ROMFS_PROMPTS_OK;
|
||||
restbl_find();
|
||||
size_t first = noverlay;
|
||||
if (prompts_wanted && prompts_off == ROMFS_PROMPTS_OK) {
|
||||
snprintf(path, sizeof path, "%s/%s", index_dir, prompts_wanted); // fits: its manifest path did
|
||||
// fits: its manifest path did
|
||||
snprintf(path, sizeof path, "%s/%s", index_dir, shaped ? pc_dir : prompts_wanted);
|
||||
if (overlay_load(path, 1, 0)) {
|
||||
prompts_loaded = prompts_wanted;
|
||||
rt_log("RomFS overlay: Xbox button prompts (%s)", path);
|
||||
rt_log("RomFS overlay: %s button prompts (%s)", style, path);
|
||||
if (shaped) patches = NULL; // in place of romfs-patches-<w>x<h>, whose UI layout it carries
|
||||
} else {
|
||||
prompts_off = ROMFS_PROMPTS_FAILED;
|
||||
rt_log("RomFS overlay: %s failed its check (run the setup again); romfs-patches loads instead", path);
|
||||
}
|
||||
} else if (prompts_wanted) {
|
||||
rt_log("RomFS overlay: prompts = xbox, but %s; Switch prompts stay",
|
||||
prompts_off == ROMFS_PROMPTS_SHAPE ? "the game resolution takes its own UI layout"
|
||||
rt_log("RomFS overlay: prompts = %s, but %s; Switch prompts stay", pick == PROMPTS_PC ? "pc" : "xbox",
|
||||
prompts_off == ROMFS_PROMPTS_SHAPE ? (shaped ? "the game resolution has no romfs-patches-pc-<w>x<h> "
|
||||
"(tools/make_romfs_patches.py --prompts pc --aspect)"
|
||||
: "the game resolution takes its own UI layout")
|
||||
: prompts_off == ROMFS_PROMPTS_PATCHES ? "TOTK_ROMFS_PATCHES is set"
|
||||
: "their folder has no manifest.tsv (run the setup again)");
|
||||
}
|
||||
@@ -856,11 +1043,13 @@ static void overlay_init(void) {
|
||||
mods_overlay_init();
|
||||
const char *clash = prompts_loaded ? overlay_clash(first, prompts_end, mods_first, noverlay) : NULL;
|
||||
if (clash) {
|
||||
rt_log("RomFS overlay: a RomFS mod replaces %s, which the Xbox prompts change too; Switch prompts stay", clash);
|
||||
rt_log("RomFS overlay: a RomFS mod replaces %s, which the %s prompts change too; Switch prompts stay", clash,
|
||||
style);
|
||||
overlay_drop(first, prompts_end);
|
||||
prompts_loaded = NULL;
|
||||
prompts_off = ROMFS_PROMPTS_MODS;
|
||||
if (plain_ok) overlay_load(plain, 0, 0);
|
||||
if (shaped) overlay_load(def, 0, 0);
|
||||
else if (plain_ok) overlay_load(plain, 0, 0);
|
||||
}
|
||||
restbl_collect();
|
||||
overlay_contest();
|
||||
@@ -891,6 +1080,19 @@ static void index_path(char *out, size_t cap) {
|
||||
strcpy(out + k, "romfs-index.bin");
|
||||
}
|
||||
|
||||
// 1 when the game resolution w x h has the UI layout of its shape: romfs-patches-<w>x<h> next to romfs-index.bin with a
|
||||
// manifest, the folder overlay_init looks for. Works before fs_init: index_dir is not set that early (res_init and
|
||||
// resolution.c decide run first), so the index path is worked out again here.
|
||||
int romfs_shaped_ready(unsigned w, unsigned h) {
|
||||
char dir[2100], path[2200];
|
||||
index_path(dir, sizeof dir);
|
||||
char *sep = plat_last_sep(dir);
|
||||
if (sep) *sep = 0;
|
||||
else strcpy(dir, ".");
|
||||
return snprintf(path, sizeof path, "%s/romfs-patches-%ux%u/manifest.tsv", dir, w, h) < (int)sizeof path &&
|
||||
plat_file_exists(path);
|
||||
}
|
||||
|
||||
static void load_index(void) {
|
||||
char path[2100];
|
||||
index_path(path, sizeof path);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#version 450
|
||||
// Write-back merge of a VRAM mirror (vk_memory.c, wb_merge_record): copies a 32-bit word of what the GPU left in the
|
||||
// mirror into the pool's imported buffer (guest memory) only where it differs from the snapshot taken before the
|
||||
// submission's GPU work, so words the GPU did not change keep what the CPU wrote meanwhile.
|
||||
// One invocation per word of the listed ranges; ranges are sorted by their first invocation (x: word offset in the
|
||||
// mirror and imported bindings, y: word offset in the snapshot binding, z: words, w: first invocation).
|
||||
layout(local_size_x = 64) in;
|
||||
layout(std430, set = 0, binding = 0) readonly buffer Post { uint post[]; };
|
||||
layout(std430, set = 0, binding = 1) readonly buffer Pre { uint pre[]; };
|
||||
layout(std430, set = 0, binding = 2) buffer Dst { uint dst[]; };
|
||||
layout(std430, set = 0, binding = 3) readonly buffer Ranges { uvec4 rng[]; };
|
||||
layout(push_constant) uniform Push { uint nranges, total; } pc;
|
||||
|
||||
void main() {
|
||||
uint i = gl_GlobalInvocationID.x;
|
||||
if (i >= pc.total) return;
|
||||
uint lo = 0, hi = pc.nranges;
|
||||
while (hi - lo > 1) {
|
||||
uint m = (lo + hi) >> 1;
|
||||
if (rng[m].w <= i) lo = m;
|
||||
else hi = m;
|
||||
}
|
||||
uvec4 r = rng[lo];
|
||||
uint k = i - r.w;
|
||||
uint v = post[r.x + k];
|
||||
if (v != pre[r.y + k]) dst[r.x + k] = v;
|
||||
}
|
||||
@@ -216,6 +216,7 @@ static void svc_body(Cpu *c, uint32_t imm, uint64_t pc) {
|
||||
rt_log("guest ExitProcess at %s; exiting", ldr_describe(pc, at, sizeof at));
|
||||
fs_save_flush();
|
||||
vk_pipeline_save();
|
||||
playtime_save();
|
||||
prof_write();
|
||||
fflush(NULL);
|
||||
if (svc_test_exit_hook) return svc_test_exit_hook(0);
|
||||
|
||||
@@ -18,7 +18,7 @@ extern "C" {
|
||||
void rt_log(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
|
||||
}
|
||||
|
||||
static_assert(UI_BIND_RSTICK_DOWN - UI_BIND_A == ACTION_COUNT - 1, "UI_BIND_* follow the actions");
|
||||
static_assert(UI_BIND_SHAKE - UI_BIND_A == ACTION_COUNT - 1 && B_SHAKE == ACTION_COUNT - 1, "UI_BIND_* follow the actions");
|
||||
static_assert(UI_BIND_DESC_MINUS - UI_BIND_DESC_A == B_MINUS, "UI_BIND_DESC_* follow the actions");
|
||||
static_assert(UI_KEY_PAGEDOWN - UI_KEY_SPACE == KEY_PAGEDOWN - KEY_SPACE, "UI_KEY_* follow the key ids");
|
||||
static_assert(UI_PAD_RPADDLE2 - UI_PAD_SOUTH == PAD_RPADDLE2 - PAD_SOUTH && PAD_RPADDLE2 == PAD_COUNT - 1,
|
||||
@@ -48,6 +48,7 @@ static const char *action_label(int a) { return menu_text((UiTextId)(UI_BIND_A +
|
||||
static UiTextId action_desc(int a) {
|
||||
if (a <= B_MINUS) return (UiTextId)(UI_BIND_DESC_A + a);
|
||||
if (a <= B_DOWN) return UI_BIND_DESC_DPAD;
|
||||
if (a == B_SHAKE) return UI_BIND_DESC_SHAKE;
|
||||
return a <= B_SL_DOWN ? UI_BIND_DESC_MOVE : UI_BIND_DESC_CAMERA;
|
||||
}
|
||||
|
||||
@@ -168,7 +169,7 @@ static void row_action(int a, const InputMap &m, int layout) {
|
||||
a >= B_SL_LEFT ? menu_text(a <= B_SL_DOWN ? UI_PAD_LSTICK : UI_PAD_RSTICK)
|
||||
: np ? pad_label(p[0])
|
||||
: ""};
|
||||
const bool enabled[3] = {true, true, a < B_SL_LEFT}; // the sticks stay sticks
|
||||
const bool enabled[3] = {true, true, a < B_SL_LEFT}; // the sticks stay sticks (and the shake is the right stick's)
|
||||
char key[32];
|
||||
snprintf(key, sizeof key, "BIND_%s", input_action_name(a));
|
||||
const char *note = g_note_action == a && plat_now_ns() < g_note_until ? g_note : nullptr;
|
||||
@@ -236,6 +237,7 @@ void binds_rows(float scale) {
|
||||
for (int a : kMove) row_action(a, m, layout);
|
||||
opt_group(UI_BINDS_GROUP_CAMERA);
|
||||
for (int a : kCamera) row_action(a, m, layout);
|
||||
row_action(B_SHAKE, m, layout); // keys only; its controller cell shows the right stick, which the pad shakes itself
|
||||
ImGui::Dummy(ImVec2(0, 6 * scale));
|
||||
g_reset_asked = opt_button("##binds_reset", UI_BINDS_RESET, UI_BINDS_RESET_DESC);
|
||||
}
|
||||
|
||||
+30
-6
@@ -57,6 +57,7 @@ extern const int rt_game_version;
|
||||
extern const char rt_port_version[];
|
||||
void cheats_frame(uint64_t frame); // cheats.c
|
||||
bool tab_cheats(float scale); // ui/cheats_tab.cpp; true: restart into the rebuild
|
||||
int playtime_get(uint64_t *total_s, uint64_t *session_s); // playtime.c
|
||||
}
|
||||
|
||||
// Roboto Medium (Apache-2.0, ui/fonts/LICENSE-Roboto.txt) with Latin and Cyrillic glyphs.
|
||||
@@ -668,6 +669,8 @@ static void tab_screen(float scale) {
|
||||
opt_slider("TOTK_SHARPNESS", UI_MENU_SHARPNESS, 0, 100, UI_MENU_SHARPNESS_DESC, 5, "%.0f %%");
|
||||
static const UiTextId aspect[] = {UI_MENU_ASPECT_FIT, UI_MENU_ASPECT_STRETCH};
|
||||
opt_choice("TOTK_ASPECT", UI_MENU_ASPECT, aspect, 2, UI_MENU_ASPECT_DESC);
|
||||
static const UiTextId movie_aspect[] = {UI_MENU_MOVIE_ASPECT_KEEP, UI_MENU_MOVIE_ASPECT_STRETCH};
|
||||
opt_choice("TOTK_MOVIE_ASPECT", UI_MENU_MOVIE_ASPECT, movie_aspect, 2, UI_MENU_MOVIE_ASPECT_DESC);
|
||||
opt_slider("TOTK_FOV", UI_MENU_FOV, 70, 140, UI_MENU_FOV_DESC, 5, "%.0f %%");
|
||||
static const char *const aniso[] = {"game", "2", "4", "8", "16"};
|
||||
const char *aniso_names[] = {menu_text(UI_MENU_ANISO_GAME), "2x", "4x", "8x", "16x"};
|
||||
@@ -696,13 +699,28 @@ static void tab_screen(float scale) {
|
||||
opt_end();
|
||||
}
|
||||
|
||||
// Play time across runs (playtime.c), read only. Hidden while it is not counted (no profile directory).
|
||||
static void row_play_time(void) {
|
||||
uint64_t total, session;
|
||||
if (!playtime_get(&total, &session)) return;
|
||||
char value[64], run[64], note[96];
|
||||
snprintf(value, sizeof value, menu_text(UI_MENU_PLAY_TIME_HM), (unsigned long long)(total / 3600),
|
||||
(unsigned)(total / 60 % 60));
|
||||
snprintf(run, sizeof run, menu_text(UI_MENU_PLAY_TIME_HM), (unsigned long long)(session / 3600),
|
||||
(unsigned)(session / 60 % 60));
|
||||
snprintf(note, sizeof note, menu_text(UI_MENU_PLAY_TIME_SESSION), run);
|
||||
opt_text_row("##play_time", menu_text(UI_MENU_PLAY_TIME), value, note, menu_text(UI_MENU_PLAY_TIME_DESC), false);
|
||||
}
|
||||
|
||||
static void tab_game(float scale) {
|
||||
opt_begin("##game", scale);
|
||||
row_fps_limit();
|
||||
opt_toggle("TOTK_FRAME_QUEUE", UI_MENU_FRAME_QUEUE, UI_MENU_FRAME_QUEUE_DESC);
|
||||
row_game_lang();
|
||||
static const UiTextId mode[] = {UI_MENU_MODE_DOCKED, UI_MENU_MODE_HANDHELD};
|
||||
opt_choice("TOTK_OPERATION_MODE", UI_MENU_OPERATION_MODE, mode, 2, UI_MENU_OPERATION_MODE_DESC);
|
||||
opt_toggle("TOTK_ASSUME_FOCUS", UI_MENU_ASSUME_FOCUS, UI_MENU_ASSUME_FOCUS_DESC);
|
||||
row_play_time();
|
||||
opt_end();
|
||||
}
|
||||
|
||||
@@ -773,8 +791,11 @@ static const char *prompts_note(bool *restart) {
|
||||
const char *layout = cfg_value("TOTK_PAD_LAYOUT"), *used = romfs_prompts();
|
||||
const char *want = input_prompts_folder(input_prompts_pick(cfg_get("TOTK_PROMPTS"), layout), layout);
|
||||
if (want == used || (want && used && !strcmp(want, used))) return nullptr;
|
||||
if (want && !romfs_prompts_ready(want)) return menu_text(UI_MENU_PROMPTS_MISSING);
|
||||
switch (romfs_prompts_off(want)) {
|
||||
// A game resolution of another shape needs a folder of its own for the PC prompts (romfs-patches-pc-<w>x<h>), which
|
||||
// the setup does not write: the shape is the reason then, not the setup.
|
||||
int off = romfs_prompts_off(want);
|
||||
if (want && !romfs_prompts_ready(want) && off != ROMFS_PROMPTS_SHAPE) return menu_text(UI_MENU_PROMPTS_MISSING);
|
||||
switch (off) {
|
||||
case ROMFS_PROMPTS_FAILED: return menu_text(UI_MENU_PROMPTS_FAILED);
|
||||
case ROMFS_PROMPTS_SHAPE: return menu_text(UI_MENU_PROMPTS_OFF_SHAPE);
|
||||
case ROMFS_PROMPTS_MODS: return menu_text(UI_MENU_PROMPTS_OFF_MODS);
|
||||
@@ -827,16 +848,19 @@ static void tab_controls(float scale) {
|
||||
opt_begin("##controls", scale);
|
||||
static const UiTextId layout[] = {UI_MENU_PAD_LAYOUT_AUTO, UI_MENU_PAD_LAYOUT_NINTENDO, UI_MENU_PAD_LAYOUT_XBOX};
|
||||
opt_choice("TOTK_PAD_LAYOUT", UI_MENU_PAD_LAYOUT, layout, 3, UI_MENU_PAD_LAYOUT_DESC, pad_layout_note());
|
||||
static const UiTextId prompts[] = {UI_MENU_PROMPTS_NINTENDO, UI_MENU_PROMPTS_XBOX};
|
||||
static const UiTextId prompts[] = {UI_MENU_PROMPTS_NINTENDO, UI_MENU_PROMPTS_XBOX, UI_MENU_PROMPTS_PC};
|
||||
bool restart;
|
||||
const char *note = prompts_note(&restart);
|
||||
opt_choice("TOTK_PROMPTS", UI_MENU_PROMPTS, prompts, 2, UI_MENU_PROMPTS_DESC, note);
|
||||
opt_choice("TOTK_PROMPTS", UI_MENU_PROMPTS, prompts, 3, UI_MENU_PROMPTS_DESC, note);
|
||||
bool ask = restart && opt_button("##restart", UI_MENU_RESTART, UI_MENU_RESTART_DESC);
|
||||
opt_toggle("TOTK_MOTION", UI_MENU_MOTION, UI_MENU_MOTION_DESC);
|
||||
opt_slider("TOTK_RUMBLE_STRENGTH", UI_MENU_RUMBLE_STRENGTH, 0, 300, UI_MENU_RUMBLE_STRENGTH_DESC, 10, "%.0f %%");
|
||||
opt_slider("TOTK_RUMBLE_CURVE", UI_MENU_RUMBLE_CURVE, 0.2f, 1.5f, UI_MENU_RUMBLE_CURVE_DESC, 0.05f, "%.2f");
|
||||
opt_toggle("TOTK_RUMBLE_TRIGGERS", UI_MENU_RUMBLE_TRIGGERS, UI_MENU_RUMBLE_TRIGGERS_DESC);
|
||||
static const UiTextId mouse_modes[] = {UI_MENU_MOUSE_STICK, UI_MENU_MOUSE_DIRECT};
|
||||
opt_choice("TOTK_MOUSE_MODE", UI_MENU_MOUSE_MODE, mouse_modes, 2, UI_MENU_MOUSE_MODE_DESC);
|
||||
opt_slider("TOTK_MOUSE_SENS", UI_MENU_MOUSE_SENS, 0, 10, UI_MENU_MOUSE_SENS_DESC, 0.1f, "%.1f");
|
||||
opt_toggle("TOTK_MENU_CURSOR", UI_MENU_MENU_CURSOR, UI_MENU_MENU_CURSOR_DESC);
|
||||
row_walk_key();
|
||||
opt_slider("TOTK_WALK_SCALE", UI_MENU_WALK_SCALE, 0.2f, 0.9f, UI_MENU_WALK_SCALE_DESC, 0.01f, "%.2f");
|
||||
// Analog sticks: one dead zone row per stick, falling back to TOTK_STICK_DEADZONE (both sticks) while unset. With
|
||||
@@ -1085,8 +1109,8 @@ static void tab_mods(float scale) {
|
||||
char code[512] = "", problem[512] = "";
|
||||
if (m->nsets && m->overrides[0])
|
||||
snprintf(code, sizeof code, menu_text(UI_MENU_MODS_REPLACES_BUILTIN), m->nsets, m->overrides);
|
||||
else if (m->nsets)
|
||||
snprintf(code, sizeof code, menu_text(UI_MENU_MODS_EXEFS_ON), m->nsets);
|
||||
else if (m->nsets || m->ndata)
|
||||
snprintf(code, sizeof code, menu_text(UI_MENU_MODS_EXEFS_ON), m->nsets + m->ndata);
|
||||
switch (m->problem) {
|
||||
case MOD_EMPTY: snprintf(problem, sizeof problem, "%s", menu_text(UI_MENU_MODS_EMPTY)); break;
|
||||
case MOD_PLUGIN: snprintf(problem, sizeof problem, "%s", menu_text(UI_MENU_MODS_SUBSDK)); break;
|
||||
|
||||
+98
-12
@@ -79,6 +79,10 @@ static const char *const text_en[UI_TEXT_COUNT] = {
|
||||
[UI_MENU_ASPECT_DESC] = "When the window's shape differs from the game resolution's (a 16:9 picture on a 21:9 "
|
||||
"screen): keep the picture's shape with black bars, or stretch it over the window.",
|
||||
[UI_MENU_ASPECT_FIT] = "Black bars", [UI_MENU_ASPECT_STRETCH] = "Stretch",
|
||||
[UI_MENU_MOVIE_ASPECT] = "Movie shape",
|
||||
[UI_MENU_MOVIE_ASPECT_DESC] = "When the game resolution is not 16:9, keep the cutscene movies' own shape with black "
|
||||
"bars, or stretch them over the whole picture. Applies from the next movie.",
|
||||
[UI_MENU_MOVIE_ASPECT_KEEP] = "Black bars", [UI_MENU_MOVIE_ASPECT_STRETCH] = "Stretch",
|
||||
[UI_MENU_FOV] = "Field of view",
|
||||
[UI_MENU_FOV_DESC] = "How much of the world the camera shows, in percent of the game's own. Game version 1.4.3 only.",
|
||||
[UI_MENU_SHADOW_RES] = "Shadow map size",
|
||||
@@ -168,6 +172,9 @@ static const char *const text_en[UI_TEXT_COUNT] = {
|
||||
[UI_MENU_FPS_60] = "60 FPS", [UI_MENU_FPS_90] = "90 FPS", [UI_MENU_FPS_120] = "120 FPS", [UI_MENU_FPS_144] = "144 FPS",
|
||||
// the monitor's refresh rate; printf format with the rate in Hz when it is known
|
||||
[UI_MENU_FPS_DISPLAY] = "Monitor rate", [UI_MENU_FPS_DISPLAY_HZ] = "Monitor rate (%.0f Hz)",
|
||||
[UI_MENU_FRAME_QUEUE] = "Frame queue",
|
||||
[UI_MENU_FRAME_QUEUE_DESC] = "The game starts a frame while the previous one waits for its turn on screen: far fewer "
|
||||
"dropped frames at the limit, up to one frame more input delay. Applies at once.",
|
||||
[UI_MENU_REALTIME] = "Game speed follows the clock",
|
||||
[UI_MENU_REALTIME_DESC] = "Steps the game by real time, so a slow frame does not slow the game down.",
|
||||
[UI_MENU_CLOTH_REALTIME] = "Cloth on the clock",
|
||||
@@ -185,6 +192,10 @@ static const char *const text_en[UI_TEXT_COUNT] = {
|
||||
"window loses focus, as on the Switch when Home is pressed; a load interrupted that "
|
||||
"way can stop for good.",
|
||||
|
||||
[UI_MENU_PLAY_TIME] = "Play time",
|
||||
[UI_MENU_PLAY_TIME_DESC] = "Time in the game over all runs, counted while the game window has the focus and the game "
|
||||
"draws frames. Kept in playtime.txt in the profile folder, next to the saves.",
|
||||
[UI_MENU_PLAY_TIME_HM] = "%llu h %02u min", [UI_MENU_PLAY_TIME_SESSION] = "this run: %s",
|
||||
[UI_MENU_MODS_BUILTIN] = "Built into the port", [UI_MENU_MODS_INSTALLED] = "Installed mods",
|
||||
[UI_MENU_MODS_NONE] = "No mods yet. Unpack a mod into the mods folder, one folder per mod with its romfs folder "
|
||||
"inside, and restart the game. TKMM can export its merged mods into a folder there too.",
|
||||
@@ -241,10 +252,11 @@ static const char *const text_en[UI_TEXT_COUNT] = {
|
||||
[UI_MENU_PROMPTS_DESC] = "Which controller's buttons the game's prompts show. Nintendo: the game's own. Xbox: LB, "
|
||||
"RB, LT, RT, Menu, View and a d-pad cross, and A, B, X and Y as labelled on the button "
|
||||
"the layout above presses (with Nintendo (A right) the right button is labelled B, so the "
|
||||
"prompts for A show B). The game reads its prompts at start, so they change after a "
|
||||
"restart. They are part of the game's files, so a button you remap in Controls keeps its "
|
||||
"old prompt on screen.",
|
||||
[UI_MENU_PROMPTS_NINTENDO] = "Nintendo", [UI_MENU_PROMPTS_XBOX] = "Xbox",
|
||||
"prompts for A show B). PC: the keys and mouse buttons of the keyboard's default layout "
|
||||
"(E, Shift, Space, left click...) and the mouse for the camera. The game reads its prompts "
|
||||
"at start, so they change after a restart. They are part of the game's files, so a button "
|
||||
"you remap in Controls keeps its old prompt on screen.",
|
||||
[UI_MENU_PROMPTS_NINTENDO] = "Nintendo", [UI_MENU_PROMPTS_XBOX] = "Xbox", [UI_MENU_PROMPTS_PC] = "PC",
|
||||
[UI_MENU_PROMPTS_MISSING] = "prompt files missing: run the setup again",
|
||||
[UI_MENU_PROMPTS_FAILED] = "prompt files damaged: run the setup again",
|
||||
[UI_MENU_PROMPTS_RESTART] = "prompts change after restart",
|
||||
@@ -261,6 +273,12 @@ static const char *const text_en[UI_TEXT_COUNT] = {
|
||||
[UI_MENU_LSTICK_DEADZONE] = "Left stick dead zone", [UI_MENU_RSTICK_DEADZONE] = "Right stick dead zone",
|
||||
[UI_MENU_STICK_DEADZONE_DESC] = "How far a gamepad stick moves before the game sees it. Raise it if the character or the camera moves on its own: let go of the stick, read \"stick now\" and set the dead zone a few percent above it.",
|
||||
[UI_MENU_STICK_DEADZONE_NOW] = "stick now: %.0f %%",
|
||||
[UI_MENU_MOUSE_MODE] = "Mouse camera",
|
||||
[UI_MENU_MOUSE_MODE_DESC] = "Direct: the camera turns by how far the mouse moves, as in a PC game, with no stick dead zone or curve (games 1.4.3 and 1.2.1; 1.1.0 uses stick emulation). Stick emulation: the mouse acts as the right stick.",
|
||||
[UI_MENU_MOUSE_STICK] = "Stick emulation",
|
||||
[UI_MENU_MOUSE_DIRECT] = "Direct",
|
||||
[UI_MENU_MENU_CURSOR] = "Mouse in the game's menus",
|
||||
[UI_MENU_MENU_CURSOR_DESC] = "In the game's own menus (title, inventory, shops, dialogue choices) the pointer shows: the item under it is selected, left click confirms, right click goes back. Only after the mouse or a key was used last, so a gamepad sees the menus unchanged (game 1.4.3).",
|
||||
[UI_MENU_MOUSE_SENS] = "Mouse camera sensitivity",
|
||||
[UI_MENU_MOUSE_SENS_DESC] = "How fast the mouse turns the camera.",
|
||||
[UI_MENU_WALK_KEY] = "Walk key",
|
||||
@@ -302,6 +320,9 @@ static const char *const text_en[UI_TEXT_COUNT] = {
|
||||
[UI_BIND_DESC_MOVE] = "Moves Link like the left stick. The controller's left stick always works too.",
|
||||
[UI_BIND_DESC_CAMERA] = "Turns the camera like the right stick. The mouse and the controller's right stick always "
|
||||
"work too.",
|
||||
[UI_BIND_SHAKE] = "Shake the camera stick",
|
||||
[UI_BIND_DESC_SHAKE] = "Swings the right stick left and right for the game: detaches a fused part from what "
|
||||
"Ultrahand holds. Holding the key keeps shaking.",
|
||||
[UI_KEY_SPACE] = "Space", [UI_KEY_TAB] = "Tab", [UI_KEY_LSHIFT] = "Left Shift", [UI_KEY_LCTRL] = "Left Ctrl",
|
||||
[UI_KEY_UP] = "Up arrow", [UI_KEY_DOWN] = "Down arrow", [UI_KEY_LEFT] = "Left arrow",
|
||||
[UI_KEY_RIGHT] = "Right arrow",
|
||||
@@ -532,6 +553,10 @@ static const char *const text_ru[UI_TEXT_COUNT] = {
|
||||
[UI_MENU_ASPECT_DESC] = "Когда форма окна отличается от разрешения игры (картинка 16:9 на экране 21:9): сохранить "
|
||||
"форму картинки с чёрными полосами или растянуть её на всё окно.",
|
||||
[UI_MENU_ASPECT_FIT] = "Чёрные полосы", [UI_MENU_ASPECT_STRETCH] = "Растянуть",
|
||||
[UI_MENU_MOVIE_ASPECT] = "Форма видеороликов",
|
||||
[UI_MENU_MOVIE_ASPECT_DESC] = "Когда разрешение игры не 16:9: сохранить форму роликов с чёрными полосами или "
|
||||
"растянуть их на всю картинку. Действует со следующего ролика.",
|
||||
[UI_MENU_MOVIE_ASPECT_KEEP] = "Чёрные полосы", [UI_MENU_MOVIE_ASPECT_STRETCH] = "Растянуть",
|
||||
[UI_MENU_FOV] = "Поле зрения",
|
||||
[UI_MENU_FOV_DESC] = "Сколько мира показывает камера, в процентах от игрового. Только для версии игры 1.4.3.",
|
||||
[UI_MENU_SHADOW_RES] = "Размер карты теней",
|
||||
@@ -621,6 +646,9 @@ static const char *const text_ru[UI_TEXT_COUNT] = {
|
||||
[UI_MENU_FPS_30] = "30 FPS", [UI_MENU_FPS_40] = "40 FPS", [UI_MENU_FPS_45] = "45 FPS",
|
||||
[UI_MENU_FPS_60] = "60 FPS", [UI_MENU_FPS_90] = "90 FPS", [UI_MENU_FPS_120] = "120 FPS", [UI_MENU_FPS_144] = "144 FPS",
|
||||
[UI_MENU_FPS_DISPLAY] = "Как у монитора", [UI_MENU_FPS_DISPLAY_HZ] = "Как у монитора (%.0f Гц)",
|
||||
[UI_MENU_FRAME_QUEUE] = "Очередь кадров",
|
||||
[UI_MENU_FRAME_QUEUE_DESC] = "Игра начинает кадр, пока предыдущий ждёт показа: намного меньше пропущенных кадров на "
|
||||
"лимите, задержка ввода больше не более чем на кадр. Применяется сразу.",
|
||||
[UI_MENU_REALTIME] = "Скорость игры по часам",
|
||||
[UI_MENU_REALTIME_DESC] = "Игра идёт по реальному времени, медленный кадр её не замедляет.",
|
||||
[UI_MENU_CLOTH_REALTIME] = "Ткань по часам",
|
||||
@@ -638,6 +666,10 @@ static const char *const text_ru[UI_TEXT_COUNT] = {
|
||||
"когда окно теряет фокус, как на Switch при нажатии Home; прерванная так загрузка "
|
||||
"может остановиться насовсем.",
|
||||
|
||||
[UI_MENU_PLAY_TIME] = "Время в игре",
|
||||
[UI_MENU_PLAY_TIME_DESC] = "Время в игре за все запуски. Считается, пока окно игры в фокусе и игра рисует кадры. "
|
||||
"Хранится в playtime.txt в папке профиля, рядом с сохранениями.",
|
||||
[UI_MENU_PLAY_TIME_HM] = "%llu ч %02u мин", [UI_MENU_PLAY_TIME_SESSION] = "в этом запуске: %s",
|
||||
[UI_MENU_MODS_BUILTIN] = "Встроенные в порт", [UI_MENU_MODS_INSTALLED] = "Установленные моды",
|
||||
[UI_MENU_MODS_NONE] = "Модов пока нет. Распакуйте мод в папку mods, каждый мод в свою папку с папкой romfs внутри, и "
|
||||
"перезапустите игру. TKMM тоже может выгрузить туда объединённые моды отдельной папкой.",
|
||||
@@ -692,10 +724,11 @@ static const char *const text_ru[UI_TEXT_COUNT] = {
|
||||
[UI_MENU_PROMPTS_DESC] = "Кнопки какого контроллера показывают подсказки игры. Nintendo: как в самой игре. Xbox: "
|
||||
"LB, RB, LT, RT, Menu, View и крестовина, а A, B, X и Y подписаны так же, как кнопка, "
|
||||
"которую нажимает раскладка выше (в Nintendo (A справа) правая кнопка подписана B, и "
|
||||
"подсказки A показывают B). Игра читает подсказки при запуске, так что они меняются после "
|
||||
"перезапуска. Подсказки записаны в файлах игры, поэтому у кнопки, переназначенной на "
|
||||
"вкладке управления, на экране остаётся прежняя подсказка.",
|
||||
[UI_MENU_PROMPTS_NINTENDO] = "Nintendo", [UI_MENU_PROMPTS_XBOX] = "Xbox",
|
||||
"подсказки A показывают B). PC: клавиши и кнопки мыши стандартной раскладки клавиатуры "
|
||||
"(E, Shift, пробел, левый клик...) и мышь для камеры. Игра читает подсказки при запуске, "
|
||||
"так что они меняются после перезапуска. Подсказки записаны в файлах игры, поэтому у "
|
||||
"кнопки, переназначенной на вкладке управления, на экране остаётся прежняя подсказка.",
|
||||
[UI_MENU_PROMPTS_NINTENDO] = "Nintendo", [UI_MENU_PROMPTS_XBOX] = "Xbox", [UI_MENU_PROMPTS_PC] = "PC",
|
||||
[UI_MENU_PROMPTS_MISSING] = "нет файлов подсказок: запустите установку снова",
|
||||
[UI_MENU_PROMPTS_FAILED] = "файлы подсказок повреждены: запустите установку снова",
|
||||
[UI_MENU_PROMPTS_RESTART] = "подсказки сменятся после перезапуска",
|
||||
@@ -712,6 +745,12 @@ static const char *const text_ru[UI_TEXT_COUNT] = {
|
||||
[UI_MENU_LSTICK_DEADZONE] = "Мёртвая зона левого стика", [UI_MENU_RSTICK_DEADZONE] = "Мёртвая зона правого стика",
|
||||
[UI_MENU_STICK_DEADZONE_DESC] = "Насколько нужно отклонить стик геймпада, чтобы игра это заметила. Увеличьте, если персонаж или камера двигаются сами: отпустите стик, посмотрите «стик сейчас» и поставьте мёртвую зону на несколько процентов выше.",
|
||||
[UI_MENU_STICK_DEADZONE_NOW] = "стик сейчас: %.0f %%",
|
||||
[UI_MENU_MOUSE_MODE] = "Управление мышью",
|
||||
[UI_MENU_MOUSE_MODE_DESC] = "Прямое: камера поворачивается на столько, на сколько сдвинута мышь, как в играх для ПК, без мёртвой зоны и кривой стика (версии игры 1.4.3 и 1.2.1; на 1.1.0 эмуляция стика). Эмуляция стика: мышь работает как правый стик.",
|
||||
[UI_MENU_MOUSE_STICK] = "Эмуляция стика",
|
||||
[UI_MENU_MOUSE_DIRECT] = "Прямое",
|
||||
[UI_MENU_MENU_CURSOR] = "Мышь в меню игры",
|
||||
[UI_MENU_MENU_CURSOR_DESC] = "В меню самой игры (заставка, инвентарь, магазины, варианты ответа) появляется указатель: пункт под ним выбирается, левый щелчок подтверждает, правый возвращает назад. Только если последними использовались мышь или клавиатура, поэтому с геймпадом меню не меняются (версия игры 1.4.3).",
|
||||
[UI_MENU_MOUSE_SENS] = "Чувствительность мыши",
|
||||
[UI_MENU_MOUSE_SENS_DESC] = "Как быстро мышь поворачивает камеру.",
|
||||
[UI_MENU_WALK_KEY] = "Клавиша шага",
|
||||
@@ -756,6 +795,9 @@ static const char *const text_ru[UI_TEXT_COUNT] = {
|
||||
[UI_BIND_DESC_MOVE] = "Двигает Линка, как левый стик. Левый стик контроллера тоже всегда работает.",
|
||||
[UI_BIND_DESC_CAMERA] = "Поворачивает камеру, как правый стик. Мышь и правый стик контроллера тоже всегда "
|
||||
"работают.",
|
||||
[UI_BIND_SHAKE] = "Встряхнуть стик камеры",
|
||||
[UI_BIND_DESC_SHAKE] = "Качает правый стик влево-вправо: отцепляет приклеенную деталь от того, что держит "
|
||||
"Ультрарука. Пока клавиша зажата, тряска продолжается.",
|
||||
[UI_KEY_SPACE] = "Пробел", [UI_KEY_TAB] = "Tab", [UI_KEY_LSHIFT] = "Левый Shift", [UI_KEY_LCTRL] = "Левый Ctrl",
|
||||
[UI_KEY_UP] = "Стрелка вверх", [UI_KEY_DOWN] = "Стрелка вниз", [UI_KEY_LEFT] = "Стрелка влево",
|
||||
[UI_KEY_RIGHT] = "Стрелка вправо",
|
||||
@@ -949,6 +991,10 @@ static const char *const text_pt_br[UI_TEXT_COUNT] = {
|
||||
[UI_MENU_TAB_SCREEN] = "Tela", [UI_MENU_TAB_GAME] = "Jogo", [UI_MENU_TAB_SOUND] = "Som",
|
||||
[UI_MENU_TAB_CONTROLS] = "Controles", [UI_MENU_TAB_AMIIBO] = "Amiibo", [UI_MENU_TAB_MENU] = "Menu", [UI_MENU_TAB_ADVANCED] = "Avançado",
|
||||
[UI_MENU_TAB_MODS] = "Mods",
|
||||
[UI_MENU_PLAY_TIME] = "Tempo de jogo",
|
||||
[UI_MENU_PLAY_TIME_DESC] = "Tempo no jogo somando todas as sessões, contado enquanto a janela do jogo está em foco e o "
|
||||
"jogo desenha quadros. Fica em playtime.txt na pasta do perfil, junto dos saves.",
|
||||
[UI_MENU_PLAY_TIME_HM] = "%llu h %02u min", [UI_MENU_PLAY_TIME_SESSION] = "nesta sessão: %s",
|
||||
[UI_MENU_MODS_BUILTIN] = "Embutidos no port", [UI_MENU_MODS_INSTALLED] = "Mods instalados",
|
||||
[UI_MENU_MODS_NONE] = "Nenhum mod ainda. Descompacte um mod na pasta mods, uma pasta por mod com a pasta romfs dentro, "
|
||||
"e reinicie o jogo. O TKMM também pode exportar os mods mesclados para uma pasta ali.",
|
||||
@@ -1020,6 +1066,10 @@ static const char *const text_pt_br[UI_TEXT_COUNT] = {
|
||||
[UI_MENU_ASPECT_DESC] = "Quando o formato da janela difere do da resolução do jogo (uma imagem 16:9 numa tela "
|
||||
"21:9): manter o formato da imagem com barras pretas ou esticá-la sobre a janela.",
|
||||
[UI_MENU_ASPECT_FIT] = "Barras pretas", [UI_MENU_ASPECT_STRETCH] = "Esticar",
|
||||
[UI_MENU_MOVIE_ASPECT] = "Formato dos vídeos",
|
||||
[UI_MENU_MOVIE_ASPECT_DESC] = "Quando a resolução do jogo não é 16:9: manter o formato dos vídeos das cenas com "
|
||||
"barras pretas ou esticá-los sobre a imagem toda. Vale a partir do próximo vídeo.",
|
||||
[UI_MENU_MOVIE_ASPECT_KEEP] = "Barras pretas", [UI_MENU_MOVIE_ASPECT_STRETCH] = "Esticar",
|
||||
[UI_MENU_SHARPNESS] = "Nitidez do FSR",
|
||||
[UI_MENU_SHARPNESS_DESC] = "Aumento de nitidez após a ampliação do FSR; 0 desliga.",
|
||||
|
||||
@@ -1034,6 +1084,9 @@ static const char *const text_pt_br[UI_TEXT_COUNT] = {
|
||||
"a janela perde o foco, como no Switch ao apertar Home; um carregamento interrompido "
|
||||
"assim pode parar de vez.",
|
||||
[UI_MENU_FPS_DISPLAY] = "Taxa do monitor", [UI_MENU_FPS_DISPLAY_HZ] = "Taxa do monitor (%.0f Hz)",
|
||||
[UI_MENU_FRAME_QUEUE] = "Fila de quadros",
|
||||
[UI_MENU_FRAME_QUEUE_DESC] = "O jogo começa um quadro enquanto o anterior espera a vez de aparecer: muito menos quadros "
|
||||
"perdidos no limite, com até um quadro a mais de atraso na entrada. Vale na hora.",
|
||||
[UI_MENU_REALTIME] = "Velocidade do jogo pelo relógio",
|
||||
[UI_MENU_REALTIME_DESC] = "Avança o jogo pelo tempo real, para que um quadro lento não deixe o jogo mais lento.",
|
||||
[UI_MENU_CLOTH_REALTIME] = "Tecidos pelo relógio",
|
||||
@@ -1108,6 +1161,9 @@ static const char *const text_pt_br[UI_TEXT_COUNT] = {
|
||||
"funciona.",
|
||||
[UI_BIND_DESC_CAMERA] = "Gira a câmera como o analógico direito. O mouse e o analógico direito do controle também "
|
||||
"sempre funcionam.",
|
||||
[UI_BIND_SHAKE] = "Sacudir o analógico da câmera",
|
||||
[UI_BIND_DESC_SHAKE] = "Balança o analógico direito para os lados: solta uma peça fundida do que a Ultramão segura. "
|
||||
"Segurar a tecla continua sacudindo.",
|
||||
[UI_KEY_SPACE] = "Espaço", [UI_KEY_TAB] = "Tab", [UI_KEY_LSHIFT] = "Shift esquerdo",
|
||||
[UI_KEY_LCTRL] = "Ctrl esquerdo",
|
||||
[UI_KEY_UP] = "Seta para cima", [UI_KEY_DOWN] = "Seta para baixo", [UI_KEY_LEFT] = "Seta para a esquerda",
|
||||
@@ -1317,11 +1373,20 @@ static const char *const text_pt_br[UI_TEXT_COUNT] = {
|
||||
[UI_MENU_PROMPTS_DESC] = "Os botões de qual controle os ícones do jogo mostram. Nintendo: os do próprio jogo. "
|
||||
"Xbox: LB, RB, LT, RT, Menu, Exibir e uma cruz direcional, e A, B, X e Y com a letra do "
|
||||
"botão que o layout acima aperta (com Nintendo (A à direita) o botão da direita tem a "
|
||||
"letra B, então os ícones de A mostram B). O jogo lê os ícones ao iniciar, então eles "
|
||||
"mudam depois de reiniciar. Eles fazem parte dos arquivos do jogo, então um botão "
|
||||
"remapeado em Controles continua com o ícone antigo na tela.",
|
||||
"letra B, então os ícones de A mostram B). PC: as teclas e os botões do mouse do layout "
|
||||
"padrão do teclado (E, Shift, Espaço, clique esquerdo...) e o mouse para a câmera. O jogo "
|
||||
"lê os ícones ao iniciar, então eles mudam depois de reiniciar. Eles fazem parte dos "
|
||||
"arquivos do jogo, então um botão remapeado em Controles continua com o ícone antigo na "
|
||||
"tela.",
|
||||
[UI_MENU_PROMPTS_NINTENDO] = "Nintendo",
|
||||
[UI_MENU_PROMPTS_XBOX] = "Xbox",
|
||||
[UI_MENU_PROMPTS_PC] = "PC",
|
||||
[UI_MENU_MOUSE_MODE] = "Câmera com o mouse",
|
||||
[UI_MENU_MOUSE_MODE_DESC] = "Direta: a câmera gira o quanto o mouse se move, como num jogo de PC, sem zona morta nem curva do analógico (jogos 1.4.3 e 1.2.1; o 1.1.0 usa a emulação do analógico). Emulação do analógico: o mouse age como o analógico direito.",
|
||||
[UI_MENU_MOUSE_STICK] = "Emulação do analógico",
|
||||
[UI_MENU_MOUSE_DIRECT] = "Direta",
|
||||
[UI_MENU_MENU_CURSOR] = "Mouse nos menus do jogo",
|
||||
[UI_MENU_MENU_CURSOR_DESC] = "Nos menus do próprio jogo (título, inventário, lojas, escolhas de diálogo) o ponteiro aparece: o item sob ele fica selecionado, o clique esquerdo confirma e o direito volta. Só depois de usar o mouse ou uma tecla por último, então com um controle os menus ficam como antes (jogo 1.4.3).",
|
||||
[UI_MENU_PROMPTS_MISSING] = "faltam os arquivos dos ícones: execute a instalação de novo",
|
||||
[UI_MENU_PROMPTS_FAILED] = "arquivos dos ícones danificados: execute a instalação de novo",
|
||||
[UI_MENU_PROMPTS_RESTART] = "os ícones mudam após reiniciar",
|
||||
@@ -1439,6 +1504,10 @@ static const char *const text_zh_hans[UI_TEXT_COUNT] = {
|
||||
[UI_MENU_TAB_MENU] = "菜单",
|
||||
[UI_MENU_TAB_ADVANCED] = "高级",
|
||||
[UI_MENU_TAB_MODS] = "模组",
|
||||
[UI_MENU_PLAY_TIME] = "游戏时间",
|
||||
[UI_MENU_PLAY_TIME_DESC] = "所有运行累计的游戏时间,仅在游戏窗口处于焦点且游戏正在绘制画面时计时。"
|
||||
"保存在配置文件夹的 playtime.txt 中,与存档放在一起。",
|
||||
[UI_MENU_PLAY_TIME_HM] = "%llu 小时 %02u 分钟", [UI_MENU_PLAY_TIME_SESSION] = "本次运行:%s",
|
||||
[UI_MENU_MODS_BUILTIN] = "移植版内置", [UI_MENU_MODS_INSTALLED] = "已安装的模组",
|
||||
[UI_MENU_MODS_NONE] = "还没有模组。把模组解压到 mods 文件夹,每个模组一个文件夹,里面放 romfs 文件夹,然后重启游戏。"
|
||||
"TKMM 也可以把合并后的模组导出到那里的一个文件夹。",
|
||||
@@ -1517,6 +1586,11 @@ static const char *const text_zh_hans[UI_TEXT_COUNT] = {
|
||||
"用黑边保持画面比例,或拉伸铺满窗口。",
|
||||
[UI_MENU_ASPECT_FIT] = "黑边",
|
||||
[UI_MENU_ASPECT_STRETCH] = "拉伸",
|
||||
[UI_MENU_MOVIE_ASPECT] = "视频画面比例",
|
||||
[UI_MENU_MOVIE_ASPECT_DESC] = "当游戏分辨率不是 16:9 时:用黑边保持过场视频的原有比例,或拉伸铺满整个画面。"
|
||||
"从下一段视频起生效。",
|
||||
[UI_MENU_MOVIE_ASPECT_KEEP] = "黑边",
|
||||
[UI_MENU_MOVIE_ASPECT_STRETCH] = "拉伸",
|
||||
[UI_MENU_FOV] = "视野",
|
||||
[UI_MENU_FOV_DESC] = "镜头能看到多大范围,以游戏自身视野的百分比表示。"
|
||||
"仅限游戏版本 1.4.3。",
|
||||
@@ -1550,6 +1624,8 @@ static const char *const text_zh_hans[UI_TEXT_COUNT] = {
|
||||
[UI_MENU_FPS_144] = "144 FPS",
|
||||
[UI_MENU_FPS_DISPLAY] = "显示器刷新率",
|
||||
[UI_MENU_FPS_DISPLAY_HZ] = "显示器刷新率(%.0f Hz)",
|
||||
[UI_MENU_FRAME_QUEUE] = "帧队列",
|
||||
[UI_MENU_FRAME_QUEUE_DESC] = "前一帧等待显示时游戏就开始绘制下一帧:在帧率上限下掉帧大幅减少,输入延迟最多增加一帧。立即生效。",
|
||||
[UI_MENU_REALTIME] = "游戏速度跟随时钟",
|
||||
[UI_MENU_REALTIME_DESC] = "按真实时间推进游戏,帧率低时游戏速度也不会变慢。",
|
||||
[UI_MENU_CLOTH_REALTIME] = "布料跟随时钟",
|
||||
@@ -1655,6 +1731,8 @@ static const char *const text_zh_hans[UI_TEXT_COUNT] = {
|
||||
[UI_BIND_DESC_DPAD] = "快捷选择。",
|
||||
[UI_BIND_DESC_MOVE] = "像左摇杆一样移动林克。手柄的左摇杆始终可用。",
|
||||
[UI_BIND_DESC_CAMERA] = "像右摇杆一样转动视角。鼠标和手柄的右摇杆始终可用。",
|
||||
[UI_BIND_SHAKE] = "晃动视角摇杆",
|
||||
[UI_BIND_DESC_SHAKE] = "让右摇杆左右晃动:把究极手抓着的物体上粘着的零件拆下。按住该键会一直晃动。",
|
||||
[UI_KEY_SPACE] = "空格",
|
||||
[UI_KEY_TAB] = "Tab",
|
||||
[UI_KEY_LSHIFT] = "左 Shift",
|
||||
@@ -1915,9 +1993,17 @@ static const char *const text_zh_hans[UI_TEXT_COUNT] = {
|
||||
[UI_MENU_PROMPTS] = "按键提示",
|
||||
[UI_MENU_PROMPTS_DESC] = "游戏的按键提示显示哪种手柄的按键。Nintendo:游戏原有的提示。Xbox:LB、RB、LT、RT、Menu、View 和十字键,以及上方布局所按下的按键上标注的 "
|
||||
"A、B、X、Y(使用 Nintendo(A 在右)时右侧按键标为 B,因此 A 的提示显示 "
|
||||
"B)。游戏在启动时读取按键提示,因此重启后才会改变。提示属于游戏文件,在“操作”中重新映射的按键在屏幕上仍显示原来的提示。",
|
||||
"B)。PC:键盘默认布局的按键和鼠标按键(E、Shift、空格、左键……),镜头用鼠标。"
|
||||
"游戏在启动时读取按键提示,因此重启后才会改变。提示属于游戏文件,在“操作”中重新映射的按键在屏幕上仍显示原来的提示。",
|
||||
[UI_MENU_PROMPTS_NINTENDO] = "Nintendo",
|
||||
[UI_MENU_PROMPTS_XBOX] = "Xbox",
|
||||
[UI_MENU_PROMPTS_PC] = "PC",
|
||||
[UI_MENU_MOUSE_MODE] = "鼠标视角",
|
||||
[UI_MENU_MOUSE_MODE_DESC] = "直接:镜头按鼠标移动的距离转动,与电脑游戏相同,没有摇杆死区和响应曲线(游戏 1.4.3 和 1.2.1;1.1.0 使用摇杆模拟)。摇杆模拟:鼠标相当于右摇杆。",
|
||||
[UI_MENU_MOUSE_STICK] = "摇杆模拟",
|
||||
[UI_MENU_MOUSE_DIRECT] = "直接",
|
||||
[UI_MENU_MENU_CURSOR] = "在游戏菜单中使用鼠标",
|
||||
[UI_MENU_MENU_CURSOR_DESC] = "在游戏自身的菜单中(标题、背包、商店、对话选项)显示指针:指针下的项目被选中,左键确认,右键返回。仅在最后使用的是鼠标或键盘时生效,因此使用手柄时菜单保持不变(游戏 1.4.3)。",
|
||||
[UI_MENU_PROMPTS_MISSING] = "缺少按键提示文件:请重新运行安装程序",
|
||||
[UI_MENU_PROMPTS_FAILED] = "按键提示文件已损坏:请重新运行安装程序",
|
||||
[UI_MENU_PROMPTS_RESTART] = "按键提示重启后生效",
|
||||
|
||||
+11
-4
@@ -37,6 +37,7 @@ typedef enum {
|
||||
UI_MENU_RENDER_SCALE, UI_MENU_RENDER_SCALE_DESC, UI_MENU_RENDER_SCALE_GAME,
|
||||
UI_MENU_UPSCALER, UI_MENU_UPSCALER_DESC, UI_MENU_UPSCALER_FSR, UI_MENU_UPSCALER_BILINEAR,
|
||||
UI_MENU_ASPECT, UI_MENU_ASPECT_DESC, UI_MENU_ASPECT_FIT, UI_MENU_ASPECT_STRETCH,
|
||||
UI_MENU_MOVIE_ASPECT, UI_MENU_MOVIE_ASPECT_DESC, UI_MENU_MOVIE_ASPECT_KEEP, UI_MENU_MOVIE_ASPECT_STRETCH,
|
||||
UI_MENU_FOV, UI_MENU_FOV_DESC,
|
||||
UI_MENU_SHADOW_RES, UI_MENU_SHADOW_RES_DESC, UI_MENU_SHADOW_RES_GAME,
|
||||
UI_MENU_ANISO, UI_MENU_ANISO_DESC, UI_MENU_ANISO_GAME,
|
||||
@@ -62,12 +63,16 @@ typedef enum {
|
||||
UI_MENU_FPS_LIMIT, UI_MENU_FPS_LIMIT_DESC, UI_MENU_FPS_30, UI_MENU_FPS_40, UI_MENU_FPS_45, UI_MENU_FPS_60, UI_MENU_FPS_90,
|
||||
UI_MENU_FPS_120, UI_MENU_FPS_144,
|
||||
UI_MENU_FPS_DISPLAY, UI_MENU_FPS_DISPLAY_HZ,
|
||||
UI_MENU_FRAME_QUEUE, UI_MENU_FRAME_QUEUE_DESC,
|
||||
UI_MENU_REALTIME, UI_MENU_REALTIME_DESC,
|
||||
UI_MENU_CLOTH_REALTIME, UI_MENU_CLOTH_REALTIME_DESC,
|
||||
UI_MENU_HIGHFPS_FIX, UI_MENU_HIGHFPS_FIX_DESC,
|
||||
UI_MENU_GAME_LANG, UI_MENU_GAME_LANG_DESC, UI_MENU_GAME_LANG_AUTO,
|
||||
UI_MENU_OPERATION_MODE, UI_MENU_OPERATION_MODE_DESC, UI_MENU_MODE_DOCKED, UI_MENU_MODE_HANDHELD,
|
||||
UI_MENU_ASSUME_FOCUS, UI_MENU_ASSUME_FOCUS_DESC,
|
||||
UI_MENU_PLAY_TIME, UI_MENU_PLAY_TIME_DESC,
|
||||
UI_MENU_PLAY_TIME_HM, // hours and minutes: "%llu h %02u min" (unsigned long long hours, unsigned minutes)
|
||||
UI_MENU_PLAY_TIME_SESSION, // note under the total: "this run: %s" (the hours and minutes above)
|
||||
|
||||
// Mods: the port's own patches and the player's mods folder (mods.c)
|
||||
UI_MENU_MODS_BUILTIN, UI_MENU_MODS_INSTALLED,
|
||||
@@ -99,7 +104,7 @@ typedef enum {
|
||||
UI_MENU_RUMBLE_TRIGGERS, UI_MENU_RUMBLE_TRIGGERS_DESC,
|
||||
UI_MENU_PAD_LAYOUT, UI_MENU_PAD_LAYOUT_DESC, UI_MENU_PAD_LAYOUT_AUTO, UI_MENU_PAD_LAYOUT_NINTENDO,
|
||||
UI_MENU_PAD_LAYOUT_XBOX, UI_MENU_PAD_LAYOUT_IN_USE,
|
||||
UI_MENU_PROMPTS, UI_MENU_PROMPTS_DESC, UI_MENU_PROMPTS_NINTENDO, UI_MENU_PROMPTS_XBOX,
|
||||
UI_MENU_PROMPTS, UI_MENU_PROMPTS_DESC, UI_MENU_PROMPTS_NINTENDO, UI_MENU_PROMPTS_XBOX, UI_MENU_PROMPTS_PC,
|
||||
// the notes under the prompts: their files are missing or damaged, they change after restart, or why they are off
|
||||
UI_MENU_PROMPTS_MISSING, UI_MENU_PROMPTS_FAILED, UI_MENU_PROMPTS_RESTART, UI_MENU_PROMPTS_OFF_SHAPE,
|
||||
UI_MENU_PROMPTS_OFF_MODS, UI_MENU_PROMPTS_OFF_PATCHES,
|
||||
@@ -107,6 +112,8 @@ typedef enum {
|
||||
UI_MENU_RESTART, UI_MENU_RESTART_DESC, UI_MENU_RESTART_CONFIRM, UI_MENU_RESTART_YES, UI_MENU_RESTART_SAVING,
|
||||
UI_MENU_GROUP_STICKS, UI_MENU_LSTICK_DEADZONE, UI_MENU_RSTICK_DEADZONE, UI_MENU_STICK_DEADZONE_DESC,
|
||||
UI_MENU_STICK_DEADZONE_NOW, UI_MENU_STICK_SENS, UI_MENU_STICK_SENS_DESC,
|
||||
UI_MENU_MOUSE_MODE, UI_MENU_MOUSE_MODE_DESC, UI_MENU_MOUSE_STICK, UI_MENU_MOUSE_DIRECT,
|
||||
UI_MENU_MENU_CURSOR, UI_MENU_MENU_CURSOR_DESC,
|
||||
UI_MENU_MOUSE_SENS, UI_MENU_MOUSE_SENS_DESC,
|
||||
UI_MENU_WALK_KEY, UI_MENU_WALK_KEY_DESC, UI_MENU_WALK_KEY_TAKEN, UI_MENU_WALK_SCALE, UI_MENU_WALK_SCALE_DESC,
|
||||
|
||||
@@ -119,15 +126,15 @@ typedef enum {
|
||||
UI_BINDS_TAKEN, // "%s was taken from %s": the key, the action that lost it
|
||||
UI_BINDS_SWAPPED, // "%s swapped with %s": the two actions
|
||||
UI_BINDS_RESET, UI_BINDS_RESET_DESC,
|
||||
// the actions, in action order (input_map.h B_A .. B_SR_DOWN)
|
||||
// the actions, in action order (input_map.h B_A .. B_SHAKE)
|
||||
UI_BIND_A, UI_BIND_B, UI_BIND_X, UI_BIND_Y, UI_BIND_LCLICK, UI_BIND_RCLICK, UI_BIND_L, UI_BIND_R, UI_BIND_ZL,
|
||||
UI_BIND_ZR, UI_BIND_PLUS, UI_BIND_MINUS, UI_BIND_LEFT, UI_BIND_UP, UI_BIND_RIGHT, UI_BIND_DOWN,
|
||||
UI_BIND_LSTICK_LEFT, UI_BIND_LSTICK_UP, UI_BIND_LSTICK_RIGHT, UI_BIND_LSTICK_DOWN,
|
||||
UI_BIND_RSTICK_LEFT, UI_BIND_RSTICK_UP, UI_BIND_RSTICK_RIGHT, UI_BIND_RSTICK_DOWN,
|
||||
UI_BIND_RSTICK_LEFT, UI_BIND_RSTICK_UP, UI_BIND_RSTICK_RIGHT, UI_BIND_RSTICK_DOWN, UI_BIND_SHAKE,
|
||||
// what an action does in the world, in action order up to Minus
|
||||
UI_BIND_DESC_A, UI_BIND_DESC_B, UI_BIND_DESC_X, UI_BIND_DESC_Y, UI_BIND_DESC_LCLICK, UI_BIND_DESC_RCLICK,
|
||||
UI_BIND_DESC_L, UI_BIND_DESC_R, UI_BIND_DESC_ZL, UI_BIND_DESC_ZR, UI_BIND_DESC_PLUS, UI_BIND_DESC_MINUS,
|
||||
UI_BIND_DESC_DPAD, UI_BIND_DESC_MOVE, UI_BIND_DESC_CAMERA,
|
||||
UI_BIND_DESC_DPAD, UI_BIND_DESC_MOVE, UI_BIND_DESC_CAMERA, UI_BIND_DESC_SHAKE,
|
||||
// key names, in key id order KEY_SPACE .. KEY_PAGEDOWN (letters, digits, F keys and punctuation show as themselves)
|
||||
UI_KEY_SPACE, UI_KEY_TAB, UI_KEY_LSHIFT, UI_KEY_LCTRL, UI_KEY_UP, UI_KEY_DOWN, UI_KEY_LEFT, UI_KEY_RIGHT,
|
||||
UI_KEY_MOUSE_L, UI_KEY_MOUSE_R, UI_KEY_MOUSE_M, UI_KEY_MOUSE_X1, UI_KEY_MOUSE_X2,
|
||||
|
||||
+12
-3
@@ -57,9 +57,10 @@ typedef struct {
|
||||
int gpl; // VK_EXT_graphics_pipeline_library enabled with fast linking (TOTK_GPL=0: off)
|
||||
int amd; // an AMD GPU (vendor 0x1002), any driver
|
||||
int amd_sc; // AMD's Windows driver branch for RDNA 1 and 2 ("AMD proprietary shader compiler")
|
||||
int mesa; // a Mesa driver the game runs on: RADV, ANV or NVK
|
||||
} VkCaps;
|
||||
extern VkCaps vk_caps;
|
||||
void vk_caps_driver(VkPhysicalDevice p); // first, before vkCreateDevice: vk_caps.amd, amd_sc
|
||||
void vk_caps_driver(VkPhysicalDevice p); // first, before vkCreateDevice: vk_caps.amd, amd_sc, mesa
|
||||
void vk_caps_features(VkPhysicalDeviceFeatures *f); // before vkCreateDevice (TOTK_VK_NO_FLOAT64 clears shaderFloat64)
|
||||
// Before vkCreateDevice, with the driver's graphics pipeline library feature and properties: sets vk_caps.gpl,
|
||||
// 1 = enable the extensions (VK_KHR_pipeline_library, VK_EXT_graphics_pipeline_library) and the feature.
|
||||
@@ -250,6 +251,9 @@ void vk_mem_pick(VkMemSpan *sp);
|
||||
#define VK_MEM_USE(sp) do { if (vk_mirror_on && (sp).pool) vk_mem_use(&(sp)); } while (0)
|
||||
#define VK_MEM_PICK(sp) do { if (vk_mirror_on && (sp).pool) vk_mem_pick(&(sp)); } while (0)
|
||||
void vk_mem_gpu_wrote(const VkMemSpan *sp, uint64_t size);
|
||||
// A shader's storage-buffer write: goes back through the write-back merge (vk_memory.c), which keeps the words the GPU
|
||||
// did not change.
|
||||
void vk_mem_gpu_wrote_shader(const VkMemSpan *sp, uint64_t size);
|
||||
// Uniform blocks as of the submit call (vk_usnap.c): vk_exec_record copies the uniform ranges a submission binds before
|
||||
// it records, ubo bindings take the copy (0: not copied, bind memory), GPU writes of the replay drop the copies they touch.
|
||||
void vk_usnap_begin(const uint64_t *handles, int n, uint64_t fence);
|
||||
@@ -279,6 +283,8 @@ void vk_mem_mirror_prologue(VkCommandBuffer cb);
|
||||
void vk_mem_shadow_sync(void);
|
||||
void vk_mem_gpu_pending(uint64_t addr, uint64_t size);
|
||||
void vk_gpu_written(uint64_t addr, uint64_t size);
|
||||
int vk_mem_flush_add_probe(void); // --probe-vkexec: the flush range list; 0 or the number of the failed check
|
||||
uint64_t vk_mem_mirror_uploaded(void); // --probe-vkexec: bytes copied into mirrors since the last summary line
|
||||
int vk_mem_mirror_probe(uint64_t addr); // --probe-vkexec: the pool at addr gets its mirror as on a first buffer use;
|
||||
// 1 when it has one
|
||||
void vk_mem_class_log(const VkPhysicalDeviceProperties *p, const VkPhysicalDeviceMemoryProperties *m); // once
|
||||
@@ -422,6 +428,7 @@ void vk_texture_stats(uint64_t *images, uint64_t *uploads, uint64_t *reuploads);
|
||||
// (images not bound into pool memory).
|
||||
typedef struct { uint64_t live, released, own_live, own_released, bytes_live, bytes_released; } VkTexCensus;
|
||||
void vk_texture_census(VkTexCensus *s);
|
||||
void vk_texture_released_log(uint64_t frame); // mem: released lines of vram_log
|
||||
void vk_texture_shutdown(void); // vk_mem_shutdown calls it
|
||||
void vk_texture_new_submit(void); // a queue submit starts: draw bindings check storage again
|
||||
uint32_t vk_texture_epoch(void); // the write-watch snapshot epoch vk_texture_new_submit started
|
||||
@@ -498,6 +505,7 @@ void vk_pipe_state_defaults(VkPipeState *s); // NVN defaults of every state ob
|
||||
enum { VK_PIPE_SUPPORT = GFX_RES_KINDS }; // VkPipeBinding.kind of the support buffer
|
||||
#define VK_PIPE_ZERO_BINDING 16 // vertex binding of shader inputs without an attribute: bind a zeroed
|
||||
// buffer of at least 16 bytes there (stride 0)
|
||||
#define VK_PIPE_ALL_BINDINGS 0x1ffffu // every vertex binding, streams 0-15 and the zero binding
|
||||
typedef struct {
|
||||
uint16_t binding; // in its set
|
||||
uint8_t stage; // GFX_STAGE_* that declared it (support buffer: the first stage)
|
||||
@@ -550,8 +558,9 @@ VkPipeline vk_pipeline_compute(const GfxShader *cs, const VkPipeInfo **info);
|
||||
// has them dynamic, depth clamp, polygon mode, blend enables, equations, write masks and alpha to coverage. The replay
|
||||
// sets the rest: vkCmdSetViewportWithCount, vkCmdSetScissorWithCount, depth bias, blend constants, stencil compare and
|
||||
// write masks and references; vertex buffers with vkCmdBindVertexBuffers2 (strides NULL when
|
||||
// vk_pipeline_vertex_input_dynamic(), else from VkPipeState.vstream).
|
||||
void vk_pipeline_set_dynamic(VkCommandBuffer cb, const VkPipeState *s);
|
||||
// vk_pipeline_vertex_input_dynamic(), else from VkPipeState.vstream). Returns the vertex bindings (bit per binding) the
|
||||
// state's vertex input reads, whether set here or made into the pipeline.
|
||||
uint32_t vk_pipeline_set_dynamic(VkCommandBuffer cb, const VkPipeState *s);
|
||||
int vk_pipeline_vertex_input_dynamic(void); // 1: vertex input is VK_EXT_vertex_input_dynamic_state
|
||||
// Translator answers from the bound state: attr_type from the vertex attribute formats (vs), frag_out_type from the
|
||||
// colour target formats (fs). Other fields are left as they are; either pointer may be NULL.
|
||||
|
||||
@@ -168,9 +168,10 @@ static uint64_t ck_reads, ck_diff, ck_diff_unexplained, ck_diff_idle;
|
||||
int vk_replay_async(void) {
|
||||
static int on = -1;
|
||||
if (on < 0) {
|
||||
// On by default: where the game waits for the GPU it overlaps the replay with the game threads (a GTX 1650 in
|
||||
// the Depths went from 450-686 frames over 50 ms a minute to 10-13, issue #88); where the replay or the game
|
||||
// threads bound the frame it neither gains nor loses. It adds up to a frame of latency.
|
||||
// Off by default. Where the game waits for the GPU it overlaps the replay with the game threads (a GTX 1650 in
|
||||
// the Depths went from 450-686 frames over 50 ms a minute to 10-13, issue #88), the only place a gain was
|
||||
// measured. While it was the default, a GTX 1660 Ti at the 60 fps cap with the GPU at 26 % reported uneven
|
||||
// motion that 0.3.4 did not have (#659). It adds up to a frame of latency.
|
||||
on = cfg_bool("TOTK_REPLAY_ASYNC");
|
||||
rt_log("vk: replay %s (TOTK_REPLAY_ASYNC=%d)",
|
||||
on ? "on its own thread, at most one frame behind the game" : "on the submitting thread", on);
|
||||
|
||||
+6
-1
@@ -43,6 +43,8 @@ void vk_caps_driver(VkPhysicalDevice p) {
|
||||
// AMD split RDNA 1 and 2 off into a driver branch of their own; its driverInfo reads
|
||||
// "26.10.1 (AMD proprietary shader compiler)" where the RDNA 3 and 4 driver names LLPC.
|
||||
vk_caps.amd_sc = drv.driverID == VK_DRIVER_ID_AMD_PROPRIETARY && strstr(drv.driverInfo, "shader compiler");
|
||||
vk_caps.mesa = drv.driverID == VK_DRIVER_ID_MESA_RADV || drv.driverID == VK_DRIVER_ID_INTEL_OPEN_SOURCE_MESA ||
|
||||
drv.driverID == VK_DRIVER_ID_MESA_NVK;
|
||||
}
|
||||
|
||||
// TOTK_GPL=0 turns graphics pipeline libraries off; they are on wherever the driver has fast linking. AMD's RDNA 1/2
|
||||
@@ -114,7 +116,10 @@ void vk_caps_init(VkCtx *c) {
|
||||
// BotW and TotK on AMD). The translator biases gather coordinates by the host's sub-texel precision; NVIDIA rounds
|
||||
// as Maxwell does. TOTK_GATHER_BIAS=<bits> forces it (0: off).
|
||||
uint32_t gather = env_u32("TOTK_GATHER_BIAS", c->props.vendorID == 0x10de ? 0 : l->subTexelPrecisionBits);
|
||||
gfx_shader_set_host(vk_caps.float64, vk_caps.sbuf_align, told, gather);
|
||||
// A register read before any write is an undefined load in SPIR-V, and Mesa folds undefined values freely, so on
|
||||
// Mesa function-local variables start at 0 (docs/maxwell.md 3.12). TOTK_ZERO_LOCALS=1 forces it, 0 turns it off.
|
||||
uint32_t zero_locals = env_u32("TOTK_ZERO_LOCALS", (uint32_t)vk_caps.mesa);
|
||||
gfx_shader_set_host(vk_caps.float64, vk_caps.sbuf_align, told, gather, zero_locals != 0);
|
||||
if (gather) rt_log("vk caps: gather coordinates biased for %u sub-texel bits (TOTK_GATHER_BIAS)", gather);
|
||||
|
||||
char dv[32];
|
||||
|
||||
+5
-3
@@ -1218,10 +1218,12 @@ void vk_dlaa_frame(void) {
|
||||
// scaled picture into the output-sized target, in a pass without the depth attachment the in-place FXAA has: there it
|
||||
// is always drawn, whatever fxaa says (skipping it would leave the 3D picture out), unless DLSS already wrote that
|
||||
// target this frame.
|
||||
// The stock translator's hash, the one with tools/xlate patch 0010 (PCSS, PR #89) applied, and the one with patches
|
||||
// 0010 and 0011 (the shadow filter spread) applied. A scaling draw this list misses still scales DLSS's picture, not
|
||||
// The stock translator's hash, the one with tools/xlate patch 0010 (PCSS, PR #89) applied, the one with patches 0010
|
||||
// and 0011 (the shadow filter spread) applied, and that one with the zero_locals job flag (Mesa, vk_caps.c).
|
||||
// A scaling draw this list misses still scales DLSS's picture, not
|
||||
// the jittered scene: after DLSS ran, passes larger than the light target sample DLSS's output (vk_dlaa_swap_img).
|
||||
static const uint64_t FXAA_FS[] = {0xf450003ebeb3dc52ull, 0x0a12f4bc2a9a4fbeull, 0x5c43ae18948d52cbull};
|
||||
static const uint64_t FXAA_FS[] = {0xf450003ebeb3dc52ull, 0x0a12f4bc2a9a4fbeull, 0x5c43ae18948d52cbull,
|
||||
0xaad42238801d31d8ull};
|
||||
|
||||
static uint64_t spirv_hash(const GfxShader *s) {
|
||||
static struct { const GfxShader *s; uint64_t h; } tab[8192];
|
||||
|
||||
+23
-3
@@ -86,6 +86,10 @@ void vk_exec_frame_log(const tex_t *p, uint64_t frame) {
|
||||
nfw = 0;
|
||||
}
|
||||
|
||||
WEAK void vk_exec_ub_flush(VkExec *ex) { (void)ex; }
|
||||
WEAK void vk_exec_ub_foreign(VkExec *ex, uint64_t addr, uint64_t n) { (void)ex, (void)addr, (void)n; }
|
||||
WEAK void vk_exec_ub_perf(void) {}
|
||||
|
||||
WEAK void vk_exec_op_draw(VkExec *ex, uint32_t op, const void *payload) {
|
||||
(void)ex, (void)payload;
|
||||
rt_fatal("vk: op %s is not replayed yet", nvn_op_name(op));
|
||||
@@ -491,18 +495,28 @@ static int tfr_recent(const VkExecTarget *t, uint64_t stamp) {
|
||||
return tfr_match(e, t) && (e->last == stamp ? e->prev : e->last) == stamp - 1;
|
||||
}
|
||||
|
||||
// No frame before the current one rendered the target (a slot another subresource took reads the same).
|
||||
static int tfr_first(const VkExecTarget *t, uint64_t stamp) {
|
||||
const TargetFrames *e = tfr_slot(t);
|
||||
return !tfr_match(e, t) || !(e->last == stamp ? e->prev : e->last);
|
||||
}
|
||||
|
||||
// With any, a recent depth target alone does not make the pass per-frame when a colour target is rendered for the
|
||||
// first time: the game's item icon capture (an inventory or Fuse icon) draws into a new colour target with the
|
||||
// per-frame depth buffer, once. Its draws skipped there stay missing from the icon until the item is captured again.
|
||||
int vk_exec_targets_recent(VkExec *ex, int any) {
|
||||
resolve_rt(ex);
|
||||
uint64_t stamp = __atomic_load_n(&gfx_counters.frames, __ATOMIC_RELAXED) + 1;
|
||||
int n = 0, recent = 0;
|
||||
int n = 0, color = 0, depth = 0, color_first = 0;
|
||||
for (int i = 0; i < 9; i++) {
|
||||
const VkExecTarget *t = i < 8 ? &ex->color[i] : &ex->depth;
|
||||
if (!t->image) continue;
|
||||
n++;
|
||||
if (tfr_recent(t, stamp)) recent++;
|
||||
if (tfr_recent(t, stamp)) *(i < 8 ? &color : &depth) = 1;
|
||||
else if (!any) return 0;
|
||||
else if (i < 8 && tfr_first(t, stamp)) color_first = 1;
|
||||
}
|
||||
return any ? recent > 0 : n > 0;
|
||||
return any ? color || (depth && !color_first) : n > 0;
|
||||
}
|
||||
|
||||
// One line naming each target of the current pass: format, extent, layer and the two frames it was last rendered in
|
||||
@@ -547,6 +561,7 @@ static VkImageMemoryBarrier2 layout_barrier(VkImage img, VkImageAspectFlags aspe
|
||||
// go back to GENERAL and the new ones leave it, in the same command.
|
||||
static void barrier_to(VkExec *ex, const VkImage *want, const VkImageAspectFlags *wasp, int nwant) {
|
||||
vk_exec_pass_end(ex);
|
||||
vk_exec_ub_flush(ex); // uniform updates overlaid in the pass reach their buffers before the barrier below
|
||||
VkImageMemoryBarrier2 ib[18];
|
||||
uint32_t n = 0;
|
||||
for (int i = 0; i < ex->nfbl; i++)
|
||||
@@ -919,6 +934,10 @@ static void walk(VkExec *ex, const op_hdr *h, int depth) {
|
||||
int vk_exec_record(VkCommandBuffer cb, const uint64_t *handles, int n, uint64_t fence) {
|
||||
VkExec *ex = &X;
|
||||
if (!x_ready) state_reset(ex), vk_dump_init(), x_ready = 1;
|
||||
const tex_t *keep[9]; // the render targets carry over to this submission (set_rt compares them by pointer)
|
||||
memcpy(keep, ex->rt.color, sizeof ex->rt.color);
|
||||
keep[8] = ex->rt.depth;
|
||||
nvn_pins_sweep(nvn_submit_count(), keep, 9);
|
||||
ex->cb = cb;
|
||||
ex->fence = fence;
|
||||
ex->recorded = 0;
|
||||
@@ -953,6 +972,7 @@ int vk_exec_record(VkCommandBuffer cb, const uint64_t *handles, int n, uint64_t
|
||||
}
|
||||
ex->cb = VK_NULL_HANDLE;
|
||||
if (n) n_subs++;
|
||||
if (n && (rt_trace & RT_TRACE_PERF)) vk_exec_ub_perf();
|
||||
if ((rt_trace & RT_TRACE_PERF) && n_subs >= 240) {
|
||||
rt_log("perf: replay command buffer parts per submission %.2f", (double)(n_subs + n_parts) / (double)n_subs);
|
||||
n_subs = n_parts = 0;
|
||||
|
||||
+10
-3
@@ -24,7 +24,7 @@ enum {
|
||||
|
||||
// One render target of OP_SET_RT, resolved to its vk_texture.c image for the current command buffer.
|
||||
typedef struct {
|
||||
const tex_t *tex; // payload copy (lives for the run); NULL: no target in this slot
|
||||
const tex_t *tex; // texture pin of ex->rt (nvn_pins_sweep keeps it); NULL: no target in this slot
|
||||
VkImage image; // VK_NULL_HANDLE: no target or not renderable (logged once)
|
||||
VkFormat format; // view format: the view's when it reinterprets a same-size format
|
||||
VkImageViewType view_type; // 2D, or 2D_ARRAY for more than one layer
|
||||
@@ -122,6 +122,12 @@ void vk_exec_pass_end(VkExec *ex);
|
||||
// Full memory barrier (all commands, all memory); ends the open pass first. Copies and dispatches order themselves with
|
||||
// it. Skipped when nothing was recorded since the last one.
|
||||
void vk_exec_barrier(VkExec *ex);
|
||||
// UpdateUniformBuffer overlays (vk_exec_draw.c): the copies into the real buffers that updates inside a pass left for
|
||||
// the next barrier (barrier_to records them first), and a GPU write of another kind into [addr, addr+n) of this
|
||||
// submission (a copy, a fill, a storage write), which retires the overlays it touches.
|
||||
void vk_exec_ub_flush(VkExec *ex);
|
||||
void vk_exec_ub_foreign(VkExec *ex, uint64_t addr, uint64_t n);
|
||||
void vk_exec_ub_perf(void);
|
||||
// vk_texture.c (TOTK_TEX_ALIAS): commands that must run at the replay's current position. vk_exec_inline_begin returns
|
||||
// the command buffer being recorded after a full barrier (an open pass is ended; *reopen says so), or VK_NULL_HANDLE
|
||||
// when this thread is not recording a replay. vk_exec_inline_end begins the pass again when it was open.
|
||||
@@ -142,8 +148,9 @@ void vk_hdr_draw(VkExec *ex, const VkPipeInfo *info);
|
||||
// Scissor 0 clipped to the render area of the open pass; extent 0: nothing to draw.
|
||||
VkRect2D vk_exec_scissor(const VkExec *ex);
|
||||
void vk_exec_noatt_dims(const VkExec *ex, uint32_t *w, uint32_t *h); // pass size when no attachment is set
|
||||
// 1: every target of the current pass (any 1: at least one) was rendered in the previous presented frame (per-frame
|
||||
// targets); 0 otherwise, or when the pass has none.
|
||||
// 1: every target of the current pass (any 1: at least one, and not only the depth target while a colour target is
|
||||
// rendered for the first time) was rendered in the previous presented frame (per-frame targets); 0 otherwise, or when
|
||||
// the pass has none.
|
||||
int vk_exec_targets_recent(VkExec *ex, int any);
|
||||
// The current pass's targets with their formats, extents and the frames they were last rendered in (diagnostics).
|
||||
void vk_exec_targets_describe(VkExec *ex, char *buf, size_t cap);
|
||||
|
||||
+292
-7
@@ -99,6 +99,8 @@ static struct {
|
||||
VkPipeline pipe[2]; // bound per bind point: 0 graphics, 1 compute
|
||||
VkPipeState st; // the state vk_pipeline_set_dynamic last recorded
|
||||
int st_valid;
|
||||
uint32_t vmask; // the vertex bindings st's vertex input reads (valid with st_valid)
|
||||
uint32_t vres; // the streams whose vb entry was resolved from vin (others: the zero buffer)
|
||||
VkBuffer vb[17]; // vertex buffers last bound
|
||||
VkDeviceSize vo[17], vsz[17], vstr[17];
|
||||
int vcls[17]; // their pool class and pool record, for the census
|
||||
@@ -158,6 +160,35 @@ static struct {
|
||||
uint8_t ck[DS_SIG_BYTES];
|
||||
} RS = {-1};
|
||||
|
||||
// UpdateUniformBuffer overlays of the submission being replayed (op_update_ub, TOTK_UB_OVERLAY). Here because
|
||||
// bind_sig reads them (ov_at).
|
||||
#define OV_N 2048
|
||||
#define OV_FOREIGN 4096
|
||||
static struct {
|
||||
int mode; // rt_knob: 0 off, 1 on, 2 ab; -1 unread
|
||||
uint64_t fence; // the submission the lists belong to
|
||||
uint64_t serial; // numbers the overlays, so bind_sig tells one from the next at the same range
|
||||
uint64_t lo, hi; // the span of the overlays: most bindings lie outside it
|
||||
uint32_t n, np, nf;
|
||||
int f_over; // more foreign writes than f holds: no more overlays in this submission
|
||||
struct { uint64_t lo, hi, serial; VkBuffer buf; VkDeviceSize off; } o[OV_N]; // oldest first, no two equal
|
||||
struct { uint64_t dst; VkBuffer src; VkDeviceSize soff; VkMemSpan sp; uint64_t size; } p[OV_N]; // to copy
|
||||
struct { uint64_t lo, hi; } f[OV_FOREIGN]; // GPU writes of other kinds
|
||||
// since the last perf line (vk_exec_ub_perf)
|
||||
uint64_t c_update, c_overlaid, c_flush, c_bind, c_merged, c_mixed, c_inpass, c_outside, c_early;
|
||||
} OV = {.mode = -1};
|
||||
|
||||
// A binding without a size reaches to the end of its backing: as far as the overlays go, for ov_at.
|
||||
static uint64_t ov_rest(uint64_t addr) { return addr < OV.hi ? OV.hi - addr : 1; }
|
||||
|
||||
// The serial of the newest overlay a binding of [addr, addr+n) reads from, 0 for none (bind_sig).
|
||||
static uint64_t ov_at(const VkExec *ex, uint64_t addr, uint64_t n) {
|
||||
if (!OV.n || OV.fence != ex->fence || addr >= OV.hi || addr + n <= OV.lo) return 0;
|
||||
for (uint32_t i = OV.n; i-- > 0;)
|
||||
if (addr < OV.o[i].hi && addr + n > OV.o[i].lo) return OV.o[i].serial;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The bound-slot bytes of VkExec a pipeline's descriptors read, as ranges merged where they touch, built once per
|
||||
// pipeline info: c0's storage table, the uniform and storage ranges of the bound slots and the texture and image
|
||||
// handles. sup and c0: the support inputs and the base instance count too. p: storage buffers whose address the shader
|
||||
@@ -269,6 +300,9 @@ static uint8_t *scratch(VkExec *ex, uint64_t size, VkBuffer *buf, VkDeviceSize *
|
||||
return p;
|
||||
}
|
||||
|
||||
static int ub_bind(VkExec *ex, uint64_t addr, uint64_t n, uint64_t align, VkDescriptorBufferInfo *out);
|
||||
static void ub_written(VkExec *ex, uint64_t addr, uint64_t n);
|
||||
|
||||
static void note_written(VkExec *ex, uint64_t addr, uint64_t size) {
|
||||
CbRes *r = cb_res(ex);
|
||||
for (int i = 0; i < r->nwritten; i++)
|
||||
@@ -807,10 +841,15 @@ static int bind_range(VkExec *ex, uint64_t addr, uint64_t size, uint64_t align,
|
||||
uint64_t n = size && size < sp.avail ? size : sp.avail;
|
||||
if (n > max) n = max;
|
||||
if (!n) return 0;
|
||||
if (!written && (use == VK_MUSE_UBO || use == VK_MUSE_SSBO) && ub_bind(ex, addr, n, align, out)) {
|
||||
count_use(VK_MCLS_SCRATCH, use, n);
|
||||
return 1;
|
||||
}
|
||||
if (sp.offset % align == 0) {
|
||||
VK_MEM_USE(sp);
|
||||
if (written) ub_written(ex, addr, n);
|
||||
*out = (VkDescriptorBufferInfo){sp.buffer, sp.offset, n};
|
||||
if (written) vk_mem_gpu_wrote(&sp, n), vk_usnap_drop(addr, n);
|
||||
if (written) vk_mem_gpu_wrote_shader(&sp, n), vk_usnap_drop(addr, n), vk_exec_ub_foreign(ex, addr, n);
|
||||
count_use(sp.cls, use, n);
|
||||
VK_MEM_BOUND(sp);
|
||||
return 1;
|
||||
@@ -920,6 +959,10 @@ static VkDescriptorBufferInfo ubo_info(VkExec *ex, int st, int slot) {
|
||||
}
|
||||
} else if (slot >= 3 && slot - 3 < 18) {
|
||||
const p_bind_buf *b = &ex->ubo[st][slot - 3];
|
||||
if (b->size && ub_bind(ex, b->addr, b->size < max ? b->size : max, align, &bi)) { // updated in this pass
|
||||
count_use(VK_MCLS_SCRATCH, VK_MUSE_UBO, bi.range);
|
||||
return bi;
|
||||
}
|
||||
if (b->size && vk_usnap_find(b->addr, b->size < max ? b->size : max, align, &bi)) { // as of the submit call
|
||||
count_use(VK_MCLS_SCRATCH, VK_MUSE_UBO, bi.range);
|
||||
return bi;
|
||||
@@ -983,6 +1026,16 @@ static int upd_read(const VkExec *ex, uint64_t a, void *out, size_t n) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Applies the updates of this submission that touch [a, a+n) onto out, oldest first (ub_bind's merged copies).
|
||||
static void upd_apply(const VkExec *ex, uint64_t a, uint8_t *out, size_t n) {
|
||||
if (UPD.fence != ex->fence) return;
|
||||
for (uint32_t i = UPD.n > UPD_N ? UPD.n - UPD_N : 0; i < UPD.n; i++) {
|
||||
const typeof(UPD.u[0]) *e = &UPD.u[i % UPD_N];
|
||||
uint64_t lo = e->lo > a ? e->lo : a, hi = e->hi < a + n ? e->hi : a + n;
|
||||
if (lo < hi) memcpy(out + (lo - a), e->data + (lo - e->lo), hi - lo);
|
||||
}
|
||||
}
|
||||
|
||||
// That address as the bound block holds it at this point of the replay; 0 when the block is unbound or too short. The
|
||||
// block has no size field.
|
||||
static uint64_t ubo_ptr(const VkExec *ex, int st, const GfxShaderRes *r) {
|
||||
@@ -1698,6 +1751,30 @@ static int bind_sig(const VkExec *ex, const VkPipeInfo *info) {
|
||||
}
|
||||
if (l->c0) PUT(c0_binst);
|
||||
for (int i = 0; i < l->np; i++) PUT(ubo_ptr(ex, l->p[i].st, l->p[i].res));
|
||||
// The uniform overlays the draw's uniform and storage bindings read (op_update_ub): a draw of an updated block binds
|
||||
// other bytes after each update, every other draw is left to skip
|
||||
if (OV.n) {
|
||||
uint64_t ov = 0;
|
||||
const size_t lo = offsetof(VkExec, ubo), hi = offsetof(VkExec, ssbo) + sizeof ex->ssbo;
|
||||
for (uint32_t i = 0; i < l->n; i++) {
|
||||
const RsRange *r = &l->r[i];
|
||||
if (r->off + r->len <= lo || r->off >= hi) continue;
|
||||
for (uint32_t o = 0; o + sizeof(p_bind_buf) <= r->len; o += sizeof(p_bind_buf)) {
|
||||
size_t at = r->off + o;
|
||||
if (at < lo || at + sizeof(p_bind_buf) > hi) continue;
|
||||
if ((at < offsetof(VkExec, ssbo) ? at - lo : at - offsetof(VkExec, ssbo)) % sizeof(p_bind_buf)) continue;
|
||||
const p_bind_buf *b = (const p_bind_buf *)((const uint8_t *)ex + at);
|
||||
if (b->addr) ov = ov * 1099511628211ull ^ ov_at(ex, b->addr, b->size ? b->size : ov_rest(b->addr));
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < l->np; i++) { // storage read through an address in a uniform block: no size either
|
||||
uint64_t a = ubo_ptr(ex, l->p[i].st, l->p[i].res);
|
||||
if (a) ov = ov * 1099511628211ull ^ ov_at(ex, a, ov_rest(a));
|
||||
}
|
||||
PUT(ov);
|
||||
} else {
|
||||
PUT(0);
|
||||
}
|
||||
#undef PUT
|
||||
diff |= n != RS.hn;
|
||||
RS.hn = n;
|
||||
@@ -1971,21 +2048,34 @@ static void record_fixed(VkExec *ex) {
|
||||
ex->dirty = 0;
|
||||
}
|
||||
|
||||
// TOTK_VB_USED=0: every bound stream is resolved and bound, also those the draw's vertex input does not read.
|
||||
static int vb_used_on(void) {
|
||||
static int on = -1;
|
||||
if (on < 0) on = cfg_bool("TOTK_VB_USED");
|
||||
return on;
|
||||
}
|
||||
|
||||
static void bind_vertex_buffers(VkExec *ex) {
|
||||
VkBuffer vb[17];
|
||||
VkDeviceSize vo[17], vsz[17], vstr[17];
|
||||
int vcls[17] = {0};
|
||||
void *vpool[17] = {0};
|
||||
int dyn = vk_pipeline_vertex_input_dynamic();
|
||||
// Only the streams the draw's vertex input reads (B.vmask, from vk_pipeline_set_dynamic): the game keeps about ten
|
||||
// bound and a draw reads a few. The others get the zero buffer, never a stale one: a buffer bound but not read must
|
||||
// still be a live handle.
|
||||
uint32_t used = vb_used_on() && B.st_valid ? B.vmask : VK_PIPE_ALL_BINDINGS;
|
||||
// A stream whose range is the one its bound buffer was resolved from, with no pool or mirror change since
|
||||
// (vk_mem_gen, read before any resolve), resolves to that buffer again: most draws change one stream or none.
|
||||
uint64_t gen = vk_mem_gen();
|
||||
int same_gen = B.vb_valid && B.vgen == gen;
|
||||
uint32_t res = 0;
|
||||
for (int b = 0; b < 17; b++) {
|
||||
vb[b] = Z.zbuf, vo[b] = 0, vsz[b] = ZERO_BYTES, vstr[b] = b < 16 ? ex->pipe.vstream[b].stride : 0;
|
||||
if (b == VK_PIPE_ZERO_BINDING) continue;
|
||||
if (b == VK_PIPE_ZERO_BINDING || !(used >> b & 1)) continue;
|
||||
res |= 1u << b;
|
||||
const NVNbufferRange *r = &ex->vbuf[b];
|
||||
if (same_gen && B.vin[b].address == r->address && B.vin[b].size == r->size) {
|
||||
if (same_gen && (B.vres >> b & 1) && B.vin[b].address == r->address && B.vin[b].size == r->size) {
|
||||
vb[b] = B.vb[b], vo[b] = B.vo[b], vsz[b] = B.vsz[b], vcls[b] = B.vcls[b], vpool[b] = B.vpool[b];
|
||||
continue;
|
||||
}
|
||||
@@ -1997,7 +2087,7 @@ static void bind_vertex_buffers(VkExec *ex) {
|
||||
}
|
||||
memcpy(B.vcls, vcls, sizeof vcls), memcpy(B.vpool, vpool, sizeof vpool);
|
||||
memcpy(B.vin, ex->vbuf, sizeof B.vin);
|
||||
B.vgen = gen;
|
||||
B.vgen = gen, B.vres = res;
|
||||
if (B.vb_valid && !memcmp(B.vb, vb, sizeof vb) && !memcmp(B.vo, vo, sizeof vo) && !memcmp(B.vsz, vsz, sizeof vsz) &&
|
||||
(dyn || !memcmp(B.vstr, vstr, sizeof vstr)))
|
||||
return;
|
||||
@@ -2017,7 +2107,8 @@ static void bind_vertex_buffers(VkExec *ex) {
|
||||
// that was not counts as one-shot and waits.
|
||||
// any (1): also draws in a pass with at least one target rendered in the previous frame (a per-frame depth
|
||||
// buffer or g-buffer with a colour target the frame before did not use: half-resolution effect buffers in fights).
|
||||
// Passes with none (volumes, LUTs, shadow layers seen for the first time) still wait.
|
||||
// Passes with none (volumes, LUTs, shadow layers seen for the first time) still wait, and so do passes whose only
|
||||
// recent target is the depth buffer while a colour target is rendered for the first time (item icon captures).
|
||||
// fullscreen (2, default): as any, and full-screen draws into such passes are skipped instead of waiting from their
|
||||
// budget. A full-screen pass whose stage libraries are cold in the driver cache took 86 ms on average (up to 194 ms)
|
||||
// in a fight on an RX 9070: three frames in a row each spent the 50 ms budget, and some of the draws were skipped
|
||||
@@ -2198,7 +2289,7 @@ static int prepare_draw(VkExec *ex, int prim, int32_t count, int restart) {
|
||||
if (!p || !info) return warn_once(W_PIPELINE, "a graphics pipeline could not be created: draws with it are skipped"), 0;
|
||||
bind_pipeline(ex, VK_PIPELINE_BIND_POINT_GRAPHICS, p);
|
||||
if (!B.st_valid || memcmp(&B.st, &s, sizeof s)) {
|
||||
vk_pipeline_set_dynamic(ex->cb, &s);
|
||||
B.vmask = vk_pipeline_set_dynamic(ex->cb, &s);
|
||||
ex->recorded++;
|
||||
B.st = s, B.st_valid = 1;
|
||||
if (ex->mark && vk_exec_frame.draws == ex->mark_draws0) ex->mark->first[0] = vs, ex->mark->first[1] = fs;
|
||||
@@ -3158,14 +3249,208 @@ static void op_dispatch_ind(VkExec *ex, const p_dispatch_ind *d) {
|
||||
if (vk_dump_armed) vk_dump_dispatch(ex, cs, p, info, &(p_dispatch){0, 0, 0, d->caller});
|
||||
}
|
||||
|
||||
// ---- UpdateUniformBuffer inside a pass (TOTK_UB_OVERLAY)
|
||||
// nvnCommandBufferUpdateUniformBuffer writes a few hundred bytes of constants between draws: the draws recorded after
|
||||
// it read the new bytes, those before it the old ones. Replayed as a buffer copy at that point it needs a barrier
|
||||
// against the earlier draws, and a barrier ends the open pass; the terrain and the sky islands' meshes update before
|
||||
// most of their draws, about 200 updates inside passes in a field submission, each a pass end, a full barrier and a
|
||||
// pass begin. Here an update inside a pass goes into the scratch buffer as an overlay: the later bindings of a range it
|
||||
// covers bind the scratch bytes (ub_bind, from ubo_info and bind_range), and the copies into the real buffers wait for
|
||||
// the next full barrier, where the pass has ended anyway (vk_exec_ub_flush from vk_exec.c barrier_to: a pass end, a
|
||||
// dispatch, a transfer, the end of the submission), behind one barrier of their own. Each draw reads the bytes it would
|
||||
// have read with the copy in between.
|
||||
// - A GPU write of another kind (a copy, a fill, a shader's storage write) is ordered with the copies only by the
|
||||
// command buffer. vk_exec_ub_foreign notes it and retires the overlays it touches; an update over such bytes takes
|
||||
// the old path. A shader that writes a range with copies pending gets them first (ub_written: the pass restarts).
|
||||
// - An update that takes the old path (outside a pass, a full list) retires the overlays it covers (ov_retire).
|
||||
// - A binding an overlay covers only in part, or at an offset the binding's alignment refuses, gets a merged scratch
|
||||
// copy: the snapshot's bytes (vk_usnap.c) or guest memory, with every update of the submission so far (UPD) on top.
|
||||
// - The descriptor build skip (bind_sig) hashes the overlay each uniform and storage binding of the draw reads
|
||||
// (ov_at), so a draw of an updated block binds the newest bytes and the other draws still skip. The lists are per submission and keep one entry per range (ov_add), and a binding outside their span is
|
||||
// answered in one comparison; when one is full, updates take the old path.
|
||||
|
||||
static int ov_on(void) {
|
||||
if (OV.mode < 0) OV.mode = rt_knob("UB_OVERLAY", "1");
|
||||
return RT_KNOB_ON(OV.mode);
|
||||
}
|
||||
|
||||
static void ov_sub(const VkExec *ex) { // the submission being replayed
|
||||
if (OV.fence == ex->fence) return;
|
||||
if (OV.np) // barrier_to records them at the latest at the submission's closing barrier
|
||||
rt_log("vk: %u overlaid uniform updates of submission %llu were never copied into their buffers", OV.np,
|
||||
(unsigned long long)OV.fence);
|
||||
OV.fence = ex->fence, OV.n = OV.np = OV.nf = 0, OV.f_over = 0, OV.lo = UINT64_MAX, OV.hi = 0;
|
||||
}
|
||||
|
||||
static void ov_span(void) {
|
||||
OV.lo = UINT64_MAX, OV.hi = 0;
|
||||
for (uint32_t i = 0; i < OV.n; i++) {
|
||||
if (OV.o[i].lo < OV.lo) OV.lo = OV.o[i].lo;
|
||||
if (OV.o[i].hi > OV.hi) OV.hi = OV.o[i].hi;
|
||||
}
|
||||
}
|
||||
|
||||
// A new overlay of [lo, hi), and its copy. An older overlay or pending copy of exactly that range is dropped: the new
|
||||
// one covers it, and the terrain rewrites one block before each draw, so the lists stay as short as the blocks.
|
||||
static void ov_add(uint64_t lo, uint64_t hi, VkBuffer b, VkDeviceSize o, const VkMemSpan *sp) {
|
||||
for (uint32_t i = 0; i < OV.n; i++)
|
||||
if (OV.o[i].lo == lo && OV.o[i].hi == hi) {
|
||||
memmove(&OV.o[i], &OV.o[i + 1], sizeof OV.o[0] * (OV.n - i - 1));
|
||||
OV.n--;
|
||||
break;
|
||||
}
|
||||
for (uint32_t i = 0; i < OV.np; i++)
|
||||
if (OV.p[i].dst == lo && OV.p[i].size == hi - lo) {
|
||||
memmove(&OV.p[i], &OV.p[i + 1], sizeof OV.p[0] * (OV.np - i - 1));
|
||||
OV.np--;
|
||||
break;
|
||||
}
|
||||
OV.o[OV.n++] = (typeof(OV.o[0])){lo, hi, ++OV.serial, b, o};
|
||||
OV.p[OV.np++] = (typeof(OV.p[0])){lo, b, o, *sp, hi - lo};
|
||||
if (lo < OV.lo) OV.lo = lo;
|
||||
if (hi > OV.hi) OV.hi = hi;
|
||||
}
|
||||
|
||||
static int ov_foreign_hits(uint64_t lo, uint64_t hi) {
|
||||
if (OV.f_over) return 1;
|
||||
for (uint32_t i = 0; i < OV.nf; i++)
|
||||
if (lo < OV.f[i].hi && hi > OV.f[i].lo) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The overlays over [lo, hi) no longer hold the newest bytes: the real buffer does. Their copies were recorded already
|
||||
// (the write came after a barrier, or ub_written brought them forward), so they only stop serving bindings.
|
||||
static void ov_retire(uint64_t lo, uint64_t hi) {
|
||||
if (!OV.n || lo >= OV.hi || hi <= OV.lo) return;
|
||||
uint32_t k = 0;
|
||||
for (uint32_t i = 0; i < OV.n; i++)
|
||||
if (!(lo < OV.o[i].hi && hi > OV.o[i].lo)) OV.o[k++] = OV.o[i];
|
||||
if (k == OV.n) return;
|
||||
OV.n = k;
|
||||
ov_span();
|
||||
}
|
||||
|
||||
// A GPU write of another kind into [addr, addr+n) in this submission (vk_exec_xfer.c note_gpu_write, storage bindings).
|
||||
void vk_exec_ub_foreign(VkExec *ex, uint64_t addr, uint64_t n) {
|
||||
if (OV.mode <= 0 || !n) return;
|
||||
ov_sub(ex);
|
||||
if (OV.nf == OV_FOREIGN) OV.f_over = 1;
|
||||
else OV.f[OV.nf].lo = addr, OV.f[OV.nf].hi = addr + n, OV.nf++;
|
||||
ov_retire(addr, addr + n);
|
||||
}
|
||||
|
||||
// Records the copies of the updates overlaid so far, behind a barrier against the draws that read the old bytes.
|
||||
void vk_exec_ub_flush(VkExec *ex) {
|
||||
if (!OV.np || OV.fence != ex->fence) return;
|
||||
VkMemoryBarrier2 mb = {.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER_2,
|
||||
.srcStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
|
||||
.srcAccessMask = VK_ACCESS_2_MEMORY_READ_BIT | VK_ACCESS_2_MEMORY_WRITE_BIT,
|
||||
.dstStageMask = VK_PIPELINE_STAGE_2_COPY_BIT, .dstAccessMask = VK_ACCESS_2_TRANSFER_WRITE_BIT};
|
||||
VkDependencyInfo di = {.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO, .memoryBarrierCount = 1, .pMemoryBarriers = &mb};
|
||||
vkCmdPipelineBarrier2(ex->cb, &di);
|
||||
ex->recorded++;
|
||||
for (uint32_t i = 0; i < OV.np; i++) {
|
||||
VkBufferCopy cp = {OV.p[i].soff, OV.p[i].sp.offset, OV.p[i].size};
|
||||
vkCmdCopyBuffer(ex->cb, OV.p[i].src, OV.p[i].sp.buffer, 1, &cp);
|
||||
ex->recorded++;
|
||||
}
|
||||
OV.np = 0;
|
||||
OV.c_flush++;
|
||||
}
|
||||
|
||||
// TOTK_TRACE=perf, every 600 submissions: per submission, the updates and how many stayed in their pass.
|
||||
void vk_exec_ub_perf(void) {
|
||||
static unsigned subs;
|
||||
if (OV.mode <= 0 || ++subs % 600) return;
|
||||
const double k = 1 / 600.0;
|
||||
rt_log("perf: uniform updates per submission %.1f: %.1f overlaid in a pass (copied in %.2f batches), %.1f copied "
|
||||
"in a pass, %.1f outside one; bindings from an overlay %.1f (%.1f merged, %.1f over other GPU writes), %.2f "
|
||||
"batches brought forward for a storage write",
|
||||
OV.c_update * k, OV.c_overlaid * k, OV.c_flush * k, OV.c_inpass * k, OV.c_outside * k, OV.c_bind * k,
|
||||
OV.c_merged * k, OV.c_mixed * k, OV.c_early * k);
|
||||
OV.c_update = OV.c_overlaid = OV.c_flush = OV.c_bind = OV.c_merged = OV.c_mixed = 0;
|
||||
OV.c_inpass = OV.c_outside = OV.c_early = 0;
|
||||
}
|
||||
|
||||
// The scratch bytes of a uniform or storage binding [addr, addr+n) an overlay of this submission touches: 1 with *out
|
||||
// set, 0 when none does.
|
||||
static int ub_bind(VkExec *ex, uint64_t addr, uint64_t n, uint64_t align, VkDescriptorBufferInfo *out) {
|
||||
if (OV.mode <= 0 || OV.fence != ex->fence || !OV.n || addr >= OV.hi || addr + n <= OV.lo) return 0;
|
||||
uint64_t hi = addr + n;
|
||||
int newest = -1;
|
||||
for (int i = (int)OV.n; i-- > 0;)
|
||||
if (addr < OV.o[i].hi && hi > OV.o[i].lo) { newest = i; break; }
|
||||
if (newest < 0) return 0;
|
||||
OV.c_bind++;
|
||||
if (OV.o[newest].lo <= addr && OV.o[newest].hi >= hi) {
|
||||
VkDeviceSize off = OV.o[newest].off + (addr - OV.o[newest].lo);
|
||||
if (off % align == 0) {
|
||||
*out = (VkDescriptorBufferInfo){OV.o[newest].buf, off, n};
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
// Merged: the bytes as of the submit call (the snapshot, else guest memory), then this submission's updates in
|
||||
// order. A transient copy, so the descriptor build skip leaves the draw after it alone (RS.volatile_). Bytes a copy
|
||||
// or a storage write of this submission produced are not in either source: counted.
|
||||
VkBuffer b;
|
||||
VkDeviceSize o;
|
||||
uint8_t *dst = scratch(ex, n, &b, &o);
|
||||
RS.volatile_ = 1;
|
||||
const uint8_t *base = vk_usnap_bytes(addr, n);
|
||||
VkMemSpan sp;
|
||||
if (base) memcpy(dst, base, n);
|
||||
else if (vk_mem_resolve(addr, n, &sp)) memcpy(dst, (const void *)(uintptr_t)addr, n);
|
||||
else memset(dst, 0, n);
|
||||
if (ov_foreign_hits(addr, hi)) OV.c_mixed++;
|
||||
upd_apply(ex, addr, dst, n);
|
||||
*out = (VkDescriptorBufferInfo){b, o, n};
|
||||
OV.c_merged++;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Before a binding the shader writes: overlaid updates whose copies are still pending go to the buffer first, behind a
|
||||
// barrier, and the pass restarts, so the shader's writes land after them as they did when every update was copied at
|
||||
// once.
|
||||
static void ub_written(VkExec *ex, uint64_t addr, uint64_t n) {
|
||||
if (OV.mode <= 0 || OV.fence != ex->fence || !OV.np) return;
|
||||
for (uint32_t i = 0; i < OV.np; i++)
|
||||
if (addr < OV.p[i].dst + OV.p[i].size && OV.p[i].dst < addr + n) {
|
||||
int reopen = ex->in_pass;
|
||||
vk_exec_barrier(ex); // records the pending copies (vk_exec_ub_flush) before its barrier
|
||||
if (reopen) vk_exec_pass_begin(ex);
|
||||
OV.c_early++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
static void op_update_ub(VkExec *ex, const p_update_ub *u) {
|
||||
if (!u->size) return;
|
||||
VkMemSpan sp;
|
||||
if (!vk_mem_resolve(u->dst, u->size, &sp)) return warn_once(W_UB, "UpdateUniformBuffer to %#llx is not backed: dropped", (unsigned long long)u->dst);
|
||||
cb_res(ex);
|
||||
vk_exec_barrier(ex); // earlier draws finished with the old contents
|
||||
VkBuffer b;
|
||||
VkDeviceSize o;
|
||||
if (ov_on()) {
|
||||
ov_sub(ex);
|
||||
OV.c_update++;
|
||||
if (ex->in_pass && OV.n < OV_N && OV.np < OV_N && !ov_foreign_hits(u->dst, u->dst + u->size)) {
|
||||
uint8_t *d = scratch(ex, u->size, &b, &o);
|
||||
memcpy(d, u->data, u->size);
|
||||
VK_MEM_PICK(sp);
|
||||
ov_add(u->dst, u->dst + u->size, b, o, &sp);
|
||||
vk_mem_gpu_wrote(&sp, u->size);
|
||||
vk_usnap_drop(u->dst, u->size);
|
||||
vk_mem_count(sp.cls, VK_MUSE_GPU_WRITE, u->size);
|
||||
note_written(ex, u->dst, u->size);
|
||||
upd_note(ex, u);
|
||||
OV.c_overlaid++;
|
||||
return;
|
||||
}
|
||||
if (ex->in_pass) OV.c_inpass++;
|
||||
else OV.c_outside++;
|
||||
ov_retire(u->dst, u->dst + u->size);
|
||||
}
|
||||
vk_exec_barrier(ex); // earlier draws finished with the old contents; overlaid updates' copies go first
|
||||
memcpy(scratch(ex, u->size, &b, &o), u->data, u->size);
|
||||
VK_MEM_PICK(sp);
|
||||
VkBufferCopy cp = {o, sp.offset, u->size};
|
||||
|
||||
@@ -912,6 +912,25 @@ static int test_mirror(const GfxBackend *b, uint8_t *guest, int *bytes) {
|
||||
CHECK(xf_submit(b, stream, p), "fence_wait of the third mirror submission timed out");
|
||||
for (int i = 0; i < 0x800; i++) CHECK(m[0x4000 + i] == 0x11, "CPU byte %#x beside a fill is %02x", i, m[0x4000 + i]);
|
||||
for (int i = 0; i < 0x800; i++) CHECK(m[0x4800 + i] == 0x77, "fill byte %#x is %02x", i, m[0x4800 + i]);
|
||||
// 4: two small flushes at the ends of the pool copy their own pages, not what lies between them.
|
||||
int fl_bad = vk_mem_flush_add_probe();
|
||||
CHECK(!fl_bad, "flush range list check %d failed", fl_bad);
|
||||
p = put(stream, OP_BEGIN, NULL, 0);
|
||||
const p_copy_b2b c4 = {a, a + 0x3000, 4096};
|
||||
p = put(p, OP_COPY_B2B, &c4, sizeof c4);
|
||||
CHECK(xf_submit(b, stream, p), "fence_wait of the mirror submission before the flushes timed out");
|
||||
uint64_t up0 = vk_mem_mirror_uploaded();
|
||||
nvn_pool_flushed(&pool, 0x6000, 0x1000);
|
||||
nvn_pool_flushed(&pool, 0xF0000, 0x1000);
|
||||
p = put(stream, OP_BEGIN, NULL, 0);
|
||||
p = put(p, OP_COPY_B2B, &c4, sizeof c4);
|
||||
CHECK(xf_submit(b, stream, p), "fence_wait of the mirror submission after the flushes timed out");
|
||||
// A submission's copies reach the counter with the next one, and c4's own write-back adds its destination page.
|
||||
CHECK(xf_submit(b, stream, p), "fence_wait of the mirror submission that counts the flushes timed out");
|
||||
uint64_t up = vk_mem_mirror_uploaded() - up0;
|
||||
if (!rt_knob("FLUSH_SPAN", "0"))
|
||||
CHECK(up >= 0x2000 && up < 0x10000, "two flushed pages copied %#llx bytes, 0xeb000 would be the span",
|
||||
(unsigned long long)up);
|
||||
b->pool_fini(&pool);
|
||||
*bytes = 4096 + 0x1000 + 4096 + 0x1000;
|
||||
return 0;
|
||||
|
||||
@@ -73,6 +73,7 @@ static void guest_written_cb(void *arg) {
|
||||
// The GPU wrote [addr, addr+size) of CPU-visible memory: tell the texture cache once the fence is reached.
|
||||
static void note_gpu_write(VkExec *ex, uint64_t addr, uint64_t size) {
|
||||
vk_usnap_drop(addr, size);
|
||||
vk_exec_ub_foreign(ex, addr, size);
|
||||
if (nvn_gpu_only(addr)) return;
|
||||
Range *r = malloc(sizeof *r);
|
||||
if (!r) rt_fatal("vk: out of memory");
|
||||
|
||||
+425
-21
@@ -21,6 +21,7 @@
|
||||
#include "nvn_pool_index.h"
|
||||
#include "vk.h"
|
||||
#include "vk_exec.h"
|
||||
#include "wb_merge_spv.h"
|
||||
#include "platform/platform.h"
|
||||
|
||||
// Timestamps for the tracking cost, in ns; qpc_ms converts.
|
||||
@@ -34,6 +35,9 @@ enum { MS_NONE, MS_ON, MS_REFUSED };
|
||||
|
||||
typedef struct { struct PoolRec *phys; uint64_t off; } VPage; // virtual page -> physical pool and offset
|
||||
|
||||
#define FLUSH_RANGES 32 // flush ranges a mirrored pool keeps between two syncs (flush_add)
|
||||
typedef struct { uint64_t lo, hi; } FlushRange;
|
||||
|
||||
typedef struct PoolRec {
|
||||
const pool_t *key;
|
||||
int kind;
|
||||
@@ -59,7 +63,9 @@ typedef struct PoolRec {
|
||||
uint32_t wb_sub; // replay: the last submission that wrote mirror bytes back to guest memory
|
||||
uint32_t mgen; // gen_lock: every page write stamped up to this generation is in the mirror
|
||||
// (or in a copy queued before its next use)
|
||||
uint64_t fl_lo, fl_hi; // flush_lock: nvnMemoryPoolFlushMappedRange bytes not copied yet (fl_hi 0: none)
|
||||
FlushRange fl[FLUSH_RANGES + 1]; // flush_lock: nvnMemoryPoolFlushMappedRange pages not copied yet (flush_add)
|
||||
int fl_n; // flush_lock: ranges in fl; read without the lock to skip an empty list
|
||||
uint64_t fl_calls, fl_sum; // flush_lock: the calls and bytes behind fl, for mirror_sync's statistics
|
||||
// Shadow storage (POOL_IMPORT in shadow mode, "shadow storage" below): buf is a host-visible copy of the guest pages.
|
||||
VmaAllocation salloc;
|
||||
uint8_t *smap; // its mapping; offsets as in buf (from import_lo)
|
||||
@@ -504,7 +510,10 @@ static int dead_pending(void);
|
||||
|
||||
static void vram_log(uint64_t frame);
|
||||
|
||||
static volatile uint64_t mirror_frame; // the frame vk_mem_census last saw, for mirror_sync's log lines
|
||||
|
||||
void vk_mem_census(uint64_t frame) {
|
||||
mirror_frame = frame;
|
||||
static uint64_t window_start;
|
||||
if ((rt_trace & RT_TRACE_PERF) && frame % 600 == 0) vram_log(frame);
|
||||
if ((rt_trace & RT_TRACE_PERF) && !vk_mirror_on) dirty_frame(frame);
|
||||
@@ -556,10 +565,23 @@ void vk_mem_census(uint64_t frame) {
|
||||
window_start = frame;
|
||||
}
|
||||
|
||||
// TOTK_VRAM_LIMIT_MIB: pretend the device-local budget is at most this many MiB, so the paths that react to the budget
|
||||
// can be driven on a card larger than the one a report came from. The driver knows nothing of it.
|
||||
static uint64_t vram_limit(void) {
|
||||
static int64_t limit = -1;
|
||||
if (limit < 0) {
|
||||
const char *s = cfg_get("TOTK_VRAM_LIMIT_MIB");
|
||||
limit = s && *s ? (int64_t)strtoull(s, NULL, 10) << 20 : 0;
|
||||
if (limit) rt_log("mem: device-local budget limited to %lld MiB (TOTK_VRAM_LIMIT_MIB)", (long long)(limit >> 20));
|
||||
}
|
||||
return (uint64_t)limit;
|
||||
}
|
||||
|
||||
int vk_mem_vram(uint64_t *used, uint64_t *budget, int *exact) {
|
||||
VkCtx *c = vk_ctx();
|
||||
*used = *budget = 0, *exact = 0;
|
||||
if (!c) return 0;
|
||||
uint64_t limit = vram_limit();
|
||||
const VkPhysicalDeviceMemoryProperties *mp = &c->mem_props;
|
||||
if (c->has_memory_budget) {
|
||||
VkPhysicalDeviceMemoryBudgetPropertiesEXT b = {.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_BUDGET_PROPERTIES_EXT};
|
||||
@@ -569,6 +591,7 @@ int vk_mem_vram(uint64_t *used, uint64_t *budget, int *exact) {
|
||||
if (p2.memoryProperties.memoryHeaps[h].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT)
|
||||
*used += b.heapUsage[h], *budget += b.heapBudget[h];
|
||||
*exact = 1;
|
||||
if (limit && *budget > limit) *budget = limit;
|
||||
return *budget != 0;
|
||||
}
|
||||
VmaBudget vb[VK_MAX_MEMORY_HEAPS];
|
||||
@@ -576,6 +599,7 @@ int vk_mem_vram(uint64_t *used, uint64_t *budget, int *exact) {
|
||||
for (uint32_t h = 0; h < mp->memoryHeapCount && h < VK_MAX_MEMORY_HEAPS; h++)
|
||||
if (mp->memoryHeaps[h].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT)
|
||||
*used += vb[h].statistics.blockBytes, *budget += mp->memoryHeaps[h].size;
|
||||
if (limit && *budget > limit) *budget = limit;
|
||||
return *budget != 0;
|
||||
}
|
||||
|
||||
@@ -624,6 +648,17 @@ static uint32_t reg_tried[RT_ADDRESS_LIMIT >> REG_SHIFT]; // gen_lock: ww_ep o
|
||||
static uint32_t *fcand; // gen_lock: regions to try at the next freeze pass
|
||||
static int nfcand, cfcand;
|
||||
static uint64_t n_frz_refused; // gen_lock: candidates mem_freeze refused
|
||||
// Regions the GPU writes. Storage-buffer stores, buffer copies and fills land in imported guest memory, directly (import
|
||||
// mode) or through a pool's mirror that vk_mem_mirror_end copies or merges into the imported buffer. They are not CPU
|
||||
// writes: no fault thaws the region and no write-watch bit is set, so a region only the GPU writes looked idle, was
|
||||
// frozen, and the GPU went on writing pages the CPU side had made read-only. LAS Recomp never changes the protection of
|
||||
// imported memory: on NVIDIA, VirtualProtect on pages imported with VK_EXT_external_memory_host left the GPU reading
|
||||
// wrong data (runtime/nvn/vulkan/exec.c, the comment on its write watch); a GPU write there is the likely cause of the
|
||||
// device faults "write invalid" at a page of guest memory on NVIDIA (#788, #802). reg_gpu holds the timeline value of
|
||||
// the newest submission that writes into the region (gpu_writes): freeze_pass leaves the region alone until the GPU is
|
||||
// past that value, and a frozen region is thawed when a write into it is recorded.
|
||||
static uint64_t reg_gpu[RT_ADDRESS_LIMIT >> REG_SHIFT]; // replay thread
|
||||
static volatile int64_t n_frz_gpu, n_thaw_gpu; // freeze candidates kept for a GPU write; thaws for one (mirror_log)
|
||||
|
||||
static uint32_t *gen_slot(uint64_t page_addr, int create) {
|
||||
uint64_t ci = page_addr >> GEN_CHUNK_SHIFT;
|
||||
@@ -1132,7 +1167,10 @@ static void dirty_frame(uint64_t frame) {
|
||||
// - CPU -> mirror: the first access of a submission to a mirror syncs it (mirror_sync): the 2 MiB write-watch
|
||||
// snapshots of its regions for this epoch (snap_regions, shared with the texture checks: the OS bits keep one
|
||||
// consumer, the shared page generations) and every page stamped after the mirror's mgen goes into a copy; so do the
|
||||
// nvnMemoryPoolFlushMappedRange ranges. The first use copies the whole range. The copies run in a prologue command
|
||||
// nvnMemoryPoolFlushMappedRange ranges, each on its own: one span from the lowest to the highest flush of a 252 MiB
|
||||
// pool copied up to the whole pool for a few MiB flushed, 30-50 ms of GPU time over PCIe 3.0 x8 in crowded towns
|
||||
// (TOTK_FLUSH_SPAN=1 restores it). Writes between the ranges need no copy: the write watch sees them. The first use
|
||||
// copies the whole range. The copies run in a prologue command
|
||||
// buffer that vk_sync.c submits right before the part that accessed the mirror first, between full barriers.
|
||||
// vk_mem_mirror_begin snaps the regions of the pools the previous submission accessed in a few merged queries.
|
||||
// - GPU -> guest: what the replay writes into a mirror (storage buffers its shaders write, buffer copies, fills,
|
||||
@@ -1160,10 +1198,13 @@ static int npend, cpend, nwbl, cwbl;
|
||||
static VkBufferCopy *mregions; // replay: vkCmdCopyBuffer regions
|
||||
static int cregions;
|
||||
static PlatMutex flush_lock = PLAT_MUTEX_INIT;
|
||||
static int flush_cap = FLUSH_RANGES; // ranges a pool keeps; 1 with TOTK_FLUSH_SPAN=1
|
||||
|
||||
typedef struct {
|
||||
uint64_t subs, syncs, upload_bytes, upload_copies, first_n, first_bytes, wb_bytes, wb_copies, wb_ranges;
|
||||
uint64_t presnap_queries, sync_queries, presnap_t, sync_t, prologues, presnap_bytes;
|
||||
uint64_t snap_bytes, merge_bytes, merge_dispatches, snap_allocs; // write-back merge
|
||||
uint64_t page_bytes, flush_n, flush_bytes, flush_calls, flush_sum; // what mirror_sync queues, by source
|
||||
} MirrorAcc;
|
||||
static MirrorAcc mrep; // replay: this submission
|
||||
static MirrorAcc macc; // mirror_stat_lock: since the last summary
|
||||
@@ -1188,6 +1229,10 @@ static void mirror_mode(int discrete) {
|
||||
if (mirror_gap < 0) mirror_gap = 0;
|
||||
// Submissions a region stays unwritten before it is frozen. 60 rather than 300: in a fight 100 of the 257 MiB
|
||||
// queried a frame lay in regions idle for 30-299 submissions, and a thaw by a write costs about 60 us.
|
||||
if (rt_knob("FLUSH_SPAN", "0")) {
|
||||
flush_cap = 1;
|
||||
rt_log("mem: a pool's flushes copy one span from the lowest to the highest (TOTK_FLUSH_SPAN=1)");
|
||||
}
|
||||
const char *fi = cfg_get("TOTK_FREEZE_IDLE");
|
||||
freeze_idle = fi && *fi ? atoi(fi) : 60;
|
||||
if (freeze_idle < 0) freeze_idle = 0;
|
||||
@@ -1390,19 +1435,30 @@ static void mirror_sync(PoolRec *r) {
|
||||
if (!ch) continue;
|
||||
for (uint64_t x = a; x < b; x += 1ull << GEN_PAGE_SHIFT)
|
||||
if (ch[(x >> GEN_PAGE_SHIFT) & (GEN_PER_CHUNK - 1)] > since)
|
||||
range_add(&pend, &npend, &cpend, r, x - lo, 1ull << GEN_PAGE_SHIFT);
|
||||
range_add(&pend, &npend, &cpend, r, x - lo, 1ull << GEN_PAGE_SHIFT), mrep.page_bytes += 1ull << GEN_PAGE_SHIFT;
|
||||
}
|
||||
r->mgen = gen_now;
|
||||
if (excl) plat_mutex_unlock(&gen_lock);
|
||||
else plat_mutex_unlock_shared(&gen_lock);
|
||||
if (__atomic_load_n(&r->fl_hi, __ATOMIC_ACQUIRE)) {
|
||||
if (__atomic_load_n(&r->fl_n, __ATOMIC_ACQUIRE)) {
|
||||
FlushRange fl[FLUSH_RANGES + 1];
|
||||
plat_mutex_lock(&flush_lock);
|
||||
uint64_t a = r->fl_lo, b = r->fl_hi;
|
||||
r->fl_hi = 0;
|
||||
int n = r->fl_n;
|
||||
memcpy(fl, r->fl, (size_t)n * sizeof *fl);
|
||||
uint64_t calls = r->fl_calls, sum = r->fl_sum;
|
||||
r->fl_n = 0, r->fl_calls = r->fl_sum = 0;
|
||||
plat_mutex_unlock(&flush_lock);
|
||||
a = a > lo ? a & ~0xfffull : lo;
|
||||
b = b < hi ? (b + 0xfff) & ~0xfffull : hi;
|
||||
if (a < b) range_add(&pend, &npend, &cpend, r, a - lo, b - a);
|
||||
mrep.flush_calls += calls, mrep.flush_sum += sum;
|
||||
for (int i = 0; i < n; i++) {
|
||||
uint64_t a = fl[i].lo > lo ? fl[i].lo : lo, b = fl[i].hi < hi ? fl[i].hi : hi;
|
||||
if (a >= b) continue;
|
||||
range_add(&pend, &npend, &cpend, r, a - lo, b - a);
|
||||
mrep.flush_n++, mrep.flush_bytes += b - a;
|
||||
if ((rt_trace & RT_TRACE_PERF) && b - a >= 16u << 20)
|
||||
rt_log("mem: mirror frame %llu: flush range %.1f MiB at +%.1f MiB of a %.1f MiB pool (%d ranges from %llu "
|
||||
"flushes of %.3f MiB)", (unsigned long long)mirror_frame, (b - a) / 1048576.0, (a - lo) / 1048576.0,
|
||||
(hi - lo) / 1048576.0, n, (unsigned long long)calls, sum / 1048576.0);
|
||||
}
|
||||
}
|
||||
mrep.syncs++;
|
||||
mrep.sync_t += qpc() - t0;
|
||||
@@ -1431,11 +1487,320 @@ int vk_mem_mirror_probe(uint64_t addr) {
|
||||
return ((PoolRec *)sp.pool)->mstate == MS_ON;
|
||||
}
|
||||
|
||||
void vk_mem_gpu_wrote(const VkMemSpan *sp, uint64_t size) {
|
||||
// ---- write-back merge
|
||||
// A storage buffer a shader writes whole records of does not
|
||||
// go back to guest memory as a plain copy. The CPU may write fields of the same records while the GPU runs: TotK's
|
||||
// particle emitters write a new particle's whole record into a free slot of a buffer that the simulation shaders update
|
||||
// (the waterfall splashes and mist, 2026-11-02), and a plain copy puts the mirror's stale copy of those words back over
|
||||
// them. Instead:
|
||||
// - record time (gpu_wrote from vk_mem_gpu_wrote_shader): each distinct range of the submission gets room in a snapshot
|
||||
// chunk (snap_add);
|
||||
// - each part's prologue, after the CPU -> mirror copies: the mirror's words of the ranges the part added are copied
|
||||
// into their snapshots (snap_copies), the state before the part's GPU work. Words of an earlier part's writes that a
|
||||
// later range overlaps are covered by the earlier range's own snapshot;
|
||||
// - vk_mem_mirror_end: shaders/wb_merge.comp writes each 32-bit word that differs between the mirror and the snapshot
|
||||
// into the pool's imported buffer (wb_merge_record), one dispatch per pool and group of ranges.
|
||||
// Words the GPU left unchanged keep the CPU's value; a word both changed takes the GPU's, as a plain copy would.
|
||||
// TOTK_WB_MERGE=0 restores the plain copies; so does a pipeline or allocation failure.
|
||||
#define SNAP_CHUNK (1u << 20)
|
||||
#define MERGE_SPAN (64ull << 20) // the bytes of one pool a merge dispatch binds at most
|
||||
#define MERGE_WORDS (65535u * 64u) // the words one merge dispatch covers at most
|
||||
#define MERGE_MAX (4ull << 20) // larger written ranges stay plain copies
|
||||
typedef struct { PoolRec *r; uint64_t off, size, soff; int chunk; } SnapRange;
|
||||
typedef struct { VkBuffer buf; VmaAllocation alloc; uint64_t size, used; int host; } SnapChunk;
|
||||
typedef struct { int n; SnapChunk c[]; } SnapFree; // a submission's chunks and range lists, pooled at its fence
|
||||
static SnapRange *snl; // replay: this submission's merged ranges; [0, snl_copied) have their snapshot copy
|
||||
static int nsnl, csnl, snl_copied;
|
||||
static SnapChunk *chunks; // replay: this submission's snapshot chunks and merge range lists
|
||||
static int nchunks, cchunks;
|
||||
// Chunks a finished submission used (snap_free) wait here for chunk_new, so a frame with merges creates no buffer once
|
||||
// the pool holds the frames in flight: a VMA allocation that needs a new memory block costs 8-11 ms on the replay
|
||||
// thread. Chunk sizes are powers of two so that one fits the next request; what the pool cannot hold is destroyed.
|
||||
#define SNAP_LIST_MIN (64u << 10)
|
||||
#define SNAP_POOL_N 32
|
||||
#define SNAP_POOL_BYTES (16ull << 20)
|
||||
static PlatMutex snap_pool_lock = PLAT_MUTEX_INIT;
|
||||
static SnapChunk snap_pool[SNAP_POOL_N];
|
||||
static int nsnap_pool;
|
||||
static uint64_t snap_pool_bytes;
|
||||
static int merge_on = -1;
|
||||
static struct {
|
||||
int tried, ok;
|
||||
PFN_vkCmdPushDescriptorSetKHR push;
|
||||
VkDescriptorSetLayout dsl;
|
||||
VkPipelineLayout layout;
|
||||
VkPipeline pipe;
|
||||
} WM;
|
||||
|
||||
static int merge_init(VkCtx *c) {
|
||||
WM.tried = 1;
|
||||
WM.push = (PFN_vkCmdPushDescriptorSetKHR)vkGetDeviceProcAddr(c->device, "vkCmdPushDescriptorSetKHR");
|
||||
VkDescriptorSetLayoutBinding b[4];
|
||||
for (int i = 0; i < 4; i++)
|
||||
b[i] = (VkDescriptorSetLayoutBinding){.binding = (uint32_t)i, .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER,
|
||||
.descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_COMPUTE_BIT};
|
||||
VkDescriptorSetLayoutCreateInfo dci = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
|
||||
.flags = VK_DESCRIPTOR_SET_LAYOUT_CREATE_PUSH_DESCRIPTOR_BIT_KHR,
|
||||
.bindingCount = 4, .pBindings = b};
|
||||
VkPushConstantRange pr = {VK_SHADER_STAGE_COMPUTE_BIT, 0, 8};
|
||||
VkShaderModuleCreateInfo mi = {.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, .codeSize = sizeof wb_merge_spv,
|
||||
.pCode = wb_merge_spv};
|
||||
VkShaderModule m = VK_NULL_HANDLE;
|
||||
if (!WM.push || vkCreateDescriptorSetLayout(c->device, &dci, NULL, &WM.dsl) != VK_SUCCESS) goto fail;
|
||||
VkPipelineLayoutCreateInfo li = {.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, .setLayoutCount = 1,
|
||||
.pSetLayouts = &WM.dsl, .pushConstantRangeCount = 1, .pPushConstantRanges = &pr};
|
||||
if (vkCreatePipelineLayout(c->device, &li, NULL, &WM.layout) != VK_SUCCESS ||
|
||||
vkCreateShaderModule(c->device, &mi, NULL, &m) != VK_SUCCESS)
|
||||
goto fail;
|
||||
VkComputePipelineCreateInfo ci = {.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 = m, .pName = "main"},
|
||||
.layout = WM.layout};
|
||||
VkResult res = vkCreateComputePipelines(c->device, VK_NULL_HANDLE, 1, &ci, NULL, &WM.pipe);
|
||||
vkDestroyShaderModule(c->device, m, NULL);
|
||||
if (res != VK_SUCCESS) goto fail;
|
||||
return WM.ok = 1;
|
||||
fail:
|
||||
rt_log("mem: write-back merge off: its pipeline could not be created");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int merging(void) {
|
||||
if (merge_on < 0) {
|
||||
merge_on = cfg_bool("TOTK_WB_MERGE");
|
||||
rt_log("mem: write-back merge %s (TOTK_WB_MERGE)", merge_on ? "on" : "off");
|
||||
}
|
||||
if (!merge_on) return 0;
|
||||
if (!WM.tried) merge_init(vk_ctx());
|
||||
return WM.ok;
|
||||
}
|
||||
|
||||
// A chunk of at least size bytes in chunks[]: the smallest fitting one of the pool, else a new buffer; -1 when the
|
||||
// allocation fails.
|
||||
static int chunk_new(uint64_t size, int host) {
|
||||
uint64_t want = host ? SNAP_LIST_MIN : SNAP_CHUNK;
|
||||
while (want < size) want <<= 1;
|
||||
if (nchunks == cchunks) grow((void **)&chunks, &cchunks, sizeof *chunks);
|
||||
SnapChunk *ch = &chunks[nchunks];
|
||||
int best = -1;
|
||||
plat_mutex_lock(&snap_pool_lock);
|
||||
for (int i = 0; i < nsnap_pool; i++)
|
||||
if (snap_pool[i].host == host && snap_pool[i].size >= want && (best < 0 || snap_pool[i].size < snap_pool[best].size))
|
||||
best = i;
|
||||
if (best >= 0) {
|
||||
*ch = snap_pool[best];
|
||||
snap_pool[best] = snap_pool[--nsnap_pool];
|
||||
snap_pool_bytes -= ch->size;
|
||||
}
|
||||
plat_mutex_unlock(&snap_pool_lock);
|
||||
if (best < 0) {
|
||||
VkBufferCreateInfo bi = {.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, .size = want,
|
||||
.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT,
|
||||
.sharingMode = VK_SHARING_MODE_EXCLUSIVE};
|
||||
VmaAllocationCreateInfo ai = {.usage = host ? VMA_MEMORY_USAGE_AUTO_PREFER_HOST : VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE,
|
||||
.flags = host ? VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT |
|
||||
VMA_ALLOCATION_CREATE_MAPPED_BIT : 0};
|
||||
if (vmaCreateBuffer(vk_ctx()->vma, &bi, &ai, &ch->buf, &ch->alloc, NULL) != VK_SUCCESS) return -1;
|
||||
ch->size = want, ch->host = host;
|
||||
mrep.snap_allocs++;
|
||||
}
|
||||
ch->used = 0;
|
||||
return nchunks++;
|
||||
}
|
||||
|
||||
// Record time: room in a snapshot chunk for [off, off+size) of r's mirror, once per submission. 0: plain copy instead.
|
||||
static int snap_add(PoolRec *r, uint64_t off, uint64_t size) {
|
||||
for (int i = nsnl; i-- > 0;)
|
||||
if (snl[i].r == r && snl[i].off == off && snl[i].size == size) return 1;
|
||||
int k = -1;
|
||||
for (int i = nchunks; i-- > 0;) // the newest snapshot chunk, if the range fits
|
||||
if (chunks[i].used != UINT64_MAX && chunks[i].size - chunks[i].used >= size) { k = i; break; }
|
||||
if (k < 0 && (k = chunk_new(size > SNAP_CHUNK ? size : SNAP_CHUNK, 0)) < 0) return 0;
|
||||
if (nsnl == csnl) grow((void **)&snl, &csnl, sizeof *snl);
|
||||
snl[nsnl++] = (SnapRange){r, off, size, chunks[k].used, k};
|
||||
chunks[k].used += (size + 15) & ~15ull;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Prologue: the snapshot copies of the ranges added since the last prologue.
|
||||
static void snap_copies(VkCommandBuffer cb) {
|
||||
for (int i = snl_copied; i < nsnl; i++) {
|
||||
VkBufferCopy cp = {snl[i].off, snl[i].soff, snl[i].size};
|
||||
vkCmdCopyBuffer(cb, snl[i].r->mbuf, chunks[snl[i].chunk].buf, 1, &cp);
|
||||
mrep.snap_bytes += snl[i].size;
|
||||
}
|
||||
snl_copied = nsnl;
|
||||
}
|
||||
|
||||
static int snap_cmp(const void *x, const void *y) {
|
||||
const SnapRange *a = x, *b = y;
|
||||
if (a->r != b->r) return (uintptr_t)a->r < (uintptr_t)b->r ? -1 : 1;
|
||||
if (a->chunk != b->chunk) return a->chunk < b->chunk ? -1 : 1;
|
||||
return a->off < b->off ? -1 : a->off > b->off;
|
||||
}
|
||||
|
||||
// One dispatch: ranges s[0..n) share a pool and a chunk and lie in [base, base + span) of the pool.
|
||||
static void merge_dispatch(VkCommandBuffer cb, const SnapRange *s, int n, uint64_t base, uint64_t span, int list,
|
||||
uint64_t list_off, uint32_t total) {
|
||||
VkDescriptorBufferInfo bi[4] = {{s->r->mbuf, base, span}, {chunks[s->chunk].buf, 0, chunks[s->chunk].size},
|
||||
{s->r->buf, base, span}, {chunks[list].buf, list_off, (uint64_t)n * 16}};
|
||||
VkWriteDescriptorSet w[4];
|
||||
for (int i = 0; i < 4; i++)
|
||||
w[i] = (VkWriteDescriptorSet){.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstBinding = (uint32_t)i,
|
||||
.descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER,
|
||||
.pBufferInfo = &bi[i]};
|
||||
rec_push(WM.push, cb, VK_PIPELINE_BIND_POINT_COMPUTE, WM.layout, 0, 4, w); // in call order (vk_rec.h)
|
||||
uint32_t pc[2] = {(uint32_t)n, total};
|
||||
vkCmdPushConstants(cb, WM.layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof pc, pc);
|
||||
vkCmdDispatch(cb, (total + 63) / 64, 1, 1);
|
||||
}
|
||||
|
||||
// vk_mem_mirror_end: the merge dispatches of this submission's ranges. Returns the commands recorded; 0 with nsnl
|
||||
// left as it was when the range list cannot be allocated (the caller copies them plainly).
|
||||
static int wb_merge_record(VkCommandBuffer cb) {
|
||||
VkCtx *c = vk_ctx();
|
||||
uint64_t align = c->props.limits.minStorageBufferOffsetAlignment;
|
||||
if (align < 16) align = 16;
|
||||
SnapRange *s = malloc(sizeof *s * (size_t)nsnl);
|
||||
if (!s) rt_fatal("vk: out of memory");
|
||||
memcpy(s, snl, sizeof *s * (size_t)nsnl);
|
||||
qsort(s, (size_t)nsnl, sizeof *s, snap_cmp);
|
||||
// the range lists: at most one 16-byte entry per range, each dispatch's list at an aligned offset
|
||||
int list = chunk_new((uint64_t)nsnl * 16 + (uint64_t)nsnl * align, 1);
|
||||
if (list < 0) { free(s); return 0; }
|
||||
chunks[list].used = UINT64_MAX; // not a snapshot chunk
|
||||
VmaAllocationInfo li;
|
||||
vmaGetAllocationInfo(c->vma, chunks[list].alloc, &li);
|
||||
uint32_t *lm = li.pMappedData;
|
||||
uint64_t lo = 0;
|
||||
int rec = 0;
|
||||
vkCmdBindPipeline(cb, VK_PIPELINE_BIND_POINT_COMPUTE, WM.pipe);
|
||||
for (int i = 0; i < nsnl;) {
|
||||
uint64_t base = s[i].off & ~(align - 1), end = s[i].off + s[i].size;
|
||||
uint32_t total = 0;
|
||||
int j = i;
|
||||
lo = (lo + align - 1) & ~(align - 1);
|
||||
uint64_t l0 = lo;
|
||||
for (; j < nsnl && s[j].r == s[i].r && s[j].chunk == s[i].chunk; j++) {
|
||||
uint64_t e = s[j].off + s[j].size > end ? s[j].off + s[j].size : end;
|
||||
if (j > i && (e - base > MERGE_SPAN || total + s[j].size / 4 > MERGE_WORDS)) break;
|
||||
end = e;
|
||||
uint32_t *q = lm + lo / 4;
|
||||
q[0] = (uint32_t)((s[j].off - base) / 4), q[1] = (uint32_t)(s[j].soff / 4), q[2] = (uint32_t)(s[j].size / 4);
|
||||
q[3] = total;
|
||||
total += (uint32_t)(s[j].size / 4);
|
||||
lo += 16;
|
||||
mrep.merge_bytes += s[j].size;
|
||||
}
|
||||
merge_dispatch(cb, s + i, j - i, base, end - base, list, l0, total);
|
||||
mrep.merge_dispatches++;
|
||||
rec += 3;
|
||||
i = j;
|
||||
}
|
||||
vmaFlushAllocation(c->vma, chunks[list].alloc, 0, VK_WHOLE_SIZE);
|
||||
for (int i = 0; i < nsnl; i++) snl[i].r->wb_sub = msub;
|
||||
free(s);
|
||||
return rec;
|
||||
}
|
||||
|
||||
static void snap_free(void *arg) { // completion thread: the submission is done with its chunks
|
||||
SnapFree *f = arg;
|
||||
plat_mutex_lock(&snap_pool_lock);
|
||||
for (int i = 0; i < f->n; i++) {
|
||||
SnapChunk *ch = &f->c[i];
|
||||
if (nsnap_pool == SNAP_POOL_N || snap_pool_bytes + ch->size > SNAP_POOL_BYTES) continue;
|
||||
snap_pool[nsnap_pool++] = *ch;
|
||||
snap_pool_bytes += ch->size;
|
||||
ch->buf = VK_NULL_HANDLE;
|
||||
}
|
||||
plat_mutex_unlock(&snap_pool_lock);
|
||||
for (int i = 0; i < f->n; i++)
|
||||
if (f->c[i].buf) vmaDestroyBuffer(vk_ctx()->vma, f->c[i].buf, f->c[i].alloc);
|
||||
free(f);
|
||||
}
|
||||
|
||||
// Record time: the submission being recorded writes [off, off+size) of r's guest range, directly into the imported
|
||||
// buffer or into the mirror whose bytes go back at its end. Keeps the regions unfrozen until the GPU is past it
|
||||
// (reg_gpu). Shadow storage needs nothing: the GPU writes the shadow and the CPU copies it back, thawing as it goes.
|
||||
static void gpu_writes(const PoolRec *r, uint64_t off, uint64_t size) {
|
||||
if (!freeze_idle || r->smap) return;
|
||||
uint64_t a = r->import_lo + off, b = a + size;
|
||||
if (b <= a || b > RT_ADDRESS_LIMIT) return;
|
||||
uint64_t f = vk_exec_recording_fence();
|
||||
if (!f) f = vk_sync_issued() + 1;
|
||||
int frozen = 0;
|
||||
for (uint64_t j = a >> REG_SHIFT; j <= (b - 1) >> REG_SHIFT; j++) {
|
||||
if (__atomic_load_n(®_gpu[j], __ATOMIC_RELAXED) < f) __atomic_store_n(®_gpu[j], f, __ATOMIC_RELAXED);
|
||||
frozen |= mem_frozen(j << REG_SHIFT);
|
||||
}
|
||||
if (frozen) mem_thaw(a, b - a), plat_inc64(&n_thaw_gpu);
|
||||
}
|
||||
|
||||
static void gpu_wrote(const VkMemSpan *sp, uint64_t size, int shader) {
|
||||
PoolRec *r = sp->pool;
|
||||
if (r && size) gpu_writes(r, sp->offset, size);
|
||||
if (!r || r->mstate != MS_ON || sp->buffer != r->mbuf || !size) return;
|
||||
// A binding to the end of its pool (no size) can span hundreds of MiB: those stay copies.
|
||||
if (shader && !(sp->offset & 3) && !(size & 3) && size <= MERGE_MAX && merging() && snap_add(r, sp->offset, size))
|
||||
return;
|
||||
range_add(&wbl, &nwbl, &cwbl, r, sp->offset, size);
|
||||
}
|
||||
void vk_mem_gpu_wrote(const VkMemSpan *sp, uint64_t size) { gpu_wrote(sp, size, 0); }
|
||||
void vk_mem_gpu_wrote_shader(const VkMemSpan *sp, uint64_t size) { gpu_wrote(sp, size, 1); }
|
||||
|
||||
// Adds [a, b) to l, n sorted ranges that do not touch; returns the new count. Above cap ranges (l holds cap + 1) the two
|
||||
// nearest merge, so a full list copies the fewest bytes that were not flushed; cap 1 keeps one span over all flushes.
|
||||
static int flush_add(FlushRange *l, int n, uint64_t a, uint64_t b, int cap) {
|
||||
int i = 0;
|
||||
while (i < n && l[i].hi < a) i++;
|
||||
int j = i;
|
||||
for (; j < n && l[j].lo <= b; j++) {
|
||||
if (l[j].lo < a) a = l[j].lo;
|
||||
if (l[j].hi > b) b = l[j].hi;
|
||||
}
|
||||
memmove(l + i + 1, l + j, (size_t)(n - j) * sizeof *l);
|
||||
l[i] = (FlushRange){a, b};
|
||||
n += 1 - (j - i);
|
||||
if (n > cap) {
|
||||
int k = 0;
|
||||
for (int x = 1; x + 1 < n; x++)
|
||||
if (l[x + 1].lo - l[x].hi < l[k + 1].lo - l[k].hi) k = x;
|
||||
l[k].hi = l[k + 1].hi;
|
||||
memmove(l + k + 1, l + k + 2, (size_t)(n - k - 2) * sizeof *l);
|
||||
n--;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
int vk_mem_flush_add_probe(void) {
|
||||
FlushRange l[5];
|
||||
int n = 0;
|
||||
n = flush_add(l, n, 0x8000, 0x9000, 4);
|
||||
n = flush_add(l, n, 0x1000, 0x2000, 4);
|
||||
n = flush_add(l, n, 0x4000, 0x5000, 4);
|
||||
if (n != 3 || l[0].lo != 0x1000 || l[1].lo != 0x4000 || l[2].hi != 0x9000) return 1; // kept apart, sorted
|
||||
n = flush_add(l, n, 0x2000, 0x3000, 4);
|
||||
if (n != 3 || l[0].hi != 0x3000) return 2; // touching ranges join
|
||||
n = flush_add(l, n, 0x800, 0x8800, 4);
|
||||
if (n != 1 || l[0].lo != 0x800 || l[0].hi != 0x9000) return 3; // one covers several
|
||||
n = 0;
|
||||
n = flush_add(l, n, 0x10000, 0x11000, 2);
|
||||
n = flush_add(l, n, 0x40000, 0x41000, 2);
|
||||
n = flush_add(l, n, 0x12000, 0x13000, 2); // over the cap: nearest merge
|
||||
if (n != 2 || l[0].lo != 0x10000 || l[0].hi != 0x13000 || l[1].lo != 0x40000) return 4;
|
||||
n = 0;
|
||||
n = flush_add(l, n, 0x10000, 0x11000, 1);
|
||||
n = flush_add(l, n, 0x40000, 0x41000, 1);
|
||||
if (n != 1 || l[0].lo != 0x10000 || l[0].hi != 0x41000) return 5; // TOTK_FLUSH_SPAN=1
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint64_t vk_mem_mirror_uploaded(void) {
|
||||
plat_mutex_lock(&mirror_stat_lock);
|
||||
uint64_t n = macc.upload_bytes;
|
||||
plat_mutex_unlock(&mirror_stat_lock);
|
||||
return n;
|
||||
}
|
||||
|
||||
void nvn_pool_flushed(const pool_t *p, uint64_t off, uint64_t size) {
|
||||
if (!vk_mirror_on || !size || !p->mem) return;
|
||||
@@ -1445,13 +1810,10 @@ void nvn_pool_flushed(const pool_t *p, uint64_t off, uint64_t size) {
|
||||
const pidx_ent *e = pidx_find(&pool_index, (uintptr_t)p->mem, 0);
|
||||
PoolRec *r = e && e->key == p ? e->buf : NULL;
|
||||
if (r && r->mstate == MS_ON) {
|
||||
uint64_t a = (uintptr_t)p->mem + off, b = a + size;
|
||||
uint64_t a = ((uintptr_t)p->mem + off) & ~0xfffull, b = ((uintptr_t)p->mem + off + size + 0xfff) & ~0xfffull;
|
||||
plat_mutex_lock(&flush_lock);
|
||||
if (!r->fl_hi) r->fl_lo = a, r->fl_hi = b;
|
||||
else {
|
||||
if (a < r->fl_lo) r->fl_lo = a;
|
||||
if (b > r->fl_hi) r->fl_hi = b;
|
||||
}
|
||||
__atomic_store_n(&r->fl_n, flush_add(r->fl, r->fl_n, a, b, flush_cap), __ATOMIC_RELEASE);
|
||||
r->fl_calls++, r->fl_sum += size;
|
||||
plat_mutex_unlock(&flush_lock);
|
||||
}
|
||||
plat_mutex_unlock_shared(&pool_lock);
|
||||
@@ -1460,9 +1822,11 @@ void nvn_pool_flushed(const pool_t *p, uint64_t off, uint64_t size) {
|
||||
// gen_lock exclusive: freezes up to FREEZE_PER_SUB candidate regions still idle. The query after the
|
||||
// protection collects the writes made before it took effect.
|
||||
static void freeze_pass(void) {
|
||||
uint64_t gpu_done = vk_sync_fence_done();
|
||||
for (int done = 0; nfcand && done < FREEZE_PER_SUB;) {
|
||||
uint64_t j = fcand[--nfcand], a = j << REG_SHIFT;
|
||||
if (ww_ep - reg_last[j] < (uint32_t)freeze_idle || mem_frozen(a)) continue;
|
||||
if (__atomic_load_n(®_gpu[j], __ATOMIC_RELAXED) > gpu_done) { plat_inc64(&n_frz_gpu); continue; } // gpu_writes
|
||||
done++;
|
||||
if (!mem_freeze(a)) { n_frz_refused++; continue; }
|
||||
query_stamp(a, a + (1ull << REG_SHIFT), ++gen_now);
|
||||
@@ -1479,6 +1843,7 @@ void vk_mem_mirror_begin(void) {
|
||||
return;
|
||||
}
|
||||
uint64_t t0 = qpc();
|
||||
nsnl = snl_copied = nchunks = 0; // the previous submission handed its chunks to snap_free
|
||||
uint32_t prev = msub;
|
||||
msub = msub + 1 ? msub + 1 : 1;
|
||||
__atomic_add_fetch(&mirror_gen, 1, __ATOMIC_RELEASE);
|
||||
@@ -1510,13 +1875,18 @@ void vk_mem_mirror_begin(void) {
|
||||
mrep.presnap_t += qpc() - t0;
|
||||
}
|
||||
|
||||
int vk_mem_mirror_pending(void) { return npend; }
|
||||
int vk_mem_mirror_pending(void) { return npend || snl_copied < nsnl; }
|
||||
|
||||
void vk_mem_mirror_prologue(VkCommandBuffer cb) {
|
||||
if (!npend) return;
|
||||
if (!npend && snl_copied == nsnl) return;
|
||||
mbarrier(cb, VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT, VK_ACCESS_2_MEMORY_WRITE_BIT, VK_PIPELINE_STAGE_2_COPY_BIT,
|
||||
VK_ACCESS_2_TRANSFER_READ_BIT | VK_ACCESS_2_TRANSFER_WRITE_BIT);
|
||||
copy_ranges(cb, pend, npend, 1, &mrep.upload_bytes, &mrep.upload_copies);
|
||||
if (snl_copied < nsnl) { // the snapshots read what the uploads above wrote
|
||||
if (npend) mbarrier(cb, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_2_COPY_BIT,
|
||||
VK_ACCESS_2_TRANSFER_READ_BIT);
|
||||
snap_copies(cb);
|
||||
}
|
||||
mbarrier(cb, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
|
||||
VK_ACCESS_2_MEMORY_READ_BIT | VK_ACCESS_2_MEMORY_WRITE_BIT);
|
||||
npend = 0;
|
||||
@@ -1615,6 +1985,26 @@ static int verify_record(VkCommandBuffer cb, uint64_t fence) {
|
||||
int vk_mem_mirror_end(VkCommandBuffer cb, uint64_t fence) {
|
||||
if (!vk_mirror_on) return 0;
|
||||
int rec = 0;
|
||||
if (nsnl) {
|
||||
// the merge reads the mirror the submission's shaders wrote and writes the imported buffers
|
||||
mbarrier(cb, VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT, VK_ACCESS_2_MEMORY_WRITE_BIT,
|
||||
VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, VK_ACCESS_2_SHADER_STORAGE_READ_BIT);
|
||||
int m = wb_merge_record(cb);
|
||||
if (!m) { // no range list: copy them plainly
|
||||
for (int i = 0; i < nsnl; i++) range_add(&wbl, &nwbl, &cwbl, snl[i].r, snl[i].off, snl[i].size);
|
||||
}
|
||||
rec += m + 1;
|
||||
mbarrier(cb, VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, VK_ACCESS_2_SHADER_STORAGE_WRITE_BIT,
|
||||
VK_PIPELINE_STAGE_2_HOST_BIT | VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT,
|
||||
VK_ACCESS_2_HOST_READ_BIT | VK_ACCESS_2_MEMORY_READ_BIT | VK_ACCESS_2_MEMORY_WRITE_BIT);
|
||||
}
|
||||
if (nchunks) { // the last part's prologue still records snapshot copies into them; back to the pool at the fence
|
||||
SnapFree *f = malloc(sizeof *f + sizeof(SnapChunk) * (size_t)nchunks);
|
||||
if (!f) rt_fatal("vk: out of memory");
|
||||
f->n = nchunks;
|
||||
memcpy(f->c, chunks, sizeof(SnapChunk) * (size_t)nchunks);
|
||||
vk_completion_add(fence, snap_free, f);
|
||||
}
|
||||
if (nwbl) {
|
||||
mbarrier(cb, VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT, VK_ACCESS_2_MEMORY_WRITE_BIT, VK_PIPELINE_STAGE_2_COPY_BIT,
|
||||
VK_ACCESS_2_TRANSFER_READ_BIT);
|
||||
@@ -1654,6 +2044,10 @@ static void mirror_log(uint64_t frame) {
|
||||
"or mapping change %llu, refused %llu",
|
||||
(unsigned long long)frame, wc / wf, wb / wf / 1048576.0, qpc_ms(wt) / wf, dc / wf, wp / wf, qpc_ms(dt) / wf,
|
||||
fz * 2.0, (unsigned long long)ff, (unsigned long long)ft, (unsigned long long)fo, (unsigned long long)nr);
|
||||
int64_t kg = plat_xchg64(&n_frz_gpu, 0), tg = plat_xchg64(&n_thaw_gpu, 0);
|
||||
if (kg || tg) // gpu_writes: before this, every count here was a region frozen under a GPU write
|
||||
rt_log("mem: frame %llu: regions the GPU writes, since the last line: %lld kept from freezing, %lld frozen ones "
|
||||
"thawed", (unsigned long long)frame, (long long)kg, (long long)tg);
|
||||
if (!vk_mirror_on) return;
|
||||
plat_mutex_lock(&mirror_stat_lock);
|
||||
MirrorAcc a = macc;
|
||||
@@ -1669,14 +2063,19 @@ static void mirror_log(uint64_t frame) {
|
||||
(long long)plat_xchg64(&v_mismatch, 0));
|
||||
rt_log("mem: mirror frame %llu: pools %lld, %.1f MiB device-local; stay imported: budget %lld (%.1f MiB), untracked "
|
||||
"%lld, overlapping %lld, failed %lld; per frame: submits %.2f, syncs %.1f, upload %.3f MiB in %.1f ranges, "
|
||||
"first use %.2f pools %.3f MiB, write-back %.3f MiB in %.1f ranges, flush calls %.1f (%.3f MiB), queries "
|
||||
"first use %.2f pools %.3f MiB, write-back %.3f MiB in %.1f ranges, merge %.3f MiB in %.1f dispatches "
|
||||
"(snapshots %.3f MiB, new buffers %.2f), flush calls %.1f (%.3f MiB), queries "
|
||||
"%.1f pre-snap (%.1f MiB) + %.1f sync, prologues %.2f, tracking cpu %.3f ms (pre-snap %.3f, syncs %.3f)%s",
|
||||
(unsigned long long)frame, (long long)m_live_n, m_live_bytes / M, (long long)m_refused_n[MR_BUDGET],
|
||||
m_refused_bytes[MR_BUDGET] / M, (long long)m_refused_n[MR_UNTRACKED], (long long)m_refused_n[MR_OVERLAP],
|
||||
(long long)m_refused_n[MR_ALLOC], a.subs / f, a.syncs / f, a.upload_bytes / f / M, a.upload_copies / f,
|
||||
a.first_n / f, a.first_bytes / f / M, a.wb_bytes / f / M, a.wb_copies / f, fc / f, fb / f / M,
|
||||
a.first_n / f, a.first_bytes / f / M, a.wb_bytes / f / M, a.wb_copies / f, a.merge_bytes / f / M,
|
||||
a.merge_dispatches / f, a.snap_bytes / f / M, a.snap_allocs / f, fc / f, fb / f / M,
|
||||
a.presnap_queries / f, a.presnap_bytes / f / M, a.sync_queries / f, a.prologues / f, qpc_ms(a.presnap_t + a.sync_t) / f,
|
||||
qpc_ms(a.presnap_t) / f, qpc_ms(a.sync_t) / f, ver);
|
||||
rt_log("mem: mirror frame %llu: upload sources per frame, overlaps counted twice: written pages %.3f MiB; flush ranges "
|
||||
"%.2f, %.3f MiB, from %.1f flushes of %.3f MiB", (unsigned long long)frame, a.page_bytes / f / M, a.flush_n / f,
|
||||
a.flush_bytes / f / M, a.flush_calls / f, a.flush_sum / f / M);
|
||||
}
|
||||
|
||||
// With TOTK_TRACE=perf every 600 frames: what holds device-local memory, to see what grows over a long session.
|
||||
@@ -1701,6 +2100,7 @@ static void vram_log(uint64_t frame) {
|
||||
alloc_bytes / M, (unsigned long long)t.live, (unsigned long long)t.own_live, t.bytes_live / M,
|
||||
(unsigned long long)t.released, (unsigned long long)t.own_released, t.bytes_released / M, (long long)m_live_n,
|
||||
m_live_bytes / M, dead_pending());
|
||||
vk_texture_released_log(frame);
|
||||
}
|
||||
|
||||
// Device class: INTEGRATED -> uma; a discrete GPU with a DEVICE_LOCAL heap of 1 GiB or more beside a
|
||||
@@ -2038,7 +2438,11 @@ void vk_mem_shutdown(void) {
|
||||
plat_mutex_lock(&pool_lock);
|
||||
for (int i = 0; i < pool_index.n; i++) destroy_rec(c, pool_index.e[i].buf);
|
||||
pool_index.n = 0;
|
||||
nm = npend = nwbl = 0; // mirrors went with their pools
|
||||
nm = npend = nwbl = nsnl = snl_copied = 0; // mirrors went with their pools
|
||||
plat_mutex_lock(&snap_pool_lock);
|
||||
for (int i = 0; i < nsnap_pool; i++) vmaDestroyBuffer(c->vma, snap_pool[i].buf, snap_pool[i].alloc);
|
||||
nsnap_pool = 0, snap_pool_bytes = 0;
|
||||
plat_mutex_unlock(&snap_pool_lock);
|
||||
plat_mutex_lock(&shadow_lock);
|
||||
nstouch = nspend = 0; // shadows too
|
||||
s_live_n = s_live_bytes = 0;
|
||||
|
||||
+11
-5
@@ -3113,8 +3113,8 @@ int vk_pipeline_warm_shaders(void) {
|
||||
|
||||
// ---- dynamic state
|
||||
|
||||
void vk_pipeline_set_dynamic(VkCommandBuffer cb, const VkPipeState *s) {
|
||||
if (!ensure_init()) return;
|
||||
uint32_t vk_pipeline_set_dynamic(VkCommandBuffer cb, const VkPipeState *s) {
|
||||
if (!ensure_init()) return VK_PIPE_ALL_BINDINGS;
|
||||
const poly_st *pl = &s->poly;
|
||||
const ds_st *d = &s->ds;
|
||||
// The front face maps unchanged: TOTK sets the upper-left window origin, and the y-flipped viewport (y_flip) puts
|
||||
@@ -3137,16 +3137,21 @@ void vk_pipeline_set_dynamic(VkCommandBuffer cb, const VkPipeState *s) {
|
||||
vkCmdSetDepthBiasEnable(cb, (pl->offset_en >> mode & 1) != 0);
|
||||
vkCmdSetRasterizerDiscardEnable(cb, s->raster_discard != 0);
|
||||
vkCmdSetPrimitiveRestartEnable(cb, s->prim_restart != 0);
|
||||
if (P.dyn_vi && s->vs) {
|
||||
// The vertex bindings the draw reads: those of its vertex input, set here or made into the pipeline from the same
|
||||
// state by the same vertex_input.
|
||||
uint32_t streams = VK_PIPE_ALL_BINDINGS;
|
||||
if (s->vs) {
|
||||
VtxInput v;
|
||||
vertex_input(s, &v);
|
||||
rec_vertex_input(P.set_vi, cb, v.n_bind, v.b, v.n_attr, v.a);
|
||||
if (P.dyn_vi) rec_vertex_input(P.set_vi, cb, v.n_bind, v.b, v.n_attr, v.a);
|
||||
streams = 0;
|
||||
for (uint32_t i = 0; i < v.n_bind; i++) streams |= 1u << v.b[i].binding;
|
||||
}
|
||||
if (P.dyn_clamp) rec_bool(P.set_clamp, cb, s->depth_clamp != 0);
|
||||
if (P.dyn_poly) rec_polygon_mode(P.set_poly, cb, polygon_mode(pl->mode));
|
||||
if (P.dyn_a2c) rec_bool((PFN_vkCmdSetDepthClampEnableEXT)P.set_a2c, cb, s->ms.a2c != 0); // the same signature
|
||||
int n = target_count(s);
|
||||
if (!n) return;
|
||||
if (!n) return streams;
|
||||
VkBool32 en[8];
|
||||
VkColorBlendEquationEXT eq[8];
|
||||
VkColorComponentFlags wm[8];
|
||||
@@ -3160,6 +3165,7 @@ void vk_pipeline_set_dynamic(VkCommandBuffer cb, const VkPipeState *s) {
|
||||
if (P.dyn_blend_en) rec_blend_enable(P.set_blend_en, cb, 0, (uint32_t)n, en);
|
||||
if (P.dyn_blend_eq) rec_blend_equation(P.set_blend_eq, cb, 0, (uint32_t)n, eq);
|
||||
if (P.dyn_wmask) rec_write_mask(P.set_wmask, cb, 0, (uint32_t)n, wm);
|
||||
return streams;
|
||||
}
|
||||
|
||||
// ---- statistics, persistence, teardown
|
||||
|
||||
+156
-48
@@ -3,7 +3,8 @@
|
||||
// call's arguments; its pointers point into the packet itself, so the recorder passes the payload to the driver as
|
||||
// it is. A packet never wraps: one that does not fit before the end is preceded by a pad. The producer publishes head
|
||||
// after each packet; the recorder makes the calls and advances tail, which the producer waits on when the ring is
|
||||
// full and in rec_drain. While a part is recorded the recorder spins on head; idle, it sleeps on an event.
|
||||
// full and in rec_drain. While a part is recorded the recorder spins on head; idle, it sleeps on an event. Packets also
|
||||
// begin, end and submit the parts (rec_begin, rec_end_cb, rec_submit), so each part stays on the recorder throughout.
|
||||
// Design: Savage Wind Recomp #189 and LAS Recomp runtime/nvn/vulkan/record.c (https://github.com/peiikai/LASRecomp
|
||||
// @ a4b53a3, MIT, Copyright (c) 2026 Peiikai); the code is ours.
|
||||
#define VK_REC_IMPL
|
||||
@@ -18,21 +19,35 @@
|
||||
|
||||
#define RING_WORDS (1u << 21) // 16 MiB: a part of a submission is ~1-2 MiB of packets in the town
|
||||
static uint64_t ring[RING_WORDS];
|
||||
static volatile uint64_t head, tail; // words published by the producer / consumed by the recorder
|
||||
static volatile int32_t sleeping; // the recorder waits for wake_ev
|
||||
// The queue state, a cache line per writer. The recorder spins on head while it waits for packets, so a line the
|
||||
// producer writes on every packet and the recorder reads or writes goes back and forth between their cores each time:
|
||||
// with tail, Q.put_h, head and the producer's statistics in one line (the linker's order of these statics), handing
|
||||
// packets over took 6% of the replay thread in the fort, more than building the descriptors.
|
||||
static struct {
|
||||
_Alignas(64) volatile uint64_t head; // words published by the producer; the recorder reads it
|
||||
_Alignas(64) volatile uint64_t tail; // words consumed by the recorder; the producer reads it when it seems full
|
||||
_Alignas(64) volatile int32_t sleeping; // the recorder waits for wake_ev; written only around a sleep
|
||||
_Alignas(64) uint64_t put_h; // producer only: words written
|
||||
uint64_t pub_h; // the last head published
|
||||
uint64_t tail_seen; // tail as last read, at most the real one: the room it leaves is there
|
||||
uint64_t pub_words; // TOTK_REC_PUB: publish head once this many words wait (pub)
|
||||
} Q;
|
||||
static PlatEvent wake_ev; // auto-reset
|
||||
static uint64_t put_h; // producer: words written (published up to head)
|
||||
static VkCommandBuffer rec_cb; // the part being recorded (set while the recorder is idle)
|
||||
static VkCommandBuffer rec_cb; // producer: the part being recorded, whose vkCmd* become packets
|
||||
static VkCommandBuffer r_cb; // recorder: the command buffer its packets go into (set by a BeginCb packet)
|
||||
static int rec_live; // producer: rec_begin started the recorder for rec_cb
|
||||
static int end_mode = -1; // TOTK_REC_END (rec_own)
|
||||
static int sub_pending; // producer: the recorder ended a part that rec_submit has not handed over yet
|
||||
static volatile VkResult r_sub_res; // recorder: the result of its last vkQueueSubmit2, read after the drain
|
||||
static PlatThread *rec_thread;
|
||||
static int rec_prio; // the submitting thread's priority, which the recorder takes
|
||||
static int rec_chk; // TOTK_REC_CHECK (rec_check_handle)
|
||||
static uint64_t rec_delay_ns; // TOTK_REC_DELAY_US (rec_main)
|
||||
static volatile uint64_t part_h0; // put_h at rec_begin: where the packets of the part being recorded start
|
||||
static volatile uint64_t part_h0; // Q.put_h at rec_begin: where the packets of the part being recorded start
|
||||
static volatile uint64_t part_fence; // the value of the part being recorded (rec_begin)
|
||||
|
||||
// Producer statistics, logged by rec_stats_log.
|
||||
static struct { uint64_t packets, words, drains, fallbacks, drain_ns, full_waits, parts, parts_on; } RS_;
|
||||
static struct { _Alignas(64) uint64_t packets, words, drains, fallbacks, drain_ns, full_waits, parts, parts_on, submits; } RS_;
|
||||
|
||||
enum {
|
||||
R_PAD, R_BeginQuery, R_BeginRendering, R_BindDescriptorSets, R_BindIndexBuffer, R_BindPipeline, R_BindVertexBuffers2,
|
||||
@@ -44,7 +59,7 @@ enum {
|
||||
R_SetRasterizerDiscardEnable, R_SetScissorWithCount, R_SetStencilCompareMask, R_SetStencilOp, R_SetStencilReference,
|
||||
R_SetStencilTestEnable, R_SetStencilWriteMask, R_SetViewportWithCount, R_WriteTimestamp, R_WriteTimestamp2,
|
||||
R_PushTpl, R_Push, R_VertexInput, R_Bool, R_PolygonMode, R_BlendEnable, R_BlendEquation, R_WriteMask, R_EndCb,
|
||||
R_PassMark,
|
||||
R_PassMark, R_BeginCb, R_Submit,
|
||||
};
|
||||
|
||||
static uint64_t now_ns(void) { return plat_now_ns(); }
|
||||
@@ -58,14 +73,14 @@ static void rec_main(void *arg) {
|
||||
plat_thread_priority(rec_prio);
|
||||
uint64_t t = 0;
|
||||
for (;;) {
|
||||
uint64_t h = __atomic_load_n(&head, __ATOMIC_ACQUIRE);
|
||||
uint64_t h = __atomic_load_n(&Q.head, __ATOMIC_ACQUIRE);
|
||||
if (t == h) {
|
||||
for (int i = 0; i < 4096 && t == h; i++) _mm_pause(), h = __atomic_load_n(&head, __ATOMIC_ACQUIRE);
|
||||
for (int i = 0; i < 4096 && t == h; i++) _mm_pause(), h = __atomic_load_n(&Q.head, __ATOMIC_ACQUIRE);
|
||||
if (t == h) {
|
||||
__atomic_store_n(&sleeping, 1, __ATOMIC_SEQ_CST);
|
||||
h = __atomic_load_n(&head, __ATOMIC_SEQ_CST);
|
||||
__atomic_store_n(&Q.sleeping, 1, __ATOMIC_SEQ_CST);
|
||||
h = __atomic_load_n(&Q.head, __ATOMIC_SEQ_CST);
|
||||
if (t == h) plat_event_wait_ns(&wake_ev, 2000000); // a missed wake costs at most 2 ms
|
||||
__atomic_store_n(&sleeping, 0, __ATOMIC_RELAXED);
|
||||
__atomic_store_n(&Q.sleeping, 0, __ATOMIC_RELAXED);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -77,9 +92,9 @@ static void rec_main(void *arg) {
|
||||
uint32_t op = (uint32_t)(w & 0xffff), n = (uint32_t)(w >> 32);
|
||||
if (op != R_PAD) exec_packet(op, &ring[t % RING_WORDS + 1]);
|
||||
t += n;
|
||||
if ((k & 63) == 63) __atomic_store_n(&tail, t, __ATOMIC_RELEASE);
|
||||
if ((k & 63) == 63) __atomic_store_n(&Q.tail, t, __ATOMIC_RELEASE);
|
||||
}
|
||||
__atomic_store_n(&tail, t, __ATOMIC_RELEASE);
|
||||
__atomic_store_n(&Q.tail, t, __ATOMIC_RELEASE);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -88,54 +103,85 @@ static void rec_main(void *arg) {
|
||||
|
||||
static void wake(void) {
|
||||
__atomic_thread_fence(__ATOMIC_SEQ_CST);
|
||||
if (__atomic_load_n(&sleeping, __ATOMIC_RELAXED)) plat_event_set(&wake_ev);
|
||||
if (__atomic_load_n(&Q.sleeping, __ATOMIC_RELAXED)) plat_event_set(&wake_ev);
|
||||
}
|
||||
|
||||
// Room for need words: the tail read last leaves it in all but about one packet in 16 MiB of them.
|
||||
static void wait_space(uint64_t need) {
|
||||
if (put_h + need - __atomic_load_n(&tail, __ATOMIC_ACQUIRE) <= RING_WORDS) return;
|
||||
if (Q.put_h + need - Q.tail_seen <= RING_WORDS) return;
|
||||
Q.tail_seen = __atomic_load_n(&Q.tail, __ATOMIC_ACQUIRE);
|
||||
if (Q.put_h + need - Q.tail_seen <= RING_WORDS) return;
|
||||
RS_.full_waits++;
|
||||
__atomic_store_n(&head, put_h, __ATOMIC_RELEASE);
|
||||
Q.pub_h = Q.put_h;
|
||||
__atomic_store_n(&Q.head, Q.put_h, __ATOMIC_RELEASE);
|
||||
wake();
|
||||
while (put_h + need - __atomic_load_n(&tail, __ATOMIC_ACQUIRE) > RING_WORDS) _mm_pause();
|
||||
while (Q.put_h + need - (Q.tail_seen = __atomic_load_n(&Q.tail, __ATOMIC_ACQUIRE)) > RING_WORDS) _mm_pause();
|
||||
}
|
||||
|
||||
// Room for a packet of bytes of arguments; the caller fills it and calls pub().
|
||||
static void *put(uint32_t op, size_t bytes) {
|
||||
uint64_t n = 1 + (bytes + 7) / 8, pos = put_h % RING_WORDS;
|
||||
uint64_t n = 1 + (bytes + 7) / 8, pos = Q.put_h % RING_WORDS;
|
||||
if (n > RING_WORDS / 4) rt_fatal("vk_rec: a packet of %llu bytes", (unsigned long long)bytes);
|
||||
if (pos + n > RING_WORDS) {
|
||||
uint64_t pad = RING_WORDS - pos;
|
||||
wait_space(pad);
|
||||
ring[pos] = (uint64_t)R_PAD | pad << 32;
|
||||
put_h += pad, pos = 0;
|
||||
Q.put_h += pad, pos = 0;
|
||||
}
|
||||
wait_space(n);
|
||||
ring[pos] = (uint64_t)op | n << 32;
|
||||
// Packers reserve room for optional arrays they may leave unwritten (BeginRendering's depth and stencil); with
|
||||
// TOTK_REC_CHECK those words would still hold handles from the ring's previous lap.
|
||||
if (rec_chk) memset(&ring[pos + 1], 0, (n - 1) * 8);
|
||||
put_h += n;
|
||||
Q.put_h += n;
|
||||
RS_.packets++, RS_.words += n;
|
||||
return &ring[pos + 1];
|
||||
}
|
||||
|
||||
// After a packet: head moves once Q.pub_words words wait, so the recorder reads a line the producer is still filling
|
||||
// once per batch instead of once per packet. Whatever the producer waits for publishes everything first (wait_space,
|
||||
// rec_drain), and the packets that begin and end a part go out at once (pub_now), so the recorder begins and ends
|
||||
// command buffers as early as before; TOTK_REC_CHECK publishes every packet, since rec_check_handle searches the ring
|
||||
// up to head.
|
||||
static void pub_now(void) {
|
||||
Q.pub_h = Q.put_h;
|
||||
__atomic_store_n(&Q.head, Q.put_h, __ATOMIC_RELEASE);
|
||||
if (__atomic_load_n(&Q.sleeping, __ATOMIC_RELAXED)) plat_event_set(&wake_ev);
|
||||
}
|
||||
|
||||
static void pub(void) {
|
||||
__atomic_store_n(&head, put_h, __ATOMIC_RELEASE);
|
||||
if (__atomic_load_n(&sleeping, __ATOMIC_RELAXED)) plat_event_set(&wake_ev);
|
||||
if (Q.put_h - Q.pub_h >= Q.pub_words) pub_now();
|
||||
}
|
||||
|
||||
void rec_drain(void) {
|
||||
if (!rec_thread || __atomic_load_n(&tail, __ATOMIC_ACQUIRE) == put_h) return;
|
||||
if (!rec_thread || __atomic_load_n(&Q.tail, __ATOMIC_ACQUIRE) == Q.put_h) return;
|
||||
uint64_t t0 = now_ns();
|
||||
__atomic_store_n(&head, put_h, __ATOMIC_RELEASE);
|
||||
Q.pub_h = Q.put_h;
|
||||
__atomic_store_n(&Q.head, Q.put_h, __ATOMIC_RELEASE);
|
||||
wake();
|
||||
for (int i = 0; __atomic_load_n(&tail, __ATOMIC_ACQUIRE) != put_h; i++)
|
||||
for (int i = 0; __atomic_load_n(&Q.tail, __ATOMIC_ACQUIRE) != Q.put_h; i++)
|
||||
if (i < 20000) _mm_pause();
|
||||
else plat_yield();
|
||||
RS_.drains++, RS_.drain_ns += now_ns() - t0;
|
||||
}
|
||||
|
||||
void rec_begin(VkCommandBuffer cb, uint64_t fence) {
|
||||
static int rec_own(void) {
|
||||
if (end_mode < 0) end_mode = rt_knob("REC_END", "1");
|
||||
return end_mode;
|
||||
}
|
||||
|
||||
// vkBeginCommandBuffer (one submission) and the breadcrumb, on the thread that records cb.
|
||||
static void begin_now(VkCommandBuffer cb, int crumb, uint64_t n) {
|
||||
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
|
||||
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
|
||||
vkBeginCommandBuffer(cb, &bi);
|
||||
if (crumb) vk_crumb(cb, crumb, n);
|
||||
}
|
||||
|
||||
// The command buffer the following packets go into; begin: the recorder begins it (TOTK_REC_END=1).
|
||||
typedef struct { VkCommandBuffer cb; int begin, crumb; uint64_t n; } A_BeginCb;
|
||||
|
||||
void rec_begin(VkCommandBuffer cb, uint64_t fence, int crumb, uint64_t n) {
|
||||
static int mode = -1;
|
||||
if (mode < 0) {
|
||||
// Default off on AMD GPUs. With the recorder those drivers lost the device or hung the GPU at startup on
|
||||
@@ -154,6 +200,9 @@ void rec_begin(VkCommandBuffer cb, uint64_t fence) {
|
||||
rec_chk = rt_knob("REC_CHECK", "0"); // debug switches, read before the recorder starts
|
||||
const char *d = cfg_get("TOTK_REC_DELAY_US");
|
||||
rec_delay_ns = d && *d && atoi(d) > 0 ? (uint64_t)atoi(d) * 1000 : 0;
|
||||
const char *pw = cfg_get("TOTK_REC_PUB"); // words per publish (pub); 0: every packet
|
||||
Q.pub_words = rec_chk ? 0 : pw && *pw ? (uint64_t)atoi(pw) : 64;
|
||||
if (Q.pub_words > RING_WORDS / 8) Q.pub_words = RING_WORDS / 8;
|
||||
rec_thread = plat_thread_new(rec_main, NULL, "vk recorder");
|
||||
if (!rec_thread) {
|
||||
rt_log("vk_rec: no recorder thread: the replay makes its Vulkan calls itself");
|
||||
@@ -162,17 +211,29 @@ void rec_begin(VkCommandBuffer cb, uint64_t fence) {
|
||||
rt_log("vk_rec: the replay's Vulkan commands are made by a recorder thread%s",
|
||||
mode == 2 ? " in the B blocks (TOTK_AB)" : "");
|
||||
if (rec_chk) rt_log("vk_rec: TOTK_REC_CHECK: deferred and destroyed objects are searched in the ring");
|
||||
if (Q.pub_words != 64) rt_log("vk_rec: TOTK_REC_PUB: head published every %llu words",
|
||||
(unsigned long long)Q.pub_words);
|
||||
if (rec_delay_ns) rt_log("vk_rec: TOTK_REC_DELAY_US: the recorder waits %llu us before each batch",
|
||||
(unsigned long long)(rec_delay_ns / 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
RS_.parts++;
|
||||
if (!RT_KNOB_ON(mode)) return;
|
||||
int on = RT_KNOB_ON(mode), own = on && rec_own();
|
||||
if (!own) {
|
||||
// The submitting thread begins cb. The part pool is the recorder's while it has packets (an A/B switch
|
||||
// between parts): it finishes them first.
|
||||
rec_drain();
|
||||
begin_now(cb, crumb, n);
|
||||
if (!on) return;
|
||||
}
|
||||
RS_.parts_on++;
|
||||
rec_cb = cb; // the recorder is idle: rec_end drained it
|
||||
__atomic_store_n(&part_h0, put_h, __ATOMIC_RELEASE);
|
||||
__atomic_store_n(&part_h0, Q.put_h, __ATOMIC_RELEASE);
|
||||
__atomic_store_n(&part_fence, fence, __ATOMIC_RELEASE);
|
||||
A_BeginCb *a = put(R_BeginCb, sizeof *a);
|
||||
*a = (A_BeginCb){cb, own, crumb, n};
|
||||
pub_now();
|
||||
rec_cb = cb;
|
||||
rec_live = 1;
|
||||
}
|
||||
|
||||
@@ -182,42 +243,82 @@ void rec_end(void) {
|
||||
rec_live = 0;
|
||||
}
|
||||
|
||||
// TOTK_REC_END (default 1): the thread that recorded the part also ends it, after the breadcrumb crumb (kind 0: none)
|
||||
// that vk_sync.c writes at the end of a submission. On an RX 6700 XT (driver 26.10.1, issue #5) parts the recorder
|
||||
// recorded and the submitting thread ended broke when a submission was split (vk_sync.c vk_sync_split): the GPU hung
|
||||
// or the scene turned green, then white. The handoff in rec_drain orders the two threads, so Vulkan allows the old
|
||||
// split; the driver did not cope with it. TOTK_REC_END=0 brings the old split back.
|
||||
// TOTK_REC_END (default 1): a part the recorder records is the recorder's from its begin to its submission: it begins
|
||||
// it, makes every command, ends it after the breadcrumb crumb (kind 0: none) that vk_sync.c writes at the end of a
|
||||
// submission, and submits it. The submitting thread touches the part pool only while the recorder is idle. That is
|
||||
// LAS Recomp's and Savage Wind Recomp's model (runtime/nvn/vulkan/record.h: command buffers allocated once, then only
|
||||
// the recorder begins, records, ends and submits them). Before, the submitting thread began and submitted parts the
|
||||
// recorder recorded and, before that, also ended them. The handoff in rec_drain orders the two threads, so Vulkan
|
||||
// allows that split, but on an RX 6700 XT (driver 26.10.1, issue #5) parts ended by the other thread broke when a
|
||||
// submission was split (vk_sync.c vk_sync_split): the GPU hung or the scene turned green, then white. Moving the end
|
||||
// fixed that; begin and submit followed. TOTK_REC_END=0 brings the old split back: the submitting thread begins,
|
||||
// ends and submits every part.
|
||||
// The line is written only when a part was recorded by the recorder: with the recorder off (the default on AMD) the
|
||||
// switch changes nothing, and reports read an unconditional line as proof that the recorder was running (#315).
|
||||
typedef struct { int kind; uint64_t n; } A_EndCb;
|
||||
int rec_end_cb(VkCommandBuffer cb, int crumb, uint64_t n) {
|
||||
static int mode = -1, said;
|
||||
if (mode < 0) mode = rt_knob("REC_END", "1");
|
||||
static int said;
|
||||
int own = rec_own();
|
||||
if (rec_live && cb == rec_cb && !said) {
|
||||
said = 1;
|
||||
rt_log("vk_rec: a part the recorder records is ended by %s (TOTK_REC_END=%d)",
|
||||
mode ? "the recorder" : "the submitting thread", mode);
|
||||
rt_log("vk_rec: a part the recorder records is begun, ended and submitted by %s (TOTK_REC_END=%d)",
|
||||
own ? "the recorder" : "the submitting thread", own);
|
||||
}
|
||||
if (!rec_live || cb != rec_cb || !mode) {
|
||||
if (!rec_live || cb != rec_cb || !own) {
|
||||
rec_end();
|
||||
if (crumb) vk_crumb(cb, crumb, n);
|
||||
return 0;
|
||||
}
|
||||
A_EndCb *a = put(R_EndCb, sizeof *a);
|
||||
*a = (A_EndCb){crumb, n};
|
||||
pub();
|
||||
rec_drain();
|
||||
pub_now();
|
||||
rec_live = 0;
|
||||
sub_pending = 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// At most two command buffers and one signal: the shape vk_sync.c submits.
|
||||
typedef struct { uint32_t ncb, nsig; VkCommandBufferSubmitInfo cbi[2]; VkSemaphoreSubmitInfo sig; } A_Submit;
|
||||
VkResult rec_submit(const VkSubmitInfo2 *si) {
|
||||
if (!sub_pending) {
|
||||
vk_queue_lock();
|
||||
VkResult r = vkQueueSubmit2(vk_ctx()->gfx_queue, 1, si, VK_NULL_HANDLE);
|
||||
vk_queue_unlock();
|
||||
return r;
|
||||
}
|
||||
sub_pending = 0;
|
||||
if (si->commandBufferInfoCount > 2 || si->signalSemaphoreInfoCount > 1 || si->waitSemaphoreInfoCount || si->pNext)
|
||||
rt_fatal("vk_rec: a submission of %u command buffers, %u waits and %u signals the recorder cannot carry",
|
||||
si->commandBufferInfoCount, si->waitSemaphoreInfoCount, si->signalSemaphoreInfoCount);
|
||||
A_Submit *a = put(R_Submit, sizeof *a);
|
||||
memset(a, 0, sizeof *a);
|
||||
a->ncb = si->commandBufferInfoCount, a->nsig = si->signalSemaphoreInfoCount;
|
||||
for (uint32_t i = 0; i < a->ncb; i++) a->cbi[i] = si->pCommandBufferInfos[i];
|
||||
if (a->nsig) a->sig = si->pSignalSemaphoreInfos[0];
|
||||
pub();
|
||||
// Waited for here, so the queue holds every earlier submission when the caller goes on, as with the recorder off:
|
||||
// the present, uploads, readbacks and device waits on other threads rely on that order.
|
||||
rec_drain();
|
||||
RS_.submits++;
|
||||
return r_sub_res;
|
||||
}
|
||||
|
||||
static void submit_run(const A_Submit *a) {
|
||||
VkSubmitInfo2 si = {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO_2, .commandBufferInfoCount = a->ncb,
|
||||
.pCommandBufferInfos = a->cbi, .signalSemaphoreInfoCount = a->nsig,
|
||||
.pSignalSemaphoreInfos = a->nsig ? &a->sig : NULL};
|
||||
vk_queue_lock();
|
||||
r_sub_res = vkQueueSubmit2(vk_ctx()->gfx_queue, 1, &si, VK_NULL_HANDLE);
|
||||
vk_queue_unlock();
|
||||
}
|
||||
|
||||
void rec_stats_log(void) {
|
||||
if (!(rt_trace & RT_TRACE_PERF) || !RS_.parts) return;
|
||||
rt_log("vk_rec: %llu of %llu parts recorded by the recorder: %llu packets (%.1f MiB), %llu drains (%llu for a call "
|
||||
"without a packet) waited %.2f ms in all, ring full %llu times",
|
||||
"without a packet) waited %.2f ms in all, ring full %llu times, %llu submissions made by the recorder",
|
||||
(unsigned long long)RS_.parts_on, (unsigned long long)RS_.parts, (unsigned long long)RS_.packets,
|
||||
RS_.words * 8 / 1048576.0, (unsigned long long)RS_.drains, (unsigned long long)RS_.fallbacks,
|
||||
RS_.drain_ns / 1e6, (unsigned long long)RS_.full_waits);
|
||||
RS_.drain_ns / 1e6, (unsigned long long)RS_.full_waits, (unsigned long long)RS_.submits);
|
||||
memset(&RS_, 0, sizeof RS_);
|
||||
}
|
||||
|
||||
@@ -235,7 +336,7 @@ static const char *const kind_name[REC_KINDS] = {"image", "view", "buffer"};
|
||||
static volatile int32_t chk_seen[2], chk_named, chk_logged, chk_hz[2][2][REC_KINDS][64]; // [destroy][made][kind][op]
|
||||
void rec_check_handle(int kind, uint64_t handle, int destroy, uint64_t tl, uint64_t gpu) {
|
||||
if (!rec_chk || !handle || kind < 0 || kind >= REC_KINDS || !__atomic_load_n(&rec_live, __ATOMIC_RELAXED)) return;
|
||||
uint64_t h = __atomic_load_n(&head, __ATOMIC_ACQUIRE), p = __atomic_load_n(&part_h0, __ATOMIC_ACQUIRE);
|
||||
uint64_t h = __atomic_load_n(&Q.head, __ATOMIC_ACQUIRE), p = __atomic_load_n(&part_h0, __ATOMIC_ACQUIRE);
|
||||
uint64_t fence = __atomic_load_n(&part_fence, __ATOMIC_ACQUIRE);
|
||||
if (p > h || h - p > RING_WORDS / 2) return; // a part this large is not searched
|
||||
plat_inc32(&chk_seen[destroy != 0]);
|
||||
@@ -246,7 +347,7 @@ void rec_check_handle(int kind, uint64_t handle, int destroy, uint64_t tl, uint6
|
||||
if (op != R_PAD)
|
||||
for (uint32_t i = 1; i < n; i++) {
|
||||
if (ring[(p + i) % RING_WORDS] != handle) continue;
|
||||
int made = p < __atomic_load_n(&tail, __ATOMIC_ACQUIRE);
|
||||
int made = p < __atomic_load_n(&Q.tail, __ATOMIC_ACQUIRE);
|
||||
if (!destroy && tl >= fence) { plat_inc32(&chk_named); return; }
|
||||
plat_inc32(&chk_hz[destroy][made][kind][op & 63]);
|
||||
if (plat_inc32(&chk_logged) <= 64)
|
||||
@@ -642,7 +743,14 @@ TARGET_CMD(rec_write_mask, R_WriteMask, PFN_vkCmdSetColorWriteMaskEXT, VkColorCo
|
||||
// ---- the recorder's side
|
||||
|
||||
static void exec_packet(uint32_t op, void *p) {
|
||||
VkCommandBuffer cb = rec_cb;
|
||||
if (op == R_BeginCb) {
|
||||
A_BeginCb *a = p;
|
||||
r_cb = a->cb;
|
||||
if (a->begin) begin_now(r_cb, a->crumb, a->n);
|
||||
return;
|
||||
}
|
||||
if (op == R_Submit) { submit_run(p); return; }
|
||||
VkCommandBuffer cb = r_cb;
|
||||
switch (op) {
|
||||
#define F(name, ...) case R_##name: { A_##name *a = p; vkCmd##name(cb, __VA_ARGS__); break; }
|
||||
F(BeginQuery, a->p, a->q, a->f)
|
||||
|
||||
+17
-11
@@ -1,11 +1,12 @@
|
||||
// The recorder thread (vk_rec.c): the Vulkan calls the replay makes into its command buffer are made by a thread of
|
||||
// their own. vk_sync.c brackets each part of a submission with rec_begin (after vkBeginCommandBuffer) and rec_end
|
||||
// (before vkEndCommandBuffer). In between, every vkCmd* on that command buffer copies its arguments into a packet of a
|
||||
// ring, in call order, and the recorder makes the real call in that order; calls into any other command buffer (uploads,
|
||||
// the window, probes) are made at once. A call the ring has no packet for (a structure with a pNext chain) waits for
|
||||
// the recorder to empty the ring and is then made at once on the calling thread. rec_end returns once the recorder made
|
||||
// every call. vk_sync.c ends a part through rec_end_cb: the recorder makes the last calls into a part it recorded,
|
||||
// vkEndCommandBuffer included; submitting and the command pool stay on the submitting thread.
|
||||
// The recorder thread (vk_rec.c): the Vulkan calls the replay makes into its command buffers are made by a thread of
|
||||
// their own. vk_sync.c begins each part of a submission (and the mirror prologue) with rec_begin, ends it with
|
||||
// rec_end_cb and submits it with rec_submit. In between, every vkCmd* on that command buffer copies its arguments
|
||||
// into a packet of a ring, in call order, and the recorder makes the real call in that order; calls into any other
|
||||
// command buffer (uploads, the window, probes) are made at once. A call the ring has no packet for (a structure with a
|
||||
// pNext chain) waits for the recorder to empty the ring and is then made at once on the calling thread. With
|
||||
// TOTK_REC_END=1 (default) the recorder also begins, ends and submits the parts it records, so a part's command buffer
|
||||
// never changes threads; rec_submit returns once the recorder submitted it. The submitting thread allocates from the
|
||||
// part pool only while the recorder is idle (vk_sync.c claim_slot).
|
||||
//
|
||||
// Every vkCmd* the runtime calls through vk.h goes through the rc_* wrappers below (the macros at the end); the
|
||||
// extension commands called through function pointers have rec_* wrappers of their own. TOTK_VK_RECORDER=0 makes every
|
||||
@@ -15,11 +16,16 @@
|
||||
// MIT, Copyright (c) 2026 Peiikai): the driver's work behind the commands depends on nothing the replay decides next.
|
||||
#pragma once
|
||||
|
||||
void rec_begin(VkCommandBuffer cb, uint64_t fence); // the submitting thread, sub_lock held; fence: the part's value
|
||||
// The submitting thread, sub_lock held: vkBeginCommandBuffer (one submission) on cb and the breadcrumb crumb (0: none,
|
||||
// vk_crumbs.c) with number n, made by the recorder when it records the part, else at once; fence: the part's value.
|
||||
void rec_begin(VkCommandBuffer cb, uint64_t fence, int crumb, uint64_t n);
|
||||
void rec_end(void); // waits for the recorder; no-op when rec_begin did not start it
|
||||
// The end of part cb: the breadcrumb crumb (0: none, vk_crumbs.c), then vkEndCommandBuffer, both made by the recorder
|
||||
// when it records cb, and the wait for it (1); otherwise rec_end and the breadcrumb, and the caller ends cb (0).
|
||||
// The end of part cb: the breadcrumb crumb, then vkEndCommandBuffer, both queued for the recorder when it records cb
|
||||
// (1: rec_submit hands the part over); otherwise rec_end and the breadcrumb, and the caller ends cb (0).
|
||||
int rec_end_cb(VkCommandBuffer cb, int crumb, uint64_t n);
|
||||
// vkQueueSubmit2 of si (at most two command buffers, one signal, no wait, no fence) under vk_queue_lock: by the
|
||||
// recorder after a part it ended, waited for, else at once.
|
||||
VkResult rec_submit(const VkSubmitInfo2 *si);
|
||||
void rec_drain(void); // waits until the recorder made every call queued so far
|
||||
void rec_stats_log(void); // TOTK_TRACE=perf: packets, bytes, drains and waits since the last call
|
||||
// TOTK_REC_CHECK=1 (debug, recorder on): vk_memory.c reports each object it defers (destroy 0, stamped tl) or destroys
|
||||
|
||||
+7
-20
@@ -328,6 +328,7 @@ static int claim_slot(VkCtx *c, uint64_t fence) {
|
||||
slots[i].fence = fence;
|
||||
return i;
|
||||
}
|
||||
rec_drain(); // the part pool is the recorder's while it has packets (vk_rec.h): it is idle for the allocation
|
||||
CmdSlot *n = realloc(slots, sizeof *n * (size_t)(nslots + 1));
|
||||
if (!n) rt_fatal("vk: out of memory (command buffers)");
|
||||
slots = n;
|
||||
@@ -353,13 +354,11 @@ static VkCommandBuffer prologue(VkCtx *c, uint64_t fence) {
|
||||
if (!vk_mem_mirror_pending()) return VK_NULL_HANDLE;
|
||||
int i = claim_slot(c, fence); // before reading slots: the claim may move it
|
||||
VkCommandBuffer cb = slots[i].cb;
|
||||
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
|
||||
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
|
||||
vkBeginCommandBuffer(cb, &bi);
|
||||
rec_begin(cb, fence, 0, 0); // a command buffer of the part pool: the recorder's like the parts
|
||||
int pair = ts_open(cb);
|
||||
vk_mem_mirror_prologue(cb);
|
||||
ts_close(cb, pair, fence);
|
||||
vkEndCommandBuffer(cb);
|
||||
if (!rec_end_cb(cb, 0, 0)) vkEndCommandBuffer(cb);
|
||||
return cb;
|
||||
}
|
||||
|
||||
@@ -375,21 +374,15 @@ VkCommandBuffer vk_sync_split(void) {
|
||||
VkSubmitInfo2 si = {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO_2, .commandBufferInfoCount = pro ? 2 : 1,
|
||||
.pCommandBufferInfos = pro ? cbi : cbi + 1};
|
||||
ts_submitted();
|
||||
vk_queue_lock();
|
||||
VkResult r = vkQueueSubmit2(c->gfx_queue, 1, &si, VK_NULL_HANDLE);
|
||||
vk_queue_unlock();
|
||||
VkResult r = rec_submit(&si); // by the recorder when it recorded the part
|
||||
if (r != VK_SUCCESS) {
|
||||
vk_device_fault_report(r);
|
||||
rt_fatal("vk: vkQueueSubmit2 of a part of value %llu failed (%d)", (unsigned long long)cur_fence, (int)r);
|
||||
}
|
||||
vk_mem_note_submit(cur_fence); // objects destroyed from here on may be in use by the part just submitted
|
||||
CmdSlot *s = take_slot(c, cur_fence); // the submitted part keeps its slot until the value is reached
|
||||
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
|
||||
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
|
||||
vkBeginCommandBuffer(s->cb, &bi);
|
||||
vk_crumb(s->cb, VK_CRUMB_SUBMIT, cur_fence);
|
||||
rec_begin(s->cb, cur_fence, VK_CRUMB_SUBMIT, cur_fence);
|
||||
ts_begin(s->cb);
|
||||
rec_begin(s->cb, cur_fence);
|
||||
return s->cb;
|
||||
}
|
||||
|
||||
@@ -412,13 +405,9 @@ uint64_t vk_sync_submit(const uint64_t *handles, int n) {
|
||||
uint64_t fence = __atomic_load_n(&issued, __ATOMIC_ACQUIRE) + 1;
|
||||
cur_fence = fence;
|
||||
CmdSlot *s = take_slot(c, fence);
|
||||
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
|
||||
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
|
||||
uint64_t t0 = perf_begin();
|
||||
vkBeginCommandBuffer(s->cb, &bi);
|
||||
vk_crumb(s->cb, VK_CRUMB_SUBMIT, fence);
|
||||
rec_begin(s->cb, fence, VK_CRUMB_SUBMIT, fence); // begun by the recorder when it records the part
|
||||
ts_begin(s->cb);
|
||||
rec_begin(s->cb, fence);
|
||||
int recorded = vk_exec_record(s->cb, handles, n, fence);
|
||||
s = &slots[cur_slot]; // the last part (vk_sync_split)
|
||||
if (recorded) ts_end(s->cb, fence);
|
||||
@@ -439,9 +428,7 @@ uint64_t vk_sync_submit(const uint64_t *handles, int n) {
|
||||
.pCommandBufferInfos = pro ? cbi : cbi + 1, .signalSemaphoreInfoCount = 1,
|
||||
.pSignalSemaphoreInfos = &sig};
|
||||
ts_submitted();
|
||||
vk_queue_lock();
|
||||
VkResult r = vkQueueSubmit2(c->gfx_queue, 1, &si, VK_NULL_HANDLE);
|
||||
vk_queue_unlock();
|
||||
VkResult r = rec_submit(&si); // by the recorder when it recorded the part
|
||||
if (r != VK_SUCCESS) {
|
||||
vk_device_fault_report(r);
|
||||
rt_fatal("vk: vkQueueSubmit2 of value %llu failed (%d)", (unsigned long long)fence, (int)r);
|
||||
|
||||
+431
-106
@@ -53,12 +53,18 @@ typedef struct Tex {
|
||||
uint32_t gpu_gen; // page generation when the GPU write that set gpu_dirty was recorded
|
||||
uint64_t gpu_seq; // order of that GPU write among all of them (gpu_writes)
|
||||
uint64_t gpu_fence; // fence of the submission that made that write (0: not recorded in one)
|
||||
uint64_t run_frame; // mirrored: the frame of the newest GPU write
|
||||
uint8_t run; // frames in a row up to run_frame with a GPU write, counted up to WB_RUN
|
||||
uint64_t wr_fence; // any entry: newest submission recorded to write its image (render, copy, clear,
|
||||
// storage binding); ReadTexels waits for it instead of draining the replay queue
|
||||
uint8_t listed; // in owned[]
|
||||
uint8_t *snap; // a texture that fills no page: its storage bytes when the GPU write was recorded
|
||||
uint32_t wt_n; // WriteTexels into the storage since the last upload
|
||||
struct Tex *rel_prev, *rel_next; // while released: the list of released entries, oldest first
|
||||
uint64_t rel_ms; // while released: when (coarse_ms)
|
||||
uint64_t use_seq; // order of the last creation or take-back among all entries
|
||||
uint64_t rel_seq; // while released: use_seq of the newest entry placed before this release; an entry
|
||||
// with a later use_seq on overlapping storage replaced this one's bytes
|
||||
} Tex;
|
||||
|
||||
// A slot keeps what vk_texture_by_id resolved for it, so a draw binding skips the key lookup and the address chain: the
|
||||
@@ -155,6 +161,17 @@ static void make_key(const tex_t *t, TexKey *k) {
|
||||
}
|
||||
|
||||
// Keys equal apart from the descriptor's swizzle selectors: the same storage, format and geometry.
|
||||
// The colour format without its sRGB decode: a target and its sRGB view share one texel layout (slot_resolve).
|
||||
static int linear_format(int f) {
|
||||
switch (f) {
|
||||
case NVN_FORMAT_RGBA8_SRGB: return NVN_FORMAT_RGBA8;
|
||||
case NVN_FORMAT_RGBX8_SRGB: return NVN_FORMAT_RGBX8;
|
||||
case NVN_FORMAT_BGRA8_SRGB: return NVN_FORMAT_BGRA8;
|
||||
case NVN_FORMAT_BGRX8_SRGB: return NVN_FORMAT_BGRX8;
|
||||
default: return f;
|
||||
}
|
||||
}
|
||||
|
||||
static int same_but_swizzle(const TexKey *a, const TexKey *b) {
|
||||
TexKey x = *a, y = *b;
|
||||
x.d.swizzle_r = x.d.swizzle_g = x.d.swizzle_b = x.d.swizzle_a = 0;
|
||||
@@ -527,6 +544,46 @@ static void rel_unlink(Tex *e) {
|
||||
e->rel_prev = e->rel_next = NULL;
|
||||
}
|
||||
|
||||
// A clock read per submit, for stamps on paths that run many times a frame.
|
||||
static uint64_t coarse_ms;
|
||||
static uint64_t use_counter;
|
||||
|
||||
// How long released entries wait until the game takes them back, split into those holding GPU-written content and the
|
||||
// others (TOTK_TRACE=perf, vk_texture_released_log): where transient textures end and the tail that can go begins.
|
||||
enum { AGE_1S, AGE_5S, AGE_30S, AGE_120S, AGE_LONG, AGE_N };
|
||||
static const char *const age_name[AGE_N] = {"<1s", "<5s", "<30s", "<120s", ">=120s"};
|
||||
static uint64_t takeback_n[2][AGE_N];
|
||||
static int age_bucket(uint64_t ms) {
|
||||
return ms < 1000 ? AGE_1S : ms < 5000 ? AGE_5S : ms < 30000 ? AGE_30S : ms < 120000 ? AGE_120S : AGE_LONG;
|
||||
}
|
||||
|
||||
// Keys of entries the pressure path dropped, to count the ones the game asks for again soon after: a drop that buys
|
||||
// nothing and, for GPU-written content, loses it. Direct-mapped, a lost record only undercounts.
|
||||
#define DROPPED_SLOTS 4096u
|
||||
static struct { uint64_t hash, ms; } dropped[DROPPED_SLOTS];
|
||||
static uint64_t n_recreated[2], n_recreated_written[2]; // index: within 10 s, later
|
||||
static uint64_t key_hash(const TexKey *k) {
|
||||
uint64_t h = 0xcbf29ce484222325ull;
|
||||
const uint8_t *b = (const uint8_t *)k;
|
||||
for (size_t i = 0; i < sizeof *k; i++) h = (h ^ b[i]) * 0x100000001b3ull;
|
||||
return h | 1;
|
||||
}
|
||||
static void dropped_note(const Tex *e) {
|
||||
uint64_t h = key_hash(&e->key);
|
||||
dropped[h >> 3 & (DROPPED_SLOTS - 1)].hash = h ^ (uint64_t)(e->pub.gpu_owned != 0) << 1;
|
||||
dropped[h >> 3 & (DROPPED_SLOTS - 1)].ms = coarse_ms;
|
||||
}
|
||||
static void dropped_check(const TexKey *k) {
|
||||
uint64_t h = key_hash(k);
|
||||
unsigned i = h >> 3 & (DROPPED_SLOTS - 1);
|
||||
uint64_t was = dropped[i].hash;
|
||||
if (!was || (was & ~2ull) != (h & ~2ull)) return;
|
||||
int later = coarse_ms - dropped[i].ms >= 10000;
|
||||
n_recreated[later]++;
|
||||
if (was != h) n_recreated_written[later]++;
|
||||
dropped[i].hash = 0;
|
||||
}
|
||||
|
||||
static void destroy_tex(Tex *e) {
|
||||
own_remove(e);
|
||||
tex_gen_bump(e->key.addr);
|
||||
@@ -556,15 +613,112 @@ static void sweep_released(void) {
|
||||
while (nreleased > RELEASED_MAX && rel_head) destroy_tex(rel_head);
|
||||
}
|
||||
|
||||
// TOTK_TEX_VRAM_PRESSURE (percent of the device-local budget, default 90; 0 = off): above it, each submit drops up to
|
||||
// PRESSURE_STEP of the oldest released entries, so a 4 GiB card does not keep RELEASED_MAX released images (each ASTC
|
||||
// one decoded to RGBA8) while the heap spills into system memory. A few per submit, for the reason above. Only entries
|
||||
// that come back intact are dropped: mirrored ones the GPU never wrote, re-uploaded from their storage. A render target
|
||||
// lives only in its image; dropping it brought the texture back empty (black flashes in a Depths boss fight, ~170
|
||||
// TOTK_TEX_VRAM_PRESSURE (percent of the device-local budget, default 90; 0 = off): above it, each budget query drops up
|
||||
// to PRESSURE_STEP released entries, oldest first, until the use is back under the line; a few at a time, for the reason
|
||||
// above. Two kinds come back without loss:
|
||||
//
|
||||
// Overwritten: the game placed another texture on the entry's storage after releasing it, so on the Switch its bytes are
|
||||
// gone too, and it has waited DEAD_AGE_MS. Transient render targets are taken back within a second or two (a stand-still
|
||||
// field window: about 19000 take-backs under 1 s, 2 later than that, none after 30 s), while their leftovers at
|
||||
// addresses the game's allocator moved on from stay released for good: 1.9 GiB of 2.0 GiB, nearly all of it render targets
|
||||
// no storage can refill. Not bound through a finalized slot for DEAD_QUIET_SUBMITS either: such a slot still samples the
|
||||
// entry (slot_resolve).
|
||||
//
|
||||
// Clean: mirrored ones the GPU never wrote, re-uploaded from their storage, at the cost of the upload, so after
|
||||
// CLEAN_AGE_MS: without the wait a fight re-created about 500 of them per 10 s. Any other render target lives only in its
|
||||
// image: dropping one that was merely old brought the texture back empty (black flashes in a Depths boss fight, ~170
|
||||
// "left uninitialised" aliases a minute).
|
||||
#define PRESSURE_STEP 32
|
||||
#define CLEAN_AGE_MS 5000u
|
||||
#define DEAD_AGE_MS 30000u
|
||||
#define DEAD_QUIET_SUBMITS 600u
|
||||
#define DEAD_RETRY_QUERIES 8 // budget queries to skip after a search that found nothing
|
||||
static int vram_pressure = -1;
|
||||
static uint64_t n_pressure_drops;
|
||||
static uint64_t n_pressure_drops, n_pressure_overwritten;
|
||||
|
||||
static int tex_by_addr(const void *a, const void *b) {
|
||||
const Tex *x = *(Tex *const *)a, *y = *(Tex *const *)b;
|
||||
return x->pub.addr < y->pub.addr ? -1 : x->pub.addr > y->pub.addr;
|
||||
}
|
||||
|
||||
// All entries sorted by storage address, with the highest storage end up to each one: the input of storage_taken.
|
||||
typedef struct { Tex **e; uint64_t *end; int n; } AddrIndex;
|
||||
|
||||
static int addr_index(AddrIndex *x) {
|
||||
x->n = 0;
|
||||
x->e = malloc(sizeof *x->e * (size_t)(by_key.n + 1));
|
||||
x->end = malloc(sizeof *x->end * (size_t)(by_key.n + 1));
|
||||
if (!x->e || !x->end) { free(x->e), free(x->end); return 0; }
|
||||
for (uint32_t i = 0; i < by_key.cap; i++)
|
||||
if (by_key.vals[i]) x->e[x->n++] = by_key.vals[i];
|
||||
qsort(x->e, (size_t)x->n, sizeof *x->e, tex_by_addr);
|
||||
uint64_t top = 0;
|
||||
for (int i = 0; i < x->n; i++) {
|
||||
uint64_t end = x->e[i]->pub.addr + x->e[i]->t.L.size;
|
||||
x->end[i] = top = end > top ? end : top;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void addr_index_free(AddrIndex *x) { free(x->e), free(x->end); }
|
||||
|
||||
// A texture placed after the release of x->e[i] overlaps its storage.
|
||||
static int storage_taken(const AddrIndex *x, int i) {
|
||||
const Tex *e = x->e[i];
|
||||
for (int j = i; --j >= 0 && x->end[j] > e->pub.addr;)
|
||||
if (x->e[j]->pub.addr + x->e[j]->t.L.size > e->pub.addr && x->e[j]->use_seq > e->rel_seq) return 1;
|
||||
for (int j = i; ++j < x->n && x->e[j]->pub.addr < e->pub.addr + e->t.L.size;)
|
||||
if (x->e[j]->use_seq > e->rel_seq) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static uint64_t own_bytes(const Tex *e) {
|
||||
VkCtx *c = vk_ctx();
|
||||
if (!e->alloc || !c) return 0;
|
||||
VmaAllocationInfo ai;
|
||||
vmaGetAllocationInfo(c->vma, e->alloc, &ai);
|
||||
return ai.size;
|
||||
}
|
||||
|
||||
static int dead_candidate(const Tex *e) {
|
||||
return e->alloc && (!e->checked || submit_epoch - e->checked >= DEAD_QUIET_SUBMITS);
|
||||
}
|
||||
|
||||
// Drops overwritten entries until `need` bytes are free or the step is used up. The sort runs only under pressure, while
|
||||
// an old enough entry exists, and rests for a while when it finds nothing to drop. Returns the bytes freed.
|
||||
static uint64_t pressure_search_ns; // longest search since the last mem: released line
|
||||
static uint64_t drop_overwritten(uint64_t need, int *dropped) {
|
||||
static int rest;
|
||||
if (rest > 0) { rest--; return 0; }
|
||||
if (!rel_head || coarse_ms - rel_head->rel_ms < DEAD_AGE_MS) return 0;
|
||||
uint64_t t0 = plat_now_ns(), freed = 0;
|
||||
AddrIndex x;
|
||||
if (!addr_index(&x)) return 0;
|
||||
Tex *cand[PRESSURE_STEP];
|
||||
int nc = 0;
|
||||
for (Tex *e = rel_head; e && nc < PRESSURE_STEP && coarse_ms - e->rel_ms >= DEAD_AGE_MS; e = e->rel_next) {
|
||||
if (!dead_candidate(e)) continue;
|
||||
int lo = 0, hi = x.n; // the first entry at e's address, then e among those
|
||||
while (lo < hi) {
|
||||
int m = (lo + hi) / 2;
|
||||
if (x.e[m]->pub.addr < e->pub.addr) lo = m + 1;
|
||||
else hi = m;
|
||||
}
|
||||
while (lo < x.n && x.e[lo] != e) lo++;
|
||||
if (lo < x.n && storage_taken(&x, lo)) cand[nc++] = e;
|
||||
}
|
||||
addr_index_free(&x);
|
||||
for (int i = 0; i < nc && freed < need; i++) {
|
||||
freed += own_bytes(cand[i]);
|
||||
dropped_note(cand[i]);
|
||||
destroy_tex(cand[i]);
|
||||
(*dropped)++, n_pressure_drops++, n_pressure_overwritten++;
|
||||
}
|
||||
if (!nc) rest = DEAD_RETRY_QUERIES;
|
||||
uint64_t ns = plat_now_ns() - t0;
|
||||
if (ns > pressure_search_ns) pressure_search_ns = ns;
|
||||
return freed;
|
||||
}
|
||||
|
||||
static void sweep_pressure(void) { // tex_lock held
|
||||
if (vram_pressure < 0) {
|
||||
@@ -576,11 +730,16 @@ static void sweep_pressure(void) { // tex_lock held
|
||||
if (!vram_pressure || !rel_head || submit_epoch & 7) return; // the budget query every 8 submits
|
||||
uint64_t used, budget;
|
||||
int exact;
|
||||
if (!vk_mem_vram(&used, &budget, &exact) || used <= budget / 100 * (uint64_t)vram_pressure) return;
|
||||
if (!vk_mem_vram(&used, &budget, &exact)) return;
|
||||
uint64_t line = budget / 100 * (uint64_t)vram_pressure;
|
||||
if (used <= line) return;
|
||||
int dropped = 0;
|
||||
for (Tex *e = rel_head, *next; e && dropped < PRESSURE_STEP; e = next) {
|
||||
uint64_t need = used - line, freed = drop_overwritten(need, &dropped);
|
||||
for (Tex *e = rel_head, *next; e && freed < need && dropped < PRESSURE_STEP && coarse_ms - e->rel_ms >= CLEAN_AGE_MS; e = next) {
|
||||
next = e->rel_next;
|
||||
if (!e->pub.mirrored || e->pub.gpu_owned || e->gpu_dirty) continue;
|
||||
freed += own_bytes(e);
|
||||
dropped_note(e);
|
||||
destroy_tex(e);
|
||||
dropped++, n_pressure_drops++;
|
||||
}
|
||||
@@ -934,6 +1093,8 @@ static Tex *entry_of(const tex_t *t) {
|
||||
}
|
||||
if (!(e = create_tex(t, &k, tex_alias))) return NULL;
|
||||
perf_count(PERF_CNT_TEX_NEW);
|
||||
dropped_check(&k);
|
||||
e->use_seq = ++use_counter;
|
||||
// released entries of another descriptor at this address are replaced by the new one; a live one holding
|
||||
// GPU-written content is the alias source, preferring the one that differs only in swizzle (same_but_swizzle)
|
||||
int alias = 0;
|
||||
@@ -969,7 +1130,11 @@ static Tex *entry_of(const tex_t *t) {
|
||||
nvn_hm_put(&by_key, &k, e);
|
||||
tex_gen_bump(k.addr);
|
||||
}
|
||||
if (e->released) e->released = 0, nreleased--, rel_unlink(e), tex_gen_bump(k.addr);
|
||||
if (e->released) {
|
||||
takeback_n[e->pub.gpu_owned != 0][age_bucket(coarse_ms - e->rel_ms)]++;
|
||||
e->use_seq = ++use_counter;
|
||||
e->released = 0, nreleased--, rel_unlink(e), tex_gen_bump(k.addr);
|
||||
}
|
||||
e->t = *t;
|
||||
return e;
|
||||
}
|
||||
@@ -995,10 +1160,11 @@ const VkTexImage *vk_texture_get(const tex_t *t) {
|
||||
return e ? &e->pub : NULL;
|
||||
}
|
||||
|
||||
// Small render targets the CPU reads without NVN's invalidate (vk_texture_write_back_small); small_pending: one may be
|
||||
// dirty. wb_limit is the largest target (bytes) that goes back when the CPU waits for a fence: TOTK_WB_FENCE_LIMIT,
|
||||
// read once. wb_trace: TOTK_TRACE=gfx, read together with it.
|
||||
enum { WB_SMALL_DEFAULT = 64 << 10 };
|
||||
// Render targets the CPU reads without NVN's invalidate (vk_texture_write_back_small); small_pending: one may be dirty.
|
||||
// wb_limit: targets up to this size (bytes) always go back when the CPU waits for a fence, TOTK_WB_FENCE_LIMIT, read
|
||||
// once. A larger one goes back unless the GPU wrote it in each of the last WB_RUN frames (wb_wanted). wb_trace:
|
||||
// TOTK_TRACE=gfx, read together with it.
|
||||
enum { WB_SMALL_DEFAULT = 64 << 10, WB_RUN = 16 };
|
||||
static int small_pending;
|
||||
static uint64_t wb_limit = WB_SMALL_DEFAULT;
|
||||
static int wb_trace;
|
||||
@@ -1013,6 +1179,12 @@ static void wb_cfg_init(void) {
|
||||
if (wb_limit != WB_SMALL_DEFAULT) rt_log("vk: fence write-back limit %llu bytes", (unsigned long long)wb_limit);
|
||||
}
|
||||
|
||||
// Whether the fence write-back takes a dirty target. A target the GPU draws every frame is scratch the CPU does not
|
||||
// read: in Hyrule Field three 256x256 RGBA8 targets and a 256x256 RG8 one, which cost 2.7 ms a frame to write back.
|
||||
// TotK's 260x260 RG8 grass maps, which its fire spread reads after a fence wait (#132), are drawn once per change, or
|
||||
// up to 9 frames in a row while a tile loads.
|
||||
static int wb_wanted(const Tex *e) { return e->t.L.size <= wb_limit || e->run < WB_RUN; }
|
||||
|
||||
void vk_texture_gpu_written(const tex_t *t) {
|
||||
if (!t || !t->mem) return;
|
||||
vk_exec_frame_written(t);
|
||||
@@ -1032,8 +1204,13 @@ void vk_texture_gpu_written(const tex_t *t) {
|
||||
if (e->snap) memcpy(e->snap, (const void *)(uintptr_t)e->pub.addr, e->t.L.size);
|
||||
e->gpu_seq = ++gpu_writes;
|
||||
e->gpu_fence = vk_exec_recording_fence();
|
||||
uint64_t fr = __atomic_load_n(&gfx_counters.frames, __ATOMIC_RELAXED);
|
||||
if (fr != e->run_frame || !e->run) {
|
||||
e->run = fr == e->run_frame + 1 && e->run ? (uint8_t)(e->run < WB_RUN ? e->run + 1 : WB_RUN) : 1;
|
||||
e->run_frame = fr;
|
||||
}
|
||||
wb_cfg_init();
|
||||
if (e->t.L.size <= wb_limit) __atomic_store_n(&small_pending, 1, __ATOMIC_RELEASE);
|
||||
if (wb_wanted(e)) __atomic_store_n(&small_pending, 1, __ATOMIC_RELEASE);
|
||||
own_add(e);
|
||||
}
|
||||
}
|
||||
@@ -1048,6 +1225,8 @@ void vk_texture_release(const tex_t *t) {
|
||||
Tex *e = nvn_hm_get(&by_key, &k);
|
||||
if (e && !e->released) {
|
||||
e->released = 1;
|
||||
e->rel_ms = coarse_ms;
|
||||
e->rel_seq = use_counter;
|
||||
nreleased++;
|
||||
rel_append(e);
|
||||
tex_gen_bump(k.addr);
|
||||
@@ -1111,10 +1290,16 @@ static Tex *slot_resolve(TexSlot *s, view_t *view, int *has_view) {
|
||||
// Prefer the live entry that differs from the slot descriptor only in its swizzle: transient render targets
|
||||
// share storage, and the newest one at the address is usually another size. The map screen samples its
|
||||
// 2250x1080 map target through an (R,G,B,1) descriptor and followed a 960x60 target at the same address.
|
||||
// Only an entry of the slot's texel format follows (sRGB aside): Maxwell reads the storage through the slot
|
||||
// descriptor's format, and an entry of another format holds another texture's texels. The quick menu samples
|
||||
// its 768x432 RGB10A2 scene capture through a slot whose entry was released; after a fuse the Autobuild
|
||||
// thumbnail of the fused object, RGBA8 at the same storage, was the live entry, and the menu drew it behind
|
||||
// the wheel (the object's silhouette, or garbled colours). Without such an entry the released one keeps the
|
||||
// slot: its image holds what was last written through that descriptor.
|
||||
Tex *own = e, *any = NULL;
|
||||
e = NULL;
|
||||
for (Tex *p = nvn_hm_get(&by_addr, &k.addr); p; p = p->next_at) {
|
||||
if (p->released) continue;
|
||||
if (p->released || linear_format(p->key.d.format) != linear_format(k.d.format)) continue;
|
||||
if (!any) any = p;
|
||||
if (same_but_swizzle(&p->key, &k)) { e = p; break; }
|
||||
}
|
||||
@@ -1205,6 +1390,7 @@ uint32_t vk_texture_epoch(void) { return __atomic_load_n(&submit_epoch, __ATOMIC
|
||||
void vk_texture_new_submit(void) {
|
||||
lock();
|
||||
if (!++submit_epoch) submit_epoch = 1;
|
||||
coarse_ms = plat_now_ms();
|
||||
sweep_pressure();
|
||||
static uint64_t log_at;
|
||||
uint64_t f = __atomic_load_n(&gfx_counters.frames, __ATOMIC_RELAXED) / 60;
|
||||
@@ -1224,9 +1410,10 @@ void vk_texture_new_submit(void) {
|
||||
(unsigned long long)f * 60, (unsigned long long)ck, (unsigned long long)n_checks, (unsigned long long)calls,
|
||||
(unsigned long long)untr);
|
||||
n_checks = 0;
|
||||
rt_log("perf: frame %llu: released textures kept %d, dropped under VRAM pressure %llu", (unsigned long long)f * 60,
|
||||
nreleased, (unsigned long long)n_pressure_drops);
|
||||
n_pressure_drops = 0;
|
||||
rt_log("perf: frame %llu: released textures kept %d, dropped under VRAM pressure %llu (%llu overwritten)",
|
||||
(unsigned long long)f * 60, nreleased, (unsigned long long)n_pressure_drops,
|
||||
(unsigned long long)n_pressure_overwritten);
|
||||
n_pressure_drops = n_pressure_overwritten = 0;
|
||||
}
|
||||
unlock();
|
||||
}
|
||||
@@ -1275,12 +1462,21 @@ static int read_aspect(const Tex *e, VkImageAspectFlags *a) {
|
||||
}
|
||||
}
|
||||
|
||||
// Image <-> caller rows through a staging buffer; the GPU copy is waited for.
|
||||
static int image_texels(Tex *e, const Region *g, const NVNcopyRegion *r, uint8_t *mem, int write) {
|
||||
// An image region read through a staging buffer. texels_read_record records the copy into the open upload batch;
|
||||
// texels_read_take copies the rows out once that batch is done (vk_upload_flush(1)) and frees the buffer. Several
|
||||
// records before one flush cost one GPU round trip instead of one each.
|
||||
typedef struct {
|
||||
VkBuffer sb;
|
||||
VmaAllocation sa;
|
||||
const uint8_t *stg;
|
||||
size_t slice, ib; // ib: bytes per texel of an expanded RGB image, 0 when the image holds the storage layout
|
||||
uint32_t w;
|
||||
} TexRead;
|
||||
|
||||
static int texels_read_record(Tex *e, const Region *g, const NVNcopyRegion *r, TexRead *t) {
|
||||
static volatile int32_t n_astc, n_ds;
|
||||
VkCtx *c = vk_ctx();
|
||||
VkImageAspectFlags aspect;
|
||||
if (e->f.astc_bw && !write) {
|
||||
if (e->f.astc_bw) {
|
||||
log_limited(&n_astc, "ReadTexels of ASTC texture %#llx: the image holds decoded RGBA8; zeros returned",
|
||||
(unsigned long long)e->pub.addr);
|
||||
return 0;
|
||||
@@ -1291,7 +1487,60 @@ static int image_texels(Tex *e, const Region *g, const NVNcopyRegion *r, uint8_t
|
||||
}
|
||||
if (g->level >= e->pub.levels) return 0;
|
||||
uint32_t w = (uint32_t)r->width, h = (uint32_t)r->height;
|
||||
int rgb = e->f.expand_rgb, astc = e->f.astc_bw != 0, agpu = astc && write && vk_astc_gpu();
|
||||
t->w = w;
|
||||
t->ib = e->f.expand_rgb ? (size_t)g->bpb / 3 * 4 : 0;
|
||||
t->slice = t->ib ? (size_t)w * h * t->ib : g->wb * g->hb;
|
||||
t->stg = vk_staging(t->slice * (size_t)g->nz, &t->sb, &t->sa);
|
||||
VkBufferImageCopy *rg = calloc((size_t)g->nz, sizeof *rg);
|
||||
if (!rg) rt_fatal("vk: out of memory for a texture read");
|
||||
for (int z = 0; z < g->nz; z++)
|
||||
rg[z] = (VkBufferImageCopy){.bufferOffset = t->slice * (size_t)z,
|
||||
.imageSubresource = {aspect, g->level, g->is3d ? 0 : g->layer0 + (uint32_t)r->zoffset + (uint32_t)z, 1},
|
||||
.imageOffset = {r->xoffset, r->yoffset, g->is3d ? r->zoffset + z : 0},
|
||||
.imageExtent = {w, h, 1}};
|
||||
VkCommandBuffer cb = vk_upload_begin();
|
||||
barrier(cb, ALL_RW, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_READ_BIT);
|
||||
vkCmdCopyImageToBuffer(cb, e->pub.image, VK_IMAGE_LAYOUT_GENERAL, t->sb, (uint32_t)g->nz, rg);
|
||||
barrier(cb, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_2_HOST_BIT,
|
||||
VK_ACCESS_2_HOST_READ_BIT);
|
||||
vk_upload_end();
|
||||
free(rg);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void texels_read_take(const Tex *e, const Region *g, const TexRead *t, uint8_t *mem) {
|
||||
VkCtx *c = vk_ctx();
|
||||
vmaInvalidateAllocation(c->vma, t->sa, 0, VK_WHOLE_SIZE);
|
||||
for (int z = 0; z < g->nz; z++) {
|
||||
const uint8_t *s = t->stg + t->slice * (size_t)z;
|
||||
uint8_t *m = mem + (size_t)z * g->img;
|
||||
for (size_t y = 0; y < g->hb; y++) {
|
||||
if (t->ib) shrink_rgb(e->t.d.format, s + y * t->w * t->ib, m + y * g->row, t->w);
|
||||
else memcpy(m + y * g->row, s + y * g->wb, g->wb);
|
||||
}
|
||||
}
|
||||
vmaDestroyBuffer(c->vma, t->sb, t->sa);
|
||||
}
|
||||
|
||||
// Image <-> caller rows through a staging buffer; the GPU copy is waited for.
|
||||
static int image_texels(Tex *e, const Region *g, const NVNcopyRegion *r, uint8_t *mem, int write) {
|
||||
static volatile int32_t n_astc, n_ds;
|
||||
VkCtx *c = vk_ctx();
|
||||
VkImageAspectFlags aspect;
|
||||
if (!write) {
|
||||
TexRead t;
|
||||
if (!texels_read_record(e, g, r, &t)) return 0;
|
||||
vk_upload_flush(1);
|
||||
texels_read_take(e, g, &t, mem);
|
||||
return 1;
|
||||
}
|
||||
if (!read_aspect(e, &aspect)) {
|
||||
log_limited(&n_ds, "texels of packed depth-stencil format %#x are not copied", e->t.d.format);
|
||||
return 0;
|
||||
}
|
||||
if (g->level >= e->pub.levels) return 0;
|
||||
uint32_t w = (uint32_t)r->width, h = (uint32_t)r->height;
|
||||
int rgb = e->f.expand_rgb, astc = e->f.astc_bw != 0, agpu = astc && vk_astc_gpu();
|
||||
size_t texels = (size_t)w * h, ib = agpu ? 0 : astc ? 4 : rgb ? (size_t)g->bpb / 3 * 4 : 0;
|
||||
size_t slice = ib ? texels * ib : g->wb * g->hb; // agpu: the raw blocks, decoded by vk_astc_record
|
||||
VkBuffer sb;
|
||||
@@ -1301,21 +1550,19 @@ static int image_texels(Tex *e, const Region *g, const NVNcopyRegion *r, uint8_t
|
||||
for (int z = 0; z < g->nz; z++) {
|
||||
uint8_t *s = stg + slice * (size_t)z;
|
||||
const uint8_t *m = mem + (size_t)z * g->img;
|
||||
if (write) {
|
||||
if (agpu) {
|
||||
for (size_t y = 0; y < g->hb; y++) memcpy(s + y * g->wb, m + y * g->row, g->wb);
|
||||
} else if (astc) {
|
||||
uint8_t *tight = malloc(g->wb * g->hb);
|
||||
for (size_t y = 0; y < g->hb; y++) memcpy(tight + y * g->wb, m + y * g->row, g->wb);
|
||||
uint64_t a0 = perf_begin();
|
||||
astc_decode_ldr_mt(tight, w, h, e->f.astc_bw, e->f.astc_bh, e->f.astc_srgb, s);
|
||||
perf_end(PERF_ASTC, a0);
|
||||
free(tight);
|
||||
} else if (rgb) {
|
||||
for (size_t y = 0; y < g->hb; y++) expand_rgb(e->t.d.format, m + y * g->row, s + y * w * ib, w);
|
||||
} else {
|
||||
for (size_t y = 0; y < g->hb; y++) memcpy(s + y * g->wb, m + y * g->row, g->wb);
|
||||
}
|
||||
if (agpu) {
|
||||
for (size_t y = 0; y < g->hb; y++) memcpy(s + y * g->wb, m + y * g->row, g->wb);
|
||||
} else if (astc) {
|
||||
uint8_t *tight = malloc(g->wb * g->hb);
|
||||
for (size_t y = 0; y < g->hb; y++) memcpy(tight + y * g->wb, m + y * g->row, g->wb);
|
||||
uint64_t a0 = perf_begin();
|
||||
astc_decode_ldr_mt(tight, w, h, e->f.astc_bw, e->f.astc_bh, e->f.astc_srgb, s);
|
||||
perf_end(PERF_ASTC, a0);
|
||||
free(tight);
|
||||
} else if (rgb) {
|
||||
for (size_t y = 0; y < g->hb; y++) expand_rgb(e->t.d.format, m + y * g->row, s + y * w * ib, w);
|
||||
} else {
|
||||
for (size_t y = 0; y < g->hb; y++) memcpy(s + y * g->wb, m + y * g->row, g->wb);
|
||||
}
|
||||
rg[z] = (VkBufferImageCopy){.bufferOffset = slice * (size_t)z,
|
||||
.imageSubresource = {aspect, g->level, g->is3d ? 0 : g->layer0 + (uint32_t)r->zoffset + (uint32_t)z, 1},
|
||||
@@ -1323,49 +1570,31 @@ static int image_texels(Tex *e, const Region *g, const NVNcopyRegion *r, uint8_t
|
||||
.imageExtent = {w, h, 1}};
|
||||
}
|
||||
VkCommandBuffer cb = vk_upload_begin();
|
||||
if (write) {
|
||||
vmaFlushAllocation(c->vma, sa, 0, VK_WHOLE_SIZE);
|
||||
barrier(cb, ALL_RW, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT);
|
||||
int done = 0;
|
||||
// A BC3 image takes whole 4x4 blocks, or a part that ends on the mip's edge; a region of a 5x5 or 6x6 footprint
|
||||
// may end inside a block (vk_astc_record checks the start corner).
|
||||
uint32_t mw = e->t.d.width >> g->level, mh = e->t.d.height >> g->level;
|
||||
int bc_cut = e->f.astc_bc && (((w & 3) && (uint32_t)r->xoffset + w != (mw ? mw : 1)) ||
|
||||
((h & 3) && (uint32_t)r->yoffset + h != (mh ? mh : 1)));
|
||||
if (agpu && !bc_cut) { // bc_cut: nothing recorded, logged below as not decoded
|
||||
VkAstcRegion *ar = calloc((size_t)g->nz, sizeof *ar);
|
||||
for (int z = 0; z < g->nz; z++)
|
||||
ar[z] = (VkAstcRegion){.src = slice * (size_t)z, .w = w, .h = h, .slices = 1, .level = rg[z].imageSubresource.mipLevel,
|
||||
.layer = rg[z].imageSubresource.baseArrayLayer, .offset = rg[z].imageOffset};
|
||||
uint64_t a0 = perf_begin();
|
||||
done = vk_astc_record(cb, sb, slice * (size_t)g->nz, ar, g->nz, e->f.astc_bw, e->f.astc_bh, e->f.astc_srgb, e->f.astc_bc,
|
||||
e->pub.image, aspect, VK_NULL_HANDLE);
|
||||
perf_end(PERF_ASTC, a0);
|
||||
free(ar);
|
||||
}
|
||||
if (!agpu) vkCmdCopyBufferToImage(cb, sb, e->pub.image, VK_IMAGE_LAYOUT_GENERAL, (uint32_t)g->nz, rg);
|
||||
else if (!done) log_limited(&n_astc, "WriteTexels of ASTC texture %#llx not decoded on the GPU: left as it was",
|
||||
(unsigned long long)e->pub.addr);
|
||||
barrier(cb, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT, ALL_RW);
|
||||
} else {
|
||||
barrier(cb, ALL_RW, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_READ_BIT);
|
||||
vkCmdCopyImageToBuffer(cb, e->pub.image, VK_IMAGE_LAYOUT_GENERAL, sb, (uint32_t)g->nz, rg);
|
||||
barrier(cb, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_2_HOST_BIT,
|
||||
VK_ACCESS_2_HOST_READ_BIT);
|
||||
vmaFlushAllocation(c->vma, sa, 0, VK_WHOLE_SIZE);
|
||||
barrier(cb, ALL_RW, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT);
|
||||
int done = 0;
|
||||
// A BC3 image takes whole 4x4 blocks, or a part that ends on the mip's edge; a region of a 5x5 or 6x6 footprint
|
||||
// may end inside a block (vk_astc_record checks the start corner).
|
||||
uint32_t mw = e->t.d.width >> g->level, mh = e->t.d.height >> g->level;
|
||||
int bc_cut = e->f.astc_bc && (((w & 3) && (uint32_t)r->xoffset + w != (mw ? mw : 1)) ||
|
||||
((h & 3) && (uint32_t)r->yoffset + h != (mh ? mh : 1)));
|
||||
if (agpu && !bc_cut) { // bc_cut: nothing recorded, logged below as not decoded
|
||||
VkAstcRegion *ar = calloc((size_t)g->nz, sizeof *ar);
|
||||
for (int z = 0; z < g->nz; z++)
|
||||
ar[z] = (VkAstcRegion){.src = slice * (size_t)z, .w = w, .h = h, .slices = 1, .level = rg[z].imageSubresource.mipLevel,
|
||||
.layer = rg[z].imageSubresource.baseArrayLayer, .offset = rg[z].imageOffset};
|
||||
uint64_t a0 = perf_begin();
|
||||
done = vk_astc_record(cb, sb, slice * (size_t)g->nz, ar, g->nz, e->f.astc_bw, e->f.astc_bh, e->f.astc_srgb, e->f.astc_bc,
|
||||
e->pub.image, aspect, VK_NULL_HANDLE);
|
||||
perf_end(PERF_ASTC, a0);
|
||||
free(ar);
|
||||
}
|
||||
if (!agpu) vkCmdCopyBufferToImage(cb, sb, e->pub.image, VK_IMAGE_LAYOUT_GENERAL, (uint32_t)g->nz, rg);
|
||||
else if (!done) log_limited(&n_astc, "WriteTexels of ASTC texture %#llx not decoded on the GPU: left as it was",
|
||||
(unsigned long long)e->pub.addr);
|
||||
barrier(cb, VK_PIPELINE_STAGE_2_COPY_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT, ALL_RW);
|
||||
vk_upload_end();
|
||||
vk_upload_flush(1);
|
||||
if (!write) {
|
||||
vmaInvalidateAllocation(c->vma, sa, 0, VK_WHOLE_SIZE);
|
||||
for (int z = 0; z < g->nz; z++) {
|
||||
const uint8_t *s = stg + slice * (size_t)z;
|
||||
uint8_t *m = mem + (size_t)z * g->img;
|
||||
for (size_t y = 0; y < g->hb; y++) {
|
||||
if (rgb) shrink_rgb(e->t.d.format, s + y * w * ib, m + y * g->row, w);
|
||||
else memcpy(m + y * g->row, s + y * g->wb, g->wb);
|
||||
}
|
||||
}
|
||||
}
|
||||
free(rg);
|
||||
vmaDestroyBuffer(c->vma, sb, sa);
|
||||
return 1;
|
||||
@@ -1441,24 +1670,51 @@ void vk_write_texels(const tex_t *t, const view_t *v, const NVNcopyRegion *r, co
|
||||
// A mirrored texture the GPU rendered into holds its texels in its image only; its storage keeps what the CPU last put
|
||||
// there. The game may still read that storage with the CPU: TotK moves its generated grass maps between pools with
|
||||
// memcpy once the GPU is done, and the copy then carried zeros. Such a read writes the image back first.
|
||||
static void write_back(Tex *e) {
|
||||
const nvn_texdesc *d = &e->t.d;
|
||||
int linear = nvn_tex_is_linear(d), is3d = d->target == NVN_TEXTURE_TARGET_3D;
|
||||
uint32_t levels = linear ? 1 : e->pub.levels, layers = linear || is3d ? 1 : e->pub.layers;
|
||||
for (uint32_t lv = 0; lv < levels; lv++)
|
||||
for (uint32_t ly = 0; ly < layers; ly++) {
|
||||
view_t v = {.set_levels = 1, .set_layers = 1, .base_level = (uint8_t)lv, .min_layer = (uint16_t)ly};
|
||||
NVNcopyRegion r = {.width = (int)mip(d->width, lv),
|
||||
.height = d->target == 0 || d->target == 3 ? 1 : (int)mip(d->height, lv),
|
||||
.depth = is3d ? (int)mip(d->depth, lv) : 1};
|
||||
Region g;
|
||||
if (!region_of(&e->t, &v, &r, 0, 0, &g)) continue;
|
||||
uint8_t *buf = malloc(g.img * (size_t)g.nz);
|
||||
if (!buf) rt_fatal("vk: out of memory for a texture write-back");
|
||||
if (image_texels(e, &g, &r, buf, 0)) storage_texels(&e->t, &g, &r, buf, 1);
|
||||
free(buf);
|
||||
}
|
||||
e->gpu_dirty = 0;
|
||||
//
|
||||
// Every level and layer of every texture in v is copied to staging in one upload batch with one wait: one GPU round
|
||||
// trip per level and layer took 0.1 ms each, and a 260x260 grass map has 9 levels. The storage is then written in the
|
||||
// order of v, so where two textures share storage the later one wins.
|
||||
typedef struct {
|
||||
Tex *e;
|
||||
Region g;
|
||||
NVNcopyRegion r;
|
||||
TexRead t;
|
||||
} WbPart;
|
||||
|
||||
static void write_back_n(Tex **v, int n) {
|
||||
int np = 0, cap = 0;
|
||||
WbPart *p = NULL;
|
||||
for (int i = 0; i < n; i++) {
|
||||
Tex *e = v[i];
|
||||
const nvn_texdesc *d = &e->t.d;
|
||||
int linear = nvn_tex_is_linear(d), is3d = d->target == NVN_TEXTURE_TARGET_3D;
|
||||
uint32_t levels = linear ? 1 : e->pub.levels, layers = linear || is3d ? 1 : e->pub.layers;
|
||||
for (uint32_t lv = 0; lv < levels; lv++)
|
||||
for (uint32_t ly = 0; ly < layers; ly++) {
|
||||
if (np == cap) {
|
||||
cap = cap ? cap * 2 : 16;
|
||||
if (!(p = realloc(p, sizeof *p * (size_t)cap))) rt_fatal("vk: out of memory for a texture write-back");
|
||||
}
|
||||
WbPart *q = &p[np];
|
||||
view_t vw = {.set_levels = 1, .set_layers = 1, .base_level = (uint8_t)lv, .min_layer = (uint16_t)ly};
|
||||
q->e = e;
|
||||
q->r = (NVNcopyRegion){.width = (int)mip(d->width, lv),
|
||||
.height = d->target == 0 || d->target == 3 ? 1 : (int)mip(d->height, lv),
|
||||
.depth = is3d ? (int)mip(d->depth, lv) : 1};
|
||||
if (region_of(&e->t, &vw, &q->r, 0, 0, &q->g) && texels_read_record(e, &q->g, &q->r, &q->t)) np++;
|
||||
}
|
||||
}
|
||||
if (np) vk_upload_flush(1);
|
||||
for (int i = 0; i < np; i++) {
|
||||
WbPart *q = &p[i];
|
||||
uint8_t *buf = malloc(q->g.img * (size_t)q->g.nz);
|
||||
if (!buf) rt_fatal("vk: out of memory for a texture write-back");
|
||||
texels_read_take(q->e, &q->g, &q->t, buf);
|
||||
storage_texels(&q->e->t, &q->g, &q->r, buf, 1);
|
||||
free(buf);
|
||||
}
|
||||
free(p);
|
||||
for (int i = 0; i < n; i++) v[i]->gpu_dirty = 0;
|
||||
}
|
||||
|
||||
// Mirrored textures overlapping [addr, addr+size) that the GPU wrote since their last write-back.
|
||||
@@ -1539,7 +1795,7 @@ void vk_texture_write_back(uint64_t addr, uint64_t size) {
|
||||
Tex *x = hit[j];
|
||||
hit[j] = hit[j - 1], hit[j - 1] = x;
|
||||
}
|
||||
for (int i = 0; i < k; i++) write_back(hit[i]);
|
||||
write_back_n(hit, k);
|
||||
for (int i = 0; i < k; i++) perf_count(PERF_CNT_WB);
|
||||
for (int i = k; i < n; i++) perf_count(PERF_CNT_WB_SKIP);
|
||||
free(hit);
|
||||
@@ -1549,13 +1805,14 @@ void vk_texture_write_back(uint64_t addr, uint64_t size) {
|
||||
// TotK's grass system renders a 128x128 RGBA8 map of cut and trampled grass into a CPU-cached pool every frame and its
|
||||
// CPU code reads the pool bytes without nvnMemoryPoolInvalidateMappedRange (cut reactions: blades, insects, the cut
|
||||
// itself). On the Switch the bytes are there once the frame's fence has passed. So when the
|
||||
// CPU waits for a fence, mirrored targets up to wb_limit bytes rendered by work up to that fence go back to their
|
||||
// storage, unless the CPU rewrote it since. Larger targets still go back on an invalidate, memcpy or ReadTexels.
|
||||
// CPU waits for a fence, mirrored targets rendered by work up to that fence go back to their storage (wb_wanted:
|
||||
// targets over wb_limit only when not drawn every frame), unless the CPU rewrote it since. Targets drawn every frame
|
||||
// still go back on an invalidate, memcpy or ReadTexels.
|
||||
int vk_texture_small_dirty(void) { return __atomic_load_n(&small_pending, __ATOMIC_ACQUIRE); }
|
||||
|
||||
// TOTK_TRACE=gfx: a dirty target the fence write-back leaves alone because it is over the limit (what a CPU read
|
||||
// after the wait would miss), or sends back although it is over the default limit (what a raised limit changed). One
|
||||
// line per target address and outcome; the table is small and a full one stops logging.
|
||||
// TOTK_TRACE=gfx: a dirty target the fence write-back leaves alone because it is over the limit and drawn every frame
|
||||
// (what a CPU read after the wait would miss), or sends back although it is over the default limit. One line per
|
||||
// target address and outcome; the table is small and a full one stops logging.
|
||||
static void wb_note(const Tex *e, int skipped) {
|
||||
static uint64_t seen[2][64];
|
||||
static int n_seen[2];
|
||||
@@ -1566,7 +1823,7 @@ static void wb_note(const Tex *e, int skipped) {
|
||||
const pool_t *p = e->t.pool;
|
||||
unsigned fl = p ? p->flags : 0;
|
||||
rt_log("vk: fence write-back %s %#llx %ux%u fmt %#x size %llu pool %s (limit %llu)",
|
||||
skipped ? "skipped-for-size" : "over-default", (unsigned long long)e->pub.addr, e->t.d.width, e->t.d.height,
|
||||
skipped ? "skipped-drawn-every-frame" : "over-default", (unsigned long long)e->pub.addr, e->t.d.width, e->t.d.height,
|
||||
e->t.d.format, (unsigned long long)e->t.L.size,
|
||||
!p ? "none" : (fl & NVN_MEMORY_POOL_FLAGS_CPU_CACHED) ? "cpu-cached" : (fl & NVN_MEMORY_POOL_FLAGS_CPU_UNCACHED) ? "cpu-uncached" : "other",
|
||||
(unsigned long long)wb_limit);
|
||||
@@ -1578,14 +1835,14 @@ void vk_texture_write_back_small(uint64_t fence) {
|
||||
__atomic_store_n(&small_pending, 0, __ATOMIC_RELAXED);
|
||||
plat_mutex_lock_shared(&owned_lock);
|
||||
int n = 0, later = 0;
|
||||
for (int i = 0; i < nowned; i++) n += owned[i]->gpu_dirty && owned[i]->t.L.size <= wb_limit;
|
||||
for (int i = 0; i < nowned; i++) n += owned[i]->gpu_dirty && wb_wanted(owned[i]);
|
||||
Tex **hit = n ? malloc(sizeof *hit * (size_t)n) : NULL;
|
||||
if (n && !hit) rt_fatal("vk: out of memory for a texture write-back");
|
||||
n = 0;
|
||||
for (int i = 0; i < nowned; i++) {
|
||||
Tex *e = owned[i];
|
||||
if (!e->gpu_dirty) continue;
|
||||
if (e->t.L.size > wb_limit) {
|
||||
if (!wb_wanted(e)) {
|
||||
if (wb_trace && e->gpu_fence <= fence) {
|
||||
wb_note(e, 1);
|
||||
perf_count(PERF_CNT_WB_SKIP);
|
||||
@@ -1611,7 +1868,7 @@ void vk_texture_write_back_small(uint64_t fence) {
|
||||
Tex *x = hit[j];
|
||||
hit[j] = hit[j - 1], hit[j - 1] = x;
|
||||
}
|
||||
for (int i = 0; i < k; i++) write_back(hit[i]);
|
||||
write_back_n(hit, k);
|
||||
for (int i = 0; i < k; i++) perf_count(PERF_CNT_WB);
|
||||
for (int i = k; i < n; i++) perf_count(PERF_CNT_WB_SKIP);
|
||||
free(hit);
|
||||
@@ -1862,6 +2119,74 @@ void vk_texture_census(VkTexCensus *s) {
|
||||
unlock();
|
||||
}
|
||||
|
||||
// TOTK_TRACE=perf, with mem: vram: what the released entries in own memory hold and how long they waited.
|
||||
void vk_texture_released_log(uint64_t frame) {
|
||||
VkCtx *c = vk_ctx();
|
||||
if (!c) return;
|
||||
enum { CL_CLEAN, CL_BACKED, CL_DIRTY, CL_POOLS, CL_N };
|
||||
uint64_t cl_n[CL_N] = {0}, cl_b[CL_N] = {0}, age_b[AGE_N] = {0}, dead_n = 0, dead_b = 0, dead_dirty_b = 0;
|
||||
uint64_t bound_n = 0, bound_b = 0, ded_n = 0, ded_b = 0;
|
||||
typedef struct { uint32_t w, h, fmt, n; uint64_t b; } Group;
|
||||
Group gr[256];
|
||||
int ng = 0;
|
||||
lock();
|
||||
AddrIndex x = {0};
|
||||
int indexed = addr_index(&x);
|
||||
for (int i = 0; indexed && i < x.n; i++) {
|
||||
Tex *e = x.e[i];
|
||||
if (!e->released || !e->alloc) continue;
|
||||
VmaAllocationInfo2 ai2;
|
||||
vmaGetAllocationInfo2(c->vma, e->alloc, &ai2);
|
||||
const VmaAllocationInfo ai = ai2.allocationInfo;
|
||||
if (ai2.dedicatedMemory) ded_n++, ded_b += ai.size;
|
||||
int cl = !e->pub.mirrored ? CL_POOLS : e->gpu_dirty ? CL_DIRTY : e->pub.gpu_owned ? CL_BACKED : CL_CLEAN;
|
||||
cl_n[cl]++, cl_b[cl] += ai.size;
|
||||
if (cl != CL_CLEAN) age_b[age_bucket(coarse_ms - e->rel_ms)] += ai.size;
|
||||
if (e->checked && submit_epoch - e->checked < 600) bound_n++, bound_b += ai.size;
|
||||
if (storage_taken(&x, i)) dead_n++, dead_b += ai.size, dead_dirty_b += cl != CL_CLEAN ? ai.size : 0;
|
||||
int g = 0;
|
||||
while (g < ng && (gr[g].w != e->t.d.width || gr[g].h != e->t.d.height || gr[g].fmt != (uint32_t)e->t.d.format)) g++;
|
||||
if (g == ng && ng < 256) gr[ng++] = (Group){e->t.d.width, e->t.d.height, (uint32_t)e->t.d.format, 0, 0};
|
||||
if (g < ng) gr[g].n++, gr[g].b += ai.size;
|
||||
}
|
||||
if (indexed) addr_index_free(&x);
|
||||
const double M = 1048576.0;
|
||||
rt_log("mem: released frame %llu: own memory: clean %llu %.0f MiB, GPU-written with storage current %llu %.0f MiB, "
|
||||
"GPU-written image only %llu %.0f MiB, not mirrored %llu %.0f MiB; all but clean by age <1s %.0f <5s %.0f <30s %.0f "
|
||||
"<120s %.0f older %.0f MiB; storage taken by a later texture %llu %.0f MiB (%.0f not clean); bound through a slot "
|
||||
"in the last 600 submits %llu %.0f MiB; in dedicated memory, returned to the driver when dropped, %llu %.0f MiB; "
|
||||
"longest search %.2f ms",
|
||||
(unsigned long long)frame, (unsigned long long)cl_n[CL_CLEAN], cl_b[CL_CLEAN] / M, (unsigned long long)cl_n[CL_BACKED],
|
||||
cl_b[CL_BACKED] / M, (unsigned long long)cl_n[CL_DIRTY], cl_b[CL_DIRTY] / M, (unsigned long long)cl_n[CL_POOLS],
|
||||
cl_b[CL_POOLS] / M, age_b[AGE_1S] / M, age_b[AGE_5S] / M, age_b[AGE_30S] / M, age_b[AGE_120S] / M,
|
||||
age_b[AGE_LONG] / M, (unsigned long long)dead_n, dead_b / M, dead_dirty_b / M, (unsigned long long)bound_n,
|
||||
bound_b / M, (unsigned long long)ded_n, ded_b / M, pressure_search_ns / 1e6);
|
||||
pressure_search_ns = 0;
|
||||
for (int k = 0; k < 2; k++) {
|
||||
char line[256];
|
||||
size_t o = 0;
|
||||
for (int a = 0; a < AGE_N; a++) {
|
||||
o += (size_t)snprintf(line + o, sizeof line - o, " %s %llu", age_name[a], (unsigned long long)takeback_n[k][a]);
|
||||
takeback_n[k][a] = 0;
|
||||
}
|
||||
rt_log("mem: released frame %llu: taken back after,%s:%s", (unsigned long long)frame, k ? " GPU-written" : " others", line);
|
||||
}
|
||||
rt_log("mem: released frame %llu: dropped under pressure and created again: within 10 s %llu (%llu GPU-written), later %llu (%llu)",
|
||||
(unsigned long long)frame, (unsigned long long)n_recreated[0], (unsigned long long)n_recreated_written[0],
|
||||
(unsigned long long)n_recreated[1], (unsigned long long)n_recreated_written[1]);
|
||||
n_recreated[0] = n_recreated[1] = n_recreated_written[0] = n_recreated_written[1] = 0;
|
||||
for (int top = 0; top < 4; top++) {
|
||||
int best = -1;
|
||||
for (int g = 0; g < ng; g++)
|
||||
if (gr[g].n && (best < 0 || gr[g].b > gr[best].b)) best = g;
|
||||
if (best < 0) break;
|
||||
rt_log("mem: released frame %llu: largest %d: %ux%u fmt %#x: %u entries %.0f MiB", (unsigned long long)frame, top + 1,
|
||||
gr[best].w, gr[best].h, gr[best].fmt, gr[best].n, gr[best].b / M);
|
||||
gr[best].n = 0;
|
||||
}
|
||||
unlock();
|
||||
}
|
||||
|
||||
void vk_texture_shutdown(void) {
|
||||
VkCtx *c = vk_ctx();
|
||||
lock();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Generated by tools/build_shaders.py from runtime/shaders/wb_merge.comp; do not edit.
|
||||
// source sha256 a2d55900390e1cca3b742068d5ee21c4cb13a7eb5cb1d5a687f1001e001316b0
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
static const uint32_t wb_merge_spv[] = {
|
||||
0x07230203, 0x00010300, 0x000d000b, 0x00000083, 0x00000000, 0x00020011, 0x00000001, 0x0006000b,
|
||||
0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e, 0x00000000, 0x0003000e, 0x00000000, 0x00000001,
|
||||
0x0006000f, 0x00000005, 0x00000004, 0x6e69616d, 0x00000000, 0x0000000b, 0x00060010, 0x00000004,
|
||||
0x00000011, 0x00000040, 0x00000001, 0x00000001, 0x00040047, 0x0000000b, 0x0000000b, 0x0000001c,
|
||||
0x00030047, 0x00000011, 0x00000002, 0x00050048, 0x00000011, 0x00000000, 0x00000023, 0x00000000,
|
||||
0x00050048, 0x00000011, 0x00000001, 0x00000023, 0x00000004, 0x00040047, 0x00000033, 0x00000006,
|
||||
0x00000010, 0x00030047, 0x00000034, 0x00000002, 0x00040048, 0x00000034, 0x00000000, 0x00000018,
|
||||
0x00050048, 0x00000034, 0x00000000, 0x00000023, 0x00000000, 0x00030047, 0x00000036, 0x00000018,
|
||||
0x00040047, 0x00000036, 0x00000021, 0x00000003, 0x00040047, 0x00000036, 0x00000022, 0x00000000,
|
||||
0x00040047, 0x0000004f, 0x00000006, 0x00000004, 0x00030047, 0x00000050, 0x00000002, 0x00040048,
|
||||
0x00000050, 0x00000000, 0x00000018, 0x00050048, 0x00000050, 0x00000000, 0x00000023, 0x00000000,
|
||||
0x00030047, 0x00000052, 0x00000018, 0x00040047, 0x00000052, 0x00000021, 0x00000000, 0x00040047,
|
||||
0x00000052, 0x00000022, 0x00000000, 0x00040047, 0x0000005a, 0x00000006, 0x00000004, 0x00030047,
|
||||
0x0000005b, 0x00000002, 0x00040048, 0x0000005b, 0x00000000, 0x00000018, 0x00050048, 0x0000005b,
|
||||
0x00000000, 0x00000023, 0x00000000, 0x00030047, 0x0000005d, 0x00000018, 0x00040047, 0x0000005d,
|
||||
0x00000021, 0x00000001, 0x00040047, 0x0000005d, 0x00000022, 0x00000000, 0x00040047, 0x00000067,
|
||||
0x00000006, 0x00000004, 0x00030047, 0x00000068, 0x00000002, 0x00050048, 0x00000068, 0x00000000,
|
||||
0x00000023, 0x00000000, 0x00040047, 0x0000006a, 0x00000021, 0x00000002, 0x00040047, 0x0000006a,
|
||||
0x00000022, 0x00000000, 0x00040047, 0x00000072, 0x0000000b, 0x00000019, 0x00020013, 0x00000002,
|
||||
0x00030021, 0x00000003, 0x00000002, 0x00040015, 0x00000006, 0x00000020, 0x00000000, 0x00040017,
|
||||
0x00000009, 0x00000006, 0x00000003, 0x00040020, 0x0000000a, 0x00000001, 0x00000009, 0x0004003b,
|
||||
0x0000000a, 0x0000000b, 0x00000001, 0x0004002b, 0x00000006, 0x0000000c, 0x00000000, 0x00040020,
|
||||
0x0000000d, 0x00000001, 0x00000006, 0x0004001e, 0x00000011, 0x00000006, 0x00000006, 0x00040020,
|
||||
0x00000012, 0x00000009, 0x00000011, 0x0004003b, 0x00000012, 0x00000013, 0x00000009, 0x00040015,
|
||||
0x00000014, 0x00000020, 0x00000001, 0x0004002b, 0x00000014, 0x00000015, 0x00000001, 0x00040020,
|
||||
0x00000016, 0x00000009, 0x00000006, 0x00020014, 0x00000019, 0x0004002b, 0x00000014, 0x00000020,
|
||||
0x00000000, 0x0004002b, 0x00000006, 0x0000002b, 0x00000001, 0x00040017, 0x00000032, 0x00000006,
|
||||
0x00000004, 0x0003001d, 0x00000033, 0x00000032, 0x0003001e, 0x00000034, 0x00000033, 0x00040020,
|
||||
0x00000035, 0x0000000c, 0x00000034, 0x0004003b, 0x00000035, 0x00000036, 0x0000000c, 0x0004002b,
|
||||
0x00000006, 0x00000038, 0x00000003, 0x00040020, 0x00000039, 0x0000000c, 0x00000006, 0x00040020,
|
||||
0x00000046, 0x0000000c, 0x00000032, 0x0003001d, 0x0000004f, 0x00000006, 0x0003001e, 0x00000050,
|
||||
0x0000004f, 0x00040020, 0x00000051, 0x0000000c, 0x00000050, 0x0004003b, 0x00000051, 0x00000052,
|
||||
0x0000000c, 0x0003001d, 0x0000005a, 0x00000006, 0x0003001e, 0x0000005b, 0x0000005a, 0x00040020,
|
||||
0x0000005c, 0x0000000c, 0x0000005b, 0x0004003b, 0x0000005c, 0x0000005d, 0x0000000c, 0x0003001d,
|
||||
0x00000067, 0x00000006, 0x0003001e, 0x00000068, 0x00000067, 0x00040020, 0x00000069, 0x0000000c,
|
||||
0x00000068, 0x0004003b, 0x00000069, 0x0000006a, 0x0000000c, 0x0004002b, 0x00000006, 0x00000071,
|
||||
0x00000040, 0x0006002c, 0x00000009, 0x00000072, 0x00000071, 0x0000002b, 0x0000002b, 0x00050036,
|
||||
0x00000002, 0x00000004, 0x00000000, 0x00000003, 0x000200f8, 0x00000005, 0x000300f7, 0x00000073,
|
||||
0x00000000, 0x000300fb, 0x0000000c, 0x00000074, 0x000200f8, 0x00000074, 0x00050041, 0x0000000d,
|
||||
0x0000000e, 0x0000000b, 0x0000000c, 0x0004003d, 0x00000006, 0x0000000f, 0x0000000e, 0x00050041,
|
||||
0x00000016, 0x00000017, 0x00000013, 0x00000015, 0x0004003d, 0x00000006, 0x00000018, 0x00000017,
|
||||
0x000500ae, 0x00000019, 0x0000001a, 0x0000000f, 0x00000018, 0x000300f7, 0x0000001c, 0x00000000,
|
||||
0x000400fa, 0x0000001a, 0x0000001b, 0x0000001c, 0x000200f8, 0x0000001b, 0x000200f9, 0x00000073,
|
||||
0x000200f8, 0x0000001c, 0x00050041, 0x00000016, 0x00000021, 0x00000013, 0x00000020, 0x0004003d,
|
||||
0x00000006, 0x00000022, 0x00000021, 0x000200f9, 0x00000023, 0x000200f8, 0x00000023, 0x000700f5,
|
||||
0x00000006, 0x0000007e, 0x0000000c, 0x0000001c, 0x00000081, 0x00000024, 0x000700f5, 0x00000006,
|
||||
0x0000007d, 0x00000022, 0x0000001c, 0x00000082, 0x00000024, 0x00050082, 0x00000006, 0x0000002a,
|
||||
0x0000007d, 0x0000007e, 0x000500ac, 0x00000019, 0x0000002c, 0x0000002a, 0x0000002b, 0x000400f6,
|
||||
0x00000025, 0x00000024, 0x00000000, 0x000400fa, 0x0000002c, 0x00000024, 0x00000025, 0x000200f8,
|
||||
0x00000024, 0x00050080, 0x00000006, 0x00000030, 0x0000007e, 0x0000007d, 0x000500c2, 0x00000006,
|
||||
0x00000031, 0x00000030, 0x00000015, 0x00070041, 0x00000039, 0x0000003a, 0x00000036, 0x00000020,
|
||||
0x00000031, 0x00000038, 0x0004003d, 0x00000006, 0x0000003b, 0x0000003a, 0x000500b2, 0x00000019,
|
||||
0x0000003d, 0x0000003b, 0x0000000f, 0x000600a9, 0x00000006, 0x00000081, 0x0000003d, 0x00000031,
|
||||
0x0000007e, 0x000600a9, 0x00000006, 0x00000082, 0x0000003d, 0x0000007d, 0x00000031, 0x000200f9,
|
||||
0x00000023, 0x000200f8, 0x00000025, 0x00060041, 0x00000046, 0x00000047, 0x00000036, 0x00000020,
|
||||
0x0000007e, 0x0004003d, 0x00000032, 0x00000048, 0x00000047, 0x00050051, 0x00000006, 0x0000004c,
|
||||
0x00000048, 0x00000003, 0x00050082, 0x00000006, 0x0000004d, 0x0000000f, 0x0000004c, 0x00050051,
|
||||
0x00000006, 0x00000054, 0x00000048, 0x00000000, 0x00050080, 0x00000006, 0x00000056, 0x00000054,
|
||||
0x0000004d, 0x00060041, 0x00000039, 0x00000057, 0x00000052, 0x00000020, 0x00000056, 0x0004003d,
|
||||
0x00000006, 0x00000058, 0x00000057, 0x00050051, 0x00000006, 0x0000005f, 0x00000048, 0x00000001,
|
||||
0x00050080, 0x00000006, 0x00000061, 0x0000005f, 0x0000004d, 0x00060041, 0x00000039, 0x00000062,
|
||||
0x0000005d, 0x00000020, 0x00000061, 0x0004003d, 0x00000006, 0x00000063, 0x00000062, 0x000500ab,
|
||||
0x00000019, 0x00000064, 0x00000058, 0x00000063, 0x000300f7, 0x00000066, 0x00000000, 0x000400fa,
|
||||
0x00000064, 0x00000065, 0x00000066, 0x000200f8, 0x00000065, 0x00060041, 0x00000039, 0x00000070,
|
||||
0x0000006a, 0x00000020, 0x00000056, 0x0003003e, 0x00000070, 0x00000058, 0x000200f9, 0x00000066,
|
||||
0x000200f8, 0x00000066, 0x000200f9, 0x00000073, 0x000200f8, 0x00000073, 0x000100fd, 0x00010038,
|
||||
};
|
||||
@@ -0,0 +1,258 @@
|
||||
"""tools/exefs_patches.py on modules it makes itself: code writes become sites, data writes become runs of the data file
|
||||
as emulators apply a patch file before the dynamic linker (plain bytes, DT_RELR words rebased, writes into words an
|
||||
explicit-addend relocation fills dropped), the writes it refuses (outside the image, into the linker's tables or a
|
||||
lazily bound pointer), the status and data files, and the check after the lift against the bytes recomp/gen.py lists in
|
||||
folded.txt, with gen.py run on a module with one jump table. No game files.
|
||||
|
||||
Run with .venv (lz4 and numpy, as recomp/): python tests/exefs_patches_test.py
|
||||
"""
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / 'tools'))
|
||||
import exefs_patches as ep # noqa: E402
|
||||
|
||||
BUILD_ID = bytes.fromhex('0123456789abcdef') + bytes(24)
|
||||
BID_NAME = '0123456789ABCDEF' # an IPS file name may leave the build id's trailing zeros out
|
||||
MOV_W8_100, MOV_W8_200, RET = 0x52802008, 0x52804008, 0xD65F03C0
|
||||
R_RELATIVE, R_GLOB_DAT, R_JUMP_SLOT = 1027, 1025, 1026
|
||||
|
||||
|
||||
def nso(text, ro, data, bss=0x1000):
|
||||
"""An uncompressed NSO with .text at 0, .rodata at 0x1000 and .data at 0x2000."""
|
||||
segs = [(0x0, text), (0x1000, ro), (0x2000, data)]
|
||||
hdr = bytearray(0x100)
|
||||
hdr[0:4] = b'NSO0'
|
||||
off = 0x100
|
||||
for i, (mem, d) in enumerate(segs):
|
||||
struct.pack_into('<IIII', hdr, 0x10 + 0x10 * i, off, mem, len(d), bss if i == 2 else 0)
|
||||
struct.pack_into('<I', hdr, 0x60 + 4 * i, len(d))
|
||||
off += len(d)
|
||||
hdr[0x40:0x60] = BUILD_ID
|
||||
return bytes(hdr) + b''.join(d for _, d in segs)
|
||||
|
||||
|
||||
def put(buf, off, data):
|
||||
buf[off:off + len(data)] = data
|
||||
|
||||
|
||||
def reloc_module():
|
||||
"""fn at 0x8 (mov w8, #0x100; ret); .rodata: constants at 0x1000, MOD0 at 0x1100 and the relocation tables at
|
||||
0x1200 (RELA: a RELATIVE word at 0x2100, a GLOB_DAT word at 0x2108), 0x1300 (JMPREL: a JUMP_SLOT word at 0x2110)
|
||||
and 0x1400 (RELR: the word at 0x2118, addend 0x30); .data: .dynamic at 0x2000, plain bytes at 0x2200."""
|
||||
text = struct.pack('<4I', 0x14000002, 0x1100, MOV_W8_100, RET)
|
||||
ro = bytearray(0x500)
|
||||
put(ro, 0x000, b'constant bytes!!')
|
||||
put(ro, 0x100, b'MOD0' + struct.pack('<6i', 0x2000 - 0x1100, 0, 0, -0x1100, -0x1100, 0))
|
||||
put(ro, 0x200, struct.pack('<QQq', 0x2100, R_RELATIVE, 0x8) + struct.pack('<QQq', 0x2108, R_GLOB_DAT, 0))
|
||||
put(ro, 0x300, struct.pack('<QQq', 0x2110, R_JUMP_SLOT, 0))
|
||||
put(ro, 0x400, struct.pack('<Q', 0x2118))
|
||||
data = bytearray(0x300)
|
||||
dyn = [(7, 0x1200), (8, 48), (23, 0x1300), (2, 24), (0x24, 0x1400), (0x23, 8), (0, 0)]
|
||||
put(data, 0x000, b''.join(struct.pack('<QQ', t, v) for t, v in dyn))
|
||||
put(data, 0x118, struct.pack('<Q', 0x30))
|
||||
put(data, 0x200, b'ABCDEFGH')
|
||||
return nso(text, bytes(ro), bytes(data))
|
||||
|
||||
|
||||
# A module with one jump table (clang's compressed form), assembled from:
|
||||
# b fn ; 0x0
|
||||
# .word mod0 ; 0x4: the MOD0 offset
|
||||
# fn: cmp w0, #3 ; 0x8
|
||||
# b.hi Ldefault
|
||||
# adrp x8, table ; table = 0x1020: .byte 0, 2, 4, 6
|
||||
# add x8, x8, #0x20
|
||||
# adr x9, L0
|
||||
# ldrb w10, [x8, w0, uxtw]
|
||||
# add x9, x9, x10, lsl #2
|
||||
# br x9
|
||||
# L0..L3: mov w0, #10/20/30/40; ret ; Ldefault: mov w0, #0; ret
|
||||
# .rodata: 32 bytes of text before the table, 21 after it, MOD0 at 0x1040 and a RELATIVE relocation to fn (the
|
||||
# lifter's function seed); .data: .dynamic (DT_RELA) and the relocated word.
|
||||
JT_TEXT = bytes.fromhex(
|
||||
'02000014401000001f0c0071e8010054080000b008810091890000100a49603829090a8b20011fd640018052c0035fd680028052'
|
||||
'c0035fd6c0038052c0035fd600058052c0035fd600008052c0035fd6')
|
||||
JT_RO = bytes.fromhex(
|
||||
'756e746f7563686564206279746573206265666f726520746865207461626c6500020406627974657320616674657220746865'
|
||||
'207461626c65000000000000004d4f4430c00f00000000000000000000c0efffffc0efffff00000000000000003020000000000000'
|
||||
'03040000000000000800000000000000')
|
||||
JT_DATA = struct.pack('<7Q', 7, 0x1060, 8, 24, 0, 0, 0) # DT_RELA, DT_RELASZ, DT_NULL; the word the RELATIVE fills
|
||||
|
||||
|
||||
def ips32(writes):
|
||||
"""An IPS32 file of {image offset: bytes} (offsets count the NSO header, as emulators read them)."""
|
||||
body = b''.join((off + ep.NSO_HEADER).to_bytes(4, 'big') + len(d).to_bytes(2, 'big') + d
|
||||
for off, d in sorted(writes.items()))
|
||||
return b'IPS32' + body + b'EEOF'
|
||||
|
||||
|
||||
class Classify(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.tmp = tempfile.TemporaryDirectory()
|
||||
path = Path(cls.tmp.name) / 'main'
|
||||
path.write_bytes(reloc_module())
|
||||
cls.m = ep.Main(path)
|
||||
cls.md = ep.MainData(cls.m)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.tmp.cleanup()
|
||||
|
||||
def run_file(self, writes, md=True):
|
||||
return ep.classify(self.m, self.md if md else None, self.m.nso.build_id, Path(f'{BID_NAME}.ips'),
|
||||
ips32(writes))
|
||||
|
||||
def test_relocated_words(self):
|
||||
self.assertEqual(self.md.rela, {0x2100: R_RELATIVE, 0x2108: R_GLOB_DAT, 0x2110: R_JUMP_SLOT})
|
||||
self.assertEqual(self.md.relr, {0x2118})
|
||||
|
||||
def test_code_only(self):
|
||||
kind, note, sites, runs, dropped = self.run_file({0x8: struct.pack('<I', MOV_W8_200)})
|
||||
self.assertEqual((kind, note, runs, dropped), ('code', '', [], 0))
|
||||
self.assertEqual(sites, {0x8: [MOV_W8_200]})
|
||||
|
||||
def test_data_only(self):
|
||||
kind, note, sites, runs, dropped = self.run_file({0x1000: b'\xAA\xBB', 0x2200: b'XY'})
|
||||
self.assertEqual((kind, sites, dropped), ('data', {}, 0))
|
||||
self.assertEqual(runs, [(0x1000, b'\xAA\xBB', False), (0x2200, b'XY', False)])
|
||||
|
||||
def test_code_and_data(self):
|
||||
kind, _, sites, runs, _ = self.run_file({0x8: struct.pack('<I', MOV_W8_200), 0x1004: b'\x01'})
|
||||
self.assertEqual(kind, 'code')
|
||||
self.assertEqual(sites, {0x8: [MOV_W8_200]})
|
||||
self.assertEqual(runs, [(0x1004, b'\x01', False)])
|
||||
|
||||
def test_same_bytes_are_nothing(self):
|
||||
kind, _, _, runs, _ = self.run_file({0x1000: b'constant'})
|
||||
self.assertEqual((kind, runs), ('empty', []))
|
||||
|
||||
def test_explicit_addend_words_change_nothing(self):
|
||||
kind, _, _, runs, dropped = self.run_file({0x2100: b'\x11\x22\x33\x44', 0x2108: b'\x55'})
|
||||
self.assertEqual((kind, runs, dropped), ('empty', [], 5))
|
||||
|
||||
def test_relr_word_is_rebased(self):
|
||||
kind, _, _, runs, _ = self.run_file({0x2118: b'\x31'})
|
||||
self.assertEqual(kind, 'data')
|
||||
self.assertEqual(runs, [(0x2118, struct.pack('<Q', 0x31), True)])
|
||||
|
||||
def test_long_runs_are_split(self):
|
||||
_, _, _, runs, _ = self.run_file({0x2200: bytes(i % 255 + 1 for i in range(ep.RUN_MAX)) + b'\xFF'})
|
||||
self.assertEqual([(o, len(d)) for o, d, _ in runs], [(0x2200, ep.RUN_MAX), (0x2200 + ep.RUN_MAX, 1)])
|
||||
|
||||
def test_refused_writes(self):
|
||||
for writes, why in (({0x2110: b'\x01'}, 'binds lazily'), ({0x2000: b'\x01'}, 'linker\'s tables'),
|
||||
({0x1208: b'\x01'}, 'linker\'s tables'), ({0x4000: b'\x01'}, 'outside main\'s image'),
|
||||
({-0x80: b'\x01'}, 'outside main\'s image')):
|
||||
kind, note, sites, runs, _ = self.run_file({0x8: struct.pack('<I', MOV_W8_200), **writes})
|
||||
self.assertEqual((kind, sites, runs), ('unsupported', {}, []), note)
|
||||
self.assertIn(why, note)
|
||||
|
||||
def test_data_without_a_data_file(self):
|
||||
kind, note, _, _, _ = self.run_file({0x1000: b'\xAA'}, md=False)
|
||||
self.assertEqual(kind, 'unsupported')
|
||||
self.assertIn('only code patches are supported', note)
|
||||
|
||||
def test_pchtxt(self):
|
||||
text = (f'@nsobid-{BID_NAME}\n@flag offset_shift 0x100\n@enabled\n'
|
||||
f'00001000 AABB\n00002200 "XY"\n@disabled\n00002204 CC\n')
|
||||
kind, _, _, runs, _ = ep.classify(self.m, self.md, self.m.nso.build_id, Path('x.pchtxt'), text.encode())
|
||||
self.assertEqual(kind, 'data')
|
||||
self.assertEqual(runs, [(0x1000, b'\xAA\xBB', False), (0x2200, b'XY', False)])
|
||||
|
||||
|
||||
class Tool(unittest.TestCase):
|
||||
"""The command line: build, then the check after the lift."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.d = Path(self.tmp.name)
|
||||
(self.d / 'exefs').mkdir()
|
||||
(self.d / 'patches.txt').write_text('# no sets of the port\n')
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def mod_file(self, mod, writes):
|
||||
f = self.d / 'mods' / mod / 'exefs' / f'{BID_NAME}.ips'
|
||||
f.parent.mkdir(parents=True, exist_ok=True)
|
||||
f.write_bytes(ips32(writes))
|
||||
|
||||
def tool(self, *args):
|
||||
r = subprocess.run([sys.executable, str(ROOT / 'tools' / 'exefs_patches.py'), '--exefs', str(self.d / 'exefs'),
|
||||
*map(str, args)], capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
return r.stdout
|
||||
|
||||
def build(self):
|
||||
self.tool('--mods', self.d / 'mods', '--patches', self.d / 'patches.txt', '--out', self.d / 'out.txt',
|
||||
'--status', self.d / 'exefs.status', '--data', self.d / 'exefs.data')
|
||||
status = {f[0]: f for f in (line.split('\t') for line in (self.d / 'exefs.status').read_text().splitlines())}
|
||||
data = [line.split('\t') for line in (self.d / 'exefs.data').read_text().splitlines()
|
||||
if not line.startswith('#')]
|
||||
return status, data
|
||||
|
||||
def key(self, mod):
|
||||
return ep.fnv(f'{mod}/exefs/{BID_NAME}.ips'.encode())
|
||||
|
||||
def test_build_and_check(self):
|
||||
(self.d / 'exefs' / 'main').write_bytes(reloc_module())
|
||||
self.mod_file('Code', {0x8: struct.pack('<I', MOV_W8_200), 0x2200: b'Q'})
|
||||
self.mod_file('Data', {0x1000: b'\xAA', 0x2118: b'\x31'})
|
||||
self.mod_file('Bad', {0x2110: b'\x01'})
|
||||
status, data = self.build()
|
||||
code, dat, bad = self.key('Code'), self.key('Data'), self.key('Bad')
|
||||
self.assertEqual([status[k][2] for k in (code, dat, bad)], ['code', 'data', 'unsupported'])
|
||||
self.assertEqual([status[k][4] for k in (code, dat)], ['data 1', 'data 9']) # bytes its data lines hold
|
||||
self.assertIn(f'set mod_{code.lower()} TOTK_MODPATCH_{code} 0', (self.d / 'out.txt').read_text())
|
||||
self.assertEqual(sorted((f[0], f[2], f[3], f[4:]) for f in data),
|
||||
sorted([(code, '2200', '51', []), (dat, '1000', 'aa', []),
|
||||
(dat, '2118', '3100000000000000', ['base'])]))
|
||||
# after the lift: .rodata bytes the lifted code read make the Data file unsupported; the Code file's data is
|
||||
# in .data, which the lifter never reads
|
||||
folded = self.d / 'folded.txt'
|
||||
folded.write_text('# test\n1000 1001\n')
|
||||
self.tool('--status', self.d / 'exefs.status', '--data', self.d / 'exefs.data', '--folded', folded)
|
||||
status2 = {f[0]: f for f in (line.split('\t') for line in (self.d / 'exefs.status').read_text().splitlines())}
|
||||
self.assertEqual(status2[code][2], 'code')
|
||||
self.assertEqual(status2[dat][2:4], ['unsupported', '-'])
|
||||
self.assertIn('jump table', status2[dat][4])
|
||||
left = [line.split('\t')[0] for line in (self.d / 'exefs.data').read_text().splitlines()
|
||||
if not line.startswith('#')]
|
||||
self.assertEqual(left, [code])
|
||||
|
||||
def test_check_without_the_lift_list(self):
|
||||
(self.d / 'exefs' / 'main').write_bytes(reloc_module())
|
||||
self.mod_file('Ro', {0x1000: b'\xAA'})
|
||||
self.mod_file('Rw', {0x2200: b'Q'})
|
||||
self.build()
|
||||
self.tool('--status', self.d / 'exefs.status', '--data', self.d / 'exefs.data', '--folded',
|
||||
self.d / 'missing.txt')
|
||||
status = {f[0]: f[2] for f in (line.split('\t') for line in (self.d / 'exefs.status').read_text().splitlines())}
|
||||
self.assertEqual((status[self.key('Ro')], status[self.key('Rw')]), ('unsupported', 'data'))
|
||||
|
||||
def test_jump_table_through_the_lift(self):
|
||||
"""recomp/gen.py lists the jump table's bytes in folded.txt; a write into them is refused, one beside them
|
||||
is kept."""
|
||||
(self.d / 'exefs' / 'main').write_bytes(nso(JT_TEXT, JT_RO, JT_DATA))
|
||||
r = subprocess.run([sys.executable, str(ROOT / 'recomp' / 'gen.py'), '--exefs', str(self.d / 'exefs'),
|
||||
'--out', str(self.d / 'gen'), '--modules', 'main', '--jobs', '1'],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
folded = self.d / 'gen' / 'main' / 'folded.txt'
|
||||
self.assertEqual(ep.read_ranges(folded), [(0x1020, 0x1024)])
|
||||
self.mod_file('Table', {0x1022: b'\x00'})
|
||||
self.mod_file('Beside', {0x1024: b'B'})
|
||||
self.build()
|
||||
self.tool('--status', self.d / 'exefs.status', '--data', self.d / 'exefs.data', '--folded', folded)
|
||||
status = {f[0]: f[2] for f in (line.split('\t') for line in (self.d / 'exefs.status').read_text().splitlines())}
|
||||
self.assertEqual((status[self.key('Table')], status[self.key('Beside')]), ('unsupported', 'data'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+250
-1
@@ -1,4 +1,5 @@
|
||||
"""The installer window: event model, form, catalogs, cancel, a self-closing Tk run and one end-to-end run.
|
||||
"""The installer window: event model, form, catalogs, cancel, DPI awareness, a self-closing Tk run and one end-to-end
|
||||
run.
|
||||
|
||||
.venv-py314\\Scripts\\python tests\\installer_gui_test.py
|
||||
|
||||
@@ -21,12 +22,19 @@ import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from installer import layout as layouts # noqa: E402
|
||||
from installer import dpi # noqa: E402
|
||||
from installer import recent # noqa: E402
|
||||
try:
|
||||
from installer import gui # noqa: E402
|
||||
except ImportError: # no tkinter
|
||||
gui = None
|
||||
from installer import state as statefile # noqa: E402
|
||||
from installer.events import Reporter # noqa: E402
|
||||
from installer.runner import Context # noqa: E402
|
||||
@@ -341,6 +349,247 @@ class CancelTest(unittest.TestCase):
|
||||
p.kill()
|
||||
|
||||
|
||||
class DpiTest(unittest.TestCase):
|
||||
def test_px_follows_the_display_scale(self):
|
||||
scale = dpi.scale()
|
||||
self.assertGreaterEqual(scale, 1.0)
|
||||
self.assertEqual(dpi.px(0), 0)
|
||||
self.assertEqual(dpi.px(14), round(14 * scale))
|
||||
|
||||
def test_px_at_the_scales_of_other_displays(self):
|
||||
"""A 96 DPI runner cannot show the scaling: give px() the scales Windows reports at 125, 150 and 200%."""
|
||||
saved = dpi._scale
|
||||
try:
|
||||
for scale, pad, wrap in ((1.0, 14, 620), (1.25, 18, 775), (1.5, 21, 930), (2.0, 28, 1240)):
|
||||
dpi._scale = scale
|
||||
self.assertEqual((dpi.px(14), dpi.px(620)), (pad, wrap), scale)
|
||||
finally:
|
||||
dpi._scale = saved
|
||||
|
||||
@unittest.skipUnless(os.name == 'nt', 'the awareness and the DPI Tk reads are Windows settings')
|
||||
def test_the_window_draws_at_the_display_scale(self):
|
||||
"""A fresh process imports the window (which makes itself DPI aware) and starts Tk: the fonts must be sized for
|
||||
the display, not for 96 DPI, which is what Windows then stretched (the blurry window)."""
|
||||
try:
|
||||
import tkinter # noqa: F401
|
||||
except ImportError:
|
||||
self.skipTest('no tkinter')
|
||||
code = ('import tkinter\n'
|
||||
'from installer import dpi, gui\n'
|
||||
'root = tkinter.Tk()\n'
|
||||
'print(dpi.awareness(), dpi.scale(), root.tk.call("tk", "scaling"), gui.PAD, gui.WRAP, '
|
||||
'root.winfo_screenwidth())\n'
|
||||
'root.destroy()\n')
|
||||
r = subprocess.run([sys.executable, '-c', code], cwd=ROOT, capture_output=True, text=True, timeout=120)
|
||||
self.assertEqual(r.returncode, 0, r.stderr[-2000:])
|
||||
awareness, scale, scaling, pad, wrap, width = r.stdout.split()
|
||||
self.assertGreaterEqual(int(awareness), 1) # 1 system aware, 2 per-monitor aware
|
||||
# Tk's win/tkWinX.c TkWinDisplayChanged rounds the nominal screen width to whole millimetres.
|
||||
# Its generic/tkCmds.c ScalingCmd converts that width back to pixels per point, so a 120 DPI display
|
||||
# can report 120.118 DPI. Keep the exact comparison, accounting for that round trip.
|
||||
width_mm = int(25.4 * int(width) / (float(scale) * 96) + 0.5)
|
||||
expected_scaling = 25.4 * int(width) / (72 * width_mm)
|
||||
self.assertAlmostEqual(float(scaling), expected_scaling, places=6)
|
||||
self.assertEqual((int(pad), int(wrap)), (round(14 * float(scale)), round(620 * float(scale))))
|
||||
|
||||
|
||||
class RecentTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.file = self.tmp / 'sub' / 'setup.json'
|
||||
old = os.environ.get(recent.ENV)
|
||||
os.environ[recent.ENV] = str(self.file)
|
||||
self.addCleanup(lambda: os.environ.pop(recent.ENV, None) if old is None else os.environ.update({recent.ENV: old}))
|
||||
self.dest = self.tmp / 'TotK'
|
||||
self.dest.mkdir()
|
||||
|
||||
def test_override_is_honoured(self):
|
||||
self.assertEqual(recent.path(), self.file)
|
||||
|
||||
def test_missing_file(self):
|
||||
self.assertEqual(recent.load(), {})
|
||||
|
||||
def installed(self):
|
||||
(self.dest / statefile.NAME).write_text('{}', encoding='utf-8')
|
||||
|
||||
def files(self):
|
||||
out = {}
|
||||
for name in ('base', 'update', 'keys'):
|
||||
f = self.tmp / (name + '.bin')
|
||||
f.write_text('x', encoding='utf-8')
|
||||
out[name] = str(f)
|
||||
fw = self.tmp / 'fw'
|
||||
fw.mkdir()
|
||||
out['firmware'] = str(fw)
|
||||
return out
|
||||
|
||||
def write(self, **fields):
|
||||
self.file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.file.write_text(json.dumps(fields), encoding='utf-8')
|
||||
|
||||
def test_folder_without_state(self):
|
||||
recent.save(self.dest)
|
||||
self.assertEqual(recent.load(), {})
|
||||
|
||||
def test_round_trip(self):
|
||||
self.installed()
|
||||
src = self.files()
|
||||
recent.save(self.dest, src)
|
||||
want = {name: os.path.abspath(v) for name, v in src.items()}
|
||||
want['dest'] = os.path.abspath(self.dest)
|
||||
self.assertEqual(json.loads(self.file.read_text(encoding='utf-8')), {'format': 1, **want})
|
||||
self.assertEqual(recent.load(), want)
|
||||
self.assertFalse(self.file.with_name('setup.json.tmp').exists())
|
||||
|
||||
def test_empty_field_keeps_saved_value(self):
|
||||
self.installed()
|
||||
src = self.files()
|
||||
recent.save(self.dest, src)
|
||||
recent.save(self.dest, {'base': '', 'update': ' ', 'keys': src['keys']})
|
||||
self.assertEqual(recent.load()['base'], os.path.abspath(src['base']))
|
||||
self.assertEqual(recent.load()['firmware'], os.path.abspath(src['firmware']))
|
||||
|
||||
def test_missing_path_not_saved(self):
|
||||
self.installed()
|
||||
recent.save(self.dest, {'base': str(self.tmp / 'gone.xci'), 'firmware': str(self.tmp / 'nofw')})
|
||||
self.assertEqual(recent.load(), {'dest': os.path.abspath(self.dest)})
|
||||
self.assertNotIn('base', json.loads(self.file.read_text(encoding='utf-8')))
|
||||
|
||||
def test_vanished_path_dropped_on_load(self):
|
||||
self.installed()
|
||||
src = self.files()
|
||||
recent.save(self.dest, src)
|
||||
os.remove(src['base'])
|
||||
self.assertNotIn('base', recent.load())
|
||||
self.assertIn('update', recent.load())
|
||||
|
||||
def test_directory_as_file_dropped(self):
|
||||
self.installed()
|
||||
self.write(format=1, dest=str(self.dest), base=str(self.tmp), update=str(self.tmp), keys=str(self.tmp))
|
||||
self.assertEqual(recent.load(), {'dest': str(self.dest)})
|
||||
|
||||
def test_file_as_firmware_dropped(self):
|
||||
self.installed()
|
||||
src = self.files()
|
||||
self.write(format=1, dest=str(self.dest), firmware=src['base'])
|
||||
self.assertEqual(recent.load(), {'dest': str(self.dest)})
|
||||
|
||||
def test_dest_file_dropped(self):
|
||||
f = self.tmp / 'afile'
|
||||
f.write_text('x', encoding='utf-8')
|
||||
self.write(format=1, dest=str(f))
|
||||
self.assertEqual(recent.load(), {})
|
||||
|
||||
def test_bad_values_dropped(self):
|
||||
self.installed()
|
||||
src = self.files()
|
||||
self.write(format=1, dest=str(self.dest), base=5, update='', keys='rel/prod.keys', firmware=['x'])
|
||||
self.assertEqual(recent.load(), {'dest': str(self.dest)})
|
||||
self.write(format=1, dest=str(self.dest), base=src['base'] + '\0x', update=None, keys=True)
|
||||
self.assertEqual(recent.load(), {'dest': str(self.dest)})
|
||||
self.write(format=1, dest='relative/dir')
|
||||
self.assertEqual(recent.load(), {})
|
||||
|
||||
def test_stat_errors_dropped(self):
|
||||
self.installed()
|
||||
src = self.files()
|
||||
self.write(format=1, dest=str(self.dest), base=src['base'])
|
||||
for exc in (OSError('share gone'), ValueError('embedded null byte')):
|
||||
with unittest.mock.patch.object(Path, 'is_file', side_effect=exc):
|
||||
self.assertEqual(recent.load(), {})
|
||||
|
||||
def test_old_dest_only_format(self):
|
||||
self.installed()
|
||||
self.write(format=0, dest=str(self.dest))
|
||||
self.assertEqual(recent.load(), {'dest': str(self.dest)})
|
||||
|
||||
def test_garbage_file(self):
|
||||
self.file.parent.mkdir()
|
||||
for text in ('not json', '[]', '{"dest": 5}', '{"format": 1}'):
|
||||
self.file.write_text(text, encoding='utf-8')
|
||||
self.assertEqual(recent.load(), {}, text)
|
||||
|
||||
@unittest.skipIf(gui is None, 'no tkinter')
|
||||
def test_blocking_load_leaves_fields_empty(self):
|
||||
with unittest.mock.patch.object(recent, 'load', side_effect=lambda: time.sleep(5) or {'dest': 'x'}):
|
||||
start = time.monotonic()
|
||||
self.assertEqual(gui.load_recent(0.3), {})
|
||||
self.assertLess(time.monotonic() - start, 2)
|
||||
|
||||
@unittest.skipIf(gui is None, 'no tkinter')
|
||||
def test_fast_load_is_returned(self):
|
||||
with unittest.mock.patch.object(recent, 'load', return_value={'dest': 'x'}):
|
||||
self.assertEqual(gui.load_recent(), {'dest': 'x'})
|
||||
|
||||
|
||||
class FakeVar:
|
||||
def __init__(self, value=''):
|
||||
self.value = value
|
||||
|
||||
def get(self):
|
||||
return self.value
|
||||
|
||||
def set(self, value):
|
||||
self.value = value
|
||||
|
||||
|
||||
@unittest.skipIf(gui is None, 'no tkinter')
|
||||
class PrefillTest(unittest.TestCase):
|
||||
"""App.match_prefill on a stand-in object: no window needed."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.update = self.tmp / 'totk-1.4.3.nsp'
|
||||
self.update.write_bytes(b'x' * 10)
|
||||
self.base = self.tmp / 'totk.xci'
|
||||
self.base.write_bytes(b'y' * 20)
|
||||
self.app = SimpleNamespace(vars={n: FakeVar() for n in ('base', 'update')}, touched=set(),
|
||||
recorded={'base': str(self.base), 'update': str(self.update)})
|
||||
self.app.set_prefill = lambda name, value: self.app.vars[name].set(value)
|
||||
for name, value in self.app.recorded.items():
|
||||
self.app.vars[name].set(value)
|
||||
|
||||
def state(self, update_name, update_size, base_size=20):
|
||||
return {'inputs': {'base': {'name': 'totk.xci', 'size': base_size},
|
||||
'update': {'name': update_name, 'size': update_size}}}
|
||||
|
||||
def shown(self):
|
||||
return {n: v.get() for n, v in self.app.vars.items()}
|
||||
|
||||
def test_same_install_keeps(self):
|
||||
gui.App.match_prefill(self.app, self.state('totk-1.4.3.nsp', 10))
|
||||
self.assertEqual(self.shown(), {'base': str(self.base), 'update': str(self.update)})
|
||||
|
||||
def test_other_install_clears_and_restores(self):
|
||||
gui.App.match_prefill(self.app, self.state('totk-1.2.1.nsp', 10))
|
||||
self.assertEqual(self.shown(), {'base': str(self.base), 'update': ''})
|
||||
gui.App.match_prefill(self.app, self.state('totk-1.4.3.nsp', 99)) # same name, other size
|
||||
self.assertEqual(self.shown()['update'], '')
|
||||
gui.App.match_prefill(self.app, self.state('totk-1.4.3.nsp', 10))
|
||||
self.assertEqual(self.shown()['update'], str(self.update))
|
||||
|
||||
def test_install_without_recorded_inputs_clears(self):
|
||||
gui.App.match_prefill(self.app, {'inputs': {}})
|
||||
self.assertEqual(self.shown(), {'base': '', 'update': ''})
|
||||
|
||||
def test_user_picked_field_survives(self):
|
||||
self.app.vars['update'].set('C:/mine/other.nsp')
|
||||
self.app.touched.add('update')
|
||||
gui.App.match_prefill(self.app, self.state('totk-1.2.1.nsp', 10, base_size=1))
|
||||
self.assertEqual(self.shown(), {'base': '', 'update': 'C:/mine/other.nsp'})
|
||||
|
||||
def test_fresh_install_keeps(self):
|
||||
gui.App.match_prefill(self.app, None)
|
||||
self.assertEqual(self.shown(), {'base': str(self.base), 'update': str(self.update)})
|
||||
|
||||
def test_vanished_file_clears(self):
|
||||
self.update.unlink()
|
||||
gui.App.match_prefill(self.app, self.state('totk-1.4.3.nsp', 10))
|
||||
self.assertEqual(self.shown()['update'], '')
|
||||
|
||||
|
||||
class SmokeTest(unittest.TestCase):
|
||||
def test_every_screen_in_both_languages(self):
|
||||
try:
|
||||
|
||||
@@ -315,6 +315,75 @@ class LiftKeyTest(unittest.TestCase):
|
||||
self.assertEqual(self.key(), before)
|
||||
|
||||
|
||||
class LiftRunTest(unittest.TestCase):
|
||||
"""Lift.run with fake tools: the ExeFS patch stage writes the status and data files, the lift lists the bytes it
|
||||
read, and the data writes are checked against that list after it; a failed lift or check drops both files."""
|
||||
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
base = Path(tmp.name)
|
||||
self.dest, temp = base / 'install', base / 'temp'
|
||||
self.dest.mkdir()
|
||||
temp.mkdir()
|
||||
layout = SimpleNamespace(recomp=ROOT / 'recomp', tools=ROOT / 'tools',
|
||||
patches=lambda version: base / 'patches-v143.txt')
|
||||
self.ctx = SimpleNamespace(dest=self.dest, temp=temp, layout=layout, keys={'lift': 'k'},
|
||||
state={'version': '1.4.3'}, reporter=Reporter(out=io.StringIO()))
|
||||
self.addCleanup(self.ctx.reporter.close)
|
||||
mod = self.dest / 'mods' / 'SQ' / 'exefs'
|
||||
mod.mkdir(parents=True)
|
||||
(mod / '277178B7DBA1B6D4.ips').write_bytes(b'IPS32EEOF')
|
||||
self.calls, self.fail = [], set()
|
||||
saved = steps.run_tool
|
||||
self.addCleanup(lambda: setattr(steps, 'run_tool', saved))
|
||||
steps.run_tool = self.run_tool
|
||||
|
||||
def run_tool(self, ctx, step, script, args, parse=None, cwd=None):
|
||||
args = [str(a) for a in args]
|
||||
name = Path(script).name + (' --folded' if '--folded' in args else '')
|
||||
self.calls.append((name, args))
|
||||
log = ctx.temp / 'tool.log'
|
||||
log.write_text('')
|
||||
if name in self.fail:
|
||||
return 1, log
|
||||
if name == 'exefs_patches.py':
|
||||
for flag in ('--status', '--data', '--out'):
|
||||
p = Path(args[args.index(flag) + 1])
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(flag)
|
||||
elif name == 'gen.py':
|
||||
out = Path(args[args.index('--out') + 1]) / 'main'
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
(out / 'folded.txt').write_text('# test\n')
|
||||
return 0, log
|
||||
|
||||
def outputs(self):
|
||||
return [p.is_file() for p in (steps.exefs_status(self.ctx), steps.exefs_data(self.ctx))]
|
||||
|
||||
def test_data_file_and_the_check_after_the_lift(self):
|
||||
steps.Lift().run(self.ctx)
|
||||
self.assertEqual([c[0] for c in self.calls], ['exefs_patches.py', 'gen.py', 'exefs_patches.py --folded'])
|
||||
build = self.calls[0][1]
|
||||
self.assertEqual(build[build.index('--data') + 1], str(steps.exefs_data(self.ctx)))
|
||||
check = self.calls[2][1]
|
||||
self.assertEqual(check[check.index('--folded') + 1], str(self.ctx.temp / 'gen' / 'main' / 'folded.txt'))
|
||||
self.assertEqual(check[check.index('--status') + 1], str(steps.exefs_status(self.ctx)))
|
||||
self.assertEqual(self.outputs(), [True, True])
|
||||
|
||||
def test_failed_lift_drops_the_data_file(self):
|
||||
self.fail = {'gen.py'}
|
||||
with self.assertRaises(steps.InstallError):
|
||||
steps.Lift().run(self.ctx)
|
||||
self.assertEqual(self.outputs(), [False, False])
|
||||
|
||||
def test_failed_check_drops_both_files(self):
|
||||
self.fail = {'exefs_patches.py --folded'}
|
||||
steps.Lift().run(self.ctx)
|
||||
self.assertEqual(self.outputs(), [False, False])
|
||||
self.assertIn('warn.exefs_skipped', self.ctx.reporter.out.getvalue())
|
||||
|
||||
|
||||
class IniTest(unittest.TestCase):
|
||||
EXAMPLE = '[display]\n# vk_present = mailbox\n\n[language]\n# locale = en-US\n# lang = en-US\n# x = 1\n'
|
||||
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
"""Check the direct camera hook's register contract and raw-count routing without game data.
|
||||
|
||||
Usage: python tests/mouse_direct_check.py --cc <clang.exe>
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HARNESS = r'''
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include "mouse_direct.c"
|
||||
const int rt_game_version = TEST_GAME_VERSION;
|
||||
static int raw_x, raw_y, takes, captured=1, foreground=1, allowed=1, mode=1, patched=1, selecting;
|
||||
static float ratio=0.5f;
|
||||
static uint64_t now=1000000000;
|
||||
static GuestFn hook, activity_hook, begin_hook, angles_hook, hand_hook, load_hook;
|
||||
const char *cfg_get(const char *key) { (void)key; return mode ? "direct" : "stick"; }
|
||||
const char *cfg_value(const char *key) { return cfg_get(key); }
|
||||
double cfg_double(const char *key) { (void)key; return 1; }
|
||||
int rt_patch_set(const char *s) { return strcmp(s,"mouse_direct") ? -1 : 0; } // no fov / aspect set: 16:9, 50 deg
|
||||
int rt_patch_enabled(int s) { (void)s; return patched; }
|
||||
int ikb_captured(void) { return captured; }
|
||||
int ikb_foreground(void) { return foreground; }
|
||||
int input_cursor_allowed(void) { return allowed; }
|
||||
int input_direct_mouse_selector_active(void) { return selecting; }
|
||||
void ikb_mouse_take(int32_t *x, int32_t *y) { ++takes; *x=raw_x; *y=raw_y; raw_x=raw_y=0; }
|
||||
void input_direct_mouse_sample(int enabled, int32_t *x, int32_t *y) {
|
||||
*x=*y=0;
|
||||
if(enabled && captured && foreground && allowed) ikb_mouse_take(x,y);
|
||||
}
|
||||
uint64_t plat_now_ns(void) { return now; }
|
||||
int frame_step_realtime(void) { return 1; }
|
||||
double frame_step_limit_hz(void) { return 60; }
|
||||
void *rt_patch_page(void) { return ∶ }
|
||||
void rt_register_host(uint64_t addr, GuestFn fn) {
|
||||
if(addr==HOST_MOUSE_DIRECT) hook=fn; else if(addr==HOST_MOUSE_ACTIVITY) activity_hook=fn;
|
||||
else if(addr==HOST_MOUSE_ORBIT_BEGIN) begin_hook=fn; else if(addr==HOST_MOUSE_ORBIT_ANGLES) angles_hook=fn;
|
||||
else if(addr==HOST_MOUSE_HAND) hand_hook=fn; else if(addr==HOST_MOUSE_LOAD) load_hook=fn; else abort();
|
||||
}
|
||||
void plat_mutex_lock(PlatMutex *m) { (void)m; }
|
||||
void plat_mutex_unlock(PlatMutex *m) { (void)m; }
|
||||
void rt_log(const char *fmt, ...) { (void)fmt; }
|
||||
float cam_speed_applied(void) { return 1; }
|
||||
uint64_t input_buttons_last(void) { return 0; }
|
||||
int mem_guest_readable(uint64_t a, uint64_t n) { (void)a; (void)n; return 0; } // no player: on-foot gains
|
||||
static float scalar(__uint128_t v) { float f; memcpy(&f,&v,4); return f; }
|
||||
#if TEST_GAME_VERSION == 143
|
||||
int main(void) {
|
||||
assert(hook && activity_hook);
|
||||
Cpu c, expected;
|
||||
unsigned char target[0x500] = {0};
|
||||
memset(&c, 0x5a, sizeof c);
|
||||
c.x[20]=(uintptr_t)target; c.x[21]=0; c.v[9]=c.v[10]=0;
|
||||
expected=c;
|
||||
raw_x=100; raw_y=-50;
|
||||
hook(&c);
|
||||
// x is STICK_PER_COUNT a count over the frame ratio; y PITCH_GAIN times that, for the cameras' linear pitch
|
||||
assert(fabsf(scalar(c.v[9])-100*STICK_PER_COUNT/ratio)<1e-5f);
|
||||
assert(fabsf(scalar(c.v[10])-PITCH_GAIN*50*STICK_PER_COUNT/ratio)<1e-5f);
|
||||
float stored; memcpy(&stored,target+0x420,4); assert(stored==scalar(c.v[9]));
|
||||
expected.v[9]=c.v[9]; expected.v[10]=c.v[10]; assert(!memcmp(&expected,&c,sizeof c));
|
||||
assert(input_direct_mouse_active());
|
||||
// The next read sees no repeated movement. Gamepad/keyboard input is preserved.
|
||||
expected=c; hook(&c); assert(!memcmp(&expected,&c,sizeof c));
|
||||
// Equal mouse distance turns the camera equally at 30, 60 and 144 Hz, yaw by x and pitch linearly by y, and as
|
||||
// many degrees a count on both axes; there is no unit-circle clamp.
|
||||
float rates[]={30,60,144};
|
||||
for(int i=0;i<3;i++) {
|
||||
ratio=30/rates[i]; raw_x=10000; raw_y=10000; c.v[9]=c.v[10]=0; hook(&c);
|
||||
float y=scalar(c.v[10]);
|
||||
assert(fabsf(scalar(c.v[9])*ratio-10000*STICK_PER_COUNT)<0.001f);
|
||||
assert(fabsf(y*ratio/PITCH_GAIN+10000*STICK_PER_COUNT)<0.001f);
|
||||
}
|
||||
// The ability camera's offset mode turns the stored stick at its own scales: they are undone.
|
||||
{
|
||||
float x=0, y=0; mouse_stick_add(100,-50,0.04f,1,&x,&y);
|
||||
assert(fabsf(x-100*0.04f*ABILITY_GAIN_X)<1e-5f && fabsf(y-50*0.04f*ABILITY_GAIN_Y)<1e-5f);
|
||||
x=y=0; mouse_stick_add(100,-50,0.04f,0,&x,&y);
|
||||
assert(fabsf(x-4)<1e-5f && fabsf(y-PITCH_GAIN*2)<1e-5f);
|
||||
}
|
||||
// One count is not discarded by a dead zone.
|
||||
ratio=0.5f; raw_x=1; raw_y=0; c.v[9]=c.v[10]=0; hook(&c); assert(scalar(c.v[9])>0);
|
||||
uint64_t stack[4]={0,0x123456789abcdef0ull,0x456789abcdef0123ull,0}; c.sp=(uintptr_t)stack;
|
||||
// The flags are in w8 at the site: the game's next instruction stores w8 to +0x418, so memory is not touched.
|
||||
uint32_t inactive=0x28, mem=0x77; memcpy(target+0x418,&mem,4);
|
||||
c.x[8]=inactive; activity_hook(&c);
|
||||
assert((uint32_t)c.x[8]==0x11);
|
||||
uint32_t after; memcpy(&after,target+0x418,4); assert(after==mem);
|
||||
assert(c.v[9]==stack[1] && c.v[8]==stack[2]);
|
||||
// A controller-only update keeps the game's activity bits.
|
||||
c.x[8]=inactive; activity_hook(&c); assert((uint32_t)c.x[8]==inactive);
|
||||
// Disabled game camera input leaves raw counts for the menu's legacy mouse-to-stick path.
|
||||
c.x[21]=1; raw_x=42; int before=takes; hook(&c);
|
||||
assert(takes==before && raw_x==42 && !input_direct_mouse_active()); c.x[21]=0;
|
||||
// Focus loss and host menus do not move the camera.
|
||||
captured=0; before=takes; hook(&c); assert(takes==before); captured=1;
|
||||
foreground=0; hook(&c); assert(takes==before); foreground=1;
|
||||
allowed=0; hook(&c); assert(takes==before); allowed=1;
|
||||
mode=0; hook(&c); assert(takes==before && !input_direct_mouse_active()); mode=1;
|
||||
patched=0; hook(&c); assert(takes==before && !input_direct_mouse_active()); patched=1;
|
||||
now+=150000001; assert(!input_direct_mouse_active());
|
||||
// Orbit input removes the mouse from the stick path and writes equal degrees per count at the final angles.
|
||||
unsigned char camera[0x300]={0}, frame[0x100]={0}, hand[0x300]={0};
|
||||
uint64_t input_address=(uintptr_t)target; memcpy(camera+0x20,&input_address,8);
|
||||
for(int rate=0;rate<3;rate++) {
|
||||
memset(orbit_seen,0,sizeof orbit_seen);
|
||||
ratio=30/rates[rate]; c.x[20]=(uintptr_t)target; c.x[21]=0; c.v[9]=c.v[10]=0;
|
||||
raw_x=100; raw_y=-50; hook(&c);
|
||||
guest_float_store((uintptr_t)camera+0x108,30); guest_float_store((uintptr_t)camera+0x120,40);
|
||||
guest_float_store((uintptr_t)frame+0x44,30); guest_float_store((uintptr_t)frame+0x48,40);
|
||||
c.x[0]=(uintptr_t)camera; begin_hook(&c);
|
||||
assert(guest_float((uintptr_t)target+0x420)==0 && guest_float((uintptr_t)target+0x424)==0);
|
||||
c.x[20]=(uintptr_t)camera; c.sp=(uintptr_t)frame; angles_hook(&c);
|
||||
assert(fabsf(guest_float((uintptr_t)frame+0x44)-(30-50*DEG_PER_COUNT))<0.0001f);
|
||||
assert(fabsf(guest_float((uintptr_t)frame+0x48)-(40-100*DEG_PER_COUNT))<0.0001f);
|
||||
assert(fabsf(guest_float((uintptr_t)camera+0x15c)-(30-50*DEG_PER_COUNT))<0.0001f);
|
||||
c.x[0]=(uintptr_t)camera; begin_hook(&c); angles_hook(&c);
|
||||
assert(fabsf(guest_float((uintptr_t)frame+0x48)-(40-100*DEG_PER_COUNT))<0.0001f); // one sample cannot repeat next update
|
||||
// The next input frame is stationary and the game's parameter update rewrites the old default target.
|
||||
// Restoring the mouse's desired target makes the camera filter stay at the new pitch, not spring to zero.
|
||||
c.x[20]=(uintptr_t)target; c.v[9]=c.v[10]=0; raw_x=raw_y=0; hook(&c);
|
||||
guest_float_store((uintptr_t)camera+0x15c,0);
|
||||
c.x[0]=(uintptr_t)camera; begin_hook(&c);
|
||||
float current=guest_float((uintptr_t)camera+0x108), goal=guest_float((uintptr_t)camera+0x15c);
|
||||
assert(fabsf(goal-(30-50*DEG_PER_COUNT))<0.0001f && fabsf((current+goal)*0.5f-(30-50*DEG_PER_COUNT))<0.0001f);
|
||||
// A later game filter overwrites the pitch after the begin hook. Neither a stationary frame nor the next
|
||||
// tiny mouse delta may integrate from that recentered result.
|
||||
guest_float_store((uintptr_t)camera+0x108,0);
|
||||
guest_float_store((uintptr_t)camera+0x11c,5);
|
||||
guest_float_store((uintptr_t)camera+0x10c,20);
|
||||
guest_float_store((uintptr_t)camera+0x88,0.3f);
|
||||
guest_float_store((uintptr_t)frame+0x44,0);
|
||||
c.x[20]=(uintptr_t)camera; angles_hook(&c);
|
||||
assert(fabsf(guest_float((uintptr_t)frame+0x44)-(30-50*DEG_PER_COUNT))<0.0001f);
|
||||
assert(fabsf(guest_float((uintptr_t)camera+0x15c)-((30-50*DEG_PER_COUNT)-19))<0.0001f);
|
||||
assert(fabsf(guest_float((uintptr_t)camera+0x108)-((30-50*DEG_PER_COUNT)-14))<0.0001f);
|
||||
for(int i=0;i<100;i++) {
|
||||
guest_float_store((uintptr_t)frame+0x44,0); angles_hook(&c);
|
||||
assert(fabsf(guest_float((uintptr_t)camera+0x108)-((30-50*DEG_PER_COUNT)-14))<0.0001f);
|
||||
}
|
||||
c.x[20]=(uintptr_t)target; c.v[9]=c.v[10]=0; raw_y=1; hook(&c);
|
||||
c.x[0]=(uintptr_t)camera; begin_hook(&c);
|
||||
guest_float_store((uintptr_t)frame+0x44,0);
|
||||
c.x[20]=(uintptr_t)camera; angles_hook(&c);
|
||||
assert(fabsf(guest_float((uintptr_t)frame+0x44)-((30-50*DEG_PER_COUNT)+DEG_PER_COUNT))<0.0001f);
|
||||
// An explicit controller recenter still hands pitch back to the game.
|
||||
uint64_t recenter=0x20; memcpy(target+0x148,&recenter,8);
|
||||
c.x[20]=(uintptr_t)target; c.v[9]=c.v[10]=0; hook(&c);
|
||||
c.x[0]=(uintptr_t)camera; begin_hook(&c);
|
||||
guest_float_store((uintptr_t)frame+0x44,5);
|
||||
c.x[20]=(uintptr_t)camera; angles_hook(&c);
|
||||
assert(guest_float((uintptr_t)frame+0x44)==5);
|
||||
memset(target+0x148,0,8);
|
||||
// When the game lets go (the recenter or an aim ends), the mouse holds the pitch the camera has then: neither its
|
||||
// target from before nor the game's default target pulls the camera back.
|
||||
guest_float_store((uintptr_t)camera+0x108,3); // the game left the pitch at 3 + the offset 14
|
||||
c.x[20]=(uintptr_t)target; c.v[9]=c.v[10]=0; hook(&c);
|
||||
c.x[0]=(uintptr_t)camera; begin_hook(&c);
|
||||
assert(fabsf(guest_float((uintptr_t)camera+0x15c)-(17-14-5))<0.0001f);
|
||||
guest_float_store((uintptr_t)frame+0x44,0);
|
||||
c.x[20]=(uintptr_t)camera; angles_hook(&c);
|
||||
assert(fabsf(guest_float((uintptr_t)frame+0x44)-17)<0.0001f);
|
||||
guest_float_store((uintptr_t)camera+0x15c,0); // the game's parameter update writes its default target
|
||||
c.x[20]=(uintptr_t)target; hook(&c);
|
||||
c.x[0]=(uintptr_t)camera; begin_hook(&c);
|
||||
assert(fabsf(guest_float((uintptr_t)camera+0x15c)-(17-14-5))<0.0001f);
|
||||
memset(camera+0x88,0,4); memset(camera+0x10c,0,4); memset(camera+0x11c,0,4);
|
||||
}
|
||||
// A gamepad-only step (no mouse motion, no pitch hold) leaves the camera input and the angles as the game made them.
|
||||
memset(orbit_seen,0,sizeof orbit_seen);
|
||||
c.x[20]=(uintptr_t)target; c.x[21]=0; raw_x=raw_y=0; c.v[9]=c.v[10]=0;
|
||||
{ float px=0.25f, py=-0.5f; memcpy(&c.v[9],&px,4); memcpy(&c.v[10],&py,4); }
|
||||
hook(&c);
|
||||
guest_float_store((uintptr_t)target+0x420,0.25f); guest_float_store((uintptr_t)target+0x424,0.75f);
|
||||
guest_float_store((uintptr_t)frame+0x44,11); guest_float_store((uintptr_t)frame+0x48,22);
|
||||
guest_float_store((uintptr_t)camera+0x15c,3);
|
||||
c.x[0]=(uintptr_t)camera; begin_hook(&c);
|
||||
assert(guest_float((uintptr_t)target+0x420)==0.25f && guest_float((uintptr_t)target+0x424)==0.75f);
|
||||
c.x[20]=(uintptr_t)camera; c.sp=(uintptr_t)frame; angles_hook(&c);
|
||||
assert(guest_float((uintptr_t)frame+0x44)==11 && guest_float((uintptr_t)frame+0x48)==22);
|
||||
assert(guest_float((uintptr_t)camera+0x15c)==3);
|
||||
guest_float_store((uintptr_t)camera+0x108,30); guest_float_store((uintptr_t)camera+0x120,40);
|
||||
for(int count=0;count<100;count++) {
|
||||
c.x[20]=(uintptr_t)target; c.x[21]=0; c.v[9]=c.v[10]=0; raw_x=1; raw_y=0; hook(&c);
|
||||
c.x[0]=(uintptr_t)camera; begin_hook(&c); c.x[20]=(uintptr_t)camera;
|
||||
guest_float_store((uintptr_t)frame+0x44,guest_float((uintptr_t)camera+0x108));
|
||||
guest_float_store((uintptr_t)frame+0x48,guest_float((uintptr_t)camera+0x120));
|
||||
angles_hook(&c);
|
||||
}
|
||||
assert(fabsf(guest_float((uintptr_t)camera+0x120)-(40-100*DEG_PER_COUNT))<0.001f);
|
||||
// A held object receives mouse deltas after its own curves at HAND_PER_COUNT a count, at most HAND_MAX full
|
||||
// tilts a step, once per sample, including with camera input off.
|
||||
c.x[20]=(uintptr_t)target; c.v[9]=c.v[10]=0; raw_x=200; raw_y=50; ratio=0.5f; hook(&c);
|
||||
c.x[19]=(uintptr_t)hand; c.x[8]=0x12345678; c.v[15]=0;
|
||||
guest_float_store((uintptr_t)frame+0x60,0); hand_hook(&c);
|
||||
assert(200*HAND_PER_COUNT/ratio>HAND_MAX && fabsf(guest_float((uintptr_t)frame+0x60)-HAND_MAX)<0.00001f);
|
||||
assert(fabsf(scalar(c.v[15])+50*HAND_PER_COUNT/ratio)<0.00001f);
|
||||
hand_hook(&c); assert(fabsf(scalar(c.v[15])+50*HAND_PER_COUNT/ratio)<0.00001f);
|
||||
c.x[21]=1; raw_x=5; raw_y=6; hook(&c); assert(raw_x==0 && raw_y==0 && input_direct_mouse_active());
|
||||
// A radial selector retains its virtual cursor between mouse packets, including repeated HID polls.
|
||||
float cursor[2]={0}; int32_t selector[2], held_selector[2];
|
||||
input_direct_mouse_radial(100,50,1,cursor,selector);
|
||||
held_selector[0]=selector[0]; held_selector[1]=selector[1];
|
||||
for(int i=0;i<100;i++) {
|
||||
input_direct_mouse_radial(0,0,1,cursor,selector);
|
||||
assert(selector[0]==held_selector[0] && selector[1]==held_selector[1]);
|
||||
}
|
||||
input_direct_mouse_radial(-100,-50,1,cursor,selector); assert(!selector[0] && !selector[1]);
|
||||
input_direct_mouse_radial(100000,100000,1,cursor,selector);
|
||||
assert((int64_t)selector[0]*selector[0]+(int64_t)selector[1]*selector[1]<=32768ll*32768);
|
||||
// Quick lists must not latch a wheel direction or automatically drain a large flick after the mouse stops.
|
||||
InputMouseRow row={0}; uint64_t row_time=0;
|
||||
input_direct_mouse_row(10000,1,row_time,&row,selector); assert(selector[0]==32767 && !selector[1]);
|
||||
input_direct_mouse_row(0,1,row_time+10000000,&row,selector); assert(selector[0]==32767);
|
||||
for(int i=1;i<=100;i++) {
|
||||
input_direct_mouse_row(0,1,row_time+i*100000000ull,&row,selector); assert(!selector[0] && !selector[1]);
|
||||
}
|
||||
// Repeated HID polls do not rearm a pulse; a frame that skips its deadline must still observe neutral first.
|
||||
row=(InputMouseRow){0};
|
||||
for(int i=0;i<31;i++) { input_direct_mouse_row(1,1,0,&row,selector); assert(!selector[0]); }
|
||||
input_direct_mouse_row(1,1,0,&row,selector); assert(selector[0]==32767);
|
||||
input_direct_mouse_row(1000,1,100000000,&row,selector); assert(!selector[0]);
|
||||
input_direct_mouse_row(1000,1,110000000,&row,selector); assert(!selector[0]);
|
||||
input_direct_mouse_row(1000,1,140000000,&row,selector); assert(selector[0]==32767);
|
||||
input_direct_mouse_row(-1000,1,141000000,&row,selector); assert(!selector[0]);
|
||||
input_direct_mouse_row(-1,1,180000000,&row,selector); assert(selector[0]==-32767);
|
||||
selecting=1; c.x[20]=(uintptr_t)target; raw_x=42; raw_y=0; before=takes; hook(&c);
|
||||
assert(takes==before && raw_x==42); selecting=0; raw_x=0;
|
||||
puts("direct mouse: register contract, routing, linearity, frame ratio and capture checks passed");
|
||||
puts("direct mouse: final orbit angles, single-count motion, equal axes and held-object routing passed");
|
||||
puts("direct mouse: persistent pitch target, radial cursor hold and gamepad-only steps untouched passed");
|
||||
puts("direct mouse: final-pitch recenter rejection, explicit handoff and bounded quick-list steps passed");
|
||||
// A shake: two direction changes of swings of at least 10 degrees within 0.35 s, then the stick follows the mouse
|
||||
// side by side and lets go when it rests; nothing plays on after the mouse
|
||||
{
|
||||
MouseShake s={0}; uint64_t t=0;
|
||||
assert(mouse_shake_step(&s,12,t+=100000000ull)==0);
|
||||
assert(mouse_shake_step(&s,-12,t+=100000000ull)==0); // one change: not yet a shake
|
||||
assert(mouse_shake_step(&s,12,t+=100000000ull)==1); // the second: the stick takes the mouse's side
|
||||
assert(mouse_shake_step(&s,-12,t+=100000000ull)==-1); // and follows it
|
||||
assert(mouse_shake_step(&s,0,t+=30000000ull)==-1); // a side is held two frames, so it is read
|
||||
assert(mouse_shake_step(&s,0,t+=40000000ull)==0); // the mouse rests: neutral, no swing of its own
|
||||
for(int i=0;i<10;i++) assert(mouse_shake_step(&s,0,t+=50000000ull)==0);
|
||||
assert(!s.turns); // and the shake is over
|
||||
// Fast reversals: a side never lasts less than two frames
|
||||
s=(MouseShake){0}; t=0; int side=0, flips=0; uint64_t last=0, shortest=~0ull;
|
||||
for(int i=0;i<20;i++) {
|
||||
int now_side=mouse_shake_step(&s,(i&1)?-12.f:12.f,t+=30000000ull);
|
||||
if(now_side!=side) { if(side&&now_side&&last) shortest=t-last<shortest?t-last:shortest; side=now_side; last=t; flips++; }
|
||||
}
|
||||
assert(flips>3 && shortest>=SHAKE_SIDE_NS);
|
||||
// A swing made of several small steps counts as one
|
||||
s=(MouseShake){0}; t=0; int got=0;
|
||||
for(int i=0;i<8;i++) got|=mouse_shake_step(&s,(i/2&1)?-6.f:6.f,t+=50000000ull)!=0;
|
||||
assert(got);
|
||||
s=(MouseShake){0}; t=0; got=0; // the same swings 0.4 s apart: a held object turned back and forth
|
||||
for(int i=0;i<6;i++) got|=mouse_shake_step(&s,(i&1)?-12.f:12.f,t+=400000000ull)!=0;
|
||||
assert(!got);
|
||||
s=(MouseShake){0}; t=0; got=0; // swings under 10 degrees
|
||||
for(int i=0;i<8;i++) got|=mouse_shake_step(&s,(i&1)?-4.f:4.f,t+=50000000ull)!=0;
|
||||
assert(!got);
|
||||
}
|
||||
// The aiming, scope, ability and horse cameras: the stick loads give the gamepad's part, the angle loads the
|
||||
// mouse's degrees, once per sample
|
||||
{
|
||||
assert(load_hook);
|
||||
unsigned char input[0x500]={0}, cam[0x200]={0}; float frame[0x20]={0};
|
||||
float pad_x=0.25f, pad_y=-0.5f;
|
||||
#define LOAD(site,reg) (c.x[30]=MOD_MAIN_BASE+(site)+4, load_hook(&c), scalar(c.v[reg]))
|
||||
#define SAMPLE(dx,dy) do { c.x[20]=(uintptr_t)input; c.x[21]=0; c.v[9]=0; c.v[10]=0; \
|
||||
memcpy(&c.v[9],&pad_x,4); memcpy(&c.v[10],&pad_y,4); raw_x=(dx); raw_y=(dy); hook(&c); \
|
||||
guest_float_store((uintptr_t)input+0x424,scalar(c.v[10])); } while(0)
|
||||
ratio=0.5f;
|
||||
SAMPLE(100,-50);
|
||||
float stick_x=guest_float((uintptr_t)input+0x420), stick_y=guest_float((uintptr_t)input+0x424);
|
||||
assert(stick_x!=pad_x && stick_y!=pad_y); // the stick the game stored has the mouse in it
|
||||
c.x[19]=(uintptr_t)cam; c.x[22]=(uintptr_t)input; c.x[29]=(uintptr_t)(frame+0x18);
|
||||
guest_float_store((uintptr_t)cam+0x15c,10); frame[0x18-0x12]=40; // [x29-0x48]
|
||||
// Aiming pitch: stick y is the pad's, the pitch target base gets dy counts of degrees
|
||||
assert(LOAD(0x23beb94,1)==pad_y);
|
||||
assert(fabsf(LOAD(0x23bebc8,1)-(10-50*DEG_PER_COUNT))<1e-5f);
|
||||
assert(LOAD(0x23bebc8,1)==10); // a base load with no stick load before it is the game's own
|
||||
assert(LOAD(0x23beb94,1)==pad_y && LOAD(0x23bebc8,1)==10); // the same sample turns the camera once
|
||||
// Aiming yaw: the base yaw on the frame gets dx counts
|
||||
assert(LOAD(0x23bee2c,1)==pad_x);
|
||||
assert(fabsf(LOAD(0x23beeec,1)-(40-100*DEG_PER_COUNT))<1e-5f);
|
||||
// A step without mouse motion keeps every load as the game made it: a stick value something else changed
|
||||
// after the input update stays, and so does a -0 base angle (bit for bit)
|
||||
{
|
||||
SAMPLE(0,0);
|
||||
guest_float_store((uintptr_t)input+0x420,pad_x+0.125f);
|
||||
assert(LOAD(0x23bee2c,1)==pad_x+0.125f);
|
||||
float neg_zero=-0.0f; memcpy(&frame[0x18-0x12],&neg_zero,4);
|
||||
float got=LOAD(0x23beeec,1); assert(!memcmp(&got,&neg_zero,4));
|
||||
frame[0x18-0x12]=40;
|
||||
}
|
||||
// Scope pitch near its lower limit: degrees up to the limit, the rest of the mouse's stick stays
|
||||
SAMPLE(0,-50);
|
||||
float mouse_y=guest_float((uintptr_t)input+0x424)-pad_y, want=-39-50*DEG_PER_COUNT;
|
||||
guest_float_store((uintptr_t)cam+0x160,-39); guest_float_store((uintptr_t)cam+0x1ac,-40);
|
||||
guest_float_store((uintptr_t)cam+0x1b0,40); c.x[23]=(uintptr_t)input;
|
||||
float y=LOAD(0x23c0a1c,11);
|
||||
assert(fabsf(y-(pad_y+mouse_y*((want+40)/(want+39))))<1e-5f);
|
||||
assert(fabsf(LOAD(0x23c0b00,0)-(-40))<1e-5f);
|
||||
// Inside the limits all of it is degrees
|
||||
SAMPLE(0,-50); guest_float_store((uintptr_t)cam+0x160,0);
|
||||
assert(LOAD(0x23c0a1c,11)==pad_y && fabsf(LOAD(0x23c0b00,0)+50*DEG_PER_COUNT)<1e-5f);
|
||||
// The ability camera's offset mode (input +0x15c bit 8) keeps the mouse in the stick and adds no degrees
|
||||
SAMPLE(100,0); uint32_t offset_mode=0x100; memcpy(input+0x15c,&offset_mode,4);
|
||||
assert(LOAD(0x23d2eec,8)==guest_float((uintptr_t)input+0x420));
|
||||
frame[0x18-0x14]=7; assert(LOAD(0x23d34cc,1)==7); // [x29-0x50]
|
||||
offset_mode=0; memcpy(input+0x15c,&offset_mode,4);
|
||||
// Horse: the input is x20 / x21; another camera's input or a stale sample is left alone
|
||||
SAMPLE(100,-50); c.x[20]=(uintptr_t)input; c.x[21]=(uintptr_t)input;
|
||||
guest_float_store((uintptr_t)cam+0x68,5); guest_float_store((uintptr_t)cam+0x64,6);
|
||||
assert(LOAD(0x23cb274,8)==pad_x && fabsf(LOAD(0x23cb2a0,1)-(5-100*DEG_PER_COUNT))<1e-5f);
|
||||
assert(LOAD(0x23cb3f8,8)==pad_y && fabsf(LOAD(0x23cb424,1)-(6-50*DEG_PER_COUNT))<1e-5f);
|
||||
SAMPLE(100,-50); unsigned char other[0x500]; memcpy(other,input,sizeof other); c.x[20]=(uintptr_t)other;
|
||||
assert(LOAD(0x23cb274,8)==guest_float((uintptr_t)other+0x420) && LOAD(0x23cb2a0,1)==5);
|
||||
c.x[20]=(uintptr_t)input; now+=150000000ull;
|
||||
assert(LOAD(0x23cb274,8)==guest_float((uintptr_t)input+0x420) && LOAD(0x23cb2a0,1)==5);
|
||||
// The ability camera's offset mode (Recall's cursor): before its scale was read the mouse stays in the stick;
|
||||
// then the offset moves so that the aim, o * P + atan(o * tan(half view angle)), moves by the mouse's degrees
|
||||
now+=1; SAMPLE(100,-50); c.x[23]=(uintptr_t)input; offset_mode=0x100; memcpy(input+0x15c,&offset_mode,4);
|
||||
guest_float_store((uintptr_t)cam+0x1d8,1); guest_float_store((uintptr_t)cam+0x1dc,0);
|
||||
assert(LOAD(0x23d2f30,8)==guest_float((uintptr_t)input+0x420) && LOAD(0x23d2f5c,2)==1);
|
||||
float scale_p[2]={2,4}; c.x[0]=(uintptr_t)scale_p;
|
||||
assert(LOAD(0x23d3210,0)==4);
|
||||
SAMPLE(100,-50); c.x[23]=(uintptr_t)input;
|
||||
{
|
||||
float ty=tanf(25*RECALL_PI/180), tx=ty*16/9, k=180/RECALL_PI;
|
||||
#define AIM(o,r,t) ((o)*(r)+atanf((o)*(t))*k)
|
||||
assert(LOAD(0x23d2f30,8)==pad_x);
|
||||
float ox=LOAD(0x23d2f5c,2);
|
||||
assert(fabsf(AIM(ox,2,tx)-AIM(1,2,tx)-100*DEG_PER_COUNT)<1e-3f);
|
||||
assert(LOAD(0x23d2f6c,8)==pad_y);
|
||||
float oy=LOAD(0x23d2f98,2);
|
||||
assert(fabsf(AIM(oy,4,ty)-50*DEG_PER_COUNT)<1e-3f);
|
||||
assert(ox>1 && ox-1<100*DEG_PER_COUNT/2 && oy>0 && oy<50*DEG_PER_COUNT/4); // slower than the camera alone
|
||||
#undef AIM
|
||||
}
|
||||
// outside the offset mode these sites are the game's
|
||||
offset_mode=0; memcpy(input+0x15c,&offset_mode,4); SAMPLE(100,-50); c.x[23]=(uintptr_t)input;
|
||||
assert(LOAD(0x23d2f30,8)==guest_float((uintptr_t)input+0x420) && LOAD(0x23d2f5c,2)==1);
|
||||
// The Purah Pad camera: pitch +0x50, yaw [sp+0x48]
|
||||
uint64_t spbuf[0x10]={0}; uint64_t old_sp=c.sp; c.sp=(uintptr_t)spbuf;
|
||||
SAMPLE(100,-50); c.x[21]=(uintptr_t)input;
|
||||
guest_float_store((uintptr_t)cam+0x50,3); guest_float_store((uintptr_t)spbuf+0x48,9);
|
||||
assert(LOAD(0x23c2890,13)==pad_y && fabsf(LOAD(0x23c28b4,1)-(3-50*DEG_PER_COUNT))<1e-5f);
|
||||
assert(LOAD(0x23c2934,13)==pad_x && fabsf(LOAD(0x23c2964,1)-(9-100*DEG_PER_COUNT))<1e-5f);
|
||||
c.sp=old_sp;
|
||||
// Lock-on: the stick gives the pad's part, both reads of the step's turn ([sp+0x20]) get the mouse's degrees
|
||||
{
|
||||
uint64_t lock_sp[0x10]={0}, keep_sp=c.sp; c.sp=(uintptr_t)lock_sp;
|
||||
SAMPLE(100,-50); c.x[24]=(uintptr_t)input; guest_float_store((uintptr_t)lock_sp+0x20,0.5f);
|
||||
assert(LOAD(0x23d756c,0)==guest_float((uintptr_t)input+0x420)); // the input test keeps the mouse
|
||||
assert(LOAD(0x23d7594,1)==pad_x);
|
||||
assert(fabsf(LOAD(0x23d75b4,0)-(0.5f-100*DEG_PER_COUNT))<1e-5f);
|
||||
assert(fabsf(LOAD(0x23d75c4,9)-(0.5f-100*DEG_PER_COUNT))<1e-5f);
|
||||
assert(LOAD(0x23d75c4,9)==0.5f); // once both reads took them, the degrees are spent
|
||||
assert(LOAD(0x23d7998,1)==pad_y);
|
||||
assert(fabsf(LOAD(0x23d79b8,0)-(0.5f-50*DEG_PER_COUNT))<1e-5f);
|
||||
assert(fabsf(LOAD(0x23d79c8,10)-(0.5f-50*DEG_PER_COUNT))<1e-5f);
|
||||
c.sp=keep_sp;
|
||||
}
|
||||
// The bow drawn while locked on: the pad's stick, the offsets +0x198 (pitch) and +0x19c (yaw) get the degrees
|
||||
SAMPLE(100,-50); c.x[21]=(uintptr_t)input;
|
||||
guest_float_store((uintptr_t)cam+0x198,2); guest_float_store((uintptr_t)cam+0x19c,3);
|
||||
assert(LOAD(0x23cecb4,9)==pad_y && fabsf(LOAD(0x23cecf8,1)-(2-50*DEG_PER_COUNT))<1e-5f);
|
||||
assert(LOAD(0x23ced0c,9)==pad_x && fabsf(LOAD(0x23ced4c,1)-(3-100*DEG_PER_COUNT))<1e-5f);
|
||||
assert(LOAD(0x23ced4c,1)==3); // the gyro path's join reads the offset with no stick load before it
|
||||
// Climbing (Tail): the pad's stick on both loads, yaw +0xc0 and pitch +0xb4 get the degrees
|
||||
SAMPLE(100,-50); c.x[20]=(uintptr_t)input;
|
||||
guest_float_store((uintptr_t)cam+0xc0,7); guest_float_store((uintptr_t)cam+0xb4,8);
|
||||
assert(LOAD(0x23d4b14,12)==pad_x && LOAD(0x23d4b1c,13)==pad_y);
|
||||
assert(fabsf(LOAD(0x23d4f34,0)-(7-100*DEG_PER_COUNT))<1e-5f && fabsf(LOAD(0x23d5028,1)-(8-50*DEG_PER_COUNT))<1e-5f);
|
||||
// Climbing an object: the patched mov has set x22 = x19 + 0x88; the pitch load writes no register but s1
|
||||
SAMPLE(100,-50); c.x[24]=(uintptr_t)input; c.x[22]=(uintptr_t)cam+0x88;
|
||||
guest_float_store((uintptr_t)cam+0x88,4); guest_float_store((uintptr_t)cam+0x90,5);
|
||||
assert(LOAD(0x23c3d64,1)==pad_y && fabsf(LOAD(0x23c3d70,1)-(4-50*DEG_PER_COUNT))<1e-5f);
|
||||
assert(c.x[22]==(uintptr_t)cam+0x88);
|
||||
assert(LOAD(0x23c3dd0,1)==pad_x && fabsf(LOAD(0x23c3dd8,1)-(5-100*DEG_PER_COUNT))<1e-5f);
|
||||
// Waterfall climbing: the yaw is loaded into w8 as bits; the height (stick y) stays the game's
|
||||
{
|
||||
float wf[0x20]={0}; uint64_t keep29=c.x[29]; c.x[29]=(uintptr_t)(wf+0x10);
|
||||
SAMPLE(100,-50); c.x[22]=(uintptr_t)input; wf[0x10-0x0d]=11; // [x29-0x34]
|
||||
assert(LOAD(0x23dced8,13)==pad_x);
|
||||
c.x[30]=MOD_MAIN_BASE+0x23dd070+4; c.x[8]=~0ull; load_hook(&c);
|
||||
float w; uint32_t wb=(uint32_t)c.x[8]; memcpy(&w,&wb,4);
|
||||
assert(c.x[8]>>32==0 && fabsf(w-(11-100*DEG_PER_COUNT))<1e-5f);
|
||||
assert(LOAD(0x23dd008,9)==guest_float((uintptr_t)input+0x424));
|
||||
assert(sample_watched==sample.generation && sample_watched_mode==MODE_WATERFALL);
|
||||
c.x[29]=keep29;
|
||||
}
|
||||
// Dialogue: the yaw stick load is also the input test, so with the pad's x at rest it gives a tiny value in the
|
||||
// mouse's direction (input, no turn) and the yaw gets the degrees
|
||||
{
|
||||
float keep_x=pad_x; pad_x=0;
|
||||
SAMPLE(100,-50); c.x[23]=(uintptr_t)input;
|
||||
guest_float_store((uintptr_t)cam+0x5c,1); guest_float_store((uintptr_t)cam+0x64,2);
|
||||
float tx=LOAD(0x23c54d4,11);
|
||||
assert(tx>0 && tx<1e-9f && fabsf(LOAD(0x23c5554,1)-(2-100*DEG_PER_COUNT))<1e-5f);
|
||||
assert(LOAD(0x23c542c,11)==pad_y && fabsf(LOAD(0x23c546c,1)-(1-50*DEG_PER_COUNT))<1e-5f);
|
||||
SAMPLE(0,-50); c.x[23]=(uintptr_t)input; assert(LOAD(0x23c54d4,11)==0); // no mouse x: the pad's 0
|
||||
pad_x=keep_x;
|
||||
}
|
||||
// Shield surfing: stick y keeps the mouse (other parameters follow it), the pitch +0xdc gets the degrees and the
|
||||
// pitch turn the game made from y is cut to the pad's share (the turn squares y)
|
||||
{
|
||||
uint64_t ssp[0x10]={0}, keep_sp=c.sp; c.sp=(uintptr_t)ssp;
|
||||
SAMPLE(100,-50); c.x[22]=(uintptr_t)input;
|
||||
float total=guest_float((uintptr_t)input+0x424);
|
||||
guest_float_store((uintptr_t)cam+0xdc,12); guest_float_store((uintptr_t)ssp+0x10,0.3f);
|
||||
guest_float_store((uintptr_t)ssp+0x3c,20);
|
||||
assert(LOAD(0x23d0100,1)==pad_x && LOAD(0x23d0104,0)==total);
|
||||
assert(fabsf(LOAD(0x23d02ac,0)-(12-50*DEG_PER_COUNT))<1e-5f);
|
||||
float cut=LOAD(0x23d0a34,0);
|
||||
assert(fabsf(cut-0.3f*(pad_y*fabsf(pad_y))/(total*fabsf(total)))<1e-6f);
|
||||
assert(LOAD(0x23d0a34,0)==0.3f); // once
|
||||
assert(fabsf(LOAD(0x23d087c,9)-(20-100*DEG_PER_COUNT))<1e-5f);
|
||||
c.sp=keep_sp;
|
||||
}
|
||||
// The stick mode leaves every load to the game
|
||||
mode=0; c.x[20]=(uintptr_t)input; assert(LOAD(0x23cb274,8)==guest_float((uintptr_t)input+0x420)); mode=1;
|
||||
#undef LOAD
|
||||
#undef SAMPLE
|
||||
}
|
||||
puts("direct mouse: aiming, scope, ability, Recall cursor, horse, Purah Pad, lock-on, bow lock-on, climbing, waterfall, dialogue and shield cameras in degrees passed");
|
||||
puts("direct mouse: one turn rate, linear pitch, ability gains and the hold after a handoff passed");
|
||||
puts("direct mouse: a mouse shake followed side by side and released at rest, not a slow turn nor small swings passed");
|
||||
}
|
||||
#else
|
||||
// 1.2.1: the same hooks with that version's registers and stack slots (MouseLayout layout_121) and its load sites
|
||||
int main(void) {
|
||||
assert(hook && activity_hook && begin_hook && angles_hook && hand_hook && load_hook);
|
||||
(void)takes;
|
||||
Cpu c, expected;
|
||||
unsigned char target[0x500]={0}, camera[0x300]={0}, hand[0x300]={0};
|
||||
uint64_t frame[0x20]={0};
|
||||
memset(&c,0x5a,sizeof c);
|
||||
// The camera input update keeps x in s11 (y in s10) and stores s11 at +0x420; s9 is not the update's
|
||||
c.x[20]=(uintptr_t)target; c.x[21]=0; c.v[11]=c.v[10]=0; expected=c;
|
||||
raw_x=100; raw_y=-50; ratio=0.5f; hook(&c);
|
||||
assert(fabsf(scalar(c.v[11])-100*STICK_PER_COUNT/ratio)<1e-5f);
|
||||
assert(fabsf(scalar(c.v[10])-PITCH_GAIN*50*STICK_PER_COUNT/ratio)<1e-5f);
|
||||
float stored; memcpy(&stored,target+0x420,4); assert(stored==scalar(c.v[11]));
|
||||
expected.v[11]=c.v[11]; expected.v[10]=c.v[10]; assert(!memcmp(&expected,&c,sizeof c));
|
||||
// The activity hook: flags in w8, the displaced ldp d9, d8, [sp, #0x10]
|
||||
uint64_t stack[4]={0,0,0x123456789abcdef0ull,0x456789abcdef0123ull}; c.sp=(uintptr_t)stack;
|
||||
c.x[8]=0x28; activity_hook(&c); assert((uint32_t)c.x[8]==0x11 && c.v[9]==stack[2] && c.v[8]==stack[3]);
|
||||
// The free orbit: the final pitch and yaw at [sp+0x3c] / [sp+0x40], the displaced ldr s13, [sp, #0x3c]
|
||||
uint64_t input_address=(uintptr_t)target; memcpy(camera+0x20,&input_address,8);
|
||||
c.x[20]=(uintptr_t)target; c.v[11]=c.v[10]=0; raw_x=100; raw_y=-50; hook(&c);
|
||||
guest_float_store((uintptr_t)camera+0x108,30); guest_float_store((uintptr_t)camera+0x120,40);
|
||||
guest_float_store((uintptr_t)frame+0x3c,30); guest_float_store((uintptr_t)frame+0x40,40);
|
||||
c.x[0]=(uintptr_t)camera; begin_hook(&c);
|
||||
assert(guest_float((uintptr_t)target+0x420)==0 && guest_float((uintptr_t)target+0x424)==0);
|
||||
c.x[20]=(uintptr_t)camera; c.sp=(uintptr_t)frame; angles_hook(&c);
|
||||
assert(fabsf(guest_float((uintptr_t)frame+0x3c)-(30-50*DEG_PER_COUNT))<0.0001f);
|
||||
assert(fabsf(guest_float((uintptr_t)frame+0x40)-(40-100*DEG_PER_COUNT))<0.0001f);
|
||||
assert(scalar(c.v[13])==guest_float((uintptr_t)frame+0x3c));
|
||||
// Ultrahand: x in s9, y in s15, the displaced str w9, [sp, #0x18]
|
||||
c.x[20]=(uintptr_t)target; c.v[11]=c.v[10]=0; raw_x=20; raw_y=10; hook(&c);
|
||||
c.x[19]=(uintptr_t)hand; c.x[9]=0x12345678; c.v[9]=0; c.v[15]=0; hand_hook(&c);
|
||||
assert(fabsf(scalar(c.v[9])-20*HAND_PER_COUNT/ratio)<1e-5f && fabsf(scalar(c.v[15])+10*HAND_PER_COUNT/ratio)<1e-5f);
|
||||
uint32_t w9; memcpy(&w9,(unsigned char *)frame+0x18,4); assert(w9==0x12345678);
|
||||
// Load sites
|
||||
{
|
||||
unsigned char input[0x500]={0}, cam[0x200]={0}; uint64_t lsp[0x10]={0}, fp[0x20]={0};
|
||||
float pad_x=0.25f, pad_y=-0.5f;
|
||||
#define LOAD(site,reg) (c.x[30]=MOD_MAIN_BASE+(site)+4, load_hook(&c), scalar(c.v[reg]))
|
||||
#define SAMPLE(dx,dy) do { c.x[20]=(uintptr_t)input; c.x[21]=0; c.v[11]=0; c.v[10]=0; \
|
||||
memcpy(&c.v[11],&pad_x,4); memcpy(&c.v[10],&pad_y,4); raw_x=(dx); raw_y=(dy); hook(&c); \
|
||||
guest_float_store((uintptr_t)input+0x424,scalar(c.v[10])); } while(0)
|
||||
c.x[19]=(uintptr_t)cam; c.sp=(uintptr_t)lsp; c.x[29]=(uintptr_t)(fp+0x10);
|
||||
// Aiming: the pitch target the helper computed (+0x15c + turn) is stored with the degrees, and s0 holds it
|
||||
SAMPLE(100,-50); c.x[22]=(uintptr_t)input;
|
||||
assert(LOAD(0x1d34338,1)==pad_y);
|
||||
float target_pitch=12; c.v[0]=0; memcpy(&c.v[0],&target_pitch,4);
|
||||
c.x[30]=MOD_MAIN_BASE+0x1d34374+4; load_hook(&c);
|
||||
assert(fabsf(scalar(c.v[0])-(12-50*DEG_PER_COUNT))<1e-5f && guest_float((uintptr_t)cam+0x154)==scalar(c.v[0]));
|
||||
// with no stick load before it the store is the game's, bit for bit
|
||||
float neg_zero=-0.0f; memcpy(&c.v[0],&neg_zero,4); c.x[30]=MOD_MAIN_BASE+0x1d34374+4; load_hook(&c);
|
||||
float got=guest_float((uintptr_t)cam+0x154); assert(!memcmp(&got,&neg_zero,4));
|
||||
assert(LOAD(0x1d34584,1)==pad_x);
|
||||
guest_float_store((uintptr_t)(fp+0x10)-0x48,40);
|
||||
assert(fabsf(LOAD(0x1d345d0,1)-(40-100*DEG_PER_COUNT))<1e-5f);
|
||||
// Horse yaw: the turn ([sp+0x18]) the helper adds to +0x68 gets the degrees
|
||||
SAMPLE(100,-50); c.x[20]=(uintptr_t)input; guest_float_store((uintptr_t)lsp+0x18,0.5f);
|
||||
assert(LOAD(0x1d4070c,8)==pad_x && fabsf(LOAD(0x1d40740,0)-(0.5f-100*DEG_PER_COUNT))<1e-5f);
|
||||
// Wakeboard reads each axis twice: the first reads give the same values and leave the degrees to the second
|
||||
SAMPLE(100,-50); c.x[22]=(uintptr_t)input;
|
||||
float total=guest_float((uintptr_t)input+0x424);
|
||||
guest_float_store((uintptr_t)cam+0xd8,12); guest_float_store((uintptr_t)lsp+0x38,20);
|
||||
assert(LOAD(0x1d50738,0)==pad_x && LOAD(0x1d50740,1)==total);
|
||||
assert(LOAD(0x1d50768,0)==total && LOAD(0x1d5076c,1)==pad_x);
|
||||
assert(fabsf(LOAD(0x1d50e5c,0)-(12-50*DEG_PER_COUNT))<1e-5f);
|
||||
assert(fabsf(LOAD(0x1d50f20,12)-(20-100*DEG_PER_COUNT))<1e-5f);
|
||||
assert(LOAD(0x1d50f20,12)==20); // once
|
||||
// Waterfall climbing: the yaw is loaded into w8 as bits by ldr w8, [sp, #0x38]
|
||||
SAMPLE(100,-50); c.x[22]=(uintptr_t)input; guest_float_store((uintptr_t)lsp+0x38,11);
|
||||
assert(LOAD(0x1d51e10,13)==pad_x);
|
||||
c.x[30]=MOD_MAIN_BASE+0x1d51fb8+4; c.x[8]=~0ull; load_hook(&c);
|
||||
float w; uint32_t wb=(uint32_t)c.x[8]; memcpy(&w,&wb,4);
|
||||
assert(c.x[8]>>32==0 && fabsf(w-(11-100*DEG_PER_COUNT))<1e-5f);
|
||||
// Dialogue: the patched mov set x23 = x19 + 0x5c; the pitch load is ldr s1, [x23]
|
||||
SAMPLE(100,-50); c.x[24]=(uintptr_t)input; c.x[23]=(uintptr_t)cam+0x5c; guest_float_store((uintptr_t)cam+0x5c,1);
|
||||
assert(LOAD(0x1d3a3f8,11)==pad_y && fabsf(LOAD(0x1d3a420,1)-(1-50*DEG_PER_COUNT))<1e-5f);
|
||||
assert(c.x[23]==(uintptr_t)cam+0x5c);
|
||||
#undef LOAD
|
||||
#undef SAMPLE
|
||||
}
|
||||
puts("direct mouse 1.2.1: camera input in s11, activity, orbit angles at sp+0x3c/0x40, Ultrahand in s9 passed");
|
||||
puts("direct mouse 1.2.1: aiming pitch store, horse turn, repeated stick reads, w8 yaw and dialogue loads passed");
|
||||
}
|
||||
#endif
|
||||
'''
|
||||
|
||||
INPUT_HARNESS = r'''
|
||||
#include <assert.h>
|
||||
#include "input.c"
|
||||
GfxStats gfx_counters;
|
||||
InputMap input_map;
|
||||
// Other input entry points are linked by PE unwind metadata; reaching them is outside this script-only test.
|
||||
void ikb_cursor_update(void) { abort(); }
|
||||
void ikb_menu_closed(void) { abort(); }
|
||||
int cfg_bool(const char *key) { (void)key; abort(); }
|
||||
int input_menu_gate(int *h,int open,uint64_t *b,int32_t s[4]) { (void)h;(void)open;(void)b;(void)s;abort(); }
|
||||
int input_overlay_gate(int *h,uint64_t *b) { (void)h;(void)b;abort(); }
|
||||
int menu_is_open(void) { abort(); }
|
||||
void menu_toggle(void) { abort(); }
|
||||
void menu_perf_cycle(void) { abort(); }
|
||||
const char *input_action_name(int action) { (void)action;abort(); }
|
||||
void input_map_configure(void) { abort(); }
|
||||
int input_direct_mouse_active(void) { return 1; }
|
||||
int input_direct_mouse_enabled(void) { return 1; }
|
||||
static int row_calls, radial_calls, live_mouse;
|
||||
void input_direct_mouse_row(int32_t dx,float sensitivity,uint64_t now,InputMouseRow *row,int32_t out[2]) {
|
||||
(void)dx;(void)sensitivity;(void)now; row_calls++; out[0]=123;out[1]=0;row->direction=1;
|
||||
}
|
||||
void input_direct_mouse_radial(int32_t dx,int32_t dy,float sensitivity,float cursor[2],int32_t out[2]) {
|
||||
(void)dx;(void)dy;(void)sensitivity;radial_calls++;cursor[0]=1;out[0]=456;out[1]=789;
|
||||
}
|
||||
static const char *script_path;
|
||||
static uint64_t test_tick;
|
||||
static int64_t test_world=-1;
|
||||
const char *cfg_get(const char *key) {
|
||||
if (!strcmp(key,"TOTK_INPUT_SCRIPT")) return script_path;
|
||||
if (!strcmp(key,"TOTK_INPUT")) return "none";
|
||||
return NULL;
|
||||
}
|
||||
unsigned cfg_version(void) { return 0; }
|
||||
double cfg_double(const char *key) { (void)key; return 1; }
|
||||
uint64_t frame_step_ticks(void) { return test_tick; }
|
||||
int64_t frame_step_world_us(void) { return test_world; }
|
||||
void rt_log(const char *fmt, ...) { (void)fmt; }
|
||||
void plat_mutex_lock(PlatMutex *m) { (void)m; }
|
||||
void plat_mutex_unlock(PlatMutex *m) { (void)m; }
|
||||
int ikb_captured(void) { return live_mouse; }
|
||||
int ikb_foreground(void) { return 0; }
|
||||
void ikb_mouse_take(int32_t *x,int32_t *y) { *x=*y=0; }
|
||||
uint64_t plat_now_ns(void) { return 1000000000ull; }
|
||||
int main(int argc,char **argv) {
|
||||
assert(argc==2); script_path=argv[1];
|
||||
uint64_t buttons=0; int32_t left[2]={0},right[2]={0},dx,dy;
|
||||
test_tick=10;
|
||||
for(int i=0;i<3;i++) script_apply(&buttons,left,right);
|
||||
input_direct_mouse_sample(1,&dx,&dy);
|
||||
assert(dx==123 && dy==-45 && !buttons && !right[0] && !right[1]);
|
||||
input_direct_mouse_sample(1,&dx,&dy); assert(!dx && !dy);
|
||||
test_tick=20; script_apply(&buttons,left,right);
|
||||
g_menu=1; input_direct_mouse_sample(1,&dx,&dy); assert(!dx && !dy);
|
||||
g_menu=0; input_direct_mouse_sample(1,&dx,&dy); assert(!dx && !dy);
|
||||
test_world=100000;
|
||||
for(int i=0;i<3;i++) script_apply(&buttons,left,right);
|
||||
rec=tmpfile(); assert(rec); rec_state=1;
|
||||
input_direct_mouse_sample(1,&dx,&dy); assert(dx==-100 && dy==50);
|
||||
rewind(rec); char line[128]; assert(fgets(line,sizeof line,rec));
|
||||
assert(strstr(line,"MOUSE -100 50")); assert(!fgets(line,sizeof line,rec));
|
||||
fclose(rec); rec_state=-1;
|
||||
live_mouse=1;
|
||||
mouse_camera(right,1); assert(radial_calls==1 && !row_calls && right[0]==456 && right[1]==789);
|
||||
assert(input_direct_mouse_selector_active());
|
||||
mouse_camera(right,2); assert(row_calls==1 && radial_calls==1 && right[0]==123 && !right[1]);
|
||||
mouse_camera(right,0); assert(!input_direct_mouse_selector_active() && !right[0] && !right[1]);
|
||||
mouse_camera(right,3); assert(row_calls==2 && right[0]==123 && !right[1]);
|
||||
live_mouse=0; mouse_camera(right,3);
|
||||
assert(!input_direct_mouse_selector_active() && !right[0] && !right[1]);
|
||||
puts("direct mouse: one-shot script replay, host-menu blocking and raw-count recording passed");
|
||||
puts("direct mouse: separate wheel/list routing, selector ownership and release passed");
|
||||
}
|
||||
'''
|
||||
|
||||
def bl_target(address, word):
|
||||
"""The target of a bl at address, None for another instruction."""
|
||||
if word >> 26 != 0x25:
|
||||
return None
|
||||
imm = word & 0x3ffffff
|
||||
return address + 4 * (imm - (1 << 26) if imm & (1 << 25) else imm)
|
||||
|
||||
|
||||
def check_tables():
|
||||
"""Each version's load-site table (runtime/mouse_direct.c) against its patch file: one bl to page +0xf80 a site, the
|
||||
same original word (a pre-indexed ldr s<t>, [x<n>, #imm]! is the plain ldr s<t>, [x<n>] after its patched mov)."""
|
||||
source = (ROOT / 'runtime' / 'mouse_direct.c').read_text()
|
||||
for version, page in (('143', 0x3adf000), ('121', 0x472b000)):
|
||||
block = re.search(r'load_sites_%s\[\] = \{(.*?)\n\};' % version, source, re.S)[1]
|
||||
table = {int(a, 16): int(w, 16) for a, w in re.findall(r'\{0x([0-9a-f]+), 0x([0-9a-f]+),', block)}
|
||||
assert len(table) == block.count('{0x'), f'v{version}: a site twice in the table'
|
||||
sites = {}
|
||||
for line in (ROOT / 'recomp' / f'patches-v{version}.txt').read_text().splitlines():
|
||||
m = re.match(r'site mouse_direct main ([0-9a-f]+) ([0-9a-f]+) ([0-9a-f]+)', line)
|
||||
if m and bl_target(int(m[1], 16), int(m[3], 16)) == page + 0xf80:
|
||||
assert int(m[1], 16) not in sites, f'v{version} {m[1]} twice in the patch file'
|
||||
sites[int(m[1], 16)] = int(m[2], 16)
|
||||
assert sites.keys() == table.keys(), (version, sorted(hex(a) for a in set(sites) ^ set(table)))
|
||||
for a, orig in sites.items():
|
||||
# ldr s<t>, [x<n>, #imm]! -> ldr s<t>, [x<n>]
|
||||
plain = 0xbd400000 | (orig & 0x3ff) if (orig & 0xffe00c00) == 0xbc400c00 else orig
|
||||
assert table[a] == plain, f'v{version} {a:x}: table {table[a]:08x}, patch file {orig:08x}'
|
||||
print(f'direct mouse: the v{version} load-site table matches recomp/patches-v{version}.txt ({len(table)} sites)')
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--cc', required=True)
|
||||
ap.add_argument('--deps', type=Path, help='dependency include directory (SDL3/Vulkan), to also check input scripts')
|
||||
args = ap.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix='totk-mouse-') as tmp:
|
||||
folder = Path(tmp)
|
||||
(folder / 'patches.h').write_text('#define RT_PATCH_PAGE 0x7003adf000ull\n')
|
||||
(folder / 'layout.h').write_text('#define MOD_MAIN_BASE 0x7000000000ull\n')
|
||||
source = folder / 'check.c'
|
||||
source.write_text(HARNESS)
|
||||
exe = folder / ('check.exe' if os.name == 'nt' else 'check')
|
||||
link = [] if os.name == 'nt' else ['-lm']
|
||||
for version in (143, 121):
|
||||
subprocess.run([args.cc, '-O2', '-Wall', '-Wextra', f'-DTEST_GAME_VERSION={version}', '-I', str(folder),
|
||||
'-I', str(ROOT/'runtime'), str(source), *link, '-o', str(exe)], check=True)
|
||||
subprocess.run([str(exe)], check=True)
|
||||
check_tables()
|
||||
if args.deps:
|
||||
source.write_text(INPUT_HARNESS)
|
||||
script = folder / 'input.txt'
|
||||
script.write_text('10 MOUSE 123 -45\n20 MOUSE 1000 0\n@100 MOUSE -100 50\n')
|
||||
subprocess.run([args.cc, '-O2', '-flto', '-ffunction-sections', '-fdata-sections', '-Wl,--gc-sections', *(['-fuse-ld=lld', '-Wl,--exclude-all-symbols'] if os.name == 'nt' else []),
|
||||
'-I', str(ROOT/'runtime'), '-I', str(args.deps), '-I', str(args.deps/'vma'),
|
||||
str(source), *link, '-o', str(exe)], check=True)
|
||||
subprocess.run([str(exe), str(script)], check=True)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,8 +1,8 @@
|
||||
"""tools/make_romfs_patches.py --prompts and tools/button_icons.py on a RomFS made here, with no game files: each prompt
|
||||
names the label of the Xbox button that the pad layout presses, the shoulder buttons, triggers, Plus, Minus and the
|
||||
d-pad are drawn as on an Xbox controller, the resource size table grows with the fonts, every other file and member
|
||||
stays byte for byte, the same input gives the same output, and a RomFS the tool does not recognise fails before it
|
||||
writes anything. Both game versions: 1.2.1 (Common.Product.110, 72x72 button guide images) and 1.4.3
|
||||
d-pad are drawn as on an Xbox controller, the PC prompts draw every button as its key or mouse button (the defaults or
|
||||
a totk.ini's bind_ lines), the resource size table grows with the fonts, every other file and member stays byte for
|
||||
byte, the same input gives the same output, and a RomFS the tool does not recognise fails before it writes anything. Both game versions: 1.2.1 (Common.Product.110, 72x72 button guide images) and 1.4.3
|
||||
(Common.Product.140, 108x108).
|
||||
|
||||
Run: .venv-py314\\Scripts\\python tests\\romfs_prompts_test.py
|
||||
@@ -296,6 +296,11 @@ LETTERS_1 = {ord('L'): (560, polygon((60, 0), (480, 0), (480, 100), (160, 100),
|
||||
rect(pen, 160, 400, 360, 600, True)]),
|
||||
ord('R'): (560, lambda pen: [rect(pen, 60, 0, 460, 700), rect(pen, 160, 400, 360, 600, True)])}
|
||||
LETTERS_2 = {ord('T'): (560, polygon((230, 0), (330, 0), (330, 600), (520, 600), (520, 700), (40, 700), (40, 600), (230, 600)))}
|
||||
# The other characters the PC prompts' keys show (button_icons.PC_LABELS of the defaults): a block with a counter, each
|
||||
# its own size.
|
||||
LETTERS_2.update({ord(ch): (560, lambda pen, i=i: [rect(pen, 60, 0, 300 + 10 * i, 700), rect(pen, 120, 100, 240, 600, True)])
|
||||
for i, ch in enumerate('ACDEFMQSW1234⇧G')})
|
||||
PC_PANES = {'A': 'E', 'B': '↑', 'X': '▬', 'Y': 'L'} # the diamond's letters with the default keys
|
||||
|
||||
|
||||
class Romfs:
|
||||
@@ -316,7 +321,8 @@ class Romfs:
|
||||
images = {f'Nt_KeyTex{b}_00': (R8_UNORM, size, rnd.randbytes(IMAGE)) for b in 'ABXY'}
|
||||
images.update({'Nt_KeyTexL_00^z': (l_format, size, rnd.randbytes(IMAGE)),
|
||||
'Nt_KeyTexZL_00': (R8_UNORM, size, rnd.randbytes(IMAGE)),
|
||||
'Nt_KeyTexLSt_00': (R8_UNORM, size, rnd.randbytes(IMAGE))})
|
||||
'Nt_KeyTexLSt_00': (R8_UNORM, size, rnd.randbytes(IMAGE)),
|
||||
'Nt_KeyTexLSt_01': (R8_UNORM, size, rnd.randbytes(IMAGE))})
|
||||
self.text_fonts = [bfotf(cff_font('Text1', LETTERS_1), 0x01020304), bfotf(cff_font('Text2', LETTERS_2), 0x0a0b0c0d)]
|
||||
default = {'scft/External.bfotf': bfotf(cff_font('External', BUTTONS), 0x5a17c3e9), 'fnt/rest.bin': rnd.randbytes(700)}
|
||||
if text_fonts:
|
||||
@@ -388,7 +394,7 @@ class PromptsTest(unittest.TestCase):
|
||||
cls.romfs = {v: Romfs(cls.base / v / 'romfs', v) for v in VERSIONS}
|
||||
cls.outs = {}
|
||||
for v, romfs in cls.romfs.items():
|
||||
for style in ('plain', 'xbox', 'xbox-label'):
|
||||
for style in ('plain', 'xbox', 'xbox-label', 'pc'):
|
||||
out = cls.base / v / style
|
||||
proc = romfs.run(out, *(() if style == 'plain' else ('--prompts', style)))
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
@@ -485,7 +491,7 @@ class PromptsTest(unittest.TestCase):
|
||||
self.assertEqual(after[member], before[member], member)
|
||||
|
||||
def test_resource_size_table_grows_with_the_fonts(self):
|
||||
for romfs, style, out, summary in self.each():
|
||||
for romfs, style, out, summary in self.each(('xbox', 'xbox-label', 'pc')):
|
||||
with self.subTest(version=romfs.version, style=style):
|
||||
table = restbl_sizes(romfs.read(out, romfs.restbl))
|
||||
original = restbl_sizes(romfs.contents[romfs.restbl])
|
||||
@@ -501,11 +507,12 @@ class PromptsTest(unittest.TestCase):
|
||||
self.assertEqual(set(table), set(original))
|
||||
|
||||
def test_manifest_lists_what_changed_with_both_hashes(self):
|
||||
for romfs, style, out, _ in self.each(('plain', 'xbox', 'xbox-label')):
|
||||
for romfs, style, out, _ in self.each(('plain', 'xbox', 'xbox-label', 'pc')):
|
||||
with self.subTest(version=romfs.version, style=style):
|
||||
expect = {romfs.common}
|
||||
if style != 'plain':
|
||||
expect |= {DEFAULT_FONT, REGIONAL_FONT, romfs.restbl} | (set(romfs.mals) if style == 'xbox' else set())
|
||||
expect |= {DEFAULT_FONT, REGIONAL_FONT, romfs.restbl}
|
||||
expect |= set(romfs.mals) if style in ('xbox', 'pc') else set()
|
||||
lines = manifest(out)
|
||||
self.assertEqual(set(lines), expect)
|
||||
for path, (size, sha, original) in lines.items():
|
||||
@@ -513,6 +520,50 @@ class PromptsTest(unittest.TestCase):
|
||||
self.assertEqual((int(size), sha), (len(data), hashlib.sha256(data).hexdigest()), path)
|
||||
self.assertEqual(original, hashlib.sha256(romfs.files[path]).hexdigest(), path)
|
||||
|
||||
def test_pc_prompts_draw_every_button_as_its_key(self):
|
||||
for romfs, _, out, _ in self.each(('pc',)):
|
||||
with self.subTest(version=romfs.version):
|
||||
original = font(sarc_files(romfs.contents[DEFAULT_FONT])['scft/External.bfotf'])
|
||||
drawn = font(sarc_files(romfs.read(out, DEFAULT_FONT))['scft/External.bfotf'])
|
||||
advances = icons.font_advances(sarc_files(romfs.contents[DEFAULT_FONT])['scft/External.bfotf'])
|
||||
pc = icons.pc_icons(icons.text_glyphs(romfs.text_fonts), advances)
|
||||
self.assertIn(0xE05E, pc) # the left stick's W A S D
|
||||
for cp in range(0xE040, 0xE069):
|
||||
outline, advance = glyph(drawn, cp)
|
||||
if cp in pc:
|
||||
self.assertNotEqual(outline, glyph(original, cp)[0], f'U+{cp:04X} drawn')
|
||||
else:
|
||||
self.assertEqual(outline, glyph(original, cp)[0], f'U+{cp:04X} kept')
|
||||
self.assertEqual(advance, glyph(original, cp)[1], f'U+{cp:04X} keeps its advance')
|
||||
# The key's label is a hole: E is the key, the block and its counter.
|
||||
self.assertEqual(sum(op == 'moveTo' for op, _ in glyph(drawn, 0xE040)[0]), 3)
|
||||
after = sarc_files(romfs.read(out, romfs.common))
|
||||
new = bntx_images(after['timg/__Combined.bntx'])
|
||||
size = VERSIONS[romfs.version]['size']
|
||||
for name, cp in icons.PC_HUD_TEXTURES.items():
|
||||
image = new[name][2]
|
||||
pixels = bytes(image[icons.gob_offset(x, y, (size + 63) // 64, 16)] for y in range(size) for x in range(size))
|
||||
self.assertEqual(pixels, icons.rasterize(pc[cp][1], size), name)
|
||||
panes = pane_texts(after['blyt/DoCommand_00.bflyt'])
|
||||
for b, letter in PC_PANES.items():
|
||||
self.assertEqual(panes[f'T_{b}_00'], letter, f'diamond letter of {b}')
|
||||
for path in romfs.mals:
|
||||
texts = message_texts(sarc_files(romfs.read(out, path))['LayoutMsg/DoCommand_00.msbt'])
|
||||
self.assertEqual((texts[f'T_{b}_00'], texts['T_Name_00']), (letter, 'A'), path)
|
||||
|
||||
def test_pc_prompts_follow_the_bind_lines(self):
|
||||
romfs = self.romfs['v143']
|
||||
ini = self.base / 'binds' / 'totk.ini'
|
||||
ini.parent.mkdir(parents=True)
|
||||
ini.write_text('[input]\n# bind_A = key:q\nbind_A = pad:east, key:F\nbind_B = pad:south\nbind_X = key:default\n',
|
||||
encoding='utf-8')
|
||||
out = self.base / 'binds' / 'out'
|
||||
proc = romfs.run(out, '--prompts', 'pc', '--binds', str(ini))
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
panes = pane_texts(sarc_files(romfs.read(out, romfs.common))['blyt/DoCommand_00.bflyt'])
|
||||
self.assertEqual({b: panes[f'T_{b}_00'] for b in PC_PANES}, {**PC_PANES, 'A': 'F'})
|
||||
self.assertEqual(icons.pc_binds(ini.read_text(encoding='utf-8')), {**icons.PC_BINDS, 'A': 'f'})
|
||||
|
||||
def test_same_input_same_output_and_the_older_flag(self):
|
||||
romfs = self.romfs['v121']
|
||||
for flags in (('--prompts', 'xbox'), ('--xbox-prompts',)):
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Every setting the runtime knows is described: in totk.ini.example for players or in docs/settings.md for the rest.
|
||||
|
||||
python tests/settings_doc_check.py
|
||||
|
||||
Fails when a name of known[] (runtime/cfg.c) is in neither file, when docs/settings.md lists a name the runtime does
|
||||
not know, or when a default written in docs/settings.md differs from the one the code holds: the metas[] table of
|
||||
runtime/cfg.c or an rt_knob(name, default) call. No game files, no build.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def known_names(cfg):
|
||||
start = cfg.index('static const char *const known[]')
|
||||
return set(re.findall(r'"([A-Z0-9_]+)"', cfg[start:cfg.index('};', start)]))
|
||||
|
||||
|
||||
def code_defaults(cfg):
|
||||
"""{name: default} of metas[] and of the rt_knob calls; metas[] wins, as it is where a default lives."""
|
||||
found = {}
|
||||
for path in (ROOT / 'runtime').rglob('*'):
|
||||
if path.suffix in ('.c', '.cpp', '.h'):
|
||||
text = path.read_text(encoding='utf-8', errors='replace')
|
||||
found.update(re.findall(r'rt_knob\("(?:TOTK_)?([A-Z0-9_]+)", *"([^"]*)"\)', text))
|
||||
found.update(re.findall(r'\{"([A-Z0-9_]+)", "([^"]*)", (?:"[^"]*"|NULL)', cfg))
|
||||
return found
|
||||
|
||||
|
||||
def main():
|
||||
cfg = (ROOT / 'runtime' / 'cfg.c').read_text(encoding='utf-8')
|
||||
example = (ROOT / 'totk.ini.example').read_text(encoding='utf-8')
|
||||
doc = (ROOT / 'docs' / 'settings.md').read_text(encoding='utf-8')
|
||||
known = known_names(cfg)
|
||||
in_example = {name.upper() for name in re.findall(r'^#? ?([a-z][a-z0-9_]*) = ', example, re.M)}
|
||||
rows = re.findall(r'^\| `([a-z0-9_<>]+)`(?: \(default ([^)]*)\))? \|(.*)$', doc, re.M)
|
||||
in_doc = {name.upper() for name, _, _ in rows if '<' not in name}
|
||||
# The row of the button bindings names each key in its text.
|
||||
for name, _, text in rows:
|
||||
if '<' in name:
|
||||
in_doc |= {key.upper() for key in re.findall(r'`(bind_[a-z_]+)`', text)}
|
||||
|
||||
problems = [f'{name}: in runtime/cfg.c known[], described in neither totk.ini.example nor docs/settings.md'
|
||||
for name in sorted(known - in_example - in_doc)]
|
||||
problems += [f'{name}: in docs/settings.md, not in runtime/cfg.c known[]' for name in sorted(in_doc - known)]
|
||||
defaults = code_defaults(cfg)
|
||||
for name, written, _ in rows:
|
||||
code = defaults.get(name.upper())
|
||||
if code and written != f'`{code}`':
|
||||
problems.append(f'{name}: docs/settings.md says default {written or "none"}, the code says {code}')
|
||||
|
||||
for problem in problems:
|
||||
print(problem)
|
||||
print(f'settings_doc_check: {len(known)} names, {len(known & in_example)} in totk.ini.example, '
|
||||
f'{len(known & in_doc)} in docs/settings.md, {len(problems)} problems')
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+56
-8
@@ -109,6 +109,36 @@ def set_cpu(cpu):
|
||||
GEN_FLAGS[GEN_FLAGS.index('-march=x86-64-v3')] = march
|
||||
RT_FLAGS[RT_FLAGS.index('-march=x86-64-v3')] = march
|
||||
RT_FLAGS.append(f'-DRT_CPU_LEVEL={3 if cpu == "v3" else 2}')
|
||||
|
||||
|
||||
# Profile-guided optimisation (--pgo). Both modes compile the generated code at -O2, the level the profile's inlining and
|
||||
# hot/cold layout work at. A static function's profile name carries its source path by default; the base name alone
|
||||
# keeps the names equal between the training build and a build from another tree (a worktree, an install).
|
||||
PGO_NAMES = ['-mllvm', '-static-func-full-module-prefix=false']
|
||||
PGO_INPUT = None # the .profdata of --pgo FILE: every compile depends on it, so a new profile rebuilds them
|
||||
|
||||
|
||||
def set_pgo(pgo, out):
|
||||
"""--pgo gen: instrument the generated code and the runtime; totk.exe adds its counts to
|
||||
out/pgo/default_<id>.profraw every minute and at exit (runtime/platform win32.c), across runs. --pgo FILE: compile
|
||||
with that merged profile (tools/pgo.py merge). Functions the profile never saw run are optimised for size."""
|
||||
global PGO_INPUT
|
||||
GEN_FLAGS[GEN_FLAGS.index('-O1')] = '-O2'
|
||||
if pgo == 'gen':
|
||||
# absolute: the instrumented exe resolves the directory against its own working directory
|
||||
flags = [f'-fprofile-generate={cmd_arg((out / "pgo").resolve().as_posix())}']
|
||||
RT_FLAGS.append('-DTOTK_PGO_GEN')
|
||||
LINK_FLAGS.append('-fprofile-generate') # links clang_rt.profile
|
||||
else:
|
||||
PGO_INPUT = Path(pgo).resolve()
|
||||
if not PGO_INPUT.is_file():
|
||||
sys.exit(f'--pgo {pgo}: no such profile')
|
||||
# unprofiled: chunks the training never entered; out-of-date and backend-plugin: functions whose code changed
|
||||
# since the training, which then compile without a profile
|
||||
flags = [f'-fprofile-use={cmd_path(PGO_INPUT)}', '-Wno-profile-instr-unprofiled',
|
||||
'-Wno-profile-instr-out-of-date', '-Wno-backend-plugin']
|
||||
GEN_FLAGS.extend(flags + PGO_NAMES)
|
||||
RT_FLAGS.extend(flags + PGO_NAMES)
|
||||
if WIN:
|
||||
# _WIN32_WINNT: Windows 10, the port's minimum. MSYS2's headers default to it, llvm-mingw's (the release toolchain)
|
||||
# to Windows 7, which hides the per-monitor DPI functions gfx_window_win.c and dpi_probe_win.c call.
|
||||
@@ -156,8 +186,8 @@ LLVM_LINK_LIBS = ['-lpsapi', '-lbcrypt', '-luser32', '-lgdi32', '-ldwmapi', '-li
|
||||
DEFAULT_DEPS = ROOT / 'toolchain' / 'deps-mingw'
|
||||
# Runtime sources compiled per install, not in the library: romfs.c includes romfs_config.h, patches.c and ov_zstd.c
|
||||
# the patches.h of the player's game version (its patch page is the page after that version's main; ov_zstd.c's host
|
||||
# functions sit on it).
|
||||
INSTALL_SIDE = {'romfs.c', 'patches.c', 'ov_zstd.c'}
|
||||
# functions sit on it). mouse_direct.c also registers host callbacks relative to that version's patch page.
|
||||
INSTALL_SIDE = {'romfs.c', 'patches.c', 'ov_zstd.c', 'mouse_direct.c'}
|
||||
LIB_NAME = 'libtotkrt.a'
|
||||
AR = UCRT / 'llvm-ar.exe' if WIN else on_path('llvm-ar')
|
||||
WINDRES = UCRT / 'windres.exe' # runtime/totk.rc: the manifest of totk.exe
|
||||
@@ -241,10 +271,13 @@ def write_if_changed(path, text):
|
||||
def copy_romfs_patches(version, out):
|
||||
"""totk.exe loads romfs-patches beside romfs-index.bin (runtime/romfs.c overlay_init). tools/make_romfs_patches.py
|
||||
writes them into build/<version> only, so a build in another directory ran without the 60 fps overlay (the quick
|
||||
menus' captured background turned black). Copies them when their manifest differs; romfs-patches-xbox and
|
||||
romfs-patches-xbox-label (the same with Xbox button prompts, make_romfs_patches.py --prompts) the same way when they
|
||||
were made."""
|
||||
for name in ('romfs-patches', 'romfs-patches-xbox', 'romfs-patches-xbox-label'):
|
||||
menus' captured background turned black). Copies every romfs-patches* folder of build/<version> that has a
|
||||
manifest, the same way when it differs: romfs-patches, romfs-patches-xbox, romfs-patches-xbox-label and
|
||||
romfs-patches-pc (the same with Xbox or keyboard and mouse button prompts, make_romfs_patches.py --prompts) and
|
||||
the ones of another shape, romfs-patches-<w>x<h> and romfs-patches-pc-<w>x<h> (make_romfs_patches.py --aspect),
|
||||
which runtime/romfs.c overlay_init needs for a game resolution that is not 16:9."""
|
||||
names = sorted(p.name for p in (ROOT / 'build' / version).glob('romfs-patches*') if p.is_dir())
|
||||
for name in names:
|
||||
src = ROOT / 'build' / version / name
|
||||
dst = out / name
|
||||
if src.resolve() == dst.resolve() or not (src / 'manifest.tsv').is_file():
|
||||
@@ -384,12 +417,13 @@ def ninja_file(gen, out, mods, pool_depth, skip=(), prebuilt=False, runtime_lib=
|
||||
'',
|
||||
]
|
||||
objs = {}
|
||||
pgo_dep = f' | {ninja_path(PGO_INPUT)}' if PGO_INPUT else ''
|
||||
for mod, files in mods.items():
|
||||
objs[mod] = []
|
||||
for f in files:
|
||||
o = out / 'obj' / mod / (f.stem + '.o')
|
||||
if not prebuilt:
|
||||
lines.append(f'build {ninja_path(o)}: cc_gen {ninja_path(f)}')
|
||||
lines.append(f'build {ninja_path(o)}: cc_gen {ninja_path(f)}{pgo_dep}')
|
||||
lines.append(' pool = gen')
|
||||
objs[mod].append(o)
|
||||
rt = []
|
||||
@@ -406,7 +440,7 @@ def ninja_file(gen, out, mods, pool_depth, skip=(), prebuilt=False, runtime_lib=
|
||||
lines.append(f'build {ninja_path(o)}: rc {ninja_path(src)} | {ninja_path(RUNTIME / "totk.manifest")} '
|
||||
f'{ninja_path(RUNTIME / "totk.ico")}')
|
||||
else:
|
||||
lines.append(f'build {ninja_path(o)}: cc_rt {ninja_path(src)}')
|
||||
lines.append(f'build {ninja_path(o)}: cc_rt {ninja_path(src)}{pgo_dep}')
|
||||
rt.append(o)
|
||||
objs['runtime'] = rt
|
||||
every = [o for v in objs.values() for o in v]
|
||||
@@ -769,6 +803,10 @@ def main():
|
||||
help=f'build only <out>/{LIB_NAME} from runtime/ (no generated C, no totk.exe)')
|
||||
ap.add_argument('--runtime-lib', type=Path, help=f'link against this {LIB_NAME} instead of compiling runtime/ '
|
||||
f'({", ".join(sorted(INSTALL_SIDE))} and modules.c still compile)')
|
||||
ap.add_argument('--pgo', metavar='gen|FILE',
|
||||
help='profile-guided optimisation, generated code at -O2: gen builds an instrumented totk.exe '
|
||||
'(needs --toolchain on Windows: MSYS2 clang ships no profile runtime); FILE builds with that '
|
||||
'.profdata (tools/pgo.py merge)')
|
||||
ap.add_argument('--expected', type=Path,
|
||||
help='expected hashes (default tools/data/expected-<display version>.json)')
|
||||
args = ap.parse_args()
|
||||
@@ -776,6 +814,11 @@ def main():
|
||||
|
||||
gen = (args.gen or ROOT / 'generated' / args.version).resolve()
|
||||
out = (args.out or ROOT / 'build' / args.version).resolve()
|
||||
if args.pgo:
|
||||
if args.pgo == 'gen' and WIN and not args.toolchain:
|
||||
sys.exit('--pgo gen: MSYS2 UCRT64 clang ships no profile runtime (clang_rt.profile); pass --toolchain with '
|
||||
'an llvm-mingw directory')
|
||||
set_pgo(args.pgo, out)
|
||||
extracted = None if args.exefs and args.romfs else work_dir() / 'extracted' / args.version
|
||||
paths = {'exefs': (args.exefs or extracted / 'exefs').resolve(),
|
||||
'romfs': (args.romfs or extracted / 'romfs').resolve(),
|
||||
@@ -878,6 +921,11 @@ def main():
|
||||
runtime_lib = args.runtime_lib.resolve() if args.runtime_lib else None
|
||||
if runtime_lib and not runtime_lib.is_file():
|
||||
sys.exit(f'{runtime_lib} not found: build it with --make-runtime-lib')
|
||||
# The library leaves INSTALL_SIDE out, so a release that ships without one of them links with undefined symbols.
|
||||
missing = sorted(n for n in INSTALL_SIDE - skip if not (RUNTIME / n).is_file())
|
||||
if runtime_lib and missing:
|
||||
sys.exit(f'build_game: {", ".join(missing)} missing from {flag_path(RUNTIME)}: the release must ship every '
|
||||
f'INSTALL_SIDE source beside {runtime_lib.name}')
|
||||
text, objs, exe = ninja_file(gen, out, mods, jobs, skip, pairs is not None, runtime_lib,
|
||||
args.make_runtime_lib)
|
||||
write_if_changed(out / 'build.ninja', text)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Build the shader translator: build/xlate/totk_xlate.exe (Ryujinx 1.3.2 + patches, NativeAOT, SPIR-V for Vulkan).
|
||||
|
||||
Copies tools/vendor/ryujinx to build/xlate/ryujinx, applies tools/xlate/patches/*.patch there (tools/vendor stays
|
||||
byte-identical; 0001-0004 are MNG's, 0005-0014 are ours), then runs `dotnet publish`. Needs the .NET 10 SDK, the MSVC build
|
||||
byte-identical; 0001-0004 are MNG's, 0005-0013, 0015 and 0016 are ours), then runs `dotnet publish`. Needs the .NET 10 SDK, the MSVC build
|
||||
tools and vswhere.exe (docs/building.md). Run with .venv. On Linux it builds build/xlate/totk_xlate for linux-x64, which
|
||||
NativeAOT links with the system's clang (no MSVC); NativeAOT builds for the OS it runs on only.
|
||||
|
||||
|
||||
+178
-7
@@ -1,6 +1,7 @@
|
||||
# Adapted from MacNativeGames games/totk/scripts/make_button_icons.py @ 933b6a0 (MIT, Copyright (c) 2026
|
||||
# MacNativeGames contributors).
|
||||
"""Xbox button icons for the game's prompts, drawn on the player's machine for tools/make_romfs_patches.py --prompts.
|
||||
"""Xbox and keyboard-and-mouse button icons for the game's prompts, drawn on the player's machine for
|
||||
tools/make_romfs_patches.py --prompts.
|
||||
The outlines are our own shapes with letters cut out of them in the game's own text fonts: nothing of the game is in
|
||||
the repository.
|
||||
|
||||
@@ -9,13 +10,15 @@ Font/Font*.Nin_NX_NVN.bfarc.zs: private-use code points from U+E040 (A, B, X, Y,
|
||||
Minus, the sticks). A .bfotf is an OpenType (CFF) font after an 8-byte header; from offset 4 on, every byte is XORed
|
||||
with a 4-byte key, which the size in the header's second word gives away (bfotf_decode). redraw_font draws the shoulder
|
||||
buttons, the triggers, Plus, Minus and the d-pad as an Xbox controller names them (LB RB, LT RT, Menu and View, a
|
||||
cross), can swap the glyphs of the face buttons (A with B, X with Y), and has fontTools write the font again. The
|
||||
archive then grows by a kilobyte or two, more than the game's resource size table allows it: grow_restbl raises those
|
||||
entries (without it the game stops at start, MacNativeGames found).
|
||||
cross), or every button as the key or mouse button the keyboard presses it with (pc_icons), can swap the glyphs of
|
||||
the face buttons (A with B, X with Y), and has fontTools write the font again. The archive then grows by a kilobyte or
|
||||
two, more than the game's resource size table allows it: grow_restbl raises those entries (without it the game stops at
|
||||
start, MacNativeGames found).
|
||||
|
||||
The button guide in play (bottom right) draws L and ZL from images of the common layout archive instead
|
||||
(timg/__Combined.bntx, Nt_KeyTexL_00 and Nt_KeyTexZL_00): bntx_redraw paints the same icons into them, the size
|
||||
unchanged. The sticks keep the Switch icons, and so do prompts drawn as fixed images (some tutorial animations).
|
||||
The button guide in play (bottom right) draws its buttons, L and ZL among them, from images of the common layout
|
||||
archive instead (timg/__Combined.bntx, Nt_KeyTexL_00 and Nt_KeyTexZL_00): bntx_redraw paints the same icons into them,
|
||||
the size unchanged. The Xbox prompts redraw L and ZL, the keyboard and mouse ones every image (PC_HUD_TEXTURES: A, B, X,
|
||||
Y, L, ZL and the stick's two). Prompts drawn as fixed images (some tutorial animations) keep the Switch icons.
|
||||
|
||||
Needs fontTools (tools/requirements-installer.txt, tools/requirements-py314.txt).
|
||||
"""
|
||||
@@ -180,6 +183,174 @@ def xbox_icons(g):
|
||||
}
|
||||
|
||||
|
||||
# ---- keyboard and mouse prompts (--prompts pc): every button the keyboard drives, drawn as the key or mouse button
|
||||
# bound to it, in the same filled-shape style. Each glyph keeps the advance of the game's own (a wider glyph runs into
|
||||
# the text of a right-aligned guide).
|
||||
# The keyboard defaults of runtime/input_map.c (input_map_reset; the totk.ini bind_ lines apply on top, pc_binds). The
|
||||
# right stick is the camera, which the mouse drives; SHAKE is the key that shakes it (the mouse never does).
|
||||
PC_BINDS = {
|
||||
'A': 'e', 'B': 'lshift', 'X': 'space', 'Y': 'mouse_l', 'L': 'q', 'R': 'r', 'ZL': 'mouse_r', 'ZR': 'f',
|
||||
'PLUS': 'tab', 'MINUS': 'm', 'LEFT': '1', 'UP': '2', 'RIGHT': '3', 'DOWN': '4', 'LCLICK': 'lctrl', 'RCLICK': 'c',
|
||||
'LSTICK_UP': 'w', 'LSTICK_LEFT': 'a', 'LSTICK_DOWN': 's', 'LSTICK_RIGHT': 'd', 'SHAKE': 'g',
|
||||
}
|
||||
# What a key shows: its letter, a symbol or a short name (key names as in totk.ini; the rest show their name).
|
||||
PC_LABELS = {
|
||||
'lshift': '⇧', 'rshift': '⇧', 'lctrl': 'CTRL', 'rctrl': 'CTRL', 'lalt': 'ALT', 'tab': 'TAB',
|
||||
'enter': 'ENT', 'backspace': 'BKSP', 'capslock': 'CAPS', 'insert': 'INS', 'delete': 'DEL', 'home': 'HOME',
|
||||
'end': 'END', 'pageup': 'PGUP', 'pagedown': 'PGDN', 'up': '↑', 'down': '↓', 'left': '←',
|
||||
'right': '→', 'num_add': '+', 'num_sub': '-', 'num_mul': '*', 'num_div': '/', 'num_decimal': '.',
|
||||
'minus': '-', 'equals': '=', 'lbracket': '[', 'rbracket': ']', 'semicolon': ';', 'apostrophe': "'", 'comma': ',',
|
||||
'period': '.', 'slash': '/', 'backslash': '\\', 'grave': '`', 'mouse_x1': 'M4', 'mouse_x2': 'M5',
|
||||
}
|
||||
# The one-letter panes around the action prompt (T_A_00 ...) are in the Normal font (Rodin B and Symbol): one character
|
||||
# of it per key.
|
||||
PC_LETTER_LABELS = {'lshift': '↑', 'rshift': '↑', 'space': '▬', 'mouse_l': 'L', 'mouse_r': 'R',
|
||||
'mouse_m': 'M', 'lctrl': 'C', 'rctrl': 'C', 'tab': 'T'}
|
||||
# The button guide's images in the pc style: every symbol mask of the action prompt. In the 1.4.3 Common archive
|
||||
# LSt_00 shows the left stick tilted up (as U+E052) and LSt_01 the left stick (as U+E05E).
|
||||
PC_HUD_TEXTURES = {'Nt_KeyTexA_00': 0xE040, 'Nt_KeyTexB_00': 0xE041, 'Nt_KeyTexX_00': 0xE042, 'Nt_KeyTexY_00': 0xE043,
|
||||
'Nt_KeyTexL_00': 0xE044, 'Nt_KeyTexZL_00': 0xE046, 'Nt_KeyTexLSt_00': 0xE052,
|
||||
'Nt_KeyTexLSt_01': 0xE05E}
|
||||
PC_MOUSE = ('mouse_l', 'mouse_r', 'mouse_m')
|
||||
|
||||
|
||||
def pc_binds(ini_text=None):
|
||||
"""PC_BINDS with the first key of each bind_<action> line of a totk.ini text over it (a line without a key entry,
|
||||
or with key:default, keeps the default)."""
|
||||
binds = dict(PC_BINDS)
|
||||
for line in (ini_text or '').splitlines():
|
||||
line = line.split('#')[0].split(';')[0].strip()
|
||||
if not line.lower().startswith('bind_') or '=' not in line:
|
||||
continue
|
||||
action, value = (s.strip() for s in line.split('=', 1))
|
||||
keys = [e.strip()[4:].strip().lower() for e in value.split(',') if e.strip().lower().startswith('key:')]
|
||||
keys = [k for k in keys if k != 'default']
|
||||
if keys and action[5:].upper() in binds:
|
||||
binds[action[5:].upper()] = keys[0]
|
||||
return binds
|
||||
|
||||
|
||||
def font_advances(blob):
|
||||
"""Code point -> advance of every glyph of the button font (a .bfotf)."""
|
||||
font = TTFont(io.BytesIO(bfotf_decode(blob)[0]))
|
||||
return {cp: font['hmtx'][name][0] for cp, name in font.getBestCmap().items()}
|
||||
|
||||
|
||||
def pc_letter(key):
|
||||
"""The one character the panes around the action prompt show for key."""
|
||||
if key in PC_LETTER_LABELS:
|
||||
return PC_LETTER_LABELS[key]
|
||||
return PC_LABELS.get(key, key.upper())[0]
|
||||
|
||||
|
||||
def pc_icons(g, adv, binds=PC_BINDS):
|
||||
"""Code point -> (advance, draw(pen)) for every button glyph but Home and Capture, drawn as the key or mouse button
|
||||
binds puts on it. g: Glyphs for the key labels; adv: the game's advance of each glyph (font_advances), which each
|
||||
drawing keeps."""
|
||||
def mouse_body(pen, cx, y0, y1, lit=None):
|
||||
"""A mouse seen from above; lit ('l', 'r', 'm' or None) its pressed button, filled, the others hollow."""
|
||||
h = y1 - y0; r = h * 0.33; t = h * 0.07; gap = h * 0.035
|
||||
x0, x1, mid = cx - r, cx + r, y0 + h * 0.52
|
||||
rounded(pen, x0, y0, x1, y1, r * 0.9, r)
|
||||
reverse = ReverseContourPen(pen) # holes; they never overlap each other or leave the body
|
||||
ri = r - t
|
||||
if lit in ('l', 'r'): # the seams around the pressed button
|
||||
sx0, sx1 = (x0 + t, cx - gap) if lit == 'l' else (cx + gap, x1 - t)
|
||||
polygon(reverse, [(sx0, mid - gap), (sx1, mid - gap), (sx1, mid + gap), (sx0, mid + gap)])
|
||||
polygon(reverse, [(cx - gap, mid + gap), (cx + gap, mid + gap), (cx + gap, y1 - t), (cx - gap, y1 - t)])
|
||||
if lit != 'l': # hollow left button, its outer top corner following the body
|
||||
ix0, ix1 = x0 + t, cx - gap
|
||||
reverse.moveTo((ix1, mid + gap)); reverse.lineTo((ix1, y1 - t)); reverse.lineTo((ix0 + ri, y1 - t))
|
||||
reverse.curveTo((ix0 + ri - K * ri, y1 - t), (ix0, y1 - t - ri + K * ri), (ix0, y1 - t - ri))
|
||||
reverse.lineTo((ix0, mid + gap)); reverse.closePath()
|
||||
if lit != 'r':
|
||||
ix0, ix1 = cx + gap, x1 - t
|
||||
reverse.moveTo((ix0, mid + gap)); reverse.lineTo((ix1, mid + gap)); reverse.lineTo((ix1, y1 - t - ri))
|
||||
reverse.curveTo((ix1, y1 - t - ri + K * ri), (ix1 - ri + K * ri, y1 - t), (ix1 - ri, y1 - t))
|
||||
reverse.lineTo((ix0, y1 - t)); reverse.closePath()
|
||||
if lit == 'm': # the wheel, filled over the gap between the hollow buttons
|
||||
rounded(pen, cx - h * 0.06, mid + h * 0.12, cx + h * 0.06, mid + h * 0.36, h * 0.06)
|
||||
|
||||
def key_cap(pen, x0, y0, x1, y1, key):
|
||||
"""A key: a rounded square with its label cut out; a mouse button draws the mouse."""
|
||||
if key in PC_MOUSE:
|
||||
mouse_body(pen, (x0 + x1) / 2, y0, y1, key[6:])
|
||||
return
|
||||
w, h = x1 - x0, y1 - y0
|
||||
rounded(pen, x0, y0, x1, y1, min(w, h) * 0.18)
|
||||
if key == 'space': # an open bracket lying on its back
|
||||
t = h * 0.09
|
||||
bx0, bx1, by0, by1 = x0 + w * 0.22, x1 - w * 0.22, y0 + h * 0.36, y0 + h * 0.60
|
||||
polygon(ReverseContourPen(pen), [(bx0, by0), (bx1, by0), (bx1, by1), (bx1 - t, by1), (bx1 - t, by0 + t),
|
||||
(bx0 + t, by0 + t), (bx0 + t, by1), (bx0, by1)])
|
||||
return
|
||||
label = PC_LABELS.get(key, key.upper())
|
||||
cap = h * (0.52 if len(label) == 1 else 0.36 if len(label) == 2 else 0.26)
|
||||
g.hole(pen, label, (x0 + x1) / 2, (y0 + y1) / 2, cap, w * 0.74)
|
||||
|
||||
def arrow(pen, cx, cy, d, s):
|
||||
"""A filled arrowhead pointing d, s its half size."""
|
||||
polygon(pen, {'up': [(cx - s, cy - s * 0.6), (cx + s, cy - s * 0.6), (cx, cy + s * 0.8)],
|
||||
'down': [(cx + s, cy + s * 0.6), (cx - s, cy + s * 0.6), (cx, cy - s * 0.8)],
|
||||
'left': [(cx + s * 0.6, cy - s), (cx + s * 0.6, cy + s), (cx - s * 0.8, cy)],
|
||||
'right': [(cx - s * 0.6, cy + s), (cx - s * 0.6, cy - s), (cx + s * 0.8, cy)]}[d])
|
||||
|
||||
def one(key, advance):
|
||||
def draw(pen):
|
||||
side = min(advance - 80, Y1 - Y0)
|
||||
key_cap(pen, (advance - side) / 2, Y0, (advance + side) / 2, Y1, key)
|
||||
return advance, draw
|
||||
|
||||
def grid(cells, advance):
|
||||
"""Small keys in a grid: cells = [(key, column, row)], row 0 on top."""
|
||||
def draw(pen):
|
||||
cols, rows = max(c for _, c, _ in cells) + 1, max(r for _, _, r in cells) + 1
|
||||
gap = 24; size = min((advance - 60 - gap * (cols - 1)) / cols, (Y1 - Y0 - gap * (rows - 1)) / rows)
|
||||
left, top = (advance - cols * size - gap * (cols - 1)) / 2, CY + (rows * size + gap * (rows - 1)) / 2
|
||||
for key, c, r in cells:
|
||||
x, y = left + c * (size + gap), top - (r + 1) * size - r * gap
|
||||
key_cap(pen, x, y, x + size, y + size, key)
|
||||
return advance, draw
|
||||
|
||||
def mouse_move(advance, *dirs):
|
||||
"""The mouse, no button pressed, with an arrow for each direction the prompt means."""
|
||||
def draw(pen):
|
||||
mouse_body(pen, advance / 2, Y0 + 30, Y1 - 30)
|
||||
off = (Y1 - Y0 - 60) * 0.33 + 70
|
||||
spots = {'up': (advance / 2 + off, CY + 110), 'down': (advance / 2 + off, CY - 110),
|
||||
'left': (advance / 2 - off, CY), 'right': (advance / 2 + off, CY)}
|
||||
for d in dirs:
|
||||
arrow(pen, *spots[d], d, 52)
|
||||
return advance, draw
|
||||
|
||||
b = binds
|
||||
up, left, down, right = b['LSTICK_UP'], b['LSTICK_LEFT'], b['LSTICK_DOWN'], b['LSTICK_RIGHT']
|
||||
du, dl, dd, dr = b['UP'], b['LEFT'], b['DOWN'], b['RIGHT']
|
||||
return {
|
||||
**{cp: one(b[name], adv[cp]) for cp, name in ((0xE040, 'A'), (0xE041, 'B'), (0xE042, 'X'), (0xE043, 'Y'),
|
||||
(0xE044, 'L'), (0xE045, 'R'), (0xE046, 'ZL'), (0xE047, 'ZR'),
|
||||
(0xE04B, 'PLUS'), (0xE04C, 'MINUS'), (0xE056, 'LCLICK'),
|
||||
(0xE057, 'RCLICK'))},
|
||||
# the d-pad: its four keys as a cross (up on top), or the keys of the directions the prompt means
|
||||
0xE049: grid([(du, 1, 0), (dl, 0, 1), (dd, 1, 1), (dr, 2, 1)], adv[0xE049]),
|
||||
0xE060: one(du, adv[0xE060]), 0xE061: one(dd, adv[0xE061]), 0xE062: one(dl, adv[0xE062]),
|
||||
0xE063: one(dr, adv[0xE063]), 0xE064: grid([(du, 0, 0), (dd, 0, 1)], adv[0xE064]),
|
||||
0xE065: grid([(dl, 0, 0), (dr, 1, 0)], adv[0xE065]),
|
||||
# the left stick: its keys (W A S D)
|
||||
0xE05E: grid([(up, 1, 0), (left, 0, 1), (down, 1, 1), (right, 2, 1)], adv[0xE05E]),
|
||||
0xE052: one(up, adv[0xE052]), 0xE050: one(down, adv[0xE050]), 0xE05A: one(left, adv[0xE05A]),
|
||||
0xE058: one(right, adv[0xE058]), 0xE05C: grid([(up, 0, 0), (down, 0, 1)], adv[0xE05C]),
|
||||
0xE054: grid([(left, 0, 0), (right, 1, 0)], adv[0xE054]),
|
||||
# the right stick: the mouse
|
||||
0xE05F: mouse_move(adv[0xE05F]), 0xE053: mouse_move(adv[0xE053], 'up'),
|
||||
0xE051: mouse_move(adv[0xE051], 'down'), 0xE05B: mouse_move(adv[0xE05B], 'left'),
|
||||
0xE059: mouse_move(adv[0xE059], 'right'), 0xE05D: mouse_move(adv[0xE05D], 'up', 'down'),
|
||||
0xE055: mouse_move(adv[0xE055], 'left', 'right'),
|
||||
# the right stick shaken: the shake key
|
||||
0xE068: one(b['SHAKE'], adv[0xE068]),
|
||||
}
|
||||
|
||||
|
||||
def redraw_font(otf, icons, swaps=()):
|
||||
"""The OpenType font otf with the glyphs of each (code point, code point) pair of swaps trading places (outlines and
|
||||
horizontal metrics), then every code point of icons (code point -> (advance, draw)) drawn anew, written again by
|
||||
|
||||
@@ -164,6 +164,8 @@ class Main:
|
||||
def __init__(self, path):
|
||||
n = Nso(str(path))
|
||||
self.image = bytes(n.image)
|
||||
n.image = self.image # one copy of the image; tools/exefs_patches.py reads nso's dynamic info
|
||||
self.nso = n
|
||||
self.text = n.text # (offset, size)
|
||||
self.code_end = n.rodata[0] # .text and its padding up to .rodata, where code caves go
|
||||
self.lifter = Lifter(BASE, 'c')
|
||||
|
||||
+256
-44
@@ -1,11 +1,16 @@
|
||||
"""ExeFS patches of the mods folder to code patch sets: IPS, IPS32 and IPSwitch (.pchtxt) files for the game's main.
|
||||
"""ExeFS patches of the mods folder to code patch sets and data writes: IPS, IPS32 and IPSwitch (.pchtxt) files for
|
||||
the game's main.
|
||||
|
||||
Lifted code cannot be byte-patched when the game runs, so a patch file's writes into main's code become `site` lines
|
||||
(recomp/patches.py), one patch set per file, which the runtime switches on while its mod is on (runtime/mods.c). The
|
||||
writes go through the same rules as the mods' code cheats (tools/cheats.py convert): an instruction replaced in place,
|
||||
a branch into a code cave the file writes into main's zero padding inlined at the branch.
|
||||
a branch into a code cave the file writes into main's zero padding inlined at the branch. Writes into main's data
|
||||
(.rodata, .data, .bss) need no lifting: the runtime stores them into the loaded module before the game runs, as
|
||||
emulators and Atmosphere apply a patch file to the module before its dynamic linker runs (data_runs).
|
||||
|
||||
python tools/exefs_patches.py --exefs <dir> --mods <mods folder> --patches <patch file> --out <patch file> --status <file>
|
||||
python tools/exefs_patches.py --exefs <dir> --mods <mods folder> --patches <patch file> --out <patch file>
|
||||
--status <file> --data <file>
|
||||
python tools/exefs_patches.py --exefs <dir> --status <file> --data <file> --folded <gen>/main/folded.txt
|
||||
|
||||
The files are <mod>/exefs/*.ips and *.pchtxt (or the Atmosphere places of the exefs folder, as runtime/mods.c finds
|
||||
them) and <mod>/atmosphere/exefs_patches/<name>/*.ips. A file is for main when its build id matches main's: an IPS
|
||||
@@ -13,19 +18,37 @@ file's name, a pchtxt file's @nsobid line (trailing zeros may be left out). Offs
|
||||
offsets count the NSO header (0x100 bytes) before main's image; a pchtxt offset adds its `@flag offset_shift` first.
|
||||
A pchtxt patch line applies between @enabled and @disabled; @stop ends the file.
|
||||
|
||||
--out gets the --patches file followed by one set per usable file (`mod_<hash>`, switch TOTK_MODPATCH_<HASH>, default
|
||||
off) and its sites. --status gets one line per file, mods by name, tab separated:
|
||||
--out gets the --patches file followed by one set per file with sites (`mod_<hash>`, switch TOTK_MODPATCH_<HASH>,
|
||||
default off) and its sites. --status gets one line per file, mods by name, tab separated:
|
||||
|
||||
<file hash> <content hash> <kind> <built-in sets> <note>
|
||||
|
||||
file hash: FNV-1a 32 of "<mod folder name>/<path in the mod>" (forward slashes); content hash: the same over the file's
|
||||
bytes, so the runtime sees a file that changed after the build. kind:
|
||||
code a set of sites
|
||||
code a set of sites, and data writes when the file has some
|
||||
data data writes only
|
||||
other for another game version or module: not used
|
||||
empty no write into main for this version (or only the game's own words)
|
||||
unsupported <note> says why: a write into main's data, a write the site rules cannot express
|
||||
empty no write into main for this version (or only the game's own bytes)
|
||||
unsupported <note> says why: a write the site rules cannot express, a write outside main's image, into the dynamic
|
||||
linker's tables or into a pointer it binds lazily, or (after the lift) into bytes the lifted code reads
|
||||
as constants
|
||||
built-in sets: the port's own sets (the --patches file's sets that are no cheats) with a site this file patches too,
|
||||
comma separated, or "-": the runtime switches them off while the mod is on, so the mod's code runs.
|
||||
comma separated, or "-": the runtime switches them off while the mod is on, so the mod's code runs. The note of a code
|
||||
or data file with data writes is `data <bytes>`, the bytes its data lines hold: the runtime keeps the mod's patches off
|
||||
when the data file does not hold them all.
|
||||
|
||||
--data gets one line per run of data bytes of the code and data files, tab separated (runtime/mods.c reads it):
|
||||
|
||||
<file hash> <content hash> <offset in main, hex> <bytes, hex> [base]
|
||||
|
||||
`base` marks a DT_RELR word: 8 bytes, little-endian, the word before relocation; the runtime adds main's base, as the
|
||||
dynamic linker does. Without --data a data write makes its file unsupported.
|
||||
|
||||
The lifted code reads some of main's .text and .rodata at lift time instead of at run time: the jump tables
|
||||
(recomp/jumptables.py), whose targets become labels of the generated C. A data write over such bytes would not reach
|
||||
the lifted code. recomp/gen.py lists the ranges it read in <gen>/<module>/folded.txt; the second form above, run after
|
||||
the lift, makes every file with a data write into those ranges unsupported (its status line says so and its data lines
|
||||
go). Without that list, every data write into .rodata counts as one into read bytes.
|
||||
|
||||
A file the rules cannot express is unsupported as a whole: half of a patch is worse than none.
|
||||
|
||||
@@ -39,9 +62,12 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from cheats import TITLE, Main, convert, fnv # noqa: E402
|
||||
from nso import (DT_JMPREL, DT_NULL, DT_PLTRELSZ, DT_RELA, DT_RELASZ, DT_RELR, DT_RELRSZ, DT_STRSZ, # noqa: E402
|
||||
DT_STRTAB, DT_SYMTAB, R_AARCH64_JUMP_SLOT)
|
||||
|
||||
NSO_HEADER = 0x100
|
||||
EXEFS_PLACES = ('exefs', f'atmosphere/contents/{TITLE}/exefs', f'contents/{TITLE}/exefs', f'{TITLE}/exefs')
|
||||
RUN_MAX = 256 # bytes per line of the data file (runtime/mods.c reads lines of at most 1024 characters)
|
||||
|
||||
|
||||
def patch_files(mods):
|
||||
@@ -159,6 +185,99 @@ def word_writes(main, writes):
|
||||
return words
|
||||
|
||||
|
||||
# ---- writes into data
|
||||
|
||||
def relocated(n):
|
||||
"""({offset: type} of the words main's explicit-addend relocations (DT_RELA, DT_JMPREL) fill, {offset} of its
|
||||
DT_RELR words): the 8-byte words the dynamic linker writes at start (runtime/loader.c relocate_table,
|
||||
relocate_relr)."""
|
||||
rela = {}
|
||||
for table, size in ((DT_RELA, DT_RELASZ), (DT_JMPREL, DT_PLTRELSZ)):
|
||||
if table in n.dyn:
|
||||
for i in range(n.dyn[size] // 24):
|
||||
off, info, _ = struct.unpack_from('<QQq', n.image, n.dyn[table] + 24 * i)
|
||||
rela[off] = info & 0xFFFFFFFF
|
||||
relr, where = set(), 0
|
||||
for i in range(n.dyn.get(DT_RELRSZ, 0) // 8):
|
||||
e = struct.unpack_from('<Q', n.image, n.dyn[DT_RELR] + 8 * i)[0]
|
||||
if not e & 1: # an address; the bitmap after it covers the 63 words that follow
|
||||
relr.add(e)
|
||||
where = e + 8
|
||||
else:
|
||||
relr.update(where + 8 * k for k in range(63) if e >> (k + 1) & 1)
|
||||
where += 63 * 8
|
||||
return rela, relr
|
||||
|
||||
|
||||
def linker_tables(n):
|
||||
"""[(start, end)] of main's .dynamic, relocation, symbol and string tables: the dynamic linker reads them at start,
|
||||
so a write into them would change what it does, which no store into the loaded module can follow."""
|
||||
end = n.dynamic
|
||||
while struct.unpack_from('<Q', n.image, end)[0] != DT_NULL:
|
||||
end += 16
|
||||
out = [(n.dynamic, end + 16)]
|
||||
for table, size in ((DT_RELA, DT_RELASZ), (DT_JMPREL, DT_PLTRELSZ), (DT_RELR, DT_RELRSZ), (DT_STRTAB, DT_STRSZ)):
|
||||
if table in n.dyn and size in n.dyn:
|
||||
out.append((n.dyn[table], n.dyn[table] + n.dyn[size]))
|
||||
if DT_SYMTAB in n.dyn:
|
||||
out.append((n.dyn[DT_SYMTAB], n.dyn[DT_SYMTAB] + 24 * n.symbol_count()))
|
||||
return out
|
||||
|
||||
|
||||
class MainData:
|
||||
"""What a write into main's data meets: main's image from .rodata to the end of .bss, the words the dynamic linker
|
||||
fills and the tables it reads."""
|
||||
|
||||
def __init__(self, main):
|
||||
n = main.nso
|
||||
self.image, self.lo, self.size, self.data = main.image, main.code_end, n.size, n.data[0]
|
||||
self.rela, self.relr = relocated(n)
|
||||
self.linker = linker_tables(n)
|
||||
|
||||
|
||||
def data_runs(md, writes):
|
||||
"""The data part of one file ({offset: byte} outside main's code), applied as emulators and Atmosphere apply a patch
|
||||
file: to the module before its dynamic linker runs. Returns ([(offset, bytes, rebase)], dropped):
|
||||
|
||||
- a byte no relocation covers is stored as it is (runs of consecutive bytes, at most RUN_MAX long);
|
||||
- a DT_RELR word holds its addend: the word with the file's bytes in it, rebase set (main's base gets added);
|
||||
- the linker overwrites a word an explicit-addend relocation fills whatever the file wrote there: such bytes
|
||||
change nothing and only count in dropped.
|
||||
|
||||
Bytes equal to main's are left out. ValueError for a write outside main's image (below .rodata: the NSO header),
|
||||
into the dynamic linker's tables, or into a function pointer it binds lazily (JUMP_SLOT): on the Switch it holds
|
||||
the file's value plus main's base until the first call binds it, which no store before the start can reproduce."""
|
||||
plain, words, dropped = {}, {}, 0
|
||||
for off in sorted(writes):
|
||||
if not md.lo <= off < md.size:
|
||||
raise ValueError(f'it writes outside main\'s image at main+{off:X}')
|
||||
if any(a <= off < b for a, b in md.linker):
|
||||
raise ValueError(f'it writes the dynamic linker\'s tables at main+{off:X}')
|
||||
w = next((w for w in range(off - 7, off + 1) if w in md.rela or w in md.relr), None)
|
||||
if w is None:
|
||||
plain[off] = writes[off]
|
||||
elif w in md.relr:
|
||||
words.setdefault(w, bytearray(md.image[w:w + 8]))[off - w] = writes[off]
|
||||
elif md.rela[w] == R_AARCH64_JUMP_SLOT:
|
||||
raise ValueError(f'it writes a function pointer the dynamic linker binds lazily at main+{w:X}')
|
||||
else:
|
||||
dropped += 1
|
||||
runs = []
|
||||
for off in sorted(o for o, b in plain.items() if b != md.image[o]):
|
||||
last = runs[-1] if runs else None
|
||||
if last and last[0] + len(last[1]) == off and len(last[1]) < RUN_MAX:
|
||||
runs[-1] = (last[0], last[1] + bytes([plain[off]]), False)
|
||||
else:
|
||||
runs.append((off, bytes([plain[off]]), False))
|
||||
runs += [(w, bytes(b), True) for w, b in words.items() if bytes(b) != md.image[w:w + 8]]
|
||||
return sorted(runs), dropped
|
||||
|
||||
|
||||
def data_line(key, content, run):
|
||||
off, data, rebase = run
|
||||
return f'{key}\t{content}\t{off:x}\t{data.hex()}' + ('\tbase' if rebase else '')
|
||||
|
||||
|
||||
def builtin_sites(text):
|
||||
"""{offset in main: [set name]} of the patch file's sets that are no cheats or mod patches."""
|
||||
out = {}
|
||||
@@ -169,41 +288,45 @@ def builtin_sites(text):
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument('--exefs', type=Path, required=True)
|
||||
ap.add_argument('--mods', type=Path, required=True, help='the mods folder next to totk.exe')
|
||||
ap.add_argument('--patches', type=Path, required=True, help='the patch file so far')
|
||||
ap.add_argument('--out', type=Path, required=True, help='the patch file with the mods\' ExeFS patches added')
|
||||
ap.add_argument('--status', type=Path, required=True)
|
||||
args = ap.parse_args()
|
||||
sys.stdout.reconfigure(errors='replace')
|
||||
def classify(m, md, bid, path, raw):
|
||||
"""(kind, note, sites, runs, dropped) of one patch file: path gives its name and suffix, raw its bytes, bid main's
|
||||
build id; md None: no data file, so a write into main's data makes the file unsupported."""
|
||||
try:
|
||||
if path.suffix.lower() == '.ips':
|
||||
for_main = same_build(path.stem, bid)
|
||||
writes = parse_ips(raw) if for_main else {}
|
||||
else:
|
||||
pid, writes = parse_pchtxt(raw.decode('utf-8-sig', errors='replace'))
|
||||
for_main = pid is not None and same_build(pid, bid)
|
||||
if not for_main:
|
||||
return 'other', '', {}, [], 0
|
||||
code = {o: b for o, b in writes.items() if m.in_code(o - o % 4)}
|
||||
data = {o: b for o, b in writes.items() if o not in code}
|
||||
if data and md is None:
|
||||
raise ValueError(f'it writes main\'s data at main+{min(data):X} (only code patches are supported)')
|
||||
sites = convert(m, word_writes(m, code)) if code else {}
|
||||
runs, dropped = data_runs(md, data) if data else ([], 0)
|
||||
return 'code' if sites else 'data' if runs else 'empty', '', sites, runs, dropped
|
||||
except ValueError as e:
|
||||
return 'unsupported', str(e), {}, [], 0
|
||||
|
||||
|
||||
DATA_HEADER = '# file hash, content hash, offset in main, bytes[, base] (tools/exefs_patches.py)'
|
||||
|
||||
|
||||
def build(args):
|
||||
m = Main(args.exefs / 'main')
|
||||
raw = (args.exefs / 'main').read_bytes()
|
||||
bid = raw[0x40:0x60].hex()
|
||||
md = MainData(m) if args.data else None
|
||||
bid = m.nso.build_id
|
||||
text = args.patches.read_text()
|
||||
builtin = builtin_sites(text)
|
||||
files = patch_files(args.mods)
|
||||
sets, sites, status, kinds = [], [], [], {}
|
||||
sets, sites, status, data_lines, kinds = [], [], [], [], {}
|
||||
for mod, rel, f in files:
|
||||
data = f.read_bytes()
|
||||
key, content = fnv(f'{mod}/{rel}'.encode()), fnv(data)
|
||||
kind, note, over, file_sites = 'code', '', [], {}
|
||||
try:
|
||||
if f.suffix.lower() == '.ips':
|
||||
for_main = same_build(f.stem, bid)
|
||||
writes = parse_ips(data) if for_main else {}
|
||||
else:
|
||||
pid, writes = parse_pchtxt(data.decode('utf-8-sig', errors='replace'))
|
||||
for_main = pid is not None and same_build(pid, bid)
|
||||
if not for_main:
|
||||
kind = 'other'
|
||||
else:
|
||||
file_sites = convert(m, word_writes(m, writes))
|
||||
if not file_sites:
|
||||
kind = 'empty'
|
||||
except ValueError as e:
|
||||
kind, note, file_sites = 'unsupported', str(e), {}
|
||||
raw = f.read_bytes()
|
||||
key, content = fnv(f'{mod}/{rel}'.encode()), fnv(raw)
|
||||
kind, note, file_sites, runs, dropped = classify(m, md, bid, f, raw)
|
||||
over = []
|
||||
if file_sites:
|
||||
name = f'mod_{key.lower()}'
|
||||
label = f'{mod}/{rel}'.encode('ascii', 'replace').decode()
|
||||
@@ -211,19 +334,108 @@ def main():
|
||||
for a, repl in sorted(file_sites.items()):
|
||||
sites.append(f'site {name} main {a:x} {m.word(a):08x} {" ".join(f"{w:08x}" for w in repl)}')
|
||||
over += [s for s in builtin.get(a, []) if s not in over]
|
||||
data_lines += [data_line(key, content, r) for r in runs]
|
||||
data_bytes = sum(len(r[1]) for r in runs)
|
||||
kinds[kind] = kinds.get(kind, 0) + 1
|
||||
status.append(f'{key}\t{content}\t{kind}\t{",".join(over) or "-"}\t{note.replace(chr(9), " ")}')
|
||||
print(f'exefs: {kind:11} {mod}/{rel}' + (f' ({note})' if note else '')
|
||||
+ (f' ({len(file_sites)} sites, over {", ".join(over)})' if over else
|
||||
f' ({len(file_sites)} sites)' if file_sites else ''))
|
||||
status_note = f'data {data_bytes}' if runs else note.replace(chr(9), ' ')
|
||||
status.append(f'{key}\t{content}\t{kind}\t{",".join(over) or "-"}\t{status_note}')
|
||||
parts = [note] if note else []
|
||||
if file_sites:
|
||||
parts.append(f'{len(file_sites)} sites' + (f', over {", ".join(over)}' if over else ''))
|
||||
if runs:
|
||||
parts.append(f'{data_bytes} bytes of main\'s data')
|
||||
if dropped:
|
||||
parts.append(f'{dropped} bytes into words the dynamic linker fills, which change nothing')
|
||||
print(f'exefs: {kind:11} {mod}/{rel}' + (f' ({"; ".join(parts)})' if parts else ''))
|
||||
if sets:
|
||||
text += ('\n# ExeFS patches of the mods folder (tools/exefs_patches.py)\n' + '\n'.join(sets) + '\n'
|
||||
+ '\n'.join(sites) + '\n')
|
||||
args.out.write_text(text)
|
||||
args.status.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.status.write_text('\n'.join(status) + ('\n' if status else ''), encoding='utf-8')
|
||||
if args.data:
|
||||
args.data.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.data.write_text('\n'.join([DATA_HEADER] + data_lines) + '\n', encoding='utf-8')
|
||||
print(f'exefs: {len(files)} patch files: ' + ', '.join(f'{n} {k}' for k, n in sorted(kinds.items()))
|
||||
+ f'; {len(sites)} sites')
|
||||
+ f'; {len(sites)} sites, {len(data_lines)} data runs')
|
||||
|
||||
|
||||
def read_ranges(path):
|
||||
"""[(start, end)] of a folded.txt (recomp/gen.py): hex module offsets, one range per line, '#' comments."""
|
||||
out = []
|
||||
for line in path.read_text().splitlines():
|
||||
f = line.split('#', 1)[0].split()
|
||||
if len(f) == 2:
|
||||
out.append((int(f[0], 16), int(f[1], 16)))
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def data_offset(main_path):
|
||||
"""The offset of main's .data segment in its image, from the NSO header (no decompression)."""
|
||||
with open(main_path, 'rb') as f:
|
||||
head = f.read(0x40)
|
||||
return struct.unpack_from('<III', head, 0x30)[1]
|
||||
|
||||
|
||||
def recheck(args):
|
||||
"""After the lift: every file with a data write into bytes the lifted code read at lift time becomes unsupported."""
|
||||
if not args.status.is_file() or not args.data.is_file():
|
||||
print('exefs: no status or data file: nothing to check')
|
||||
return
|
||||
folded = read_ranges(args.folded) if args.folded.is_file() else None
|
||||
data_off = data_offset(args.exefs / 'main')
|
||||
lines = args.data.read_text(encoding='utf-8').splitlines()
|
||||
bad = {}
|
||||
for line in lines:
|
||||
f = line.split('\t')
|
||||
if line.startswith('#') or len(f) < 4 or f[0] in bad:
|
||||
continue
|
||||
a = int(f[2], 16)
|
||||
b = a + len(f[3]) // 2
|
||||
if folded is None and a < data_off:
|
||||
bad[f[0]] = (f'it writes main+{a:X} in .rodata, and the lift left no list of the bytes it reads as '
|
||||
f'constants')
|
||||
elif folded is not None:
|
||||
hit = next((s for s, e in folded if s < b and a < e), None)
|
||||
if hit is not None:
|
||||
bad[f[0]] = (f'it writes main+{max(a, hit):X}, which the lifted code reads when the game is built '
|
||||
f'(a jump table): the change would not reach it')
|
||||
if not bad:
|
||||
print(f'exefs: data writes checked against {"the bytes the lift read" if folded is not None else ".rodata"}: '
|
||||
f'none meets them')
|
||||
return
|
||||
status = []
|
||||
for line in args.status.read_text(encoding='utf-8').splitlines():
|
||||
f = line.split('\t')
|
||||
if len(f) >= 5 and f[0] in bad:
|
||||
line = '\t'.join([f[0], f[1], 'unsupported', '-', bad[f[0]]])
|
||||
print(f'exefs: unsupported file {f[0]} ({bad[f[0]]})')
|
||||
status.append(line)
|
||||
args.status.write_text('\n'.join(status) + '\n', encoding='utf-8')
|
||||
kept = [line for line in lines if line.split('\t')[0] not in bad]
|
||||
args.data.write_text('\n'.join(kept) + '\n', encoding='utf-8')
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument('--exefs', type=Path, required=True)
|
||||
ap.add_argument('--mods', type=Path, help='the mods folder next to totk.exe')
|
||||
ap.add_argument('--patches', type=Path, help='the patch file so far')
|
||||
ap.add_argument('--out', type=Path, help='the patch file with the mods\' ExeFS patches added')
|
||||
ap.add_argument('--status', type=Path, required=True)
|
||||
ap.add_argument('--data', type=Path, help='the data writes of the mods\' patch files (runtime/mods.c reads them)')
|
||||
ap.add_argument('--folded', type=Path, help='after the lift: <gen>/main/folded.txt; checks --status and --data '
|
||||
'against it instead of building')
|
||||
args = ap.parse_args()
|
||||
sys.stdout.reconfigure(errors='replace')
|
||||
if args.folded:
|
||||
if not args.data:
|
||||
ap.error('--folded needs --data')
|
||||
recheck(args)
|
||||
else:
|
||||
if not (args.mods and args.patches and args.out):
|
||||
ap.error('--mods, --patches and --out are needed to build')
|
||||
build(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+46
-11
@@ -15,6 +15,8 @@ cached in toolchain/release-dl/ and checked against the sha256 in PINS; a mismat
|
||||
libraries of SDL3, Opus, libvpx, Vulkan and VMA)
|
||||
bin/ totk_xlate.exe (+ .id), hactool.exe, SDL3.dll (upstream), libopus-0.dll, ninja.exe
|
||||
recomp/, tools/, data/, installer/, licenses/, THIRD_PARTY_NOTICES.md, totk.ini.example
|
||||
data/pgo-<v>.profdata build/pgo's execution profile of an update (tools/pgo.py) and its .json record: the
|
||||
install compiles that update's code and the runtime libraries with it
|
||||
VERSION the repository's VERSION plus the commit, 0.1.0+973c626; the installer's release string
|
||||
TotK-Setup.exe installer/launcher/launcher.c built with llvm/ (-mwindows): starts python/pythonw.exe -m installer.gui
|
||||
|
||||
@@ -27,9 +29,11 @@ runtime library reads only what both versions' layout.h share (MOD_COUNT, MOD_MA
|
||||
|
||||
A release also needs build/xlate built from the current sources (tools/build_xlate.py); the script stops first if not.
|
||||
|
||||
The folder contains nothing derived from the game: check_content runs over it before the script finishes. Then the
|
||||
folder's own python runs a check-only installer pass (no game files: it must get through the release, toolchain and host
|
||||
checks and stop at the missing game files) and the window's --selftest.
|
||||
The folder contains no code, data, text or assets of the game: check_content runs over it before the script finishes.
|
||||
The execution profiles name the lifted functions by their address (f_<address>) and hold branch counts and
|
||||
control-flow hashes of the generated C, nothing of the game's own bytes. Then the folder's own python runs a check-only
|
||||
installer pass (no game files: it must get through the release, toolchain and host checks and stop at the missing game
|
||||
files) and the window's --selftest.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
@@ -47,6 +51,7 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import build_game # noqa: E402
|
||||
import build_xlate # noqa: E402
|
||||
import check_content # noqa: E402
|
||||
import shader_list # noqa: E402
|
||||
@@ -55,6 +60,7 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
MAIN_ROOT = (ROOT / 'build').resolve().parent # build/, generated/, toolchain/ and refs/ are junctions in a worktree
|
||||
DL = MAIN_ROOT / 'toolchain' / 'release-dl'
|
||||
WORK = MAIN_ROOT / 'toolchain' / 'release-work' # unpacked downloads
|
||||
PGO_DIR = MAIN_ROOT / 'build' / 'pgo' # pgo-<version>.profdata + .json (tools/pgo.py merge)
|
||||
|
||||
MSYS2 = 'https://repo.msys2.org/mingw/ucrt64/'
|
||||
SDL_VERSION = '3.4.18'
|
||||
@@ -378,28 +384,50 @@ def lifted_version():
|
||||
'(tools/extract.py, recomp/gen.py; docs/building.md)')
|
||||
|
||||
|
||||
def runtime_lib(out, llvm):
|
||||
def profiles():
|
||||
"""{version tag: .profdata} of build/pgo (tools/pgo.py merge). A profile trained on another recomp/ than the one
|
||||
shipped still works but misses the functions whose generated C changed since: warn, so the next release retrains."""
|
||||
tree = subprocess.run(['git', '-C', str(ROOT), 'rev-parse', 'HEAD:recomp'], capture_output=True, text=True).stdout.strip()
|
||||
dirty = subprocess.run(['git', '-C', str(ROOT), 'status', '--porcelain', '--', 'recomp'], capture_output=True,
|
||||
text=True).stdout.strip()
|
||||
found = {}
|
||||
for p in sorted(PGO_DIR.glob('pgo-*.profdata')):
|
||||
rec = p.with_suffix('.json')
|
||||
record = json.loads(rec.read_text(encoding='utf-8')) if rec.is_file() else {}
|
||||
if record.get('recomp_tree') != tree or dirty:
|
||||
print(f'make_release: warning: {p.name} was trained on recomp tree {record.get("recomp_tree", "unknown")[:12]}, '
|
||||
f'this is {tree[:12]}{" with local changes" if dirty else ""}: functions whose generated C changed '
|
||||
'build without a profile; retrain (docs/building.md, "Profile-guided build")', file=sys.stderr)
|
||||
found[p.stem.removeprefix('pgo-')] = p
|
||||
if not found:
|
||||
print(f'make_release: no execution profile in {PGO_DIR}: installs build the game at -O1', file=sys.stderr)
|
||||
return found
|
||||
|
||||
|
||||
def runtime_lib(out, llvm, pgo):
|
||||
"""Build libtotkrt.a (x86-64-v3) and libtotkrt-v2.a (x86-64-v2) with the folder's llvm/, and copy them, the headers
|
||||
and the install-side sources into runtime/."""
|
||||
and the install-side sources into runtime/. pgo: profiles() of the release."""
|
||||
version = lifted_version()
|
||||
gen = MAIN_ROOT / 'generated' / version
|
||||
rt = out / 'runtime'
|
||||
# the runtime is the same for every update: any update's profile covers it (the newest update's, as players run it)
|
||||
newest = sorted(pgo.items())[-1:]
|
||||
for cpu, name in (('v3', 'libtotkrt.a'), ('v2', 'libtotkrt-v2.a')):
|
||||
build = MAIN_ROOT / 'build' / 'release-check' / f'rtlib-{cpu}'
|
||||
run([sys.executable, ROOT / 'tools' / 'build_game.py', '--make-runtime-lib', '--toolchain', llvm, '--cpu', cpu,
|
||||
'--version', version, '--deps', out / 'runtime' / 'deps', '--ninja', out / 'bin' / 'ninja.exe',
|
||||
'--gen', gen, '--out', build],
|
||||
'--gen', gen, '--out', build, *(['--pgo', newest[0][1]] if newest else [])],
|
||||
env=clean_env(llvm / 'bin', out / 'bin'))
|
||||
shutil.copy2(build / 'libtotkrt.a', rt / name)
|
||||
for p in sorted((ROOT / 'runtime').rglob('*')):
|
||||
if p.suffix in RUNTIME_SUFFIXES and p.is_file():
|
||||
(rt / p.relative_to(ROOT / 'runtime')).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(p, rt / p.relative_to(ROOT / 'runtime'))
|
||||
for name in ('romfs.c', 'patches.c', 'ov_zstd.c'): # build_game.py INSTALL_SIDE
|
||||
for name in sorted(build_game.INSTALL_SIDE): # compiled at install, so left out of libtotkrt.a
|
||||
shutil.copy2(ROOT / 'runtime' / name, rt / name)
|
||||
|
||||
|
||||
def build_sources(out):
|
||||
def build_sources(out, pgo):
|
||||
copy_tree(ROOT / 'recomp', out / 'recomp', lambda r: not r.endswith(('.py', '.txt')) or '__pycache__' in r)
|
||||
for f in TOOLS_FILES:
|
||||
(out / 'tools').mkdir(exist_ok=True)
|
||||
@@ -409,6 +437,10 @@ def build_sources(out):
|
||||
(out / 'data').mkdir(exist_ok=True)
|
||||
text = shader_list.history(MAIN_ROOT / 'refs' / 'MacNativeGames', MNG_COMMIT)
|
||||
(out / 'data' / 'shader_list.tsv').write_text(text, encoding='utf-8', newline='\n')
|
||||
for p in pgo.values():
|
||||
for f in (p, p.with_suffix('.json')):
|
||||
if f.is_file():
|
||||
shutil.copy2(f, out / 'data' / f.name)
|
||||
copy_tree(ROOT / 'installer', out / 'installer', lambda r: '__pycache__' in r or r.startswith('launcher/'))
|
||||
shutil.copy2(ROOT / 'totk.ini.example', out / 'totk.ini.example')
|
||||
shutil.copy2(ROOT / 'THIRD_PARTY_NOTICES.md', out / 'THIRD_PARTY_NOTICES.md')
|
||||
@@ -527,12 +559,14 @@ def prove(out, cpu='v3'):
|
||||
ext = MAIN_ROOT / 'build' / 'extracted' / version
|
||||
chk = MAIN_ROOT / 'build' / 'release-check'
|
||||
env = clean_env(out / 'python', out / 'llvm' / 'bin', out / 'bin')
|
||||
pgo = out / 'data' / f'pgo-{version}.profdata'
|
||||
t0 = time.time()
|
||||
run([out / 'python' / 'python.exe', out / 'tools' / 'build_game.py', '--toolchain', out / 'llvm', '--cpu', cpu,
|
||||
'--deps', out / 'runtime' / 'deps', '--runtime', out / 'runtime',
|
||||
'--runtime-lib', out / 'runtime' / ('libtotkrt-v2.a' if cpu == 'v2' else 'libtotkrt.a'),
|
||||
'--ninja', out / 'bin' / 'ninja.exe', '--version', version, '--gen', gen, '--out', chk, '--exefs', ext / 'exefs',
|
||||
'--romfs', ext / 'romfs', '--profile', chk / 'profile'], env=env)
|
||||
'--romfs', ext / 'romfs', '--profile', chk / 'profile', *(['--pgo', pgo] if pgo.is_file() else [])],
|
||||
env=env)
|
||||
print(f'prove: build {time.time() - t0:.0f} s')
|
||||
for dll in ('SDL3.dll', 'libopus-0.dll'):
|
||||
shutil.copy2(out / 'bin' / dll, chk / dll)
|
||||
@@ -568,8 +602,9 @@ def main():
|
||||
llvm = build_llvm(out)
|
||||
build_bin(out)
|
||||
build_deps(out)
|
||||
runtime_lib(out, llvm)
|
||||
build_sources(out)
|
||||
pgo = profiles()
|
||||
runtime_lib(out, llvm, pgo)
|
||||
build_sources(out, pgo)
|
||||
build_launcher(out, llvm)
|
||||
collect_licenses(out)
|
||||
check_folder(out)
|
||||
|
||||
@@ -579,7 +579,7 @@ def main():
|
||||
rtlib = runtime_lib(out, llvm, sysroot)
|
||||
build_sysroot(out, sysroot, used_sysroot_files(out, llvm, sysroot, rtlib))
|
||||
build_python(out, llvm, sysroot)
|
||||
mr.build_sources(out)
|
||||
mr.build_sources(out, {}) # no profiles: the Linux setup compiles without one (installer/layout.py, pgo_dir)
|
||||
collect_licenses(out, sysroot)
|
||||
build_entry(out)
|
||||
mr.check_folder(out)
|
||||
|
||||
+27
-10
@@ -38,6 +38,11 @@ from the game's own files on the player's machine, nothing added (tools/button_i
|
||||
every pad not labelled the Nintendo way), so the face buttons keep their letters; the action prompt's diamond keeps
|
||||
the Switch positions (A on the right).
|
||||
--xbox-prompts is the older name of --prompts xbox.
|
||||
--prompts pc: keyboard and mouse prompts, for prompts = pc (romfs-patches-pc, or romfs-patches-pc-<w>x<h> with
|
||||
--aspect): every button of the button font is drawn as the key or mouse button the keyboard defaults put on it
|
||||
(button_icons.pc_icons; --binds <totk.ini> takes the first key of its bind_ lines instead), and so are the button
|
||||
guide's images (A, B, X, Y, L, ZL and the stick's two). The one-letter panes around the action prompt name the keys
|
||||
with one character of the Normal font (E, an arrow for Shift, a bar for Space, L and R for the mouse buttons).
|
||||
|
||||
Input: build/extracted/<version>/romfs (romfs.bin and index.json, verified by tools/extract.py). Output:
|
||||
build/<version>/romfs-patches/<path> by default (romfs-patches-<style> with --prompts), and <out>/manifest.tsv (path, size, SHA-256 of the overlay, SHA-256 of the original). Run with .venv-py314.
|
||||
@@ -58,9 +63,11 @@ ap.add_argument('--romfs', type=Path, help='default build/extracted/<version>/ro
|
||||
ap.add_argument('--out', type=Path,
|
||||
help='default build/<version>/romfs-patches (romfs-patches-<style> with --prompts)')
|
||||
ap.add_argument('--aspect', help='<w>x<h> of a game resolution of another shape than 16:9: also lay the UI out for it')
|
||||
ap.add_argument('--prompts', choices=('xbox', 'xbox-label'),
|
||||
ap.add_argument('--prompts', choices=('xbox', 'xbox-label', 'pc'),
|
||||
help='Xbox button icons in the prompts: xbox for pad_layout = nintendo (A shown as B, X as Y), '
|
||||
'xbox-label for pad_layout = xbox or auto (the face buttons keep their letters)')
|
||||
'xbox-label for pad_layout = xbox or auto (the face buttons keep their letters), '
|
||||
'pc for the keyboard and mouse')
|
||||
ap.add_argument('--binds', type=Path, help='--prompts pc: a totk.ini whose bind_ lines the key icons follow')
|
||||
ap.add_argument('--xbox-prompts', dest='prompts', action='store_const', const='xbox', help='the same as --prompts xbox')
|
||||
args = ap.parse_args()
|
||||
args.romfs = args.romfs or root / 'build/extracted' / args.version / 'romfs'
|
||||
@@ -231,9 +238,11 @@ LAYOUT_MSG = 'LayoutMsg/DoCommand_00.msbt'
|
||||
RESTBL = max((p for p in files if p.startswith('System/Resource/ResourceSizeTable.') and p.endswith('.rsizetable.zs')),
|
||||
key=lambda p: int((re.search(r'\.(\d+)\.', p) or [0, 0])[1]), default=None)
|
||||
by_position = args.prompts == 'xbox' # the face buttons' prompts swapped to the labels (pad_layout = nintendo)
|
||||
pc = args.prompts == 'pc'
|
||||
letter_panes = by_position or pc # the one-letter panes around the action prompt change
|
||||
if args.prompts:
|
||||
assert FONTS, 'no Font/Font*.Nin_NX_NVN.bfarc.zs in the RomFS'
|
||||
assert MALS or not by_position, 'no Mals/<language>.Product.<version>.sarc.zs in the RomFS'
|
||||
assert MALS or not letter_panes, 'no Mals/<language>.Product.<version>.sarc.zs in the RomFS'
|
||||
assert RESTBL, 'no System/Resource/ResourceSizeTable.Product.<n>...rsizetable.zs in the RomFS'
|
||||
spec = importlib.util.spec_from_file_location('button_icons', Path(__file__).with_name('button_icons.py'))
|
||||
icons = importlib.util.module_from_spec(spec)
|
||||
@@ -252,7 +261,7 @@ with (args.romfs / 'romfs.bin').open('rb') as image:
|
||||
for name, (lo, hi) in sarc_members(pack).items():
|
||||
d = zstd.ZstdDict(pack[lo:hi])
|
||||
dictionaries[d.dict_id] = d
|
||||
prompt_files = (FONTS + (MALS if by_position else []) + [RESTBL]) if args.prompts else []
|
||||
prompt_files = (FONTS + (MALS if letter_panes else []) + [RESTBL]) if args.prompts else []
|
||||
originals = {path: read(path) for path in [ARCHIVE] + prompt_files}
|
||||
raw = originals[ARCHIVE]
|
||||
info = zstd.get_frame_info(raw)
|
||||
@@ -297,19 +306,27 @@ if args.prompts:
|
||||
text = sarc_members(archives[icons.TEXT_ARCHIVE])
|
||||
missing = [name for name in icons.TEXT_FONTS if name not in text]
|
||||
assert not missing, f'no {", ".join(missing)} in {icons.TEXT_ARCHIVE}'
|
||||
drawn = icons.xbox_icons(icons.text_glyphs(archives[icons.TEXT_ARCHIVE][slice(*text[name])]
|
||||
for name in icons.TEXT_FONTS))
|
||||
glyphs = icons.text_glyphs(archives[icons.TEXT_ARCHIVE][slice(*text[name])] for name in icons.TEXT_FONTS)
|
||||
if pc:
|
||||
binds = icons.pc_binds(args.binds.read_text(encoding='utf-8', errors='replace') if args.binds else None)
|
||||
advances = icons.font_advances(archives[icons.TEXT_ARCHIVE][slice(*text[icons.FONT])])
|
||||
drawn = icons.pc_icons(glyphs, advances, binds)
|
||||
pane_letters = {letter: icons.pc_letter(binds[letter]) for letter in 'ABXY'}
|
||||
else:
|
||||
drawn = icons.xbox_icons(glyphs)
|
||||
pane_letters = KEY_LETTERS
|
||||
bntx = members['timg/__Combined.bntx'][0]
|
||||
letters = {}
|
||||
if by_position:
|
||||
bntx_swap(blarc, bntx, KEY_TEXTURES)
|
||||
if letter_panes:
|
||||
for member, (lo, hi) in members.items():
|
||||
if member.endswith('.bflyt'):
|
||||
changed = bflyt_letters(blarc, lo, hi, KEY_LETTERS)
|
||||
changed = bflyt_letters(blarc, lo, hi, pane_letters)
|
||||
if changed:
|
||||
letters[member] = changed
|
||||
assert 'blyt/DoCommand_00.bflyt' in letters, 'no letter panes in blyt/DoCommand_00.bflyt'
|
||||
hud = icons.bntx_redraw(blarc, bntx, icons.HUD_TEXTURES, drawn)
|
||||
hud = icons.bntx_redraw(blarc, bntx, icons.PC_HUD_TEXTURES if pc else icons.HUD_TEXTURES, drawn)
|
||||
outputs[ARCHIVE] = bytes(blarc)
|
||||
fonts = {} # each distinct font is drawn once (1.4.3's four archives hold the same one)
|
||||
for path, bfarc in archives.items():
|
||||
@@ -319,11 +336,11 @@ if args.prompts:
|
||||
fonts[otf] = icons.redraw_font(otf, drawn, KEY_GLYPHS if by_position else ())
|
||||
outputs[path] = sarc_replace(bfarc, {icons.FONT: icons.bfotf_encode(fonts[otf], key, bfarc[lo:hi])})
|
||||
messages = {}
|
||||
for path in MALS if by_position else []:
|
||||
for path in MALS if letter_panes else []:
|
||||
mals = bytearray(unpack(path))
|
||||
lo, hi = sarc_members(mals)[LAYOUT_MSG]
|
||||
msbt = bytearray(mals[lo:hi])
|
||||
changed = msbt_letters(msbt, KEY_LETTERS)
|
||||
changed = msbt_letters(msbt, pane_letters)
|
||||
assert len(changed) == sum(len(v) for v in letters.values()), (path, changed)
|
||||
mals[lo:hi] = msbt
|
||||
outputs[path] = bytes(mals)
|
||||
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user