mirror of
https://github.com/HayatoG/tlg_pc.git
synced 2026-11-07 22:54:50 -10:00
Merge pull request #145 from HayatoG/integracao
Integra todos os PRs abertos (#59 #74 #75 #77 #79 #83 #91 #92 #108 #120 #140 #141 #143 #144)
This commit is contained in:
93 files changed
+4628
-1579
No files matched your search
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: dlaa-debug
|
||||
description: Find and fix DLAA/jitter artifacts in The Last Guardian port (flicker, blocks, swimming surfaces that appear only with DLAA on). Use when the user reports a visual glitch with DLAA or temporal jitter.
|
||||
---
|
||||
|
||||
Read `port/docs/dlaa-debugging.md` first: it has the method, the live debug controls and the
|
||||
rules already in place. Then:
|
||||
|
||||
1. Ask the user to stand at the broken spot. Launch the game with `TLG_TOGGLE_FILE` and
|
||||
`TLG_JITTER_CONTROL` pointing to two files you can edit, so all tests run live without
|
||||
rebuilds.
|
||||
2. Confirm it is jitter (bit 25 in the toggle file), then bisect with the jitter control file.
|
||||
The user judges each step by eye. Keep the steps short and wait for the answer, no long
|
||||
scripted runs.
|
||||
3. When one shader is isolated, write `map` and read its `Jitter draw:` lines. Explain the
|
||||
cause in terms of what the draw does, and write a general rule in `vk_rasterizer.cpp` next to
|
||||
the existing ones, commented with the symptom, shader hashes and date like those.
|
||||
4. Before committing, have the user check the new spot and the known test spots in the doc.
|
||||
Add the new rule and spot to the doc in the same commit.
|
||||
@@ -1 +1,2 @@
|
||||
github: HayatoG
|
||||
custom: ['https://buymeacoffee.com/hayatog']
|
||||
@@ -126,10 +126,25 @@ jobs:
|
||||
progress_pid=$!
|
||||
trap 'kill "$progress_pid" 2>/dev/null || true' EXIT
|
||||
TLG_LTO=OFF bash build.sh
|
||||
# DLAA: NVIDIA's DLSS SDK (pinned release) and the MSVC bridge (gpu/dlss_bridge). The SDK
|
||||
# license allows shipping nvngx_dlss.dll with the application; NVIDIA's license text ships too.
|
||||
- name: DLSS SDK
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: NVIDIA/DLSS
|
||||
ref: v310.9.16
|
||||
path: dlss-sdk
|
||||
- name: DLSS bridge
|
||||
run: |
|
||||
DLSS_SDK_ROOT=$(cygpath -u "$GITHUB_WORKSPACE")/dlss-sdk \
|
||||
CMAKE_EXE="/c/Program Files/CMake/bin/cmake.exe" bash packaging/windows/build_dlss.sh
|
||||
- name: Regression - speculative fault recovery
|
||||
run: python -m unittest discover -s tests -p test_windows_fault_recovery.py -v
|
||||
- name: Package
|
||||
env:
|
||||
MSYS2_ROOT: ${{ runner.temp }}/msys64
|
||||
WINPYTHON_WIN: ${{ steps.python.outputs.python-path }}
|
||||
TLG_REQUIRE_DLSS: '1'
|
||||
run: |
|
||||
export MSYS2_ROOT=$(cygpath -u "$MSYS2_ROOT")
|
||||
export WINPYTHON=$(cygpath -u "$WINPYTHON_WIN")
|
||||
@@ -195,6 +210,8 @@ jobs:
|
||||
Build automático do commit ${short}. **Nenhum arquivo do jogo incluído**: veja o README-player.md dentro do pacote para o que copiar do seu PS4.
|
||||
Automatic build of commit ${short}. **No game files included**: see README-player.md inside the package for what to copy from your PS4.
|
||||
|
||||
☕ Apoie o desenvolvimento / Support development: [Buy me a coffee](https://buymeacoffee.com/hayatog)
|
||||
|
||||
- Linux x86-64 (glibc 2.39+): \`tlg-linux-x86_64.tar.gz\`
|
||||
- Windows 10/11 64-bit: \`tlg-windows-x86_64.zip\` (ainda não testado em jogo / not yet tested in game)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<h1 align="center">The Last Guardian — port nativo para PC</h1>
|
||||
|
||||
<p align="center"><a href="https://buymeacoffee.com/hayatog"><img src="https://img.shields.io/badge/Buy_me_a_coffee-Apoie_o_projeto-FFDD00?logo=buymeacoffee&logoColor=000000" alt="Apoie com um café / Buy me a coffee"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#english"><b>English below</b></a> ·
|
||||
<a href="https://github.com/HayatoG/tlg_pc/releases/latest">Download</a> ·
|
||||
@@ -162,10 +164,12 @@ The screenshots and the performance chart are at the top of this page.
|
||||
- **60 FPS and unlocked frame rate (experimental):** launcher → Video → "Frame rate (experimental)", or `TLG_FPS=30|60|unlocked`. The default is still 30 FPS, as on PS4. Details in [`docs/60fps.md`](docs/60fps.md) (in Portuguese, [#5](https://github.com/HayatoG/tlg_pc/issues/5)).
|
||||
- **Image filters:** SMAA anti-aliasing, RCAS (FSR 1) or CAS (AMD) sharpening and colour adjustments (brightness, contrast, saturation, gamma), in the launcher and live in the in-game menu (Insert → Image) ([#2](https://github.com/HayatoG/tlg_pc/issues/2)).
|
||||
- **Mouse and keyboard:** the mouse turns the camera (sensitivity and inverted Y in the launcher), and keys and mouse buttons can be remapped ([#8](https://github.com/HayatoG/tlg_pc/issues/8)).
|
||||
- **Japanese text (experimental):** on EU CUSA03745 update 01.03, choose **日本語 (EU 1.03 text fallback)** in Game language. The port handles the embedded-asset fallback without moving or deleting archives. See [setup and limitations](docs/japanese.md); this is not Japanese-region disc support.
|
||||
- **Motion sensor:** the DualShock 4/DualSense gyro works as is. On controllers without one, a tap on Share (or L3+R3) toggles "sensor mode": the left stick tilts the controller and cross/A shakes it. On the keyboard, T/G tilt and Y shakes.
|
||||
- **PS4-like brightness and lighting:** the game's auto exposure now reads the right value from the GPU, so outdoor scenes no longer blow out to white (`TLG_READBACK_LINEAR=1`, on by default; `0` restores the old behaviour) ([#38](https://github.com/HayatoG/tlg_pc/issues/38), [#40](https://github.com/HayatoG/tlg_pc/issues/40)).
|
||||
- **Direct memory access (shaders):** new launcher option (Graphics → GPU), **off by default** (`TLG_DIRECT_MEMORY_ACCESS=1`). Shaders read constants straight from the game's memory: fixes some broken scenes, but the game runs slower. Its shaders live in a cache of their own and are compiled again the first time.
|
||||
- **Sticks:** a 10% dead zone against drift (`TLG_STICK_DEADZONE`), and the last controller used is the one that counts.
|
||||
- **Menu buttons:** [Cross/Circle confirmation](docs/menu-buttons.md) in launcher Controls, without swapping gameplay buttons. The game-menu conversion currently requires the verified EU 01.03 executable.
|
||||
- **Touchpad without a touchpad:** hold View/Select (or Tab) and move the right stick (or the arrow keys) to swipe; a quick tap still clicks.
|
||||
- **Performance:** FPS no longer drops over time (shader variants are now reused), and repeated partial image transitions are skipped.
|
||||
- **Automatic shader cache:** it lives in `user/cache/`, next to the saves (Linux and Windows; the old cache in `~/.local/share/shadPS4` or `user/user/` is moved over on the first start). The folder is keyed by the shader recompiler version, and the old cache is deleted on its own after an update. To rebuild everything, the launcher has a **Clear shader cache** button (or `./run_tlg.sh --clear-shader-cache`); it deletes only this game's compiled shaders, never saves or settings.
|
||||
@@ -265,3 +269,9 @@ Technical notes (Ghidra findings, fixed bugs, `TLG_*` options) are in [`docs/TEC
|
||||
Code base: [gow3_pc](https://github.com/FelipePrado0/gow3_pc) (FelipePrado0), [bloodborne_pc](https://github.com/deadinside28/bloodborne_pc) and [shadPS4](https://github.com/shadps4-emu/shadPS4), all GPL-2.0, as is this project ([LICENSE](port/LICENSE)). ATRAC9 audio via [LibAtrac9](https://github.com/Thealexbarney/LibAtrac9).
|
||||
|
||||
This project is not affiliated with Sony Interactive Entertainment, SIE Japan Studio or genDESIGN. The Last Guardian is a trademark of Sony Interactive Entertainment. No game code, files or art are distributed here. The icon is original artwork made for this project.
|
||||
|
||||
## Apoie o desenvolvimento / Support development
|
||||
|
||||
Se quiser apoiar os ports, [pague um café pelo Buy Me a Coffee](https://buymeacoffee.com/hayatog). O apoio é opcional; os ports continuam gratuitos.
|
||||
|
||||
If you would like to support the ports, [buy me a coffee](https://buymeacoffee.com/hayatog). Support is optional; the ports remain free.
|
||||
@@ -67,6 +67,7 @@ some `0x4000`.
|
||||
|---|---|---|---|
|
||||
| `flip_rate_60` | `0x1c5151a` | `be01000000` → `be00000000` (`SetFlipRate(h,0)`) | 60, livre |
|
||||
| `dt_snap_60` | `0x24f17f8` / `0x24f17fc` / `0x24f1800` | 0,035 / 1/30 / 0,031667 → 0,0175 / 1/60 / 0,015833 | 60 |
|
||||
| `cloth_null_guard` | `0x128c161` | 67 B (guarda contra `[r12+0x28]==NULL` em `0x28c193` e `[rcx+rax*8]==NULL` em `0x28c197`) | 60, livre |
|
||||
| `max_fps_double` | `0x12091db` | `vmovss xmm1,[1.0]` → `[0.5]` (`0x24f17e8`) | nenhum (teste) |
|
||||
| `max_fps_off` | `0x12091e3` | `vdivss` → `vxorps xmm1,xmm1,xmm1` | nenhum (teste) |
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# DLAA and FSR 3.1 Native AA
|
||||
|
||||
DLAA is NVIDIA's DLSS anti-aliasing at native resolution. The port feeds it the game's own motion
|
||||
vectors and uses it instead of the game's SMAA: foliage, feathers and fences stop shimmering.
|
||||
|
||||
FSR 3.1 Native AA is the same with AMD FSR 3.1 (through
|
||||
[FSR-Vulkan](https://github.com/FireBurn/FSR-Vulkan) by @FireBurn): it runs on any card and needs
|
||||
no extra files. On an RTX card both are available; pick the one you like.
|
||||
|
||||
## Playing
|
||||
|
||||
**Needs:** Windows and an NVIDIA RTX card (RTX 20xx or newer) with a recent driver.
|
||||
|
||||
**The release zip has everything.** The two DLLs DLAA needs come with the Windows package, in `out\`
|
||||
next to the game's exe:
|
||||
|
||||
| File | |
|
||||
|---|---|
|
||||
| `out\tlg_dlss.dll` | the port's bridge to DLSS |
|
||||
| `out\nvngx_dlss.dll` | NVIDIA's DLSS runtime, shipped under NVIDIA's license |
|
||||
| `out\NVIDIA-DLSS-LICENSE.txt` | that license |
|
||||
|
||||
DLAA is **on by default**. Nothing needs to be downloaded or copied.
|
||||
|
||||
- **In game:** the overlay menu (Insert) > *Image* > *Anti-aliasing*: Off, DLAA or FSR 3.1 Native AA.
|
||||
- **Launcher:** the DLAA checkbox turns the chosen anti-aliasing on or off.
|
||||
- **F8** switches between that choice and the game's SMAA and saves it; a label in the top right
|
||||
shows which one is active.
|
||||
|
||||
On other cards, on Linux, or when a DLL is missing, DLAA is greyed out with the reason; FSR 3.1
|
||||
Native AA still works.
|
||||
|
||||
`tlg.ini` keys: `dlaa=1` turns the anti-aliasing on, `upscaler=dlss` or `upscaler=fsr3` picks
|
||||
it. `fsr_velocity` (0..1, default 1, AMD's value) sets how much motion cuts FSR's history: lower
|
||||
keeps moving hair and fur smoother, at the risk of ghosting.
|
||||
|
||||
## Building from source
|
||||
|
||||
`build.sh` builds the port without DLAA. The DLSS part is a separate DLL built with MSVC, because
|
||||
the DLSS SDK is a Visual Studio library:
|
||||
|
||||
1. Install Visual Studio 2022 or newer (the Build Tools are enough) with *Desktop development
|
||||
with C++*.
|
||||
2. Get NVIDIA's DLSS SDK at the version the CI uses:
|
||||
```bash
|
||||
git clone --depth 1 --branch v310.9.16 https://github.com/NVIDIA/DLSS.git F:/sdk/DLSS
|
||||
```
|
||||
3. In the MSYS2 CLANG64 shell, from `port/`:
|
||||
```bash
|
||||
DLSS_SDK_ROOT=F:/sdk/DLSS bash packaging/windows/build_dlss.sh
|
||||
```
|
||||
It puts `tlg_dlss.dll`, `nvngx_dlss.dll` and `NVIDIA-DLSS-LICENSE.txt` in `out/`, next to
|
||||
`tlg-probe.exe`. `packaging/windows/package.sh` ships them when they are there;
|
||||
`TLG_REQUIRE_DLSS=1` makes their absence an error (the CI sets it).
|
||||
|
||||
`DLSS_VS_GENERATOR` overrides the Visual Studio version that `vswhere` finds; `CMAKE_EXE` points at
|
||||
a CMake other than the one on the `PATH`.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Japanese text on the EU build
|
||||
|
||||
## What this supports
|
||||
|
||||
The verified setup is **EU CUSA03745 with update 01.03 installed/merged into the game
|
||||
folder**, displaying Japanese assets already included in the shared boot archives.
|
||||
This is **not support for the Japanese-region disc executable**. US, Korean, Chinese,
|
||||
other updates and modified asset sets have not been validated for this fallback.
|
||||
Japanese menus and gameplay prompts have been seen in-game; full-game text, narration
|
||||
and audio coverage have **not** been exhaustively verified.
|
||||
|
||||
The launcher option and automatic handling below require **both a launcher and native
|
||||
runtime built with this change**. Do not copy only the new launcher over an old runtime:
|
||||
the old runtime will not know how to hide the French packs.
|
||||
|
||||
## Setup — no archive editing or moving required
|
||||
|
||||
1. Use your own EU game dump with its **01.03 update applied to the same game folder**.
|
||||
An update PKG merely stored beside the base game is not an installed update.
|
||||
2. In the launcher, select the folder containing `eboot.bin` and `sce_sys/param.sfo`.
|
||||
Keep the normal game files, including `trico_boot.psarc`, `trico_bootp.psarc`,
|
||||
`trico_frf.psarc` and `trico_frfp.psarc`. Do not rename, delete or edit them.
|
||||
3. In **Game language**, choose **日本語 (EU 1.03 text fallback)**, then **Play**.
|
||||
Both the Qt and classic Tk launchers expose this choice for the verified asset layout.
|
||||
|
||||
The port automatically uses the appropriate internal language id and hides the two
|
||||
French localization packs **only from the game's view for that launch**. Their bytes
|
||||
and names on disk remain unchanged. No extra language download, save conversion, package
|
||||
modification, region spoof or custom launcher is required.
|
||||
|
||||
To return to French, select **Français** and launch again. Other languages also disable
|
||||
the temporary mask automatically. Japanese text is opt-in: **Automatic** does not quietly
|
||||
select this experimental fallback just because the operating system is Japanese.
|
||||
|
||||
This does not swap controller buttons. Japanese-style O-confirm/X-back is a separate
|
||||
menu preference/change ([PR #140](https://github.com/HayatoG/tlg_pc/pull/140)); selecting
|
||||
Japanese text alone does not enable it.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **The option is missing:** verify that the launcher/runtime contain this change, the
|
||||
game folder is EU `CUSA03745`, `APP_VER` is `01.03`, and both boot archives are present.
|
||||
The launcher does not advertise this workaround for unverified regions or versions.
|
||||
- **A previously saved Japanese choice is shown for a different game folder:** that
|
||||
choice is preserved, but the fallback is disabled and the game uses English. The Qt
|
||||
launcher displays an explanation; this does not enable Japanese-region disc support.
|
||||
- **It still shows French:** ensure the native runtime was rebuilt too. Remove a manual
|
||||
`TLG_LANGUAGE` override that conflicts with the launcher selection. The normal launch
|
||||
log should report `Japanese text fallback: French root archives hidden from guest`.
|
||||
- **It shows English with a forced language id:** setting `TLG_LANGUAGE=0` is not this
|
||||
workaround; that fell back to English in the tested EU build. Let the launcher select
|
||||
the internal id instead.
|
||||
- **You already moved the French packs for the old workaround:** the option also works
|
||||
without them, but French cannot be restored by changing the language until you put
|
||||
your original packs back. Never replace game archives from an unknown source.
|
||||
|
||||
Saves stay under the same EU title id and user folder. This feature does not rewrite
|
||||
saves, game archives, metadata or the game executable. Backups before each launch still
|
||||
follow the port's normal save-backup policy.
|
||||
|
||||
## Why the code is needed
|
||||
|
||||
Japanese paths remain in the shared boot archives, but the EU launcher's normal language
|
||||
table does not expose them. The working fallback requests the internal French language
|
||||
id (`2`) while making the France-French packs unavailable. With those packs absent, the
|
||||
game falls back to its embedded Japanese assets. Simply requesting native Japanese id
|
||||
`0` did not produce the same result.
|
||||
|
||||
Previously a player had to select the misleading **French** option and manually move
|
||||
`trico_frf.psarc` and `trico_frfp.psarc`, then restore them to play in French. The new
|
||||
launcher explicitly names the Japanese fallback and sets `TLG_JAPANESE_FALLBACK=1`
|
||||
together with the internal id. `runtime_file.c` returns `ENOENT` for those two root
|
||||
archives in `/app0`, `/hostapp` and equivalent relative paths, and omits them from guest
|
||||
directory listings. It does not hide similarly named files in saves or nested folders,
|
||||
other language packs, or any host files from other processes.
|
||||
|
||||
Switching away clears the mask, even if the launcher's parent environment contained a
|
||||
stale value. An explicit incompatible `TLG_LANGUAGE` override wins and disables the mask;
|
||||
remove that debugging override to use the launcher option. Direct native-runtime users
|
||||
must supply both `TLG_LANGUAGE=2` and `TLG_JAPANESE_FALLBACK=1` on the verified build;
|
||||
the native flag alone does not identify or validate a disc region.
|
||||
|
||||
## Validation and limits
|
||||
|
||||
The earlier manual archive-omission setup was tested in-game on EU 01.03 and produced
|
||||
Japanese menus/gameplay prompts. Its actual kernel opens were `/app0/trico_frf.psarc`
|
||||
and `/app0/trico_frfp.psarc`, the paths now masked by this change.
|
||||
|
||||
Automated C tests exercise real guest open/stat/list behavior with French packs still
|
||||
present on disk, check relative and hostapp aliases, ensure saves/nested files and other
|
||||
packs remain readable, and verify French is readable again when the option is off.
|
||||
Python tests cover version/region/layout gating, UI choices, language-id/mask pairing,
|
||||
normal French, stale environment cleanup, explicit overrides and non-mutation.
|
||||
|
||||
A full newly rebuilt launcher/runtime still needs end-to-end in-game regression testing.
|
||||
The previous manual proof is not presented as testing of a rebuilt version of this PR.
|
||||
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"baseline_commit": "b8d5f412398fae52a444400a9b5131bc1c970cc3",
|
||||
"unchanged_methods": [
|
||||
"__init__",
|
||||
"pt",
|
||||
"t",
|
||||
"filters_changed",
|
||||
"picked_language",
|
||||
"refresh_language_choices",
|
||||
"check",
|
||||
"changed",
|
||||
"refresh_status",
|
||||
"browse",
|
||||
"open",
|
||||
"clear_cache",
|
||||
"append",
|
||||
"play",
|
||||
"run_game",
|
||||
"pump",
|
||||
"quit"
|
||||
],
|
||||
"presentation_methods_changed": [
|
||||
"build",
|
||||
"load_art",
|
||||
"build_filters",
|
||||
"combo",
|
||||
"build_game_language",
|
||||
"remap_dialog",
|
||||
"mods_dialog"
|
||||
],
|
||||
"contrast": {
|
||||
"pairs": {
|
||||
"main": {
|
||||
"foreground": "#283d35",
|
||||
"background": "#f8f5ed",
|
||||
"ratio": 10.654
|
||||
},
|
||||
"secondary": {
|
||||
"foreground": "#536158",
|
||||
"background": "#eeeae0",
|
||||
"ratio": 5.431
|
||||
},
|
||||
"links": {
|
||||
"foreground": "#48644f",
|
||||
"background": "#eeeae0",
|
||||
"ratio": 5.444
|
||||
},
|
||||
"selection": {
|
||||
"foreground": "#f4f0e4",
|
||||
"background": "#283d35",
|
||||
"ratio": 10.188
|
||||
},
|
||||
"popup_selection": {
|
||||
"foreground": "#f4f0e4",
|
||||
"background": "#48644f",
|
||||
"ratio": 5.739
|
||||
},
|
||||
"disabled": {
|
||||
"foreground": "#536158",
|
||||
"background": "#d5d9cc",
|
||||
"ratio": 4.544
|
||||
},
|
||||
"control_border": {
|
||||
"foreground": "#7a8477",
|
||||
"background": "#f8f5ed",
|
||||
"ratio": 3.573
|
||||
}
|
||||
},
|
||||
"hero": [
|
||||
{
|
||||
"language": "pt_BR",
|
||||
"geometry": "1040x700",
|
||||
"text": "UM VÍNCULO ALÉM DAS PALAVRAS",
|
||||
"minimum_ratio": 4.732,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "pt_BR",
|
||||
"geometry": "1040x700",
|
||||
"text": "THE LAST\nGUARDIAN",
|
||||
"minimum_ratio": 3.269,
|
||||
"threshold": 3.0
|
||||
},
|
||||
{
|
||||
"language": "pt_BR",
|
||||
"geometry": "1040x700",
|
||||
"text": "Um menino. Um companheiro misterioso.\nUma jornada extraordinária espera por você.",
|
||||
"minimum_ratio": 4.769,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "pt_BR",
|
||||
"geometry": "1040x700",
|
||||
"text": "RECOMPILAÇÃO PARA PC / HAYATOG",
|
||||
"minimum_ratio": 5.597,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "pt_BR",
|
||||
"geometry": "1240x820",
|
||||
"text": "UM VÍNCULO ALÉM DAS PALAVRAS",
|
||||
"minimum_ratio": 8.102,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "pt_BR",
|
||||
"geometry": "1240x820",
|
||||
"text": "THE LAST\nGUARDIAN",
|
||||
"minimum_ratio": 5.597,
|
||||
"threshold": 3.0
|
||||
},
|
||||
{
|
||||
"language": "pt_BR",
|
||||
"geometry": "1240x820",
|
||||
"text": "Um menino. Um companheiro misterioso.\nUma jornada extraordinária espera por você.",
|
||||
"minimum_ratio": 5.258,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "pt_BR",
|
||||
"geometry": "1240x820",
|
||||
"text": "RECOMPILAÇÃO PARA PC / HAYATOG",
|
||||
"minimum_ratio": 10.529,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "pt_BR",
|
||||
"geometry": "1920x1080",
|
||||
"text": "UM VÍNCULO ALÉM DAS PALAVRAS",
|
||||
"minimum_ratio": 9.465,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "pt_BR",
|
||||
"geometry": "1920x1080",
|
||||
"text": "THE LAST\nGUARDIAN",
|
||||
"minimum_ratio": 13.835,
|
||||
"threshold": 3.0
|
||||
},
|
||||
{
|
||||
"language": "pt_BR",
|
||||
"geometry": "1920x1080",
|
||||
"text": "Um menino. Um companheiro misterioso.\nUma jornada extraordinária espera por você.",
|
||||
"minimum_ratio": 11.06,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "pt_BR",
|
||||
"geometry": "1920x1080",
|
||||
"text": "RECOMPILAÇÃO PARA PC / HAYATOG",
|
||||
"minimum_ratio": 13.993,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "en_US",
|
||||
"geometry": "1040x700",
|
||||
"text": "A BOND BEYOND WORDS",
|
||||
"minimum_ratio": 4.732,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "en_US",
|
||||
"geometry": "1040x700",
|
||||
"text": "THE LAST\nGUARDIAN",
|
||||
"minimum_ratio": 3.269,
|
||||
"threshold": 3.0
|
||||
},
|
||||
{
|
||||
"language": "en_US",
|
||||
"geometry": "1040x700",
|
||||
"text": "A boy. A mysterious companion.\nAn extraordinary journey awaits.",
|
||||
"minimum_ratio": 5.174,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "en_US",
|
||||
"geometry": "1040x700",
|
||||
"text": "PC RECOMPILATION / HAYATOG",
|
||||
"minimum_ratio": 5.597,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "en_US",
|
||||
"geometry": "1240x820",
|
||||
"text": "A BOND BEYOND WORDS",
|
||||
"minimum_ratio": 9.121,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "en_US",
|
||||
"geometry": "1240x820",
|
||||
"text": "THE LAST\nGUARDIAN",
|
||||
"minimum_ratio": 5.597,
|
||||
"threshold": 3.0
|
||||
},
|
||||
{
|
||||
"language": "en_US",
|
||||
"geometry": "1240x820",
|
||||
"text": "A boy. A mysterious companion.\nAn extraordinary journey awaits.",
|
||||
"minimum_ratio": 7.452,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "en_US",
|
||||
"geometry": "1240x820",
|
||||
"text": "PC RECOMPILATION / HAYATOG",
|
||||
"minimum_ratio": 11.938,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "en_US",
|
||||
"geometry": "1920x1080",
|
||||
"text": "A BOND BEYOND WORDS",
|
||||
"minimum_ratio": 9.465,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "en_US",
|
||||
"geometry": "1920x1080",
|
||||
"text": "THE LAST\nGUARDIAN",
|
||||
"minimum_ratio": 13.835,
|
||||
"threshold": 3.0
|
||||
},
|
||||
{
|
||||
"language": "en_US",
|
||||
"geometry": "1920x1080",
|
||||
"text": "A boy. A mysterious companion.\nAn extraordinary journey awaits.",
|
||||
"minimum_ratio": 11.06,
|
||||
"threshold": 4.5
|
||||
},
|
||||
{
|
||||
"language": "en_US",
|
||||
"geometry": "1920x1080",
|
||||
"text": "PC RECOMPILATION / HAYATOG",
|
||||
"minimum_ratio": 13.993,
|
||||
"threshold": 4.5
|
||||
}
|
||||
],
|
||||
"dialog_text": [
|
||||
{
|
||||
"text": "PS4 button",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "Keys / mouse",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "Default",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "PS4 button",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "Keys / mouse",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "Default",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "Cross (X) — jump",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "Space",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "Circle",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "Left Shift",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "Square",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "E|Mouse Middle",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "Triangle — grab, hold on",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "Q|Mouse Left",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "L1",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "1",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "R1 — call Trico",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "3|Mouse Right",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "L2",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "R",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "R2",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "F",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "L3",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "Z",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "R3",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "C",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "Options — menu",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "Return",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "Touchpad (left) — photo mode",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "Tab",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "Touchpad (right) — pause",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "Backspace",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "D-pad up",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "I",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "D-pad down",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "K",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "D-pad left",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "J",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "D-pad right",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "L",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "Click Set and press a key or mouse button, or type SDL names (Space, Left Shift, Mouse Left, Mouse X1, Wheel Up…). \"|\" gives several: Q|Mouse Left. \"none\": no key. Empty: the default (grey). Applies at the next start.",
|
||||
"ratio": 5.431
|
||||
},
|
||||
{
|
||||
"text": "Drop shadPS4/GoldHEN cheat XML files in the mods folder, then turn them on here. Nothing here changes the game until you enable it.",
|
||||
"ratio": 9.663
|
||||
},
|
||||
{
|
||||
"text": "A cheat can crash the game or behave oddly; if that happens, turn it off here.",
|
||||
"ratio": 5.411
|
||||
},
|
||||
{
|
||||
"text": "No cheat XML files found in the mods folder yet.",
|
||||
"ratio": 5.431
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
# Auditoria da interface do launcher
|
||||
|
||||
Data: 17 de outubro de 2026.
|
||||
|
||||
## Resultado e alcance
|
||||
|
||||
**100% dos controles inventariados na UI antiga estão presentes na nova UI.**
|
||||
Nenhuma opção, faixa numérica, alternativa de seletor, ação principal ou ação dos
|
||||
diálogos foi removida. A nova UI acrescenta cinco botões de navegação e uma barra
|
||||
horizontal no log. A comparação foi feita instanciando ambas as interfaces com
|
||||
configurações isoladas e comparando seus widgets, além de comparar o código dos callbacks.
|
||||
|
||||
Isso representa paridade do inventário funcional da interface, não uma declaração
|
||||
de 100% de cobertura de linhas ou de validação do jogo completo. Os testes de
|
||||
preparação/execução usam serviços e subprocessos simulados; nenhum jogo foi iniciado.
|
||||
A inspeção visual foi realizada no Windows com Tk/ttk `clam`; Linux não foi inspecionado.
|
||||
|
||||
Base anterior ao redesign: `b8d5f412398fae52a444400a9b5131bc1c970cc3`,
|
||||
`port/launcher/tlg_launcher.py`. O inventário original está congelado em
|
||||
[`launcher_ui_legacy.json`](../port/tests/data/launcher_ui_legacy.json).
|
||||
|
||||
## Paridade dos controles
|
||||
|
||||
As linhas abaixo se sobrepõem por função; não devem ser somadas.
|
||||
|
||||
| Grupo | UI antiga → nova | Localização atual / evidência |
|
||||
| --- | --- | --- |
|
||||
| Pastas do jogo e dos módulos | 2 → 2 campos; 2 → 2 ações Procurar | Jogo e idioma; escolha de pasta e persistência verificadas |
|
||||
| Idioma do jogo e da interface | 2 → 2 seletores | Jogo e idioma; eventos reais de seleção, idiomas US/EU e reconstrução da interface verificados |
|
||||
| Modo de exibição e FPS | 2 → 2 seletores, com as mesmas alternativas | Imagem e vídeo; todas as escolhas exercitadas |
|
||||
| Áudio nativo, VSync, overlay FPS, câmera no mouse, inverter Y, SMAA e ajustes de cor | 7 → 7 checkboxes | As sete opções alternam e persistem pelos callbacks existentes |
|
||||
| VRAM, sensibilidade e quatro ajustes de cor | 6 → 6 campos numéricos | Mesmas faixas e incrementos; persistência e limites dos filtros verificados |
|
||||
| Nitidez | 1 → 1 seletor | Desligada, RCAS e CAS; valores correspondentes no INI verificados |
|
||||
| Ações principais | 9 → 9 botões existentes, mais 5 de navegação | Procurar ×2, remapear, Sair, Jogar, logs, mods, saves e limpar cache |
|
||||
| Links externos | 3 → 3 | YouTube, Discord e apoio; destinos verificados sem abrir sites |
|
||||
| Remapeamento | 17 → 17 campos; 19 → 19 botões | Os 17 Definir, Restaurar tudo e Fechar continuam disponíveis; gravação e restauração verificadas |
|
||||
| Mods | 3 → 3 botões | Abrir pasta, atualizar e fechar; lista e ativação verificadas com mod temporário simulado |
|
||||
| Registro | Log e atualização preservados | Saída, conclusão, erros e preservação após troca de idioma verificados |
|
||||
| Estado de execução | Regras preservadas | Pronto, arquivos ausentes, jogo em execução, bloqueio de Jogar/cache, confirmação do cache e Sair verificados |
|
||||
|
||||
Os conjuntos de variáveis são idênticos: **9 variáveis de configurações + 6 de
|
||||
filtros**, além dos cinco seletores. Uma comparação estrutural da AST confirmou
|
||||
que **17 métodos existentes permanecem idênticos**, incluindo `changed`,
|
||||
`filters_changed`, `refresh_status`, `play`, `run_game`, `pump`, `clear_cache` e `quit`.
|
||||
Os sete métodos antigos alterados cuidam da construção/apresentação da UI; as
|
||||
funções aninhadas de remapeamento e mods mantêm sua lógica.
|
||||
|
||||
## Problemas de contraste encontrados e corrigidos
|
||||
|
||||
1. **Seletor com foco:** o mapa herdado de `clam` em `readonly + focus` aplicava
|
||||
texto branco (`#ffffff`) ao fundo claro (`#f8f5ed`): **1,09:1**. Agora o texto
|
||||
normal/com foco é `#283d35`; texto selecionado usa marfim sobre verde escuro.
|
||||
2. **Textos secundários e links:** mediam **4,24:1** e **4,32:1** sobre o fundo
|
||||
principal. Os tons foram escurecidos, preservando a paleta verde.
|
||||
3. **Seleção em campos de texto e numéricos:** removida a dependência das cores
|
||||
herdadas do sistema, inclusive quando o campo perde o foco. A lista suspensa
|
||||
usa uma paleta explícita e foi inspecionada separadamente da área do campo.
|
||||
4. **Diálogos:** os padrões do remapeamento e textos dos mods usavam cinzas que
|
||||
chegavam a **2,95:1**. Agora usam a cor secundária verificada.
|
||||
5. **Texto sobre a arte:** o rodapé pequeno da abertura chegava a **2,99:1** no
|
||||
tamanho mínimo. Sua cor foi clareada; todos os textos avaliados agora passam
|
||||
o limiar aplicável.
|
||||
6. **Controles:** bordas dos campos e marca de checkbox ganharam contraste.
|
||||
A seleção do texto do log também tem cores explícitas.
|
||||
|
||||
## Medições após a correção
|
||||
|
||||
Referência usada: [WCAG 2.2 — contraste de texto](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html),
|
||||
**4,5:1 para texto comum** e **3:1 para texto grande**. Para bordas e indicadores,
|
||||
a referência é [contraste não textual](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html), **3:1**.
|
||||
Controles desativados são isentos pelo critério, mas também receberam texto legível.
|
||||
Esta é uma verificação de contraste da UI, não uma certificação integral WCAG.
|
||||
|
||||
| Elemento | Cores de texto / fundo | Contraste |
|
||||
| --- | --- | --- |
|
||||
| Campos e texto principal nos cartões | `#283d35` / `#f8f5ed` | **10,65:1** |
|
||||
| Texto secundário no fundo principal | `#536158` / `#eeeae0` | **5,43:1** |
|
||||
| Links e legendas verdes | `#48644f` / `#eeeae0` | **5,44:1** |
|
||||
| Seleção de texto nos campos | `#f4f0e4` / `#283d35` | **10,19:1** |
|
||||
| Item selecionado da lista suspensa | `#f4f0e4` / `#48644f` | **5,74:1** |
|
||||
| Texto em controle desativado | `#536158` / `#d5d9cc` | **4,54:1** |
|
||||
| Borda de campo sobre seu fundo | `#7a8477` / `#f8f5ed` | **3,57:1** |
|
||||
| Menor contraste medido em texto dos diálogos | Cores efetivas dos widgets | **5,41:1** |
|
||||
| Menor contraste de texto comum sobre a arte | Pixels de fundo sob os limites dos textos | **4,73:1** |
|
||||
| Menor contraste do título grande sobre a arte | Pixels de fundo sob os limites do título | **3,27:1** |
|
||||
|
||||
Os estados normal, foco, readonly, seleção, desativado e suas combinações foram
|
||||
verificados nos estilos dos campos; botões foram verificados também em hover e
|
||||
pressionados. A lista suspensa foi consultada em seu próprio widget Tcl.
|
||||
|
||||
Na abertura, foi reconstruído o fundo sem texto, usando a arte real e seu
|
||||
posicionamento no canvas. Foi medida a pior cor de fundo sob cada caixa de texto
|
||||
em **1040×700, 1240×820 e 1920×1080**, em português e inglês. O título permanece
|
||||
acima de 18 pontos e foi avaliado com o limiar de 3:1. Os resultados detalhados
|
||||
estão em [`launcher-ui-audit-results.json`](launcher-ui-audit-results.json).
|
||||
|
||||
## Regressão automatizada
|
||||
|
||||
**12 testes de UI passaram.** Executar da raiz do repositório:
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s port/tests -p test_launcher_ui.py -v
|
||||
```
|
||||
|
||||
Os testes usam arquivos temporários em `port/out/`, preservam as configurações do
|
||||
usuário e não iniciam o jogo, abrem pastas reais nem seguem links externos.
|
||||
O arquivo de teste registra tanto a paridade histórica quanto o contraste dos
|
||||
estados que causaram o problema do campo branco.
|
||||
|
||||
As alterações de correção limitam-se à apresentação em `tlg_launcher.py`, aos
|
||||
testes e à documentação desta auditoria. Preparação, execução e persistência
|
||||
continuam usando a implementação existente.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Menu confirm button
|
||||
|
||||
Choose **Menu confirm button** on the Qt launcher's Controls screen (or the Tk launcher's
|
||||
Controls box). Cross (X / A) is the default; Circle (O / B) enables Japanese-style menus.
|
||||
The other button goes back. Changes take effect on the next launch, without swapping
|
||||
normal gameplay actions.
|
||||
|
||||
The setting is `menu_confirm=cross|circle` in the user's `tlg.ini`, mirrored in launcher
|
||||
settings. `TLG_MENU_CONFIRM=circle` overrides it. Missing/invalid values keep Cross.
|
||||
The launcher passes the same normalized preference to the runtime and to the game-patch
|
||||
preparation step. Direct patch-tool users can select `--menu-confirm circle`.
|
||||
|
||||
## Two separate layers
|
||||
|
||||
PC dialogs and overlays honor the preference in system parameter 1000, text entry,
|
||||
save/message dialog input and controller glyphs, and Dear ImGui navigation. Keyboard
|
||||
Enter/Escape and mouse handling in these PC dialogs are unchanged. The ordinary SDL/PS4
|
||||
pad mapping is never changed.
|
||||
|
||||
The game's own menus do not query system parameter 1000: the only call to that import
|
||||
requests system language (parameter 1). Its gamepad normalizer constructs separate
|
||||
menu-confirm (`0x20000`) and menu-cancel (`0x40000`) flags, in addition to ordinary
|
||||
Cross/Circle bits. On the verified EU executable:
|
||||
|
||||
- At image offset `0xd2ebee`, raw Circle (`0x2000`) is shifted left by 5, making cancel.
|
||||
- At `0xd2ebf2`, raw Cross (`0x4000`) is shifted left by 3, making confirm.
|
||||
- Circle preference changes both shifts to 4. The ordinary Cross (`0x1`) and Circle
|
||||
(`0x2`) normalization instructions remain untouched.
|
||||
|
||||
The change is applied only to the loaded image, alongside (but independently of) FPS
|
||||
patches. Neither the original game executable nor extracted ELF is rewritten.
|
||||
|
||||
## Supported game build and safety
|
||||
|
||||
The game-menu patch currently supports **CUSA03745 EU, installed update 01.03 only**.
|
||||
The entire decoded ELF must have SHA-256
|
||||
`ce2a5da1de13d7e43a2403cefe649f0f35a968e8b3779b911a314292472ef573`, and all 14 bytes of the
|
||||
conversion sequence must match before the 8-byte write is emitted. A plain ELF and the
|
||||
SELF containing that same ELF are supported. Other builds receive no game-menu write
|
||||
and a diagnostic; PC dialogs still honor the chosen preference. US/KR/CN and other
|
||||
updates are not claimed to be supported. External mods overlapping the conversion
|
||||
cause external mod writes to be skipped with a diagnostic, not silently overriding it.
|
||||
|
||||
## Validation
|
||||
|
||||
C service tests cover the PS4 preference and dialog action roles. Python tests cover
|
||||
launcher/environment/ini propagation, the default no-op, exact writes, instruction and
|
||||
hash rejection, ordinary gameplay bits, 30 FPS independence and mod overlap handling.
|
||||
The generated write was also checked against the real EU SELF without modifying it.
|
||||
|
||||
The earlier equivalent in-memory proof of concept passed scripted live-process testing
|
||||
(Cross did not confirm; Circle opened the selected menu; Cross returned) and the user
|
||||
confirmed it worked in-game with their controller. A complete rebuilt source runtime
|
||||
still needs UI/controller regression testing; exhaustive in-game glyph coverage is not
|
||||
claimed.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Windows: deadlock entre recuperação especulativa e readback da GPU
|
||||
|
||||
## Evidência
|
||||
|
||||
O log local `run-20261017-202612.log` parou após 2.427 frames. O relatório
|
||||
`FREEZE` mostrou `tlg:DrawPrep1` e `workerThread1` dentro do handler de page fault,
|
||||
aguardando `GPU thread (command)`. O thread principal e os demais workers ficaram
|
||||
aguardando a renderização. O contador de frames permaneceu parado enquanto os
|
||||
vblanks continuavam avançando.
|
||||
|
||||
## Causa
|
||||
|
||||
`PipelineCache::GetProgram` mantém `programs_mutex` compartilhado enquanto um
|
||||
worker especulativo executa `RefreshFlatBuf`/`StageSpecialization`. Esses acessos
|
||||
à memória guest têm um ponto de recuperação (`runtime_fault_recover`), pois os
|
||||
dados podem estar protegidos pela GPU ou ter sido reutilizados.
|
||||
|
||||
No Windows, `vectored_fault` chamava `tlggpu_handle_fault` antes de consultar
|
||||
esse ponto. O readback podia enfileirar um comando e aguardar a thread da GPU;
|
||||
essa thread, ao precisar publicar/atualizar shaders, precisava adquirir
|
||||
`programs_mutex` exclusivamente, ainda retido pelo worker. A espera era circular.
|
||||
O handler POSIX já recuperava a leitura especulativa antes do readback.
|
||||
|
||||
## Correção
|
||||
|
||||
O handler Windows agora recupera o acesso especulativo primeiro, usando o
|
||||
trampolim `tlg_longjmp` existente. O worker abandona o preparo daquele draw e
|
||||
libera o lock. Apenas acessos sem ponto de recuperação entram no rastreamento e
|
||||
readback da GPU. Os readbacks precisos e os workers permanecem habilitados.
|
||||
|
||||
## Regressão
|
||||
|
||||
`test_windows_fault_recovery.py` extrai o handler de produção e o compila com
|
||||
`compat_win.c`. Usa páginas Windows realmente protegidas e verifica:
|
||||
|
||||
- 100 falhas especulativas recuperadas sem chamar o callback da GPU;
|
||||
- limpeza do ponto de recuperação em TLS;
|
||||
- falhas normais de leitura e escrita ainda encaminhadas ao rastreamento da GPU.
|
||||
|
||||
O mesmo teste **falha com o handler anterior**, código 99, e **passa com a
|
||||
correção**. O callback simulado falha imediatamente no caminho proibido para não
|
||||
deixar o teste preso no deadlock. A etapa foi incluída no CI Windows.
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s port/tests -p test_windows_fault_recovery.py -v
|
||||
```
|
||||
|
||||
É necessário um compilador MSYS2 CLANG64 no PATH ou em `TLG_TEST_CLANG`.
|
||||
Os 12 testes da interface também passaram. O motor completo foi recompilado com
|
||||
Clang/MSYS2 e suas DLLs foram empacotadas a partir da árvore de imports.
|
||||
A instalação original e os saves do usuário foram preservados.
|
||||
|
||||
## Validação em execução
|
||||
|
||||
Na sessão local `run-20261017-203640.log`, a build recompilada ultrapassou três
|
||||
minutos na tela inicial/menu, com estatísticas recentes de 30 FPS e sem relatório
|
||||
`FREEZE`. O menu respondeu à entrada e abriu a opção New Game. O teste preservou
|
||||
os 8 workers DrawPrep e o readback preciso; não desativou a otimização nem ocultou
|
||||
o detector de congelamento. Isso valida a inicialização e a continuidade da
|
||||
renderização; a campanha completa não foi testada.
|
||||
|
||||
Em 18 de outubro de 2026, o usuário executou novamente o launcher com a build
|
||||
corrigida e confirmou que o jogo funciona. Essa confirmação complementa o teste
|
||||
local de inicialização/menu; não representa validação da campanha completa.
|
||||
|
||||
Artefatos locais: `port/dist/tlg-ui-test-windows-fixed.zip` e a cópia de teste
|
||||
existente atualizada em `port/dist/tlg-ui-test-windows/`.
|
||||
@@ -104,6 +104,8 @@ if [[ ${1:-} == --test ]]; then
|
||||
out/runtime-test
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -pthread -Isrc tests/test_file_mods.c -o out/file-mods-test
|
||||
out/file-mods-test
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -pthread -Isrc tests/test_japanese_language.c -o out/japanese-language-test
|
||||
out/japanese-language-test
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -pthread "${includes[@]}" -I. -Isrc tests/test_sema.c "${runtime[@]}" out/libatrac9.a -lm "${gpu[@]}" "${libraries[@]}" -o out/sema-test
|
||||
out/sema-test
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -pthread -Isrc tests/test_rwlock_rp.c -o out/rwlock-rp-test
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Debugging DLAA artifacts
|
||||
|
||||
DLAA (DLSS at native resolution) needs every scene draw shifted by a sub-pixel jitter each
|
||||
frame. The game was not written for that, so some passes break when jitter is applied wrongly:
|
||||
flicker, blocks, or surfaces that swim. This is the method that found the known cases, and the
|
||||
rules they turned into. Code: `vk_rasterizer.cpp` (jitter decision per draw),
|
||||
`vk_temporal_upscaler.cpp` (DLSS dispatch, jitter sequence), `camera_motion.comp` (motion).
|
||||
|
||||
## Method
|
||||
|
||||
1. **Is it jitter?** Run with `TLG_TOGGLE_FILE=<file>` and write `33554432` (bit 25) into the
|
||||
file: jitter off, live. Artifact gone → a draw is jittered that must not be, or two passes
|
||||
of one surface are jittered differently. Still there → not a jitter bug (for example DLSS on
|
||||
alpha-tested grass shimmers in motion; that is a DLSS limit).
|
||||
2. **Find the draw.** Run with `TLG_JITTER_CONTROL=<file>`. The file is re-read twice a second,
|
||||
so stand at the broken spot and edit it while the game runs, no rebuilds:
|
||||
- `map`: logs one frame of jitter-eligible draws (`Jitter map:` index and pixel shader) and
|
||||
a line per scene-depth draw (`Jitter draw:` shader, indices, depth write and compare
|
||||
function, depth target id, whether it was jittered, bound images with ids).
|
||||
- `lo hi`: only eligible draws in that index range (counted from the sky pass) are
|
||||
jittered. Halve the range until one shader remains.
|
||||
- `only <ps>`, `not <ps>`: jitter only, or never, draws of that pixel shader (hex).
|
||||
- `skip <ps>`: drop the draws of that shader, to see what they are on screen.
|
||||
Judge each step by eye in the game. Scripted camera moves rarely frame the spot.
|
||||
3. **Understand it, then write a rule.** Once a shader is isolated, look at its `Jitter draw:`
|
||||
lines: what it writes, what it samples, how it tests depth, and which other draws touch the
|
||||
same mesh or depth. Fix the class of draw, not the hash: other scenes have other shaders
|
||||
with the same pattern.
|
||||
4. **Check the known cases** below still look right after a change.
|
||||
|
||||
## Rules in place
|
||||
|
||||
- **Only geometry drawn into the scene depth is jittered** (the depth the size of
|
||||
`GameSize()`, written by the sky pass). Full-screen passes are not: a shifted quad leaves an
|
||||
edge column unwritten.
|
||||
- **Draws that sample a scene depth are not jittered** (`SamplesSceneDepth()`). Decal and
|
||||
light volumes rebuild positions per pixel from the already jittered depth. Jittering their
|
||||
boxes misplaced their G-buffer writes: blocks on the boy's face, neck and hair
|
||||
(ps `da3456ba`, 36-index boxes).
|
||||
- **A redraw of a mesh takes the jitter of its first jittered depth pass**, sampling or not
|
||||
(mesh = index address and count, reset each frame). Cave water draws a depth pre-pass
|
||||
(ps `26c53528`) and then shades the same mesh with a LessEqual test (ps `05adfd4d`,
|
||||
`4755277d`). The shading samples the scene depth, so the previous rule left it unshifted and
|
||||
its test hit or missed the pre-pass per pixel: flicker.
|
||||
- **One jitter per frame.** The emulator can start several "frames" per game frame. The jitter
|
||||
advances once, at the game's motion pass, so the pre-pass and G-buffer share one offset.
|
||||
- **Hair writes no motion vectors**; it inherits those of the surface behind it. The motion
|
||||
shader replaces such vectors with camera motion at the hair's depth (mode 3).
|
||||
|
||||
## Known test spots
|
||||
|
||||
- The boy up close (face, neck, hair), any scene.
|
||||
- Water in the first cave.
|
||||
- Grass while moving: shimmer here is DLSS, not a bug (jitter off is worse).
|
||||
@@ -421,20 +421,10 @@ add_executable(upscaler-support-test EXCLUDE_FROM_ALL ../tests/test_upscaler_sup
|
||||
shim/tlg_settings.cpp)
|
||||
target_include_directories(upscaler-support-test PRIVATE shim)
|
||||
target_compile_options(upscaler-support-test PRIVATE -UNDEBUG)
|
||||
add_executable(orb-multiplier-test EXCLUDE_FROM_ALL ../tests/test_orb_multiplier.cpp
|
||||
shim/tlg_orbs.cpp shim/tlg_settings.cpp)
|
||||
target_include_directories(orb-multiplier-test PRIVATE shim)
|
||||
target_compile_options(orb-multiplier-test PRIVATE -UNDEBUG)
|
||||
add_executable(stat-multipliers-test EXCLUDE_FROM_ALL ../tests/test_stat_multipliers.cpp
|
||||
shim/tlg_settings.cpp)
|
||||
target_include_directories(stat-multipliers-test PRIVATE shim)
|
||||
target_compile_options(stat-multipliers-test PRIVATE -UNDEBUG)
|
||||
add_executable(perf-overlay-test EXCLUDE_FROM_ALL ../tests/test_perf_overlay.cpp
|
||||
shim/tlg_settings.cpp)
|
||||
target_include_directories(perf-overlay-test PRIVATE shim)
|
||||
target_compile_options(perf-overlay-test PRIVATE -UNDEBUG)
|
||||
add_executable(cheat-hooks-test EXCLUDE_FROM_ALL ../tests/test_cheat_hooks.cpp)
|
||||
target_compile_options(cheat-hooks-test PRIVATE -UNDEBUG)
|
||||
add_executable(wave64-ballot-test EXCLUDE_FROM_ALL ../tests/test_wave64_ballot.cpp ../tests/test_gpu_runtime_stubs.cpp)
|
||||
target_include_directories(wave64-ballot-test PRIVATE shim shadps4 third_party/gcn/include)
|
||||
target_compile_options(wave64-ballot-test PRIVATE -UNDEBUG -fPIC)
|
||||
@@ -449,9 +439,6 @@ add_executable(graphics-settings-test EXCLUDE_FROM_ALL ../tests/test_graphics_se
|
||||
shim/tlg_settings.cpp)
|
||||
target_include_directories(graphics-settings-test PRIVATE shim)
|
||||
target_compile_options(graphics-settings-test PRIVATE -UNDEBUG)
|
||||
add_executable(actor-hook-test EXCLUDE_FROM_ALL ../tests/test_actor_hook.cpp)
|
||||
target_include_directories(actor-hook-test PRIVATE shim)
|
||||
target_compile_options(actor-hook-test PRIVATE -UNDEBUG -Wall -Wextra -Werror)
|
||||
add_executable(readback-sources-test EXCLUDE_FROM_ALL ../tests/test_readback_sources.cpp)
|
||||
target_include_directories(readback-sources-test PRIVATE shadps4)
|
||||
target_compile_options(readback-sources-test PRIVATE -UNDEBUG -Wall -Wextra -Werror)
|
||||
@@ -464,6 +451,3 @@ add_executable(fsr4-bench EXCLUDE_FROM_ALL tools/fsr4_bench.cpp
|
||||
shadps4/video_core/renderer_vulkan/fsr411/fsr411.cpp)
|
||||
target_include_directories(fsr4-bench PRIVATE shadps4/video_core/renderer_vulkan/fsr411)
|
||||
target_link_libraries(fsr4-bench PRIVATE ffx-vulkan::fsr4-v07-vulkan ffx-vulkan::fsr4-v07-assets Vulkan::Vulkan)
|
||||
|
||||
add_executable(cheat-hook-test ../tests/test_cheat_hook.cpp)
|
||||
target_compile_options(cheat-hook-test PRIVATE -UNDEBUG -Wall -Wextra -Werror)
|
||||
@@ -178,8 +178,8 @@ int32_t CreateFeature(VkCommandBuffer command, const TlgDlssFeature* desc) {
|
||||
settings.InFeatureCreateFlags =
|
||||
NVSDK_NGX_DLSS_Feature_Flags_MVLowRes |
|
||||
(desc->depth_inverted ? NVSDK_NGX_DLSS_Feature_Flags_DepthInverted : 0) |
|
||||
(desc->hdr ? NVSDK_NGX_DLSS_Feature_Flags_IsHDR | NVSDK_NGX_DLSS_Feature_Flags_AutoExposure
|
||||
: 0);
|
||||
(desc->hdr ? NVSDK_NGX_DLSS_Feature_Flags_IsHDR : 0) |
|
||||
(desc->hdr == 1 ? NVSDK_NGX_DLSS_Feature_Flags_AutoExposure : 0);
|
||||
const auto result = NGX_VULKAN_CREATE_DLSS_EXT1(state.device, command, 1, 1, &state.feature,
|
||||
state.parameters, &settings);
|
||||
if (!Check("DLSS feature creation", result) || !state.feature) {
|
||||
@@ -189,7 +189,7 @@ int32_t CreateFeature(VkCommandBuffer command, const TlgDlssFeature* desc) {
|
||||
state.feature_desc = *desc;
|
||||
Log(0, "DLSS %ux%u -> %ux%u, quality %d, preset %u%s", desc->input_width, desc->input_height,
|
||||
desc->output_width, desc->output_height, desc->quality, desc->preset,
|
||||
desc->hdr ? ", HDR input" : "");
|
||||
desc->hdr == 1 ? ", HDR input" : desc->hdr ? ", HDR input, fixed exposure" : "");
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ typedef struct TlgDlssFeature {
|
||||
int32_t quality; // 0 DLAA, 1 Quality, 2 Balanced, 3 Performance, 4 Ultra Performance
|
||||
int32_t depth_inverted;
|
||||
uint32_t preset; // NVSDK_NGX_DLSS_Hint_Render_Preset value, 0 = driver default
|
||||
int32_t hdr; // 1: linear HDR colour with automatic exposure, 0: tonemapped colour
|
||||
int32_t hdr; // 1: linear HDR colour with automatic exposure, 2: without, 0: tonemapped colour
|
||||
} TlgDlssFeature;
|
||||
|
||||
typedef struct TlgDlssEvaluate {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
From: tlg
|
||||
Subject: [PATCH] FSR 3 portable API: ffxVkPortableUpscaleContextSetVelocityFactor
|
||||
|
||||
---
|
||||
--- a/include/ffx_vk_portable.h
|
||||
+++ b/include/ffx_vk_portable.h
|
||||
@@ -323,6 +323,14 @@
|
||||
const FfxVkPortableUpscaleDispatchInfo* dispatchInfo);
|
||||
|
||||
/*
|
||||
+ * Set FSR 3's fVelocityFactor (0..1, default 1): how much motion reduces the
|
||||
+ * history weight. Applies from the next dispatch.
|
||||
+ */
|
||||
+FfxVkPortableResult ffxVkPortableUpscaleContextSetVelocityFactor(
|
||||
+ FfxVkPortableUpscaleContext* context,
|
||||
+ float velocityFactor);
|
||||
+
|
||||
+/*
|
||||
* Destroy a context. NULL is accepted. The application must first ensure
|
||||
* that no submitted command buffer can still reference this context's
|
||||
* resources (normally by waiting for the relevant frame fences).
|
||||
--- a/src/ffx_vk_fsr3_portable.cpp
|
||||
+++ b/src/ffx_vk_fsr3_portable.cpp
|
||||
@@ -616,6 +616,18 @@
|
||||
return fromFfxResult(ffxResult);
|
||||
}
|
||||
|
||||
+extern "C" FfxVkPortableResult ffxVkPortableUpscaleContextSetVelocityFactor(
|
||||
+ FfxVkPortableUpscaleContext* context,
|
||||
+ float velocityFactor)
|
||||
+{
|
||||
+ if (context == nullptr || context->magic != kContextMagic)
|
||||
+ return FFX_VK_PORTABLE_ERROR_INVALID_ARGUMENT;
|
||||
+ std::lock_guard<std::mutex> lock(context->mutex);
|
||||
+ return fromFfxResult(ffxFsr3UpscalerSetConstant(
|
||||
+ &context->ffxContext, FFX_FSR3UPSCALER_CONFIGURE_UPSCALE_KEY_FVELOCITYFACTOR,
|
||||
+ &velocityFactor));
|
||||
+}
|
||||
+
|
||||
extern "C" FfxVkPortableResult ffxVkPortableUpscaleContextDestroy(
|
||||
FfxVkPortableUpscaleContext* context)
|
||||
{
|
||||
--- a/src/ffx_vk_portable.c
|
||||
+++ b/src/ffx_vk_portable.c
|
||||
@@ -492,6 +492,16 @@
|
||||
return FFX_VK_PORTABLE_ERROR_UNSUPPORTED;
|
||||
}
|
||||
|
||||
+FfxVkPortableResult ffxVkPortableUpscaleContextSetVelocityFactor(
|
||||
+ FfxVkPortableUpscaleContext* context,
|
||||
+ float velocityFactor)
|
||||
+{
|
||||
+ (void)velocityFactor;
|
||||
+ return context == NULL
|
||||
+ ? FFX_VK_PORTABLE_ERROR_INVALID_POINTER
|
||||
+ : FFX_VK_PORTABLE_ERROR_UNSUPPORTED;
|
||||
+}
|
||||
+
|
||||
FfxVkPortableResult ffxVkPortableUpscaleContextDestroy(
|
||||
FfxVkPortableUpscaleContext* context)
|
||||
{
|
||||
@@ -10,3 +10,5 @@ patches here to its working tree when they are not applied yet:
|
||||
- `0003-...`: the rest of the standard includes the FFX 1.1.4 SDK relies on being pulled in
|
||||
transitively by libstdc++ but not by libc++ / MinGW: `<cstdlib>` for `_countof`, `<wchar.h>`
|
||||
for `swprintf_s`, `<cmath>` for `log2` and `<new>` for placement new.
|
||||
- `0004-...`: `ffxVkPortableUpscaleContextSetVelocityFactor`, FSR 3's `fVelocityFactor` (how much
|
||||
motion cuts the history) in the portable API, for the `fsr_velocity` setting.
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2024-2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <cstdio>
|
||||
#include "common/assert.h"
|
||||
#include "common/elf_info.h"
|
||||
#include "common/logging/log.h"
|
||||
@@ -369,6 +370,9 @@ s32 PS4_SYSV_ABI sceVideoOutGetDeviceCapabilityInfo(
|
||||
pDeviceCapabilityInfo->capability |= ORBIS_VIDEO_OUT_DEVICE_CAPABILITY_BT2020_PQ;
|
||||
}
|
||||
}
|
||||
std::printf("VideoOut: HDR capability %llu (display %d, game param.sfo %d)\n",
|
||||
(unsigned long long)pDeviceCapabilityInfo->capability, int(presenter->IsHDRSupported()),
|
||||
int(Common::ElfInfo::Instance().GetPSFAttributes().support_hdr));
|
||||
return ORBIS_OK;
|
||||
}
|
||||
|
||||
@@ -436,6 +440,7 @@ s32 PS4_SYSV_ABI sceVideoOutConfigureOutputMode_(s32 handle, u32 reserved, const
|
||||
return ORBIS_VIDEO_OUT_ERROR_INVALID_VALUE;
|
||||
}
|
||||
|
||||
std::printf("VideoOut: output mode colorimetry %d\n", int(mode->colorimetry));
|
||||
switch (mode->colorimetry) {
|
||||
case OrbisVideoOutColorimetry::Any:
|
||||
port->is_hdr = false;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "common/assert.h"
|
||||
#include "shader_recompiler/frontend/decode.h"
|
||||
#include "shader_recompiler/frontend/fetch_shader.h"
|
||||
#include "video_core/amdgpu/regs_shader.h"
|
||||
|
||||
namespace Shader::Gcn {
|
||||
|
||||
@@ -110,10 +111,17 @@ std::optional<FetchShaderData> ParseFetchShader(const Shader::Info& info) {
|
||||
FetchShaderData data;
|
||||
};
|
||||
thread_local std::unordered_map<const u32*, CachedParse> cache;
|
||||
thread_local std::vector<u32> current;
|
||||
if (const auto it = cache.find(code);
|
||||
!TlgToggle::Disabled(TlgToggle::FetchShaderCache) && it != cache.end() &&
|
||||
std::memcmp(code, it->second.code.data(), it->second.code.size() * sizeof(u32)) == 0) {
|
||||
return it->second.data;
|
||||
!TlgToggle::Disabled(TlgToggle::FetchShaderCache) && it != cache.end()) {
|
||||
// Read through the backing view: a direct read faults into a readback when the code
|
||||
// shares a page with GPU-written data (see AmdGpu::ReadShaderCode).
|
||||
const u64 bytes = it->second.code.size() * sizeof(u32);
|
||||
current.resize(it->second.code.size());
|
||||
AmdGpu::ReadShaderCode(code, current.data(), bytes);
|
||||
if (std::memcmp(current.data(), it->second.code.data(), bytes) == 0) {
|
||||
return it->second.data;
|
||||
}
|
||||
}
|
||||
|
||||
FetchShaderData data{};
|
||||
|
||||
@@ -3,12 +3,29 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include "common/assert.h"
|
||||
#include "common/types.h"
|
||||
#include "shader_recompiler/params.h"
|
||||
#include "tlg_toggles.h"
|
||||
|
||||
// runtime_memory.c
|
||||
extern "C" int runtime_memory_read_backing(uintptr_t address, void* data, uint64_t size);
|
||||
|
||||
namespace AmdGpu {
|
||||
|
||||
/// tlg: reads shader code through the unprotected backing view. Shader code shares pages with
|
||||
/// GPU-written data, so with Precise readbacks every per-draw read of a shader header faulted
|
||||
/// into a readback (TLG_FAULT_PROFILE: a third of all fault time). The GPU never writes shader
|
||||
/// code, so the backing is current; direct read when the range has no backing.
|
||||
inline void ReadShaderCode(const void* code, void* dest, u64 size) {
|
||||
if (TlgToggle::Disabled2(TlgToggle::ShaderCodeBacking) ||
|
||||
!runtime_memory_read_backing(reinterpret_cast<uintptr_t>(code), dest, size)) {
|
||||
std::memcpy(dest, code, size);
|
||||
}
|
||||
}
|
||||
|
||||
static constexpr u32 NUM_USER_DATA = 16;
|
||||
|
||||
using UserData = std::array<u32, NUM_USER_DATA>;
|
||||
@@ -221,12 +238,15 @@ struct ComputeProgram {
|
||||
}
|
||||
};
|
||||
|
||||
static constexpr const BinaryInfo& SearchBinaryInfo(const u32* code) {
|
||||
static BinaryInfo SearchBinaryInfo(const u32* code) {
|
||||
constexpr u32 token_mov_vcchi = 0xBEEB03FF;
|
||||
if (code[0] == token_mov_vcchi) {
|
||||
const auto* info = std::bit_cast<const BinaryInfo*>(code + (code[1] + 1) * 2);
|
||||
if (info->Valid()) {
|
||||
return *info;
|
||||
u32 head[2];
|
||||
ReadShaderCode(code, head, sizeof(head));
|
||||
if (head[0] == token_mov_vcchi) {
|
||||
BinaryInfo info;
|
||||
ReadShaderCode(code + (head[1] + 1) * 2, &info, sizeof(info));
|
||||
if (info.Valid()) {
|
||||
return info;
|
||||
}
|
||||
}
|
||||
constexpr u32 signature_size = sizeof(BinaryInfo::signature_ref) / sizeof(u8);
|
||||
@@ -240,9 +260,9 @@ static constexpr const BinaryInfo& SearchBinaryInfo(const u32* code) {
|
||||
UNREACHABLE_MSG("Shader binary info not found.");
|
||||
}
|
||||
|
||||
static constexpr Shader::ShaderParams GetParams(const auto& sh) {
|
||||
static Shader::ShaderParams GetParams(const auto& sh) {
|
||||
const auto* code = sh.template Address<u32*>();
|
||||
const auto& bininfo = SearchBinaryInfo(code);
|
||||
const auto bininfo = SearchBinaryInfo(code);
|
||||
return {
|
||||
.user_data = sh.user_data,
|
||||
.code = std::span{code, bininfo.length / sizeof(u32)},
|
||||
|
||||
@@ -106,6 +106,11 @@ public:
|
||||
/// Return true when a region is modified from the GPU
|
||||
[[nodiscard]] bool IsRegionGpuModified(VAddr addr, size_t size);
|
||||
|
||||
/// True when GPU writes to these exact bytes have not been downloaded yet.
|
||||
[[nodiscard]] bool HasPendingGpuWrite(VAddr addr, size_t size) const {
|
||||
return gpu_modified_ranges.Intersects(addr, size);
|
||||
}
|
||||
|
||||
/// Processes the fault buffer.
|
||||
void ProcessFaultBuffer();
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ layout(push_constant) uniform Constants {
|
||||
vec2 size; // pixels
|
||||
vec2 jitter; // current viewport shift in render pixels (x right, y down)
|
||||
vec2 previous_jitter;
|
||||
uint mode; // motion: 1 = object motion vectors available; debug: 0 motion, 1 raw
|
||||
uint mode; // motion: 1 = object motion vectors available, 2 = game motion, 3 = game
|
||||
// motion with the hair fix; debug: 0 motion, 1 raw
|
||||
// depth, 2 view z / 50, 3 reprojected previous frame, 4 |difference|
|
||||
};
|
||||
|
||||
@@ -84,7 +85,34 @@ void main() {
|
||||
imageStore(color_img, pixel, vec4(mix(current.rgb, mv_color, 0.75), current.a));
|
||||
#else
|
||||
vec2 result = motion;
|
||||
if (mode == 1) {
|
||||
if (mode >= 2) {
|
||||
// tlg: the game's own motion target (UV units, current - previous, unjittered).
|
||||
const vec2 game = texelFetch(object_tex, pixel, 0).rg;
|
||||
result = any(isnan(game)) || any(isinf(game)) ? vec2(0.0) : -game * size;
|
||||
// Mode 3: the hair does not write the motion target, so it keeps the vector of the
|
||||
// surface behind it. A static point on this pixel's ray moves along the segment from
|
||||
// `motion` (this depth) to the motion at infinite depth; a vector inside that segment
|
||||
// belongs to something farther away. Use the camera motion at this depth instead.
|
||||
if (mode == 3 && !sky) {
|
||||
const vec3 far_view = vec3(dot(reproject[0].xyz, view.xyz),
|
||||
dot(reproject[1].xyz, view.xyz),
|
||||
dot(reproject[2].xyz, view.xyz));
|
||||
const vec2 far_ndc = vec2(far_view.x * prev_proj.x, far_view.y * prev_proj.y) /
|
||||
max(far_view.z, 1e-4);
|
||||
const vec2 far_motion =
|
||||
(vec2(far_ndc.x * 0.5 + 0.5, 0.5 - far_ndc.y * 0.5) - scene_uv) * size;
|
||||
const vec2 axis = far_motion - motion;
|
||||
const float length2 = dot(axis, axis);
|
||||
if (length2 > 4.0) {
|
||||
const vec2 offset = result - motion;
|
||||
const float t = dot(offset, axis) / length2;
|
||||
const float miss = length(offset - t * axis);
|
||||
if (t > 0.1 && t < 0.95 && miss < max(1.0, 0.05 * sqrt(length2))) {
|
||||
result = motion;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (mode == 1) {
|
||||
const vec4 object_motion = texelFetch(object_tex, pixel, 0);
|
||||
// A later ordinary draw may have covered this pixel without touching the motion
|
||||
// target. Its final depth then differs from the depth stored with the vector.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// RCAS on the resolved scene only. The unsharpened history and HUD are untouched.
|
||||
// Strength 0..2: above 1 the RCAS change is extrapolated (stronger than AMD's maximum). The
|
||||
// same pass sharpens FSR 3/4 output beyond their own RCAS (LDR_OUTPUT: the 8-bit UI image).
|
||||
// same pass sharpens FSR 3/4 output beyond their own RCAS (LDR_OUTPUT: the 8-bit UI image),
|
||||
// and DLSS output like FSR 3's own RCAS. Blend: how much of the RCAS change is applied (DLSS:
|
||||
// 1 up to strength 1, as AMD's; the others: the strength).
|
||||
#version 450
|
||||
#extension GL_GOOGLE_include_directive : require
|
||||
layout(local_size_x = 8, local_size_y = 8) in;
|
||||
@@ -11,7 +13,7 @@ layout(binding = 1, rgba8) uniform writeonly image2D result_img;
|
||||
#else
|
||||
layout(binding = 1, rgba16f) uniform writeonly image2D result_img;
|
||||
#endif
|
||||
layout(push_constant) uniform Params { float strength; };
|
||||
layout(push_constant) uniform Params { float strength; float blend; };
|
||||
#define A_GPU 1
|
||||
#define A_GLSL 1
|
||||
#include "fsr/ffx_a.h"
|
||||
@@ -47,6 +49,6 @@ void main() {
|
||||
uvec4 constants = uvec4(floatBitsToUint(exp2(-2.0*(1.0-min(amount, 1.0)))), 0, 0, 0);
|
||||
vec3 sharpened;
|
||||
FsrRcasF(sharpened.r, sharpened.g, sharpened.b, uvec2(p), constants);
|
||||
imageStore(result_img, p, vec4(clamp(mix(center, sharpened*color_scale, amount),
|
||||
imageStore(result_img, p, vec4(clamp(mix(center, sharpened*color_scale, clamp(blend, 0.0, 2.0)),
|
||||
vec3(0), vec3(color_scale)), 1));
|
||||
}
|
||||
@@ -36,6 +36,11 @@ void main() {
|
||||
const bool object = object_size == size && texelFetch(objects, source, 0).b > 0.99;
|
||||
const float luma = dot(min(color, vec3(1.0)), vec3(0.3, 0.6, 0.1));
|
||||
color = vec3(luma * 0.3) + vec3(clamp(abs(mv) / 8.0, 0.0, 1.0), object ? 0.6 : 0.0);
|
||||
} else if (mode == 3) {
|
||||
// Signed motion (DLAA): grey = still, red/green = x/y, 32 render pixels = full.
|
||||
const vec2 mv = texelFetch(motion, pixel, 0).rg;
|
||||
const float luma = dot(min(color, vec3(1.0)), vec3(0.3, 0.6, 0.1));
|
||||
color = vec3(luma * 0.02) + vec3(clamp(0.5 + mv / 64.0, 0.0, 1.0), 0.0) * 0.2;
|
||||
}
|
||||
imageStore(scene, pixel, vec4(color, original.a));
|
||||
}
|
||||
@@ -218,6 +218,48 @@ void CameraMotion::RecordMotion(vk::CommandBuffer cmdbuf, vk::ImageView depth_vi
|
||||
cmdbuf.dispatch((width + 7) / 8, (height + 7) / 8, 1);
|
||||
}
|
||||
|
||||
void CameraMotion::RecordGameMotion(vk::CommandBuffer cmdbuf, vk::ImageView depth_view,
|
||||
vk::ImageView game_view, vk::ImageView motion_view,
|
||||
u32 width, u32 height) {
|
||||
// Mode 3 also needs both cameras: it replaces vectors the hair inherited from behind it.
|
||||
const bool fix = Ready() && !TlgToggle::Disabled(TlgToggle::DlaaMotionFix);
|
||||
const PushConstants push{
|
||||
.reproject = Multiply(previous.view, current.inv_view),
|
||||
.proj = current.proj,
|
||||
.prev_proj = previous.proj,
|
||||
.size = {float(width), float(height)},
|
||||
.jitter = jitter,
|
||||
.previous_jitter = previous_jitter,
|
||||
.mode = fix ? 3u : 2u,
|
||||
};
|
||||
const vk::DescriptorImageInfo depth_info{.imageView = depth_view,
|
||||
.imageLayout = vk::ImageLayout::eGeneral};
|
||||
const vk::DescriptorImageInfo motion_info{.imageView = motion_view,
|
||||
.imageLayout = vk::ImageLayout::eGeneral};
|
||||
const vk::DescriptorImageInfo game_info{.imageView = game_view,
|
||||
.imageLayout = vk::ImageLayout::eGeneral};
|
||||
const std::array<vk::WriteDescriptorSet, 3> writes = {{
|
||||
{.dstBinding = 0,
|
||||
.descriptorCount = 1,
|
||||
.descriptorType = vk::DescriptorType::eSampledImage,
|
||||
.pImageInfo = &depth_info},
|
||||
{.dstBinding = 1,
|
||||
.descriptorCount = 1,
|
||||
.descriptorType = vk::DescriptorType::eStorageImage,
|
||||
.pImageInfo = &motion_info},
|
||||
{.dstBinding = 2,
|
||||
.descriptorCount = 1,
|
||||
.descriptorType = vk::DescriptorType::eSampledImage,
|
||||
.pImageInfo = &game_info},
|
||||
}};
|
||||
cmdbuf.bindPipeline(vk::PipelineBindPoint::eCompute, *motion_pipeline);
|
||||
cmdbuf.pushDescriptorSetKHR(vk::PipelineBindPoint::eCompute, *motion_pipeline_layout, 0,
|
||||
writes);
|
||||
cmdbuf.pushConstants(*motion_pipeline_layout, vk::ShaderStageFlagBits::eCompute, 0,
|
||||
sizeof(push), &push);
|
||||
cmdbuf.dispatch((width + 7) / 8, (height + 7) / 8, 1);
|
||||
}
|
||||
|
||||
void CameraMotion::OnConstants(const float* data) {
|
||||
// Scene constants: far plane 3000, 1/far, and the render size.
|
||||
if (data[0] != 3000.0f || data[4] < 64.0f || data[5] < 64.0f ||
|
||||
|
||||
@@ -68,6 +68,11 @@ public:
|
||||
/// `motion_view` (RG16F storage, General), pixels, previous minus current.
|
||||
void RecordMotion(vk::CommandBuffer cmdbuf, vk::ImageView depth_view, vk::ImageView motion_view,
|
||||
u32 width, u32 height);
|
||||
/// tlg: converts the game's own motion target (R16G16, UV current - previous) into
|
||||
/// `motion_view` (pixels, previous - current).
|
||||
void RecordGameMotion(vk::CommandBuffer cmdbuf, vk::ImageView depth_view,
|
||||
vk::ImageView game_view, vk::ImageView motion_view, u32 width,
|
||||
u32 height);
|
||||
|
||||
/// A bound constant buffer of 864 bytes: checks the scene constant signature.
|
||||
void OnConstants(const float* data);
|
||||
|
||||
@@ -206,7 +206,7 @@ bool Dlss::CreateFeature(vk::CommandBuffer command, const FeatureDesc& desc) {
|
||||
impl->feature.reset();
|
||||
const TlgDlssFeature feature{desc.input_width, desc.input_height, desc.output_width,
|
||||
desc.output_height, desc.quality, 0,
|
||||
0, desc.hdr ? 1 : 0};
|
||||
desc.preset, desc.hdr ? (desc.auto_exposure ? 1 : 2) : 0};
|
||||
if (!impl->api->CreateFeature(command, &feature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ public:
|
||||
u32 input_width, input_height, output_width, output_height;
|
||||
int quality;
|
||||
bool hdr;
|
||||
u32 preset = 0; // NVSDK_NGX_DLSS_Hint_Render_Preset, 0: driver default
|
||||
bool auto_exposure = true;
|
||||
bool operator==(const FeatureDesc&) const = default;
|
||||
};
|
||||
struct Frame {
|
||||
|
||||
@@ -53,6 +53,12 @@ public:
|
||||
return *stages[u32(stage)];
|
||||
}
|
||||
|
||||
/// tlg: the fragment shader's hash, 0 for depth-only pipelines.
|
||||
u64 FragmentHash() const noexcept {
|
||||
const auto* fs = stages[u32(Shader::SwStage::Fragment)];
|
||||
return fs ? fs->pgm_hash : 0;
|
||||
}
|
||||
|
||||
bool IsCompute() const {
|
||||
return is_compute;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
#ifndef _WIN32
|
||||
#include <dlfcn.h>
|
||||
#endif
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <unordered_set>
|
||||
#include <xxhash.h>
|
||||
#include "video_core/renderer_vulkan/ui_composition.h"
|
||||
#include "tlg_settings.h"
|
||||
#include "tlg_toggles.h"
|
||||
#include "video_core/renderer_vulkan/vk_frame_capture.h"
|
||||
#include "common/debug.h"
|
||||
@@ -223,6 +228,110 @@ VideoCore::ImageId Rasterizer::FindTargetMemoized(VideoCore::TextureCache::Image
|
||||
return image_id;
|
||||
}
|
||||
|
||||
// tlg debug: TLG_JITTER_CONTROL=<file> bisects which scene draws break under jitter while the
|
||||
// game runs. "lo hi" pairs: only the jitter-eligible draws in those ranges (counted from the sky
|
||||
// pass) are shifted; "only <ps hex>": only draws of these pixel shaders; "not <ps hex>": these are
|
||||
// never shifted; "skip <ps hex>": draws of that pixel shader are dropped; "map": print the
|
||||
// eligible draws of one frame (index, pixel shader). Re-read twice a second.
|
||||
namespace {
|
||||
struct JitterControl {
|
||||
std::vector<std::pair<u32, u32>> ranges;
|
||||
std::vector<u32> skip, only, never;
|
||||
bool map = false;
|
||||
u32 index = 0; ///< jitter-eligible draws since the sky pass
|
||||
} jitter_control;
|
||||
|
||||
/// Meshes (index address, count) drawn jittered into a scene depth since the sky pass.
|
||||
std::vector<std::pair<VAddr, u32>> jittered_meshes;
|
||||
|
||||
bool Listed(const std::vector<u32>& list, u32 ps) {
|
||||
return std::find(list.begin(), list.end(), ps) != list.end();
|
||||
}
|
||||
|
||||
void ReadJitterControl() {
|
||||
auto& control = jitter_control;
|
||||
const u32 count = control.index;
|
||||
control.index = 0;
|
||||
jittered_meshes.clear(); // a new frame
|
||||
control.map = false;
|
||||
static const char* path = std::getenv("TLG_JITTER_CONTROL");
|
||||
static auto last = std::chrono::steady_clock::time_point{};
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (!path || now - last < std::chrono::milliseconds(500)) return;
|
||||
last = now;
|
||||
FILE* file = std::fopen(path, "r");
|
||||
if (!file) return;
|
||||
JitterControl next;
|
||||
char token[32];
|
||||
u32 lo = ~0u;
|
||||
while (std::fscanf(file, "%31s", token) == 1) {
|
||||
u32 hash;
|
||||
auto* list = !std::strcmp(token, "skip") ? &next.skip
|
||||
: !std::strcmp(token, "only") ? &next.only
|
||||
: !std::strcmp(token, "not") ? &next.never
|
||||
: nullptr;
|
||||
if (list) {
|
||||
if (std::fscanf(file, "%x", &hash) == 1) list->push_back(hash);
|
||||
} else if (!std::strcmp(token, "map")) {
|
||||
next.map = true;
|
||||
} else if (lo == ~0u) {
|
||||
lo = u32(std::strtoul(token, nullptr, 10));
|
||||
} else {
|
||||
next.ranges.emplace_back(lo, u32(std::strtoul(token, nullptr, 10)));
|
||||
lo = ~0u;
|
||||
}
|
||||
}
|
||||
std::fclose(file);
|
||||
std::printf("Jitter control: %zu ranges, %zu only, %zu not, %zu skipped (%u eligible)\n",
|
||||
next.ranges.size(), next.only.size(), next.never.size(), next.skip.size(), count);
|
||||
std::fflush(stdout);
|
||||
// A one-shot map: remove the token from the file to avoid a map every half second.
|
||||
next.index = 0;
|
||||
control = std::move(next);
|
||||
}
|
||||
|
||||
/// Called once per jitter-eligible draw: whether this one is shifted.
|
||||
bool JitterInRange(u32 ps) {
|
||||
const auto& control = jitter_control;
|
||||
const u32 index = jitter_control.index++;
|
||||
if (control.map) {
|
||||
std::printf("Jitter map: %u ps %08x\n", index, ps);
|
||||
}
|
||||
if (Listed(control.never, ps) || (!control.only.empty() && !Listed(control.only, ps))) {
|
||||
return false;
|
||||
}
|
||||
if (control.ranges.empty()) return true;
|
||||
for (const auto& [lo, hi] : control.ranges) {
|
||||
if (index >= lo && index < hi) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SkippedShader(u32 ps) {
|
||||
return Listed(jitter_control.skip, ps);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
/// tlg: whether the draw samples a scene depth: the bound images hold its depth attachment
|
||||
/// once, and a sampled depth of the scene size again.
|
||||
bool Rasterizer::SamplesSceneDepth() {
|
||||
u32 depths = 0;
|
||||
for (const auto image_id : bound_images) {
|
||||
const auto& info = texture_cache.GetImage(image_id).info;
|
||||
depths += info.props.is_depth &&
|
||||
std::array{info.size.width, info.size.height} == upscaler->GameSize();
|
||||
}
|
||||
return depths > 1;
|
||||
}
|
||||
|
||||
bool Rasterizer::JitterDepth(VideoCore::ImageId depth) {
|
||||
if (!upscaler->GameMotion()) return depth == camera_motion->Depth();
|
||||
// Game motion: both ping-pong scene depths (the prepass and G-buffer of a frame use the
|
||||
// same one, of the sky pass's size), not the half-resolution transparents or shadow maps.
|
||||
const auto& size = texture_cache.GetImage(depth).info.size;
|
||||
return std::array{size.width, size.height} == upscaler->GameSize();
|
||||
}
|
||||
|
||||
void Rasterizer::PrepareRenderState(const GraphicsPipeline* pipeline) {
|
||||
// Prefetch render targets to handle overlaps with bound textures (e.g. mipgen)
|
||||
const auto& key = pipeline->GetGraphicsKey();
|
||||
@@ -1012,12 +1121,19 @@ void Rasterizer::DrawRecord(const GraphicsPipeline* pipeline, const PreparedDraw
|
||||
static const char* scene_debug = std::getenv("TLG_SCENE_DEBUG");
|
||||
scene_debug_frame = scene_debug && std::remove(scene_debug) == 0;
|
||||
scene_targets->debug = scene_debug_frame;
|
||||
scene_targets->SetShadowScale(u32(std::clamp(TlgSettings::Get().shadow_scale.load(), 1, 4)));
|
||||
scene_targets->ShadowReport();
|
||||
}
|
||||
bind_prepared = used_prepared;
|
||||
motion_draw = pipeline->GetGraphicsKey().motion_vectors;
|
||||
motion_geometry = 0;
|
||||
|
||||
PrepareRenderState(pipeline);
|
||||
if (upscaler->Enabled() &&
|
||||
pipeline->FragmentHash() == upscaler->SkyMotionPs()) {
|
||||
upscaler->OnGameMotionPass(cb_descs[0].first, cb_descs[2].first, db_desc.first);
|
||||
ReadJitterControl();
|
||||
}
|
||||
if (upscaler->Enabled() && std::popcount(pipeline->GetGraphicsKey().mrt_mask) == 1) {
|
||||
const auto& viewport = Regs().viewports[0];
|
||||
upscaler->OnDraw(pipeline->GetStage(Shader::SwStage::Vertex).pgm_hash,
|
||||
@@ -1143,10 +1259,46 @@ void Rasterizer::DrawRecord(const GraphicsPipeline* pipeline, const PreparedDraw
|
||||
// tlg: jitter geometry drawn with the scene depth, not full-screen passes (a shifted
|
||||
// full-screen quad leaves an edge column unwritten).
|
||||
draw_jitter = {};
|
||||
if (upscaler->Enabled() && db_desc.first && db_desc.first == camera_motion->Depth() &&
|
||||
(regs.num_indices > 6 || regs.num_instances.NumInstances() > 1)) {
|
||||
// Not volumes that sample a scene depth (decals, lights): they rebuild positions per pixel
|
||||
// from the already jittered depth, and a shifted box misplaces what they write into the
|
||||
// G-buffer (blocks on the boy's face, ps da3456ba, 2026-10-21).
|
||||
if (upscaler->Enabled() && db_desc.first && JitterDepth(db_desc.first) &&
|
||||
(regs.num_indices > 6 || regs.num_instances.NumInstances() > 1) &&
|
||||
(!TlgToggle::Disabled(TlgToggle::JitterDepthWritesOnly) ||
|
||||
regs.depth_control.depth_write_enable) &&
|
||||
!SamplesSceneDepth() && JitterInRange(pipeline->FragmentHash())) {
|
||||
draw_jitter = upscaler->Jitter();
|
||||
}
|
||||
// A redraw of a mesh already drawn jittered into this depth takes the same shift, sampling
|
||||
// or not: water shading (ps 05adfd4d, 4755277d) tests LessEqual against its own depth
|
||||
// pre-pass (ps 26c53528) and flickered where the two disagreed (2026-10-21).
|
||||
if (upscaler->Enabled() && is_indexed && db_desc.first && JitterDepth(db_desc.first)) {
|
||||
const std::pair mesh{regs.index_base_address.Address<VAddr>() +
|
||||
u64(index_offset) * 4, regs.num_indices};
|
||||
const bool known = std::find(jittered_meshes.begin(), jittered_meshes.end(), mesh) !=
|
||||
jittered_meshes.end();
|
||||
if (draw_jitter[0] != 0.0f || draw_jitter[1] != 0.0f) {
|
||||
if (!known && regs.depth_control.depth_write_enable) jittered_meshes.push_back(mesh);
|
||||
} else if (known) {
|
||||
draw_jitter = upscaler->Jitter();
|
||||
}
|
||||
}
|
||||
if (jitter_control.map && db_desc.first && JitterDepth(db_desc.first)) {
|
||||
// The map also describes the draw: indices, depth state, blend, jitter, bound images.
|
||||
std::string images;
|
||||
for (const auto image_id : bound_images) {
|
||||
const auto& info = texture_cache.GetImage(image_id).info;
|
||||
images += fmt::format(" {}x{}:{}{}#{}", info.size.width, info.size.height,
|
||||
u32(info.pixel_format), info.props.is_depth ? "d" : "",
|
||||
image_id.index);
|
||||
}
|
||||
std::printf("Jitter draw: ps %08x indices %u inst %u zwrite %u zfunc %u blend %u "
|
||||
"depth #%u jittered %d images%s\n",
|
||||
pipeline->FragmentHash(), regs.num_indices, regs.num_instances.NumInstances(),
|
||||
u32(regs.depth_control.depth_write_enable), u32(regs.depth_control.depth_func),
|
||||
u32(regs.blend_control[0].enable), db_desc.first.index,
|
||||
int(draw_jitter[0] != 0.0f || draw_jitter[1] != 0.0f), images.c_str());
|
||||
}
|
||||
UpdateDynamicState(pipeline, is_indexed);
|
||||
MarkPass(pipeline, state);
|
||||
scheduler.BeginRendering(state);
|
||||
@@ -1157,6 +1309,9 @@ void Rasterizer::DrawRecord(const GraphicsPipeline* pipeline, const PreparedDraw
|
||||
|
||||
const vk::Pipeline handle = pipeline->Handle();
|
||||
const vk::Pipeline square = SquarePass(*pipeline);
|
||||
const bool clear_smaa_weights = upscaler->SkipGameSmaa(vs_info.pgm_hash);
|
||||
const bool skipped = !jitter_control.skip.empty() && SkippedShader(pipeline->FragmentHash());
|
||||
const vk::Rect2D render_area{{0, 0}, {state.width, state.height}};
|
||||
const u32 num_indices = regs.num_indices;
|
||||
const u32 num_instances = regs.num_instances.NumInstances();
|
||||
const u32 first_vertex = vertex_offset;
|
||||
@@ -1170,6 +1325,15 @@ void Rasterizer::DrawRecord(const GraphicsPipeline* pipeline, const PreparedDraw
|
||||
cmdbuf.draw(num_indices, num_instances, first_vertex, first_instance);
|
||||
}
|
||||
};
|
||||
if (skipped) {
|
||||
return;
|
||||
}
|
||||
if (clear_smaa_weights) {
|
||||
const vk::ClearAttachment clear{vk::ImageAspectFlagBits::eColor, 0,
|
||||
vk::ClearColorValue{std::array{0.0f, 0.0f, 0.0f, 0.0f}}};
|
||||
cmdbuf.clearAttachments(clear, vk::ClearRect{render_area, 0, 1});
|
||||
return;
|
||||
}
|
||||
cmdbuf.bindPipeline(vk::PipelineBindPoint::eGraphics, handle);
|
||||
draw();
|
||||
if (square) [[unlikely]] {
|
||||
@@ -1242,6 +1406,11 @@ void Rasterizer::DrawIndirectRecord(const GraphicsPipeline* pipeline, bool is_in
|
||||
motion_draw = false;
|
||||
motion_geometry = 0;
|
||||
PrepareRenderState(pipeline);
|
||||
if (upscaler->Enabled() &&
|
||||
pipeline->FragmentHash() == upscaler->SkyMotionPs()) {
|
||||
upscaler->OnGameMotionPass(cb_descs[0].first, cb_descs[2].first, db_desc.first);
|
||||
ReadJitterControl();
|
||||
}
|
||||
if (upscaler->Enabled() && std::popcount(pipeline->GetGraphicsKey().mrt_mask) == 1) {
|
||||
const auto& viewport = Regs().viewports[0];
|
||||
upscaler->OnDraw(pipeline->GetStage(Shader::SwStage::Vertex).pgm_hash,
|
||||
@@ -1280,7 +1449,8 @@ void Rasterizer::DrawIndirectRecord(const GraphicsPipeline* pipeline, bool is_in
|
||||
push_data.yoffset *= target_scale[1];
|
||||
pipeline->BindResources(set_writes, push_data);
|
||||
draw_jitter = {};
|
||||
if (upscaler->Enabled() && db_desc.first && db_desc.first == camera_motion->Depth()) {
|
||||
if (upscaler->Enabled() && db_desc.first && JitterDepth(db_desc.first) && !SamplesSceneDepth() &&
|
||||
JitterInRange(pipeline->FragmentHash())) {
|
||||
draw_jitter = upscaler->Jitter();
|
||||
}
|
||||
UpdateDynamicState(pipeline, is_indexed);
|
||||
@@ -2289,7 +2459,8 @@ void Rasterizer::BindTextures(const Shader::Info& stage, const PreparedStage* pr
|
||||
// image (normalized sampling only; see Shader::ImageResource::needs_native).
|
||||
boost::container::small_vector<bool, 16> binding_proxy_ok;
|
||||
const bool sample_proxies = !TlgToggle::Disabled(TlgToggle::SampleSceneProxies) &&
|
||||
!on_helper && scene_targets->Reduced();
|
||||
!on_helper &&
|
||||
(scene_targets->Reduced() || scene_targets->ShadowsUpscaled());
|
||||
|
||||
for (u32 image_index = 0; image_index < stage.images.size(); ++image_index) {
|
||||
const auto& image_desc = stage.images[image_index];
|
||||
@@ -2747,6 +2918,30 @@ RenderState Rasterizer::BeginRenderingFull(const GraphicsPipeline* pipeline) {
|
||||
// A proxy attachment cannot represent MSAA or a feedback loop that reads the
|
||||
// same image through the guest's native descriptor during this draw.
|
||||
bool reduced = scene_started && upscaler->RasterScaling() && key.num_samples == 1;
|
||||
// tlg: a depth-only pass into a shadow map draws into its upscaled proxy instead.
|
||||
u32 shadow_scale = 0;
|
||||
if (db_desc.first && !key.mrt_mask && key.num_samples == 1) {
|
||||
const auto& depth = texture_cache.GetImage(db_desc.first);
|
||||
if (scene_targets->Eligible(depth) && !depth.binding.needs_rebind) {
|
||||
shadow_scale = scene_targets->Upscale(depth);
|
||||
}
|
||||
}
|
||||
if (shadow_scale) reduced = true;
|
||||
if (db_desc.first && !shadow_scale && scene_targets->ShadowsUpscaled()) {
|
||||
const auto& depth = texture_cache.GetImage(db_desc.first);
|
||||
static u32 logged = 0;
|
||||
if (depth.info.size.width == 2048 && depth.info.size.height == 2048 && logged < 16) {
|
||||
++logged;
|
||||
std::printf("Shadow pass kept native: %#llx %s mrt %#x samples %u layer %u levels %u "
|
||||
"layers %u depth %d eligible %d rebind %d\n",
|
||||
(unsigned long long)depth.info.guest_address,
|
||||
vk::to_string(depth.info.pixel_format).c_str(), key.mrt_mask,
|
||||
u32(key.num_samples), db_desc.second.view_info.range.base.layer,
|
||||
depth.info.resources.levels, depth.info.resources.layers,
|
||||
int(depth.info.props.is_depth), int(scene_targets->Eligible(depth)),
|
||||
int(depth.binding.needs_rebind));
|
||||
}
|
||||
}
|
||||
// TLG_SCENE_DEBUG=<file>: once the file exists, why the passes of one frame keep the
|
||||
// native size (see NoteFrameStart).
|
||||
const bool debug_pass = scene_debug_frame && scene_started;
|
||||
@@ -2801,7 +2996,11 @@ RenderState Rasterizer::BeginRenderingFull(const GraphicsPipeline* pipeline) {
|
||||
}
|
||||
std::printf("Scene pass: reduced %d %s\n", reduced, why.c_str());
|
||||
}
|
||||
push_data.scene_size = reduced ? SceneResolution::Pack(scene_targets->Size()) : 0;
|
||||
// (A shadow pass keeps the FragCoord conversion right with a scene of k x 1920x1080.)
|
||||
push_data.scene_size =
|
||||
!reduced ? 0
|
||||
: shadow_scale ? SceneResolution::Pack({1920 * shadow_scale, 1080 * shadow_scale})
|
||||
: SceneResolution::Pack(scene_targets->Size());
|
||||
if (TlgStats::enabled) {
|
||||
TlgStats::reduced_draws.fetch_add(reduced, std::memory_order_relaxed);
|
||||
TlgStats::scene_draws.fetch_add(scene_started, std::memory_order_relaxed);
|
||||
@@ -3027,7 +3226,9 @@ RenderState Rasterizer::BeginRenderingFull(const GraphicsPipeline* pipeline) {
|
||||
const auto proxy = pass_size.value_or(size);
|
||||
state.width = proxy.width;
|
||||
state.height = proxy.height;
|
||||
target_scale = {float(size.width) / 1920.0f, float(size.height) / 1080.0f};
|
||||
target_scale = shadow_scale
|
||||
? std::array{float(shadow_scale), float(shadow_scale)}
|
||||
: std::array{float(size.width) / 1920.0f, float(size.height) / 1080.0f};
|
||||
}
|
||||
if (FrameCapture::Active()) {
|
||||
std::array<const VideoCore::ImageInfo*, AmdGpu::NUM_COLOR_BUFFERS> colors{};
|
||||
|
||||
@@ -165,6 +165,9 @@ public:
|
||||
|
||||
private:
|
||||
void PrepareRenderState(const GraphicsPipeline* pipeline);
|
||||
/// tlg: geometry drawn into this depth gets the upscaler's sub-pixel jitter.
|
||||
bool JitterDepth(VideoCore::ImageId depth);
|
||||
bool SamplesSceneDepth();
|
||||
RenderState BeginRendering(const GraphicsPipeline* pipeline);
|
||||
RenderState BeginRenderingFull(const GraphicsPipeline* pipeline);
|
||||
/// tlg: a draw continuing the open render pass with the same inputs gets the same render
|
||||
|
||||
@@ -78,8 +78,19 @@ static u32 SizeDivisor(const VideoCore::ImageInfo& i, SceneResolution::Size scen
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
u32 SceneTargets::Upscale(const VideoCore::Image& image) const {
|
||||
const auto& i = image.info;
|
||||
return shadow_scale > 1 && i.props.is_depth && i.size.width == 2048 && i.size.height == 2048 &&
|
||||
i.size.depth == 1 && i.resources.layers <= 8 && i.resources.levels == 1 &&
|
||||
i.num_samples == 1 && image.backing && image.backing->num_samples == 1
|
||||
? shadow_scale
|
||||
: 0;
|
||||
}
|
||||
bool SceneTargets::Eligible(const VideoCore::Image& image) const {
|
||||
const auto& i = image.info;
|
||||
if (Upscale(image)) {
|
||||
return Blittable(image.backing->image.image_ci.format) || ShaderResampled(image);
|
||||
}
|
||||
const u32 div = SizeDivisor(i, size);
|
||||
if (!div || i.size.depth != 1 || i.resources.layers != 1 || i.num_samples != 1 ||
|
||||
i.props.is_block || !image.backing || image.backing->num_samples != 1) {
|
||||
@@ -96,17 +107,38 @@ bool SceneTargets::EligibleScene(const VideoCore::Image& image) const {
|
||||
return image.info.size.width == 1920 && image.info.size.height == 1080 && Eligible(image);
|
||||
}
|
||||
SceneResolution::Size SceneTargets::ProxySize(const VideoCore::Image& image, u32 level) const {
|
||||
if (const u32 k = Upscale(image)) {
|
||||
return {std::max(1u, image.info.size.width * k >> level),
|
||||
std::max(1u, image.info.size.height * k >> level)};
|
||||
}
|
||||
const u32 div = std::max(1u, SizeDivisor(image.info, size));
|
||||
return {std::max(1u, size.width / div >> level), std::max(1u, size.height / div >> level)};
|
||||
}
|
||||
bool SceneTargets::SetSize(SceneResolution::Size next) {
|
||||
if (next == size) return false;
|
||||
void SceneTargets::Reset() {
|
||||
ResolveAll();
|
||||
scheduler.Finish();
|
||||
entries.clear(); // no command buffer can still reference these images/views
|
||||
tracked.clear();
|
||||
++generation;
|
||||
recent = {};
|
||||
}
|
||||
bool SceneTargets::SetShadowScale(u32 scale) {
|
||||
if (scale == shadow_scale) return false;
|
||||
Reset();
|
||||
shadow_scale = scale;
|
||||
std::printf("Shadow maps: %ux native (live)\n", scale);
|
||||
return true;
|
||||
}
|
||||
void SceneTargets::ShadowReport() {
|
||||
if (shadow_scale < 2 || ++shadow_frames < 120) return;
|
||||
std::printf("Shadow maps %ux: %u proxy passes, %u proxy samples, %u fills, %u resolves "
|
||||
"(120 frames)\n", shadow_scale, shadow_passes, shadow_samples, shadow_fills,
|
||||
shadow_resolves);
|
||||
shadow_frames = shadow_passes = shadow_samples = shadow_fills = shadow_resolves = 0;
|
||||
}
|
||||
bool SceneTargets::SetSize(SceneResolution::Size next) {
|
||||
if (next == size) return false;
|
||||
Reset();
|
||||
size = next;
|
||||
std::printf("Scene resolution: raster %ux%u, half-resolution targets %ux%u, UI 1920x1080 "
|
||||
"(live)\n", size.width, size.height, size.width / 2, size.height / 2);
|
||||
@@ -141,7 +173,7 @@ void SceneTargets::Transition(Entry& e, vk::ImageAspectFlags aspect, vk::ImageLa
|
||||
.srcAccessMask = vk::AccessFlagBits2::eMemoryRead | vk::AccessFlagBits2::eMemoryWrite,
|
||||
.dstStageMask = stages, .dstAccessMask = access,
|
||||
.oldLayout = e.layout, .newLayout = layout,
|
||||
.image = e.image, .subresourceRange = {aspect, 0, 1, 0, 1},
|
||||
.image = e.image, .subresourceRange = {aspect, 0, 1, 0, VK_REMAINING_ARRAY_LAYERS},
|
||||
};
|
||||
scheduler.Record([barrier](vk::CommandBuffer cmd) {
|
||||
cmd.pipelineBarrier2({.imageMemoryBarrierCount = 1, .pImageMemoryBarriers = &barrier});
|
||||
@@ -150,6 +182,7 @@ void SceneTargets::Transition(Entry& e, vk::ImageAspectFlags aspect, vk::ImageLa
|
||||
}
|
||||
void SceneTargets::Copy(Entry& e, VideoCore::Image& original, bool to_native) {
|
||||
copying = true;
|
||||
if (Upscale(original)) ++(to_native ? shadow_resolves : shadow_fills);
|
||||
scheduler.EndRendering();
|
||||
const Runtime::TransferMark mark{runtime, to_native ? "scene proxy resolve" : "scene proxy fill",
|
||||
original};
|
||||
@@ -184,7 +217,8 @@ void SceneTargets::Copy(Entry& e, VideoCore::Image& original, bool to_native) {
|
||||
copying = false;
|
||||
return;
|
||||
}
|
||||
const VideoCore::SubresourceRange level_range{.base = {e.level, 0}, .extent = {1, 1}};
|
||||
const u32 layers = e.image.image_ci.arrayLayers;
|
||||
const VideoCore::SubresourceRange level_range{.base = {e.level, 0}, .extent = {1, layers}};
|
||||
runtime.Transit(&original, to_native ? vk::ImageLayout::eTransferDstOptimal
|
||||
: vk::ImageLayout::eTransferSrcOptimal,
|
||||
vk::PipelineStageFlagBits2::eTransfer,
|
||||
@@ -203,9 +237,9 @@ void SceneTargets::Copy(Entry& e, VideoCore::Image& original, bool to_native) {
|
||||
vk::ImageAspectFlagBits::eStencil}) {
|
||||
if (!(original.aspect_mask & aspect)) continue;
|
||||
const vk::ImageBlit region{
|
||||
.srcSubresource = {aspect, to_native ? 0u : e.level, 0, 1},
|
||||
.srcSubresource = {aspect, to_native ? 0u : e.level, 0, layers},
|
||||
.srcOffsets = std::array{vk::Offset3D{}, to_native ? reduced : native},
|
||||
.dstSubresource = {aspect, to_native ? e.level : 0u, 0, 1},
|
||||
.dstSubresource = {aspect, to_native ? e.level : 0u, 0, layers},
|
||||
.dstOffsets = std::array{vk::Offset3D{}, to_native ? native : reduced},
|
||||
};
|
||||
// Nearest preserves depth/stencil and avoids assuming linear blit support for
|
||||
@@ -421,9 +455,13 @@ vk::ImageView SceneTargets::View(Entry& e, const VideoCore::Image& original,
|
||||
? (info.is_storage ? original.aspect_mask : vk::ImageAspectFlags(vk::ImageAspectFlagBits::eDepth))
|
||||
: vk::ImageAspectFlags(vk::ImageAspectFlagBits::eColor);
|
||||
const auto format = original.info.props.is_depth ? original.backing->image.image_ci.format : info.format;
|
||||
// Layered proxies (shadow cascades) keep the view's layers.
|
||||
const u32 layers = std::max(1u, u32(info.range.extent.layers));
|
||||
const bool array = info.type == AmdGpu::ImageType::Color2DArray || layers > 1;
|
||||
auto view = Check(instance.GetDevice().createImageViewUnique({
|
||||
.image = e.image, .viewType = vk::ImageViewType::e2D, .format = format,
|
||||
.components = info.mapping, .subresourceRange = {aspect,0,1,0,1},
|
||||
.image = e.image, .viewType = array ? vk::ImageViewType::e2DArray : vk::ImageViewType::e2D,
|
||||
.format = format, .components = info.mapping,
|
||||
.subresourceRange = {aspect, 0, 1, u32(info.range.base.layer), layers},
|
||||
}));
|
||||
const auto result = *view;
|
||||
e.views.emplace_back(info, std::move(view));
|
||||
@@ -445,12 +483,14 @@ SceneTargets::Target SceneTargets::Attachment(VideoCore::ImageId id,
|
||||
: vk::AccessFlagBits2::eColorAttachmentRead | vk::AccessFlagBits2::eColorAttachmentWrite);
|
||||
}
|
||||
e.state.ProxyWrite();
|
||||
if (Upscale(original)) ++shadow_passes;
|
||||
auto attachment_info = info;
|
||||
attachment_info.is_storage = true; // include stencil in attachment views
|
||||
return {e.image, View(e, original, attachment_info), e.layout, e.image.image_ci.usage};
|
||||
}
|
||||
bool SceneTargets::ProxyCurrent(const VideoCore::Image& image, u32 level, u32 levels) const {
|
||||
if (copying || !image.scene_proxy || image.info.props.is_depth || levels != 1) {
|
||||
if (copying || !image.scene_proxy || (image.info.props.is_depth && !Upscale(image)) ||
|
||||
levels != 1) {
|
||||
return false;
|
||||
}
|
||||
const auto it = entries.find(Key(image.image_uid, level));
|
||||
@@ -476,6 +516,7 @@ std::optional<SceneTargets::Target> SceneTargets::SampleProxy(
|
||||
}
|
||||
auto& e = *entries.find(Key(image.image_uid, level))->second;
|
||||
const auto layout = PrepareSample(image, level);
|
||||
if (Upscale(image)) ++shadow_samples;
|
||||
return Target{e.image, View(e, image, info), layout, e.image.image_ci.usage};
|
||||
}
|
||||
SceneTargets::Target SceneTargets::Read(VideoCore::ImageId id,
|
||||
|
||||
@@ -26,6 +26,13 @@ public:
|
||||
bool SetSize(SceneResolution::Size);
|
||||
SceneResolution::Size Size() const { return size; }
|
||||
bool Reduced() const { return size != SceneResolution::Size{}; }
|
||||
/// tlg: shadow maps (2048x2048 depth) get proxies `scale` times their size: the shadow
|
||||
/// passes draw into them and shaders sample them (Settings shadow_scale; 1 = native).
|
||||
bool SetShadowScale(u32 scale);
|
||||
/// The upscale factor of `image` (0 when it is no upscaled shadow map).
|
||||
u32 Upscale(const VideoCore::Image&) const;
|
||||
bool ShadowsUpscaled() const { return shadow_scale > 1; }
|
||||
void ShadowReport(); ///< once per frame: prints proxy use every 120 frames
|
||||
/// A reduced proxy can stand in for the image: a 1920x1080 scene target or, scaled by
|
||||
/// the same factor, a half-resolution (960x540) one (toggle SceneHalfRes).
|
||||
bool Eligible(const VideoCore::Image&) const;
|
||||
@@ -97,6 +104,10 @@ private:
|
||||
Runtime& runtime;
|
||||
Lookup lookup;
|
||||
SceneResolution::Size size;
|
||||
u32 shadow_scale = 1;
|
||||
u32 shadow_frames = 0, shadow_passes = 0, shadow_samples = 0, shadow_fills = 0,
|
||||
shadow_resolves = 0;
|
||||
void Reset();
|
||||
std::unordered_map<u64, std::unique_ptr<Entry>> entries; ///< by Key(uid, level)
|
||||
std::unordered_set<u64> tracked; ///< uids with proxies
|
||||
bool copying = false;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include <vk_mem_alloc.h>
|
||||
|
||||
@@ -154,6 +155,19 @@ void PrintIssues(const char* what, u64 issues) {
|
||||
std::printf("\n");
|
||||
}
|
||||
|
||||
// Without a captured camera (a zero projection; CameraMotion::OnConstants finds none in TLG)
|
||||
// the near plane is not finite and the field of view is pi: FSR rejects those, use stand-ins.
|
||||
float CameraValue(float value, float fallback, float limit = 1e30f) {
|
||||
if (std::isfinite(value) && value > 0.0f && value < limit) {
|
||||
return value;
|
||||
}
|
||||
static u32 printed = 0;
|
||||
if (printed++ < 4) {
|
||||
std::printf("Upscaler: no game camera, default %.2f\n", fallback);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TemporalUpscaler::TemporalUpscaler(const Instance& instance_, Scheduler& scheduler_,
|
||||
@@ -178,6 +192,11 @@ TemporalUpscaler::TemporalUpscaler(const Instance& instance_, Scheduler& schedul
|
||||
if (const char* hash = std::getenv("TLG_UPSCALE_BEFORE_CS")) {
|
||||
trigger_hash = std::strtoull(hash, nullptr, 16);
|
||||
}
|
||||
if (const char* v = std::getenv("TLG_GAME_MOTION")) game_motion_on = v[0] != '0';
|
||||
if (const char* hash = std::getenv("TLG_SKY_PS")) sky_motion_ps = std::strtoull(hash, nullptr, 16);
|
||||
if (const char* hash = std::getenv("TLG_GAME_TRIGGER_CS")) {
|
||||
game_trigger_hash = std::strtoull(hash, nullptr, 16);
|
||||
}
|
||||
if (const char* hash = std::getenv("TLG_UI_TRIGGER_VS")) {
|
||||
ui_trigger_vs = std::strtoull(hash, nullptr, 16);
|
||||
}
|
||||
@@ -288,20 +307,43 @@ bool TemporalUpscaler::OnFrameStart() {
|
||||
std::fclose(f);
|
||||
}
|
||||
}
|
||||
// tlg: TLG_RESOLUTION_PATCH=WxH (run.py, another size than 1920x1080) makes the game render
|
||||
// natively at that size. The scene targets only know the 1920x1080 buffers (reduced presets
|
||||
// misplace the half-resolution layers), so the upscaler stays off; at 1920x1080 it works.
|
||||
static const char* resolution_patch = std::getenv("TLG_RESOLUTION_PATCH");
|
||||
// DLAA and FSR 3.1 native AA work on the game's own targets at any size (RunGameMotion).
|
||||
if (resolution_patch && TlgSettings::Get().upscaler != TlgSettings::UpscalerOff &&
|
||||
TlgSettings::Get().upscaler != TlgSettings::UpscalerDlss &&
|
||||
TlgSettings::Get().upscaler != TlgSettings::UpscalerFsr3) {
|
||||
TlgSettings::Get().upscaler.store(TlgSettings::UpscalerOff);
|
||||
static bool logged = false;
|
||||
if (!std::exchange(logged, true)) {
|
||||
std::printf("Upscaler: off (resolution patch %s renders natively)\n", resolution_patch);
|
||||
}
|
||||
}
|
||||
const auto& settings = TlgSettings::Get();
|
||||
// The guest's startup resolution patch remains in effect until restart. Keep the FSR
|
||||
// model at the applied preset while the menu saves the requested one for run.sh.
|
||||
const int preset = TlgSettings::RenderPreset();
|
||||
if (applied_preset != preset || settings.upscaler == TlgSettings::UpscalerOff) failed = false;
|
||||
const bool active = Active();
|
||||
const bool jitter_on = active && settings.jitter && !TlgToggle::Disabled(1u << 25);
|
||||
const bool jitter_on = active && settings.jitter && !TlgToggle::Disabled(1u << 25) &&
|
||||
settings.dlaa;
|
||||
const int upscaler = settings.upscaler.load();
|
||||
const int output = settings.output_res.load();
|
||||
const bool output_changed = !scaled_session && applied_output != output;
|
||||
if (output_changed) {
|
||||
target_width = TlgSettings::OutputWidths[output];
|
||||
target_height = TlgSettings::OutputHeights[output];
|
||||
std::printf("Output resolution: %ux%u (live)\n", target_width, target_height);
|
||||
if (resolution_patch) {
|
||||
// The scene targets stay at the game's 1920x1080, which the patched game no longer
|
||||
// renders: they leave its buffers alone.
|
||||
target_width = 1920;
|
||||
target_height = 1080;
|
||||
std::printf("Output resolution: %s (resolution patch, no upscaler)\n", resolution_patch);
|
||||
} else {
|
||||
std::printf("Output resolution: %ux%u (live)\n", target_width, target_height);
|
||||
}
|
||||
failed = false;
|
||||
fsr4_failed = false;
|
||||
}
|
||||
@@ -325,6 +367,7 @@ bool TemporalUpscaler::OnFrameStart() {
|
||||
TlgSettings::Get().active_render_width = Scaled() ? render_width : scene_targets.Size().width;
|
||||
TlgSettings::Get().active_render_height = Scaled() ? render_height : scene_targets.Size().height;
|
||||
if (changed || !dispatched_last_frame) reset = true;
|
||||
if (changed) game_reset = true;
|
||||
if (changed) jitter_index = 0;
|
||||
applied_preset = preset;
|
||||
applied_output = output;
|
||||
@@ -343,6 +386,9 @@ bool TemporalUpscaler::OnFrameStart() {
|
||||
snapshot_taken = false;
|
||||
opaque_valid = false;
|
||||
mask_ready = false;
|
||||
// tlg: game-motion DLAA jitters only frames DLSS resolves (unresolved jitter shimmers);
|
||||
// FSR 3.1 native AA resolves them the same way.
|
||||
jitter_active = jitter_on && (UseDlss() || FsrNativeAa()) && !Scaled();
|
||||
// Halton(2, 3); the menu or toggle 1 << 25 disables it.
|
||||
if (!jitter_on) {
|
||||
jitter = {};
|
||||
@@ -350,15 +396,54 @@ bool TemporalUpscaler::OnFrameStart() {
|
||||
}
|
||||
// CameraMotion::OnDisplayPass has already cleared Depth(). Use the context's render
|
||||
// size, which survives the frame boundary (Performance 960 -> 1920 needs 32 phases).
|
||||
// DLAA: the emulator starts about three frames per game frame, so the game's own post
|
||||
// (RunGameMotion) advances the jitter; a mid-frame change would shift part of the G-buffer.
|
||||
if (jitter_active && game_motion_on && game_motion_ok && jitter_started && !changed) {
|
||||
// Applied here, at the display pass: the trigger runs after the next frame's depth
|
||||
// prepass, and a change there would jitter the prepass and the G-buffer differently.
|
||||
if (jitter_pending) {
|
||||
jitter_pending = false;
|
||||
AdvanceJitter();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
AdvanceJitter();
|
||||
jitter_started = true;
|
||||
return changed;
|
||||
}
|
||||
|
||||
void TemporalUpscaler::AdvanceJitter() {
|
||||
const u32 phases = Scaled() ? Motion::JitterPhases(render_width, target_width)
|
||||
: Motion::JitterPhases(scene_targets.Size().width, 1920);
|
||||
jitter_index = jitter_index % phases + 1;
|
||||
jitter = {Halton(jitter_index, 2) - 0.5f, Halton(jitter_index, 3) - 0.5f};
|
||||
return changed;
|
||||
}
|
||||
|
||||
void TemporalUpscaler::OnGameMotionPass(VideoCore::ImageId scene, VideoCore::ImageId motion,
|
||||
VideoCore::ImageId depth) {
|
||||
// The jitter this frame's geometry was drawn with; the post runs in the next emulator frame.
|
||||
game_frame = {scene, motion, depth, jitter, scene && motion && depth};
|
||||
if (depth) {
|
||||
const auto& size = texture_cache.GetImage(depth).info.size;
|
||||
game_size = {size.width, size.height};
|
||||
}
|
||||
}
|
||||
|
||||
void TemporalUpscaler::OnDispatch(u64 cs_hash) {
|
||||
if (cs_hash != trigger_hash || done_this_frame || failed || Scaled()) {
|
||||
if (cs_hash == game_trigger_hash && game_frame.valid) {
|
||||
if (GameMotion()) {
|
||||
RunGameMotion();
|
||||
} else {
|
||||
reset = true;
|
||||
game_reset = true;
|
||||
}
|
||||
game_frame.valid = false;
|
||||
return;
|
||||
}
|
||||
// tlg: DLSS only runs as DLAA on the game's motion (above); the GoW3 scene path made it shake.
|
||||
// FSR 3.1 native AA runs there too.
|
||||
if (cs_hash != trigger_hash || done_this_frame || failed || Scaled() || GameMotion() ||
|
||||
TlgSettings::Get().upscaler == TlgSettings::UpscalerDlss) {
|
||||
return;
|
||||
}
|
||||
done_this_frame = true;
|
||||
@@ -604,7 +689,7 @@ void TemporalUpscaler::CreatePipelines() {
|
||||
merge_pipeline = compute(UPSCALE_MERGE_COMP, *merge_pipeline_layout);
|
||||
storage_layout(3, reactive_desc_layout, reactive_pipeline_layout, 3 * sizeof(float));
|
||||
reactive_pipeline = compute(UPSCALE_REACTIVE_COMP, *reactive_pipeline_layout);
|
||||
storage_layout(2, taa_sharpen_desc_layout, taa_sharpen_pipeline_layout, sizeof(float));
|
||||
storage_layout(2, taa_sharpen_desc_layout, taa_sharpen_pipeline_layout, 2 * sizeof(float));
|
||||
taa_sharpen_pipeline = compute(TAA_SHARPEN_COMP, *taa_sharpen_pipeline_layout);
|
||||
taa_sharpen_ldr_pipeline = compute(TAA_SHARPEN_LDR_COMP, *taa_sharpen_pipeline_layout);
|
||||
{
|
||||
@@ -703,6 +788,7 @@ void TemporalUpscaler::RecordTaa(vk::CommandBuffer cmdbuf, vk::ImageView color,
|
||||
cmdbuf.dispatch((out_width + 7) / 8, (out_height + 7) / 8, 1);
|
||||
const auto& settings = TlgSettings::Get();
|
||||
const float strength = std::clamp(settings.sharpness.load(), 0.0f, 2.0f);
|
||||
const std::array<float, 2> sharpen_params{strength, strength};
|
||||
if (settings.sharpen && strength > 0.0f && !(params.pad & 0xffu)) {
|
||||
const vk::MemoryBarrier2 resolved{
|
||||
.srcStageMask = vk::PipelineStageFlagBits2::eComputeShader,
|
||||
@@ -722,7 +808,7 @@ void TemporalUpscaler::RecordTaa(vk::CommandBuffer cmdbuf, vk::ImageView color,
|
||||
cmdbuf.pushDescriptorSetKHR(vk::PipelineBindPoint::eCompute,
|
||||
*taa_sharpen_pipeline_layout, 0, sharpen_writes);
|
||||
cmdbuf.pushConstants(*taa_sharpen_pipeline_layout, vk::ShaderStageFlagBits::eCompute,
|
||||
0, sizeof(strength), &strength);
|
||||
0, sizeof(sharpen_params), sharpen_params.data());
|
||||
cmdbuf.dispatch((out_width + 7) / 8, (out_height + 7) / 8, 1);
|
||||
}
|
||||
taa_next = 1 - taa_next;
|
||||
@@ -732,9 +818,23 @@ void TemporalUpscaler::ExtraSharpen(vk::CommandBuffer cmdbuf, vk::Image target,
|
||||
u32 h) {
|
||||
const auto& settings = TlgSettings::Get();
|
||||
const float extra = std::clamp(settings.sharpness.load(), 0.0f, 2.0f) - 1.0f;
|
||||
if (!settings.sharpen || extra <= 0.0f) {
|
||||
return;
|
||||
if (settings.sharpen && extra > 0.0f) {
|
||||
RcasPass(cmdbuf, target, ldr, w, h, extra, extra);
|
||||
}
|
||||
}
|
||||
|
||||
void TemporalUpscaler::DlssSharpen(vk::CommandBuffer cmdbuf, u32 w, u32 h) {
|
||||
// The menu's 0..1 matches FSR 3's RCAS (fully applied); above 1 extrapolated.
|
||||
const auto& settings = TlgSettings::Get();
|
||||
const float strength = std::clamp(settings.sharpness.load(), 0.0f, 2.0f);
|
||||
if (settings.sharpen && strength > 0.0f) {
|
||||
RcasPass(cmdbuf, vk::Image(output_image), false, w, h, strength,
|
||||
std::max(strength, 1.0f));
|
||||
}
|
||||
}
|
||||
|
||||
void TemporalUpscaler::RcasPass(vk::CommandBuffer cmdbuf, vk::Image target, bool ldr, u32 w,
|
||||
u32 h, float strength, float blend) {
|
||||
const auto device = instance.GetDevice();
|
||||
if (extra_sharpen_width != w || extra_sharpen_height != h) {
|
||||
// Retired command buffers may still read the old copy. `cmdbuf` stays open.
|
||||
@@ -811,8 +911,9 @@ void TemporalUpscaler::ExtraSharpen(vk::CommandBuffer cmdbuf, vk::Image target,
|
||||
ldr ? *taa_sharpen_ldr_pipeline : *taa_sharpen_pipeline);
|
||||
cmdbuf.pushDescriptorSetKHR(vk::PipelineBindPoint::eCompute, *taa_sharpen_pipeline_layout, 0,
|
||||
writes);
|
||||
const std::array<float, 2> params{strength, blend};
|
||||
cmdbuf.pushConstants(*taa_sharpen_pipeline_layout, vk::ShaderStageFlagBits::eCompute, 0,
|
||||
sizeof(extra), &extra);
|
||||
sizeof(params), params.data());
|
||||
cmdbuf.dispatch((w + 7) / 8, (h + 7) / 8, 1);
|
||||
image_barrier(target, vk::ImageLayout::eGeneral, vk::PipelineStageFlagBits2::eComputeShader,
|
||||
vk::AccessFlagBits2::eShaderStorageWrite, all, rw);
|
||||
@@ -994,6 +1095,153 @@ bool TemporalUpscaler::RecordReactive(vk::ImageView color_view) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void TemporalUpscaler::RunGameMotion() {
|
||||
auto& color = texture_cache.GetImage(game_frame.scene);
|
||||
auto& motion = texture_cache.GetImage(game_frame.motion);
|
||||
auto& depth = texture_cache.GetImage(game_frame.depth);
|
||||
const u32 w = color.info.size.width, h = color.info.size.height;
|
||||
const auto depth_format = depth.info.pixel_format;
|
||||
const bool usable = color.info.pixel_format == vk::Format::eB10G11R11UfloatPack32 &&
|
||||
motion.info.pixel_format == vk::Format::eR16G16Sfloat &&
|
||||
motion.info.size.width == w && motion.info.size.height == h &&
|
||||
depth.info.size.width == w && depth.info.size.height == h &&
|
||||
(color.usage_flags & vk::ImageUsageFlagBits::eTransferDst);
|
||||
if (!usable) {
|
||||
std::printf("Upscaler: game motion inputs unusable (scene %d %ux%u, motion %d, depth %d "
|
||||
"%ux%u); DLAA off\n",
|
||||
int(color.info.pixel_format), w, h, int(motion.info.pixel_format),
|
||||
int(depth_format), depth.info.size.width, depth.info.size.height);
|
||||
game_motion_ok = false;
|
||||
return;
|
||||
}
|
||||
if (!EnsureResources(w, h, w, h, true)) {
|
||||
failed = true;
|
||||
return;
|
||||
}
|
||||
const char* provider = UseDlss() ? "DLAA" : "FSR 3.1 native AA";
|
||||
static const char* logged = nullptr;
|
||||
if (std::exchange(logged, provider) != provider) {
|
||||
std::printf("Upscaler: %s on game motion vectors %ux%u\n", provider, w, h);
|
||||
}
|
||||
const auto color_view = CachedView(color, color.info.pixel_format, vk::ImageAspectFlagBits::eColor);
|
||||
const auto motion_src_view =
|
||||
CachedView(motion, vk::Format::eR16G16Sfloat, vk::ImageAspectFlagBits::eColor);
|
||||
const auto depth_view = CachedView(depth, depth_format, vk::ImageAspectFlagBits::eDepth);
|
||||
|
||||
scheduler.EndRendering();
|
||||
const auto read = vk::PipelineStageFlagBits2::eComputeShader;
|
||||
runtime.Transit(&depth, vk::ImageLayout::eGeneral, read, vk::AccessFlagBits2::eShaderRead);
|
||||
runtime.Transit(&motion, vk::ImageLayout::eGeneral, read, vk::AccessFlagBits2::eShaderRead);
|
||||
runtime.Transit(&color, vk::ImageLayout::eGeneral, read, vk::AccessFlagBits2::eShaderRead);
|
||||
runtime.FlushBarriers();
|
||||
const auto cmdbuf = scheduler.CommandBuffer();
|
||||
const auto all = vk::PipelineStageFlagBits2::eAllCommands;
|
||||
const auto rw = vk::AccessFlagBits2::eShaderRead | vk::AccessFlagBits2::eShaderWrite;
|
||||
const auto own_barrier = [&](vk::Image image, vk::ImageLayout old_layout,
|
||||
vk::PipelineStageFlags2 src_stage, vk::AccessFlags2 src_access,
|
||||
vk::ImageLayout new_layout, vk::PipelineStageFlags2 dst_stage,
|
||||
vk::AccessFlags2 dst_access) {
|
||||
const vk::ImageMemoryBarrier2 barrier{
|
||||
.srcStageMask = src_stage,
|
||||
.srcAccessMask = src_access,
|
||||
.dstStageMask = dst_stage,
|
||||
.dstAccessMask = dst_access,
|
||||
.oldLayout = old_layout,
|
||||
.newLayout = new_layout,
|
||||
.image = image,
|
||||
.subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1},
|
||||
};
|
||||
cmdbuf.pipelineBarrier2({.imageMemoryBarrierCount = 1, .pImageMemoryBarriers = &barrier});
|
||||
};
|
||||
own_barrier(vk::Image(motion_image), vk::ImageLayout::eUndefined, all,
|
||||
vk::AccessFlagBits2::eNone, vk::ImageLayout::eGeneral, read,
|
||||
vk::AccessFlagBits2::eShaderWrite);
|
||||
own_barrier(vk::Image(output_image), vk::ImageLayout::eUndefined, all,
|
||||
vk::AccessFlagBits2::eNone, vk::ImageLayout::eGeneral, all, rw);
|
||||
camera_motion.RecordGameMotion(cmdbuf, depth_view, motion_src_view, *motion_view, w, h);
|
||||
own_barrier(vk::Image(motion_image), vk::ImageLayout::eGeneral, read,
|
||||
vk::AccessFlagBits2::eShaderWrite, vk::ImageLayout::eGeneral, all,
|
||||
vk::AccessFlagBits2::eShaderRead);
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
float frame_ms = std::chrono::duration<float, std::milli>(now - last_frame).count();
|
||||
if (frame_ms <= 0.0f || frame_ms > 200.0f) frame_ms = 16.6f;
|
||||
last_frame = now;
|
||||
// Fresh history after a settings change, a skipped frame, or a menu or loading screen.
|
||||
reset = game_reset || now - last_game_motion > std::chrono::milliseconds(100);
|
||||
const bool ok = UseDlss()
|
||||
? RecordDlss(cmdbuf,
|
||||
{color.GetImage(), color_view, color.info.pixel_format,
|
||||
vk::ImageAspectFlagBits::eColor, w, h},
|
||||
{depth.GetImage(), depth_view, depth_format, vk::ImageAspectFlagBits::eDepth,
|
||||
w, h},
|
||||
w, h, w, h, frame_ms, true, game_frame.jitter)
|
||||
: FsrNativeAa() &&
|
||||
RecordFsr3(cmdbuf, color.GetImage(), color.info.pixel_format, color.usage_flags,
|
||||
depth.GetImage(), depth_format, depth.usage_flags, w, h, w, h, frame_ms,
|
||||
false, game_frame.jitter);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
if (UseDlss()) {
|
||||
DlssSharpen(cmdbuf, w, h);
|
||||
} else {
|
||||
ExtraSharpen(cmdbuf, vk::Image(output_image), false, w, h);
|
||||
}
|
||||
reset = false;
|
||||
game_reset = false;
|
||||
last_game_motion = now;
|
||||
jitter_pending = true; // for the next game frame (OnFrameStart)
|
||||
dispatched_last_frame = true;
|
||||
// Debug (settings or toggle): the game's motion vectors over the DLAA result, signed.
|
||||
if (TlgSettings::Get().debug_view == TlgSettings::DebugMotion ||
|
||||
TlgToggle::Disabled(TlgToggle::DlaaMotionView)) {
|
||||
own_barrier(vk::Image(output_image), vk::ImageLayout::eGeneral, all, rw,
|
||||
vk::ImageLayout::eGeneral, all, rw);
|
||||
const vk::DescriptorImageInfo out_info{.imageView = *output_view,
|
||||
.imageLayout = vk::ImageLayout::eGeneral};
|
||||
const vk::DescriptorImageInfo mask_info{.imageView = *reactive_view,
|
||||
.imageLayout = vk::ImageLayout::eGeneral};
|
||||
const vk::DescriptorImageInfo motion_info{.imageView = *motion_view,
|
||||
.imageLayout = vk::ImageLayout::eGeneral};
|
||||
const std::array<vk::WriteDescriptorSet, 5> writes = {{
|
||||
{.dstBinding = 0, .descriptorCount = 1,
|
||||
.descriptorType = vk::DescriptorType::eStorageImage, .pImageInfo = &out_info},
|
||||
{.dstBinding = 1, .descriptorCount = 1,
|
||||
.descriptorType = vk::DescriptorType::eStorageImage, .pImageInfo = &out_info},
|
||||
{.dstBinding = 2, .descriptorCount = 1,
|
||||
.descriptorType = vk::DescriptorType::eStorageImage, .pImageInfo = &mask_info},
|
||||
{.dstBinding = 3, .descriptorCount = 1,
|
||||
.descriptorType = vk::DescriptorType::eSampledImage, .pImageInfo = &motion_info},
|
||||
{.dstBinding = 4, .descriptorCount = 1,
|
||||
.descriptorType = vk::DescriptorType::eSampledImage, .pImageInfo = &motion_info},
|
||||
}};
|
||||
const u32 mode = 3;
|
||||
cmdbuf.bindPipeline(vk::PipelineBindPoint::eCompute, *merge_pipeline);
|
||||
cmdbuf.pushDescriptorSetKHR(vk::PipelineBindPoint::eCompute, *merge_pipeline_layout, 0,
|
||||
writes);
|
||||
cmdbuf.pushConstants(*merge_pipeline_layout, vk::ShaderStageFlagBits::eCompute, 0,
|
||||
sizeof(mode), &mode);
|
||||
cmdbuf.dispatch((w + 7) / 8, (h + 7) / 8, 1);
|
||||
}
|
||||
// The DLSS output replaces the scene (B10G11R11 has no alpha to keep).
|
||||
own_barrier(vk::Image(output_image), vk::ImageLayout::eGeneral, all, rw,
|
||||
vk::ImageLayout::eTransferSrcOptimal, vk::PipelineStageFlagBits2::eTransfer,
|
||||
vk::AccessFlagBits2::eTransferRead);
|
||||
runtime.Transit(&color, vk::ImageLayout::eTransferDstOptimal,
|
||||
vk::PipelineStageFlagBits2::eTransfer, vk::AccessFlagBits2::eTransferWrite);
|
||||
runtime.FlushBarriers();
|
||||
const vk::ImageBlit region{
|
||||
.srcSubresource = {vk::ImageAspectFlagBits::eColor, 0, 0, 1},
|
||||
.srcOffsets = std::array{vk::Offset3D{0, 0, 0}, vk::Offset3D{int(w), int(h), 1}},
|
||||
.dstSubresource = {vk::ImageAspectFlagBits::eColor, 0, 0, 1},
|
||||
.dstOffsets = std::array{vk::Offset3D{0, 0, 0}, vk::Offset3D{int(w), int(h), 1}},
|
||||
};
|
||||
cmdbuf.blitImage(vk::Image(output_image), vk::ImageLayout::eTransferSrcOptimal,
|
||||
color.GetImage(), vk::ImageLayout::eTransferDstOptimal, region,
|
||||
vk::Filter::eNearest);
|
||||
}
|
||||
|
||||
void TemporalUpscaler::Run() {
|
||||
if (auto* profiler = GpuProfiler::Get()) {
|
||||
const char* label = TlgSettings::Get().upscaler == TlgSettings::UpscalerTaa
|
||||
@@ -1094,80 +1342,18 @@ void TemporalUpscaler::Run() {
|
||||
cmdbuf,
|
||||
{input_color, input_color_view, color.info.pixel_format, vk::ImageAspectFlagBits::eColor, w, h},
|
||||
{input_depth, input_depth_view, depth_format, vk::ImageAspectFlagBits::eDepth, w, h}, w, h,
|
||||
ow, oh, frame_ms, true);
|
||||
ow, oh, frame_ms, true, jitter);
|
||||
} else {
|
||||
FfxVkPortableUpscaleDispatchInfo info{};
|
||||
info.structSize = sizeof(info);
|
||||
info.commandBuffer = cmdbuf;
|
||||
info.color = Describe(input_color, color.info.pixel_format, w, h,
|
||||
color.usage_flags, vk::ImageAspectFlagBits::eColor,
|
||||
FFX_VK_PORTABLE_RESOURCE_STATE_GENERIC_READ);
|
||||
info.depth = Describe(input_depth, depth_format, w, h, depth.usage_flags,
|
||||
vk::ImageAspectFlagBits::eDepth,
|
||||
FFX_VK_PORTABLE_RESOURCE_STATE_GENERIC_READ);
|
||||
info.motionVectors = Describe(vk::Image(motion_image), vk::Format::eR16G16Sfloat, w, h,
|
||||
vk::ImageUsageFlagBits::eStorage | vk::ImageUsageFlagBits::eSampled,
|
||||
vk::ImageAspectFlagBits::eColor,
|
||||
FFX_VK_PORTABLE_RESOURCE_STATE_GENERIC_READ);
|
||||
info.output = Describe(vk::Image(output_image), vk::Format::eR16G16B16A16Sfloat, ow, oh,
|
||||
vk::ImageUsageFlagBits::eStorage | vk::ImageUsageFlagBits::eSampled |
|
||||
vk::ImageUsageFlagBits::eTransferSrc,
|
||||
vk::ImageAspectFlagBits::eColor,
|
||||
FFX_VK_PORTABLE_RESOURCE_STATE_UNORDERED_ACCESS);
|
||||
// Optional inputs, absent: a described but null image.
|
||||
info.exposure.structSize = sizeof(info.exposure);
|
||||
info.reactiveMask.structSize = sizeof(info.reactiveMask);
|
||||
if (has_reactive) {
|
||||
info.reactiveMask = Describe(vk::Image(reactive_image), vk::Format::eR8Unorm, w, h,
|
||||
vk::ImageUsageFlagBits::eStorage |
|
||||
vk::ImageUsageFlagBits::eSampled,
|
||||
vk::ImageAspectFlagBits::eColor,
|
||||
FFX_VK_PORTABLE_RESOURCE_STATE_GENERIC_READ);
|
||||
}
|
||||
info.transparencyAndCompositionMask.structSize = sizeof(info.transparencyAndCompositionMask);
|
||||
// Toggle 1 << 26 (tests): the opposite sign convention for FSR.
|
||||
const float sign = TlgToggle::Disabled(1u << 26) ? -1.0f : 1.0f;
|
||||
info.jitterOffset = {sign * jitter[0], sign * jitter[1]};
|
||||
info.motionVectorScale = {1.0f, 1.0f};
|
||||
info.renderSize = {w, h};
|
||||
info.outputSize = {ow, oh};
|
||||
info.frameTimeMilliseconds = frame_ms;
|
||||
info.preExposure = 1.0f;
|
||||
info.cameraNear = camera_motion.Near();
|
||||
info.cameraFar = 3000.0f;
|
||||
info.cameraVerticalFovRadians = camera_motion.VerticalFov();
|
||||
info.viewSpaceToMeters = 1.0f;
|
||||
// RCAS strength 0..1 (menu, TLG_FSR_SHARPNESS); jitter at 1:1 softens the image slightly.
|
||||
// AMD's RCAS ends at 1; ExtraSharpen adds the rest of the menu's 0..2.
|
||||
info.sharpness = std::min(TlgSettings::Get().sharpness.load(), 1.0f);
|
||||
info.enableSharpening = TlgSettings::Get().sharpen ? VK_TRUE : VK_FALSE;
|
||||
info.reset = reset ? VK_TRUE : VK_FALSE;
|
||||
info.frameId = frame_id++;
|
||||
|
||||
FfxVkPortableUpscaleCreateInfo create_info{};
|
||||
create_info.structSize = sizeof(create_info);
|
||||
create_info.flags = FFX_VK_PORTABLE_CONTEXT_HDR_COLOR_INPUT | FFX_VK_PORTABLE_CONTEXT_AUTO_EXPOSURE;
|
||||
create_info.maxRenderSize = {w, h};
|
||||
create_info.maxOutputSize = {ow, oh};
|
||||
if (const u64 issues = ffxVkPortableValidateUpscaleDispatchInfo(&create_info, &info)) {
|
||||
static bool printed = false;
|
||||
if (!printed) {
|
||||
printed = true;
|
||||
PrintIssues("dispatch", issues);
|
||||
}
|
||||
failed = true;
|
||||
} else if (ffxVkPortableUpscaleContextRecordDispatch(context, &info) !=
|
||||
FFX_VK_PORTABLE_OK) {
|
||||
std::printf("Upscaler: FSR 3 dispatch failed\n");
|
||||
failed = true;
|
||||
} else {
|
||||
dispatched = true;
|
||||
}
|
||||
dispatched = RecordFsr3(cmdbuf, input_color, color.info.pixel_format, color.usage_flags,
|
||||
input_depth, depth_format, depth.usage_flags, w, h, ow, oh,
|
||||
frame_ms, has_reactive, jitter);
|
||||
}
|
||||
if (dispatched) {
|
||||
reset = false;
|
||||
dispatched_last_frame = true;
|
||||
if (TlgSettings::Get().upscaler != TlgSettings::UpscalerTaa) {
|
||||
if (UseDlss()) {
|
||||
DlssSharpen(cmdbuf, ow, oh);
|
||||
} else if (TlgSettings::Get().upscaler != TlgSettings::UpscalerTaa) {
|
||||
ExtraSharpen(cmdbuf, vk::Image(output_image), false, ow, oh);
|
||||
}
|
||||
// The result replaces the scene color's RGB (its alpha carries data for the post).
|
||||
@@ -1281,7 +1467,10 @@ float TemporalUpscaler::SceneMipBias() const {
|
||||
if (!Active() || TlgSettings::Get().upscaler == TlgSettings::UpscalerTaa) return 0.0f;
|
||||
const float render = float(TlgSettings::Get().active_render_width.load());
|
||||
const float output = float(Scaled() ? target_width : 1920u);
|
||||
return render > 0.0f && render < output ? std::log2(render / output) : 0.0f;
|
||||
float bias = render > 0.0f && render < output ? std::log2(render / output) : 0.0f;
|
||||
// DLSS and FSR guides: log2(render / output) - 1; the resolve keeps the extra detail stable.
|
||||
if ((UseDlss() || FsrNativeAa()) && TlgSettings::Get().dlaa) bias -= 1.0f;
|
||||
return bias;
|
||||
}
|
||||
|
||||
bool TemporalUpscaler::Scaled() const {
|
||||
@@ -1628,14 +1817,16 @@ void TemporalUpscaler::RunScaled() {
|
||||
vk::ImageAspectFlagBits::eColor, source_width, source_height},
|
||||
{depth_image, depth_view, depth_format, vk::ImageAspectFlagBits::eDepth,
|
||||
source_width, source_height},
|
||||
w, h, ow, oh, frame_ms, false);
|
||||
w, h, ow, oh, frame_ms, false, jitter);
|
||||
} else {
|
||||
ok4 = RecordFsr4(cmdbuf, {color_image, color_view, source_width, source_height},
|
||||
{depth_image, depth_view, source_width, source_height}, w, h, ow,
|
||||
oh, frame_ms);
|
||||
}
|
||||
if (ok4) {
|
||||
if (TlgSettings::Get().upscaler != TlgSettings::UpscalerTaa) {
|
||||
if (UseDlss()) {
|
||||
DlssSharpen(cmdbuf, ow, oh);
|
||||
} else if (TlgSettings::Get().upscaler != TlgSettings::UpscalerTaa) {
|
||||
ExtraSharpen(cmdbuf, vk::Image(output_image), false, ow, oh);
|
||||
}
|
||||
barrier(vk::Image(output_image), vk::ImageAspectFlagBits::eColor,
|
||||
@@ -1924,6 +2115,84 @@ bool TemporalUpscaler::DisplayOverride(VAddr address, Display& display) {
|
||||
|
||||
namespace Vulkan {
|
||||
|
||||
bool TemporalUpscaler::RecordFsr3(vk::CommandBuffer cmdbuf, vk::Image color, vk::Format color_format,
|
||||
vk::ImageUsageFlags color_usage, vk::Image depth,
|
||||
vk::Format depth_format, vk::ImageUsageFlags depth_usage,
|
||||
u32 w, u32 h, u32 ow, u32 oh, float frame_ms,
|
||||
bool has_reactive, std::array<float, 2> frame_jitter) {
|
||||
FfxVkPortableUpscaleDispatchInfo info{};
|
||||
info.structSize = sizeof(info);
|
||||
info.commandBuffer = cmdbuf;
|
||||
info.color = Describe(color, color_format, w, h,
|
||||
color_usage, vk::ImageAspectFlagBits::eColor,
|
||||
FFX_VK_PORTABLE_RESOURCE_STATE_GENERIC_READ);
|
||||
info.depth = Describe(depth, depth_format, w, h, depth_usage,
|
||||
vk::ImageAspectFlagBits::eDepth,
|
||||
FFX_VK_PORTABLE_RESOURCE_STATE_GENERIC_READ);
|
||||
info.motionVectors = Describe(vk::Image(motion_image), vk::Format::eR16G16Sfloat, w, h,
|
||||
vk::ImageUsageFlagBits::eStorage | vk::ImageUsageFlagBits::eSampled,
|
||||
vk::ImageAspectFlagBits::eColor,
|
||||
FFX_VK_PORTABLE_RESOURCE_STATE_GENERIC_READ);
|
||||
info.output = Describe(vk::Image(output_image), vk::Format::eR16G16B16A16Sfloat, ow, oh,
|
||||
vk::ImageUsageFlagBits::eStorage | vk::ImageUsageFlagBits::eSampled |
|
||||
vk::ImageUsageFlagBits::eTransferSrc,
|
||||
vk::ImageAspectFlagBits::eColor,
|
||||
FFX_VK_PORTABLE_RESOURCE_STATE_UNORDERED_ACCESS);
|
||||
// Optional inputs, absent: a described but null image.
|
||||
info.exposure.structSize = sizeof(info.exposure);
|
||||
info.reactiveMask.structSize = sizeof(info.reactiveMask);
|
||||
if (has_reactive) {
|
||||
info.reactiveMask = Describe(vk::Image(reactive_image), vk::Format::eR8Unorm, w, h,
|
||||
vk::ImageUsageFlagBits::eStorage |
|
||||
vk::ImageUsageFlagBits::eSampled,
|
||||
vk::ImageAspectFlagBits::eColor,
|
||||
FFX_VK_PORTABLE_RESOURCE_STATE_GENERIC_READ);
|
||||
}
|
||||
info.transparencyAndCompositionMask.structSize = sizeof(info.transparencyAndCompositionMask);
|
||||
// Toggle 1 << 26 (tests): the opposite sign convention for FSR.
|
||||
const float sign = TlgToggle::Disabled(1u << 26) ? -1.0f : 1.0f;
|
||||
info.jitterOffset = {sign * frame_jitter[0], sign * frame_jitter[1]};
|
||||
info.motionVectorScale = {1.0f, 1.0f};
|
||||
info.renderSize = {w, h};
|
||||
info.outputSize = {ow, oh};
|
||||
info.frameTimeMilliseconds = frame_ms;
|
||||
info.preExposure = 1.0f;
|
||||
info.cameraNear = CameraValue(camera_motion.Near(), 0.1f);
|
||||
info.cameraFar = 3000.0f;
|
||||
info.cameraVerticalFovRadians = CameraValue(camera_motion.VerticalFov(), 1.0f, 3.0f);
|
||||
info.viewSpaceToMeters = 1.0f;
|
||||
// RCAS strength 0..1 (menu, TLG_FSR_SHARPNESS); jitter at 1:1 softens the image slightly.
|
||||
// AMD's RCAS ends at 1; ExtraSharpen adds the rest of the menu's 0..2.
|
||||
info.sharpness = std::min(TlgSettings::Get().sharpness.load(), 1.0f);
|
||||
info.enableSharpening = TlgSettings::Get().sharpen ? VK_TRUE : VK_FALSE;
|
||||
info.reset = reset ? VK_TRUE : VK_FALSE;
|
||||
info.frameId = frame_id++;
|
||||
|
||||
FfxVkPortableUpscaleCreateInfo create_info{};
|
||||
create_info.structSize = sizeof(create_info);
|
||||
create_info.flags = FFX_VK_PORTABLE_CONTEXT_HDR_COLOR_INPUT | FFX_VK_PORTABLE_CONTEXT_AUTO_EXPOSURE;
|
||||
create_info.maxRenderSize = {w, h};
|
||||
create_info.maxOutputSize = {ow, oh};
|
||||
// Motion cuts FSR 3's history (AMD default 1): at 1 a running boy shows the raw frame, with
|
||||
// the dither holes of the hair and a soft image.
|
||||
ffxVkPortableUpscaleContextSetVelocityFactor(context, TlgSettings::Get().fsr_velocity.load());
|
||||
if (const u64 issues = ffxVkPortableValidateUpscaleDispatchInfo(&create_info, &info)) {
|
||||
static bool printed = false;
|
||||
if (!printed) {
|
||||
printed = true;
|
||||
PrintIssues("dispatch", issues);
|
||||
}
|
||||
failed = true;
|
||||
} else if (ffxVkPortableUpscaleContextRecordDispatch(context, &info) !=
|
||||
FFX_VK_PORTABLE_OK) {
|
||||
std::printf("Upscaler: FSR 3 dispatch failed\n");
|
||||
failed = true;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool TemporalUpscaler::UseFsr4() const {
|
||||
const int selected = TlgSettings::Get().upscaler;
|
||||
const bool supported = selected == TlgSettings::UpscalerFsr411
|
||||
@@ -1973,13 +2242,24 @@ bool TemporalUpscaler::RecordFsr4(vk::CommandBuffer cmdbuf, Fsr4Upscaler::Image
|
||||
}
|
||||
if (!ok && fsr4->Fatal()) {
|
||||
std::printf("Upscaler: falling back to FSR 3.1\n");
|
||||
TlgSettings::Get().upscaler = TlgSettings::UpscalerFsr3;
|
||||
TlgSettings::FallBackToFsr3();
|
||||
fsr4_failed = true; // EnsureResources creates the FSR 3 context next frame
|
||||
reset = true;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool TemporalUpscaler::SkipGameSmaa(u64 vs_hash) const {
|
||||
return vs_hash == 0x0a449aef && GameMotion() &&
|
||||
!TlgToggle::Disabled(TlgToggle::DlaaSkipGameSmaa);
|
||||
}
|
||||
|
||||
bool TemporalUpscaler::FsrNativeAa() const {
|
||||
// FSR 3.1 at native size is its anti-aliasing mode, on the game's motion like DLAA.
|
||||
const auto& settings = TlgSettings::Get();
|
||||
return settings.upscaler == TlgSettings::UpscalerFsr3 && settings.dlaa && settings.jitter;
|
||||
}
|
||||
|
||||
bool TemporalUpscaler::UseDlss() const {
|
||||
const Dlss* dlss = Dlss::Get();
|
||||
return TlgSettings::Get().upscaler == TlgSettings::UpscalerDlss && dlss && dlss->Available() &&
|
||||
@@ -1988,9 +2268,12 @@ bool TemporalUpscaler::UseDlss() const {
|
||||
|
||||
bool TemporalUpscaler::RecordDlss(vk::CommandBuffer cmdbuf, const Dlss::Resource& color,
|
||||
const Dlss::Resource& depth, u32 w, u32 h, u32 ow, u32 oh,
|
||||
float frame_ms, bool hdr) {
|
||||
float frame_ms, bool hdr, std::array<float, 2> frame_jitter) {
|
||||
Dlss* dlss = Dlss::Get();
|
||||
const Dlss::FeatureDesc desc{w, h, ow, oh, Dlss::QualityForScale(float(ow) / float(w)), hdr};
|
||||
const u64 tests = __atomic_load_n(&runtime_disabled_optimizations, __ATOMIC_RELAXED);
|
||||
const Dlss::FeatureDesc desc{w, h, ow, oh, Dlss::QualityForScale(float(ow) / float(w)), hdr,
|
||||
tests >> 60 ? u32(tests >> 60) : u32(TlgSettings::Get().dlss_preset.load()),
|
||||
!TlgToggle::Disabled(TlgToggle::DlssAutoExposure)};
|
||||
bool ok = true;
|
||||
if (!dlss->HasFeature(desc)) {
|
||||
// The previous feature may still be in use by submitted work; `cmdbuf` stays open.
|
||||
@@ -2010,8 +2293,8 @@ bool TemporalUpscaler::RecordDlss(vk::CommandBuffer cmdbuf, const Dlss::Resource
|
||||
vk::ImageAspectFlagBits::eColor, w, h},
|
||||
.output = {vk::Image(output_image), *output_view, vk::Format::eR16G16B16A16Sfloat,
|
||||
vk::ImageAspectFlagBits::eColor, ow, oh},
|
||||
.jitter_x = sign * jitter[0],
|
||||
.jitter_y = sign * jitter[1],
|
||||
.jitter_x = sign * frame_jitter[0],
|
||||
.jitter_y = sign * frame_jitter[1],
|
||||
.reset = reset,
|
||||
.frame_ms = frame_ms,
|
||||
.sharpness = settings.sharpen ? std::min(settings.sharpness.load(), 1.0f) : 0.0f,
|
||||
@@ -2019,7 +2302,7 @@ bool TemporalUpscaler::RecordDlss(vk::CommandBuffer cmdbuf, const Dlss::Resource
|
||||
}
|
||||
if (!ok) {
|
||||
std::printf("Upscaler: DLSS failed; falling back to FSR 3.1\n");
|
||||
TlgSettings::Get().upscaler = TlgSettings::UpscalerFsr3;
|
||||
TlgSettings::FallBackToFsr3();
|
||||
dlss_failed = true; // EnsureResources creates the FSR 3 context next frame
|
||||
reset = true;
|
||||
}
|
||||
|
||||
@@ -81,9 +81,25 @@ public:
|
||||
/// This frame's sub-pixel jitter in pixels (screen x right, y down); zero when off and after
|
||||
/// the upscale (post and UI are not jittered).
|
||||
[[nodiscard]] std::array<float, 2> Jitter() const noexcept {
|
||||
if (GameMotion()) return jitter; // the post of a frame runs before the next G-buffer
|
||||
return done_this_frame ? std::array<float, 2>{} : jitter;
|
||||
}
|
||||
|
||||
/// tlg: DLAA or FSR 3.1 native AA on the game's own motion target (RunGameMotion).
|
||||
[[nodiscard]] bool GameMotion() const noexcept {
|
||||
return game_motion_on && game_motion_ok && jitter_active;
|
||||
}
|
||||
[[nodiscard]] u64 SkyMotionPs() const noexcept { return sky_motion_ps; }
|
||||
/// The game's SMAA blending-weight pass (vertex shader; edges 0ea3ec8b, blend 0811cca2).
|
||||
/// While DLAA runs it is cleared instead: zero weights make the blend pass a plain copy,
|
||||
/// so DLSS gets the unfiltered, temporally consistent edges it expects.
|
||||
[[nodiscard]] bool SkipGameSmaa(u64 vs_hash) const;
|
||||
/// The scene depth size of the last sky pass (the game's render size, patched or not).
|
||||
[[nodiscard]] std::array<u32, 2> GameSize() const noexcept { return game_size; }
|
||||
/// The sky pass writes the HDR scene, the motion target and the scene depth of a frame.
|
||||
void OnGameMotionPass(VideoCore::ImageId scene, VideoCore::ImageId motion,
|
||||
VideoCore::ImageId depth);
|
||||
|
||||
// Scaled presets.
|
||||
|
||||
/// After identifying this draw's targets, before drawing any UI pixels. Menus use
|
||||
@@ -140,6 +156,8 @@ private:
|
||||
u64 view_uses = 0;
|
||||
|
||||
void Run();
|
||||
void RunGameMotion();
|
||||
void AdvanceJitter();
|
||||
void RunScaled();
|
||||
void RunUiOnly(VideoCore::ImageId color, VideoCore::ImageId depth);
|
||||
void EnsureUiResources(u32 width, u32 height, vk::Format color, vk::Format depth);
|
||||
@@ -171,14 +189,25 @@ private:
|
||||
/// Records DLSS into `cmdbuf` (output in General). `hdr`: linear scene color input.
|
||||
bool RecordDlss(vk::CommandBuffer cmdbuf, const Dlss::Resource& color,
|
||||
const Dlss::Resource& depth, u32 w, u32 h, u32 ow, u32 oh, float frame_ms,
|
||||
bool hdr);
|
||||
bool hdr, std::array<float, 2> frame_jitter);
|
||||
/// Records FSR 4 into output_image; on a permanent failure FSR 3 takes over.
|
||||
bool RecordFsr4(vk::CommandBuffer cmdbuf, Fsr4Upscaler::Image color, Fsr4Upscaler::Image depth,
|
||||
u32 w, u32 h, u32 ow, u32 oh, float frame_ms);
|
||||
/// Records the portable FSR 3.1 context into output_image (HDR color, General layouts).
|
||||
bool RecordFsr3(vk::CommandBuffer cmdbuf, vk::Image color, vk::Format color_format,
|
||||
vk::ImageUsageFlags color_usage, vk::Image depth, vk::Format depth_format,
|
||||
vk::ImageUsageFlags depth_usage, u32 w, u32 h, u32 ow, u32 oh, float frame_ms,
|
||||
bool has_reactive, std::array<float, 2> frame_jitter);
|
||||
/// tlg: FSR 3.1 at native size on the game's motion (the anti-aliasing row).
|
||||
[[nodiscard]] bool FsrNativeAa() const;
|
||||
void RecordTaa(vk::CommandBuffer cmdbuf, vk::ImageView color, vk::ImageView depth);
|
||||
/// Sharpness above 1 for FSR 3/4 (their RCAS stops at 1): one more RCAS pass over the target
|
||||
/// (output_image, or the 8-bit UI image with ldr) in General layout after the upscaler.
|
||||
void ExtraSharpen(vk::CommandBuffer cmdbuf, vk::Image target, bool ldr, u32 w, u32 h);
|
||||
/// DLSS ignores its sharpness input (since 2.5.1): RCAS on its output, like FSR 3's own.
|
||||
void DlssSharpen(vk::CommandBuffer cmdbuf, u32 w, u32 h);
|
||||
void RcasPass(vk::CommandBuffer cmdbuf, vk::Image target, bool ldr, u32 w, u32 h,
|
||||
float strength, float blend);
|
||||
|
||||
const Instance& instance;
|
||||
Scheduler& scheduler;
|
||||
@@ -205,6 +234,12 @@ private:
|
||||
std::array<float, 2> jitter{};
|
||||
u32 jitter_index = 0;
|
||||
bool reset = true;
|
||||
/// DLAA's own history reset: the shared `reset` is set every frame by the UI and FSR paths.
|
||||
bool game_reset = true;
|
||||
/// The emulator starts several frames per game frame; DLAA counts game frames instead.
|
||||
std::chrono::steady_clock::time_point last_game_motion{};
|
||||
bool jitter_started = false;
|
||||
bool jitter_pending = false; ///< RunGameMotion ran: advance at the next display pass
|
||||
u64 frame_id = 0;
|
||||
std::chrono::steady_clock::time_point last_frame{};
|
||||
|
||||
@@ -213,6 +248,18 @@ private:
|
||||
bool context_hdr = true;
|
||||
u32 target_width = 1920, target_height = 1080; ///< output size of scaled presets
|
||||
u64 ui_trigger_vs = 0x34e8a281;
|
||||
// tlg: game-motion DLAA (TLG_GAME_MOTION=0 disables).
|
||||
bool game_motion_on = true;
|
||||
bool game_motion_ok = true; ///< false after a format mismatch
|
||||
bool jitter_active = false; ///< this frame jitters for DLSS (OnFrameStart)
|
||||
u64 sky_motion_ps = 0x5fce13a4;
|
||||
u64 game_trigger_hash = 0x252f2091;
|
||||
struct GameFrame {
|
||||
VideoCore::ImageId scene{}, motion{}, depth{};
|
||||
std::array<float, 2> jitter{};
|
||||
bool valid = false;
|
||||
} game_frame;
|
||||
std::array<u32, 2> game_size{};
|
||||
VideoCore::ImageId ldr_target{};
|
||||
VideoCore::ImageId ui_color{}, ui_depth{};
|
||||
bool ui_phase = false; ///< from the upscale to the next display pass
|
||||
|
||||
@@ -11,12 +11,22 @@ namespace VideoCore {
|
||||
Sampler::Sampler(const Vulkan::Instance& instance, const AmdGpu::Sampler& sampler,
|
||||
const AmdGpu::BorderColorBuffer border_color_base, const bool is_depth, float extra_lod_bias) {
|
||||
using namespace Vulkan;
|
||||
const bool anisotropy_enable = instance.IsAnisotropicFilteringSupported() &&
|
||||
(AmdGpu::IsAnisoFilter(sampler.xy_mag_filter) ||
|
||||
AmdGpu::IsAnisoFilter(sampler.xy_min_filter));
|
||||
// tlg: 16x anisotropic filtering on mipmapped, linearly filtered textures; depth compares,
|
||||
// point lookups and single-level targets keep the guest's filter.
|
||||
constexpr float forced_aniso = 16.0f;
|
||||
const bool force_aniso = !is_depth &&
|
||||
sampler.xy_min_filter != AmdGpu::Filter::Point &&
|
||||
sampler.xy_min_filter != AmdGpu::Filter::AnisoPoint &&
|
||||
sampler.mip_filter != AmdGpu::MipFilter::None && sampler.MaxLod() > 0.0f;
|
||||
const bool anisotropy_enable = instance.IsAnisotropicFilteringSupported() && (force_aniso ||
|
||||
AmdGpu::IsAnisoFilter(sampler.xy_mag_filter) ||
|
||||
AmdGpu::IsAnisoFilter(sampler.xy_min_filter));
|
||||
const float max_anisotropy =
|
||||
anisotropy_enable ? std::clamp(sampler.MaxAniso(), 1.0f, instance.MaxSamplerAnisotropy())
|
||||
: 1.0f;
|
||||
anisotropy_enable
|
||||
? std::clamp(force_aniso ? std::max(sampler.MaxAniso(), forced_aniso)
|
||||
: sampler.MaxAniso(),
|
||||
1.0f, instance.MaxSamplerAnisotropy())
|
||||
: 1.0f;
|
||||
auto border_color = LiverpoolToVK::BorderColor(sampler.border_color_type);
|
||||
if (border_color == vk::BorderColor::eFloatCustomEXT &&
|
||||
!instance.IsCustomBorderColorSupported()) {
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
#include "video_core/texture_cache/texture_cache.h"
|
||||
#include "video_core/texture_cache/tile_manager.h"
|
||||
|
||||
// runtime_memory.c
|
||||
extern "C" int runtime_memory_read_backing(uintptr_t address, void* data, uint64_t size);
|
||||
|
||||
namespace VideoCore {
|
||||
|
||||
static constexpr u64 PageShift = 12;
|
||||
@@ -701,6 +704,17 @@ std::tuple<ImageId, int, int> TextureCache::ResolveOverlap(const ImageInfo& imag
|
||||
return {merged_image_id, -1, -1};
|
||||
}
|
||||
|
||||
// tlg: a 3D image at the same address with more slices (a stage switch reuses the
|
||||
// memory of a smaller volume). Depth is not part of resources, so replace it.
|
||||
if (image_info.type == AmdGpu::ImageType::Color3D &&
|
||||
cache_image.info.type == AmdGpu::ImageType::Color3D &&
|
||||
image_info.size.depth > cache_image.info.size.depth) {
|
||||
if (safe_to_delete) {
|
||||
FreeImage(cache_image_id);
|
||||
}
|
||||
return {merged_image_id, -1, -1};
|
||||
}
|
||||
|
||||
// Enhanced debug logging for unreachable case
|
||||
// Calculate expected size based on format and dimensions
|
||||
u64 expected_size =
|
||||
@@ -1174,7 +1188,18 @@ void TextureCache::RefreshImage(Image& image) {
|
||||
const u32 s_w = image.info.props.is_block ? Common::DivCeil(w, 4u) : w;
|
||||
const u32 s_h = image.info.props.is_block ? Common::DivCeil(h, 4u) : h;
|
||||
const u32 size = s_w * s_h * (image.info.num_bits / 8);
|
||||
const u64 hash = XXH3_64bits(addr, size);
|
||||
// tlg: these images share a page with GPU-written buffer bytes (garden: next to the
|
||||
// light constant ring), so reading the page faulted into a full GPU readback, ~5 ms
|
||||
// each (TLG_FAULT_PROFILE: the top fault site). When the hashed bytes themselves have
|
||||
// no pending GPU write, the unprotected backing view holds them.
|
||||
std::array<u8, 8 * 8 * 16> bytes;
|
||||
const u8* src = addr;
|
||||
if (size <= bytes.size() && !TlgToggle::Disabled2(TlgToggle::ImageHashBacking) &&
|
||||
!buffer_cache.HasPendingGpuWrite(image.info.guest_address, size) &&
|
||||
runtime_memory_read_backing(image.info.guest_address, bytes.data(), size)) {
|
||||
src = bytes.data();
|
||||
}
|
||||
const u64 hash = XXH3_64bits(src, size);
|
||||
if (image.hash == hash) {
|
||||
image.flags &= ~ImageFlagBits::MaybeCpuDirty;
|
||||
return;
|
||||
@@ -1198,8 +1223,19 @@ void TextureCache::RefreshImage(Image& image) {
|
||||
|
||||
// Protect GPU modified resources from accidental CPU reuploads.
|
||||
if (is_gpu_modified && !is_gpu_dirty) {
|
||||
const u8* addr = std::bit_cast<u8*>(image.info.guest_address);
|
||||
const u64 hash = XXH3_64bits(addr + mip_offset, mip_size);
|
||||
// Same as the MaybeCpuDirty hash: read through the backing view when no GPU write
|
||||
// is pending, so the hash does not fault into a full GPU readback.
|
||||
const u8* src = std::bit_cast<u8*>(image.info.guest_address) + mip_offset;
|
||||
thread_local std::vector<u8> mip_bytes;
|
||||
const VAddr mip_addr = image.info.guest_address + mip_offset;
|
||||
if (mip_size <= 4_MB && !TlgToggle::Disabled2(TlgToggle::ImageHashBacking) &&
|
||||
!buffer_cache.HasPendingGpuWrite(mip_addr, mip_size)) {
|
||||
mip_bytes.resize(mip_size);
|
||||
if (runtime_memory_read_backing(mip_addr, mip_bytes.data(), mip_size)) {
|
||||
src = mip_bytes.data();
|
||||
}
|
||||
}
|
||||
const u64 hash = XXH3_64bits(src, mip_size);
|
||||
if (image.mip_hashes[m] == hash) {
|
||||
continue;
|
||||
}
|
||||
@@ -1244,8 +1280,9 @@ vk::Sampler TextureCache::GetSampler(const AmdGpu::Sampler& sampler,
|
||||
AmdGpu::BorderColorBuffer border_color_base,
|
||||
const bool is_depth, float extra_lod_bias) {
|
||||
// Compare and plain uses of one S# need separate samplers; so do extra LOD biases.
|
||||
const u64 hash = HashCombine(HashCombine(XXH3_64bits(&sampler, sizeof(sampler)), is_depth),
|
||||
u64(std::bit_cast<u32>(extra_lod_bias)));
|
||||
const u64 hash =
|
||||
HashCombine(HashCombine(XXH3_64bits(&sampler, sizeof(sampler)), is_depth),
|
||||
u64(std::bit_cast<u32>(extra_lod_bias)));
|
||||
|
||||
std::scoped_lock lock{samplers_mutex};
|
||||
const auto [it, new_sampler] = samplers.try_emplace(hash, instance, sampler, border_color_base,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "common/types.h"
|
||||
|
||||
u32 TlgDisplayRefreshHz(); // tlggpu.cpp: primary display refresh rate, 60 when unknown
|
||||
bool TlgHdrStartup(); // tlggpu.cpp: the HDR setting this launch started with
|
||||
|
||||
enum GpuReadbacksMode : int { Disabled, Relaxed, Precise };
|
||||
|
||||
@@ -52,7 +53,7 @@ public:
|
||||
bool IsCopyGpuBuffers() { static const auto value = Flag("TLG_COPY_GPU_BUFFERS", false); return value; }
|
||||
bool IsDirectMemoryAccessEnabled() { static const auto value = Flag("TLG_DIRECT_MEMORY_ACCESS", false); return value; }
|
||||
bool IsDumpShaders() { static const auto value = Flag("TLG_DUMP_SHADERS", false); return value; }
|
||||
bool IsHdrAllowed() { static const auto value = Flag("TLG_HDR", false); return value; }
|
||||
bool IsHdrAllowed() { static const auto value = TlgHdrStartup(); return value; }
|
||||
bool IsNullGPU() { static const auto value = Flag("TLG_NULL_GPU", false); return value; }
|
||||
bool IsPatchShaders() { return false; }
|
||||
bool IsPipelineCacheArchived() { return false; }
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#include "tlg_actors.h"
|
||||
#include "tlg_settings.h"
|
||||
#include <cstdint>
|
||||
#include "../../src/actor_hook.h"
|
||||
|
||||
alignas(64) TlgActorSlot tlg_actor_slots[2];
|
||||
|
||||
extern "C" uintptr_t tlg_actor_slots_address(void) {
|
||||
return reinterpret_cast<uintptr_t>(tlg_actor_slots);
|
||||
}
|
||||
|
||||
extern "C" void tlg_actor_status(int supported) {
|
||||
TlgSettings::Get().actor_watch_supported = supported != 0;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// tlg: health of the player and of the last actor hit, recorded by the damage hook
|
||||
// (src/actor_hook.h); the overlay draws the enemy health bar from it.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace TlgActors {
|
||||
/// A recorded pair is shown only when it looks like health: finite, 0 <= health <= max.
|
||||
inline bool Valid(float health, float max_health) {
|
||||
return std::isfinite(health) && std::isfinite(max_health) && max_health > 0.0f &&
|
||||
max_health < 1e7f && health >= 0.0f && health <= max_health * 1.001f;
|
||||
}
|
||||
inline float Fraction(float health, float max_health) {
|
||||
return Valid(health, max_health) ? std::clamp(health / max_health, 0.0f, 1.0f) : 0.0f;
|
||||
}
|
||||
}
|
||||
@@ -70,13 +70,13 @@ inline constexpr std::array<int, 3> FramesQueued = {1, 2, 0};
|
||||
inline int ParseFramesQueued(int value) {
|
||||
return IndexOf(FramesQueued, value, -1) >= 0 ? value : 1;
|
||||
}
|
||||
/// RCAS / CAS strength in percent; invalid values fall back to `fallback` (RCAS 75, CAS 50).
|
||||
/// RCAS / CAS strength in percent; invalid values fall back to `fallback` (RCAS 75, CAS 100).
|
||||
inline int ParseRcasStrength(int value, int fallback = 75) {
|
||||
return value >= 0 && value <= 100 ? value : fallback;
|
||||
}
|
||||
/// CAS sharpness (0 to 1) for a strength in percent.
|
||||
inline float CasSharpness(int strength) {
|
||||
return float(ParseRcasStrength(strength, 50)) / 100.0f;
|
||||
return float(ParseRcasStrength(strength, 100)) / 100.0f;
|
||||
}
|
||||
|
||||
/// Color adjustments (post_process.frag): range and neutral value of each slider.
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// tlg: the game's Lua loadbuffer (HavokScript) is redirected here (src/probe.c,
|
||||
// install_lua_hook). utils.lua gets a tick that loads "--TLGSET" every 15 frames; that chunk
|
||||
// is replaced by code applying the Effects settings to the game's post-process objects, so
|
||||
// menu changes apply live. "--TLGLOG <text>" chunks print <text> (data out of Lua).
|
||||
#include "tlg_settings.h"
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace {
|
||||
using LoadFn = int(__attribute__((sysv_abi))*)(void*, void*, const char*, uint64_t,
|
||||
const char*);
|
||||
LoadFn original;
|
||||
|
||||
constexpr std::string_view Bootstrap = R"(
|
||||
do local n,ls=0,loadstring or load RegisterTick(function() n=n+1 if n>=15 then n=0
|
||||
local f,e=ls('--TLGSET') local ok=true if f then ok,e=pcall(f) end
|
||||
if (not f or not ok) and e~=_TLGERR then _TLGERR=e local g=ls('--TLGLOG error: '..tostring(e)) if g then g() end end
|
||||
end end) end
|
||||
)";
|
||||
|
||||
// Shared by every TLGSET chunk: Scale keeps the game's own value per object property and
|
||||
// writes it times pct/100, or plus add when given (vectors: the listed components). A value differing from our last
|
||||
// write was set by the game (cutscene, area change): it becomes the new base. Feature turns a
|
||||
// pass off at 0% (it costs nothing then) and back on when the game had it.
|
||||
constexpr std::string_view Prelude = R"(
|
||||
local ls=loadstring or load
|
||||
local function L(s) local f=ls('--TLGLOG '..s) if f then f() end end
|
||||
_TLGB=_TLGB or {}
|
||||
local B=_TLGB
|
||||
local function Parts(v) local t={} for n in tostring(v):gmatch('%S+') do t[#t+1]=tonumber(n) or 0 end return t end
|
||||
local function Scale(o,key,prop,pct,parts,add)
|
||||
if not o then return end
|
||||
local cur=o[prop]
|
||||
local s=tostring(cur)
|
||||
local e=B[key]
|
||||
if not e or s~=e.w then e={b=parts and Parts(cur) or cur} B[key]=e end
|
||||
if parts then
|
||||
if type(cur)~='userdata' then return end
|
||||
local t={} for i=1,4 do t[i]=e.b[i] or 0 end
|
||||
for _,i in ipairs(parts) do t[i]=add and t[i]+add or t[i]*pct/100 end
|
||||
local n=Vector(t[1],t[2],t[3],t[4])
|
||||
if tostring(n)~=s then o[prop]=n end
|
||||
else
|
||||
if type(cur)~='number' then return end
|
||||
local w=add and e.b+add or e.b*pct/100
|
||||
if w~=cur then o[prop]=w end
|
||||
end
|
||||
e.w=tostring(o[prop])
|
||||
end
|
||||
B.off=B.off or {}
|
||||
local function Feature(o,name,pct)
|
||||
if not o or type(o.features)~='string' then return end
|
||||
local on=o.features:find(name,1,true)~=nil
|
||||
if pct==0 and on then o.features='~'..name B.off[name]=true
|
||||
elseif pct>0 and B.off[name] then B.off[name]=nil if not on then o.features='|'..name end end
|
||||
end
|
||||
local pp=FindObject('_default_env_pp')
|
||||
local cam=FindObject('_trico_camera')
|
||||
local cs=FindObject('_default_env_cs')
|
||||
local sk=FindObject('_default_env_sk')
|
||||
local XY,XZ,ALL,XYZ={1,2},{1,3},{1,2,3,4},{1,2,3}
|
||||
)";
|
||||
|
||||
// Development: the file named by TLG_LUA_EXEC runs once each time it changes (live console).
|
||||
std::string DevChunk() {
|
||||
static const char* path = std::getenv("TLG_LUA_EXEC");
|
||||
static std::filesystem::file_time_type seen{};
|
||||
std::error_code error;
|
||||
if (!path) return {};
|
||||
const auto time = std::filesystem::last_write_time(path, error);
|
||||
if (error || time == seen) return {};
|
||||
seen = time;
|
||||
std::ifstream file(path);
|
||||
std::stringstream text;
|
||||
text << file.rdbuf();
|
||||
std::printf("Lua: running %s\n", path);
|
||||
return "\ndo\n" + text.str() + "\nend\n";
|
||||
}
|
||||
|
||||
// Wider than 16:9 the resolution patch widens the UI canvas to 1080*W/H (tlg_patches.py); the
|
||||
// subtitle layout still centers on 1920, so shift it right by half the extra width.
|
||||
int SubtitleShift() {
|
||||
const char* resolution = std::getenv("TLG_RESOLUTION_PATCH");
|
||||
int width = 0, height = 0;
|
||||
if (!resolution || std::sscanf(resolution, "%dx%d", &width, &height) != 2 || height <= 0 ||
|
||||
width * 9 <= height * 16)
|
||||
return 0;
|
||||
const int ui_width = static_cast<int>(1080.0 * width / height + 0.5);
|
||||
return (ui_width - 1920) / 2;
|
||||
}
|
||||
|
||||
std::string SetChunk() {
|
||||
static const int subtitle_shift = SubtitleShift();
|
||||
const auto& s = TlgSettings::Get();
|
||||
std::string code(Prelude);
|
||||
char line[1024];
|
||||
std::snprintf(line, sizeof(line),
|
||||
"Scale(pp,'bloom','bloomAmount',%d) Feature(pp,'Bloom',%d)\n"
|
||||
"Scale(pp,'ao','saoIntensity',%d,XY) Feature(pp,'SSAO',%d)\n"
|
||||
"Scale(pp,'ssr','ssrStrength',%d) Feature(pp,'SSR',%d)\n"
|
||||
"Scale(pp,'blur','cameraBlurScale',%d) Feature(pp,'CameraBlur',%d)\n"
|
||||
"Scale(pp,'ca','chromaticAberrationAmount',100,nil,%.4f)\n"
|
||||
"Scale(pp,'vig','vignetteParams',100,XZ,%.4f)\n"
|
||||
"Scale(pp,'sharp','sharpenScale',%d)\n"
|
||||
"Scale(sk,'lod','viewDistanceScale',%d,XYZ)\n",
|
||||
s.bloom.load(), s.bloom.load(), s.ssao.load(), s.ssao.load(), s.ssr.load(),
|
||||
s.ssr.load(), s.motion_blur.load(), s.motion_blur.load(),
|
||||
s.chromatic.load() * 0.002, s.vignette.load() * 0.01, s.game_sharpen.load(),
|
||||
s.lod.load());
|
||||
code += line;
|
||||
if (subtitle_shift) {
|
||||
// Re-applied every tick: SubtitleSystem:Initialize resets it to (0, -OFFSET_Y).
|
||||
std::snprintf(line, sizeof(line),
|
||||
"if Subtitle_SetParam then Subtitle_SetParam(%d,-(SubtitleSystem and "
|
||||
"SubtitleSystem.OFFSET_Y or 64)) end\n",
|
||||
subtitle_shift);
|
||||
code += line;
|
||||
}
|
||||
code += DevChunk();
|
||||
return code;
|
||||
}
|
||||
|
||||
bool StartsWith(const char* buffer, uint64_t size, std::string_view prefix) {
|
||||
return size >= prefix.size() && std::memcmp(buffer, prefix.data(), prefix.size()) == 0;
|
||||
}
|
||||
|
||||
// Development: every chunk the game loads is written to the folder named by TLG_LUA_DUMP.
|
||||
void Dump(const char* buffer, uint64_t size, const char* name) {
|
||||
static const char* dir = std::getenv("TLG_LUA_DUMP");
|
||||
static int count = 0;
|
||||
if (!dir) return;
|
||||
std::string file = name ? name : "chunk";
|
||||
for (char& c : file)
|
||||
if (c == '/' || c == '\\' || c == ':' || c == '@' || c == '=') c = '_';
|
||||
std::filesystem::create_directories(dir);
|
||||
std::ofstream(std::string(dir) + "/" + std::to_string(count++) + "_" + file, std::ios::binary)
|
||||
.write(buffer, static_cast<std::streamsize>(size));
|
||||
}
|
||||
|
||||
// Debug menus (setting level_select): the GoldHEN "Level Select + QA Menu" patch by illusiony
|
||||
// and DanielSvoboda, applied to the source. Startup gets the QA menu (in game: hold L1, press
|
||||
// L3); the title menu's press-build level select is enabled (hold L1 as the menu closes).
|
||||
// The game is paused while the QA menu is open. The menu also closes itself when a stage is
|
||||
// picked, so its Hide resumes: paused, the new stage's fade-in (a game-time delay) never came.
|
||||
constexpr std::string_view QaMenu = R"(
|
||||
if not _TLGQA then _TLGQA=true
|
||||
local function Registered(f) for _,v in ipairs(tick_funcs) do if v==f then return true end end return false end
|
||||
RegisterTick(function()
|
||||
if MS.PadGetHold(PadButton.L1) and MS.PadGetDown(PadButton.L3) then
|
||||
if Registered(ControllerMenu.Tick) then
|
||||
if ControllerMenu.Hide then ControllerMenu.Hide() end
|
||||
else _TLGQA_UNLOAD=true DelayExec(function() RegisterTick(ControllerMenu.Tick) end) luaCommand("trico pause") end
|
||||
end
|
||||
end)
|
||||
Load("game/scripts/_editor/ControllerMenu")
|
||||
local hide = ControllerMenu.Hide
|
||||
ControllerMenu.Hide = function() hide() luaCommand("trico resume") end
|
||||
end
|
||||
)";
|
||||
|
||||
/// Edits game chunk `name` for the debug menus and the bridge fix; true when it changed.
|
||||
bool GameEdits(std::string& patched, std::string_view name) {
|
||||
const auto& s = TlgSettings::Get();
|
||||
if (s.level_select && name == "data_trico/game/scripts/startup.lua") {
|
||||
patched += QaMenu;
|
||||
return true;
|
||||
}
|
||||
if (s.level_select && name == "data_trico/game/scripts/common/GameSetupTbl.lua") {
|
||||
// A stage started from the QA menu is loaded on top of the current one (StartStage
|
||||
// unloads nothing), so each switch held two stages and ran the GPU heap out of memory.
|
||||
// Do what the game's own restart does: disconnect the stage's regions, preload the new
|
||||
// ones, then unload what is still disconnected. The common regions stay.
|
||||
static constexpr std::string_view Blocker = "self.timeBlocker = CreateTimeBlocker()";
|
||||
static constexpr std::string_view Preloaded =
|
||||
"DelayExec( function() self:Start_Stage_2nd_PreloadWait(area) end )";
|
||||
const size_t blocker = patched.find(Blocker);
|
||||
const size_t preloaded = patched.find(Preloaded);
|
||||
if (blocker == std::string::npos || preloaded == std::string::npos || preloaded < blocker) {
|
||||
return false;
|
||||
}
|
||||
patched.insert(preloaded, "if _TLGQA_UNLOAD then _TLGQA_UNLOAD = nil RegionController.UnloadAll_IfDisconnect() end ");
|
||||
patched.insert(blocker + Blocker.size(),
|
||||
" if _TLGQA_UNLOAD then for _, v in ipairs(Class('Region')) do"
|
||||
" if OBJ[v].status ~= 'None' and not string.find(v, 'RG_common') then"
|
||||
" RegionController.Disconnect(OBJ[v].regionName) end end"
|
||||
" self:Disconnect_CommonRegion() end");
|
||||
return true;
|
||||
}
|
||||
if (s.level_select && name == "data_trico/game/scripts/stages/s01_Opening/missionScript.lua") {
|
||||
// Only the test guarding the L1 level select, not the press build's other changes.
|
||||
static constexpr std::string_view Test = "if gameConfig_buildId_Press==true then";
|
||||
static constexpr std::string_view Hold = "if MS.PadGetHold(PadButton.L1) then";
|
||||
for (size_t at = patched.find(Test); at != std::string::npos;
|
||||
at = patched.find(Test, at + 1)) {
|
||||
const size_t next = patched.find_first_not_of(" \t\r\n", at + Test.size());
|
||||
if (next != std::string::npos && patched.compare(next, Hold.size(), Hold) == 0) {
|
||||
patched.replace(at, Test.size(), "if true then");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Bridge fix (setting bridge_fix): the GoldHEN "Collapsing Bridge Freeze Fix". The slow
|
||||
// motion while the bridge in the pit collapses can leave the game frozen; skip it.
|
||||
static constexpr std::string_view SlowMotion = "MS.SetGameSpeed(0.10, 0.2)";
|
||||
if (s.bridge_fix && name == "data_trico/game/scripts/stages/s10_Pit/missionScript.lua") {
|
||||
const size_t at = patched.find(SlowMotion);
|
||||
if (at == std::string::npos) {
|
||||
return false;
|
||||
}
|
||||
patched.insert(at, "--");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
extern "C" void tlg_lua_status(uintptr_t loadbuffer) {
|
||||
original = reinterpret_cast<LoadFn>(loadbuffer);
|
||||
}
|
||||
|
||||
extern "C" __attribute__((sysv_abi)) int tlg_lua_load(void* L, void* options, const char* buffer,
|
||||
uint64_t size, const char* name) {
|
||||
static constexpr std::string_view Empty = "--";
|
||||
if (StartsWith(buffer, size, "--TLGLOG ")) {
|
||||
std::printf("Lua: %.*s\n", static_cast<int>(size - 9), buffer + 9);
|
||||
std::fflush(stdout);
|
||||
return original(L, options, Empty.data(), Empty.size(), name);
|
||||
}
|
||||
if (StartsWith(buffer, size, "--TLGSET")) {
|
||||
static std::string code; // game thread only
|
||||
code = SetChunk();
|
||||
return original(L, options, code.data(), code.size(), name);
|
||||
}
|
||||
Dump(buffer, size, name);
|
||||
static constexpr std::string_view Marker = "function RegisterTick(";
|
||||
std::string patched(buffer, size);
|
||||
bool changed = false;
|
||||
if (patched.find(Marker) != std::string::npos) {
|
||||
patched += Bootstrap;
|
||||
std::printf("Lua: settings tick added to %s\n", name ? name : "?");
|
||||
changed = true;
|
||||
}
|
||||
if (name && GameEdits(patched, name)) {
|
||||
std::printf("Lua: edited %s\n", name);
|
||||
changed = true;
|
||||
}
|
||||
std::fflush(stdout);
|
||||
return changed ? original(L, options, patched.data(), patched.size(), name)
|
||||
: original(L, options, buffer, size, name);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#include "tlg_orbs.h"
|
||||
#include "tlg_settings.h"
|
||||
#include <cstring>
|
||||
#include <cstdint>
|
||||
#include "../../src/cheat_hook.h"
|
||||
|
||||
extern "C" float __attribute__((sysv_abi)) tlg_orbs_gain(void* player, float requested) {
|
||||
float balance;
|
||||
std::memcpy(&balance, static_cast<unsigned char*>(player) + 0x1e8, sizeof(balance));
|
||||
const auto& settings = TlgSettings::Get();
|
||||
if ((settings.cheats_supported.load() & (1u << TLG_CHEAT_ORBS)) &&
|
||||
settings.cheats[TLG_CHEAT_ORBS].load()) {
|
||||
return requested;
|
||||
}
|
||||
return TlgOrbs::Scale(balance, requested, TlgSettings::Get().red_orb_multiplier.load());
|
||||
}
|
||||
|
||||
extern "C" uintptr_t tlg_cheat_flag(unsigned id) {
|
||||
static_assert(sizeof(std::atomic<bool>) == 1 && std::atomic<bool>::is_always_lock_free);
|
||||
return id < TLG_CHEAT_COUNT ? reinterpret_cast<uintptr_t>(&TlgSettings::Get().cheats[id]) : 0;
|
||||
}
|
||||
|
||||
extern "C" void tlg_cheat_status(unsigned id) {
|
||||
if (id < TLG_CHEAT_COUNT) {
|
||||
TlgSettings::Get().cheats_supported.fetch_or(1u << id);
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" void tlg_orbs_status(int supported) {
|
||||
TlgSettings::Get().red_orbs_supported = supported != 0;
|
||||
}
|
||||
|
||||
/// Damage (src/damage_hook.h): the multiplier the player's (taken) or another actor's hits use.
|
||||
extern "C" uintptr_t tlg_damage_multiplier(int taken) {
|
||||
static_assert(sizeof(std::atomic<float>) == 4 && std::atomic<float>::is_always_lock_free);
|
||||
auto& s = TlgSettings::Get();
|
||||
return reinterpret_cast<uintptr_t>(taken ? &s.damage_taken : &s.damage_dealt);
|
||||
}
|
||||
|
||||
/// Orb pickup (src/orb_hook.h): 0 green, 1 blue, 2 gold.
|
||||
extern "C" uintptr_t tlg_orb_pickup_multiplier(unsigned kind) {
|
||||
auto& s = TlgSettings::Get();
|
||||
return reinterpret_cast<uintptr_t>(kind == 0 ? &s.green_orb_multiplier
|
||||
: kind == 1 ? &s.blue_orb_multiplier
|
||||
: &s.gold_orb_multiplier);
|
||||
}
|
||||
|
||||
extern "C" void tlg_stat_multipliers_status(int damage, int orb_pickup) {
|
||||
TlgSettings::Get().damage_supported = damage != 0;
|
||||
TlgSettings::Get().orb_pickup_supported = orb_pickup != 0;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace TlgOrbs {
|
||||
inline float ClampMultiplier(float value) {
|
||||
return std::isfinite(value) ? std::clamp(value, 0.1f, 100.0f) : 1.0f;
|
||||
}
|
||||
inline float Scale(float balance, float requested, float multiplier) {
|
||||
if (!std::isfinite(balance) || !std::isfinite(requested) || requested <= balance) {
|
||||
return requested;
|
||||
}
|
||||
// The game stores fractional orbs as a float; preserve them rather than rounding each pickup.
|
||||
return float(double(balance) + (double(requested) - balance) * ClampMultiplier(multiplier));
|
||||
}
|
||||
}
|
||||
+102
-86
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#include "tlg_overlay.h"
|
||||
#include "../../src/runtime_menu.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
@@ -15,13 +16,8 @@
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include "tlg_settings.h"
|
||||
#include "tlg_orbs.h"
|
||||
#include "tlg_actors.h"
|
||||
#include "tlg_graphics.h"
|
||||
#include "tlg_perf_stats.h"
|
||||
#include "../../src/actor_hook.h"
|
||||
|
||||
extern TlgActorSlot tlg_actor_slots[2];
|
||||
extern "C" void runtime_restart(void); // src/probe.c: starts run.py again, ends this process
|
||||
#include "common/elf_info.h"
|
||||
#include "imgui.h"
|
||||
@@ -68,6 +64,7 @@ std::atomic<bool> loading{false};
|
||||
std::atomic<u32> loading_done{0}, loading_total{0};
|
||||
std::atomic<u32> background_done{0}, background_total{0};
|
||||
bool dirty = false; // settings changed while open: saved on close
|
||||
std::chrono::steady_clock::time_point aa_label_until{}; // the AA label after F8
|
||||
int requested_tab = -1; // switch on the next frame (L1/R1, Q/E, menu opened)
|
||||
// The game's text dialog (SetTextEntry), guarded by imgui_mutex.
|
||||
bool text_entry_active = false;
|
||||
@@ -248,27 +245,6 @@ void ChoiceRow(const char* label, std::atomic<int>& target, const char* const* l
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
// A 0.1x to 100x multiplier with a 1x button.
|
||||
void MultiplierRow(const char* label, std::atomic<float>& value, const char* help) {
|
||||
Row(label);
|
||||
const ImGuiStyle& style = ImGui::GetStyle();
|
||||
ImGui::SetNextItemWidth(-(ImGui::CalcTextSize("1x").x + style.FramePadding.x * 2 + style.ItemSpacing.x));
|
||||
float v = value;
|
||||
if (ImGui::SliderFloat("##value", &v, 0.1f, 100.0f, "%.2fx",
|
||||
ImGuiSliderFlags_Logarithmic | ImGuiSliderFlags_AlwaysClamp)) {
|
||||
value = TlgOrbs::ClampMultiplier(v);
|
||||
dirty = true;
|
||||
}
|
||||
Help(help);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("1x")) {
|
||||
value = 1.0f;
|
||||
dirty = true;
|
||||
}
|
||||
Help(help);
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
void InfoRow(const char* label, const char* text) {
|
||||
Row(label);
|
||||
ImGui::TextDisabled("%s", text);
|
||||
@@ -370,6 +346,9 @@ void ImageTab() {
|
||||
ChoiceRow(TL("Display mode", "Modo de exibição"), s.display_mode, G::DisplayModeCount == 3 ? modes : G::DisplayModeNames.data(),
|
||||
nullptr, G::DisplayModeCount, TL("Windowed, borderless window or exclusive fullscreen.", "Janela, janela sem borda ou tela cheia exclusiva."));
|
||||
CheckRow("VSync", s.vsync, TL("Waits for the monitor: no tearing, a little more input delay.", "Espera o monitor: sem tearing, um pouco mais de atraso de entrada."));
|
||||
CheckRow("HDR", s.hdr,
|
||||
TL("The game's own HDR10 output, used when HDR is on in Windows display settings. Applies after a restart.",
|
||||
"A saída HDR10 do próprio jogo, usada quando o HDR está ligado nas configurações de tela do Windows. Vale após reiniciar."));
|
||||
ImGui::EndTable();
|
||||
}
|
||||
const int iw = s.image_width, ih = s.image_height, ww = s.window_width, wh = s.window_height;
|
||||
@@ -405,11 +384,64 @@ void ImageTab() {
|
||||
}
|
||||
ImGui::SeparatorText(TL("Anti-aliasing", "Anti-aliasing"));
|
||||
if (BeginRows("##antialiasing")) {
|
||||
// tlg: one row for DLAA and FSR 3.1 native AA (TlgSettings::AaMode). DLAA needs DLSS
|
||||
// (tlg_dlss.dll, an NVIDIA RTX card); the game's own SMAA runs either way.
|
||||
static const char* const aa_labels[TlgSettings::AaModeCount] = {
|
||||
TL("Off", "Desligado"), "DLAA (NVIDIA DLSS)", TL("FSR 3.1 Native AA", "FSR 3.1 AA nativo")};
|
||||
Row(TL("Anti-aliasing", "Anti-aliasing"));
|
||||
const int aa_mode = TlgSettings::AaModeOf();
|
||||
if (ImGui::BeginCombo("##value", aa_labels[aa_mode])) {
|
||||
for (int i = 0; i < TlgSettings::AaModeCount; ++i) {
|
||||
ImGui::BeginDisabled(!TlgSettings::AaModeSupported(i));
|
||||
if (ImGui::Selectable(aa_labels[i], i == aa_mode) && i != aa_mode) {
|
||||
TlgSettings::SetAaMode(i);
|
||||
dirty = true;
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
Help(!s.dlss_supported && aa_mode == TlgSettings::AaOff
|
||||
? (s.dlss_problem.load() ? s.dlss_problem.load() : TL("DLAA needs an NVIDIA RTX card and tlg_dlss.dll; FSR 3.1 native AA runs on any card.", "O DLAA precisa de uma placa NVIDIA RTX e do tlg_dlss.dll; o FSR 3.1 AA nativo roda em qualquer placa."))
|
||||
: TL("Stable, smooth edges on foliage, feathers and railings, at native resolution on the game's own motion vectors. DLAA: NVIDIA DLSS (RTX cards). FSR 3.1 native AA: AMD FSR, on any card. Off: the game's own anti-aliasing only. F8 switches between off and the choice in game.",
|
||||
"Bordas estáveis e suaves em folhagem, penas e grades, na resolução nativa com os vetores de movimento do próprio jogo. DLAA: NVIDIA DLSS (placas RTX). FSR 3.1 AA nativo: AMD FSR, em qualquer placa. Desligado: só o anti-aliasing do jogo. F8 alterna no jogo entre desligado e a escolha."));
|
||||
ImGui::PopID();
|
||||
CheckRow("SMAA 1x", s.smaa,
|
||||
TL("Smooths jagged edges (feathers, grass, railings) on the final image. The game draws its HUD and subtitles into the same image, so text edges are smoothed too. About 0.2 to 0.4 ms per frame at 1080p (RTX 2060).",
|
||||
"Suaviza o serrilhado (penas, grama, grades) na imagem final. O jogo desenha o HUD e as legendas na mesma imagem, então as bordas do texto também são suavizadas. Cerca de 0,2 a 0,4 ms por quadro em 1080p (RTX 2060)."));
|
||||
ImGui::EndTable();
|
||||
}
|
||||
ImGui::SeparatorText(TL("Enhancements", "Melhorias"));
|
||||
if (BeginRows("##enhancements")) {
|
||||
static const char* const shadow_labels[] = {TL("Game (2048)", "Jogo (2048)"), "4096", "8192"};
|
||||
static const int shadow_values[] = {1, 2, 4};
|
||||
ChoiceRow(TL("Shadow resolution", "Resolução das sombras"), s.shadow_scale, shadow_labels, shadow_values, 3,
|
||||
TL("Draws the shadow maps at 2x or 4x the game's 2048x2048. Recommended: Game (2048). The sharper edges are hard to see, and 8192 costs about 8 FPS and about 1 GB of video memory.",
|
||||
"Desenha os mapas de sombra em 2x ou 4x os 2048x2048 do jogo. Recomendado: Jogo (2048). As bordas mais nítidas são difíceis de notar, e 8192 custa cerca de 8 FPS e cerca de 1 GB de memória de vídeo."));
|
||||
ImGui::EndTable();
|
||||
}
|
||||
ImGui::SeparatorText(TL("Game effects (100% = as shipped)", "Efeitos do jogo (100% = original)"));
|
||||
if (BeginRows("##effects")) {
|
||||
SliderRow(TL("Ambient occlusion", "Oclusão de ambiente"), s.ssao, 0, 400, "%d%%",
|
||||
TL("Strength of the contact shadows in corners and under grass. 0% turns the pass off (saves a little GPU time). Above 150% the half-resolution AO shows blocky halos.",
|
||||
"Força das sombras de contato em cantos e sob a grama. 0% desliga o passe (economiza um pouco de GPU). Acima de 150% a AO em meia resolução mostra halos em blocos."));
|
||||
SliderRow(TL("Bloom", "Bloom"), s.bloom, 0, 400, "%d%%",
|
||||
TL("Glow around bright light. 0% turns the pass off.", "Brilho em volta da luz forte. 0% desliga o passe."));
|
||||
SliderRow(TL("Screen-space reflections", "Reflexos em espaço de tela"), s.ssr, 0, 400, "%d%%",
|
||||
TL("Reflections and light glare on water and wet stone. 0% turns the pass off.", "Reflexos e brilho de luz na água e na pedra molhada. 0% desliga o passe."));
|
||||
SliderRow(TL("Motion blur", "Desfoque de movimento"), s.motion_blur, 0, 400, "%d%%",
|
||||
TL("Camera motion blur. 0% turns the pass off.", "Desfoque ao mover a câmera. 0% desliga o passe."));
|
||||
SliderRow(TL("Chromatic aberration", "Aberração cromática"), s.chromatic, 0, 100, "%d%%",
|
||||
TL("Color fringes toward the screen edges. The game ships with this off: 0% is as shipped.", "Franjas de cor nas bordas da tela. O jogo vem com isto desligado: 0% é o original."));
|
||||
SliderRow(TL("Vignette", "Vinheta"), s.vignette, 0, 100, "%d%%",
|
||||
TL("Darkened screen corners. The game ships with this off: 0% is as shipped.", "Cantos da tela escurecidos. O jogo vem com isto desligado: 0% é o original."));
|
||||
SliderRow(TL("Game sharpening", "Nitidez do jogo"), s.game_sharpen, 0, 400, "%d%%",
|
||||
TL("The game's own sharpening filter. The difference is hard to see: 100% recommended.", "O filtro de nitidez do próprio jogo. A diferença é difícil de ver: 100% recomendado."));
|
||||
SliderRow(TL("Character detail distance", "Distância de detalhe dos personagens"), s.lod, 25, 400, "%d%%",
|
||||
TL("How far the boy and Trico keep their detailed models. Higher keeps full detail further away.",
|
||||
"Até onde o menino e o Trico mantêm os modelos detalhados. Mais alto mantém o detalhe completo mais longe."));
|
||||
ImGui::EndTable();
|
||||
}
|
||||
ImGui::SeparatorText(TL("Color", "Cor"));
|
||||
if (BeginRows("##color")) {
|
||||
CheckRow(TL("Color adjustments", "Ajustes de cor"), s.color_adjust,
|
||||
@@ -460,7 +492,7 @@ void PerformanceTab() {
|
||||
ChoiceRow(TL("Frames queued", "Quadros na fila"), s.frames_queued, queued_labels, G::FramesQueued.data(),
|
||||
int(G::FramesQueued.size()), TL("More frames queued: steadier FPS, slower response to input.", "Mais quadros na fila: FPS mais estável, resposta mais lenta."));
|
||||
CheckRow(TL("Compile new shaders in the background", "Compilar shaders novos em segundo plano"), s.async_shaders,
|
||||
TL("Fewer stutters in new areas; an object or effect may appear a moment later.", "Menos engasgos em áreas novas; um objeto ou efeito pode aparecer um instante depois."));
|
||||
TL("Fewer stutters in new areas; an object or effect may appear a moment later, and the lighting can stay wrong (overexposed) after a load.", "Menos engasgos em áreas novas; um objeto ou efeito pode aparecer um instante depois, e a iluminação pode ficar errada (estourada) depois de um carregamento."));
|
||||
ImGui::EndTable();
|
||||
}
|
||||
}
|
||||
@@ -580,7 +612,8 @@ void ControlsTab() {
|
||||
int RestartChanges() {
|
||||
const auto& s = TlgSettings::Get();
|
||||
return (s.render_resolution != s.startup_render_resolution) + (s.engine_fps != s.startup_engine_fps) +
|
||||
(s.deferred_readback != s.startup_deferred_readback) + (s.stale_readback != s.startup_stale_readback);
|
||||
(s.deferred_readback != s.startup_deferred_readback) + (s.stale_readback != s.startup_stale_readback) +
|
||||
(s.hdr != s.startup_hdr);
|
||||
}
|
||||
|
||||
void Menu() {
|
||||
@@ -679,55 +712,6 @@ void Menu() {
|
||||
}
|
||||
}
|
||||
|
||||
// Enemy health bar from the damage hook's records (tlg_actors.cpp).
|
||||
// Only the copied values are read, never the game's memory: an actor may be gone by now.
|
||||
void HealthDisplay() {
|
||||
struct Seen {
|
||||
uint32_t seq = 0;
|
||||
std::chrono::steady_clock::time_point at{};
|
||||
};
|
||||
static Seen seen[2];
|
||||
const auto& s = TlgSettings::Get();
|
||||
if (!s.actor_watch_supported.load()) {
|
||||
return;
|
||||
}
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
TlgActorSlot slot[2];
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
std::memcpy(&slot[i], const_cast<const TlgActorSlot*>(&tlg_actor_slots[i]), sizeof(slot[i]));
|
||||
if (slot[i].seq != seen[i].seq) {
|
||||
seen[i] = {slot[i].seq, now};
|
||||
}
|
||||
}
|
||||
const auto recent = [&](int i, int seconds) {
|
||||
return seen[i].seq != 0 && now - seen[i].at < std::chrono::seconds(seconds);
|
||||
};
|
||||
const ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
ImDrawList* draw = ImGui::GetForegroundDrawList();
|
||||
const float scale = base_scale;
|
||||
if (s.enemy_health_bar && recent(1, 4) && TlgActors::Valid(slot[1].health, slot[1].max_health)) {
|
||||
// Minimal: a thin green bar centered on the screen's top edge; it shrinks toward the center
|
||||
// so it stays symmetric, with the health in numbers below.
|
||||
const ImVec2 display = ImGui::GetIO().DisplaySize;
|
||||
const float width = 260.0f * scale, height = 5.0f * scale, center = display.x * 0.5f;
|
||||
const float top = 36.0f * scale;
|
||||
const float fraction = TlgActors::Fraction(slot[1].health, slot[1].max_health);
|
||||
draw->AddRectFilled(ImVec2(center - width * 0.5f - scale, top - scale),
|
||||
ImVec2(center + width * 0.5f + scale, top + height + scale),
|
||||
IM_COL32(0, 0, 0, 140), 2 * scale);
|
||||
const float half = width * 0.5f * fraction;
|
||||
draw->AddRectFilled(ImVec2(center - half, top), ImVec2(center + half, top + height),
|
||||
IM_COL32(70, 190, 80, 220), 2 * scale);
|
||||
char text[48];
|
||||
std::snprintf(text, sizeof(text), "%.0f / %.0f", std::ceil(slot[1].health), slot[1].max_health);
|
||||
const float font = ImGui::GetFontSize() * 0.9f;
|
||||
const ImVec2 size = ImGui::GetFont()->CalcTextSizeA(font, FLT_MAX, 0.0f, text);
|
||||
const ImVec2 at{center - size.x * 0.5f, top + height + 4.0f * scale};
|
||||
draw->AddText(ImGui::GetFont(), font, ImVec2(at.x + scale, at.y + scale), IM_COL32(0, 0, 0, 160), text);
|
||||
draw->AddText(ImGui::GetFont(), font, at, IM_COL32(225, 235, 220, 220), text);
|
||||
}
|
||||
}
|
||||
|
||||
// The game's text dialog: what is typed, and how to finish (keyboard or controller).
|
||||
void TextEntryBox() {
|
||||
const ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
@@ -748,7 +732,9 @@ void TextEntryBox() {
|
||||
ImGui::SetWindowFontScale(1.0f);
|
||||
ImGui::Separator();
|
||||
ImGui::TextDisabled("Keyboard: type, Backspace to erase, Enter = OK, Esc = cancel");
|
||||
ImGui::TextDisabled("Controller: Cross (A) = OK, Circle (B) = cancel");
|
||||
ImGui::TextDisabled("%s", menu_circle_confirm()
|
||||
? "Controller: Circle (B) = OK, Cross (A) = cancel"
|
||||
: "Controller: Cross (A) = OK, Circle (B) = cancel");
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
@@ -803,8 +789,9 @@ struct SavePainter {
|
||||
void Line(float x0, float y0, float x1, float y1, u32 rgb, float thickness, float alpha = 1.0f) const {
|
||||
dl->AddLine(P(x0, y0), P(x1, y1), SaveColor(rgb, alpha), thickness * k);
|
||||
}
|
||||
// A controller button: 0 Cross (confirm), 1 Circle (back), centred at (x, y).
|
||||
// Semantic action: 0 confirm, 1 back; choose its physical glyph by menu policy.
|
||||
void Button(float x, float y, int which) const {
|
||||
which ^= menu_circle_confirm();
|
||||
const ImVec2 c = P(x, y);
|
||||
dl->AddCircleFilled(c, 11.0f * k, SaveColor(kSaveThumb));
|
||||
dl->AddCircle(c, 11.0f * k, SaveColor(kSaveKnob), 0, 1.0f * k);
|
||||
@@ -1313,6 +1300,7 @@ void Init(const Vulkan::Instance& instance, vk::Format format, u32 image_count)
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
io.IniFilename = nullptr; // window positions are not kept
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard | ImGuiConfigFlags_NavEnableGamepad;
|
||||
io.ConfigNavSwapGamepadButtons = menu_circle_confirm();
|
||||
io.BackendFlags |= ImGuiBackendFlags_HasGamepad;
|
||||
io.BackendPlatformName = "tlg";
|
||||
|
||||
@@ -1447,7 +1435,8 @@ bool HandleSaveEvent(const SDL_Event& event) {
|
||||
move = button == SDL_GAMEPAD_BUTTON_DPAD_UP || button == SDL_GAMEPAD_BUTTON_DPAD_LEFT ? -1
|
||||
: button == SDL_GAMEPAD_BUTTON_DPAD_DOWN || button == SDL_GAMEPAD_BUTTON_DPAD_RIGHT ? 1
|
||||
: 0;
|
||||
finish = button == SDL_GAMEPAD_BUTTON_SOUTH ? 1 : button == SDL_GAMEPAD_BUTTON_EAST ? 2 : 0;
|
||||
finish = menu_button_result(button == SDL_GAMEPAD_BUTTON_SOUTH,
|
||||
button == SDL_GAMEPAD_BUTTON_EAST);
|
||||
break;
|
||||
}
|
||||
case SDL_EVENT_MOUSE_MOTION: {
|
||||
@@ -1528,6 +1517,16 @@ bool HandleEvent(const SDL_Event& event) {
|
||||
Vulkan::FrameCapture::Request();
|
||||
return true;
|
||||
}
|
||||
// tlg: F8 switches between the anti-aliasing choice and the game's own SMAA (saved; the
|
||||
// label shows briefly).
|
||||
if (down && !event.key.repeat && event.key.key == SDLK_F8) {
|
||||
auto& settings = TlgSettings::Get();
|
||||
settings.dlaa = !settings.dlaa;
|
||||
aa_label_until = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
std::printf("AA: %s (F8)\n", settings.dlaa ? TlgSettings::AaModeName(TlgSettings::AaModeOf()) : "game SMAA");
|
||||
TlgSettings::Save();
|
||||
return true;
|
||||
}
|
||||
// tlg: F10 shows or hides the performance overlay (FPS counter) at any time.
|
||||
if (down && !event.key.repeat && event.key.key == SDLK_F10) {
|
||||
auto& settings = TlgSettings::Get();
|
||||
@@ -1659,14 +1658,20 @@ static bool NoticeActive() {
|
||||
return std::chrono::steady_clock::now() < notice_until;
|
||||
}
|
||||
|
||||
/// tlg: the AA label (top right), for a moment after F8.
|
||||
bool AaLabelOn() {
|
||||
const auto& s = TlgSettings::Get();
|
||||
return s.upscaler == TlgSettings::UpscalerDlss &&
|
||||
std::chrono::steady_clock::now() < aa_label_until;
|
||||
}
|
||||
|
||||
bool Visible() {
|
||||
const auto& s = TlgSettings::Get();
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
return initialized && (loading || background_total || menu_open || text_entry_active || choice_active ||
|
||||
message_active || progress_on || now < progress_until || now < toast_until ||
|
||||
message_active || progress_on || now < progress_until || now < toast_until || AaLabelOn() ||
|
||||
NoticeActive() ||
|
||||
choice_retiring || s.show_fps ||
|
||||
(s.actor_watch_supported && s.enemy_health_bar));
|
||||
choice_retiring || s.show_fps);
|
||||
}
|
||||
|
||||
bool CapturesInput() {
|
||||
@@ -1813,9 +1818,6 @@ void Render(vk::CommandBuffer cmdbuf, vk::ImageView view, vk::Extent2D extent) {
|
||||
if (TlgSettings::Get().show_fps && !menu_open) {
|
||||
FpsCounter();
|
||||
}
|
||||
if (!loading && !menu_open) {
|
||||
HealthDisplay();
|
||||
}
|
||||
if (background_total && !loading && !menu_open) {
|
||||
BackgroundLoading();
|
||||
}
|
||||
@@ -1828,6 +1830,20 @@ void Render(vk::CommandBuffer cmdbuf, vk::ImageView view, vk::Extent2D extent) {
|
||||
if (NoticeActive() && !menu_open) {
|
||||
NoticeBox();
|
||||
}
|
||||
if (AaLabelOn() && !menu_open) {
|
||||
const auto& s = TlgSettings::Get();
|
||||
const int aa_mode = TlgSettings::AaModeOf();
|
||||
const int mode = s.jitter && TlgSettings::AaModeSupported(aa_mode) ? aa_mode : TlgSettings::AaOff;
|
||||
const ImVec2 size = ImGui::GetIO().DisplaySize;
|
||||
ImGui::SetNextWindowPos({size.x - 8.0f, 8.0f}, ImGuiCond_Always, {1.0f, 0.0f});
|
||||
ImGui::SetNextWindowBgAlpha(0.6f);
|
||||
ImGui::Begin("##aa_label", nullptr,
|
||||
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize |
|
||||
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoSavedSettings |
|
||||
ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav);
|
||||
ImGui::Text("AA: %s (F8)", mode == TlgSettings::AaOff ? "game SMAA" : TlgSettings::AaModeName(mode));
|
||||
ImGui::End();
|
||||
}
|
||||
ImGui::Render();
|
||||
|
||||
const vk::RenderingAttachmentInfo attachment{
|
||||
|
||||
+138
-47
@@ -1,14 +1,16 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#include "tlg_settings.h"
|
||||
#include "tlg_orbs.h"
|
||||
#include "tlg_graphics.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace TlgSettings {
|
||||
|
||||
@@ -19,10 +21,37 @@ const char* Path() {
|
||||
return env && env[0] ? env : "tlg.ini";
|
||||
}
|
||||
|
||||
using Effect = std::atomic<int> Values::*;
|
||||
constexpr std::pair<const char*, Effect> EffectKeys[] = {
|
||||
{"bloom", &Values::bloom}, {"dof", &Values::dof}, {"motion_blur", &Values::motion_blur},
|
||||
{"chromatic_add", &Values::chromatic}, {"vignette_add", &Values::vignette}, {"ssao", &Values::ssao},
|
||||
{"ssr", &Values::ssr},
|
||||
{"game_sharpen", &Values::game_sharpen}, {"shadow_distance", &Values::shadow_distance}, {"lod", &Values::lod}};
|
||||
|
||||
std::atomic<int>* EffectSetting(Values& v, const std::string& key) {
|
||||
for (const auto& [name, member] : EffectKeys)
|
||||
if (key == name) return &(v.*member);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
float Clamp(float v, float lo, float hi) {
|
||||
return std::clamp(v, lo, hi);
|
||||
}
|
||||
|
||||
// tlg: tlg.ini has the anti-aliasing row's upscaler (aa_row=1). Before it the launcher chose DLSS
|
||||
// for DLAA, and files kept the FSR 3.1 default.
|
||||
bool aa_row_file = false;
|
||||
// ConfigureDlssSupport ran: a reload without DLSS keeps the FSR 3.1 fallback.
|
||||
bool dlss_checked = false;
|
||||
// dlss_supported in tlg.ini: the game's last DLSS check, for the launcher (-1: none).
|
||||
int dlss_supported_file = -1;
|
||||
// The upscaler tlg.ini selected before a fallback to FSR 3.1 (-1: none): Save writes it back.
|
||||
int selected_upscaler = -1;
|
||||
|
||||
void DlssFallback(Values& v) {
|
||||
if (v.upscaler == UpscalerDlss && dlss_checked && !v.dlss_supported) FallBackToFsr3();
|
||||
}
|
||||
|
||||
void Set(Values& v, const std::string& key, const std::string& value) {
|
||||
const float f = float(std::atof(value.c_str()));
|
||||
const int i = std::atoi(value.c_str());
|
||||
@@ -55,13 +84,11 @@ void Set(Values& v, const std::string& key, const std::string& value) {
|
||||
v.menu_tab = i >= 0 && i <= 3 ? i : 0;
|
||||
return;
|
||||
}
|
||||
const char* cheat_keys[] = {"cheat_health", "cheat_magic", "cheat_item", "cheat_rage", "cheat_orbs",
|
||||
"cheat_very_hard", "cheat_double_jumps"};
|
||||
for (unsigned cheat = 0; cheat < TLG_CHEAT_COUNT; ++cheat) {
|
||||
if (key == cheat_keys[cheat]) {
|
||||
v.cheats[cheat] = i != 0;
|
||||
return;
|
||||
}
|
||||
if (key == "aa_row") {
|
||||
aa_row_file = i != 0;
|
||||
} else if (key == "dlss_supported") {
|
||||
dlss_supported_file = i != 0;
|
||||
return;
|
||||
}
|
||||
if (key == "key_map") {
|
||||
v.key_map = value;
|
||||
@@ -91,6 +118,10 @@ void Set(Values& v, const std::string& key, const std::string& value) {
|
||||
v.reactive_threshold = Clamp(f, 0.0f, 1.0f);
|
||||
} else if (key == "reactive_max") {
|
||||
v.reactive_max = Clamp(f, 0.0f, 1.0f);
|
||||
} else if (key == "fsr_velocity") {
|
||||
v.fsr_velocity = Clamp(f, 0.0f, 1.0f);
|
||||
} else if (key == "dlss_preset") {
|
||||
v.dlss_preset = std::clamp(i, 0, 15);
|
||||
} else if (key == "debug_view") {
|
||||
v.debug_view = std::clamp(i, 0, DebugViewCount - 1);
|
||||
} else if (key == "show_fps") {
|
||||
@@ -109,12 +140,22 @@ void Set(Values& v, const std::string& key, const std::string& value) {
|
||||
v.rcas = i != 0;
|
||||
} else if (key == "rcas_strength") {
|
||||
v.rcas_strength = TlgGraphics::ParseRcasStrength(value.empty() ? -1 : i);
|
||||
} else if (key == "dlaa") {
|
||||
v.dlaa = i != 0;
|
||||
} else if (key == "level_select") {
|
||||
v.level_select = i != 0;
|
||||
} else if (key == "bridge_fix") {
|
||||
v.bridge_fix = i != 0;
|
||||
} else if (key == "shadow_scale") {
|
||||
v.shadow_scale = i >= 4 ? 4 : i >= 2 ? 2 : 1;
|
||||
} else if (std::atomic<int>* effect = EffectSetting(v, key)) {
|
||||
*effect = std::clamp(i, 0, 400);
|
||||
} else if (key == "smaa") {
|
||||
v.smaa = i != 0;
|
||||
} else if (key == "cas") {
|
||||
v.cas = i != 0;
|
||||
} else if (key == "cas_strength") {
|
||||
v.cas_strength = TlgGraphics::ParseRcasStrength(value.empty() ? -1 : i, 50);
|
||||
v.cas_strength = TlgGraphics::ParseRcasStrength(value.empty() ? -1 : i, 100);
|
||||
} else if (key == "color_adjust") {
|
||||
v.color_adjust = i != 0;
|
||||
} else if (key == "color_brightness") {
|
||||
@@ -131,26 +172,14 @@ void Set(Values& v, const std::string& key, const std::string& value) {
|
||||
v.render_resolution = TlgGraphics::ParseResolution(value);
|
||||
} else if (key == "engine_fps") {
|
||||
v.engine_fps = TlgGraphics::ParseEngineFps(i);
|
||||
} else if (key == "hdr") {
|
||||
v.hdr = i != 0;
|
||||
} else if (key == "deferred_readback") {
|
||||
v.deferred_readback = i != 0;
|
||||
} else if (key == "stale_readback") {
|
||||
v.stale_readback = i != 0;
|
||||
} else if (key == "restart_unconfirmed") {
|
||||
v.restart_unconfirmed = i != 0;
|
||||
} else if (key == "enemy_health_bar") {
|
||||
v.enemy_health_bar = i != 0;
|
||||
} else if (key == "red_orb_multiplier") {
|
||||
v.red_orb_multiplier = TlgOrbs::ClampMultiplier(f);
|
||||
} else if (key == "damage_dealt") {
|
||||
v.damage_dealt = TlgOrbs::ClampMultiplier(f);
|
||||
} else if (key == "damage_taken") {
|
||||
v.damage_taken = TlgOrbs::ClampMultiplier(f);
|
||||
} else if (key == "green_orb_multiplier") {
|
||||
v.green_orb_multiplier = TlgOrbs::ClampMultiplier(f);
|
||||
} else if (key == "blue_orb_multiplier") {
|
||||
v.blue_orb_multiplier = TlgOrbs::ClampMultiplier(f);
|
||||
} else if (key == "gold_orb_multiplier") {
|
||||
v.gold_orb_multiplier = TlgOrbs::ClampMultiplier(f);
|
||||
} else if (key == "fsr4_auto_exposure") {
|
||||
v.fsr4_auto_exposure = i != 0;
|
||||
} else if (key == "fsr4_invert_jitter") {
|
||||
@@ -209,6 +238,8 @@ bool ReadFile(Values& v) {
|
||||
void ReloadIfRequested() {
|
||||
static const char* trigger = std::getenv("TLG_SETTINGS_RELOAD");
|
||||
if (trigger && trigger[0] && std::remove(trigger) == 0 && ReadFile(Get())) {
|
||||
selected_upscaler = -1;
|
||||
DlssFallback(Get());
|
||||
std::printf("Settings: reloaded %s\n", Path());
|
||||
}
|
||||
}
|
||||
@@ -218,6 +249,13 @@ void Load() {
|
||||
if (ReadFile(v)) {
|
||||
std::printf("Settings: %s\n", Path());
|
||||
}
|
||||
// tlg: the anti-aliasing row runs DLAA or FSR 3.1 at native size (AaMode); files from before
|
||||
// it, and other upscalers, load as DLAA.
|
||||
if (!aa_row_file || (v.upscaler != UpscalerTaa && v.upscaler != UpscalerFsr3)) {
|
||||
v.upscaler = UpscalerDlss;
|
||||
}
|
||||
DlssFallback(v);
|
||||
if (v.upscaler != UpscalerTaa) v.preset = NativeAA;
|
||||
// Environment overrides (scripts, A/B tests).
|
||||
if (const char* env = std::getenv("TLG_UPSCALER")) {
|
||||
v.upscaler = UpscalerOff;
|
||||
@@ -233,12 +271,15 @@ void Load() {
|
||||
{"TLG_ASYNC_SHADERS", "async_shaders"}, {"TLG_DEFERRED_READBACK", "deferred_readback"},
|
||||
{"TLG_STALE_READBACK", "stale_readback"}, {"TLG_FSR1", "fsr1"},
|
||||
{"TLG_RCAS", "rcas"}, {"TLG_FRAMES_AHEAD", "frames_queued"},
|
||||
{"TLG_SHOW_FPS", "show_fps"},
|
||||
{"TLG_SHOW_FPS", "show_fps"}, {"TLG_FSR_VELOCITY", "fsr_velocity"},
|
||||
{"TLG_DLSS_PRESET", "dlss_preset"},
|
||||
{"TLG_MOUSE", "mouse_enabled"}, {"TLG_MOUSE_SENS", "mouse_sensitivity"},
|
||||
{"TLG_MOUSE_INVERT_Y", "mouse_invert_y"}, {"TLG_MOUSE_SMOOTHING", "mouse_smoothing"},
|
||||
{"TLG_MOUSE_ACCEL", "mouse_accel"}, {"TLG_MOUSE_DEADZONE", "mouse_deadzone"},
|
||||
{"TLG_SMAA", "smaa"},
|
||||
{"TLG_SMAA", "smaa"}, {"TLG_DLAA", "dlaa"},
|
||||
{"TLG_LEVEL_SELECT", "level_select"}, {"TLG_BRIDGE_FIX", "bridge_fix"},
|
||||
{"TLG_CAS", "cas"}, {"TLG_COLOR_ADJUST", "color_adjust"},
|
||||
{"TLG_HDR", "hdr"},
|
||||
};
|
||||
for (const auto& [env, key] : env_keys) {
|
||||
if (const char* value = std::getenv(env); value && value[0]) {
|
||||
@@ -257,6 +298,7 @@ void Load() {
|
||||
: v.engine_fps.load();
|
||||
v.startup_deferred_readback = v.deferred_readback;
|
||||
v.startup_stale_readback = v.stale_readback;
|
||||
v.startup_hdr = v.hdr;
|
||||
}
|
||||
|
||||
bool GraphicsNeedRestart() {
|
||||
@@ -264,7 +306,7 @@ bool GraphicsNeedRestart() {
|
||||
return v.render_resolution != v.startup_render_resolution ||
|
||||
v.engine_fps != v.startup_engine_fps ||
|
||||
v.deferred_readback != v.startup_deferred_readback ||
|
||||
v.stale_readback != v.startup_stale_readback;
|
||||
v.stale_readback != v.startup_stale_readback || v.hdr != v.startup_hdr;
|
||||
}
|
||||
|
||||
void ConfigureUpscalerSupport(bool fsr4, bool fsr411) {
|
||||
@@ -277,29 +319,54 @@ void ConfigureUpscalerSupport(bool fsr4, bool fsr411) {
|
||||
v.fsr4_problem = "GPU does not support the selected FSR 4 shaders; using FSR 3.1";
|
||||
std::printf("Upscaler: %s unsupported on this GPU; falling back to FSR 3.1 before the first frame\n",
|
||||
UpscalerName(requested));
|
||||
v.upscaler = UpscalerFsr3;
|
||||
FallBackToFsr3();
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigureDlssSupport(bool available, const char* problem) {
|
||||
auto& v = Get();
|
||||
v.dlss_supported = available;
|
||||
dlss_checked = true;
|
||||
if (dlss_supported_file != int(available)) {
|
||||
// Only this line changes: the launcher greys DLAA out from it.
|
||||
std::vector<std::string> lines;
|
||||
if (std::ifstream in{Path()}) {
|
||||
for (std::string line; std::getline(in, line);)
|
||||
if (!line.starts_with("dlss_supported=")) lines.push_back(line);
|
||||
}
|
||||
if (!lines.empty()) {
|
||||
if (std::ofstream out{Path()}) {
|
||||
for (const auto& line : lines) out << line << '\n';
|
||||
out << "dlss_supported=" << int(available) << '\n';
|
||||
dlss_supported_file = available;
|
||||
}
|
||||
}
|
||||
}
|
||||
static std::string kept;
|
||||
kept = problem ? problem : "";
|
||||
v.dlss_problem = available || kept.empty() ? nullptr : kept.c_str();
|
||||
if (v.upscaler == UpscalerDlss && !available) {
|
||||
std::printf("Upscaler: DLSS unavailable (%s); falling back to FSR 3.1\n",
|
||||
kept.empty() ? "tlg_dlss.dll or nvngx_dlss.dll missing" : kept.c_str());
|
||||
v.upscaler = UpscalerFsr3;
|
||||
FallBackToFsr3();
|
||||
}
|
||||
}
|
||||
|
||||
void FallBackToFsr3() {
|
||||
auto& v = Get();
|
||||
if (v.upscaler != UpscalerFsr3) selected_upscaler = v.upscaler;
|
||||
v.upscaler = UpscalerFsr3;
|
||||
}
|
||||
|
||||
bool FixedRenderSession() {
|
||||
const char* size = std::getenv("TLG_RENDER_RES");
|
||||
return size && size[0];
|
||||
}
|
||||
|
||||
int RenderPreset() {
|
||||
// The resolution patch (TLG_RESOLUTION_PATCH, run.py) renders natively: no reduced presets.
|
||||
static const bool resolution_patch = std::getenv("TLG_RESOLUTION_PATCH") != nullptr;
|
||||
if (resolution_patch) return NativeAA;
|
||||
const auto& v = Get();
|
||||
return FixedRenderSession() ? v.startup_preset :
|
||||
v.upscaler == UpscalerTaa ? NativeAA : v.preset.load();
|
||||
@@ -316,6 +383,9 @@ bool ResolutionNeedsRestart() {
|
||||
|
||||
void Save() {
|
||||
const auto& v = Get();
|
||||
// A fallback still running FSR 3.1 keeps the selection in the file.
|
||||
const int upscaler = selected_upscaler >= 0 && v.upscaler == UpscalerFsr3 ? selected_upscaler
|
||||
: v.upscaler.load();
|
||||
FILE* file = std::fopen(Path(), "w");
|
||||
if (!file) {
|
||||
std::printf("Settings: cannot write %s\n", Path());
|
||||
@@ -323,28 +393,18 @@ void Save() {
|
||||
}
|
||||
std::fprintf(file,
|
||||
"# tlg settings (in-game menu: Insert / R3+L2)\n"
|
||||
"upscaler=%s\npreset=%d\nsharpen=%d\nsharpness=%.2f\njitter=%d\n"
|
||||
"aa_row=1\nupscaler=%s\npreset=%d\nsharpen=%d\nsharpness=%.2f\njitter=%d\n"
|
||||
"reactive=%d\nobject_motion=%d\nreactive_scale=%.2f\nreactive_threshold=%.2f\nreactive_max=%.2f\n"
|
||||
"debug_view=%d\nshow_fps=%d\nfsr4_auto_exposure=%d\nfsr4_invert_jitter=%d\n",
|
||||
UpscalerName(v.upscaler), v.preset.load(), int(v.sharpen.load()),
|
||||
UpscalerName(upscaler), v.preset.load(), int(v.sharpen.load()),
|
||||
v.sharpness.load(), int(v.jitter.load()), int(v.reactive.load()),
|
||||
int(v.object_motion.load()),
|
||||
v.reactive_scale.load(), v.reactive_threshold.load(), v.reactive_max.load(),
|
||||
v.debug_view.load(), int(v.show_fps.load()),
|
||||
int(v.fsr4_auto_exposure.load()), int(v.fsr4_invert_jitter.load()));
|
||||
std::fprintf(file, "fsr_velocity=%.2f\n", v.fsr_velocity.load());
|
||||
std::fprintf(file, "dlss_preset=%d\n", v.dlss_preset.load());
|
||||
std::fprintf(file, "output_res=%dx%d\n", OutputWidths[v.output_res], OutputHeights[v.output_res]);
|
||||
std::fprintf(file, "red_orb_multiplier=%.3f\n", v.red_orb_multiplier.load());
|
||||
std::fprintf(file, "damage_dealt=%.3f\n", v.damage_dealt.load());
|
||||
std::fprintf(file, "damage_taken=%.3f\n", v.damage_taken.load());
|
||||
std::fprintf(file, "green_orb_multiplier=%.3f\n", v.green_orb_multiplier.load());
|
||||
std::fprintf(file, "blue_orb_multiplier=%.3f\n", v.blue_orb_multiplier.load());
|
||||
std::fprintf(file, "gold_orb_multiplier=%.3f\n", v.gold_orb_multiplier.load());
|
||||
std::fprintf(file, "cheat_health=%d\ncheat_magic=%d\ncheat_item=%d\ncheat_rage=%d\ncheat_orbs=%d\n"
|
||||
"cheat_very_hard=%d\ncheat_double_jumps=%d\n",
|
||||
int(v.cheats[0].load()), int(v.cheats[1].load()), int(v.cheats[2].load()),
|
||||
int(v.cheats[3].load()), int(v.cheats[4].load()),
|
||||
int(v.cheats[5].load()), int(v.cheats[6].load()));
|
||||
std::fprintf(file, "enemy_health_bar=%d\n", int(v.enemy_health_bar.load()));
|
||||
std::fprintf(file,
|
||||
"overlay_fps=%d\noverlay_frametime=%d\noverlay_upscaler=%d\noverlay_gpu=%d\n"
|
||||
"overlay_cpu=%d\noverlay_ram=%d\noverlay_vram=%d\noverlay_scale=%d\noverlay_corner=%d\n"
|
||||
@@ -356,13 +416,13 @@ void Save() {
|
||||
std::fprintf(file,
|
||||
"display_mode=%s\nvsync=%d\nfps_limit=%d\nasync_shaders=%d\n"
|
||||
"fsr1=%d\nrcas=%d\nrcas_strength=%d\nframes_queued=%d\n"
|
||||
"render_resolution=%s\nengine_fps=%d\ndeferred_readback=%d\nstale_readback=%d\n"
|
||||
"render_resolution=%s\nengine_fps=%d\nhdr=%d\ndeferred_readback=%d\nstale_readback=%d\n"
|
||||
"restart_unconfirmed=%d\n",
|
||||
TlgGraphics::DisplayModeKeys[std::clamp(v.display_mode.load(), 0, 2)].data(),
|
||||
int(v.vsync.load()), v.fps_limit.load(), int(v.async_shaders.load()),
|
||||
int(v.fsr1.load()), int(v.rcas.load()), v.rcas_strength.load(), v.frames_queued.load(),
|
||||
TlgGraphics::Resolutions[std::clamp(v.render_resolution.load(), 0, 5)].data(),
|
||||
v.engine_fps.load(), int(v.deferred_readback.load()), int(v.stale_readback.load()),
|
||||
v.engine_fps.load(), int(v.hdr.load()), int(v.deferred_readback.load()), int(v.stale_readback.load()),
|
||||
int(v.restart_unconfirmed.load()));
|
||||
std::fprintf(file,
|
||||
"mouse_enabled=%d\nmouse_sensitivity=%.2f\nmouse_invert_y=%d\nmouse_smoothing=%.2f\n"
|
||||
@@ -370,11 +430,17 @@ void Save() {
|
||||
int(v.mouse_enabled.load()), v.mouse_sensitivity.load(), int(v.mouse_invert_y.load()),
|
||||
v.mouse_smoothing.load(), v.mouse_accel.load(), v.mouse_deadzone.load(), v.key_map.c_str());
|
||||
std::fprintf(file,
|
||||
"smaa=%d\ncas=%d\ncas_strength=%d\ncolor_adjust=%d\ncolor_brightness=%.2f\n"
|
||||
"color_contrast=%.2f\ncolor_saturation=%.2f\ncolor_gamma=%.2f\n",
|
||||
int(v.smaa.load()), int(v.cas.load()), v.cas_strength.load(),
|
||||
"dlaa=%d\nshadow_scale=%d\nsmaa=%d\ncas=%d\ncas_strength=%d\ncolor_adjust=%d\n"
|
||||
"color_brightness=%.2f\ncolor_contrast=%.2f\ncolor_saturation=%.2f\ncolor_gamma=%.2f\n",
|
||||
int(v.dlaa.load()),v.shadow_scale.load(), int(v.smaa.load()), int(v.cas.load()), v.cas_strength.load(),
|
||||
int(v.color_adjust.load()), v.brightness.load(), v.contrast.load(),
|
||||
v.saturation.load(), v.color_gamma.load());
|
||||
std::fprintf(file, "level_select=%d\nbridge_fix=%d\n", int(v.level_select.load()),
|
||||
int(v.bridge_fix.load()));
|
||||
for (const auto& [name, member] : EffectKeys)
|
||||
std::fprintf(file, "%s=%d\n", name, (v.*member).load());
|
||||
if (dlss_checked || dlss_supported_file >= 0)
|
||||
std::fprintf(file, "dlss_supported=%d\n", dlss_checked ? int(v.dlss_supported.load()) : dlss_supported_file);
|
||||
// Read by run.sh at start.
|
||||
std::fprintf(file, "live_resolution=%s\n", v.live_resolution < 0 ? "auto"
|
||||
: v.live_resolution ? "1" : "0");
|
||||
@@ -397,4 +463,29 @@ const char* UpscalerName(int upscaler) {
|
||||
return names[std::clamp(upscaler, 0, UpscalerCount - 1)];
|
||||
}
|
||||
|
||||
int AaModeOf() {
|
||||
const auto& v = Get();
|
||||
if (!v.dlaa || v.upscaler == UpscalerTaa) return AaOff;
|
||||
return v.upscaler == UpscalerFsr3 ? AaFsr3Native : AaDlaa;
|
||||
}
|
||||
|
||||
void SetAaMode(int mode) {
|
||||
auto& v = Get();
|
||||
mode = std::clamp(mode, 0, AaModeCount - 1);
|
||||
v.dlaa = mode != AaOff;
|
||||
if (mode == AaOff) return;
|
||||
v.upscaler = mode == AaDlaa ? UpscalerDlss : UpscalerFsr3;
|
||||
selected_upscaler = -1;
|
||||
v.preset = NativeAA;
|
||||
}
|
||||
|
||||
bool AaModeSupported(int mode) {
|
||||
return mode != AaDlaa || Get().dlss_supported;
|
||||
}
|
||||
|
||||
const char* AaModeName(int mode) {
|
||||
static constexpr const char* names[AaModeCount] = {"Off", "DLAA", "FSR 3.1 Native AA"};
|
||||
return names[std::clamp(mode, 0, AaModeCount - 1)];
|
||||
}
|
||||
|
||||
} // namespace TlgSettings
|
||||
@@ -6,8 +6,6 @@
|
||||
#pragma once
|
||||
#include "tlg_perf_stats.h"
|
||||
|
||||
#include "../../src/cheat_hook.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
@@ -40,39 +38,46 @@ struct Values {
|
||||
std::atomic<float> reactive_scale{1.0f};
|
||||
std::atomic<float> reactive_threshold{0.2f};
|
||||
std::atomic<float> reactive_max{0.9f};
|
||||
/// FSR 3 fVelocityFactor 0..1: how much motion cuts the history (1 = AMD's default, the
|
||||
/// history drops to 15% from half a 4K pixel per frame; 0 keeps it while moving).
|
||||
std::atomic<float> fsr_velocity{1.0f};
|
||||
/// NVSDK_NGX_DLSS_Hint_Render_Preset for every DLSS mode (0 = the driver's default,
|
||||
/// 1..15 = presets A..O, e.g. 11 = K).
|
||||
std::atomic<int> dlss_preset{0};
|
||||
std::atomic<int> debug_view{DebugNone};
|
||||
std::atomic<bool> show_fps{false};
|
||||
/// DLAA (NVIDIA DLSS at native size on the game's motion vectors, vk_temporal_upscaler);
|
||||
/// off: the game's own SMAA only. F8 switches it (a label shows which one for a moment).
|
||||
std::atomic<bool> dlaa{true};
|
||||
/// Debug (launcher, at start; tlg_lua.cpp): the game's level select and QA menu, and the
|
||||
/// collapsing bridge fix (skips the slow motion that can freeze the game in the pit).
|
||||
std::atomic<bool> level_select{false}, bridge_fix{false};
|
||||
/// Enhancements (menu, live): shadow map resolution as a multiple of the game's 2048
|
||||
/// (1, 2 or 4; vk_scene_resolution SetShadowScale). 8192 looked barely different, ~8 FPS.
|
||||
std::atomic<int> shadow_scale{1};
|
||||
/// Game effects (menu, live): percent of the game's own value, written into its
|
||||
/// post-process and camera objects through Lua (tlg_lua.cpp). 100 = as shipped.
|
||||
std::atomic<int> bloom{100}, dof{100}, motion_blur{100}, chromatic{0}, vignette{0},
|
||||
ssao{100}, ssr{100}, game_sharpen{100}, shadow_distance{100}, lod{100};
|
||||
/// Performance overlay (tlg_perf_stats.h): items shown, font size and background opacity
|
||||
/// in percent, corner (0 top left, 1 top right, 2 bottom left, 3 bottom right), layout
|
||||
/// (0 one line, 1 one item per line). menu_tab: the menu's last tab.
|
||||
std::atomic<unsigned> overlay_items{TlgPerfStats::DefaultItems};
|
||||
std::atomic<int> overlay_scale{100}, overlay_corner{1}, overlay_opacity{50}, overlay_layout{0};
|
||||
std::atomic<int> menu_tab{0};
|
||||
std::atomic<float> red_orb_multiplier{1.0f};
|
||||
std::atomic<bool> red_orbs_supported{false};
|
||||
/// Damage and orb pickup multipliers (src/damage_hook.h, src/orb_hook.h), 0.1x to 100x.
|
||||
/// The game's code reads these floats directly.
|
||||
std::atomic<float> damage_dealt{1.0f}, damage_taken{1.0f};
|
||||
std::atomic<float> green_orb_multiplier{1.0f}, blue_orb_multiplier{1.0f}, gold_orb_multiplier{1.0f};
|
||||
std::atomic<bool> damage_supported{false}, orb_pickup_supported{false};
|
||||
std::atomic<bool> cheats[TLG_CHEAT_COUNT]{};
|
||||
std::atomic<uint32_t> cheats_supported{0};
|
||||
/// Damage hook (src/actor_hook.h): enemy health bar, off by default.
|
||||
std::atomic<bool> enemy_health_bar{false};
|
||||
std::atomic<bool> actor_watch_supported{false};
|
||||
// Graphics (tlg_graphics.h). Live: applied on the next frame.
|
||||
std::atomic<int> display_mode{0};
|
||||
std::atomic<bool> vsync{true};
|
||||
std::atomic<int> fps_limit{0};
|
||||
std::atomic<bool> async_shaders{true};
|
||||
std::atomic<bool> async_shaders{false};
|
||||
std::atomic<bool> fsr1{false};
|
||||
std::atomic<bool> rcas{true};
|
||||
std::atomic<int> rcas_strength{75};
|
||||
/// Filters on the final image (postfx_pass.h, post_process.frag), all off by default:
|
||||
/// SMAA 1x, AMD CAS (replaces RCAS while on) and the color adjustments.
|
||||
std::atomic<bool> smaa{false};
|
||||
std::atomic<bool> cas{false};
|
||||
std::atomic<int> cas_strength{50};
|
||||
std::atomic<bool> cas{true};
|
||||
std::atomic<int> cas_strength{100};
|
||||
std::atomic<bool> color_adjust{false};
|
||||
std::atomic<float> brightness{0.0f}, contrast{1.0f}, saturation{1.0f}, color_gamma{1.0f};
|
||||
/// Set by the presenter: sharpening ran on the last frame (it needs FSR 1 when the game's
|
||||
@@ -86,6 +91,8 @@ struct Values {
|
||||
std::atomic<int> engine_fps{120};
|
||||
std::atomic<bool> deferred_readback{true};
|
||||
std::atomic<bool> stale_readback{true};
|
||||
/// The game's own HDR10 output (BT.2020 PQ) when Windows HDR is on (TLG_HDR overrides).
|
||||
std::atomic<bool> hdr{true};
|
||||
/// Set before an "Apply and restart"; cleared once the new launch shows the game. run.py
|
||||
/// restores the previous startup options when a launch never cleared it.
|
||||
std::atomic<bool> restart_unconfirmed{false};
|
||||
@@ -93,7 +100,7 @@ struct Values {
|
||||
/// The engine rate this launch really runs at (run.py's TLG_ENGINE_FPS): 60 when the frame
|
||||
/// rate patch does not apply to this game build.
|
||||
std::atomic<int> applied_engine_fps{120};
|
||||
bool startup_deferred_readback = true, startup_stale_readback = true;
|
||||
bool startup_deferred_readback = true, startup_stale_readback = true, startup_hdr = true;
|
||||
// FSR 4 checks (menu): the provider's auto exposure, the jitter sign it is given.
|
||||
std::atomic<bool> fsr4_auto_exposure{true};
|
||||
std::atomic<bool> fsr4_invert_jitter{false};
|
||||
@@ -132,6 +139,8 @@ void Load();
|
||||
void ConfigureUpscalerSupport(bool fsr4, bool fsr411);
|
||||
/// After device creation: DLSS availability; a DLSS setting falls back to FSR 3.1 without it.
|
||||
void ConfigureDlssSupport(bool available, const char* problem);
|
||||
/// The selected upscaler does not work here: runs FSR 3.1, while Save keeps the selection.
|
||||
void FallBackToFsr3();
|
||||
/// Startup-patched scene dimensions cannot change until run.sh prepares a new image.
|
||||
bool FixedRenderSession();
|
||||
int RenderPreset();
|
||||
@@ -149,4 +158,12 @@ float PresetScale(int preset);
|
||||
const char* PresetName(int preset);
|
||||
const char* UpscalerName(int upscaler);
|
||||
|
||||
/// tlg: the anti-aliasing row (menu, launcher): one choice over dlaa + upscaler (native size).
|
||||
enum AaMode : int { AaOff = 0, AaDlaa, AaFsr3Native, AaModeCount };
|
||||
int AaModeOf();
|
||||
/// Sets dlaa and the upscaler for a mode.
|
||||
void SetAaMode(int mode);
|
||||
bool AaModeSupported(int mode);
|
||||
const char* AaModeName(int mode);
|
||||
|
||||
} // namespace TlgSettings
|
||||
@@ -15,6 +15,7 @@ extern "C" __attribute__((returns_twice)) int tlg_setjmp(sigjmp_buf buffer);
|
||||
#endif
|
||||
|
||||
extern "C" std::uint64_t runtime_disabled_optimizations;
|
||||
extern "C" std::uint64_t runtime_disabled_optimizations2;
|
||||
/// Recovery point for speculative guest memory reads on this thread (runtime_memory.c).
|
||||
extern "C" __thread sigjmp_buf* runtime_fault_recover;
|
||||
|
||||
@@ -69,11 +70,26 @@ enum : std::uint64_t {
|
||||
// when nothing moves. Static-camera flicker of railings/window bars p99.9 -45% (2026-10-03).
|
||||
TaaKeepNearerHistory = 1ull << 55,
|
||||
SceneMipBias = 1ull << 57, ///< negative LOD bias of G-buffer samplers at reduced scene sizes
|
||||
// DLAA tests: DLSS without its automatic exposure, the game's SMAA kept under DLAA, and
|
||||
// bits 60-63 a DLSS render preset (0: driver default; recreates the feature on change).
|
||||
DlssAutoExposure = 1ull << 58,
|
||||
DlaaSkipGameSmaa = 1ull << 59,
|
||||
DlaaMotionView = 1ull << 56, ///< debug: the game motion vectors over the DLAA result
|
||||
DlaaMotionFix = 1ull << 30, ///< replace game vectors the hair inherited from behind it
|
||||
JitterDepthWritesOnly = 1ull << 31, ///< test: jitter only draws that write the scene depth
|
||||
// Bits 20-29 are used as raw debug toggles by the camera/object motion and the upscaler.
|
||||
};
|
||||
inline bool Disabled(std::uint64_t bit) {
|
||||
return (__atomic_load_n(&runtime_disabled_optimizations, __ATOMIC_RELAXED) & bit) != 0;
|
||||
}
|
||||
/// The second mask (second number in TLG_TOGGLE_FILE): the first one has no free bits left.
|
||||
enum : std::uint64_t {
|
||||
ShaderCodeBacking = 1, ///< per-draw shader code reads through the backing view
|
||||
ImageHashBacking = 32, ///< texture change hashes read through the backing view
|
||||
};
|
||||
inline bool Disabled2(std::uint64_t bit) {
|
||||
return (__atomic_load_n(&runtime_disabled_optimizations2, __ATOMIC_RELAXED) & bit) != 0;
|
||||
}
|
||||
} // namespace TlgToggle
|
||||
|
||||
namespace TlgStats {
|
||||
|
||||
@@ -215,6 +215,10 @@ static void StartProfileWriter() {
|
||||
}
|
||||
#endif
|
||||
|
||||
bool TlgHdrStartup() {
|
||||
return TlgSettings::Get().startup_hdr;
|
||||
}
|
||||
|
||||
extern "C" int tlggpu_init(const TlgGpuConfig* config) {
|
||||
TlgSettings::Load();
|
||||
#ifdef TLG_PGO_GENERATE
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "common/logging/log.h"
|
||||
#include "sdl_window.h"
|
||||
#include "tlg_overlay.h"
|
||||
#include "../../src/runtime_menu.h"
|
||||
#include "tlg_graphics.h"
|
||||
#include "tlg_settings.h"
|
||||
#include "../tlggpu.h"
|
||||
@@ -333,12 +334,13 @@ bool WindowSDL::PollEvents() {
|
||||
UpdateTextTitle();
|
||||
continue;
|
||||
}
|
||||
// The controller finishes the text dialog too: Cross (A) accepts, Circle (B) cancels.
|
||||
// The controller follows the menu preference; keyboard Enter/Escape stay unchanged.
|
||||
if (text_active && event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN &&
|
||||
(event.gbutton.button == SDL_GAMEPAD_BUTTON_SOUTH ||
|
||||
event.gbutton.button == SDL_GAMEPAD_BUTTON_EAST)) {
|
||||
std::scoped_lock lock{text_mutex};
|
||||
text_state = event.gbutton.button == SDL_GAMEPAD_BUTTON_SOUTH ? 1 : 2;
|
||||
text_state = menu_button_result(event.gbutton.button == SDL_GAMEPAD_BUTTON_SOUTH,
|
||||
event.gbutton.button == SDL_GAMEPAD_BUTTON_EAST);
|
||||
text_active = false;
|
||||
SDL_StopTextInput(window);
|
||||
UpdateTextTitle();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Launcher visual references
|
||||
|
||||
The presentation uses warm parchment, desaturated stone, moss green, soft light and
|
||||
serif titles, inspired by the official environments and Trico's plumage. The initial
|
||||
screen gives the artwork room; settings and the session log have separate pages.
|
||||
English and Brazilian Portuguese share the same layout.
|
||||
|
||||
## Official artwork
|
||||
|
||||
`guardian-hero.png` is a presentation crop of Sony's official screenshot, resized
|
||||
and shaded for text legibility. It is loaded locally, with no network requests or
|
||||
additional Python dependencies. The original artwork remains © Sony Interactive
|
||||
Entertainment; it is not covered by the source code's GPL license.
|
||||
|
||||
- Official game page: https://www.playstation.com/en-us/games/the-last-guardian/
|
||||
- Official gallery: https://store.playstation.com/en-us/product/UP9000-CUSA03627_00-LASTGUARDIANUS00
|
||||
- Hero original: https://image.api.playstation.com/cdn/UP9000/CUSA03627_00/FREE_CONTENTrz6xdLO2VRKfw0Cfax6D/PREVIEW_SCREENSHOT1_118520.jpg
|
||||
- E3 reference: https://blog.playstation.com/2015/06/15/the-last-guardian-coming-to-ps4-in-2016/
|
||||
- Creative direction reference: https://blog.playstation.com/2016/06/21/the-last-guardian-5-storytelling-secrets/
|
||||
|
||||
Only launcher presentation is changed. Game preparation, settings persistence,
|
||||
rendering, input mappings, mods and the game launch path use the existing callbacks.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 908 KiB |
@@ -50,14 +50,56 @@ MENU = [
|
||||
'Deixa a imagem final mais nítida. Precisa do FSR 1 quando a imagem do jogo é menor que a janela.')),
|
||||
_menu('Image', 'rcas_strength', 'int', '75', ('Sharpening strength', 'Intensidade da nitidez'),
|
||||
('How strong the RCAS sharpening is.', 'Quão forte é a nitidez do RCAS.'), lo=0, hi=100, unit='%'),
|
||||
_menu('Image', 'cas', 'bool', '0', ('AMD CAS sharpening', 'Nitidez AMD CAS'),
|
||||
_menu('Image', 'cas', 'bool', '1', ('AMD CAS sharpening', 'Nitidez AMD CAS'),
|
||||
('Contrast-adaptive sharpening at any image size; replaces RCAS while on.',
|
||||
'Nitidez adaptativa ao contraste em qualquer tamanho de imagem; substitui o RCAS enquanto ligada.')),
|
||||
_menu('Image', 'cas_strength', 'int', '50', ('CAS strength', 'Intensidade do CAS'),
|
||||
_menu('Image', 'cas_strength', 'int', '100', ('CAS strength', 'Intensidade do CAS'),
|
||||
('How strong the CAS sharpening is.', 'Quão forte é a nitidez do CAS.'), lo=0, hi=100, unit='%'),
|
||||
_menu('Image', 'smaa', 'bool', '0', ('SMAA 1x anti-aliasing', 'Antisserrilhado SMAA 1x'),
|
||||
('Smooths jagged edges on the final image (a little GPU time).',
|
||||
'Suaviza as bordas serrilhadas da imagem final (um pouco de tempo de GPU).')),
|
||||
_menu('Image', 'dlaa', 'bool', '1', ('DLAA anti-aliasing', 'Anti-aliasing DLAA'),
|
||||
("NVIDIA RTX: stable foliage edges; F8 switches to the game's own SMAA.",
|
||||
'NVIDIA RTX: bordas estáveis na folhagem; F8 troca para o SMAA do próprio jogo.')),
|
||||
_menu('Image', 'hdr', 'bool', '1', ('HDR', 'HDR'),
|
||||
("The game's own HDR10 output, used when HDR is on in Windows display settings. Recommended: on.",
|
||||
'A saída HDR10 do próprio jogo, usada quando o HDR está ligado nas configurações de tela do Windows. '
|
||||
'Recomendado: ligado.')),
|
||||
_menu('Effects', 'shadow_scale', 'choice', '1', ('Shadow resolution', 'Resolução das sombras'),
|
||||
("Draws the shadow maps at 2x or 4x the game's 2048x2048. Recommended: Game (2048). The sharper edges are "
|
||||
'hard to see, and 8192 costs about 8 FPS and about 1 GB of video memory.',
|
||||
'Desenha os mapas de sombra em 2x ou 4x os 2048x2048 do jogo. Recomendado: Jogo (2048). As bordas mais '
|
||||
'nítidas são difíceis de notar, e 8192 custa cerca de 8 FPS e cerca de 1 GB de memória de vídeo.'),
|
||||
choices=(('1', 'Game (2048)', 'Jogo (2048)'), ('2', '4096', '4096'), ('4', '8192', '8192'))),
|
||||
_menu('Effects', 'ssao', 'int', '100', ('Ambient occlusion', 'Oclusão de ambiente'),
|
||||
('Strength of the contact shadows in corners and under grass. 0% turns the pass off (saves a little GPU '
|
||||
'time). Above 150% the half-resolution AO shows blocky halos.',
|
||||
'Força das sombras de contato em cantos e sob a grama. 0% desliga o passe (economiza um pouco de GPU). '
|
||||
'Acima de 150% a AO em meia resolução mostra halos em blocos.'), lo=0, hi=400, unit='%'),
|
||||
_menu('Effects', 'bloom', 'int', '100', ('Bloom', 'Bloom'),
|
||||
('Glow around bright light. 0% turns the pass off.', 'Brilho em volta da luz forte. 0% desliga o passe.'),
|
||||
lo=0, hi=400, unit='%'),
|
||||
_menu('Effects', 'ssr', 'int', '100', ('Screen-space reflections', 'Reflexos em espaço de tela'),
|
||||
('Reflections and light glare on water and wet stone. 0% turns the pass off.',
|
||||
'Reflexos e brilho de luz na água e na pedra molhada. 0% desliga o passe.'), lo=0, hi=400, unit='%'),
|
||||
_menu('Effects', 'motion_blur', 'int', '100', ('Motion blur', 'Desfoque de movimento'),
|
||||
('Camera motion blur. 0% turns the pass off.', 'Desfoque ao mover a câmera. 0% desliga o passe.'),
|
||||
lo=0, hi=400, unit='%'),
|
||||
_menu('Effects', 'chromatic_add', 'int', '0', ('Chromatic aberration', 'Aberração cromática'),
|
||||
('Color fringes toward the screen edges. The game ships with this off: 0% is as shipped.',
|
||||
'Franjas de cor nas bordas da tela. O jogo vem com isto desligado: 0% é o original.'),
|
||||
lo=0, hi=100, unit='%'),
|
||||
_menu('Effects', 'vignette_add', 'int', '0', ('Vignette', 'Vinheta'),
|
||||
('Darkened screen corners. The game ships with this off: 0% is as shipped.',
|
||||
'Cantos da tela escurecidos. O jogo vem com isto desligado: 0% é o original.'), lo=0, hi=100, unit='%'),
|
||||
_menu('Effects', 'game_sharpen', 'int', '100', ('Game sharpening', 'Nitidez do jogo'),
|
||||
("The game's own sharpening filter. The difference is hard to see: 100% recommended.",
|
||||
'O filtro de nitidez do próprio jogo. A diferença é difícil de ver: 100% recomendado.'),
|
||||
lo=0, hi=400, unit='%'),
|
||||
_menu('Effects', 'lod', 'int', '100', ('Character detail distance', 'Distância de detalhe dos personagens'),
|
||||
('How far the boy and Trico keep their detailed models. Higher keeps full detail further away.',
|
||||
'Até onde o menino e o Trico mantêm os modelos detalhados. Mais alto mantém o detalhe completo mais longe.'),
|
||||
lo=25, hi=400, unit='%'),
|
||||
_menu('Color', 'color_adjust', 'bool', '0', ('Color adjustments', 'Ajustes de cor'),
|
||||
('Turns on the brightness, contrast, saturation and gamma sliders below.',
|
||||
'Liga os controles de brilho, contraste, saturação e gama abaixo.')),
|
||||
@@ -80,10 +122,10 @@ MENU = [
|
||||
'Mais quadros na fila: FPS mais estável, resposta mais lenta.'),
|
||||
choices=(('1', '1 (lowest latency)', '1 (menor atraso)'), ('2', '2', '2'),
|
||||
('0', 'Unlimited', 'Sem limite'))),
|
||||
_menu('Performance', 'async_shaders', 'bool', '1',
|
||||
_menu('Performance', 'async_shaders', 'bool', '0',
|
||||
('Compile new shaders in the background', 'Compilar shaders novos em segundo plano'),
|
||||
('Fewer hitches when a new effect appears; an object whose shader is not ready yet may be missing for a frame.',
|
||||
'Menos engasgos quando aparece um efeito novo; um objeto cujo shader ainda não está pronto pode sumir por um quadro.')),
|
||||
('Fewer hitches when a new effect appears; an object whose shader is not ready yet is skipped, which can leave the lighting wrong (an overexposed image) after a load.',
|
||||
'Menos engasgos quando aparece um efeito novo; um objeto cujo shader ainda não está pronto é pulado, o que pode deixar a iluminação errada (imagem estourada) depois de um carregamento.')),
|
||||
_menu('Overlay', 'overlay_fps', 'bool', '1', ('Overlay: FPS', 'Overlay: FPS'),
|
||||
('Shows the frame rate in the performance overlay.', 'Mostra os quadros por segundo no overlay.')),
|
||||
_menu('Overlay', 'overlay_frametime', 'bool', '1', ('Overlay: frame time', 'Overlay: tempo de quadro'),
|
||||
|
||||
@@ -625,6 +625,10 @@ class Backend(QObject):
|
||||
@Property(str, notify=gameLanguageChanged)
|
||||
def gameLanguageNow(self):
|
||||
"""'Automatic: Português (Brasil)' while 'auto' is active, else empty."""
|
||||
if self.settings.get('game_language') == 'ja':
|
||||
return self.t_('game_language_japanese_fallback' if
|
||||
tlg_run.japanese_fallback_available(self.settings.get('game_dir')) else
|
||||
'game_language_japanese_unavailable')
|
||||
if self.settings.get('game_language', 'auto') in tlg_run.GAME_LANGUAGES:
|
||||
return ''
|
||||
return self.t_('game_language_now', name=logic.GAME_LANGUAGE_NAMES[tlg_run.resolve_game_language(self.settings)])
|
||||
|
||||
@@ -71,6 +71,12 @@ class Controls(QObject):
|
||||
rows = [self._row('c:stick_deadzone', self.t('deadzone'), self.t('deadzone_hint'), 'slider', False,
|
||||
zone != tlg_run.DEFAULT_SETTINGS['stick_deadzone'], lo=0, hi=40, step=1, unit='%',
|
||||
value=zone, valueText=f'{zone}%')]
|
||||
choice = self.settings.get('menu_confirm', 'cross')
|
||||
rows.append(self._row('c:menu_confirm', self.t('ctl_menu_confirm'), self.t('ctl_menu_confirm_hint'),
|
||||
'combo', False, choice != 'cross',
|
||||
options=[{'value': 'cross', 'label': 'Cross (X / A)'},
|
||||
{'value': 'circle', 'label': 'Circle (O / B)'}],
|
||||
index=int(choice == 'circle')))
|
||||
return rows
|
||||
|
||||
def _mouse_rows(self):
|
||||
@@ -104,6 +110,10 @@ class Controls(QObject):
|
||||
if rid == 'c:stick_deadzone':
|
||||
s['stick_deadzone'] = min(max(int(float(value)), 0), 40)
|
||||
tlg_run.save_settings(s)
|
||||
elif rid == 'c:menu_confirm':
|
||||
s['menu_confirm'] = 'circle' if value == 'circle' else 'cross'
|
||||
backports_table.write_ini(self.ini_path(), {'menu_confirm': s['menu_confirm']})
|
||||
tlg_run.save_settings(s)
|
||||
elif rid.startswith('m:'):
|
||||
key = rid[2:]
|
||||
kind = padsettings.MOUSE_SPEC[key][1]
|
||||
|
||||
@@ -15,6 +15,7 @@ import preset_share # noqa: E402 (launcher/preset_share.py; logic put the fold
|
||||
# (id, kind, live) of the options kept in settings.json. live: also in the Insert menu.
|
||||
SETTING_ROWS = {
|
||||
's:display_mode': ('display_mode', 'combo', True),
|
||||
's:resolution': ('resolution', 'combo', False),
|
||||
's:vsync': ('vsync', 'switch', True),
|
||||
's:show_fps': ('show_fps', 'switch', True),
|
||||
's:frame_rate': ('frame_rate', 'combo', False),
|
||||
@@ -22,24 +23,29 @@ SETTING_ROWS = {
|
||||
's:native_audio': ('native_audio', 'switch', False),
|
||||
's:close_on_play': ('close_on_play', 'switch', False),
|
||||
's:reopen_launcher': ('reopen_launcher', 'switch', False),
|
||||
's:level_select': ('level_select', 'switch', False),
|
||||
's:bridge_fix': ('bridge_fix', 'switch', False),
|
||||
's:vram_budget_mb': ('vram_budget_mb', 'slider', False),
|
||||
}
|
||||
DISPLAY_MODES = ('Windowed', 'Borderless', 'Fullscreen')
|
||||
COMBO_VALUES = {'display_mode': DISPLAY_MODES, 'gpu_readbacks': ('0', '1', '2'),
|
||||
'frame_rate': tuple(tlg_run.FRAME_RATES)}
|
||||
COMBO_LABEL = {'display_mode': '', 'gpu_readbacks': 'readbacks_', 'frame_rate': 'fps_'}
|
||||
'frame_rate': tuple(tlg_run.FRAME_RATES), 'resolution': tuple(tlg_run.RESOLUTIONS)}
|
||||
COMBO_LABEL = {'display_mode': '', 'gpu_readbacks': 'readbacks_', 'frame_rate': 'fps_'} # resolution: tlg_run.resolution_label
|
||||
OVERLAY_IDS = tuple(i.id for i in backports_table.MENU if i.tab == 'Overlay')
|
||||
COLOR_IDS = tuple(i.id for i in backports_table.MENU if i.tab == 'Color')
|
||||
EFFECT_IDS = tuple(i.id for i in backports_table.MENU if i.tab == 'Effects')
|
||||
SECTIONS = (
|
||||
('gfx_sec_image', ('s:display_mode', 's:vsync', 'menu:fsr1', 'menu:rcas', 'menu:rcas_strength',
|
||||
'menu:cas', 'menu:cas_strength', 'menu:smaa')),
|
||||
('gfx_sec_image', ('s:display_mode', 's:resolution', 's:vsync', 'menu:fsr1', 'menu:rcas', 'menu:rcas_strength',
|
||||
'menu:cas', 'menu:cas_strength', 'menu:smaa', 'menu:dlaa', 'menu:hdr')),
|
||||
('gfx_sec_color', COLOR_IDS),
|
||||
('gfx_sec_effects', EFFECT_IDS),
|
||||
('gfx_sec_rate', ('s:frame_rate', 'menu:fps_limit', 'menu:frames_queued')),
|
||||
('gfx_sec_gpu', ('menu:async_shaders', 'TLG_BACKGROUND_WARMUP', 's:gpu_readbacks', 'TLG_READBACK_LINEAR',
|
||||
'TLG_DIRECT_MEMORY_ACCESS')),
|
||||
('gfx_sec_vram', ('s:vram_budget_mb',)),
|
||||
('gfx_sec_overlay', ('s:show_fps',) + OVERLAY_IDS),
|
||||
('gfx_sec_launcher', ('s:native_audio', 's:close_on_play', 's:reopen_launcher')),
|
||||
('gfx_sec_debug', ('s:level_select', 's:bridge_fix')),
|
||||
)
|
||||
# Sliders greyed out while the switch they belong to is off.
|
||||
NEEDS = {'menu:rcas_strength': 'menu:rcas', 'menu:cas_strength': 'menu:cas',
|
||||
@@ -128,7 +134,8 @@ class Graphics(QObject):
|
||||
value=int(value), valueText=f'{int(value)} MB')
|
||||
else:
|
||||
names = COMBO_VALUES[key]
|
||||
options = [(n, self.t(COMBO_LABEL[key] + n)) for n in names]
|
||||
options = [(n, tlg_run.resolution_label(n) if key == 'resolution' else self.t(COMBO_LABEL[key] + n))
|
||||
for n in names]
|
||||
options = [(n, label + (f' ({self.t("exp_default")})' if n == str(default) else ''))
|
||||
for n, label in options]
|
||||
row['options'] = [{'value': n, 'label': label} for n, label in options]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"game_language_label": {"en": "Game language", "pt": "Idioma do jogo"},
|
||||
"game_language_auto": {"en": "Automatic (system language)", "pt": "Automático (idioma do sistema)"},
|
||||
"game_language_japanese_fallback": {"en": "Experimental Japanese text from embedded EU 1.03 assets. French archives stay on disk and are hidden only from this launch. Full-game audio/text coverage is not yet verified.", "pt": "Texto japonês experimental dos arquivos europeus 1.03. Os arquivos franceses ficam no disco e são ocultados apenas nesta execução. A cobertura de áudio/texto do jogo inteiro ainda não foi verificada."},
|
||||
"game_language_japanese_unavailable": {"en": "Japanese text fallback requires EU CUSA03745 update 01.03 and both boot archives. This game folder will use English instead. This does not add Japanese-region disc support.", "pt": "O texto japonês exige CUSA03745 europeu com atualização 01.03 e os dois arquivos boot. Esta pasta usará inglês. Isso não adiciona suporte à edição japonesa do disco."},
|
||||
"game_language_now": {"en": "Automatic: {name}", "pt": "Automático: {name}"},
|
||||
"game_language_hint": {"en": "Applies on the next start. Works if your game files have that language.", "pt": "Vale na próxima partida. Funciona se os arquivos do seu jogo tiverem esse idioma."},
|
||||
"nav_play": {"en": "Play", "pt": "Jogar"},
|
||||
@@ -469,7 +471,12 @@
|
||||
"adv_profile_default": {"en": "All defaults", "pt": "Tudo no padrão"},
|
||||
"adv_profile_custom": {"en": "{n} changed", "pt": "{n} alterado(s)"},
|
||||
"gfx_sec_color": {"en": "Color", "pt": "Cor"},
|
||||
"gfx_sec_effects": {"en": "Game effects (100% = as shipped)", "pt": "Efeitos do jogo (100% = original)"},
|
||||
"gfx_sec_debug": {"en": "Debug (at start)", "pt": "Depuração (ao iniciar)"},
|
||||
"gfx_what_level_select": {"en": "Level select: hold L1 while the title menu closes (Continue/New Game). QA menu: in game, hold L1 and press L3. GoldHEN patches by illusiony and DanielSvoboda.", "pt": "Seleção de fases: segure L1 enquanto o menu inicial fecha (Continuar/Novo jogo). Menu de QA: no jogo, segure L1 e aperte L3. Patches GoldHEN de illusiony e DanielSvoboda."},
|
||||
"gfx_what_bridge_fix": {"en": "Only if the game freezes when the bridge in the pit collapses; skips that slow motion.", "pt": "Só se o jogo travar quando a ponte do fosso desaba; pula essa câmera lenta."},
|
||||
"gfx_sec_launcher": {"en": "Audio and launcher", "pt": "Áudio e launcher"},
|
||||
"gfx_what_resolution": {"en": "Rendered natively; wider than 16:9 shows more of the scene and moves the HUD out to the edges.", "pt": "Renderizado nativamente; mais largo que 16:9 mostra mais da cena e leva o HUD às bordas."},
|
||||
"gfx_what_frame_rate": {"en": "The game's own frame rate: 30 as on PS4; 60 and unlocked patch the 1.03 eboot in memory (experimental, see docs/60fps.md).", "pt": "A taxa de quadros do próprio jogo: 30 como no PS4; 60 e livre alteram o eboot 1.03 na memória (experimental, ver docs/60fps.md)."},
|
||||
"gfx_what_native_audio": {"en": "Uses libSceNgs2.sprx from your console (system_lib/) when present; otherwise the port's own mixer plays the audio.", "pt": "Usa o libSceNgs2.sprx do seu console (system_lib/) quando ele existe; senão o mixer do próprio port toca o áudio."},
|
||||
"gpu_readbacks": {"en": "GPU accuracy", "pt": "Precisão da GPU"},
|
||||
@@ -477,6 +484,8 @@
|
||||
"readbacks_1": {"en": "Relaxed", "pt": "Intermediário"},
|
||||
"readbacks_2": {"en": "Precise (recommended)", "pt": "Preciso (recomendado)"},
|
||||
"deadzone": {"en": "Stick dead zone", "pt": "Zona morta dos analógicos"},
|
||||
"ctl_menu_confirm": {"en": "Menu confirm button", "pt": "Botão de confirmação dos menus"},
|
||||
"ctl_menu_confirm_hint": {"en": "Cross or Circle to confirm; the other button goes back. Applies on next launch, without changing gameplay buttons. Game menus currently require the verified EU 1.03 executable.", "pt": "Cross ou Circle para confirmar; o outro botão volta. Aplicado na próxima inicialização, sem alterar os botões de jogo. Os menus do jogo exigem o executável europeu 1.03 verificado."},
|
||||
"deadzone_hint": {"en": "Raise it if the boy or the camera moves on its own (stick drift).", "pt": "Aumente se o garoto ou a câmera se mexer sozinho (drift)."},
|
||||
"exp_live": {"en": "in game", "pt": "no jogo"},
|
||||
"exp_restart": {"en": "next start", "pt": "na próxima partida"},
|
||||
|
||||
@@ -30,7 +30,7 @@ GAME_LANGUAGE_NAMES = {'en': 'English', 'en_GB': 'English (UK)', 'pt_BR': 'Portu
|
||||
'fr_CA': 'Français (Canada)', 'fr': 'Français', 'de': 'Deutsch', 'it': 'Italiano',
|
||||
'nl': 'Nederlands', 'pl': 'Polski', 'tr': 'Türkçe', 'ru': 'Русский', 'cs': 'Čeština',
|
||||
'hu': 'Magyar', 'el': 'Ελληνικά', 'ro': 'Română', 'fi': 'Suomi', 'sv': 'Svenska', 'da': 'Dansk',
|
||||
'no': 'Norsk', 'ar': 'العربية', 'ja': '日本語', 'ko': '한국어', 'zh_TW': '繁體中文',
|
||||
'no': 'Norsk', 'ar': 'العربية', 'ja': tlg_run.JAPANESE_FALLBACK_LABEL, 'ko': '한국어', 'zh_TW': '繁體中文',
|
||||
'zh_CN': '简体中文', 'th': 'ไทย', 'vi': 'Tiếng Việt', 'id': 'Bahasa Indonesia'}
|
||||
|
||||
|
||||
|
||||
+538
-129
@@ -13,13 +13,36 @@ import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
import webbrowser
|
||||
from tkinter import font as tkfont
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import logtail # noqa: E402
|
||||
import tlg_run # noqa: E402
|
||||
|
||||
TEXT = {
|
||||
'journey': ('Journey', 'Jornada'),
|
||||
'setup': ('Game & language', 'Jogo e idioma'),
|
||||
'image': ('Image & display', 'Imagem e vídeo'),
|
||||
'journal': ('Session log', 'Registro da sessão'),
|
||||
'eyebrow': ('A BOND BEYOND WORDS', 'UM VÍNCULO ALÉM DAS PALAVRAS'),
|
||||
'hero_copy': ('A boy. A mysterious companion.\nAn extraordinary journey awaits.',
|
||||
'Um menino. Um companheiro misterioso.\nUma jornada extraordinária espera por você.'),
|
||||
'edition': ('PC RECOMPILATION', 'RECOMPILAÇÃO PARA PC'),
|
||||
'home_note': ('Before the journey', 'Antes da jornada'),
|
||||
'home_help': ('Choose your game folder in Game & language. Fine-tune your experience in Image & display and Controls.',
|
||||
'Selecione a pasta do jogo em Jogo e idioma. Ajuste sua experiência em Imagem e vídeo e Controles.'),
|
||||
'setup_hint': ('Your game, your language. Settings are saved automatically.',
|
||||
'Seu jogo, seu idioma. As configurações são salvas automaticamente.'),
|
||||
'image_hint': ('Find the right balance for your journey.', 'Encontre o equilíbrio para sua jornada.'),
|
||||
'controls_hint': ('A little closer to Trico.', 'Um pouco mais perto de Trico.'),
|
||||
'journal_hint': ('Follow the game output and find details about your session.',
|
||||
'Acompanhe a saída do jogo e os detalhes da sua sessão.'),
|
||||
'files': ('Files & maintenance', 'Arquivos e manutenção'),
|
||||
'automatic_save': ('Settings saved automatically', 'Configurações salvas automaticamente'),
|
||||
'title': ('The Last Guardian — PC', 'The Last Guardian — PC'),
|
||||
'game': ('Game', 'Jogo'),
|
||||
'game_dir': ('Game folder', 'Pasta do jogo'),
|
||||
@@ -36,11 +59,23 @@ TEXT = {
|
||||
'Fullscreen': ('Fullscreen', 'Tela cheia'),
|
||||
'vsync': ('VSync', 'VSync'),
|
||||
'frame_rate': ('Frame rate (experimental)', 'Taxa de quadros (experimental)'),
|
||||
'resolution': ('Resolution', 'Resolução'),
|
||||
'resolution_hint': ('Rendered natively; wider than 16:9 shows more of the scene and moves the HUD out to the edges',
|
||||
'Renderizado nativamente; mais largo que 16:9 mostra mais da cena e leva o HUD às bordas'),
|
||||
'fps_30': ('30 FPS (as on PS4)', '30 FPS (como no PS4)'),
|
||||
'fps_60': ('60 FPS', '60 FPS'),
|
||||
'fps_unlocked': ('Unlocked (up to the display)', 'Livre (até a taxa do monitor)'),
|
||||
'fps_hint': ('60/unlocked patch eboot 1.03 in memory; see docs/60fps.md',
|
||||
'60/livre alteram o eboot 1.03 na memória; ver docs/60fps.md'),
|
||||
'debug': ('Debug (at start)', 'Depuração (ao iniciar)'),
|
||||
'level_select': ('Level select and QA menu', 'Seleção de fases e menu de QA'),
|
||||
'bridge_fix': ('Collapsing bridge freeze fix', 'Correção do travamento da ponte que desaba'),
|
||||
'debug_hint': ('Level select: hold L1 while the title menu closes (Continue/New Game). QA menu: in game, hold L1 and press L3. '
|
||||
'Bridge fix: only if the game freezes when the bridge in the pit collapses; skips that slow motion. '
|
||||
'GoldHEN patches by illusiony and DanielSvoboda.',
|
||||
'Seleção de fases: segure L1 enquanto o menu inicial fecha (Continuar/Novo jogo). Menu de QA: no jogo, segure L1 e aperte L3. '
|
||||
'Correção da ponte: só se o jogo travar quando a ponte do fosso desaba; pula essa câmera lenta. '
|
||||
'Patches GoldHEN de illusiony e DanielSvoboda.'),
|
||||
'show_fps': ('Show FPS overlay at start (F10 toggles)', 'Mostrar overlay de FPS ao iniciar (F10 alterna)'),
|
||||
'vram': ('Texture cache budget (MB of VRAM)', 'Orçamento do cache de texturas (MB de VRAM)'),
|
||||
'vram_hint': ('6 GB card: 5600 · 8 GB: 7000 · 12 GB: 10500 · 16 GB: 14000 · 24 GB: 21500. This is video card memory (VRAM), not RAM; never above your card\'s VRAM.',
|
||||
@@ -48,6 +83,8 @@ TEXT = {
|
||||
'filters': ('Image filters (live in the in-game menu too, Insert → Image)',
|
||||
'Filtros de imagem (também no menu do jogo, Insert → Imagem)'),
|
||||
'smaa': ('SMAA anti-aliasing (smooths edges; the HUD too)', 'Anti-aliasing SMAA (suaviza bordas; o HUD também)'),
|
||||
'dlaa': ('DLAA anti-aliasing (NVIDIA RTX: stable foliage edges; F8 in game)',
|
||||
'Anti-aliasing DLAA (NVIDIA RTX: bordas estáveis na folhagem; F8 no jogo)'),
|
||||
'sharpening': ('Sharpening', 'Nitidez'),
|
||||
'sharpen_off': ('Off', 'Desligada'),
|
||||
'sharpen_rcas': ('RCAS (FSR 1)', 'RCAS (FSR 1)'),
|
||||
@@ -60,6 +97,7 @@ TEXT = {
|
||||
'audio': ('Audio', 'Áudio'),
|
||||
'native_audio': ('Native audio (libSceNgs2.sprx from your console)', 'Áudio nativo (libSceNgs2.sprx do seu console)'),
|
||||
'controls': ('Controls', 'Controles'),
|
||||
'menu_confirm': ('Menu confirm button (next launch)', 'Botão de confirmação (próxima inicialização)'),
|
||||
'mouse_enabled': ('Mouse turns the camera', 'Mouse gira a câmera'),
|
||||
'mouse_invert_y': ('Invert mouse Y axis', 'Inverter eixo Y do mouse'),
|
||||
'mouse_sensitivity': ('Mouse sensitivity', 'Sensibilidade do mouse'),
|
||||
@@ -95,6 +133,48 @@ TEXT = {
|
||||
'{size} liberados, mas {count} arquivo(s) não puderam ser apagados (em uso? Feche o '
|
||||
'jogo e tente de novo).'),
|
||||
'mods': ('Mods / Cheats…', 'Mods / Cheats…'),
|
||||
'menu_note': ('In game, press Insert for the graphics menu: every setting there applies live.',
|
||||
'No jogo, aperte Insert para o menu de gráficos: tudo nele vale na hora.'),
|
||||
'graphics': ('Graphics…', 'Gráficos…'),
|
||||
'graphics_title': ('Graphics', 'Gráficos'),
|
||||
'graphics_hint': ('The same settings are in the in-game graphics menu (press Insert in game), where changes '
|
||||
'show right away. 100% is the game as shipped.',
|
||||
'As mesmas configurações estão no menu de gráficos do jogo (aperte Insert no jogo), onde as '
|
||||
'mudanças aparecem na hora. 100% é o jogo original.'),
|
||||
'hdr_hint': ("The game's own HDR10 output, used when HDR is on in Windows display settings. Recommended: on.",
|
||||
'A saída HDR10 do próprio jogo, usada quando o HDR está ligado nas configurações de tela do Windows. '
|
||||
'Recomendado: ligado.'),
|
||||
'shadow_scale': ('Shadow resolution', 'Resolução das sombras'),
|
||||
'shadow_game': ('Game (2048)', 'Jogo (2048)'),
|
||||
'shadow_hint': ("Draws the shadow maps at 2x or 4x the game's 2048x2048. Recommended: Game (2048). The sharper "
|
||||
'edges are hard to see, and 8192 costs about 8 FPS and about 1 GB of video memory.',
|
||||
'Desenha os mapas de sombra em 2x ou 4x os 2048x2048 do jogo. Recomendado: Jogo (2048). As bordas '
|
||||
'mais nítidas são difíceis de notar, e 8192 custa cerca de 8 FPS e cerca de 1 GB de memória de vídeo.'),
|
||||
'effects': ('Game effects (100% = as shipped)', 'Efeitos do jogo (100% = original)'),
|
||||
'effect_ssao': ('Ambient occlusion', 'Oclusão de ambiente'),
|
||||
'effect_ssao_hint': ('Strength of the contact shadows in corners and under grass. 0% turns the pass off (saves a '
|
||||
'little GPU time). Above 150% the half-resolution AO shows blocky halos.',
|
||||
'Força das sombras de contato em cantos e sob a grama. 0% desliga o passe (economiza um pouco '
|
||||
'de GPU). Acima de 150% a AO em meia resolução mostra halos em blocos.'),
|
||||
'effect_bloom': ('Bloom', 'Bloom'),
|
||||
'effect_bloom_hint': ('Glow around bright light. 0% turns the pass off.', 'Brilho em volta da luz forte. 0% desliga o passe.'),
|
||||
'effect_ssr': ('Screen-space reflections', 'Reflexos em espaço de tela'),
|
||||
'effect_ssr_hint': ('Reflections and light glare on water and wet stone. 0% turns the pass off.',
|
||||
'Reflexos e brilho de luz na água e na pedra molhada. 0% desliga o passe.'),
|
||||
'effect_motion_blur': ('Motion blur', 'Desfoque de movimento'),
|
||||
'effect_motion_blur_hint': ('Camera motion blur. 0% turns the pass off.', 'Desfoque ao mover a câmera. 0% desliga o passe.'),
|
||||
'effect_chromatic_add': ('Chromatic aberration', 'Aberração cromática'),
|
||||
'effect_chromatic_add_hint': ('Color fringes toward the screen edges. The game ships with this off: 0% is as shipped.',
|
||||
'Franjas de cor nas bordas da tela. O jogo vem com isto desligado: 0% é o original.'),
|
||||
'effect_vignette_add': ('Vignette', 'Vinheta'),
|
||||
'effect_vignette_add_hint': ('Darkened screen corners. The game ships with this off: 0% is as shipped.',
|
||||
'Cantos da tela escurecidos. O jogo vem com isto desligado: 0% é o original.'),
|
||||
'effect_game_sharpen': ('Game sharpening', 'Nitidez do jogo'),
|
||||
'effect_game_sharpen_hint': ("The game's own sharpening filter. The difference is hard to see: 100% recommended.",
|
||||
'O filtro de nitidez do próprio jogo. A diferença é difícil de ver: 100% recomendado.'),
|
||||
'effect_lod': ('Character detail distance', 'Distância de detalhe dos personagens'),
|
||||
'effect_lod_hint': ('How far the boy and Trico keep their detailed models. Higher keeps full detail further away.',
|
||||
'Até onde o menino e o Trico mantêm os modelos detalhados. Mais alto mantém o detalhe completo mais longe.'),
|
||||
'mods_title': ('Mods / Cheats', 'Mods / Cheats'),
|
||||
'mods_hint': ('Drop shadPS4/GoldHEN cheat XML files in the mods folder, then turn them on here. '
|
||||
'Nothing here changes the game until you enable it.',
|
||||
@@ -121,14 +201,17 @@ TEXT = {
|
||||
'already': ('The game is already running.', 'O jogo já está rodando.'),
|
||||
'error': ('Error', 'Erro'),
|
||||
'port_by': ('PC port by HayatoG', 'Port para PC por HayatoG'),
|
||||
'coffee': ('Buy me a coffee', 'Apoie com um café'),
|
||||
'channel': ('YouTube channel: @hayatog-dev', 'Canal no YouTube: @hayatog-dev'),
|
||||
'discord': ('Discord community', 'Comunidade no Discord'),
|
||||
'sponsor': ('♥ Sponsor on GitHub', '♥ Apoie no GitHub'),
|
||||
}
|
||||
COFFEE_URL = 'https://buymeacoffee.com/hayatog'
|
||||
CHANNEL_URL = 'https://www.youtube.com/@hayatog-dev'
|
||||
DISCORD_URL = 'https://discord.gg/UMkdMYZh9g'
|
||||
SPONSOR_URL = 'https://github.com/sponsors/HayatoG'
|
||||
GAME_LANGUAGE_NAMES = {'en': 'English', 'fr': 'Français', 'es_ES': 'Español (España)',
|
||||
GAME_LANGUAGE_NAMES = {'ja': tlg_run.JAPANESE_FALLBACK_LABEL,
|
||||
'en': 'English', 'fr': 'Français', 'es_ES': 'Español (España)',
|
||||
'de': 'Deutsch', 'it': 'Italiano', 'nl': 'Nederlands',
|
||||
'pt_PT': 'Português (Portugal)', 'ru': 'Русский', 'fi': 'Suomi',
|
||||
'sv': 'Svenska', 'da': 'Dansk', 'no': 'Norsk', 'pl': 'Polski',
|
||||
@@ -139,6 +222,8 @@ GAME_LANGUAGE_NAMES = {'en': 'English', 'fr': 'Français', 'es_ES': 'Español (E
|
||||
UI_LANGUAGES = ['auto', 'pt_BR', 'en_US']
|
||||
UI_LANGUAGE_NAMES = {'pt_BR': 'Português (Brasil)', 'en_US': 'English (US)'}
|
||||
DISPLAY_MODES = ['Windowed', 'Borderless', 'Fullscreen']
|
||||
|
||||
|
||||
# The PS4 buttons the remap dialog lists, with what they do in The Last Guardian when known.
|
||||
PS4_BUTTONS = [
|
||||
('cross', 'Cross (X)', ('jump', 'pular')), ('circle', 'Circle', None), ('square', 'Square', None),
|
||||
@@ -175,10 +260,16 @@ def sdl_key_name(keysym):
|
||||
SHARPENERS = ['off', 'rcas', 'cas']
|
||||
# Image filters: kept in tlg.ini only (the in-game menu writes the same keys), with their
|
||||
# defaults and ranges (tlg_graphics.h).
|
||||
INI_DEFAULTS = {'smaa': '0', 'rcas': '1', 'cas': '0', 'color_adjust': '0', 'color_brightness': '0.00',
|
||||
INI_DEFAULTS = {'dlaa': '1', 'smaa': '0', 'rcas': '1', 'cas': '0', 'color_adjust': '0', 'color_brightness': '0.00',
|
||||
'color_contrast': '1.00', 'color_saturation': '1.00', 'color_gamma': '1.00'}
|
||||
COLOR_RANGES = {'color_brightness': (-0.5, 0.5), 'color_contrast': (0.5, 1.5),
|
||||
'color_saturation': (0.0, 2.0), 'color_gamma': (0.5, 2.0)}
|
||||
# Graphics dialog: the in-game menu's HDR, Enhancements and Game effects, same tlg.ini keys
|
||||
# (tlg_settings.cpp). Effects: key -> (default, low, high) in percent, the default as shipped.
|
||||
SHADOW_SCALES = [1, 2, 4]
|
||||
EFFECTS = {'ssao': (100, 0, 400), 'bloom': (100, 0, 400), 'ssr': (100, 0, 400), 'motion_blur': (100, 0, 400),
|
||||
'chromatic_add': (0, 0, 100), 'vignette_add': (0, 0, 100), 'game_sharpen': (100, 0, 400),
|
||||
'lod': (100, 25, 400)}
|
||||
|
||||
|
||||
def art_path(settings, name):
|
||||
@@ -219,161 +310,393 @@ class Launcher:
|
||||
return (pt if self.pt() else en).format(**fmt)
|
||||
|
||||
# -- window -----------------------------------------------------------------------------
|
||||
def configure_theme(self):
|
||||
"""Stone, parchment and moss; all controls remain native Tk/ttk widgets."""
|
||||
self.colors = {'paper': '#eeeae0', 'card': '#f8f5ed', 'ink': '#283d35',
|
||||
'muted': '#536158', 'line': '#d4d6c8', 'accent': '#48644f',
|
||||
'control_border': '#7a8477', 'disabled': '#d5d9cc',
|
||||
'dark': '#1a2420', 'light': '#f4f0e4'}
|
||||
c = self.colors
|
||||
families = set(tkfont.families(self.root))
|
||||
self.serif = next((f for f in ('Palatino Linotype', 'Book Antiqua', 'DejaVu Serif')
|
||||
if f in families), 'Times')
|
||||
self.sans = next((f for f in ('Segoe UI', 'Noto Sans', 'DejaVu Sans')
|
||||
if f in families), 'TkDefaultFont')
|
||||
self.root.configure(background=c['paper'])
|
||||
self.root.option_add('*TCombobox*Listbox.background', c['card'])
|
||||
self.root.option_add('*TCombobox*Listbox.foreground', c['ink'])
|
||||
self.root.option_add('*TCombobox*Listbox.selectBackground', c['accent'])
|
||||
self.root.option_add('*TCombobox*Listbox.selectForeground', c['light'])
|
||||
style = ttk.Style(self.root)
|
||||
style.theme_use('clam')
|
||||
style.configure('.', font=(self.sans, 10), background=c['paper'], foreground=c['ink'],
|
||||
bordercolor=c['line'], lightcolor=c['line'], darkcolor=c['line'],
|
||||
troughcolor=c['paper'], focuscolor=c['accent'])
|
||||
style.map('.', foreground=[('disabled', c['muted'])])
|
||||
style.configure('TFrame', background=c['paper'])
|
||||
style.configure('TLabel', background=c['paper'], foreground=c['ink'])
|
||||
style.configure('Muted.TLabel', foreground=c['muted'])
|
||||
style.configure('Heading.TLabel', font=(self.serif, 25), foreground=c['ink'])
|
||||
style.configure('Brand.TLabel', font=(self.serif, 17))
|
||||
style.configure('Eyebrow.TLabel', font=(self.sans, 8, 'bold'), foreground=c['accent'])
|
||||
style.configure('TLabelframe', background=c['card'], borderwidth=1, relief='solid')
|
||||
style.configure('TLabelframe.Label', background=c['paper'], foreground=c['accent'],
|
||||
font=(self.sans, 10, 'bold'))
|
||||
style.configure('Card.TFrame', background=c['card'])
|
||||
style.configure('Card.TLabel', background=c['card'])
|
||||
style.configure('TButton', background=c['card'], foreground=c['ink'], padding=(14, 9),
|
||||
borderwidth=1, relief='flat')
|
||||
style.map('TButton', background=[('active', '#e0e4d7'), ('pressed', '#d1daca')],
|
||||
foreground=[('disabled', c['muted'])], bordercolor=[('focus', c['accent'])])
|
||||
style.configure('Nav.TButton', background=c['paper'], padding=(18, 12),
|
||||
font=(self.sans, 10), borderwidth=0)
|
||||
style.configure('Selected.Nav.TButton', background=c['ink'], foreground=c['light'])
|
||||
style.map('Selected.Nav.TButton', background=[('active', c['accent'])],
|
||||
foreground=[('active', c['light'])])
|
||||
style.configure('Play.TButton', background=c['ink'], foreground=c['light'],
|
||||
font=(self.sans, 13, 'bold'), padding=(38, 14))
|
||||
style.map('Play.TButton', background=[('disabled', c['disabled']), ('active', '#42604d'),
|
||||
('pressed', '#1a3026')],
|
||||
foreground=[('disabled', c['muted']), ('active', c['light'])])
|
||||
style.configure('TEntry', fieldbackground=c['card'], padding=8, insertcolor=c['ink'])
|
||||
style.configure('TCombobox', fieldbackground=c['card'], background=c['card'],
|
||||
foreground=c['ink'], arrowcolor=c['accent'], padding=7)
|
||||
style.configure('TSpinbox', fieldbackground=c['card'], background=c['card'],
|
||||
arrowcolor=c['accent'], padding=6)
|
||||
# Clam makes readonly+focus combobox text white. Override the foreground
|
||||
# independently of text selection, including inactive selections in entries.
|
||||
for field_style in ('TEntry', 'TCombobox', 'TSpinbox'):
|
||||
style.configure(field_style, foreground=c['ink'], selectforeground=c['light'],
|
||||
selectbackground=c['ink'], bordercolor=c['control_border'],
|
||||
lightcolor=c['control_border'], darkcolor=c['control_border'])
|
||||
style.map(field_style,
|
||||
foreground=[('disabled', c['muted']), ('!disabled', c['ink'])],
|
||||
fieldbackground=[('disabled', c['disabled']), ('!disabled', c['card'])],
|
||||
selectforeground=[('disabled', c['muted']), ('!disabled', c['light'])],
|
||||
selectbackground=[('disabled', c['disabled']), ('!disabled', c['ink'])],
|
||||
bordercolor=[('focus', c['ink']), ('!focus', c['control_border'])],
|
||||
lightcolor=[('focus', c['ink']), ('!focus', c['control_border'])],
|
||||
darkcolor=[('focus', c['ink']), ('!focus', c['control_border'])])
|
||||
style.configure('TCheckbutton', background=c['card'], padding=(0, 7),
|
||||
indicatorbackground=c['paper'], indicatorforeground=c['ink'],
|
||||
upperbordercolor=c['control_border'], lowerbordercolor=c['control_border'])
|
||||
style.map('TCheckbutton', background=[('active', c['card'])],
|
||||
indicatorbackground=[('selected', c['accent'])],
|
||||
indicatorforeground=[('selected', c['light'])])
|
||||
style.configure('Vertical.TScrollbar', background=c['control_border'], borderwidth=0,
|
||||
arrowsize=12, troughcolor=c['paper'])
|
||||
style.configure('TSeparator', background=c['line'])
|
||||
|
||||
def build(self):
|
||||
"""Horizontal layout: banner on top; Game + Audio | Language + Video | game log; buttons below."""
|
||||
"""Presentation only: existing settings, callbacks and launch path are reused."""
|
||||
old_log = self.log.get('1.0', 'end-1c') if getattr(self, 'log', None) else ''
|
||||
current_page = getattr(self, 'current_page', 'journey')
|
||||
for child in self.root.winfo_children():
|
||||
child.destroy()
|
||||
root = self.root
|
||||
root.title(self.t('title'))
|
||||
root.minsize(1120, 680)
|
||||
pad = {'padx': 6, 'pady': 4}
|
||||
main = ttk.Frame(root, padding=12)
|
||||
main.pack(fill='both', expand=True)
|
||||
for column, weight in enumerate((3, 3, 4)):
|
||||
main.columnconfigure(column, weight=weight, uniform='cols' if column < 2 else '')
|
||||
main.rowconfigure(2, weight=1)
|
||||
|
||||
header = ttk.Frame(main)
|
||||
header.grid(row=0, column=0, columnspan=3, sticky='ew', pady=(0, 8))
|
||||
self.configure_theme()
|
||||
if not getattr(self, 'window_sized', False):
|
||||
root.geometry('1240x820')
|
||||
self.window_sized = True
|
||||
root.minsize(1040, 700)
|
||||
self.load_art()
|
||||
if self.banner:
|
||||
ttk.Label(header, image=self.banner).pack(side='left', padx=(0, 14))
|
||||
titles = ttk.Frame(header)
|
||||
titles.pack(side='left', fill='y')
|
||||
ttk.Label(titles, text='The Last Guardian', font=('TkDefaultFont', 22, 'bold')).pack(anchor='w')
|
||||
ttk.Label(titles, text='PC — CUSA03627 v1.03', foreground='#777').pack(anchor='w')
|
||||
credits = ttk.Frame(header)
|
||||
credits.pack(side='right', anchor='ne')
|
||||
ttk.Label(credits, text=self.t('port_by'), foreground='#777').pack(anchor='e')
|
||||
for key, url in (('channel', CHANNEL_URL), ('discord', DISCORD_URL), ('sponsor', SPONSOR_URL)):
|
||||
link = ttk.Label(credits, text=self.t(key), foreground='#2a6fdb', cursor='hand2',
|
||||
font=('TkDefaultFont', 10, 'underline'))
|
||||
link.pack(anchor='e')
|
||||
link.bind('<Button-1>', lambda _, u=url: tlg_run.open_external(u))
|
||||
self.status = ttk.Label(titles, text='', foreground='#555', wraplength=420, justify='left')
|
||||
self.status.pack(anchor='w', pady=(10, 0))
|
||||
main = ttk.Frame(root)
|
||||
main.pack(fill='both', expand=True)
|
||||
|
||||
header = ttk.Frame(main, padding=(28, 18))
|
||||
header.pack(fill='x')
|
||||
brand = ttk.Frame(header)
|
||||
brand.pack(side='left')
|
||||
ttk.Label(brand, text='THE LAST GUARDIAN', style='Brand.TLabel').pack(anchor='w')
|
||||
ttk.Label(brand, text=self.t('edition') + ' / CUSA03627 · v1.03',
|
||||
style='Eyebrow.TLabel').pack(anchor='w', pady=(3, 0))
|
||||
credits = ttk.Frame(header)
|
||||
credits.pack(side='right')
|
||||
ttk.Label(credits, text=self.t('port_by'), style='Muted.TLabel').pack(anchor='e')
|
||||
self.coffee_button = tk.Button(credits, text='☕ ' + self.t('coffee'),
|
||||
command=lambda: webbrowser.open(COFFEE_URL),
|
||||
background='#FFDD00', foreground='#000000',
|
||||
activebackground='#FFE873', activeforeground='#000000',
|
||||
font=(self.sans, 9, 'bold'), cursor='hand2',
|
||||
relief='flat', padx=12, pady=6)
|
||||
self.coffee_button.pack(anchor='e', pady=(6, 0))
|
||||
links = ttk.Frame(credits)
|
||||
links.pack(anchor='e', pady=(5, 0))
|
||||
for index, (key, url) in enumerate((('channel', CHANNEL_URL), ('discord', DISCORD_URL),
|
||||
('sponsor', SPONSOR_URL))):
|
||||
link = ttk.Label(links, text=self.t(key), foreground=self.colors['accent'],
|
||||
cursor='hand2', font=(self.sans, 9))
|
||||
link.pack(side='left', padx=(16 if index else 0, 0))
|
||||
link.bind('<Button-1>', lambda _, u=url: webbrowser.open(u))
|
||||
ttk.Separator(main).pack(fill='x', padx=28)
|
||||
navigation = ttk.Frame(main, padding=(28, 10))
|
||||
navigation.pack(fill='x')
|
||||
self.nav_buttons = {}
|
||||
for key in ('journey', 'setup', 'image', 'controls', 'journal'):
|
||||
button = ttk.Button(navigation, text=self.t(key), style='Nav.TButton',
|
||||
command=lambda k=key: self.show_page(k))
|
||||
button.pack(side='left', padx=(0, 6))
|
||||
self.nav_buttons[key] = button
|
||||
self.content = ttk.Frame(main)
|
||||
self.content.columnconfigure(0, weight=1)
|
||||
self.content.rowconfigure(0, weight=1)
|
||||
self.pages = {}
|
||||
self.vars = {}
|
||||
left = ttk.Frame(main)
|
||||
left.grid(row=2, column=0, sticky='nsew', **pad)
|
||||
left.columnconfigure(0, weight=1)
|
||||
game = ttk.LabelFrame(left, text=self.t('game'), padding=8)
|
||||
game.grid(sticky='ew', pady=(0, 8))
|
||||
game.columnconfigure(0, weight=1)
|
||||
self.build_journey()
|
||||
|
||||
setup = self.settings_page('setup', 'setup_hint')
|
||||
game = self.card(setup, self.t('game'), 0, 0)
|
||||
for row, key in enumerate(('game_dir', 'system_lib_dir')):
|
||||
ttk.Label(game, text=self.t(key)).grid(row=row * 2, column=0, columnspan=2, sticky='w')
|
||||
ttk.Label(game, text=self.t(key), style='Card.TLabel').grid(
|
||||
row=row * 2, column=0, columnspan=2, sticky='w', pady=(0, 6))
|
||||
var = tk.StringVar(value=self.settings[key])
|
||||
var.trace_add('write', lambda *_: self.changed())
|
||||
self.vars[key] = var
|
||||
ttk.Entry(game, textvariable=var).grid(row=row * 2 + 1, column=0, sticky='ew', pady=(0, 6))
|
||||
ttk.Entry(game, textvariable=var, width=30).grid(
|
||||
row=row * 2 + 1, column=0, sticky='ew', pady=(0, 18))
|
||||
ttk.Button(game, text=self.t('browse'), command=lambda k=key: self.browse(k)).grid(
|
||||
row=row * 2 + 1, column=1, padx=(6, 0), pady=(0, 6))
|
||||
audio = ttk.LabelFrame(left, text=self.t('audio'), padding=8)
|
||||
audio.grid(sticky='ew', pady=(0, 8))
|
||||
self.check(audio, 0, 'native_audio')
|
||||
controls = ttk.LabelFrame(left, text=self.t('controls'), padding=8)
|
||||
controls.grid(sticky='ew')
|
||||
controls.columnconfigure(1, weight=1)
|
||||
self.check(controls, 0, 'mouse_enabled')
|
||||
self.check(controls, 1, 'mouse_invert_y')
|
||||
ttk.Label(controls, text=self.t('mouse_sensitivity')).grid(row=2, column=0, sticky='w', pady=(4, 0))
|
||||
sens = tk.DoubleVar(value=float(self.settings.get('mouse_sensitivity', 1.0)))
|
||||
self.vars['mouse_sensitivity'] = sens
|
||||
sens_spin = ttk.Spinbox(controls, from_=0.1, to=5.0, increment=0.1, format='%.2f', textvariable=sens,
|
||||
width=6, command=self.changed)
|
||||
sens_spin.grid(row=2, column=1, sticky='w', padx=6, pady=(4, 0))
|
||||
sens_spin.bind('<FocusOut>', lambda _: self.changed())
|
||||
ttk.Button(controls, text=self.t('remap'), command=self.remap_dialog).grid(row=2, column=2, sticky='e', pady=(4, 0))
|
||||
ttk.Label(controls, text=self.t('mouse_hint'), foreground='#777', wraplength=420, justify='left').grid(
|
||||
row=3, column=0, columnspan=3, sticky='w', pady=(2, 0))
|
||||
|
||||
middle = ttk.Frame(main)
|
||||
middle.grid(row=2, column=1, sticky='nsew', **pad)
|
||||
middle.columnconfigure(0, weight=1)
|
||||
lang = ttk.LabelFrame(middle, text=self.t('language'), padding=8)
|
||||
lang.grid(sticky='ew', pady=(0, 8))
|
||||
lang.columnconfigure(1, weight=1)
|
||||
row=row * 2 + 1, column=1, padx=(8, 0), pady=(0, 18))
|
||||
lang = self.card(setup, self.t('language'), 0, 1)
|
||||
self.game_lang_box = None
|
||||
self.build_game_language(lang)
|
||||
self.combo(lang, 1, 'ui_language', UI_LANGUAGES,
|
||||
lambda k: self.t('auto') if k == 'auto' else UI_LANGUAGE_NAMES[k], on_change=self.build)
|
||||
video = ttk.LabelFrame(middle, text=self.t('video'), padding=8)
|
||||
video.grid(sticky='ew')
|
||||
video.columnconfigure(1, weight=1)
|
||||
lambda k: self.t('auto') if k == 'auto' else UI_LANGUAGE_NAMES[k],
|
||||
on_change=self.build)
|
||||
audio = self.card(setup, self.t('audio'), 1, 0, span=2)
|
||||
self.check(audio, 0, 'native_audio')
|
||||
|
||||
image = self.settings_page('image', 'image_hint')
|
||||
video = self.card(image, self.t('video'), 0, 0)
|
||||
self.combo(video, 0, 'display_mode', DISPLAY_MODES, lambda k: self.t(k))
|
||||
self.check(video, 1, 'vsync')
|
||||
self.check(video, 2, 'show_fps')
|
||||
self.combo(video, 5, 'frame_rate', tlg_run.FRAME_RATES, lambda k: self.t(f'fps_{k}'))
|
||||
ttk.Label(video, text=self.t('fps_hint'), foreground='#777', wraplength=320).grid(
|
||||
row=6, column=0, columnspan=2, sticky='w')
|
||||
ttk.Label(video, text=self.t('vram'), wraplength=220).grid(row=3, column=0, sticky='w', pady=(6, 0))
|
||||
ttk.Label(video, text=self.t('vram'), wraplength=230, style='Card.TLabel').grid(
|
||||
row=3, column=0, sticky='w', pady=(12, 0))
|
||||
vram = tk.IntVar(value=int(self.settings['vram_budget_mb']))
|
||||
self.vars['vram_budget_mb'] = vram
|
||||
spin = ttk.Spinbox(video, from_=2000, to=24000, increment=200, textvariable=vram, width=8,
|
||||
command=self.changed)
|
||||
spin.grid(row=3, column=1, sticky='w', padx=6, pady=(6, 0))
|
||||
spin = ttk.Spinbox(video, from_=2000, to=24000, increment=200,
|
||||
textvariable=vram, width=8, command=self.changed)
|
||||
spin.grid(row=3, column=1, sticky='e', pady=(12, 0))
|
||||
spin.bind('<FocusOut>', lambda _: self.changed())
|
||||
ttk.Label(video, text=self.t('vram_hint'), foreground='#777', wraplength=360).grid(row=4, column=0, columnspan=2, sticky='w')
|
||||
self.build_filters(video, 7)
|
||||
ttk.Label(video, text=self.t('vram_hint'), style='Card.TLabel',
|
||||
foreground=self.colors['muted'], wraplength=420, justify='left').grid(
|
||||
row=4, column=0, columnspan=2, sticky='w', pady=(10, 18))
|
||||
self.combo(video, 5, 'frame_rate', tlg_run.FRAME_RATES, lambda k: self.t(f'fps_{k}'))
|
||||
ttk.Label(video, text=self.t('fps_hint'), style='Card.TLabel',
|
||||
foreground=self.colors['muted'], wraplength=420, justify='left').grid(
|
||||
row=6, column=0, columnspan=2, sticky='w', pady=(8, 0))
|
||||
self.combo(video, 7, 'resolution', tlg_run.RESOLUTIONS, tlg_run.resolution_label)
|
||||
ttk.Label(video, text=self.t('resolution_hint'), style='Card.TLabel',
|
||||
foreground=self.colors['muted'], wraplength=360, justify='left').grid(
|
||||
row=8, column=0, columnspan=2, sticky='w', pady=(8, 0))
|
||||
filters = self.card(image, self.t('image'), 1, 0, span=2)
|
||||
self.build_filters(filters, 0)
|
||||
debug = self.card(image, self.t('debug'), 2, 0, span=2)
|
||||
self.check(debug, 0, 'level_select')
|
||||
self.check(debug, 1, 'bridge_fix')
|
||||
ttk.Label(debug, text=self.t('debug_hint'), style='Card.TLabel',
|
||||
foreground=self.colors['muted'], wraplength=560, justify='left').grid(
|
||||
row=2, column=0, columnspan=3, sticky='w', pady=(10, 0))
|
||||
|
||||
log_frame = ttk.LabelFrame(main, text='Log', padding=4)
|
||||
log_frame.grid(row=2, column=2, sticky='nsew', **pad)
|
||||
self.log = tk.Text(log_frame, wrap='none', font=('TkFixedFont', 9), state='disabled', width=60)
|
||||
control_page = self.settings_page('controls', 'controls_hint')
|
||||
controls = self.card(control_page, self.t('controls'), 0, 0, span=2)
|
||||
self.check(controls, 0, 'mouse_enabled')
|
||||
self.check(controls, 1, 'mouse_invert_y')
|
||||
ttk.Label(controls, text=self.t('mouse_sensitivity'), style='Card.TLabel').grid(
|
||||
row=2, column=0, sticky='w', pady=(12, 0))
|
||||
sens = tk.DoubleVar(value=float(self.settings.get('mouse_sensitivity', 1.0)))
|
||||
self.vars['mouse_sensitivity'] = sens
|
||||
sens_spin = ttk.Spinbox(controls, from_=0.1, to=5.0, increment=0.1, format='%.2f',
|
||||
textvariable=sens, width=6, command=self.changed)
|
||||
sens_spin.grid(row=2, column=1, sticky='w', padx=12, pady=(12, 0))
|
||||
sens_spin.bind('<FocusOut>', lambda _: self.changed())
|
||||
ttk.Button(controls, text=self.t('remap'), command=self.remap_dialog).grid(
|
||||
row=2, column=2, sticky='e', pady=(12, 0))
|
||||
ttk.Label(controls, text=self.t('mouse_hint'), style='Card.TLabel',
|
||||
foreground=self.colors['muted'], wraplength=760).grid(
|
||||
row=3, column=0, columnspan=3, sticky='w', pady=(18, 0))
|
||||
self.combo(controls, 4, 'menu_confirm', ['cross', 'circle'],
|
||||
lambda k: 'Cross (X / A)' if k == 'cross' else 'Circle (O / B)')
|
||||
|
||||
journal = self.settings_page('journal', 'journal_hint')
|
||||
tools = ttk.Frame(journal)
|
||||
tools.grid(row=0, column=0, columnspan=2, sticky='ew', pady=(0, 14))
|
||||
for key, command in (('saves', lambda: self.open(tlg_run.USER_DIR / 'savedata')),
|
||||
('logs', lambda: self.open(tlg_run.LOG_DIR)),
|
||||
('mods', self.mods_dialog)):
|
||||
ttk.Button(tools, text=self.t(key), command=command).pack(side='left', padx=(0, 8))
|
||||
self.cache_button = ttk.Button(tools, text=self.t('clear_cache'), command=self.clear_cache)
|
||||
self.cache_button.pack(side='right')
|
||||
log_frame = ttk.Frame(journal)
|
||||
log_frame.grid(row=1, column=0, columnspan=2, sticky='nsew')
|
||||
journal.rowconfigure(1, weight=1)
|
||||
self.log = tk.Text(log_frame, wrap='none', font=('TkFixedFont', 10),
|
||||
background=self.colors['dark'], foreground='#d9e3d0',
|
||||
insertbackground=self.colors['light'], selectbackground='#45634f',
|
||||
selectforeground=self.colors['light'],
|
||||
relief='flat', borderwidth=0, highlightthickness=0,
|
||||
padx=18, pady=16, width=60, height=16)
|
||||
self.log.insert('1.0', old_log)
|
||||
self.log.configure(state='disabled')
|
||||
scroll = ttk.Scrollbar(log_frame, command=self.log.yview)
|
||||
self.log.configure(yscrollcommand=scroll.set)
|
||||
self.log.pack(side='left', fill='both', expand=True)
|
||||
scroll.pack(side='right', fill='y')
|
||||
horizontal = ttk.Scrollbar(log_frame, orient='horizontal', command=self.log.xview)
|
||||
self.log.configure(yscrollcommand=scroll.set, xscrollcommand=horizontal.set)
|
||||
log_frame.rowconfigure(0, weight=1)
|
||||
log_frame.columnconfigure(0, weight=1)
|
||||
self.log.grid(row=0, column=0, sticky='nsew')
|
||||
scroll.grid(row=0, column=1, sticky='ns')
|
||||
horizontal.grid(row=1, column=0, sticky='ew')
|
||||
|
||||
buttons = ttk.Frame(main)
|
||||
buttons.grid(row=3, column=0, columnspan=3, sticky='ew', pady=(10, 0))
|
||||
ttk.Button(buttons, text=self.t('quit'), command=self.quit).pack(side='left')
|
||||
style = ttk.Style()
|
||||
style.configure('Play.TButton', font=('TkDefaultFont', 13, 'bold'))
|
||||
self.play_button = ttk.Button(buttons, text=self.t('play'), style='Play.TButton', command=self.play)
|
||||
self.play_button.pack(side='right', ipadx=30, ipady=8)
|
||||
ttk.Button(buttons, text=self.t('logs'), command=lambda: self.open(tlg_run.LOG_DIR)).pack(side='right', padx=6)
|
||||
ttk.Button(buttons, text=self.t('mods'), command=self.mods_dialog).pack(side='right', padx=6)
|
||||
ttk.Button(buttons, text=self.t('saves'), command=lambda: self.open(tlg_run.USER_DIR / 'savedata')).pack(side='right')
|
||||
self.cache_button = ttk.Button(buttons, text=self.t('clear_cache'), command=self.clear_cache)
|
||||
self.cache_button.pack(side='right', padx=(0, 6))
|
||||
footer = ttk.Frame(main, padding=(28, 16))
|
||||
footer.pack(side='bottom', fill='x')
|
||||
ttk.Separator(main).pack(side='bottom', fill='x')
|
||||
self.content.pack(fill='both', expand=True)
|
||||
self.play_button = ttk.Button(footer, text='▷ ' + self.t('play'),
|
||||
style='Play.TButton', command=self.play)
|
||||
self.play_button.pack(side='right', padx=(24, 0))
|
||||
ttk.Button(footer, text=self.t('quit'), command=self.quit).pack(side='right')
|
||||
ttk.Button(footer, text=self.t('graphics'), command=self.graphics_dialog).pack(side='right', padx=(0, 8))
|
||||
status_frame = ttk.Frame(footer)
|
||||
status_frame.pack(side='left', fill='x', expand=True)
|
||||
ttk.Label(status_frame, text=self.t('automatic_save'), style='Eyebrow.TLabel').pack(anchor='w')
|
||||
self.status = ttk.Label(status_frame, text='', style='Muted.TLabel',
|
||||
wraplength=690, justify='left')
|
||||
self.status.pack(anchor='w', pady=(5, 0))
|
||||
status_frame.bind('<Configure>', lambda event: self.status.configure(
|
||||
wraplength=max(240, event.width)))
|
||||
self.show_page(current_page)
|
||||
self.refresh_status()
|
||||
|
||||
def show_page(self, key):
|
||||
self.current_page = key
|
||||
self.pages[key].tkraise()
|
||||
for name, button in self.nav_buttons.items():
|
||||
button.configure(style='Selected.Nav.TButton' if name == key else 'Nav.TButton')
|
||||
|
||||
def settings_page(self, key, hint):
|
||||
"""Scrollable pages keep every setting reachable at smaller window sizes."""
|
||||
page = ttk.Frame(self.content)
|
||||
page.grid(row=0, column=0, sticky='nsew')
|
||||
self.pages[key] = page
|
||||
heading = ttk.Frame(page, padding=(32, 18, 32, 20))
|
||||
heading.pack(fill='x')
|
||||
ttk.Label(heading, text=self.t(key), style='Heading.TLabel').pack(anchor='w')
|
||||
ttk.Label(heading, text=self.t(hint), style='Muted.TLabel').pack(anchor='w', pady=(5, 0))
|
||||
holder = ttk.Frame(page)
|
||||
holder.pack(fill='both', expand=True, padx=(24, 16), pady=(0, 24))
|
||||
canvas = tk.Canvas(holder, background=self.colors['paper'], highlightthickness=0)
|
||||
scroll = ttk.Scrollbar(holder, command=canvas.yview)
|
||||
canvas.configure(yscrollcommand=scroll.set)
|
||||
scroll.pack(side='right', fill='y')
|
||||
canvas.pack(side='left', fill='both', expand=True)
|
||||
body = ttk.Frame(canvas)
|
||||
window = canvas.create_window((0, 0), window=body, anchor='nw')
|
||||
body.columnconfigure((0, 1), weight=1, uniform='cards')
|
||||
body.bind('<Configure>', lambda _: canvas.configure(scrollregion=canvas.bbox('all')))
|
||||
|
||||
def resized(event):
|
||||
canvas.itemconfigure(window, width=event.width)
|
||||
canvas.itemconfigure(window, height=max(event.height, body.winfo_reqheight()))
|
||||
canvas.bind('<Configure>', resized)
|
||||
# Bind only this page's descendants: comboboxes and spinboxes keep their own wheel behavior.
|
||||
def wheel(event):
|
||||
if canvas.winfo_height() < body.winfo_reqheight():
|
||||
delta = (-1 if event.num == 4 else 1) if event.num in (4, 5) else -int(event.delta / 120)
|
||||
canvas.yview_scroll(delta, 'units')
|
||||
return 'break'
|
||||
def bind_wheel():
|
||||
def visit(widget):
|
||||
if not isinstance(widget, (ttk.Combobox, ttk.Spinbox, tk.Text)):
|
||||
for sequence in ('<MouseWheel>', '<Button-4>', '<Button-5>'):
|
||||
widget.bind(sequence, wheel)
|
||||
for child in widget.winfo_children():
|
||||
visit(child)
|
||||
visit(body)
|
||||
page.after_idle(bind_wheel)
|
||||
return body
|
||||
|
||||
def card(self, parent, title, row, column, span=1):
|
||||
card = ttk.LabelFrame(parent, text=' ' + title + ' ', padding=20)
|
||||
card.grid(row=row, column=column, columnspan=span, sticky='new', padx=8, pady=(6, 18))
|
||||
card.columnconfigure(0, weight=1)
|
||||
return card
|
||||
|
||||
def build_journey(self):
|
||||
page = ttk.Frame(self.content)
|
||||
page.grid(row=0, column=0, sticky='nsew')
|
||||
self.pages['journey'] = page
|
||||
canvas = tk.Canvas(page, background=self.colors['dark'], highlightthickness=0, height=430)
|
||||
canvas.pack(fill='both', expand=True)
|
||||
art = canvas.create_image(0, 0, image=self.banner, anchor='ne') if self.banner else None
|
||||
items = []
|
||||
items.append(canvas.create_text(56, 92, text=self.t('eyebrow'), fill='#c0ccab',
|
||||
font=(self.sans, 9, 'bold'), anchor='nw'))
|
||||
items.append(canvas.create_line(56, 123, 108, 123, fill='#a6b18f', width=2))
|
||||
items.append(canvas.create_text(52, 146, text='THE LAST\nGUARDIAN', fill='#f3efdf',
|
||||
font=(self.serif, 45), anchor='nw'))
|
||||
items.append(canvas.create_text(56, 318, text=self.t('hero_copy'), fill='#d4d9c9',
|
||||
font=(self.sans, 12), anchor='nw', width=420))
|
||||
items.append(canvas.create_text(56, 397, text=self.t('edition') + ' / HAYATOG',
|
||||
fill=self.colors['light'], font=(self.sans, 8), anchor='nw'))
|
||||
def resized(event):
|
||||
if art:
|
||||
canvas.coords(art, event.width, 0)
|
||||
scale = min(1.0, event.height / 490)
|
||||
offset = max(0, (event.height - 490) / 2)
|
||||
canvas.itemconfigure(items[2], font=(self.serif, max(23, round(45 * scale))))
|
||||
canvas.itemconfigure(items[3], font=(self.sans, max(10, round(12 * scale))))
|
||||
for item, y in zip(items, (92, 123, 146, 318, 397)):
|
||||
coords = canvas.coords(item)
|
||||
if len(coords) == 4:
|
||||
canvas.coords(item, 56, y * scale + offset, 108, y * scale + offset)
|
||||
else:
|
||||
canvas.coords(item, 52 if y == 146 else 56, y * scale + offset)
|
||||
canvas.bind('<Configure>', resized)
|
||||
note = ttk.Frame(page, padding=(32, 18))
|
||||
note.pack(fill='x')
|
||||
ttk.Label(note, text=self.t('home_note'), font=(self.serif, 15)).pack(anchor='w')
|
||||
help_text = ttk.Label(note, text=self.t('home_help'), style='Muted.TLabel', wraplength=1080)
|
||||
help_text.pack(anchor='w', pady=(5, 0))
|
||||
note.bind('<Configure>', lambda event: help_text.configure(wraplength=max(400, event.width - 64)))
|
||||
|
||||
def load_art(self):
|
||||
"""Window icon (the port's tlg.png) and a 480x135 banner (a band of pic1.png from the dump, when present)."""
|
||||
"""Local presentation asset, with the user's dump as a fallback; no network at startup."""
|
||||
self.banner = getattr(self, 'banner', None)
|
||||
if getattr(self, 'art_loaded', False):
|
||||
return
|
||||
self.art_loaded = True
|
||||
icon = tlg_run.PORT_DIR / 'launcher' / 'tlg.png'
|
||||
try:
|
||||
icon = tlg_run.PORT_DIR / 'launcher' / 'tlg.png' # the port's own icon
|
||||
if icon.is_file():
|
||||
self.icon = tk.PhotoImage(file=str(icon))
|
||||
self.root.iconphoto(True, self.icon)
|
||||
picture = art_path(self.settings, 'pic1.png') or art_path(self.settings, 'pic0.png')
|
||||
if picture:
|
||||
full = tk.PhotoImage(file=str(picture))
|
||||
band = full.width() * 9 // 32 # a 32:9 band from the middle of the image
|
||||
top = (full.height() - band) // 2
|
||||
self.banner = tk.PhotoImage()
|
||||
self.banner.tk.call(self.banner, 'copy', full, '-from', 0, top, full.width(), top + band,
|
||||
'-subsample', 4, 4) # Tk command: same on every Python version
|
||||
except tk.TclError:
|
||||
self.banner = None
|
||||
pass
|
||||
picture = tlg_run.PORT_DIR / 'launcher' / 'assets' / 'guardian-hero.png'
|
||||
if not picture.is_file():
|
||||
picture = art_path(self.settings, 'pic1.png') or art_path(self.settings, 'pic0.png')
|
||||
if picture:
|
||||
try:
|
||||
self.banner = tk.PhotoImage(file=str(picture))
|
||||
except tk.TclError:
|
||||
self.banner = None
|
||||
|
||||
def build_filters(self, parent, row):
|
||||
"""SMAA, sharpening and color adjustments, read from and written to tlg.ini right away."""
|
||||
ttk.Separator(parent).grid(row=row, column=0, columnspan=2, sticky='ew', pady=(8, 4))
|
||||
ttk.Label(parent, text=self.t('filters'), foreground='#555', wraplength=360).grid(
|
||||
ttk.Label(parent, text=self.t('filters'), style='Card.TLabel',
|
||||
foreground=self.colors['muted'], wraplength=360).grid(
|
||||
row=row + 1, column=0, columnspan=2, sticky='w')
|
||||
ini = dict(INI_DEFAULTS)
|
||||
ini.update({k: v for k, v in tlg_run.read_ini(tlg_run.USER_DIR / 'tlg.ini').items() if k in ini})
|
||||
self.ini_vars = {}
|
||||
for offset, key in ((2, 'smaa'), (4, 'color_adjust')):
|
||||
for offset, key in ((2, 'smaa'), (4, 'color_adjust'), (9, 'dlaa')):
|
||||
var = tk.BooleanVar(value=ini[key] not in ('0', ''))
|
||||
self.ini_vars[key] = var
|
||||
ttk.Checkbutton(parent, text=self.t(key), variable=var, command=self.filters_changed).grid(
|
||||
row=row + offset, column=0, columnspan=2, sticky='w', pady=2)
|
||||
ttk.Label(parent, text=self.t('sharpening')).grid(row=row + 3, column=0, sticky='w')
|
||||
ttk.Label(parent, text=self.t('sharpening'), style='Card.TLabel').grid(row=row + 3, column=0, sticky='w')
|
||||
self.sharpener = ttk.Combobox(parent, values=[self.t('sharpen_' + k) for k in SHARPENERS],
|
||||
state='readonly', width=16)
|
||||
self.sharpener.current(2 if ini['cas'] not in ('0', '') else 1 if ini['rcas'] not in ('0', '') else 0)
|
||||
@@ -381,7 +704,8 @@ class Launcher:
|
||||
self.sharpener.bind('<<ComboboxSelected>>', lambda _: self.filters_changed())
|
||||
for index, key in enumerate(COLOR_RANGES):
|
||||
low, high = COLOR_RANGES[key]
|
||||
ttk.Label(parent, text=self.t(key)).grid(row=row + 5 + index, column=0, sticky='w', padx=(16, 0))
|
||||
ttk.Label(parent, text=self.t(key), style='Card.TLabel', wraplength=230).grid(
|
||||
row=row + 5 + index, column=0, sticky='w', padx=(16, 0))
|
||||
var = tk.StringVar(value=ini[key])
|
||||
self.ini_vars[key] = var
|
||||
spin = ttk.Spinbox(parent, from_=low, to=high, increment=0.05, format='%.2f', textvariable=var,
|
||||
@@ -390,7 +714,7 @@ class Launcher:
|
||||
spin.bind('<FocusOut>', lambda _: self.filters_changed())
|
||||
|
||||
def filters_changed(self):
|
||||
values = {'smaa': int(self.ini_vars['smaa'].get()), 'color_adjust': int(self.ini_vars['color_adjust'].get()),
|
||||
values = {'dlaa': int(self.ini_vars['dlaa'].get()), 'smaa': int(self.ini_vars['smaa'].get()), 'color_adjust': int(self.ini_vars['color_adjust'].get()),
|
||||
'rcas': int(self.sharpener.current() == 1), 'cas': int(self.sharpener.current() == 2)}
|
||||
for key, (low, high) in COLOR_RANGES.items():
|
||||
try:
|
||||
@@ -402,12 +726,13 @@ class Launcher:
|
||||
tlg_run.update_ini(tlg_run.USER_DIR / 'tlg.ini', values)
|
||||
|
||||
def combo(self, parent, row, key, values, label, on_change=None):
|
||||
ttk.Label(parent, text=self.t(key)).grid(row=row, column=0, sticky='w')
|
||||
ttk.Label(parent, text=self.t(key), style='Card.TLabel', wraplength=185).grid(
|
||||
row=row, column=0, sticky='w', pady=8)
|
||||
names = [label(v) for v in values]
|
||||
box = ttk.Combobox(parent, values=names, state='readonly', width=28)
|
||||
box = ttk.Combobox(parent, values=names, state='readonly', width=22)
|
||||
current = self.settings.get(key, values[0])
|
||||
box.current(values.index(current) if current in values else 0)
|
||||
box.grid(row=row, column=1, sticky='w', padx=6, pady=2)
|
||||
box.grid(row=row, column=1, sticky='ew', padx=(12, 0), pady=8)
|
||||
|
||||
def picked(_):
|
||||
self.settings[key] = values[box.current()]
|
||||
@@ -422,9 +747,10 @@ class Launcher:
|
||||
# -- game language (depends on the loaded CUSA) ---------------------------------------------
|
||||
def build_game_language(self, parent):
|
||||
"""Game language: the ones the loaded CUSA ships (by its title id), US by default."""
|
||||
ttk.Label(parent, text=self.t('game_language')).grid(row=0, column=0, sticky='w')
|
||||
self.game_lang_box = ttk.Combobox(parent, state='readonly', width=28)
|
||||
self.game_lang_box.grid(row=0, column=1, sticky='w', padx=6, pady=2)
|
||||
ttk.Label(parent, text=self.t('game_language'), style='Card.TLabel', wraplength=185).grid(
|
||||
row=0, column=0, sticky='w', pady=8)
|
||||
self.game_lang_box = ttk.Combobox(parent, state='readonly', width=22)
|
||||
self.game_lang_box.grid(row=0, column=1, sticky='ew', padx=(12, 0), pady=8)
|
||||
self.game_lang_box.bind('<<ComboboxSelected>>', self.picked_language)
|
||||
self.refresh_language_choices()
|
||||
|
||||
@@ -438,7 +764,7 @@ class Launcher:
|
||||
"""Reload the combo for the game folder: another CUSA ships another set of languages."""
|
||||
if getattr(self, 'game_lang_box', None) is None:
|
||||
return
|
||||
values = list(tlg_run.game_languages(self.settings.get('game_dir')))
|
||||
values = [key for key in tlg_run.game_language_choices(self.settings) if key != 'auto']
|
||||
self.game_lang_values = values
|
||||
self.game_lang_box.configure(values=[GAME_LANGUAGE_NAMES.get(v, v) for v in values])
|
||||
current = self.settings.get('game_language')
|
||||
@@ -457,6 +783,7 @@ class Launcher:
|
||||
self.remap_window.lift()
|
||||
return
|
||||
win = self.remap_window = tk.Toplevel(self.root)
|
||||
win.configure(background=self.colors['paper'])
|
||||
win.title(self.t('remap_title'))
|
||||
win.transient(self.root)
|
||||
frame = ttk.Frame(win, padding=12)
|
||||
@@ -511,9 +838,9 @@ class Launcher:
|
||||
set_button = ttk.Button(frame, text=self.t('set'), width=10)
|
||||
set_button.configure(command=lambda b=button, e=entry, s=set_button: capture(b, e, s))
|
||||
set_button.grid(row=row, column=column + 2, padx=4, pady=1)
|
||||
ttk.Label(frame, text=tlg_run.DEFAULT_KEYS[button], foreground='#888').grid(
|
||||
ttk.Label(frame, text=tlg_run.DEFAULT_KEYS[button], foreground=self.colors['muted']).grid(
|
||||
row=row, column=column + 3, sticky='w', padx=(0, 18))
|
||||
ttk.Label(frame, text=self.t('remap_help'), foreground='#555', wraplength=760, justify='left').grid(
|
||||
ttk.Label(frame, text=self.t('remap_help'), foreground=self.colors['muted'], wraplength=760, justify='left').grid(
|
||||
row=half + 1, column=0, columnspan=8, sticky='w', pady=(10, 6))
|
||||
bottom = ttk.Frame(frame)
|
||||
bottom.grid(row=half + 2, column=0, columnspan=8, sticky='ew')
|
||||
@@ -537,6 +864,7 @@ class Launcher:
|
||||
self.mods_window.lift()
|
||||
return
|
||||
win = self.mods_window = tk.Toplevel(self.root)
|
||||
win.configure(background=self.colors['paper'])
|
||||
win.title(self.t('mods_title'))
|
||||
win.transient(self.root)
|
||||
win.minsize(560, 360)
|
||||
@@ -548,7 +876,7 @@ class Launcher:
|
||||
|
||||
list_frame = ttk.Frame(frame)
|
||||
list_frame.pack(fill='both', expand=True)
|
||||
canvas = tk.Canvas(list_frame, highlightthickness=0)
|
||||
canvas = tk.Canvas(list_frame, background=self.colors['paper'], highlightthickness=0)
|
||||
scroll = ttk.Scrollbar(list_frame, orient='vertical', command=canvas.yview)
|
||||
rows = ttk.Frame(canvas)
|
||||
rows.bind('<Configure>', lambda _: canvas.configure(scrollregion=canvas.bbox('all')))
|
||||
@@ -569,7 +897,7 @@ class Launcher:
|
||||
mods = []
|
||||
ttk.Label(rows, text=str(error), foreground='#a33').pack(anchor='w')
|
||||
if not mods:
|
||||
ttk.Label(rows, text=self.t('mods_empty'), foreground='#777').pack(anchor='w', pady=8)
|
||||
ttk.Label(rows, text=self.t('mods_empty'), foreground=self.colors['muted']).pack(anchor='w', pady=8)
|
||||
for key, name, note, enabled in mods:
|
||||
row = ttk.Frame(rows)
|
||||
row.pack(fill='x', anchor='w', pady=2)
|
||||
@@ -577,7 +905,7 @@ class Launcher:
|
||||
ttk.Checkbutton(row, text=name or key, variable=var,
|
||||
command=lambda k=key, v=var: toggle(k, v)).pack(anchor='w')
|
||||
if note:
|
||||
ttk.Label(row, text=note, foreground='#777', wraplength=480, justify='left').pack(
|
||||
ttk.Label(row, text=note, foreground=self.colors['muted'], wraplength=480, justify='left').pack(
|
||||
anchor='w', padx=(22, 0))
|
||||
populate()
|
||||
|
||||
@@ -587,6 +915,83 @@ class Launcher:
|
||||
ttk.Button(bottom, text=self.t('mods_refresh'), command=populate).pack(side='left', padx=6)
|
||||
ttk.Button(bottom, text=self.t('close'), command=win.destroy).pack(side='right')
|
||||
|
||||
def graphics_dialog(self):
|
||||
"""HDR, shadow resolution and the game's effect strengths (the in-game menu's, Insert),
|
||||
written to tlg.ini right away."""
|
||||
if getattr(self, 'graphics_window', None) and self.graphics_window.winfo_exists():
|
||||
self.graphics_window.lift()
|
||||
return
|
||||
win = self.graphics_window = tk.Toplevel(self.root)
|
||||
win.title(self.t('graphics_title'))
|
||||
win.transient(self.root)
|
||||
frame = ttk.Frame(win, padding=12)
|
||||
frame.pack(fill='both', expand=True)
|
||||
frame.columnconfigure(1, weight=1)
|
||||
ini = tlg_run.read_ini(tlg_run.USER_DIR / 'tlg.ini')
|
||||
|
||||
def number(key, default):
|
||||
try:
|
||||
return int(ini.get(key, default))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
def write(values):
|
||||
tlg_run.USER_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tlg_run.update_ini(tlg_run.USER_DIR / 'tlg.ini', values)
|
||||
|
||||
def hint(row, key):
|
||||
ttk.Label(frame, text=self.t(key), foreground='#777', wraplength=560, justify='left').grid(
|
||||
row=row, column=0, columnspan=3, sticky='w', pady=(0, 6))
|
||||
|
||||
ttk.Label(frame, text=self.t('graphics_hint'), wraplength=560, justify='left').grid(
|
||||
row=0, column=0, columnspan=3, sticky='w', pady=(0, 8))
|
||||
hdr = tk.BooleanVar(value=number('hdr', 1) != 0)
|
||||
ttk.Checkbutton(frame, text='HDR', variable=hdr, command=lambda: write({'hdr': int(hdr.get())})).grid(
|
||||
row=1, column=0, columnspan=3, sticky='w')
|
||||
hint(2, 'hdr_hint')
|
||||
ttk.Label(frame, text=self.t('shadow_scale')).grid(row=3, column=0, sticky='w')
|
||||
shadow = ttk.Combobox(frame, values=[self.t('shadow_game'), '4096', '8192'], state='readonly', width=14)
|
||||
scale = number('shadow_scale', 1)
|
||||
shadow.current(2 if scale >= 4 else 1 if scale >= 2 else 0)
|
||||
shadow.grid(row=3, column=1, sticky='w', padx=6)
|
||||
shadow.bind('<<ComboboxSelected>>', lambda _: write({'shadow_scale': SHADOW_SCALES[shadow.current()]}))
|
||||
hint(4, 'shadow_hint')
|
||||
ttk.Separator(frame).grid(row=5, column=0, columnspan=3, sticky='ew', pady=4)
|
||||
ttk.Label(frame, text=self.t('effects'), foreground='#555').grid(row=6, column=0, columnspan=3, sticky='w')
|
||||
|
||||
sliders = {}
|
||||
for index, (key, (default, low, high)) in enumerate(EFFECTS.items()):
|
||||
row = 7 + index * 2
|
||||
value = min(max(number(key, default), low), high)
|
||||
ttk.Label(frame, text=self.t('effect_' + key)).grid(row=row, column=0, sticky='w')
|
||||
shown = ttk.Label(frame, text=f'{value}%', width=5)
|
||||
shown.grid(row=row, column=2, sticky='e')
|
||||
|
||||
def moved(raw, key=key, shown=shown, current={'value': value}):
|
||||
step = int(round(float(raw) / 5) * 5) # 5% steps
|
||||
shown.configure(text=f'{step}%')
|
||||
if step != current['value']:
|
||||
current['value'] = step
|
||||
write({key: step})
|
||||
|
||||
slider = ttk.Scale(frame, from_=low, to=high, value=value, length=260, command=moved)
|
||||
slider.grid(row=row, column=1, sticky='ew', padx=6)
|
||||
sliders[key] = (slider, moved)
|
||||
hint(row + 1, 'effect_' + key + '_hint')
|
||||
|
||||
def reset():
|
||||
hdr.set(True)
|
||||
shadow.current(0)
|
||||
write({'hdr': 1, 'shadow_scale': 1})
|
||||
for key, (slider, moved) in sliders.items():
|
||||
slider.set(EFFECTS[key][0])
|
||||
moved(EFFECTS[key][0])
|
||||
|
||||
bottom = ttk.Frame(frame)
|
||||
bottom.grid(row=7 + len(EFFECTS) * 2, column=0, columnspan=3, sticky='ew', pady=(10, 0))
|
||||
ttk.Button(bottom, text=self.t('reset'), command=reset).pack(side='left')
|
||||
ttk.Button(bottom, text=self.t('close'), command=win.destroy).pack(side='right')
|
||||
|
||||
def check(self, parent, row, key):
|
||||
var = tk.BooleanVar(value=bool(self.settings[key]))
|
||||
self.vars[key] = var
|
||||
@@ -672,17 +1077,21 @@ class Launcher:
|
||||
def run_game(self):
|
||||
try:
|
||||
tlg_run.prepare(self.settings, log=lambda line: self.lines.put(line))
|
||||
self.process, log = tlg_run.launch(self.settings)
|
||||
header = [f'Frame rate: requested {tlg_run.frame_rate(self.settings)}, '
|
||||
f'applied {tlg_run.applied_frame_rate}']
|
||||
self.process, log = tlg_run.launch(self.settings, header=header)
|
||||
except Exception as error: # shown to the user, the launcher keeps running
|
||||
self.lines.put(('error', str(error)))
|
||||
return
|
||||
self.lines.put(f"{self.t('running')} {log}")
|
||||
with open(log, 'w', encoding='utf-8') as out:
|
||||
out.write(f'Frame rate: requested {tlg_run.frame_rate(self.settings)}, applied {tlg_run.applied_frame_rate}\n')
|
||||
for line in self.process.stdout:
|
||||
out.write(line)
|
||||
out.flush()
|
||||
self.lines.put(line.rstrip('\n'))
|
||||
tail = logtail.LogTail(log)
|
||||
while True: # the game writes its own log file; this only follows it
|
||||
done = self.process.poll() is not None
|
||||
for line in tail.poll(final=done):
|
||||
self.lines.put(line)
|
||||
if done:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
code = self.process.wait()
|
||||
self.process = None
|
||||
self.lines.put(('ended', code))
|
||||
|
||||
+124
-38
@@ -17,6 +17,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -32,14 +33,18 @@ PORT_DIR = Path(sys.executable).resolve().parent if FROZEN else Path(__file__).r
|
||||
sys.path.insert(0, str(PORT_DIR / 'scripts'))
|
||||
import mod_patches # noqa: E402
|
||||
REPO_DIR = PORT_DIR.parent
|
||||
OUT_DIR = PORT_DIR / 'out'
|
||||
|
||||
# Writeable dirs (out/, user/ by default): a read-only mount (the Linux AppImage, a sandboxed package) has
|
||||
# nowhere to write under PORT_DIR, so TLG_DATA_DIR moves them to a writable root; the shipped
|
||||
# binaries and libraries stay in the package (PORT_OUT_DIR).
|
||||
DATA_DIR = Path(os.environ['TLG_DATA_DIR']) if os.environ.get('TLG_DATA_DIR') else PORT_DIR
|
||||
OUT_DIR = DATA_DIR / 'out'
|
||||
PORT_OUT_DIR = PORT_DIR / 'out'
|
||||
|
||||
def user_dir():
|
||||
"""The folder of saves, tlg.ini, mods, backups and GPU caches: TLG_USER_DIR, else the per-user
|
||||
pointer file (survives reinstalling; the Qt launcher's Saves screen sets it), else
|
||||
<port folder>/user (userdata.py has the rules). Always call this instead of caching it."""
|
||||
return userdata.user_dir(PORT_DIR)
|
||||
return userdata.user_dir(Path(os.environ['TLG_DATA_DIR']) if os.environ.get('TLG_DATA_DIR') else PORT_DIR)
|
||||
|
||||
|
||||
def __getattr__(name): # USER_DIR (the Tk launcher, older code) is the current folder
|
||||
@@ -47,9 +52,8 @@ def __getattr__(name): # USER_DIR (the Tk launcher, older code) is the current
|
||||
return user_dir()
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
LOG_DIR = OUT_DIR / 'logs'
|
||||
PROBE = OUT_DIR / ('tlg-probe.exe' if os.name == 'nt' else 'tlg-probe')
|
||||
PROBE = PORT_OUT_DIR / ('tlg-probe.exe' if os.name == 'nt' else 'tlg-probe')
|
||||
KEEP_LOGS = 30
|
||||
|
||||
# Game language: the engine's own ids (NOT the PS4 system ones), looked up through
|
||||
@@ -79,8 +83,23 @@ def default_language(game_dir=''):
|
||||
return DEFAULT_LANGUAGE_BY_TITLE.get(title, 'es_LA')
|
||||
|
||||
|
||||
# Every language key either disc has (the Qt launcher's names table covers them all).
|
||||
GAME_LANGUAGES = {**LANGUAGES_US, **LANGUAGES_EU, **LANGUAGES_ASIA}
|
||||
JAPANESE_FALLBACK_LABEL = '日本語 (EU 1.03 text fallback)'
|
||||
|
||||
|
||||
def japanese_fallback_available(game_dir):
|
||||
"""Only the verified EU 01.03 asset layout; not Japanese-region disc support."""
|
||||
if not game_dir or shader_cache.title_id(game_dir) != 'CUSA03745':
|
||||
return False
|
||||
try:
|
||||
return (mod_patches.game_app_version(game_dir) == '01.03' and
|
||||
all((Path(game_dir) / name).is_file()
|
||||
for name in ('trico_boot.psarc', 'trico_bootp.psarc')))
|
||||
except (OSError, ValueError, struct.error):
|
||||
return False
|
||||
|
||||
|
||||
# 'ja' is an explicit embedded-asset fallback, not native engine language 0.
|
||||
GAME_LANGUAGES = {**LANGUAGES_US, **LANGUAGES_EU, **LANGUAGES_ASIA, 'ja': 2}
|
||||
# The closest choice when the system's language is missing from the disc.
|
||||
_LANGUAGE_SIBLING = {'pt_BR': 'pt_PT', 'pt_PT': 'pt_BR', 'es_LA': 'es_ES', 'es_ES': 'es_LA',
|
||||
'fr_CA': 'fr', 'fr': 'fr_CA'}
|
||||
@@ -121,6 +140,8 @@ def game_language_choices(settings):
|
||||
"""The game language box of the Qt launcher: 'auto', the languages the disc has and the saved
|
||||
choice even when the disc lacks it (never changed behind the player's back)."""
|
||||
keys = ['auto'] + list(game_languages(settings.get('game_dir', '')))
|
||||
if japanese_fallback_available(settings.get('game_dir', '')):
|
||||
keys.append('ja')
|
||||
choice = settings.get('game_language', 'auto')
|
||||
return keys + [choice] if choice in GAME_LANGUAGES and choice not in keys else keys
|
||||
|
||||
@@ -130,6 +151,8 @@ def resolve_game_language(settings, environ=None):
|
||||
system (or its closest variant); anything else is English, as before."""
|
||||
table = game_languages(settings.get('game_dir', ''))
|
||||
choice = settings.get('game_language', 'auto')
|
||||
if choice == 'ja' and japanese_fallback_available(settings.get('game_dir', '')):
|
||||
return 'ja'
|
||||
if choice in table:
|
||||
return choice
|
||||
if choice == 'auto':
|
||||
@@ -139,14 +162,29 @@ def resolve_game_language(settings, environ=None):
|
||||
return key
|
||||
return 'en'
|
||||
|
||||
|
||||
def game_language_environment(settings, environ=None):
|
||||
"""Select the internal French id plus a non-destructive guest archive mask for Japanese.
|
||||
|
||||
Explicit engine-language overrides still win; a different override disables the mask.
|
||||
Never inherit a stale mask from a previous launch when another language is selected.
|
||||
"""
|
||||
environ = os.environ if environ is None else environ
|
||||
language = resolve_game_language(settings, environ)
|
||||
engine_id = 2 if language == 'ja' else game_languages(settings.get('game_dir')).get(language, 1)
|
||||
selected = environ.get('TLG_LANGUAGE', str(engine_id))
|
||||
return {'TLG_LANGUAGE': selected,
|
||||
'TLG_JAPANESE_FALLBACK': '1' if language == 'ja' and selected == '2' else '0'}
|
||||
|
||||
|
||||
# TLG defaults (see README "Opções" and the reasons in each comment).
|
||||
DEFAULT_ENV = {
|
||||
'TLG_PIPELINE_BINARIES': '0', # VK_KHR_pipeline_binary crashes on the NVIDIA 595 driver
|
||||
'TLG_READBACKS': '2', # the game reads GPU compute results on the CPU (AsyncMap)
|
||||
'TLG_READBACK_LINEAR': '1', # small/linear render targets back to the CPU: auto exposure (#38, #40)
|
||||
'TLG_GC_BUDGET_MB': '5600', # texture cache budget for 6 GB of VRAM
|
||||
'TLG_UPSCALER': 'off', # native image: GoW3's temporal upscalers made it shake
|
||||
'TLG_JITTER': '0',
|
||||
# The anti-aliasing comes from tlg.ini (dlaa, upscaler: DLAA or FSR 3.1 native AA).
|
||||
'TLG_JITTER': '1', # jitter only while DLAA or FSR 3.1 native AA runs
|
||||
'TLG_OBJECT_MOTION': '0',
|
||||
'TLG_FRAME_STATS': '1', # FPS in the log every 5 s (scripts/watch_session.sh heartbeat)
|
||||
}
|
||||
@@ -161,6 +199,8 @@ DEFAULT_SETTINGS = {
|
||||
'show_fps': False,
|
||||
'display_mode': 'Windowed', # Windowed | Borderless | Fullscreen (tlg.ini)
|
||||
'vsync': True,
|
||||
'level_select': False, # tlg.ini; tlg_lua.cpp
|
||||
'bridge_fix': False,
|
||||
'vram_budget_mb': 5600,
|
||||
'native_audio': True,
|
||||
# Mouse and keyboard (tlg.ini; the in-game menu's Controls tab changes the mouse ones too).
|
||||
@@ -168,15 +208,25 @@ DEFAULT_SETTINGS = {
|
||||
'mouse_sensitivity': 1.0,
|
||||
'mouse_invert_y': False,
|
||||
'key_map': {}, # PS4 button -> 'Key|Mouse Left' (empty: the default)
|
||||
'menu_confirm': 'cross', # cross | circle; menu-only, applied on next launch
|
||||
'frame_rate': '30', # 30 | 60 | unlocked (scripts/tlg_patches.py; TLG_FPS wins)
|
||||
'gpu_readbacks': '2', # TLG_READBACKS: 2 precise (the game reads GPU compute results), 0 fast
|
||||
'stick_deadzone': 10, # percent; runtime_pad.c TLG_STICK_DEADZONE
|
||||
'close_on_play': True, # Qt launcher: close about 2 s after the game starts
|
||||
'reopen_launcher': True, # ... and open again when the game exits
|
||||
'resolution': '1920x1080', # WxH (scripts/tlg_patches.py; TLG_RESOLUTION wins)
|
||||
}
|
||||
FRAME_RATES = ['30', '60', 'unlocked']
|
||||
# 16:9, then ultrawide 21:9 and super ultrawide 32:9 (Hor+ and a widened UI).
|
||||
RESOLUTIONS = ['1280x720', '1920x1080', '2560x1440', '3840x2160',
|
||||
'2560x1080', '3440x1440', '3840x1600', '5120x2160',
|
||||
'3840x1080', '5120x1440']
|
||||
# The frame rate mode prepare() really applied (another eboot build stays at 30).
|
||||
applied_frame_rate = '30'
|
||||
# MiB the Garlic heap patch added to the game's GPU heap (0: none); the direct memory grows with it.
|
||||
applied_garlic_extra = 0
|
||||
# The size the resolution patch really applied (None: the game's own 1920x1080).
|
||||
applied_resolution = None
|
||||
|
||||
# Keyboard and mouse defaults of src/runtime_pad.c, in its key_map syntax ('|': several).
|
||||
DEFAULT_KEYS = {
|
||||
@@ -185,7 +235,7 @@ DEFAULT_KEYS = {
|
||||
'touchpad_left': 'Tab', 'touchpad_right': 'Backspace', 'up': 'I', 'down': 'K', 'left': 'J', 'right': 'L',
|
||||
}
|
||||
# Synced with tlg.ini both ways: the in-game menu saves them there.
|
||||
INI_KEYS = ('mouse_enabled', 'mouse_sensitivity', 'mouse_invert_y', 'key_map')
|
||||
INI_KEYS = ('mouse_enabled', 'mouse_sensitivity', 'mouse_invert_y', 'key_map', 'menu_confirm')
|
||||
|
||||
|
||||
def key_map_spec(mapping):
|
||||
@@ -239,9 +289,16 @@ def load_settings():
|
||||
pass
|
||||
if 'key_map' in ini:
|
||||
settings['key_map'] = parse_key_map(ini['key_map'])
|
||||
if 'menu_confirm' in ini:
|
||||
settings['menu_confirm'] = ini['menu_confirm']
|
||||
return settings
|
||||
|
||||
|
||||
def menu_confirm(settings):
|
||||
value = os.environ.get('TLG_MENU_CONFIRM', settings.get('menu_confirm', 'cross'))
|
||||
return 'circle' if value == 'circle' else 'cross'
|
||||
|
||||
|
||||
def save_settings(settings):
|
||||
path = config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -419,6 +476,19 @@ def frame_rate(settings):
|
||||
return os.environ.get('TLG_FPS') or str(settings.get('frame_rate', '30'))
|
||||
|
||||
|
||||
def resolution(settings):
|
||||
"""The requested render and output size "WxH": TLG_RESOLUTION, else the launcher's setting."""
|
||||
return os.environ.get('TLG_RESOLUTION') or str(settings.get('resolution', '1920x1080'))
|
||||
|
||||
|
||||
def resolution_label(size):
|
||||
"""'3440x1440 (21:9)': ultrawide sizes are 64:27 (2560x1080) or 43:18 (3440x1440), sold as 21:9."""
|
||||
width, height = (int(n) for n in size.split('x'))
|
||||
ratio = width / height
|
||||
name = '32:9' if ratio > 3.4 else '21:9' if ratio > 2.2 else '16:9'
|
||||
return f'{width} × {height} ({name})'
|
||||
|
||||
|
||||
def mods_dir():
|
||||
"""TLG_MODS_DIR, else user_dir()/mods (created empty on first use so the player sees where
|
||||
to drop shadPS4/GoldHEN cheat XML files)."""
|
||||
@@ -458,7 +528,9 @@ def set_mod_enabled(key, enabled):
|
||||
def prepare(settings, log=print):
|
||||
"""Builds out/boot-linked.bin, out/content.bin and out/patches.bin (frame rate patches and
|
||||
any enabled mod); returns the module list that was linked."""
|
||||
global applied_frame_rate
|
||||
global applied_frame_rate, applied_garlic_extra, applied_resolution
|
||||
applied_garlic_extra = 0
|
||||
applied_resolution = None
|
||||
game = settings['game_dir']
|
||||
scripts = PORT_DIR / 'scripts'
|
||||
modules = ['libc.prx', 'libSceFios2.prx']
|
||||
@@ -470,7 +542,8 @@ def prepare(settings, log=print):
|
||||
[*python_command(scripts / 'link_modules.py'), game, '--out', str(OUT_DIR), '--modules', *modules],
|
||||
[*python_command(scripts / 'content_profile.py'), game, '--out', str(OUT_DIR)],
|
||||
[*python_command(scripts / 'tlg_patches.py'), game, '--out', str(OUT_DIR), '--mode', frame_rate(settings),
|
||||
'--mods-dir', str(mods_dir()), '--mods-config', str(mods_config())],
|
||||
'--mods-dir', str(mods_dir()), '--mods-config', str(mods_config()), '--resolution', resolution(settings),
|
||||
'--menu-confirm', menu_confirm(settings)],
|
||||
]
|
||||
for step in steps:
|
||||
result = subprocess.run(step, cwd=str(scripts), capture_output=True, text=True,
|
||||
@@ -482,6 +555,11 @@ def prepare(settings, log=print):
|
||||
log(line)
|
||||
if line.startswith('Frame rate: ') and line.endswith(' patch writes'):
|
||||
applied_frame_rate = line.split()[2]
|
||||
if line.startswith('Patch garlic: '):
|
||||
words = line.split() # '... GPU heap 2816 -> N MB (bytes)'
|
||||
applied_garlic_extra = int(words[-3]) - int(words[-5])
|
||||
if line.startswith('Resolution patch resolution: '):
|
||||
applied_resolution = line.split()[-2] # '... 1920x1080 -> WxH (bytes)'
|
||||
isa_table(log)
|
||||
return modules
|
||||
|
||||
@@ -584,10 +662,10 @@ def build_env(settings, extra=None):
|
||||
env.setdefault(key, value) # explicit TLG_* in the environment always win
|
||||
env['TLG_GC_BUDGET_MB'] = os.environ.get('TLG_GC_BUDGET_MB', str(int(settings['vram_budget_mb'])))
|
||||
env['TLG_READBACKS'] = os.environ.get('TLG_READBACKS', str(settings.get('gpu_readbacks', '2')))
|
||||
env['TLG_MENU_CONFIRM'] = menu_confirm(settings)
|
||||
env['TLG_STICK_DEADZONE'] = os.environ.get(
|
||||
'TLG_STICK_DEADZONE', f"{int(settings.get('stick_deadzone', 10)) / 100:.2f}")
|
||||
env['TLG_LANGUAGE'] = os.environ.get('TLG_LANGUAGE', str(
|
||||
game_languages(settings.get('game_dir')).get(resolve_game_language(settings), 1)))
|
||||
env.update(game_language_environment(settings))
|
||||
if settings.get('ui_language', 'auto') != 'auto':
|
||||
env['TLG_UI_LANG'] = settings['ui_language']
|
||||
# Without the console module the port's own mixer (runtime_ngs2.c) plays the audio.
|
||||
@@ -604,6 +682,12 @@ def build_env(settings, extra=None):
|
||||
env['TLG_ENGINE_FPS'] = {'30': '30', '60': '60'}.get(applied_frame_rate, '240')
|
||||
if applied_frame_rate == 'unlocked':
|
||||
env.setdefault('TLG_VBLANK_HZ', '0')
|
||||
# The larger Garlic heap comes out of direct memory (runtime_memory.c: 5056 MiB retail).
|
||||
if applied_garlic_extra:
|
||||
env.setdefault('TLG_DMEM_MB', str(5056 + applied_garlic_extra))
|
||||
# The game renders natively at the patched size: the upscaler stays off (vk_temporal_upscaler).
|
||||
if applied_resolution:
|
||||
env['TLG_RESOLUTION_PATCH'] = applied_resolution
|
||||
restart = f'"{sys.executable}" --play' if FROZEN else f'"{sys.executable}" "{Path(__file__).resolve()}"'
|
||||
env.setdefault('TLG_RESTART_COMMAND', restart)
|
||||
# Linux package: the libraries built with the port (SDL3, FFmpeg, ...) are in out/lib, never the
|
||||
@@ -737,12 +821,12 @@ def raise_open_file_limit():
|
||||
pass
|
||||
|
||||
|
||||
def launch(settings, seconds=0, extra_env=None, detached=False):
|
||||
def launch(settings, seconds=0, extra_env=None, detached=False, header=()):
|
||||
"""Writes the in-game settings, starts the game; returns (process, log path).
|
||||
detached=False (Tk launcher, command line): the process's combined output is a text pipe; the
|
||||
caller copies it to the log. detached=True (Qt launcher): the game writes straight into the log
|
||||
file and runs in its own session, so closing the launcher never reaches it; follow the file with
|
||||
logtail.LogTail and wait() for the exit code."""
|
||||
The game always writes straight into the log file (after the header lines): a pipe that only
|
||||
the launcher reads stalls the game once its buffer fills and the launcher is gone. Follow the
|
||||
file with logtail.LogTail and wait() for the exit code. detached=True (Qt launcher): the game
|
||||
also runs in its own session, so closing the launcher never reaches it."""
|
||||
data = user_dir()
|
||||
data.mkdir(parents=True, exist_ok=True)
|
||||
key_map = settings.get('key_map') or {}
|
||||
@@ -750,9 +834,12 @@ def launch(settings, seconds=0, extra_env=None, detached=False):
|
||||
'display_mode': str(settings['display_mode']).lower(), # the game's keys: windowed|borderless|fullscreen
|
||||
'vsync': int(bool(settings['vsync'])),
|
||||
'show_fps': int(bool(settings['show_fps'])),
|
||||
'level_select': int(bool(settings.get('level_select', False))),
|
||||
'bridge_fix': int(bool(settings.get('bridge_fix', False))),
|
||||
'mouse_enabled': int(bool(settings.get('mouse_enabled', True))),
|
||||
'mouse_sensitivity': f"{float(settings.get('mouse_sensitivity', 1.0)):.2f}",
|
||||
'mouse_invert_y': int(bool(settings.get('mouse_invert_y', False))),
|
||||
'menu_confirm': settings.get('menu_confirm', 'cross'),
|
||||
'key_map': key_map_spec(key_map) if isinstance(key_map, dict) else str(key_map),
|
||||
})
|
||||
log = new_log()
|
||||
@@ -769,15 +856,12 @@ def launch(settings, seconds=0, extra_env=None, detached=False):
|
||||
if ISA_TABLE.is_file():
|
||||
cmd += ['--isa-rewrite', str(ISA_TABLE)]
|
||||
raise_open_file_limit()
|
||||
if detached:
|
||||
with open(log, 'wb') as out: # the child keeps its own copy of the descriptor
|
||||
process = subprocess.Popen(cmd, cwd=str(data), env=build_env(settings, extra_env),
|
||||
stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT,
|
||||
**detach_options())
|
||||
return process, log
|
||||
process = subprocess.Popen(cmd, cwd=str(data), env=build_env(settings, extra_env),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
encoding='utf-8', errors='replace', bufsize=1)
|
||||
with open(log, 'wb') as out: # the child keeps its own copy of the descriptor
|
||||
out.write(''.join(line + '\n' for line in header).encode('utf-8'))
|
||||
out.flush()
|
||||
process = subprocess.Popen(cmd, cwd=str(data), env=build_env(settings, extra_env),
|
||||
stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT,
|
||||
**(detach_options() if detached else {}))
|
||||
return process, log
|
||||
|
||||
|
||||
@@ -804,17 +888,19 @@ def main(argv=None):
|
||||
return 1
|
||||
notes = []
|
||||
prepare(settings, log=lambda line: notes.append(line) if line.startswith('Frame rate') else None)
|
||||
process, log = launch(settings, seconds)
|
||||
process, log = launch(settings, seconds, header=notes)
|
||||
print(f'Rodando (log: {log})', flush=True)
|
||||
with open(log, 'w', encoding='utf-8') as out:
|
||||
for line in notes:
|
||||
print(line, flush=True)
|
||||
out.write(line + '\n')
|
||||
for line in process.stdout:
|
||||
sys.stdout.write(line)
|
||||
out.write(line)
|
||||
out.flush()
|
||||
return process.wait()
|
||||
tail = logtail.LogTail(log)
|
||||
while True: # echo only: the game never waits for this loop or the console
|
||||
done = process.poll() is not None
|
||||
try:
|
||||
for line in tail.poll(final=done):
|
||||
print(line, flush=True)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if done:
|
||||
return process.returncode
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -40,7 +40,9 @@ Tudo vem do **seu** PS4 (GoldHEN + Itemzflow). Nada disso vai no download. Use *
|
||||
3. Abra **The Last Guardian** (`The Last Guardian.exe` no Windows): é o launcher novo (Qt), com telas de Gráficos, Controles, Correções, Saves, Mods, Suporte e Atualizações. O launcher antigo continua ao lado como **The Last Guardian (classic)**. Por padrão o launcher fecha quando o jogo abre e volta quando o jogo fecha (dá para mudar em Gráficos → Áudio e launcher).
|
||||
- No Windows, se aparecer "O Windows protegeu o computador", clique em *Mais informações* e depois em *Executar assim mesmo*. O programa não é assinado digitalmente.
|
||||
4. Em **Pasta do jogo**, escolha a pasta que tem o `eboot.bin`.
|
||||
5. Escolha o idioma do jogo (inglês, português, espanhol ou francês) e clique em **Jogar**. A primeira partida demora um pouco mais.
|
||||
5. Escolha um dos idiomas oferecidos para sua pasta do jogo e clique em **Jogar**. A primeira partida demora um pouco mais.
|
||||
|
||||
**Texto japonês experimental (EU 01.03):** com CUSA03745 e a atualização 01.03 aplicada na mesma pasta, escolha **日本語 (EU 1.03 text fallback)**. Não mova nem apague os arquivos franceses: o port os oculta apenas da visão do jogo nesta execução. Para voltar ao francês, escolha Français e inicie novamente. É necessário um launcher e runtime que incluam esse recurso; isso não adiciona suporte ao executável da edição japonesa do disco. Menus e dicas de jogo em japonês foram verificados; áudio/texto do jogo inteiro ainda não foram testados. Os botões de controle não são trocados.
|
||||
|
||||
No Linux, `./install_desktop.sh` adiciona o jogo ao menu de aplicativos.
|
||||
|
||||
@@ -48,6 +50,7 @@ No Linux, `./install_desktop.sh` adiciona o jogo ao menu de aplicativos.
|
||||
|
||||
- **F10**: liga e desliga o contador de FPS
|
||||
- **Insert** (ou R3 + L2 no controle): abre o menu do port (imagem, desempenho, overlay)
|
||||
- **F8**: alterna entre o DLAA e o anti-aliasing do próprio jogo (Windows, NVIDIA RTX). O DLAA (NVIDIA DLSS na resolução nativa) deixa a folhagem e as bordas finas estáveis; vem ligado e pode ser desligado no launcher ou em Imagem › Anti-aliasing no menu do port. Em outras placas a opção fica desativada.
|
||||
- Controles de PS4/PS5, Xbox e 8BitDo funcionam via SDL3, por USB ou Bluetooth.
|
||||
|
||||
### Teclado e mouse
|
||||
@@ -118,7 +121,9 @@ Everything comes from **your own** PS4 (GoldHEN + Itemzflow). None of it is in t
|
||||
3. Open **The Last Guardian** (`The Last Guardian.exe` on Windows): the new (Qt) launcher, with Graphics, Controls, Fixes, Saves, Mods, Support and Updates screens. The old launcher is still next to it as **The Last Guardian (classic)**. By default the launcher closes when the game starts and comes back when the game closes (change it in Graphics → Audio and launcher).
|
||||
- On Windows, if "Windows protected your PC" appears, click *More info*, then *Run anyway*. The program is not digitally signed.
|
||||
4. Under **Game folder**, choose the folder that has `eboot.bin`.
|
||||
5. Pick the game language (English, Portuguese, Spanish or French) and click **Play**. The first start takes a little longer.
|
||||
5. Pick one of the languages offered for your game folder and click **Play**. The first start takes a little longer.
|
||||
|
||||
**Experimental Japanese text (EU 01.03):** with CUSA03745 and update 01.03 applied to the same folder, select **日本語 (EU 1.03 text fallback)**. Do not move or delete the French archives: the port hides them only from the game's view for that launch. To return to French, select Français and launch again. Both launcher and runtime must contain this feature; it does not add Japanese-region disc executable support. Japanese menus and gameplay prompts were verified, not full-game audio/text coverage. Controller buttons are not swapped.
|
||||
|
||||
On Linux, `./install_desktop.sh` adds the game to the applications menu.
|
||||
|
||||
@@ -126,6 +131,7 @@ On Linux, `./install_desktop.sh` adds the game to the applications menu.
|
||||
|
||||
- **F10**: toggles the FPS counter
|
||||
- **Insert** (or R3 + L2 on a controller): opens the port's menu (image, performance, overlay)
|
||||
- **F8**: switches between DLAA and the game's own anti-aliasing (Windows, NVIDIA RTX). DLAA (NVIDIA DLSS at native resolution) keeps foliage and thin edges stable; it is on by default and can be turned off in the launcher or under Image › Anti-aliasing in the port's menu. On other graphics cards the setting is greyed out.
|
||||
- PS4/PS5, Xbox and 8BitDo controllers work through SDL3, over USB or Bluetooth.
|
||||
|
||||
### Keyboard and mouse
|
||||
@@ -165,3 +171,12 @@ While the game window has focus, it takes the mouse and the mouse turns the came
|
||||
---
|
||||
|
||||
This project is not affiliated with Sony Interactive Entertainment, SIE Japan Studio or genDESIGN. The Last Guardian is a trademark of Sony Interactive Entertainment. / Este projeto não tem vínculo com a Sony Interactive Entertainment, SIE Japan Studio ou genDESIGN.
|
||||
|
||||
## Apoie o desenvolvimento / Support development
|
||||
|
||||
☕ [Apoie com um café / Buy me a coffee](https://buymeacoffee.com/hayatog)
|
||||
|
||||
Apoio opcional para continuar desenvolvendo os ports. Os ports continuam gratuitos.
|
||||
Optional support for continued port development. The ports remain free.
|
||||
|
||||
The Windows package contains NVIDIA DLSS (`out/nvngx_dlss.dll`) under NVIDIA's license (`out/NVIDIA-DLSS-LICENSE.txt`). NVIDIA, RTX and DLSS are trademarks of NVIDIA Corporation. / O pacote Windows contém o NVIDIA DLSS (`out/nvngx_dlss.dll`) sob a licença da NVIDIA (`out/NVIDIA-DLSS-LICENSE.txt`). NVIDIA, RTX e DLSS são marcas da NVIDIA Corporation.
|
||||
@@ -33,6 +33,7 @@ rm -rf -- out/stage
|
||||
mkdir -p "$dest/out/gpu" "$dest/out/lib" "$dest/launcher" "$dest/system_lib"
|
||||
cp -r "out/pyi-dist/$PORT_NAME/." "$dest/"
|
||||
cp launcher/$PORT_ICON.png "$dest/launcher/"
|
||||
cp -r launcher/assets "$dest/launcher/"
|
||||
strip --strip-debug -o "$dest/out/tlg-probe" out/tlg-probe
|
||||
strip --strip-debug -o "$dest/out/gpu/libtlggpu.so" out/gpu/libtlggpu.so
|
||||
[[ -f out/tlg-gpu-capabilities ]] && strip -o "$dest/out/tlg-gpu-capabilities" out/tlg-gpu-capabilities
|
||||
|
||||
@@ -25,27 +25,35 @@ git submodule update --init --recursive
|
||||
bash build.sh # out/tlg-probe.exe, out/tlg-gpu-capabilities.exe
|
||||
TLG_GAME_DIR=F:/path/to/CUSA01623 python run.py
|
||||
python launcher/tlg_launcher_win.py
|
||||
bash packaging/windows/package.sh # dist/tlg-windows/ and .zip: God of War III.exe + DLLs
|
||||
bash packaging/windows/package.sh # dist/tlg-windows-x86_64.zip: The Last Guardian.exe + DLLs
|
||||
```
|
||||
|
||||
`run.py` is the Windows `run.sh` (same variables; works from a plain Windows Python too).
|
||||
The package freezes the launcher with PyInstaller (a private venv in `out/pyenv`, from a
|
||||
python.org Python) into `God of War III.exe`, which also runs `run.py` (`--run`), the preparation
|
||||
scripts (`--script`) and the game without the window (`--play`): players need no Python.
|
||||
`Play God of War III.exe` (`packaging/windows/play.c`) is a small native program that runs
|
||||
`God of War III.exe --play` from its folder and waits for it, for players who want to start the
|
||||
python.org Python) into `The Last Guardian.exe`, which also runs `run.py` (`--run`), the
|
||||
preparation scripts (`--script`) and the game without the window (`--play`): players need no Python.
|
||||
`Play The Last Guardian.exe` (`packaging/windows/play.c`) is a small native program that runs
|
||||
`The Last Guardian.exe --play` from its folder and waits for it, for players who want to start the
|
||||
game directly with the saved settings.
|
||||
`patches.py` applies the built-in patches only to the game build they were checked against
|
||||
(CUSA01623 01.02, by eboot.bin sha256; `TLG_FORCE_PATCHES=1` overrides); other builds run unpatched.
|
||||
From a source tree it adds `C:\msys64\clang64\bin` (or `$MSYS2_ROOT`) to `PATH` for the DLLs.
|
||||
`bash tools/fetch_fsr4_assets.sh` works in the CLANG64 shell as on Linux.
|
||||
|
||||
DLSS (optional): `DLSS_SDK_ROOT=<checkout of github.com/NVIDIA/DLSS> bash
|
||||
packaging/windows/build_dlss.sh` builds `tlg_dlss.dll` (`gpu/dlss_bridge`, the only code that
|
||||
uses the NVIDIA SDK, compiled with MSVC 2022) and copies it with `nvngx_dlss.dll` next to
|
||||
`out/tlg-probe.exe`; `package.sh` ships both when present. The renderer loads the bridge at run
|
||||
time (`vk_dlss.cpp`), adds the Vulkan extensions NGX asks for, and DLSS gets the same inputs as
|
||||
FSR 3 (scene color, depth, render-pixel motion vectors, jitter). `TLG_DLSS=0` turns it off.
|
||||
DLAA (optional, NVIDIA RTX): `DLSS_SDK_ROOT=<checkout of github.com/NVIDIA/DLSS, tag v310.9.16>
|
||||
bash packaging/windows/build_dlss.sh` builds `tlg_dlss.dll` (`gpu/dlss_bridge`, the only code that
|
||||
uses the NVIDIA SDK, compiled with MSVC 2022 or 2026) and copies it with `nvngx_dlss.dll` and
|
||||
`NVIDIA-DLSS-LICENSE.txt` next to `out/tlg-probe.exe`; `package.sh` ships all three when present
|
||||
(`TLG_REQUIRE_DLSS=1` makes their absence an error, as in CI, which fetches the SDK itself). The
|
||||
renderer loads the bridge at run time (`vk_dlss.cpp`). DLSS runs at native resolution (DLAA) on
|
||||
the game's own motion vectors (sky pass), with a sub-pixel jitter on the scene draws, before the
|
||||
first post pass. Without the DLLs, or on a GPU without DLSS, the menu greys the DLAA setting out
|
||||
and the game keeps its own anti-aliasing. Settings: `dlaa` in `tlg.ini` (menu: Image ›
|
||||
Anti-aliasing; F8 toggles in game), `TLG_DLAA=0`, `TLG_DLSS=0`.
|
||||
|
||||
The NVIDIA DLSS SDK is NVIDIA's proprietary software under its own license
|
||||
(`NVIDIA-DLSS-LICENSE.txt`), not under this repository's license; only the NVIDIA binaries ship,
|
||||
and the SDK sources are not part of this repository.
|
||||
|
||||
The launcher texts are written in English with the Russian beside them; the other languages
|
||||
are in `launcher/tlg_lang.py` (one list per language in the order of `KEYS`; a missing or
|
||||
|
||||
@@ -7,10 +7,22 @@ set -euo pipefail
|
||||
cd -- "$(dirname -- "$0")/../.."
|
||||
sdk=${DLSS_SDK_ROOT:?Set DLSS_SDK_ROOT to a checkout of github.com/NVIDIA/DLSS}
|
||||
cmake=${CMAKE_EXE:-cmake}
|
||||
# The newest Visual Studio found by vswhere (GitHub's windows runners move to new versions);
|
||||
# DLSS_VS_GENERATOR overrides.
|
||||
generator=${DLSS_VS_GENERATOR:-}
|
||||
if [[ -z $generator ]]; then
|
||||
vswhere="/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
major=$("$vswhere" -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 \
|
||||
-property installationVersion 2>/dev/null | tr -d '\r' | cut -d. -f1)
|
||||
case $major in
|
||||
18) generator="Visual Studio 18 2026" ;;
|
||||
*) generator="Visual Studio 17 2022" ;;
|
||||
esac
|
||||
fi
|
||||
# Only the Vulkan headers: MSYS2's C headers must not reach MSVC.
|
||||
rm -rf out/vkinclude && mkdir -p out/vkinclude
|
||||
cp -r /c/msys64/clang64/include/vulkan /c/msys64/clang64/include/vk_video out/vkinclude/
|
||||
"$cmake" -S gpu/dlss_bridge -B out/dlss-bridge -G "Visual Studio 17 2022" -A x64 \
|
||||
cp -r /clang64/include/vulkan /clang64/include/vk_video out/vkinclude/
|
||||
"$cmake" -S gpu/dlss_bridge -B out/dlss-bridge -G "$generator" -A x64 \
|
||||
-DDLSS_SDK_ROOT="$(cygpath -m "$sdk")" -DVULKAN_INCLUDE="$(cygpath -m "$PWD/out/vkinclude")"
|
||||
"$cmake" --build out/dlss-bridge --config Release
|
||||
cp out/dlss-bridge/Release/tlg_dlss.dll "$sdk/lib/Windows_x86_64/rel/nvngx_dlss.dll" out/
|
||||
|
||||
Regular → Executable
+13
@@ -45,6 +45,7 @@ mkdir -p "$dest/out" "$dest/launcher" "$dest/system_lib"
|
||||
cp -r "out/pyi-dist/$PORT_NAME/." "$dest/"
|
||||
llvm-strip -o "$dest/Play $PORT_NAME.exe" out/tlg-play.exe
|
||||
cp launcher/$PORT_ICON.ico launcher/$PORT_ICON.png "$dest/launcher/"
|
||||
cp -r launcher/assets "$dest/launcher/"
|
||||
# The executables without debug information, where tlg_run.py looks for them (out/).
|
||||
for exe in tlg-probe.exe tlg-gpu-capabilities.exe; do
|
||||
llvm-strip --strip-debug -o "$dest/out/$exe" "out/$exe"
|
||||
@@ -70,6 +71,18 @@ while ((${#queue[@]})); do
|
||||
done < <(llvm-readobj --coff-imports "$binary" | awk '$1 == "Name:" && tolower($2) ~ /\.dll$/ {print $2}')
|
||||
done
|
||||
echo "Packaged $(find "$dest/out" -maxdepth 1 -iname '*.dll' | wc -l) DLLs from /clang64/bin"
|
||||
# DLAA (optional): the MSVC bridge and NVIDIA's runtime from build_dlss.sh, with NVIDIA's license
|
||||
# (the DLSS SDK license allows shipping nvngx_dlss.dll inside an application). Without them the
|
||||
# DLAA setting is greyed out and the game keeps its own anti-aliasing.
|
||||
if [[ -f out/tlg_dlss.dll && -f out/nvngx_dlss.dll && -f out/NVIDIA-DLSS-LICENSE.txt ]]; then
|
||||
cp out/tlg_dlss.dll out/nvngx_dlss.dll out/NVIDIA-DLSS-LICENSE.txt "$dest/out/"
|
||||
echo "Packaged DLSS: tlg_dlss.dll, nvngx_dlss.dll"
|
||||
elif [[ -n ${TLG_REQUIRE_DLSS:-} ]]; then
|
||||
echo 'TLG_REQUIRE_DLSS is set but out/tlg_dlss.dll or out/nvngx_dlss.dll is missing: run build_dlss.sh' >&2
|
||||
exit 1
|
||||
else
|
||||
echo 'No DLSS bridge in out/ (build_dlss.sh not run): packaging without DLAA'
|
||||
fi
|
||||
cp -r scripts "$dest/"
|
||||
find "$dest" -name __pycache__ -prune -exec rm -r {} +
|
||||
cp LICENSE packaging/README-player.md "$dest/"
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
RESOLUTIONS = ('native', '480p', '720p', '1440p', '1800p', '4K')
|
||||
ENGINE_FPS = (60, 120, 240)
|
||||
DEFAULTS = {'render_resolution': 'native', 'engine_fps': '120', 'deferred_readback': '1',
|
||||
'stale_readback': '1', 'async_shaders': '1'}
|
||||
'stale_readback': '1', 'async_shaders': '0'}
|
||||
RESOLUTION_PREFIX = 'Resolution Patch'
|
||||
FPS_PREFIX = 'Frame Rate Patch'
|
||||
TEXTURE_FIX = 'Bug Fix - Texture Corruption Fix'
|
||||
@@ -59,7 +59,8 @@ def startup_options(ini):
|
||||
if options['engine_fps'] not in {str(f) for f in ENGINE_FPS}:
|
||||
options['engine_fps'] = '120'
|
||||
for key in ('deferred_readback', 'stale_readback', 'async_shaders'):
|
||||
options[key] = '0' if options[key] == '0' else '1'
|
||||
if options[key] not in ('0', '1'):
|
||||
options[key] = DEFAULTS[key]
|
||||
return options
|
||||
|
||||
|
||||
|
||||
+229
-6
@@ -10,11 +10,15 @@ loader (probe.c apply_patches) applies the file after relocation. Image offsets
|
||||
virtual addresses; the Ghidra addresses in the comments (base 0x1000000) are offset + 0x1000000.
|
||||
Research: docs/60fps.md.
|
||||
|
||||
30 with no mods enabled writes an empty patch file: the game runs exactly as on PS4.
|
||||
30 with no mods and the default Cross-confirm writes an empty patch file.
|
||||
Circle-confirm adds a separately hash-guarded game-menu conversion (verified EU 01.03 only).
|
||||
|
||||
Mods/cheats (--mods-dir/--mods-config, mod_patches.py): shadPS4/GoldHEN cheat XML files the
|
||||
player drops in the mods folder. Nothing in there has any effect until it is enabled (the
|
||||
file's own isEnabled="true", or mods.json), same contract as the built-in patches above.
|
||||
|
||||
Resolution (--resolution WxH, TLG_RESOLUTION wins): the default 1920x1080 mode becomes WxH,
|
||||
rendered natively; wider than 16:9 also gets Hor+ cameras and a UI canvas of that aspect.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
@@ -73,6 +77,24 @@ PATCHES = {
|
||||
(0x24f17fc, f32(1 / 30), f32(1 / 60), 'dt snap target 1/30 -> 1/60'),
|
||||
(0x24f1800, f32(0.0316666663), f32(0.0158333331), 'dt snap lower bound 0.031667 -> 0.015833'),
|
||||
],
|
||||
# Material/cloth update null pointer guard (0x128c100, loop header at 0x128c161..0x128c1a4):
|
||||
# At 60+ FPS on fast multi-core CPUs, worker threads can run 0x128c100 before streaming
|
||||
# populates the pointer table at [r12 + 0x28] (fault at 0x28c193) or an element entry at
|
||||
# [rcx + rax*8] (fault at 0x28c197 reading [rax + 0x28] right before test r15, r15).
|
||||
'cloth_null_guard': [
|
||||
(0x128c161,
|
||||
bytes.fromhex(
|
||||
'6666666666662e0f1f840000000000'
|
||||
'4d8b7424484863c7498b4c242841bb00000000488d1c4048c1e306'
|
||||
'4963841ea0000000488b04c14c8b7828488945904d85ff7479'
|
||||
),
|
||||
bytes.fromhex(
|
||||
'49837c2428000f84f90400000f1f00'
|
||||
'4d8b74244889f8498b4c24284531db8d1c4048c1e306'
|
||||
'4963841ea0000000488b04c1488945904885c074074c8b78284d85ff7479'
|
||||
),
|
||||
'cloth update null table/element guard at 0x28c161..0x28c1a4'),
|
||||
],
|
||||
}
|
||||
|
||||
MODES = {
|
||||
@@ -80,8 +102,8 @@ MODES = {
|
||||
# max_fps_double/max_fps_off are left out: 60 and unlocked were reached without them
|
||||
# (game+0x180 is no frame limiter in the tested scenes; its reader is not found yet).
|
||||
# TLG_FPS_PATCHES=flip_rate_60,max_fps_off tries them.
|
||||
'60': ['flip_rate_60', 'dt_snap_60'],
|
||||
'unlocked': ['flip_rate_60'],
|
||||
'60': ['flip_rate_60', 'dt_snap_60', 'cloth_null_guard'],
|
||||
'unlocked': ['flip_rate_60', 'cloth_null_guard'],
|
||||
}
|
||||
# The engine rate each mode presents at most (TLG_ENGINE_FPS: the overlay's frame limiter and
|
||||
# tlg_graphics.h PresentLimit). Unlocked is paced by the present thread (TLG_VBLANK_HZ=0).
|
||||
@@ -108,6 +130,176 @@ def mode_writes(eboot, names):
|
||||
return writes
|
||||
|
||||
|
||||
# Resolution (TLG_RESOLUTION=WxH, e.g. 3440x1440): the mode table (0x18 bytes per entry: u16
|
||||
# render W,H, output W,H; u64 checkerboard; name pointer) starts with the normal (non-Pro) list,
|
||||
# whose default entry at 0x26ec3a0 is 1920x1080 rendered, 1920x1080 output. It becomes WxH, WxH;
|
||||
# the Pro list (0x26ec3e8, picked when the output height is > 1080) is left alone.
|
||||
RESOLUTION_ENTRY = 0x26ec3a0
|
||||
RESOLUTION_EXPECTED = bytes.fromhex('80073804800738040000000000000000')
|
||||
# Segment types the loader maps: PT_LOAD and PT_SCE_RELRO (read-only data fixed up at load).
|
||||
LOADED_SEGMENTS = (1, 0x61000010)
|
||||
|
||||
|
||||
def parse_resolution(value):
|
||||
"""(width, height) of "WxH", or None when it is empty, not a size or 1920x1080."""
|
||||
parts = str(value or '').lower().replace(' ', '').split('x')
|
||||
if len(parts) != 2 or not all(part.isdigit() for part in parts):
|
||||
return None
|
||||
width, height = (int(part) for part in parts)
|
||||
if not (0 < width <= 0xffff and 0 < height <= 0xffff) or (width, height) == (1920, 1080):
|
||||
return None
|
||||
return width, height
|
||||
|
||||
|
||||
def file_offset(eboot, offset, length):
|
||||
"""File offset of image bytes [offset, offset + length) of the ELF eboot through its PT_LOAD
|
||||
and PT_SCE_RELRO headers; None when no segment holds them."""
|
||||
if eboot[:4] != b'ELF' or len(eboot) < 0x40:
|
||||
return None
|
||||
phoff, = struct.unpack_from('<Q', eboot, 0x20)
|
||||
phentsize, phnum = struct.unpack_from('<HH', eboot, 0x36)
|
||||
for i in range(phnum):
|
||||
kind, _, p_offset, vaddr, _, filesz, _, _ = struct.unpack_from('<IIQQQQQQ', eboot, phoff + i * phentsize)
|
||||
if kind in LOADED_SEGMENTS and vaddr <= offset and offset + length <= vaddr + filesz:
|
||||
return p_offset + offset - vaddr
|
||||
return None
|
||||
|
||||
|
||||
# Hor+ for frames wider than 16:9. The perspective builder 0x1de0440 sets m00 = cot(fov/2) and
|
||||
# m11 = m00 * aspect ([camera+0x414]), and the gameplay cameras keep that aspect at 16:9 whatever
|
||||
# the frame size, so a wider frame stretched the 16:9 view. Its post-tan code spills the same 13
|
||||
# registers on both paths (0x1de06af and 0x1de076c); doing that once before the branch frees
|
||||
# 0x1de076c-0x1de07e1 for a routine, called instead of `vmulss xmm8,xmm10,[rsp+160h]` (m11), that
|
||||
# sets m00 = m11 / frame aspect when the camera aspect is within 1.70-1.85. Cameras that take the
|
||||
# aspect from the frame size (the main gameplay camera once the mode is patched) would keep the
|
||||
# 16:9 width and lose height (Vert-); they get m11 = m00 * 16/9 first, so they widen the same way.
|
||||
# Other cameras (square targets) are left alone.
|
||||
HOR_PLUS_CALL = 0x1de0584
|
||||
HOR_PLUS_CALL_EXPECTED = bytes.fromhex('c52a59842460010000')
|
||||
HOR_PLUS_CALL_NEW = bytes.fromhex('e8e30100000f1f4000') # call 0x1de076c; nop
|
||||
HOR_PLUS_BLOCK = 0x1de06a1
|
||||
HOR_PLUS_BLOCK_EXPECTED = bytes.fromhex(
|
||||
'c5782eb3080400000f84bd000000c5f8298424c0000000c58a590564137100c57829ac24d0000000c578299c24e0'
|
||||
'000000c5f829bc24f0000000c5f829a42400010000c5f8299c2410010000c5f829942420010000c5f8298c243001'
|
||||
'0000c57829a42440010000c57829942450010000c578298c2460010000c5f829b42470010000c5f829ac24800100'
|
||||
'00c57a11b42490010000e88ed36d00c5fa100dea127100c5f25ec0c5fa118304040000c5fa11830c040000c5fa10'
|
||||
'842490010000c5fa118308040000e9ce000000c5f8298424c0000000c57829ac24d0000000c578299c24e0000000'
|
||||
'c5f829bc24f0000000c5f829a42400010000c5f8299c2410010000c5f829942420010000c5f8298c243001000'
|
||||
'0c57829a42440010000c57829942450010000c578298c2460010000c5f829b42470010000c5f829ac2480010000')
|
||||
# Spills, vucomiss xmm14,[rbx+408h]; je 0x1de07e1; the unchanged fov path ending in jmp 0x1de083a;
|
||||
# then at 0x1de076c: vmovss xmm3,[rsp+168h] (aspect); an aspect within 0.02 of the frame's becomes
|
||||
# 16:9; vmulss xmm8,xmm10,xmm3; skip unless 1.70 <= aspect <= 1.85; vmulss xmm10,xmm8,[1/frame
|
||||
# aspect]; ret; f32 1.70, 1.85, 16/9, then the frame aspect - 0.02 and + 0.02 and 1/frame aspect.
|
||||
HOR_PLUS_BLOCK_NEW = bytes.fromhex(
|
||||
'c5f8298424c0000000c57829ac24d0000000c578299c24e0000000c5f829bc24f0000000c5f829a42400010000c5'
|
||||
'f8299c2410010000c5f829942420010000c5f8298c2430010000c57829a42440010000c57829942450010000c578'
|
||||
'298c2460010000c5f829b42470010000c5f829ac2480010000c5782eb3080400000f84bd000000c58a5905f81271'
|
||||
'00c57a11b42490010000e88ed36d00c5fa100dea127100c5f25ec0c5fa118304040000c5fa11830c040000c5fa10'
|
||||
'842490010000c5fa118308040000e9ce000000'
|
||||
'c5fa109c2468010000c5f82e1d410000007212c5f82e1d3b0000007708c5fa101d29000000c52a59c3c5f82e1d15'
|
||||
'0000007212c5f82e1d0f0000007708c53a591515000000c39a99d93fcdccec3f398ee33f')
|
||||
|
||||
|
||||
# UI canvas: the UI init copies {1920, 1080, 1920, 1080} from 0x2525480 (its only reference) into
|
||||
# the UI state; the first pair becomes the UI projection size, the second the layout size that
|
||||
# anchors elements to the screen edges. For a wider frame both widths become 1080 * frame aspect,
|
||||
# so the UI keeps its proportions and edge-anchored elements (HUD prompts, menus) move out to the
|
||||
# edges. Widening only the projection leaves the screen grey.
|
||||
UI_SIZE = 0x2525480
|
||||
UI_SIZE_EXPECTED = struct.pack('<4i', 1920, 1080, 1920, 1080)
|
||||
# Guide (button hint box) draw, 0x190fdf0: scales its 1920x1080 layout to the UI canvas with
|
||||
# width/1920 and height/1080 and its 16 px corners with width*16/1920 (these four floats, used
|
||||
# only there). With a wider canvas the box stretched sideways; dividing by the canvas width keeps it square.
|
||||
GUIDE_SCALE = 0x24efa14
|
||||
GUIDE_SCALE_EXPECTED = struct.pack('<4f', 1 / 1920, 1 / 1080, -32, 16 / 1920)
|
||||
# Subtitle draw, 0x1c30320: the same width/1920, height/1080 scale (these two floats, used only
|
||||
# there) stretched the subtitle lines sideways on a wider canvas.
|
||||
SUBTITLE_SCALE = 0x24f1464
|
||||
SUBTITLE_SCALE_EXPECTED = struct.pack('<2f', 1 / 1920, 1 / 1080)
|
||||
# Garlic (GPU) heap: the render setup (0x1dd3a46) allocates 0xb0000000 bytes (2816 MiB; the Pro
|
||||
# path adds 512 MiB) for the game's own allocator (Gomalloc), render targets included. A large
|
||||
# stage at 1080p already uses most of it, so it always gets 4064 MiB: headroom for larger frames
|
||||
# and supersampling. The launcher grows the direct memory (TLG_DMEM_MB) by the same amount.
|
||||
# mov r15d, imm32 caps it below 4 GiB; 32 MiB stay free in case the allocator rounds sizes up
|
||||
# in 32 bits.
|
||||
GARLIC_SIZE = 0x1dd3a46
|
||||
GARLIC_SIZE_EXPECTED = bytes.fromhex('41bf000000b0')
|
||||
GARLIC_EXTRA_MB = 1248
|
||||
|
||||
|
||||
def checked_offset(eboot, address, expected):
|
||||
"""Image offset of `address` (Ghidra) after checking the eboot holds `expected` there."""
|
||||
offset = address - GHIDRA_BASE
|
||||
start = file_offset(eboot or b'', offset, len(expected))
|
||||
if start is None:
|
||||
raise ValueError(f'{address:#x} is not in the eboot image')
|
||||
found = eboot[start:start + len(expected)]
|
||||
if found != expected:
|
||||
raise ValueError(f'{address:#x} has {found.hex()}, expected {expected.hex()}')
|
||||
return offset
|
||||
|
||||
|
||||
def resolution_writes(eboot, size):
|
||||
"""[(image offset, new bytes, description)] of the default mode entry, and of Hor+ and the UI
|
||||
canvas for frames wider than 16:9, after checking them."""
|
||||
width, height = size
|
||||
offset = checked_offset(eboot, RESOLUTION_ENTRY, RESOLUTION_EXPECTED)
|
||||
new = struct.pack('<4H', width, height, width, height) + RESOLUTION_EXPECTED[8:]
|
||||
writes = [(offset, new, f'resolution: {RESOLUTION_ENTRY:#x} default mode 1920x1080 -> {width}x{height}')]
|
||||
if width * 9 > height * 16:
|
||||
call = checked_offset(eboot, HOR_PLUS_CALL, HOR_PLUS_CALL_EXPECTED)
|
||||
block = checked_offset(eboot, HOR_PLUS_BLOCK, HOR_PLUS_BLOCK_EXPECTED)
|
||||
aspect = width / height
|
||||
new = HOR_PLUS_BLOCK_NEW + struct.pack('<3f', aspect - 0.02, aspect + 0.02, height / width)
|
||||
new += b'\xcc' * (len(HOR_PLUS_BLOCK_EXPECTED) - len(new))
|
||||
writes += [(block, new, f'Hor+: {HOR_PLUS_BLOCK:#x} 16:9 cameras -> {width}:{height}'),
|
||||
(call, HOR_PLUS_CALL_NEW, f'Hor+: {HOR_PLUS_CALL:#x} calls it')]
|
||||
ui = checked_offset(eboot, UI_SIZE, UI_SIZE_EXPECTED)
|
||||
ui_width = round(1080 * width / height)
|
||||
writes.append((ui, struct.pack('<4i', ui_width, 1080, ui_width, 1080),
|
||||
f'UI: {UI_SIZE:#x} canvas 1920x1080 -> {ui_width}x1080'))
|
||||
guide = checked_offset(eboot, GUIDE_SCALE, GUIDE_SCALE_EXPECTED)
|
||||
writes.append((guide, struct.pack('<4f', 1 / ui_width, 1 / 1080, -32, 16 / ui_width),
|
||||
f'UI: {GUIDE_SCALE:#x} hint box scale 1920 -> {ui_width}'))
|
||||
subtitle = checked_offset(eboot, SUBTITLE_SCALE, SUBTITLE_SCALE_EXPECTED)
|
||||
writes.append((subtitle, struct.pack('<2f', 1 / ui_width, 1 / 1080),
|
||||
f'UI: {SUBTITLE_SCALE:#x} subtitle scale 1920 -> {ui_width}'))
|
||||
return writes
|
||||
|
||||
|
||||
def garlic_writes(eboot):
|
||||
"""[(image offset, new bytes, description)] of the larger GPU heap, after checking it."""
|
||||
garlic = checked_offset(eboot, GARLIC_SIZE, GARLIC_SIZE_EXPECTED)
|
||||
size_mb = 2816 + GARLIC_EXTRA_MB
|
||||
return [(garlic, GARLIC_SIZE_EXPECTED[:2] + struct.pack('<I', size_mb << 20),
|
||||
f'garlic: {GARLIC_SIZE:#x} GPU heap 2816 -> {size_mb} MB')]
|
||||
|
||||
|
||||
# Verified EU CUSA03745 01.03, decoded from the SELF. Other builds fail closed.
|
||||
MENU_ELF_SHA256 = 'ce2a5da1de13d7e43a2403cefe649f0f35a968e8b3779b911a314292472ef573'
|
||||
MENU_OFFSET = 0xd2ebee
|
||||
MENU_EXPECTED = bytes.fromhex('41c1e20541c1e5034509d54489e9')
|
||||
|
||||
|
||||
def menu_writes(eboot, preference):
|
||||
"""Only synthesize different menu flags; ordinary gameplay face-button bits stay intact.
|
||||
|
||||
The game ignores system parameter 1000. Its normalizer separately appends confirm
|
||||
(0x20000) and cancel (0x40000): raw Circle 0x2000 << 5, raw Cross 0x4000 << 3.
|
||||
Shifting both by 4 exchanges these two extra flags, not the ordinary Cross/Circle bits.
|
||||
"""
|
||||
if preference == 'cross':
|
||||
return []
|
||||
if preference != 'circle':
|
||||
raise ValueError(f'unknown menu confirm button: {preference!r}')
|
||||
if not eboot or hashlib.sha256(eboot).hexdigest() != MENU_ELF_SHA256:
|
||||
raise ValueError('Circle confirm requires the verified CUSA03745 01.03 executable')
|
||||
start = TEXT_FILE_OFFSET + MENU_OFFSET
|
||||
if eboot[start:start + len(MENU_EXPECTED)] != MENU_EXPECTED:
|
||||
raise ValueError('menu flag conversion instructions do not match')
|
||||
return [(MENU_OFFSET, bytes.fromhex('41c1e20441c1e504'), 'Circle-confirm / Cross-back menu flags')]
|
||||
|
||||
|
||||
def write_patch_file(path, writes):
|
||||
"""G3PATCH2 (probe.c apply_patches): base 0 (no pointer slots), then (offset, length, bytes).
|
||||
Base 0 only matters for a write that lands exactly on a relocated pointer slot (none of the
|
||||
@@ -151,7 +343,7 @@ def compile_mods(game_dir, eboot, mods_dir, mods_config, lines):
|
||||
return []
|
||||
|
||||
|
||||
def prepare(game_dir, out_dir, mode, mods_dir=None, mods_config=None):
|
||||
def prepare(game_dir, out_dir, mode, mods_dir=None, mods_config=None, resolution=None, menu_confirm='cross'):
|
||||
"""Writes out/patches.bin for the mode plus any enabled mod; returns (applied mode, lines).
|
||||
Any problem with the frame rate patches (other eboot build, unexpected bytes) falls back to
|
||||
30 FPS without them; a mod problem only drops that mod, never the frame rate patches."""
|
||||
@@ -200,7 +392,35 @@ def prepare(game_dir, out_dir, mode, mods_dir=None, mods_config=None):
|
||||
mod_writes = compile_mods(game_dir, eboot or read_eboot_elf(game_dir), mods_dir, mods_config, lines)
|
||||
for _, data, what in mod_writes:
|
||||
lines.append(f'Mod {what} ({data.hex()})')
|
||||
writes = fps_writes + mod_writes
|
||||
res_writes = []
|
||||
size = parse_resolution(os.environ.get('TLG_RESOLUTION') or resolution)
|
||||
if size:
|
||||
try:
|
||||
res_writes = resolution_writes(read_eboot_elf(game_dir), size)
|
||||
except ValueError as error:
|
||||
lines.append(f'Resolution: {error}: not applied, 1920x1080')
|
||||
for _, data, what in res_writes:
|
||||
lines.append(f'Resolution patch {what} ({data.hex()})')
|
||||
heap = []
|
||||
try:
|
||||
heap = garlic_writes(read_eboot_elf(game_dir))
|
||||
except (OSError, ValueError) as error:
|
||||
lines.append(f'GPU heap: {error}: not applied, 2816 MB')
|
||||
for _, data, what in heap:
|
||||
lines.append(f'Patch {what} ({data.hex()})')
|
||||
menu = []
|
||||
if menu_confirm != 'cross':
|
||||
try:
|
||||
menu = menu_writes(read_eboot_elf(game_dir), menu_confirm)
|
||||
lines.append('Menu buttons: Circle confirms, Cross goes back (gameplay unchanged)')
|
||||
except ValueError as error:
|
||||
lines.append(f'Menu buttons: {error}; game-menu patch not applied')
|
||||
# External mods must not silently overwrite the selected menu policy.
|
||||
if any(a < b + len(other) and b < a + len(data)
|
||||
for a, data, _ in menu for b, other, _ in mod_writes):
|
||||
lines.append('Mods: overlap with menu-button conversion; external mods not applied')
|
||||
mod_writes = []
|
||||
writes = fps_writes + menu + mod_writes + res_writes + heap
|
||||
write_patch_file(Path(out_dir) / 'patches.bin', writes)
|
||||
lines.append(f'Frame rate: {mode} FPS mode, {len(writes)} patch writes')
|
||||
return mode, lines
|
||||
@@ -213,8 +433,11 @@ def main():
|
||||
p.add_argument('--mode', default='30', help='30, 60 or unlocked')
|
||||
p.add_argument('--mods-dir', help='folder of shadPS4/GoldHEN cheat XML files')
|
||||
p.add_argument('--mods-config', help='mods.json: {"enabled": [...], "disabled": [...]}')
|
||||
p.add_argument('--resolution', help='WxH, e.g. 3440x1440 (default 1920x1080)')
|
||||
p.add_argument('--menu-confirm', choices=('cross', 'circle'), default='cross',
|
||||
help='menu confirm button; Circle game-menu patch requires verified EU 01.03')
|
||||
a = p.parse_args()
|
||||
_, lines = prepare(a.game_dir, a.out, a.mode, a.mods_dir, a.mods_config)
|
||||
_, lines = prepare(a.game_dir, a.out, a.mode, a.mods_dir, a.mods_config, a.resolution, a.menu_confirm)
|
||||
print('\n'.join(lines))
|
||||
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later */
|
||||
#ifndef TLG_ACTOR_HOOK_H
|
||||
#define TLG_ACTOR_HOOK_H
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
/* CUSA01623 v01.02, image offset 0x50417: the instruction after the damage routine stores an
|
||||
* actor's new health (vmovss [r13+0x1cc], xmm0 at 0x5040e, the infinite-health cheat's site).
|
||||
* r13 is the actor, health at +0x1cc, maximum at +0x1d0, and +0x298 is 0 for the player
|
||||
* (the infinite-health cheat refills only those). rax, rdi
|
||||
* and the flags are overwritten right after, so the stub may use them. */
|
||||
enum { TLG_ACTOR_HOOK_OFFSET = 0x50417, TLG_ACTOR_HOOK_LENGTH = 7 };
|
||||
static const unsigned char tlg_actor_original[TLG_ACTOR_HOOK_LENGTH] = {
|
||||
0x49,0x8b,0x45,0x00, /* mov rax, [r13] */
|
||||
0x4c,0x89,0xef /* mov rdi, r13 */
|
||||
};
|
||||
|
||||
/* What the stub records for the player (slots[0]) and the last other actor hit (slots[1]).
|
||||
* Written by the game thread, read by the overlay; seq changes on every hit. */
|
||||
typedef struct {
|
||||
uint64_t actor;
|
||||
float health, max_health;
|
||||
uint32_t seq, pad;
|
||||
uint64_t reserved;
|
||||
} TlgActorSlot;
|
||||
#ifdef __cplusplus
|
||||
static_assert(sizeof(TlgActorSlot) == 32, "stub uses a 32-byte stride");
|
||||
#else
|
||||
_Static_assert(sizeof(TlgActorSlot) == 32, "stub uses a 32-byte stride");
|
||||
#endif
|
||||
|
||||
static inline int tlg_actor_signature_match(const unsigned char *code, uint64_t size) {
|
||||
return size >= TLG_ACTOR_HOOK_LENGTH && !memcmp(code, tlg_actor_original, TLG_ACTOR_HOOK_LENGTH);
|
||||
}
|
||||
|
||||
static inline unsigned tlg_actor_build_stub(unsigned char *out, uintptr_t slots, uintptr_t resume) {
|
||||
static const unsigned char body[] = {
|
||||
0x48,0xb8, 0,0,0,0,0,0,0,0, /* movabs rax, slots */
|
||||
0x41,0x80,0xbd,0x98,0x02,0,0, 0x00, /* cmp byte [r13+0x298], 0 */
|
||||
0x74,0x04, /* je +4 (player: slots[0]) */
|
||||
0x48,0x83,0xc0,0x20, /* add rax, 32 (other actor: slots[1]) */
|
||||
0x4c,0x89,0x28, /* mov [rax], r13 */
|
||||
0x41,0x8b,0xbd,0xcc,0x01,0,0, /* mov edi, [r13+0x1cc] */
|
||||
0x89,0x78,0x08, /* mov [rax+8], edi */
|
||||
0x41,0x8b,0xbd,0xd0,0x01,0,0, /* mov edi, [r13+0x1d0] */
|
||||
0x89,0x78,0x0c, /* mov [rax+12], edi */
|
||||
0xf0,0xff,0x40,0x10, /* lock inc dword [rax+16] */
|
||||
};
|
||||
static const unsigned char jump[] = {0xff,0x25,0,0,0,0}; /* jmp [rip+0] */
|
||||
unsigned n = 0;
|
||||
memcpy(out, body, sizeof(body));
|
||||
memcpy(out + 2, &slots, 8);
|
||||
n += sizeof(body);
|
||||
memcpy(out + n, tlg_actor_original, TLG_ACTOR_HOOK_LENGTH);
|
||||
n += TLG_ACTOR_HOOK_LENGTH;
|
||||
memcpy(out + n, jump, sizeof(jump));
|
||||
n += sizeof(jump);
|
||||
memcpy(out + n, &resume, 8);
|
||||
return n + 8;
|
||||
}
|
||||
#endif
|
||||
@@ -1,79 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later */
|
||||
#ifndef TLG_CHEAT_HOOK_H
|
||||
#define TLG_CHEAT_HOOK_H
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
enum { TLG_CHEAT_HEALTH, TLG_CHEAT_MAGIC, TLG_CHEAT_ITEM, TLG_CHEAT_RAGE,
|
||||
TLG_CHEAT_ORBS, TLG_CHEAT_VERY_HARD, TLG_CHEAT_DOUBLE_JUMPS, TLG_CHEAT_COUNT };
|
||||
typedef struct {
|
||||
uint32_t offset;
|
||||
unsigned id, length, replacement_length;
|
||||
unsigned char original[9], replacement[40];
|
||||
} TlgCheatHook;
|
||||
/* Celogamez's CUSA01623 v01.02 cheats, adapted to the local executable's offsets.
|
||||
* https://github.com/GoldHEN/GoldHEN_Cheat_Repository/blob/main/json/CUSA01623_01.02.json */
|
||||
static const TlgCheatHook tlg_cheat_hooks[] = {
|
||||
{0x5040e,TLG_CHEAT_HEALTH,9,30,
|
||||
{0xc4,0xc1,0x7a,0x11,0x85,0xcc,0x01,0,0},
|
||||
{0x9c,0x41,0x80,0xbd,0x98,0x02,0,0,0,0x75,9,
|
||||
0xc4,0xc1,0x7a,0x10,0x85,0xd0,0x01,0,0,0x9d,
|
||||
0xc4,0xc1,0x7a,0x11,0x85,0xcc,0x01,0,0}},
|
||||
{0x72cdc,TLG_CHEAT_MAGIC,8,16,
|
||||
{0xc5,0xfa,0x11,0x83,0xd0,0x01,0,0},
|
||||
{0xc5,0xfa,0x10,0x83,0xd0,0x01,0,0,0xc5,0xfa,0x11,0x83,0xd0,0x01,0,0}},
|
||||
{0x72d97,TLG_CHEAT_ITEM,8,16,
|
||||
{0xc5,0xfa,0x11,0x87,0xd4,0x01,0,0},
|
||||
{0xc5,0xfa,0x10,0x87,0xd4,0x01,0,0,0xc5,0xfa,0x11,0x87,0xd4,0x01,0,0}},
|
||||
{0x6a9b8,TLG_CHEAT_RAGE,8,10,
|
||||
{0xc5,0xfa,0x11,0x8b,0xd8,0x01,0,0},
|
||||
{0xc7,0x83,0xd8,0x01,0,0,0x28,0x6b,0x6e,0x4e}},
|
||||
{0x6a6a0,TLG_CHEAT_RAGE,8,10,
|
||||
{0xc5,0xfa,0x11,0x8b,0xd8,0x01,0,0},
|
||||
{0xc7,0x83,0xd8,0x01,0,0,0x28,0x6b,0x6e,0x4e}},
|
||||
{0x6a9fc,TLG_CHEAT_ORBS,8,10,
|
||||
{0xc5,0xfa,0x11,0x87,0xe8,0x01,0,0},
|
||||
{0xc7,0x87,0xe8,0x01,0,0,0xf0,0x23,0x74,0x49}},
|
||||
/* Override only the queried completion level, leaving stored progress untouched. */
|
||||
{0xc77ea,TLG_CHEAT_VERY_HARD,8,8,
|
||||
{0xb8,3,0,0,0,0xf6,0xc1,8},
|
||||
{0xb8,3,0,0,0,0x85,0xc0,0x90}},
|
||||
/* Ignore the used-air-jump flag for Kratos, preserving every other movement check. */
|
||||
{0x95d51,TLG_CHEAT_DOUBLE_JUMPS,9,27,
|
||||
{0x41,0xf6,0x84,0x24,0xac,4,0,0,4},
|
||||
{0x41,0x80,0xbc,0x24,0x98,2,0,0,0,0x75,7,
|
||||
0xa9,0,0,0,0,0xeb,9,
|
||||
0x41,0xf6,0x84,0x24,0xac,4,0,0,4}}
|
||||
};
|
||||
#define TLG_CHEAT_HOOK_COUNT (sizeof(tlg_cheat_hooks)/sizeof(tlg_cheat_hooks[0]))
|
||||
static inline int tlg_cheat_signature_match(const TlgCheatHook *hook,
|
||||
const unsigned char *code, uint64_t size) {
|
||||
return size >= hook->length && !memcmp(code, hook->original, hook->length);
|
||||
}
|
||||
static inline unsigned tlg_cheat_build_stub(unsigned char *out, const TlgCheatHook *hook,
|
||||
uintptr_t flag, uintptr_t resume) {
|
||||
/* Keep the SysV red zone, GPRs and condition flags intact; no host call on the hot path. */
|
||||
const unsigned char prefix[] = {
|
||||
0x48,0x8d,0x64,0x24,0x80,0x9c,0x50,0x48,0xb8,0,0,0,0,0,0,0,0,
|
||||
0x80,0x38,0,0x74,0
|
||||
};
|
||||
const unsigned char restore[] = {0x58,0x9d};
|
||||
const unsigned char stack[] = {0x48,0x8d,0xa4,0x24,0x80,0,0,0};
|
||||
const unsigned char jump[] = {0xff,0x25,0,0,0,0};
|
||||
unsigned n = sizeof(prefix);
|
||||
memcpy(out, prefix, n);
|
||||
memcpy(out + 9, &flag, 8);
|
||||
out[21] = (unsigned char)(sizeof(restore) + hook->replacement_length + sizeof(stack) + 14);
|
||||
memcpy(out+n,restore,sizeof(restore)); n += sizeof(restore);
|
||||
memcpy(out+n,hook->replacement,hook->replacement_length); n += hook->replacement_length;
|
||||
memcpy(out+n,stack,sizeof(stack)); n += sizeof(stack);
|
||||
memcpy(out+n,jump,sizeof(jump)); n += sizeof(jump);
|
||||
memcpy(out+n,&resume,8); n += 8;
|
||||
memcpy(out+n,restore,sizeof(restore)); n += sizeof(restore);
|
||||
memcpy(out+n,hook->original,hook->length); n += hook->length;
|
||||
memcpy(out+n,stack,sizeof(stack)); n += sizeof(stack);
|
||||
memcpy(out+n,jump,sizeof(jump)); n += sizeof(jump);
|
||||
memcpy(out+n,&resume,8); n += 8;
|
||||
return n;
|
||||
}
|
||||
#endif
|
||||
@@ -1,49 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later */
|
||||
#ifndef TLG_DAMAGE_HOOK_H
|
||||
#define TLG_DAMAGE_HOOK_H
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
/* CUSA01623 v01.02, image offset 0x503f3, inside the damage routine: the actor's health before
|
||||
* the hit is at [rbp-0x5c], the damage at [rbp-0x60]; the game then clamps the difference to
|
||||
* [0, maximum] and stores it (0x5040e, the infinite-health cheat's site). r13 is the actor and
|
||||
* +0x298 is 0 for the player. rax, xmm1 and the flags are overwritten before they are read. */
|
||||
enum { TLG_DAMAGE_HOOK_OFFSET = 0x503f3, TLG_DAMAGE_HOOK_LENGTH = 10 };
|
||||
static const unsigned char tlg_damage_original[TLG_DAMAGE_HOOK_LENGTH] = {
|
||||
0xc5,0xfa,0x10,0x45,0xa4, /* vmovss xmm0, [rbp-0x5c] */
|
||||
0xc5,0xfa,0x5c,0x45,0xa0 /* vsubss xmm0, xmm0, [rbp-0x60] */
|
||||
};
|
||||
|
||||
static inline int tlg_damage_signature_match(const unsigned char *code, uint64_t size) {
|
||||
return size >= TLG_DAMAGE_HOOK_LENGTH && !memcmp(code, tlg_damage_original, TLG_DAMAGE_HOOK_LENGTH);
|
||||
}
|
||||
|
||||
/* Damage above zero is multiplied by *taken (player) or *dealt (any other actor). */
|
||||
static inline unsigned tlg_damage_build_stub(unsigned char *out, uintptr_t taken, uintptr_t dealt,
|
||||
uintptr_t resume) {
|
||||
static const unsigned char body[] = {
|
||||
0x48,0xb8, 0,0,0,0,0,0,0,0, /* movabs rax, taken */
|
||||
0x41,0x80,0xbd,0x98,0x02,0,0, 0x00, /* cmp byte [r13+0x298], 0 */
|
||||
0x74,0x0a, /* je +10 (player) */
|
||||
0x48,0xb8, 0,0,0,0,0,0,0,0, /* movabs rax, dealt */
|
||||
0xc5,0xfa,0x10,0x4d,0xa0, /* vmovss xmm1, [rbp-0x60] */
|
||||
0xc5,0xf8,0x57,0xc0, /* vxorps xmm0, xmm0, xmm0 */
|
||||
0xc5,0xf8,0x2e,0xc8, /* vucomiss xmm1, xmm0 */
|
||||
0x76,0x09, /* jbe +9 (no damage, or NaN) */
|
||||
0xc5,0xf2,0x59,0x08, /* vmulss xmm1, xmm1, [rax] */
|
||||
0xc5,0xfa,0x11,0x4d,0xa0, /* vmovss [rbp-0x60], xmm1 */
|
||||
};
|
||||
static const unsigned char jump[] = {0xff,0x25,0,0,0,0}; /* jmp [rip+0] */
|
||||
unsigned n = 0;
|
||||
memcpy(out, body, sizeof(body));
|
||||
memcpy(out + 2, &taken, 8);
|
||||
memcpy(out + 22, &dealt, 8);
|
||||
n += sizeof(body);
|
||||
memcpy(out + n, tlg_damage_original, TLG_DAMAGE_HOOK_LENGTH);
|
||||
n += TLG_DAMAGE_HOOK_LENGTH;
|
||||
memcpy(out + n, jump, sizeof(jump));
|
||||
n += sizeof(jump);
|
||||
memcpy(out + n, &resume, 8);
|
||||
return n + 8;
|
||||
}
|
||||
#endif
|
||||
@@ -1,81 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later */
|
||||
#ifndef TLG_ORB_HOOK_H
|
||||
#define TLG_ORB_HOOK_H
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
static const uint32_t tlg_orb_calls[] = {0x6a775, 0x6fa33, 0x6fbd7};
|
||||
static const unsigned char tlg_orb_call_bytes[][5] = {
|
||||
{0xe8, 0x76, 0x02, 0x00, 0x00},
|
||||
{0xe8, 0xb8, 0xaf, 0xff, 0xff},
|
||||
{0xe8, 0x14, 0xae, 0xff, 0xff}
|
||||
};
|
||||
static inline void tlg_orb_build_stub(unsigned char code[26], uintptr_t helper, uintptr_t setter) {
|
||||
static const unsigned char pattern[26] = {
|
||||
0x57,0x48,0xb8,0,0,0,0,0,0,0,0,0xff,0xd0,0x5f,
|
||||
0x48,0xb8,0,0,0,0,0,0,0,0,0xff,0xe0
|
||||
};
|
||||
memcpy(code, pattern, 26);
|
||||
memcpy(code + 3, &helper, 8);
|
||||
memcpy(code + 16, &setter, 8);
|
||||
}
|
||||
static inline int tlg_orb_signatures_match(const unsigned char* image, uint64_t size) {
|
||||
static const unsigned char setter[] = {
|
||||
0x55,0x48,0x89,0xe5,0xc5,0xfa,0x5d,0x05,0x20,0xa4,0x28,0x00,
|
||||
0xc5,0xfa,0x11,0x87,0xe8,0x01,0x00,0x00
|
||||
};
|
||||
if (size < 0x6fbdc || memcmp(image + 0x6a9f0, setter, sizeof(setter))) return 0;
|
||||
for (unsigned i = 0; i < 3; ++i)
|
||||
if (memcmp(image + tlg_orb_calls[i], tlg_orb_call_bytes[i], 5)) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Orb pickup, image offset 0x6a480 (one caller, 0xbf496): rdi is the player's stats; xmm0
|
||||
* health (green), xmm1 magic (blue), xmm2 Rage of Sparta (gold), xmm3 red orbs (scaled by the
|
||||
* hooks above). The routine adds each positive amount and clamps it to the bar. The stub
|
||||
* replaces the prologue at the entry, where rax, xmm4 and the flags are free. */
|
||||
enum { TLG_ORB_PICKUP_OFFSET = 0x6a480, TLG_ORB_PICKUP_LENGTH = 9 };
|
||||
static const unsigned char tlg_orb_pickup_original[TLG_ORB_PICKUP_LENGTH] = {
|
||||
0x55, /* push rbp */
|
||||
0x48,0x89,0xe5, /* mov rbp, rsp */
|
||||
0x53, /* push rbx */
|
||||
0x48,0x83,0xec,0x18 /* sub rsp, 0x18 */
|
||||
};
|
||||
|
||||
static inline int tlg_orb_pickup_signature_match(const unsigned char *code, uint64_t size) {
|
||||
return size >= TLG_ORB_PICKUP_LENGTH && !memcmp(code, tlg_orb_pickup_original, TLG_ORB_PICKUP_LENGTH);
|
||||
}
|
||||
|
||||
/* Each amount above zero is multiplied by the float at green, blue or gold. */
|
||||
static inline unsigned tlg_orb_pickup_build_stub(unsigned char *out, uintptr_t green, uintptr_t blue,
|
||||
uintptr_t gold, uintptr_t resume) {
|
||||
static const unsigned char body[] = {
|
||||
0xc5,0xd8,0x57,0xe4, /* vxorps xmm4, xmm4, xmm4 */
|
||||
0x48,0xb8, 0,0,0,0,0,0,0,0, /* movabs rax, green */
|
||||
0xc5,0xf8,0x2e,0xc4, /* vucomiss xmm0, xmm4 */
|
||||
0x76,0x04, /* jbe +4 */
|
||||
0xc5,0xfa,0x59,0x00, /* vmulss xmm0, xmm0, [rax] */
|
||||
0x48,0xb8, 0,0,0,0,0,0,0,0, /* movabs rax, blue */
|
||||
0xc5,0xf8,0x2e,0xcc, /* vucomiss xmm1, xmm4 */
|
||||
0x76,0x04, /* jbe +4 */
|
||||
0xc5,0xf2,0x59,0x08, /* vmulss xmm1, xmm1, [rax] */
|
||||
0x48,0xb8, 0,0,0,0,0,0,0,0, /* movabs rax, gold */
|
||||
0xc5,0xf8,0x2e,0xd4, /* vucomiss xmm2, xmm4 */
|
||||
0x76,0x04, /* jbe +4 */
|
||||
0xc5,0xea,0x59,0x10, /* vmulss xmm2, xmm2, [rax] */
|
||||
};
|
||||
static const unsigned char jump[] = {0xff,0x25,0,0,0,0}; /* jmp [rip+0] */
|
||||
unsigned n = 0;
|
||||
memcpy(out, body, sizeof(body));
|
||||
memcpy(out + 6, &green, 8);
|
||||
memcpy(out + 26, &blue, 8);
|
||||
memcpy(out + 46, &gold, 8);
|
||||
n += sizeof(body);
|
||||
memcpy(out + n, tlg_orb_pickup_original, TLG_ORB_PICKUP_LENGTH);
|
||||
n += TLG_ORB_PICKUP_LENGTH;
|
||||
memcpy(out + n, jump, sizeof(jump));
|
||||
n += sizeof(jump);
|
||||
memcpy(out + n, &resume, 8);
|
||||
return n + 8;
|
||||
}
|
||||
#endif
|
||||
+26
-163
@@ -8,10 +8,6 @@
|
||||
#include "runtime.h"
|
||||
#include "cpu_check.h"
|
||||
#include "isa_rewrite.h"
|
||||
#include "orb_hook.h"
|
||||
#include "cheat_hook.h"
|
||||
#include "actor_hook.h"
|
||||
#include "damage_hook.h"
|
||||
#include "gpu/tlggpu.h"
|
||||
#if !defined(__x86_64__) || !defined(__GNUC__)
|
||||
#error This prototype requires x86-64 GCC or Clang (including MinGW).
|
||||
@@ -538,174 +534,44 @@ static int mapped(Segment *segments, uint64_t count, uint64_t address, uint64_t
|
||||
address - segments[i].address <= segments[i].size - bytes) return 1;
|
||||
return 0;
|
||||
}
|
||||
extern ABI float tlg_orbs_gain(void *player, float requested);
|
||||
extern void tlg_orbs_status(int supported);
|
||||
extern uintptr_t tlg_cheat_flag(unsigned id);
|
||||
extern uintptr_t tlg_actor_slots_address(void);
|
||||
extern void tlg_actor_status(int supported);
|
||||
extern void tlg_cheat_status(unsigned id);
|
||||
extern uintptr_t tlg_damage_multiplier(int taken);
|
||||
extern uintptr_t tlg_orb_pickup_multiplier(unsigned kind);
|
||||
extern void tlg_stat_multipliers_status(int damage, int orb_pickup);
|
||||
|
||||
static void install_cheats(const char *app0, Segment *segments, uint64_t count, uint64_t size) {
|
||||
char path[4096], serial[32] = "", version[32] = "";
|
||||
snprintf(path, sizeof(path), "%s/sce_sys/param.sfo", app0 ? app0 : ".");
|
||||
sfo_value(path, "TITLE_ID", serial, sizeof(serial), NULL);
|
||||
sfo_value(path, "APP_VER", version, sizeof(version), NULL);
|
||||
if (strcmp(serial, "CUSA01623") || strcmp(version, "01.02")) return;
|
||||
const char *names[] = {"health", "magic", "item meter", "Rage of Sparta", "red orbs",
|
||||
"Very Hard unlock", "infinite double jumps"};
|
||||
for (unsigned id = 0; id < TLG_CHEAT_COUNT; ++id) {
|
||||
int valid = 1;
|
||||
for (unsigned i = 0; i < TLG_CHEAT_HOOK_COUNT; ++i) {
|
||||
const TlgCheatHook *hook = &tlg_cheat_hooks[i];
|
||||
if (hook->id != id) continue;
|
||||
if (hook->offset > size || hook->length > size - hook->offset ||
|
||||
!mapped(segments, count, hook->offset, hook->length) ||
|
||||
!tlg_cheat_signature_match(hook, image + hook->offset, size - hook->offset)) valid = 0;
|
||||
}
|
||||
if (!valid) {
|
||||
printf("Cheats: %s unavailable (code signature mismatch)\n", names[id]);
|
||||
continue;
|
||||
}
|
||||
unsigned char *stubs = allocate(page_size);
|
||||
int32_t relative[TLG_CHEAT_HOOK_COUNT] = {0};
|
||||
for (unsigned i = 0; i < TLG_CHEAT_HOOK_COUNT; ++i) {
|
||||
const TlgCheatHook *hook = &tlg_cheat_hooks[i];
|
||||
if (hook->id != id) continue;
|
||||
unsigned char *stub = stubs + i * 256;
|
||||
int64_t delta = (int64_t)(uintptr_t)stub - (int64_t)(uintptr_t)(image + hook->offset + 5);
|
||||
if (delta < INT32_MIN || delta > INT32_MAX) { valid = 0; break; }
|
||||
relative[i] = (int32_t)delta;
|
||||
tlg_cheat_build_stub(stub, hook, tlg_cheat_flag(id),
|
||||
(uintptr_t)(image + hook->offset + hook->length));
|
||||
}
|
||||
if (!valid) {
|
||||
printf("Cheats: %s unavailable (hook outside relative-jump range)\n", names[id]);
|
||||
continue;
|
||||
}
|
||||
protect(stubs, page_size, 5);
|
||||
for (unsigned i = 0; i < TLG_CHEAT_HOOK_COUNT; ++i) {
|
||||
const TlgCheatHook *hook = &tlg_cheat_hooks[i];
|
||||
if (hook->id != id) continue;
|
||||
image[hook->offset] = 0xe9;
|
||||
memcpy(image + hook->offset + 1, &relative[i], 4);
|
||||
memset(image + hook->offset + 5, 0x90, hook->length - 5);
|
||||
}
|
||||
tlg_cheat_status(id);
|
||||
printf("Cheats: %s ready (live toggle, initially disabled unless saved)\n", names[id]);
|
||||
}
|
||||
}
|
||||
|
||||
/* Damage hook for the enemy health bar (src/actor_hook.h). Always
|
||||
* installed when the code matches: it only records values; the overlay decides what to show. */
|
||||
static void install_actor_watch(const char *app0, Segment *segments, uint64_t count, uint64_t size) {
|
||||
char path[4096], serial[32] = "", version[32] = "";
|
||||
snprintf(path, sizeof(path), "%s/sce_sys/param.sfo", app0 ? app0 : ".");
|
||||
sfo_value(path, "TITLE_ID", serial, sizeof(serial), NULL);
|
||||
sfo_value(path, "APP_VER", version, sizeof(version), NULL);
|
||||
const uint64_t at = TLG_ACTOR_HOOK_OFFSET;
|
||||
if (strcmp(serial, "CUSA01623") || strcmp(version, "01.02") || at > size ||
|
||||
!mapped(segments, count, at, TLG_ACTOR_HOOK_LENGTH) ||
|
||||
!tlg_actor_signature_match(image + at, size - at)) {
|
||||
printf("Health display: unavailable (game version or code signature mismatch)\n");
|
||||
return;
|
||||
}
|
||||
unsigned char *stub = allocate(page_size);
|
||||
const int64_t delta = (int64_t)(uintptr_t)stub - (int64_t)(uintptr_t)(image + at + 5);
|
||||
if (delta < INT32_MIN || delta > INT32_MAX) {
|
||||
printf("Health display: unavailable (hook outside relative-jump range)\n");
|
||||
return;
|
||||
}
|
||||
tlg_actor_build_stub(stub, tlg_actor_slots_address(), (uintptr_t)(image + at + TLG_ACTOR_HOOK_LENGTH));
|
||||
protect(stub, page_size, 5);
|
||||
const int32_t relative = (int32_t)delta;
|
||||
image[at] = 0xe9;
|
||||
memcpy(image + at + 1, &relative, 4);
|
||||
memset(image + at + 5, 0x90, TLG_ACTOR_HOOK_LENGTH - 5);
|
||||
tlg_actor_status(1);
|
||||
printf("Health display: damage hook ready (enemy health bar in the menu)\n");
|
||||
}
|
||||
|
||||
static void install_orb_multiplier(const char *app0, Segment *segments, uint64_t count,
|
||||
uint64_t size) {
|
||||
char path[4096], serial[32] = "", version[32] = "";
|
||||
snprintf(path, sizeof(path), "%s/sce_sys/param.sfo", app0 ? app0 : ".");
|
||||
sfo_value(path, "TITLE_ID", serial, sizeof(serial), NULL);
|
||||
sfo_value(path, "APP_VER", version, sizeof(version), NULL);
|
||||
if (strcmp(serial, "CUSA01623") || strcmp(version, "01.02") ||
|
||||
!tlg_orb_signatures_match(image, size)) {
|
||||
printf("Red orbs: multiplier unavailable (game version or gain signatures mismatch)\n");
|
||||
return;
|
||||
}
|
||||
/* HavokScript loadbuffer (0xe7e430, CUSA03745 v01.00): its three callers (file dofile,
|
||||
* loadstring, one more) go through the shim, which may log a chunk, append to it or swap it
|
||||
* for generated code (gpu/shim/tlg_lua.cpp). */
|
||||
extern ABI int tlg_lua_load(void *L, void *options, const char *buffer, uint64_t size,
|
||||
const char *name);
|
||||
extern void tlg_lua_status(uintptr_t original);
|
||||
static void install_lua_hook(Segment *segments, uint64_t count) {
|
||||
static const uint64_t calls[] = {0xd4fe8e, 0xe7e37f, 0xec24bc};
|
||||
const uint64_t target = 0xe7e430;
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
if (!mapped(segments, count, tlg_orb_calls[i], 5)) return;
|
||||
int32_t relative;
|
||||
if (!mapped(segments, count, calls[i], 5) || image[calls[i]] != 0xe8) goto mismatch;
|
||||
memcpy(&relative, image + calls[i] + 1, 4);
|
||||
if (calls[i] + 5 + (int64_t)relative != target) goto mismatch;
|
||||
}
|
||||
/* SysV call sites: preserve the receiver across the helper, then tail-call the original
|
||||
* setter. Its cap and upgrade notifications remain in the game's own code. */
|
||||
unsigned char *stub = allocate(page_size);
|
||||
uintptr_t helper = (uintptr_t)tlg_orbs_gain, setter = (uintptr_t)(image + 0x6a9f0);
|
||||
unsigned char code[26];
|
||||
tlg_orb_build_stub(code, helper, setter);
|
||||
static const unsigned char jump[] = {0x48,0xb8, 0,0,0,0,0,0,0,0, 0xff,0xe0}; /* movabs rax; jmp rax */
|
||||
memcpy(stub, jump, sizeof(jump));
|
||||
const uintptr_t helper = (uintptr_t)tlg_lua_load;
|
||||
memcpy(stub + 2, &helper, 8);
|
||||
int32_t displacements[3];
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
int64_t delta = (int64_t)(uintptr_t)stub - (int64_t)(uintptr_t)(image + tlg_orb_calls[i] + 5);
|
||||
const int64_t delta = (int64_t)(uintptr_t)stub - (int64_t)(uintptr_t)(image + calls[i] + 5);
|
||||
if (delta < INT32_MIN || delta > INT32_MAX) {
|
||||
printf("Red orbs: multiplier unavailable (hook outside relative-call range)\n");
|
||||
printf("Lua hook: unavailable (outside relative-call range)\n");
|
||||
return;
|
||||
}
|
||||
displacements[i] = (int32_t)delta;
|
||||
}
|
||||
memcpy(stub, code, sizeof(code));
|
||||
protect(stub, page_size, 5);
|
||||
for (unsigned i = 0; i < 3; ++i)
|
||||
memcpy(image + tlg_orb_calls[i] + 1, &displacements[i], 4);
|
||||
tlg_orbs_status(1);
|
||||
printf("Red orbs: live multiplier ready (3 validated gain paths, 0.1x to 100x)\n");
|
||||
}
|
||||
/* Writes a jump from image+at to `stub`; the rest of the replaced bytes become nops (the stub
|
||||
* replays them). 0 when the stub is out of relative-jump range. */
|
||||
static int install_jump(uint64_t at, unsigned length, unsigned char *stub) {
|
||||
const int64_t delta = (int64_t)(uintptr_t)stub - (int64_t)(uintptr_t)(image + at + 5);
|
||||
if (delta < INT32_MIN || delta > INT32_MAX) return 0;
|
||||
const int32_t relative = (int32_t)delta;
|
||||
image[at] = 0xe9;
|
||||
memcpy(image + at + 1, &relative, 4);
|
||||
memset(image + at + 5, 0x90, length - 5);
|
||||
return 1;
|
||||
tlg_lua_status((uintptr_t)(image + target));
|
||||
for (unsigned i = 0; i < 3; ++i) memcpy(image + calls[i] + 1, &displacements[i], 4);
|
||||
printf("Lua hook: ready (3 loadbuffer calls)\n");
|
||||
return;
|
||||
mismatch:
|
||||
printf("Lua hook: unavailable (code signature mismatch)\n");
|
||||
}
|
||||
|
||||
/* Damage dealt/taken and green/blue/gold orb multipliers (src/damage_hook.h, src/orb_hook.h):
|
||||
* the stubs read the menu's floats directly, so changes apply on the next hit or pickup. */
|
||||
static void install_stat_multipliers(const char *app0, Segment *segments, uint64_t count, uint64_t size) {
|
||||
char path[4096], serial[32] = "", version[32] = "";
|
||||
snprintf(path, sizeof(path), "%s/sce_sys/param.sfo", app0 ? app0 : ".");
|
||||
sfo_value(path, "TITLE_ID", serial, sizeof(serial), NULL);
|
||||
sfo_value(path, "APP_VER", version, sizeof(version), NULL);
|
||||
const int game = !strcmp(serial, "CUSA01623") && !strcmp(version, "01.02");
|
||||
const uint64_t damage_at = TLG_DAMAGE_HOOK_OFFSET, pickup_at = TLG_ORB_PICKUP_OFFSET;
|
||||
int damage = game && damage_at < size && mapped(segments, count, damage_at, TLG_DAMAGE_HOOK_LENGTH) &&
|
||||
tlg_damage_signature_match(image + damage_at, size - damage_at);
|
||||
int pickup = game && pickup_at < size && mapped(segments, count, pickup_at, TLG_ORB_PICKUP_LENGTH) &&
|
||||
tlg_orb_pickup_signature_match(image + pickup_at, size - pickup_at);
|
||||
unsigned char *stubs = damage || pickup ? allocate(page_size) : NULL;
|
||||
if (damage) {
|
||||
tlg_damage_build_stub(stubs, tlg_damage_multiplier(1), tlg_damage_multiplier(0),
|
||||
(uintptr_t)(image + damage_at + TLG_DAMAGE_HOOK_LENGTH));
|
||||
}
|
||||
if (pickup) {
|
||||
tlg_orb_pickup_build_stub(stubs + 256, tlg_orb_pickup_multiplier(0), tlg_orb_pickup_multiplier(1),
|
||||
tlg_orb_pickup_multiplier(2),
|
||||
(uintptr_t)(image + pickup_at + TLG_ORB_PICKUP_LENGTH));
|
||||
}
|
||||
if (stubs) protect(stubs, page_size, 5);
|
||||
damage = damage && install_jump(damage_at, TLG_DAMAGE_HOOK_LENGTH, stubs);
|
||||
pickup = pickup && install_jump(pickup_at, TLG_ORB_PICKUP_LENGTH, stubs + 256);
|
||||
tlg_stat_multipliers_status(damage, pickup);
|
||||
printf("Multipliers: damage %s, green/blue/gold orbs %s\n", damage ? "ready" : "unavailable",
|
||||
pickup ? "ready" : "unavailable");
|
||||
}
|
||||
/* G3PATCH2 (patches.py): the patches' image base, then byte writes at image offsets, applied
|
||||
* after relocation. A write may replace a whole base-relative pointer slot (60/90 FPS++ swap
|
||||
* function pointers): the patch holds the address at the patches' base, rebased here. */
|
||||
@@ -1126,10 +992,7 @@ int main(int argc, char **argv) {
|
||||
_exit(missing ? 1 : 0); /* no destructors: GPU threads are running */
|
||||
}
|
||||
if (patch_file) apply_patches(patch_file, segments, ns, relocs, nr);
|
||||
if (gpu_enabled) install_orb_multiplier(app0, segments, ns, size);
|
||||
if (gpu_enabled) install_cheats(app0, segments, ns, size);
|
||||
if (gpu_enabled) install_actor_watch(app0, segments, ns, size);
|
||||
if (gpu_enabled) install_stat_multipliers(app0, segments, ns, size);
|
||||
if (gpu_enabled) install_lua_hook(segments, ns);
|
||||
#ifdef _WIN32
|
||||
printf("Guest TCB loads redirected to the TEB TLS slot: %" PRIu64 "\n", patch_tcb_loads(segments, ns));
|
||||
#endif
|
||||
|
||||
+38
-3
@@ -90,6 +90,9 @@ void runtime_file_configure(const char *app0,const char *user) {
|
||||
snprintf(user_root,sizeof(user_root),"%s",user);
|
||||
runtime_file_mount("/app0",app0);
|
||||
runtime_file_mount("/hostapp",app0);
|
||||
const char *japanese=getenv("TLG_JAPANESE_FALLBACK");
|
||||
if (japanese && !strcmp(japanese,"1"))
|
||||
puts("Runtime: Japanese text fallback: French root archives hidden from guest; host files unchanged");
|
||||
const char *writable[]={"temp0","download0","data"};
|
||||
mkdir(user,0755);
|
||||
for (int i=0;i<3;++i) {
|
||||
@@ -99,6 +102,34 @@ void runtime_file_configure(const char *app0,const char *user) {
|
||||
runtime_file_mount(guest,path);
|
||||
}
|
||||
}
|
||||
/* Opt-in EU Japanese-text fallback: hide only the root French localization archives
|
||||
* from the guest. Never move/delete host files or affect saves/other language packs. */
|
||||
static int language_archive_name(const char *name,const char *expected) {
|
||||
for (;*name && *expected;++name,++expected) {
|
||||
unsigned char c=(unsigned char)*name;
|
||||
if (c>='A' && c<='Z') c+=(unsigned char)('a'-'A');
|
||||
if (c!=(unsigned char)*expected) return 0;
|
||||
}
|
||||
return !*name && !*expected;
|
||||
}
|
||||
static int japanese_hidden_archive(const char *guest) {
|
||||
const char *enabled=getenv("TLG_JAPANESE_FALLBACK");
|
||||
if (!enabled || strcmp(enabled,"1") || !guest) return 0;
|
||||
const char *p=guest;
|
||||
if (*p=='/') {
|
||||
if (!strncmp(p,"/app0/",6)) p+=6;
|
||||
else if (!strncmp(p,"/hostapp/",9)) p+=9;
|
||||
else return 0;
|
||||
}
|
||||
for (;;) {
|
||||
while (*p=='/') ++p;
|
||||
if (p[0]=='.' && p[1]=='/') { p+=2; continue; }
|
||||
break;
|
||||
}
|
||||
return language_archive_name(p,"trico_frf.psarc") ||
|
||||
language_archive_name(p,"trico_frfp.psarc");
|
||||
}
|
||||
|
||||
/* Resolve a guest path to a host path; returns 0 or a host errno. */
|
||||
static int translate(const char *guest,char *out,size_t size) {
|
||||
if (!guest || !*guest) return ENOENT;
|
||||
@@ -107,6 +138,7 @@ static int translate(const char *guest,char *out,size_t size) {
|
||||
else snprintf(buffer,sizeof(buffer),"%s",guest);
|
||||
for (const char *p=buffer;(p=strstr(p,".."));p+=2)
|
||||
if ((p==buffer || p[-1]=='/') && (p[2]==0 || p[2]=='/')) return EACCES;
|
||||
if (japanese_hidden_archive(buffer)) return ENOENT;
|
||||
pthread_mutex_lock(&lock);
|
||||
size_t best=0; const Mount *m=NULL;
|
||||
for (size_t i=0;i<mount_count;++i) {
|
||||
@@ -136,13 +168,16 @@ static int host_flags(int flags) {
|
||||
return r|O_CLOEXEC;
|
||||
}
|
||||
static void free_listing(Listing *l) { if (l) { free(l->names); free(l->offsets); free(l->types); free(l); } }
|
||||
static Listing *list_directory(const char *path) {
|
||||
static Listing *list_directory(const char *path,const char *guest) {
|
||||
DIR *d=opendir(path);
|
||||
if (!d) return NULL;
|
||||
Listing *l=calloc(1,sizeof(*l));
|
||||
size_t capacity=0,bytes=0,cap_names=0;
|
||||
struct dirent *e;
|
||||
while (l && (e=readdir(d))) {
|
||||
char child_guest[1400];
|
||||
if ((size_t)snprintf(child_guest,sizeof(child_guest),"%s/%s",guest,e->d_name)<sizeof(child_guest) &&
|
||||
japanese_hidden_archive(child_guest)) continue;
|
||||
size_t n=strlen(e->d_name)+1;
|
||||
if (l->count==capacity) {
|
||||
capacity=capacity ? capacity*2 : 64;
|
||||
@@ -227,7 +262,7 @@ static int64_t do_open(const char *guest,int flags,int mode) {
|
||||
int host=-1;
|
||||
if (!host_stat(path,&s) && S_ISDIR(s.st_mode)) {
|
||||
if (flags&3) return -EISDIR;
|
||||
if (!(dir=list_directory(path))) return -EACCES;
|
||||
if (!(dir=list_directory(path,guest))) return -EACCES;
|
||||
} else if (flags&0x20000) {
|
||||
e=errno==ENOENT ? ENOENT : ENOTDIR;
|
||||
if (e==ENOENT) { ++missing; printf("Runtime: open(%s) -> not found\n",guest); }
|
||||
@@ -252,7 +287,7 @@ static int64_t do_open(const char *guest,int flags,int mode) {
|
||||
}
|
||||
struct stat s;
|
||||
Listing *dir=NULL;
|
||||
if (!fstat(host,&s) && S_ISDIR(s.st_mode)) dir=list_directory(path);
|
||||
if (!fstat(host,&s) && S_ISDIR(s.st_mode)) dir=list_directory(path,guest);
|
||||
#endif
|
||||
pthread_mutex_lock(&lock);
|
||||
int fd=-1;
|
||||
|
||||
+28
-10
@@ -755,20 +755,24 @@ void *runtime_low_map(size_t size, int prot) {
|
||||
* 262144 with TLG_ASYNC_FENCES=1: wait for guest copies at fences again,
|
||||
* 524288 small guest copies batched for the copy threads instead of the recording thread,
|
||||
* 1073741824 the lock-free UpdateImage path for clean, tracked images. */
|
||||
/* Bits 32 and up: the draw pipeline and related GPU thread work (gpu/shim/tlg_toggles.h). */
|
||||
/* Bits 32 and up: the draw pipeline and related GPU thread work (gpu/shim/tlg_toggles.h).
|
||||
* An optional second number in the file is a second mask (TlgToggle::Disabled2): 1 shader code
|
||||
* read through the backing view. */
|
||||
uint64_t runtime_disabled_optimizations;
|
||||
uint64_t runtime_disabled_optimizations2;
|
||||
/* Speculative readers of guest memory (GPU draw-preparation workers) register a recovery
|
||||
* point: a fault on that thread jumps back to it instead of terminating (probe.c). */
|
||||
__thread sigjmp_buf *runtime_fault_recover;
|
||||
static void *toggle_watcher(void *path) {
|
||||
for (unsigned long long last=ULLONG_MAX;;) {
|
||||
for (unsigned long long last=ULLONG_MAX, last2=ULLONG_MAX;;) {
|
||||
FILE *f=fopen(path,"r");
|
||||
unsigned long long value=0;
|
||||
if (f) { if (fscanf(f,"%llu",&value)!=1) value=0; fclose(f); }
|
||||
if (value!=last) {
|
||||
unsigned long long value=0, value2=0;
|
||||
if (f) { if (fscanf(f,"%llu %llu",&value,&value2)<1) value=0; fclose(f); }
|
||||
if (value!=last || value2!=last2) {
|
||||
__atomic_store_n(&runtime_disabled_optimizations,(uint64_t)value,__ATOMIC_RELEASE);
|
||||
printf("Runtime: disabled optimizations mask=%llu\n",value);
|
||||
last=value;
|
||||
__atomic_store_n(&runtime_disabled_optimizations2,(uint64_t)value2,__ATOMIC_RELEASE);
|
||||
printf("Runtime: disabled optimizations mask=%llu %llu\n",value,value2);
|
||||
last=value; last2=value2;
|
||||
}
|
||||
#ifdef _WIN32
|
||||
compat_sleep_ns(250000000);
|
||||
@@ -806,7 +810,7 @@ int runtime_memory_write_backing(uintptr_t address, const void *data, uint64_t s
|
||||
* backing (the caller then reads the guest address directly). GPU-internal threads use it so a
|
||||
* read of a page protected for readback (TLG_READBACKS=2) cannot fault into a readback that waits
|
||||
* for the same thread (the Vulkan recorder). */
|
||||
int runtime_memory_read_backing(uintptr_t address, void *data, uint64_t size) {
|
||||
static int read_backing_locked(uintptr_t address, void *data, uint64_t size) {
|
||||
read_lock();
|
||||
int ok=1;
|
||||
for (uintptr_t at=address, end=address+size; at<end && ok;) {
|
||||
@@ -821,7 +825,7 @@ int runtime_memory_read_backing(uintptr_t address, void *data, uint64_t size) {
|
||||
}
|
||||
/* Per-thread cache of recently found regions for the GPU's per-draw queries:
|
||||
* entries hold for as long as the table generation they were read at. */
|
||||
typedef struct { uint64_t generation; uintptr_t start, end; int kind; } CachedRegion;
|
||||
typedef struct { uint64_t generation; uintptr_t start, end; uint64_t phys; int kind; } CachedRegion;
|
||||
static __thread CachedRegion region_cache[8];
|
||||
static __thread unsigned region_cache_next;
|
||||
static const CachedRegion *cached_region(uintptr_t address) {
|
||||
@@ -837,7 +841,21 @@ static const CachedRegion *cached_region(uintptr_t address) {
|
||||
static void cache_region(size_t i) {
|
||||
uint64_t generation=__atomic_load_n(&table_generation,__ATOMIC_ACQUIRE);
|
||||
if (i==vma_count || (generation & 1)) return;
|
||||
region_cache[region_cache_next++ % 8]=(CachedRegion){generation,vmas[i].start,vmas[i].end,vmas[i].kind};
|
||||
region_cache[region_cache_next++ % 8]=(CachedRegion){generation,vmas[i].start,vmas[i].end,vmas[i].phys,vmas[i].kind};
|
||||
}
|
||||
/* Copy from the backing view. The GPU thread reads shader headers here per draw: from a cached
|
||||
* region the copy needs no lock or table search, and it only counts if no writer started meanwhile. */
|
||||
int runtime_memory_read_backing(uintptr_t address, void *data, uint64_t size) {
|
||||
const CachedRegion *c=(runtime_disabled_optimizations & 1) ? NULL : cached_region(address);
|
||||
if (c && c->kind!=KIND_RESERVED && size<=c->end-address) {
|
||||
uint64_t generation=c->generation;
|
||||
memcpy(data,backing_base+c->phys+(address-c->start),size);
|
||||
if (__atomic_load_n(&table_generation,__ATOMIC_ACQUIRE)==generation) return 1;
|
||||
}
|
||||
read_lock();
|
||||
{ size_t i=vma_index(address); if (i<vma_count && vmas[i].start<=address) cache_region(i); }
|
||||
read_unlock();
|
||||
return read_backing_locked(address,data,size);
|
||||
}
|
||||
/* Mapped length from address, up to size, across adjacent mappings. */
|
||||
uint64_t runtime_memory_clamp(uintptr_t address, uint64_t size) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later */
|
||||
#ifndef RUNTIME_MENU_H
|
||||
#define RUNTIME_MENU_H
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Menu policy only. Never use this to remap the gameplay pad's face buttons.
|
||||
* Set once at launch; the launcher stores menu_confirm=cross|circle in the ini.
|
||||
* Missing/invalid values preserve the existing Cross-confirm behavior. */
|
||||
static inline int menu_circle_confirm(void) {
|
||||
const char *value = getenv("TLG_MENU_CONFIRM");
|
||||
return value && strcmp(value, "circle") == 0;
|
||||
}
|
||||
|
||||
/* Dialog result: 0 nothing, 1 accept, 2 cancel. Keyboard handling stays separate. */
|
||||
static inline int menu_button_result(int cross, int circle) {
|
||||
const int japanese = menu_circle_confirm();
|
||||
return (japanese ? circle : cross) ? 1 : (japanese ? cross : circle) ? 2 : 0;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -6,6 +6,7 @@
|
||||
* Every entry here is an explicit contract; unknown functions still stop. */
|
||||
#define _GNU_SOURCE
|
||||
#include "runtime.h"
|
||||
#include "runtime_menu.h"
|
||||
#include "gpu/tlggpu.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -85,7 +86,7 @@ static ABI int32_t system_param(int32_t id,int32_t *value) {
|
||||
#endif
|
||||
case 5: *value=0; break; /* summer time */
|
||||
case 7: *value=0; break; /* parental level off */
|
||||
case 1000: *value=1; break; /* enter button = cross */
|
||||
case 1000: *value=menu_circle_confirm() ? 0 : 1; break; /* menu enter button */
|
||||
default: fprintf(stderr,"STOP: unsupported system parameter %d\n",id); exit(21);
|
||||
}
|
||||
return 0;
|
||||
@@ -513,7 +514,8 @@ static ABI int32_t ime_init(const ImeParam *param, const void *extended) {
|
||||
const char *name=getenv("TLG_USER_NAME");
|
||||
const char *start=initial[0] ? initial : name && *name ? name : "Hunter";
|
||||
if (!tlggpu_text_input_begin(start,prompt[0] ? prompt : "Text")) ime_complete(0,start);
|
||||
else printf("Runtime: ImeDialog opened: shown on screen; keyboard Enter or Cross accepts, Esc or Circle cancels\n");
|
||||
else printf("Runtime: ImeDialog opened: shown on screen; keyboard Enter or %s accepts, Esc or %s cancels\n",
|
||||
menu_circle_confirm() ? "Circle" : "Cross", menu_circle_confirm() ? "Cross" : "Circle");
|
||||
return 0;
|
||||
}
|
||||
static ABI int32_t ime_status(void) {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"source_commit": "b8d5f412398fae52a444400a9b5131bc1c970cc3",
|
||||
"inventory": {
|
||||
"vars": [
|
||||
"game_dir",
|
||||
"mouse_enabled",
|
||||
"mouse_invert_y",
|
||||
"mouse_sensitivity",
|
||||
"native_audio",
|
||||
"show_fps",
|
||||
"system_lib_dir",
|
||||
"vram_budget_mb",
|
||||
"vsync"
|
||||
],
|
||||
"filters": [
|
||||
"color_adjust",
|
||||
"color_brightness",
|
||||
"color_contrast",
|
||||
"color_gamma",
|
||||
"color_saturation",
|
||||
"smaa"
|
||||
],
|
||||
"buttons": [
|
||||
"Browse…",
|
||||
"Browse…",
|
||||
"Keyboard and mouse buttons…",
|
||||
"Quit",
|
||||
"Play",
|
||||
"Logs folder",
|
||||
"Mods / Cheats…",
|
||||
"Saves folder",
|
||||
"Clear shader cache"
|
||||
],
|
||||
"checks": [
|
||||
"Native audio (libSceNgs2.sprx from your console)",
|
||||
"Mouse turns the camera",
|
||||
"Invert mouse Y axis",
|
||||
"VSync",
|
||||
"Show FPS overlay at start (F10 toggles)",
|
||||
"SMAA anti-aliasing (smooths edges; the HUD too)",
|
||||
"Color adjustments"
|
||||
],
|
||||
"combos": [
|
||||
[
|
||||
"English",
|
||||
"Português (Brasil)",
|
||||
"Español (Latinoamérica)",
|
||||
"Français (Canada)"
|
||||
],
|
||||
[
|
||||
"Automatic (system)",
|
||||
"Português (Brasil)",
|
||||
"English (US)"
|
||||
],
|
||||
[
|
||||
"Windowed",
|
||||
"Borderless window",
|
||||
"Fullscreen"
|
||||
],
|
||||
[
|
||||
"30 FPS (as on PS4)",
|
||||
"60 FPS",
|
||||
"Unlocked (up to the display)"
|
||||
],
|
||||
[
|
||||
"Off",
|
||||
"RCAS (FSR 1)",
|
||||
"CAS (AMD)"
|
||||
]
|
||||
],
|
||||
"spins": [
|
||||
[
|
||||
"0.1",
|
||||
"5.0",
|
||||
"0.1"
|
||||
],
|
||||
[
|
||||
"2000",
|
||||
"24000",
|
||||
"200"
|
||||
],
|
||||
[
|
||||
"-0.5",
|
||||
"0.5",
|
||||
"0.05"
|
||||
],
|
||||
[
|
||||
"0.5",
|
||||
"1.5",
|
||||
"0.05"
|
||||
],
|
||||
[
|
||||
"0.0",
|
||||
"2.0",
|
||||
"0.05"
|
||||
],
|
||||
[
|
||||
"0.5",
|
||||
"2.0",
|
||||
"0.05"
|
||||
]
|
||||
],
|
||||
"links": [
|
||||
"YouTube channel: @hayatog-dev",
|
||||
"Discord community",
|
||||
"♥ Sponsor on GitHub"
|
||||
],
|
||||
"remap_entries": 17,
|
||||
"remap_buttons": [
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Set",
|
||||
"Reset all",
|
||||
"Close"
|
||||
],
|
||||
"mods_buttons": [
|
||||
"Open mods folder",
|
||||
"Refresh list",
|
||||
"Close"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Runs the damage-hook stub (src/actor_hook.h) on fake actors and checks what it records, then
|
||||
// the overlay's validity rules (gpu/shim/tlg_actors.h).
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <windows.h>
|
||||
#include "../src/actor_hook.h"
|
||||
#include "tlg_actors.h"
|
||||
|
||||
// The stub ends with a jump to its resume address; here that is a `ret` back to the test.
|
||||
extern "C" void actor_hook_return();
|
||||
asm(".globl actor_hook_return\nactor_hook_return:\n ret\n");
|
||||
|
||||
alignas(64) static TlgActorSlot slots[2];
|
||||
|
||||
static void hit(unsigned char* stub, unsigned char* actor) {
|
||||
asm volatile("mov %0, %%r13\n\tcall *%1\n\t"
|
||||
:
|
||||
: "r"(actor), "r"(stub)
|
||||
: "rax", "rdi", "r13", "memory", "cc");
|
||||
}
|
||||
|
||||
static void set_health(unsigned char* actor, float health, float max_health, bool player) {
|
||||
std::memcpy(actor + 0x1cc, &health, 4);
|
||||
std::memcpy(actor + 0x1d0, &max_health, 4);
|
||||
actor[0x298] = player ? 0 : 1; // the infinite-health cheat writes the maximum only when 0
|
||||
}
|
||||
|
||||
int main() {
|
||||
assert(tlg_actor_signature_match(tlg_actor_original, TLG_ACTOR_HOOK_LENGTH));
|
||||
const unsigned char other[] = {0x49, 0x8b, 0x45, 0x00, 0x4c, 0x89, 0xee};
|
||||
assert(!tlg_actor_signature_match(other, sizeof(other)));
|
||||
assert(!tlg_actor_signature_match(tlg_actor_original, TLG_ACTOR_HOOK_LENGTH - 1));
|
||||
|
||||
auto* stub = static_cast<unsigned char*>(
|
||||
VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE));
|
||||
assert(stub);
|
||||
const unsigned n = tlg_actor_build_stub(stub, reinterpret_cast<uintptr_t>(slots),
|
||||
reinterpret_cast<uintptr_t>(&actor_hook_return));
|
||||
assert(n < 256);
|
||||
|
||||
alignas(16) static unsigned char player[0x300], enemy[0x300];
|
||||
set_health(player, 80.0f, 400.0f, true);
|
||||
set_health(enemy, 250.0f, 1000.0f, false);
|
||||
hit(stub, enemy);
|
||||
assert(slots[1].actor == reinterpret_cast<uint64_t>(enemy));
|
||||
assert(slots[1].health == 250.0f && slots[1].max_health == 1000.0f && slots[1].seq == 1);
|
||||
assert(slots[0].seq == 0);
|
||||
hit(stub, player);
|
||||
assert(slots[0].actor == reinterpret_cast<uint64_t>(player) && slots[0].health == 80.0f);
|
||||
set_health(enemy, 0.0f, 1000.0f, false);
|
||||
hit(stub, enemy);
|
||||
assert(slots[1].health == 0.0f && slots[1].seq == 2 && slots[0].seq == 1);
|
||||
|
||||
assert(TlgActors::Valid(250.0f, 1000.0f) && TlgActors::Fraction(250.0f, 1000.0f) == 0.25f);
|
||||
assert(!TlgActors::Valid(NAN, 100.0f) && !TlgActors::Valid(10.0f, 0.0f));
|
||||
assert(!TlgActors::Valid(-1.0f, 100.0f) && !TlgActors::Valid(200.0f, 100.0f));
|
||||
assert(!TlgActors::Valid(1.0f, 1e9f) && TlgActors::Fraction(NAN, 1.0f) == 0.0f);
|
||||
VirtualFree(stub, 0, MEM_RELEASE);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// Live cheat stubs execute on synthetic state without touching game saves.
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <windows.h>
|
||||
#include "../src/cheat_hook.h"
|
||||
|
||||
static int read_difficulty(void* entry, unsigned completed) {
|
||||
int result;
|
||||
asm volatile("mov %2, %%ecx; call *%1"
|
||||
: "=a"(result) : "r"(entry), "r"(completed) : "rcx", "memory", "cc");
|
||||
return result;
|
||||
}
|
||||
|
||||
static int jump_blocked(void* entry, void* actor) {
|
||||
int result;
|
||||
asm volatile("mov %2, %%r12; call *%1"
|
||||
: "=a"(result) : "r"(entry), "r"(actor) : "r12", "memory", "cc");
|
||||
return result;
|
||||
}
|
||||
|
||||
int main() {
|
||||
_set_error_mode(_OUT_TO_STDERR);
|
||||
const TlgCheatHook* unlock = nullptr;
|
||||
for (const auto& hook : tlg_cheat_hooks) {
|
||||
if (hook.offset == 0xc77ea) unlock = &hook;
|
||||
}
|
||||
assert(unlock);
|
||||
const unsigned char tail[] = {
|
||||
0xb8,3,0,0,0,0xf6,0xc1,8,0x75,0x1b,
|
||||
0xb8,2,0,0,0,0xf6,0xc1,4,0x75,0x11,
|
||||
0xb8,1,0,0,0,0xf6,0xc1,2,0x75,7,
|
||||
0x83,0xe1,1,0xff,0xc9,0x89,0xc8,0xc3
|
||||
};
|
||||
assert(tlg_cheat_signature_match(unlock, tail, sizeof(tail)));
|
||||
auto* memory = static_cast<unsigned char*>(VirtualAlloc(nullptr,4096,MEM_COMMIT|MEM_RESERVE,PAGE_EXECUTE_READWRITE));
|
||||
assert(memory);
|
||||
std::memcpy(memory+512,tail,sizeof(tail));
|
||||
unsigned char enabled=0;
|
||||
tlg_cheat_build_stub(memory,unlock,reinterpret_cast<uintptr_t>(&enabled),reinterpret_cast<uintptr_t>(memory+512+unlock->length));
|
||||
for(unsigned mask=0;mask<16;++mask) {
|
||||
enabled=0;
|
||||
const int expected=mask&8?3:mask&4?2:mask&2?1:mask&1?0:-1;
|
||||
assert(read_difficulty(memory,mask)==expected);
|
||||
enabled=1;
|
||||
assert(read_difficulty(memory,mask)==3);
|
||||
}
|
||||
const TlgCheatHook* jumps = nullptr;
|
||||
for (const auto& hook : tlg_cheat_hooks) {
|
||||
if (hook.offset == 0x95d51) jumps = &hook;
|
||||
}
|
||||
assert(jumps);
|
||||
const unsigned char original[] = {0x41,0xf6,0x84,0x24,0xac,4,0,0,4};
|
||||
assert(tlg_cheat_signature_match(jumps,original,sizeof(original)));
|
||||
assert(!tlg_cheat_signature_match(jumps,original,sizeof(original)-1));
|
||||
unsigned char mismatch[9];std::memcpy(mismatch,original,9);mismatch[8]=8;
|
||||
assert(!tlg_cheat_signature_match(jumps,mismatch,9));
|
||||
const unsigned char resume[] = {0x0f,0x95,0xc0,0x0f,0xb6,0xc0,0xc3};
|
||||
std::memcpy(memory+512,resume,sizeof(resume));
|
||||
tlg_cheat_build_stub(memory,jumps,reinterpret_cast<uintptr_t>(&enabled),reinterpret_cast<uintptr_t>(memory+512));
|
||||
unsigned char actor[0x500]{};
|
||||
for (unsigned flags : {0u,4u,0x80004u}) {
|
||||
std::memcpy(actor+0x4ac,&flags,4);
|
||||
actor[0x298]=0;enabled=0;
|
||||
assert(jump_blocked(memory,actor)==int((flags&4)!=0));
|
||||
enabled=1;
|
||||
assert(jump_blocked(memory,actor)==0);
|
||||
actor[0x298]=1;
|
||||
assert(jump_blocked(memory,actor)==int((flags&4)!=0));
|
||||
unsigned after;std::memcpy(&after,actor+0x4ac,4);assert(after==flags);
|
||||
}
|
||||
VirtualFree(memory,0,MEM_RELEASE);
|
||||
std::puts("PASS: live cheat hooks");
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
#include <cassert>
|
||||
#include "../src/cheat_hook.h"
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <sys/mman.h>
|
||||
#endif
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
unsigned char code[256]{};
|
||||
std::vector<unsigned char> image;
|
||||
if (argc > 1) {
|
||||
FILE* file = std::fopen(argv[1], "rb");
|
||||
assert(file && !std::fseek(file, 0, SEEK_END));
|
||||
const auto size = std::ftell(file);
|
||||
assert(size > 0x4000);
|
||||
image.resize(size - 0x4000);
|
||||
assert(!std::fseek(file, 0x4000, SEEK_SET));
|
||||
assert(std::fread(image.data(), 1, image.size(), file) == image.size());
|
||||
std::fclose(file);
|
||||
}
|
||||
for (unsigned i = 0; i < TLG_CHEAT_HOOK_COUNT; ++i) {
|
||||
const auto& hook = tlg_cheat_hooks[i];
|
||||
assert(tlg_cheat_signature_match(&hook, hook.original, hook.length));
|
||||
code[0] = 0;
|
||||
assert(!tlg_cheat_signature_match(&hook, code, hook.length));
|
||||
assert(!tlg_cheat_signature_match(&hook, hook.original, hook.length - 1));
|
||||
assert(tlg_cheat_build_stub(code, &hook, 0x12345678, 0x87654321) < sizeof(code));
|
||||
if (!image.empty()) {
|
||||
assert(hook.offset + hook.length <= image.size());
|
||||
assert(tlg_cheat_signature_match(&hook, image.data() + hook.offset, image.size() - hook.offset));
|
||||
}
|
||||
#ifdef _WIN32
|
||||
auto* executable = static_cast<unsigned char*>(VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
|
||||
#else
|
||||
auto* executable = static_cast<unsigned char*>(mmap(nullptr, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0));
|
||||
assert(executable != MAP_FAILED);
|
||||
#endif
|
||||
assert(executable);
|
||||
// Emulate each game's register convention, including its flags and a live RAX value.
|
||||
const unsigned char entry[] = {0x53,0x41,0x55,0x48,0x89,0xfb,0x49,0x89,0xfd,
|
||||
0xc5,0xf8,0x28,0xc8,0x48,0xb8,0x78,0x56,0x34,0x12,0,0,0,0,0xf9,
|
||||
0xff,0x25,0,0,0,0};
|
||||
memcpy(executable, entry, sizeof(entry));
|
||||
uintptr_t target = reinterpret_cast<uintptr_t>(executable + 64);
|
||||
memcpy(executable + sizeof(entry), &target, 8);
|
||||
std::atomic<bool> flag{false};
|
||||
assert(tlg_cheat_build_stub(executable + 64, &hook, reinterpret_cast<uintptr_t>(&flag),
|
||||
reinterpret_cast<uintptr_t>(executable + 256)) < 192);
|
||||
const unsigned char exit[] = {0x0f,0x92,0x87,0,3,0,0,
|
||||
0x48,0x89,0x87,8,3,0,0,0x41,0x5d,0x5b,0xc3};
|
||||
memcpy(executable + 256, exit, sizeof(exit));
|
||||
#ifdef _WIN32
|
||||
DWORD previous;
|
||||
assert(VirtualProtect(executable, 4096, PAGE_EXECUTE_READ, &previous));
|
||||
FlushInstructionCache(GetCurrentProcess(), executable, 4096);
|
||||
#else
|
||||
assert(!mprotect(executable, 4096, PROT_READ | PROT_EXEC));
|
||||
#endif
|
||||
using Execute = void (__attribute__((sysv_abi)) *)(void*, float);
|
||||
const auto execute = reinterpret_cast<Execute>(executable);
|
||||
std::array<unsigned char, 0x400> player{};
|
||||
const unsigned offsets[] = {0x1cc,0x1d0,0x1d4,0x1d8,0x1e8};
|
||||
const unsigned offset = offsets[hook.id];
|
||||
const float original = 100, health_max = 250;
|
||||
memcpy(player.data() + offset, &original, 4);
|
||||
memcpy(player.data() + 0x1d0, &health_max, 4);
|
||||
execute(player.data(), 5);
|
||||
float result;
|
||||
memcpy(&result, player.data() + offset, 4);
|
||||
assert(result == 5 && player[0x300] == 1);
|
||||
uint64_t preserved;
|
||||
memcpy(&preserved, player.data() + 0x308, 8);
|
||||
assert(preserved == 0x12345678);
|
||||
memcpy(player.data() + offset, &original, 4);
|
||||
if (hook.id == TLG_CHEAT_HEALTH) memcpy(player.data() + 0x1d0, &health_max, 4);
|
||||
flag.store(true);
|
||||
execute(player.data(), 5);
|
||||
memcpy(&result, player.data() + offset, 4);
|
||||
const float expected[] = {250,100,100,1000000000,999999};
|
||||
assert(result == expected[hook.id] && player[0x300] == 1);
|
||||
memcpy(&preserved, player.data() + 0x308, 8);
|
||||
assert(preserved == 0x12345678);
|
||||
if (hook.id == TLG_CHEAT_HEALTH) {
|
||||
player[0x298] = 1;
|
||||
execute(player.data(), 7);
|
||||
memcpy(&result, player.data() + offset, 4);
|
||||
assert(result == 7);
|
||||
}
|
||||
flag.store(false);
|
||||
execute(player.data(), 3);
|
||||
memcpy(&result, player.data() + offset, 4);
|
||||
assert(result == 3);
|
||||
#ifdef _WIN32
|
||||
VirtualFree(executable, 0, MEM_RELEASE);
|
||||
#else
|
||||
munmap(executable, 4096);
|
||||
#endif
|
||||
}
|
||||
std::puts("cheat-hooks-test: OK (6 hooks, live toggles, registers, flags, signatures, non-player health)");
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class GraphicsOptionsTest(unittest.TestCase):
|
||||
options, reverted = g.prepare(self.ini, self.state)
|
||||
self.assertFalse(reverted)
|
||||
self.assertEqual(options, {'render_resolution': 'native', 'engine_fps': '120', 'deferred_readback': '1',
|
||||
'stale_readback': '1', 'async_shaders': '1'})
|
||||
'stale_readback': '1', 'async_shaders': '0'})
|
||||
|
||||
def test_write_keys_keeps_other_lines(self):
|
||||
self.ini.write_text('# comment\nshow_fps=1\nengine_fps=60\n', encoding='utf-8')
|
||||
@@ -67,7 +67,7 @@ class GraphicsOptionsTest(unittest.TestCase):
|
||||
|
||||
def test_environment(self):
|
||||
env = g.environment(g.startup_options({'stale_readback': '0'}))
|
||||
self.assertEqual(env, {'TLG_DEFERRED_READBACK': '1', 'TLG_STALE_READBACK': '0', 'TLG_ASYNC_SHADERS': '1'})
|
||||
self.assertEqual(env, {'TLG_DEFERRED_READBACK': '1', 'TLG_STALE_READBACK': '0', 'TLG_ASYNC_SHADERS': '0'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -94,15 +94,6 @@ int main() {
|
||||
assert(v.display_mode == G::Windowed && v.fps_limit == 0 && v.render_resolution == 0 && v.engine_fps == 120);
|
||||
assert(v.rcas_strength == 75 && v.frames_queued == 1);
|
||||
assert(v.async_shaders);
|
||||
{
|
||||
std::ofstream out(path);
|
||||
out << "cheat_double_jumps=1\ncheat_very_hard=1\n";
|
||||
}
|
||||
TlgSettings::Load();
|
||||
TlgSettings::Save();
|
||||
const std::string cheats = Read(path);
|
||||
assert(cheats.find("cheat_double_jumps=1\n") != std::string::npos);
|
||||
assert(cheats.find("cheat_very_hard=1\n") != std::string::npos);
|
||||
std::remove(path);
|
||||
std::puts("PASS: graphics settings");
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* The Japanese-text view must look like missing French packs, without touching host files. */
|
||||
#undef NDEBUG
|
||||
#include "../src/runtime_file.c"
|
||||
#include <assert.h>
|
||||
|
||||
static int32_t guest_errno;
|
||||
int32_t *runtime_errno(void) { return &guest_errno; }
|
||||
int32_t runtime_guest_errno(int e) { return e; }
|
||||
void runtime_wait_begin(const char *kind,const void *object,const char *name,const void *const *owner,uintptr_t frame) {
|
||||
(void)kind; (void)object; (void)name; (void)owner; (void)frame;
|
||||
}
|
||||
void runtime_wait_end(void) {}
|
||||
uintptr_t runtime_lookup(const RuntimeExport *table,size_t count,const char *name) {
|
||||
(void)table; (void)count; (void)name; return 0;
|
||||
}
|
||||
static void make_file(const char *path) {
|
||||
FILE *f=fopen(path,"wb"); assert(f);
|
||||
assert(fputs("unchanged archive",f)>=0 && !fclose(f));
|
||||
}
|
||||
static void readable(const char *guest) {
|
||||
int fd=(int)do_open(guest,0,0); assert(fd>=3);
|
||||
char data[32]={0}; assert(do_read(fd,data,17)==17);
|
||||
assert(!strcmp(data,"unchanged archive") && !do_close(fd));
|
||||
}
|
||||
static int listed(const char *guest,const char *name) {
|
||||
int fd=(int)do_open(guest,0x20000,0); assert(fd>=3);
|
||||
char entries[4096]; int64_t count=do_getdents(fd,entries,sizeof(entries),NULL); assert(count>0);
|
||||
int found=0;
|
||||
for (int64_t at=0;at<count;) {
|
||||
uint16_t length; memcpy(&length,entries+at+4,2); assert(length);
|
||||
if (!strcmp(entries+at+8,name)) found=1;
|
||||
at+=length;
|
||||
}
|
||||
assert(!do_close(fd)); return found;
|
||||
}
|
||||
int main(void) {
|
||||
char root[512];
|
||||
snprintf(root,sizeof(root),"%s/tlg-jp-files-XXXXXX",getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp");
|
||||
assert(mkdtemp(root));
|
||||
char game[600],user[600],path[800],nested[800];
|
||||
snprintf(game,sizeof(game),"%s/game",root);
|
||||
snprintf(user,sizeof(user),"%s/user",root);
|
||||
assert(!mkdir(game,0755));
|
||||
const char *packs[]={"trico_frf.psarc","trico_frfp.psarc","trico_boot.psarc","trico_frc.psarc"};
|
||||
for (size_t i=0;i<4;++i) { snprintf(path,sizeof(path),"%s/%s",game,packs[i]); make_file(path); }
|
||||
snprintf(nested,sizeof(nested),"%s/nested",game); assert(!mkdir(nested,0755));
|
||||
snprintf(path,sizeof(path),"%s/nested/trico_frf.psarc",game); make_file(path);
|
||||
unsetenv("TLG_JAPANESE_FALLBACK");
|
||||
runtime_file_configure(game,user);
|
||||
snprintf(path,sizeof(path),"%s/data/trico_frf.psarc",user); make_file(path);
|
||||
readable("/app0/trico_frf.psarc"); readable("/app0/trico_frfp.psarc");
|
||||
assert(listed("/app0","trico_frf.psarc") && listed("/hostapp","trico_frfp.psarc"));
|
||||
|
||||
setenv("TLG_JAPANESE_FALLBACK","1",1);
|
||||
const char *hidden[]={"trico_frf.psarc","/app0/trico_frf.psarc","/hostapp/trico_frfp.psarc",
|
||||
"./trico_frfp.psarc","/app0/./trico_frf.psarc","/app0//TRICO_FRF.PSARC"};
|
||||
for (size_t i=0;i<sizeof(hidden)/sizeof(*hidden);++i) {
|
||||
GuestStat info;
|
||||
assert(do_open(hidden[i],0,0)==-ENOENT);
|
||||
assert(do_stat(hidden[i],&info)==-ENOENT);
|
||||
}
|
||||
assert(!listed("/app0","trico_frf.psarc") && !listed("/hostapp","trico_frfp.psarc"));
|
||||
assert(!listed(".","trico_frf.psarc") && !listed("/app0/.","trico_frfp.psarc"));
|
||||
assert(listed("/app0","trico_boot.psarc") && listed("/app0","trico_frc.psarc"));
|
||||
readable("/app0/trico_boot.psarc"); readable("/app0/trico_frc.psarc");
|
||||
readable("/app0/nested/trico_frf.psarc"); readable("/data/trico_frf.psarc");
|
||||
assert(listed("/app0/nested","trico_frf.psarc") && listed("/data","trico_frf.psarc"));
|
||||
assert(do_open("/app0/nested/../trico_frf.psarc",0,0)==-EACCES);
|
||||
|
||||
for (int i=0;i<2;++i) {
|
||||
setenv("TLG_JAPANESE_FALLBACK",i ? "invalid" : "0",1);
|
||||
readable("/app0/trico_frf.psarc"); readable("/app0/trico_frfp.psarc");
|
||||
assert(listed("/app0","trico_frf.psarc"));
|
||||
}
|
||||
unsetenv("TLG_JAPANESE_FALLBACK");
|
||||
readable("/hostapp/trico_frf.psarc");
|
||||
for (size_t i=0;i<4;++i) { snprintf(path,sizeof(path),"%s/%s",game,packs[i]); assert(!unlink(path)); }
|
||||
snprintf(path,sizeof(path),"%s/nested/trico_frf.psarc",game); assert(!unlink(path));
|
||||
assert(!rmdir(nested) && !rmdir(game));
|
||||
snprintf(path,sizeof(path),"%s/data/trico_frf.psarc",user); assert(!unlink(path));
|
||||
const char *dirs[]={"temp0","download0","data"};
|
||||
for (size_t i=0;i<3;++i) { snprintf(path,sizeof(path),"%s/%s",user,dirs[i]); assert(!rmdir(path)); }
|
||||
assert(!rmdir(user) && !rmdir(root));
|
||||
puts("Japanese text view: open/stat/list masking, isolation and reversible host-file preservation PASS");
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
from paths import ROOT
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(ROOT / 'launcher'))
|
||||
import tlg_run
|
||||
|
||||
|
||||
def param_sfo(title='CUSA03745', version='01.03'):
|
||||
keys, values, entries = bytearray(), bytearray(), []
|
||||
for key, value in [('TITLE_ID', title), ('APP_VER', version)]:
|
||||
encoded = value.encode() + b'\0'
|
||||
size = (len(encoded) + 3) & ~3
|
||||
entries.append(struct.pack('<HHIII', len(keys), 0x204, len(encoded), size, len(values)))
|
||||
keys += key.encode() + b'\0'
|
||||
values += encoded + b'\0' * (size - len(encoded))
|
||||
key_offset = 20 + 16 * len(entries)
|
||||
value_offset = (key_offset + len(keys) + 3) & ~3
|
||||
return (struct.pack('<4sIIII', b'\0PSF', 0x101, key_offset, value_offset, len(entries)) +
|
||||
b''.join(entries) + keys + b'\0' * (value_offset - key_offset - len(keys)) + values)
|
||||
|
||||
|
||||
class JapaneseLanguageTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.game = Path(self.tmp.name)
|
||||
(self.game / 'sce_sys').mkdir()
|
||||
for name in ('trico_boot.psarc', 'trico_bootp.psarc', 'trico_frf.psarc', 'trico_frfp.psarc'):
|
||||
(self.game / name).write_bytes(b'untouched archive fixture')
|
||||
self.metadata()
|
||||
self.settings = {'game_dir': str(self.game), 'game_language': 'ja'}
|
||||
|
||||
def metadata(self, title='CUSA03745', version='01.03'):
|
||||
(self.game / 'sce_sys/param.sfo').write_bytes(param_sfo(title, version))
|
||||
|
||||
def test_verified_layout_offers_explicit_japanese_choice(self):
|
||||
self.assertTrue(tlg_run.japanese_fallback_available(self.game))
|
||||
self.assertIn('ja', tlg_run.game_language_choices(dict(self.settings, game_language='auto')))
|
||||
self.assertEqual(tlg_run.resolve_game_language(self.settings, {}), 'ja')
|
||||
self.assertNotIn('ja', tlg_run.game_languages(self.game)) # native engine table unchanged
|
||||
|
||||
def test_japanese_emits_internal_french_id_and_guest_mask(self):
|
||||
self.assertEqual(tlg_run.game_language_environment(self.settings, {}),
|
||||
{'TLG_LANGUAGE': '2', 'TLG_JAPANESE_FALLBACK': '1'})
|
||||
|
||||
def test_ordinary_french_keeps_both_french_archives_visible(self):
|
||||
s = dict(self.settings, game_language='fr')
|
||||
self.assertEqual(tlg_run.game_language_environment(s, {'TLG_JAPANESE_FALLBACK': '1'}),
|
||||
{'TLG_LANGUAGE': '2', 'TLG_JAPANESE_FALLBACK': '0'})
|
||||
|
||||
def test_other_languages_do_not_inherit_a_stale_mask(self):
|
||||
for choice, code in [('en', '1'), ('de', '4')]:
|
||||
self.assertEqual(tlg_run.game_language_environment(dict(self.settings, game_language=choice),
|
||||
{'TLG_JAPANESE_FALLBACK': '1'}),
|
||||
{'TLG_LANGUAGE': code, 'TLG_JAPANESE_FALLBACK': '0'})
|
||||
|
||||
def test_automatic_does_not_silently_opt_into_experimental_fallback(self):
|
||||
self.assertEqual(tlg_run.game_language_environment(dict(self.settings, game_language='auto'),
|
||||
{'LANG': 'ja_JP.UTF-8'}),
|
||||
{'TLG_LANGUAGE': '1', 'TLG_JAPANESE_FALLBACK': '0'})
|
||||
|
||||
def test_other_regions_and_versions_are_not_advertised(self):
|
||||
for title, version in [('CUSA03627', '01.03'), ('CUSA04936', '01.03'),
|
||||
('CUSA04943', '01.03'), ('CUSA99999', '01.03'),
|
||||
('CUSA03745', '01.00'), ('CUSA03745', '01.04')]:
|
||||
self.metadata(title, version)
|
||||
self.assertFalse(tlg_run.japanese_fallback_available(self.game))
|
||||
self.assertNotIn('ja', tlg_run.game_language_choices(dict(self.settings, game_language='auto')))
|
||||
self.assertEqual(tlg_run.game_language_environment(self.settings, {}),
|
||||
{'TLG_LANGUAGE': '1', 'TLG_JAPANESE_FALLBACK': '0'})
|
||||
|
||||
def test_missing_boot_archive_or_bad_metadata_fails_closed(self):
|
||||
(self.game / 'trico_bootp.psarc').unlink()
|
||||
self.assertFalse(tlg_run.japanese_fallback_available(self.game))
|
||||
(self.game / 'sce_sys/param.sfo').write_bytes(b'broken')
|
||||
self.assertFalse(tlg_run.japanese_fallback_available(self.game))
|
||||
with patch.object(tlg_run.shader_cache, 'title_id', return_value='CUSA03745'), \
|
||||
patch.object(tlg_run.mod_patches, 'game_app_version', side_effect=struct.error('broken')):
|
||||
self.assertFalse(tlg_run.japanese_fallback_available(self.game))
|
||||
|
||||
def test_explicit_engine_override_is_not_combined_with_wrong_mask(self):
|
||||
self.assertEqual(tlg_run.game_language_environment(self.settings, {'TLG_LANGUAGE': '1'}),
|
||||
{'TLG_LANGUAGE': '1', 'TLG_JAPANESE_FALLBACK': '0'})
|
||||
|
||||
def test_preexisting_manual_workaround_does_not_need_french_archives_restored(self):
|
||||
for name in ('trico_frf.psarc', 'trico_frfp.psarc'):
|
||||
(self.game / name).unlink()
|
||||
self.assertTrue(tlg_run.japanese_fallback_available(self.game))
|
||||
self.assertEqual(tlg_run.game_language_environment(self.settings, {})['TLG_JAPANESE_FALLBACK'], '1')
|
||||
|
||||
def test_build_env_uses_the_same_language_policy(self):
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(tlg_run, 'window_icon', return_value=None), \
|
||||
patch.object(tlg_run, 'ngs2_module', return_value=None), \
|
||||
patch.object(tlg_run, 'user_dir', return_value=self.game / 'unused-user'), \
|
||||
patch.object(tlg_run, 'probe_env', return_value={}):
|
||||
settings = dict(tlg_run.DEFAULT_SETTINGS, **self.settings)
|
||||
env = tlg_run.build_env(settings)
|
||||
self.assertEqual(env['TLG_LANGUAGE'], '2')
|
||||
self.assertEqual(env['TLG_JAPANESE_FALLBACK'], '1')
|
||||
|
||||
def test_environment_planning_does_not_modify_game_or_saves(self):
|
||||
before = {str(p.relative_to(self.game)): p.read_bytes() for p in self.game.rglob('*') if p.is_file()}
|
||||
tlg_run.game_language_environment(self.settings, {})
|
||||
after = {str(p.relative_to(self.game)): p.read_bytes() for p in self.game.rglob('*') if p.is_file()}
|
||||
self.assertEqual(before, after)
|
||||
@@ -318,9 +318,10 @@ class OptionTextTests(unittest.TestCase):
|
||||
|
||||
|
||||
class TkPathTests(unittest.TestCase):
|
||||
"""The classic Tk launcher and `--play` still read the game's output through a pipe."""
|
||||
"""The classic Tk launcher and `--play` stay attached, but the game writes its own log file:
|
||||
a pipe stalls the game once nothing reads it (a closed or crashed launcher)."""
|
||||
|
||||
def test_default_launch_keeps_the_pipe(self):
|
||||
def test_default_launch_writes_the_log_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
settings = dict(tlg_run.DEFAULT_SETTINGS, game_dir=str(root / 'game'))
|
||||
@@ -330,9 +331,10 @@ class TkPathTests(unittest.TestCase):
|
||||
mock.patch.object(tlg_run, 'probe_env', return_value={}), \
|
||||
mock.patch.object(userdata, 'read_pointer', return_value={}), \
|
||||
mock.patch.object(tlg_run.subprocess, 'Popen') as popen:
|
||||
tlg_run.launch(settings)
|
||||
self.assertEqual(tlg_run.subprocess.PIPE, popen.call_args.kwargs['stdout'])
|
||||
_, log = tlg_run.launch(settings, header=['Frame rate: test'])
|
||||
self.assertEqual(str(log), popen.call_args.kwargs['stdout'].name)
|
||||
self.assertNotIn('start_new_session', popen.call_args.kwargs)
|
||||
self.assertEqual('Frame rate: test\n', log.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
class ReopenTests(unittest.TestCase):
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Launcher presentation regression checks; no game process or real settings are used."""
|
||||
import copy
|
||||
from collections import Counter
|
||||
import itertools
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'launcher'))
|
||||
import tlg_launcher as ui
|
||||
|
||||
|
||||
def widgets(parent):
|
||||
for child in parent.winfo_children():
|
||||
yield child
|
||||
yield from widgets(child)
|
||||
|
||||
|
||||
def contrast(root, foreground, background):
|
||||
def luminance(color):
|
||||
channels = [value / 65535 for value in root.winfo_rgb(color)]
|
||||
linear = [value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4
|
||||
for value in channels]
|
||||
return sum(value * weight for value, weight in zip(linear, (0.2126, 0.7152, 0.0722)))
|
||||
low, high = sorted((luminance(foreground), luminance(background)))
|
||||
return (high + 0.05) / (low + 0.05)
|
||||
|
||||
|
||||
class LauncherUITest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
try:
|
||||
self.root = ui.tk.Tk()
|
||||
except ui.tk.TclError as error:
|
||||
self.skipTest(f'Tk display unavailable: {error}')
|
||||
self.root.withdraw()
|
||||
output = Path(__file__).resolve().parents[1] / 'out'
|
||||
output.mkdir(exist_ok=True)
|
||||
self.folder = tempfile.TemporaryDirectory(dir=output)
|
||||
self.addCleanup(self.folder.cleanup)
|
||||
self.user = Path(self.folder.name)
|
||||
self.settings = copy.deepcopy(ui.tlg_run.DEFAULT_SETTINGS)
|
||||
self.settings['ui_language'] = 'pt_BR'
|
||||
self.errors = []
|
||||
self.root.report_callback_exception = lambda *error: self.errors.append(error)
|
||||
for patch in (
|
||||
mock.patch.object(ui.tlg_run, 'load_settings', return_value=self.settings),
|
||||
mock.patch.object(ui.tlg_run, 'config_path', return_value=self.user / 'settings.json'),
|
||||
mock.patch.object(ui.tlg_run, 'USER_DIR', self.user),
|
||||
mock.patch.object(ui.tlg_run, 'check_game', return_value=[]),
|
||||
):
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
self.app = ui.Launcher(self.root)
|
||||
self.addCleanup(self.close_root)
|
||||
self.root.deiconify()
|
||||
self.root.update()
|
||||
|
||||
def close_root(self):
|
||||
owners = [self.root, *widgets(self.root)]
|
||||
for timer in self.root.tk.call('after', 'info'):
|
||||
command = str(self.root.tk.call('after', 'info', timer)[0])
|
||||
owner = next((widget for widget in owners if command in (widget._tclCommands or [])), self.root)
|
||||
owner.after_cancel(timer)
|
||||
self.root.destroy()
|
||||
|
||||
def test_navigation_language_rebuild_and_session_log(self):
|
||||
self.app.append('Session preserved — Trico')
|
||||
for key, button in self.app.nav_buttons.items():
|
||||
button.invoke()
|
||||
self.assertEqual(self.app.current_page, key)
|
||||
self.assertEqual(button.cget('style'), 'Selected.Nav.TButton')
|
||||
self.app.settings['ui_language'] = 'en_US'
|
||||
self.app.build()
|
||||
self.root.update()
|
||||
self.assertEqual(self.app.current_page, 'journal')
|
||||
self.assertIn('Session preserved — Trico', self.app.log.get('1.0', 'end'))
|
||||
self.assertEqual(self.app.nav_buttons['setup'].cget('text'), 'Game & language')
|
||||
self.app.lines.put('New session output')
|
||||
self.app.pump()
|
||||
self.assertIn('New session output', self.app.log.get('1.0', 'end'))
|
||||
self.assertFalse(self.errors)
|
||||
|
||||
def test_all_settings_remain_available_and_existing_callbacks_save(self):
|
||||
expected = {'game_dir', 'system_lib_dir', 'native_audio', 'vsync', 'show_fps',
|
||||
'vram_budget_mb', 'mouse_enabled', 'mouse_invert_y', 'mouse_sensitivity',
|
||||
'level_select', 'bridge_fix'}
|
||||
self.assertEqual(set(self.app.vars), expected)
|
||||
self.assertEqual(set(self.app.ini_vars), set(ui.INI_DEFAULTS) - {'rcas', 'cas'})
|
||||
self.app.vars['vram_budget_mb'].set(7000)
|
||||
self.app.vars['mouse_sensitivity'].set(1.5)
|
||||
self.app.changed()
|
||||
saved_file = json.loads((self.user / 'settings.json').read_text(encoding='utf-8'))
|
||||
self.assertEqual(saved_file['vram_budget_mb'], 7000)
|
||||
self.assertEqual(saved_file['mouse_sensitivity'], 1.5)
|
||||
self.app.ini_vars['smaa'].set(True)
|
||||
self.app.filters_changed()
|
||||
self.assertEqual(ui.tlg_run.read_ini(self.user / 'tlg.ini')['smaa'], '1')
|
||||
self.assertFalse(self.errors)
|
||||
|
||||
def test_historical_control_inventory_has_no_missing_controls(self):
|
||||
# This fixture was collected from the original UI at source_commit, not the new layout.
|
||||
baseline = json.loads((Path(__file__).parent / 'data' / 'launcher_ui_legacy.json').read_text(
|
||||
encoding='utf-8'))['inventory']
|
||||
self.app.settings['ui_language'] = 'en_US'
|
||||
self.app.build()
|
||||
actual = {'vars': sorted(self.app.vars), 'filters': sorted(self.app.ini_vars),
|
||||
'buttons': [], 'checks': [], 'combos': [], 'spins': [], 'links': []}
|
||||
for widget in widgets(self.root):
|
||||
if isinstance(widget, ui.ttk.Button):
|
||||
actual['buttons'].append(str(widget.cget('text')).removeprefix('▷ '))
|
||||
if isinstance(widget, ui.ttk.Checkbutton):
|
||||
actual['checks'].append(str(widget.cget('text')))
|
||||
if isinstance(widget, ui.ttk.Combobox):
|
||||
actual['combos'].append(list(widget.cget('values')))
|
||||
if isinstance(widget, ui.ttk.Spinbox):
|
||||
actual['spins'].append([str(widget.cget(key)) for key in ('from', 'to', 'increment')])
|
||||
if isinstance(widget, ui.ttk.Label) and widget.bind('<Button-1>'):
|
||||
actual['links'].append(str(widget.cget('text')))
|
||||
self.app.remap_dialog()
|
||||
actual['remap_entries'] = sum(type(widget) == ui.ttk.Entry
|
||||
for widget in widgets(self.app.remap_window))
|
||||
actual['remap_buttons'] = [str(widget.cget('text')) for widget in widgets(self.app.remap_window)
|
||||
if isinstance(widget, ui.ttk.Button)]
|
||||
self.app.mods_dialog()
|
||||
actual['mods_buttons'] = [str(widget.cget('text')) for widget in widgets(self.app.mods_window)
|
||||
if isinstance(widget, ui.ttk.Button)]
|
||||
for key, expected in baseline.items():
|
||||
with self.subTest(inventory=key):
|
||||
if isinstance(expected, int):
|
||||
self.assertEqual(actual[key], expected)
|
||||
else:
|
||||
encode = lambda values: Counter(json.dumps(value, sort_keys=True) for value in values)
|
||||
self.assertFalse(encode(expected) - encode(actual[key]))
|
||||
self.assertEqual(len(actual['buttons']) - len(baseline['buttons']), 6) # New navigation and the graphics button.
|
||||
|
||||
def test_text_contrast_in_normal_focus_selection_and_disabled_states(self):
|
||||
style = ui.ttk.Style(self.root)
|
||||
states = [tuple(state for state, enabled in zip(('focus', 'readonly', 'disabled'), flags) if enabled)
|
||||
for flags in itertools.product((False, True), repeat=3)]
|
||||
for name in ('TEntry', 'TSpinbox', 'TCombobox'):
|
||||
for state in states:
|
||||
for foreground, background in (('foreground', 'fieldbackground'),
|
||||
('selectforeground', 'selectbackground')):
|
||||
with self.subTest(style=name, state=state, pair=foreground):
|
||||
self.assertGreaterEqual(contrast(self.root, style.lookup(name, foreground, state),
|
||||
style.lookup(name, background, state)), 4.5)
|
||||
for name in ('TLabel', 'Muted.TLabel', 'Eyebrow.TLabel', 'Card.TLabel',
|
||||
'TButton', 'Nav.TButton', 'Selected.Nav.TButton', 'Play.TButton', 'TCheckbutton'):
|
||||
for state in ((), ('focus',), ('active',), ('pressed',), ('disabled',)):
|
||||
with self.subTest(style=name, state=state):
|
||||
self.assertGreaterEqual(contrast(self.root, style.lookup(name, 'foreground', state),
|
||||
style.lookup(name, 'background', state)), 4.5)
|
||||
for state in ((), ('selected',)):
|
||||
self.assertGreaterEqual(contrast(self.root, style.lookup('TCheckbutton', 'indicatorforeground', state),
|
||||
style.lookup('TCheckbutton', 'indicatorbackground', state)), 4.5)
|
||||
for name in ('TEntry', 'TSpinbox', 'TCombobox'):
|
||||
for state in ((), ('focus',), ('readonly', 'focus')):
|
||||
self.assertGreaterEqual(contrast(self.root, style.lookup(name, 'bordercolor', state),
|
||||
style.lookup(name, 'fieldbackground', state)), 3)
|
||||
self.assertGreaterEqual(contrast(self.root, self.app.log.cget('foreground'),
|
||||
self.app.log.cget('background')), 4.5)
|
||||
self.assertGreaterEqual(contrast(self.root, self.app.log.cget('selectforeground'),
|
||||
self.app.log.cget('selectbackground')), 4.5)
|
||||
# Check the actual Tcl popdown listbox, which has a separate palette from ttk.
|
||||
popdown = self.root.tk.call('ttk::combobox::PopdownWindow', str(self.app.game_lang_box))
|
||||
listbox = str(popdown) + '.f.l'
|
||||
for foreground, background in (('-foreground', '-background'),
|
||||
('-selectforeground', '-selectbackground')):
|
||||
self.assertGreaterEqual(contrast(self.root, self.root.tk.call(listbox, 'cget', foreground),
|
||||
self.root.tk.call(listbox, 'cget', background)), 4.5)
|
||||
|
||||
def test_every_choice_and_checkbox_uses_the_existing_persistence_callbacks(self):
|
||||
for key, choices in (('display_mode', [self.app.t(value) for value in ui.DISPLAY_MODES]),
|
||||
('frame_rate', [self.app.t('fps_' + value) for value in ui.tlg_run.FRAME_RATES])):
|
||||
box = next(widget for widget in widgets(self.root) if isinstance(widget, ui.ttk.Combobox)
|
||||
and list(widget.cget('values')) == choices)
|
||||
values = ui.DISPLAY_MODES if key == 'display_mode' else ui.tlg_run.FRAME_RATES
|
||||
for index, expected in enumerate(values):
|
||||
box.current(index)
|
||||
box.event_generate('<<ComboboxSelected>>')
|
||||
self.assertEqual(json.loads((self.user / 'settings.json').read_text(encoding='utf-8'))[key], expected)
|
||||
for widget in widgets(self.root):
|
||||
if isinstance(widget, ui.ttk.Checkbutton):
|
||||
variable = str(widget.cget('variable'))
|
||||
before = self.root.getvar(variable)
|
||||
widget.invoke()
|
||||
self.assertNotEqual(self.root.getvar(variable), before)
|
||||
key = next((key for key, var in self.app.vars.items() if str(var) == variable), None)
|
||||
if key:
|
||||
saved = json.loads((self.user / 'settings.json').read_text(encoding='utf-8'))
|
||||
self.assertEqual(saved[key], self.root.tk.getboolean(self.root.getvar(variable)))
|
||||
else:
|
||||
key = next(key for key, var in self.app.ini_vars.items() if str(var) == variable)
|
||||
self.assertEqual(ui.tlg_run.read_ini(self.user / 'tlg.ini')[key],
|
||||
str(int(self.root.getvar(variable))))
|
||||
for index in range(3):
|
||||
self.app.sharpener.current(index)
|
||||
self.app.sharpener.event_generate('<<ComboboxSelected>>')
|
||||
ini = ui.tlg_run.read_ini(self.user / 'tlg.ini')
|
||||
self.assertEqual((ini['rcas'], ini['cas']), (str(int(index == 1)), str(int(index == 2))))
|
||||
self.app.game_lang_box.current(0)
|
||||
self.app.game_lang_box.event_generate('<<ComboboxSelected>>')
|
||||
self.assertEqual(self.app.settings['game_language'], self.app.game_lang_values[0])
|
||||
for key, (low, high) in ui.COLOR_RANGES.items():
|
||||
self.app.ini_vars[key].set(str(high + 1))
|
||||
self.app.filters_changed()
|
||||
self.assertEqual(float(ui.tlg_run.read_ini(self.user / 'tlg.ini')[key]), high)
|
||||
self.app.ini_vars[key].set(str(low - 1))
|
||||
self.app.filters_changed()
|
||||
self.assertEqual(float(ui.tlg_run.read_ini(self.user / 'tlg.ini')[key]), low)
|
||||
self.assertFalse(self.errors)
|
||||
|
||||
def test_folder_buttons_shortcuts_links_and_dialog_actions(self):
|
||||
buttons = [widget for widget in widgets(self.root) if isinstance(widget, ui.ttk.Button)]
|
||||
browse_buttons = [widget for widget in buttons if widget.cget('text') == self.app.t('browse')]
|
||||
for key, button in zip(('game_dir', 'system_lib_dir'), browse_buttons):
|
||||
with mock.patch.object(ui.filedialog, 'askdirectory', return_value=str(self.user / key)):
|
||||
button.invoke()
|
||||
self.assertEqual(self.app.settings[key], str(self.user / key))
|
||||
with mock.patch.object(self.app, 'open') as open_folder:
|
||||
for key, path in (('saves', self.user / 'savedata'), ('logs', ui.tlg_run.LOG_DIR)):
|
||||
next(widget for widget in buttons if widget.cget('text') == self.app.t(key)).invoke()
|
||||
open_folder.assert_called_with(path)
|
||||
with mock.patch.object(ui.webbrowser, 'open') as open_link:
|
||||
for label, url in zip((widget for widget in widgets(self.root)
|
||||
if isinstance(widget, ui.ttk.Label) and widget.bind('<Button-1>')),
|
||||
(ui.CHANNEL_URL, ui.DISCORD_URL, ui.SPONSOR_URL)):
|
||||
label.event_generate('<Button-1>')
|
||||
open_link.assert_called_with(url)
|
||||
next(widget for widget in buttons if widget.cget('text') == self.app.t('remap')).invoke()
|
||||
entries = [widget for widget in widgets(self.app.remap_window) if type(widget) == ui.ttk.Entry]
|
||||
entries[0].insert(0, 'Mouse X1')
|
||||
entries[0].event_generate('<FocusOut>')
|
||||
self.assertEqual(self.app.settings['key_map']['cross'], 'Mouse X1')
|
||||
remap_buttons = [widget for widget in widgets(self.app.remap_window) if isinstance(widget, ui.ttk.Button)]
|
||||
next(widget for widget in remap_buttons if widget.cget('text') == self.app.t('reset')).invoke()
|
||||
self.assertEqual(self.app.settings['key_map'], {})
|
||||
next(widget for widget in remap_buttons if widget.cget('text') == self.app.t('close')).invoke()
|
||||
mods = [('fixture.xml:test', 'Audit fixture', 'Temporary test mod', False)]
|
||||
with mock.patch.object(ui.tlg_run, 'list_mods', return_value=mods) as list_mods, \
|
||||
mock.patch.object(ui.tlg_run, 'set_mod_enabled') as toggle:
|
||||
next(widget for widget in buttons if widget.cget('text') == self.app.t('mods')).invoke()
|
||||
next(widget for widget in widgets(self.app.mods_window) if isinstance(widget, ui.ttk.Checkbutton)).invoke()
|
||||
toggle.assert_called_once_with('fixture.xml:test', True)
|
||||
next(widget for widget in widgets(self.app.mods_window) if isinstance(widget, ui.ttk.Button)
|
||||
and widget.cget('text') == self.app.t('mods_refresh')).invoke()
|
||||
self.assertEqual(list_mods.call_count, 2)
|
||||
self.assertFalse(self.errors)
|
||||
|
||||
def test_play_button_preparation_launch_log_completion_and_error(self):
|
||||
process = mock.Mock()
|
||||
process.poll.return_value = 0 # the game writes its own log file; the launcher follows it
|
||||
process.wait.return_value = 0
|
||||
logfile = self.user / 'session.log'
|
||||
logfile.write_text('Trico session output\n', encoding='utf-8')
|
||||
with mock.patch.object(ui.threading, 'Thread') as thread, \
|
||||
mock.patch.object(ui.tlg_run, 'prepare') as prepare, \
|
||||
mock.patch.object(ui.tlg_run, 'launch', return_value=(process, logfile)) as launch:
|
||||
self.app.play_button.invoke()
|
||||
self.assertTrue(self.app.play_button.instate(['disabled']))
|
||||
self.assertTrue(self.app.cache_button.instate(['disabled']))
|
||||
thread.return_value.start.assert_called_once()
|
||||
thread.call_args.kwargs['target']() # Execute only the mocked game services.
|
||||
self.app.pump()
|
||||
prepare.assert_called_once()
|
||||
launch.assert_called_once_with(self.app.settings, header=mock.ANY)
|
||||
self.assertIn('Trico session output', logfile.read_text(encoding='utf-8'))
|
||||
self.assertIn(self.app.t('ended', code=0), self.app.log.get('1.0', 'end'))
|
||||
self.assertFalse(self.app.play_button.instate(['disabled']))
|
||||
with mock.patch.object(ui.tlg_run, 'prepare', side_effect=RuntimeError('Audit error')), \
|
||||
mock.patch.object(ui.messagebox, 'showerror') as error:
|
||||
self.app.run_game()
|
||||
self.app.pump()
|
||||
error.assert_called_once_with(self.app.t('error'), 'Audit error')
|
||||
self.app.lines.put(('ended', 2))
|
||||
with mock.patch.object(ui.messagebox, 'showwarning') as warning:
|
||||
self.app.pump()
|
||||
warning.assert_called_once()
|
||||
self.assertFalse(self.errors)
|
||||
|
||||
def test_cache_confirmation_and_quit_callbacks(self):
|
||||
with mock.patch.object(ui.tlg_run, 'shader_cache_size', return_value=1024), \
|
||||
mock.patch.object(ui.tlg_run, 'clear_shader_cache', return_value=(1024, [])) as clear, \
|
||||
mock.patch.object(ui.messagebox, 'askyesno', return_value=False) as confirm, \
|
||||
mock.patch.object(ui.messagebox, 'showinfo') as information:
|
||||
self.app.cache_button.invoke()
|
||||
clear.assert_not_called()
|
||||
confirm.return_value = True
|
||||
self.app.cache_button.invoke()
|
||||
clear.assert_called_once_with(self.app.settings)
|
||||
information.assert_called_once()
|
||||
self.app.process = mock.Mock()
|
||||
with mock.patch.object(self.root, 'destroy') as close:
|
||||
next(widget for widget in widgets(self.root) if isinstance(widget, ui.ttk.Button)
|
||||
and widget.cget('text') == self.app.t('quit')).invoke()
|
||||
self.app.process.terminate.assert_called_once()
|
||||
close.assert_called_once()
|
||||
self.app.process = None
|
||||
self.assertFalse(self.errors)
|
||||
|
||||
def test_game_region_language_choices_and_interface_language_event(self):
|
||||
with mock.patch.object(ui.tlg_run, 'game_languages', return_value=ui.tlg_run.LANGUAGES_EU):
|
||||
self.app.refresh_language_choices()
|
||||
self.assertEqual(list(self.app.game_lang_box.cget('values')),
|
||||
[ui.GAME_LANGUAGE_NAMES[key] for key in ui.tlg_run.game_language_choices(self.app.settings)
|
||||
if key != 'auto'])
|
||||
index = list(ui.tlg_run.LANGUAGES_EU).index('pt_PT')
|
||||
self.app.game_lang_box.current(index)
|
||||
self.app.game_lang_box.event_generate('<<ComboboxSelected>>')
|
||||
self.assertEqual(self.app.settings['game_language'], 'pt_PT')
|
||||
self.app.append('Language event audit')
|
||||
self.app.show_page('setup')
|
||||
box = next(widget for widget in widgets(self.root) if isinstance(widget, ui.ttk.Combobox)
|
||||
and list(widget.cget('values')) == [self.app.t('auto'), *ui.UI_LANGUAGE_NAMES.values()])
|
||||
box.current(ui.UI_LANGUAGES.index('en_US'))
|
||||
box.event_generate('<<ComboboxSelected>>')
|
||||
self.root.update()
|
||||
self.assertEqual(self.app.settings['ui_language'], 'en_US')
|
||||
self.assertEqual(self.app.current_page, 'setup')
|
||||
self.assertIn('Language event audit', self.app.log.get('1.0', 'end'))
|
||||
self.assertFalse(self.errors)
|
||||
|
||||
def test_missing_art_falls_back_to_themed_initial_screen(self):
|
||||
self.app.art_loaded = False
|
||||
self.app.banner = None
|
||||
with mock.patch.object(Path, 'is_file', return_value=False), \
|
||||
mock.patch.object(ui, 'art_path', return_value=None):
|
||||
self.app.build()
|
||||
self.root.update()
|
||||
self.assertIsNone(self.app.banner)
|
||||
self.assertEqual(self.app.current_page, 'journey')
|
||||
self.assertFalse(self.errors)
|
||||
|
||||
def test_ready_running_and_missing_game_states(self):
|
||||
self.assertFalse(self.app.play_button.instate(['disabled']))
|
||||
self.app.process = mock.Mock()
|
||||
self.app.refresh_status()
|
||||
self.assertTrue(self.app.play_button.instate(['disabled']))
|
||||
self.assertTrue(self.app.cache_button.instate(['disabled']))
|
||||
self.app.process = None
|
||||
with mock.patch.object(ui.tlg_run, 'check_game', return_value=[('missing', 'eboot.bin')]):
|
||||
self.app.refresh_status()
|
||||
self.assertTrue(self.app.play_button.instate(['disabled']))
|
||||
self.assertIn('eboot.bin', self.app.status.cget('text'))
|
||||
self.assertFalse(self.app.cache_button.instate(['disabled']))
|
||||
|
||||
def test_pages_fit_minimum_size_in_both_languages_and_dialogs_open(self):
|
||||
for language in ('pt_BR', 'en_US'):
|
||||
self.app.settings['ui_language'] = language
|
||||
self.app.build()
|
||||
self.root.geometry('1040x700')
|
||||
self.root.update()
|
||||
for key in self.app.pages:
|
||||
self.app.show_page(key)
|
||||
self.root.update()
|
||||
self.assertTrue(self.app.play_button.winfo_ismapped())
|
||||
self.assertLessEqual(self.app.play_button.winfo_rooty() + self.app.play_button.winfo_height(),
|
||||
self.root.winfo_rooty() + self.root.winfo_height())
|
||||
if key == 'journey':
|
||||
canvas = next(w for w in self.app.pages[key].winfo_children()
|
||||
if isinstance(w, ui.tk.Canvas))
|
||||
for item in canvas.find_all():
|
||||
if canvas.type(item) == 'text':
|
||||
self.assertLessEqual(canvas.bbox(item)[3], canvas.winfo_height())
|
||||
continue
|
||||
page = self.app.pages[key]
|
||||
canvases = [w for holder in page.winfo_children()
|
||||
for w in holder.winfo_children() if isinstance(w, ui.tk.Canvas)]
|
||||
self.assertEqual(len(canvases), 1)
|
||||
canvas = canvases[0]
|
||||
body = canvas.nametowidget(canvas.itemcget(canvas.find_all()[0], 'window'))
|
||||
self.assertLessEqual(body.winfo_reqwidth(), canvas.winfo_width(), (language, key))
|
||||
self.app.remap_dialog()
|
||||
self.app.mods_dialog()
|
||||
self.root.update()
|
||||
self.assertTrue(self.app.remap_window.winfo_exists())
|
||||
self.assertTrue(self.app.mods_window.winfo_exists())
|
||||
self.assertFalse(self.errors)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,102 @@
|
||||
from paths import ROOT
|
||||
import hashlib
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
import tlg_patches as t
|
||||
|
||||
sys.path.insert(0, str(ROOT / 'launcher'))
|
||||
import tlg_run
|
||||
|
||||
|
||||
def fixture_elf(instructions=t.MENU_EXPECTED):
|
||||
data = bytearray(t.TEXT_FILE_OFFSET + t.MENU_OFFSET + len(instructions))
|
||||
data[:4] = b'\x7fELF'
|
||||
data[t.TEXT_FILE_OFFSET + t.MENU_OFFSET:] = instructions
|
||||
return bytes(data)
|
||||
|
||||
|
||||
class MenuConfirmTests(unittest.TestCase):
|
||||
def test_default_is_no_game_patch(self):
|
||||
self.assertEqual(t.menu_writes(None, 'cross'), [])
|
||||
|
||||
def test_wrong_build_and_unknown_preference_fail_closed(self):
|
||||
for elf, preference in ((b'wrong build', 'circle'), (b'', 'circle'), (None, 'invalid')):
|
||||
with self.assertRaises(ValueError):
|
||||
t.menu_writes(elf, preference)
|
||||
|
||||
def test_exact_conversion_and_ordinary_face_bits(self):
|
||||
elf = fixture_elf()
|
||||
with patch.object(t, 'MENU_ELF_SHA256', hashlib.sha256(elf).hexdigest()):
|
||||
writes = t.menu_writes(elf, 'circle')
|
||||
self.assertEqual([(at, data) for at, data, _ in writes],
|
||||
[(t.MENU_OFFSET, bytes.fromhex('41c1e20441c1e504'))])
|
||||
for raw, ordinary, menu in ((0, 0, 0), (0x2000, 2, 0x20000),
|
||||
(0x4000, 1, 0x40000), (0x6000, 3, 0x60000)):
|
||||
# Ordinary Cross/Circle bits are produced by separate, untouched instructions.
|
||||
self.assertEqual(((raw & 0x4000) >> 14) | ((raw & 0x2000) >> 12), ordinary)
|
||||
self.assertEqual(((raw & 0x2000) << 4) | ((raw & 0x4000) << 4), menu)
|
||||
|
||||
def test_instruction_guard_even_with_matching_hash(self):
|
||||
elf = fixture_elf(b'\xcc' * len(t.MENU_EXPECTED))
|
||||
with patch.object(t, 'MENU_ELF_SHA256', hashlib.sha256(elf).hexdigest()):
|
||||
with self.assertRaisesRegex(ValueError, 'instructions'):
|
||||
t.menu_writes(elf, 'circle')
|
||||
|
||||
def test_30fps_can_apply_menu_patch_without_editing_original(self):
|
||||
elf = fixture_elf()
|
||||
with tempfile.TemporaryDirectory() as tmp, \
|
||||
patch.object(t, 'MENU_ELF_SHA256', hashlib.sha256(elf).hexdigest()):
|
||||
source = Path(tmp, 'eboot.bin')
|
||||
source.write_bytes(elf)
|
||||
mode, lines = t.prepare(tmp, tmp, '30', menu_confirm='circle')
|
||||
self.assertEqual(mode, '30')
|
||||
self.assertTrue(any('gameplay unchanged' in line for line in lines))
|
||||
blob = Path(tmp, 'patches.bin').read_bytes()
|
||||
self.assertEqual(struct.unpack_from('<8sQQ', blob), (b'G3PATCH2', 0, 1))
|
||||
self.assertEqual(struct.unpack_from('<QQ', blob, 24), (t.MENU_OFFSET, 8))
|
||||
self.assertEqual(blob[40:], bytes.fromhex('41c1e20441c1e504'))
|
||||
self.assertEqual(source.read_bytes(), elf)
|
||||
|
||||
def test_unsupported_game_does_not_receive_menu_writes(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
Path(tmp, 'eboot.bin').write_bytes(b'unsupported')
|
||||
mode, lines = t.prepare(tmp, tmp, '30', menu_confirm='circle')
|
||||
self.assertEqual(mode, '30')
|
||||
self.assertTrue(any('game-menu patch not applied' in line for line in lines))
|
||||
self.assertEqual(struct.unpack('<8sQQ', Path(tmp, 'patches.bin').read_bytes()),
|
||||
(b'G3PATCH2', 0, 0))
|
||||
|
||||
def test_conflicting_mod_cannot_overwrite_menu_policy(self):
|
||||
elf = fixture_elf()
|
||||
with tempfile.TemporaryDirectory() as tmp, \
|
||||
patch.object(t, 'MENU_ELF_SHA256', hashlib.sha256(elf).hexdigest()), \
|
||||
patch.object(t, 'compile_mods', return_value=[(t.MENU_OFFSET + 3, b'\x05', 'conflict')]):
|
||||
Path(tmp, 'eboot.bin').write_bytes(elf)
|
||||
_, lines = t.prepare(tmp, tmp, '30', mods_dir='unused', menu_confirm='circle')
|
||||
self.assertTrue(any('overlap' in line for line in lines))
|
||||
self.assertEqual(struct.unpack_from('<Q', Path(tmp, 'patches.bin').read_bytes(), 16)[0], 1)
|
||||
|
||||
def test_launcher_and_runtime_share_normalized_preference(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(tlg_run.menu_confirm({}), 'cross')
|
||||
self.assertEqual(tlg_run.menu_confirm({'menu_confirm': 'invalid'}), 'cross')
|
||||
with patch.object(tlg_run, 'window_icon', return_value=None), \
|
||||
patch.object(tlg_run, 'ngs2_module', return_value=None), \
|
||||
patch.object(tlg_run, 'user_dir', return_value=ROOT / 'unused-test-user'), \
|
||||
patch.object(tlg_run, 'probe_env', return_value={}):
|
||||
settings = dict(tlg_run.DEFAULT_SETTINGS, menu_confirm='circle')
|
||||
self.assertEqual(tlg_run.build_env(settings)['TLG_MENU_CONFIRM'], 'circle')
|
||||
with patch.dict(os.environ, {'TLG_MENU_CONFIRM': 'cross'}):
|
||||
self.assertEqual(tlg_run.menu_confirm({'menu_confirm': 'circle'}), 'cross')
|
||||
|
||||
def test_ini_preference_is_loaded(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, \
|
||||
patch.object(tlg_run, 'user_dir', return_value=Path(tmp)), \
|
||||
patch.object(tlg_run, 'config_path', return_value=Path(tmp, 'settings.json')):
|
||||
Path(tmp, 'tlg.ini').write_text('menu_confirm=circle\n')
|
||||
self.assertEqual(tlg_run.load_settings()['menu_confirm'], 'circle')
|
||||
@@ -1,118 +0,0 @@
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include "tlg_orbs.h"
|
||||
#include "../src/orb_hook.h"
|
||||
#include <vector>
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include "tlg_settings.h"
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <sys/mman.h>
|
||||
#endif
|
||||
|
||||
extern "C" float __attribute__((sysv_abi)) tlg_orbs_gain(void*, float);
|
||||
extern "C" void tlg_orbs_status(int);
|
||||
extern "C" void tlg_cheat_status(unsigned);
|
||||
static void __attribute__((sysv_abi)) Setter(void* player, float balance) {
|
||||
balance = std::min(balance, 999999.0f);
|
||||
std::memcpy(static_cast<unsigned char*>(player) + 0x1e8, &balance, 4);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
assert(TlgOrbs::Scale(100, 110, 2) == 120);
|
||||
assert(TlgOrbs::Scale(100, 90, 100) == 90);
|
||||
assert(TlgOrbs::Scale(100, 100, 100) == 100);
|
||||
assert(TlgOrbs::Scale(100, 110, 1) == 110);
|
||||
assert(TlgOrbs::Scale(0, 1, 100) == 100);
|
||||
float balance = 0;
|
||||
for (int i = 0; i < 10; ++i) balance = TlgOrbs::Scale(balance, balance + 1, 0.1f);
|
||||
assert(std::abs(balance - 1) < 0.00001f);
|
||||
assert(TlgOrbs::Scale(balance, balance + 2, 2) == balance + 4);
|
||||
assert(TlgOrbs::Scale(100, 110, std::numeric_limits<float>::quiet_NaN()) == 110);
|
||||
assert(TlgOrbs::ClampMultiplier(0) == 0.1f);
|
||||
assert(TlgOrbs::ClampMultiplier(101) == 100);
|
||||
assert(TlgOrbs::ClampMultiplier(std::numeric_limits<float>::infinity()) == 1);
|
||||
std::vector<unsigned char> image(0x6fbdc);
|
||||
assert(!tlg_orb_signatures_match(image.data(), image.size()));
|
||||
assert(!tlg_orb_signatures_match(image.data(), 1));
|
||||
if (argc > 1) {
|
||||
FILE* file = std::fopen(argv[1], "rb");
|
||||
assert(file && !std::fseek(file, 0x4000, SEEK_SET));
|
||||
assert(std::fread(image.data(), 1, image.size(), file) == image.size());
|
||||
std::fclose(file);
|
||||
assert(tlg_orb_signatures_match(image.data(), image.size()));
|
||||
image[tlg_orb_calls[1]] ^= 1;
|
||||
assert(!tlg_orb_signatures_match(image.data(), image.size()));
|
||||
}
|
||||
#ifdef _WIN32
|
||||
auto* stub = static_cast<unsigned char*>(VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
|
||||
#else
|
||||
auto* stub = static_cast<unsigned char*>(mmap(nullptr, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0));
|
||||
assert(stub != MAP_FAILED);
|
||||
#endif
|
||||
assert(stub);
|
||||
tlg_orb_build_stub(stub, reinterpret_cast<uintptr_t>(tlg_orbs_gain), reinterpret_cast<uintptr_t>(Setter));
|
||||
#ifdef _WIN32
|
||||
DWORD previous;
|
||||
assert(VirtualProtect(stub, 4096, PAGE_EXECUTE_READ, &previous));
|
||||
FlushInstructionCache(GetCurrentProcess(), stub, 26);
|
||||
#else
|
||||
assert(!mprotect(stub, 4096, PROT_READ | PROT_EXEC));
|
||||
#endif
|
||||
using Hook = void (__attribute__((sysv_abi)) *)(void*, float);
|
||||
auto hook = reinterpret_cast<Hook>(stub);
|
||||
std::array<unsigned char, 0x200> player{};
|
||||
balance = 100;
|
||||
std::memcpy(player.data() + 0x1e8, &balance, 4);
|
||||
auto& settings = TlgSettings::Get();
|
||||
settings.red_orb_multiplier = 2;
|
||||
hook(player.data(), 110);
|
||||
std::memcpy(&balance, player.data() + 0x1e8, 4);
|
||||
assert(balance == 120);
|
||||
settings.cheats[4] = true;
|
||||
assert(tlg_orbs_gain(player.data(), 130) == 140);
|
||||
tlg_cheat_status(4);
|
||||
assert(tlg_orbs_gain(player.data(), 130) == 130);
|
||||
settings.cheats[4] = false;
|
||||
assert(tlg_orbs_gain(player.data(), 130) == 140);
|
||||
settings.red_orb_multiplier = 100;
|
||||
hook(player.data(), 90);
|
||||
std::memcpy(&balance, player.data() + 0x1e8, 4);
|
||||
assert(balance == 90);
|
||||
hook(player.data(), 100000);
|
||||
std::memcpy(&balance, player.data() + 0x1e8, 4);
|
||||
assert(balance == 999999);
|
||||
tlg_orbs_status(1);
|
||||
assert(settings.red_orbs_supported);
|
||||
tlg_orbs_status(0);
|
||||
assert(!settings.red_orbs_supported);
|
||||
auto config = std::filesystem::temp_directory_path() / "tlg-orb-multiplier-test.ini";
|
||||
assert(!std::filesystem::exists(config));
|
||||
#ifdef _WIN32
|
||||
_putenv_s("TLG_CONFIG", config.string().c_str());
|
||||
#else
|
||||
setenv("TLG_CONFIG", config.string().c_str(), 1);
|
||||
#endif
|
||||
settings.red_orb_multiplier = 0.125f;
|
||||
settings.cheats[0] = true;
|
||||
settings.cheats[2] = true;
|
||||
TlgSettings::Save();
|
||||
settings.red_orb_multiplier = 1;
|
||||
settings.cheats[0] = false;
|
||||
settings.cheats[2] = false;
|
||||
TlgSettings::Load();
|
||||
assert(settings.red_orb_multiplier == 0.125f);
|
||||
assert(settings.cheats[0] && settings.cheats[2] && !settings.cheats[1] && !settings.cheats[4]);
|
||||
assert(std::filesystem::remove(config));
|
||||
#ifdef _WIN32
|
||||
VirtualFree(stub, 0, MEM_RELEASE);
|
||||
#else
|
||||
munmap(stub, 4096);
|
||||
#endif
|
||||
std::puts("orb-multiplier-test: OK (gains, spending, fractions, signatures, executable hook)");
|
||||
}
|
||||
@@ -27,6 +27,20 @@ int main(void) {
|
||||
unsigned char param[128]={0}, result[64]={0}; /* the game zeroes the pointers it does not want */
|
||||
uint32_t value;
|
||||
|
||||
/* A menu preference, not a global gameplay remap. Invalid/unset keeps Cross. */
|
||||
int enter;
|
||||
unsetenv("TLG_MENU_CONFIRM");
|
||||
assert(system_param(1000, &enter)==0 && enter==1);
|
||||
assert(menu_button_result(1,0)==1 && menu_button_result(0,1)==2);
|
||||
assert(menu_button_result(0,0)==0 && menu_button_result(1,1)==1);
|
||||
setenv("TLG_MENU_CONFIRM", "circle", 1);
|
||||
assert(system_param(1000, &enter)==0 && enter==0);
|
||||
assert(menu_button_result(1,0)==2 && menu_button_result(0,1)==1);
|
||||
assert(menu_button_result(0,0)==0 && menu_button_result(1,1)==1);
|
||||
setenv("TLG_MENU_CONFIRM", "invalid", 1);
|
||||
assert(system_param(1000, &enter)==0 && enter==1);
|
||||
unsetenv("TLG_MENU_CONFIRM");
|
||||
|
||||
/* MsgDialog: a user message finishes with OK; a progress bar runs until closed. */
|
||||
assert(msg_init()==0);
|
||||
memset(param+56,0,4); param[56]=1;
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
// Damage and green/blue/gold orb multipliers (src/damage_hook.h, src/orb_hook.h).
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "../src/damage_hook.h"
|
||||
#include "../src/orb_hook.h"
|
||||
#include "tlg_settings.h"
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <sys/mman.h>
|
||||
#endif
|
||||
|
||||
static unsigned char* Executable() {
|
||||
#ifdef _WIN32
|
||||
auto* code = static_cast<unsigned char*>(VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
|
||||
#else
|
||||
auto* code = static_cast<unsigned char*>(mmap(nullptr, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0));
|
||||
assert(code != MAP_FAILED);
|
||||
#endif
|
||||
assert(code);
|
||||
return code;
|
||||
}
|
||||
|
||||
static void Seal(unsigned char* code) {
|
||||
#ifdef _WIN32
|
||||
DWORD previous;
|
||||
assert(VirtualProtect(code, 4096, PAGE_EXECUTE_READ, &previous));
|
||||
FlushInstructionCache(GetCurrentProcess(), code, 4096);
|
||||
#else
|
||||
assert(!mprotect(code, 4096, PROT_READ | PROT_EXEC));
|
||||
#endif
|
||||
}
|
||||
|
||||
static uintptr_t Address(const void* p) {
|
||||
return reinterpret_cast<uintptr_t>(p);
|
||||
}
|
||||
|
||||
// Runs the damage stub as the game would: r13 the actor, rbp the routine's frame. Returns the
|
||||
// health the game would compute (xmm0 at the resume point) and the damage left in the frame.
|
||||
static float Damage(bool player, float health, float damage, float taken, float dealt, float* frame_damage) {
|
||||
unsigned char* code = Executable();
|
||||
const unsigned char entry[] = {0x55, 0x41,0x55, 0x49,0x89,0xfd, 0x48,0x89,0xf5, 0xff,0x25,0,0,0,0};
|
||||
std::memcpy(code, entry, sizeof(entry));
|
||||
const uintptr_t stub = Address(code + 64), exit = Address(code + 512);
|
||||
std::memcpy(code + sizeof(entry), &stub, 8);
|
||||
assert(tlg_damage_build_stub(code + 64, Address(&taken), Address(&dealt), exit) < 448);
|
||||
const unsigned char leave[] = {0xc5,0xfa,0x11,0x45,0x98, 0x41,0x5d, 0x5d, 0xc3};
|
||||
std::memcpy(code + 512, leave, sizeof(leave));
|
||||
Seal(code);
|
||||
std::vector<unsigned char> actor(0x400);
|
||||
actor[0x298] = player ? 0 : 1;
|
||||
float frame[32]{};
|
||||
float* top = frame + 32;
|
||||
std::memcpy(reinterpret_cast<unsigned char*>(top) - 0x5c, &health, 4);
|
||||
std::memcpy(reinterpret_cast<unsigned char*>(top) - 0x60, &damage, 4);
|
||||
reinterpret_cast<void(__attribute__((sysv_abi))*)(void*, void*)>(code)(actor.data(), top);
|
||||
float result;
|
||||
std::memcpy(&result, reinterpret_cast<unsigned char*>(top) - 0x68, 4);
|
||||
std::memcpy(frame_damage, reinterpret_cast<unsigned char*>(top) - 0x60, 4);
|
||||
return result;
|
||||
}
|
||||
|
||||
struct Amounts {
|
||||
float health, magic, rage;
|
||||
};
|
||||
|
||||
// Runs the pickup stub with the routine's arguments; the resume point stores them and undoes
|
||||
// the prologue the stub replayed.
|
||||
static Amounts Pickup(Amounts in, float green, float blue, float gold) {
|
||||
unsigned char* code = Executable();
|
||||
assert(tlg_orb_pickup_build_stub(code, Address(&green), Address(&blue), Address(&gold),
|
||||
Address(code + 512)) < 512);
|
||||
const unsigned char leave[] = {0xc5,0xfa,0x11,0x07, 0xc5,0xfa,0x11,0x4f,0x04, 0xc5,0xfa,0x11,0x57,0x08,
|
||||
0x48,0x83,0xc4,0x18, 0x5b, 0x5d, 0xc3};
|
||||
std::memcpy(code + 512, leave, sizeof(leave));
|
||||
Seal(code);
|
||||
Amounts out{};
|
||||
reinterpret_cast<void(__attribute__((sysv_abi))*)(Amounts*, float, float, float)>(code)(
|
||||
&out, in.health, in.magic, in.rage);
|
||||
return out;
|
||||
}
|
||||
|
||||
static std::string Read(const char* path) {
|
||||
std::ifstream in(path);
|
||||
std::stringstream s;
|
||||
s << in.rdbuf();
|
||||
return s.str();
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
assert(tlg_damage_signature_match(tlg_damage_original, TLG_DAMAGE_HOOK_LENGTH));
|
||||
assert(!tlg_damage_signature_match(tlg_damage_original, TLG_DAMAGE_HOOK_LENGTH - 1));
|
||||
assert(tlg_orb_pickup_signature_match(tlg_orb_pickup_original, TLG_ORB_PICKUP_LENGTH));
|
||||
if (argc > 1) {
|
||||
std::ifstream file(argv[1], std::ios::binary);
|
||||
std::vector<unsigned char> image((std::istreambuf_iterator<char>(file)), {});
|
||||
assert(image.size() > 0x4000 + TLG_ORB_PICKUP_OFFSET + TLG_ORB_PICKUP_LENGTH);
|
||||
assert(tlg_damage_signature_match(image.data() + 0x4000 + TLG_DAMAGE_HOOK_OFFSET, TLG_DAMAGE_HOOK_LENGTH));
|
||||
assert(tlg_orb_pickup_signature_match(image.data() + 0x4000 + TLG_ORB_PICKUP_OFFSET, TLG_ORB_PICKUP_LENGTH));
|
||||
}
|
||||
|
||||
float left;
|
||||
assert(Damage(true, 100, 10, 2, 5, &left) == 80 && left == 20); // player: taken
|
||||
assert(Damage(false, 100, 10, 2, 5, &left) == 50 && left == 50); // enemy: dealt
|
||||
assert(Damage(false, 100, 10, 0.5f, 0.1f, &left) == 99 && left == 1);
|
||||
assert(Damage(true, 100, -10, 2, 5, &left) == 110 && left == -10); // healing stays
|
||||
assert(Damage(true, 100, 0, 2, 5, &left) == 100 && left == 0);
|
||||
const float nan = std::numeric_limits<float>::quiet_NaN();
|
||||
Damage(false, 100, nan, 2, 5, &left);
|
||||
assert(left != left);
|
||||
|
||||
Amounts a = Pickup({10, 20, 30}, 2, 3, 0.5f);
|
||||
assert(a.health == 20 && a.magic == 60 && a.rage == 15);
|
||||
a = Pickup({0, -5, 4}, 2, 3, 100);
|
||||
assert(a.health == 0 && a.magic == -5 && a.rage == 400);
|
||||
|
||||
const char* path = "stat-multipliers-test.ini";
|
||||
{
|
||||
std::ofstream out(path);
|
||||
out << "damage_dealt=2.5\ndamage_taken=0.5\ngreen_orb_multiplier=3\n"
|
||||
"blue_orb_multiplier=1000\ngold_orb_multiplier=0\n";
|
||||
}
|
||||
#ifdef _WIN32
|
||||
_putenv_s("TLG_CONFIG", path);
|
||||
#else
|
||||
setenv("TLG_CONFIG", path, 1);
|
||||
#endif
|
||||
auto& v = TlgSettings::Get();
|
||||
TlgSettings::Load();
|
||||
assert(v.damage_dealt == 2.5f && v.damage_taken == 0.5f && v.green_orb_multiplier == 3.0f);
|
||||
assert(v.blue_orb_multiplier == 100.0f && v.gold_orb_multiplier == 0.1f);
|
||||
v.gold_orb_multiplier = 4.0f;
|
||||
TlgSettings::Save();
|
||||
const std::string saved = Read(path);
|
||||
for (const char* line : {"damage_dealt=2.500\n", "damage_taken=0.500\n", "green_orb_multiplier=3.000\n",
|
||||
"blue_orb_multiplier=100.000\n", "gold_orb_multiplier=4.000\n"}) {
|
||||
assert(saved.find(line) != std::string::npos);
|
||||
}
|
||||
std::remove(path);
|
||||
std::puts("PASS: stat multipliers");
|
||||
}
|
||||
@@ -47,8 +47,8 @@ class FrameRatePatchTests(unittest.TestCase):
|
||||
# #40: a SELF dump of 1.03 has another sha256 than the plain dump, but the same code.
|
||||
applied, lines, blob = self.prepare(self_dump(text_with(t.MODES['60'])), '60')
|
||||
self.assertEqual(applied, '60')
|
||||
self.assertIn('Frame rate: 60 FPS mode, 4 patch writes', lines)
|
||||
self.assertEqual(struct.unpack_from('<8sQQ', blob), (b'G3PATCH2', 0, 4))
|
||||
self.assertIn('Frame rate: 60 FPS mode, 5 patch writes', lines)
|
||||
self.assertEqual(struct.unpack_from('<8sQQ', blob), (b'G3PATCH2', 0, 5))
|
||||
|
||||
def test_self_dump_of_another_build_stays_at_30(self):
|
||||
applied, lines, blob = self.prepare(self_dump(text_with(t.MODES['unlocked'], expected=False)), 'unlocked')
|
||||
@@ -91,3 +91,56 @@ class ModTitleIdTests(unittest.TestCase):
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
def resolution_elf(expected=True):
|
||||
"""An ELF whose one PT_LOAD maps image offset 0 up to the mode table, with the bytes the
|
||||
resolution patches expect (or other bytes) in place."""
|
||||
size = t.RESOLUTION_ENTRY - t.GHIDRA_BASE + 0x100
|
||||
image = bytearray(size)
|
||||
for address, original in ((t.RESOLUTION_ENTRY, t.RESOLUTION_EXPECTED),
|
||||
(t.HOR_PLUS_CALL, t.HOR_PLUS_CALL_EXPECTED),
|
||||
(t.HOR_PLUS_BLOCK, t.HOR_PLUS_BLOCK_EXPECTED),
|
||||
(t.UI_SIZE, t.UI_SIZE_EXPECTED),
|
||||
(t.GUIDE_SCALE, t.GUIDE_SCALE_EXPECTED),
|
||||
(t.SUBTITLE_SCALE, t.SUBTITLE_SCALE_EXPECTED),
|
||||
(t.GARLIC_SIZE, t.GARLIC_SIZE_EXPECTED)):
|
||||
offset = address - t.GHIDRA_BASE
|
||||
image[offset:offset + len(original)] = original if expected else b'\xcc' * len(original)
|
||||
return plain_elf(image)
|
||||
|
||||
|
||||
class ResolutionPatchTests(unittest.TestCase):
|
||||
def test_parse(self):
|
||||
self.assertEqual(t.parse_resolution('3840x1600'), (3840, 1600))
|
||||
self.assertEqual(t.parse_resolution(' 3440 X 1440'), (3440, 1440))
|
||||
for value in (None, '', '1920x1080', 'native', '0x1080', '70000x1080'):
|
||||
self.assertIsNone(t.parse_resolution(value))
|
||||
|
||||
def test_16_9_changes_only_the_mode(self):
|
||||
writes = t.resolution_writes(resolution_elf(), (2560, 1440))
|
||||
self.assertEqual(len(writes), 1)
|
||||
self.assertEqual(writes[0][1][:8], struct.pack('<4H', 2560, 1440, 2560, 1440))
|
||||
|
||||
def test_gpu_heap(self):
|
||||
(offset, data, _), = t.garlic_writes(resolution_elf())
|
||||
self.assertEqual(offset, t.GARLIC_SIZE - t.GHIDRA_BASE)
|
||||
self.assertEqual(data, bytes.fromhex('41bf') + struct.pack('<I', (2816 + t.GARLIC_EXTRA_MB) << 20))
|
||||
with self.assertRaises(ValueError):
|
||||
t.garlic_writes(resolution_elf(expected=False))
|
||||
|
||||
def test_ultrawide_gets_hor_plus_and_a_wider_ui(self):
|
||||
writes = t.resolution_writes(resolution_elf(), (3840, 1600))
|
||||
offsets = {offset: data for offset, data, _ in writes}
|
||||
self.assertEqual(len(writes), 6)
|
||||
self.assertEqual(offsets[t.UI_SIZE - t.GHIDRA_BASE], struct.pack('<4i', 2592, 1080, 2592, 1080))
|
||||
self.assertEqual(offsets[t.GUIDE_SCALE - t.GHIDRA_BASE],
|
||||
struct.pack('<4f', 1 / 2592, 1 / 1080, -32, 16 / 2592))
|
||||
self.assertEqual(offsets[t.SUBTITLE_SCALE - t.GHIDRA_BASE], struct.pack('<2f', 1 / 2592, 1 / 1080))
|
||||
block = offsets[t.HOR_PLUS_BLOCK - t.GHIDRA_BASE]
|
||||
self.assertEqual(len(block), len(t.HOR_PLUS_BLOCK_EXPECTED))
|
||||
self.assertIn(struct.pack('<f', 1600 / 3840), block)
|
||||
|
||||
def test_other_bytes_are_refused(self):
|
||||
with self.assertRaises(ValueError):
|
||||
t.resolution_writes(resolution_elf(expected=False), (3440, 1440))
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Exercise the production Windows exception handler with real protected-memory faults.
|
||||
|
||||
No renderer or game files are needed. The GPU callback fails immediately if a speculative
|
||||
read reaches it, instead of letting the original readback/pipeline-cache deadlock hang CI.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
HARNESS = r'''
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include "compat_win.h"
|
||||
|
||||
typedef tlg_jmp_buf sigjmp_buf;
|
||||
static __thread sigjmp_buf *runtime_fault_recover;
|
||||
typedef struct { int unused; } IsaRegs;
|
||||
static void *isa_table;
|
||||
static unsigned char *image;
|
||||
static int gpu_enabled=1;
|
||||
static volatile int speculative_expected, gpu_calls;
|
||||
static volatile unsigned char *page;
|
||||
static void isa_context_regs(CONTEXT *c, IsaRegs *r, int to) { (void)c; (void)r; (void)to; }
|
||||
static int isa_rewrite_trap(void *table, uintptr_t at, IsaRegs *regs, void *xmm, uintptr_t *rip) {
|
||||
(void)table; (void)at; (void)regs; (void)xmm; (void)rip; return 0;
|
||||
}
|
||||
static LONG fatal_exception(EXCEPTION_POINTERS *info) { (void)info; return EXCEPTION_CONTINUE_SEARCH; }
|
||||
static int tlggpu_handle_fault(void *context, void *address) {
|
||||
(void)context; (void)address;
|
||||
++gpu_calls;
|
||||
if (speculative_expected) {
|
||||
fputs("FAIL: speculative worker entered GPU readback before recovery\n",stderr);
|
||||
ExitProcess(99);
|
||||
}
|
||||
DWORD old;
|
||||
if (!VirtualProtect((void *)page,4096,PAGE_READWRITE,&old)) ExitProcess(98);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* The exact production handler is inserted here by the test. */
|
||||
__HANDLER__
|
||||
|
||||
static void require(int condition, const char *message) {
|
||||
if (!condition) { fprintf(stderr,"FAIL: %s\n",message); ExitProcess(97); }
|
||||
}
|
||||
int main(void) {
|
||||
PVOID handler=AddVectoredExceptionHandler(1,vectored_fault);
|
||||
require(handler!=NULL,"install handler");
|
||||
page=VirtualAlloc(NULL,4096,MEM_RESERVE|MEM_COMMIT,PAGE_NOACCESS);
|
||||
require(page!=NULL,"allocate protected page");
|
||||
|
||||
/* Repeated recoveries exercise the context trampoline and TLS reset too. */
|
||||
for (int iteration=0; iteration<100; ++iteration) {
|
||||
sigjmp_buf recovery;
|
||||
speculative_expected=1;
|
||||
if (!tlg_setjmp(recovery)) {
|
||||
runtime_fault_recover=&recovery;
|
||||
volatile unsigned char value=*page;
|
||||
(void)value;
|
||||
require(0,"speculative read must recover");
|
||||
}
|
||||
require(runtime_fault_recover==NULL,"clear speculative TLS recovery point");
|
||||
require(gpu_calls==0,"speculative read must not enqueue GPU work");
|
||||
}
|
||||
speculative_expected=0;
|
||||
volatile unsigned char value=*page;
|
||||
require(value==0 && gpu_calls==1,"normal read still resolves through GPU tracking");
|
||||
|
||||
/* A write-protection fault must still use the GPU tracking callback. */
|
||||
DWORD old;
|
||||
require(VirtualProtect((void *)page,4096,PAGE_READONLY,&old),"protect write");
|
||||
*page=42;
|
||||
require(*page==42 && gpu_calls==2,"normal write still resolves through GPU tracking");
|
||||
VirtualFree((void *)page,0,MEM_RELEASE);
|
||||
RemoveVectoredExceptionHandler(handler);
|
||||
puts("PASS: speculative recovery precedes readback; normal read/write tracking preserved");
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == 'nt', 'Windows exception handling only')
|
||||
class WindowsFaultRecoveryTest(unittest.TestCase):
|
||||
def test_speculative_fault_recovers_before_gpu_readback(self):
|
||||
compiler = os.environ.get('TLG_TEST_CLANG') or shutil.which('clang')
|
||||
local = ROOT / 'out/native-tools/msys64/clang64/bin/clang.exe'
|
||||
if not compiler and local.is_file():
|
||||
compiler = str(local)
|
||||
if not compiler:
|
||||
self.skipTest('MSYS2 CLANG64 compiler unavailable; set TLG_TEST_CLANG')
|
||||
source = (ROOT / 'src/probe.c').read_text(encoding='utf-8')
|
||||
start = source.index('static LONG CALLBACK vectored_fault(')
|
||||
end = source.index('static LONG WINAPI unhandled_fault(', start)
|
||||
harness = HARNESS.replace('__HANDLER__', source[start:end])
|
||||
output = ROOT / 'out'
|
||||
output.mkdir(exist_ok=True)
|
||||
env = dict(os.environ)
|
||||
env['PATH'] = str(Path(compiler).resolve().parent) + os.pathsep + env.get('PATH', '')
|
||||
with tempfile.TemporaryDirectory(dir=output) as folder:
|
||||
env['TEMP'] = env['TMP'] = folder
|
||||
test_source = Path(folder) / 'test.c'
|
||||
executable = Path(folder) / 'fault-recovery.exe'
|
||||
test_source.write_text(harness, encoding='utf-8')
|
||||
built = subprocess.run([compiler, '-std=c11', '-O2', '-Wall', '-Wextra', '-Werror',
|
||||
'-I' + str(ROOT / 'src'), str(test_source), str(ROOT / 'src/compat_win.c'),
|
||||
'-lbcrypt', '-o', str(executable)], env=env, capture_output=True, text=True)
|
||||
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
|
||||
result = subprocess.run([str(executable)], env=env, capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn('normal read/write tracking preserved', result.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user