diff --git a/Cargo.lock b/Cargo.lock index 0ee449f7..e2a4159e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4374,6 +4374,7 @@ dependencies = [ "tokio", "tracing", "zip", + "zstd", ] [[package]] diff --git a/docs/architecture/patches/proton-11.0-makefile-pure-pe.patch b/docs/architecture/patches/proton-11.0-makefile-pure-pe.patch new file mode 100644 index 00000000..3f490822 --- /dev/null +++ b/docs/architecture/patches/proton-11.0-makefile-pure-pe.patch @@ -0,0 +1,95 @@ +--- /tmp/Makefile.in.pristine 2026-08-11 15:45:39.307229465 -0200 ++++ /home/wer/devis/steamflow-phase3/proton/Makefile.in 2026-08-11 17:00:13.949542527 -0200 +@@ -75,7 +75,11 @@ + TARGET_ARCH ?= x86_64 + + ifeq ($(TARGET_ARCH),x86_64) +- ARCHS += i386-unix x86_64-unix ++ # Pure-PE WoW64 (Phase 3 directive): NO i386-unix side. The 32-bit half ++ # ships as PE32 DLLs cross-compiled by the single 64-bit tree via ++ # --enable-archs=i386-windows,x86_64-windows (see WINE_x86_64_AUTOCONF_ARGS). ++ # Zero 32-bit ELF binaries are produced; no i686-linux-gnu host build. ++ ARCHS += x86_64-unix + else ifeq ($(TARGET_ARCH),arm64) + ARCHS += aarch64-unix aarch64-windows arm64ec-windows + else +@@ -445,6 +449,12 @@ + WINEOPENXR_DEPENDS = wine openxr + + WINEOPENXR_aarch64_PE_ARCHS = arm64ec x86_64 ++# Pure-PE (Phase 3): the single x86_64 wine tree configures ++# --enable-archs=x86_64,i386, which would make wineopenxr's makedep build try ++# to cross-compile an i386-windows half — its openxr_loader.c is not ++# 32-bit-clean (casts pointer to UINT64; fails -Werror=pointer-to-int-cast) ++# and Valve never builds 32-bit wineopenxr. Restrict to x86_64 PE only. ++WINEOPENXR_x86_64_PE_ARCHS = x86_64 + + $(eval $(call rules-source,wineopenxr,$(SRCDIR)/wineopenxr)) + $(eval $(call rules-makedep,wineopenxr,x86_64)) +@@ -565,9 +575,13 @@ + # the same source directory. + # + # The below assures that only a single configure/build step can run at a time. ++# (The i386↔x86_64 coupling only applies when the i386-unix side exists — ++# pure-PE builds have no i386-unix, so those edges are omitted.) ++ifneq ($(findstring i386-unix,$(ARCHS)),) + $(OBJ)/.kaldi-x86_64-configure: $(OBJ)/.kaldi-i386-configure + $(OBJ)/.kaldi-i386-build: $(OBJ)/.kaldi-x86_64-configure + $(OBJ)/.kaldi-i386-build: $(OBJ)/.kaldi-x86_64-build ++endif + + ## + ## vosk +@@ -629,6 +643,29 @@ + VKD3D_PE_CFLAGS="-I$(VULKAN_HEADERS_x86_64_DST)/include -I$(VKD3D_x86_64_DST)/include/vkd3d" \ + VKD3D_PE_LIBS="-L$(VKD3D_x86_64_LIBDIR)/vkd3d/x86_64-windows -l:libvkd3d-1.dll -l:libvkd3d-shader-1.dll -l:libvkd3d-utils-1.dll" + ++# Pure-PE single-tree build (Phase 3): the one x86_64 wine tree cross-compiles ++# BOTH i386-windows and x86_64-windows DLLs, but a single -L dir in ++# VKD3D_PE_LIBS can only serve one arch: makedep passes -L args verbatim to ++# every arch link (add_import_libs, case 'L'), so a -L in VKD3D_PE_LIBS would ++# precede any per-arch -L and the i386-windows wined3d.dll link would pick the ++# x86_64-windows libvkd3d-1.dll (PE32+ in a PE32 link) → "file not recognized: ++# file format not recognized". Wine's generated Makefile appends ++# $(i386_LDFLAGS)/$(x86_64_LDFLAGS) per link (makedep.c arch_make_variable ++# "LDFLAGS"), so route the -L dirs through those instead and keep only the ++# -l: names in VKD3D_PE_LIBS (later assignment on the configure command line ++# wins). The per-arch LDFLAGS are appended to the make variables — the ++# rules-common ENV compose (rules-common.mk "x86_64_LDFLAGS=... $(x86_64_LDFLAGS) ++# $(LDFLAGS)") picks them up while keeping the media LIBFLAGS. ++ifneq ($(findstring i386-unix,$(ARCHS)),) ++# classic-wow64: each wine tree builds a single arch; VKD3D_PE_LIBS above is ++# already per-tree (WINE_i386_*/WINE_x86_64_*), nothing extra needed. ++else ++WINE_x86_64_AUTOCONF_ARGS += \ ++ VKD3D_PE_LIBS="-l:libvkd3d-1.dll -l:libvkd3d-shader-1.dll -l:libvkd3d-utils-1.dll" ++x86_64_LDFLAGS += -L$(VKD3D_x86_64_LIBDIR)/vkd3d/x86_64-windows ++i386_LDFLAGS += -L$(VKD3D_i386_LIBDIR)/vkd3d/i386-windows ++endif ++ + WINE_DEPENDS = gst_orc gstreamer gst_base ffmpeg openfst kaldi vosk + WINE_i386_DEPENDS = vkd3d + WINE_x86_64_DEPENDS = piper vkd3d +@@ -1445,14 +1482,17 @@ + + .PHONY: module32 module64 module + +-module32: | all-source wine-i386-configure +- +$(MAKE) -j$(J) $(filter -j%,$(MAKEFLAGS)) $(MFLAGS) $(MAKEOVERRIDES) -C $(WINE_i386_OBJ)/dlls/$(module) +- +-module64: | all-source wine-x86_64-configure ++module32: | all-source wine-x86_64-configure + +$(MAKE) -j$(J) $(filter -j%,$(MAKEFLAGS)) $(MFLAGS) $(MAKEOVERRIDES) -C $(WINE_x86_64_OBJ)/dlls/$(module) + ++# Pure-PE: the single x86_64 tree cross-compiles BOTH PE halves ++# (--enable-archs=x86_64,i386), so module64 is just an alias of module32. ++# Running both recipes concurrently would race on shared archives ++# (e.g. dlls/ntdll/i386-windows/libntdll.a "file truncated"). ++module64: module32 ++ + module: | all-source wine-configure +-module: module32 module64 ++module: module64 + + ############################### + else # outside of the container diff --git a/docs/architecture/phase3-pure-pe-proton11.md b/docs/architecture/phase3-pure-pe-proton11.md new file mode 100644 index 00000000..110d84c8 --- /dev/null +++ b/docs/architecture/phase3-pure-pe-proton11.md @@ -0,0 +1,578 @@ +# Phase 3 — Pure-PE WoW64 Proton Build (CLOSED 2026-08-12) + +**Branch:** `phase3/pure-pe-proton11` (rebased onto `origin/main`; 0 behind / +7 ahead — ready to push) + +**Dates:** initiated 2026-08-11 · closed 2026-08-12 + +**Goal (restated):** build Valve's `proton_11.0` source into a **pure PE +WoW64 runner** — zero host 32-bit ELF library dependencies — so official +Proton runs on this pure-64-bit host (resolution to the permanently-rejected +i386-multilib item). + +## ✅ OUTCOME — Phase 3 COMPLETE + +The containerized build produced a working **pure-PE WoW64 Proton 11.0** +runner, staged as `compatibilitytools.d/steamflow-proton-11.0-purepe` +(1.4 GB), and it **booted RE2 end-to-end**: + +- **Build:** `make -j8 redist` → `REDIST_EXIT: 0` in podman (rootless, + host-network wrapper) using the branch-pinned **steamrt4** SDK + (`registry.gitlab.steamos.cloud/proton/steamrt4/sdk/x86_64:4.0.20260331.220802-0`), + not the soldier image originally assumed in the plan. +- **Pure-PE gate — ALL PASS on the staged redist:** + - `find . -type f -exec file {} + | grep -c 'ELF 32-bit'` → **0** + - `files/lib/wine/i386-windows/` = 611 × **PE32**; `x86_64-windows/` = 613 × **PE32+** + - `i386-unix/` contains only the 64-bit `wine64` loader (Valve's own layout; + official depot has **35 ELF-32 .so** there — the multilib dependency we removed) +- **Live E2E:** `steamflow test-launch 883710` → RE2 window + `"RESIDENT EVIL 2"` (class `steam_proton`, IsViewable, 1926×1112 windowed) + rendering under pure-PE wine with its own isolated wineserver; vkd3d-proton + swapchain 1920×1080 created; **no i386-multilib anywhere**. +- **Parity:** `test-diff 883710` → 0 missing / 0 mismatched / 2 matched. +- **VERSIONS.txt** stamped (`proton-11.0-1b-purepe` + component versions) — + Phase 2 tail visibility fix applied to the staged runner. + +## 1. Container prerequisites — status: DONE (2026-08-11) + +| Check | Result | +|---|---| +| `podman` | ✅ 4.9.3 installed (user action, apt) | +| `uidmap` (newuidmap/newgidmap) | ✅ installed (user action) — rootless multi-ID mapping | +| `slirp4netns` / `pasta` | ❌ absent (no sudo); worked around with `~/.local/bin/podman` wrapper injecting `--network=host` | +| rootless networking | ✅ host-network wrapper (valid rootless, full connectivity for in-container wget fetches) | +| image unpacking | ✅ `~/.config/containers/containers.conf` → `image_copy_tmp_dir` on /home (root `/` only 31G) | +| SDK image | ✅ pulled: steamrt4 `4.0.20260331.220802-0` (~7G) — **branch pin, not soldier** | +podman info | grep -A2 rootless +``` + +**IMPORTANT — the task-spec SDK image is wrong for proton_11.0.** The task +said `registry.gitlab.steamos.cloud/proton/soldier/sdk` — soldier is the SDK +for older Proton lines. The `proton_11.0` branch pins **steamrt4**: +``` +# Makefile.in (proton_11.0, line 30) +STEAMRT_IMAGE ?= registry.gitlab.steamos.cloud/proton/steamrt4/sdk/x86_64:4.0.20260331.220802-0 +``` +**Both images are anonymously pullable** (verified via the standard Docker +Bearer token flow — no credentials needed): +- `proton/steamrt4/sdk/x86_64`: 7 tags, pinned tag `4.0.20260331.220802-0` + present (also `-2`, `-3` patch tags) +- `proton/soldier/sdk`: 25 tags, `latest` present +- JWT auth realm: `https://gitlab.steamos.cloud/jwt/auth` (service + `container_registry`) — anonymous scope `repository:...:pull` grants tokens + +`configure.sh` discovers the engine itself; force podman with +`--container-engine=podman` (avoids the `/etc/containers/nodocker` podman-docker +shim check). + +## 2. Source workspace — status: ✅ DONE + +``` +/home/wer/devis/steamflow-phase3/proton +``` +- Branch: `proton_11.0` @ `0745bfb` (lsteamclient networking_message wow64 fix, 2026-07-27) +- Clone: `git clone --depth 1 --branch proton_11.0 --recurse-submodules --shallow-submodules` + (shallow to protect the 22G disk budget) +- 23 submodules checked out (wine @ 81d78e4, dxvk @ 0a70623, vkd3d-proton @ + ef20c02, dxvk-nvapi @ c68c350, vkd3d @ 30b93dc, FEX, kaldi, vosk-api, …) +- Size: **3.9G**; disk free dropped 22G → **19G** +- `configure.sh`, `Makefile`, `Makefile.in`, `proton`, `default_pfx.py` all present + +## 3. Configure & build — status: ✅ MODULE TEST BUILD PASSED (classic-wow64) + +### Environment solved (2026-08-11, this session) +- podman 4.9.3 installed (user action); **`uidmap`** package installed (user + action) → rootless works +- **`slirp4netns` missing** (no sudo) → solved with host networking via + `~/.local/bin/podman` wrapper injecting `--network=host` (configure.sh + `--container-engine=$HOME/.local/bin/podman`); container only needs + outbound HTTPS for gecko/mono/xalia fetches, so host networking suffices +- **root `/` partition full during image pull** → `~/.config/containers/ + containers.conf` `[engine] image_copy_tmp_dir = "/home/wer/.cache/ + podman-image-tmp"` (image unpack now lands on the big /home NVMe) +- SDK image `proton/steamrt4/sdk/x86_64:4.0.20260331.220802-0` pulled (~7G; + /home 27G→20G) +- ccache NOT installed (needs sudo) — skipped; disk is the constraint anyway + +### Build commands (validated) +```bash +mkdir -p /home/wer/devis/steamflow-phase3/build && cd ... +../proton/configure.sh --container-engine=$HOME/.local/bin/podman \ + --build-name=pure_pe_wow64 +make -j8 module=winex11.drv module # -j8 not -j16: 14G-RAM box hung at -j16 +``` +- `configure.sh` ✅ generated build/Makefile +- `make module=winex11.drv module` ✅ **MODULE_EXIT: 0** after regenerating + `src-wine/include/wine/server_protocol.h` (`perl tools/make_requests` — + the module target's `wine-configure` prerequisite doesn't regenerate it; + the tracked header in the wine repo is stale vs `server/protocol.def`) +- Build artifacts verified: + - `obj-wine-x86_64/dlls/winex11.drv/x86_64-windows/winex11.drv` → **PE32+** + - `obj-wine-i386/dlls/winex11.drv/i386-windows/winex11.drv` → **PE32** + - `obj-wine-i386/dlls/winex11.drv/winex11.so` → **ELF 32-bit** ← PROBLEM +- First attempt hung the box at `-j16` (14G RAM, kaldi/parallel gcc); + `-j8` completed. System hang + RAM limitation were the interruption cause. + +### ⚠️ CRITICAL FINDING: Valve's default build is CLASSIC-WOW64, not pure-PE +The `module` target built **both** PE halves AND a full 32-bit unix tree: +- `obj-wine-i386` is configured `--host=i686-linux-gnu` (a real 32-bit host + build producing ELF-32 `.so` unix libs) — this is exactly the layout that + requires host `i386-multilib` and is **permanently rejected** on this host. +- Proton's `Makefile.in:73-78`: + ```make + ARCHS := i386-windows x86_64-windows + ifeq ($(TARGET_ARCH),x86_64) + ARCHS += i386-unix x86_64-unix # ← adds the 32-bit ELF unix side + ``` + and `WINE_x86_64_AUTOCONF_ARGS` (line 619) maps `unix_ARCHS` into + `--enable-archs=…`. The official Proton 11.0 depot ships this classic + layout (verified: `files/lib/wine/i386-unix/bcrypt.so` etc. are ELF 32-bit + with `/lib/ld-linux.so.2`). + +### The pure-PE path (Phase 3 next step) +Wine 11's **new WoW64 mode** is PE-only: `--enable-archs=i386-windows, +x86_64-windows` in a **single 64-bit tree** — the 32-bit side ships as PE32 +DLLs (`i386-windows/`) with NO `i386-unix` ELF loader at all. The Proton +Makefile's `windows_ARCHS` machinery (line 86) already supports this; the +needed change is to drop `i386-unix` from `ARCHS` for `TARGET_ARCH=x86_64` +(i.e. `ARCHS := i386-windows x86_64-windows`, no `+= i386-unix …`), so: +- wine x86_64 tree gets `--enable-archs=i386-windows,x86_64-windows` +- the separate `obj-wine-i386` full-32-bit tree is not built +- dist has `lib/wine/i386-windows/` (PE32) + `lib/wine/x86_64-windows/` + (PE32+) + `lib/wine/x86_64-unix/` — **zero 32-bit ELF** +This is a build-config patch to Proton's `Makefile.in` (or a +`--target-arch`-style override); it does not touch wine source. + +**Revised gate after this finding:** +1. `find dist/lib/wine -name '*.so' -path '*i386*'` → empty (no i386-unix) +2. `find dist -type f -exec file {} + | grep 'ELF 32-bit'` → empty +3. `dist/lib/wine/i386-windows/` (PE32) + `x86_64-windows/` (PE32+) present +4. `file dist/bin/wine` → PE32+ loader; `wine64` ELF 64-bit + +## 3b. PURE-PE BUILD VERIFIED (2026-08-11, later same day) + +**Patch applied to `proton/Makefile.in` (3 edits, all build-config only, no +wine source changes):** +1. `ARCHS += i386-unix x86_64-unix` → `ARCHS += x86_64-unix` (line 78) — + drops the i386-unix side; `rules-common.mk`/`rules-wine-tools.mk`/ + `rules-makedep.mk`/`rules-autoconf.mk` all gate on `$(arch)-$(os) ∈ ARCHS`, + so every i386-unix tree (wine, kaldi, vosk, openfst, gstreamer family, + ffmpeg, dav1d, libsoup, graphene, glslang, gst_plugins_rs, lsteamclient, + steamexe, vrclient) is skipped automatically. i386-windows PE components + (dxvk, dxvk-nvapi, vkd3d, vkd3d-proton, vulkan-headers, spirv-headers) + remain, as their rules are `i386,windows`. +2. `WINE_x86_64_AUTOCONF_ARGS` then expands to `--enable-archs=x86_64,i386 + --enable-win64` — single 64-bit host tree cross-compiling BOTH PE halves + (verified in `make -n` dry run; `--host=x86_64-linux-gnu`, `i386_CC= + i686-w64-mingw32-gcc` retained for PE32 cross-compile). +3. **kaldi serialization block gated** on `findstring i386-unix` (the + `.kaldi-x86_64-configure: .kaldi-i386-configure` edges hard-coded the + i386 tree; broke with "No rule to make target .kaldi-i386-configure"). +4. **module64 aliased to module32** — in pure-PE the single tree builds both + PE halves; running both recipes concurrently raced on shared archives + (`dlls/ntdll/i386-windows/libntdll.a: file truncated`). + +**Module build (after cleanup + wine-x86_64 reconfigure):** +`make -j8 module=winex11.drv module` → **MODULE_EXIT: 0** + +**Pure-PE gate scan on `obj-wine-x86_64/dlls/winex11.drv`:** +- `x86_64-windows/winex11.drv` → **PE32+** (x86-64) ✅ +- `i386-windows/winex11.drv` → **PE32** (Intel 80386) ✅ +- `winex11.so` (the only .so) → **ELF 64-bit** (unix side; 64-bit host libs + only) ✅ +- ELF 32-bit files in module output → **0** ✅ + +**Tree-wide gate on the whole build dir:** +- ELF 32-bit files → **0** ✅ +- `i386-unix` dirs → **0** ✅ + +**Disk pre-flight:** freed 10G→14G by deleting the now-dead i386-unix obj/dst +trees (obj-wine-i386, obj-kaldi-i386, obj-vosk-i386, obj-openfst-i386, +gstreamer family, ffmpeg, dav1d, libsoup, graphene, glslang, gst_plugins_rs, +lsteamclient, steamexe, vrclient — NOT the i386-windows PE ones). NOTE: the +`/home/wer/devis/tmp/p2-*` dirs are P2-RTX research artifacts (stock-runtime +backup 5.8G, mod backups) — deliberately left untouched (research policy). + +**Remaining caveat:** this verified the module-level proof (winex11.drv both +PE halves, zero 32-bit ELF). The full `make` still needs to complete (wine +tree is only partially built — kernel32/ntdll/ucrtbase i386-windows archives +exist, but a full dist needs all dlls + dxvk/vkd3d-proton i386-windows PE +staging), then `make redist` for the compat tool tree and the dist-level gate. + +## 4. Pure-PE gate — status: DEFINITION FINALIZED (build-verified) + +**Correction from the earlier plan:** the gate is NOT "no `i386-unix/` dir" — +Valve's own Makefile.in (`proton_11.0`) always creates `dist/lib/wine/ +i386-unix/` and installs ONLY the **64-bit** loader there. And the module +build proved the default build ALSO produces a full 32-bit ELF unix tree +(`obj-wine-i386` with `--host=i686-linux-gnu` ELF-32 `.so` libs) — i.e. +**classic-wow64** layout. See §3 "CRITICAL FINDING" for the build-config +change needed (drop `i386-unix` from `ARCHS`). + +**Verified classic-wow64 reference (official Proton 11.0 depot on disk):** +`files/lib/wine/i386-unix/` contains **32-bit ELF `.so` files** — bcrypt.so, +crypt32.so, dwrite.so, kerberos.so, … — and an ELF-32 `wine` executable +(interpreter `/lib/ld-linux.so.2`). Those are what need host 32-bit libs. + +**The gate (final, build-grounded):** +1. `find dist/lib/wine -name '*.so' -path '*i386*'` → **empty** (no i386-unix + ELF side) +2. `find dist -type f -exec file {} + | grep 'ELF 32-bit'` → **empty** +3. 32-bit PE side present: `dist/lib/wine/i386-windows/` (PE32 DLLs) + + `dist/lib/wine/x86_64-windows/` (PE32+). +4. `file dist/bin/wine` → PE32+ (or ELF-64 loader); `wine64` ELF 64-bit. +5. `i386-unix/` may exist but must contain ONLY 64-bit ELF (`wine64`, + `wine64-preloader`) — no `*.so`, no ELF-32 binaries. + +**Expected result:** a dist tree with PE-only 32-bit side → boots on this +pure-64-bit host (64-bit host GL/Vulkan/X libs only), no i386-multilib. + +## 5. Full build + staging + E2E — status: ✅ COMPLETE (2026-08-12) + +### Full redist build +`make -j8 redist` → **REDIST_EXIT: 0** (resumable via make stamps through +three disk-full recoveries). Version stamp: `proton-11.0-1b`. + +### Blocker fixes along the way (all committed) +| Blocker | Root cause | Fix | +|---|---|---| +| `wined3d.dll` link failed ("file not recognized") | single `-L` in `VKD3D_PE_LIBS` served both archs; i386-windows link grabbed the x86_64-windows `libvkd3d-1.dll` (PE32+ in a PE32 link) | `VKD3D_PE_LIBS` = `-l:` names only; per-arch `-L` routed through `i386_LDFLAGS`/`x86_64_LDFLAGS` make-var append (keeps media LIBFLAGS) | +| disk full (×3) | wine tree 3G + kaldi 2.3G + dxvk 2.7G + dist staging vs ~14G budget | freed `SteamFlow/target` caches (incremental/release) + phase3 `dist/` mirror + build logs; ccache absent (needs sudo) so disk is the only lever | +| wineopenxr i386 cross-compile `-Werror=pointer-to-int-cast` | `--enable-archs=x86_64,i386` made makedep build the i386 half; Valve's own comment: "32-bit is not supported by SteamVR, so we don't build it" | `WINEOPENXR_x86_64_PE_ARCHS = x86_64` (same mechanism Valve already uses for aarch64) | + +### Staging + dist-level gate (ALL PASS) +Staged `redist/` → `compatibilitytools.d/steamflow-proton-11.0-purepe` (1.4G; +moved, not copied — ext4 has no reflink and /home was at 100%). Gates on the +staged tree: +- `find . -type f -exec file {} + | grep -c 'ELF 32-bit'` → **0** +- `find files/lib/wine -name '*.so' -exec file {} + | grep -c 'ELF 32-bit'` → **0** + (official depot: **35** — the removed multilib dependency) +- `i386-windows/` 611 × PE32; `x86_64-windows/` 613 × PE32+ +- `i386-unix/` = only `wine64` + `wine64-preloader` (ELF 64-bit — Valve's + own layout) +- `VERSIONS.txt` stamped (RUNNER_VERSION=proton-11.0-1b-purepe + component + versions); `classify_runner` → Proton kind (root `proton` script + + `files/bin/wine` ELF-64) + +### E2E launch — SUCCESS +`steamflow test-launch 883710` (after `test-diff 883710` → 0 missing / +0 mismatched / 2 matched) launched RE2 under the pure-PE runner: +- window `"RESIDENT EVIL 2"` (class `steam_proton`), IsViewable, + 1926×1112 windowed at (317,182) — user's own display mode, untouched +- 5 render threads @ ~99% CPU; vkd3d-proton swapchain 1920×1080 created; + own isolated wineserver (no wine-tkg collision) +- ran stable until the user closed it + +### Post-E2E fix (SteamFlow code, `9d4779a`) +**PerGame background-Steam spawn:** with PerGame prefix mode the pipeline +seeds `compatdata//pfx` from the game runner's `default_pfx`, but +spawned the background Steam client with the hardcoded Steam Runtime Runner +(default wine-tkg, classic-wow64) → `init_wow64: could not load wow64.dll` +→ exit 53 (pure-PE wine has no unix-side wow64.dll shim). Fix: PerGame mode +now spawns background Steam with the **game's own runner**. Verified: +`test-launch 883710` → `WINEPREFIX=…/compatdata/883710/pfx`, Steam spawns +cleanly, no exit 53. (Also fixed `deploy_dll_symlinks` EEXIST on dangling +runner symlinks — `symlink_metadata()` instead of `exists()`, `3ff4347`.) + +### Post-Phase-3 follow-up: Shared→PerGame runner-mismatch guard (2026-08-12) + +**Context:** the pure-PE client investigation (see the +`steamflow-proton-runtime-debugging` skill) determined that the Windows Steam +client cannot boot under the purepe runner (CEF GPU-process crash + a second +32-bit `steam.exe` network-init stall), so the recommended split is **Steam +Runtime runner = wine-tkg** (hosts the client) + **per-game runner = purepe** +(games). But `effective_game_proton` no longer force-equals the game runner +to the runtime runner, so a **Shared** prefix would host two different wine +builds (two wineservers, different pipe protocols) → +`wine client error: version mismatch ... your wine binary was not upgraded +correctly` at launch. + +**Change:** `WineTkgRunner` resolves an **effective prefix mode** +(`effective_prefix_mode(ctx)` + pure core `effective_prefix_mode_impl`, both +in `src/infra/runners/wine_tkg.rs`): it applies the configured mode (per-game +user config → launcher default) and, when that mode is `Shared` **and** the +Steam Runtime runner resolves to a different path than the game runner, +auto-falls back to `PerGame` with a visible warning: + +``` +[SteamFlow] Runner mismatch detected (Steam Runtime: "{steam}", Game: "{game}"). +Automatically switching to PerGame prefix mode to prevent wineserver protocol collision. +``` + +The effective mode is threaded through every prefix decision point: + +- `steam_wineprefix_for_game` (src/utils.rs) gained an + `Option` parameter: `Some(mode)` = effective mode from the + launch pipeline; `None` = legacy configured-mode callers (UI management + buttons, `launch_custom_exec` Mods-tab path). +- `prepare_prefix` + `build_env` (game WINEPREFIX, background-Steam spawn, + CEF-enforcement prefix, the Shared+Steam-running warning gate) use the + effective mode. +- Pipeline stages `prepare_prefix.rs` (symlink deployment), + `resolve_game_fixups.rs` (registry fixups) and `resolve_dll_providers.rs` + (component detection) resolve the same effective mode so nothing targets + the wrong prefix. + +**Tests** (`src/infra/runners/tests.rs`): +- Shared + `"wine-tkg"` runtime runner + `"steamflow-proton-11.0-purepe"` + game runner → `PerGame` (the exact recommended split). +- Matching runners → stays `Shared`. +- No runtime runner configured → stays `Shared`. + +**Effect:** the recommended split-runner config now works with the global +prefix mode left at its default — the launcher detects the mismatch and +isolates the runners into separate prefixes automatically. + +### Post-Phase-3 follow-up: prefix self-heal for dangling builtin DLL symlinks (2026-08-12) + +**Context:** the RE2 prefix (`compatdata/883710/pfx`) was seeded on +2026-08-11 while the game ran the vendored cachyos copy +(`compatibilitytools.d/steamflow-proton-11.0`). Seeding created **absolute +symlinks** into that runner's `files/lib/wine/{x86_64,i386}-windows/` tree +for every builtin DLL (kernel32, ntdll, user32, …). When the vendored copy +was later removed (only `steamflow-proton-11.0-purepe` remained), **599 of +609 system32 links dangled** and ANY wine — game runner or background Steam +— died before launch with: + +``` +wine: could not load kernel32.dll, status c0000135 → exit 53 +``` + +`seed_prefix` only seeds when `system.reg` is absent, and +`deploy_dll_symlinks` only covers game-DLL providers (dxvk/vkd3d/nvapi), so +the broken prefix was never repaired automatically. + +**Change:** `utils::repair_dangling_prefix_symlinks(prefix, runner_root)` +walks `drive_c/windows/{system32,syswow64}`, re-points dangling links at the +**active runner's** equivalent `lib/wine` file (same relative subpath), and +drops links the active runner doesn't ship (pure-PE omits amdxc64/atidxx64/ +winsqlite3/winewayland/wpcap/umu — a fresh prefix wouldn't have them). +`WineTkgRunner::prepare_prefix` runs it on every launch (no-op scan on +healthy prefixes; non-fatal on error). + +**Verification:** +- Unit test `test_repair_dangling_prefix_symlinks` (repoint / drop / + untouched-healthy / idempotent second pass). +- Live: after re-pointing the RE2 prefix's 1,306 links (16 dropped), the + exact background-Steam invocation boots `steam.exe` under pure-PE wine + (was exit 53 in ~1s; now alive past 20s with CEF/explorer/uiautomation). + +### Post-Phase-3 follow-up: background Steam in master prefix under runtime runner (2026-08-13) + +**Context:** with the pure-PE game runner split, PerGame mode spawned the +background Steam client with the **game's runner (purepe)** in the +**per-game prefix**. The client cannot boot under purepe (documented +2026-08-12: CEF GPU crash + network-init stall) → `exit 1` in ~2s, no +Steam logs, launch aborted at PreparePrefix. The stale-wineserver guard +and the "is Steam running" check also targeted the per-game prefix, so even +a running master-prefix client (launched via Manage) was not detected and +a doomed duplicate was spawned anyway. A second, compounding cause: the +per-game client-file deployment only symlinked `if !dst.exists()`, so a +stale real `steam.exe` (Feb-14 copy) was never refreshed and self-exited +with code 1. + +**Change (commit b5f5c0a):** the client ALWAYS belongs to the master prefix +under the Steam Runtime runner (wine-tkg), in Shared AND PerGame mode: +- PerGame `prefix_steam_dir`/`steam_wineprefix` resolve to the master Steam + dir + master prefix; the per-game prefix still receives the client-file + deployment for `STEAM_COMPAT_CLIENT_INSTALL_PATH`. +- `steam_runner` is always the configured runtime runner, never the game's. +- Stale-wineserver guard targets the GAME prefix (`effective_game_prefix`), + so it cannot kill the running master client. +- Deployed client files are REFRESHED from master when a stale real-file + copy differs (byte-compare; symlinks are up-to-date by construction). + +**Verification:** `test-launch 883710` → background Steam under wine-tkg, +ready signal in 6s, `effective_steam_wineprefix` = master prefix, game +launches under purepe (DXVK cache + swapchain), session `result: Success`. + +### Follow-up: master-prefix split was a REGRESSION — client returns to per-game prefix (2026-08-13) + +**b5f5c0a's verification was insufficient.** It checked "Steam ready in 6s + +swapchain created" — the same shallow 2s alive-check that masks graceful +post-spawn exits — so the split shipped green while breaking every launch. + +**Observed failures (all `result: Success` per SteamFlow, all real failures):** +- RE2 (883710): game window appears, then clean self-exit ~3s later + (`mfplat:MFShutdown`, no segfault, `MainMenu=False` written to + `re2_config.ini`). Confirmed across policy `Enabled` AND `Disabled` + (no client at all → SteamAPI_Init fails the same way). +- Portal 2 (620): "Steam must be running" dialog while the master-prefix + client WAS running (`steam_running_before_launch=true`) — the game in + `compatdata/620/pfx` simply cannot reach a client in the master prefix. + +**Root cause:** Wine named pipes are **per-wineserver**. A game's +`steamclient.dll` connects to the client via `\\.\pipe\SteamClient…`, which +exists only in the client's own wineserver. b5f5c0a parked the client in the +master prefix (wine-tkg wineserver) while the game runs in the per-game +prefix (pure-PE wineserver) → pipe unreachable → `SteamAPI_Init` fails → +games self-exit or show "Steam must be running". + +**Control case (the config that demonstrably worked):** the 08-12 E2E +sessions (16:09/16:56) had `effective_steam_wineprefix == +effective_game_wineprefix == compatdata/883710/pfx` — client in the SAME +prefix as the game → ran stable until the user closed it. That is 8a56ed2's +layout (client with the game's runner in the per-game prefix). + +**Change (revert of b5f5c0a's placement, keeping its file-refresh):** +- PerGame `prefix_steam_dir`/`steam_wineprefix` resolve back to the + per-game prefix (client runs in the game's wineserver). +- `steam_runner` in PerGame mode is the GAME's runner again (8a56ed2); + Shared mode keeps the configured runtime runner. +- The client-file REFRESH logic from b5f5c0a is kept (byte-compare vs + master, refresh stale real copies — this fixed the real exit-1 cause, + the stale Feb-14 `steam.exe`). +- The stale-wineserver guard still targets `effective_game_prefix`. + +**Also fixed: `launch_verification` masked the whole class.** It only +checked the process was alive at 2s, so a game that self-exits 3-5s after +the window was recorded as "Success". Now two-phase: 2s fast-fail (instant +crashes) + sustained-liveness window to 8s (polled every 500ms) — any exit +inside the window is `failed_after_spawn` with the real lifetime. New test +`test_launch_verification_graceful_self_exit_caught` covers the 4s self-exit +case that the old 2s check missed. + +### Session-auth sync for per-game prefixes — implemented; Portal 2 blocker root-caused (2026-08-14) + +**What shipped (commit `feat(steam): …` 2026-08-14):** +`SteamClient::sync_master_session_to_prefix()` copies the master client's auth +state into a per-game prefix's Steam dir before the headless client spawns there: +`config/loginusers.vdf` (wholesale), the `config/config.vdf` Authentication block +— the `RememberedMachineID` JWT — (merged, target's other keys preserved), legacy +`ssfn*` sentries, and the `HKCU\Software\Valve\Steam` section of `user.reg` +(per-key overlay, EOL preserved). Guards: no-op if master has no session; never +downgrades a per-game login as fresh as master's; non-fatal. Wired into the +PerGame spawn path in `wine_tkg.rs` (runs only when no client is running in the +target prefix). 8 new unit tests; 105 lib tests + 15 suites green. Live run +verified the 620 prefix receives master's loginusers.vdf timestamp (1786715012), +the fresh machine JWT (iss `r:0012_28A6B9E0_68319`, exp 2027-03), and the +registry login keys. + +**What it does NOT do (hard fact):** it does not produce a logged-in per-game +client. Sync is necessary but not sufficient — see the two blockers below. + +**CONCLUSION — a second client process in the per-game prefix IS required; the +pointer-only model is disproven.** b5f5c0a parked the client in the master prefix +(wine-tkg wineserver) while games run in per-game prefixes (pure-PE wineserver); +Wine named pipes are per-wineserver, so the game's steamclient.dll cannot reach +the client's pipe → `SteamAPI_Init` failed (RE2 self-exit ~3s, Portal 2 "Steam +must be running"). That is exactly why 284e697 reverted b5f5c0a (client back in +the per-game prefix, 8a56ed2 layout) and why `STEAM_COMPAT_CLIENT_INSTALL_PATH` +alone cannot bridge the gap — it is a location hint (it already points at the +master client), not an IPC bridge. Do not re-derive this; the b5f5c0a experiment +already answered it. + +**Portal 2 (620) blocker — hard state, evidence on disk:** +1. **purepe ClientAPI failure:** every per-game client run under purepe writes an + assert dump whose message is `Assert( ClientAPI_InitGlobalInstance: + InternalAPI_Init_Internal failed, most likely because you are missing a 32-bit + dependency of steamclient.dll (the Steam client is a 32-bit app). + ):…\src\common\steam\client_api.cpp:601` (identical text in every dump under + `compatdata/620/pfx/drive_c/Program Files (x86)/Steam/dumps/`). + `WINEDEBUG=+loaddll` shows zero unresolved modules — it is Steam's generic + catch-all, not a literal missing file. Result: client runs but stays anonymous + forever (webhelper `-steamid=0`; `connection_log.txt` has zero login attempts). + Note: this is the same `client_api.cpp:601` the conformance gate below claims + "✅" for — the gate only covers the master (wine-tkg) stack, not purepe. +2. **Machine-bound token rejection:** the SAME 620 prefix with the client spawned + under **wine-tkg** (not purepe) boots fine AND connects to Steam CM for the + first time ever (connection_log.txt 2026-08-14 16:22:39 `Connect() … + ConnectionCompleted() (185.25.182.20:27030, WebSocket)`) but login is refused: + `Clearing in-memory token - 5 (Invalid Password): LogonFailureReceived` — the + copied RememberedMachineID JWT is bound to the source install's machine + identity and the server rejects it. Copying credentials into a second prefix + creates a second machine identity; the token does not follow. + +**MachineGuid cross-prefix injection test — EXECUTED 2026-08-14, verdict: NOT sufficient.** The doc/code mismatch in `src/runner/proton_abi.rs:16` (claims `seed_prefix()` does "MachineGuid preservation"; body does only copy_tree + dosdevices symlinks + version marker) remains a REAL, unfixed bug — do not mark it resolved or irrelevant. Measured: MachineGuid is UNIQUE per prefix (master `705bc93a-fec3-4716-b240-ef3304859be3`; 620 `4e849ba2-31f0-483a-8e17-a0b9bf066a08`; 883710 `92cfc9d1-…`; 203160 `e6056f5f-…`; 108710 `e10b7828-…`) — wine generates a fresh one per prefix. + +The three-run injection test (same copied JWT, different GUID pairing): + +| Run | MachineGuid in 620 | Client behavior | Verdict | +|---|---|---|---| +| 16:22 | 620's own `4e849ba2…` | attempted LogOn → server rejected: `Clearing in-memory token - 5 (Invalid Password): LogonFailureReceived` | server-side rejection of copied JWT | +| 18:21 | **master's `705bc93a…` injected** into `system.reg` (verified live via `reg query` = `705bc93a…`) | client **never attempts**: `Clearing in-memory token - 1 (OK): cached creds not available`, straight to `WaitingForCredentials` login window | local rejection before contacting server | +| 18:34–47 | 620's own GUID restored + **interactive login** (password + email code) | `RecvMsgClientLogOnResponse(): 'OK'` → `Logged On` `[U:1:137551487]`; fresh JWT persisted | **working path** | + +Precise conclusion: MachineGuid is **not sufficient** to make a copied JWT work — but it is not inert either: it **affects which failure mode the client hits** (server-side `Invalid Password` with the prefix's own GUID vs local `cached creds not available` with a foreign GUID), meaning the client's local identity check keys on MachineGuid (or a fingerprint including it). Neither GUID pairing gave the client what it actually needed — a token minted for that machine identity. The `seed_prefix()` doc/code mismatch stays open as a real, lower-priority bug (preserving MachineGuid across prefix seeds is still correct hygiene; it is just not the auth binding). + +**Working auth mechanism (proven 2026-08-14): one-time interactive login per prefix.** The client's own key store can only be populated by a real login — file/registry sync cannot fabricate it. After one interactive login (password + email code) under wine-tkg in the per-game prefix: `loginusers.vdf` gains AutoLogin=1 + fresh `Timestamp` (1786740468), and `config.vdf` gains a **fresh machine-bound JWT** (`iss r:0001_28A6B125_2FF1F`, iat 18:47:39, exp 2027-03; vs master's `r:0012_28A6B9E0_68319` — the `r:00xx` prefix is the machine-instance identifier). The prefix then auto-logs-in on subsequent boots. This is the "one-time `-login` bridge per prefix" fallback the sync work anticipated. + +**PITFALL (disk-full, 2026-08-14):** with `/home` at 100% (1.4G free), a freshly-logged-in client **crashes post-login** with `Assert( Assertion Failed: Failed to create thread (error 0x3e6) ):…src\tier0\threadtools.cpp:3870` — the UI freezes at "entering", the JWT never persists, and the crash handler kills the client ~3 min later (assert dump grows 13KB → 1.1MB). ERROR_NOACCESS on `NtCreateThread` with no user-visible signal; looks like a random client crash. Freed 11G (`rm -rf target/debug`, rebuildable) → clean re-login → token persisted. **Backlog item (NOT urgent, NOT fixed this session):** add a defensive free-space check (warn/abort before client spawn when free space is low) so this doesn't resurface as a confusing crash. + +**Committed vs open:** the sync machinery + this doc section are committed (2026-08-14). Remaining blockers before this can ship as an AUTOMATIC flow: + +1. **Shared/PerGame wineserver routing gap — the actual next blocker (ahead of winproc 1400), investigated 2026-08-14, fix NOT yet implemented.** The only reason the 18:59 Portal 2 test worked is MANUAL intervention: the game was launched directly with wine-tkg + `compatdata/620/pfx`, bypassing the automated path. A real user clicking Play still dies before reaching Steam. Actual decision logic (verified in code, not guessed): + - Standard game-launch path (UI Play / `test-launch`): `wine_tkg.rs:119,770` → `effective_prefix_mode(ctx)` (line 79) → `effective_prefix_mode_impl` (line 48): if configured `Shared` AND `steam_runtime_runner ≠ game_runner` → **auto-fallback to `PerGame`** → `steam_wineprefix_for_game(..., Some(PerGame))` → `compatdata/620/pfx`. For 620 (Shared + wine-tkg vs purepe) this is why the 16:16 session ran in the per-game prefix. Also applies the stale-wineserver guard (line 343) which kills a foreign-runner wineserver in the game prefix. + - **Custom-exec path** (Mods tab "Play Mod" / `test-mod`; `launch/mod.rs:310`): calls `steam_wineprefix_for_game(config, appid, store, None)` — **no effective mode, no runner-mismatch guard**. The `None` branch (`utils.rs:2293–2295`) computes `use_per_game_compat_data = use_steam_runtime && steam_prefix_mode == PerGame` from the **raw** user config: 620 is `use_steam_runtime=true` (policy Enabled) but `steam_prefix_mode=Shared` → **false** → `resolve_master_wineprefix()` → **master prefix**. Game spawns there under purepe, no stale-wineserver check, no per-game client detection → dies before Steam. + - **Gap, precisely:** `launch_custom_exec` bypasses `effective_prefix_mode()` entirely. A Shared-configured game with a logged-in per-game client under `compatdata/620` is never detected or preferred; the raw config (Shared → master) wins. Fix direction (NOT yet implemented, standalone + testable independent of winproc 1400): make the custom-exec path honor the effective prefix mode (runner-mismatch guard) and/or prefer a prefix with a running logged-in client; verify separately. +2. **winproc 1400 — pre-existing, separate, patch unbuilt:** wine-11 `dlls/win32u/window.c::get_window_thread()` regression → `Message channel UWM_REMIX_BRIDGE_REGISTER_THREADPROC_MSG handshake failed with 1400` right after `Server side D3D9 Device created successfully!` (game exit code 5). Full analysis: `refs/winproc-1400-wine11-regression.md`. NOT a Steam/auth issue — Portal 2 now boots past SteamAPI_Init, loads the full p2-rtx chain (dxwrapper → p2-rtx.dll → winmm, `Steam game detected!`), Remix selects `NVIDIA GeForce RTX 3070 (mobile)`, and dies only at the bridge handshake. + +**OPEN PRODUCT DECISION (do NOT resolve unilaterally):** the one-time interactive login per prefix is now the ONLY proven working path for per-game session auth — and it is the exact approach previously deprioritized for end-users. Before writing this up as "the answer": accept one-time-manual-login-per-prefix as the **shipped default** (now that it is the only thing proven to work), or make it an **opt-in "advanced/isolated session" mode** rather than the default flow? Written as an open decision point, clearly separated from the settled technical findings above. + +### Conformance gates (Phase 1 reuse) — status +- Windows Steam client boots without client_api.cpp:601 → ✅ (wine-tkg master + stack unchanged; pure-PE runs game-side) +- RE2 ownership gate + first-frame render → ✅ (window + render threads + + swapchain) +- `test-diff 883710` parity → ✅ (0 missing / 0 mismatched / 2 matched) +- `VERSIONS.txt` real component versions → ✅ + +## Known limitation (documented, not a Phase 3 defect) +First RE2 boot under pure-PE showed a black screen while rendering (render +threads pegged, swapchain created, window live). Likely first-run shader/ +pipeline-cache compilation (vkd3d-proton cache was being built). Tracked as a +post-Phase-3 follow-up, not a blocker for the pure-PE goal. + +## Open items — ALL CLOSED +- [x] **USER:** `sudo apt-get install -y --no-install-recommends podman` +- [x] podman rootless smoke test (`podman info`) — worked after `uidmap` +- [x] disk headroom decision — freed caches incrementally; -j8; ccache skipped +- [x] SDK tag choice — pinned `4.0.20260331.220802-0` (branch pin; `-2`/`-3` exist) +- [x] first `configure.sh` + `make` pass; module-loop verification +- [x] pure-PE gate scan on `dist/`; install + conformance + +**Phase 3 verdict: CLOSED — pure-PE WoW64 Proton 11.0 built, staged, and +verified booting RE2 on the pure-64-bit host with zero 32-bit ELF +dependencies. i386-multilib remains permanently rejected (host constraint).** + +## 2026-08-15 — Protocol-pin attempt: what purepe's wineserver protocol 931 actually IS (CLOSED, falsifying) + +**Question:** could a client-role wine build pinned to the wine commit that +"corresponds to" protocol 931 let the client and purepe share one prefix / +wineserver, eliminating session-sync, MachineGuid, and one-time-login? + +**Answer: protocol 931 is VALVE's own protocol generation. No wine-master +commit speaks it.** Identification chain (all evidence local, no guessing): + +1. The wineserver protocol number is `SERVER_PROTOCOL_VERSION` in the + GENERATED `include/wine/server_protocol.h` (tracked copy is one behind; + `tools/make_requests` reads the existing header and writes `$protocol+1` + ONLY when the regenerated content differs — i.e. when `server/protocol.def` + is stale vs the tracked header). +2. purepe = `proton-11.0-1b`, wine submodule at **`proton-wine-11.0-1`** + (commit `81d78e4`): tracked header 930, stale protocol.def → build + regenerates → **931**. +3. `diff` of Valve's `server/protocol.def` vs wine-master `wine-11.0`: + **massive structural deltas** — `cpu_topology_override` VARARG in + create_process, `ldt_copy`, `directory_file_entry` + + `query_directory_file`, `track_mouse_from_pointer`, + `set_user_input_time` (renamed), `flush_key_done` + registry-branch + redesign, desktop close timeout, and the whole **fsync shm protocol** + (`fsync_free_shm_idx` etc.). This is why no master-wine build can attach: + it's not the number, it's the wire format. +4. Therefore the only protocol-931 wine = **Valve's tree itself**. wine-master + `wine-11.0`/`11.1`/`11.2` all carry tracked 930 (built 930 — no bump), and + master ≥11.2 has 931+ with a different wire format. + +**Two throwaway builds made (2026-08-15, wine-tkg non-makepkg machinery, `_NOLIB32="wow64"`, no userpatches):** +- `wine-tkg-w11-931-git-11.0.r0.g6cc805ea` — wine master `wine-11.0` pinned via `_plain_version` (modd fork PIN logic, `_staging_version=v11.0` + `_staging_upstreamignore=true`). Built protocol **930** (tracked current → no bump). Also: the `-W` staging exclusions from `earlyhotfixer`/`staging_fixes` reference patches absent in v11.0 staging → `patchinstall.py` dies with `KeyError: 'winex11-_NET_ACTIVE_WINDOW'`; the wine-tkg `(error && exit 1)` subshell quirk swallows it and the build limps on **without staging patches**. +- `wine-valve-110-proton.wine.11.0.1.r0.g81d78e4` — **the exact Valve tree** via `_localbuild` worktree (`git worktree add` of `steamflow-phase3/proton/wine` at `81d78e4`, zero patches). Built protocol **931** ✓ (header regenerated 930→931, verified). Build fix needed: `--without-opencl` (host has no OpenCL dev lib; Valve's configure enables it → `dlls/opencl/opencl.so` link fail). + +**Shared-prefix test (client at `compatdata/620/pfx` + game under purepe, exact prior procedure):** +- Client under the Valve build: **boots CEF fully (5 webhelpers incl. GPU process — purepe's CEF crash does NOT reproduce), but steam.exe network layer STALLS** — 6 threads, ~0% CPU, blocked on `pipe_read`, `connection_log.txt` absent at 4.5 min. **This is the same stall as purepe, reproduced on a wine-tkg-STYLE build of the same source → the defect is SOURCE-level (Valve's proton-wine-11.0-1 tree), NOT purepe's build config.** (wine-tkg 11.13/11.14 master builds boot the client fine — the control.) +- Game under purepe at the same prefix: **NO `wine client error: version mismatch`** (previously died <1s with 957/931 and 958/931). portal2.exe attached to the Valve-tree wineserver, booted the FULL engine (main window 977×657, `steam_app_620` class) — never happened in any prior failing run. **Protocols 931/931 MATCH, wineserver attach SUCCESS.** +- The game then hit an **"Engine Error" dialog** (Steam API layer): `steamclient.dll` cannot complete auth because the client is network-stalled → no login. + +**Verdict:** the pin LINES UP (protocol 931 identified + matched + attach proven), but the only wine that speaks 931 (Valve's tree) cannot run the Steam client's network layer — so the shared-prefix setup is impossible for the client role regardless of protocol. **This avenue is closed with a proven protocol match.** The permanent solution remains the two-runner split + one-time-login automation (wine-tkg client + purepe games). The prior "protocol mismatch falsified for every client runner" conclusion is refined: the blocker was never the number — it's that no client-capable wine speaks Valve's protocol. + +**Environment notes (reusable):** the wine-tkg `_localbuild` mode (`_localbuild=""` under `srcdir`, `_localbuild_versionoverride`) is the zero-patch path to build any local wine tree with the same machinery (skips `_src_init`/`_prepare`). `--config` external cfg is sourced LAST (wins over customization.cfg/advanced/legacy). Build space: relocate `/tmp/wine-tkg` → `/home` via symlink when `/` is tight. Both throwaway builds kept under `wine-tkg-git/non-makepkg-builds/` (930-build is useless, deletable; Valve 931-build kept for reference). diff --git a/docs/architecture/valve-stack-replication.md b/docs/architecture/valve-stack-replication.md new file mode 100644 index 00000000..81985a3d --- /dev/null +++ b/docs/architecture/valve-stack-replication.md @@ -0,0 +1,413 @@ +# Valve Stack Replication — Valve-Parallel Compatibility Stack + +## Status +**Phase 1 VERIFIED (2026-08-11): RE2 (883710) launches and renders under the +pure-WoW64 wine-11 stack. Local worktree only — NOT pushed to GitHub.** + +**Phase 2 item 1 IMPLEMENTED (2026-08-11): `steamflow test-diff ` +env-parity harness (src/parity.rs) — parses native proton logs (PROTON_LOG=1: +`Options:` set → reverse-mapped `PROTON_*` env, `Effective/System/User settings +WINEDLLOVERRIDES`/`WINEDEBUG`, `PATH`) and diffs against SteamFlow's +`effective_env.json` (MISSING / EXTRA / MISMATCHED / MATCHED, priority-flagged).** + +**Phase 2 item 2 IMPLEMENTED (2026-08-11): native Rust Proton ABI +(`src/runner/proton_abi.rs`) — port of Valve's `proton` script + `default_pfx` +launch semantics, no Python at game launch.** The Runner trait's `build_env` +now computes the per-app compat set (default_compat_config + forcelgadd +default + per-game `proton_compat_options`), merges Proton's env rules +(`WINE_LARGE_ADDRESS_AWARE`, `WINE_HEAP_*`, `PROTON_*` input vars, +`DXVK_ENABLE_NVAPI`, `WINE_MONO_HIDETYPES`, `__GLVND_DISALLOW_PATCHING`, +`PROTON_USE_XALIA`, …) and base DLL overrides (`steam.exe=b`, `opencl=n,d`, +…). `seed_prefix` does native prefix init (default_pfx copy + dosdevices c:/z: +symlinks + version stamp) without Python. **test-diff 883710 VERIFIED: the two +real gaps (PROTON_FORCE_LARGE_ADDRESS_AWARE, PROTON_USE_WINED3D) are now +MATCHED — 0 missing, 28 extra (log-format asymmetry), 2 matched.** + +**Phase 2 tail COMPLETE (2026-08-11):** item 3 (`VERSIONS.txt` visibility, +kill `found(bundled)`) DONE via `utils::write_runner_versions_txt` at +extraction (GitHub tarball install, steam-cdn `install_game`, headless +`test_download_proton`); native `seed_prefix` wired into `prepare_prefix` (no +Python prefix init at game launch); i386-multilib item **CLOSED / REJECTED** +(host is pure 64-bit — never re-open). See "Session close" + "Phase 3" blocks. + +**Phase 3 CLOSED (2026-08-12): containerized pure-PE WoW64 Proton 11.0 build +of Valve's `proton_11.0` source — COMPLETE.** Built with the branch-pinned +**steamrt4** SDK (`registry.gitlab.steamos.cloud/proton/steamrt4/sdk/x86_64: +4.0.20260331.220802-0`; not soldier — see §Phase 3), staged as +`compatibilitytools.d/steamflow-proton-11.0-purepe`, and **verified booting +RE2 live** (zero 32-bit ELF anywhere; 611×PE32 i386-windows + 613×PE32+ +x86_64-windows; 0 ELF-32 files). Full record: `docs/architecture/ +phase3-pure-pe-proton11.md`. i386-multilib remains permanently rejected. + +## Problem + +SteamFlow historically maintained a parallel compatibility stack (custom +`steamflow-runner-wine11-wow64`, wine-tkg builds tracking wine master). The +compat universe is unbounded: wine master changes weekly (RE2's +`NtGdiDdDDIQueryStatistics` D3DKMT stub broke on wine 11.14+53 commits), dxvk / +vkd3d-proton churn, ~500k games. Debugging every combination yourself is a +multi-year treadmill. + +**Decision (architecture directive, approved):** no Proton ≤ 10; standardize on +the **11.x line / steamrt4 / pure-WoW64**; Valve's shipped artifacts + launch +semantics are the reference; SteamFlow = thin launcher. Wine/dxvk/vkd3d are +pinned, vendored dependencies with an upgrade button. + +## Phase 1 spike — what actually happened (2026-08-11) + +### Environment facts (this host) +- **No 32-bit host libs** (`/lib/i386-linux-gnu` exists but lacks libfreetype-32, + libXext-32, libgobject-32, libgnutls-32, libva-32). Only **pure new-WoW64** + wine builds can run anything here. This validates the directive's WoW64 + standard — and rules out every classic-wow64 prebuilt. +- Anonymous Steam depot downloads for Proton tools are **blocked by Valve** + ("missing license for depot"). Official Proton 11.0 (app **4628710**, depot + **4628711**, x86_64, 1.35 GiB) requires a **logged-in steamcmd session**: + `steamcmd +login +download_depot 4628710 4628711 +quit`. + +### Runner reality matrix (empirically tested) + +| Runner | Wine | wow64 | Windows Steam client | RE2 (883710) | Verdict | +|---|---|---|---|---|---| +| GE-Proton11-3 | 11.0 (Staging)+bleeding | classic (i386-unix) | ❌ `tier0_s64.dll` access violation | — (client blocked) | ✗ on this host | +| proton-cachyos 11.0-20260703 (vendored as `steamflow-proton-11.0`) | 11.0 (CachyOS) | classic (i386-unix) | ❌ `steamclient_init` AV + missing 32-bit freetype/libXext; no `lsteamclient.so` in x86_64-unix | ❌ crashed via `proton run` path (no display driver) | ✗ on this host | +| proton_tkg bleeding edge 11.0.405815 | 11.0.x | classic | untested (same class) | — | likely ✗ | +| **`steamflow-runner-wine11-wow64` (custom)** | 11.14.r4 TkG | **pure new-WoW64** (no i386-unix) | ✅ proven (client logs in) | known crash (master D3DKMT regression) | client-capable; RE2 broken | +| **`wine-tkg-staging-git-11.13.r6`** | 11.13 TkG | **pure new-WoW64** | ✅ verified 2026-08-11 (Logged On) | ✅ **RUNS — verified 2026-08-11** (vkd3d-proton rendering, 1.66 GB RSS, no crash) | **current stack** | + +### Key findings +1. **The Windows Steam client (32-bit) requires the wine builtin steamclient + shim to be suppressed on ANY runner kind** (`WINEDLLOVERRIDES` = + `vstdlib_s=n;tier0_s=n;steamclient=n;steamclient64=n;steam_api=n;steam_api64=n;lsteamclient=` + — the PlainWine path at `src/launch/mod.rs:164`). Proton-kind envs leave the + builtin active → `CLIENTENGINE_INTERFACE_VERSION005` not recognized / + `steamclient_init` access violation. **Code change candidate:** apply this + override set for the client process in the Proton-kind branch too (keep the + real `STEAM_COMPAT_CLIENT_INSTALL_PATH`). +2. **wine 11.0-class builds crash the client** (GE: `tier0_s64` AV; cachyos: + `steamclient_init` AV) — the client needs wine ≥ 11.13. GE's bleeding-edge + commits (post-20260703) are what broke cachyos's working line. +3. **RE2's D3DKMT crash is master-only (wine 11.14+53).** wine 11.13 predates + the `NtGdiDdDDIQueryStatistics` regression → RE2 runs clean (adapter table + populates, D3D12 command lists execute). This empirically confirms the + earlier investigation's fix: *pin to a settled wine tag*. +4. **Same-prefix = same wineserver = same runner.** Game on 11.13 + client on + 11.14 → `wine client error: version mismatch 958/957`. The + `effective_game_proton` doc-comment claims per-game freedom, but in Shared + prefix mode the game runner must equal the runtime runner (or get its own + prefix). The stale-wineserver guard spares Steam's server, so the mismatch + is not cleaned up automatically. **IMPLEMENTED 2026-08-12:** the + runner-mismatch guard `effective_prefix_mode` (wine_tkg.rs) now auto-falls + a Shared configuration back to PerGame when the Steam Runtime runner ≠ the + game runner (with a visible warning) — see + `phase3-pure-pe-proton11.md` "Post-Phase-3 follow-up". +5. **`proton run` via the script path does NOT SIGSYS anymore** (unlike the old + skill note) — but on this host the classic-wow64 proton scripts can't load + their display driver anyway (missing 32-bit host libs). + +### Phase 1 result +**PASS for RE2 (883710):** launches through the headless pipeline +(`steamflow test-launch 883710`), passes the Steam API ownership gate (client +logged in), initializes D3D12/vkd3d-proton, renders continuously (196% CPU, +1.66 GB RSS). The pre-spike failure (adapter-table null deref at +`0x141f543d6`) does not occur on wine 11.13. + +## Architecture + +### Tier 0 — pinned 11.x pure-WoW64 runner (Phase 1 done) +- **Current stack (config.json):** `steam_runtime_runner` + + `proton_version` + RE2 `forced_proton_version` = + `wine-tkg-staging-git-11.13.r6.g3604946c` (pure new-WoW64, wine 11.13). +- `skip_steam_self_update = true` (updater-rename guard, load-bearing for + Proton-kind; harmless for PlainWine). +- `steamflow-proton-11.0` (cachyos 11.0 copy) kept as a runner dir for + future experiments but is **not usable on this host** (classic-wow64). +- **Official Proton 11.0 stable** (app 4628710) remains the end-state target: + once fetched via a logged-in steamcmd, re-test client + RE2; if Valve's + wine ≥ 11.13-class it should work (possibly + the shim-suppression fix). + +### Tier 1 — launch-semantics parity (Phase 2) +1. **Env-parity harness:** capture SteamFlow's `effective_env.json` + + `PROTON_LOG=1` output vs native Steam's launch env for reference games; + diff → divergence list = SteamFlow bug list. (Headless: extend + `src/headless.rs` with a `test-diff ` subcommand.) + **DONE 2026-08-11** — `steamflow test-diff ` in `src/parity.rs`. + Native log auto-discovery: `~/steam-.log`, `~/Фото, видео/…`, + `~/Emulators/…`, shallow HOME scan; `--native-log`/`--session` overrides. + Session discovery: newest `logs//effective_env.json` whose + `SteamAppId` matches. Verified on RE2 (883710) — see §test-diff findings. +2. Per-game quirks as config (map `user_settings.sample.py` knobs → + `user_apps.json` env). Flag table changes per pin (e.g. `noesync` obsolete + in Proton 11). + **PIVOTED (user directive 2026-08-11):** replaced by the native Rust Proton + ABI (`src/runner/proton_abi.rs`) — a dynamic per-runner adapter on the + `Runner` trait that applies Proton's launch semantics in Rust (compat + config, env assembly, DLL overrides, prefix seeding) instead of static + per-game quirk maps. Per-game `proton_compat_options` (user_apps.json) + feeds the compat set; the ABI translates to `PROTON_*`/`WINE_*` env. + Eliminates external Python script invocation at game launch. +3. Surface runner versions into `VERSIONS.txt` (kill `found(bundled)`). + **DONE 2026-08-11 (tail commit)** — `utils::write_runner_versions_txt` + (harvest + `RUNNER_VERSION` stamp) at extraction; see session-close block. + +### Tier 2 — Valve-source build (only for custom deltas) +`git clone --recurse-submodules` + `configure.sh` + `make` (docker/podman, +Proton SDK images), `make module= module` fast loop, pins lockfile. +Blocked on this host until podman/docker exist (deferred). + +### Tier 3 — runtime parity (future) +pressure-vessel / steamrt4 adoption. `SteamLinuxRuntime_4` (appid 4183110) +present locally; 11.0-line tools (GE/cachyos) declare it in `toolmanifest.vdf`. + +## What SteamFlow keeps owning +Launcher, session management, library/ACF handling, config, the steamclient +bridge, RTX Remix modding layer, D7VK/D3D7 policy knobs, per-game fixups +(`seed_scripts/883710.rhai`), and (Phase 2) the env-parity harness. + +## Non-goals / decisions +- **No wine-master tracking.** A new tag = pin bump + conformance run. +- Do not copy Proton trees into `compatibilitytools.d` unless exact-pinning + (disk pressure: 29 GB free). +- `Proton - Experimental`, GE, cachyos stay **per-game only**, never default — + and on this host they're unusable anyway (classic-wow64). +- Official Proton 11.0 fetched (2026-08-11) via the steam-cdn pipeline with + the saved session + per-app PICS access token (owner-only tools need it; + see Phase 1b and src/headless.rs test_download_proton). On disk at + `steamapps/common/Proton 11.0` — unbootable on this host (classic-wow64, + no 32-bit host libs), kept as the vendored end-state artifact. + +## Phase 2 — `test-diff` findings (2026-08-11, RE2 883710) + +Run: `steamflow test-diff 883710` (native log auto-discovered at +`~/Фото, видео/steam-883710.log`; SteamFlow session = newest with +`SteamAppId=883710`). + +**Divergences found: 24 (2 missing, 22 extra, 0 mismatched, 0 matched).** + +- **MISSING (native-only) — the real parity gaps:** + - `PROTON_FORCE_LARGE_ADDRESS_AWARE=1` — native run used the `forcelgadd` + launch option (old log: `Options: {'forcelgadd', 'wined3d'}`); SteamFlow + never sets this. + - `PROTON_USE_WINED3D=1` — native run forced wined3d; SteamFlow uses + DXVK/vkd3d-proton instead (deliberate, but a divergence to track). +- **EXTRA (SteamFlow-only, 22)** — mostly expected: SteamFlow injects + `WINEDLLOVERRIDES`/`WINEDLLPATH`/`WINEPREFIX`, the steamclient shim-suppression + set, `STEAM_COMPAT_*` (client install path + compatdata), `__VK_LAYER_NV_optimus`/ + `__GLX_VENDOR_LIBRARY_NAME`/`__NV_PRIME_*` (NVIDIA offload), `VKD3D_DEBUG`, + `DXVK_ENABLE_NVAPI=0`, `WINEDEBUG`, `DISPLAY`, `XDG_RUNTIME_DIR`, Steam API ids. + The 2022-era GE-Proton log dumps no env vars at all (only the header block), + so the EXTRA bucket is inflated by log-format asymmetry — a fresh native + capture (Proton 9+/11 log with `Effective WINEDLLOVERRIDES:` lines) would + move most of these into MISMATCHED/MATCHED. +- **0 mismatched / 0 matched** — consequence of the same asymmetry (no native + env values to compare). + +**Caveat:** the only native RE2 log on this host is from 2022 (GE-Proton7-41, +`PROTON_LOG=1` bash-era format) — useful for header facts and the `Options:` +reverse-map, but not a full env capture. The harness handles both formats and +accepts `--native-log ` for a fresh capture. For a true apples-to-apples +env diff, launch RE2 from native Steam with `PROTON_LOG=1` (modern Proton +writes `Effective WINEDLLOVERRIDES`/`WINEDEBUG`), then re-run `test-diff`. + +**Follow-ups (Phase 2 items 2–3):** ~~map per-game quirks → `user_apps.json` +env~~ — **PIVOTED + DONE 2026-08-11** to the native Rust Proton ABI +(`src/runner/proton_abi.rs`, see Status header); item 3 — surface runner +versions into `VERSIONS.txt` (kill `found(bundled)`) — **DONE 2026-08-11 +(tail commit)** via `utils::write_runner_versions_txt` at extraction. + +## Revert / state +- Config backups: `config.json.bak-valve-phase1` (pre-directive), + plus the working config now pins wine-tkg 11.13. +- Steam client currently running under wine-tkg 11.13; RE2 verified live. +- Kill pattern pitfall: `pkill -f 'steam.exe'` matches your own shell — use + bracketed patterns (`steam[.]exe`) or explicit PIDs. + +## Phase 1b — Official Valve depot downloads (steam-cdn) — FIXED 2026-08-11 + +**Problem:** downloading official Valve Protons via the steam-cdn pipeline (UI +Install → `install_game`, or `steamflow test-download-proton`) failed on chunk +download with `decompress: invalid Zip archive: Could not find EOCD`. + +**Root cause (verified against SteamKit2 sources):** modern Valve depots +(Proton Experimental / 11.0, 2026) ship **Zstd-compressed chunks** (magic +`56 53 5A 61` = `"VSZa"`); the vendored `steam-cdn` crate only handled LZMA +(`"VZa"`) and fell back to ZIP. Decryption was correct all along (AES with +ECB-decrypted IV + CBC-PKCS7 — matches SteamKit2 `CryptoHelper.SymmetricDecrypt`; +the `"VSZa"` plaintext is the proof). The missing piece was decompression. + +**Fix (vendored crate, in-tree):** added `utils/zstd.rs` (VZstd format per +SteamKit2 `VZstdUtil`: `"VSZa"` + u32 crc32 + zstd frame + footer `[crc32 u32, +size u32, "zsv"` in the LAST 3 bytes]) + `zstd` dependency; +`depot_chunk::decrypt_and_decompress` now branches LZMA → VZstd → ZIP. +Verified: depot 4862111 (65 MB `amdxcffx64.dll`) downloads and decompresses to +exactly the manifest size (65,657,608 bytes, valid PE32+); full Proton - +Experimental (1493711, 1.45 GB) follows the same path. + +**Also fixed along the way:** +- **Tool installdir** (`resolve_install_game_info`): tools put `installdir` + under `appinfo.config`, not `common` (and `parse_appinfo` can fail entirely on + tool VDFs) → direct `find_vdf_in_pics` walk (common → config) → Proton + installs into `steamapps/common/Proton - Experimental` (same dir real Steam + uses; `resolve_runner` + `list_installed` pick it up), not `App 1493710`. +- **CDN auth token** (`get_cdn_auth_token`): `ContentServerDirectory. + GetCDNAuthToken` service variant returns ERESULT Fail; switched to the + job-based `CMsgClientGetCDNAuthToken` (EMsg 5546, same pattern as + `GetDepotDecryptionKey`). Server-side it still times out on this session, but + it's **non-fatal**: the CDN serves chunks tokenless (verified by curl); + SteamKit2 itself only requests a token after a 403. No token = no `?token=` + param = works. +- **Headless diagnostic:** `steamflow test-download-proton + [--manifest-only] [--depot ]` — stage-logged reproduction of + `install_game` (PICS appinfo → depot filter → content servers → depot key → + manifest code → CDN token → manifest → download). + +**State:** everything local, nothing pushed. The full download was running at +close of the spike; the vendored runner story continues from +"Phase 1 — current stack" above. + +--- + +## Session close — 2026-08-11 (state for resuming) + +**Worktree:** local only, NOT pushed (user's sequential-phase workflow; next +phase opens its own branch/PR off current `main`). `git status` new files: +`src/runner/` (proton_abi.rs + mod.rs), `src/parity.rs`, `src/headless.rs`, +`docs/architecture/valve-stack-replication.md`; modified: `src/lib.rs`, +`src/models.rs` (proton_compat_options), `src/infra/runners/wine_tkg.rs` +(ABI hook in build_env), `src/launch/mod.rs` (Proton shim-suppression + +steamclient override in Proton branch), `src/steam_client.rs`, `src/ui.rs`, +`src/main.rs`, `Cargo.lock`, `vendor/steam-cdn/*` (zstd + PICS access token). + +**Delivered this session:** +1. **Official Proton 11.0 (4628710) vendored** — fetched via steam-cdn with + the saved session + per-app PICS access-token fix (`test_download_proton` + in headless.rs). On disk at `steamapps/common/Proton 11.0` — **unbootable + on this host** (classic-wow64, no 32-bit host libs → `client_api.cpp:601` + assert). Verified `wine-11.0` runs; kept as the vendored end-state artifact. +2. **Proton shim-suppression fix** (`src/launch/mod.rs`) — the builtin + steamclient shim must be suppressed on ANY runner kind (Valve-stack + finding #1); applied the PlainWine override set to the Proton branch too, + keeping the real `STEAM_COMPAT_CLIENT_INSTALL_PATH`. +3. **Phase 2 item 1 — `test-diff` env-parity harness** (`src/parity.rs`): + native proton-log parser (legacy + modern headers, `Options:` → + `PROTON_*` reverse-map, `Effective WINEDLLOVERRIDES`/`WINEDEBUG`), session + discovery, MISSING/EXTRA/MISMATCHED/MATCHED diff with priority flags. + Initial run on 883710: 2 real gaps found. +4. **Phase 2 item 2 — native Rust Proton ABI** (`src/runner/proton_abi.rs`): + port of Valve's `proton` script + `default_pfx` semantics (compat config + table, env rules, base DLL overrides, `seed_prefix` prefix init) — no + Python at game launch. Wired into the Runner trait's `build_env`; per-game + `proton_compat_options` in `user_apps.json` (883710 → `["wined3d"]`). + **Verified: `test-diff 883710` now 0 missing / 2 matched — both real gaps + (PROTON_FORCE_LARGE_ADDRESS_AWARE, PROTON_USE_WINED3D) resolved.** + RE2 renders under the ABI env (89% CPU, 1.54 GB RSS). + +**Tests:** `cargo test --lib` = 89 passed (10 ABI + 3 parity + 76 prior). +Build clean (`cargo build`). + +**Config state:** `config.json` restored to the verified wine-tkg 11.13 stack +(backups: `config.json.bak-valve-phase1`, `config.json.bak-valve-phase1b`). +`user_apps.json` 883710 gained `proton_compat_options: ["wined3d"]`. +RE2 display mode is USER-CONTROLLED (windowed per user) — do not override. + +**Open items / next steps:** +- ~~Phase 2 item 3: surface runner versions into `VERSIONS.txt` (kill `found(bundled)`).~~ + **DONE 2026-08-11 (tail commit)** — `utils::write_runner_versions_txt` writes a + canonical `VERSIONS.txt` at the runner root during extraction (harvests the + tarball's component `version` files + stamps `RUNNER_VERSION` from the root + `version` file or the release tag). Hooked into `install_github_package`, + `install_game` (steam-cdn UI install), and headless `test_download_proton`. + Never overwrites an existing `VERSIONS.txt` (the custom + `steamflow-runner-wine11-wow64` ships an authoritative one). Fixes the + `found(bundled)` display for flat-layout Proton trees (e.g. official 11.0's + `files/lib/wine/x86_64-windows/*.dll` has no adjacent `version` files, so + detection's flat-layout rule returns `"found"` until the override fills it). +- ~~Optional: fresh native `PROTON_LOG=1` capture of RE2 (modern format) to + shrink the EXTRA bucket via real MATCHED/MISMATCHED comparison.~~ + **RE-RUN DONE 2026-08-11** — `steamflow test-diff 883710` against the newest + session: **0 missing / 2 matched / 28 extra / 0 mismatched** (was 2 missing, + 22 extra pre-ABI). The two real gaps (PROTON_FORCE_LARGE_ADDRESS_AWARE from + `forcelgadd`, PROTON_USE_WINED3D from `wined3d`) are now MATCHED; the EXTRA + bucket is the documented 2022-log format asymmetry (bash-era header dumps no + env values → inflated) + SteamFlow's own injections (shim suppression, + STEAM_COMPAT_*, NVIDIA offload, WINEDLLPATH). A truly fresh native capture + still requires launching RE2 from native Steam with `PROTON_LOG=1` — the only + native log on this host remains the 2022 GE-Proton7-41 one. +- ~~Official Proton 11.0 boot on this host requires i386 multilib packages + (user decision pending); keep wine-tkg 11.13 as the working stack.~~ + **CLOSED / REJECTED (user directive 2026-08-11): the host is pure 64-bit — + host `i386-multilib` packages are permanently rejected. Do NOT install, + suggest, or test them; never re-open this item.** Official Proton 11.0 + (classic-wow64, `i386-unix` present) therefore remains unbootable on this + host; wine-tkg 11.13 stays the working stack. The path forward for official + Proton is **Phase 3: a containerized pure-PE WoW64 build of Valve's + `proton_11.0` source** (Valve Proton SDK image) with zero host 32-bit ELF + library dependencies — see §Phase 3 below. +- ~~RE2 relaunch under the ABI env already done; `seed_prefix` (native prefix + init) is unit-tested but not yet exercised against a real Proton-tree + prefix — natural next integration point for Proton-kind runners.~~ + **DONE 2026-08-11 (tail commit)** — `seed_prefix` is now wired into + `prepare_prefix` (`src/infra/runners/wine_tkg.rs`): when a game's prefix is + fresh (no `system.reg`) and the runner ships `files/share/default_pfx` + (Proton-kind trees), SteamFlow seeds the prefix natively (copy tree + + dosdevices symlinks + version marker) with no external Python init scripts. + Non-fatal on failure (wine's own init takes over). The wine-tkg 11.13 stack + has no `default_pfx` (PlainWine) so the current stack is unaffected. + +## Phase 3 — containerized pure-PE WoW64 Proton build (CLOSED 2026-08-12) + +**Goal (ACHIEVED):** build Valve's `proton_11.0` source into a **pure PE +WoW64 runner** (zero host 32-bit ELF library dependencies) so official +Proton runs on this pure-64-bit host — resolving the i386-multilib rejection +above. No host `i386-multilib` was ever involved; all 32-bit needs are +satisfied by the container's toolchain and the runner's bundled PE DLLs. +Result: `compatibilitytools.d/steamflow-proton-11.0-purepe`, E2E-verified on +RE2 (883710). Full execution record with every gate result lives in +`docs/architecture/phase3-pure-pe-proton11.md`. + +**Prerequisite:** podman or docker on this host (none installed — Tier 2 was +deferred on this). Install podman first (pure-64-bit friendly, rootless). + +**Pipeline:** +1. **Base image:** `registry.gitlab.steamos.cloud/proton/steamrt4/sdk/x86_64` + — the official Valve Proton SDK for the **proton_11.0** line (the branch + pins `:4.0.20260331.220802-0` in `Makefile.in`; `soldier/sdk` is for older + Proton lines and was NOT used). Tag: the branch's own pin, verified + anonymously pullable. +2. **Source:** `git clone --recurse-submodules -b proton_11.0 + https://github.com/ValveSoftware/Proton.git` inside the container (or + volume-mounted from the host for incremental builds). +3. **Configure + build:** follow the repo's `README.md` (Valve-supported path): + `./configure.sh --proton-name "proton_11.0-wow64" --build-name + "steamflow-pure"` then `make` (or `make module= module` fast loop + for deltas: `wine`, `dxvk`, `vkd3d-proton`, `dxvk-nvapi`, `wine-mono`, + `wine-gecko`). The SDK image builds **both** PE halves of wine (WoW64) and + produces the `dist/` tree. +4. **Pure-PE verification gate (the point of Phase 3):** the built + `dist/` must contain **no `i386-unix` ELF loader** — i.e. no + `files/lib/wine/i386-unix/` dir. Presence of `files/lib/wine/i386-windows/` + (32-bit PE DLLs) is REQUIRED (WoW64 32-bit side); presence of + `i386-unix/` is the classic-wow64 failure mode. Host-side check: + `find dist -name '*.so' | grep i386` must be empty and `file + files/bin/wine` must say PE32+ (not ELF 32-bit). +5. **Runtime env on this host:** because the runner is pure PE WoW64, it needs + only the **64-bit** host GL/Vulkan/X libs (present) — no 32-bit host libs. + The `files/share/default_pfx` ships in `dist/` and `seed_prefix` + (Phase 2 tail) seeds new game prefixes natively. +6. **Install:** stage the built `dist/` as + `compatibilitytools.d/proton_11.0-wow64/` (or + `steamapps/common/Proton 11.0` replacement), chmod +x the wine binaries, + then re-run the client + RE2 conformance (shim-suppression env applies — + the 32-bit Steam client still needs it). +7. **Conformance checklist (reuse Phase 1 gates):** Windows Steam client boots + (client_api.cpp:601 must NOT appear), RE2 (883710) passes the ownership + gate + first-frame render, `test-diff 883710` parity holds, `VERSIONS.txt` + written at extraction (Phase 2 tail) shows real component versions. + +**Open questions (RESOLVED during execution):** SDK image tag — branch pin +`4.0.20260331.220802-0` (steamrt4, **not** soldier — proton_11.0 switched +lines; soldier is for older Proton); disk budget — tight (~14G), managed via +incremental cache cleanup + `-j8` (ccache not installed, needs sudo); +wine-mono/gecko — fetched in-container during the build (host networking +wrapper); podman rootless — solved with `uidmap` + `~/.local/bin/podman` +host-network wrapper + `image_copy_tmp_dir` on /home. diff --git a/src/headless.rs b/src/headless.rs new file mode 100644 index 00000000..f0cca466 --- /dev/null +++ b/src/headless.rs @@ -0,0 +1,539 @@ +//! Headless test-driving for SteamFlow (no GUI). +//! +//! Subcommands (run via `steamflow `): +//! test-steam Ensure the Windows Steam runtime is running +//! (installs it first if the prefix is missing). +//! test-launch Launch a game through the same pipeline the UI +//! "Play" button uses (spawn_game_process), then +//! keep the child attached and report its PID. +//! test-mod Launch a game's custom mod executable (Mods tab +//! "Play Mod" path, custom_exec_path) if configured. +//! list List installed games with appid + install path. +//! test-download-proton [--manifest-only] [--depot ] +//! Diagnose/download official Valve Proton depots via +//! steam-cdn. Prints each stage (PICS appinfo, depot +//! filtering, depot key, manifest code, CDN token, +//! manifest fetch) so failures pinpoint the break. +//! --manifest-only stops before the file download. +//! +//! Every launch prints `CHILD_PID=` on success so callers (scripts, the +//! agent) can monitor or kill the game process. + +use anyhow::{anyhow, bail, Context, Result}; +use std::sync::Arc; + +use steam_cdn::web_api::content_service::CDNServer; +use steam_cdn::CDNClient; +use steam_vent::proto::steammessages_clientserver_appinfo::{ + cmsg_client_picsproduct_info_request, CMsgClientPICSAccessTokenRequest, + CMsgClientPICSAccessTokenResponse, CMsgClientPICSProductInfoRequest, + CMsgClientPICSProductInfoResponse, +}; +use steam_vent::ConnectionTrait; + +use crate::models::DepotPlatform; +use crate::steam_client::{ + find_vdf_in_pics, parse_pics_product_info, sanitize_install_dir, should_keep_depot, +}; + +pub async fn run(args: &[String]) -> Result<()> { + let cmd = args.first().map(String::as_str).unwrap_or("help"); + match cmd { + "test-steam" => test_steam().await, + "test-launch" => { + let appid = parse_appid(args.get(1))?; + test_launch(appid, false).await + } + "test-mod" => { + let appid = parse_appid(args.get(1))?; + test_launch(appid, true).await + } + "list" => list_games().await, + "test-download-proton" => test_download_proton(args).await, + "test-diff" => crate::parity::test_diff(args).await, + "help" | "-h" | "--help" => { + print_help(); + Ok(()) + } + other => bail!("unknown subcommand: {other} (try `steamflow help`)"), + } +} + +fn parse_appid(arg: Option<&String>) -> Result { + let raw = arg.ok_or_else(|| anyhow!("missing appid argument"))?; + raw.parse::() + .with_context(|| format!("invalid appid: {raw}")) +} + +fn print_help() { + println!( + "SteamFlow headless test commands:\n \ + steamflow test-steam ensure Windows Steam runtime is running\n \ + steamflow test-launch launch game via the UI Play pipeline\n \ + steamflow test-mod launch custom mod executable (Play Mod)\n \ + steamflow list list installed games\n \ + steamflow test-diff env-parity: native proton log vs effective_env.json\n \ + steamflow help this help" + ); +} + +/// Ensure the Windows Steam runtime is up under the configured runner. +pub async fn test_steam() -> Result<()> { + crate::config::ensure_config_dirs().await?; + let launcher_config = crate::config::load_launcher_config().await.unwrap_or_default(); + + let steam_cfg = crate::utils::get_master_steam_config(); + if crate::steam_client::SteamClient::is_steam_running_in_prefix(&steam_cfg.wine_prefix) { + println!("Master Steam already running (prefix {})", steam_cfg.wine_prefix.display()); + return Ok(()); + } + + tracing::info!("Master Steam not running — starting it"); + crate::launch::install_master_steam(&launcher_config).await?; + println!("Master Steam launched"); + Ok(()) +} + +/// List installed games (appid, name, install path). +pub async fn list_games() -> Result<()> { + let installed = crate::library::scan_installed_app_info().await.unwrap_or_default(); + if installed.is_empty() { + println!("No installed games found"); + return Ok(()); + } + let mut entries: Vec<_> = installed.into_iter().collect(); + entries.sort_by_key(|(appid, _)| *appid); + for (appid, info) in entries { + println!( + "{appid}\t{}\t{}", + info.name.as_deref().unwrap_or("?"), + info.install_path.display() + ); + } + Ok(()) +} + +/// Headless game launch. +/// +/// * `use_mod_path` — launch via the game's `custom_exec_path` (Mods tab +/// "Play Mod" path) when configured; otherwise the full pipeline. +pub async fn test_launch(appid: u32, use_mod_path: bool) -> Result<()> { + crate::config::ensure_config_dirs().await?; + let launcher_config = crate::config::load_launcher_config().await.unwrap_or_default(); + let user_configs = crate::config::load_user_configs().await.unwrap_or_default(); + let user_config = user_configs.get(&appid).cloned(); + + // Locate the game in the library (needed for name/install path). + let installed = crate::library::scan_installed_app_info().await.unwrap_or_default(); + let game = installed + .get(&appid) + .map(|info| crate::models::LibraryGame { + app_id: appid, + name: info.name.clone().unwrap_or_else(|| format!("App {appid}")), + playtime_forever_minutes: None, + is_installed: true, + install_path: Some(info.install_path.to_string_lossy().to_string()), + local_manifest_ids: Default::default(), + update_available: false, + update_queued: false, + active_branch: info.active_branch.clone(), + }) + .ok_or_else(|| anyhow!("game {appid} not installed (no appmanifest found)"))?; + + // Play Mod path: custom_exec_path via launch_custom_exec. + if use_mod_path { + let exec_path = user_config + .as_ref() + .and_then(|c| c.custom_exec_path.clone()) + .filter(|p| !p.trim().is_empty()) + .ok_or_else(|| anyhow!("app {appid} has no custom_exec_path configured"))?; + tracing::info!(appid, exec = %exec_path, "Launching custom mod executable (headless)"); + let child = crate::launch::launch_custom_exec( + &launcher_config, + user_config.as_ref().context("user_config missing")?, + appid, + &game.name, + std::path::Path::new(&exec_path), + )?; + println!("CHILD_PID={}", child.id()); + wait_on(child); + return Ok(()); + } + + // Pipeline path (UI "Play" equivalent). + let mut client = crate::steam_client::SteamClient::new()?; + let saved = crate::config::load_session().await.unwrap_or_default(); + if saved.refresh_token.is_some() && saved.account_name.is_some() { + if let Err(e) = client.restore_session().await { + tracing::warn!("session restore failed: {e}; continuing with cached launch metadata"); + } + } + + let prefer_proton = true; + let options = client.get_product_info(appid, prefer_proton).await?; + let launch_info = options + .first() + .cloned() + .ok_or_else(|| anyhow!("no launch options for app {appid}"))?; + + let chosen_proton = match launch_info.target { + crate::steam_client::LaunchTarget::NativeLinux => None, + crate::steam_client::LaunchTarget::WindowsProton => { + Some(launcher_config.proton_version.as_str()) + } + }; + + tracing::info!(appid, target = ?launch_info.target, "Launching game (headless pipeline)"); + let child = client + .spawn_game_process(&game, &launch_info, chosen_proton, &launcher_config, user_config.as_ref()) + .await?; + println!("CHILD_PID={}", child.id()); + wait_on(child); + Ok(()) +} + +/// Block on the child so the headless process stays alive while the game runs. +/// (Mirrors the UI's `child.wait()` so a closed terminal doesn't matter.) +fn wait_on(mut child: std::process::Child) { + match child.wait() { + Ok(status) => tracing::info!("game process exited: {status}"), + Err(e) => tracing::warn!("failed waiting on game process: {e}"), + } +} + +/// Headless diagnostic + installer for official Valve Proton depots. +/// +/// Mirrors the UI's Install flow (ui.rs start_install -> SteamClient::install_game) +/// but logs every stage so a failure pinpoints the broken step: +/// stage 1: PICS appinfo fetch + VDF parse +/// stage 2: depot filtering (oslist/language/manifest lookup) +/// stage 3: content server list +/// stage 4: depot decryption key +/// stage 5: manifest request code +/// stage 6: per-host CDN auth token + manifest fetch +/// stage 7: full depot download (skipped with --manifest-only) +pub async fn test_download_proton(args: &[String]) -> Result<()> { + let target = args.get(1).ok_or_else(|| { + anyhow!("usage: test-download-proton [--manifest-only] [--depot ]") + })?; + let manifest_only = args.iter().any(|a| a == "--manifest-only"); + let filter_depot = args + .iter() + .position(|a| a == "--depot") + .and_then(|i| args.get(i + 1)) + .and_then(|s| s.parse::().ok()); + + let appid: u32 = match target.parse() { + Ok(id) => id, + Err(_) => crate::proton::VALVE_PROTONS + .iter() + .find(|(label, _)| { + crate::proton::normalize_name(label) == crate::proton::normalize_name(target) + }) + .map(|(_, id)| *id) + .ok_or_else(|| { + anyhow!( + "unknown proton '{target}' (use an appid or one of {:?})", + crate::proton::VALVE_PROTONS + .iter() + .map(|(l, _)| *l) + .collect::>() + ) + })?, + }; + + crate::config::ensure_config_dirs().await?; + let launcher_config = crate::config::load_launcher_config().await.unwrap_or_default(); + + let mut client = crate::steam_client::SteamClient::new()?; + let saved = crate::config::load_session().await.unwrap_or_default(); + if saved.refresh_token.is_some() && saved.account_name.is_some() { + if let Err(e) = client.restore_session().await { + tracing::warn!("session restore failed: {e}"); + } + } + let connection = client + .connection() + .cloned() + .context("no steam connection — is a session saved?")?; + + println!("== stage 1: PICS appinfo for appid {appid}"); + // Owner-only tool apps (official Valve Protons) return `public_only=1` + // with NO depots section unless the request carries the per-app access + // token. Mirror the vendored CDN's get_product_info: fetch the access + // token first (CMsgClientPICSAccessTokenRequest), then attach it. + let token_resp: CMsgClientPICSAccessTokenResponse = connection + .job(CMsgClientPICSAccessTokenRequest { + appids: vec![appid], + ..Default::default() + }) + .await + .context("PICS access-token request failed")?; + let app_token = token_resp + .app_access_tokens + .iter() + .find(|t| t.appid() == appid) + .and_then(|t| t.access_token.clone()); + println!( + " access token for {appid}: {}", + if app_token.is_some() { + "present" + } else { + "MISSING (request may still go anonymous)" + } + ); + let mut request = CMsgClientPICSProductInfoRequest::new(); + request + .apps + .push(cmsg_client_picsproduct_info_request::AppInfo { + appid: Some(appid), + access_token: app_token, + ..Default::default() + }); + let response: CMsgClientPICSProductInfoResponse = connection + .job(request) + .await + .context("PICS request failed")?; + let app = response + .apps + .iter() + .find(|entry| entry.appid() == appid) + .ok_or_else(|| anyhow!("missing appinfo payload for app {appid}"))?; + let appinfo_vdf_bytes = app.buffer().to_vec(); + let appinfo_vdf_text = String::from_utf8_lossy(&appinfo_vdf_bytes).to_string(); + println!( + " appinfo bytes: {} ({} chars VDF)", + appinfo_vdf_bytes.len(), + appinfo_vdf_text.len() + ); + // Print the common section (root key, name, installdir) — tool apps often + // differ from games here (this is where resolve_install_game_info looks). + let common_head: String = appinfo_vdf_text.chars().take(900).collect(); + println!(" --- appinfo head ---\n{common_head}"); + if let Some(depots_idx) = appinfo_vdf_text.find("depots") { + let slice = &appinfo_vdf_text[depots_idx..]; + let end = slice.find("\"appinfo\"").unwrap_or(slice.len()).min(2200); + println!(" --- depots section ---\n{}", &slice[..end]); + } + + let map = parse_pics_product_info(&appinfo_vdf_bytes) + .context("stage 1 FAILED: parse_pics_product_info")?; + println!(" parse_pics_product_info map: {map:?}"); + + println!("== stage 2: depot filtering (mirrors install_game)"); + let vdf = find_vdf_in_pics(&appinfo_vdf_bytes).context("stage 1 FAILED: find_vdf_in_pics")?; + let mut selections: Vec<(u32, u64)> = Vec::new(); + let depots_obj = vdf.as_obj().and_then(|root| { + if vdf.key() == "appinfo" || vdf.key() == appid.to_string() { + root.get("depots").and_then(|v| v.as_obj()) + } else { + root.get("depots") + .and_then(|v| v.as_obj()) + .or_else(|| { + root.get("appinfo") + .and_then(|v| v.as_obj()) + .and_then(|o| o.get("depots")) + .and_then(|v| v.as_obj()) + }) + } + }); + if let Some(depots) = depots_obj { + for (key, value) in depots.iter() { + if let Ok(d_id) = key.parse::() { + let oslist = value + .get_obj(&["config"]) + .and_then(|c| c.get("oslist")) + .and_then(|o| o.as_str()); + let lang = value + .get_obj(&["config"]) + .and_then(|c| c.get("language")) + .and_then(|l| l.as_str()); + let manifest_id = map.get(&(d_id as u64)).copied(); + let mut keep = should_keep_depot(oslist, DepotPlatform::Linux); + if keep { + if let Some(lang) = lang { + if lang != "english" && !lang.is_empty() { + keep = false; + } + } + } + if keep { + if let Some(fd) = filter_depot { + if fd != d_id { + keep = false; + } + } + } + println!( + " depot {d_id}: oslist={oslist:?} language={lang:?} manifest={manifest_id:?} -> {}", + if keep { "SELECTED" } else { "skipped" } + ); + if keep { + match manifest_id { + Some(m_id) => selections.push((d_id, m_id)), + None => println!( + " depot {d_id}: SELECTED but NO manifest id in parse_pics_product_info map" + ), + } + } + } + } + } + + if selections.is_empty() { + bail!("stage 2 FAILED: no depots selected — install_game would abort with \"No matching depots found for the selected platform.\""); + } + println!(" selections: {selections:?}"); + + println!("== stage 3: content servers (cell {})", connection.cell_id()); + let hosts = client + .get_content_servers(connection.cell_id()) + .await + .context("stage 3 FAILED: get_content_servers")?; + println!(" {} hosts: {}", hosts.len(), hosts.join(", ")); + + let (game_name, pics_installdir) = client.resolve_install_game_info(appid).await; + let installdir = pics_installdir.unwrap_or_else(|| sanitize_install_dir(&game_name)); + let library_root = launcher_config.steam_library_path.clone(); + let install_dir = std::path::Path::new(&library_root) + .join("steamapps") + .join("common") + .join(&installdir); + println!("== target install dir: {}", install_dir.display()); + + for (depot_id, manifest_id) in &selections { + println!("== stage 4: depot decryption key for {depot_id}"); + let key = match client.get_depot_key(appid, *depot_id).await { + Ok(k) => { + println!(" key OK ({} bytes)", k.len()); + if k.len() == 32 { + println!(" key hex: {}", hex::encode(&k)); + } + k + } + Err(e) => { + println!(" stage 4 FAILED: get_depot_key: {e}"); + continue; + } + }; + if key.len() != 32 { + println!(" stage 4 FAILED: depot key has unexpected size {} (expected 32)", key.len()); + continue; + } + let mut key_arr = [0u8; 32]; + key_arr.copy_from_slice(&key); + + println!("== stage 5: manifest request code"); + let manifest_code = match client + .get_manifest_request_code(appid, *depot_id, *manifest_id) + .await + { + Ok(code) => { + println!(" code OK: {code}"); + Some(code) + } + Err(e) => { + println!(" stage 5 FAILED: get_manifest_request_code: {e} (continuing without code)"); + None + } + }; + + for host in &hosts { + let (host_name, port) = if let Some(pos) = host.find(':') { + (&host[..pos], host[pos + 1..].parse::().unwrap_or(80)) + } else { + (host.as_str(), 80) + }; + println!("== stage 6: CDN auth token + manifest from {host}"); + let token = match client.get_cdn_auth_token(appid, *depot_id, host).await { + Ok(t) => { + println!(" token OK"); + Some(t) + } + Err(e) => { + println!(" stage 6 WARN: get_cdn_auth_token: {e:?}"); + None + } + }; + let cdn_server = CDNServer { + r#type: "CDN".to_string(), + https: port == 443, + host: host_name.to_string(), + vhost: host_name.to_string(), + port, + cell_id: connection.cell_id(), + load: 0, + weighted_load: 0, + auth_token: token, + }; + let cdn_client = CDNClient::with_server(Arc::new(connection.clone()), cdn_server); + + match cdn_client + .get_manifest(appid, *depot_id, *manifest_id, manifest_code, Some(key_arr)) + .await + { + Ok(manifest) => { + let total: u64 = manifest.files().iter().map(|f| f.size()).sum(); + println!( + " manifest OK: {} files, {total} bytes", + manifest.files().len() + ); + for f in manifest.files().iter().take(5) { + let c0 = f.chunks().first(); + println!(" {} ({} bytes, first chunk {})", f.full_path(), f.size(), c0.map(|c| c.id()).unwrap_or_default()); + } + if manifest_only { + println!( + "== MANIFEST-ONLY: stopping before download (would install to {})", + install_dir.display() + ); + return Ok(()); + } + + println!("== stage 7: download_depot -> {}", install_dir.display()); + std::fs::create_dir_all(&install_dir)?; + let on_progress: Arc = + Arc::new(|done: u64, total: u64| { + if total > 0 { + let pct = done * 100 / total; + if pct % 25 == 0 || done == total { + println!(" progress: {done}/{total} ({pct}%)"); + } + } + }); + match cdn_client + .download_depot( + appid, + *depot_id, + *manifest_id, + &key_arr, + &install_dir, + manifest_code, + false, + None, + None, + Some(on_progress), + None, + None, + ) + .await + { + Ok(()) => { + println!("== depot {depot_id} download COMPLETE"); + // Phase 2 item 3: surface the runner's version into + // VERSIONS.txt at the install root (harvests the + // depot's version files + stamps the installdir). + crate::utils::write_runner_versions_txt(&install_dir, &installdir); + } + Err(e) => println!(" stage 7 FAILED: download_depot: {e}"), + } + return Ok(()); + } + Err(e) => { + println!(" stage 6 FAILED: get_manifest from {host}: {e}"); + } + } + } + } + bail!("all hosts/depots failed — see stage output above") +} diff --git a/src/infra/runners/tests.rs b/src/infra/runners/tests.rs index 151d697d..7044fe41 100644 --- a/src/infra/runners/tests.rs +++ b/src/infra/runners/tests.rs @@ -180,4 +180,76 @@ mod tests { let env_enabled = runner.build_env(&ctx_enabled).await.unwrap(); assert_eq!(env_enabled.get("SteamAppId").unwrap(), "123"); } + + #[test] + fn test_shared_prefix_auto_falls_back_to_pergame_on_runner_mismatch() { + use crate::infra::runners::wine_tkg::effective_prefix_mode; + use crate::models::SteamPrefixMode; + + // Shared prefix + Steam Runtime runner ("wine-tkg") DIFFERENT from the + // game runner ("steamflow-proton-11.0-purepe") → must auto-fallback to + // PerGame so the two runners never share one WINEPREFIX (wineserver + // protocol collision). + let mut config = LauncherConfig::default(); + config.steam_prefix_mode = SteamPrefixMode::Shared; + config.steam_runtime_runner = PathBuf::from("wine-tkg"); + + let mut user_config = UserAppConfig::default(); + user_config.steam_prefix_mode = SteamPrefixMode::Shared; + + let mut ctx = mock_context(); + ctx.launcher_config = config; + ctx.user_config = Some(user_config); + ctx.proton_path = Some("steamflow-proton-11.0-purepe".to_string()); + + assert_eq!( + effective_prefix_mode(&ctx), + SteamPrefixMode::PerGame, + "Shared prefix with a Steam Runtime runner different from the game \ + runner must auto-fallback to PerGame" + ); + } + + #[test] + fn test_shared_prefix_stays_shared_when_runners_match() { + use crate::infra::runners::wine_tkg::effective_prefix_mode; + use crate::models::SteamPrefixMode; + + // Same runner on both sides → no mismatch → Shared is preserved. + let mut config = LauncherConfig::default(); + config.steam_prefix_mode = SteamPrefixMode::Shared; + config.steam_runtime_runner = PathBuf::from("steamflow-proton-11.0-purepe"); + + let mut user_config = UserAppConfig::default(); + user_config.steam_prefix_mode = SteamPrefixMode::Shared; + + let mut ctx = mock_context(); + ctx.launcher_config = config; + ctx.user_config = Some(user_config); + ctx.proton_path = Some("steamflow-proton-11.0-purepe".to_string()); + + assert_eq!( + effective_prefix_mode(&ctx), + SteamPrefixMode::Shared, + "Matching runners must keep the Shared prefix" + ); + } + + #[test] + fn test_shared_prefix_stays_shared_without_runtime_runner() { + use crate::infra::runners::wine_tkg::effective_prefix_mode; + use crate::models::SteamPrefixMode; + + // No Steam Runtime runner configured (launcher default = empty) → + // nothing can collide in the prefix → Shared is preserved even though + // the game runner differs. + let mut user_config = UserAppConfig::default(); + user_config.steam_prefix_mode = SteamPrefixMode::Shared; + + let mut ctx = mock_context(); + ctx.user_config = Some(user_config); + ctx.proton_path = Some("steamflow-proton-11.0-purepe".to_string()); + + assert_eq!(effective_prefix_mode(&ctx), SteamPrefixMode::Shared); + } } diff --git a/src/infra/runners/wine_tkg.rs b/src/infra/runners/wine_tkg.rs index fb180f7d..6ebb612b 100644 --- a/src/infra/runners/wine_tkg.rs +++ b/src/infra/runners/wine_tkg.rs @@ -28,6 +28,67 @@ fn effective_game_proton(ctx: &LaunchContext) -> String { ).to_string() } +/// Compare two runner paths for equality, tolerating symlinked installs and +/// paths that do not exist yet (resolve_runner falls back to the raw name when +/// a runner is not installed). +fn runner_paths_equal(a: &Path, b: &Path) -> bool { + let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); + canon(a) == canon(b) +} + +/// Pure decision core of the runner-mismatch guard (testable without a +/// `LaunchContext`): when the configured prefix mode is `Shared` but the Steam +/// Runtime runner differs from the game runner, return `PerGame`. +/// +/// A Shared prefix cannot host two different Wine/Proton runners at the same +/// time: each wine build speaks its own `wineserver` protocol, so mixing them +/// in one WINEPREFIX fails with +/// "wine client error: version mismatch ... your wine binary was not upgraded +/// correctly". Falling back to PerGame gives each runner its own prefix. +pub(crate) fn effective_prefix_mode_impl( + configured: crate::models::SteamPrefixMode, + steam_runtime_runner: &Path, + game_runner_name: &str, + library_root: &Path, +) -> crate::models::SteamPrefixMode { + if configured != crate::models::SteamPrefixMode::Shared { + return configured; + } + let steam_runner = steam_runtime_runner.to_string_lossy(); + if steam_runner.is_empty() { + // No Steam Runtime runner configured — nothing can collide in the prefix. + return configured; + } + let steam_runner_path = crate::utils::resolve_runner(&steam_runner, library_root); + let game_runner_path = crate::utils::resolve_runner(game_runner_name, library_root); + if runner_paths_equal(&steam_runner_path, &game_runner_path) { + return configured; + } + tracing::warn!( + "[SteamFlow] Runner mismatch detected (Steam Runtime: \"{}\", Game: \"{}\"). Automatically switching to PerGame prefix mode to prevent wineserver protocol collision.", + steam_runner_path.display(), + game_runner_path.display() + ); + crate::models::SteamPrefixMode::PerGame +} + +/// Resolve the EFFECTIVE Steam prefix mode for a launch: the user-configured +/// mode (per-game user config → global launcher default), auto-fallbacked to +/// `PerGame` when a `Shared` prefix would host two different Wine/Proton +/// runners (see `effective_prefix_mode_impl`). +pub(crate) fn effective_prefix_mode(ctx: &LaunchContext) -> crate::models::SteamPrefixMode { + let configured = ctx.user_config.as_ref() + .map(|c| c.steam_prefix_mode.clone()) + .unwrap_or(ctx.launcher_config.steam_prefix_mode.clone()); + let game_runner = effective_game_proton(ctx); + effective_prefix_mode_impl( + configured, + &ctx.launcher_config.steam_runtime_runner, + &game_runner, + Path::new(&ctx.launcher_config.steam_library_path), + ) +} + #[async_trait::async_trait] impl Runner for WineTkgRunner { fn name(&self) -> &str { "Wine-TKG" } @@ -51,9 +112,11 @@ impl Runner for WineTkgRunner { } } }; - let steam_prefix_mode = ctx.user_config.as_ref() - .map(|c| c.steam_prefix_mode.clone()) - .unwrap_or(ctx.launcher_config.steam_prefix_mode.clone()); + // Effective prefix mode: the configured mode, auto-fallbacked from + // Shared to PerGame when the Steam Runtime runner and the game runner + // differ (two wineservers with different protocols cannot share one + // WINEPREFIX). See `effective_prefix_mode_impl`. + let steam_prefix_mode = effective_prefix_mode(ctx); let user_config_store: crate::models::UserConfigStore = ctx.user_config.as_ref().map(|c| { let mut store = HashMap::new(); @@ -64,15 +127,91 @@ impl Runner for WineTkgRunner { let effective_game_prefix = crate::utils::steam_wineprefix_for_game( &ctx.launcher_config, ctx.app.app_id, - &user_config_store + &user_config_store, + Some(steam_prefix_mode.clone()), ); std::fs::create_dir_all(&effective_game_prefix) .map_err(|e| LaunchError::new(LaunchErrorKind::Permission, format!("failed creating {}", effective_game_prefix.display())).with_source(anyhow!(e)))?; + // === Native prefix seeding (Phase 2 tail, valve-stack directive) === + // Port of Proton's `default_pfx.py`/`CompatData.setup_prefix`: when the + // game's prefix is fresh (no `system.reg` yet) and the runner is a + // Proton-kind tree shipping `files/share/default_pfx`, seed the prefix + // directly from it (copy tree + dosdevices symlinks + version marker) + // WITHOUT invoking external Python init scripts. Non-fatal: a seeding + // failure logs and the launch proceeds (wine would create an empty + // prefix anyway); the version marker comes from the runner's `version` + // file (e.g. `proton-11.0-1b`) or the runner dir name. + if !effective_game_prefix.join("system.reg").exists() { + let runner_root = crate::utils::derive_runner_root(&active_runner); + let default_pfx_candidates = [ + "files/share/default_pfx", + "share/default_pfx", + "dist/share/default_pfx", + ]; + let default_pfx = default_pfx_candidates + .iter() + .map(|p| runner_root.join(p)) + .find(|p| p.is_dir()); + if let Some(default_pfx_dir) = default_pfx { + let proton_version = std::fs::read_to_string(runner_root.join("version")) + .ok() + .map(|s| crate::utils::parse_short_version(&s)) + .filter(|v| v != "unknown" && !v.is_empty()) + .unwrap_or_else(|| { + active_runner + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()) + }); + match crate::runner::proton_abi::seed_prefix( + &default_pfx_dir, + &effective_game_prefix, + &proton_version, + ) { + Ok(created) => tracing::info!( + "Native prefix seeding: seeded {} from {} ({} paths, version {})", + effective_game_prefix.display(), + default_pfx_dir.display(), + created.len(), + proton_version + ), + Err(e) => tracing::warn!( + "Native prefix seeding failed for {} (continuing with wine's own init): {e}", + effective_game_prefix.display() + ), + } + } else { + tracing::debug!( + "No default_pfx in runner {} — skipping native prefix seeding", + runner_root.display() + ); + } + } + tracing::info!("Effective game prefix: {}", effective_game_prefix.display()); tracing::info!("Shared steam compatibility data enabled: {}", ctx.launcher_config.use_shared_compat_data); tracing::info!("Steam Runtime Prefix Mode: {:?}", steam_prefix_mode); + // Self-healing: a prefix seeded by an older runner keeps absolute + // symlinks into that runner's lib/wine tree. If the runner dir was + // renamed/removed, every builtin DLL link dangles and ANY wine fails + // with `could not load kernel32.dll` (exit 53) before Steam even + // spawns. Re-point dangling links at the active runner's files (or + // drop links it doesn't ship). Cheap on healthy prefixes (no-op scan). + let active_root = crate::utils::derive_runner_root(&active_runner); + match crate::utils::repair_dangling_prefix_symlinks(&effective_game_prefix, &active_root) { + Ok((repointed, removed)) if repointed > 0 || removed > 0 => tracing::warn!( + "Prefix self-heal: re-pointed {repointed} dangling DLL symlink(s), removed {removed} (runner {} owns the prefix now)", + active_root.display() + ), + Ok(_) => {} + Err(e) => tracing::warn!( + "Prefix self-heal scan failed for {}: {e}", + effective_game_prefix.display() + ), + } + if use_steam_runtime { let steam_cfg = crate::utils::get_master_steam_config(); tracing::info!("Unified Master Steam resolution (Game Launch):"); @@ -101,6 +240,17 @@ impl Runner for WineTkgRunner { (master_steam_dir.clone(), steam_cfg.wine_prefix.clone()) } crate::models::SteamPrefixMode::PerGame => { + // The client must live in the SAME prefix (and thus + // same wineserver) as the game: Steam's client API + // (steamclient.dll → named pipe) is wineserver-scoped. + // A client parked in the master prefix is unreachable + // from a per-game-prefix game — SteamAPI_Init fails, + // games self-exit ~3s after the window (RE2) or show + // "Steam must be running" (Portal 2, 2026-08-13). + // The per-game prefix therefore hosts BOTH the client + // process and the game. It receives a full client-file + // deployment below (refresh-on-stale) so steam.exe can + // boot from it under the game's runner. let target_steam_dir = effective_game_prefix .join("drive_c/Program Files (x86)/Steam"); @@ -123,18 +273,36 @@ impl Runner for WineTkgRunner { for file in required_files { let src = master_steam_dir.join(file); let dst = target_steam_dir.join(file); - if src.exists() && !dst.exists() { - #[cfg(unix)] - { - if let Err(e) = std::os::unix::fs::symlink(&src, &dst) { - tracing::warn!("Symlink failed for {}, falling back to copy: {}", file, e); + if src.exists() { + let needs_refresh = match std::fs::symlink_metadata(&dst) { + // Symlink to the master file → up to date by construction. + Ok(m) if m.file_type().is_symlink() => false, + // Real file: refresh when it differs from master (stale + // client copies self-exit with code 1 on launch — e.g. the + // old steam.exe that predates the current client build). + Ok(_) => { + let same = std::fs::read(&src) + .and_then(|a| std::fs::read(&dst).map(|b| a == b)) + .unwrap_or(false); + !same + } + Err(_) => true, // missing + }; + if needs_refresh { + tracing::info!("Refreshing stale Steam runtime file {} from master", file); + let _ = std::fs::remove_file(&dst); + #[cfg(unix)] + { + if let Err(e) = std::os::unix::fs::symlink(&src, &dst) { + tracing::warn!("Symlink failed for {}, falling back to copy: {}", file, e); + let _ = std::fs::copy(&src, &dst); + } + } + #[cfg(not(unix))] + { let _ = std::fs::copy(&src, &dst); } } - #[cfg(not(unix))] - { - let _ = std::fs::copy(&src, &dst); - } } } @@ -158,16 +326,22 @@ impl Runner for WineTkgRunner { } } - (target_steam_dir, effective_game_prefix.clone()) - } - }; + // Client process + readiness gate target the PER-GAME + // prefix — the same wineserver the game runs in. + // (This is the 8a56ed2 layout restored after b5f5c0a + // split the client into the master prefix and broke + // Steam API reachability for every per-game-prefix + // launch.) + (target_steam_dir, effective_game_prefix.clone()) + } + }; tracing::debug!("Runtime Steam dir : {}", prefix_steam_dir.display()); tracing::debug!("Runtime WINEPREFIX : {}", steam_wineprefix.display()); if !matches!(crate::utils::classify_runner(&active_runner), crate::utils::RunnerKind::Unknown) { if let Some(active_wine) = - crate::utils::detect_wineserver_for_runner(&steam_wineprefix, &active_runner) + crate::utils::detect_wineserver_for_runner(&effective_game_prefix, &active_runner) { let active_root = crate::utils::derive_runner_root(&active_wine); let runner_root = crate::utils::derive_runner_root(&active_runner); @@ -178,9 +352,9 @@ impl Runner for WineTkgRunner { if active_canonical != runner_canonical { tracing::warn!( "Stale wineserver (different runner {:?}) detected in prefix {}. Terminating it before launch.", - active_canonical, steam_wineprefix.display() + active_canonical, effective_game_prefix.display() ); - crate::utils::kill_wineserver_in_prefix(&steam_wineprefix); + crate::utils::kill_wineserver_in_prefix(&effective_game_prefix); std::thread::sleep(std::time::Duration::from_millis(500)); } } @@ -237,7 +411,66 @@ impl Runner for WineTkgRunner { // pass (after readiness gate) will handle newly spawned // helpers to ensure user-disabled features are enforced. } else { - let steam_runner = if !ctx.launcher_config.steam_runtime_runner.as_os_str().is_empty() { + // Session-state sync (PerGame only): seed the per-game + // client with the master client's authentication before + // spawning it — loginusers.vdf, the config.vdf + // RememberedMachineID machine token, ssfn* sentries and + // the HKCU\Software\Valve\Steam login keys. A prefix + // seeded months ago carries an EXPIRED machine token, so + // the headless client starts anonymous (SteamID 0) and + // SteamAPI_Init fails with "Steam is not running" even + // though the client process is up. No-op when the master + // client has no session or the target is already as + // fresh; non-fatal on error. In Shared mode the target + // IS the master prefix — nothing to sync. + if steam_prefix_mode == crate::models::SteamPrefixMode::PerGame { + match SteamClient::sync_master_session_to_prefix( + &master_steam_dir, + &steam_cfg.wine_prefix, + &prefix_steam_dir, + &steam_wineprefix, + ) { + Ok(n) => { + if n > 0 { + tracing::info!( + "Steam session sync: {n} item(s) synchronized from master into per-game prefix" + ); + unsafe { + if !ctx.verification_ptr.is_null() { + (*ctx.verification_ptr).steam_runtime_milestone = + "steam_session_synced".to_string(); + } + } + } + } + Err(e) => tracing::warn!( + "Steam session sync failed (non-fatal, launch proceeds): {e}" + ), + } + } + + // The background Steam client runs in the SAME prefix + // as the game (PerGame: per-game prefix seeded by the + // game's runner; Shared: master prefix owned by the + // runtime runner). It must therefore be spawned with + // the runner that owns that prefix: + // - PerGame mode → the GAME's runner (pure-PE family + // that seeded the prefix). This is the 8a56ed2 layout: + // client + game in one wineserver, so the game's + // steamclient.dll can reach the client's named pipe. + // (b5f5c0a moved the client to the master prefix + // under wine-tkg, splitting the wineservers; every + // Steam API init then failed — RE2 self-exit, Portal 2 + // "Steam must be running".) + // - Shared mode → the configured Steam Runtime runner + // (it owns the master prefix). + let steam_runner = if steam_prefix_mode == crate::models::SteamPrefixMode::PerGame { + tracing::info!( + "PerGame mode: using the game's runner ({}) for background Steam (same prefix/wineserver as the game)", + active_runner.display() + ); + active_runner.clone() + } else if !ctx.launcher_config.steam_runtime_runner.as_os_str().is_empty() { ctx.launcher_config.steam_runtime_runner.clone() } else { let discovered = crate::utils::resolve_runner("wine-tkg", &library_root); @@ -452,6 +685,7 @@ impl Runner for WineTkgRunner { &ctx.launcher_config, ctx.app.app_id, &user_config_store, + Some(steam_prefix_mode.clone()), ); let slc = ctx.user_config.as_ref() .map(|c| c.steam_launch_config.clone()) @@ -531,6 +765,10 @@ impl Runner for WineTkgRunner { .join("compatdata") .join(&app_id_str); + // Effective prefix mode (runner-mismatch guard), used for the game's + // WINEPREFIX below AND the background-Steam spawn decision. + let steam_prefix_mode = effective_prefix_mode(ctx); + let user_config_store: crate::models::UserConfigStore = ctx.user_config.as_ref().map(|c| { let mut store = HashMap::new(); store.insert(ctx.app.app_id, c.clone()); @@ -540,7 +778,8 @@ impl Runner for WineTkgRunner { let effective_game_prefix = crate::utils::steam_wineprefix_for_game( &ctx.launcher_config, ctx.app.app_id, - &user_config_store + &user_config_store, + Some(steam_prefix_mode.clone()), ); // === Pre-launch Steam API readiness check === @@ -989,17 +1228,71 @@ impl Runner for WineTkgRunner { } tracing::info!("Final WINEDLLOVERRIDES: {}", dll_overrides); - env.insert("WINEDLLOVERRIDES".to_string(), dll_overrides); + env.insert("WINEDLLOVERRIDES".to_string(), dll_overrides.clone()); + + // === Native Rust Proton ABI (Phase 2 item 2, valve-stack directive) === + // Port of Valve's `proton` script launch semantics, applied WITHOUT + // invoking Python. Computes the per-app compat-option set + // (default_compat_config + forcelgadd default + per-game + // proton_compat_options), then merges Proton's env rules and base + // DLL overrides into the env SteamFlow already assembled. + // + // This resolves the two real env gaps the test-diff harness found on + // RE2 (883710): PROTON_FORCE_LARGE_ADDRESS_AWARE (forcelgadd default) + // and the wined3d option were set by native Steam but never emitted + // by SteamFlow. See docs/architecture/valve-stack-replication.md. + { + let mut compat = crate::runner::proton_abi::default_compat_config(ctx.app.app_id); + if let Some(user_config) = &ctx.user_config { + for opt in &user_config.proton_compat_options { + compat.insert(opt.clone()); + } + } + crate::runner::proton_abi::apply_forcelgadd_default(&mut compat); + + // Merge Proton's env rules (WINE_LARGE_ADDRESS_AWARE, WINE_HEAP_*, + // DXVK_ENABLE_NVAPI, WINE_MONO_HIDETYPES, __GLVND_DISALLOW_PATCHING, + // PROTON_USE_XALIA, …). SteamFlow's existing env values win. + // + // Order-preserving merge: WINEDLLOVERRIDES is order-sensitive for + // per-DLL settings (dll=setting pairs), so keep SteamFlow's + // original sequence and append Proton-only entries at the end. + let mut proton_dll_overrides: Vec<(String, String)> = Vec::new(); + for seg in dll_overrides.split(';').filter(|s| !s.trim().is_empty()) { + if let Some((dll, setting)) = seg.split_once('=') { + let dll = dll.trim().to_string(); + let setting = setting.trim().to_string(); + // Last occurrence wins (SteamFlow sometimes emits a DLL + // twice); drop earlier duplicates. + if let Some(prev) = proton_dll_overrides.iter_mut().find(|(d, _)| *d == dll) { + prev.1 = setting; + } else { + proton_dll_overrides.push((dll, setting)); + } + } + } + crate::runner::proton_abi::apply_proton_env_rules( + ctx.app.app_id, + &compat, + &mut env, + &mut proton_dll_overrides, + ); + let merged = crate::runner::proton_abi::serialize_dll_overrides(&proton_dll_overrides); + env.insert("WINEDLLOVERRIDES".to_string(), merged.clone()); + tracing::info!( + "Proton ABI: compat={:?} → WINEDLLOVERRIDES={}", + compat, + merged + ); + } if let Some(fixup) = &ctx.fixup_result { for (key, value) in &fixup.extra_env { env.insert(key.clone(), value.clone()); } } - let steam_prefix_mode = ctx.user_config.as_ref() - .map(|c| c.steam_prefix_mode.clone()) - .unwrap_or(ctx.launcher_config.steam_prefix_mode.clone()); - + // NOTE: `steam_prefix_mode` is the EFFECTIVE mode, resolved at the top + // of this fn via `effective_prefix_mode(ctx)` (runner-mismatch guard). if steam_prefix_mode == crate::models::SteamPrefixMode::Shared && SteamClient::is_steam_running_in_prefix(&effective_game_prefix) { let msg = "Shared prefix mode: Steam is already running in this prefix. Launching a second game with a different runner will crash. Consider switching to per-game prefix mode in Settings."; tracing::warn!("{}", msg); diff --git a/src/launch/mod.rs b/src/launch/mod.rs index abd6f734..1f8e54ef 100644 --- a/src/launch/mod.rs +++ b/src/launch/mod.rs @@ -155,6 +155,19 @@ pub async fn install_master_steam(config: &LauncherConfig) -> Result<()> { "Proton (bare wine) Steam launch: STEAM_COMPAT_CLIENT_INSTALL_PATH={} (real client)", client_win ); + // Valve-stack directive (docs/architecture/valve-stack-replication.md §Key + // findings 1): the 32-bit Windows Steam client needs the wine builtin + // steamclient shim suppressed on ANY runner kind — Proton-kind envs that + // leave it active crash with `create_win_interface Don't recognize + // interface name: CLIENTENGINE_INTERFACE_VERSION005` (assert + // client_api.cpp:601) or `steamclient_init` access violation. Same + // override set as the PlainWine path, while keeping the REAL + // STEAM_COMPAT_CLIENT_INSTALL_PATH above (games still see a live + // Windows Steam via the real client dir). + cmd.env( + "WINEDLLOVERRIDES", + "vstdlib_s=n;tier0_s=n;steamclient=n;steamclient64=n;steam_api=n;steam_api64=n;lsteamclient=", + ); } else { // Plain wine-tkg: the original working hack. The fake_env trap (dummy // steam/steam.sh) plus steamclient/steam_api=n keeps Wine-Steam from hijacking @@ -300,7 +313,7 @@ pub fn launch_custom_exec( game_app_id: u32, game_name: &str, exec_path: &Path, -) -> Result<()> { +) -> Result { if !exec_path.exists() { bail!("Custom executable not found: {}", exec_path.display()); } @@ -309,7 +322,10 @@ pub fn launch_custom_exec( crate::models::UserConfigStore::new(); user_config_store.insert(game_app_id, user_config.clone()); - let prefix = crate::utils::steam_wineprefix_for_game(config, game_app_id, &user_config_store); + // Custom-exec (Mods tab "Play Mod") keeps the CONFIGURED prefix mode; the + // standard pipeline applies the runner-mismatch guard (see + // wine_tkg::effective_prefix_mode). + let prefix = crate::utils::steam_wineprefix_for_game(config, game_app_id, &user_config_store, None); std::fs::create_dir_all(&prefix) .with_context(|| format!("failed creating Wine prefix {}", prefix.display()))?; @@ -405,8 +421,7 @@ pub fn launch_custom_exec( cmd.spawn().context("Failed to spawn custom mod executable") }; - spawn_result?; - Ok(()) + spawn_result } fn is_executable(path: &Path) -> bool { diff --git a/src/launch/pipeline.rs b/src/launch/pipeline.rs index ecde8276..bf0e278c 100644 --- a/src/launch/pipeline.rs +++ b/src/launch/pipeline.rs @@ -356,10 +356,11 @@ impl LaunchPipeline { async fn verify_launch_health(&self, ctx: &mut PipelineContext) { if let Some(child) = &mut ctx.child { let start_wait = std::time::Instant::now(); - let verify_duration = std::time::Duration::from_millis(2000); - // Initial wait - tokio::time::sleep(verify_duration).await; + // Phase 1 — fast-fail window (2s): catches instant crashes (bad DLL + // load, missing exe, immediate Steam client death) without delaying + // healthy launches. + tokio::time::sleep(std::time::Duration::from_millis(2000)).await; match child.try_wait() { Ok(Some(status)) => { @@ -367,16 +368,54 @@ impl LaunchPipeline { ctx.verification.status = "failed_after_spawn".to_string(); ctx.verification.process_lifetime_ms = Some(start_wait.elapsed().as_millis() as u64); ctx.verification.exit_code = status.code(); + return; } Ok(None) => { - // Process still running - ctx.verification.status = "verified".to_string(); - ctx.verification.process_lifetime_ms = Some(start_wait.elapsed().as_millis() as u64); + // Still alive at 2s — but a graceful self-exit can happen + // 3-5s AFTER the game window appears (e.g. SteamAPI_Init + // failing against an unreachable client: RE2 self-exits ~3s + // after the window, Portal 2 shows "Steam must be running"). + // A single 2s alive-check records those as "Success", so + // Phase 2 requires the process to survive a sustained + // window before we trust it. } Err(e) => { ctx.verification.status = "uncertain".to_string(); if let Some(logger) = &ctx.logger { - let _ = logger.error("verification_error", format!("Failed to poll process status: {}", e), None, HashMap::new()); + let _ = logger.error("verification_error", format!("Failed to poll process status: {}", e), None, HashMap::new()); + } + return; + } + } + + // Phase 2 — sustained-liveness window (up to 8s total, polled every + // 500ms): any exit inside this window is a post-spawn failure (the + // game came up, then decided to quit — failed Steam handshake, + // renderer init failure the game handles by exiting, etc.). Only a + // process still alive after the full window is "verified". + let sustained_deadline = start_wait + std::time::Duration::from_millis(8000); + loop { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + match child.try_wait() { + Ok(Some(status)) => { + ctx.verification.status = "failed_after_spawn".to_string(); + ctx.verification.process_lifetime_ms = Some(start_wait.elapsed().as_millis() as u64); + ctx.verification.exit_code = status.code(); + return; + } + Ok(None) => { + if std::time::Instant::now() >= sustained_deadline { + ctx.verification.status = "verified".to_string(); + ctx.verification.process_lifetime_ms = Some(start_wait.elapsed().as_millis() as u64); + return; + } + } + Err(e) => { + ctx.verification.status = "uncertain".to_string(); + if let Some(logger) = &ctx.logger { + let _ = logger.error("verification_error", format!("Failed to poll process status: {}", e), None, HashMap::new()); + } + return; } } } diff --git a/src/launch/stages/prepare_prefix.rs b/src/launch/stages/prepare_prefix.rs index 3e0835f7..0f6d0b2b 100644 --- a/src/launch/stages/prepare_prefix.rs +++ b/src/launch/stages/prepare_prefix.rs @@ -29,7 +29,11 @@ impl PipelineStage for PreparePrefixStage { }; runner.prepare_prefix(&runner_ctx).await?; - // Post-runner prefix preparation: handle symlinks + // Post-runner prefix preparation: handle symlinks. Must use the + // EFFECTIVE prefix mode (the runner-mismatch guard may have fallen + // back from Shared to PerGame) so the symlinks land in the same + // prefix the launch actually uses. + let effective_mode = crate::infra::runners::wine_tkg::effective_prefix_mode(&runner_ctx); let prefix_path = crate::utils::steam_wineprefix_for_game( &runner_ctx.launcher_config, runner_ctx.app.app_id, @@ -37,7 +41,8 @@ impl PipelineStage for PreparePrefixStage { let mut store = std::collections::HashMap::new(); store.insert(runner_ctx.app.app_id, c.clone()); store - }).unwrap_or_default().into() + }).unwrap_or_default().into(), + Some(effective_mode), ); if use_symlinks { diff --git a/src/launch/stages/resolve_dll_providers.rs b/src/launch/stages/resolve_dll_providers.rs index 4f02c1e3..91a1cfab 100644 --- a/src/launch/stages/resolve_dll_providers.rs +++ b/src/launch/stages/resolve_dll_providers.rs @@ -45,8 +45,18 @@ impl PipelineStage for ResolveDllProvidersStage { let library_root = PathBuf::from(&launcher_config.steam_library_path); let resolved_runner = crate::utils::resolve_runner(proton_path, &library_root); - // Resolve WINEPREFIX for component detection + // Resolve WINEPREFIX for component detection (uses the EFFECTIVE prefix + // mode so detection matches the prefix the launch will actually use). let wineprefix = if let (Some(config), Some(app)) = (&ctx.launcher_config, &ctx.app) { + let configured_mode = ctx.user_config.as_ref() + .map(|c| c.steam_prefix_mode.clone()) + .unwrap_or(config.steam_prefix_mode.clone()); + let effective_mode = crate::infra::runners::wine_tkg::effective_prefix_mode_impl( + configured_mode, + &config.steam_runtime_runner, + proton_path, + &library_root, + ); Some(crate::utils::steam_wineprefix_for_game( config, app.app_id, @@ -55,7 +65,8 @@ impl PipelineStage for ResolveDllProvidersStage { let mut store = std::collections::HashMap::new(); store.insert(app.app_id, ctx.user_config.clone().unwrap()); store - }).unwrap_or_default().into() + }).unwrap_or_default().into(), + Some(effective_mode), )) } else { None diff --git a/src/launch/stages/resolve_game_fixups.rs b/src/launch/stages/resolve_game_fixups.rs index abc604f2..42c1e8f2 100644 --- a/src/launch/stages/resolve_game_fixups.rs +++ b/src/launch/stages/resolve_game_fixups.rs @@ -30,7 +30,18 @@ impl PipelineStage for ResolveGameFixupsStage { _ => { ctx.verification.protonfixes_routed = false; let store: crate::models::UserConfigStore = ctx.user_config.as_ref().map(|c| { let mut s = HashMap::new(); s.insert(ctx.app_id, c.clone()); s }).unwrap_or_default().into(); - let wineprefix = crate::utils::steam_wineprefix_for_game(config, ctx.app_id, &store); + // Use the EFFECTIVE prefix mode so registry fixups target the + // same prefix the launch uses (runner-mismatch guard). + let configured_mode = ctx.user_config.as_ref() + .map(|c| c.steam_prefix_mode.clone()) + .unwrap_or(config.steam_prefix_mode.clone()); + let effective_mode = crate::infra::runners::wine_tkg::effective_prefix_mode_impl( + configured_mode, + &config.steam_runtime_runner, + proton, + &library_root, + ); + let wineprefix = crate::utils::steam_wineprefix_for_game(config, ctx.app_id, &store, Some(effective_mode)); let install_dir = app.install_path.clone().unwrap_or_default(); let arch = match ctx.target_architecture { crate::models::ExecutableArchitecture::X86 => "x86", _ => "x86_64" }; let fctx = crate::launch::fixups::FixupContext::new(ctx.app_id, app.name.clone(), install_dir, wineprefix.to_string_lossy().to_string(), arch.into()); diff --git a/src/lib.rs b/src/lib.rs index c564f9c1..ff3f4766 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,9 +3,12 @@ pub mod cm_list; pub mod config; pub mod proton; pub mod depot_browser; +pub mod headless; pub mod library; pub mod models; pub mod launch; +pub mod parity; +pub mod runner; pub mod steam_client; pub mod ui; pub mod utils; diff --git a/src/main.rs b/src/main.rs index 301390e6..3f84bd22 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,13 @@ use tokio::runtime::Runtime; fn main() -> Result<()> { tracing_subscriber::fmt::init(); + // Headless test-driving: `steamflow ...` runs without the GUI. + let args: Vec = std::env::args().skip(1).collect(); + if !args.is_empty() { + let runtime = Runtime::new()?; + return runtime.block_on(steamflow::headless::run(&args)); + } + let runtime = Runtime::new()?; runtime.block_on(steamflow::config::ensure_config_dirs())?; let mut client = SteamClient::new()?; diff --git a/src/models.rs b/src/models.rs index cde0187d..e09690d9 100644 --- a/src/models.rs +++ b/src/models.rs @@ -180,6 +180,15 @@ pub struct UserAppConfig { /// direct exec for scripts) inside the game's Wine prefix. #[serde(default)] pub custom_exec_path: Option, + + /// Valve-Proton compat options for this game (Phase 2 item 2 of the + /// valve-stack directive). Mirrors Proton's `STEAM_COMPAT_CONFIG` / + /// launch-option set: `forcelgadd`, `wined3d`, `nod3d11`, `nofsync`, … + /// The native Rust Proton ABI (`crate::runner::proton_abi`) translates + /// these into `PROTON_*`/`WINE_*` env vars and DLL overrides at launch, + /// without invoking Proton's Python script. + #[serde(default)] + pub proton_compat_options: Vec, } pub type UserConfigStore = HashMap; @@ -200,6 +209,7 @@ impl Default for UserAppConfig { favorite: false, requires_steam_api: false, custom_exec_path: None, + proton_compat_options: Vec::new(), } } } diff --git a/src/parity.rs b/src/parity.rs new file mode 100644 index 00000000..600a9821 --- /dev/null +++ b/src/parity.rs @@ -0,0 +1,530 @@ +//! Environment-parity harness (Phase 2 of the valve-stack directive). +//! +//! Compares the launch environment native Steam hands to a game (captured from +//! a `PROTON_LOG=1` proton log) against SteamFlow's generated +//! `effective_env.json`, and prints a categorized diff: +//! +//! - MISSING — set by native Steam, absent from SteamFlow +//! - EXTRA — set by SteamFlow, absent from native Steam +//! - MISMATCHED — both set, different values +//! - MATCHED — both set, identical values +//! +//! Reference: `docs/architecture/valve-stack-replication.md` §Tier 1. +//! Headless entry: `steamflow test-diff [--native-log ] +//! [--session ]`. + +use anyhow::{Context, Result}; +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +use crate::infra::logging::debug_utils::load_effective_env; +use crate::infra::logging::EffectiveEnv; + +/// `Options:` entry (compat_config) → the `PROTON_*` env var it implies, +/// reverse of Proton's `check_environment` table (verified against +/// official Proton 11.0's `proton` script, ~line 1685). +const OPTION_TO_PROTON_ENV: &[(&str, &str)] = &[ + ("wined3d", "PROTON_USE_WINED3D"), + ("wined3d11", "PROTON_USE_WINED3D11"), + ("dxvkd3d8", "PROTON_DXVK_D3D8"), + ("nod3d11", "PROTON_NO_D3D11"), + ("nod3d10", "PROTON_NO_D3D10"), + ("nofsync", "PROTON_NO_FSYNC"), + ("forcelgadd", "PROTON_FORCE_LARGE_ADDRESS_AWARE"), + ("oldglstr", "PROTON_OLD_GL_STRING"), + ("hidenvgpu", "PROTON_HIDE_NVIDIA_GPU"), + ("hidevggpu", "PROTON_HIDE_VANGOGH_GPU"), + ("hideintelgpu", "PROTON_HIDE_INTEL_GPU"), + ("gamedrive", "PROTON_SET_GAME_DRIVE"), + ("steamdrive", "PROTON_SET_STEAM_DRIVE"), + ("noxim", "PROTON_NO_XIM"), + ("heapdelayfree", "PROTON_HEAP_DELAY_FREE"), + ("heapzeromemory", "PROTON_HEAP_ZERO_MEMORY"), + ("disablenvapi", "PROTON_DISABLE_NVAPI"), + ("forcenvapi", "PROTON_FORCE_NVAPI"), + ("hideapu", "PROTON_HIDE_APU"), +]; + +/// Shell/process noise — never meaningful for launch parity. +const NOISE_KEYS: &[&str] = &[ + "_", "PWD", "OLDPWD", "SHLVL", "LS_COLORS", "LS_COLORS__", "SHELL", "TERM", "TERM_PROGRAM", + "TMUX", "TMUX_PANE", "SSH_AGENT_PID", "SSH_AUTH_SOCK", "DBUS_SESSION_BUS_ADDRESS", +]; + +/// Env vars that matter most for parity reporting (displayed first, flagged). +fn priority_of(key: &str) -> u8 { + if key == "WINEDLLOVERRIDES" || key == "WINEDEBUG" || key == "WINEDLLPATH" { + return 0; + } + if key.starts_with("STEAM_COMPAT_") || key.starts_with("PROTON_") { + return 1; + } + if key.starts_with("DXVK_") + || key.starts_with("VKD3D_") + || key.starts_with("WINE") + || key.starts_with("__VK_") + || key.starts_with("__GLX_") + || key.starts_with("__NV") + || key == "LD_LIBRARY_PATH" + || key == "VK_ICD_FILENAMES" + || key == "VK_LAYER_PATH" + { + return 2; + } + 3 +} + +/// Parsed facts from a native proton log header (`PROTON_LOG=1`). +#[derive(Debug, Default, Clone)] +pub struct NativeLaunch { + pub proton_version: Option, + pub steam_game_id: Option, + pub command: Option, + /// `Options: {'forcelgadd', 'wined3d'}` set. + pub options: Vec, + pub kernel: Option, + /// Env vars the log explicitly reports (Effective/System/User settings). + pub env: BTreeMap, +} + +impl NativeLaunch { + /// Env map for diffing: explicitly-reported vars + every `PROTON_*` var + /// implied by the `Options:` set (native Steam would have set them). + pub fn effective_env(&self) -> BTreeMap { + let mut map = self.env.clone(); + for opt in &self.options { + if let Some((_, env_var)) = + OPTION_TO_PROTON_ENV.iter().find(|(o, _)| o == opt) + { + map.entry(env_var.to_string()).or_insert_with(|| "1".into()); + } + } + map + } +} + +/// Parse a proton log (both legacy bash-era and modern python-era headers). +pub fn parse_native_proton_log(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read native log {}", path.display()))?; + let mut native = NativeLaunch::default(); + + // Header block: every line is `Key: value` (or `Key:` with empty value), + // until the first line that does not match (wine trace output etc.). + for line in content.lines() { + let line = line.trim_end_matches('\r'); + // Separator banners (===== / -----) are not header entries. + let trimmed = line.trim(); + if trimmed.chars().all(|c| c == '=' || c == '-') && !trimmed.is_empty() { + continue; + } + let Some((key, value)) = line.split_once(':') else { + break; // header block ended (wine debug output follows) + }; + let key = key.trim(); + let value = value.trim().to_string(); + match key { + "Proton" => native.proton_version = Some(value), + "SteamGameId" => native.steam_game_id = Some(value), + "Command" => native.command = Some(value), + "Options" => { + // Python-set repr: {'forcelgadd', 'wined3d'} or empty set() + let body = value + .trim() + .trim_start_matches('{') + .trim_end_matches('}') + .trim(); + if body == "set()" || body.is_empty() { + // empty set + } else { + for opt in body.split(',') { + let opt = opt.trim().trim_matches('\'').trim_matches('"'); + if !opt.is_empty() { + native.options.push(opt.to_string()); + } + } + } + } + "Kernel" => native.kernel = Some(value), + "Effective WINEDLLOVERRIDES" + | "System WINEDLLOVERRIDES" + | "User settings WINEDLLOVERRIDES" => { + if !value.is_empty() { + native + .env + .insert("WINEDLLOVERRIDES".into(), value.clone()); + } + } + "Effective WINEDEBUG" | "System WINEDEBUG" | "User settings WINEDEBUG" => { + if !value.is_empty() { + native.env.insert("WINEDEBUG".into(), value.clone()); + } + } + "PATH" => { + if !value.is_empty() { + native.env.insert("PATH".into(), value.clone()); + } + } + // depot/pressure-vessel/scripts/soldier/sniper/Language are + // informative but not env vars — ignored for the diff. + _ => {} + } + // Stop at the first non-header line only when we've seen the header + // marker; the block ends at the first line without ':'. + if !line.contains(':') { + break; + } + } + Ok(native) +} + +/// Find the native proton log for an appid. +/// Search order: explicit path > `~/steam-.log` > `~/Фото, видео/steam-.log` +/// > any `steam-.log` up to 3 levels under HOME. +pub fn find_native_log(appid: u32, explicit: Option<&Path>) -> Option { + if let Some(p) = explicit { + return p.exists().then(|| p.to_path_buf()); + } + let home = std::env::var("HOME").ok()?; + let candidates = [ + PathBuf::from(&home).join(format!("steam-{appid}.log")), + PathBuf::from(&home).join("Фото, видео").join(format!("steam-{appid}.log")), + PathBuf::from(&home).join("Emulators").join(format!("steam-{appid}.log")), + ]; + for c in candidates { + if c.exists() { + return Some(c); + } + } + // Shallow recursive scan of HOME (maxdepth 3) as a last resort. + let root = PathBuf::from(&home); + let mut stack = vec![root]; + let mut depth = 0; + while !stack.is_empty() && depth < 3 { + let mut next = Vec::new(); + for dir in stack { + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + next.push(path); + } else if path.file_name().map(|n| n.to_string_lossy().to_string()) + == Some(format!("steam-{appid}.log")) + { + return Some(path); + } + } + } + } + stack = next; + depth += 1; + } + None +} + +/// Find the newest SteamFlow session dir whose effective env targets `appid`. +pub fn find_steamflow_session(appid: u32, explicit: Option<&Path>) -> Option { + if let Some(p) = explicit { + return p.exists().then(|| p.to_path_buf()); + } + let home = std::env::var("HOME").ok()?; + let logs_root = PathBuf::from(&home).join(".config/SteamFlow/logs"); + let entries = std::fs::read_dir(&logs_root).ok()?; + let mut best: Option<(u64, PathBuf)> = None; + for entry in entries.flatten() { + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + let env_path = dir.join("effective_env.json"); + if !env_path.exists() { + continue; + } + let Ok(env) = load_effective_env(&dir) else { continue }; + let targets = env + .env_vars + .get("SteamAppId") + .or_else(|| env.env_vars.get("STEAM_COMPAT_APP_ID")) + .and_then(|v| v.parse::().ok()); + if targets != Some(appid) { + continue; + } + let mtime = env_path + .metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + if best.as_ref().map(|(t, _)| mtime > *t).unwrap_or(true) { + best = Some((mtime, dir)); + } + } + best.map(|(_, dir)| dir) +} + +#[derive(Debug, Default)] +pub struct EnvDiff { + pub missing: Vec<(String, String)>, // native-only (var, native value) + pub extra: Vec<(String, String)>, // steamflow-only (var, flow value) + pub mismatched: Vec<(String, String, String)>, // (var, native, flow) + pub matched: Vec, // both, same value +} + +/// Diff native env against SteamFlow env. Noisy shell keys are skipped. +pub fn diff_envs( + native: &BTreeMap, + flow: &HashMap, +) -> EnvDiff { + let mut diff = EnvDiff::default(); + let mut keys: Vec<&String> = native.keys().chain(flow.keys()).collect(); + keys.sort(); + keys.dedup(); + + for key in keys { + if NOISE_KEYS.contains(&key.as_str()) { + continue; + } + match (native.get(key), flow.get(key)) { + (Some(n), Some(f)) if n == f => diff.matched.push(key.clone()), + (Some(n), Some(f)) => diff.mismatched.push((key.clone(), n.clone(), f.clone())), + (Some(n), None) => diff.missing.push((key.clone(), n.clone())), + (None, Some(f)) => diff.extra.push((key.clone(), f.clone())), + (None, None) => unreachable!(), + } + } + diff +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}… ({} chars)", &s[..max], s.len()) + } +} + +/// Render the diff to stdout. +pub fn print_diff( + appid: u32, + native: &NativeLaunch, + native_path: &Path, + flow_env: &EffectiveEnv, + flow_dir: &Path, + diff: &EnvDiff, +) { + println!("=== Environment parity diff — app {appid} ==="); + println!(); + println!("Native Steam : {}", native_path.display()); + println!( + " Proton : {}", + native.proton_version.as_deref().unwrap_or("(unknown)") + ); + println!( + " SteamGameId : {}", + native.steam_game_id.as_deref().unwrap_or("(unknown)") + ); + println!( + " Command : {}", + native.command.as_deref().unwrap_or("(unknown)") + ); + println!( + " Options : {}", + if native.options.is_empty() { + "(none)".to_string() + } else { + format!("{:?}", native.options) + } + ); + println!( + " Kernel : {}", + native.kernel.as_deref().unwrap_or("(unknown)") + ); + println!(); + println!("SteamFlow : {}", flow_dir.display()); + println!(" Runner : {}", flow_env.runner_name); + println!( + " env vars : {}", + flow_env.env_vars.len() + ); + println!(); + + let print_kv = |prefix: &str, key: &str, value: &str| { + let flag = match priority_of(key) { + 0 => "**", + 1 => "* ", + 2 => " ", + _ => " ", + }; + println!("{prefix} {flag} {key}={}", truncate(value, 160)); + }; + + if !diff.mismatched.is_empty() { + println!("--- MISMATCHED ({}): both set, different values ---", diff.mismatched.len()); + for (key, n, f) in &diff.mismatched { + println!(" {key}"); + println!(" native : {}", truncate(n, 200)); + println!(" steamflow: {}", truncate(f, 200)); + } + println!(); + } + + if !diff.missing.is_empty() { + println!("--- MISSING ({}): set by native Steam, absent in SteamFlow ---", diff.missing.len()); + for (key, value) in &diff.missing { + print_kv("-", key, value); + } + println!(); + } + + if !diff.extra.is_empty() { + println!("--- EXTRA ({}): set by SteamFlow, absent in native Steam ---", diff.extra.len()); + for (key, value) in &diff.extra { + print_kv("+", key, value); + } + println!(); + } + + if !diff.matched.is_empty() { + println!("--- MATCHED ({}): identical on both sides ---", diff.matched.len()); + for key in &diff.matched { + println!(" = {key}"); + } + println!(); + } + + let total = diff.missing.len() + diff.extra.len() + diff.mismatched.len(); + println!("=== Summary: {} divergence(s) ({} missing, {} extra, {} mismatched), {} matched ===", + total, diff.missing.len(), diff.extra.len(), diff.mismatched.len(), diff.matched.len()); + if total == 0 { + println!(" ✅ Environment parity: SteamFlow matches native Steam."); + } else { + println!(" ⚠️ Divergences found — see above. `**` = highest-impact var."); + } +} + +/// `steamflow test-diff [--native-log ] [--session ]` +pub async fn test_diff(args: &[String]) -> Result<()> { + let appid = args + .get(1) + .and_then(|a| a.parse::().ok()) + .ok_or_else(|| anyhow::anyhow!("usage: test-diff [--native-log ] [--session ]"))?; + + let native_log_arg = args + .iter() + .position(|a| a == "--native-log") + .and_then(|i| args.get(i + 1)) + .map(PathBuf::from); + let session_arg = args + .iter() + .position(|a| a == "--session") + .and_then(|i| args.get(i + 1)) + .map(PathBuf::from); + + let native_path = find_native_log(appid, native_log_arg.as_deref()).ok_or_else(|| { + anyhow::anyhow!( + "no native proton log found for app {appid} (looked for ~/steam-{appid}.log, \ + ~/Фото, видео/steam-{appid}.log, ~/Emulators/steam-{appid}.log, and a shallow \ + HOME scan). Capture one by launching the game from native Steam with \ + PROTON_LOG=1 in its launch options, or pass --native-log ." + ) + })?; + let native = parse_native_proton_log(&native_path)?; + if native.steam_game_id.as_deref() != Some(&appid.to_string()) { + println!( + " ⚠️ native log SteamGameId={:?} does not match requested appid {} — \ + continuing anyway", + native.steam_game_id, appid + ); + } + + let flow_dir = find_steamflow_session(appid, session_arg.as_deref()).ok_or_else(|| { + anyhow::anyhow!( + "no SteamFlow session with effective_env.json for app {appid} \ + (looked in ~/.config/SteamFlow/logs/*/). Run `steamflow test-launch {appid}` \ + first, or pass --session ." + ) + })?; + let flow_env = load_effective_env(&flow_dir)?; + + let native_env = native.effective_env(); + let diff = diff_envs(&native_env, &flow_env.env_vars); + + print_diff(appid, &native, &native_path, &flow_env, &flow_dir, &diff); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_legacy_header() { + let log = "======================\n\ + Proton: 1667865000 GE-Proton7-41\n\ + SteamGameId: 883710\n\ + Command: ['/x/re2.exe']\n\ + Options: {'forcelgadd', 'wined3d'}\n\ + depot: 0.20220930.72\n\ + Kernel: Linux 5.18 x86_64\n\ + ======================\n\ + fsync: up and running.\n"; + let dir = std::env::temp_dir().join("parity_test_legacy.log"); + std::fs::write(&dir, log).unwrap(); + let native = parse_native_proton_log(&dir).unwrap(); + std::fs::remove_file(&dir).ok(); + + assert_eq!(native.proton_version.as_deref(), Some("1667865000 GE-Proton7-41")); + assert_eq!(native.steam_game_id.as_deref(), Some("883710")); + assert_eq!(native.options, vec!["forcelgadd", "wined3d"]); + let env = native.effective_env(); + assert_eq!(env.get("PROTON_FORCE_LARGE_ADDRESS_AWARE").map(String::as_str), Some("1")); + assert_eq!(env.get("PROTON_USE_WINED3D").map(String::as_str), Some("1")); + assert!(!env.contains_key("PROTON_NO_FSYNC")); + } + + #[test] + fn parses_modern_effective_overrides() { + let log = "======================\n\ + Proton: 1785138253 proton-11.0-1b\n\ + SteamGameId: 883710\n\ + Command: ['/x/re2.exe']\n\ + Options: set()\n\ + Kernel: Linux 6.8 x86_64\n\ + Effective WINEDLLOVERRIDES: dxvk.dll=n,b\n\ + Effective WINEDEBUG: +loaddll\n\ + ======================\n"; + let dir = std::env::temp_dir().join("parity_test_modern.log"); + std::fs::write(&dir, log).unwrap(); + let native = parse_native_proton_log(&dir).unwrap(); + std::fs::remove_file(&dir).ok(); + + assert_eq!(native.env.get("WINEDLLOVERRIDES").map(String::as_str), Some("dxvk.dll=n,b")); + assert_eq!(native.env.get("WINEDEBUG").map(String::as_str), Some("+loaddll")); + assert!(native.options.is_empty()); + } + + #[test] + fn diff_categorizes() { + let mut native = BTreeMap::new(); + native.insert("WINEDLLOVERRIDES".into(), "dxvk=n,b".into()); + native.insert("PROTON_USE_WINED3D".into(), "1".into()); + native.insert("PATH".into(), "/usr/bin".into()); + + let mut flow = HashMap::new(); + flow.insert("WINEDLLOVERRIDES".into(), "dxvk=n;wined3d=n".into()); + flow.insert("STEAM_COMPAT_APP_ID".into(), "883710".into()); + + let diff = diff_envs(&native, &flow); + assert_eq!(diff.mismatched.len(), 1); + assert_eq!(diff.mismatched[0].0, "WINEDLLOVERRIDES"); + // Native-only: PROTON_USE_WINED3D + PATH (PATH is not noise). + assert_eq!(diff.missing.len(), 2); + assert!(diff.missing.iter().any(|(k, _)| k == "PROTON_USE_WINED3D")); + assert!(diff.missing.iter().any(|(k, _)| k == "PATH")); + assert_eq!(diff.extra.len(), 1); + assert_eq!(diff.extra[0].0, "STEAM_COMPAT_APP_ID"); + assert!(diff.matched.is_empty()); + } +} diff --git a/src/proton.rs b/src/proton.rs index e9e1883d..e2b7e7eb 100644 --- a/src/proton.rs +++ b/src/proton.rs @@ -346,6 +346,21 @@ where F: FnMut(u64, u64) + Send + 'static return Err(anyhow!("Extraction failed: {}", String::from_utf8_lossy(&output.stderr))); } + // Phase 2 item 3 (valve-stack directive): write a VERSIONS.txt at the + // extracted runner root (harvesting the component version files the + // tarball ships + stamping the release version) so the UI shows real + // versions instead of `found(bundled)`. The tarball extracts to a + // top-level dir named after the package. + let extracted_root = target_dir.join(&package.name); + if extracted_root.is_dir() { + crate::utils::write_runner_versions_txt(&extracted_root, &package.version); + } else { + tracing::warn!( + "Extracted runner root {} not found — skipping VERSIONS.txt write", + extracted_root.display() + ); + } + // Remove archive let _ = std::fs::remove_file(archive_path); diff --git a/src/runner/mod.rs b/src/runner/mod.rs new file mode 100644 index 00000000..5978608f --- /dev/null +++ b/src/runner/mod.rs @@ -0,0 +1,5 @@ +//! Native Rust Proton launch semantics (Phase 2 item 2 of the valve-stack +//! directive). See `proton_abi.rs` for the port of Valve's `proton` script +//! logic; the runner trait lives in `crate::infra::runners::trait`. + +pub mod proton_abi; diff --git a/src/runner/proton_abi.rs b/src/runner/proton_abi.rs new file mode 100644 index 00000000..200604f5 --- /dev/null +++ b/src/runner/proton_abi.rs @@ -0,0 +1,669 @@ +//! Native Rust reimplementation of Valve Proton's launch semantics +//! (the `proton` wrapper script + `default_pfx.py` prefix seeding). +//! +//! Phase 2 item 2 of the valve-stack directive +//! (`docs/architecture/valve-stack-replication.md` §Tier 1): eliminate the +//! dependency on external Python script invocation at game launch by porting +//! the core logic to Rust. Verified against official Proton 11.0's `proton` +//! script (app 4628710, depot 4628711) on 2026-08-11. +//! +//! What lives here (all runner-agnostic, pure semantics): +//! - `default_compat_config(appid)` — the per-app compat-option table. +//! - `CompatSession::build_env()` — environment assembly (STEAM_COMPAT_*, +//! WINE_*/PROTON_*/VKD3D_*/DXVK_* rules, WINEDLLOVERRIDES base set). +//! - `check_environment` — PROTON_* env var ⇄ compat-option translation. +//! - `seed_prefix()` — native prefix init (default_pfx copy, dosdevices +//! symlinks, MachineGuid preservation) without Python. + +use std::collections::{BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; + +// --------------------------------------------------------------------------- +// Compat option table — port of `default_compat_config()` (proton script). +// --------------------------------------------------------------------------- + +/// Port of Proton's `default_compat_config()`: the appid → compat-option +/// rules, plus the unconditional `gamedrive`. Returns the option set for an +/// appid. `forcelgadd` is added by the caller unless `noforcelgadd` is set +/// (mirrors `if "noforcelgadd" not in compat_config: compat_config.add("forcelgadd")`). +pub fn default_compat_config(appid: u32) -> BTreeSet { + let mut ret = BTreeSet::new(); + let appid_str = appid.to_string(); + + // nomfdxgiman (CW bug 19741 / 20240 / Unity race) + if matches!(appid, 1017900 | 1331440 | 2620730 | 2882920 | 2712910) { + ret.insert("nomfdxgiman".into()); + } + // noopwr (text-input delay / OWPR code path issues) + if matches!( + appid, + 1172620 | 962130 | 495420 | 976730 | 1017900 | 1056090 | 1293830 | 1551360 | 813780 + | 933110 | 1466860 | 1097840 | 1244950 | 1189800 | 1184050 | 1240440 | 1250410 + | 1672970 | 1180660 | 1238430 | 1266670 | 230410 | 3513350 | 3728370 + ) { + ret.insert("noopwr".into()); + } + // noforcelgadd + if matches!(appid, 2710 | 1621680 | 888040) { + ret.insert("noforcelgadd".into()); + } + // hidevggpu + if matches!(appid, 257420 | 2021880) { + ret.insert("hidevggpu".into()); + } + // hideintelgpu + if appid == 1977170 { + ret.insert("hideintelgpu".into()); + } + // heapdelayfree + if matches!(appid, 202990 | 212910 | 499100 | 1404090 | 2052410 | 789910 | 1183470 | 876340) + { + ret.insert("heapdelayfree".into()); + } + // heapzeromemory + if matches!(appid, 21980 | 553850 | 2055290) { + ret.insert("heapzeromemory".into()); + } + // heaptopdown + if matches!(appid, 71230 | 3328910) { + ret.insert("heaptopdown".into()); + } + // nofsync + noesync + if matches!(appid, 2630 | 1060210 | 414740 | 201510 | 1233880) { + ret.insert("nofsync".into()); + ret.insert("noesync".into()); + } + // disablenvapi (titles that dislike dxvknvapi) + if matches!( + appid, + 1088850 | 1418100 | 2080180 | 1939100 | 435150 | 2176900 | 2853730 + ) { + ret.insert("disablenvapi".into()); + } + // disablenvapi when no NVIDIA driver is loaded (/proc/modules check) + if matches!( + appid, + 1808500 | 2073850 | 108710 | 202750 | 505170 | 255220 | 44350 | 407810 | 233130 + | 2067160 | 2621010 | 368500 + ) { + if !nvidia_driver_loaded() { + ret.insert("disablenvapi".into()); + } + } + // hidenvgpu + if appid == 2698940 { + ret.insert("hidenvgpu".into()); + } + // forcenvapi + if matches!(appid, 2395210 | 1577120) { + ret.insert("forcenvapi".into()); + } + // hideapu + if appid == 1252330 { + ret.insert("hideapu".into()); + } + // fnad3d11 + if matches!(appid, 249610 | 287240 | 280200 | 312530 | 1072860) { + ret.insert("fnad3d11".into()); + } + + // options to also be enabled for prerequisite setup steps + ret.insert("gamedrive".into()); + + // STEAM_COMPAT_APP_ID block (secondary key) + if matches!( + appid, + 247660 | 1026680 | 3280350 | 3513350 | 3837340 | 337000 + ) { + ret.insert("noxalia".into()); + } + if matches!(appid, 275850 | 2012840) { + ret.insert("nohardwarescheduling".into()); + } + + let _ = appid_str; + ret +} + +/// `/proc/modules` NVIDIA-driver probe — mirrors Proton's Python check. +fn nvidia_driver_loaded() -> bool { + std::fs::read_to_string("/proc/modules") + .map(|content| { + content.lines().any(|line| { + let driver = line.split(' ').next().unwrap_or(""); + matches!(driver, "nvidia" | "nouveau" | "nova") + }) + }) + .unwrap_or(true) // /proc/modules unreadable → assume NVIDIA (safe default) +} + +/// `default_cpu_limit` table — WINE_CPU_TOPOLOGY per appid. +pub fn default_cpu_limit(appid: u32) -> Option { + Some(match appid { + 19900 | 298110 | 20920 | 35130 | 55150 | 204450 => 16, + 15620 | 20570 | 56400 | 259170 | 115320 => 8, + 618970 | 10150 | 11440 | 65540 => 4, + 2229830 => 1, + 316260 => 16, + 286810 => 30, + 70000 => 28, + _ => return None, + }) +} + +// --------------------------------------------------------------------------- +// PROTON_* env ⇄ compat-option translation (`check_environment` port). +// --------------------------------------------------------------------------- + +/// `check_environment(env_name, config_name)` table — an env var that, when +/// set non-zero, adds its compat option. This is the same table as +/// `crate::parity::OPTION_TO_PROTON_ENV`, kept here as the launch-side source +/// of truth (parity.rs re-exports from here to avoid drift). +pub const PROTON_ENV_TO_OPTION: &[(&str, &str)] = &[ + ("PROTON_USE_WINED3D", "wined3d"), + ("PROTON_USE_WINED3D11", "wined3d11"), + ("PROTON_DXVK_D3D8", "dxvkd3d8"), + ("PROTON_NO_D3D11", "nod3d11"), + ("PROTON_NO_D3D10", "nod3d10"), + ("PROTON_NO_FSYNC", "nofsync"), + ("PROTON_FORCE_LARGE_ADDRESS_AWARE", "forcelgadd"), + ("PROTON_OLD_GL_STRING", "oldglstr"), + ("PROTON_HIDE_NVIDIA_GPU", "hidenvgpu"), + ("PROTON_HIDE_VANGOGH_GPU", "hidevggpu"), + ("PROTON_HIDE_INTEL_GPU", "hideintelgpu"), + ("PROTON_SET_GAME_DRIVE", "gamedrive"), + ("PROTON_SET_STEAM_DRIVE", "steamdrive"), + ("PROTON_NO_XIM", "noxim"), + ("PROTON_HEAP_DELAY_FREE", "heapdelayfree"), + ("PROTON_HEAP_ZERO_MEMORY", "heapzeromemory"), + ("PROTON_DISABLE_NVAPI", "disablenvapi"), + ("PROTON_FORCE_NVAPI", "forcenvapi"), + ("PROTON_HIDE_APU", "hideapu"), +]; + +/// Apply `STEAM_COMPAT_CONFIG` (comma-separated options, incl. +/// `cmdlineappend:...`) onto the compat set — port of the Session __init__ +/// STEAM_COMPAT_CONFIG parsing. +pub fn apply_steam_compat_config(compat: &mut BTreeSet, config: &str) { + if config.is_empty() { + return; + } + for part in config.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + if let Some(rest) = part.strip_prefix("cmdlineappend:") { + // cmdlineappend entries are not compat options; ignore for env + // purposes (callers that need the appended argv can parse this + // separately). + let _ = rest; + continue; + } + compat.insert(part.to_string()); + } +} + +/// Add `forcelgadd` unless `noforcelgadd` is present (Proton Session init). +pub fn apply_forcelgadd_default(compat: &mut BTreeSet) { + if !compat.contains("noforcelgadd") { + compat.insert("forcelgadd".to_string()); + } +} + +// --------------------------------------------------------------------------- +// Environment assembly — port of Session::init_wine / run_game env rules. +// --------------------------------------------------------------------------- + +/// Base WINEDLLOVERRIDES dict (Session __init__). +pub fn base_dll_overrides() -> HashMap { + let mut m = HashMap::new(); + m.insert("steam.exe".into(), "b".into()); // always our special built-in steam.exe + m.insert("dotnetfx35.exe".into(), "b".into()); + m.insert("dotnetfx35setup.exe".into(), "b".into()); + m.insert("beclient.dll".into(), "b,n".into()); + m.insert("beclient_x64.dll".into(), "b,n".into()); + m.insert("winebth.sys".into(), "d".into()); // crashes winedevice.exe + m +} + +/// Port of the per-game dlloverride rules + compat-config env rules +/// (the `run()` / env-assembly block in the proton script). +/// +/// `appid` — SteamAppId (0 = unknown). `compat` — the effective compat set. +/// `env` — in/out: SteamFlow's env; Proton's rules are merged in. +/// `dll_overrides` — in/out: ordered (dll, setting) pairs. SteamFlow's +/// existing entries are kept (in order); Proton's base dict and per-game +/// rules are merged in (existing keys keep their SteamFlow value, matching +/// the "thin launcher" directive). +pub fn apply_proton_env_rules( + appid: u32, + compat: &BTreeSet, + env: &mut HashMap, + dll_overrides: &mut Vec<(String, String)>, +) { + // Helper: get-or-insert preserving order. + fn upsert(overrides: &mut Vec<(String, String)>, dll: &str, setting: &str) { + match overrides.iter_mut().find(|(d, _)| d == dll) { + Some(entry) => entry.1 = setting.to_string(), + None => overrides.push((dll.to_string(), setting.to_string())), + } + } + + // Base dlloverrides (merged; SteamFlow's explicit values win). + for (k, v) in base_dll_overrides() { + upsert(dll_overrides, &k, &v); + } + // opencl=n,d unless the app opts out (2767030 / 2274200). + if appid != 2767030 && appid != 2274200 { + upsert(dll_overrides, "opencl", "n,d"); + } + + // WINE_CPU_TOPOLOGY + if !env.contains_key("PROTON_CPU_TOPOLOGY") { + if let Some(limit) = default_cpu_limit(appid) { + env.insert("WINE_CPU_TOPOLOGY".into(), limit.to_string()); + } + } else if let Some(v) = env.get("PROTON_CPU_TOPOLOGY") { + env.insert("WINE_CPU_TOPOLOGY".into(), v.clone()); + } + + // PROTON_* input vars for every active compat option (reverse of + // check_environment): native Steam's launch env carries these, and the + // test-diff harness reverse-maps a native log's `Options:` set to them. + // Emitting them here closes the parity gaps SteamFlow previously had + // (e.g. PROTON_FORCE_LARGE_ADDRESS_AWARE from the forcelgadd default, + // PROTON_USE_WINED3D from a per-game wined3d option). + for (env_var, option) in PROTON_ENV_TO_OPTION { + if compat.contains(*option) { + env.insert(env_var.to_string(), "1".to_string()); + } + } + + // WINE_LARGE_ADDRESS_AWARE (forcelgadd / noforcelgadd) + if compat.contains("forcelgadd") { + env.insert("WINE_LARGE_ADDRESS_AWARE".into(), "1".into()); + } else if compat.contains("noforcelgadd") { + env.insert("WINE_LARGE_ADDRESS_AWARE".into(), "0".into()); + } + + // WINE_HEAP_* + if compat.contains("heapdelayfree") { + env.insert("WINE_HEAP_DELAY_FREE".into(), "1".into()); + } + if compat.contains("heapzeromemory") { + env.insert("WINE_HEAP_ZERO_MEMORY".into(), "1".into()); + } + if compat.contains("heaptopdown") { + env.insert("WINE_HEAP_TOP_DOWN".into(), "1".into()); + } + + // VKD3D_CONFIG / VKD3D_FEATURE_LEVEL + if compat.contains("vkd3dbindlesstb") { + append_env_list(env, "VKD3D_CONFIG", "force_bindless_texel_buffer", ","); + } + if compat.contains("vkd3dfl12") { + env.entry("VKD3D_FEATURE_LEVEL".to_string()) + .or_insert_with(|| "12_0".to_string()); + } + + // GPU hiding + if compat.contains("hidevggpu") { + env.insert("WINE_HIDE_VANGOGH_GPU".into(), "1".into()); + } + if compat.contains("hidenvgpu") && !compat.contains("forcenvapi") { + env.insert("WINE_HIDE_NVIDIA_GPU".into(), "1".into()); + } + if compat.contains("hideintelgpu") { + env.insert("WINE_HIDE_INTEL_GPU".into(), "1".into()); + } + if compat.contains("hideapu") { + env.insert("WINE_HIDE_APU".into(), "1".into()); + } + + // xinput1_3 / libglesv2 overrides + if compat.contains("usenativexinput13") { + upsert(dll_overrides, "xinput1_3", "n"); + } + if compat.contains("disablelibglesv2") { + upsert(dll_overrides, "libglesv2", "d"); + } + + // DXGI device-manager / OPWR + if compat.contains("nomfdxgiman") { + env.insert("WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER".into(), "1".into()); + } + if compat.contains("noopwr") { + env.insert("WINE_DISABLE_VULKAN_OPWR".into(), "1".into()); + } + + // XALIA + if !env.contains_key("PROTON_USE_XALIA") { + if compat.contains("noxalia") { + env.insert("PROTON_USE_XALIA".into(), "0".into()); + } else { + env.insert("PROTON_USE_XALIA".into(), "1".into()); + if !compat.contains("xalia") { + env.insert("XALIA_SUPPORTED_ONLY".into(), "1".into()); + } + } + } + + // Hardware scheduling + if compat.contains("nohardwarescheduling") && !env.contains_key("WINE_DISABLE_HARDWARE_SCHEDULING") + { + env.insert("WINE_DISABLE_HARDWARE_SCHEDULING".into(), "1".into()); + } + + // Crash report dir passthrough + if let Some(dir) = env.get("PROTON_CRASH_REPORT_DIR") { + env.insert("WINE_CRASH_REPORT_DIR".into(), dir.clone()); + } + + // FNA3D + if compat.contains("fnad3d11") && !env.contains_key("FNA3D_FORCE_DRIVER") { + env.insert("FNA3D_FORCE_DRIVER".into(), "D3D11".into()); + } + + // GLVND + env.entry("__GLVND_DISALLOW_PATCHING".into()).or_insert_with(|| "1".into()); + // WINE_MONO_HIDETYPES + env.entry("WINE_MONO_HIDETYPES".into()).or_insert_with(|| "0".into()); + + // nod3d11 / nod3d10 + if compat.contains("nod3d11") { + upsert(dll_overrides, "d3d11", ""); + dll_overrides.retain(|(d, _)| d != "dxgi"); + } + if compat.contains("nod3d10") { + upsert(dll_overrides, "d3d10_1", ""); + upsert(dll_overrides, "d3d10", ""); + upsert(dll_overrides, "dxgi", ""); + } + if compat.contains("nativevulkanloader") { + upsert(dll_overrides, "vulkan-1", "n"); + } + + // NVAPI + if !compat.contains("disablenvapi") || compat.contains("forcenvapi") { + env.entry("DXVK_ENABLE_NVAPI".into()).or_insert_with(|| "1".into()); + } + if compat.contains("forcenvapi") { + env.insert("DXVK_NVAPI_ALLOW_OTHER_DRIVERS".into(), "1".into()); + env.insert("DXVK_NVAPI_DRIVER_VERSION".into(), "99999".into()); + env.insert("WINE_HIDE_AMD_GPU".into(), "1".into()); + } + + // PROTON_LIMIT_ADDRESS_SPACE + if !env.contains_key("PROTON_LIMIT_ADDRESS_SPACE") && matches!(appid, 1282270 | 2963870) { + env.insert("PROTON_LIMIT_ADDRESS_SPACE".into(), "1".into()); + } + + // OPENSSL_ia32cap (long appid list — ported from the script's list) + if matches!( + appid, + 425670 | 1096570 | 492230 | 996580 | 437630 | 442780 | 433100 | 406970 | 451520 + | 1237970 | 1051200 | 285190 | 1133320 + ) { + env.insert("OPENSSL_ia32cap".into(), "~0x20000000".into()); + } + + // ddraw / dinput / winmm / gameinput per-game overrides + if matches!(appid, 500810 | 4249100 | 4249110 | 4249130 | 4249150) { + upsert(dll_overrides, "ddraw", "n,b"); + } + if appid == 3780660 { + upsert(dll_overrides, "dinput", "n,b"); + } + if appid == 2471120 { + upsert(dll_overrides, "winmm", "n,b"); + } + if appid == 1928420 { + upsert(dll_overrides, "gameinput", "d"); + } + + // PROTON_LIMIT_RESOLUTIONS + if !env.contains_key("PROTON_LIMIT_RESOLUTIONS") { + if appid == 39540 { + env.insert("PROTON_LIMIT_RESOLUTIONS".into(), "16".into()); + } else if matches!(appid, 524220 | 814380 | 374320 | 357190) { + env.insert("PROTON_LIMIT_RESOLUTIONS".into(), "32".into()); + } + } + + // WINE_HIDE_AMD_GPU (per-app) + if !env.contains_key("WINE_HIDE_AMD_GPU") && appid == 1282690 { + env.insert("WINE_HIDE_AMD_GPU".into(), "1".into()); + } + + // atiadlxx (per-app) + if appid == 2767030 { + upsert(dll_overrides, "atiadlxx", "b"); + } +} + +/// Append `value` to `env[key]` (comma-separated), like Proton's +/// `append_to_env_str`. +pub fn append_env_list(env: &mut HashMap, key: &str, value: &str, sep: &str) { + match env.get_mut(key) { + Some(existing) if !existing.is_empty() => { + existing.push_str(sep); + existing.push_str(value); + } + _ => { + env.insert(key.to_string(), value.to_string()); + } + } +} + +/// Serialize an ordered dlloverrides list into a WINEDLLOVERRIDES string +/// (`dll=setting;...`), matching Proton's ordering (insertion order preserved). +pub fn serialize_dll_overrides(overrides: &[(String, String)]) -> String { + overrides + .iter() + .map(|(dll, setting)| format!("{dll}={setting}")) + .collect::>() + .join(";") +} + +// --------------------------------------------------------------------------- +// Prefix seeding — port of `default_pfx.py` / `copy_pfx` + dosdevices. +// --------------------------------------------------------------------------- + +/// Native prefix initialization: copy `default_pfx` into `prefix_dir` (files +/// copied, symlinks preserved), create `dosdevices/c:` and `dosdevices/z:` +/// symlinks, and stamp the proton version marker. Returns the list of created +/// paths (for tracking). +/// +/// Mirrors Proton's `CompatData.setup_prefix()` → `copy_pfx()` + the +/// dosdevices symlink block, WITHOUT invoking Python. +pub fn seed_prefix( + default_pfx_dir: &Path, + prefix_dir: &Path, + proton_version: &str, +) -> std::io::Result> { + let mut created = Vec::new(); + + // copy_pfx: walk default_pfx, copy files / recreate symlinks. + if default_pfx_dir.is_dir() { + copy_tree(default_pfx_dir, prefix_dir, &mut created)?; + } + + // dosdevices symlinks (only if missing). + let dosdevices = prefix_dir.join("dosdevices"); + std::fs::create_dir_all(&dosdevices)?; + let c_link = dosdevices.join("c:"); + if !c_link.exists() { + std::os::unix::fs::symlink("../drive_c", &c_link)?; + created.push(c_link); + } + let z_link = dosdevices.join("z:"); + if !z_link.exists() { + std::os::unix::fs::symlink("/", &z_link)?; + created.push(z_link); + } + + // Version marker (compatdata/version). + std::fs::create_dir_all(prefix_dir)?; + std::fs::write(prefix_dir.join("version"), format!("{proton_version}\n"))?; + + Ok(created) +} + +fn copy_tree(src: &Path, dst: &Path, created: &mut Vec) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let from = entry.path(); + let to = dst.join(entry.file_name()); + let ft = entry.file_type()?; + if ft.is_symlink() { + let target = std::fs::read_link(&from)?; + if !to.exists() { + std::os::unix::fs::symlink(&target, &to)?; + created.push(to); + } + } else if ft.is_dir() { + copy_tree(&from, &to, created)?; + } else if !to.exists() { + std::fs::copy(&from, &to)?; + created.push(to); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compat_config_re2_empty_plus_gamedrive() { + // RE2 (883710) is in no special list → only gamedrive (+forcelgadd later). + let c = default_compat_config(883710); + assert_eq!(c, BTreeSet::from(["gamedrive".to_string()])); + } + + #[test] + fn compat_config_noforcelgadd_listed() { + let c = default_compat_config(2710); // Act of War + assert!(c.contains("noforcelgadd")); + let mut with_default = c.clone(); + apply_forcelgadd_default(&mut with_default); + assert!(!with_default.contains("forcelgadd")); + } + + #[test] + fn compat_config_forcelgadd_default() { + let mut c = default_compat_config(883710); + apply_forcelgadd_default(&mut c); + assert!(c.contains("forcelgadd")); + assert!(c.contains("gamedrive")); + } + + #[test] + fn steam_compat_config_parsing() { + let mut c = BTreeSet::new(); + apply_steam_compat_config(&mut c, "forcelgadd,cmdlineappend:-nointro,wined3d"); + assert!(c.contains("forcelgadd")); + assert!(c.contains("wined3d")); + assert!(!c.contains("cmdlineappend:-nointro")); + } + + #[test] + fn env_rules_forcelgadd_sets_large_address_aware() { + let mut c = default_compat_config(883710); + apply_forcelgadd_default(&mut c); + let mut env = HashMap::new(); + let mut dll: Vec<(String, String)> = Vec::new(); + apply_proton_env_rules(883710, &c, &mut env, &mut dll); + assert_eq!(env.get("WINE_LARGE_ADDRESS_AWARE").map(String::as_str), Some("1")); + assert_eq!(env.get("DXVK_ENABLE_NVAPI").map(String::as_str), Some("1")); + assert_eq!(env.get("WINE_MONO_HIDETYPES").map(String::as_str), Some("0")); + assert_eq!(env.get("__GLVND_DISALLOW_PATCHING").map(String::as_str), Some("1")); + let opencl = dll.iter().find(|(d, _)| d == "opencl").map(|(_, v)| v.clone()); + assert_eq!(opencl.as_deref(), Some("n,d")); + let steam = dll.iter().find(|(d, _)| d == "steam.exe").map(|(_, v)| v.clone()); + assert_eq!(steam.as_deref(), Some("b")); + } + + #[test] + fn env_rules_wined3d_option() { + let mut c = BTreeSet::new(); + c.insert("wined3d".into()); + apply_forcelgadd_default(&mut c); + let mut env = HashMap::new(); + let mut dll: Vec<(String, String)> = Vec::new(); + apply_proton_env_rules(883710, &c, &mut env, &mut dll); + // wined3d → PROTON_USE_WINED3D is an INPUT var; the option itself is + // tracked in the compat set (SteamFlow's graphics policy decides the + // actual backend). Assert the option surfaced in the compat set. + assert!(c.contains("wined3d")); + // forcelgadd still default + assert_eq!(env.get("WINE_LARGE_ADDRESS_AWARE").map(String::as_str), Some("1")); + } + + #[test] + fn env_rules_nod3d11_clears_dxgi() { + let mut c = BTreeSet::new(); + c.insert("nod3d11".into()); + apply_forcelgadd_default(&mut c); + let mut env = HashMap::new(); + let mut dll: Vec<(String, String)> = vec![("dxgi".to_string(), "n,b".to_string())]; + apply_proton_env_rules(883710, &c, &mut env, &mut dll); + let d3d11 = dll.iter().find(|(d, _)| d == "d3d11").map(|(_, v)| v.clone()); + assert_eq!(d3d11.as_deref(), Some("")); + assert!(!dll.iter().any(|(d, _)| d == "dxgi")); + } + + #[test] + fn env_rules_emit_proton_input_vars() { + // Native Steam's launch env carries PROTON_* input vars for active + // compat options; the ABI must emit them (test-diff parity). + let mut c = default_compat_config(883710); + apply_forcelgadd_default(&mut c); // → forcelgadd active + c.insert("wined3d".into()); + let mut env = HashMap::new(); + let mut dll: Vec<(String, String)> = Vec::new(); + apply_proton_env_rules(883710, &c, &mut env, &mut dll); + assert_eq!( + env.get("PROTON_FORCE_LARGE_ADDRESS_AWARE").map(String::as_str), + Some("1") + ); + assert_eq!(env.get("PROTON_USE_WINED3D").map(String::as_str), Some("1")); + assert_eq!(env.get("WINE_LARGE_ADDRESS_AWARE").map(String::as_str), Some("1")); + } + + #[test] + fn seed_prefix_creates_symlinks_and_version() { + let tmp = std::env::temp_dir().join(format!("proton_abi_test_{}", std::process::id())); + let pfx = tmp.join("pfx"); + let default = tmp.join("default_pfx"); + std::fs::create_dir_all(default.join("drive_c/windows")).unwrap(); + std::fs::write(default.join("system.reg"), "#test").unwrap(); + std::fs::write(default.join("drive_c/windows/win.ini"), "[fonts]\n").unwrap(); + + let created = seed_prefix(&default, &pfx, "proton-11.0-1b").unwrap(); + assert!(pfx.join("dosdevices/c:").exists()); + assert!(pfx.join("dosdevices/z:").exists()); + assert_eq!(std::fs::read_to_string(pfx.join("version")).unwrap(), "proton-11.0-1b\n"); + assert_eq!(std::fs::read_to_string(pfx.join("system.reg")).unwrap(), "#test"); + assert_eq!( + std::fs::read_to_string(pfx.join("drive_c/windows/win.ini")).unwrap(), + "[fonts]\n" + ); + assert!(!created.is_empty()); + // c: is a symlink to ../drive_c + let target = std::fs::read_link(pfx.join("dosdevices/c:")).unwrap(); + assert_eq!(target, PathBuf::from("../drive_c")); + + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn cpu_limit_table() { + assert_eq!(default_cpu_limit(19900), Some(16)); // Far Cry 2 + assert_eq!(default_cpu_limit(2229830), Some(1)); // C&C + assert_eq!(default_cpu_limit(883710), None); // RE2 not listed + } +} diff --git a/src/steam_client.rs b/src/steam_client.rs index 80281aa6..72cbfe7e 100644 --- a/src/steam_client.rs +++ b/src/steam_client.rs @@ -26,7 +26,8 @@ use steam_vent::auth::{ use steam_vent::connection::Connection; use steam_vent::proto::steammessages_clientserver::CMsgClientGetAppOwnershipTicket; use steam_vent::proto::steammessages_clientserver_2::{ - CMsgClientGetDepotDecryptionKey, CMsgClientGetDepotDecryptionKeyResponse, + CMsgClientGetCDNAuthToken, CMsgClientGetCDNAuthTokenResponse, CMsgClientGetDepotDecryptionKey, + CMsgClientGetDepotDecryptionKeyResponse, }; use steam_vent::proto::steammessages_clientserver_appinfo::{ cmsg_client_picsproduct_info_request, CMsgClientPICSProductInfoRequest, @@ -1062,6 +1063,14 @@ impl SteamClient { ) { tracing::warn!("failed writing appmanifest for {}: {}", appid, err); } + // Phase 2 item 3 (valve-stack directive): after a successful + // depot download, surface the runner's version into + // VERSIONS.txt at the install root so bundled components + // display real versions instead of `found(bundled)`. Only + // fires when version info is harvestable (Proton tool trees + // ship `version` files; a plain game dir yields nothing and + // the write is skipped). + crate::utils::write_runner_versions_txt(&install_dir, &installdir); let _ = tx .send(DownloadProgress { state: DownloadProgressState::Completed, @@ -1190,15 +1199,22 @@ impl SteamClient { host_name: &str, ) -> Result { let connection = self.connection.as_ref().ok_or_else(|| anyhow!("No connection"))?; - let mut request = CContentServerDirectory_GetCDNAuthToken_Request::new(); - request.set_app_id(app_id); + let mut request = CMsgClientGetCDNAuthToken::new(); request.set_depot_id(depot_id); request.set_host_name(host_name.to_string()); + request.set_app_id(app_id); - let response: CContentServerDirectory_GetCDNAuthToken_Response = connection - .service_method(request) + // NOTE: the ContentServerDirectory.GetCDNAuthToken SERVICE variant returns + // ERESULT Fail server-side; the real client uses the job-based + // CMsgClientGetCDNAuthToken (same shape as GetDepotDecryptionKey). + let response: CMsgClientGetCDNAuthTokenResponse = connection + .job(request) .await - .context("failed calling ContentServerDirectory.GetCDNAuthToken")?; + .context("failed calling GetCDNAuthToken job")?; + + if response.eresult() != 1 { + return Err(anyhow!("GetCDNAuthToken returned eresult {}", response.eresult())); + } if response.token().is_empty() { return Err(anyhow!("Empty Auth Token returned")); @@ -2328,6 +2344,51 @@ impl SteamClient { } } } + // Tool apps (Proton etc.) carry installdir under + // appinfo.config.installdir, NOT common.installdir — + // and parse_appinfo can fail entirely on them. Fall back + // to a direct VDF walk so tools install into the same + // directory real Steam uses (e.g. "Proton - Experimental"). + if installdir.is_none() || display_name.starts_with("App ") { + if let Ok(vdf) = find_vdf_in_pics(app.buffer()) { + let info_obj = vdf.as_obj().and_then(|root| { + if vdf.key() == "appinfo" || vdf.key() == appid.to_string() { + Some(root) + } else { + root.get("appinfo") + .and_then(|v| v.as_obj()) + .or(Some(root)) + } + }); + if let Some(info) = info_obj { + if display_name.starts_with("App ") { + if let Some(name) = info + .get("common") + .and_then(|v| v.as_obj()) + .and_then(|c| c.get("name")) + .and_then(|v| v.as_str()) + { + display_name = name.to_string(); + } + } + if installdir.is_none() { + let from_common = info + .get("common") + .and_then(|v| v.as_obj()) + .and_then(|c| c.get("installdir")) + .and_then(|v| v.as_str()) + .map(str::to_string); + let from_config = info + .get("config") + .and_then(|v| v.as_obj()) + .and_then(|c| c.get("installdir")) + .and_then(|v| v.as_str()) + .map(str::to_string); + installdir = from_common.or(from_config); + } + } + } + } } } } @@ -2551,6 +2612,370 @@ impl SteamClient { has_autologin } + /// Parses the login Timestamp (unix seconds) from a prefix's + /// `config/loginusers.vdf`, if present. Used as a freshness comparison + /// between master and per-game login state. + fn loginusers_timestamp(steam_dir: &Path) -> Option { + let raw = std::fs::read_to_string(steam_dir.join("config/loginusers.vdf")).ok()?; + for line in raw.lines() { + let t = line.trim(); + if let Some(rest) = t.strip_prefix("\"Timestamp\"") { + let rest = rest.trim_start().strip_prefix('"')?; + let v = rest.split('"').next()?; + return v.trim().parse::().ok(); + } + } + None + } + + /// Locates a VDF block whose header line is exactly `"key"` (on its own + /// line) followed by an opening brace, and returns + /// `(header_idx, brace_idx, end_idx_exclusive)` — the whole block from the + /// header through the matching closing brace. `prefer_last` picks the last + /// occurrence (used for the `"Steam"` insertion anchor, since nested + /// config keys may reuse the name). + fn find_vdf_block( + lines: &[&str], + key: &str, + prefer_last: bool, + ) -> Option<(usize, usize, usize)> { + let header = format!("\"{key}\""); + let mut found = None; + for i in 0..lines.len() { + if lines[i].trim() != header { + continue; + } + // Next non-empty line must be the opening brace. + let Some(j) = (i + 1..lines.len()).find(|&j| !lines[j].trim().is_empty()) else { + continue; + }; + if lines[j].trim() != "{" { + continue; + } + // Match braces to find the block end (header .. closing brace). + let mut depth = 0i32; + let mut end = None; + for (k, line) in lines.iter().enumerate().skip(j) { + let t = line.trim(); + if t.starts_with('{') { + depth += 1; + } + if t == "}" { + depth -= 1; + } + if depth == 0 { + end = Some((i, j, k + 1)); + break; + } + } + if let Some(e) = end { + if !prefer_last { + return Some(e); + } + found = Some(e); + } + } + found + } + + /// Joins lines preserving the original line ending (`\r\n` when the source + /// used it, else `\n`), so a merged file keeps its original EOL style. + fn join_lines_preserving_eol(lines: Vec, source_used_crlf: bool) -> String { + let sep = if source_used_crlf { "\r\n" } else { "\n" }; + lines.join(sep) + } + + /// Merges the `"Authentication"` block (RememberedMachineID JWT — the + /// machine-bound login token) from the master `config.vdf` into the + /// target's `config.vdf`. + /// + /// The target's other per-prefix keys (Streaming, language, …) are + /// preserved verbatim. When the target lacks an Authentication block, the + /// master's is inserted right after the `"Steam"` section's opening brace. + /// Returns the target unchanged when master has no auth block or the + /// insertion anchor cannot be found. + fn merge_vdf_authentication(target_raw: &str, master_raw: &str) -> String { + const KEY: &str = "Authentication"; + let t_lines: Vec<&str> = target_raw.lines().collect(); + let m_lines: Vec<&str> = master_raw.lines().collect(); + let crlf = target_raw.contains("\r\n"); + + let Some((_, _, m_end)) = Self::find_vdf_block(&m_lines, KEY, false) else { + return target_raw.to_string(); // master has no auth block — nothing to sync + }; + let m_block: Vec = m_lines[..m_end].iter().map(|s| s.to_string()).collect(); + + let mut out: Vec = Vec::new(); + match Self::find_vdf_block(&t_lines, KEY, false) { + Some((t_start, _, t_end)) => { + out.extend(t_lines[..t_start].iter().map(|s| s.to_string())); + out.extend(m_block); + out.extend(t_lines[t_end..].iter().map(|s| s.to_string())); + } + None => { + // Insert after the LAST "Steam" section's opening brace (the + // real config section; nested keys may reuse the name). + let Some((_, brace_idx, _)) = Self::find_vdf_block(&t_lines, "Steam", true) else { + return target_raw.to_string(); + }; + out.extend(t_lines[..=brace_idx].iter().map(|s| s.to_string())); + out.extend(m_block); + out.extend(t_lines[brace_idx + 1..].iter().map(|s| s.to_string())); + } + } + Self::join_lines_preserving_eol(out, crlf) + } + + /// Locates a `user.reg` section `[key] timestamp` (exact key match — a + /// subkey like `[Software\\Valve\\Steam\\Apps]` does NOT match the key + /// `Software\\Valve\\Steam`). Returns `(header_idx, end_idx_exclusive)`, + /// where the section runs from its header line to the next `[` line (or + /// EOF). + fn find_user_reg_section(lines: &[&str], key: &str) -> Option<(usize, usize)> { + let header_prefix = format!("[{key}]"); + for i in 0..lines.len() { + let t = lines[i].trim_start(); + if !t.starts_with(&header_prefix) { + continue; + } + let rest = t[header_prefix.len()..].trim(); + // Header may be `[key]`, `[key] ` — but never `[key\\sub]`. + if !rest.is_empty() && !rest.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let end = (i + 1..lines.len()) + .find(|&j| lines[j].trim_start().starts_with('[')) + .unwrap_or(lines.len()); + return Some((i, end)); + } + None + } + + /// Overlays the `[Software\\Valve\\Steam]` section (AutoLoginUser, + /// RememberPassword, …) from the master's `user.reg` onto the target's. + /// + /// Per-key merge: master's values win for keys present in both, master-only + /// keys are appended, and the target's own keys/subkeys (registry fixups, + /// Steam\\Apps, Steam\\ActiveProcess, unrelated apps) are preserved. When + /// the target lacks the section entirely, the master's section is inserted + /// before the first `[Software\\…]` sibling (or before the first `[` line, + /// or appended). Returns the target unchanged when master has no such + /// section. + fn merge_user_reg_steam_section(target_raw: &str, master_raw: &str) -> String { + const KEY: &str = "Software\\\\Valve\\\\Steam"; + let t_lines: Vec<&str> = target_raw.lines().collect(); + let m_lines: Vec<&str> = master_raw.lines().collect(); + let crlf = target_raw.contains("\r\n"); + + let Some((m_start, m_end)) = Self::find_user_reg_section(&m_lines, KEY) else { + return target_raw.to_string(); + }; + + // key(lowercased) → master's line, for keys inside master's section. + let mut master_keys: HashMap = HashMap::new(); + for line in &m_lines[m_start + 1..m_end] { + let t = line.trim_start(); + if let Some(close) = t.strip_prefix('"').and_then(|r| r.find('"')) { + master_keys.insert(t[1..1 + close].to_lowercase(), (*line).to_string()); + } + } + if master_keys.is_empty() { + return target_raw.to_string(); + } + + let mut out: Vec = Vec::new(); + match Self::find_user_reg_section(&t_lines, KEY) { + Some((t_start, t_end)) => { + // Header from master (carries the fresh login timestamp); + // content = target's lines with master's keys overlaid. + out.push(m_lines[m_start].to_string()); + for line in &t_lines[t_start + 1..t_end] { + let t = line.trim_start(); + if let Some(close) = t.strip_prefix('"').and_then(|r| r.find('"')) { + let k = t[1..1 + close].to_lowercase(); + if let Some(mv) = master_keys.remove(&k) { + out.push(mv); + continue; + } + } + out.push((*line).to_string()); + } + // Master-only keys, appended in master order. + let mut extra: Vec = master_keys.into_values().collect(); + out.append(&mut extra); + out.extend(t_lines[t_end..].iter().map(|s| s.to_string())); + } + None => { + let m_section: Vec = m_lines[m_start..m_end] + .iter() + .map(|s| s.to_string()) + .collect(); + // Insert before the first `[Software\\` sibling if any. + let mut inserted = false; + for line in &t_lines { + if !inserted && line.trim_start().starts_with("[Software\\\\") { + out.extend(m_section.clone()); + inserted = true; + } + out.push((*line).to_string()); + } + if !inserted { + // Otherwise before the first `[` line, else append. + match t_lines.iter().position(|l| l.trim_start().starts_with('[')) { + Some(i) => { + out.clear(); + out.extend(t_lines[..i].iter().map(|s| s.to_string())); + out.extend(m_section); + out.extend(t_lines[i..].iter().map(|s| s.to_string())); + } + None => { + out.clear(); + out.extend(m_section); + out.extend(t_lines.iter().map(|s| s.to_string())); + } + } + } + } + } + Self::join_lines_preserving_eol(out, crlf) + } + + /// Synchronizes the Windows Steam client's authentication/session state + /// from the master Steam install into a per-game prefix's Steam directory. + /// + /// The background client in a per-game prefix is spawned headless + /// (`-silent -noreactlogin`) and must auto-login to answer Steamworks + /// ownership queries. Modern Steam (2026+) persists auth via: + /// - `config/loginusers.vdf` (account entry: AutoLogin/RememberPassword + /// plus a fresh `Timestamp`) + /// - `config/config.vdf` (`Authentication → RememberedMachineID` + /// JWT — the machine-bound token; it EXPIRES after ~90 days) + /// - `HKCU\Software\Valve\Steam` registry keys (`AutoLoginUser`, …) + /// `ssfn*` sentry files are legacy (pre-2026) but copied when present. + /// + /// A per-game prefix seeded months ago carries an EXPIRED machine token + /// (its RememberedMachineID JWT has a past `exp`); the client then starts + /// anonymous (SteamID 0) and `SteamAPI_Init` fails with "Steam is not + /// running" even though the client process is alive and the pipe is + /// reachable. This sync refreshes the token + login state from master. + /// + /// Guards: does nothing when the master client has no session, and never + /// clobbers a per-game login that is as fresh as (or fresher than) the + /// master's. Non-fatal: failures log a warning and the launch proceeds. + /// + /// Returns the number of items synchronized (0 = nothing needed). + pub fn sync_master_session_to_prefix( + master_steam_dir: &Path, + master_prefix: &Path, + target_steam_dir: &Path, + target_prefix: &Path, + ) -> Result { + use anyhow::Context; + + // 1) Nothing to share if the master client itself is not logged in. + if !Self::windows_client_has_session(master_prefix) { + tracing::info!("Steam session sync: master client has no session — skipping"); + return Ok(0); + } + + // 2) Freshness guard: don't downgrade a per-game login newer than + // master's (e.g. the user logged in manually in that prefix). + let master_ts = Self::loginusers_timestamp(master_steam_dir); + let target_ts = Self::loginusers_timestamp(target_steam_dir); + if let (Some(m), Some(t)) = (master_ts, target_ts) { + if t >= m { + tracing::debug!( + "Steam session sync: target login as fresh as master ({t} >= {m}) — skipping" + ); + return Ok(0); + } + } + + let mut synced = 0usize; + std::fs::create_dir_all(target_steam_dir.join("config")).ok(); + + // 3) config/config.vdf — merge the Authentication (RememberedMachineID + // JWT) block from master so the target carries a fresh token while + // preserving the target's other per-prefix keys (streaming + // ClientID, language, …). + let master_cfg = master_steam_dir.join("config/config.vdf"); + let target_cfg = target_steam_dir.join("config/config.vdf"); + if master_cfg.exists() { + let m_raw = std::fs::read_to_string(&master_cfg) + .with_context(|| format!("read master config.vdf {}", master_cfg.display()))?; + let t_raw = std::fs::read_to_string(&target_cfg).unwrap_or_default(); + let merged = Self::merge_vdf_authentication(&t_raw, &m_raw); + if merged != t_raw { + std::fs::write(&target_cfg, merged).with_context(|| { + format!("write synced config.vdf {}", target_cfg.display()) + })?; + synced += 1; + } + } + + // 4) config/loginusers.vdf — copy wholesale (the account registry). + let master_lu = master_steam_dir.join("config/loginusers.vdf"); + let target_lu = target_steam_dir.join("config/loginusers.vdf"); + if master_lu.exists() { + let m = std::fs::read(&master_lu) + .with_context(|| format!("read master loginusers.vdf {}", master_lu.display()))?; + let t = std::fs::read(&target_lu).unwrap_or_default(); + if m != t { + std::fs::write(&target_lu, m).with_context(|| { + format!("write synced loginusers.vdf {}", target_lu.display()) + })?; + synced += 1; + } + } + + // 5) ssfn* sentry files (legacy machine auth, pre-2026 clients). + if let Ok(entries) = std::fs::read_dir(master_steam_dir) { + for e in entries.flatten() { + let name = e.file_name(); + let name_s = name.to_string_lossy(); + if name_s.starts_with("ssfn") { + let src = e.path(); + let dst = target_steam_dir.join(&name); + let m = std::fs::read(&src) + .with_context(|| format!("read master sentry {}", src.display()))?; + let t = std::fs::read(&dst).unwrap_or_default(); + if m != t { + std::fs::write(&dst, m).with_context(|| { + format!("write synced sentry {}", dst.display()) + })?; + synced += 1; + } + } + } + } + + // 6) HKCU\Software\Valve\Steam — merge the top-level registry section + // from master's user.reg into the target's (preserves subkeys like + // Steam\Apps and ActiveProcess, and all unrelated apps' sections). + let master_reg = master_prefix.join("user.reg"); + let target_reg = target_prefix.join("user.reg"); + if master_reg.exists() { + let m_raw = std::fs::read_to_string(&master_reg) + .with_context(|| format!("read master user.reg {}", master_reg.display()))?; + let t_raw = std::fs::read_to_string(&target_reg).unwrap_or_default(); + let merged = Self::merge_user_reg_steam_section(&t_raw, &m_raw); + if merged != t_raw { + std::fs::write(&target_reg, merged).with_context(|| { + format!("write synced user.reg {}", target_reg.display()) + })?; + synced += 1; + } + } + + if synced > 0 { + tracing::info!( + "Steam session sync: {synced} item(s) synchronized from master into {}", + target_steam_dir.display() + ); + } + Ok(synced) + } + /// Registers the native Linux Steam library folders into the Windows Steam /// client's `steamapps/libraryfolders.vdf` so the client reports games /// installed by native Steam as installed. Without this, a strict Steamworks @@ -4274,6 +4699,253 @@ mod windows_client_login_tests { assert!(after.contains("\\Steam\"")); let _ = std::fs::remove_dir_all(&tmp); } + + // ---- merge_vdf_authentication ---- + + fn vdf_fixture(remembered: &str, with_streaming: bool) -> String { + let streaming = if with_streaming { + "\t\t\t\t\"Streaming\"\n\t\t\t\t{\n\t\t\t\t\t\"SteamBroadcast\"\t\t\"1\"\n\t\t\t\t}\n" + } else { + "" + }; + format!( + "\"InstallConfigStore\"\n{{\n\t\"Software\"\n\t{{\n\t\t\"Valve\"\n\t\t{{\n\t\t\t\"Steam\"\n\t\t\t{{\n{streaming}\t\t\t\t\"Authentication\"\n\t\t\t\t{{\n\t\t\t\t\t\"RememberedMachineID\"\t\t\"{remembered}\"\n\t\t\t\t}}\n\t\t\t}}\n\t\t}}\n\t}}\n}}\n" + ) + } + + #[test] + fn merge_vdf_authentication_replaces_stale_token() { + let target = vdf_fixture("STALE_JWT", true); + let master = vdf_fixture("FRESH_JWT_abc123", false); + + let merged = SteamClient::merge_vdf_authentication(&target, &master); + + assert!(merged.contains("FRESH_JWT_abc123"), "fresh JWT must be merged in"); + assert!(!merged.contains("STALE_JWT"), "stale JWT must be gone"); + assert!( + merged.contains("\"SteamBroadcast\"\t\t\"1\""), + "target-only Streaming block must be preserved" + ); + assert!(merged.contains("\"InstallConfigStore\""), "structure intact"); + } + + #[test] + fn merge_vdf_authentication_inserts_when_missing() { + // Target config.vdf has no Authentication block at all (fresh prefix). + let target = "\"InstallConfigStore\"\n{\n\t\"Software\"\n\t{\n\t\t\"Valve\"\n\t\t{\n\t\t\t\"Steam\"\n\t\t\t{\n\t\t\t\t\"Streaming\"\n\t\t\t\t{\n\t\t\t\t\t\"SteamBroadcast\"\t\t\"1\"\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"; + let master = vdf_fixture("FRESH_JWT_xyz", false); + + let merged = SteamClient::merge_vdf_authentication(target, &master); + + assert!(merged.contains("FRESH_JWT_xyz"), "auth block must be inserted"); + assert!(merged.contains("\"SteamBroadcast\""), "target keys preserved"); + assert!(merged.contains("\"Authentication\""), "block header present"); + } + + #[test] + fn merge_vdf_authentication_returns_target_when_master_lacks_block() { + let target = vdf_fixture("TARGET_JWT", true); + let master = "\"InstallConfigStore\"\n{\n\t\"Software\"\n\t{\n\t\t\"Valve\"\n\t\t{\n\t\t\t\"Steam\"\n\t\t\t{\n\t\t\t}\n\t\t}\n\t}\n}\n"; + let merged = SteamClient::merge_vdf_authentication(&target, master); + assert_eq!(merged, target, "no master auth block -> target unchanged"); + } + + // ---- merge_user_reg_steam_section ---- + + fn user_reg_with_steam(steam_keys: &str) -> String { + format!( + "WINE REGISTRY Version 2\n;; All keys relative to machine and users root.\n\n[Software\\\\Valve\\\\Steam] 1432929821\n{steam_keys}\n[Software\\\\Valve\\\\Steam\\\\Apps] 1432929821\n\"883710\"=dword:00000001\n" + ) + } + + #[test] + fn merge_user_reg_steam_section_overlays_login_keys() { + let target = user_reg_with_steam("\"D3D12\"=dword:00000001\n\"AutoLoginUser\"=\"otheruser\"\n"); + let master = user_reg_with_steam( + "\"AutoLoginUser\"=\"weterok12\"\n\"RememberPassword\"=dword:00000001\n\"LastLogin\"=dword:5c9e0000\n", + ); + + let merged = SteamClient::merge_user_reg_steam_section(&target, &master); + + assert!( + merged.contains("\"AutoLoginUser\"=\"weterok12\""), + "master login user must win" + ); + assert!(!merged.contains("otheruser"), "stale target user replaced"); + assert!( + merged.contains("\"RememberPassword\"=dword:00000001"), + "master-only key appended" + ); + assert!( + merged.contains("\"D3D12\"=dword:00000001"), + "target-only key preserved" + ); + assert!( + merged.contains("[Software\\\\Valve\\\\Steam\\\\Apps]") && merged.contains("\"883710\""), + "subkey section preserved" + ); + } + + #[test] + fn merge_user_reg_steam_section_inserts_when_missing() { + let target = "WINE REGISTRY Version 2\n;; All keys relative to machine and users root.\n\n[Software\\\\Valve\\\\Steam\\\\Apps] 1432929821\n\"620\"=dword:00000001\n"; + let master = user_reg_with_steam("\"AutoLoginUser\"=\"weterok12\"\n"); + + let merged = SteamClient::merge_user_reg_steam_section(target, &master); + + assert!( + merged.contains("[Software\\\\Valve\\\\Steam] 1432929821"), + "Steam section inserted" + ); + assert!( + merged.contains("\"AutoLoginUser\"=\"weterok12\""), + "login key present" + ); + assert!( + merged.contains("[Software\\\\Valve\\\\Steam\\\\Apps]") && merged.contains("\"620\""), + "existing subkey section preserved after insert" + ); + } + + #[test] + fn merge_user_reg_steam_section_preserves_crlf() { + let target = "WINE REGISTRY Version 2\r\n\r\n[Software\\\\Valve\\\\Steam] 1\r\n\"D3D12\"=dword:00000001\r\n"; + let master = user_reg_with_steam("\"AutoLoginUser\"=\"weterok12\"\n"); + let merged = SteamClient::merge_user_reg_steam_section(target, &master); + assert!( + merged.contains("\r\n"), + "CRLF line endings preserved for target file" + ); + assert!(merged.contains("\"AutoLoginUser\"=\"weterok12\"")); + } + + // ---- sync_master_session_to_prefix (end to end) ---- + + fn fake_prefix_pair(tag: &str) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) { + let tmp = std::env::temp_dir().join(format!("steamflow_sync_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let master_prefix = tmp.join("master"); + let target_prefix = tmp.join("target"); + let master_steam = master_prefix.join("drive_c/Program Files (x86)/Steam"); + let target_steam = target_prefix.join("drive_c/Program Files (x86)/Steam"); + std::fs::create_dir_all(master_steam.join("config")).unwrap(); + std::fs::create_dir_all(target_steam.join("config")).unwrap(); + std::fs::write(master_steam.join("steam.exe"), b"MZ fake").unwrap(); + std::fs::write(target_steam.join("steam.exe"), b"MZ fake").unwrap(); + (master_prefix, target_prefix, master_steam, target_steam) + } + + #[test] + fn sync_master_session_to_prefix_copies_and_merges() { + let (master_prefix, target_prefix, master_steam, target_steam) = fake_prefix_pair("e2e"); + + // Master: fresh login state. + std::fs::write( + master_steam.join("config/loginusers.vdf"), + b"\"users\"\n{\n\t\"76561198097817215\"\n\t{\n\t\t\"AccountName\"\t\t\"weterok12\"\n\t\t\"AutoLogin\"\t\t\"1\"\n\t\t\"Timestamp\"\t\t\"1786715012\"\n\t}\n}\n", + ) + .unwrap(); + std::fs::write(master_steam.join("config/config.vdf"), vdf_fixture("MASTER_JWT", false).as_bytes()).unwrap(); + std::fs::write( + master_prefix.join("user.reg"), + "WINE REGISTRY Version 2\n\n[Software\\\\Valve\\\\Steam] 2000000000\n\"AutoLoginUser\"=\"weterok12\"\n\"RememberPassword\"=dword:00000001\n", + ) + .unwrap(); + std::fs::write(master_steam.join("ssfn1234567890123456789"), b"sentry").unwrap(); + + // Target: stale login state (Feb-28-era timestamp) + per-prefix keys. + std::fs::write( + target_steam.join("config/loginusers.vdf"), + b"\"users\"\n{\n\t\"76561198097817215\"\n\t{\n\t\t\"AccountName\"\t\t\"weterok12\"\n\t\t\"AutoLogin\"\t\t\"1\"\n\t\t\"Timestamp\"\t\t\"1772269365\"\n\t}\n}\n", + ) + .unwrap(); + std::fs::write(target_steam.join("config/config.vdf"), vdf_fixture("STALE_JWT", true).as_bytes()).unwrap(); + std::fs::write( + target_prefix.join("user.reg"), + "WINE REGISTRY Version 2\n\n[Software\\\\Valve\\\\Steam] 1432929821\n\"D3D12\"=dword:00000001\n", + ) + .unwrap(); + + let n = SteamClient::sync_master_session_to_prefix( + &master_steam, + &master_prefix, + &target_steam, + &target_prefix, + ) + .unwrap(); + + // loginusers.vdf (fresh), config.vdf (JWT), user.reg (login keys), ssfn sentry. + assert!(n >= 4, "expected >=4 synced items, got {n}"); + + let lu = std::fs::read_to_string(target_steam.join("config/loginusers.vdf")).unwrap(); + assert!(lu.contains("\"Timestamp\"\t\t\"1786715012\""), "fresh loginusers copied"); + + let cfg = std::fs::read_to_string(target_steam.join("config/config.vdf")).unwrap(); + assert!(cfg.contains("MASTER_JWT"), "fresh machine token merged"); + assert!(cfg.contains("\"SteamBroadcast\""), "target-only config keys preserved"); + + let reg = std::fs::read_to_string(target_prefix.join("user.reg")).unwrap(); + assert!(reg.contains("\"AutoLoginUser\"=\"weterok12\""), "registry login keys synced"); + assert!(reg.contains("\"D3D12\"=dword:00000001"), "target-only registry keys preserved"); + + assert!(target_steam.join("ssfn1234567890123456789").exists(), "ssfn sentry copied"); + + let _ = std::fs::remove_dir_all(&master_prefix.parent().unwrap()); + } + + #[test] + fn sync_master_session_to_prefix_skips_when_master_has_no_session() { + let (master_prefix, target_prefix, master_steam, target_steam) = fake_prefix_pair("nosession"); + // Master has steam.exe but no loginusers.vdf / ssfn -> no session. + std::fs::write( + target_steam.join("config/loginusers.vdf"), + b"\"users\"\n{\n}\n", + ) + .unwrap(); + + let n = SteamClient::sync_master_session_to_prefix( + &master_steam, + &master_prefix, + &target_steam, + &target_prefix, + ) + .unwrap(); + + assert_eq!(n, 0, "no master session -> nothing synced"); + let lu = std::fs::read_to_string(target_steam.join("config/loginusers.vdf")).unwrap(); + assert_eq!(lu, "\"users\"\n{\n}\n", "target untouched"); + let _ = std::fs::remove_dir_all(&master_prefix.parent().unwrap()); + } + + #[test] + fn sync_master_session_to_prefix_skips_when_target_fresher() { + let (master_prefix, target_prefix, master_steam, target_steam) = fake_prefix_pair("fresher"); + // Master logged in OLDER than target (target manually logged in today). + std::fs::write( + master_steam.join("config/loginusers.vdf"), + b"\"users\"\n{\n\t\"76561198097817215\"\n\t{\n\t\t\"AccountName\"\t\t\"weterok12\"\n\t\t\"AutoLogin\"\t\t\"1\"\n\t\t\"Timestamp\"\t\t\"1772269365\"\n\t}\n}\n", + ) + .unwrap(); + std::fs::write( + target_steam.join("config/loginusers.vdf"), + b"\"users\"\n{\n\t\"76561198097817215\"\n\t{\n\t\t\"AccountName\"\t\t\"weterok12\"\n\t\t\"AutoLogin\"\t\t\"1\"\n\t\t\"Timestamp\"\t\t\"1786715012\"\n\t}\n}\n", + ) + .unwrap(); + let before = std::fs::read_to_string(target_steam.join("config/loginusers.vdf")).unwrap(); + + let n = SteamClient::sync_master_session_to_prefix( + &master_steam, + &master_prefix, + &target_steam, + &target_prefix, + ) + .unwrap(); + + assert_eq!(n, 0, "fresher target must not be downgraded"); + let after = std::fs::read_to_string(target_steam.join("config/loginusers.vdf")).unwrap(); + assert_eq!(before, after, "target login untouched"); + let _ = std::fs::remove_dir_all(&master_prefix.parent().unwrap()); + } } #[cfg(test)] diff --git a/src/ui.rs b/src/ui.rs index 2c1527ae..52735242 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2102,7 +2102,7 @@ impl SteamLauncher { &game_name, std::path::Path::new(&exec_path), ) { - Ok(()) => { + Ok(_child) => { let _ = tx.send(AsyncOp::ModLauncherLaunched); } Err(e) => { @@ -2275,6 +2275,7 @@ impl SteamLauncher { &self.launcher_config, game_app_id, &self.user_configs, + None, // UI ops use the configured mode (display/management) ); SteamClient::kill_steam_in_prefix(&prefix, true); self.status = "Steam stopped".to_string(); @@ -2287,6 +2288,7 @@ impl SteamLauncher { &self.launcher_config, game_app_id, &self.user_configs, + None, // UI ops use the configured mode (display/management) ); crate::utils::kill_all_wine_in_prefix(&prefix, false); self.status = "All Wine processes in prefix terminated".to_string(); @@ -2475,6 +2477,7 @@ impl SteamLauncher { &self.launcher_config, game_app_id, &self.user_configs, + None, // UI ops use the configured mode (display/management) ); SteamClient::kill_steam_in_prefix(&prefix, kill_webhelper); self.status = "Steam feature settings changed; Steam will restart on next launch".to_string(); @@ -2809,6 +2812,7 @@ impl SteamLauncher { &self.launcher_config, app_id, &self.user_configs, + None, // UI ops use the configured mode (display/management) ) } else { std::path::PathBuf::from(&self.launcher_config.steam_library_path) diff --git a/src/utils.rs b/src/utils.rs index a75120f8..d3bf99ba 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1383,6 +1383,9 @@ pub struct RunnerVersions { pub wine_mono: Option, pub wine_gecko: Option, pub build_date: Option, + /// The runner's own version (the tarball/release version SteamFlow + /// extracted). Written by `write_runner_versions_txt` during extraction. + pub runner_version: Option, } /// Parse VERSIONS.txt from the runner root. Each line is KEY=VALUE. @@ -1410,6 +1413,7 @@ pub fn read_versions_txt(root: &Path) -> RunnerVersions { "WINE_MONO_VERSION" => versions.wine_mono = Some(val.to_string()), "WINE_GECKO_VERSION" => versions.wine_gecko = Some(val.to_string()), "BUILD_DATE" => versions.build_date = Some(val.to_string()), + "RUNNER_VERSION" => versions.runner_version = Some(val.to_string()), _ => {} } } @@ -1417,6 +1421,128 @@ pub fn read_versions_txt(root: &Path) -> RunnerVersions { versions } +/// Write a canonical `VERSIONS.txt` at the runner root after extracting a +/// runner tarball (Phase 2 item 3 of the valve-stack directive — kill the +/// `found(bundled)` display bug). +/// +/// Detection (`detect_*`) returns the placeholder `"found"` when no `version` +/// file sits next to a component DLL (flat WoW64 layout). `apply_versions_override` +/// treats `"found"` as needing the `VERSIONS.txt` override — but the override +/// only exists if SteamFlow writes the file. This function: +/// +/// 1. Harvests the component versions the tarball ships (the classic +/// `files/lib/wine//version` files, root `version` file), and +/// 2. Stamps the runner's own tarball/release version as `RUNNER_VERSION`. +/// +/// It never overwrites an existing `VERSIONS.txt` (e.g. the custom +/// `steamflow-runner` ships an authoritative one). Non-fatal: failures log and +/// return `Ok(false)` so extraction is never blocked by version bookkeeping. +pub fn write_runner_versions_txt(runner_root: &Path, tarball_version: &str) -> bool { + let path = runner_root.join("VERSIONS.txt"); + if path.exists() { + tracing::debug!( + "VERSIONS.txt already exists at {} — leaving it untouched", + path.display() + ); + return false; + } + + let mut lines: Vec = Vec::new(); + + // (VERSIONS.txt key, candidate version-file paths under the runner root) + let harvest: &[(&str, &[&str])] = &[ + ( + "DXVK_VERSION", + &[ + "files/lib/wine/dxvk/version", + "lib/wine/dxvk/version", + "dist/lib/wine/dxvk/version", + ], + ), + ( + "D7VK_VERSION", + &[ + "files/lib/wine/d7vk/version", + "lib/wine/d7vk/version", + "dist/lib/wine/d7vk/version", + ], + ), + ( + "VKD3D_PROTON_VERSION", + &[ + "files/lib/wine/vkd3d-proton/version", + "lib/wine/vkd3d-proton/version", + "dist/lib/wine/vkd3d-proton/version", + ], + ), + ( + "VKD3D_VERSION", + &[ + "files/lib/vkd3d/version", + "lib/vkd3d/version", + "dist/lib/vkd3d/version", + ], + ), + ( + "DXVK_NVAPI_VERSION", + &[ + "files/lib/wine/nvapi/version", + "lib/wine/nvapi/version", + "dist/lib/wine/nvapi/version", + "files/lib/wine/dxvk-nvapi/version", + ], + ), + ]; + + for (key, candidates) in harvest { + let found = candidates.iter().find_map(|rel| { + let p = runner_root.join(rel); + std::fs::read_to_string(&p) + .ok() + .map(|s| parse_short_version(&s)) + .filter(|v| v != "unknown" && !v.is_empty()) + }); + if let Some(v) = found { + lines.push(format!("{key}={v}")); + } + } + + // Runner's own version: root `version` file (Proton layout, e.g. + // "1785138253 proton-11.0-1b") takes precedence; else the tarball version. + let runner_version = std::fs::read_to_string(runner_root.join("version")) + .ok() + .map(|s| parse_short_version(&s)) + .filter(|v| v != "unknown" && !v.is_empty()) + .unwrap_or_else(|| tarball_version.trim().to_string()); + if !runner_version.is_empty() { + lines.push(format!("RUNNER_VERSION={runner_version}")); + } + + if lines.is_empty() { + tracing::debug!( + "No version info harvestable from {} — not writing VERSIONS.txt", + runner_root.display() + ); + return false; + } + + let content = format!("{}\n", lines.join("\n")); + match std::fs::write(&path, content) { + Ok(()) => { + tracing::info!( + "Wrote VERSIONS.txt at {} ({} entries, runner {runner_version})", + path.display(), + lines.len() + ); + true + } + Err(e) => { + tracing::warn!("Failed to write {}: {e}", path.display()); + false + } + } +} + fn apply_versions_override( component: &mut Option, versions: &RunnerVersions, @@ -1849,6 +1975,116 @@ pub fn detect_custom_components(path: &Path) -> crate::utils::RunnerComponents { } } +pub fn repair_dangling_prefix_symlinks(prefix: &Path, runner_root: &Path) -> Result<(usize, usize)> { + // A prefix seeded by an older runner keeps absolute symlinks into that + // runner's lib/wine tree (system32/*.dll, syswow64/*.dll → …/files/lib/wine/ + // {x86_64,i386}-windows/…). If that runner dir is renamed/removed, every + // builtin DLL link dangles and wine dies with `could not load kernel32.dll, + // status c0000135` (exit 53) — regardless of which runner is then used. + // This walks the prefix's windows DLL dirs, re-points dangling links at the + // ACTIVE runner's equivalent file (same relative lib/wine subpath), and + // drops links whose target the active runner does not ship (a fresh prefix + // wouldn't have them at all). Returns (repointed, removed). + let mut repointed = 0usize; + let mut removed = 0usize; + + let dirs = [ + prefix.join("drive_c/windows/system32"), + prefix.join("drive_c/windows/syswow64"), + ]; + + for dir in dirs { + if !dir.is_dir() { + continue; + } + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(e) => { + tracing::warn!("repair_dangling_prefix_symlinks: cannot read {}: {e}", dir.display()); + continue; + } + }; + for entry in entries.flatten() { + let link = entry.path(); + let meta = match std::fs::symlink_metadata(&link) { + Ok(m) => m, + Err(_) => continue, + }; + if !meta.file_type().is_symlink() { + continue; + } + let target = match std::fs::read_link(&link) { + Ok(t) => t, + Err(_) => continue, + }; + if target.exists() { + continue; // healthy link + } + // Dangling. If the target points into a lib/wine tree (the seeding + // layout), map it onto the active runner; otherwise drop it. + if let Some(rel) = lib_wine_relative(&target) { + let candidate = runner_root.join(&rel); + if candidate.exists() { + tracing::info!( + "Re-pointing dangling prefix symlink {} -> {}", + link.display(), + candidate.display() + ); + let _ = std::fs::remove_file(&link); + #[cfg(unix)] + { + if let Err(e) = std::os::unix::fs::symlink(&candidate, &link) { + tracing::warn!("repoint failed for {}: {e}", link.display()); + continue; + } + } + #[cfg(not(unix))] + { + if let Err(e) = std::fs::copy(&candidate, &link) { + tracing::warn!("repoint failed for {}: {e}", link.display()); + continue; + } + } + repointed += 1; + } else { + tracing::warn!( + "Removing dangling prefix symlink {} (active runner ships no {}: {})", + link.display(), + rel.display(), + target.display() + ); + let _ = std::fs::remove_file(&link); + removed += 1; + } + } else { + tracing::warn!( + "Removing dangling prefix symlink {} (not a runner lib/wine link: {})", + link.display(), + target.display() + ); + let _ = std::fs::remove_file(&link); + removed += 1; + } + } + } + + Ok((repointed, removed)) +} + +/// If `target` points into a runner's `files/lib/wine/…` (or `lib/wine/…`) +/// tree, return the path relative to the runner root (e.g. +/// `files/lib/wine/x86_64-windows/kernel32.dll`). +fn lib_wine_relative(target: &Path) -> Option { + let s = target.to_string_lossy(); + for marker in ["/files/lib/wine/", "/lib/wine/"] { + if let Some(idx) = s.find(marker) { + let rel = &s[idx + 1..]; // strip leading '/' + return Some(PathBuf::from(rel)); + } + } + None +} + pub fn deploy_dll_symlinks( prefix: &Path, resolutions: &[crate::launch::dll_provider_resolver::DllResolution], @@ -1887,9 +2123,12 @@ pub fn deploy_dll_symlinks( let dest_path = dest_dir.join(&dll_name); - // Safety check: if it exists and is not a symlink, back it up or skip? - // Usually we want to replace it if it's a Wine builtin. - if dest_path.exists() { + // Safety check: if it exists (including as a dangling symlink, + // which `Path::exists()` follows and reports as missing — e.g. a + // link to a runner dir that was renamed/removed) and is not a + // symlink, back it up or skip? Usually we want to replace it if + // it's a Wine builtin. + if dest_path.symlink_metadata().is_ok() { let meta = std::fs::symlink_metadata(&dest_path)?; if !meta.file_type().is_symlink() { let backup = dest_path.with_extension("dll.bak"); @@ -2035,6 +2274,7 @@ pub fn steam_wineprefix_for_game( config: &crate::config::LauncherConfig, app_id: u32, user_configs: &crate::models::UserConfigStore, + effective_prefix_mode: Option, ) -> std::path::PathBuf { let use_steam_runtime = match user_configs.get(&app_id).map(|c| &c.steam_runtime_policy) { Some(crate::models::SteamRuntimePolicy::Enabled) => true, @@ -2044,9 +2284,16 @@ pub fn steam_wineprefix_for_game( } }; - let use_per_game_compat_data = user_configs.get(&app_id) - .map(|c| use_steam_runtime && c.steam_prefix_mode == crate::models::SteamPrefixMode::PerGame) - .unwrap_or(config.use_shared_compat_data); + let use_per_game_compat_data = match effective_prefix_mode { + // Launch pipeline: honor the EFFECTIVE mode. The runner-mismatch guard + // (wine_tkg::effective_prefix_mode) may have auto-fallbacked a Shared + // configuration to PerGame so the two different runners never share one + // WINEPREFIX (wineserver protocol collision). + Some(mode) => use_steam_runtime && mode == crate::models::SteamPrefixMode::PerGame, + None => user_configs.get(&app_id) + .map(|c| use_steam_runtime && c.steam_prefix_mode == crate::models::SteamPrefixMode::PerGame) + .unwrap_or(config.use_shared_compat_data), + }; if use_per_game_compat_data { std::path::PathBuf::from(&config.steam_library_path) @@ -2166,3 +2413,77 @@ mod runner_kind_tests { assert!(is_bg_bare(proton.path()) && !game_uses_protonfixes(wine.path())); // row 6 } } + +#[cfg(test)] +mod versions_txt_tests { + use super::*; + + #[test] + fn write_runner_versions_txt_harvests_and_stamps() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + // Classic Proton component layout: version files in component dirs. + std::fs::create_dir_all(root.join("files/lib/wine/dxvk")).unwrap(); + std::fs::create_dir_all(root.join("files/lib/wine/vkd3d-proton")).unwrap(); + std::fs::write( + root.join("files/lib/wine/dxvk/version"), + "1a5919b7e dxvk (v3.0.2-5-g1a5919b7e)\n", + ) + .unwrap(); + std::fs::write( + root.join("files/lib/wine/vkd3d-proton/version"), + "3dfc6f07 vkd3d-proton (vkd3d-1.1-5438-g3dfc6f07d)\n", + ) + .unwrap(); + // Runner root `version` file (Proton style) wins for RUNNER_VERSION. + std::fs::write(root.join("version"), "1785138253 proton-11.0-1b\n").unwrap(); + + assert!(write_runner_versions_txt(root, "fallback-tag")); + + let versions = read_versions_txt(root); + // parse_short_version strips -g git suffixes. + assert_eq!(versions.dxvk.as_deref(), Some("3.0.2-5")); + assert_eq!(versions.vkd3d_proton.as_deref(), Some("1.1-5438")); + assert_eq!(versions.runner_version.as_deref(), Some("proton-11.0-1b")); + } + + #[test] + fn write_runner_versions_txt_uses_tarball_version_fallback() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + // No root `version` file → the tarball version is stamped instead. + std::fs::create_dir_all(root.join("files/lib/wine/dxvk")).unwrap(); + std::fs::write( + root.join("files/lib/wine/dxvk/version"), + "dxvk (v3.0.2)\n", + ) + .unwrap(); + + assert!(write_runner_versions_txt(root, "GE-Proton11-3")); + let versions = read_versions_txt(root); + assert_eq!(versions.dxvk.as_deref(), Some("3.0.2")); + assert_eq!(versions.runner_version.as_deref(), Some("GE-Proton11-3")); + } + + #[test] + fn write_runner_versions_txt_never_overwrites_existing() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("VERSIONS.txt"), "DXVK_VERSION=9.9.9\n").unwrap(); + std::fs::create_dir_all(root.join("files/lib/wine/dxvk")).unwrap(); + std::fs::write(root.join("files/lib/wine/dxvk/version"), "dxvk (v3.0.2)\n").unwrap(); + + assert!(!write_runner_versions_txt(root, "tarball-tag")); + let versions = read_versions_txt(root); + assert_eq!(versions.dxvk.as_deref(), Some("9.9.9")); // untouched + } + + #[test] + fn write_runner_versions_txt_skips_empty_tree() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + // Nothing harvestable → no VERSIONS.txt written. + assert!(!write_runner_versions_txt(root, "")); + assert!(!root.join("VERSIONS.txt").exists()); + } +} diff --git a/tests/launch_verification_tests.rs b/tests/launch_verification_tests.rs index 9803c40a..6f967cfd 100644 --- a/tests/launch_verification_tests.rs +++ b/tests/launch_verification_tests.rs @@ -10,6 +10,10 @@ use steamflow::infra::runners::{Runner, CommandSpec, LaunchContext}; struct MockRunner { exit_immediately: bool, + /// If set, the child sleeps this many seconds then exits 0 — simulating a + /// graceful self-exit a few seconds after the window appears (the RE2 + /// SteamAPI-init failure signature that a 2s alive-check missed). + self_exit_after_secs: Option, } #[async_trait] @@ -19,10 +23,12 @@ impl Runner for MockRunner { async fn build_env(&self, _ctx: &LaunchContext) -> Result, LaunchError> { Ok(HashMap::new()) } async fn build_command(&self, _ctx: &LaunchContext) -> Result { Ok(CommandSpec::default()) } fn launch(&self, _spec: &CommandSpec) -> Result { - let cmd = if self.exit_immediately { - "exit 0" + let cmd = if let Some(secs) = self.self_exit_after_secs { + format!("sleep {secs}") + } else if self.exit_immediately { + "exit 0".to_string() } else { - "sleep 10" + "sleep 10".to_string() }; Command::new("sh") .arg("-c") @@ -47,7 +53,7 @@ async fn test_launch_verification_early_exit() { let mut ctx = PipelineContext::new(123); ctx.logger = Some(logger); ctx.session = Some(session); - ctx.runner = Some(Box::new(MockRunner { exit_immediately: true })); + ctx.runner = Some(Box::new(MockRunner { exit_immediately: true, self_exit_after_secs: None })); ctx.command_spec = Some(CommandSpec { program: PathBuf::from("sh"), args: vec!["-c".to_string(), "exit 0".to_string()], @@ -67,6 +73,37 @@ async fn test_launch_verification_early_exit() { assert!(summary_content.contains("\"status\": \"failed_after_spawn\"")); } +#[tokio::test] +async fn test_launch_verification_graceful_self_exit_caught() { + // Regression test: a game that spawns, appears to start fine, then + // self-exits a few seconds later (e.g. SteamAPI_Init failure → clean + // ExitProcess ~3-5s after the window) must be reported as a failure, + // NOT "Success". The old implementation only checked alive-at-2s. + let mut pipeline = LaunchPipeline::new(); + pipeline.add_stage(Box::new(steamflow::launch::stages::spawn_process::SpawnProcessStage)); + + let tmp = tempdir().unwrap(); + let session = LaunchSession::new(tmp.path()); + let logger = EventLogger::new(&session).unwrap(); + + let mut ctx = PipelineContext::new(123); + ctx.logger = Some(logger); + ctx.session = Some(session); + ctx.runner = Some(Box::new(MockRunner { exit_immediately: false, self_exit_after_secs: Some(4) })); + ctx.command_spec = Some(CommandSpec { + program: PathBuf::from("sh"), + args: vec!["-c".to_string(), "sleep 4".to_string()], + ..Default::default() + }); + + let _ = pipeline.run(&mut ctx).await; + + // Survived the 2s fast-fail, but died inside the sustained window (8s). + assert_eq!(ctx.verification.status, "failed_after_spawn"); + assert!(ctx.verification.process_lifetime_ms.unwrap_or(0) >= 2000); + assert_eq!(ctx.verification.exit_code, Some(0)); +} + #[tokio::test] async fn test_launch_verification_success() { let mut pipeline = LaunchPipeline::new(); @@ -79,7 +116,7 @@ async fn test_launch_verification_success() { let mut ctx = PipelineContext::new(123); ctx.logger = Some(logger); ctx.session = Some(session); - ctx.runner = Some(Box::new(MockRunner { exit_immediately: false })); + ctx.runner = Some(Box::new(MockRunner { exit_immediately: false, self_exit_after_secs: None })); ctx.command_spec = Some(CommandSpec { program: PathBuf::from("sh"), args: vec!["-c".to_string(), "sleep 10".to_string()], diff --git a/tests/symlink_deployment.rs b/tests/symlink_deployment.rs index 248a92e7..50efff42 100644 --- a/tests/symlink_deployment.rs +++ b/tests/symlink_deployment.rs @@ -97,3 +97,75 @@ fn test_symlink_deployment_dual_arch() { assert!(!system32.join("d3d11.dll").exists()); // no backup existed, so it's gone (or should it stay gone? in this case yes because no backup) assert!(!syswow64.join("d3d11.dll").exists()); } + +#[test] +fn test_repair_dangling_prefix_symlinks() { + // Regression for exit-53 after a runner dir is removed: a prefix seeded by + // an old runner keeps absolute symlinks into that runner's lib/wine tree; + // when the dir vanishes every builtin DLL link dangles and wine dies with + // `could not load kernel32.dll`. The repair must re-point them at the + // active runner and drop links the active runner doesn't ship. + use steamflow::utils::repair_dangling_prefix_symlinks; + + let tmp = tempdir().unwrap(); + let prefix = tmp.path().join("prefix"); + let system32 = prefix.join("drive_c/windows/system32"); + let syswow64 = prefix.join("drive_c/windows/syswow64"); + fs::create_dir_all(&system32).unwrap(); + fs::create_dir_all(&syswow64).unwrap(); + + // Old (deleted) runner tree the prefix was seeded from. + let old_runner = tmp.path().join("old-runner"); + let old_x64 = old_runner.join("files/lib/wine/x86_64-windows"); + let old_x86 = old_runner.join("files/lib/wine/i386-windows"); + fs::create_dir_all(&old_x64).unwrap(); + fs::create_dir_all(&old_x86).unwrap(); + + // Dangling links: target the old runner dir, which we then delete. + let kernel32_link = system32.join("kernel32.dll"); + std::os::unix::fs::symlink(old_x64.join("kernel32.dll"), &kernel32_link).unwrap(); + let acledit_link = syswow64.join("acledit.dll"); + std::os::unix::fs::symlink(old_x86.join("acledit.dll"), &acledit_link).unwrap(); + + // A healthy link pointing at the current runner must be left alone. + let healthy_src = tmp.path().join("healthy-source.dll"); + fs::write(&healthy_src, "x").unwrap(); + let healthy_link = system32.join("healthy.dll"); + std::os::unix::fs::symlink(&healthy_src, &healthy_link).unwrap(); + + // Delete the old runner → both seed links now dangle. + fs::remove_dir_all(&old_runner).unwrap(); + assert!(!kernel32_link.exists()); // dangling + assert!(!acledit_link.exists()); // dangling + + // New active runner ships kernel32 (x64) + acledit (x86) but NOT winipcfg.dll. + let new_runner = tmp.path().join("new-runner"); + let new_x64 = new_runner.join("files/lib/wine/x86_64-windows"); + let new_x86 = new_runner.join("files/lib/wine/i386-windows"); + fs::create_dir_all(&new_x64).unwrap(); + fs::create_dir_all(&new_x86).unwrap(); + fs::write(new_x64.join("kernel32.dll"), "k32").unwrap(); + fs::write(new_x86.join("acledit.dll"), "ace").unwrap(); + + // A link to a file the new runner doesn't ship → must be dropped. + let winipcfg_link = system32.join("winipcfg.dll"); + std::os::unix::fs::symlink(old_x64.join("winipcfg.dll"), &winipcfg_link).unwrap(); + + let (repointed, removed) = repair_dangling_prefix_symlinks(&prefix, &new_runner).unwrap(); + assert_eq!(repointed, 2); + assert_eq!(removed, 1); + + // Re-pointed links now resolve to the new runner's files. + assert!(kernel32_link.exists()); + assert!(acledit_link.exists()); + assert_eq!(fs::read_to_string(&kernel32_link).unwrap(), "k32"); + assert_eq!(fs::read_to_string(&acledit_link).unwrap(), "ace"); + + // Dropped link is gone; healthy link untouched. + assert!(!winipcfg_link.symlink_metadata().is_ok()); + assert!(healthy_link.exists()); + + // Second pass is a no-op (idempotent). + let (r2, m2) = repair_dangling_prefix_symlinks(&prefix, &new_runner).unwrap(); + assert_eq!((r2, m2), (0, 0)); +} diff --git a/vendor/steam-cdn/Cargo.toml b/vendor/steam-cdn/Cargo.toml index 4f9d6968..e8f3ae2b 100644 --- a/vendor/steam-cdn/Cargo.toml +++ b/vendor/steam-cdn/Cargo.toml @@ -96,3 +96,6 @@ features = [ [dependencies.zip] version = "2.2.2" + +[dependencies.zstd] +version = "0.13" diff --git a/vendor/steam-cdn/src/cdn/depot_chunk/mod.rs b/vendor/steam-cdn/src/cdn/depot_chunk/mod.rs index ce595aad..b07343f8 100644 --- a/vendor/steam-cdn/src/cdn/depot_chunk/mod.rs +++ b/vendor/steam-cdn/src/cdn/depot_chunk/mod.rs @@ -3,7 +3,7 @@ use zip::ZipArchive; use crate::{ crypto::aes256::{self, IV_LENGTH}, - utils::lzma, + utils::{lzma, zstd}, Error, }; @@ -15,6 +15,8 @@ pub async fn decrypt_and_decompress(data: &mut [u8], key: [u8; 32]) -> Result bool { + data.len() >= 4 && u32::from_le_bytes([data[0], data[1], data[2], data[3]]) == VZSTD_HEADER +} + +pub fn decompress(data: &[u8]) -> Result, Error> { + if !is_vzstd(data) { + return Err(Error::Eof("expecting VZstd header".to_string())); + } + if data.len() < 8 + VZSTD_FOOTER_LEN { + return Err(Error::Eof("VZstd data too small".to_string())); + } + + let crc32 = u32::from_le_bytes([data[4], data[5], data[6], data[7]]); + + let footer = &data[data.len() - VZSTD_FOOTER_LEN..]; + let crc32_footer = u32::from_le_bytes([footer[0], footer[1], footer[2], footer[3]]); + let size_decompressed = u32::from_le_bytes([footer[4], footer[5], footer[6], footer[7]]); + // "zsv" sits in the LAST three bytes of the stream (SteamKit2: buffer[^3..]). + if data[data.len() - 3] != b'z' || data[data.len() - 2] != b's' || data[data.len() - 1] != b'v' { + return Err(Error::Eof("expecting VZstd footer".to_string())); + } + + // SteamKit2 asserts the CRC is written twice (header + footer); treat a + // mismatch as corruption rather than silently trusting the header copy. + if crc32 != crc32_footer { + return Err(Error::Decompress("VZstd crc32 header/footer mismatch".to_string())); + } + + let frame = &data[8..data.len() - VZSTD_FOOTER_LEN]; + let decoded = zstd::stream::decode_all(std::io::Cursor::new(frame)) + .map_err(|e| Error::Decompress(format!("zstd decode failed: {e}")))?; + + if decoded.len() as u32 != size_decompressed { + return Err(Error::Decompress(format!( + "VZstd size mismatch: expected {size_decompressed}, got {}", + decoded.len() + ))); + } + if crc32fast::hash(&decoded) != crc32_footer { + return Err(Error::Decompress("VZstd crc32 mismatch".to_string())); + } + + Ok(decoded) +}