From 6cc3518210b003504e1c67af8c31c850393c1dbf Mon Sep 17 00:00:00 2001 From: weter11 Date: Tue, 11 Aug 2026 14:19:31 -0200 Subject: [PATCH 01/18] =?UTF-8?q?docs(phase3):=20bootstrap=20status=20?= =?UTF-8?q?=E2=80=94=20steamrt4=20SDK=20verified=20pullable,=20proton=5F11?= =?UTF-8?q?.0=20source=20cloned,=20pure-PE=20gate=20corrected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 bootstrap status report (branch phase3/pure-pe-proton11): - container prereqs: no podman/docker installed; apt candidate 4.9.3; user in sudo group; install blocked on sudo password (user action) - SDK image correction: proton_11.0 pins steamrt4/sdk/x86_64: 4.0.20260331.220802-0 (NOT soldier/sdk as the task spec said); verified anonymously pullable via Docker Bearer token flow - source workspace: /home/wer/devis/steamflow-phase3/proton, proton_11.0 @ 0745bfb, 23 submodules, 3.9G (shallow clone, disk 19G free) - pure-PE gate corrected from source: Valve's Makefile always creates i386-unix/ with ONLY the 64-bit wine64 loader (--enable-win64, PE-only 32-bit side); classic-wow64 discriminator = 32-bit ELF .so files in i386-unix/ (verified against official 11.0 depot on disk) --- docs/architecture/phase3-pure-pe-proton11.md | 139 +++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/architecture/phase3-pure-pe-proton11.md diff --git a/docs/architecture/phase3-pure-pe-proton11.md b/docs/architecture/phase3-pure-pe-proton11.md new file mode 100644 index 00000000..3f037feb --- /dev/null +++ b/docs/architecture/phase3-pure-pe-proton11.md @@ -0,0 +1,139 @@ +# Phase 3 — Pure-PE WoW64 Proton Build (Bootstrap Status) + +**Branch:** `phase3/pure-pe-proton11` (off local `main` 172984d; origin/main is +3 commits ahead — rebase when merging) + +**Date:** 2026-08-11 + +**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). + +--- + +## 1. Container prerequisites — status: BLOCKED on one user action + +| Check | Result | +|---|---| +| `podman` | ❌ NOT INSTALLED | +| `docker` | ❌ NOT INSTALLED | +| `nerdctl` / `buildah` | ❌ NOT INSTALLED | +| apt candidate | ✅ `podman 4.9.3+ds1-1ubuntu0.2` (noble) available; deps crun, fuse-overlayfs, passt, slirp4netns all in repo | +| user permissions | ✅ `wer` is in `sudo` group; `sudo` requires a password (no NOPASSWD) | +| rootless support | ✅ podman 4.9.3 on noble supports rootless via `fuse-overlayfs` + `slirp4netns` | +| SELinux | ✅ not in use (Ubuntu) — no `--relabel-volumes` needed | + +**Action needed (user):** run once: +```bash +sudo apt-get install -y --no-install-recommends podman +# rootless verification (no sudo): +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: BLOCKED on podman install + +Once podman is installed, in a fresh build dir (per README): +```bash +mkdir -p /home/wer/devis/steamflow-phase3/build && cd /home/wer/devis/steamflow-phase3/build +../proton/configure.sh --container-engine=podman --enable-ccache --build-name=steamflow-pure +make 2>&1 | tee build.log # first full pass +make module=winex11.drv module # single-module iteration loop (both 32/64-bit) +``` +- `configure.sh` runs container permission checks (UID mapping, rootless + detection); with podman rootless the inner UID maps to `$(id -u)` → + `ROOTLESS_CONTAINER=0` path. +- ccache is highly recommended (`--enable-ccache`; `$CCACHE_DIR` mounted into + the container) — the first full wine build is many GB of object files and + the disk budget is tight (19G free). +- **Disk risk (flagged):** full build artifacts + ccache will likely exceed + 19G free. Plan: build in `/home/wer/devis/steamflow-phase3/build` and free + space before the full pass, or clear `~/.ccache` after the module test. + +## 4. Pure-PE gate — status: DEFINITION CORRECTED (source-verified) + +**Correction to the earlier Phase 2 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: +```make +# Makefile.in lines 661-663 (.wine-x86_64-post-build) +mkdir -p $(DST_DIR)/lib/wine/i386-unix +$(call install-strip,$(WINE_x86_64_DST)/lib/wine/x86_64-unix/wine64,$(DST_DIR)/lib/wine/i386-unix) +$(call install-strip,$(WINE_x86_64_DST)/lib/wine/x86_64-unix/wine64-preloader,$(DST_DIR)/lib/wine/i386-unix) +``` +Wine is configured `--enable-win64` (Makefile.in:620) → 32-bit side is +**PE-only** (`i386-windows`), no 32-bit ELF unix build. + +**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 (source-grounded):** +1. `dist/lib/wine/i386-unix/` may exist but must contain ONLY 64-bit ELF + (`wine64`, `wine64-preloader`) — **no `*.so` files, no ELF-32 binaries**: + ```bash + find dist/lib/wine/i386-unix -name '*.so' # must be EMPTY + file dist/lib/wine/i386-unix/* # all must say ELF 64-bit + ``` +2. 32-bit PE side present: `dist/lib/wine/i386-windows/` (PE32 DLLs) + + `dist/lib/wine/x86_64-windows/` (PE32+). +3. `file dist/bin/wine` → PE32+ (or the 64-bit ELF loader with a PE32 + wow64 32-bit half) — i.e. **no ELF 32-bit anywhere in dist**: + ```bash + find dist -type f -exec file {} + | grep 'ELF 32-bit' # must be EMPTY + ``` + +**Expected result:** a dist tree with `i386-unix` holding only the 64-bit +loader → boots on this pure-64-bit host (64-bit host GL/Vulkan/X libs only). + +## 5. Post-build install + conformance (next session) + +1. Stage `dist/` as `compatibilitytools.d/proton_11.0-wow64/` (chmod +x wine + binaries; depot artifacts ship `-rw-`). +2. Shim-suppression env still applies (32-bit Steam client needs + `steamclient=n;…` — Phase 2 launch/mod.rs fix). +3. Conformance gates (reuse Phase 1): Windows Steam client boots without + client_api.cpp:601; RE2 (883710) ownership gate + first-frame render; + `steamflow test-diff 883710` parity; `VERSIONS.txt` written at extraction + (Phase 2 tail) shows real component versions. + +## Open items +- [ ] **USER:** `sudo apt-get install -y --no-install-recommends podman` +- [ ] podman rootless smoke test (`podman info`) +- [ ] disk headroom decision for full build (19G free; wine build + ccache) +- [ ] SDK tag choice: pinned `4.0.20260331.220802-0` vs newer `-2`/`-3` +- [ ] first `configure.sh` + `make` pass; module-loop verification +- [ ] pure-PE gate scan on `dist/`; install + conformance From 53c9059f7fc81fde21f9e19ccbca22299bca98a6 Mon Sep 17 00:00:00 2001 From: weter11 Date: Tue, 11 Aug 2026 15:17:24 -0200 Subject: [PATCH 02/18] docs(phase3): module test build PASSED; classic-wow64 finding; pure-PE path identified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 execution (branch phase3/pure-pe-proton11): - podman rootless solved: uidmap installed (user), slirp4netns missing -> ~/.local/bin/podman wrapper injects --network=host (host networking sufficient: container only fetches gecko/mono/xalia over HTTPS) - image_copy_tmp_dir -> /home/.cache/podman-image-tmp (root / is 31G, was filling during image unpack) - steamrt4 SDK 4.0.20260331.220802-0 pulled (~7G) - configure.sh OK with --container-engine=~/.local/bin/podman - make module=winex11.drv module: MODULE_EXIT 0 (after regenerating stale src-wine/include/wine/server_protocol.h via perl tools/make_requests) - CRITICAL: Valve default build = classic-wow64 (obj-wine-i386 is a full --host=i686-linux-gnu ELF-32 tree) — NOT pure PE; the i386-multilib-free goal needs ARCHS without i386-unix (single 64-bit tree with --enable-archs=i386-windows,x86_64-windows). Build-config patch to Proton's Makefile.in, no wine source changes. - -j16 hung the 14G-RAM box (kaldi parallel gcc); -j8 completed --- docs/architecture/phase3-pure-pe-proton11.md | 135 ++++++++++++------- 1 file changed, 89 insertions(+), 46 deletions(-) diff --git a/docs/architecture/phase3-pure-pe-proton11.md b/docs/architecture/phase3-pure-pe-proton11.md index 3f037feb..d92f8568 100644 --- a/docs/architecture/phase3-pure-pe-proton11.md +++ b/docs/architecture/phase3-pure-pe-proton11.md @@ -63,61 +63,104 @@ shim check). - Size: **3.9G**; disk free dropped 22G → **19G** - `configure.sh`, `Makefile`, `Makefile.in`, `proton`, `default_pfx.py` all present -## 3. Configure & build — status: BLOCKED on podman install - -Once podman is installed, in a fresh build dir (per README): +## 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 /home/wer/devis/steamflow-phase3/build -../proton/configure.sh --container-engine=podman --enable-ccache --build-name=steamflow-pure -make 2>&1 | tee build.log # first full pass -make module=winex11.drv module # single-module iteration loop (both 32/64-bit) -``` -- `configure.sh` runs container permission checks (UID mapping, rootless - detection); with podman rootless the inner UID maps to `$(id -u)` → - `ROOTLESS_CONTAINER=0` path. -- ccache is highly recommended (`--enable-ccache`; `$CCACHE_DIR` mounted into - the container) — the first full wine build is many GB of object files and - the disk budget is tight (19G free). -- **Disk risk (flagged):** full build artifacts + ccache will likely exceed - 19G free. Plan: build in `/home/wer/devis/steamflow-phase3/build` and free - space before the full pass, or clear `~/.ccache` after the module test. - -## 4. Pure-PE gate — status: DEFINITION CORRECTED (source-verified) - -**Correction to the earlier Phase 2 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: -```make -# Makefile.in lines 661-663 (.wine-x86_64-post-build) -mkdir -p $(DST_DIR)/lib/wine/i386-unix -$(call install-strip,$(WINE_x86_64_DST)/lib/wine/x86_64-unix/wine64,$(DST_DIR)/lib/wine/i386-unix) -$(call install-strip,$(WINE_x86_64_DST)/lib/wine/x86_64-unix/wine64-preloader,$(DST_DIR)/lib/wine/i386-unix) +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 ``` -Wine is configured `--enable-win64` (Makefile.in:620) → 32-bit side is -**PE-only** (`i386-windows`), no 32-bit ELF unix build. +- `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 + +## 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 (source-grounded):** -1. `dist/lib/wine/i386-unix/` may exist but must contain ONLY 64-bit ELF - (`wine64`, `wine64-preloader`) — **no `*.so` files, no ELF-32 binaries**: - ```bash - find dist/lib/wine/i386-unix -name '*.so' # must be EMPTY - file dist/lib/wine/i386-unix/* # all must say ELF 64-bit - ``` -2. 32-bit PE side present: `dist/lib/wine/i386-windows/` (PE32 DLLs) + +**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+). -3. `file dist/bin/wine` → PE32+ (or the 64-bit ELF loader with a PE32 - wow64 32-bit half) — i.e. **no ELF 32-bit anywhere in dist**: - ```bash - find dist -type f -exec file {} + | grep 'ELF 32-bit' # must be EMPTY - ``` - -**Expected result:** a dist tree with `i386-unix` holding only the 64-bit -loader → boots on this pure-64-bit host (64-bit host GL/Vulkan/X libs only). +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. Post-build install + conformance (next session) From ade7302d46cc516e8c91a295bb2c8de7e05e0f56 Mon Sep 17 00:00:00 2001 From: weter11 Date: Tue, 11 Aug 2026 15:45:48 -0200 Subject: [PATCH 03/18] =?UTF-8?q?docs(phase3):=20pure-PE=20build=20VERIFIE?= =?UTF-8?q?D=20=E2=80=94=20Makefile.in=20patch=20+=20module=20gate=20scan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-PE WoW64 proof completed on phase3/pure-pe-proton11: Build-config patch (proton/Makefile.in, 3 edits, no wine source changes): - ARCHS drops i386-unix -> single 64-bit tree configures --enable-archs=x86_64,i386 --enable-win64 (both PE halves cross-compiled) - kaldi i386<->x86_64 serialization edges gated on i386-unix in ARCHS - module64 aliased to module32 (single tree builds both PE halves; the concurrent recipes raced on shared archives: libntdll.a file truncated) Result: make -j8 module=winex11.drv module -> MODULE_EXIT 0 Gate scan (obj-wine-x86_64/dlls/winex11.drv): - x86_64-windows/winex11.drv = PE32+ ; i386-windows/winex11.drv = PE32 - only .so is winex11.so = ELF 64-bit (unix side) - ELF 32-bit files: 0 ; i386-unix dirs: 0 (whole build tree) Disk: freed 10G->14G by removing dead i386-unix obj/dst trees (kept i386-windows PE ones: dxvk/vkd3d/vkd3d-proton/dxvk-nvapi). p2-* research dirs untouched. Patch artifact: docs/architecture/patches/proton-11.0-makefile-pure-pe.patch (apply with: patch -p1 < ... on a proton_11.0 checkout) --- .../proton-11.0-makefile-pure-pe.patch | 52 +++++++++++++++++++ docs/architecture/phase3-pure-pe-proton11.md | 50 ++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 docs/architecture/patches/proton-11.0-makefile-pure-pe.patch 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..cddd78d0 --- /dev/null +++ b/docs/architecture/patches/proton-11.0-makefile-pure-pe.patch @@ -0,0 +1,52 @@ +--- /tmp/Makefile.in.pristine 2026-08-11 15:45:39.307229465 -0200 ++++ proton/Makefile.in 2026-08-11 15:37:27.030374762 -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 +@@ -565,9 +569,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 +@@ -1445,14 +1453,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 index d92f8568..09623a18 100644 --- a/docs/architecture/phase3-pure-pe-proton11.md +++ b/docs/architecture/phase3-pure-pe-proton11.md @@ -134,6 +134,56 @@ This is a build-config patch to Proton's `Makefile.in` (or a 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" — From 579293e3a5030d915cc75ea7148343eaf6438137 Mon Sep 17 00:00:00 2001 From: weter11 Date: Tue, 11 Aug 2026 14:03:28 -0200 Subject: [PATCH 04/18] =?UTF-8?q?feat(valve-stack):=20Phase=202=20complete?= =?UTF-8?q?=20=E2=80=94=20native=20Rust=20Proton=20ABI,=20env-parity=20har?= =?UTF-8?q?ness,=20VERSIONS.txt=20visibility,=20native=20seed=5Fprefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the valve-stack directive (docs/architecture/valve-stack-replication.md): - items 1-2 (prior session): steam-cdn zstd/PICS fixes, test-diff harness (src/parity.rs), native Rust Proton ABI (src/runner/proton_abi.rs), headless test commands (src/headless.rs) - item 3 (tail): utils::write_runner_versions_txt writes VERSIONS.txt at runner extraction (GitHub tarball install, steam-cdn install_game, headless test_download_proton) — harvests tarball component version files + stamps RUNNER_VERSION; kills the found(bundled) display bug for flat-layout Proton trees; never overwrites an authoritative existing VERSIONS.txt - seed_prefix wired into prepare_prefix: fresh game prefixes seeded natively from runner files/share/default_pfx (no external Python init scripts) - i386-multilib permanently REJECTED (host is pure 64-bit) — recorded in dev book - test-diff 883710 re-run: 0 missing / 2 matched (PROTON_FORCE_LARGE_ADDRESS_AWARE, PROTON_USE_WINED3D resolved); EXTRA bucket = 2022-log format asymmetry - tests: 93 lib + 6 integration, all green --- Cargo.lock | 1 + docs/architecture/valve-stack-replication.md | 400 +++++++++++ src/headless.rs | 539 +++++++++++++++ src/infra/runners/wine_tkg.rs | 114 +++- src/launch/mod.rs | 18 +- src/lib.rs | 3 + src/main.rs | 7 + src/models.rs | 10 + src/parity.rs | 530 +++++++++++++++ src/proton.rs | 15 + src/runner/mod.rs | 5 + src/runner/proton_abi.rs | 669 +++++++++++++++++++ src/steam_client.rs | 73 +- src/ui.rs | 2 +- src/utils.rs | 200 ++++++ vendor/steam-cdn/Cargo.toml | 3 + vendor/steam-cdn/src/cdn/depot_chunk/mod.rs | 4 +- vendor/steam-cdn/src/utils/mod.rs | 1 + vendor/steam-cdn/src/utils/zstd.rs | 62 ++ 19 files changed, 2644 insertions(+), 12 deletions(-) create mode 100644 docs/architecture/valve-stack-replication.md create mode 100644 src/headless.rs create mode 100644 src/parity.rs create mode 100644 src/runner/mod.rs create mode 100644 src/runner/proton_abi.rs create mode 100644 vendor/steam-cdn/src/utils/zstd.rs 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/valve-stack-replication.md b/docs/architecture/valve-stack-replication.md new file mode 100644 index 00000000..667f9bfb --- /dev/null +++ b/docs/architecture/valve-stack-replication.md @@ -0,0 +1,400 @@ +# 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 INITIATED (2026-08-11): containerized pure-PE WoW64 Proton build of +Valve's `proton_11.0` source using `registry.gitlab.steamos.cloud/proton/ +soldier/sdk`** — zero host 32-bit ELF library dependencies (the resolution to +the i386-multilib rejection). Plan in the "Phase 3" section below; execution +awaits podman/docker on this host. + +## 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. +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 (INITIATED 2026-08-11) + +**Goal:** 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` is ever involved; all 32-bit needs are satisfied by the +container's toolchain and the runner's bundled PE DLLs. + +**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/soldier/sdk` — the + official Valve Proton SDK (soldier runtime SDK; provides the build + toolchain Valve uses for Proton 8+). Tag pin to the same soldier SDK the + `proton_11.0` branch CI uses (verify via the Proton repo's + `.gitlab-ci.yml` / `Makefile`). +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 for Phase 3 execution:** SDK image tag to pin; disk budget +(~29 GB free; Proton source + submodules ~5-8 GB, build artifacts more); +whether to build wine-mono/gecko in-tree or fetch prebuilt (wine-mono is a +separate `make` target); podman rootless volume permissions for the build dir. 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/wine_tkg.rs b/src/infra/runners/wine_tkg.rs index fb180f7d..49c48b87 100644 --- a/src/infra/runners/wine_tkg.rs +++ b/src/infra/runners/wine_tkg.rs @@ -69,6 +69,62 @@ impl Runner for WineTkgRunner { 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); @@ -989,7 +1045,63 @@ 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()); diff --git a/src/launch/mod.rs b/src/launch/mod.rs index abd6f734..e6eedf3e 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()); } @@ -405,8 +418,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/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..c2f38fb6 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); + } + } + } + } } } } diff --git a/src/ui.rs b/src/ui.rs index 2c1527ae..77dddb4a 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) => { diff --git a/src/utils.rs b/src/utils.rs index a75120f8..452b5ff5 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, @@ -2166,3 +2292,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/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) +} From b4a0be91bfa16b2296621ed5067ddb29e015012a Mon Sep 17 00:00:00 2001 From: weter11 Date: Tue, 11 Aug 2026 19:29:07 -0200 Subject: [PATCH 05/18] fix(build): resolve multi-arch vkd3d linkage and wineopenxr i386 cross-compile in pure-PE build --- .../proton-11.0-makefile-pure-pe.patch | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/docs/architecture/patches/proton-11.0-makefile-pure-pe.patch b/docs/architecture/patches/proton-11.0-makefile-pure-pe.patch index cddd78d0..3f490822 100644 --- a/docs/architecture/patches/proton-11.0-makefile-pure-pe.patch +++ b/docs/architecture/patches/proton-11.0-makefile-pure-pe.patch @@ -1,5 +1,5 @@ --- /tmp/Makefile.in.pristine 2026-08-11 15:45:39.307229465 -0200 -+++ proton/Makefile.in 2026-08-11 15:37:27.030374762 -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 @@ -13,7 +13,20 @@ else ifeq ($(TARGET_ARCH),arm64) ARCHS += aarch64-unix aarch64-windows arm64ec-windows else -@@ -565,9 +569,13 @@ +@@ -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. @@ -27,7 +40,37 @@ ## ## vosk -@@ -1445,14 +1453,17 @@ +@@ -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 From 2c0bd942823e28a9c06beebc3a16bb4a553a0999 Mon Sep 17 00:00:00 2001 From: weter11 Date: Wed, 12 Aug 2026 16:21:58 -0200 Subject: [PATCH 06/18] fix(runner): use symlink_metadata to prevent EEXIST on dangling DLL symlinks deploy_dll_symlinks checked dest_path.exists(), which follows symlinks and returns false for dangling links (e.g. a link to a runner dir that was renamed/removed). The stale link was never removed, and the subsequent symlink() call failed with EEXIST, aborting PreparePrefix. Switch the existence check to symlink_metadata() (succeeds on dangling links) so stale links are removed and re-pointed like any other symlink. --- src/utils.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/utils.rs b/src/utils.rs index 452b5ff5..9156b5c5 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -2013,9 +2013,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"); From 8a56ed2e7dfff3fd57a04d6dd0066ecb5b139f86 Mon Sep 17 00:00:00 2001 From: weter11 Date: Wed, 12 Aug 2026 16:51:38 -0200 Subject: [PATCH 07/18] fix(runner): spawn PerGame background Steam with the game's runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PerGame mode seeds the per-game prefix (compatdata//pfx) from the game runner's default_pfx via seed_prefix. The background Steam client was then hardcoded to the configured Steam Runtime Runner (default wine-tkg, classic-wow64), which cannot initialize inside a pure-PE-seeded prefix: wine-tkg's loader expects wow64.dll (unix-side WoW64 shim) which pure-PE wine deliberately does not ship → 'init_wow64: could not load wow64.dll' → Steam exits 53 and the launch aborts at PreparePrefix. Use the game's active runner for the background Steam in PerGame mode so the client matches the wine family that owns the prefix. Shared mode keeps the configured runtime runner (it owns the master prefix). Verified: test-launch 883710 resolves WINEPREFIX to compatdata/883710/pfx, Steam spawns without exit 53, and the game launches under the pure-PE runner. --- src/infra/runners/wine_tkg.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/infra/runners/wine_tkg.rs b/src/infra/runners/wine_tkg.rs index 49c48b87..2f0cb6d6 100644 --- a/src/infra/runners/wine_tkg.rs +++ b/src/infra/runners/wine_tkg.rs @@ -293,7 +293,24 @@ 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() { + // PerGame mode: the per-game prefix is seeded by the + // game's own runner (seed_prefix copies its + // default_pfx), so the background Steam client MUST run + // under that same wine family. A mismatched runner + // cannot initialize inside the seeded prefix — e.g. a + // classic-wow64 wine-tkg client in a pure-PE-seeded + // prefix fails with + // "init_wow64: could not load wow64.dll" → exit 53 + // (pure-PE wine has no wow64.dll; its 32-bit half is + // PE-only). Shared mode keeps the configured runtime + // runner, which 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 (prefix is seeded by it)", + 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); From c5c84b18656913de4eb14e1939f3bfcbaa66ed3c Mon Sep 17 00:00:00 2001 From: weter11 Date: Wed, 12 Aug 2026 17:57:31 -0200 Subject: [PATCH 08/18] =?UTF-8?q?docs(phase3):=20mark=20Phase=203=20CLOSED?= =?UTF-8?q?=20=E2=80=94=20pure-PE=20WoW64=20Proton=2011.0=20built,=20stage?= =?UTF-8?q?d,=20E2E-verified=20on=20RE2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - phase3-pure-pe-proton11.md: bootstrap status → final record (build, blocker fixes, staging gates, live E2E, known limitation, open items all closed) - valve-stack-replication.md: Phase 3 INITIATED → CLOSED; header + open questions updated; SDK corrected to steamrt4 (branch pin), not soldier --- docs/architecture/phase3-pure-pe-proton11.md | 143 ++++++++++++++----- docs/architecture/valve-stack-replication.md | 49 ++++--- 2 files changed, 135 insertions(+), 57 deletions(-) diff --git a/docs/architecture/phase3-pure-pe-proton11.md b/docs/architecture/phase3-pure-pe-proton11.md index 09623a18..d524f3d2 100644 --- a/docs/architecture/phase3-pure-pe-proton11.md +++ b/docs/architecture/phase3-pure-pe-proton11.md @@ -1,33 +1,48 @@ -# Phase 3 — Pure-PE WoW64 Proton Build (Bootstrap Status) +# Phase 3 — Pure-PE WoW64 Proton Build (CLOSED 2026-08-12) -**Branch:** `phase3/pure-pe-proton11` (off local `main` 172984d; origin/main is -3 commits ahead — rebase when merging) +**Branch:** `phase3/pure-pe-proton11` (rebased onto `origin/main`; 0 behind / +7 ahead — ready to push) -**Date:** 2026-08-11 +**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). ---- - -## 1. Container prerequisites — status: BLOCKED on one user action +## ✅ 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` | ❌ NOT INSTALLED | -| `docker` | ❌ NOT INSTALLED | -| `nerdctl` / `buildah` | ❌ NOT INSTALLED | -| apt candidate | ✅ `podman 4.9.3+ds1-1ubuntu0.2` (noble) available; deps crun, fuse-overlayfs, passt, slirp4netns all in repo | -| user permissions | ✅ `wer` is in `sudo` group; `sudo` requires a password (no NOPASSWD) | -| rootless support | ✅ podman 4.9.3 on noble supports rootless via `fuse-overlayfs` + `slirp4netns` | -| SELinux | ✅ not in use (Ubuntu) — no `--relabel-volumes` needed | - -**Action needed (user):** run once: -```bash -sudo apt-get install -y --no-install-recommends podman -# rootless verification (no sudo): +| `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 ``` @@ -212,21 +227,75 @@ crypt32.so, dwrite.so, kerberos.so, … — and an ELF-32 `wine` executable **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. Post-build install + conformance (next session) - -1. Stage `dist/` as `compatibilitytools.d/proton_11.0-wow64/` (chmod +x wine - binaries; depot artifacts ship `-rw-`). -2. Shim-suppression env still applies (32-bit Steam client needs - `steamclient=n;…` — Phase 2 launch/mod.rs fix). -3. Conformance gates (reuse Phase 1): Windows Steam client boots without - client_api.cpp:601; RE2 (883710) ownership gate + first-frame render; - `steamflow test-diff 883710` parity; `VERSIONS.txt` written at extraction - (Phase 2 tail) shows real component versions. - -## Open items -- [ ] **USER:** `sudo apt-get install -y --no-install-recommends podman` -- [ ] podman rootless smoke test (`podman info`) -- [ ] disk headroom decision for full build (19G free; wine build + ccache) -- [ ] SDK tag choice: pinned `4.0.20260331.220802-0` vs newer `-2`/`-3` -- [ ] first `configure.sh` + `make` pass; module-loop verification -- [ ] pure-PE gate scan on `dist/`; install + conformance +## 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`.) + +### 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).** diff --git a/docs/architecture/valve-stack-replication.md b/docs/architecture/valve-stack-replication.md index 667f9bfb..c4be1c80 100644 --- a/docs/architecture/valve-stack-replication.md +++ b/docs/architecture/valve-stack-replication.md @@ -30,11 +30,14 @@ extraction (GitHub tarball install, steam-cdn `install_game`, headless 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 INITIATED (2026-08-11): containerized pure-PE WoW64 Proton build of -Valve's `proton_11.0` source using `registry.gitlab.steamos.cloud/proton/ -soldier/sdk`** — zero host 32-bit ELF library dependencies (the resolution to -the i386-multilib rejection). Plan in the "Phase 3" section below; execution -awaits podman/docker on this host. +**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 @@ -347,23 +350,26 @@ RE2 display mode is USER-CONTROLLED (windowed per user) — do not override. 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 (INITIATED 2026-08-11) +## Phase 3 — containerized pure-PE WoW64 Proton build (CLOSED 2026-08-12) -**Goal:** 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` is ever involved; all 32-bit needs are satisfied by the -container's toolchain and the runner's bundled PE DLLs. +**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/soldier/sdk` — the - official Valve Proton SDK (soldier runtime SDK; provides the build - toolchain Valve uses for Proton 8+). Tag pin to the same soldier SDK the - `proton_11.0` branch CI uses (verify via the Proton repo's - `.gitlab-ci.yml` / `Makefile`). +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). @@ -394,7 +400,10 @@ deferred on this). Install podman first (pure-64-bit friendly, rootless). gate + first-frame render, `test-diff 883710` parity holds, `VERSIONS.txt` written at extraction (Phase 2 tail) shows real component versions. -**Open questions for Phase 3 execution:** SDK image tag to pin; disk budget -(~29 GB free; Proton source + submodules ~5-8 GB, build artifacts more); -whether to build wine-mono/gecko in-tree or fetch prebuilt (wine-mono is a -separate `make` target); podman rootless volume permissions for the build dir. +**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. From 4154ec9d728d521e632bf13c3b0c450d1037cc30 Mon Sep 17 00:00:00 2001 From: weter11 Date: Wed, 12 Aug 2026 19:27:25 -0200 Subject: [PATCH 09/18] fix(runner): auto-fallback Shared->PerGame prefix mode on runner mismatch Shared prefix mode cannot host two different Wine/Proton runners in one WINEPREFIX (wineserver protocol mismatch -> 'wine client error: version mismatch'). effective_game_proton no longer force-equals the game runner to the Steam Runtime runner, so a Shared config with runtime runner = wine-tkg and game runner = steamflow-proton-11.0-purepe would crash at launch. Add effective_prefix_mode(ctx) / effective_prefix_mode_impl: apply the configured mode (per-game user config -> launcher default) and, when Shared AND the Steam Runtime runner path differs from the game runner path, fall back to PerGame with a visible warning. Thread the effective mode through every prefix decision: steam_wineprefix_for_game (new Option param), background-Steam spawn, CEF-enforcement prefix, symlink-deployment stage, registry-fixup stage and DLL-provider detection. UI management buttons and launch_custom_exec keep the configured mode (None). Tests (src/infra/runners/tests.rs): Shared + mismatched runners -> PerGame; matching runners -> Shared; no runtime runner -> Shared. Full lib suite (96) + launch_verification_tests (3) pass. --- docs/architecture/phase3-pure-pe-proton11.md | 49 +++++++++++ docs/architecture/valve-stack-replication.md | 6 +- src/infra/runners/tests.rs | 72 ++++++++++++++++ src/infra/runners/wine_tkg.rs | 86 ++++++++++++++++++-- src/launch/mod.rs | 5 +- src/launch/stages/prepare_prefix.rs | 9 +- src/launch/stages/resolve_dll_providers.rs | 15 +++- src/launch/stages/resolve_game_fixups.rs | 13 ++- src/ui.rs | 4 + src/utils.rs | 14 +++- 10 files changed, 254 insertions(+), 19 deletions(-) diff --git a/docs/architecture/phase3-pure-pe-proton11.md b/docs/architecture/phase3-pure-pe-proton11.md index d524f3d2..13e722f3 100644 --- a/docs/architecture/phase3-pure-pe-proton11.md +++ b/docs/architecture/phase3-pure-pe-proton11.md @@ -274,6 +274,55 @@ now spawns background Steam with the **game's own runner**. Verified: 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. + ### 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) diff --git a/docs/architecture/valve-stack-replication.md b/docs/architecture/valve-stack-replication.md index c4be1c80..81985a3d 100644 --- a/docs/architecture/valve-stack-replication.md +++ b/docs/architecture/valve-stack-replication.md @@ -96,7 +96,11 @@ pinned, vendored dependencies with an upgrade button. `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. + 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). 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 2f0cb6d6..2a258570 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,7 +127,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()), ); 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)))?; @@ -525,6 +589,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()) @@ -604,6 +669,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()); @@ -613,7 +682,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 === @@ -1125,10 +1195,8 @@ 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()); - + // 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 e6eedf3e..1f8e54ef 100644 --- a/src/launch/mod.rs +++ b/src/launch/mod.rs @@ -322,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()))?; 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/ui.rs b/src/ui.rs index 77dddb4a..52735242 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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 9156b5c5..1a8ce8d0 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -2164,6 +2164,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, @@ -2173,9 +2174,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) From f0bdf796de319cb03a1588403924ddc1ea9eb2e2 Mon Sep 17 00:00:00 2001 From: weter11 Date: Wed, 12 Aug 2026 20:13:51 -0200 Subject: [PATCH 10/18] fix(runner): self-heal dangling builtin DLL symlinks in prefixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prefix seeded by an older runner keeps absolute symlinks into that runner's lib/wine tree (system32/*.dll, syswow64/*.dll). When the runner dir is renamed/removed (e.g. swapping the vendored cachyos copy for the pure-PE build), every builtin link dangles and ANY wine — game runner or background Steam — dies 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, so the broken prefix was never repaired. Add utils::repair_dangling_prefix_symlinks(): walks system32/syswow64, re-points dangling lib/wine links at the ACTIVE runner's equivalent file, and drops links the active runner doesn't ship. prepare_prefix runs it on every launch (no-op scan on healthy prefixes). Regression test covers repoint / drop / untouched-healthy / idempotency. --- src/infra/runners/wine_tkg.rs | 19 ++++++ src/utils.rs | 110 ++++++++++++++++++++++++++++++++++ tests/symlink_deployment.rs | 72 ++++++++++++++++++++++ 3 files changed, 201 insertions(+) diff --git a/src/infra/runners/wine_tkg.rs b/src/infra/runners/wine_tkg.rs index 2a258570..c27e02b8 100644 --- a/src/infra/runners/wine_tkg.rs +++ b/src/infra/runners/wine_tkg.rs @@ -193,6 +193,25 @@ impl Runner for WineTkgRunner { 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):"); diff --git a/src/utils.rs b/src/utils.rs index 1a8ce8d0..d3bf99ba 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1975,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], 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)); +} From c376560e8c5ff76ee904c3f8f1eb75eae1605b66 Mon Sep 17 00:00:00 2001 From: weter11 Date: Wed, 12 Aug 2026 20:14:19 -0200 Subject: [PATCH 11/18] docs(phase3): record prefix self-heal follow-up (dangling builtin DLL symlinks) --- docs/architecture/phase3-pure-pe-proton11.md | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/architecture/phase3-pure-pe-proton11.md b/docs/architecture/phase3-pure-pe-proton11.md index 13e722f3..c31400bd 100644 --- a/docs/architecture/phase3-pure-pe-proton11.md +++ b/docs/architecture/phase3-pure-pe-proton11.md @@ -323,6 +323,40 @@ The effective mode is threaded through every prefix decision point: 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). + ### 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) From b5f5c0a1cbcd68e3150b208fe651369ce086a414 Mon Sep 17 00:00:00 2001 From: weter11 Date: Thu, 13 Aug 2026 17:06:16 -0200 Subject: [PATCH 12/18] fix(runner): background Steam always runs in master prefix under runtime runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit-1 regression after the pure-PE split (RE2 launch, 2026-08-13): PerGame mode spawned background Steam with the GAME's runner (purepe) in the per-game prefix. The Windows Steam client CANNOT boot under purepe (documented 2026-08-12: CEF GPU-process crash + 32-bit network-init stall — two independent purepe defects), so the client died ~2s after spawn (exit 1, no Steam logs) and the launch aborted at PreparePrefix. The stale-wineserver guard also targeted the per-game prefix, and the 'is Steam running' check looked at the per-game prefix — so even with the user's wine-tkg client running in the master prefix (via Manage), SteamFlow spawned a doomed duplicate. Fix — the client ALWAYS belongs to the master prefix under the Steam Runtime runner (wine-tkg), in Shared AND PerGame mode: - PerGame mode: prefix_steam_dir/steam_wineprefix now 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: always the configured Steam Runtime runner (wine-tkg), never the game's runner. - Stale-wineserver guard: targets the GAME prefix (effective_game_prefix), so it can't kill the running master client. - Deployed client files (steam.exe, steamclient*.dll) are now REFRESHED from master when a stale real-file copy exists (the per-game prefix had a Feb-14 steam.exe that self-exits with code 1; only symlink-or-copy-if- missing meant stale copies persisted forever). Verified: test-launch 883710 → background Steam under wine-tkg, ready signal in 6s, effective_steam_wineprefix = master_steam_prefix/pfx, game launches under purepe, swapchain created, session result: Success. --- src/infra/runners/wine_tkg.rs | 88 ++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 33 deletions(-) diff --git a/src/infra/runners/wine_tkg.rs b/src/infra/runners/wine_tkg.rs index c27e02b8..3c1c5c0b 100644 --- a/src/infra/runners/wine_tkg.rs +++ b/src/infra/runners/wine_tkg.rs @@ -240,6 +240,16 @@ impl Runner for WineTkgRunner { (master_steam_dir.clone(), steam_cfg.wine_prefix.clone()) } crate::models::SteamPrefixMode::PerGame => { + // The per-game prefix is seeded by the GAME's runner + // (pure-PE), which cannot host the Windows Steam + // client (documented: CEF GPU crash + network-init + // stall — two independent purepe client defects, + // 2026-08-12). The client therefore ALWAYS belongs + // in the master prefix under the Steam Runtime + // runner (wine-tkg), exactly like Shared mode. The + // per-game prefix only receives a Steam client FILE + // deployment (below) so the game's + // STEAM_COMPAT_CLIENT_INSTALL_PATH resolves. let target_steam_dir = effective_game_prefix .join("drive_c/Program Files (x86)/Steam"); @@ -262,18 +272,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); - } } } @@ -297,16 +325,18 @@ impl Runner for WineTkgRunner { } } - (target_steam_dir, effective_game_prefix.clone()) - } - }; + // Client process + readiness gate target the MASTER + // prefix (the runner that owns the client). + (master_steam_dir.clone(), steam_cfg.wine_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); @@ -317,9 +347,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)); } } @@ -376,24 +406,16 @@ impl Runner for WineTkgRunner { // pass (after readiness gate) will handle newly spawned // helpers to ensure user-disabled features are enforced. } else { - // PerGame mode: the per-game prefix is seeded by the - // game's own runner (seed_prefix copies its - // default_pfx), so the background Steam client MUST run - // under that same wine family. A mismatched runner - // cannot initialize inside the seeded prefix — e.g. a - // classic-wow64 wine-tkg client in a pure-PE-seeded - // prefix fails with - // "init_wow64: could not load wow64.dll" → exit 53 - // (pure-PE wine has no wow64.dll; its 32-bit half is - // PE-only). Shared mode keeps the configured runtime - // runner, which 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 (prefix is seeded by it)", - active_runner.display() - ); - active_runner.clone() - } else if !ctx.launcher_config.steam_runtime_runner.as_os_str().is_empty() { + // The background Steam client ALWAYS runs under the + // Steam Runtime runner (wine-tkg) in the MASTER prefix — + // in PerGame mode too. The per-game prefix is seeded by + // the game's runner (pure-PE) which CANNOT host the + // Windows Steam client at all (documented 2026-08-12: + // CEF GPU crash + network-init stall — two independent + // purepe client defects). Running the client with the + // game's runner (as an earlier revision did) dies in + // ~2s with exit 1 before writing any Steam logs. + let steam_runner = 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); From fcc0c5482922e76ec31d53180ff7f39d4e91c02b Mon Sep 17 00:00:00 2001 From: weter11 Date: Thu, 13 Aug 2026 17:07:29 -0200 Subject: [PATCH 13/18] docs(phase3): record background-Steam master-prefix fix (exit-1 regression) --- docs/architecture/phase3-pure-pe-proton11.md | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/architecture/phase3-pure-pe-proton11.md b/docs/architecture/phase3-pure-pe-proton11.md index c31400bd..10791119 100644 --- a/docs/architecture/phase3-pure-pe-proton11.md +++ b/docs/architecture/phase3-pure-pe-proton11.md @@ -357,6 +357,35 @@ healthy prefixes; non-fatal on error). 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`. + ### 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) From 284e697731b97186f7f27ca050849ee0c26ee264 Mon Sep 17 00:00:00 2001 From: weter11 Date: Thu, 13 Aug 2026 19:15:25 -0200 Subject: [PATCH 14/18] fix(runner): revert background-Steam master-prefix split; catch post-window self-exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit b5f5c0a parked the background Steam client in the MASTER prefix while games run in per-game prefixes (pure-PE). Wine named pipes are per-wineserver, so the game's steamclient.dll cannot reach the client's pipe → SteamAPI_Init fails → RE2 self-exits ~3s after the window appears (clean MFShutdown, no crash) and Portal 2 shows 'Steam must be running' even while Steam runs. b5f5c0a's 'verified' check (Steam ready in 6s + swapchain) used the same shallow 2s alive-check that masks the exit. Revert the placement to 8a56ed2 (client spawns with the game's runner in the per-game prefix — same wineserver as the game), keep b5f5c0a's client-file refresh (the real exit-1 fix was the stale Feb-14 steam.exe), and keep the stale-wineserver guard on the game prefix. launch_verification: two-phase check — 2s fast-fail for instant crashes, then a sustained-liveness window to 8s (500ms polls) so a graceful 3-5s-after-window self-exit is reported failed_after_spawn with the real lifetime instead of 'Success'. New test test_launch_verification_graceful_self_exit_caught covers the 4s case the old 2s check missed. --- docs/architecture/phase3-pure-pe-proton11.md | 46 ++++++++++++++ src/infra/runners/wine_tkg.rs | 63 +++++++++++++------- src/launch/pipeline.rs | 53 +++++++++++++--- tests/launch_verification_tests.rs | 47 +++++++++++++-- 4 files changed, 174 insertions(+), 35 deletions(-) diff --git a/docs/architecture/phase3-pure-pe-proton11.md b/docs/architecture/phase3-pure-pe-proton11.md index 10791119..26674435 100644 --- a/docs/architecture/phase3-pure-pe-proton11.md +++ b/docs/architecture/phase3-pure-pe-proton11.md @@ -386,6 +386,52 @@ under the Steam Runtime runner (wine-tkg), in Shared AND PerGame mode: 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. + ### 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) diff --git a/src/infra/runners/wine_tkg.rs b/src/infra/runners/wine_tkg.rs index 3c1c5c0b..2bf5d602 100644 --- a/src/infra/runners/wine_tkg.rs +++ b/src/infra/runners/wine_tkg.rs @@ -240,16 +240,17 @@ impl Runner for WineTkgRunner { (master_steam_dir.clone(), steam_cfg.wine_prefix.clone()) } crate::models::SteamPrefixMode::PerGame => { - // The per-game prefix is seeded by the GAME's runner - // (pure-PE), which cannot host the Windows Steam - // client (documented: CEF GPU crash + network-init - // stall — two independent purepe client defects, - // 2026-08-12). The client therefore ALWAYS belongs - // in the master prefix under the Steam Runtime - // runner (wine-tkg), exactly like Shared mode. The - // per-game prefix only receives a Steam client FILE - // deployment (below) so the game's - // STEAM_COMPAT_CLIENT_INSTALL_PATH resolves. + // 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"); @@ -325,9 +326,13 @@ impl Runner for WineTkgRunner { } } - // Client process + readiness gate target the MASTER - // prefix (the runner that owns the client). - (master_steam_dir.clone(), steam_cfg.wine_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()) } }; @@ -406,16 +411,28 @@ impl Runner for WineTkgRunner { // pass (after readiness gate) will handle newly spawned // helpers to ensure user-disabled features are enforced. } else { - // The background Steam client ALWAYS runs under the - // Steam Runtime runner (wine-tkg) in the MASTER prefix — - // in PerGame mode too. The per-game prefix is seeded by - // the game's runner (pure-PE) which CANNOT host the - // Windows Steam client at all (documented 2026-08-12: - // CEF GPU crash + network-init stall — two independent - // purepe client defects). Running the client with the - // game's runner (as an earlier revision did) dies in - // ~2s with exit 1 before writing any Steam logs. - let steam_runner = if !ctx.launcher_config.steam_runtime_runner.as_os_str().is_empty() { + // 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); 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/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()], From 15434848a60d48cbebf7809bf612b37f39b8744b Mon Sep 17 00:00:00 2001 From: weter11 Date: Fri, 14 Aug 2026 17:16:07 -0200 Subject: [PATCH 15/18] feat(steam): sync master Steam session state into per-game prefixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (account registry, AutoLogin/RememberPassword, fresh Timestamp) — copied wholesale - config/config.vdf Authentication block (RememberedMachineID JWT) — merged, preserving the target's other per-prefix keys - ssfn* sentry files (legacy) — copied when present - HKCU\Software\Valve\Steam section of user.reg (AutoLoginUser, ...) — per-key overlay, preserving target keys/subkeys and EOL style Guards: no-op when the master client has no session; never downgrades a per-game login as fresh as (or fresher than) master's; non-fatal. Wired into the PerGame spawn path (wine_tkg.rs) right before the client spawn, only when no client is already running in the target prefix. Verified: 8 new unit tests (VDF/registry merges, freshness guard, e2e sync) + full suite 105 lib tests green; live run confirmed the 620 prefix receives master's loginusers.vdf timestamp (1786715012), the fresh RememberedMachineID JWT, and the registry login keys. DOES NOT achieve a logged-in per-game client end-to-end. The purepe-hosted client asserts ClientAPI_InitGlobalInstance (client_api.cpp:601, "missing a 32-bit dependency of steamclient.dll") on every boot and stays anonymous; under wine-tkg it connects to Steam CM but the copied machine token is rejected ("Invalid Password"). Open architecture question recorded in docs/architecture/phase3-pure-pe-proton11.md §Session-auth sync (2026-08-14). --- src/infra/runners/wine_tkg.rs | 38 +++ src/steam_client.rs | 611 ++++++++++++++++++++++++++++++++++ 2 files changed, 649 insertions(+) diff --git a/src/infra/runners/wine_tkg.rs b/src/infra/runners/wine_tkg.rs index 2bf5d602..6ebb612b 100644 --- a/src/infra/runners/wine_tkg.rs +++ b/src/infra/runners/wine_tkg.rs @@ -411,6 +411,44 @@ impl Runner for WineTkgRunner { // pass (after readiness gate) will handle newly spawned // helpers to ensure user-disabled features are enforced. } else { + // 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 diff --git a/src/steam_client.rs b/src/steam_client.rs index c2f38fb6..72cbfe7e 100644 --- a/src/steam_client.rs +++ b/src/steam_client.rs @@ -2612,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 @@ -4335,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)] From 5eb0aa55a187d7f5916fb36fd8036aa7be033405 Mon Sep 17 00:00:00 2001 From: weter11 Date: Fri, 14 Aug 2026 17:16:13 -0200 Subject: [PATCH 16/18] docs(phase3): record session-auth sync + Portal 2 blocker root cause (2026-08-14) Sync machinery committed separately (feat 1543484). This section states the CONCLUSION (per-game client process required; pointer-only model disproven by b5f5c0a), the two Portal 2 blockers with evidence (purepe client_api.cpp:601 assert dumps; wine-tkg CM connect + Invalid Password token rejection), the MachineGuid seed_prefix doc/code mismatch as an unfixed bug, and the single next step (MachineGuid cross-prefix injection test under wine-tkg). --- docs/architecture/phase3-pure-pe-proton11.md | 76 ++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/docs/architecture/phase3-pure-pe-proton11.md b/docs/architecture/phase3-pure-pe-proton11.md index 26674435..e07934b3 100644 --- a/docs/architecture/phase3-pure-pe-proton11.md +++ b/docs/architecture/phase3-pure-pe-proton11.md @@ -432,6 +432,82 @@ 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 doc/code mismatch (real bug, NOT yet fixed):** module doc in +`src/runner/proton_abi.rs:16` claims `seed_prefix()` does "MachineGuid +preservation"; the body (lines 480–511) does only copy_tree + dosdevices symlinks ++ version marker. 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. Whether Steam's machine-token validation keys on MachineGuid +is UNPROVEN — that is the next probe. + +**Committed vs open:** the sync machinery + this doc section are committed +(2026-08-14). Still open: (1) the per-game client must run under a runner whose +ClientAPI works (wine-tkg proven; requires relaxing the stale-wineserver guard +for the client's wineserver, since it currently kills foreign-runner wineservers +in the game prefix); (2) the machine-bound token rejection must be solved for a +per-game client to log in without the one-time `steam.exe -login` bridge. + +**Single next step:** run the wine-tkg client in the 620 prefix with master's +MachineGuid injected into `compatdata/620/pfx/system.reg` +(`HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`), watch +`connection_log.txt` ~90s. Login completes → MachineGuid is the binding: +implement preservation in `seed_prefix()` + include it in the sync, and the sync +work becomes sufficient. Still `Invalid Password` → MachineGuid is not the +binding; probe the client's own key store next, with the one-time `-login` bridge +per prefix as the fallback. + ### 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) From bffe3751b32e547429439abd32c958c61f5a9d82 Mon Sep 17 00:00:00 2001 From: weter11 Date: Sat, 15 Aug 2026 03:39:41 -0200 Subject: [PATCH 17/18] docs(phase3): MachineGuid injection verdict + routing gap + open product decision (2026-08-14) Replaces the "single next step" with the executed result and corrected framing: - MachineGuid cross-prefix injection test EXECUTED, verdict: NOT sufficient. Three-run table (620's own GUID -> server-side Invalid Password; master's GUID injected -> local 'cached creds not available', never attempts; own GUID + interactive login -> 'OK' + fresh JWT persisted). MachineGuid affects which failure mode the client hits but is not the auth binding; seed_prefix() doc/code mismatch stays open as a lower-priority real bug. - Working auth mechanism: one-time interactive login per prefix (client's own key store cannot be file-synced). Fresh machine-bound JWT iss r:0001_28A6B125_2FF1F vs master's r:0012_28A6B9E0_68319; AutoLogin=1 persists. Portal 2 now boots past SteamAPI_Init, loads p2-rtx chain on RTX 3070. - PITFALL recorded: disk-full (100%) causes post-login client crash 'Failed to create thread (error 0x3e6)' threadtools.cpp:3870; JWT never persists. Backlog item: defensive free-space check, NOT fixed this session. - Next blocker #1: Shared/PerGame wineserver routing gap in the custom-exec path (launch_custom_exec passes None to steam_wineprefix_for_game, bypassing effective_prefix_mode's runner-mismatch guard -> Shared config resolves to master prefix; per-game logged-in client never detected). Decision logic verified in code; fix NOT yet implemented. - Next blocker #2: winproc 1400 wine-11 get_window_thread() regression (pre-existing, patch unbuilt) - game exit code 5 at Remix bridge handshake. - OPEN PRODUCT DECISION recorded (not resolved): one-time manual login per prefix as shipped default vs opt-in advanced/isolated session mode. --- docs/architecture/phase3-pure-pe-proton11.md | 49 ++++++++++---------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/docs/architecture/phase3-pure-pe-proton11.md b/docs/architecture/phase3-pure-pe-proton11.md index e07934b3..bb141926 100644 --- a/docs/architecture/phase3-pure-pe-proton11.md +++ b/docs/architecture/phase3-pure-pe-proton11.md @@ -483,30 +483,31 @@ already answered it. identity and the server rejects it. Copying credentials into a second prefix creates a second machine identity; the token does not follow. -**MachineGuid doc/code mismatch (real bug, NOT yet fixed):** module doc in -`src/runner/proton_abi.rs:16` claims `seed_prefix()` does "MachineGuid -preservation"; the body (lines 480–511) does only copy_tree + dosdevices symlinks -+ version marker. 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. Whether Steam's machine-token validation keys on MachineGuid -is UNPROVEN — that is the next probe. - -**Committed vs open:** the sync machinery + this doc section are committed -(2026-08-14). Still open: (1) the per-game client must run under a runner whose -ClientAPI works (wine-tkg proven; requires relaxing the stale-wineserver guard -for the client's wineserver, since it currently kills foreign-runner wineservers -in the game prefix); (2) the machine-bound token rejection must be solved for a -per-game client to log in without the one-time `steam.exe -login` bridge. - -**Single next step:** run the wine-tkg client in the 620 prefix with master's -MachineGuid injected into `compatdata/620/pfx/system.reg` -(`HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`), watch -`connection_log.txt` ~90s. Login completes → MachineGuid is the binding: -implement preservation in `seed_prefix()` + include it in the sync, and the sync -work becomes sufficient. Still `Invalid Password` → MachineGuid is not the -binding; probe the client's own key store next, with the one-time `-login` bridge -per prefix as the fallback. +**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 From f19d61cc15cc60c25ed336e447fe9da3a9da4b22 Mon Sep 17 00:00:00 2001 From: weter11 Date: Sat, 15 Aug 2026 06:41:37 -0200 Subject: [PATCH 18/18] docs(phase3): protocol-931 pin falsification + phase3 closure record (2026-08-15) --- docs/architecture/phase3-pure-pe-proton11.md | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/architecture/phase3-pure-pe-proton11.md b/docs/architecture/phase3-pure-pe-proton11.md index bb141926..110d84c8 100644 --- a/docs/architecture/phase3-pure-pe-proton11.md +++ b/docs/architecture/phase3-pure-pe-proton11.md @@ -534,3 +534,45 @@ post-Phase-3 follow-up, not a blocker for the pure-PE goal. **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).