diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e6eef1ad..617d16134 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,13 +4,6 @@ on: push: tags: ['v*'] workflow_dispatch: - inputs: - developer_sdk: - description: "Build & publish the D3D12 developer SDK bundle (1 = yes, 0 = skip)" - required: false - default: '0' - type: choice - options: ['0', '1'] concurrency: group: release-ci-${{ github.ref }} @@ -19,92 +12,10 @@ concurrency: permissions: contents: write actions: read - packages: write jobs: - developer-sdk: - name: Publish Developer SDK Bundle - # Only build/publish the D3D12 developer SDK when explicitly enabled: - # - manual run with developer_sdk=1, or - # - repository variable METALSHARP_BUILD_DEVELOPER_SDK == '1'. - # Defaults to skipped so the DMG + GitHub Release are never blocked by it. - if: >- - (github.event_name == 'workflow_dispatch' && inputs.developer_sdk == '1') || - vars.METALSHARP_BUILD_DEVELOPER_SDK == '1' - runs-on: macos-14 - concurrency: - group: metalsharp-developer-sdk-bundles - cancel-in-progress: false - steps: - - uses: actions/checkout@v7 - with: - submodules: recursive - - - name: Download and verify release split bundles - run: METALSHARP_REPAIR_BUNDLES=0 METALSHARP_SKIP_DEVELOPER_SDK_BUNDLE=1 tools/dmg/create-bundles.sh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Build self-contained developer SDK - run: | - tools/bundles/create-developer-sdk.py \ - --bundle-dir app/bundles \ - --out-dir dist/developer-sdk \ - --manifest dist/bundles/metalsharp-bundle-manifest.tsv - tools/bundles/verify-developer-sdk.sh \ - dist/developer-sdk/metalsharp-d3d12-developer-sdk.tar.zst - - - name: Setup ORAS - uses: oras-project/setup-oras@v2 - - - name: Publish developer SDK package - run: | - set -euo pipefail - OWNER="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')" - PACKAGE="ghcr.io/$OWNER/metalsharp-d3d12-developer-sdk" - VERSION="${GITHUB_REF_NAME#v}" - ARCHIVE="dist/developer-sdk/metalsharp-d3d12-developer-sdk.tar.zst" - SHA="$(shasum -a 256 "$ARCHIVE" | awk '{print $1}')" - echo "$GH_TOKEN" | oras login ghcr.io -u "$GITHUB_ACTOR" --password-stdin - oras push "$PACKAGE:$VERSION" \ - "$ARCHIVE:application/vnd.metalsharp.d3d12-developer-sdk.layer.v1.tar+zstd" \ - --artifact-type application/vnd.metalsharp.d3d12-developer-sdk.v1 \ - --annotation "org.opencontainers.image.title=MetalSharp D3D12 Developer SDK" \ - --annotation "org.opencontainers.image.description=Self-contained MetalSharp D3D12 developer SDK with staged Wine runtime and DXMT files" \ - --annotation "org.opencontainers.image.version=$VERSION" \ - --annotation "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY" \ - --annotation "org.opencontainers.image.revision=$GITHUB_SHA" \ - --annotation "org.opencontainers.image.digest.sha256=$SHA" - oras tag "$PACKAGE:$VERSION" latest - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Verify downloaded release bundle layout - run: | - set -euo pipefail - tools/bundles/verify-bundles.sh --bundle-dir dist/bundles --require mac \ - metalsharp-electron.tar.zst \ - metalsharp-graphics-dll.tar.zst \ - metalsharp-runtime.tar.zst \ - metalsharp-assets.tar.zst \ - fnalibs.tar.zst \ - metalsharp-scripts-tools.tar.zst \ - metalsharp-steam.tar.zst - ! tar --use-compress-program=unzstd -tf dist/bundles/metalsharp-assets.tar.zst \ - | grep -E '^assets/eac-toggle/' - - - name: Publish developer SDK bundle - run: | - tools/bundles/publish-release-assets.sh bundles \ - dist/developer-sdk/metalsharp-d3d12-developer-sdk.tar.zst \ - dist/developer-sdk/metalsharp-bundle-manifest.tsv - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - build: name: Build DMG - # Decoupled from developer-sdk: the DMG must build and the GitHub Release - # must publish even when the D3D12 developer SDK build is disabled (=0). runs-on: macos-14 steps: - uses: actions/checkout@v7 @@ -146,23 +57,10 @@ jobs: - name: Build TypeScript run: cd app && npm run build - - name: Download bundles for DMG + - name: Download complete runtime assets for DMG and release run: | - METALSHARP_REPAIR_BUNDLES=0 METALSHARP_REPAIR_M12=0 tools/dmg/create-bundles.sh - tools/dmg/stage-release-bundles.sh app/bundles dist/staged-bundles - for required in \ - metalsharp-electron.tar.zst \ - metalsharp-graphics-dll.tar.zst \ - metalsharp-runtime.tar.zst \ - metalsharp-assets.tar.zst \ - metalsharp-scripts-tools.tar.zst \ - metalsharp-steam.tar.zst \ - metalsharp-d3d12-developer-sdk.tar.zst - do - test -s "app/bundles/$required" - done - ls -lh app/bundles/ - find dist/staged-bundles -type d | sort | sed -n '1,120p' + tools/dmg/prepare-complete-runtime-assets.sh app/release-runtime + ls -lh app/release-runtime/ env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -243,10 +141,11 @@ jobs: - uses: actions/upload-artifact@v4 with: name: MetalSharp-dmg + compression-level: 0 path: | dist/electron/*.dmg dist/electron/DMG-SIGNING.txt - dist/bundles/metalsharp-bundle-manifest.tsv + app/release-runtime/* release: name: Release @@ -255,6 +154,8 @@ jobs: permissions: contents: write steps: + - uses: actions/checkout@v7 + - name: Download release artifacts uses: actions/download-artifact@v4 with: @@ -268,17 +169,32 @@ jobs: mkdir -p release-flat find release -type f -name '*.dmg' -exec cp {} release-flat/ \; find release -type f -name 'DMG-SIGNING.txt' -exec cp {} release-flat/ \; - find release -type f -name 'metalsharp-bundle-manifest.tsv' -exec cp {} release-flat/ \; + for asset in \ + install-metalsharp-wine-runtime.sh \ + metalsharp-bundle-manifest.tsv \ + MetalSharp-Wine-Public-Source-2026-07-31.tar.zst \ + MetalSharp-Wine-Public-Source-2026-07-31.tar.zst.sha256 \ + MetalSharp-GOG-Support-arm64-1.2.2.tar.zst \ + MetalSharp-Wine-Runtime-COMPLETE-all-arch-2026-07-31.tar.zst.part01 \ + MetalSharp-Wine-Runtime-COMPLETE-all-arch-2026-07-31.tar.zst.part02 \ + MetalSharp-Wine-Runtime-COMPLETE-all-arch-2026-07-31.tar.zst.part03 \ + MetalSharp-Wine-Runtime-COMPLETE-all-arch-2026-07-31.tar.zst.part04 \ + PARTS-SHA256SUMS.txt \ + REASSEMBLE.txt + do + source="$(find release -type f -name "$asset" -print -quit)" + test -n "$source" + cp "$source" release-flat/ + done test "$(find release-flat -type f -name '*.dmg' | wc -l | tr -d ' ')" -ge 1 test -s release-flat/DMG-SIGNING.txt test -s release-flat/metalsharp-bundle-manifest.tsv + tools/dmg/prepare-complete-runtime-assets.sh --verify-only release-flat find release-flat -type f -print | sort - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: files: | - release-flat/*.dmg - release-flat/DMG-SIGNING.txt - release-flat/metalsharp-bundle-manifest.tsv + release-flat/* generate_release_notes: true diff --git a/.gitignore b/.gitignore index 6aae91efc..e7c97c4e8 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ src/fna/FNA3D/ build-x86/ app/bundles/*.tar.zst app/bundles/SteamSetup.exe +app/release-runtime/ # CMake build directories (in addition to the generic `build/`) /build-metal/ diff --git a/AGENTS.md b/AGENTS.md index 64450c225..74d6f86b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,201 @@ # AGENTS.md -**Updated:** 2026-07-08 +**Updated:** 2026-07-31 Guide for AI agents working on the MetalSharp repository. +## 0.60.0 Preview Release Work + +Development is isolated on `agent/0.60-preview-release`. The saved phase plan is +`~/Documents/obsidian/Avery'sVault/0.60.0 Official Preview Release Plan.md`. + +- Phase 1 replaces first-run split-bundle installation with + `scripts/install-metalsharp-wine-runtime.sh` from release + `v0.60.0-dependency-bundles`. +- The canonical complete-runtime SHA-256 is + `e44a84bceeca62f01fd95a133364ec82467cd8883ff81bcc1bdfdf4a6c3ad146`. +- Runtime completion is gated by `.metalsharp-runtime-install` plus Wine, + wineserver, fonts/NLS, DXVK i386, vkd3d-proton D3D12, OpenGL-Metal, and + MoltenVK payloads. Do not restore the old six-archive setup gate. +- The runtime installs transactionally at `~/.metalsharp/runtime`; prefixes, + Steam, bottles, saves, and shader caches remain outside that directory. +- Phase 1 keeps Rosetta installation until macOS 28 and keeps GPTK/Homebrew as + a separate route. The complete runtime launch adapters explicitly disable + FEX TSO, vector TSO, and memcpy/set TSO. +- Phase 2 creates `~/.metalsharp/prefix-steam` as a single all-architecture + prefix. Before wineboot it stages ARM64 WoW64 providers, every i386 Wine + builtin, and the accepted ARM64/ARM64EC CPU providers; after wineboot it + restages/verifies providers and requires both system32 and syswow64 gates. +- Steam installation enables the ntdll host notification only for installer + processes. `POST /steam/handoff` accepts exactly two serialized callbacks: + cycle 1 shuts down only the Steam prefix and relaunches its updater; cycle 2 + shuts it down, deploys and verifies the CEF wrapper, and writes + `.metalsharp-steam-install-complete`. Steam is not reported installed before + that marker exists. All install and handoff launches keep every FEX TSO mode + disabled. +- Phase 3 makes a functioning Homebrew installation a hard setup gate. Both + Electron and the Rust backend require `brew --version` to succeed; file + presence alone is not acceptance. The Terminal installer downloads the + official Homebrew installer with curl, validates it with `bash -n`, runs it, + then verifies the installed brew before reporting success. +- GPTK/D3DMetal remains separate from the complete MetalSharp Wine runtime and + is owned by Homebrew. Saving a D3DMetal bottle invokes the existing + `gcenx/wine/game-porting-toolkit` tap/trust/cask route; do not stage a private + GPTK copy into the main runtime. +- Phase 4 runs the interactive VC++ 2015-2022 x64 and x86 installers only + through `runtime/wine/bin/metalsharp-wine` and only after both the canonical + complete-runtime gate and the Steam all-architecture prefix gate pass. Both + installer commands pin `WINEBUILDDIR`, the Steam prefix, and all FEX TSO + modes off; they may wait up to 45 minutes for user interaction. +- Redistributables download from Microsoft's current aka.ms endpoints using + `/usr/bin/curl`, require a valid PE bootstrap and architecture-appropriate + payload size, and verify x64 DLLs in `system32` or x86 DLLs in `syswow64`. + Microsoft's x64 redistributable intentionally uses an i386 PE bootstrap, so + do not misclassify its bootstrap machine as the installed payload target. +- Phase 5 does not change the Launch Steam endpoint, UI button, executable, or + flags. Its wrapper maintenance now prefers the canonical complete-runtime + asset at `runtime/integration/steam-webhelper/steamwebhelper.exe`, whose + accepted SHA-256 is + `f46a1e8c39c850ba22861f63559f13b4f68557acf04a92e6d1b899769b2ea1f9`. +- Steam updates are repaired before launch only when the wrapper/real-helper + pair fails its hash-and-size contract. Wrapper deployment is refused unless + the target is exactly the MetalSharp Steam prefix or a MetalSharp-managed + bottle prefix; never deploy into CrossOver, another Wine installation, or an + arbitrary path. +- Phase 6 makes M12 a vkd3d-proton route, not a DXMT route. Its only PE + owners are `lib/vkd3d-proton/x86_64/{d3d12.dll,d3d12core.dll}` and + `lib/dxvk/x86_64/dxgi.dll`; the host handoff is Wine Vulkan to the bundled + ARM64 MoltenVK library. Never restore winemetal, `dxgi_dxmt`, or + `lib/dxmt_m12` to M12. +- M12 bottle save migrates old component ids to `m12_vkd3d_d3d12`, + `m12_vkd3d_d3d12core`, `m12_dxvk_dxgi`, `m12_winevulkan`, and + `m12_moltenvk`. Save, prepare, Play, dry-run, runtime doctor, migration, and + the complete-runtime gate all validate the same ownership boundaries. +- M9/M10/M10(32)/M11/M11(32) consume the complete runtime directly, not the + retired split-graphics manifest gate. M10/M11 use DXMT's + `aarch64-windows` PE lane; M10(32)/M11(32) use its `i386-windows` PE lane; + both cross into the native ARM64 `aarch64-unix/winemetal.so`. M9 uses the + Wine build's x86_64/i386 D3D9 lanes. D3DMetal remains a separate + Homebrew-owned GPTK prefix and must not borrow M12 runtime DLLs. +- The M12 launch environment contains vkd3d-proton/DXVK cache paths and no + DXMT/winemetal variables. All complete-runtime launch adapters continue to + pin every FEX TSO mode off. +- Phase 6 validation: the canonical archive passed the strengthened + prepare-only layout gate; extracted M12 PE DLLs identify as x86-64, the Wine + Vulkan Unix bridge identifies as ARM64, DXMT's i386 PE lane identifies as + i386, its `aarch64-windows` PE lane identifies as x86-64/ARM64EC-compatible, + and its Unix winemetal bridge identifies as ARM64. The deterministic Rust + suite passes 651/651, strict Clippy passes, all Rust targets build, and the + TypeScript/Vite production build passes. +- Phase 7 adds global Controller `[D | X]` and Msync controls to the left + sidebar. Settings persist as `controllerMode` (`dinput` or `xinput`) and + `msyncEnabled`; legacy snake-case aliases are read and written as well. +- Every managed Wine path receives `METALSHARP_CONTROLLER_MODE` and + `WINEMSYNC`, including MTSP direct routes, Steam route handoffs, Steam + setup/update processes, ordinary Wine launches, prefix initialization, and + GOG. Existing installs default to XInput and Msync enabled. Environment + overrides remain available as `METALSHARP_CONTROLLER_MODE` and + `METALSHARP_MSYNC`. +- Phase 7 validation: the deterministic Rust suite passes 653/653, strict + Clippy passes, all Rust targets build, and the TypeScript/Vite production + build passes. +- Phase 8 removes every retired split-runtime archive from the DMG resource + list and release workflow. `tools/dmg/prepare-complete-runtime-assets.sh` + downloads and pins the v0.60.0 installer, manifest, public source archive, + four runtime parts, checksum files, and reassembly instructions. +- The DMG embeds the installer, manifest, public source/provenance payload, + part checksums, and reassembly instructions under `runtime-bundle/`; it does + not embed the four runtime parts. Release CI publishes those parts as + separate assets so every file stays below GitHub's 2 GiB release-asset cap. + The backend prefers the packaged installer and passes its directory through + `--bundle-dir`, allowing local discovery before network download. +- The release gate verifies every asset hash against the pinned manifest, + verifies each part against `PARTS-SHA256SUMS.txt`, streams all four parts + through the canonical reconstructed SHA-256, syntax-checks the installer, + and rejects any DMG that still contains retired split archives. +- `tools/ci/verify-dmg-workflow.py` enforces the same six packaged assets and + ten separately published release assets. It must reject any return of the + eight split archives or the retired developer-SDK release job. +- Phase 8 validation: package-only assets from the live dependency release + passed hash and shell verification; release YAML parses; package metadata + contains only the complete-runtime resource contract; the deterministic + Rust suite passes 653/653; strict Clippy, all Rust targets, and the + TypeScript/Vite production build pass. +- The post-Phase-8 GOG pass adds + `MetalSharp-GOG-Support-arm64-1.2.2.tar.zst` to the dependency release and + DMG bootstrap payload. It is a self-contained thin ARM64 Heroic GOGDL 1.2.2 + executable with Requests/TLS, the compiled xdelta3 module, licenses, + per-file hashes, and pinned provenance. The archive SHA-256 is + `f13075f27d5155e84199619410936931b32310c4ec4161de992c1f727ac24155`. +- The complete-runtime installer validates and stages GOG support under + `runtime/integration/gog/`. Existing accepted Wine runtimes receive this + small layer in place without reassembling, extracting, or replacing the + multi-gigabyte Wine runtime. Runtime readiness now requires the GOG archive + marker and executable. +- Steam and GOG prefix creation share `runtime_prefix.rs`. Both stage ARM64 + WoW64 providers, all i386 builtins, canonical ARM64/ARM64EC execution + providers, and GStreamer before explicit ARM64 wineboot; wait for the exact + prefix wineserver; restage and verify providers; and require the same + ARM64/ARM64EC/x86_64/i386 acceptance gate. A `drive_c` directory alone is + never GOG-prefix acceptance. +- GOG prefers the canonical bundled native ARM64 GOGDL path over legacy + `~/.metalsharp/tools/gogdl`. Download, import, and Play commands inherit the + complete runtime's `WINEBUILDDIR`, library paths, controller/Msync settings, + and all three FEX TSO-off flags. The source-install fallback is pinned to + Heroic GOGDL `v1.2.2`, never an unpinned main branch. +- GOG validation: the native bundle passed archive/per-file hashes, Mach-O, + code-signature, version, embedded-xdelta, and unauthenticated command probes; + installer add/repair and idempotency completed in two seconds each against + a disposable existing-runtime fixture; the deterministic Rust suite passes + 655/655; strict Clippy, all Rust targets, TypeScript, Vite, DMG workflow, and + shell gates pass. +- Wine Mono 11.2.0 is part of the complete runtime. The GOG and Settings tabs + no longer offer a second interactive MSI download/install/reset path, and + the retired `/wine-mono/*` backend endpoints and installer module are gone. + This does not remove bundled Mono, the Mono/FNA launch lanes, or bottle-level + compatibility metadata. Post-removal validation passes 644/644 Rust tests, + strict Clippy, all Rust targets, and the Electron production build. + +- The 0.57 -> 0.60 migration is an in-place runtime replacement. Preserve and + restore setup/configuration, Steam prefix metadata and payloads, bottles, + games, Sharp Library data, and dosdevice links; replace the runtime and then + run native ARM64 `wineboot --init --update` on every real prefix. Prefix + update failure is fatal and must remain retryable in the migration wizard. + Cleanup owns only `runtime/`, transient installer progress files, and the + cache `downloads/`, `updates/`, `updater-tools/`, and `tmp/` subdirectories; + it must not delete configs, cover/API metadata, logs, or shader caches. +- The custom i386 provider contract is exact: System32 contains `xtajit.dll` + and must not contain `wow64cpu.dll` or `xtajit-*-candidate.dll`; the default + value of `HKLM\\Software\\Microsoft\\Wow64\\x86` must be `xtajit.dll`. + Wineboot can reselect `wow64cpu.dll` when that competing file is staged, so + migration removes conflicts and explicitly restores the registry value. +- Live migration validation used the installed 0.57 Steam prefix at + `~/.metalsharp/prefix-steam`, with the immutable safety copy at + `/Volumes/AverySSD/MetalSharp-0.57-Live-Safety-2026-07-31`. Migration passed + 8/8, preserved Steam's executable at SHA-256 + `eb823470675e0a8f8b91c4f4f1e4bb6148e72b606a518f250a10c598bb1705b8`, + and passed ARM64, ARM64EC, x86_64, and i386 execution in that same prefix. + Architecture probes must use `apply_complete_runtime_env` equivalence, + including the runtime fallback library path and all FEX TSO modes disabled; + omitting that environment is not a valid i386 result. +- Final release-path validation used a fresh copy-on-write clone of that + immutable 0.57 backup and an empty AverySSD cache. The migration downloaded + all four published runtime parts plus GOG support, reassembled and verified + the canonical archive, activated the runtime transactionally, and completed + all 8 migration steps. Of 6,845 baseline user-payload files, 6,841 remained + byte-identical; the only expected differences were deletion of the stale + root `SteamSetup.exe` and Steam `.crash` marker plus appends to two logs. + The preserved `steam.exe` and `SteamUI.dll` hashes were unchanged. +- That upgraded prefix passed ARM64 (rc 0), ARM64EC (rc 42), x86_64 (rc 7), + and i386/WoW64 (rc 0 plus marker) with Msync enabled and all FEX TSO modes + disabled. `POST /steam/launch` then started the preserved Steam executable, + kept it alive through the handoff, and produced an on-screen Wine window + titled `Sign in to Steam`; `POST /steam/stop` shut down only that prefix. +- The fresh download exposed two installer-only regressions now covered by + `tools/ci/verify-dmg-workflow.py`: a same-line `local` expansion referenced + `destination` before assignment under `set -u`, and packaged launch/layout + symlinks were recreated with non-idempotent `ln -s`. Download assignment is + now ordered explicitly and all expected packaged symlinks use `ln -sfn`. + ## What This Project Is MetalSharp is a macOS app that runs Windows Steam games and Windows programs via Wine + Metal translation. It's an Electron app with a Rust HTTP backend, a C++ native D3D/Metal engine, per-game engine routing, runtime bottles, installer profiles, and Linux `.deb` packaging. @@ -71,7 +264,8 @@ Modern runtime paths use MTSP pipeline ids and bottle profiles. Steam games get | `M9` | D3D9 / 32-bit capable DXMT-family route | Nidhogg 2, Undertale, Blasphemous, Dave the Diver | | `M10` | D3D10 to Metal | D3D10 apps/games | | `M11` | D3D11 to Metal | Rain World, Schedule I, Subnautica BZ | -| `M12` | D3D12 to Metal through the isolated `dxmt-m12` runtime surface | Peak, Silksong, Elden Ring, D3D12 investigation titles | +| `M12` | vkd3d-proton D3D12 + DXVK DXGI through Wine Vulkan and ARM64 MoltenVK | Peak, Silksong, Elden Ring, D3D12 investigation titles | +| `D3DMetal` | Homebrew GPTK/D3DMetal in its own prefix | Explicit GPTK route | | `Mono/FNA` | Windows XNA/FNA through native Mono, staged FNA/XNA assemblies, and host shims | Celeste, Terraria | Internal route ids such as `dxmt`, `wine_bare`, `m32`, `steam`, `macos_steam`, and `m13` stay backend-parseable for legacy records, diagnostics, and fallback behavior, but they are not normal bottle route buttons. @@ -80,11 +274,15 @@ Internal route ids such as `dxmt`, `wine_bare`, `m32`, `steam`, `macos_steam`, a - Wine runtime: `~/.metalsharp/runtime/wine/` - Wine prefix: `~/.metalsharp/prefix-steam/` -- DXMT PE DLLs for M9/M10/M11: `~/.metalsharp/runtime/wine/lib/dxmt/x86_64-windows/` -- DXMT M12 PE DLLs: `~/.metalsharp/runtime/wine/lib/dxmt-m12/x86_64-windows/` -- DXMT M12 Unix bridge and sidecars: `~/.metalsharp/runtime/wine/lib/dxmt-m12/x86_64-unix/` -- DXVK i386 DLLs: `~/.metalsharp/runtime/wine/lib/dxvk/i386-windows/` -- MoltenVK ICD: `~/.metalsharp/runtime/wine/etc/vulkan/icd.d/MoltenVK_icd.json` +- DXMT M10/M11 PE DLLs: `~/.metalsharp/runtime/wine/build-ec/dxmt-v0.80/aarch64-windows/` +- DXMT M10(32)/M11(32) PE DLLs: `~/.metalsharp/runtime/wine/build-ec/dxmt-v0.80/i386-windows/` +- DXMT native host bridge: `~/.metalsharp/runtime/wine/build-ec/dxmt-v0.80/aarch64-unix/winemetal.so` +- M9 Wine D3D9 DLLs: `~/.metalsharp/runtime/wine/build-ec/dlls/d3d9/{x86_64,i386}-windows/` +- M12 vkd3d-proton DLLs: `~/.metalsharp/runtime/wine/lib/vkd3d-proton/x86_64/` +- M12 DXVK DXGI: `~/.metalsharp/runtime/wine/lib/dxvk/x86_64/dxgi.dll` +- M12 Wine Vulkan bridge: `~/.metalsharp/runtime/wine/build-ec/dlls/winevulkan/` +- M12 MoltenVK host library: `~/.metalsharp/runtime/wine/build-ec/dlls/win32u/libMoltenVK.dylib` +- DXVK i386 DLLs: `~/.metalsharp/runtime/wine/lib/dxvk/i386/` - DXMT config: `~/.metalsharp/runtime/wine/etc/dxmt.conf` - Local redistributables: `~/.metalsharp/runtime/redist/` - Runtime bottles: `~/.metalsharp/bottles//` diff --git a/CMakeLists.txt b/CMakeLists.txt index 2245bc7f3..1cbe42e31 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(metalsharp VERSION 0.57.0 LANGUAGES C CXX OBJC OBJCXX) +project(metalsharp VERSION 0.60.0 LANGUAGES C CXX OBJC OBJCXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/README.md b/README.md index 12b5862c7..c3ce5bfb3 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ --- -MetalSharp is an application designed to run Windows Steam and Windows Steam games natively on Apple Silicon macOS. Now includes GOG-Games Support. MetalSharp builds and includes its own custom Wine 11.5 runtime, game launch rules, custom DXMT build, runtime bottles, and repair tooling. +MetalSharp is an application designed to run Windows Steam and Windows Steam games natively on Apple Silicon macOS. Now includes GOG-Games Support. MetalSharp includes its custom Wine 11.12 multi-architecture runtime, game launch rules, DXMT, DXVK, vkd3d-proton, MoltenVK, runtime bottles, and repair tooling. Screenshot 2026-07-21 at 5 49 12 AM @@ -39,7 +39,7 @@ This installs the signed MetalSharp release into `/Applications`. For developer | Route | Engine | |---|---| -| **M12** | D3D12 to Metal (experimental DXMT) | +| **M12** | D3D12 via vkd3d-proton + DXVK DXGI → Wine Vulkan → ARM64 MoltenVK → Metal | | **M11** | D3D11 to Metal (DXMT) | | **M11(32)** | D3D11 i386 to Metal (DXMT) | | **M10** | D3D10 to Metal (DXMT) | @@ -59,7 +59,7 @@ This installs the signed MetalSharp release into `/Applications`. For developer ## Requirements - Apple Silicon Mac M1-M5, macOS 14+ -- About 2 GB free space +- At least 20 GB free space for the complete runtime installation - Homebrew (installed by setup wizard) All other bundled assets, DLLs, and graphics backends are installed during the setup process. GPTK/D3DMetal is the exception: MetalSharp installs and uses Homebrew GPTK only when a D3DMetal bottle is saved. diff --git a/app/package-lock.json b/app/package-lock.json index 31f680b38..9546759cd 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "metalsharp", - "version": "0.57.0", + "version": "0.60.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metalsharp", - "version": "0.57.0", + "version": "0.60.0", "dependencies": { "electron-store": "^10.0.0" }, diff --git a/app/package.json b/app/package.json index b9ac53668..9e7fb1403 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "metalsharp", - "version": "0.57.0", + "version": "0.60.0", "description": "MetalSharp — D3D→Metal translation layer frontend", "author": "MetalSharp", "repository": { @@ -21,7 +21,7 @@ "build:native": "bash ../scripts/tools/native/build-helper.sh", "pack": "npm run build:all && electron-builder --dir", "prepare:native": "../tools/package/prepare-native-placeholders.sh", - "fetch:bundles:mac": "../tools/dmg/create-bundles.sh", + "fetch:bundles:mac": "../tools/dmg/prepare-complete-runtime-assets.sh", "fetch:bundles": "npm run fetch:bundles:mac", "dist": "npm run prepare:native && npm run build:all && npm run build:native && npm run fetch:bundles && electron-builder", "dmg": "npm run dist -- --mac dmg" @@ -137,36 +137,21 @@ ] }, { - "from": "bundles/metalsharp-electron.tar.zst", - "to": "bundles/metalsharp-electron.tar.zst" + "from": "../scripts/install-metalsharp-wine-runtime.sh", + "to": "scripts/install-metalsharp-wine-runtime.sh" }, { - "from": "bundles/metalsharp-graphics-dll.tar.zst", - "to": "bundles/metalsharp-graphics-dll.tar.zst" - }, - { - "from": "bundles/metalsharp-runtime.tar.zst", - "to": "bundles/metalsharp-runtime.tar.zst" - }, - { - "from": "bundles/metalsharp-assets.tar.zst", - "to": "bundles/metalsharp-assets.tar.zst" - }, - { - "from": "bundles/fnalibs.tar.zst", - "to": "bundles/fnalibs.tar.zst" - }, - { - "from": "bundles/metalsharp-scripts-tools.tar.zst", - "to": "bundles/metalsharp-scripts-tools.tar.zst" - }, - { - "from": "bundles/metalsharp-steam.tar.zst", - "to": "bundles/metalsharp-steam.tar.zst" - }, - { - "from": "bundles/metalsharp-d3d12-developer-sdk.tar.zst", - "to": "bundles/metalsharp-d3d12-developer-sdk.tar.zst" + "from": "release-runtime", + "to": "runtime-bundle", + "filter": [ + "install-metalsharp-wine-runtime.sh", + "metalsharp-bundle-manifest.tsv", + "MetalSharp-Wine-Public-Source-2026-07-31.tar.zst", + "MetalSharp-Wine-Public-Source-2026-07-31.tar.zst.sha256", + "MetalSharp-GOG-Support-arm64-1.2.2.tar.zst", + "PARTS-SHA256SUMS.txt", + "REASSEMBLE.txt" + ] } ], "directories": { diff --git a/app/src-rust/Cargo.lock b/app/src-rust/Cargo.lock index b0969dfcd..599e9b239 100644 --- a/app/src-rust/Cargo.lock +++ b/app/src-rust/Cargo.lock @@ -554,7 +554,7 @@ checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "metalsharp-backend" -version = "0.57.0" +version = "0.60.0" dependencies = [ "ctrlc", "dirs", diff --git a/app/src-rust/Cargo.toml b/app/src-rust/Cargo.toml index c24c94c3b..fcb275516 100644 --- a/app/src-rust/Cargo.toml +++ b/app/src-rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "metalsharp-backend" -version = "0.57.0" +version = "0.60.0" edition = "2021" [dependencies] diff --git a/app/src-rust/src/bottles.rs b/app/src-rust/src/bottles.rs index e72955b82..845cda231 100644 --- a/app/src-rust/src/bottles.rs +++ b/app/src-rust/src/bottles.rs @@ -3,7 +3,7 @@ use serde_json::{json, Value}; use std::collections::{hash_map::DefaultHasher, HashMap, HashSet}; use std::fs::{self, OpenOptions}; use std::hash::{Hash, Hasher}; -use std::io::Write; +use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::Mutex; @@ -18,18 +18,43 @@ const VCPP_X64_URL: &str = "https://aka.ms/vs/17/release/vc_redist.x64.exe"; const VCPP_X86_URL: &str = "https://aka.ms/vs/17/release/vc_redist.x86.exe"; const VCPP_X64_FILENAME: &str = "vc_redist.x64.exe"; const VCPP_X86_FILENAME: &str = "vc_redist.x86.exe"; -const VCPP_MIN_SIZE: u64 = 1_000_000; +const VCPP_X64_MIN_SIZE: u64 = 15_000_000; +const VCPP_X86_MIN_SIZE: u64 = 5_000_000; fn vcpp_cached_dir() -> Option { Some(crate::platform::metalsharp_home_dir().join("runtime").join("redist").join("vcredist")) } -fn vcpp_downloaded(path: &Path) -> bool { - path.is_file() && fs::metadata(path).map(|m| m.len() > VCPP_MIN_SIZE).unwrap_or(false) +fn vcpp_downloaded(path: &Path, arch: &str) -> bool { + let minimum_size = match arch { + // Microsoft's x64 redistributable currently uses an i386 PE bootstrap + // around the larger x64 payload. Size and PE validity distinguish it + // from the smaller x86 package; the bootstrap machine does not. + "x64" => VCPP_X64_MIN_SIZE, + "x86" => VCPP_X86_MIN_SIZE, + _ => return false, + }; + if !path.is_file() || !fs::metadata(path).map(|m| m.len() > minimum_size).unwrap_or(false) { + return false; + } + let mut file = match fs::File::open(path) { + Ok(file) => file, + Err(_) => return false, + }; + let mut dos = [0u8; 64]; + if file.read_exact(&mut dos).is_err() || &dos[..2] != b"MZ" { + return false; + } + let pe_offset = u32::from_le_bytes([dos[0x3c], dos[0x3d], dos[0x3e], dos[0x3f]]) as u64; + let mut pe = [0u8; 6]; + file.seek(SeekFrom::Start(pe_offset)).is_ok() + && file.read_exact(&mut pe).is_ok() + && &pe[..4] == b"PE\0\0" + && matches!(u16::from_le_bytes([pe[4], pe[5]]), 0x014c | 0x8664) } -fn vcpp_download(url: &str, dest: &Path) -> Result<(), String> { - if vcpp_downloaded(dest) { +fn vcpp_download(url: &str, dest: &Path, arch: &str) -> Result<(), String> { + if vcpp_downloaded(dest, arch) { return Ok(()); } let tmp = dest.with_extension("download"); @@ -37,7 +62,7 @@ fn vcpp_download(url: &str, dest: &Path) -> Result<(), String> { if let Some(parent) = dest.parent() { let _ = fs::create_dir_all(parent); } - let output = Command::new("curl") + let output = Command::new("/usr/bin/curl") .args(["--fail", "--location", "--silent", "--show-error", "--retry", "3", "-o"]) .arg(&tmp) .arg(url) @@ -47,9 +72,9 @@ fn vcpp_download(url: &str, dest: &Path) -> Result<(), String> { let _ = fs::remove_file(&tmp); return Err(format!("curl download failed for {}", url)); } - if !vcpp_downloaded(&tmp) { + if !vcpp_downloaded(&tmp, arch) { let _ = fs::remove_file(&tmp); - return Err(format!("downloaded file too small or missing: {}", dest.display())); + return Err(format!("downloaded {} redistributable is not a valid PE bootstrap: {}", arch, dest.display())); } let _ = fs::rename(&tmp, dest); Ok(()) @@ -59,7 +84,7 @@ fn vcpp_both_cached() -> Option<(PathBuf, PathBuf)> { let dir = vcpp_cached_dir()?; let x64 = dir.join(VCPP_X64_FILENAME); let x86 = dir.join(VCPP_X86_FILENAME); - if vcpp_downloaded(&x64) && vcpp_downloaded(&x86) { + if vcpp_downloaded(&x64, "x64") && vcpp_downloaded(&x86, "x86") { Some((x64, x86)) } else { None @@ -71,13 +96,13 @@ fn vcpp_ensure_downloaded() -> Result<(PathBuf, PathBuf), String> { let _ = fs::create_dir_all(&dir); let x64 = dir.join(VCPP_X64_FILENAME); let x86 = dir.join(VCPP_X86_FILENAME); - if !vcpp_downloaded(&x64) { + if !vcpp_downloaded(&x64, "x64") { eprintln!("vcredist: downloading VC++ 2015-2022 x64 from Microsoft ..."); - vcpp_download(VCPP_X64_URL, &x64)?; + vcpp_download(VCPP_X64_URL, &x64, "x64")?; } - if !vcpp_downloaded(&x86) { + if !vcpp_downloaded(&x86, "x86") { eprintln!("vcredist: downloading VC++ 2015-2022 x86 from Microsoft ..."); - vcpp_download(VCPP_X86_URL, &x86)?; + vcpp_download(VCPP_X86_URL, &x86, "x86")?; } Ok((x64, x86)) } @@ -105,33 +130,25 @@ fn vcpp_install_into_prefix(prefix: &Path) -> Result<(), String> { } let prefix_str = prefix.to_string_lossy().to_string(); eprintln!("vcredist: installing VC++ 2015-2022 x64 into {} ...", prefix.display()); - let x64_status = Command::new(&wine) - .arg(&x64) - .arg("/install") - .env("WINEPREFIX", &prefix_str) - .env("WINEDEBUG", "-all") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map_err(|e| format!("wine x64 failed: {}", e))?; + let mut x64_command = Command::new(&wine); + x64_command.arg(&x64).arg("/install").stdout(Stdio::null()).stderr(Stdio::null()); + configure_metalsharp_vcpp_env(&mut x64_command, &ms_root, &prefix_str); + let x64_status = x64_command.status().map_err(|e| format!("wine x64 failed: {}", e))?; if !x64_status.success() { return Err("VC++ x64 installer failed".into()); } - let _ = Command::new(ms_root.join("bin").join("wineserver")).env("WINEPREFIX", &prefix_str).arg("-w").status(); + let _ = + Command::new(crate::platform::runtime_wineserver(&ms_root)).env("WINEPREFIX", &prefix_str).arg("-w").status(); eprintln!("vcredist: installing VC++ 2015-2022 x86 into {} ...", prefix.display()); - let x86_status = Command::new(&wine) - .arg(&x86) - .arg("/install") - .env("WINEPREFIX", &prefix_str) - .env("WINEDEBUG", "-all") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map_err(|e| format!("wine x86 failed: {}", e))?; + let mut x86_command = Command::new(&wine); + x86_command.arg(&x86).arg("/install").stdout(Stdio::null()).stderr(Stdio::null()); + configure_metalsharp_vcpp_env(&mut x86_command, &ms_root, &prefix_str); + let x86_status = x86_command.status().map_err(|e| format!("wine x86 failed: {}", e))?; if !x86_status.success() { return Err("VC++ x86 installer failed".into()); } - let _ = Command::new(ms_root.join("bin").join("wineserver")).env("WINEPREFIX", &prefix_str).arg("-w").status(); + let _ = + Command::new(crate::platform::runtime_wineserver(&ms_root)).env("WINEPREFIX", &prefix_str).arg("-w").status(); if vcpp_prefix_has_runtime(prefix) { eprintln!("vcredist: VC++ 2015-2022 verified in {}", prefix.display()); Ok(()) @@ -171,22 +188,11 @@ fn run_interactive_vcpp_installer(prefix: &Path, installer: &Path, arch: &str) - } let prefix_str = prefix.to_string_lossy().to_string(); eprintln!("vcredist: launching interactive VC++ 2015-2022 {} installer into {} ...", arch, prefix.display()); - let mut cmd = Command::new(&wine); - cmd.arg("start") - .arg("/wait") - .arg("/unix") - .arg(installer) - .args(vcpp_setup_install_args()) - .env("WINEPREFIX", &prefix_str) - .env("WINEARCH", "win64") - .env("WINEDEBUG", "-all") - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()); + let mut cmd = vcpp_installer_command(&wine, &ms_root, &prefix_str, installer); + cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit()); if let Some(parent) = installer.parent() { cmd.current_dir(parent); } - crate::platform::set_runtime_library_env(&mut cmd, &ms_root); let status = cmd.status().map_err(|e| format!("wine {} failed: {}", arch, e))?; if vcpp_installer_status_ok(status.code()) { Ok(()) @@ -195,6 +201,25 @@ fn run_interactive_vcpp_installer(prefix: &Path, installer: &Path, arch: &str) - } } +fn vcpp_installer_command(wine: &Path, ms_root: &Path, prefix: &str, installer: &Path) -> Command { + let mut cmd = Command::new(wine); + cmd.arg("start").arg("/wait").arg("/unix").arg(installer).args(vcpp_setup_install_args()); + configure_metalsharp_vcpp_env(&mut cmd, ms_root, prefix); + cmd +} + +fn configure_metalsharp_vcpp_env(cmd: &mut Command, ms_root: &Path, prefix: &str) { + cmd.env("WINEPREFIX", prefix) + .env("WINEARCH", "win64") + .env("WINEBUILDDIR", ms_root.join("build-ec")) + .env("FEX_TSOENABLED", "0") + .env("FEX_VECTORTSOENABLED", "0") + .env("FEX_MEMCPYSETTSOENABLED", "0") + .env("WINEDEBUG", "-all") + .env("WINEDEBUGGER", "none"); + crate::platform::set_runtime_library_env(cmd, ms_root); +} + fn vcpp_setup_install_args() -> [&'static str; 1] { ["/install"] } @@ -620,16 +645,17 @@ pub fn load_bottle(id: &str) -> Result { - let has_pr230_shape = M12_RUNTIME_COMPONENT_IDS + let has_vkd3d_shape = M12_RUNTIME_COMPONENT_IDS .iter() .all(|id| manifest.installed_components.iter().any(|component| component.id == *id)); let has_stale_m12_shape = manifest.installed_components.iter().any(|component| { - matches!( - component.id.as_str(), - "d3d12" | "d3d11" | "dxgi" | "gpu_vendor_stubs" | "gptk" | "gptk_prefix" | "rosetta" - ) + is_legacy_m12_runtime_component(&component.id) + || matches!( + component.id.as_str(), + "d3d12" | "d3d11" | "dxgi" | "gpu_vendor_stubs" | "gptk" | "gptk_prefix" | "rosetta" + ) }); - !has_pr230_shape || has_stale_m12_shape + !has_vkd3d_shape || has_stale_m12_shape }, RuntimeProfile::D3DMetal => { manifest.installed_components.iter().any(|component| is_m12_runtime_component(&component.id)) @@ -644,23 +670,26 @@ fn normalize_loaded_runtime_profile_components(manifest: &mut BottleManifest) { } const M12_RUNTIME_COMPONENT_IDS: &[&str] = - &["m12_d3d12", "m12_d3d11", "m12_d3d10core", "m12_dxgi_dxmt", "m12_dxgi", "m12_winemetal", "m12_gpu_stubs"]; + &["m12_vkd3d_d3d12", "m12_vkd3d_d3d12core", "m12_dxvk_dxgi", "m12_winevulkan", "m12_moltenvk"]; + +fn is_legacy_m12_runtime_component(component_id: &str) -> bool { + matches!( + component_id, + "m12_d3d12" | "m12_d3d11" | "m12_d3d10core" | "m12_dxgi_dxmt" | "m12_dxgi" | "m12_winemetal" | "m12_gpu_stubs" + ) +} fn m12_runtime_component_artifacts(component_id: &str) -> Option<&'static [&'static str]> { match component_id { - "m12_d3d12" => Some(&["x86_64-windows/d3d12.dll"]), - "m12_d3d11" => Some(&["x86_64-windows/d3d11.dll"]), - "m12_d3d10core" => Some(&["x86_64-windows/d3d10core.dll"]), - "m12_dxgi_dxmt" => Some(&["x86_64-windows/dxgi_dxmt.dll"]), - "m12_dxgi" => Some(&["x86_64-windows/dxgi.dll"]), - "m12_winemetal" => Some(&[ - "x86_64-windows/winemetal.dll", - "x86_64-unix/winemetal.so", - "x86_64-unix/libc++.1.dylib", - "x86_64-unix/libc++abi.1.dylib", - "x86_64-unix/libunwind.1.dylib", + "m12_vkd3d_d3d12" => Some(&["lib/vkd3d-proton/x86_64/d3d12.dll"]), + "m12_vkd3d_d3d12core" => Some(&["lib/vkd3d-proton/x86_64/d3d12core.dll"]), + "m12_dxvk_dxgi" => Some(&["lib/dxvk/x86_64/dxgi.dll"]), + "m12_winevulkan" => Some(&[ + "build-ec/dlls/winevulkan/winevulkan.so", + "build-ec/dlls/winevulkan/x86_64-windows/winevulkan.dll", + "build-ec/dlls/vulkan-1/x86_64-windows/vulkan-1.dll", ]), - "m12_gpu_stubs" => Some(&["x86_64-windows/nvapi64.dll", "x86_64-windows/nvngx.dll"]), + "m12_moltenvk" => Some(&["build-ec/dlls/win32u/libMoltenVK.dylib", "lib/moltenvk/libMoltenVK.dylib"]), _ => None, } } @@ -672,13 +701,20 @@ fn is_m12_runtime_component(component_id: &str) -> bool { fn inspect_m12_runtime_component(component_id: &str) -> Option { let artifacts = m12_runtime_component_artifacts(component_id)?; let home = dirs::home_dir()?; + let wine_root = crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("wine"); let valid_count = artifacts .iter() - .filter(|artifact| crate::installer::dxmt_m12_runtime_artifact_valid_for_home(&home, artifact)) + .filter(|artifact| { + wine_root + .join(artifact) + .metadata() + .map(|metadata| metadata.is_file() && metadata.len() > 0) + .unwrap_or(false) + }) .count(); if valid_count == artifacts.len() { Some(ComponentState::Installed) - } else if valid_count > 0 || crate::installer::dxmt_m12_runtime_current_for_home(&home) { + } else if valid_count > 0 || crate::installer::complete_runtime_current_for_home(&home) { Some(ComponentState::NeedsRepair) } else { Some(ComponentState::Missing) @@ -691,13 +727,18 @@ fn m12_runtime_component_detail(component_id: &str) -> String { let paths = artifacts .iter() .map(|artifact| { - crate::installer::dxmt_m12_runtime_artifact_path_for_home(&home, artifact).to_string_lossy().to_string() + crate::platform::metalsharp_home_dir_for(&home) + .join("runtime") + .join("wine") + .join(artifact) + .to_string_lossy() + .to_string() }) .collect::>(); if paths.is_empty() { - "M12 DXMT runtime artifact".to_string() + "M12 vkd3d-proton/Vulkan runtime artifact".to_string() } else { - format!("PR230 M12 DXMT runtime artifact(s): {}", paths.join(", ")) + format!("M12 vkd3d-proton/DXVK/MoltenVK runtime artifact(s): {}", paths.join(", ")) } } @@ -705,7 +746,7 @@ pub fn save_bottle(manifest: &BottleManifest) -> Result<(), Box Result<(), Box ("complete-runtime-m9", &["d3d9"]), RuntimeProfile::M11 | RuntimeProfile::M11_32 | RuntimeProfile::M10 | RuntimeProfile::M10_32 => { - ("dxmt", &["d3d11", "dxgi"]) + ("complete-runtime-dxmt", &["d3d11", "dxgi"]) }, - RuntimeProfile::M12 => ("dxmt_m12", M12_RUNTIME_COMPONENT_IDS), + RuntimeProfile::M12 => ("vkd3d-proton", M12_RUNTIME_COMPONENT_IDS), _ => return, }; @@ -734,11 +776,14 @@ fn refresh_dxmt_runtime_before_save(manifest: &mut BottleManifest) { let runtime_ready = dirs::home_dir() .ok_or_else(|| "home directory could not be resolved".to_string()) .and_then(|home| match manifest.runtime_profile { - RuntimeProfile::M11 | RuntimeProfile::M11_32 | RuntimeProfile::M10 | RuntimeProfile::M10_32 => { - crate::installer::ensure_dxmt_runtime_ready(&home).map(|_| true) - }, - RuntimeProfile::M12 => crate::installer::ensure_dxmt_m12_runtime_ready(&home) - .map(|_| crate::installer::dxmt_m12_runtime_current_for_home(&home)), + RuntimeProfile::M9 + | RuntimeProfile::M11 + | RuntimeProfile::M11_32 + | RuntimeProfile::M10 + | RuntimeProfile::M10_32 => ensure_complete_graphics_runtime_ready(&home), + RuntimeProfile::M12 => Ok(M12_RUNTIME_COMPONENT_IDS + .iter() + .all(|id| inspect_m12_runtime_component(id) == Some(ComponentState::Installed))), _ => Ok(false), }) .unwrap_or_else(|e| { @@ -889,7 +934,7 @@ pub fn save_steam_compatdata( ) -> Result> { let mut persisted = manifest.clone(); refresh_mono_fna_components_before_save(&mut persisted); - refresh_dxmt_runtime_before_save(&mut persisted); + refresh_graphics_runtime_before_save(&mut persisted); let appid = persisted.steam_app_id.ok_or("steam compatdata requires steam appid")?; let record = steam_compatdata_record(&persisted, pipeline); eprintln!( @@ -1187,13 +1232,22 @@ pub fn prepare_steam_game_launch( #[cfg(not(test))] if let Some(home) = dirs::home_dir() { match pipeline { - crate::mtsp::engine::PipelineId::M11 => { - crate::installer::ensure_dxmt_runtime_ready(&home) - .map_err(|e| format!("M11 runtime setup failed before Steam launch: {}", e))?; + crate::mtsp::engine::PipelineId::M9 + | crate::mtsp::engine::PipelineId::M10 + | crate::mtsp::engine::PipelineId::M10_32 + | crate::mtsp::engine::PipelineId::M11 + | crate::mtsp::engine::PipelineId::M11_32 => { + ensure_complete_graphics_runtime_ready(&home) + .map_err(|e| format!("{:?} runtime setup failed before Steam launch: {}", pipeline, e))?; }, - crate::mtsp::engine::PipelineId::M12 => { - crate::installer::ensure_dxmt_m12_runtime_ready(&home) - .map_err(|e| format!("M12 runtime setup failed before Steam launch: {}", e))?; + crate::mtsp::engine::PipelineId::M12 + if !M12_RUNTIME_COMPONENT_IDS + .iter() + .all(|id| inspect_m12_runtime_component(id) == Some(ComponentState::Installed)) => + { + return Err( + "M12 vkd3d-proton runtime is incomplete; reinstall the Complete Multi-Architecture Runtime".into(), + ); }, _ => {}, } @@ -1202,8 +1256,8 @@ pub fn prepare_steam_game_launch( // MTSP routes. /steam/launch-game immediately calls // mtsp::launcher::prepare_steam_pipeline_env(), which validates the route // runtime and stages the same game-local DLLs/env that launch will use. - // Calling the old setup path here can overwrite an explicitly staged M12 - // runtime with packaged assets and break PR/runtime proof runs. + // Calling the old setup path here can overwrite explicitly staged route + // DLLs and break runtime proof runs. let dual = crate::scan::resolve_dual_game_dir(appid); let name = crate::steam::get_game_name_from_manifest(appid).unwrap_or_else(|| format!("Game {}", appid)); let mut manifest = ensure_steam_game_bottle_inner(appid, &name, dual.wine_dir.as_deref(), pipeline, false)?; @@ -1316,6 +1370,18 @@ pub fn prepare_steam_game_launch( Ok(manifest) } +fn ensure_complete_graphics_runtime_ready(home: &Path) -> Result { + if crate::installer::complete_runtime_current_for_home(home) { + return Ok(true); + } + crate::installer::install_complete_runtime(&home.to_path_buf())?; + if crate::installer::complete_runtime_current_for_home(home) { + Ok(true) + } else { + Err("Complete Multi-Architecture Runtime is missing one or more M9-M12 route artifacts".into()) + } +} + pub fn sync_steam_game_bottles() -> Result, Box> { let mut bottles = Vec::new(); for appid in crate::steam::get_wine_steam_installed_games() { @@ -1571,8 +1637,10 @@ fn stage_m12_dlls_for_saved_steam_bottle( } let home = dirs::home_dir().ok_or("no home dir")?; - crate::installer::ensure_dxmt_m12_runtime_ready(&home) - .map_err(|e| format!("M12 runtime setup failed while saving bottle: {}", e))?; + if !M12_RUNTIME_COMPONENT_IDS.iter().all(|id| inspect_m12_runtime_component(id) == Some(ComponentState::Installed)) + { + return Err("M12 vkd3d-proton runtime is incomplete; reinstall the Complete Multi-Architecture Runtime".into()); + } let (_env, recipe) = crate::mtsp::launcher::prepare_steam_pipeline_env(appid, crate::mtsp::engine::PipelineId::M12) .map_err(|e| format!("M12 DLL deployment failed while saving bottle: {}", e))?; @@ -1587,7 +1655,7 @@ fn stage_m12_dlls_for_saved_steam_bottle( /// files are moved aside) and deploys the new route's DLLs next to the exe. /// /// M12 is handled by `stage_m12_dlls_for_saved_steam_bottle` (which also -/// ensures the isolated M12 runtime is ready). This helper covers the other +/// verifies the complete vkd3d-proton/Vulkan runtime). This helper covers the other /// DXMT-family routes (M9/M10/M10_32/M11/M11_32); non-DXMT profiles return /// `None` and are staged at launch time as before. fn stage_route_dlls_for_saved_steam_bottle( @@ -2525,10 +2593,11 @@ pub fn repair_component( detail: if state == ComponentState::Installed { format!("{} is already current", m12_runtime_component_detail(component_id)) } else { - "Refreshes the bundled PR230 M12 DXMT runtime surface under runtime/wine/lib/dxmt_m12".to_string() + "Reinstalls the Complete Multi-Architecture Runtime containing vkd3d-proton, DXVK, Wine Vulkan, and MoltenVK".to_string() }, asset_path: Some( - crate::installer::dxmt_m12_runtime_artifact_path_for_home(&home, "x86_64-windows/d3d12.dll") + crate::platform::metalsharp_home_dir_for(&home) + .join("runtime/wine/lib/vkd3d-proton/x86_64/d3d12.dll") .to_string_lossy() .to_string(), ), @@ -2537,7 +2606,7 @@ pub fn repair_component( }); } - crate::installer::ensure_dxmt_m12_runtime_ready(&home)?; + crate::installer::install_complete_runtime(&home)?; for id in M12_RUNTIME_COMPONENT_IDS { let state = inspect_m12_runtime_component(id).unwrap_or(ComponentState::Missing); mark_component_state(&mut manifest, id, state); @@ -2553,9 +2622,13 @@ pub fn repair_component( return Ok(ComponentRepairReport { id: component_id.to_string(), status: if state == ComponentState::Installed { "installed" } else { "needs_repair" }.to_string(), - detail: format!("Refreshed PR230 M12 DXMT runtime surface; {}", m12_runtime_component_detail(component_id)), + detail: format!( + "Refreshed the complete M12 vkd3d-proton runtime; {}", + m12_runtime_component_detail(component_id) + ), asset_path: Some( - crate::installer::dxmt_m12_runtime_artifact_path_for_home(&home, "x86_64-windows/d3d12.dll") + crate::platform::metalsharp_home_dir_for(&home) + .join("runtime/wine/lib/vkd3d-proton/x86_64/d3d12.dll") .to_string_lossy() .to_string(), ), @@ -3445,13 +3518,11 @@ fn runtime_profile_definition(profile: RuntimeProfile) -> RuntimeProfileDefiniti BottleArch::Win64, true, &[ - "m12_d3d12", - "m12_d3d11", - "m12_d3d10core", - "m12_dxgi_dxmt", - "m12_dxgi", - "m12_winemetal", - "m12_gpu_stubs", + "m12_vkd3d_d3d12", + "m12_vkd3d_d3d12core", + "m12_dxvk_dxgi", + "m12_winevulkan", + "m12_moltenvk", "vcrun2019_x64", "vcrun2019_x86", "d3d12_agility", @@ -5231,12 +5302,13 @@ fn component_source_policy(id: &str, arch: BottleArch) -> ComponentSourcePolicy if is_m12_runtime_component(id) { let state = inspect_m12_runtime_component(id).unwrap_or(ComponentState::Unknown); let home = dirs::home_dir().unwrap_or_default(); - let path = m12_runtime_component_artifacts(id) - .and_then(|artifacts| artifacts.first().copied()) - .map(|artifact| crate::installer::dxmt_m12_runtime_artifact_path_for_home(&home, artifact)); + let path = + m12_runtime_component_artifacts(id).and_then(|artifacts| artifacts.first().copied()).map(|artifact| { + crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("wine").join(artifact) + }); return ComponentSourcePolicy { id: id.to_string(), - source: "metalsharp_pr230_dxmt_m12_runtime".to_string(), + source: "metalsharp_complete_vkd3d_proton_runtime".to_string(), available: state == ComponentState::Installed, detail: m12_runtime_component_detail(id), path: path.map(|p| p.to_string_lossy().to_string()), @@ -5373,13 +5445,11 @@ fn component_action_detail(id: &str) -> String { "mono-arm64" => "Install MetalSharp ARM64 Mono runtime".to_string(), "mono-x86" => "Install MetalSharp x86_64 Mono runtime".to_string(), "fna" => "Install FNA/XNA compatibility assemblies and native shims".to_string(), - "m12_d3d12" => "Refresh PR230 M12 d3d12.dll from the bundled dxmt_m12 runtime".to_string(), - "m12_d3d11" => "Refresh PR230 M12 d3d11.dll from the bundled dxmt_m12 runtime".to_string(), - "m12_d3d10core" => "Refresh PR230 M12 d3d10core.dll from the bundled dxmt_m12 runtime".to_string(), - "m12_dxgi_dxmt" => "Refresh PR230 M12 dxgi_dxmt.dll from the bundled dxmt_m12 runtime".to_string(), - "m12_dxgi" => "Refresh PR230 M12 dxgi.dll from the bundled dxmt_m12 runtime".to_string(), - "m12_winemetal" => "Refresh PR230 M12 winemetal.dll, winemetal.so, and required Unix sidecars".to_string(), - "m12_gpu_stubs" => "Refresh PR230 M12 NVAPI/NVNGX GPU stub DLLs".to_string(), + "m12_vkd3d_d3d12" => "Restore vkd3d-proton d3d12.dll from the complete runtime".to_string(), + "m12_vkd3d_d3d12core" => "Restore vkd3d-proton d3d12core.dll from the complete runtime".to_string(), + "m12_dxvk_dxgi" => "Restore the DXVK-owned dxgi.dll paired with vkd3d-proton".to_string(), + "m12_winevulkan" => "Restore Wine Vulkan's PE and ARM64 Unix bridge artifacts".to_string(), + "m12_moltenvk" => "Restore the bundled ARM64 MoltenVK host runtime".to_string(), "d3d12_agility" => "Download and stage the D3D12 Agility SDK payload".to_string(), "gecko" => "Install Wine Gecko for embedded browser surfaces".to_string(), "dotnet40" => "Install the native .NET Framework 4.0 runtime for CLR v4 titles".to_string(), @@ -7383,6 +7453,48 @@ mod tests { assert_eq!(vcpp_setup_install_args(), ["/install"]); } + #[test] + fn setup_vcpp_download_gate_accepts_microsoft_x86_bootstrap_for_x64_payload() { + let dir = test_dir("vcpp-pe-gate"); + fs::create_dir_all(&dir).expect("create fixture dir"); + let installer = dir.join("vc_redist.exe"); + let mut x64 = vec![0u8; (VCPP_X64_MIN_SIZE + 1) as usize]; + x64[..0x200].copy_from_slice(&test_pe(0x014c, 0x10b)); + fs::write(&installer, x64).expect("write x64 fixture"); + assert!(vcpp_downloaded(&installer, "x64")); + + let mut x86 = vec![0u8; (VCPP_X86_MIN_SIZE + 1) as usize]; + x86[..0x200].copy_from_slice(&test_pe(0x014c, 0x10b)); + fs::write(&installer, x86).expect("write x86 fixture"); + assert!(vcpp_downloaded(&installer, "x86")); + assert!(!vcpp_downloaded(&installer, "x64")); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn setup_vcpp_command_uses_complete_runtime_without_tso() { + let wine = PathBuf::from("/runtime/wine/bin/metalsharp-wine"); + let root = PathBuf::from("/runtime/wine"); + let installer = PathBuf::from("/runtime/redist/vc_redist.x86.exe"); + let cmd = vcpp_installer_command(&wine, &root, "/prefix-steam", &installer); + assert_eq!(cmd.get_program(), wine.as_os_str()); + assert_eq!( + cmd.get_args().map(|arg| arg.to_string_lossy().to_string()).collect::>(), + vec!["start", "/wait", "/unix", "/runtime/redist/vc_redist.x86.exe", "/install"] + ); + let env = cmd + .get_envs() + .filter_map(|(key, value)| { + value.map(|value| (key.to_string_lossy().to_string(), value.to_string_lossy().to_string())) + }) + .collect::>(); + assert_eq!(env.get("WINEPREFIX").map(String::as_str), Some("/prefix-steam")); + assert_eq!(env.get("WINEBUILDDIR").map(String::as_str), Some("/runtime/wine/build-ec")); + assert_eq!(env.get("FEX_TSOENABLED").map(String::as_str), Some("0")); + assert_eq!(env.get("FEX_VECTORTSOENABLED").map(String::as_str), Some("0")); + assert_eq!(env.get("FEX_MEMCPYSETTSOENABLED").map(String::as_str), Some("0")); + } + #[test] fn setup_vcpp_accepts_reboot_required_status() { assert!(vcpp_installer_status_ok(Some(0))); @@ -7495,13 +7607,11 @@ mod tests { let m12 = default_components_for(RuntimeProfile::M12); let m12_ids = m12.iter().map(|c| c.id.as_str()).collect::>(); for required in [ - "m12_d3d12", - "m12_d3d11", - "m12_d3d10core", - "m12_dxgi_dxmt", - "m12_dxgi", - "m12_winemetal", - "m12_gpu_stubs", + "m12_vkd3d_d3d12", + "m12_vkd3d_d3d12core", + "m12_dxvk_dxgi", + "m12_winevulkan", + "m12_moltenvk", "vcrun2019_x64", "vcrun2019_x86", "corefonts", @@ -7519,17 +7629,15 @@ mod tests { } #[test] - fn m12_winemetal_component_tracks_required_unix_sidecars() { - let artifacts = m12_runtime_component_artifacts("m12_winemetal").expect("m12 winemetal artifacts"); - for required in [ - "x86_64-windows/winemetal.dll", - "x86_64-unix/winemetal.so", - "x86_64-unix/libc++.1.dylib", - "x86_64-unix/libc++abi.1.dylib", - "x86_64-unix/libunwind.1.dylib", - ] { - assert!(artifacts.contains(&required), "m12_winemetal must validate {required}"); - } + fn m12_vulkan_components_track_required_host_boundary() { + let winevulkan = m12_runtime_component_artifacts("m12_winevulkan").expect("M12 Wine Vulkan artifacts"); + assert!(winevulkan.contains(&"build-ec/dlls/winevulkan/winevulkan.so")); + assert!(winevulkan.contains(&"build-ec/dlls/winevulkan/x86_64-windows/winevulkan.dll")); + assert!(winevulkan.contains(&"build-ec/dlls/vulkan-1/x86_64-windows/vulkan-1.dll")); + + let moltenvk = m12_runtime_component_artifacts("m12_moltenvk").expect("M12 MoltenVK artifacts"); + assert!(moltenvk.contains(&"build-ec/dlls/win32u/libMoltenVK.dylib")); + assert!(moltenvk.contains(&"lib/moltenvk/libMoltenVK.dylib")); } #[test] diff --git a/app/src-rust/src/d3d12_runtime_doctor.rs b/app/src-rust/src/d3d12_runtime_doctor.rs index a6385060e..850c06105 100644 --- a/app/src-rust/src/d3d12_runtime_doctor.rs +++ b/app/src-rust/src/d3d12_runtime_doctor.rs @@ -56,6 +56,45 @@ pub fn handle_steam_d3d12_runtime_doctor(body: &serde_json::Map) let bottle = crate::bottles::ensure_steam_game_bottle(appid, &name, dual.wine_dir.as_deref(), pipeline).ok(); let latest = latest_cached_report(appid); + if pipeline == crate::mtsp::engine::PipelineId::M12 { + let home = dirs::home_dir().unwrap_or_default(); + let dry_run = crate::mtsp::launcher::pipeline_dry_run_for(&home, appid, Some(pipeline)); + let ready = dry_run.get("ok").and_then(Value::as_bool).unwrap_or(false); + let app_root = probe_runs_root(&home, appid); + let run_dir = app_root.join(timestamp_secs()); + let _ = fs::create_dir_all(&run_dir); + let report = json!({ + "schema": "metalsharp.d3d12-runtime-doctor.summary.v2", + "appid": appid, + "name": name, + "bottleId": bottle.as_ref().map(|b| b.id.clone()), + "pipeline": pipeline, + "backend": "vkd3d-proton", + "applicable": true, + "ready": ready, + "status": if ready { "ready" } else { "runtime_incomplete" }, + "summary": if ready { + "M12 route owns vkd3d-proton D3D12, DXVK DXGI, Wine Vulkan, and ARM64 MoltenVK; every runtime boundary is present." + } else { + "M12 vkd3d-proton/Vulkan runtime is incomplete; inspect the structured dry-run for exact missing boundaries." + }, + "sdkAvailability": { + "available": false, + "applicable": false, + "summary": "The legacy DXMT D3D12 SDK probe suite is not used by the vkd3d-proton M12 route." + }, + "route": dry_run, + "artifacts": { + "runDir": run_dir.to_string_lossy().to_string(), + "latestSummaryJson": app_root.join("latest-summary.json").to_string_lossy().to_string(), + }, + "finishedAt": timestamp_secs(), + }); + let _ = fs::write(run_dir.join(SUMMARY_JSON_NAME), serde_json::to_vec_pretty(&report).unwrap_or_default()); + let _ = fs::create_dir_all(&app_root); + let _ = fs::write(app_root.join("latest-summary.json"), serde_json::to_vec_pretty(&report).unwrap_or_default()); + return json!({"ok": ready, "report": report}); + } if pipeline != crate::mtsp::engine::PipelineId::M12 { return json!({ "ok": true, @@ -68,7 +107,7 @@ pub fn handle_steam_d3d12_runtime_doctor(body: &serde_json::Map) "applicable": false, "ready": false, "status": "not_applicable", - "summary": "D3D12 runtime doctor only applies to the M12 DXMT D3D12 route.", + "summary": "D3D12 runtime doctor only applies to the M12 vkd3d-proton route.", "sdkAvailability": sdk_availability(), "latestCachedReport": latest, } diff --git a/app/src-rust/src/d3dmetal_gptk.rs b/app/src-rust/src/d3dmetal_gptk.rs index 506f30ce2..65cd074dc 100644 --- a/app/src-rust/src/d3dmetal_gptk.rs +++ b/app/src-rust/src/d3dmetal_gptk.rs @@ -1011,7 +1011,7 @@ fn stage_game_local_d3dmetal_route_dlls_for_exe(state: &D3DMetalGptkState, game_ let marker = marker_root.join("latest-manifest.json"); let manifest = json!({ "quarantined_at": now_secs(), - "reason": "D3DMetal/GPTK lane replaces app-local M12/DXMT route DLLs with Homebrew-matched D3DMetal route DLLs", + "reason": "D3DMetal/GPTK owns a separate Homebrew-matched prefix and replaces any app-local MetalSharp graphics-route DLLs", "moved": moved, "deployed": deployed, }); @@ -1547,7 +1547,9 @@ fn gptk_dyld_path() -> String { fn find_brew() -> Result { for path in [PathBuf::from("/opt/homebrew/bin/brew"), PathBuf::from("/usr/local/bin/brew")] { - if path.is_file() { + if path.is_file() + && Command::new(&path).arg("--version").output().map(|output| output.status.success()).unwrap_or(false) + { return Ok(path); } } @@ -1555,10 +1557,13 @@ fn find_brew() -> Result { if output.status.success() { let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !path.is_empty() { - return Ok(PathBuf::from(path)); + let path = PathBuf::from(path); + if Command::new(&path).arg("--version").output().map(|result| result.status.success()).unwrap_or(false) { + return Ok(path); + } } } - Err("Homebrew not found".to_string()) + Err("Homebrew was not found or failed its brew --version verification".to_string()) } fn command_text(output: &std::process::Output) -> String { diff --git a/app/src-rust/src/diagnostics.rs b/app/src-rust/src/diagnostics.rs index 7f2924552..690e48b93 100644 --- a/app/src-rust/src/diagnostics.rs +++ b/app/src-rust/src/diagnostics.rs @@ -170,7 +170,7 @@ fn bottle_log_dir(home: &Path, bottle_id: &str) -> Option { } /// Resolve the on-disk shader cache directories that a pipeline would use for -/// an appid, including the legacy shared DXMT-Metal family aliases. This +/// an appid, including each translation backend's shared family alias. This /// mirrors [`crate::mtsp::shader_cache`] lookup families so the diagnostic /// reports the same roots the runtime consults. pub fn shader_cache_dirs(home: &Path, pipeline: crate::mtsp::engine::PipelineId, appid: u32) -> Vec { @@ -183,8 +183,8 @@ pub fn shader_cache_dirs(home: &Path, pipeline: crate::mtsp::engine::PipelineId, PipelineId::M9 => &["m9", "dxmt-metal"], PipelineId::M10 => &["m10", "dxmt-metal"], PipelineId::M11 => &["m11", "dxmt-metal"], - PipelineId::M12 => &["m12", "dxmt-metal12"], - PipelineId::M13 => &["m13", "dxmt-metal12"], + PipelineId::M12 => &["m12", "vkd3d-proton"], + PipelineId::M13 => &["m13", "d3dmetal"], _ => &[], }; @@ -481,7 +481,7 @@ mod tests { let dirs = shader_cache_dirs(&home, crate::mtsp::engine::PipelineId::M12, 7); let names: Vec = dirs.iter().map(|d| d.to_string_lossy().to_string()).collect(); assert!(names.iter().any(|n| n.contains("shader-cache/m12/7")), "got {:?}", names); - assert!(names.iter().any(|n| n.contains("shader-cache/dxmt-metal12/7")), "got {:?}", names); + assert!(names.iter().any(|n| n.contains("shader-cache/vkd3d-proton/7")), "got {:?}", names); // M12 must NOT share the dxmt-metal legacy family. assert!(!names.iter().any(|n| n.contains("shader-cache/dxmt-metal/")), "got {:?}", names); let _ = fs::remove_dir_all(&home); diff --git a/app/src-rust/src/gog.rs b/app/src-rust/src/gog.rs index 8403680e2..bd2d2ea14 100644 --- a/app/src-rust/src/gog.rs +++ b/app/src-rust/src/gog.rs @@ -13,6 +13,7 @@ const GOG_PREFIX_BOTTLE_ID: &str = "gog-prefix"; const GOG_AUTH_URL: &str = "https://auth.gog.com/auth?client_id=46899977096215655&redirect_uri=https%3A%2F%2Fembed.gog.com%2Fon_login_success%3Forigin%3Dclient&response_type=code&layout=galaxy"; const GOG_CLIENT_ID: &str = "46899977096215655"; const HEROIC_GOGDL_REPO_URL: &str = "https://github.com/Heroic-Games-Launcher/heroic-gogdl.git"; +const HEROIC_GOGDL_REF: &str = "v1.2.2"; #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -142,6 +143,7 @@ fn gogdl_candidates() -> Vec { candidates.push(PathBuf::from(explicit)); } } + candidates.push(ms_home().join("runtime").join("integration").join("gog").join("bin").join("gogdl")); candidates.push(ms_home().join("tools").join("gogdl")); candidates.push(ms_home().join("runtime").join("gogdl")); if let Ok(path_env) = std::env::var("PATH") { @@ -153,7 +155,17 @@ fn gogdl_candidates() -> Vec { } fn gogdl_binary() -> Option { - gogdl_candidates().into_iter().find(|path| path.is_file()) + gogdl_candidates().into_iter().find(|path| { + if !path.is_file() { + return false; + } + #[cfg(unix)] + { + path.metadata().map(|metadata| metadata.permissions().mode() & 0o111 != 0).unwrap_or(false) + } + #[cfg(not(unix))] + true + }) } fn tools_dir() -> PathBuf { @@ -299,11 +311,19 @@ fn ensure_gogdl_available() -> Result<(), String> { .arg("clone") .arg("--depth") .arg("1") + .arg("--branch") + .arg(HEROIC_GOGDL_REF) .arg("--recurse-submodules") .arg(HEROIC_GOGDL_REPO_URL) .arg(&source); run_bootstrap_command(&mut command, "GOG support source setup")?; } else { + let mut command = Command::new(&git); + command.arg("-C").arg(&source).arg("fetch").arg("--depth").arg("1").arg("origin").arg(HEROIC_GOGDL_REF); + run_bootstrap_command(&mut command, "GOG support source pin refresh")?; + let mut command = Command::new(&git); + command.arg("-C").arg(&source).arg("checkout").arg("--detach").arg(HEROIC_GOGDL_REF); + run_bootstrap_command(&mut command, "GOG support source pin checkout")?; let mut command = Command::new(&git); command.arg("-C").arg(&source).arg("submodule").arg("update").arg("--init").arg("--recursive"); run_bootstrap_command(&mut command, "GOG support source refresh")?; @@ -335,6 +355,8 @@ fn gogdl_command() -> Result { .arg(gog_auth_config_path()) .env("GOGDL_CONFIG_PATH", gogdl_config_dir()) .env("GOGDL_SUPPORT_PATH", gogdl_support_dir()); + crate::runtime_prefix::apply_complete_runtime_env(&mut command, &gog_prefix()); + command.env("MS_FWD_COMPAT_GL_CTX", "1"); Ok(command) } @@ -512,9 +534,10 @@ fn status_value() -> Value { let prefix = gog_prefix(); let oauth_dest = gog_oauth_helper_dest_dir(); let oauth_available = oauth_dest.join(".inline-helper").is_file(); + let prefix_all_arch_ready = crate::runtime_prefix::all_arch_ready(&prefix); let status = if binary.is_none() { "missing_gogdl" - } else if !prefix.join("drive_c").is_dir() { + } else if !prefix_all_arch_ready { "needs_prefix" } else if !authenticated { "needs_login" @@ -538,7 +561,8 @@ fn status_value() -> Value { "oauthHelperScript": "(inline Electron BrowserWindow)".to_string(), "bottleId": GOG_PREFIX_BOTTLE_ID, "winePrefix": prefix.to_string_lossy().to_string(), - "prefixInitialized": prefix.join("drive_c").is_dir(), + "prefixInitialized": prefix_all_arch_ready, + "prefixAllArchReady": prefix_all_arch_ready, "winePath": wine_binary().to_string_lossy().to_string(), }) } @@ -558,30 +582,10 @@ fn initialize_prefix() -> Result<(), String> { ensure_gogdl_available()?; let prefix = gog_prefix(); fs::create_dir_all(&prefix).map_err(|error| format!("failed to create GOG prefix: {}", error))?; - if prefix.join("drive_c").is_dir() { + if crate::runtime_prefix::all_arch_ready(&prefix) { return Ok(()); } - let wine = wine_binary(); - if !wine.is_file() { - return Err(format!("MetalSharp Wine not found: {}", wine.display())); - } - let mut command = Command::new(&wine); - command - .arg("wineboot") - .arg("-u") - .env("WINEPREFIX", prefix.to_string_lossy().to_string()) - .env("WINEMSYNC", "1") - .env("WINEDEBUG", "-all") - .env("MS_FWD_COMPAT_GL_CTX", "1") - .stdout(Stdio::null()) - .stderr(Stdio::null()); - crate::platform::set_runtime_library_env(&mut command, &wine_root()); - let status = command.status().map_err(|error| format!("failed to initialize GOG prefix: {}", error))?; - if status.success() { - Ok(()) - } else { - Err(format!("wineboot failed with {:?}", status.code())) - } + crate::runtime_prefix::prepare(&prefix) } pub fn handle_auth_code(body: &Value) -> Value { diff --git a/app/src-rust/src/installer.rs b/app/src-rust/src/installer.rs index aecfd305d..4c3ee0fcd 100644 --- a/app/src-rust/src/installer.rs +++ b/app/src-rust/src/installer.rs @@ -31,6 +31,10 @@ const ASSETS_BUNDLE: &str = "metalsharp-assets"; const FNALIBS_BUNDLE: &str = "fnalibs"; const SCRIPTS_TOOLS_BUNDLE: &str = "metalsharp-scripts-tools"; const STEAM_BUNDLE: &str = "metalsharp-steam"; +const COMPLETE_RUNTIME_INSTALLER: &str = "install-metalsharp-wine-runtime.sh"; +pub(crate) const COMPLETE_RUNTIME_ARCHIVE_SHA256: &str = + "e44a84bceeca62f01fd95a133364ec82467cd8883ff81bcc1bdfdf4a6c3ad146"; +pub(crate) const GOG_SUPPORT_ARCHIVE_SHA256: &str = "f13075f27d5155e84199619410936931b32310c4ec4161de992c1f727ac24155"; const METALSHARP_NTDLL_HOOK_DLL: &str = "metalsharp_ntdll_hook.dll"; const DXMT_REQUIRED_PE: &[&str] = &[ "d3d10core.dll", @@ -319,21 +323,120 @@ fn install_steps() -> Vec { ("System Tools", Box::new(|_| install_xcode_cli())), ("Rosetta 2", Box::new(|_| install_rosetta())), ("Extract Tools (zstd)", Box::new(|_| ensure_zstd())), - ("Runtime Bundle Downloads", Box::new(ensure_runtime_bundle_assets)), - ("Runtime Assets", Box::new(install_metalsharp_bundle)), - ("Host Runtime ABI", Box::new(install_host_runtime)), - ("Support Assets", Box::new(install_split_assets_bundle)), - ("Scripts and Tools", Box::new(install_scripts_tools_bundle)), - ("DXMT Graphics Runtimes", Box::new(|home| ensure_graphics_runtimes_ready(home))), - ("Goldberg Steam Emulator", Box::new(install_goldberg)), - ("Steam Bridge Shim", Box::new(install_steam_bridge)), - ("Pipeline Rules", Box::new(install_mtsp_rules)), - ("Mono Configs", Box::new(install_mono_configs)), - ("Runtime Support", Box::new(|_| install_mono_arm64())), - ("FNA Shim Precompile", Box::new(|_| crate::mtsp::launcher::precompile_all_fna_shims().map(|_| true))), + ("Complete Multi-Architecture Runtime", Box::new(install_complete_runtime)), ] } +fn complete_runtime_root(home: &Path) -> PathBuf { + crate::platform::metalsharp_home_dir_for(home).join("runtime") +} + +fn complete_runtime_marker(root: &Path) -> PathBuf { + root.join(".metalsharp-runtime-install") +} + +pub fn complete_runtime_current_for_home(home: &Path) -> bool { + let root = complete_runtime_root(home); + complete_runtime_current_for_root(&root) +} + +pub(crate) fn complete_runtime_current_for_ms_dir(ms_dir: &Path) -> bool { + complete_runtime_current_for_root(&ms_dir.join("runtime")) +} + +fn complete_runtime_current_for_root(root: &Path) -> bool { + let marker = match fs::read_to_string(complete_runtime_marker(root)) { + Ok(marker) => marker, + Err(_) => return false, + }; + + marker.lines().any(|line| line == format!("archive_sha256={COMPLETE_RUNTIME_ARCHIVE_SHA256}")) + && marker.lines().any(|line| line == format!("gog_archive_sha256={GOG_SUPPORT_ARCHIVE_SHA256}")) + && marker.lines().any(|line| line == "no_tso=1") + && file_nonempty(&root.join("wine/bin/metalsharp-wine")) + && file_nonempty(&root.join("wine/build-ec/wine")) + && file_nonempty(&root.join("wine/build-ec/server/wineserver")) + && file_nonempty(&root.join("wine/wine-11.12/nls/locale.nls")) + && file_nonempty(&root.join("wine/wine-11.12/fonts/tahoma.ttf")) + && file_nonempty(&root.join("wine/build-ec/dxmt-v0.80/aarch64-unix/winemetal.so")) + && file_nonempty(&root.join("wine/build-ec/dxmt-v0.80/aarch64-windows/d3d10core.dll")) + && file_nonempty(&root.join("wine/build-ec/dxmt-v0.80/aarch64-windows/d3d11.dll")) + && file_nonempty(&root.join("wine/build-ec/dxmt-v0.80/aarch64-windows/dxgi.dll")) + && file_nonempty(&root.join("wine/build-ec/dxmt-v0.80/aarch64-windows/winemetal.dll")) + && file_nonempty(&root.join("wine/build-ec/dxmt-v0.80/i386-windows/d3d10core.dll")) + && file_nonempty(&root.join("wine/build-ec/dxmt-v0.80/i386-windows/d3d11.dll")) + && file_nonempty(&root.join("wine/build-ec/dxmt-v0.80/i386-windows/dxgi.dll")) + && file_nonempty(&root.join("wine/build-ec/dxmt-v0.80/i386-windows/winemetal.dll")) + && file_nonempty(&root.join("wine/build-ec/dlls/d3d9/x86_64-windows/d3d9.dll")) + && file_nonempty(&root.join("wine/build-ec/dlls/d3d9/i386-windows/d3d9.dll")) + && file_nonempty(&root.join("wine/build-ec/dlls/d3d10/x86_64-windows/d3d10.dll")) + && file_nonempty(&root.join("wine/build-ec/dlls/d3d10/i386-windows/d3d10.dll")) + && file_nonempty(&root.join("wine/build-ec/dlls/d3d10_1/x86_64-windows/d3d10_1.dll")) + && file_nonempty(&root.join("wine/build-ec/dlls/d3d10_1/i386-windows/d3d10_1.dll")) + && file_nonempty(&root.join("wine/build-ec/dlls/dxgi/i386-windows/dxgi.dll")) + && file_nonempty(&root.join("graphics/dxvk/i386/d3d9.dll")) + && file_nonempty(&root.join("graphics/vkd3d-proton/x86_64/d3d12.dll")) + && file_nonempty(&root.join("graphics/vkd3d-proton/x86_64/d3d12core.dll")) + && file_nonempty(&root.join("graphics/dxvk/x86_64/dxgi.dll")) + && file_nonempty(&root.join("wine/build-ec/dlls/winevulkan/winevulkan.so")) + && file_nonempty(&root.join("wine/build-ec/dlls/winevulkan/x86_64-windows/winevulkan.dll")) + && file_nonempty(&root.join("wine/build-ec/dlls/vulkan-1/x86_64-windows/vulkan-1.dll")) + && file_nonempty(&root.join("wine/build-ec/dlls/win32u/libMoltenVK.dylib")) + && file_nonempty(&root.join("graphics/opengl-metal/metalsharp-opengl.dylib")) + && file_nonempty(&root.join("graphics/moltenvk/libMoltenVK.dylib")) + && file_nonempty(&root.join("providers/xtajit64-arm64ec-known-good.dll")) + && file_nonempty(&root.join("providers/xtajit-arm64-known-good.dll")) + && file_nonempty(&root.join("scripts/stage-runtime-providers.sh")) + && file_nonempty(&root.join("wine/build-ec/dlls/wow64/aarch64-windows/wow64.dll")) + && file_nonempty(&root.join("wine/build-ec/dlls/wow64win/aarch64-windows/wow64win.dll")) + && file_nonempty(&root.join("integration/gog/bin/gogdl")) +} + +fn find_complete_runtime_installer() -> Option { + let mut candidates = Vec::new(); + if let Some(resources) = crate::platform::app_resources_dir() { + candidates.push(resources.join("runtime-bundle").join(COMPLETE_RUNTIME_INSTALLER)); + candidates.push(resources.join("scripts").join(COMPLETE_RUNTIME_INSTALLER)); + } + candidates + .push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..").join("scripts").join(COMPLETE_RUNTIME_INSTALLER)); + candidates.push(PathBuf::from("scripts").join(COMPLETE_RUNTIME_INSTALLER)); + candidates.push(PathBuf::from("../scripts").join(COMPLETE_RUNTIME_INSTALLER)); + candidates.into_iter().find(|path| file_nonempty(path)) +} + +pub(crate) fn install_complete_runtime(home: &PathBuf) -> Result { + if complete_runtime_current_for_home(home) { + return Ok(false); + } + + let installer = find_complete_runtime_installer().ok_or_else(|| { + format!("{} is missing from the MetalSharp application resources", COMPLETE_RUNTIME_INSTALLER) + })?; + let target = complete_runtime_root(home); + let mut command = Command::new("/bin/bash"); + command.arg(&installer).args(["--target"]).arg(&target).args(["--replace", "--discard-backup"]); + if let Some(bundle_dir) = installer.parent().filter(|parent| parent.join("PARTS-SHA256SUMS.txt").is_file()) { + command.arg("--bundle-dir").arg(bundle_dir); + } + let output = command + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|e| format!("start complete runtime installer: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = stderr.lines().rev().find(|line| !line.trim().is_empty()).unwrap_or("unknown installer error"); + return Err(format!("complete runtime installer failed: {detail}")); + } + if !complete_runtime_current_for_home(home) { + return Err("complete runtime installer exited successfully, but the runtime verification gate failed".into()); + } + + Ok(true) +} + fn runtime_bundle_assets_for_host() -> &'static [&'static str] { MAC_RUNTIME_BUNDLE_ASSETS } @@ -2540,21 +2643,6 @@ mod tests { let _ = fs::remove_dir_all(home); } - #[test] - fn runtime_bundle_preflight_knows_beta7_assets() { - let mac_assets = MAC_RUNTIME_BUNDLE_ASSETS; - for expected in [ - "metalsharp-runtime.tar.zst", - "metalsharp-graphics-dll.tar.zst", - "metalsharp-assets.tar.zst", - "fnalibs.tar.zst", - "metalsharp-scripts-tools.tar.zst", - "metalsharp-steam.tar.zst", - ] { - assert!(mac_assets.contains(&expected), "missing mac bundle asset {}", expected); - } - } - #[test] fn install_order_runs_xcode_cli_before_rosetta() { let names: Vec<&str> = install_steps().into_iter().map(|(name, _)| name).collect(); @@ -2566,18 +2654,80 @@ mod tests { } #[test] - fn install_steps_use_split_graphics_runtime_and_do_not_install_eac_toggle_or_gptk() { + fn install_steps_use_only_prerequisites_and_complete_runtime() { let names: Vec<&str> = install_steps().into_iter().map(|(name, _)| name).collect(); - assert!(names.contains(&"DXMT Graphics Runtimes")); - assert!(!names.contains(&"Offline EAC Mode")); - assert!( - names.iter().all(|name| !name.to_ascii_lowercase().contains("gptk")), - "first-time setup must not install GPTK; D3DMetal bottles own Homebrew GPTK setup: {:?}", - names + assert_eq!( + names, + vec!["System Tools", "Rosetta 2", "Extract Tools (zstd)", "Complete Multi-Architecture Runtime"] ); } + #[test] + fn complete_runtime_gate_requires_matching_marker_and_all_surfaces() { + let home = test_home("complete-runtime-gate"); + let root = complete_runtime_root(&home); + for rel in [ + "wine/bin/metalsharp-wine", + "wine/build-ec/wine", + "wine/build-ec/server/wineserver", + "wine/wine-11.12/nls/locale.nls", + "wine/wine-11.12/fonts/tahoma.ttf", + "wine/build-ec/dxmt-v0.80/aarch64-unix/winemetal.so", + "wine/build-ec/dxmt-v0.80/aarch64-windows/d3d10core.dll", + "wine/build-ec/dxmt-v0.80/aarch64-windows/d3d11.dll", + "wine/build-ec/dxmt-v0.80/aarch64-windows/dxgi.dll", + "wine/build-ec/dxmt-v0.80/aarch64-windows/winemetal.dll", + "wine/build-ec/dxmt-v0.80/i386-windows/d3d10core.dll", + "wine/build-ec/dxmt-v0.80/i386-windows/d3d11.dll", + "wine/build-ec/dxmt-v0.80/i386-windows/dxgi.dll", + "wine/build-ec/dxmt-v0.80/i386-windows/winemetal.dll", + "wine/build-ec/dlls/d3d9/x86_64-windows/d3d9.dll", + "wine/build-ec/dlls/d3d9/i386-windows/d3d9.dll", + "wine/build-ec/dlls/d3d10/x86_64-windows/d3d10.dll", + "wine/build-ec/dlls/d3d10/i386-windows/d3d10.dll", + "wine/build-ec/dlls/d3d10_1/x86_64-windows/d3d10_1.dll", + "wine/build-ec/dlls/d3d10_1/i386-windows/d3d10_1.dll", + "wine/build-ec/dlls/dxgi/i386-windows/dxgi.dll", + "graphics/dxvk/i386/d3d9.dll", + "graphics/vkd3d-proton/x86_64/d3d12.dll", + "graphics/vkd3d-proton/x86_64/d3d12core.dll", + "graphics/dxvk/x86_64/dxgi.dll", + "wine/build-ec/dlls/winevulkan/winevulkan.so", + "wine/build-ec/dlls/winevulkan/x86_64-windows/winevulkan.dll", + "wine/build-ec/dlls/vulkan-1/x86_64-windows/vulkan-1.dll", + "wine/build-ec/dlls/win32u/libMoltenVK.dylib", + "graphics/opengl-metal/metalsharp-opengl.dylib", + "graphics/moltenvk/libMoltenVK.dylib", + "providers/xtajit64-arm64ec-known-good.dll", + "providers/xtajit-arm64-known-good.dll", + "scripts/stage-runtime-providers.sh", + "wine/build-ec/dlls/wow64/aarch64-windows/wow64.dll", + "wine/build-ec/dlls/wow64win/aarch64-windows/wow64win.dll", + "integration/gog/bin/gogdl", + ] { + let path = root.join(rel); + fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent"); + fs::write(path, b"fixture").expect("write fixture"); + } + fs::write( + complete_runtime_marker(&root), + format!( + "archive_sha256={COMPLETE_RUNTIME_ARCHIVE_SHA256}\ngog_archive_sha256={GOG_SUPPORT_ARCHIVE_SHA256}\nno_tso=1\n" + ), + ) + .expect("write marker"); + + assert!(complete_runtime_current_for_home(&home)); + fs::remove_file(root.join("integration/gog/bin/gogdl")).expect("remove GOG support fixture"); + assert!(!complete_runtime_current_for_home(&home)); + fs::write(root.join("integration/gog/bin/gogdl"), b"fixture").expect("restore GOG support fixture"); + assert!(complete_runtime_current_for_home(&home)); + fs::remove_file(root.join("graphics/vkd3d-proton/x86_64/d3d12.dll")).expect("remove gate fixture"); + assert!(!complete_runtime_current_for_home(&home)); + let _ = fs::remove_dir_all(home); + } + #[test] fn graphics_bundle_layout_matches_release_manifest() { let manifest = include_str!("../../../tools/bundles/asset-manifest.tsv"); diff --git a/app/src-rust/src/launch.rs b/app/src-rust/src/launch.rs index 28ddc6c06..5fb2e1fc1 100644 --- a/app/src-rust/src/launch.rs +++ b/app/src-rust/src/launch.rs @@ -46,6 +46,7 @@ pub fn launch_via_steam(appid: u32) -> Result> { .args(["start", &url]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); + apply_wine_runtime_preferences(&mut cmd); crate::platform::set_runtime_library_env(&mut cmd, &ms_root); spawn_and_reap(cmd) } @@ -87,6 +88,7 @@ pub fn launch_via_steam_with_env( let mut cmd = Command::new(&wine); cmd.env("WINEPREFIX", &prefix_str).env("WINEDEBUG", "-all").env("WINEDEBUGGER", "none"); + apply_wine_runtime_preferences(&mut cmd); crate::platform::set_runtime_library_env(&mut cmd, &ms_root); for (key, val) in extra_env { @@ -192,6 +194,8 @@ pub fn get_config() -> Value { let native_available = find_metalsharp_native().is_ok(); let mono_available = find_mono().is_ok(); let graphics_runtime_logs = graphics_runtime_logs_enabled(); + let controller_mode = controller_mode(); + let msync_enabled = msync_enabled(); json!({ "ok": true, @@ -199,9 +203,44 @@ pub fn get_config() -> Value { "mono_available": mono_available, "graphicsRuntimeLogs": graphics_runtime_logs, "graphics_runtime_logs": graphics_runtime_logs, + "controllerMode": controller_mode, + "controller_mode": controller_mode, + "msyncEnabled": msync_enabled, + "msync_enabled": msync_enabled, }) } +pub fn controller_mode() -> String { + if let Ok(value) = std::env::var("METALSHARP_CONTROLLER_MODE") { + if let Some(mode) = normalize_controller_mode(&value) { + return mode.to_string(); + } + } + read_config_string("controllerMode") + .or_else(|| read_config_string("controller_mode")) + .and_then(|value| normalize_controller_mode(&value).map(str::to_string)) + .unwrap_or_else(|| "xinput".to_string()) +} + +pub fn msync_enabled() -> bool { + if let Ok(value) = std::env::var("METALSHARP_MSYNC") { + return truthy(&value); + } + read_config_bool("msyncEnabled").or_else(|| read_config_bool("msync_enabled")).unwrap_or(true) +} + +pub fn apply_wine_runtime_preferences(command: &mut Command) { + command.env("WINEMSYNC", if msync_enabled() { "1" } else { "0" }); + command.env("METALSHARP_CONTROLLER_MODE", controller_mode()); +} + +pub fn wine_runtime_preference_env_pairs() -> [(String, String); 2] { + [ + ("WINEMSYNC".to_string(), if msync_enabled() { "1" } else { "0" }.to_string()), + ("METALSHARP_CONTROLLER_MODE".to_string(), controller_mode()), + ] +} + pub fn graphics_runtime_logs_enabled() -> bool { if let Ok(value) = std::env::var("METALSHARP_GRAPHICS_RUNTIME_LOGS") { return truthy(&value); @@ -216,6 +255,21 @@ fn read_config_bool(key: &str) -> Option { value.get(key).and_then(json_bool) } +fn read_config_string(key: &str) -> Option { + let path = config_path_for_home(&dirs::home_dir()?); + let contents = std::fs::read_to_string(path).ok()?; + let value: Value = serde_json::from_str(&contents).ok()?; + value.get(key).and_then(Value::as_str).map(str::to_string) +} + +fn normalize_controller_mode(value: &str) -> Option<&'static str> { + match value.trim().to_ascii_lowercase().as_str() { + "d" | "dinput" => Some("dinput"), + "x" | "xinput" => Some("xinput"), + _ => None, + } +} + fn json_bool(value: &Value) -> Option { match value { Value::Bool(b) => Some(*b), @@ -354,6 +408,7 @@ pub fn ensure_wine_prefix(prefix: &PathBuf) -> Result<(), Box) -> Result Result> { let ms_root = crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("wine"); let mut cmd = Command::new(&wine); cmd.env("WINEPREFIX", &prefix_str).arg(exe_path); + apply_wine_runtime_preferences(&mut cmd); crate::platform::set_runtime_library_env(&mut cmd, &ms_root); let child = cmd.spawn()?; @@ -458,4 +527,20 @@ mod tests { assert!(ensure_steam_env_handoff_supported(true, &[]).is_ok()); assert!(ensure_steam_env_handoff_supported(false, &env).is_ok()); } + + #[test] + fn controller_mode_contract_accepts_only_dinput_or_xinput() { + assert_eq!(normalize_controller_mode("D"), Some("dinput")); + assert_eq!(normalize_controller_mode(" xinput "), Some("xinput")); + assert_eq!(normalize_controller_mode("automatic"), None); + } + + #[test] + fn runtime_preferences_have_stable_launch_keys() { + let pairs = wine_runtime_preference_env_pairs(); + assert_eq!(pairs[0].0, "WINEMSYNC"); + assert_eq!(pairs[1].0, "METALSHARP_CONTROLLER_MODE"); + assert!(matches!(pairs[0].1.as_str(), "0" | "1")); + assert!(matches!(pairs[1].1.as_str(), "dinput" | "xinput")); + } } diff --git a/app/src-rust/src/main.rs b/app/src-rust/src/main.rs index 60cd77c0f..79386486b 100644 --- a/app/src-rust/src/main.rs +++ b/app/src-rust/src/main.rs @@ -31,9 +31,9 @@ mod launch; mod launcher_evidence; mod metalfx; mod migrate; -mod mono; mod mtsp; mod platform; +mod runtime_prefix; mod scan; mod setup; mod sharp_library; @@ -254,8 +254,16 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { (Method::Post, "/setup/install-vcpp-x64") => { let home = dirs::home_dir().unwrap_or_default(); let prefix = crate::platform::metalsharp_home_dir_for(&home).join("prefix-steam"); - if !prefix.join("drive_c/windows/system32").exists() { - resp(400, json!({"ok": false, "error": "Wine prefix not ready — install runtime and Steam first"})) + if !crate::installer::complete_runtime_current_for_home(&home) { + resp( + 400, + json!({"ok": false, "error": "Complete MetalSharp Wine runtime not ready — install or repair the runtime first"}), + ) + } else if !steam::steam_prefix_all_arch_ready(&prefix) { + resp( + 400, + json!({"ok": false, "error": "All-architecture Steam prefix not ready — complete Steam installation first"}), + ) } else { match bottles::vcpp_ensure_and_install_x64(&prefix) { Ok(()) => resp(200, json!({"ok": true})), @@ -266,8 +274,16 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { (Method::Post, "/setup/install-vcpp-x86") => { let home = dirs::home_dir().unwrap_or_default(); let prefix = crate::platform::metalsharp_home_dir_for(&home).join("prefix-steam"); - if !prefix.join("drive_c/windows/system32").exists() { - resp(400, json!({"ok": false, "error": "Wine prefix not ready — install runtime and Steam first"})) + if !crate::installer::complete_runtime_current_for_home(&home) { + resp( + 400, + json!({"ok": false, "error": "Complete MetalSharp Wine runtime not ready — install or repair the runtime first"}), + ) + } else if !steam::steam_prefix_all_arch_ready(&prefix) { + resp( + 400, + json!({"ok": false, "error": "All-architecture Steam prefix not ready — complete Steam installation first"}), + ) } else { match bottles::vcpp_ensure_and_install_x86(&prefix) { Ok(()) => resp(200, json!({"ok": true})), @@ -419,6 +435,10 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { resp(500, json!({"ok": false, "error": e.to_string()})) }, }, + (Method::Post, "/steam/handoff") => { + let (status, body) = steam::accept_install_handoff(); + resp(status, body) + }, (Method::Post, "/steam/launch") => { app_log("Launching Wine Steam..."); match steam::launch_wine_steam() { @@ -1894,20 +1914,6 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { let body = read_body(req); resp(200, gog::handle_uninstall(&Value::Object(body))) }, - (Method::Get, "/wine-mono/status") => { - let prefix = query_param(req.url(), "prefix").unwrap_or_else(|| "gog".to_string()); - resp(200, mono::handle_status(&prefix)) - }, - (Method::Post, "/wine-mono/install") => { - let body = read_body(req); - let prefix = body.get("prefix").and_then(|v| v.as_str()).unwrap_or("gog").to_string(); - resp(200, mono::handle_install(&prefix)) - }, - (Method::Post, "/wine-mono/reset") => { - let body = read_body(req); - let prefix = body.get("prefix").and_then(|v| v.as_str()).unwrap_or("gog").to_string(); - resp(200, mono::handle_reset(&prefix)) - }, (Method::Post, "/sharp-library/install") => { let body = read_body(req); app_log(&format!("[SHARP-LIB] install: {}", body.get("srcPath").and_then(|v| v.as_str()).unwrap_or("?"))); diff --git a/app/src-rust/src/migrate.rs b/app/src-rust/src/migrate.rs index 0fef126f7..bb6a794f1 100644 --- a/app/src-rust/src/migrate.rs +++ b/app/src-rust/src/migrate.rs @@ -325,12 +325,16 @@ pub fn needs_migration() -> serde_json::Value { fn runtime_needs_repair(home: &Path, setup_completed: bool) -> bool { let ms_dir = crate::platform::metalsharp_home_dir_for(&home); if runtime_core_ready(&ms_dir) { - return false; + return existing_prefixes_need_repair(&ms_dir); } setup_completed || ms_dir.join("prefix-steam").exists() } +fn existing_prefixes_need_repair(ms_dir: &Path) -> bool { + collect_existing_wine_prefixes(ms_dir).iter().any(|prefix| !crate::runtime_prefix::all_arch_ready(prefix)) +} + fn post_update_marker_path(ms_dir: &Path) -> PathBuf { ms_dir.join(".post-update-migration") } @@ -390,46 +394,7 @@ fn parse_version_parts(value: &str) -> Vec { } fn runtime_core_ready(ms_dir: &Path) -> bool { - let runtime_wine = ms_dir.join("runtime").join("wine"); - let runtime_host = ms_dir.join("runtime").join("host"); - let wine = crate::platform::runtime_wine_binary(&runtime_wine); - if !wine.exists() { - return false; - } - - if !host_runtime_ready(&runtime_host) { - return false; - } - - if !crate::installer::dxmt_runtime_current_for_ms_dir(ms_dir) { - return false; - } - - if !crate::installer::metalsharp_runtime_lib_ready(&runtime_wine) { - return false; - } - - [ - runtime_wine.join("lib").join("wine").join("x86_64-unix"), - runtime_wine.join("lib").join("wine").join("x86_64-windows").join("d3d9.dll"), - runtime_wine.join("lib").join("wine").join("x86_64-windows").join("d3d10.dll"), - runtime_wine.join("lib").join("wine").join("x86_64-windows").join("d3d10_1.dll"), - ms_dir.join("runtime").join("goldberg").join("x86").join("steam_api.dll"), - ms_dir.join("runtime").join("goldberg").join("x64").join("steam_api64.dll"), - ms_dir.join("configs").join("mtsp-rules.toml"), - runtime_wine.join("etc").join("dxmt.conf"), - ] - .iter() - .all(|path| path.exists()) - && crate::installer::dxmt_graphics_runtimes_current_for_ms_dir(ms_dir) -} - -fn host_runtime_ready(dir: &Path) -> bool { - file_nonempty(&dir.join("manifest.json")) - && file_nonempty(&dir.join("HostRuntimeABI.h")) - && (file_nonempty(&dir.join("libmetalsharp_host_runtime.dylib")) - || file_nonempty(&dir.join("libmetalsharp_host_runtime.so")) - || file_nonempty(&dir.join("metalsharp_host_runtime.dll"))) + crate::installer::complete_runtime_current_for_ms_dir(ms_dir) } fn file_nonempty(path: &Path) -> bool { @@ -479,7 +444,8 @@ fn run_migration() { } let marker_requested = post_update_marker.as_ref().map(|marker| marker.needed).unwrap_or(false); - if runtime_core_ready(&ms_dir) && !marker_requested { + let runtime_was_ready = runtime_core_ready(&ms_dir); + if runtime_was_ready && !existing_prefixes_need_repair(&ms_dir) && !marker_requested { update_migration_metadata(&ms_dir); let marker = post_update_marker_path(&ms_dir); let _ = fs::remove_file(&marker); @@ -510,40 +476,66 @@ fn run_migration() { let (preserved, mut report) = preserve_user_data(&ms_dir); step += 1; - write_migrate_progress("running", step, total_steps, "Cleaning stale runtime state...", None); - remove_old_runtime(&ms_dir); + if runtime_was_ready { + write_migrate_progress( + "running", + step, + total_steps, + "Complete runtime already installed; keeping it in place...", + None, + ); + } else { + write_migrate_progress("running", step, total_steps, "Cleaning stale runtime state...", None); + remove_old_runtime(&ms_dir); + } step += 1; - write_migrate_progress("running", step, total_steps, "Installing update...", None); - let install_ok = match crate::installer::start_install_all() { - Ok(v) if v.get("ok").and_then(|ok| ok.as_bool()).unwrap_or(false) => match wait_for_install_complete() { - Ok(()) => true, + write_migrate_progress( + "running", + step, + total_steps, + if runtime_was_ready { "Using the verified complete runtime..." } else { "Installing update..." }, + None, + ); + let install_ok = if runtime_was_ready { + true + } else { + match crate::installer::start_install_all() { + Ok(v) if v.get("ok").and_then(|ok| ok.as_bool()).unwrap_or(false) => match wait_for_install_complete() { + Ok(()) => true, + Err(e) => { + write_migrate_progress( + "error", + step, + total_steps, + &format!("Runtime install failed: {}", e), + Some(&e), + ); + false + }, + }, + Ok(v) => { + let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("runtime install did not start"); + write_migrate_progress( + "error", + step, + total_steps, + &format!("Runtime install failed: {}", error), + Some(error), + ); + false + }, Err(e) => { - write_migrate_progress("error", step, total_steps, &format!("Runtime install failed: {}", e), Some(&e)); + write_migrate_progress( + "error", + step, + total_steps, + &format!("Runtime install failed: {}", e), + Some(&e.to_string()), + ); false }, - }, - Ok(v) => { - let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("runtime install did not start"); - write_migrate_progress( - "error", - step, - total_steps, - &format!("Runtime install failed: {}", error), - Some(error), - ); - false - }, - Err(e) => { - write_migrate_progress( - "error", - step, - total_steps, - &format!("Runtime install failed: {}", e), - Some(&e.to_string()), - ); - false - }, + } }; step += 1; @@ -572,8 +564,21 @@ fn run_migration() { None, ); match update_existing_wine_prefixes(&ms_dir, step) { - Ok(updated) => log_to_file(&format!("Migration: wineboot -u completed for {} prefix(es)", updated)), - Err(e) => log_to_file(&format!("Migration: wineboot -u failed (non-fatal): {}", e)), + Ok(updated) => log_to_file(&format!( + "Migration: all-architecture provider staging and wineboot update completed for {} prefix(es)", + updated + )), + Err(error) => { + write_migrate_progress( + "error", + step, + total_steps, + &format!("Wine prefix update failed: {error}"), + Some(&error), + ); + log_to_file(&format!("Migration blocked by Wine prefix acceptance failure: {error}")); + return; + }, } register_external_steam_libraries(&ms_dir); clear_steam_crash_marker(&ms_dir); @@ -603,10 +608,10 @@ fn run_migration() { let _ = fs::remove_file(&marker); let _ = fs::remove_file(migration_steam_config_backup_path(&ms_dir)); - // wineboot -u can fork Steam.exe twice for self-update. Let those updater - // windows finish naturally so the next app launch is not left with a - // half-completed Steam update. - wait_for_steam_update_windows_after_migration(&ms_dir, 15); + // Prefix migration uses wineboot's combined init+update contract, which + // deliberately suppresses HKCU Run entries. Steam remains installed but + // is not launched or updated behind the user's back during migration. + log_to_file("Migration: Wine prefix update completed without launching user startup applications"); write_migrate_progress("complete", total_steps, total_steps, "MetalSharp is updated and ready.", None); log_to_file(&format!("Migration to v{} finished (install_ok=true)", MIGRATE_VERSION)); @@ -998,20 +1003,9 @@ fn preserve_user_data(ms_dir: &PathBuf) -> (PreservedData, MigrationReport) { } fn update_existing_wine_prefixes(ms_dir: &Path, step: usize) -> Result { - let runtime_wine = ms_dir.join("runtime").join("wine"); - let wine = crate::platform::runtime_wine_binary(&runtime_wine); - if !wine.exists() { - return Err(format!("MetalSharp Wine not found at {}", wine.display())); - } - let mut updated = 0usize; + let mut failures = Vec::new(); for prefix in collect_existing_wine_prefixes(ms_dir) { - let steam_library_drive_links = collect_steam_library_drive_links(&prefix); - // Snapshot all dosdevice symlinks before wineboot rewrites them. - // This covers custom drive mappings (external drives, Z:, etc.) that - // wineboot -u may destroy. - let all_dosdevice_links = collect_prefix_dosdevice_links(&prefix); - write_migrate_progress( "running", step, @@ -1030,29 +1024,63 @@ fn update_existing_wine_prefixes(ms_dir: &Path, step: usize) -> Result Vec { let mut prefixes = Vec::new(); push_existing_prefix(&mut prefixes, ms_dir.join("prefix-steam")); - push_existing_prefix(&mut prefixes, gog_bottle_prefix_path(ms_dir)); + push_existing_prefix(&mut prefixes, ms_dir.join("sharp-prefix")); + let bottles = ms_dir.join("bottles"); + if let Ok(entries) = fs::read_dir(&bottles) { + for entry in entries.flatten() { + push_existing_prefix(&mut prefixes, entry.path().join("prefix")); + } + } prefixes } fn push_existing_prefix(prefixes: &mut Vec, prefix: PathBuf) { - if !prefix.exists() { + if !prefix.join("drive_c").is_dir() { return; } if prefixes.iter().any(|existing| existing == &prefix) { @@ -1208,52 +1236,6 @@ fn restore_gog_bottle_prefix(bottles_tmp: &Path, bottles: &Path, report: &mut Mi ); } -fn run_wineboot_update(wine: &Path, runtime_wine: &Path, prefix: &Path) -> Result<(), String> { - let mut cmd = Command::new(wine); - cmd.env("WINEPREFIX", prefix.to_string_lossy().to_string()) - .env("WINEDEBUG", "-all") - .env("WINEDEBUGGER", "/usr/bin/true") - .env("WINEDLLOVERRIDES", "winedbg=d") - .arg("wineboot") - .arg("-u") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - crate::platform::set_runtime_library_env(&mut cmd, runtime_wine); - - log_to_file(&format!("Starting wineboot -u for prefix: {}", prefix.display())); - - let mut child = cmd.spawn().map_err(|e| { - log_to_file(&format!("Failed to spawn wineboot for {}: {}", prefix.display(), e)); - format!("spawn wineboot for {}: {}", prefix.display(), e) - })?; - - for attempt in 0..240 { - if let Some(status) = child.try_wait().map_err(|e| { - log_to_file(&format!("Failed to wait for wineboot for {}: {}", prefix.display(), e)); - format!("wait wineboot for {}: {}", prefix.display(), e) - })? { - if status.success() { - log_to_file(&format!("wineboot -u completed successfully for prefix: {}", prefix.display())); - return Ok(()); - } - let error_msg = format!("wineboot -u failed for {} with exit code: {:?}", prefix.display(), status.code()); - log_to_file(&error_msg); - return Err(error_msg); - } - std::thread::sleep(std::time::Duration::from_millis(500)); - - if attempt == 120 { - log_to_file(&format!("wineboot -u still running after 60 seconds for prefix: {}", prefix.display())); - } - } - - let error_msg = format!("wineboot -u timed out (120 seconds) for {}", prefix.display()); - log_to_file(&error_msg); - let _ = child.kill(); - let _ = child.wait(); - Err(error_msg) -} - #[derive(Clone, Debug, PartialEq, Eq)] struct SteamLibraryDriveLink { drive: char, @@ -1499,6 +1481,51 @@ fn is_dmg_mount_target(target: &Path) -> bool { lower.contains("-arm64") || lower.contains("-x86_64") || lower.contains(".dmg") || lower.contains("-intel") } +fn prune_stale_installer_dosdevices(prefix: &Path) -> Result { + let dosdevices = prefix.join("dosdevices"); + if !dosdevices.is_dir() { + return Ok(0); + } + + let mut stale_names = Vec::new(); + for entry in fs::read_dir(&dosdevices) + .map_err(|error| format!("failed to enumerate {}: {error}", dosdevices.display()))? + .flatten() + { + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + let Ok(target) = fs::read_link(entry.path()) else { + continue; + }; + if is_dmg_mount_target(&target) { + stale_names.push(name.clone()); + if name.ends_with(':') && !name.ends_with("::") { + stale_names.push(format!("{name}:")); + } + } + } + + stale_names.sort(); + stale_names.dedup(); + let mut removed = 0; + for name in stale_names { + let path = dosdevices.join(&name); + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => { + fs::remove_file(&path) + .map_err(|error| format!("failed to remove stale {}: {error}", path.display()))?; + removed += 1; + log_to_file(&format!("Migration: removed stale installer dosdevice {}", path.display())); + }, + Ok(_) => return Err(format!("stale installer dosdevice is not a removable link: {}", path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}, + Err(error) => return Err(format!("failed to inspect stale {}: {error}", path.display())), + } + } + Ok(removed) +} + fn collect_prefix_dosdevice_links(prefix: &Path) -> Vec<(String, PathBuf)> { let dosdevices = prefix.join("dosdevices"); if !dosdevices.is_dir() { @@ -2089,17 +2116,7 @@ fn ensure_z_drive(dosdevices: &Path) { } fn remove_old_runtime(ms_dir: &PathBuf) { - let dirs_to_remove = [ - "runtime", - "configs", - "cache", - "logs", - "shader-cache", - "crashes", - "SteamSetup.exe", - "install_progress.json", - "update_progress.json", - ]; + let dirs_to_remove = ["runtime", "SteamSetup.exe", "install_progress.json", "update_progress.json"]; for name in &dirs_to_remove { let p = ms_dir.join(name); @@ -2112,11 +2129,19 @@ fn remove_old_runtime(ms_dir: &PathBuf) { } } + // Keep user-facing cache metadata (covers, API configuration), logs, and + // compiled shader caches. Only installer/download scratch payloads are + // runtime-owned and safe to discard during replacement. + for name in ["downloads", "updates", "updater-tools", "tmp"] { + let scratch = ms_dir.join("cache").join(name); + if fs::symlink_metadata(&scratch).map(|metadata| metadata.is_dir()).unwrap_or(false) { + let _ = fs::remove_dir_all(&scratch); + } else if fs::symlink_metadata(&scratch).map(|metadata| metadata.is_file()).unwrap_or(false) { + let _ = fs::remove_file(&scratch); + } + } + let _ = fs::create_dir_all(ms_dir.join("runtime")); - let _ = fs::create_dir_all(ms_dir.join("configs")); - let _ = fs::create_dir_all(ms_dir.join("cache")); - let _ = fs::create_dir_all(ms_dir.join("logs")); - let _ = fs::create_dir_all(ms_dir.join("shader-cache")); } fn restore_user_data(ms_dir: &PathBuf, preserved: &PreservedData, report: &mut MigrationReport) { @@ -2404,6 +2429,27 @@ fn unix_days_to_ymd(days_since_epoch: u64) -> (i64, u32, u32) { mod tests { use super::*; + #[test] + fn migration_removes_only_stale_installer_drive_pair() { + let prefix = test_dir("stale-installer-dosdevices"); + let dosdevices = prefix.join("dosdevices"); + fs::create_dir_all(&dosdevices).expect("create dosdevices fixture"); + std::os::unix::fs::symlink("../drive_c", dosdevices.join("c:")).expect("create C fixture"); + std::os::unix::fs::symlink("/Volumes/MetalSharp 0.57.0-arm64 1", dosdevices.join("d:")) + .expect("create stale installer fixture"); + std::os::unix::fs::symlink("/dev/rdisk8s1", dosdevices.join("d::")).expect("create raw device fixture"); + std::os::unix::fs::symlink("/Volumes/Games", dosdevices.join("e:")).expect("create user drive fixture"); + + assert_eq!(prune_stale_installer_dosdevices(&prefix).expect("prune stale installer mappings"), 2); + assert!(fs::symlink_metadata(dosdevices.join("d:")).is_err()); + assert!(fs::symlink_metadata(dosdevices.join("d::")).is_err()); + assert_eq!(fs::read_link(dosdevices.join("c:")).expect("preserve C mapping"), Path::new("../drive_c")); + assert_eq!(fs::read_link(dosdevices.join("e:")).expect("preserve user mapping"), Path::new("/Volumes/Games")); + assert_eq!(collect_prefix_dosdevice_links(&prefix), vec![("e:".to_string(), PathBuf::from("/Volumes/Games"))]); + + let _ = fs::remove_dir_all(prefix); + } + #[test] fn cleanup_preserved_temp_dirs_removes_only_real_matching_directories() { let temp_root = test_dir("cleanup-preserved-temp"); @@ -2559,15 +2605,13 @@ mod tests { } #[test] - fn missing_dxmt_manifest_requests_repair() { - let home = test_dir("missing-dxmt-manifest"); + fn missing_m12_vkd3d_runtime_requests_repair() { + let home = test_dir("missing-m12-vkd3d-runtime"); let ms_dir = crate::platform::metalsharp_home_dir_for(&home); write_runtime_core(&ms_dir); - fs::remove_file( - ms_dir.join("runtime").join("wine").join("lib").join("dxmt").join("metalsharp-dxmt-runtime.json"), - ) - .expect("remove DXMT manifest"); + fs::remove_file(ms_dir.join("runtime").join("graphics").join("vkd3d-proton").join("x86_64").join("d3d12.dll")) + .expect("remove M12 vkd3d-proton d3d12 runtime"); assert!(!runtime_core_ready(&ms_dir)); assert!(runtime_needs_repair(&home, true)); @@ -2575,8 +2619,8 @@ mod tests { } #[test] - fn missing_metalsharp_hook_requests_runtime_repair() { - let home = test_dir("missing-metalsharp-hook"); + fn retired_split_bundle_hook_does_not_invalidate_complete_runtime() { + let home = test_dir("retired-split-bundle-hook"); let ms_dir = crate::platform::metalsharp_home_dir_for(&home); write_runtime_core(&ms_dir); @@ -2591,8 +2635,8 @@ mod tests { ) .expect("remove MetalSharp ntdll hook"); - assert!(!runtime_core_ready(&ms_dir)); - assert!(runtime_needs_repair(&home, true)); + assert!(runtime_core_ready(&ms_dir)); + assert!(!runtime_needs_repair(&home, true)); let _ = fs::remove_dir_all(home); } @@ -2603,8 +2647,10 @@ mod tests { let steam_prefix = ms_dir.join("prefix-steam"); let bottle_dir = ms_dir.join("bottles").join("installer_demo"); let bottle_prefix = bottle_dir.join("prefix"); - fs::create_dir_all(&steam_prefix).expect("create Steam prefix"); - fs::create_dir_all(&bottle_prefix).expect("create bottle prefix"); + let sharp_prefix = ms_dir.join("sharp-prefix"); + fs::create_dir_all(steam_prefix.join("drive_c")).expect("create Steam prefix"); + fs::create_dir_all(bottle_prefix.join("drive_c")).expect("create bottle prefix"); + fs::create_dir_all(sharp_prefix.join("drive_c")).expect("create Sharp prefix"); fs::write( bottle_dir.join("bottle.json"), serde_json::to_vec(&json!({ @@ -2618,8 +2664,9 @@ mod tests { let prefixes = collect_existing_wine_prefixes(&ms_dir); assert!(prefixes.contains(&steam_prefix)); - assert!(!prefixes.contains(&bottle_prefix)); - assert_eq!(prefixes.len(), 1); + assert!(prefixes.contains(&bottle_prefix)); + assert!(prefixes.contains(&sharp_prefix)); + assert_eq!(prefixes.len(), 3); let _ = fs::remove_dir_all(home); } @@ -2629,12 +2676,12 @@ mod tests { let ms_dir = crate::platform::metalsharp_home_dir_for(&home); let steam_prefix = ms_dir.join("prefix-steam"); let gog_prefix = ms_dir.join("bottles").join(GOG_PREFIX_BOTTLE_ID).join("prefix"); - fs::create_dir_all(&steam_prefix).expect("create Steam prefix"); + fs::create_dir_all(steam_prefix.join("drive_c")).expect("create Steam prefix"); let prefixes_without_gog = collect_existing_wine_prefixes(&ms_dir); assert_eq!(prefixes_without_gog, vec![steam_prefix.clone()]); - fs::create_dir_all(&gog_prefix).expect("create GOG prefix"); + fs::create_dir_all(gog_prefix.join("drive_c")).expect("create GOG prefix"); let prefixes_with_gog = collect_existing_wine_prefixes(&ms_dir); assert!(prefixes_with_gog.contains(&steam_prefix)); assert!(prefixes_with_gog.contains(&gog_prefix)); @@ -2962,10 +3009,15 @@ mod tests { let ms_dir = crate::platform::metalsharp_home_dir_for(&home); fs::create_dir_all(ms_dir.join("cache").join("covers")).expect("create cache metadata dir"); fs::create_dir_all(ms_dir.join("cache").join("updates")).expect("create update cache dir"); + fs::create_dir_all(ms_dir.join("configs")).expect("create settings dir"); + fs::create_dir_all(ms_dir.join("shader-cache")).expect("create shader cache dir"); fs::write(ms_dir.join("cache").join("steam_config.json"), br#"{"api_key_set":true}"#) .expect("write steam config"); fs::write(ms_dir.join("cache").join("covers").join("620.png"), b"cover").expect("write cover"); fs::write(ms_dir.join("cache").join("updates").join("MetalSharp.dmg"), b"dmg").expect("write cached dmg"); + fs::write(ms_dir.join("configs").join("user.json"), b"settings").expect("write settings fixture"); + fs::write(ms_dir.join("shader-cache").join("pipeline.bin"), b"shader cache") + .expect("write shader cache fixture"); let (preserved, mut report) = preserve_user_data(&ms_dir); remove_old_runtime(&ms_dir); @@ -2975,8 +3027,10 @@ mod tests { fs::read_to_string(ms_dir.join("cache").join("steam_config.json")).unwrap(), r#"{"api_key_set":true}"# ); - assert!(!ms_dir.join("cache").join("covers").join("620.png").exists()); + assert!(ms_dir.join("cache").join("covers").join("620.png").exists()); assert!(!ms_dir.join("cache").join("updates").join("MetalSharp.dmg").exists()); + assert_eq!(fs::read(ms_dir.join("configs").join("user.json")).unwrap(), b"settings"); + assert_eq!(fs::read(ms_dir.join("shader-cache").join("pipeline.bin")).unwrap(), b"shader cache"); let _ = fs::remove_dir_all(home); } @@ -3152,15 +3206,12 @@ mod tests { ); write_runtime_core(&ms_dir); - fs::write( - ms_dir.join("runtime").join("wine").join("lib").join("dxmt_m12").join("x86_64-windows").join("d3d12.dll"), - b"stale-m12-d3d12", - ) - .expect("poison M12 hash-gated runtime file"); + fs::remove_file(ms_dir.join("runtime").join("graphics").join("vkd3d-proton/x86_64/d3d12.dll")) + .expect("remove required M12 runtime file"); assert_eq!( verify_migration_ready(&ms_dir, None).unwrap_err(), "runtime bundle is still incomplete after install", - "stale M12 hashes must not satisfy migration readiness" + "missing M12 vkd3d-proton files must not satisfy migration readiness" ); let _ = fs::remove_dir_all(home); } @@ -3183,6 +3234,42 @@ mod tests { write_host_runtime(ms_dir); for path in [ + runtime_wine.join("build-ec").join("wine"), + runtime_wine.join("build-ec").join("server").join("wineserver"), + runtime_wine.join("wine-11.12").join("nls").join("locale.nls"), + runtime_wine.join("wine-11.12").join("fonts").join("tahoma.ttf"), + runtime_wine.join("build-ec").join("dxmt-v0.80").join("aarch64-unix").join("winemetal.so"), + runtime_wine.join("build-ec").join("dxmt-v0.80").join("aarch64-windows").join("d3d10core.dll"), + runtime_wine.join("build-ec").join("dxmt-v0.80").join("aarch64-windows").join("d3d11.dll"), + runtime_wine.join("build-ec").join("dxmt-v0.80").join("aarch64-windows").join("dxgi.dll"), + runtime_wine.join("build-ec").join("dxmt-v0.80").join("aarch64-windows").join("winemetal.dll"), + runtime_wine.join("build-ec").join("dxmt-v0.80").join("i386-windows").join("d3d10core.dll"), + runtime_wine.join("build-ec").join("dxmt-v0.80").join("i386-windows").join("d3d11.dll"), + runtime_wine.join("build-ec").join("dxmt-v0.80").join("i386-windows").join("dxgi.dll"), + runtime_wine.join("build-ec").join("dxmt-v0.80").join("i386-windows").join("winemetal.dll"), + runtime_wine.join("build-ec").join("dlls").join("d3d9").join("x86_64-windows").join("d3d9.dll"), + runtime_wine.join("build-ec").join("dlls").join("d3d9").join("i386-windows").join("d3d9.dll"), + runtime_wine.join("build-ec").join("dlls").join("d3d10").join("x86_64-windows").join("d3d10.dll"), + runtime_wine.join("build-ec").join("dlls").join("d3d10").join("i386-windows").join("d3d10.dll"), + runtime_wine.join("build-ec").join("dlls").join("d3d10_1").join("x86_64-windows").join("d3d10_1.dll"), + runtime_wine.join("build-ec").join("dlls").join("d3d10_1").join("i386-windows").join("d3d10_1.dll"), + runtime_wine.join("build-ec").join("dlls").join("dxgi").join("i386-windows").join("dxgi.dll"), + ms_dir.join("runtime").join("graphics").join("dxvk").join("i386").join("d3d9.dll"), + ms_dir.join("runtime").join("graphics").join("dxvk").join("x86_64").join("dxgi.dll"), + ms_dir.join("runtime").join("graphics").join("vkd3d-proton").join("x86_64").join("d3d12.dll"), + ms_dir.join("runtime").join("graphics").join("vkd3d-proton").join("x86_64").join("d3d12core.dll"), + ms_dir.join("runtime").join("graphics").join("opengl-metal").join("metalsharp-opengl.dylib"), + ms_dir.join("runtime").join("graphics").join("moltenvk").join("libMoltenVK.dylib"), + ms_dir.join("runtime").join("providers").join("xtajit64-arm64ec-known-good.dll"), + ms_dir.join("runtime").join("providers").join("xtajit-arm64-known-good.dll"), + ms_dir.join("runtime").join("scripts").join("stage-runtime-providers.sh"), + ms_dir.join("runtime").join("integration").join("gog").join("bin").join("gogdl"), + runtime_wine.join("build-ec").join("dlls").join("wow64").join("aarch64-windows").join("wow64.dll"), + runtime_wine.join("build-ec").join("dlls").join("wow64win").join("aarch64-windows").join("wow64win.dll"), + runtime_wine.join("build-ec").join("dlls").join("winevulkan").join("winevulkan.so"), + runtime_wine.join("build-ec").join("dlls").join("winevulkan").join("x86_64-windows").join("winevulkan.dll"), + runtime_wine.join("build-ec").join("dlls").join("vulkan-1").join("x86_64-windows").join("vulkan-1.dll"), + runtime_wine.join("build-ec").join("dlls").join("win32u").join("libMoltenVK.dylib"), runtime_wine.join("lib").join("wine").join("x86_64-unix").join(".keep"), runtime_wine.join("lib").join("wine").join("x86_64-windows").join("d3d9.dll"), runtime_wine.join("lib").join("wine").join("x86_64-windows").join("d3d10.dll"), @@ -3241,6 +3328,16 @@ mod tests { fs::write(path, b"test").expect("write runtime file"); } + fs::write( + ms_dir.join("runtime").join(".metalsharp-runtime-install"), + format!( + "archive_sha256={}\ngog_archive_sha256={}\nno_tso=1\n", + crate::installer::COMPLETE_RUNTIME_ARCHIVE_SHA256, + crate::installer::GOG_SUPPORT_ARCHIVE_SHA256 + ), + ) + .expect("write complete runtime marker"); + crate::installer::write_dxmt_m12_expected_test_files(&runtime_wine.join("lib").join("dxmt_m12")); for lane in ["dxmt", "dxmt_m12"] { diff --git a/app/src-rust/src/mono.rs b/app/src-rust/src/mono.rs deleted file mode 100644 index a22598500..000000000 --- a/app/src-rust/src/mono.rs +++ /dev/null @@ -1,875 +0,0 @@ -//! Wine Mono install / upgrade surface. -//! -//! Provides the backend for the GOG "Install Mono" header button and the -//! Settings "Upgrade Mono" action. Both download the official Wine Mono -//! `x86.msi` for the pinned latest release and run it -//! (`wine msiexec /i REINSTALLMODE=vomus REINSTALL=ALL /qn`) inside -//! a self-cleaning, bounded state machine. Once the versioned -//! `windows/mono/wine-mono-/` directory exists in the prefix, the -//! install is considered complete and the caller can hide its button. -//! -//! The installer harness explicitly defends against the Wine Mono MSI's -//! embedded `RemoveExistingProducts` action that spawns a detached -//! `msiexec REMOVE=ALL` for the prior `{AF2C9281-50A4-543B-A8E2-F0A38015A9F8}` -//! product — those detached children are prone to outliving the wine -//! parent if anyone deletes the install filesystem first. The harness: -//! 1. kills the prefix's wineserver (`wineserver -k`) and sweeps any -//! leftover mono-related processes, -//! 2. only then runs `wine msiexec /i ... REINSTALLMODE=vomus REINSTALL=ALL /qn`, -//! 3. waits on the child with a hard timeout, and -//! 4. always reaps orphan `msiexec REMOVE=ALL` / -//! `winedevice.exe` / `start.exe /exec msiexec` children before -//! updating the install state. -//! -//! Detection is prefix-filesystem based (no registry reads) so it works -//! identically for the GOG prefix and the Steam prefix. - -use serde_json::{json, Value}; -use std::fs::{self, OpenOptions}; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::sync::OnceLock; -use std::time::{Duration, Instant}; - -/// Pinned "latest" Wine Mono release. Bumping this (version + msi filename + -/// expected install dir) is the only change needed to ship a new mono. -pub const WINE_MONO_LATEST_VERSION: &str = "11.2.0"; -const WINE_MONO_LATEST_MSI_FILENAME: &str = "wine-mono-11.2.0-x86.msi"; -const WINE_MONO_RELEASE_TAG: &str = "wine-mono-11.2.0"; -const WINE_MONO_INSTALL_DIR_NAME: &str = "wine-mono-11.2.0"; - -fn wine_mono_msi_url() -> &'static str { - "https://github.com/wine-mono/wine-mono/releases/download/wine-mono-11.2.0/wine-mono-11.2.0-x86.msi" -} - -fn now_secs() -> u64 { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) -} - -/// Directory used to cache the downloaded MSI so repeated installs/upgrades do -/// not re-download. Lives under the MetalSharp data folder. -fn mono_cache_dir() -> PathBuf { - crate::platform::metalsharp_home_dir().join("cache").join("wine-mono") -} - -fn mono_cache_msi_path() -> PathBuf { - mono_cache_dir().join(WINE_MONO_LATEST_MSI_FILENAME) -} - -/// In-flight install state so the renderer can poll a single status endpoint -/// without re-deriving it. Mirrors the wineboot/poll patterns elsewhere. -#[derive(Debug, Clone, Default, serde::Serialize)] -struct MonoInstallState { - running: bool, - pid: Option, - /// Unix-epoch seconds when the current run started; lets the UI flag - /// installs that look hung for too long. - started_at: Option, - prefix: Option, - log_path: Option, - target_version: String, - last_error: Option, - /// Background MSI download tracking so the frontend can show progress. - downloading: bool, - download_bytes: u64, - download_total: u64, - download_error: Option, -} - -static INSTALL_STATE: OnceLock> = OnceLock::new(); - -fn install_state() -> &'static std::sync::Mutex { - INSTALL_STATE.get_or_init(|| { - std::sync::Mutex::new(MonoInstallState { - target_version: WINE_MONO_LATEST_VERSION.to_string(), - ..Default::default() - }) - }) -} - -fn read_install_state() -> MonoInstallState { - install_state().lock().map(|s| s.clone()).unwrap_or_default() -} - -fn mutate_install_state(f: F) { - if let Ok(mut state) = install_state().lock() { - f(&mut state); - } -} - -pub fn detect_wine_mono_version(prefix: &Path) -> Option { - // The version marker is a synthetic directory we create after a - // successful install. Its presence means the current latest version - // was installed by us. - let marker = prefix.join("drive_c").join("windows").join("mono").join(WINE_MONO_INSTALL_DIR_NAME); - if marker.is_dir() { - Some(WINE_MONO_LATEST_VERSION.to_string()) - } else { - None - } -} - -/// True when ANY wine-mono is present in the prefix (either our version -/// marker OR the mono-2.0 directory that the MSI creates). Used to decide -/// fresh install vs upgrade. -pub fn has_any_wine_mono(prefix: &Path) -> bool { - detect_wine_mono_version(prefix).is_some() - || prefix.join("drive_c").join("windows").join("mono").join("mono-2.0").is_dir() -} - -/// Compare two dotted version strings (e.g. "11.2.0" vs "9.5.0") numerically. -fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering { - let mut a_it = a.split('.'); - let mut b_it = b.split('.'); - loop { - match (a_it.next(), b_it.next()) { - (Some(a_part), Some(b_part)) => { - let a_num = a_part.parse::().unwrap_or(0); - let b_num = b_part.parse::().unwrap_or(0); - match a_num.cmp(&b_num) { - std::cmp::Ordering::Equal => continue, - other => return other, - } - }, - (Some(_), None) => return std::cmp::Ordering::Greater, - (None, Some(_)) => return std::cmp::Ordering::Less, - (None, None) => return std::cmp::Ordering::Equal, - } - } -} - -/// True when our version marker is present — the definitive signal that -/// the latest version was installed AND the install completed. -pub fn is_wine_mono_latest(prefix: &Path) -> bool { - detect_wine_mono_version(prefix).as_deref() == Some(WINE_MONO_LATEST_VERSION) -} - -/// For the UI status: "installed" means any mono exists. -pub fn is_any_mono_installed(prefix: &Path) -> bool { - has_any_wine_mono(prefix) -} - -/// Status payload shared by the GOG header button and the Settings row. -/// `prefix_kind` is "gog" or "steam" so the renderer can label appropriately. -pub fn wine_mono_status(prefix: &Path, prefix_kind: &str) -> Value { - let installed_version = detect_wine_mono_version(prefix); - let any_installed = is_any_mono_installed(prefix); - let up_to_date = installed_version.as_deref() == Some(WINE_MONO_LATEST_VERSION); - let state = read_install_state(); - let started_at = state.started_at; - let elapsed_seconds = started_at.map(|start| now_secs().saturating_sub(start)); - // Flag the install as stalled if it has been running for >75% of the - // hard timeout — this gives the UI a chance to surface a recovery - // affordance without forcing the user to wait the full timeout. - let stalled = state.running - && elapsed_seconds.map(|seconds| seconds >= (WINE_MONO_INSTALL_TIMEOUT.as_secs() * 3) / 4).unwrap_or(false); - json!({ - "ok": true, - "prefixKind": prefix_kind, - "latestVersion": WINE_MONO_LATEST_VERSION, - "installedVersion": installed_version, - "installed": any_installed, - "upToDate": up_to_date, - "running": state.running, - "stalled": stalled, - "pid": state.pid, - "startedAt": started_at, - "elapsedSeconds": elapsed_seconds, - "logPath": state.log_path, - "targetVersion": state.target_version, - "lastError": state.last_error, - "msiCached": mono_cache_msi_path().is_file(), - "downloading": state.downloading, - "downloadBytes": state.download_bytes, - "downloadTotal": state.download_total, - "downloadError": state.download_error, - }) -} - -// --------------------------------------------------------------------------- -// MSI staging — mirrors sharp_library::stage_installer_exe + current_dir -// --------------------------------------------------------------------------- - -/// Copy the cached MSI into a staging directory inside the prefix so the -/// installer runs with its working directory co-located with the prefix. -/// Matches the Sharp Library pattern of staging the installer payload before -/// execution. -fn stage_msi_into_prefix(cached_msi: &Path, prefix: &Path) -> Result { - let staging_dir = prefix.join(".ms-mono-install"); - fs::create_dir_all(&staging_dir).map_err(|e| format!("create mono staging dir: {}", e))?; - let file_name = cached_msi.file_name().ok_or_else(|| "msi cache filename missing".to_string())?; - let staged = staging_dir.join(file_name); - - // Only re-copy when the cached MSI is newer or the staged copy is absent. - let needs_copy = !staged.is_file() - || cached_msi.metadata().ok().and_then(|m| m.modified().ok()) - > staged.metadata().ok().and_then(|m| m.modified().ok()); - if needs_copy { - fs::copy(cached_msi, &staged).map_err(|e| format!("stage mono msi: {}", e))?; - } - Ok(staged) -} - -// --------------------------------------------------------------------------- -// Async download — non-blocking, progress reported through MonoInstallState -// --------------------------------------------------------------------------- - -/// Spawn a background thread to download the MSI. Updates `MonoInstallState` -/// with progress as bytes arrive. Idempotent: does nothing if a download is -/// already in flight. -fn spawn_msi_download() { - { - let state = read_install_state(); - if state.downloading { - return; - } - } - mutate_install_state(|s| { - s.downloading = true; - s.download_bytes = 0; - s.download_total = 0; - s.download_error = None; - }); - - let cache_dir = mono_cache_dir(); - let cache_path = mono_cache_msi_path(); - let url = wine_mono_msi_url().to_string(); - - std::thread::spawn(move || { - let result = download_msi_to_cache(&url, &cache_dir, &cache_path); - match result { - Ok(_) => mutate_install_state(|s| { - s.downloading = false; - }), - Err(e) => mutate_install_state(|s| { - s.downloading = false; - s.download_error = Some(e); - }), - } - }); -} - -/// Synchronous download worker (runs on the background thread). Writes -/// progress into `MonoInstallState` so the status-poll endpoint can surface -/// byte-level progress to the frontend. -fn download_msi_to_cache(url: &str, cache_dir: &Path, cache_path: &Path) -> Result<(), String> { - fs::create_dir_all(cache_dir).map_err(|e| format!("create wine-mono cache dir: {}", e))?; - - let config = ureq::config::Config::builder() - .user_agent(format!("MetalSharp/wine-mono-{}", WINE_MONO_LATEST_VERSION)) - .build(); - let agent = ureq::Agent::new_with_config(config); - let response = agent.get(url).call().map_err(|e| format!("wine-mono download failed: {}", e))?; - - let total: u64 = response - .headers() - .get("content-length") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); - mutate_install_state(|s| { - s.download_total = total; - }); - - let tmp = cache_path.with_extension("msi.part"); - let mut file = fs::File::create(&tmp).map_err(|e| format!("create wine-mono msi: {}", e))?; - let mut reader = response.into_body().into_reader(); - let mut buf = [0u8; 65536]; - let mut downloaded: u64 = 0; - loop { - let n = reader.read(&mut buf).map_err(|e| format!("wine-mono read: {}", e))?; - if n == 0 { - break; - } - file.write_all(&buf[..n]).map_err(|e| format!("wine-mono write: {}", e))?; - downloaded += n as u64; - mutate_install_state(|s| { - s.download_bytes = downloaded; - }); - if total > 0 && downloaded >= total { - break; - } - } - drop(file); - fs::rename(&tmp, cache_path).map_err(|e| format!("finalize wine-mono msi: {}", e))?; - Ok(()) -} - -// --------------------------------------------------------------------------- -// Process hygiene: kill & sweep helpers that bound the wine process tree -// so the orphaned-`msiexec REMOVE=ALL` problem cannot reappear. -// --------------------------------------------------------------------------- - -/// Hard timeout for the entire Wine Mono install. The MSI ships ~480 MB of -/// files and on a busy Apple Silicon machine the install can legitimately -/// take 5–8 minutes end to end. We allow 10 minutes and force a cleanup -/// afterwards either way. -const WINE_MONO_INSTALL_TIMEOUT: Duration = Duration::from_secs(10 * 60); - -/// Polling cadence when waiting on `child.wait()` so we can still apply the -/// hard timeout and run orphan sweeps while the child is alive. -const WINE_MONO_WAIT_POLL: Duration = Duration::from_millis(500); - -/// Run the prefix's `wineserver -k` to terminate any leftover wine/msiexec/ -/// winedevice processes attached to the prefix. Best effort — any error -/// (e.g. wineserver missing) is swallowed because the sweep below is the -/// real cleanup. -fn kill_prefix_wineserver(prefix: &Path) { - let home = match dirs::home_dir() { - Some(home) => home, - None => return, - }; - let ms_root = crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("wine"); - let wineserver = ms_root.join("bin").join("wineserver"); - if !wineserver.is_file() { - return; - } - let mut cmd = Command::new(&wineserver); - cmd.arg("-k").env("WINEPREFIX", prefix.to_string_lossy().to_string()); - crate::platform::set_runtime_library_env(&mut cmd, &ms_root); - let _ = cmd.status(); -} - -/// Best-effort sweep of stale wine mono/wine-mono-related processes. We -/// intentionally match by command-line fragment so we only kill processes -/// we created (or whose previous attempt orphaned them) — not arbitrary -/// wine installs on the user's machine. -fn sweep_orphan_mono_processes(prefix: &Path) -> u32 { - let needle_prefix = prefix.to_string_lossy(); - let output = Command::new("/bin/ps").args(["-axo", "pid=,command="]).output(); - let stdout = match output { - Ok(output) => String::from_utf8_lossy(&output.stdout).to_string(), - Err(_) => return 0, - }; - let mut killed: u32 = 0; - for line in stdout.lines() { - let trimmed = line.trim(); - let Some((pid_text, command)) = trimmed.split_once(' ') else { - continue; - }; - let Ok(pid) = pid_text.trim().parse::() else { - continue; - }; - // Never signal ourselves. - if pid == std::process::id() { - continue; - } - - // Match processes related to wine mono: msiexec working on the - // mono MSI, the wine start.exe wrapper, winedevice remnants, or - // anything running inside our prefix path. - let is_msiexec_orphan = - command.contains("msiexec") && (command.contains("REMOVE=ALL") || command.contains(needle_prefix.as_ref())); - let is_start_wrapper = command.contains("/exec msiexec") && command.contains(".ms-mono-install"); - let is_winedevice = command.contains("winedevice"); - let is_in_prefix = command.contains(needle_prefix.as_ref()) - && (command.contains("wine") || command.contains("msiexec") || command.contains("winedevice")); - if is_msiexec_orphan || is_start_wrapper || is_winedevice || is_in_prefix { - // Escalate: TERM first, fall back to KILL if the process - // ignored TERM. - let _ = Command::new("/bin/kill").arg("-TERM").arg(pid.to_string()).status(); - std::thread::sleep(Duration::from_millis(50)); - if let Ok(status) = Command::new("/bin/kill").arg("-0").arg(pid.to_string()).status() { - if status.success() { - let _ = Command::new("/bin/kill").arg("-KILL").arg(pid.to_string()).status(); - } - } - killed += 1; - } - } - killed -} - -/// Wait for `child` to exit, sweeping orphans every `WINE_MONO_WAIT_POLL` so -/// we surface a clean state. Returns `Ok(status)` if the child exited within -/// the timeout and `Err(message)` if the timeout fired — in which case the -/// caller is expected to kill + sweep + reap the child. -/// Wait for `child` to exit, polling every `WINE_MONO_WAIT_POLL`. -/// We no longer sweep orphans during the wait — doing so killed the -/// wineserver and winedevice processes that the MSI installer needs to -/// function, causing exit code 1 and an empty install. -/// The post-reap cleanup (kill_prefix_wineserver + sweep at the -/// call site) handles leftover orphans. -fn wait_with_sweep(child: &mut Child, _prefix: &Path, _sweep_log: &Path) -> Result { - let deadline = Instant::now() + WINE_MONO_INSTALL_TIMEOUT; - loop { - if let Ok(Some(status)) = child.try_wait() { - return Ok(status); - } - if Instant::now() >= deadline { - return Err(format!("Wine Mono installer exceeded {:?} budget", WINE_MONO_INSTALL_TIMEOUT)); - } - std::thread::sleep(WINE_MONO_WAIT_POLL); - } -} - -/// Remove leftover `Installer/*.msi` stub files and the `mono-2.0` install -/// directory if (and only if) the prefix's wineserver is fully down. This -/// matches what the Wine Mono MSI's `RemoveExistingProducts` action would -/// have done had it not previously hung — we now do it ourselves, after -/// killing the prefix's wineserver so the registry hive is unlocked. -fn cleanup_prefix_for_mono_install(prefix: &Path) { - let mono_dir = prefix.join("drive_c").join("windows").join("mono").join("mono-2.0"); - if mono_dir.is_dir() { - let _ = fs::remove_dir_all(&mono_dir); - } - let installer_dir = prefix.join("drive_c").join("windows").join("Installer"); - if let Ok(entries) = fs::read_dir(&installer_dir) { - for entry in entries.flatten() { - if entry.path().extension().map(|e| e == "msi").unwrap_or(false) { - let _ = fs::remove_file(entry.path()); - } - } - } - // Some Wine Mono installs also leave the registry entry for the old - // product; surface that in the log for diagnostic purposes (the MSI's - // REINSTALLMODE=vomus below handles it without external cleanup). -} - -pub fn install_wine_mono_latest(prefix: &Path, prefix_kind: &str) -> Result { - // 1. Resolve wine binary — always use the real `wine` binary, not the - // `metalsharp-wine` wrapper, because the wrapper sets env vars that can - // interfere with msiexec. - let home = dirs::home_dir().ok_or_else(|| "no home dir".to_string())?; - let ms_root = crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("wine"); - let wine = ms_root.join("bin").join("wine"); - if !wine.exists() { - return Err(format!("MetalSharp Wine not found: {}", wine.display())); - } - - // 2. Ensure prefix directory exists (match sharp library) - fs::create_dir_all(prefix).map_err(|e| format!("create prefix dir: {}", e))?; - if !prefix.join("drive_c").is_dir() { - return Err(format!("prefix not initialized (no drive_c): {}", prefix.display())); - } - - // 3. Verify the cached MSI is present - let cached_msi = mono_cache_msi_path(); - if !cached_msi.is_file() || cached_msi.metadata().map(|m| m.len() < 1_000_000).unwrap_or(true) { - return Err("Wine Mono MSI not downloaded yet".to_string()); - } - - // 4. Re-stage the MSI into the prefix (each install gets a fresh copy). - let staged_msi = stage_msi_into_prefix(&cached_msi, prefix)?; - - // 5. Log setup — single open, clone handle for stdout (match sharp) - let log_dir = crate::platform::metalsharp_home_dir_for(&home).join("logs"); - fs::create_dir_all(&log_dir).map_err(|e| format!("create log dir: {}", e))?; - let log_path = log_dir.join(format!("wine-mono-install-{}.log", prefix_kind)); - - let mut log = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&log_path) - .map_err(|e| format!("open wine-mono log: {}", e))?; - let _ = writeln!(log, "installer_kind=msi"); - let _ = writeln!(log, "prefix={}", prefix.display()); - let _ = writeln!(log, "msi={}", staged_msi.display()); - let _ = writeln!(log, "mono_version={}", WINE_MONO_LATEST_VERSION); - let _ = writeln!(log, "timeout={:?}; reinstall_mode=vomus; reinstall=ALL", WINE_MONO_INSTALL_TIMEOUT); - let _ = writeln!(log, "--- wine output ---"); - let stdout = log.try_clone().map_err(|e| format!("clone log handle: {}", e))?; - let stderr = log.try_clone().map_err(|e| format!("clone stderr handle: {}", e))?; - - // 6. Before cleanup, check whether wine-mono already exists so we - // can decide between a fresh install and an upgrade install. - let has_existing_mono = has_any_wine_mono(prefix); - - // 7. Hygiene: kill any wineserver for the prefix (optional — the - // new wine will start its own) and sweep orphans so we don't - // have stale processes from prior attempts. The sweep in - // wait_with_sweep was removed because it was killing the - // wineserver and winedevice that the MSI installer needs. - kill_prefix_wineserver(prefix); - sweep_orphan_mono_processes(prefix); - cleanup_prefix_for_mono_install(prefix); - - // 8. Build the install command. - // - // For a FRESH install (no prior wine-mono in the prefix), we use - // a plain `msiexec /i /qn` — the REINSTALL flags cause the - // MSI to exit with code 67 (ERROR_INSTALL_FAILURE) when there is - // nothing to reinstall. - // - // For an UPGRADE (prior wine-mono present), REINSTALLMODE=vomus - // tells the MSI to overwrite, re-install missing, and update the - // registry — patching the prior - // {AF2C9281-50A4-543B-A8E2-F0A38015A9F8} reference rather than - // detaching a hung msiexec REMOVE=ALL. - // REINSTALL=ALL forces every feature to install. - let mut cmd = Command::new(&wine); - cmd.arg("msiexec").arg("/i").arg(&staged_msi); - if has_existing_mono { - cmd.arg("REINSTALLMODE=vomus").arg("REINSTALL=ALL"); - } - cmd.arg("/qn") - .env("WINEPREFIX", prefix.to_string_lossy().to_string()) - .env("WINEDEBUG", "-all") - .stdout(Stdio::from(stdout)) - .stderr(Stdio::from(stderr)); - if let Some(parent) = staged_msi.parent() { - cmd.current_dir(parent); - } - crate::platform::set_runtime_library_env(&mut cmd, &ms_root); - - // 8. Spawn - let mut child = cmd.spawn().map_err(|e| format!("spawn wine msiexec: {}", e))?; - let pid = child.id(); - - mutate_install_state(|s| { - s.running = true; - s.pid = Some(pid); - s.started_at = Some(now_secs()); - s.prefix = Some(prefix.to_string_lossy().to_string()); - s.log_path = Some(log_path.to_string_lossy().to_string()); - s.target_version = WINE_MONO_LATEST_VERSION.to_string(); - s.last_error = None; - }); - - // Reap the installer process. Use the bounded wait+helper, then - // **always** kill+reap any stragglers and update state. - let prefix_for_thread: PathBuf = prefix.to_path_buf(); - let log_for_thread: PathBuf = log_path.clone(); - let pid_for_thread = pid; - std::thread::spawn(move || { - let wait_result = wait_with_sweep(&mut child, &prefix_for_thread, &log_for_thread); - // Always force-cleanup regardless of why wait returned. - kill_prefix_wineserver(&prefix_for_thread); - let _ = sweep_orphan_mono_processes(&prefix_for_thread); - // If the child is still around, terminate it directly. - if let Ok(None) = child.try_wait() { - let _ = child.kill(); - let _ = child.wait(); - } - - match wait_result { - Ok(status) => { - if status.success() { - let marker = - prefix_for_thread.join("drive_c").join("windows").join("mono").join(WINE_MONO_INSTALL_DIR_NAME); - let _ = fs::create_dir_all(&marker); - } - let still_latest = - detect_wine_mono_version(&prefix_for_thread).as_deref() == Some(WINE_MONO_LATEST_VERSION); - mutate_install_state(|s| { - s.running = false; - s.pid = None; - s.started_at = None; - if !still_latest { - s.last_error = Some(format!( - "Wine Mono installer exited with {:?} but {} was not detected in {}", - status.code(), - WINE_MONO_LATEST_VERSION, - prefix_for_thread.display() - )); - } - }); - }, - Err(error) => { - if let Ok(mut log) = fs::OpenOptions::new().create(true).append(true).open(&log_for_thread) { - let _ = writeln!(log, "[metal-sharp] timeout: {}", error); - } - mutate_install_state(|s| { - s.running = false; - s.pid = None; - s.started_at = None; - s.last_error = Some(error); - }); - }, - } - - // Drop the pid we recorded so any extra state is consistent. - mutate_install_state(|s| { - if s.pid == Some(pid_for_thread) { - s.pid = None; - } - s.started_at = None; - }); - }); - - Ok(pid) -} - -// --------------------------------------------------------------------------- -// Public handlers -// --------------------------------------------------------------------------- - -/// Path resolution for the two supported prefixes. -pub fn prefix_for_kind(prefix_kind: &str) -> Result { - match prefix_kind { - "gog" => Ok(crate::bottles::bottle_dir("gog-prefix").join("prefix")), - "steam" => Ok(crate::platform::metalsharp_home_dir().join("prefix-steam")), - other => Err(format!("unknown prefix kind: {}", other)), - } -} - -pub fn handle_status(prefix_kind: &str) -> Value { - match prefix_for_kind(prefix_kind) { - Ok(prefix) => wine_mono_status(&prefix, prefix_kind), - Err(error) => json!({"ok": false, "error": error}), - } -} - -/// Install handler with two-phase flow: -/// -/// 1. **MSI not cached** → spawn async download, return immediately with -/// `downloading: true`. The frontend polls `/wine-mono/status` for -/// progress and re-calls this endpoint once `msiCached` is true. -/// 2. **MSI cached** → stage into prefix and launch the installer. -pub fn handle_install(prefix_kind: &str) -> Value { - let prefix = match prefix_for_kind(prefix_kind) { - Ok(p) => p, - Err(error) => return json!({"ok": false, "error": error}), - }; - - // If the latest is already installed, treat as a no-op success so a - // redundant button press does not re-launch the installer. - if is_wine_mono_latest(&prefix) { - return json!({ - "ok": true, - "alreadyInstalled": true, - "status": wine_mono_status(&prefix, prefix_kind), - }); - } - - // If a download is already in progress, just return current status. - { - let state = read_install_state(); - if state.downloading { - return json!({ - "ok": true, - "downloading": true, - "status": wine_mono_status(&prefix, prefix_kind), - }); - } - } - - // Phase 1: MSI not cached yet — start async download. - if !mono_cache_msi_path().is_file() { - spawn_msi_download(); - return json!({ - "ok": true, - "downloading": true, - "status": wine_mono_status(&prefix, prefix_kind), - }); - } - - // Phase 2: MSI cached — launch the installer. - match install_wine_mono_latest(&prefix, prefix_kind) { - Ok(pid) => json!({ - "ok": true, - "pid": pid, - "status": wine_mono_status(&prefix, prefix_kind), - }), - Err(error) => { - mutate_install_state(|s| { - s.running = false; - s.last_error = Some(error.clone()); - }); - json!({ - "ok": false, - "error": error, - "status": wine_mono_status(&prefix, prefix_kind), - }) - }, - } -} - -/// Hard-reset the Mono install for a prefix. Kills the prefix's wineserver, -/// sweeps any orphan msiexec/winedevice processes (the hangover from -/// previous failed install attempts), and clears the in-memory install state -/// so the next click of "Install Mono" starts fresh. -/// -/// This is a no-op if there is nothing to clean up; it does not touch the -/// cached MSI in `~/.metalsharp/cache/wine-mono/` so a successful download -/// is preserved across resets. -pub fn handle_reset(prefix_kind: &str) -> Value { - let prefix = match prefix_for_kind(prefix_kind) { - Ok(p) => p, - Err(error) => { - mutate_install_state(|s| { - s.running = false; - s.pid = None; - s.started_at = None; - s.last_error = Some(error.clone()); - }); - return json!({"ok": false, "error": error}); - }, - }; - kill_prefix_wineserver(&prefix); - let killed = sweep_orphan_mono_processes(&prefix); - mutate_install_state(|s| { - s.running = false; - s.pid = None; - s.started_at = None; - s.last_error = None; - }); - json!({ - "ok": true, - "killedProcesses": killed, - "prefix": prefix.to_string_lossy().to_string(), - "status": wine_mono_status(&prefix, prefix_kind), - }) -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn latest_version_constants_are_consistent() { - assert!(WINE_MONO_LATEST_VERSION.contains('.')); - assert!(WINE_MONO_LATEST_MSI_FILENAME.contains(WINE_MONO_LATEST_VERSION)); - assert!(WINE_MONO_INSTALL_DIR_NAME.contains(WINE_MONO_LATEST_VERSION)); - assert!(wine_mono_msi_url().contains(WINE_MONO_RELEASE_TAG)); - assert!(wine_mono_msi_url().ends_with(WINE_MONO_LATEST_MSI_FILENAME)); - } - - #[test] - fn compare_versions_is_numeric_not_lexicographic() { - assert!(compare_versions("11.2.0", "9.5.0").is_gt()); - assert!(compare_versions("9.5.0", "11.2.0").is_lt()); - assert!(compare_versions("11.2.0", "11.2.0").is_eq()); - assert!(compare_versions("11.2.1", "11.2.0").is_gt()); - assert!(compare_versions("11.3.0", "11.2.9").is_gt()); - assert_eq!(compare_versions("2.0", "2.0.0"), std::cmp::Ordering::Less); - } - - #[test] - fn detect_returns_none_when_mono_dir_absent() { - let tmp = std::env::temp_dir().join(format!( - "ms-mono-none-{}-{}", - std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() - )); - assert_eq!(detect_wine_mono_version(&tmp), None); - assert!(!is_wine_mono_latest(&tmp)); - let _ = std::fs::remove_dir_all(&tmp); - } - - #[test] - fn detect_finds_version_marker_dir() { - let tmp = std::env::temp_dir().join(format!( - "ms-mono-detect-{}-{}", - std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() - )); - let marker = tmp.join("drive_c/windows/mono").join(WINE_MONO_INSTALL_DIR_NAME); - std::fs::create_dir_all(&marker).unwrap(); - - assert_eq!(detect_wine_mono_version(&tmp), Some("11.2.0".to_string())); - assert!(is_wine_mono_latest(&tmp)); - - let status = wine_mono_status(&tmp, "gog"); - assert_eq!(status.get("installedVersion").and_then(|v| v.as_str()), Some("11.2.0")); - assert_eq!(status.get("upToDate").and_then(|v| v.as_bool()), Some(true)); - let _ = std::fs::remove_dir_all(&tmp); - } - - #[test] - fn status_reports_not_installed_when_mono_2_0_absent() { - let tmp = std::env::temp_dir().join(format!( - "ms-mono-outdated-{}-{}", - std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() - )); - let status = wine_mono_status(&tmp, "steam"); - assert_eq!(status.get("installedVersion").and_then(|v| v.as_str()), None); - assert_eq!(status.get("upToDate").and_then(|v| v.as_bool()), Some(false)); - assert_eq!(status.get("installed").and_then(|v| v.as_bool()), Some(false)); - let _ = std::fs::remove_dir_all(&tmp); - } - - #[test] - fn prefix_for_kind_resolves_known_prefixes() { - assert!(prefix_for_kind("gog").is_ok()); - assert!(prefix_for_kind("steam").is_ok()); - assert!(prefix_for_kind("unknown").is_err()); - } - - #[test] - fn handle_install_rejects_unknown_prefix_without_spawning() { - let result = handle_install("nope"); - assert_eq!(result.get("ok").and_then(|v| v.as_bool()), Some(false)); - assert!(result.get("error").is_some()); - } - - #[test] - fn wine_mono_status_includes_download_fields() { - let tmp = std::env::temp_dir().join(format!( - "ms-mono-dl-fields-{}-{}", - std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() - )); - // Reset install state so the test starts clean. - mutate_install_state(|s| { - s.downloading = true; - s.download_bytes = 42; - s.download_total = 100; - }); - let status = wine_mono_status(&tmp, "steam"); - assert_eq!(status.get("downloading").and_then(|v| v.as_bool()), Some(true)); - assert_eq!(status.get("downloadBytes").and_then(|v| v.as_u64()), Some(42)); - assert_eq!(status.get("downloadTotal").and_then(|v| v.as_u64()), Some(100)); - mutate_install_state(|s| { - s.downloading = false; - s.download_bytes = 0; - s.download_total = 0; - }); - let _ = std::fs::remove_dir_all(&tmp); - } - - #[test] - fn orphan_sweep_skips_self_pid_and_returns_zero_when_no_match() { - let tmp = std::env::temp_dir().join(format!( - "ms-mono-sweep-{}-{}", - std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() - )); - // A path that will never appear in /bin/ps output for this machine. - let killed = sweep_orphan_mono_processes(&tmp); - // We can't assert the exact count (other tests may run concurrently), - // but we MUST NOT have signalled our own pid. - assert!(killed == killed, "sweep_orphan_mono_processes must complete and return a u32"); - // Confirm our pid is alive (we did not signal ourselves). - let alive = std::process::Command::new("/bin/kill") - .arg("-0") - .arg(std::process::id().to_string()) - .status() - .map(|s| s.success()) - .unwrap_or(false); - assert!(alive, "sweep must never signal its own pid"); - let _ = std::fs::remove_dir_all(&tmp); - } - - #[test] - fn handle_reset_does_not_panic_and_reports_status() { - let result = handle_install("gog"); - // We just want to make sure the reset call after install is - // observable. Pre-clean by running the reset directly. - let reset = handle_reset("gog"); - assert_eq!(reset.get("ok").and_then(|v| v.as_bool()), Some(true)); - let _ = result; - } - - #[test] - fn status_reports_up_to_date_with_marker_after_reset() { - let tmp = std::env::temp_dir().join(format!( - "ms-mono-marker-{}-{}", - std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() - )); - let marker = tmp.join("drive_c/windows/mono").join(WINE_MONO_INSTALL_DIR_NAME); - std::fs::create_dir_all(&marker).unwrap(); - let status = wine_mono_status(&tmp, "gog"); - assert_eq!(status.get("upToDate").and_then(|v| v.as_bool()), Some(true)); - assert_eq!(status.get("installed").and_then(|v| v.as_bool()), Some(true)); - let _ = std::fs::remove_dir_all(&tmp); - } -} diff --git a/app/src-rust/src/mtsp/default_rules.rs b/app/src-rust/src/mtsp/default_rules.rs index 873c98e3d..ad68d8924 100644 --- a/app/src-rust/src/mtsp/default_rules.rs +++ b/app/src-rust/src/mtsp/default_rules.rs @@ -28,7 +28,7 @@ fn has_custom_exe_fix(recipe: &GameRecipe) -> bool { fn dll_arch_for_source_subpath(subpath: &str) -> &'static str { if subpath.contains("i386") { "32-bit" - } else if subpath.contains("x86_64") { + } else if subpath.contains("x86_64") || subpath.contains("aarch64-windows") { "64-bit" } else { "mixed" @@ -147,7 +147,8 @@ mod tests { #[test] fn launch_shape_for_hades_m11_reports_64_bit_dxmt_deploy_set() { // Switching Hades from the M11(32) default to plain M11 must report the - // 64-bit DXMT deploy set (d3d11/dxgi/winemetal from lib/dxmt/x86_64-windows) + // 64-bit DXMT deploy set (the x86-64/ARM64EC-compatible PE payload in + // the complete runtime's aarch64-windows lane) // and the M11 wine overrides, so the UI can show the user exactly what // will be applied. let shape = handle_launch_shape(1145360, PipelineId::M11); @@ -174,7 +175,7 @@ mod tests { assert!(filenames.contains(&"winemetal.dll")); for dll in dlls { assert_eq!(dll.get("arch").and_then(|v| v.as_str()), Some("64-bit")); - assert!(dll.get("source_subpath").and_then(|v| v.as_str()).unwrap_or("").contains("x86_64-windows")); + assert!(dll.get("source_subpath").and_then(|v| v.as_str()).unwrap_or("").contains("aarch64-windows")); } } diff --git a/app/src-rust/src/mtsp/engine.rs b/app/src-rust/src/mtsp/engine.rs index 262eaebeb..a14bc7993 100644 --- a/app/src-rust/src/mtsp/engine.rs +++ b/app/src-rust/src/mtsp/engine.rs @@ -60,7 +60,6 @@ impl PipelineNode { static PIPELINES: OnceLock> = OnceLock::new(); const DXMT_70_PERCENT_UPSCALE_CONFIG: &str = "d3d11.metalSpatialUpscaleFactor=1.43;d3d11.preferredMaxFrameRate=60"; -const DXMT_M12_SAFE_CONFIG: &str = "d3d11.metalSpatialUpscaleFactor=1.43;d3d11.preferredMaxFrameRate=60"; pub fn pipelines() -> &'static Vec { PIPELINES.get_or_init(|| { @@ -68,7 +67,7 @@ pub fn pipelines() -> &'static Vec { PipelineNode { id: PipelineId::Dxmt, name: "DXMT", - description: "Auto-selected D3D9/D3D10/D3D11/D3D12 -> Metal via unified DXMT runtime", + description: "Auto-selects DXMT for D3D9-D3D11 or vkd3d-proton/MoltenVK for D3D12", backend: "dxmt", graphics_backend: "dxmt", experimental: false, @@ -85,68 +84,25 @@ pub fn pipelines() -> &'static Vec { PipelineNode { id: PipelineId::M12, name: "M12", - description: "D3D12 -> Metal via DXMT", - backend: "dxmt", - graphics_backend: "dxmt", + description: "D3D12 -> Vulkan via vkd3d-proton -> Metal via MoltenVK", + backend: "vkd3d-proton", + graphics_backend: "vkd3d-proton", experimental: false, requires_wine: true, - wine_overrides: Some( - "winemetal,d3d12,dxgi,dxgi_dxmt,d3d11,d3d10core=n,b;gameoverlayrenderer,gameoverlayrenderer64=d", - ), - dyld_paths: vec!["lib/dxmt_m12/x86_64-unix", "lib/wine/x86_64-unix"], - winedllpath_dirs: vec!["lib/dxmt_m12/x86_64-windows"], + wine_overrides: Some("d3d12,d3d12core,dxgi=n,b;gameoverlayrenderer,gameoverlayrenderer64=d"), + dyld_paths: vec!["build-ec/dlls/winevulkan", "build-ec/dlls/win32u", "build-ec/dlls/ntdll"], + winedllpath_dirs: vec!["lib/vkd3d-proton/x86_64", "lib/dxvk/x86_64"], deploy_dlls: vec![ + DllDeploy { source_subpath: "lib/vkd3d-proton/x86_64", filename: "d3d12.dll", dest_filename: None }, DllDeploy { - source_subpath: "lib/dxmt_m12/x86_64-windows", - filename: "d3d12.dll", + source_subpath: "lib/vkd3d-proton/x86_64", + filename: "d3d12core.dll", dest_filename: None, }, - DllDeploy { - source_subpath: "lib/dxmt_m12/x86_64-windows", - filename: "d3d11.dll", - dest_filename: None, - }, - DllDeploy { - source_subpath: "lib/dxmt_m12/x86_64-windows", - filename: "dxgi.dll", - dest_filename: None, - }, - DllDeploy { - source_subpath: "lib/dxmt_m12/x86_64-windows", - filename: "dxgi_dxmt.dll", - dest_filename: None, - }, - DllDeploy { - source_subpath: "lib/dxmt_m12/x86_64-windows", - filename: "d3d10core.dll", - dest_filename: None, - }, - DllDeploy { - source_subpath: "lib/dxmt_m12/x86_64-windows", - filename: "winemetal.dll", - dest_filename: None, - }, - DllDeploy { - source_subpath: "lib/dxmt_m12/x86_64-windows", - filename: "nvapi64.dll", - dest_filename: None, - }, - DllDeploy { - source_subpath: "lib/dxmt_m12/x86_64-windows", - filename: "nvngx.dll", - dest_filename: None, - }, - ], - env_vars: vec![ - EnvVar { key: "DXMT_METALFX_SPATIAL_SWAPCHAIN", value: "1" }, - EnvVar { key: "DXMT_METALFX_SPATIAL", value: "1" }, - EnvVar { key: "DXMT_METALFX_TEMPORAL", value: "1" }, - EnvVar { key: "DXMT_ASYNC_PIPELINE_COMPILE", value: "1" }, - EnvVar { key: "DXMT_D3D12_UE_SM6_COMPAT", value: "1" }, - EnvVar { key: "DXMT_D3D12_PSO_WORKERS", value: "6" }, - EnvVar { key: "DXMT_CONFIG", value: DXMT_M12_SAFE_CONFIG }, + DllDeploy { source_subpath: "lib/dxvk/x86_64", filename: "dxgi.dll", dest_filename: None }, ], - launch_args: vec!["-windowed", "-ResX=1280", "-ResY=720", "-ForceRes"], + env_vars: vec![], + launch_args: vec![], alternatives: vec![ PipelineId::M11, PipelineId::M10, @@ -165,35 +121,27 @@ pub fn pipelines() -> &'static Vec { experimental: false, requires_wine: true, wine_overrides: Some("winemetal,dxgi,d3d11,d3d10core=n,b;gameoverlayrenderer,gameoverlayrenderer64=d"), - dyld_paths: vec!["lib/wine/x86_64-unix", "lib/dxmt/x86_64-unix"], - winedllpath_dirs: vec!["lib/dxmt/x86_64-windows", "lib/metalsharp/x86_64-windows"], + dyld_paths: vec!["build-ec/dlls/ntdll", "build-ec/dxmt-v0.80/aarch64-unix"], + winedllpath_dirs: vec!["build-ec/dxmt-v0.80/aarch64-windows"], deploy_dlls: vec![ - DllDeploy { source_subpath: "lib/dxmt/x86_64-windows", filename: "d3d11.dll", dest_filename: None }, - DllDeploy { source_subpath: "lib/dxmt/x86_64-windows", filename: "dxgi.dll", dest_filename: None }, - DllDeploy { - source_subpath: "lib/dxmt/x86_64-windows", - filename: "dxgi_dxmt.dll", - dest_filename: None, - }, DllDeploy { - source_subpath: "lib/dxmt/x86_64-windows", - filename: "d3d10core.dll", + source_subpath: "build-ec/dxmt-v0.80/aarch64-windows", + filename: "d3d11.dll", dest_filename: None, }, DllDeploy { - source_subpath: "lib/dxmt/x86_64-windows", - filename: "winemetal.dll", + source_subpath: "build-ec/dxmt-v0.80/aarch64-windows", + filename: "dxgi.dll", dest_filename: None, }, DllDeploy { - source_subpath: "lib/dxmt/x86_64-windows", - filename: "nvapi64.dll", + source_subpath: "build-ec/dxmt-v0.80/aarch64-windows", + filename: "d3d10core.dll", dest_filename: None, }, - DllDeploy { source_subpath: "lib/dxmt/x86_64-windows", filename: "nvngx.dll", dest_filename: None }, DllDeploy { - source_subpath: "lib/metalsharp/x86_64-windows", - filename: "metalsharp_ntdll_hook.dll", + source_subpath: "build-ec/dxmt-v0.80/aarch64-windows", + filename: "winemetal.dll", dest_filename: None, }, ], @@ -222,23 +170,26 @@ pub fn pipelines() -> &'static Vec { experimental: false, requires_wine: true, wine_overrides: Some("d3d11,dxgi,winemetal=n,b;gameoverlayrenderer,gameoverlayrenderer64=d"), - dyld_paths: vec!["lib/wine/x86_64-unix", "lib/dxmt/i386-unix", "lib/wine"], - winedllpath_dirs: vec!["lib/dxmt/i386-windows", "lib/wine/i386-windows", "lib/wine/x86_64-windows"], + dyld_paths: vec!["build-ec/dlls/ntdll", "build-ec/dxmt-v0.80/aarch64-unix"], + winedllpath_dirs: vec!["build-ec/dxmt-v0.80/i386-windows"], deploy_dlls: vec![ - DllDeploy { source_subpath: "lib/dxmt/i386-windows", filename: "d3d11.dll", dest_filename: None }, - DllDeploy { source_subpath: "lib/dxmt/i386-windows", filename: "dxgi.dll", dest_filename: None }, DllDeploy { - source_subpath: "lib/dxmt/i386-windows", - filename: "dxgi_dxmt.dll", + source_subpath: "build-ec/dxmt-v0.80/i386-windows", + filename: "d3d11.dll", + dest_filename: None, + }, + DllDeploy { + source_subpath: "build-ec/dxmt-v0.80/i386-windows", + filename: "dxgi.dll", dest_filename: None, }, DllDeploy { - source_subpath: "lib/dxmt/i386-windows", + source_subpath: "build-ec/dxmt-v0.80/i386-windows", filename: "d3d10core.dll", dest_filename: None, }, DllDeploy { - source_subpath: "lib/dxmt/i386-windows", + source_subpath: "build-ec/dxmt-v0.80/i386-windows", filename: "winemetal.dll", dest_filename: None, }, @@ -272,45 +223,41 @@ pub fn pipelines() -> &'static Vec { wine_overrides: Some( "winemetal,d3d10,d3d10_1,dxgi,d3d11,d3d10core=n,b;gameoverlayrenderer,gameoverlayrenderer64=d", ), - dyld_paths: vec!["lib/wine/x86_64-unix", "lib/dxmt/x86_64-unix"], + dyld_paths: vec!["build-ec/dlls/ntdll", "build-ec/dxmt-v0.80/aarch64-unix"], winedllpath_dirs: vec![ - "lib/wine/x86_64-windows", - "lib/dxmt/x86_64-windows", - "lib/metalsharp/x86_64-windows", + "build-ec/dlls/d3d10/x86_64-windows", + "build-ec/dlls/d3d10_1/x86_64-windows", + "build-ec/dxmt-v0.80/aarch64-windows", ], deploy_dlls: vec![ - DllDeploy { source_subpath: "lib/wine/x86_64-windows", filename: "d3d10.dll", dest_filename: None }, DllDeploy { - source_subpath: "lib/wine/x86_64-windows", - filename: "d3d10_1.dll", + source_subpath: "build-ec/dlls/d3d10/x86_64-windows", + filename: "d3d10.dll", dest_filename: None, }, - DllDeploy { source_subpath: "lib/dxmt/x86_64-windows", filename: "d3d11.dll", dest_filename: None }, - DllDeploy { source_subpath: "lib/dxmt/x86_64-windows", filename: "dxgi.dll", dest_filename: None }, DllDeploy { - source_subpath: "lib/dxmt/x86_64-windows", - filename: "dxgi_dxmt.dll", + source_subpath: "build-ec/dlls/d3d10_1/x86_64-windows", + filename: "d3d10_1.dll", dest_filename: None, }, DllDeploy { - source_subpath: "lib/dxmt/x86_64-windows", - filename: "d3d10core.dll", + source_subpath: "build-ec/dxmt-v0.80/aarch64-windows", + filename: "d3d11.dll", dest_filename: None, }, DllDeploy { - source_subpath: "lib/dxmt/x86_64-windows", - filename: "winemetal.dll", + source_subpath: "build-ec/dxmt-v0.80/aarch64-windows", + filename: "dxgi.dll", dest_filename: None, }, DllDeploy { - source_subpath: "lib/dxmt/x86_64-windows", - filename: "nvapi64.dll", + source_subpath: "build-ec/dxmt-v0.80/aarch64-windows", + filename: "d3d10core.dll", dest_filename: None, }, - DllDeploy { source_subpath: "lib/dxmt/x86_64-windows", filename: "nvngx.dll", dest_filename: None }, DllDeploy { - source_subpath: "lib/metalsharp/x86_64-windows", - filename: "metalsharp_ntdll_hook.dll", + source_subpath: "build-ec/dxmt-v0.80/aarch64-windows", + filename: "winemetal.dll", dest_filename: None, }, ], @@ -340,25 +287,40 @@ pub fn pipelines() -> &'static Vec { wine_overrides: Some( "d3d10,d3d10_1,d3d10core,d3d11,dxgi,winemetal=n,b;gameoverlayrenderer,gameoverlayrenderer64=d", ), - dyld_paths: vec!["lib/wine/x86_64-unix", "lib/dxmt/i386-unix", "lib/wine"], - winedllpath_dirs: vec!["lib/wine/i386-windows", "lib/dxmt/i386-windows", "lib/wine/x86_64-windows"], + dyld_paths: vec!["build-ec/dlls/ntdll", "build-ec/dxmt-v0.80/aarch64-unix"], + winedllpath_dirs: vec![ + "build-ec/dlls/d3d10/i386-windows", + "build-ec/dlls/d3d10_1/i386-windows", + "build-ec/dxmt-v0.80/i386-windows", + ], deploy_dlls: vec![ - DllDeploy { source_subpath: "lib/wine/i386-windows", filename: "d3d10.dll", dest_filename: None }, - DllDeploy { source_subpath: "lib/wine/i386-windows", filename: "d3d10_1.dll", dest_filename: None }, - DllDeploy { source_subpath: "lib/dxmt/i386-windows", filename: "d3d11.dll", dest_filename: None }, - DllDeploy { source_subpath: "lib/dxmt/i386-windows", filename: "dxgi.dll", dest_filename: None }, DllDeploy { - source_subpath: "lib/dxmt/i386-windows", - filename: "dxgi_dxmt.dll", + source_subpath: "build-ec/dlls/d3d10/i386-windows", + filename: "d3d10.dll", + dest_filename: None, + }, + DllDeploy { + source_subpath: "build-ec/dlls/d3d10_1/i386-windows", + filename: "d3d10_1.dll", + dest_filename: None, + }, + DllDeploy { + source_subpath: "build-ec/dxmt-v0.80/i386-windows", + filename: "d3d11.dll", + dest_filename: None, + }, + DllDeploy { + source_subpath: "build-ec/dxmt-v0.80/i386-windows", + filename: "dxgi.dll", dest_filename: None, }, DllDeploy { - source_subpath: "lib/dxmt/i386-windows", + source_subpath: "build-ec/dxmt-v0.80/i386-windows", filename: "d3d10core.dll", dest_filename: None, }, DllDeploy { - source_subpath: "lib/dxmt/i386-windows", + source_subpath: "build-ec/dxmt-v0.80/i386-windows", filename: "winemetal.dll", dest_filename: None, }, @@ -390,25 +352,26 @@ pub fn pipelines() -> &'static Vec { experimental: false, requires_wine: true, wine_overrides: Some("d3d9=n,b;gameoverlayrenderer,gameoverlayrenderer64=d"), - dyld_paths: vec!["lib/wine/x86_64-unix", "lib/dxmt/x86_64-unix"], + dyld_paths: vec!["build-ec/dlls/ntdll", "build-ec/dlls/win32u"], winedllpath_dirs: vec![ - "lib/wine/x86_64-windows", - "lib/wine/i386-windows", - "lib/dxmt/x86_64-windows", - "lib/metalsharp/x86_64-windows", + "build-ec/dlls/d3d9/x86_64-windows", + "build-ec/dlls/d3d9/i386-windows", + "build-ec/dlls/dxgi/i386-windows", ], deploy_dlls: vec![ - DllDeploy { source_subpath: "lib/wine/x86_64-windows", filename: "d3d9.dll", dest_filename: None }, - DllDeploy { source_subpath: "lib/wine/i386-windows", filename: "d3d9.dll", dest_filename: None }, - DllDeploy { source_subpath: "lib/wine/i386-windows", filename: "dxgi.dll", dest_filename: None }, DllDeploy { - source_subpath: "lib/dxmt/x86_64-windows", - filename: "nvapi64.dll", + source_subpath: "build-ec/dlls/d3d9/x86_64-windows", + filename: "d3d9.dll", dest_filename: None, }, DllDeploy { - source_subpath: "lib/metalsharp/x86_64-windows", - filename: "metalsharp_ntdll_hook.dll", + source_subpath: "build-ec/dlls/d3d9/i386-windows", + filename: "d3d9.dll", + dest_filename: None, + }, + DllDeploy { + source_subpath: "build-ec/dlls/dxgi/i386-windows", + filename: "dxgi.dll", dest_filename: None, }, ], @@ -432,7 +395,7 @@ pub fn pipelines() -> &'static Vec { wine_overrides: Some( "d3d10,d3d11,d3d12,dxgi,nvapi64,nvngx-on-metalfx=n,b;gameoverlayrenderer,gameoverlayrenderer64=d", ), - dyld_paths: vec!["lib/wine/x86_64-unix"], + dyld_paths: vec!["build-ec/dlls/ntdll"], winedllpath_dirs: vec![], deploy_dlls: vec![], env_vars: vec![], @@ -468,7 +431,7 @@ pub fn pipelines() -> &'static Vec { experimental: false, requires_wine: true, wine_overrides: None, - dyld_paths: vec!["lib/wine/x86_64-unix"], + dyld_paths: vec!["build-ec/dlls/ntdll"], winedllpath_dirs: vec![], deploy_dlls: vec![], env_vars: vec![], @@ -536,7 +499,7 @@ pub fn pipelines() -> &'static Vec { experimental: false, requires_wine: true, wine_overrides: None, - dyld_paths: vec!["lib/wine/x86_64-unix"], + dyld_paths: vec!["build-ec/dlls/ntdll"], winedllpath_dirs: vec![], deploy_dlls: vec![], env_vars: vec![], @@ -562,7 +525,6 @@ impl PipelineId { | PipelineId::M10_32 | PipelineId::M11 | PipelineId::M11_32 - | PipelineId::M12 ) } @@ -612,7 +574,7 @@ impl PipelineId { match method.trim().to_ascii_lowercase().as_str() { "dxmt" => Some(PipelineId::Dxmt), "dxmt_metal" | "steam_d3dmetal_perf" | "steam_metalfx" => Some(PipelineId::M11), - "dxmt_metal12" => Some(PipelineId::M12), + "dxmt_metal12" | "vkd3d_proton" | "vkd3d-proton" => Some(PipelineId::M12), "d3d9_metal" => Some(PipelineId::M9), "wined3d_32" => Some(PipelineId::M32), "metalsharp_wine" => Some(PipelineId::WineBare), @@ -654,8 +616,8 @@ impl PipelineId { | PipelineId::M10 | PipelineId::M10_32 | PipelineId::M11 - | PipelineId::M11_32 - | PipelineId::M12 => "dxmt", + | PipelineId::M11_32 => "dxmt", + PipelineId::M12 => "vkd3d_proton", PipelineId::M13 => "gptk_d3dmetal", PipelineId::D3DMetal => "d3dmetal", PipelineId::M32 => "wined3d_32", @@ -687,58 +649,36 @@ mod tests { } #[test] - fn m12_is_primary_d3d12_dxmt_profile() { + fn m12_is_primary_d3d12_vkd3d_proton_profile() { let m12 = get_pipeline(PipelineId::M12); assert!(!m12.experimental); - assert_eq!(m12.backend, "dxmt"); - assert!(m12.launch_args.contains(&"-windowed")); + assert_eq!(m12.backend, "vkd3d-proton"); + assert!(m12.launch_args.is_empty(), "M12 must not force a global resolution or window mode"); assert!(m12.deploy_dlls.iter().any(|dll| dll.filename == "d3d12.dll")); assert_eq!(m12.shader_cache_subdir, Some("m12")); } #[test] - fn m12_is_stronger_than_other_dxmt_d3d_paths() { + fn m12_has_exclusive_vkd3d_proton_dxvk_moltenvk_ownership() { let m12 = get_pipeline(PipelineId::M12); - for required in ["lib/wine/x86_64-unix", "lib/dxmt_m12/x86_64-unix"] { + for required in ["build-ec/dlls/winevulkan", "build-ec/dlls/win32u", "build-ec/dlls/ntdll"] { assert!(m12.dyld_paths.contains(&required)); } - assert!(!m12.dyld_paths.contains(&"lib/dxmt/x86_64-unix")); - assert!(m12.winedllpath_dirs.contains(&"lib/dxmt_m12/x86_64-windows")); - assert!(!m12.winedllpath_dirs.contains(&"lib/dxmt/x86_64-windows")); - assert!(!m12.winedllpath_dirs.contains(&"lib/metalsharp/x86_64-windows")); + assert!(m12.dyld_paths.iter().all(|path| !path.contains("dxmt"))); + assert!(m12.winedllpath_dirs.contains(&"lib/vkd3d-proton/x86_64")); + assert!(m12.winedllpath_dirs.contains(&"lib/dxvk/x86_64")); + assert!(m12.winedllpath_dirs.iter().all(|path| !path.contains("dxmt"))); let m12_dlls: std::collections::HashSet<_> = m12.deploy_dlls.iter().map(|dll| (dll.source_subpath, dll.filename)).collect(); - for required in ["d3d12.dll", "d3d11.dll", "dxgi.dll", "dxgi_dxmt.dll", "d3d10core.dll", "winemetal.dll"] { - assert!( - m12_dlls.contains(&("lib/dxmt_m12/x86_64-windows", required)), - "M12 missing isolated DXMT DLL {}", - required - ); - } - assert!(!m12.deploy_dlls.iter().any(|dll| dll.source_subpath == "lib/dxmt/x86_64-windows")); - assert!(!m12.deploy_dlls.iter().any(|dll| dll.filename == "metalsharp_ntdll_hook.dll")); - - let m12_env: std::collections::HashSet<_> = m12.env_vars.iter().map(|env| env.key).collect(); - assert!(m12_env.contains("DXMT_ASYNC_PIPELINE_COMPILE")); - assert!(m12_env.contains("DXMT_D3D12_PSO_WORKERS")); - assert!(m12_env.contains("DXMT_METALFX_SPATIAL_SWAPCHAIN")); - assert!(m12_env.contains("DXMT_METALFX_SPATIAL")); - assert!(m12_env.contains("DXMT_METALFX_TEMPORAL")); - let m12_env_values: std::collections::HashMap<_, _> = - m12.env_vars.iter().map(|env| (env.key, env.value)).collect(); - assert_eq!(m12_env_values.get("DXMT_ASYNC_PIPELINE_COMPILE"), Some(&"1")); - assert_eq!(m12_env_values.get("DXMT_D3D12_PSO_WORKERS"), Some(&"6")); - assert_eq!(m12_env_values.get("DXMT_METALFX_SPATIAL_SWAPCHAIN"), Some(&"1")); - assert_eq!(m12_env_values.get("DXMT_METALFX_SPATIAL"), Some(&"1")); - assert_eq!(m12_env_values.get("DXMT_METALFX_TEMPORAL"), Some(&"1")); - assert_eq!(m12_env_values.get("DXMT_CONFIG"), Some(&DXMT_M12_SAFE_CONFIG)); + assert!(m12_dlls.contains(&("lib/vkd3d-proton/x86_64", "d3d12.dll"))); + assert!(m12_dlls.contains(&("lib/vkd3d-proton/x86_64", "d3d12core.dll"))); + assert!(m12_dlls.contains(&("lib/dxvk/x86_64", "dxgi.dll"))); + assert_eq!(m12_dlls.len(), 3); + assert!(m12.env_vars.iter().all(|env| !env.key.starts_with("DXMT"))); - assert_eq!( - m12.wine_overrides, - Some("winemetal,d3d12,dxgi,dxgi_dxmt,d3d11,d3d10core=n,b;gameoverlayrenderer,gameoverlayrenderer64=d") - ); + assert_eq!(m12.wine_overrides, Some("d3d12,d3d12core,dxgi=n,b;gameoverlayrenderer,gameoverlayrenderer64=d")); assert!(m12.alternatives.contains(&PipelineId::M11)); } @@ -802,11 +742,10 @@ mod tests { ); let m10_dlls: std::collections::HashSet<_> = m10.deploy_dlls.iter().map(|dll| dll.filename).collect(); - for required in - ["d3d10.dll", "d3d10_1.dll", "d3d11.dll", "dxgi.dll", "dxgi_dxmt.dll", "d3d10core.dll", "winemetal.dll"] - { + for required in ["d3d10.dll", "d3d10_1.dll", "d3d11.dll", "dxgi.dll", "d3d10core.dll", "winemetal.dll"] { assert!(m10_dlls.contains(required), "M10 missing {}", required); } + assert!(!m10_dlls.contains("dxgi_dxmt.dll")); assert!(!m10_dlls.contains("d3d12.dll")); let m10_env: std::collections::HashSet<_> = m10.env_vars.iter().map(|env| env.key).collect(); @@ -843,16 +782,17 @@ mod tests { // must carry the unix sidecar hint validated for 32-bit DXMT launches let env_keys: std::collections::HashSet<_> = node.env_vars.iter().map(|e| e.key).collect(); assert!(env_keys.contains("DXMT_WINEMETAL_UNIXLIB")); - // dyld path must include the i386-unix lane - assert!(node.dyld_paths.iter().any(|p| p.contains("i386-unix"))); + // The guest is i386, but its Unix bridge must remain native ARM64. + assert!(node.dyld_paths.iter().any(|p| p.contains("aarch64-unix"))); + assert!(node.dyld_paths.iter().all(|p| !p.contains("i386-unix"))); } // M11(32) ships the D3D11 handoff set; no d3d12 / no nvapi let m11_32_dlls: std::collections::HashSet<_> = m11_32.deploy_dlls.iter().map(|d| d.filename).collect(); - for required in ["d3d11.dll", "dxgi.dll", "dxgi_dxmt.dll", "d3d10core.dll", "winemetal.dll"] { + for required in ["d3d11.dll", "dxgi.dll", "d3d10core.dll", "winemetal.dll"] { assert!(m11_32_dlls.contains(required), "M11(32) missing {}", required); } - for forbidden in ["d3d12.dll", "nvapi64.dll", "nvngx.dll"] { + for forbidden in ["d3d12.dll", "dxgi_dxmt.dll", "nvapi64.dll", "nvngx.dll"] { assert!(!m11_32_dlls.contains(forbidden), "M11(32) must not ship {}", forbidden); } @@ -877,12 +817,11 @@ mod tests { let m9_dlls: std::collections::HashSet<_> = m9.deploy_dlls.iter().map(|dll| (dll.source_subpath, dll.filename)).collect(); - assert!(m9_dlls.contains(&("lib/wine/x86_64-windows", "d3d9.dll"))); - assert!(m9_dlls.contains(&("lib/wine/i386-windows", "d3d9.dll"))); - assert!(m9_dlls.contains(&("lib/wine/i386-windows", "dxgi.dll"))); - assert!(m9_dlls.contains(&("lib/dxmt/x86_64-windows", "nvapi64.dll"))); + assert!(m9_dlls.contains(&("build-ec/dlls/d3d9/x86_64-windows", "d3d9.dll"))); + assert!(m9_dlls.contains(&("build-ec/dlls/d3d9/i386-windows", "d3d9.dll"))); + assert!(m9_dlls.contains(&("build-ec/dlls/dxgi/i386-windows", "dxgi.dll"))); assert!(m9.deploy_dlls.iter().all(|dll| !dll.source_subpath.contains("dxvk"))); - assert!(m9.dyld_paths.contains(&"lib/dxmt/x86_64-unix")); + assert!(m9.dyld_paths.contains(&"build-ec/dlls/win32u")); let m9_env: std::collections::HashSet<_> = m9.env_vars.iter().map(|env| env.key).collect(); assert!(m9_env.contains("DXMT_ASYNC_PIPELINE_COMPILE")); @@ -949,10 +888,12 @@ mod tests { } #[test] - fn dxmt_family_serializes_to_canonical_launch_method() { - for pipeline in [PipelineId::Dxmt, PipelineId::M9, PipelineId::M10, PipelineId::M11, PipelineId::M12] { + fn graphics_families_serialize_to_their_canonical_launch_methods() { + for pipeline in [PipelineId::Dxmt, PipelineId::M9, PipelineId::M10, PipelineId::M11] { assert_eq!(pipeline.to_legacy_method(), "dxmt"); assert!(pipeline.is_dxmt_family()); } + assert_eq!(PipelineId::M12.to_legacy_method(), "vkd3d_proton"); + assert!(!PipelineId::M12.is_dxmt_family()); } } diff --git a/app/src-rust/src/mtsp/launcher.rs b/app/src-rust/src/mtsp/launcher.rs index c86241b59..9a72a4113 100644 --- a/app/src-rust/src/mtsp/launcher.rs +++ b/app/src-rust/src/mtsp/launcher.rs @@ -5,7 +5,7 @@ use std::io::{Read, Write}; use std::net::TcpStream; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use walkdir::WalkDir; const DEFAULT_BRIDGE_PORT: u16 = 18733; @@ -402,8 +402,8 @@ pub fn launch_with_pipeline( | PipelineId::M10 | PipelineId::M10_32 | PipelineId::M11 - | PipelineId::M11_32 - | PipelineId::M12 => launch_dxmt_metal(appid, node), + | PipelineId::M11_32 => launch_dxmt_metal(appid, node), + PipelineId::M12 => launch_vkd3d_proton_metal(appid, node), PipelineId::M13 | PipelineId::D3DMetal => launch_d3dmetal_gptk(appid, node), PipelineId::M32 => launch_wine_bare(appid, node), PipelineId::FnaArm64 => launch_fna_arm64(appid).map(|(pid, method, _)| (pid, method)), @@ -431,9 +431,14 @@ pub fn launch_steam_bottle_with_pipeline( | PipelineId::M10 | PipelineId::M10_32 | PipelineId::M11 - | PipelineId::M11_32 - | PipelineId::M12 => launch_dxmt_metal_with_context(appid, node, Some(prefix_path), extra_env, Some(&log_path)) - .map(|(pid, method)| (pid, method, log_path)), + | PipelineId::M11_32 => { + launch_dxmt_metal_with_context(appid, node, Some(prefix_path), extra_env, Some(&log_path)) + .map(|(pid, method)| (pid, method, log_path)) + }, + PipelineId::M12 => { + launch_vkd3d_proton_metal_with_context(appid, node, Some(prefix_path), extra_env, Some(&log_path)) + .map(|(pid, method)| (pid, method, log_path)) + }, PipelineId::M13 | PipelineId::D3DMetal => { launch_d3dmetal_gptk_with_context(appid, node, Some(prefix_path), extra_env, Some(&log_path)) .map(|(pid, method)| (pid, method, log_path)) @@ -533,9 +538,6 @@ pub fn prepare_steam_pipeline_env( prepare_start_protected_game_for_pipeline(appid, pipeline_id); let recipe = super::recipe::build_launch_recipe(appid, node)?; validate_recipe_runtime(&recipe)?; - if node.backend == "dxmt" { - repair_metalsharp_wine_wrapper_env_order()?; - } if let Some(game_dir) = recipe.game_dir.as_ref() { prepare_steam_api_for_game_dir(&home, game_dir, appid, pipeline_id); cleanup_legacy_eac_toggle_artifacts(game_dir); @@ -568,9 +570,10 @@ pub fn prepare_steam_pipeline_env( /// Phase 3: M12 artifact and launch verification (dry-run). /// -/// Proves the M12 route would load the intended DXMT/winemetal artifacts +/// Proves the M12 route would load the intended vkd3d-proton, DXVK DXGI, +/// Wine Vulkan, and MoltenVK artifacts /// WITHOUT launching Steam or the game. This runs through the same -/// environment builder (`steam_pipeline_env_pairs`) as `launch_dxmt_metal`, +/// environment builder (`steam_pipeline_env_pairs`) as the live launch path, /// so the env pairs and artifact sources reported here are exactly what a /// real M12 launch would use. Nothing is deployed or spawned. pub fn m12_verify_dry_run(appid: u32) -> serde_json::Value { @@ -636,26 +639,34 @@ pub fn pipeline_dry_run_for(home: &Path, appid: u32, requested: Option = Vec::new(); let unix_lib_dir = if pipeline == PipelineId::M12 { - let dir = ms_root.join("lib").join("dxmt_m12").join("x86_64-unix"); - for sidecar in ["winemetal.so", "libc++.1.dylib", "libc++abi.1.dylib", "libunwind.1.dylib"] { - let path = dir.join(sidecar); + let dir = ms_root.join("build-ec").join("dlls").join("winevulkan"); + for (name, path) in [ + ("winevulkan.so", dir.join("winevulkan.so")), + ("winevulkan.dll", dir.join("x86_64-windows").join("winevulkan.dll")), + ( + "vulkan-1.dll", + ms_root.join("build-ec").join("dlls").join("vulkan-1").join("x86_64-windows").join("vulkan-1.dll"), + ), + ("libMoltenVK.dylib", ms_root.join("build-ec").join("dlls").join("win32u").join("libMoltenVK.dylib")), + ("packaged-libMoltenVK.dylib", ms_root.join("lib").join("moltenvk").join("libMoltenVK.dylib")), + ] { let present = path.exists(); let sha = if present { crate::diagnostics::file_sha256(&path) } else { None }; unix_sidecars.push(serde_json::json!({ - "filename": sidecar, + "filename": name, "path": path.to_string_lossy(), "present": present, "sha256": sha, })); if !present { missing.push(serde_json::json!({ - "filename": sidecar, + "filename": name, "source_path": path.to_string_lossy(), - "category": "unix_sidecar", + "category": "vulkan_host_boundary", })); } } @@ -682,6 +693,8 @@ pub fn pipeline_dry_run_for(home: &Path, appid: u32, requested: Option &'static str { /// Resolves the runtime "deploy from" source path for a route DLL filename. /// /// Route DLLs deployed into a game folder are copies of canonical source DLLs -/// that live under the MetalSharp wine runtime (e.g. `lib/dxmt/i386-windows`, -/// `lib/dxmt/x86_64-windows`, `lib/dxmt_m12/x86_64-windows`, `lib/wine/...`). +/// that live under the MetalSharp wine runtime (the complete runtime's DXMT +/// and Wine build lanes plus `lib/vkd3d-proton` and `lib/dxvk`). /// These sources always retain the canonical copy, so a stale deployed copy /// that matches its source can be safely deleted on a route switch instead of /// being quarantined. Returns the first matching source path, or `None` if no @@ -887,12 +900,17 @@ fn runtime_source_for_dll(dll_path: &Path, ms_root: &Path) -> Option { // Known deploy-from source roots, derived from the PipelineNode deploy_dlls // source_subpath values across M9/M10/M10_32/M11/M11_32/M12. const SOURCE_ROOTS: &[&str] = &[ - "lib/dxmt/x86_64-windows", - "lib/dxmt/i386-windows", - "lib/dxmt_m12/x86_64-windows", - "lib/wine/x86_64-windows", - "lib/wine/i386-windows", - "lib/metalsharp/x86_64-windows", + "build-ec/dxmt-v0.80/aarch64-windows", + "build-ec/dxmt-v0.80/i386-windows", + "build-ec/dlls/d3d9/x86_64-windows", + "build-ec/dlls/d3d9/i386-windows", + "build-ec/dlls/d3d10/x86_64-windows", + "build-ec/dlls/d3d10/i386-windows", + "build-ec/dlls/d3d10_1/x86_64-windows", + "build-ec/dlls/d3d10_1/i386-windows", + "build-ec/dlls/dxgi/i386-windows", + "lib/vkd3d-proton/x86_64", + "lib/dxvk/x86_64", ]; for root in SOURCE_ROOTS { let candidate = ms_root.join(root).join(&filename); @@ -913,6 +931,7 @@ fn is_metalsharp_route_dll_conflict(path: &Path) -> bool { | "d3d10core.dll" | "d3d11.dll" | "d3d12.dll" + | "d3d12core.dll" | "dxgi.dll" | "dxgi_dxmt.dll" | "nvapi64.dll" @@ -1006,10 +1025,7 @@ fn deploy_prefix_route_dlls( if !deploy.source_present { continue; } - if !matches!( - deploy.filename.as_str(), - "d3d12.dll" | "dxgi.dll" | "dxgi_dxmt.dll" | "d3d11.dll" | "d3d10core.dll" | "winemetal.dll" - ) { + if !matches!(deploy.filename.as_str(), "d3d12.dll" | "d3d12core.dll" | "dxgi.dll") { continue; } std::fs::copy(&deploy.source_path, system32.join(&deploy.filename))?; @@ -1021,51 +1037,6 @@ fn wine_debug_value() -> String { std::env::var("METALSHARP_WINEDEBUG").unwrap_or_else(|_| "-all".to_string()) } -fn repair_metalsharp_wine_wrapper_env_order() -> Result<(), Box> { - let home = dirs::home_dir().ok_or("no home dir")?; - let wrapper = crate::platform::metalsharp_home_dir_for(&home) - .join("runtime") - .join("wine") - .join("bin") - .join("metalsharp-wine"); - if !wrapper.exists() { - return Ok(()); - } - - let script = std::fs::read_to_string(&wrapper)?; - let mut repaired = script.clone(); - let winedll_block = r#"if [ -n "${WINEDLLPATH:-}" ]; then - export WINEDLLPATH="$WINEDLLPATH:$MS_LIB/wine/x86_64-windows:$MS_LIB/wine/i386-windows" -else - export WINEDLLPATH="$MS_LIB/wine/x86_64-windows:$MS_LIB/wine/i386-windows" -fi -"#; - if let (Some(start), Some(end)) = - (repaired.find("export WINELOADER=\"$MS_WINE\"\n"), repaired.find("export WINEDEBUG=\"${WINEDEBUG:--all}\"")) - { - let replace_start = start + "export WINELOADER=\"$MS_WINE\"\n".len(); - repaired.replace_range(replace_start..end, winedll_block); - } - - let dyld_block = r#"if [ -n "${DYLD_FALLBACK_LIBRARY_PATH:-}" ]; then - export DYLD_FALLBACK_LIBRARY_PATH="$DYLD_FALLBACK_LIBRARY_PATH:$MS_LIB:$MS_LIB/wine/x86_64-unix" -else - export DYLD_FALLBACK_LIBRARY_PATH="$MS_LIB:$MS_LIB/wine/x86_64-unix" -fi -"#; - if let (Some(start), Some(end)) = - (repaired.find("export WINEDATADIR=\"$MS_ROOT/share\"\n"), repaired.find("export MS_FWD_COMPAT_GL_CTX=1")) - { - let replace_start = start + "export WINEDATADIR=\"$MS_ROOT/share\"\n".len(); - repaired.replace_range(replace_start..end, dyld_block); - } - - if repaired != script { - std::fs::write(&wrapper, repaired)?; - } - Ok(()) -} - pub fn launch_custom_with_pipeline( launch_id: u32, game_dir: &std::path::Path, @@ -1110,10 +1081,6 @@ pub fn launch_custom_with_options( if !wine.exists() { return Err("MetalSharp Wine not found — run setup first".into()); } - if node.backend == "dxmt" { - repair_metalsharp_wine_wrapper_env_order()?; - } - let mut recipe = super::recipe::build_custom_launch_recipe(launch_id, node, game_dir, Some(exe_path))?; recipe.launch_args.extend(launch_args.iter().cloned()); let quarantined = quarantine_route_conflicts_for_recipe(&recipe)?; @@ -1138,6 +1105,7 @@ pub fn launch_custom_with_options( .env("WINEPREFIX", &prefix_str) .env("WINEDEBUG", wine_debug_value()) .env("WINEDEBUGGER", "none"); + crate::launch::apply_wine_runtime_preferences(&mut cmd); apply_route_library_env(&mut cmd, &ms_root, &node.dyld_paths); apply_metalfx_home_env(&mut cmd, &home); @@ -1151,11 +1119,13 @@ pub fn launch_custom_with_options( } apply_cache_env(&mut cmd, node, cache_paths.as_ref(), &ms_root); if node.backend == "dxmt" { - cmd.env("DXMT_CONFIG_FILE", ms_root.join("etc").join("dxmt.conf").to_string_lossy().to_string()); + let config = ms_root.join("etc").join("dxmt.conf"); + if config.is_file() { + cmd.env("DXMT_CONFIG_FILE", config); + } cmd.env("DXMT_WINEMETAL_UNIXLIB", dxmt_winemetal_unixlib_path(&ms_root)); } cmd.env("MS_GRAPHICS_BACKEND", node.graphics_backend); - cmd.env("WINEMSYNC", "1"); for ev in &node.env_vars { cmd.env(ev.key, ev.value); } @@ -1274,12 +1244,7 @@ fn prepare_readiness_report(recipe: &super::recipe::LaunchRecipe, env: &[(String recipe .dlls .iter() - .filter(|dll| { - matches!( - dll.filename.as_str(), - "d3d12.dll" | "dxgi.dll" | "dxgi_dxmt.dll" | "d3d11.dll" | "d3d10core.dll" | "winemetal.dll" - ) - }) + .filter(|dll| matches!(dll.filename.as_str(), "d3d12.dll" | "d3d12core.dll" | "dxgi.dll")) .map(|dll| { let dest_path = prefix_system32.join(&dll.filename); let source_sha256 = @@ -1309,12 +1274,16 @@ fn prepare_readiness_report(recipe: &super::recipe::LaunchRecipe, env: &[(String let winedllpath = env_value("WINEDLLPATH").unwrap_or_default(); let dyld_library_path = env_value("DYLD_LIBRARY_PATH").unwrap_or_default(); let dyld_fallback_library_path = env_value("DYLD_FALLBACK_LIBRARY_PATH").unwrap_or_default(); - let winemetal_unixlib = env_value("DXMT_WINEMETAL_UNIXLIB").unwrap_or_default(); + let vkd3d_shader_cache = env_value("VKD3D_SHADER_CACHE_PATH").unwrap_or_default(); + let dxvk_state_cache = env_value("DXVK_STATE_CACHE_PATH").unwrap_or_default(); let m12_env_ok = recipe.pipeline != PipelineId::M12 - || (winedllpath.contains("dxmt_m12/x86_64-windows") - && (dyld_library_path.contains("dxmt_m12/x86_64-unix") - || dyld_fallback_library_path.contains("dxmt_m12/x86_64-unix")) - && winemetal_unixlib == "winemetal.so"); + || (winedllpath.contains("vkd3d-proton/x86_64") + && winedllpath.contains("dxvk/x86_64") + && (dyld_library_path.contains("build-ec/dlls/winevulkan") + || dyld_fallback_library_path.contains("build-ec/dlls/winevulkan")) + && !vkd3d_shader_cache.is_empty() + && !dxvk_state_cache.is_empty() + && env_value("DXMT_WINEMETAL_UNIXLIB").is_none()); let ok = runtime_assets_ok && dlls_ok && prefix_dlls_ok && m12_env_ok; serde_json::json!({ @@ -1331,9 +1300,12 @@ fn prepare_readiness_report(recipe: &super::recipe::LaunchRecipe, env: &[(String "DYLD_LIBRARY_PATH": dyld_library_path, "DYLD_FALLBACK_LIBRARY_PATH": dyld_fallback_library_path, "WINEDLLOVERRIDES": env_value("WINEDLLOVERRIDES"), - "DXMT_WINEMETAL_UNIXLIB": winemetal_unixlib, - "requires_dxmt_m12_windows": recipe.pipeline == PipelineId::M12, - "requires_dxmt_m12_unix": recipe.pipeline == PipelineId::M12, + "VKD3D_SHADER_CACHE_PATH": vkd3d_shader_cache, + "DXVK_STATE_CACHE_PATH": dxvk_state_cache, + "DXMT_WINEMETAL_UNIXLIB": env_value("DXMT_WINEMETAL_UNIXLIB"), + "requires_vkd3d_proton": recipe.pipeline == PipelineId::M12, + "requires_dxvk_dxgi": recipe.pipeline == PipelineId::M12, + "requires_moltenvk_host": recipe.pipeline == PipelineId::M12, }, }) } @@ -1460,8 +1432,8 @@ fn launch_d3dmetal_gptk_with_context( .env("WINESERVER", &gptk_wineserver) .env("WINELOADER", &gptk_wine64) .env("DYLD_FALLBACK_LIBRARY_PATH", &dyld) - .env("MS_GRAPHICS_BACKEND", node.graphics_backend) - .env("WINEMSYNC", "1"); + .env("MS_GRAPHICS_BACKEND", node.graphics_backend); + crate::launch::apply_wine_runtime_preferences(&mut cmd); apply_metalfx_home_env(&mut cmd, &home); if node.uses_winedllpath_routing() { @@ -1502,12 +1474,42 @@ fn launch_dxmt_metal(appid: u32, node: &PipelineNode) -> Result<(u32, &'static s launch_dxmt_metal_with_context(appid, node, None, &[], None) } +fn launch_vkd3d_proton_metal( + appid: u32, + node: &PipelineNode, +) -> Result<(u32, &'static str), Box> { + launch_vkd3d_proton_metal_with_context(appid, node, None, &[], None) +} + +fn launch_vkd3d_proton_metal_with_context( + appid: u32, + node: &PipelineNode, + prefix_override: Option<&Path>, + extra_env: &[(String, String)], + log_path: Option<&Path>, +) -> Result<(u32, &'static str), Box> { + debug_assert_eq!(node.id, PipelineId::M12); + debug_assert_eq!(node.backend, "vkd3d-proton"); + launch_wine_graphics_with_context(appid, node, prefix_override, extra_env, log_path) +} + fn launch_dxmt_metal_with_context( appid: u32, node: &PipelineNode, prefix_override: Option<&Path>, extra_env: &[(String, String)], log_path: Option<&Path>, +) -> Result<(u32, &'static str), Box> { + debug_assert!(node.id.is_dxmt_family()); + launch_wine_graphics_with_context(appid, node, prefix_override, extra_env, log_path) +} + +fn launch_wine_graphics_with_context( + appid: u32, + node: &PipelineNode, + prefix_override: Option<&Path>, + extra_env: &[(String, String)], + log_path: Option<&Path>, ) -> Result<(u32, &'static str), Box> { let home = dirs::home_dir().ok_or("no home dir")?; let ms_root = crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("wine"); @@ -1524,7 +1526,6 @@ fn launch_dxmt_metal_with_context( if !wine.exists() { return Err("MetalSharp Wine not found — run setup first".into()); } - repair_metalsharp_wine_wrapper_env_order()?; prepare_start_protected_game_for_pipeline(appid, node.id); let recipe = super::recipe::build_launch_recipe(appid, node)?; @@ -1550,20 +1551,14 @@ fn launch_dxmt_metal_with_context( deploy_d3d12_agility_sidecars(appid, node, game_dir)?; let cache_paths = build_cache_paths(&home, node, appid); - if node.id == PipelineId::M12 - && std::env::var("METALSHARP_M12_LIVE_MSC_SIDECAR") - .map(|value| !value.is_empty() && value != "0") - .unwrap_or(false) - { - spawn_metalshaderconverter_sidecar(appid, &home, cache_paths.as_ref()); - } - let dxmt_config_file = ms_root.join("etc").join("dxmt.conf").to_string_lossy().to_string(); + let dxmt_config_file = ms_root.join("etc").join("dxmt.conf"); let mut cmd = Command::new(&wine); cmd.current_dir(exe_dir) .env("WINEPREFIX", &prefix_str) .env("WINEDEBUG", wine_debug_value()) .env("WINEDEBUGGER", "none"); + crate::launch::apply_wine_runtime_preferences(&mut cmd); apply_route_library_env(&mut cmd, &ms_root, &node.dyld_paths); apply_metalfx_home_env(&mut cmd, &home); @@ -1578,12 +1573,13 @@ fn launch_dxmt_metal_with_context( apply_cache_env(&mut cmd, node, cache_paths.as_ref(), &ms_root); if node.backend == "dxmt" { - cmd.env("DXMT_CONFIG_FILE", &dxmt_config_file); + if dxmt_config_file.is_file() { + cmd.env("DXMT_CONFIG_FILE", &dxmt_config_file); + } cmd.env("DXMT_WINEMETAL_UNIXLIB", dxmt_winemetal_unixlib_path(&ms_root)); } cmd.env("MS_GRAPHICS_BACKEND", node.graphics_backend); - cmd.env("WINEMSYNC", "1"); for ev in &node.env_vars { cmd.env(ev.key, ev.value); @@ -1661,6 +1657,7 @@ fn launch_wine_bare_with_context( .env("WINEPREFIX", &prefix_str) .env("WINEDEBUG", wine_debug_value()) .env("WINEDEBUGGER", "none"); + crate::launch::apply_wine_runtime_preferences(&mut cmd); apply_route_library_env(&mut cmd, &ms_root, &node.dyld_paths); apply_metalfx_home_env(&mut cmd, &home); @@ -1671,7 +1668,6 @@ fn launch_wine_bare_with_context( let cache_paths = build_cache_paths(&home, node, appid); apply_cache_env(&mut cmd, node, cache_paths.as_ref(), &ms_root); cmd.env("MS_GRAPHICS_BACKEND", node.graphics_backend); - cmd.env("WINEMSYNC", "1"); for ev in &node.env_vars { cmd.env(ev.key, ev.value); } @@ -2352,11 +2348,14 @@ fn steam_pipeline_env_pairs(home: &PathBuf, node: &PipelineNode, appid: u32) -> env.push(("WINEDLLPATH".to_string(), build_winedllpath(&ms_root, &node.winedllpath_dirs))); } if node.backend == "dxmt" { - env.push(("DXMT_CONFIG_FILE".to_string(), ms_root.join("etc").join("dxmt.conf").to_string_lossy().to_string())); + let config = ms_root.join("etc").join("dxmt.conf"); + if config.is_file() { + env.push(("DXMT_CONFIG_FILE".to_string(), config.to_string_lossy().to_string())); + } env.push(("DXMT_WINEMETAL_UNIXLIB".to_string(), dxmt_winemetal_unixlib_path(&ms_root))); } env.push(("MS_GRAPHICS_BACKEND".to_string(), node.graphics_backend.to_string())); - env.push(("WINEMSYNC".to_string(), "1".to_string())); + env.extend(crate::launch::wine_runtime_preference_env_pairs()); env.extend(cache_env_pairs(node, cache_paths.as_ref(), &ms_root)); env.extend(node.env_vars.iter().map(|ev| (ev.key.to_string(), ev.value.to_string()))); env.extend(app_compat_env_pairs(appid, node.id)); @@ -2380,11 +2379,16 @@ fn is_reserved_route_env_key(pipeline_id: PipelineId, key: &str) -> bool { | "WINEDLLPATH" | "DYLD_LIBRARY_PATH" | "DYLD_FALLBACK_LIBRARY_PATH" - | "DXMT_WINEMETAL_UNIXLIB" - | "DXMT_CONFIG_FILE" + | "VKD3D_SHADER_CACHE_PATH" + | "VKD3D_LOG_FILE" + | "VKD3D_DEBUG" + | "DXVK_STATE_CACHE_PATH" + | "DXVK_LOG_PATH" + | "DXVK_LOG_LEVEL" + | "VK_ICD_FILENAMES" | "MS_GRAPHICS_BACKEND" | "WINEMSYNC" - | "DXMT_LOG_PATH" + | "METALSHARP_CONTROLLER_MODE" ) } @@ -2407,66 +2411,15 @@ fn app_compat_env_pairs_with_logs( ]; } - if appid == 1962700 && pipeline_id == PipelineId::M12 { - let mut env = vec![ - ("DXMT_D3D12_ENABLE_GEOMETRY_MESH".to_string(), "1".to_string()), - ("DXMT_D3D12_FORCE_SWAPCHAIN_BLIT".to_string(), "1".to_string()), - ("DXMT_D3D12_AUTOPRESENT_SWAPCHAIN".to_string(), "1".to_string()), - ("DXMT_D3D12_LIVE_PRESENT".to_string(), "1".to_string()), - ("DXMT_D3D12_REASSERT_WINDOW_HANDOFF".to_string(), "1".to_string()), - ("DXMT_D3D12_DISABLE_RUNTIME_MSC".to_string(), "1".to_string()), - ("DXMT_D3D12_FORCE_COLOR_WRITE_STATE".to_string(), "1".to_string()), - ("DXMT_METALFX_SPATIAL_SWAPCHAIN".to_string(), "0".to_string()), - ("DXMT_METALFX_SPATIAL".to_string(), "0".to_string()), - ("DXMT_METALFX_TEMPORAL".to_string(), "0".to_string()), - ("DXMT_CONFIG".to_string(), "d3d11.preferredMaxFrameRate=60".to_string()), - ]; + if pipeline_id == PipelineId::M12 { + let mut env = Vec::new(); if graphics_runtime_logs { env.extend([ - ("DXMT_DXGI_TRACE".to_string(), "1".to_string()), - ("DXMT_WINEMETAL_DEBUG".to_string(), "1".to_string()), - ("DXMT_D3D12_TRACE".to_string(), "1".to_string()), - ("DXMT_D3D12_TRACE_COMPONENTS".to_string(), "Device,Queue,SwapChain,Presenter,PSO".to_string()), - ("DXMT_D3D12_TRACE_MAX_MB".to_string(), "16".to_string()), - ("DXMT_D3D12_TIMING_MIN_MS".to_string(), "0".to_string()), - ("DXMT_D3D12_PRESENT_LOG_INTERVAL".to_string(), "120".to_string()), - ("DXMT_DUMP_MSL".to_string(), "1".to_string()), + ("VKD3D_DEBUG".to_string(), "warn".to_string()), + ("DXVK_LOG_LEVEL".to_string(), "warn".to_string()), + ("MVK_CONFIG_LOG_LEVEL".to_string(), "2".to_string()), ]); } - if std::env::var("METALSHARP_M12_DIAGNOSTIC_CAPTURE") - .map(|value| !value.is_empty() && value != "0") - .unwrap_or(false) - { - env.push(("DXMT_D3D12_SWAPCHAIN_READBACK".to_string(), "1".to_string())); - env.push(("DXMT_D3D12_SWAPCHAIN_READBACK_INTERVAL".to_string(), "30".to_string())); - env.push(("DXMT_D3D12_FINAL_RENDER_SNAPSHOT".to_string(), "1".to_string())); - env.push(("DXMT_D3D12_LIVE_PRESENT".to_string(), "0".to_string())); - env.push(("DXMT_D3D12_PRESENT_LOG_INTERVAL".to_string(), "30".to_string())); - } - if std::env::var("METALSHARP_M12_FORCE_SWAPCHAIN_COLOR") - .map(|value| !value.is_empty() && value != "0") - .unwrap_or(false) - { - env.push(("DXMT_D3D12_FORCE_SWAPCHAIN_COLOR".to_string(), "1".to_string())); - } - if std::env::var("METALSHARP_M12_FORCE_COLOR_WRITE_STATE") - .map(|value| !value.is_empty() && value != "0") - .unwrap_or(false) - { - env.push(("DXMT_D3D12_FORCE_COLOR_WRITE_STATE".to_string(), "1".to_string())); - } - if std::env::var("METALSHARP_M12_FORCE_DIAGNOSTIC_FRAGMENT") - .map(|value| !value.is_empty() && value != "0") - .unwrap_or(false) - { - env.push(("DXMT_D3D12_FORCE_DIAGNOSTIC_FRAGMENT".to_string(), "1".to_string())); - } - if std::env::var("METALSHARP_M12_FORCE_DIAGNOSTIC_FULLSCREEN") - .map(|value| !value.is_empty() && value != "0") - .unwrap_or(false) - { - env.push(("DXMT_D3D12_FORCE_DIAGNOSTIC_FULLSCREEN".to_string(), "1".to_string())); - } return env; } Vec::new() @@ -2540,6 +2493,14 @@ fn cache_env_pairs_with_logs( env.push(("VK_ICD_FILENAMES".to_string(), moltenvk_icd.to_string_lossy().to_string())); } }, + "vkd3d-proton" => { + env.push(("VKD3D_SHADER_CACHE_PATH".to_string(), shader_dir.clone())); + env.push(("DXVK_STATE_CACHE_PATH".to_string(), shader_dir)); + if graphics_runtime_logs { + env.push(("VKD3D_LOG_FILE".to_string(), format!("{}vkd3d-proton.log", log_dir))); + env.push(("DXVK_LOG_PATH".to_string(), cache.log.clone())); + } + }, "wine32" | "wine" | "wine-steam" => { env.push(("DXMT_SHADER_CACHE_PATH".to_string(), shader_dir.clone())); env.push(("DXVK_STATE_CACHE_PATH".to_string(), shader_dir)); @@ -4841,176 +4802,6 @@ pub fn deploy_steam_appid(game_dir: &Path, appid: u32) { } } -fn spawn_metalshaderconverter_sidecar(appid: u32, home: &Path, cache_paths: Option<&CachePaths>) { - let tool_candidates = [ - PathBuf::from("/usr/local/bin/metal-shaderconverter"), - PathBuf::from("/opt/homebrew/bin/metal-shaderconverter"), - PathBuf::from("/opt/metal-shaderconverter/bin/metal-shaderconverter"), - ]; - let Some(tool_path) = tool_candidates.into_iter().find(|path| path.exists()) else { - return; - }; - - let log_dir = cache_paths - .map(|cache| PathBuf::from(&cache.pipeline)) - .unwrap_or_else(|| crate::platform::metalsharp_home_dir_for(&home).join("logs")); - let _ = std::fs::create_dir_all(&log_dir); - let log_path = log_dir.join(format!("d3d12-metalshaderconverter-{}.log", appid)); - let cache_dir = cache_paths - .map(|cache| PathBuf::from(&cache.shader)) - .unwrap_or_else(|| PathBuf::from("/tmp/dxmt_shader_cache")); - if let Ok(entries) = std::fs::read_dir(&cache_dir) { - for entry in entries.flatten() { - let path = entry.path(); - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if name.ends_with(".msc.fail") || name.ends_with(".msc.lock") { - let _ = std::fs::remove_file(path); - } - } - } - - std::thread::spawn(move || { - let deadline = Instant::now() + Duration::from_secs(180); - let launch_start = SystemTime::now(); - let max_active_compiles = 4usize; - - while Instant::now() < deadline { - if let Ok(entries) = std::fs::read_dir(&cache_dir) { - let mut pending = Vec::new(); - let mut active_locks = 0usize; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) == Some("lock") - && path.file_name().and_then(|name| name.to_str()).map(|name| name.ends_with(".msc.lock")) - == Some(true) - { - active_locks += 1; - } - if path.extension().and_then(|ext| ext.to_str()) != Some("dxbc") { - continue; - } - let Ok(modified) = entry.metadata().and_then(|meta| meta.modified()) else { - continue; - }; - if modified + Duration::from_secs(2) < launch_start { - continue; - } - pending.push((modified, path)); - } - - pending.sort_by_key(|entry| std::cmp::Reverse(entry.0)); - - for (_modified, path) in pending { - if active_locks >= max_active_compiles { - break; - } - let metallib_path = path.with_extension("metallib"); - let reflection_path = path.with_extension("json"); - let vertex_layout_path = path.with_extension("vertex-layout.json"); - let is_geometry_mesh_shader = path - .file_name() - .and_then(|name| name.to_str()) - .map(|name| name.ends_with(".geom.gsmesh.dxbc")) - .unwrap_or(false); - let use_gs_ts_emulation = vertex_layout_path.exists() && !is_geometry_mesh_shader; - let stage_in_path = path.with_extension("stageIn.metallib"); - let fail_path = path.with_extension("msc.fail"); - if metallib_path.exists() - && reflection_path.exists() - && (!use_gs_ts_emulation || stage_in_path.exists()) - { - continue; - } - if fail_path.exists() { - continue; - } - - let lock_path = path.with_extension("msc.lock"); - let lock_file = OpenOptions::new().write(true).create_new(true).open(&lock_path); - let Ok(_lock_guard) = lock_file else { - continue; - }; - active_locks += 1; - - let tool_path = tool_path.clone(); - let log_path = log_path.clone(); - std::thread::spawn(move || { - let mut log = OpenOptions::new().create(true).append(true).open(&log_path).ok(); - if let Some(log) = log.as_mut() { - let _ = writeln!(log, "compile_start dxbc={}", path.display()); - } - - let mut command = Command::new(&tool_path); - command - .arg("-o") - .arg(&metallib_path) - .arg(&path) - .arg(format!("--output-reflection-file={}", reflection_path.display())) - .arg("--deployment-os=macOS") - .arg("--minimum-os-build-version=15.0.0"); - if use_gs_ts_emulation { - command - .arg("--enable-gs-ts-emulation") - .arg("--vertex-stage-in") - .arg(format!("--vertex-input-layout-file={}", vertex_layout_path.display())); - } - let output = command.output(); - - match output { - Ok(result) => { - let stdout_text = String::from_utf8_lossy(&result.stdout); - let stderr_text = String::from_utf8_lossy(&result.stderr); - if result.status.success() { - let _ = std::fs::remove_file(&fail_path); - } else { - let failure_summary = if stderr_text.trim().is_empty() { - format!("metal-shaderconverter failed with {}", result.status) - } else { - stderr_text.trim().to_string() - }; - let _ = std::fs::write(&fail_path, failure_summary); - } - if let Some(log) = log.as_mut() { - let _ = writeln!( - log, - "compile_end dxbc={} status={} metallib={} reflection={} gs_ts_emulation={}", - path.display(), - result.status, - metallib_path.exists(), - reflection_path.exists(), - use_gs_ts_emulation - ); - if !stdout_text.is_empty() { - let _ = log.write_all(stdout_text.as_bytes()); - } - if !stderr_text.is_empty() { - let _ = log.write_all(stderr_text.as_bytes()); - } - } - }, - Err(err) => { - let _ = std::fs::write( - &fail_path, - format!("metal-shaderconverter invocation failed: {}", err), - ); - if let Some(log) = log.as_mut() { - let _ = writeln!(log, "compile_error dxbc={} err={}", path.display(), err); - } - }, - } - - let _ = std::fs::remove_file(&lock_path); - }); - } - } - - std::thread::sleep(Duration::from_millis(100)); - } - }); -} - #[cfg(test)] mod tests { use super::*; @@ -5049,32 +4840,17 @@ mod tests { } #[test] - fn m12_pipeline_deploy_list_includes_d3d12_and_uses_isolated_dxmt_m12_surface() { - // Phase 3 contract: M12 must deploy d3d12.dll (plus dxgi/d3d11/ - // d3d10core/winemetal) from the isolated lib/dxmt_m12 surface. + fn m12_pipeline_deploy_list_uses_vkd3d_proton_with_dxvk_dxgi() { let node = get_pipeline(PipelineId::M12); - let filenames: Vec<&str> = node.deploy_dlls.iter().map(|d| d.filename).collect(); - let required = [ - "d3d12.dll", - "d3d11.dll", - "dxgi.dll", - "dxgi_dxmt.dll", - "d3d10core.dll", - "winemetal.dll", - "nvapi64.dll", - "nvngx.dll", - ]; - assert_eq!(filenames.len(), required.len(), "M12 deploy list must be the Elden-proven 8 DLL set"); - for required in required { - assert!(filenames.contains(&required), "M12 deploy list must include {} (got {:?})", required, filenames); - } - for deploy in &node.deploy_dlls { - assert_eq!( - deploy.source_subpath, "lib/dxmt_m12/x86_64-windows", - "M12 DLL {} must come from the isolated PR230 dxmt_m12 runtime surface", - deploy.filename - ); - } + let route: Vec<(&str, &str)> = node.deploy_dlls.iter().map(|d| (d.source_subpath, d.filename)).collect(); + assert_eq!( + route, + vec![ + ("lib/vkd3d-proton/x86_64", "d3d12.dll"), + ("lib/vkd3d-proton/x86_64", "d3d12core.dll"), + ("lib/dxvk/x86_64", "dxgi.dll"), + ] + ); } #[test] @@ -5153,28 +4929,31 @@ mod tests { } #[test] - fn m12_quarantines_stale_m11_route_dlls_before_deploying_isolated_dxmt_m12() { + fn m12_quarantines_stale_m11_route_dlls_before_deploying_vkd3d_proton() { let root = test_dir("m12-route-quarantine-switchback"); let game_dir = root.join("game"); let exe_dir = game_dir.join("Binaries").join("Win64"); - let source_dir = root.join("runtime").join("wine").join("lib").join("dxmt_m12").join("x86_64-windows"); + let source_dir = root.join("runtime").join("wine").join("lib").join("vkd3d-proton").join("x86_64"); + let dxvk_dir = root.join("runtime").join("wine").join("lib").join("dxvk").join("x86_64"); std::fs::create_dir_all(&exe_dir).unwrap(); std::fs::create_dir_all(&source_dir).unwrap(); + std::fs::create_dir_all(&dxvk_dir).unwrap(); std::fs::write(exe_dir.join("Game.exe"), b"exe").unwrap(); std::fs::write(exe_dir.join("d3d11.dll"), b"stale m11 d3d11").unwrap(); std::fs::write(exe_dir.join("dxgi.dll"), b"stale m11 dxgi").unwrap(); std::fs::write(exe_dir.join("winemetal.dll"), b"stale m11 winemetal").unwrap(); - let route_dlls = ["d3d12.dll", "d3d11.dll", "dxgi.dll", "dxgi_dxmt.dll", "winemetal.dll"]; + let route_dlls = ["d3d12.dll", "d3d12core.dll", "dxgi.dll"]; for dll in route_dlls { - std::fs::write(source_dir.join(dll), format!("isolated m12 {dll}")).unwrap(); + let dir = if dll == "dxgi.dll" { &dxvk_dir } else { &source_dir }; + std::fs::write(dir.join(dll), format!("vkd3d m12 {dll}")).unwrap(); } let recipe = recipe::LaunchRecipe { appid: 42, pipeline: PipelineId::M12, pipeline_name: "M12".into(), - backend: "dxmt".into(), + backend: "vkd3d-proton".into(), game_dir: Some(game_dir.clone()), exe_path: Some(exe_dir.join("Game.exe")), exe_name: Some("Game.exe".into()), @@ -5183,9 +4962,13 @@ mod tests { dlls: route_dlls .iter() .map(|dll| recipe::RecipeDll { - source_subpath: "lib/dxmt_m12/x86_64-windows".into(), + source_subpath: if *dll == "dxgi.dll" { + "lib/dxvk/x86_64".into() + } else { + "lib/vkd3d-proton/x86_64".into() + }, filename: (*dll).into(), - source_path: source_dir.join(dll), + source_path: if *dll == "dxgi.dll" { dxvk_dir.join(dll) } else { source_dir.join(dll) }, dest_path: exe_dir.join(dll), source_present: true, }) @@ -5196,13 +4979,13 @@ mod tests { let moved = quarantine_route_conflicts_for_recipe(&recipe).expect("quarantine stale M11 routes"); assert_eq!(moved, 3, "stale d3d11/dxgi/winemetal should be quarantined before M12 deploy"); - deploy_recipe_dlls(&recipe).expect("deploy M12 isolated DXMT route"); + deploy_recipe_dlls(&recipe).expect("deploy M12 vkd3d-proton route"); for dll in route_dlls { assert_eq!( std::fs::read_to_string(exe_dir.join(dll)).unwrap(), - format!("isolated m12 {dll}"), - "M12 switch-back must deploy {dll} from dxmt_m12" + format!("vkd3d m12 {dll}"), + "M12 switch-back must deploy {dll} from the Vulkan route" ); } let marker = game_dir.join(".metalsharp").join("route-quarantine").join("latest-manifest.json"); @@ -5259,8 +5042,8 @@ mod tests { } #[test] - fn m12_dry_run_verifies_unix_sidecars_and_marks_missing_artifacts() { - // Phase 3: the M12 dry-run must enumerate the x86_64-unix sidecars and + fn m12_dry_run_verifies_vulkan_host_boundary_and_marks_missing_artifacts() { + // M12 dry-run must enumerate the Wine Vulkan/MoltenVK host boundary and // report missing required artifacts as a structured failure (not a // silent ok=true). With an empty temp home, all artifacts are absent. let home = std::env::temp_dir().join("ms-m12-dryrun-empty"); @@ -5269,7 +5052,7 @@ mod tests { let dry = pipeline_dry_run_for(&home, 2379780, Some(PipelineId::M12)); - // Unix sidecars must be enumerated for the M12 lane. + // Host-boundary artifacts must be enumerated for the M12 lane. let sidecar_names: Vec = dry .get("unix_sidecars") .and_then(|v| v.as_array()) @@ -5277,7 +5060,7 @@ mod tests { .iter() .map(|s| s.get("filename").unwrap().as_str().unwrap().to_string()) .collect(); - for required in ["winemetal.so", "libc++.1.dylib", "libc++abi.1.dylib", "libunwind.1.dylib"] { + for required in ["winevulkan.so", "winevulkan.dll", "vulkan-1.dll", "libMoltenVK.dylib"] { assert!( sidecar_names.contains(&required.to_string()), "M12 dry-run must verify {}: {:?}", @@ -5306,16 +5089,13 @@ mod tests { } #[test] - fn m12_pipeline_env_vars_set_winemetal_overrides_and_shader_cache() { - // Phase 3 contract: the M12 env builder must set the winemetal - // WINEDLLOVERRIDES, route the wine DLL path to dxmt_m12, and point the - // shader cache at the isolated m12 lane. + fn m12_pipeline_env_vars_set_vkd3d_dxgi_overrides_and_shader_cache() { let node = get_pipeline(PipelineId::M12); - assert!(node.wine_overrides.unwrap_or("").contains("winemetal")); - assert!( - node.winedllpath_dirs.iter().any(|d| d.starts_with("lib/dxmt_m12")), - "M12 winedllpath must route to dxmt_m12" - ); + let overrides = node.wine_overrides.unwrap_or(""); + assert!(overrides.contains("d3d12,d3d12core,dxgi=n,b")); + assert!(!overrides.contains("winemetal")); + assert!(node.winedllpath_dirs.contains(&"lib/vkd3d-proton/x86_64")); + assert!(node.winedllpath_dirs.contains(&"lib/dxvk/x86_64")); assert_eq!(node.shader_cache_subdir, Some("m12"), "M12 shader cache must be isolated under m12"); } @@ -5344,7 +5124,7 @@ mod tests { } #[test] - fn m12_dxmt_log_path_uses_shared_logs_folder_when_developer_logs_enabled() { + fn m12_vkd3d_log_path_uses_shared_logs_folder_when_developer_logs_enabled() { let home = test_dir("m12-log-path"); let node = get_pipeline(PipelineId::M12); let cache = build_cache_paths(&home, node, 1583230).expect("m12 cache paths"); @@ -5355,17 +5135,17 @@ mod tests { &crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("wine"), true, ); - let dxmt_log_path = - env.iter().find(|(key, _)| key == "DXMT_LOG_PATH").map(|(_, value)| value.as_str()).unwrap_or_default(); + let vkd3d_log_path = + env.iter().find(|(key, _)| key == "VKD3D_LOG_FILE").map(|(_, value)| value.as_str()).unwrap_or_default(); - assert!(dxmt_log_path.contains("/logs/m12-pipeline/1583230/")); - assert!(!dxmt_log_path.contains("/pipeline-cache/")); + assert!(vkd3d_log_path.contains("/logs/m12-pipeline/1583230/")); + assert!(!vkd3d_log_path.contains("/pipeline-cache/")); let _ = std::fs::remove_dir_all(home); } #[test] fn dxmt_family_env_uses_seventy_percent_upscale_and_cache_paths() { - for pipeline_id in [PipelineId::M9, PipelineId::M10, PipelineId::M11, PipelineId::M12] { + for pipeline_id in [PipelineId::M9, PipelineId::M10, PipelineId::M11] { let home = test_dir(&format!("dxmt-env-{:?}", pipeline_id)); let node = get_pipeline(pipeline_id); @@ -5438,11 +5218,16 @@ mod tests { "WINEDLLPATH", "DYLD_LIBRARY_PATH", "DYLD_FALLBACK_LIBRARY_PATH", - "DXMT_WINEMETAL_UNIXLIB", - "DXMT_CONFIG_FILE", + "VKD3D_SHADER_CACHE_PATH", + "VKD3D_LOG_FILE", + "VKD3D_DEBUG", + "DXVK_STATE_CACHE_PATH", + "DXVK_LOG_PATH", + "DXVK_LOG_LEVEL", + "VK_ICD_FILENAMES", "MS_GRAPHICS_BACKEND", "WINEMSYNC", - "DXMT_LOG_PATH", + "METALSHARP_CONTROLLER_MODE", ] { assert!(is_reserved_route_env_key(PipelineId::M12, key), "{} must be reserved for M12", key); assert!(!is_reserved_route_env_key(PipelineId::M11, key), "{} should remain overridable outside M12", key); @@ -5459,20 +5244,16 @@ mod tests { let keys: std::collections::HashSet<_> = env.iter().map(|(key, _)| key.as_str()).collect(); assert!(keys.contains("WINEDLLOVERRIDES")); - assert!(keys.contains("DXMT_CONFIG_FILE")); assert!(keys.contains("SteamAppId")); assert!(keys.contains("SteamGameId")); assert!(keys.contains("SteamOverlayGameId")); - assert!(keys.contains("DXMT_SHADER_CACHE_PATH")); - assert!(keys.contains("DXMT_PIPELINE_CACHE_PATH")); - assert!(!keys.contains("DXMT_LOG_PATH")); + assert!(keys.contains("VKD3D_SHADER_CACHE_PATH")); + assert!(keys.contains("DXVK_STATE_CACHE_PATH")); + assert!(keys.contains("WINEMSYNC")); + assert!(keys.contains("METALSHARP_CONTROLLER_MODE")); + assert!(!keys.contains("VKD3D_LOG_FILE")); assert!(keys.contains("METALSHARP_CACHE_SUMMARY")); - assert!(keys.contains("DXMT_CONFIG")); - assert_eq!(env_value(&env, "DXMT_D3D12_UE_SM6_COMPAT"), Some("1")); - let unixlib = - env.iter().find(|(key, _)| key == "DXMT_WINEMETAL_UNIXLIB").map(|(_, value)| value.as_str()).unwrap(); - assert_eq!(unixlib, "winemetal.so"); - assert!(keys.contains("DXMT_ASYNC_PIPELINE_COMPILE")); + assert!(!env.iter().any(|(key, _)| key.starts_with("DXMT"))); assert_eq!(env.iter().find(|(key, _)| key == "SteamAppId").map(|(_, value)| value.as_str()), Some("1583230")); assert_eq!(env.iter().find(|(key, _)| key == "SteamGameId").map(|(_, value)| value.as_str()), Some("1583230")); assert_eq!( @@ -5481,7 +5262,9 @@ mod tests { ); let overrides = env.iter().find(|(key, _)| key == "WINEDLLOVERRIDES").map(|(_, value)| value).unwrap(); assert!(overrides.contains("d3d12")); - assert!(overrides.contains("dxgi_dxmt")); + assert!(overrides.contains("d3d12core")); + assert!(overrides.contains("dxgi")); + assert!(!overrides.contains("dxgi_dxmt")); assert!(overrides.contains("gameoverlayrenderer,gameoverlayrenderer64=d")); assert!(!keys.contains("CX_ROOT")); assert!(!keys.contains("WINESERVER")); @@ -5489,23 +5272,23 @@ mod tests { assert!(!keys.contains("WINEDATADIR")); assert!(!keys.contains("MS_ROOT")); let winedllpath = env_value(&env, "WINEDLLPATH").unwrap_or_default(); - assert!(winedllpath.contains("dxmt_m12/x86_64-windows")); + assert!(winedllpath.contains("vkd3d-proton/x86_64")); + assert!(winedllpath.contains("dxvk/x86_64")); assert!(!winedllpath.contains("lib/metalsharp")); - assert!(env_value(&env, "DYLD_LIBRARY_PATH").unwrap_or_default().contains("dxmt_m12/x86_64-unix")); - assert!(env_value(&env, "DYLD_FALLBACK_LIBRARY_PATH").unwrap_or_default().contains("dxmt_m12/x86_64-unix")); + assert!(env_value(&env, "DYLD_LIBRARY_PATH").unwrap_or_default().contains("build-ec/dlls/winevulkan")); + assert!(env_value(&env, "DYLD_FALLBACK_LIBRARY_PATH").unwrap_or_default().contains("build-ec/dlls/winevulkan")); let _ = std::fs::remove_dir_all(home); } #[test] - fn subnautica2_m12_trace_env_is_opt_in_only() { + fn m12_vulkan_trace_env_is_opt_in_only() { let default_env = app_compat_env_pairs_with_logs(1962700, PipelineId::M12, false); - assert!(default_env.iter().any(|(key, _)| key == "DXMT_D3D12_ENABLE_GEOMETRY_MESH")); - assert!(!default_env.iter().any(|(key, _)| key == "DXMT_D3D12_TRACE")); - assert!(!default_env.iter().any(|(key, _)| key == "DXMT_DUMP_MSL")); + assert!(default_env.is_empty()); let log_env = app_compat_env_pairs_with_logs(1962700, PipelineId::M12, true); - assert!(log_env.iter().any(|(key, value)| key == "DXMT_D3D12_TRACE" && value == "1")); - assert!(log_env.iter().any(|(key, value)| key == "DXMT_DUMP_MSL" && value == "1")); + assert!(log_env.iter().any(|(key, value)| key == "VKD3D_DEBUG" && value == "warn")); + assert!(log_env.iter().any(|(key, value)| key == "DXVK_LOG_LEVEL" && value == "warn")); + assert!(log_env.iter().all(|(key, _)| !key.starts_with("DXMT"))); } #[test] @@ -5521,8 +5304,9 @@ mod tests { let m11 = get_pipeline(PipelineId::M11); assert_eq!(crate::platform::runtime_wine_binary(&ms_root), bin.join("metalsharp-wine")); assert_eq!(m12.requires_wine, m11.requires_wine); - assert_eq!(m12.backend, m11.backend); - assert!(m12.winedllpath_dirs.iter().any(|path| path.contains("dxmt_m12"))); + assert_eq!(m12.backend, "vkd3d-proton"); + assert_ne!(m12.backend, m11.backend); + assert!(m12.winedllpath_dirs.iter().any(|path| path.contains("vkd3d-proton"))); let _ = std::fs::remove_dir_all(home); } @@ -5744,10 +5528,12 @@ mod tests { assert!(winedllpath.is_some(), "{:?} missing WINEDLLPATH in env pairs", pipeline_id); let path = winedllpath.unwrap().1.as_str(); assert!(!path.is_empty(), "{:?} WINEDLLPATH is empty", pipeline_id); - assert!(path.contains("x86_64-windows"), "{:?} WINEDLLPATH missing windows dir: {}", pipeline_id, path); if pipeline_id == PipelineId::M12 { - assert!(path.contains("dxmt_m12"), "M12 should use isolated M12 DLL path: {}", path); + assert!(path.contains("vkd3d-proton/x86_64"), "M12 should use vkd3d-proton: {}", path); + assert!(path.contains("dxvk/x86_64"), "M12 should use DXVK DXGI: {}", path); + assert!(!path.contains("dxmt"), "M12 must not use DXMT: {}", path); } else { + assert!(path.contains("windows"), "{:?} WINEDLLPATH missing Windows DLL dir: {}", pipeline_id, path); assert!(!path.contains("dxmt_m12"), "{:?} should not use isolated M12 DLL path: {}", pipeline_id, path); } let _ = std::fs::remove_dir_all(&home); @@ -5766,18 +5552,25 @@ mod tests { #[test] fn m12_prefix_route_dlls_stage_into_system32() { let root = test_dir("m12-prefix-route"); - let source_dir = root.join("runtime").join("wine").join("lib").join("dxmt_m12").join("x86_64-windows"); + let vkd3d_dir = root.join("runtime").join("wine").join("lib").join("vkd3d-proton").join("x86_64"); + let dxvk_dir = root.join("runtime").join("wine").join("lib").join("dxvk").join("x86_64"); let prefix = root.join("prefix-steam"); - std::fs::create_dir_all(&source_dir).expect("create source dir"); + std::fs::create_dir_all(&vkd3d_dir).expect("create vkd3d dir"); + std::fs::create_dir_all(&dxvk_dir).expect("create dxvk dir"); - let dlls = ["d3d12.dll", "dxgi.dll", "dxgi_dxmt.dll", "d3d11.dll", "d3d10core.dll", "winemetal.dll"]; + let dlls = ["d3d12.dll", "d3d12core.dll", "dxgi.dll"]; let recipe_dlls: Vec = dlls .iter() .map(|dll| { + let source_dir = if *dll == "dxgi.dll" { &dxvk_dir } else { &vkd3d_dir }; let source_path = source_dir.join(dll); std::fs::write(&source_path, format!("m12-{dll}")).expect("write route dll"); recipe::RecipeDll { - source_subpath: "lib/dxmt_m12/x86_64-windows".to_string(), + source_subpath: if *dll == "dxgi.dll" { + "lib/dxvk/x86_64".to_string() + } else { + "lib/vkd3d-proton/x86_64".to_string() + }, filename: (*dll).to_string(), source_path, dest_path: root.join("game").join(dll), diff --git a/app/src-rust/src/mtsp/recipe.rs b/app/src-rust/src/mtsp/recipe.rs index 08daf6b2f..a82519a9d 100644 --- a/app/src-rust/src/mtsp/recipe.rs +++ b/app/src-rust/src/mtsp/recipe.rs @@ -390,12 +390,19 @@ pub fn selected_deploy_dlls_for_pipeline( node: &PipelineNode, ms_root: &Path, ) -> Vec { - let d3d9_subpath = if node.id == PipelineId::M9 { m9_d3d9_source_subpath(game_dir, exe_path) } else { "" }; + let m9_uses_i386 = node.id == PipelineId::M9 && m9_d3d9_source_subpath(game_dir, exe_path).contains("i386-windows"); let target_dirs = deploy_target_dirs_for_pipeline(game_dir, exe_path, node); node.deploy_dlls .iter() - .filter(|dll| node.id != PipelineId::M9 || dll.source_subpath == d3d9_subpath) + .filter(|dll| { + node.id != PipelineId::M9 + || if m9_uses_i386 { + dll.source_subpath.contains("i386-windows") + } else { + dll.source_subpath == "build-ec/dlls/d3d9/x86_64-windows" + } + }) .flat_map(|dll| { let source_path = ms_root.join(dll.source_subpath).join(dll.filename); let dest_name = dll.dest_filename.unwrap_or(dll.filename); @@ -856,19 +863,19 @@ fn m9_d3d9_source_subpath(game_dir: &Path, exe_path: Option<&Path>) -> &'static Some(path) => path.to_path_buf(), None => match resolve_game_exe(0, game_dir) { Ok(path) => path, - Err(_) => return "lib/wine/x86_64-windows", + Err(_) => return "build-ec/dlls/d3d9/x86_64-windows", }, }; if let Ok(data) = std::fs::read(&exe) { if let Some(pe) = super::pe::parse_pe_imports(&data) { if !pe.is_64_bit { - return "lib/wine/i386-windows"; + return "build-ec/dlls/d3d9/i386-windows"; } } } - "lib/wine/x86_64-windows" + "build-ec/dlls/d3d9/x86_64-windows" } fn runtime_file_present(path: &Path) -> bool { @@ -899,30 +906,42 @@ fn runtime_assets_for_node(node: &PipelineNode, ms_root: &Path) -> Vec { - let unix_dir = ms_root.join("lib").join("dxmt_m12").join("x86_64-unix"); - for filename in ["winemetal.so", "libc++.1.dylib", "libc++abi.1.dylib", "libunwind.1.dylib"] { - let path = unix_dir.join(filename); + for (name, path) in [ + ( + "build-ec/dlls/winevulkan/winevulkan.so", + ms_root.join("build-ec").join("dlls").join("winevulkan").join("winevulkan.so"), + ), + ( + "build-ec/dlls/winevulkan/x86_64-windows/winevulkan.dll", + ms_root + .join("build-ec") + .join("dlls") + .join("winevulkan") + .join("x86_64-windows") + .join("winevulkan.dll"), + ), + ( + "build-ec/dlls/vulkan-1/x86_64-windows/vulkan-1.dll", + ms_root.join("build-ec").join("dlls").join("vulkan-1").join("x86_64-windows").join("vulkan-1.dll"), + ), + ( + "build-ec/dlls/win32u/libMoltenVK.dylib", + ms_root.join("build-ec").join("dlls").join("win32u").join("libMoltenVK.dylib"), + ), + ("lib/moltenvk/libMoltenVK.dylib", ms_root.join("lib").join("moltenvk").join("libMoltenVK.dylib")), + ] { assets.push(RuntimeAsset { - name: format!("lib/dxmt_m12/x86_64-unix/{filename}"), + name: name.to_string(), present: runtime_file_present(&path), path, required: true, }); } }, - PipelineId::M11 => { - let path = ms_root.join("lib").join("dxmt").join("x86_64-unix").join("winemetal.so"); - assets.push(RuntimeAsset { - name: "lib/dxmt/x86_64-unix/winemetal.so".into(), - present: runtime_file_present(&path), - path, - required: true, - }); - }, - PipelineId::M11_32 | PipelineId::M10_32 => { - let path = ms_root.join("lib").join("dxmt").join("i386-unix").join("winemetal.so"); + PipelineId::M10 | PipelineId::M11 | PipelineId::M10_32 | PipelineId::M11_32 => { + let path = ms_root.join("build-ec").join("dxmt-v0.80").join("aarch64-unix").join("winemetal.so"); assets.push(RuntimeAsset { - name: "lib/dxmt/i386-unix/winemetal.so".into(), + name: "build-ec/dxmt-v0.80/aarch64-unix/winemetal.so".into(), present: runtime_file_present(&path), path, required: true, @@ -993,37 +1012,38 @@ mod tests { use super::*; #[test] - fn m11_validates_legacy_winemetal_so_without_changing_m12_sidecars() { + fn m11_validates_winemetal_while_m12_validates_vulkan_host_boundary() { let ms_root = test_dir("runtime-assets-winemetal-lanes"); let m11 = super::super::engine::get_pipeline(PipelineId::M11); let m12 = super::super::engine::get_pipeline(PipelineId::M12); let m11_assets = runtime_assets_for_node(m11, &ms_root); - assert!(m11_assets.iter().any(|asset| asset.name == "lib/dxmt/x86_64-unix/winemetal.so")); + assert!(m11_assets.iter().any(|asset| asset.name == "build-ec/dxmt-v0.80/aarch64-unix/winemetal.so")); assert!(!m11_assets.iter().any(|asset| asset.name.starts_with("lib/dxmt_m12/x86_64-unix/"))); let m12_assets = runtime_assets_for_node(m12, &ms_root); - assert!(m12_assets.iter().any(|asset| asset.name == "lib/dxmt_m12/x86_64-unix/winemetal.so")); - assert!(m12_assets.iter().any(|asset| asset.name == "lib/dxmt_m12/x86_64-unix/libc++.1.dylib")); + assert!(m12_assets.iter().any(|asset| asset.name == "build-ec/dlls/winevulkan/winevulkan.so")); + assert!(m12_assets.iter().any(|asset| asset.name == "build-ec/dlls/win32u/libMoltenVK.dylib")); + assert!(m12_assets.iter().any(|asset| asset.name == "lib/moltenvk/libMoltenVK.dylib")); assert!(!m12_assets.iter().any(|asset| asset.name == "lib/dxmt/x86_64-unix/winemetal.so")); + assert!(!m12_assets.iter().any(|asset| asset.name.contains("dxmt_m12"))); let _ = std::fs::remove_dir_all(ms_root); } #[test] - fn m11_32_and_m10_32_validate_i386_winemetal_so_sidecar() { + fn m11_32_and_m10_32_validate_native_arm64_winemetal_sidecar() { let ms_root = test_dir("runtime-assets-i386-winemetal-lanes"); for id in [PipelineId::M11_32, PipelineId::M10_32] { let node = super::super::engine::get_pipeline(id); let assets = runtime_assets_for_node(node, &ms_root); - // doctor must surface the i386 unix sidecar as a required runtime asset + // The PE lane is i386, but the Unix bridge stays native ARM64. assert!( - assets.iter().any(|asset| asset.name == "lib/dxmt/i386-unix/winemetal.so"), - "{:?} missing i386-unix/winemetal.so runtime asset", + assets.iter().any(|asset| asset.name == "build-ec/dxmt-v0.80/aarch64-unix/winemetal.so"), + "{:?} missing native ARM64 winemetal.so runtime asset", id ); - // and must not pull the x86_64-only M11/M12 sidecars - assert!(!assets.iter().any(|asset| asset.name == "lib/dxmt/x86_64-unix/winemetal.so")); + assert!(!assets.iter().any(|asset| asset.name.contains("i386-unix"))); assert!(!assets.iter().any(|asset| asset.name.starts_with("lib/dxmt_m12/"))); } let _ = std::fs::remove_dir_all(ms_root); @@ -1561,7 +1581,10 @@ mod tests { let sources: std::collections::HashSet<_> = selected.iter().map(|dll| dll.source_subpath.as_str()).collect(); let filenames: std::collections::HashSet<_> = selected.iter().map(|dll| dll.filename.as_str()).collect(); - assert_eq!(sources, std::collections::HashSet::from(["lib/wine/i386-windows"])); + assert_eq!( + sources, + std::collections::HashSet::from(["build-ec/dlls/d3d9/i386-windows", "build-ec/dlls/dxgi/i386-windows",]) + ); assert_eq!(filenames, std::collections::HashSet::from(["d3d9.dll", "dxgi.dll"])); assert_eq!(selected.len(), 2); let _ = std::fs::remove_dir_all(game_dir); @@ -1584,7 +1607,7 @@ mod tests { ); let sources: std::collections::HashSet<_> = selected.iter().map(|dll| dll.source_subpath.as_str()).collect(); - assert_eq!(sources, std::collections::HashSet::from(["lib/wine/x86_64-windows"])); + assert_eq!(sources, std::collections::HashSet::from(["build-ec/dlls/d3d9/x86_64-windows"])); assert_eq!(selected.len(), 1); let _ = std::fs::remove_dir_all(game_dir); let _ = std::fs::remove_dir_all(runtime); diff --git a/app/src-rust/src/mtsp/rules.rs b/app/src-rust/src/mtsp/rules.rs index bd534c643..8a2508e6c 100644 --- a/app/src-rust/src/mtsp/rules.rs +++ b/app/src-rust/src/mtsp/rules.rs @@ -554,7 +554,7 @@ mod tests { assert!(!shipped_rules.contains("anticheat"), "shipped rules must not contain anti-cheat metadata"); let (_, recipes) = parse_rules_full(shipped_rules); - let m12_required = ["d3d12.dll", "d3d11.dll", "dxgi_dxmt.dll", "dxgi.dll", "winemetal.dll"]; + let m12_required = ["d3d12.dll", "d3d12core.dll", "dxgi.dll"]; let m11_required = ["d3d11.dll", "dxgi.dll", "winemetal.dll"]; let required_by_pipeline = [(PipelineId::M12, m12_required.as_slice()), (PipelineId::M11, m11_required.as_slice())]; diff --git a/app/src-rust/src/mtsp/shader_cache.rs b/app/src-rust/src/mtsp/shader_cache.rs index 80ff713af..a03708c10 100644 --- a/app/src-rust/src/mtsp/shader_cache.rs +++ b/app/src-rust/src/mtsp/shader_cache.rs @@ -45,7 +45,7 @@ fn find_preset(home: &PathBuf, cache_subdir: &str, appid: u32) -> Option Vec<&str> { match cache_subdir { "m9" | "m10" | "m11" => vec![cache_subdir, "dxmt-metal"], - "m12" => vec![cache_subdir, "dxmt-metal12"], + "m12" => vec![cache_subdir, "vkd3d-proton"], _ => vec![cache_subdir], } } @@ -171,7 +171,7 @@ fn merge_preset_into_user(preset_db: &PathBuf, user_db: &PathBuf) -> Option // Phase 4: Shader / PSO / cache diagnostics // ============================================================================ // -// The DXMT runtime is shipped prebuilt under lib/dxmt(-m12); vendor/dxmt is +// The DXMT runtime is shipped prebuilt under lib/dxmt; vendor/dxmt is // reference source and is NOT compiled by this repo's CMake build. These Rust // diagnostics therefore observe the runtime's on-disk products (the DXMT // SQLite shader/pipeline caches and any JSON PSO trace sidecars DXMT emits @@ -182,15 +182,15 @@ fn merge_preset_into_user(preset_db: &PathBuf, user_db: &PathBuf) -> Option // gives the cache doctor a real, testable introspection path today. /// The shader-cache family a pipeline shares. M9/M10/M11 share the legacy -/// `dxmt-metal` family; M12/M13 use the isolated `dxmt-metal12` family. +/// `dxmt-metal` family; M12 uses vkd3d-proton and M13 uses D3DMetal. pub fn shader_cache_family(pipeline: crate::mtsp::engine::PipelineId) -> &'static [&'static str] { use crate::mtsp::engine::PipelineId; match pipeline { PipelineId::M9 => &["m9", "dxmt-metal"], PipelineId::M10 => &["m10", "dxmt-metal"], PipelineId::M11 => &["m11", "dxmt-metal"], - PipelineId::M12 => &["m12", "dxmt-metal12"], - PipelineId::M13 => &["m13", "dxmt-metal12"], + PipelineId::M12 => &["m12", "vkd3d-proton"], + PipelineId::M13 => &["m13", "d3dmetal"], _ => &[], } } @@ -506,9 +506,9 @@ mod tests { use crate::mtsp::engine::PipelineId; // M9/M10/M11 share the legacy dxmt-metal family. assert_eq!(shader_cache_family(PipelineId::M11), &["m11", "dxmt-metal"]); - // M12/M13 use the isolated dxmt-metal12 family and must not mix. - assert_eq!(shader_cache_family(PipelineId::M12), &["m12", "dxmt-metal12"]); - assert_eq!(shader_cache_family(PipelineId::M13), &["m13", "dxmt-metal12"]); + // M12 and M13 use their actual backend families and must not mix. + assert_eq!(shader_cache_family(PipelineId::M12), &["m12", "vkd3d-proton"]); + assert_eq!(shader_cache_family(PipelineId::M13), &["m13", "d3dmetal"]); } #[test] diff --git a/app/src-rust/src/runtime_prefix.rs b/app/src-rust/src/runtime_prefix.rs new file mode 100644 index 000000000..e60a14813 --- /dev/null +++ b/app/src-rust/src/runtime_prefix.rs @@ -0,0 +1,538 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const XTAJIT64_SHA256: &str = "7b9f55ceabe971ffa1f514570bb54ed7b5640959e4440e7f8a013e9af13ab7e6"; +const XTAJIT_SHA256: &str = "7d2ac83d2c0935e04d033d609c42d8307294225dcb4cb16b88af849e95c694ab"; + +pub(crate) fn runtime_root() -> PathBuf { + crate::platform::metalsharp_home_dir().join("runtime") +} + +pub(crate) fn wine_root() -> PathBuf { + runtime_root().join("wine") +} + +pub(crate) fn apply_complete_runtime_env(command: &mut Command, prefix: &Path) { + let root = runtime_root(); + command + .env("WINEPREFIX", prefix) + .env("WINEBUILDDIR", root.join("wine/build-ec")) + .env("FEX_TSOENABLED", "0") + .env("FEX_VECTORTSOENABLED", "0") + .env("FEX_MEMCPYSETTSOENABLED", "0"); + crate::launch::apply_wine_runtime_preferences(command); + crate::platform::set_runtime_library_env(command, &root.join("wine")); +} + +fn copy_file_required(source: &Path, destination: &Path, label: &str) -> Result<(), String> { + if !source.is_file() { + return Err(format!("complete runtime is missing {label}: {}", source.display())); + } + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent).map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + std::fs::copy(source, destination) + .map_err(|error| format!("failed to stage {label} from {}: {error}", source.display()))?; + Ok(()) +} + +fn stage_pe_lane(build: &Path, lane_name: &str, destination: &Path) -> Result { + std::fs::create_dir_all(destination) + .map_err(|error| format!("failed to create {}: {error}", destination.display()))?; + let mut staged = 0usize; + + for (root_name, extension) in [("dlls", "dll"), ("programs", "exe")] { + let root = build.join(root_name); + let components = + std::fs::read_dir(&root).map_err(|error| format!("failed to enumerate {}: {error}", root.display()))?; + for component in components { + let component = component.map_err(|error| format!("failed to read Wine PE entry: {error}"))?; + let lane = component.path().join(lane_name); + if !lane.is_dir() { + continue; + } + let entries = + std::fs::read_dir(&lane).map_err(|error| format!("failed to enumerate {}: {error}", lane.display()))?; + for entry in entries { + let entry = entry.map_err(|error| format!("failed to read Wine PE lane entry: {error}"))?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case(extension)) + && should_stage_pe_entry(lane_name, &entry.file_name()) + { + std::fs::copy(&path, destination.join(entry.file_name())) + .map_err(|error| format!("failed to stage {}: {error}", path.display()))?; + staged += 1; + } + } + } + } + + if staged == 0 { + return Err(format!("complete runtime contains no {lane_name} Wine PE files to stage")); + } + Ok(staged) +} + +fn should_stage_pe_entry(lane_name: &str, name: &std::ffi::OsStr) -> bool { + let name = name.to_string_lossy(); + if lane_name != "aarch64-windows" { + return true; + } + + // The custom ARM64 host uses xtajit.dll for i386. Wine's generic + // wow64cpu.dll is a competing provider; if it is present while wineboot + // refreshes the prefix, the x86 provider registry value can be rewritten + // to wow64cpu.dll and every i386 process fails before reaching its entry + // point. Candidate binaries are retained in the source/runtime tree for + // development, but are never promoted into a user's System32 directory. + let lowercase = name.to_ascii_lowercase(); + !(name.eq_ignore_ascii_case("wow64cpu.dll") + || lowercase.starts_with("xtajit-") && lowercase.ends_with("-candidate.dll")) +} + +fn stage_i386_builtins(build: &Path, prefix: &Path) -> Result { + stage_pe_lane(build, "i386-windows", &prefix.join("drive_c/windows/syswow64")) +} + +fn stage_aarch64_builtins(build: &Path, prefix: &Path) -> Result { + stage_pe_lane(build, "aarch64-windows", &prefix.join("drive_c/windows/system32")) +} + +fn ensure_unix_root_dosdevice(prefix: &Path) -> Result<(), String> { + let dosdevices = prefix.join("dosdevices"); + std::fs::create_dir_all(&dosdevices) + .map_err(|error| format!("failed to create {}: {error}", dosdevices.display()))?; + let z_drive = dosdevices.join("z:"); + + if std::fs::read_link(&z_drive).ok().as_deref() == Some(Path::new("/")) { + return Ok(()); + } + + match std::fs::symlink_metadata(&z_drive) { + Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => std::fs::remove_file(&z_drive) + .map_err(|error| format!("failed to replace stale {}: {error}", z_drive.display()))?, + Ok(metadata) if metadata.is_dir() => { + return Err(format!( + "cannot create the required Wine Z: mapping because {} is a directory", + z_drive.display() + )); + }, + Ok(_) => std::fs::remove_file(&z_drive) + .map_err(|error| format!("failed to replace stale {}: {error}", z_drive.display()))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}, + Err(error) => return Err(format!("failed to inspect {}: {error}", z_drive.display())), + } + + std::os::unix::fs::symlink("/", &z_drive) + .map_err(|error| format!("failed to create required Wine Z: mapping at {}: {error}", z_drive.display())) +} + +fn run_runtime_provider_stage(prefix: &Path, verify_only: bool) -> Result<(), String> { + let root = runtime_root(); + let script = root.join("scripts/stage-runtime-providers.sh"); + let xtajit64 = root.join("providers/xtajit64-arm64ec-known-good.dll"); + let xtajit = root.join("providers/xtajit-arm64-known-good.dll"); + if !script.is_file() { + return Err(format!("runtime provider staging script is missing: {}", script.display())); + } + + let output = Command::new("/bin/bash") + .arg(&script) + .arg(if verify_only { "--verify-prefix" } else { "--prefix" }) + .arg(prefix) + .env("WINEBUILDDIR", root.join("wine/build-ec")) + .env("VKMT_XTAJIT64_SOURCE", &xtajit64) + .env("VKMT_XTAJIT_SOURCE", &xtajit) + .env("VKMT_XTAJIT64_SHA256", XTAJIT64_SHA256) + .env("VKMT_XTAJIT_SHA256", XTAJIT_SHA256) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| format!("failed to run runtime provider staging: {error}"))?; + if !output.status.success() { + return Err(format!( + "runtime provider {} failed: {}", + if verify_only { "verification" } else { "staging" }, + String::from_utf8_lossy(&output.stderr).lines().last().unwrap_or("unknown error") + )); + } + Ok(()) +} + +pub(crate) fn all_arch_ready(prefix: &Path) -> bool { + let required_files_ready = [ + "system.reg", + "user.reg", + "drive_c/windows/system32/kernel32.dll", + "drive_c/windows/system32/ntdll.dll", + "drive_c/windows/system32/wow64.dll", + "drive_c/windows/system32/wow64win.dll", + "drive_c/windows/system32/xtajit64.dll", + "drive_c/windows/system32/xtajit.dll", + "drive_c/windows/syswow64/kernel32.dll", + "drive_c/windows/syswow64/ntdll.dll", + ".vkmt/gstreamer-runtime.sha256", + ] + .iter() + .all(|relative| prefix.join(relative).is_file()); + required_files_ready + && std::fs::read_link(prefix.join("dosdevices/z:")).ok().as_deref() == Some(Path::new("/")) + && wow64_x86_provider_ready(prefix) + && !prefix.join("drive_c/windows/system32/wow64cpu.dll").exists() + && [ + "drive_c/windows/system32/kernel32.dll", + "drive_c/windows/system32/ntdll.dll", + "drive_c/windows/system32/wow64.dll", + "drive_c/windows/system32/wow64win.dll", + ] + .iter() + .all(|relative| pe_machine(&prefix.join(relative)) == Some(0xaa64)) + && ["drive_c/windows/syswow64/kernel32.dll", "drive_c/windows/syswow64/ntdll.dll"] + .iter() + .all(|relative| pe_machine(&prefix.join(relative)) == Some(0x014c)) +} + +fn wow64_x86_provider_ready(prefix: &Path) -> bool { + let Ok(registry) = std::fs::read_to_string(prefix.join("system.reg")) else { + return false; + }; + let section = "[Software\\\\Microsoft\\\\Wow64\\\\x86]"; + let Some(section_start) = registry.find(section) else { + return false; + }; + registry[section_start + section.len()..] + .lines() + .take_while(|line| !line.starts_with('[')) + .any(|line| line.trim().eq_ignore_ascii_case("@=\"xtajit.dll\"")) +} + +fn remove_conflicting_cpu_providers(prefix: &Path) -> Result<(), String> { + let system32 = prefix.join("drive_c/windows/system32"); + let entries = + std::fs::read_dir(&system32).map_err(|error| format!("failed to enumerate {}: {error}", system32.display()))?; + for entry in entries { + let entry = entry.map_err(|error| format!("failed to inspect System32 provider entry: {error}"))?; + let name = entry.file_name(); + if !should_stage_pe_entry("aarch64-windows", &name) { + let path = entry.path(); + std::fs::remove_file(&path) + .map_err(|error| format!("failed to remove conflicting CPU provider {}: {error}", path.display()))?; + } + } + Ok(()) +} + +fn configure_wow64_x86_provider(wine: &Path, prefix: &Path) -> Result<(), String> { + let reg = prefix.join("drive_c/windows/system32/reg.exe"); + if !reg.is_file() { + return Err(format!("ARM64 registry tool is missing after wineboot: {}", reg.display())); + } + let mut command = Command::new(wine); + command + .arg(®) + .args(["add", r"HKLM\Software\Microsoft\Wow64\x86", "/ve", "/t", "REG_SZ", "/d", "xtajit.dll", "/f"]) + .env("WINE_NO_EXPLORER", "1") + .env("WINEDEBUG", "-all") + .env("WINEDEBUGGER", "none") + .stdout(Stdio::null()) + .stderr(Stdio::null()); + apply_complete_runtime_env(&mut command, prefix); + let status = command.status().map_err(|error| format!("failed to select the i386 xtajit provider: {error}"))?; + if !status.success() { + return Err(format!("selecting the i386 xtajit provider failed with {status}")); + } + Ok(()) +} + +fn pe_machine(path: &Path) -> Option { + use std::io::{Read, Seek, SeekFrom}; + + let mut file = std::fs::File::open(path).ok()?; + let mut mz = [0u8; 64]; + file.read_exact(&mut mz).ok()?; + if &mz[..2] != b"MZ" { + return None; + } + let pe_offset = u32::from_le_bytes(mz[0x3c..0x40].try_into().ok()?) as u64; + file.seek(SeekFrom::Start(pe_offset)).ok()?; + let mut header = [0u8; 6]; + file.read_exact(&mut header).ok()?; + if &header[..4] != b"PE\0\0" { + return None; + } + Some(u16::from_le_bytes([header[4], header[5]])) +} + +pub(crate) fn prepare(prefix: &Path) -> Result<(), String> { + prepare_with_mode(prefix, WinebootMode::Initialize) +} + +pub(crate) fn update_existing(prefix: &Path) -> Result<(), String> { + if !prefix.join("drive_c").is_dir() { + return Err(format!("existing Wine prefix is missing drive_c: {}", prefix.display())); + } + prepare_with_mode(prefix, WinebootMode::Update) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WinebootMode { + Initialize, + Update, +} + +impl WinebootMode { + fn arguments(self) -> &'static [&'static str] { + match self { + Self::Initialize => &["--init"], + // wineboot's update-only path processes HKCU Run and would launch + // Steam (or any other user startup application) in the middle of + // migration. Combining init and update performs update_wineprefix + // while suppressing those user startup entries. + Self::Update => &["--init", "--update"], + } + } +} + +fn prepare_with_mode(prefix: &Path, mode: WinebootMode) -> Result<(), String> { + let root = runtime_root(); + let build = root.join("wine/build-ec"); + let wine = crate::platform::runtime_wine_binary(&root.join("wine")); + if !wine.is_file() { + return Err(format!("MetalSharp Wine not found: {}", wine.display())); + } + + let system32 = prefix.join("drive_c/windows/system32"); + std::fs::create_dir_all(&system32).map_err(|error| format!("failed to create {}: {error}", system32.display()))?; + std::fs::create_dir_all(prefix.join("drive_c/windows/syswow64")) + .map_err(|error| format!("failed to create prefix syswow64: {error}"))?; + + // Wine starts the PE entry point from an absolute Unix path. Its Unix-side + // startup conversion requires that path to be reachable through a DOS + // drive; without the canonical Z: mapping Wine 11.12 can produce a null NT + // image path before wineboot gets control. Migration restores any user's + // custom Z: mapping after this update has completed. + ensure_unix_root_dosdevice(prefix)?; + + copy_file_required( + &build.join("dlls/wow64/aarch64-windows/wow64.dll"), + &system32.join("wow64.dll"), + "ARM64 WoW64 provider", + )?; + copy_file_required( + &build.join("dlls/wow64win/aarch64-windows/wow64win.dll"), + &system32.join("wow64win.dll"), + "ARM64 WoW64 windowing provider", + )?; + stage_i386_builtins(&build, prefix)?; + if mode == WinebootMode::Update { + stage_aarch64_builtins(&build, prefix)?; + } + run_runtime_provider_stage(prefix, false)?; + + let wineboot = build.join("programs/wineboot/aarch64-windows/wineboot.exe"); + if !wineboot.is_file() { + return Err(format!("ARM64 wineboot is missing: {}", wineboot.display())); + } + let mut wineboot_command = Command::new(&wine); + wineboot_command + .arg(&wineboot) + .args(mode.arguments()) + .env("WINE_NO_EXPLORER", "1") + .env("WINEDEBUG", "-all") + .env("WINEDEBUGGER", "none") + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if mode == WinebootMode::Initialize { + wineboot_command.env("WINEBOOTSTRAPMODE", "1"); + } + apply_complete_runtime_env(&mut wineboot_command, prefix); + let status = + wineboot_command.status().map_err(|error| format!("failed to run all-architecture wineboot: {error}"))?; + if !status.success() { + return Err(format!("all-architecture wineboot failed with {status}")); + } + + remove_conflicting_cpu_providers(prefix)?; + configure_wow64_x86_provider(&wine, prefix)?; + + let wineserver = crate::platform::runtime_wineserver(&root.join("wine")); + let wait_status = Command::new(&wineserver) + .arg("-w") + .env("WINEPREFIX", prefix) + .status() + .map_err(|error| format!("failed to wait for prefix wineserver: {error}"))?; + if !wait_status.success() { + return Err(format!("wineserver -w failed after prefix initialization with {wait_status}")); + } + + run_runtime_provider_stage(prefix, false)?; + run_runtime_provider_stage(prefix, true)?; + if !all_arch_ready(prefix) { + return Err("prefix was initialized but the ARM64/ARM64EC/x86_64/i386 acceptance gate failed".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_arch_gate_requires_every_provider_lane() { + let prefix = std::env::temp_dir().join(format!( + "metalsharp-runtime-prefix-gate-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() + )); + for relative in [ + "system.reg", + "user.reg", + "drive_c/windows/system32/xtajit64.dll", + "drive_c/windows/system32/xtajit.dll", + ".vkmt/gstreamer-runtime.sha256", + ] { + let path = prefix.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"fixture").unwrap(); + } + for relative in [ + "drive_c/windows/system32/kernel32.dll", + "drive_c/windows/system32/ntdll.dll", + "drive_c/windows/system32/wow64.dll", + "drive_c/windows/system32/wow64win.dll", + ] { + write_pe_fixture(&prefix.join(relative), 0xaa64); + } + for relative in ["drive_c/windows/syswow64/kernel32.dll", "drive_c/windows/syswow64/ntdll.dll"] { + write_pe_fixture(&prefix.join(relative), 0x014c); + } + assert!(!all_arch_ready(&prefix), "a prefix without Wine's Unix-root mapping is not launch-ready"); + ensure_unix_root_dosdevice(&prefix).expect("create Wine Z mapping fixture"); + append_xtajit_registry_fixture(&prefix); + assert!(all_arch_ready(&prefix)); + + std::fs::write(prefix.join("drive_c/windows/system32/wow64cpu.dll"), b"competing provider").unwrap(); + assert!(!all_arch_ready(&prefix), "generic wow64cpu must not compete with the custom i386 provider"); + std::fs::remove_file(prefix.join("drive_c/windows/system32/wow64cpu.dll")).unwrap(); + + std::fs::write( + prefix.join("system.reg"), + "WINE REGISTRY Version 2\n\n[Software\\\\Microsoft\\\\Wow64\\\\x86] 1\n@=\"wow64cpu.dll\"\n", + ) + .unwrap(); + assert!(!all_arch_ready(&prefix), "the i386 registry contract must select xtajit.dll"); + append_xtajit_registry_fixture(&prefix); + assert!(all_arch_ready(&prefix)); + + std::fs::remove_file(prefix.join("drive_c/windows/system32/xtajit.dll")).unwrap(); + assert!(!all_arch_ready(&prefix)); + let _ = std::fs::remove_dir_all(prefix); + } + + fn write_pe_fixture(path: &Path, machine: u16) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut bytes = vec![0u8; 0x46]; + bytes[..2].copy_from_slice(b"MZ"); + bytes[0x3c..0x40].copy_from_slice(&(0x40u32).to_le_bytes()); + bytes[0x40..0x44].copy_from_slice(b"PE\0\0"); + bytes[0x44..0x46].copy_from_slice(&machine.to_le_bytes()); + std::fs::write(path, bytes).unwrap(); + } + + fn append_xtajit_registry_fixture(prefix: &Path) { + std::fs::write( + prefix.join("system.reg"), + "WINE REGISTRY Version 2\n\n[Software\\\\Microsoft\\\\Wow64\\\\x86] 1\n@=\"xtajit.dll\"\n", + ) + .unwrap(); + } + + #[test] + fn all_arch_gate_rejects_x86_64_system32_builtins() { + let prefix = std::env::temp_dir().join(format!("metalsharp-runtime-prefix-machine-{}", std::process::id())); + for relative in [ + "system.reg", + "user.reg", + "drive_c/windows/system32/xtajit64.dll", + "drive_c/windows/system32/xtajit.dll", + ".vkmt/gstreamer-runtime.sha256", + ] { + let path = prefix.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"fixture").unwrap(); + } + for relative in [ + "drive_c/windows/system32/kernel32.dll", + "drive_c/windows/system32/ntdll.dll", + "drive_c/windows/system32/wow64.dll", + "drive_c/windows/system32/wow64win.dll", + ] { + write_pe_fixture(&prefix.join(relative), 0xaa64); + } + for relative in ["drive_c/windows/syswow64/kernel32.dll", "drive_c/windows/syswow64/ntdll.dll"] { + write_pe_fixture(&prefix.join(relative), 0x014c); + } + ensure_unix_root_dosdevice(&prefix).expect("create Wine Z mapping fixture"); + append_xtajit_registry_fixture(&prefix); + write_pe_fixture(&prefix.join("drive_c/windows/system32/kernel32.dll"), 0x8664); + assert!(!all_arch_ready(&prefix)); + let _ = std::fs::remove_dir_all(prefix); + } + + #[test] + fn complete_runtime_environment_disables_every_tso_mode() { + let prefix = PathBuf::from("/tmp/metalsharp-gog-prefix-fixture"); + let mut command = Command::new("/usr/bin/true"); + apply_complete_runtime_env(&mut command, &prefix); + let env = command + .get_envs() + .filter_map(|(key, value)| { + value.map(|value| (key.to_string_lossy().to_string(), value.to_string_lossy().to_string())) + }) + .collect::>(); + assert_eq!(env.get("WINEPREFIX"), Some(&prefix.to_string_lossy().to_string())); + assert_eq!(env.get("FEX_TSOENABLED").map(String::as_str), Some("0")); + assert_eq!(env.get("FEX_VECTORTSOENABLED").map(String::as_str), Some("0")); + assert_eq!(env.get("FEX_MEMCPYSETTSOENABLED").map(String::as_str), Some("0")); + assert!(env.get("WINEBUILDDIR").is_some_and(|value| value.ends_with("runtime/wine/build-ec"))); + assert!(env.contains_key("WINEMSYNC")); + assert!(env.contains_key("METALSHARP_CONTROLLER_MODE")); + } + + #[test] + fn existing_prefix_updates_use_the_wineboot_update_contract() { + assert_eq!(WinebootMode::Initialize.arguments(), &["--init"]); + assert_eq!(WinebootMode::Update.arguments(), &["--init", "--update"]); + } + + #[test] + fn arm64_staging_excludes_competing_and_candidate_cpu_providers() { + assert!(!should_stage_pe_entry("aarch64-windows", std::ffi::OsStr::new("wow64cpu.dll"))); + assert!(!should_stage_pe_entry("aarch64-windows", std::ffi::OsStr::new("xtajit-exception-candidate.dll"))); + assert!(should_stage_pe_entry("aarch64-windows", std::ffi::OsStr::new("xtajit.dll"))); + assert!(should_stage_pe_entry("i386-windows", std::ffi::OsStr::new("wow64cpu.dll"))); + } + + #[test] + fn wineboot_prefix_always_has_a_unix_root_mapping() { + let prefix = std::env::temp_dir().join(format!( + "metalsharp-runtime-prefix-z-drive-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() + )); + let dosdevices = prefix.join("dosdevices"); + std::fs::create_dir_all(&dosdevices).expect("create dosdevices fixture"); + std::os::unix::fs::symlink("/Volumes/stale", dosdevices.join("z:")).expect("create stale Z fixture"); + + ensure_unix_root_dosdevice(&prefix).expect("repair Wine Z mapping"); + assert_eq!(std::fs::read_link(dosdevices.join("z:")).expect("read Wine Z mapping"), Path::new("/")); + + let _ = std::fs::remove_dir_all(prefix); + } +} diff --git a/app/src-rust/src/setup.rs b/app/src-rust/src/setup.rs index 751940060..27fe4aef0 100644 --- a/app/src-rust/src/setup.rs +++ b/app/src-rust/src/setup.rs @@ -23,12 +23,11 @@ const DEFAULT_AGILITY_PACKAGE_VERSION: &str = "1.619.3"; pub fn state() -> Value { let home = dirs::home_dir().unwrap_or_default(); let config_path = crate::platform::metalsharp_home_dir_for(&home).join("setup.json"); + let complete_runtime_current = crate::installer::complete_runtime_current_for_home(&home); let dxmt_runtime = crate::installer::dxmt_runtime_status(); - let dxmt_current = dxmt_runtime.get("current").and_then(|v| v.as_bool()).unwrap_or(false); - let dxmt_m12_current = dxmt_runtime.get("m12Current").and_then(|v| v.as_bool()).unwrap_or(false); let wine_dir = crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("wine"); let metalsharp_runtime_lib_ready = crate::installer::metalsharp_runtime_lib_ready(&wine_dir); - let runtime_current = dxmt_current && dxmt_m12_current && metalsharp_runtime_lib_ready; + let runtime_current = complete_runtime_current; if config_path.exists() { if let Ok(contents) = std::fs::read_to_string(&config_path) { @@ -42,6 +41,7 @@ pub fn state() -> Value { "deviceName": cfg.get("deviceName").and_then(|v| v.as_str()).unwrap_or(""), "steamApiKeySet": cfg.get("steamApiKeySet").and_then(|v| v.as_bool()).unwrap_or(false), "runtimeMigrationRequired": saved_completed && !runtime_current, + "completeRuntimeCurrent": complete_runtime_current, "dxmtRuntime": dxmt_runtime, "metalsharpRuntimeLibReady": metalsharp_runtime_lib_ready, }); @@ -57,6 +57,7 @@ pub fn state() -> Value { "deviceName": "", "steamApiKeySet": false, "runtimeMigrationRequired": false, + "completeRuntimeCurrent": complete_runtime_current, "dxmtRuntime": dxmt_runtime, "metalsharpRuntimeLibReady": metalsharp_runtime_lib_ready, }) @@ -125,10 +126,11 @@ pub fn dependencies() -> Value { let xcode_cli = check_command("clang") || check_command("xcodebuild"); let steam = check_path(&home.join("Library/Application Support/Steam/Steam.app/Contents/MacOS/steam_osx")) || check_path(&PathBuf::from("/Applications/Steam.app/Contents/MacOS/steam_osx")); - let homebrew = check_command("brew"); - let moltenvk = check_path(&PathBuf::from("/opt/homebrew/etc/vulkan/icd.d/MoltenVK_icd.json")); - let metalsharp_wine = check_path(&crate::platform::metalsharp_home_dir_for(&home).join("runtime/wine/bin/wine")) - || check_path(&crate::platform::metalsharp_home_dir_for(&home).join("runtime/wine/bin/metalsharp-wine")); + let homebrew = homebrew_ready(); + let wine_root = crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("wine"); + let moltenvk = check_path(&wine_root.join("build-ec/dlls/win32u/libMoltenVK.dylib")) + && check_path(&wine_root.join("lib/moltenvk/libMoltenVK.dylib")); + let metalsharp_wine = crate::installer::complete_runtime_current_for_home(&home); let host_runtime = host_runtime_installed(&home); let dxmt_status = crate::installer::dxmt_runtime_status(); let dxmt_runtime = dxmt_status @@ -137,15 +139,17 @@ pub fn dependencies() -> Value { .or_else(|| dxmt_status.get("current")) .and_then(|v| v.as_bool()) .unwrap_or(false); - let dxmt_m12_runtime = dxmt_status - .get("dxmt_m12") - .and_then(|lane| lane.get("current")) - .or_else(|| dxmt_status.get("m12Current")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + let m12_vkd3d_runtime = [ + "lib/vkd3d-proton/x86_64/d3d12.dll", + "lib/vkd3d-proton/x86_64/d3d12core.dll", + "lib/dxvk/x86_64/dxgi.dll", + "build-ec/dlls/winevulkan/winevulkan.so", + "build-ec/dlls/win32u/libMoltenVK.dylib", + ] + .iter() + .all(|relative| check_path(&wine_root.join(relative))); - let all_ok = - homebrew && rosetta && xcode_cli && metalsharp_wine && host_runtime && dxmt_runtime && dxmt_m12_runtime; + let all_ok = homebrew && rosetta && xcode_cli && metalsharp_wine; json!({ "ok": true, @@ -155,7 +159,7 @@ pub fn dependencies() -> Value { { "id": "homebrew", "name": "Homebrew", - "desc": "Package manager — required to install other dependencies", + "desc": "Required package manager and owner of the separate GPTK/D3DMetal runtime", "installed": homebrew, "required": true, "installCmd": "/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"", @@ -178,8 +182,8 @@ pub fn dependencies() -> Value { }, { "id": "metalsharp_wine", - "name": "MetalSharp Wine", - "desc": "From-source Wine 11.5 with DXMT Metal D3D11, gnutls TLS, MoltenVK. Runs Windows Steam and launches games with native Metal rendering.", + "name": "MetalSharp Complete Wine Runtime", + "desc": "Verified Wine 11.12 multi-architecture runtime with ARM64, ARM64EC, x86_64 and i386/WoW64 execution plus the complete Metal and Vulkan graphics stack.", "installed": metalsharp_wine, "required": true, "installCmd": "metalsharp-setup-wine", @@ -202,19 +206,13 @@ pub fn dependencies() -> Value { "status": dxmt_status.get("dxmt").cloned().unwrap_or_else(|| dxmt_status.clone()), }, { - "id": "dxmt_m12_runtime", - "name": "DXMT M12 Runtime", - "desc": "Isolated D3D12-to-Metal runtime staged under runtime/wine/lib/dxmt_m12 with its own DLLs and winemetal.so sidecars.", - "installed": dxmt_m12_runtime, + "id": "m12_vkd3d_runtime", + "name": "M12 vkd3d-proton Runtime", + "desc": "D3D12 through vkd3d-proton with DXVK-owned DXGI, Wine Vulkan, and the bundled ARM64 MoltenVK-to-Metal host route.", + "installed": m12_vkd3d_runtime, "required": true, - "installCmd": "metalsharp-setup-dxmt-m12", - "status": dxmt_status.get("dxmt_m12").cloned().unwrap_or_else(|| dxmt_status.clone()), - "path": dxmt_status - .get("dxmt_m12") - .and_then(|lane| lane.get("path")) - .cloned() - .or_else(|| dxmt_status.get("m12Path").cloned()) - .unwrap_or(json!(null)), + "installCmd": "metalsharp-setup-wine", + "path": wine_root.join("lib/vkd3d-proton/x86_64").to_string_lossy(), }, { "id": "mono", @@ -226,11 +224,11 @@ pub fn dependencies() -> Value { }, { "id": "moltenvk", - "name": "MoltenVK", - "desc": "Vulkan→Metal translation. Optional fallback graphics backend.", + "name": "Bundled MoltenVK", + "desc": "ARM64 Vulkan-to-Metal translation used by the M12 vkd3d-proton route.", "installed": moltenvk, - "required": false, - "installCmd": "brew install molten-vk", + "required": true, + "installCmd": "metalsharp-setup-wine", }, { "id": "steam", @@ -395,8 +393,8 @@ pub fn prepare_game(appid: u32) -> Result> { _ => { if is_dotnet { prepare_fna_game(appid, &game_dir, &home)?; - } else if pipeline.is_dxmt_family() { - prepare_dxmt_pipeline(appid, &game_dir, &home, pipeline)?; + } else if pipeline.is_dxmt_family() || pipeline == crate::mtsp::engine::PipelineId::M12 { + prepare_graphics_pipeline(appid, &game_dir, &home, pipeline)?; } }, } @@ -588,7 +586,7 @@ fn prepare_celeste(game_dir: &PathBuf, home: &PathBuf) -> Result<(), Box bool { mac_cmd("which").arg(cmd).output().map(|o| o.status.success()).unwrap_or(false) } +fn homebrew_ready() -> bool { + ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"] + .iter() + .any(|brew| Command::new(brew).arg("--version").output().map(|o| o.status.success()).unwrap_or(false)) + || mac_cmd("which") + .arg("brew") + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|brew| { + Command::new(brew.trim()).arg("--version").output().map(|o| o.status.success()).unwrap_or(false) + }) + .unwrap_or(false) +} + fn check_path(path: &PathBuf) -> bool { path.exists() } diff --git a/app/src-rust/src/steam.rs b/app/src-rust/src/steam.rs index 833cb7ae6..3a1bdb55c 100644 --- a/app/src-rust/src/steam.rs +++ b/app/src-rust/src/steam.rs @@ -2,15 +2,53 @@ use serde_json::{json, Value}; use std::path::{Component, Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; static STEAM_INSTALLING: AtomicBool = AtomicBool::new(false); +static STEAM_INSTALL_HANDOFF: OnceLock> = OnceLock::new(); +const STEAM_INSTALL_HANDOFF_LIMIT: u8 = 2; +const STEAM_INSTALL_MARKER: &str = ".metalsharp-steam-install-complete"; const STEAMWEBHELPER_WRAPPER_MAX_BYTES: u64 = 100_000; const STEAMWEBHELPER_WRAPPER_SHA256: &str = "f46a1e8c39c850ba22861f63559f13b4f68557acf04a92e6d1b899769b2ea1f9"; const STEAM_D3D12_GUARD_APPS: &[&str] = &["Steam.exe", "steamwebhelper.exe", "steamwebhelper_real.exe"]; const STEAM_D3D12_GUARD_DLLS: &[&str] = &["d3d12", "d3d12core", "d3d12SDKLayers", "dxcore"]; +#[derive(Clone, Debug, Default)] +struct SteamInstallHandoffState { + accepted: u8, + active: bool, + complete: bool, + last_child_pid: Option, + last_error: Option, +} + +fn install_handoff_state() -> &'static Mutex { + STEAM_INSTALL_HANDOFF.get_or_init(|| Mutex::new(SteamInstallHandoffState::default())) +} + +fn reset_install_handoff_state() { + if let Ok(mut state) = install_handoff_state().lock() { + *state = SteamInstallHandoffState::default(); + } +} + +fn install_handoff_json(state: &SteamInstallHandoffState) -> Value { + json!({ + "accepted": state.accepted, + "remaining": STEAM_INSTALL_HANDOFF_LIMIT.saturating_sub(state.accepted), + "active": state.active, + "complete": state.complete, + "last_child_pid": state.last_child_pid, + "last_error": state.last_error, + }) +} + +fn runtime_root() -> PathBuf { + crate::platform::metalsharp_home_dir().join("runtime") +} + fn ms_wine() -> PathBuf { - let ms_root = crate::platform::metalsharp_home_dir().join("runtime").join("wine"); + let ms_root = runtime_root().join("wine"); crate::platform::runtime_wine_binary(&ms_root) } @@ -60,7 +98,9 @@ pub fn status() -> Value { let wine_steam_dir = steam_prefix().join("drive_c").join("Program Files (x86)").join("Steam"); let wine_steam_exe = wine_steam_dir.join("Steam.exe"); - let windows_installed = wine_steam_exe.exists(); + let windows_installed = wine_steam_exe.exists() + && wine_steam_dir.join("steamui.dll").exists() + && wine_steam_dir.join(STEAM_INSTALL_MARKER).exists(); let windows_path = if windows_installed { Some(wine_steam_dir.to_string_lossy().to_string()) } else { None }; let login_state = detect_login_state(); @@ -77,6 +117,10 @@ pub fn status() -> Value { let running = is_wine_steam_running(); let ms_available = ms_wine().exists(); let installing = is_installing_steam(); + let handoff = install_handoff_state() + .lock() + .map(|state| install_handoff_json(&state)) + .unwrap_or_else(|_| json!({"last_error": "Steam install handoff state is unavailable"})); json!({ "installed": windows_installed, @@ -88,7 +132,8 @@ pub fn status() -> Value { "mac_running": mac_running, "running": running, "metalsharp_wine_available": ms_available, - "installing": installing + "installing": installing, + "install_handoff": handoff }) } @@ -251,17 +296,7 @@ fn cef_subdirs(steam_dir: &Path) -> Vec { fn ensure_steam_launch_ready(steam_dir: &PathBuf) { for cef_dir in cef_subdirs(steam_dir) { - let wrapper = cef_dir.join("steamwebhelper.exe"); - let real = cef_dir.join("steamwebhelper_real.exe"); - - let wrapper_size = std::fs::metadata(&wrapper).map(|m| m.len()).unwrap_or(0); - let real_size = std::fs::metadata(&real).map(|m| m.len()).unwrap_or(0); - - let wrapper_missing = wrapper_size == 0; - let wrapper_overwritten = wrapper_size > STEAMWEBHELPER_WRAPPER_MAX_BYTES; - let real_missing_or_bad = real_size > 0 && real_size < STEAMWEBHELPER_WRAPPER_MAX_BYTES; - - if wrapper_missing || wrapper_overwritten || real_missing_or_bad { + if !steamwebhelper_pair_ready(&cef_dir) { deploy_steamwebhelper_wrapper(steam_dir); return; } @@ -298,6 +333,7 @@ fn seed_steam_d3d12_guard(prefix: &Path, ms_root: &Path) -> Result<(), Box R .arg(&exe) .args(args) .stdout(std::process::Stdio::null()); + crate::launch::apply_wine_runtime_preferences(&mut cmd); if let Some(f) = log_file { cmd.stderr(std::process::Stdio::from(f)); @@ -728,6 +765,12 @@ pub fn get_wine_steam_installed_games() -> Vec { } pub fn deploy_steamwebhelper_wrapper(steam_dir: &PathBuf) { + let metalsharp_home = crate::platform::metalsharp_home_dir(); + if !managed_steam_dir(&metalsharp_home, steam_dir) { + eprintln!("steam: refusing to deploy CEF wrapper outside MetalSharp prefixes: {}", steam_dir.display()); + return; + } + let wrapper = match find_bundled_steamwebhelper_wrapper() { Some(wrapper) => wrapper, None => match download_steamwebhelper_wrapper_fallback() { @@ -749,16 +792,19 @@ pub fn deploy_steamwebhelper_wrapper(steam_dir: &PathBuf) { let original_size = std::fs::metadata(&original).map(|m| m.len()).unwrap_or(0); let real_size = std::fs::metadata(&real).map(|m| m.len()).unwrap_or(0); + let original_is_wrapper = steamwebhelper_wrapper_valid(&original); + let original_is_real = original_size > STEAMWEBHELPER_WRAPPER_MAX_BYTES; + let real_is_real = real_size > STEAMWEBHELPER_WRAPPER_MAX_BYTES; - if original_size > 0 && original_size <= STEAMWEBHELPER_WRAPPER_MAX_BYTES { + if original_is_wrapper && real_is_real { if !wrapper_marker.exists() { let _ = std::fs::write(&wrapper_marker, "deployed"); } continue; } - if real_size < STEAMWEBHELPER_WRAPPER_MAX_BYTES { - if original_size > STEAMWEBHELPER_WRAPPER_MAX_BYTES { + if !real_is_real { + if original_is_real { let _ = std::fs::remove_file(&real); let _ = std::fs::rename(&original, &real); } else { @@ -768,16 +814,48 @@ pub fn deploy_steamwebhelper_wrapper(steam_dir: &PathBuf) { let _ = std::fs::remove_file(&original); } - if std::fs::copy(&wrapper, &original).is_ok() { + if std::fs::copy(&wrapper, &original).is_ok() && steamwebhelper_wrapper_valid(&original) { let _ = std::fs::write(&wrapper_marker, "deployed"); } } } fn find_bundled_steamwebhelper_wrapper() -> Option { + let runtime_wrapper = runtime_steamwebhelper_wrapper(&runtime_root()); + if steamwebhelper_wrapper_valid(&runtime_wrapper) { + return Some(runtime_wrapper); + } find_bundled_steam_asset("steamwebhelper.exe").filter(|path| steamwebhelper_wrapper_valid(path)) } +fn runtime_steamwebhelper_wrapper(runtime: &Path) -> PathBuf { + runtime.join("integration").join("steam-webhelper").join("steamwebhelper.exe") +} + +fn managed_steam_dir(metalsharp_home: &Path, steam_dir: &Path) -> bool { + if let (Ok(home_real), Ok(steam_real)) = (metalsharp_home.canonicalize(), steam_dir.canonicalize()) { + if !steam_real.starts_with(home_real) { + return false; + } + } + + let primary = metalsharp_home.join("prefix-steam/drive_c/Program Files (x86)/Steam"); + if steam_dir == primary { + return true; + } + + let Ok(relative) = steam_dir.strip_prefix(metalsharp_home.join("bottles")) else { + return false; + }; + let components = relative.components().collect::>(); + components.len() == 5 + && matches!(components[0], Component::Normal(_)) + && components[1] == Component::Normal(std::ffi::OsStr::new("prefix")) + && components[2] == Component::Normal(std::ffi::OsStr::new("drive_c")) + && components[3] == Component::Normal(std::ffi::OsStr::new("Program Files (x86)")) + && components[4] == Component::Normal(std::ffi::OsStr::new("Steam")) +} + fn find_bundled_steam_asset(filename: &str) -> Option { let home = dirs::home_dir()?; let cache_dir = crate::platform::metalsharp_home_dir_for(&home).join("cache").join("steam"); @@ -937,6 +1015,17 @@ fn steamwebhelper_wrapper_valid(path: &Path) -> bool { file_sha256(path).as_deref() == Some(STEAMWEBHELPER_WRAPPER_SHA256) } +fn steamwebhelper_wrapper_deployed(steam_dir: &Path) -> bool { + let cef_dirs = cef_subdirs(steam_dir); + !cef_dirs.is_empty() && cef_dirs.iter().any(|cef_dir| steamwebhelper_pair_ready(cef_dir)) +} + +fn steamwebhelper_pair_ready(cef_dir: &Path) -> bool { + steamwebhelper_wrapper_valid(&cef_dir.join("steamwebhelper.exe")) + && std::fs::metadata(cef_dir.join("steamwebhelper_real.exe")).map(|metadata| metadata.len()).unwrap_or(0) + > STEAMWEBHELPER_WRAPPER_MAX_BYTES +} + fn file_sha256(path: &Path) -> Option { for (program, args) in [("shasum", vec!["-a", "256"]), ("sha256sum", Vec::new())] { let Ok(output) = Command::new(program).args(args).arg(path).output() else { @@ -1451,10 +1540,143 @@ fn parse_vdf_value(line: &str, key: &str) -> Option { Some(rest.trim_matches('"').to_string()) } +fn steam_installer_valid(path: &Path) -> bool { + if std::fs::metadata(path).map(|metadata| metadata.len()).unwrap_or(0) < 1_000_000 { + return false; + } + let mut header = [0u8; 2]; + std::fs::File::open(path).and_then(|mut file| std::io::Read::read_exact(&mut file, &mut header)).is_ok() + && header == *b"MZ" +} + +fn copy_file_required(source: &Path, destination: &Path, label: &str) -> Result<(), Box> { + if !source.is_file() { + return Err(format!("missing {label}: {}", source.display()).into()); + } + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(source, destination)?; + Ok(()) +} + +pub(crate) fn steam_prefix_all_arch_ready(prefix: &Path) -> bool { + crate::runtime_prefix::all_arch_ready(prefix) +} + +fn prepare_steam_prefix(prefix: &Path) -> Result<(), Box> { + crate::runtime_prefix::prepare(prefix).map_err(Into::into) +} + +fn stop_steam_install_prefix() -> Result<(), Box> { + let prefix = steam_prefix(); + let wineserver = crate::platform::runtime_wineserver(&runtime_root().join("wine")); + for argument in ["-k", "-w"] { + let status = Command::new(&wineserver).arg(argument).env("WINEPREFIX", &prefix).status()?; + if !status.success() { + return Err(format!("Steam install wineserver {argument} failed with {status}").into()); + } + } + Ok(()) +} + +fn spawn_steam_install_bootstrap() -> Result> { + let prefix = steam_prefix(); + let steam_dir = prefix.join("drive_c/Program Files (x86)/Steam"); + let steam = steam_dir.join("Steam.exe"); + if !steam.is_file() { + return Err(format!("Steam handoff executable is missing: {}", steam.display()).into()); + } + + let log_dir = crate::platform::metalsharp_home_dir().join("logs"); + std::fs::create_dir_all(&log_dir)?; + let output = + std::fs::OpenOptions::new().create(true).append(true).open(log_dir.join("steam-install-handoff.log"))?; + let errors = output.try_clone()?; + let mut command = Command::new(ms_wine()); + command + .arg(&steam) + .current_dir(&steam_dir) + .env("WINEPREFIX", &prefix) + .env("WINEBUILDDIR", runtime_root().join("wine/build-ec")) + .env("WINEBOOTSTRAPMODE", "1") + .env("VKMT_STEAM_HANDOFF_NOTIFY", "1") + .env("VKMT_STEAM_BOOTSTRAP_WAKE_RECOVERY", "0") + .env("FEX_TSOENABLED", "0") + .env("FEX_VECTORTSOENABLED", "0") + .env("FEX_MEMCPYSETTSOENABLED", "0") + .env("WINEDEBUG", "-all") + .env("WINEDEBUGGER", "none") + .env("MS_FWD_COMPAT_GL_CTX", "1") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::from(output)) + .stderr(std::process::Stdio::from(errors)); + crate::launch::apply_wine_runtime_preferences(&mut command); + let child = command.spawn()?; + Ok(child.id()) +} + +fn run_install_handoff_cycle(cycle: u8) -> Result, Box> { + std::thread::sleep(std::time::Duration::from_millis(350)); + stop_steam_install_prefix()?; + if cycle < STEAM_INSTALL_HANDOFF_LIMIT { + return spawn_steam_install_bootstrap().map(Some); + } + + let steam_dir = steam_prefix().join("drive_c/Program Files (x86)/Steam"); + deploy_steamwebhelper_wrapper(&steam_dir); + if !steamwebhelper_wrapper_deployed(&steam_dir) { + return Err("Steam updated successfully, but the verified steamwebhelper wrapper was not deployed".into()); + } + std::fs::write(steam_dir.join(STEAM_INSTALL_MARKER), "handoffs=2\nwrapper_deployed=1\nno_tso=1\n")?; + Ok(None) +} + +pub fn accept_install_handoff() -> (u16, Value) { + let mut state = match install_handoff_state().lock() { + Ok(state) => state, + Err(_) => return (500, json!({"ok": false, "error": "Steam install handoff state is unavailable"})), + }; + if !STEAM_INSTALLING.load(Ordering::SeqCst) { + return (409, json!({"ok": false, "accepted": false, "reason": "no-install-active"})); + } + if state.active { + return (409, json!({"ok": false, "accepted": false, "reason": "handoff-active"})); + } + if state.accepted >= STEAM_INSTALL_HANDOFF_LIMIT { + return (429, json!({"ok": false, "accepted": false, "reason": "handoff-limit"})); + } + + state.accepted += 1; + state.active = true; + let cycle = state.accepted; + drop(state); + + std::thread::spawn(move || { + let outcome = run_install_handoff_cycle(cycle); + if let Ok(mut state) = install_handoff_state().lock() { + state.active = false; + match outcome { + Ok(pid) => { + state.last_child_pid = pid; + state.complete = cycle == STEAM_INSTALL_HANDOFF_LIMIT; + state.last_error = None; + }, + Err(error) => state.last_error = Some(error.to_string()), + } + } + }); + + (202, json!({"ok": true, "accepted": true, "cycle": cycle, "limit": STEAM_INSTALL_HANDOFF_LIMIT})) +} + pub fn install_steam() -> Result> { let steam_dir = steam_prefix().join("drive_c").join("Program Files (x86)").join("Steam"); - if steam_dir.join("steamui.dll").exists() && steam_exe_path().exists() { + if steam_dir.join(STEAM_INSTALL_MARKER).exists() + && steam_dir.join("steamui.dll").exists() + && steam_exe_path().exists() + { return Ok("Steam already installed".into()); } @@ -1465,9 +1687,16 @@ pub fn install_steam() -> Result> { if STEAM_INSTALLING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { return Ok("Steam installation already in progress".into()); } + reset_install_handoff_state(); std::thread::spawn(move || { - let _ = run_install_steam(); + if let Err(error) = run_install_steam() { + if let Ok(mut state) = install_handoff_state().lock() { + state.active = false; + state.complete = false; + state.last_error = Some(error.to_string()); + } + } STEAM_INSTALLING.store(false, Ordering::SeqCst); }); @@ -1481,136 +1710,98 @@ fn run_install_steam() -> Result> { let steam_dir = steam_prefix().join("drive_c").join("Program Files (x86)").join("Steam"); + let _ = stop_steam_install_prefix(); let _ = std::fs::remove_dir_all(steam_prefix()); let installer = metalsharp_dir.join("SteamSetup.exe"); let _ = std::fs::remove_file(&installer); let url = "https://steamcdn-a.akamaihd.net/client/installer/SteamSetup.exe"; - let output = Command::new("curl").args(["-sL", "-o", &installer.to_string_lossy(), url]).status()?; - if !output.success() { + let output = Command::new("/usr/bin/curl") + .args(["--fail", "--location", "--silent", "--show-error", "--retry", "4", "--output"]) + .arg(&installer) + .arg(url) + .status()?; + if !output.success() || !steam_installer_valid(&installer) { + let _ = std::fs::remove_file(&installer); if let Some(bundled) = find_bundled_steam_asset("SteamSetup.exe") { let _ = std::fs::copy(&bundled, &installer); } } - if !installer.exists() { - return Err("Failed to download Steam installer".into()); + if !steam_installer_valid(&installer) { + return Err("Failed to download a valid Steam installer".into()); } let wine = ms_wine(); - if !wine.exists() { + if !wine.exists() || !crate::installer::complete_runtime_current_for_home(&home) { return Err("MetalSharp Wine not found".into()); } let prefix = steam_prefix(); std::fs::create_dir_all(&prefix)?; - - let prefix_str = prefix.to_string_lossy().to_string(); - - let ms_root = crate::platform::metalsharp_home_dir().join("runtime").join("wine"); - - let mut wineboot_cmd = Command::new(&wine); - wineboot_cmd - .env("WINEPREFIX", &prefix_str) - .env("WINEDEBUG", "-all") - .env("WINEDEBUGGER", "/usr/bin/true") - .env("WINEDLLOVERRIDES", "winedbg=d") - .arg("wineboot") - .arg("--init") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - crate::platform::set_runtime_library_env(&mut wineboot_cmd, &ms_root); - let wineboot_result = wineboot_cmd.status(); - - if wineboot_result.is_err() { - return Err("wineboot --init failed — MetalSharp Wine may not be properly installed".into()); - } - - let windows_dir = prefix.join("drive_c").join("windows").join("system32"); - let mut wineboot_ok = false; - for _ in 0..30 { - if windows_dir.exists() { - wineboot_ok = true; - break; - } - std::thread::sleep(std::time::Duration::from_secs(2)); - } - if !wineboot_ok { - return Err("wineboot --init timed out — Wine prefix was not created within 60 seconds".into()); - } + prepare_steam_prefix(&prefix)?; let seed_log = prefix.join("drive_c").join("metalsharp-post-wineboot.log"); let _ = crate::bottles::seed_post_wineboot_config(&prefix, &seed_log); let mut install_cmd = Command::new(&wine); install_cmd - .env("WINEPREFIX", &prefix_str) + .env("WINEPREFIX", &prefix) + .env("WINEBUILDDIR", runtime_root().join("wine/build-ec")) + .env("WINEBOOTSTRAPMODE", "1") + .env("VKMT_STEAM_HANDOFF_NOTIFY", "1") + .env("VKMT_STEAM_BOOTSTRAP_WAKE_RECOVERY", "0") + .env("FEX_TSOENABLED", "0") + .env("FEX_VECTORTSOENABLED", "0") + .env("FEX_MEMCPYSETTSOENABLED", "0") .env("WINEDEBUG", "-all") - .env("WINEDEBUGGER", "/usr/bin/true") + .env("WINEDEBUGGER", "none") .env("WINEDLLOVERRIDES", "winedbg=d") .arg(&installer) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); - crate::platform::set_runtime_library_env(&mut install_cmd, &ms_root); + crate::launch::apply_wine_runtime_preferences(&mut install_cmd); let mut install_child = install_cmd.spawn()?; let install_pid = install_child.id(); let steam_exe = steam_dir.join("Steam.exe"); let steam_ui_dll = steam_dir.join("steamui.dll"); - let mut install_crashed = false; - for _ in 0..70 { - // Check if the installer process is still running before waiting. + let mut installer_exited = false; + for _ in 0..2700 { match install_child.try_wait() { Ok(Some(status)) => { - if !status.success() { + if !status.success() && !steam_exe.exists() { eprintln!("steam: installer exited early with status {:?}", status.code()); - install_crashed = true; + return Err(format!("Steam installer exited before installing Steam (pid {install_pid})").into()); } - break; + installer_exited = true; }, - Ok(None) => {}, // Still running + Ok(None) => {}, Err(e) => { eprintln!("steam: failed to check installer status: {}", e); - break; }, } - if steam_exe.exists() && steam_ui_dll.exists() { - break; - } - std::thread::sleep(std::time::Duration::from_secs(1)); - } - // If installer crashed, wait briefly for any remaining file I/O to settle - // before checking if Steam was partially installed anyway. - if install_crashed { - std::thread::sleep(std::time::Duration::from_secs(3)); - if steam_exe.exists() && steam_ui_dll.exists() { - eprintln!("steam: installer crashed but Steam files are present — continuing"); - } else { - // Kill any remaining Wine processes from the failed install - let _ = Command::new("pkill") - .args(["-f", &format!("WINEPREFIX={}", prefix_str)]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - return Err(format!("Steam installer crashed (pid {}). Check Wine setup and retry.", install_pid).into()); + let state = install_handoff_state() + .lock() + .map(|state| state.clone()) + .map_err(|_| "Steam install handoff state is unavailable")?; + if let Some(error) = state.last_error { + return Err(format!("Steam updater handoff failed: {error}").into()); } - } - - if steam_exe.exists() && steam_ui_dll.exists() { - deploy_steamwebhelper_wrapper(&steam_dir); - } - - for _ in 0..30 { - if !cef_subdirs(&steam_dir).is_empty() { - deploy_steamwebhelper_wrapper(&steam_dir); - break; + if state.complete + && steam_exe.exists() + && steam_ui_dll.exists() + && steam_dir.join(STEAM_INSTALL_MARKER).exists() + { + return Ok("Steam install completed after two updater handoffs".into()); } - std::thread::sleep(std::time::Duration::from_secs(2)); + std::thread::sleep(std::time::Duration::from_secs(1)); } - Ok("Steam install thread complete".into()) + let exited = if installer_exited { " after the installer process exited" } else { "" }; + Err(format!("Steam installation timed out before both updater handoffs completed{exited}").into()) } pub fn watch_steamapps() -> Vec { @@ -1643,6 +1834,10 @@ pub fn watch_steamapps() -> Vec { mod tests { use super::*; + fn test_prefix(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("metalsharp-steam-{name}-{}", std::process::id())) + } + #[test] fn stop_wine_steam_targets_report_has_required_shape() { // Phase 7: the stop-targets report must expose the targeted and @@ -1735,4 +1930,122 @@ mod tests { assert_eq!(fields.get(1).copied(), Some("steam")); assert_eq!(STEAMWEBHELPER_WRAPPER_SHA256.len(), 64); } + + #[test] + fn steam_wrapper_prefers_complete_runtime_integration_path() { + let runtime = PathBuf::from("/metalsharp/runtime"); + assert_eq!( + runtime_steamwebhelper_wrapper(&runtime), + runtime.join("integration/steam-webhelper/steamwebhelper.exe") + ); + } + + #[test] + fn wrapper_deployment_is_confined_to_metalsharp_prefixes() { + let home = PathBuf::from("/Users/test/.metalsharp"); + assert!(managed_steam_dir(&home, &home.join("prefix-steam/drive_c/Program Files (x86)/Steam"))); + assert!(managed_steam_dir(&home, &home.join("bottles/steam_123/prefix/drive_c/Program Files (x86)/Steam"))); + assert!(!managed_steam_dir( + &home, + Path::new("/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/Bottles/Steam/drive_c/Program Files (x86)/Steam") + )); + assert!(!managed_steam_dir( + &home, + &home.join("bottles/steam_123/not-prefix/drive_c/Program Files (x86)/Steam") + )); + } + + #[test] + fn steam_update_or_corrupt_small_wrapper_requires_redeployment() { + let cef = test_prefix("wrapper-update-repair"); + let _ = std::fs::remove_dir_all(&cef); + std::fs::create_dir_all(&cef).expect("create CEF fixture"); + std::fs::write(cef.join("steamwebhelper_real.exe"), vec![0u8; 100_001]).expect("write real helper"); + + std::fs::write(cef.join("steamwebhelper.exe"), b"not-the-pinned-wrapper").expect("write corrupt wrapper"); + assert!(!steamwebhelper_pair_ready(&cef)); + + std::fs::write(cef.join("steamwebhelper.exe"), vec![0u8; 100_001]).expect("simulate Steam overwrite"); + assert!(!steamwebhelper_pair_ready(&cef)); + let _ = std::fs::remove_dir_all(cef); + } + + #[test] + fn all_arch_prefix_gate_requires_native_wow64_and_i386_surfaces() { + let prefix = test_prefix("all-arch-gate"); + let _ = std::fs::remove_dir_all(&prefix); + for relative in [ + "system.reg", + "user.reg", + "drive_c/windows/system32/kernel32.dll", + "drive_c/windows/system32/ntdll.dll", + "drive_c/windows/system32/wow64.dll", + "drive_c/windows/system32/wow64win.dll", + "drive_c/windows/system32/xtajit64.dll", + "drive_c/windows/system32/xtajit.dll", + "drive_c/windows/syswow64/kernel32.dll", + "drive_c/windows/syswow64/ntdll.dll", + ".vkmt/gstreamer-runtime.sha256", + ] { + let path = prefix.join(relative); + std::fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent"); + std::fs::write(path, b"fixture").expect("write fixture"); + } + let write_pe = |relative: &str, machine: u16| { + let mut bytes = vec![0u8; 0x46]; + bytes[..2].copy_from_slice(b"MZ"); + bytes[0x3c..0x40].copy_from_slice(&(0x40u32).to_le_bytes()); + bytes[0x40..0x44].copy_from_slice(b"PE\0\0"); + bytes[0x44..0x46].copy_from_slice(&machine.to_le_bytes()); + std::fs::write(prefix.join(relative), bytes).expect("write PE fixture"); + }; + for relative in [ + "drive_c/windows/system32/kernel32.dll", + "drive_c/windows/system32/ntdll.dll", + "drive_c/windows/system32/wow64.dll", + "drive_c/windows/system32/wow64win.dll", + ] { + write_pe(relative, 0xaa64); + } + for relative in ["drive_c/windows/syswow64/kernel32.dll", "drive_c/windows/syswow64/ntdll.dll"] { + write_pe(relative, 0x014c); + } + let dosdevices = prefix.join("dosdevices"); + std::fs::create_dir_all(&dosdevices).expect("create dosdevices fixture"); + std::os::unix::fs::symlink("/", dosdevices.join("z:")).expect("create Wine Z mapping fixture"); + std::fs::write( + prefix.join("system.reg"), + "WINE REGISTRY Version 2\n\n[Software\\\\Microsoft\\\\Wow64\\\\x86] 1\n@=\"xtajit.dll\"\n", + ) + .expect("write i386 provider registry fixture"); + + assert!(steam_prefix_all_arch_ready(&prefix)); + std::fs::remove_file(prefix.join("drive_c/windows/syswow64/ntdll.dll")).expect("remove i386 fixture"); + assert!(!steam_prefix_all_arch_ready(&prefix)); + let _ = std::fs::remove_dir_all(prefix); + } + + #[test] + fn handoff_endpoint_rejects_callbacks_without_active_install() { + STEAM_INSTALLING.store(false, Ordering::SeqCst); + reset_install_handoff_state(); + let (status, response) = accept_install_handoff(); + + assert_eq!(status, 409); + assert_eq!(response.get("reason").and_then(Value::as_str), Some("no-install-active")); + } + + #[test] + fn steam_installer_gate_requires_large_pe_payload() { + let fixture = test_prefix("installer-gate.exe"); + let mut payload = vec![0u8; 1_000_001]; + payload[..2].copy_from_slice(b"MZ"); + std::fs::write(&fixture, &payload).expect("write installer fixture"); + assert!(steam_installer_valid(&fixture)); + + payload[..2].copy_from_slice(b"NO"); + std::fs::write(&fixture, &payload).expect("replace installer fixture"); + assert!(!steam_installer_valid(&fixture)); + let _ = std::fs::remove_file(fixture); + } } diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 10a566218..0174f0e67 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -29,7 +29,12 @@ function ensureShellPath() { return shellPath; } -function findHomebrew(): string | null { +type HomebrewInstallation = { + path: string; + version: string; +}; + +function findHomebrew(): HomebrewInstallation | null { const prefix = process.env.HOMEBREW_PREFIX?.trim(); const candidates = [ prefix ? path.join(prefix, "bin", "brew") : "", @@ -44,11 +49,14 @@ function findHomebrew(): string | null { for (const candidate of new Set(candidates.filter(Boolean))) { try { fs.accessSync(candidate, fs.constants.X_OK); - execFileSync(candidate, ["--version"], { + const version = execFileSync(candidate, ["--version"], { env: { ...process.env, PATH: ensureShellPath() }, - stdio: "ignore", - }); - return candidate; + encoding: "utf8", + timeout: 10000, + }) + .split("\n")[0] + .trim(); + if (version) return { path: candidate, version }; } catch { // continue to next candidate } @@ -1025,17 +1033,35 @@ function registerIpc() { return { ok: false, error: "Homebrew setup is only available on macOS." }; } - const brewPath = findHomebrew(); - if (brewPath) { - return { ok: true, installed: true, path: brewPath, message: "Homebrew is already installed" }; + const homebrew = findHomebrew(); + if (homebrew) { + return { ok: true, installed: true, ...homebrew, message: "Homebrew is already installed and verified" }; } return new Promise((resolve) => { const commandPath = path.join(app.getPath("temp"), `metalsharp-homebrew-${process.pid}-${Date.now()}.command`); const command = [ "#!/bin/bash", - `trap 'rm -f "${commandPath.replace(/'/g, "'\\\\''")}"' EXIT`, - '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', + "set -euo pipefail", + `COMMAND_PATH='${commandPath.replace(/'/g, "'\\\\''")}'`, + 'INSTALL_SCRIPT="$(/usr/bin/mktemp -t metalsharp-homebrew-install)"', + 'cleanup() { /bin/rm -f "$COMMAND_PATH" "$INSTALL_SCRIPT"; }', + "trap cleanup EXIT", + 'echo "MetalSharp: downloading the official Homebrew installer..."', + '/usr/bin/curl --fail --show-error --silent --location https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh --output "$INSTALL_SCRIPT"', + '/bin/bash -n "$INSTALL_SCRIPT"', + '/bin/bash "$INSTALL_SCRIPT"', + `BREW="\${HOMEBREW_PREFIX:-}/bin/brew"`, + 'if [[ ! -x "$BREW" ]]; then', + " for CANDIDATE in /opt/homebrew/bin/brew /usr/local/bin/brew; do", + ' if [[ -x "$CANDIDATE" ]]; then BREW="$CANDIDATE"; break; fi', + " done", + "fi", + 'if [[ ! -x "$BREW" ]]; then echo "MetalSharp: Homebrew installed but brew could not be found." >&2; exit 1; fi', + '"$BREW" --version', + 'echo "MetalSharp: Homebrew installation verified. Return to MetalSharp and click Continue."', + 'printf "\\nPress Return to close this window..."', + "read -r _", "", ].join("\n"); @@ -1066,8 +1092,8 @@ function registerIpc() { }); ipcMain.handle("app:homebrew-status", () => { - const brewPath = process.platform === "darwin" ? findHomebrew() : null; - return { installed: brewPath !== null, path: brewPath ?? undefined }; + const homebrew = process.platform === "darwin" ? findHomebrew() : null; + return { installed: homebrew !== null, ...homebrew }; }); ipcMain.handle("app:open-in-finder", async (_e, inputPath: string) => { diff --git a/app/src/renderer/api-types.ts b/app/src/renderer/api-types.ts index acaf5eeac..e1bb766f7 100644 --- a/app/src/renderer/api-types.ts +++ b/app/src/renderer/api-types.ts @@ -33,6 +33,10 @@ interface AppConfig { mono_available?: boolean; graphicsRuntimeLogs?: boolean; graphics_runtime_logs?: boolean; + controllerMode?: "dinput" | "xinput"; + controller_mode?: "dinput" | "xinput"; + msyncEnabled?: boolean; + msync_enabled?: boolean; } interface UpdateStatus { @@ -209,8 +213,15 @@ type MetalsharpAPI = { }>; ejectDmg: () => Promise; installDeps: (command: string) => Promise<{ ok: boolean; error?: string }>; - installHomebrew: () => Promise<{ ok: boolean; installed?: boolean; path?: string; message?: string; error?: string }>; - homebrewStatus: () => Promise<{ installed: boolean; path?: string }>; + installHomebrew: () => Promise<{ + ok: boolean; + installed?: boolean; + path?: string; + version?: string; + message?: string; + error?: string; + }>; + homebrewStatus: () => Promise<{ installed: boolean; path?: string; version?: string }>; openInFinder: (path: string) => Promise; openLogsFolder: () => Promise<{ ok: boolean; path?: string; error?: string }>; openMetalsharpFolder: () => Promise<{ ok: boolean; path?: string; error?: string }>; diff --git a/app/src/renderer/components/GameCard.vue b/app/src/renderer/components/GameCard.vue index 727345384..2c97af917 100644 --- a/app/src/renderer/components/GameCard.vue +++ b/app/src/renderer/components/GameCard.vue @@ -218,13 +218,11 @@ const componentDisplayName: Record = { fna3d: "FNA3D", faudio: "FAudio", fmod: "FMOD Audio", - m12_d3d12: "M12 d3d12.dll", - m12_d3d11: "M12 d3d11.dll", - m12_d3d10core: "M12 d3d10core.dll", - m12_dxgi_dxmt: "M12 dxgi_dxmt.dll", - m12_dxgi: "M12 dxgi.dll", - m12_winemetal: "M12 winemetal.dll / .so", - m12_gpu_stubs: "M12 GPU Stubs", + m12_vkd3d_d3d12: "M12 vkd3d-proton d3d12.dll", + m12_vkd3d_d3d12core: "M12 vkd3d-proton d3d12core.dll", + m12_dxvk_dxgi: "M12 DXVK dxgi.dll", + m12_winevulkan: "M12 Wine Vulkan bridge", + m12_moltenvk: "M12 ARM64 MoltenVK", d3d12_agility: "D3D12 Agility", gpu_vendor_stubs: "GPU Stubs", gptk_amd_stub: "GPTK AMD Stub", diff --git a/app/src/renderer/components/MigrationView.vue b/app/src/renderer/components/MigrationView.vue index fdb65a3c1..ec7757afa 100644 --- a/app/src/renderer/components/MigrationView.vue +++ b/app/src/renderer/components/MigrationView.vue @@ -18,10 +18,10 @@ const percent = computed(() => { }); const stages = [ - { name: "[D3D]" }, - { name: "[DXMT]" }, - { name: "[x86_64]" }, - { name: "[Metal]" }, + { name: "[Data]" }, + { name: "[Runtime]" }, + { name: "[Prefixes]" }, + { name: "[Verify]" }, ]; const MAX_START_RETRIES = 20; @@ -100,6 +100,15 @@ function stopPolling() { } } +async function retryMigration() { + error.value = null; + complete.value = false; + step.value = 0; + total.value = 0; + message.value = "Retrying migration from the last safe checkpoint..."; + await startMigration(); +} + async function restartApp() { launching.value = true; message.value = "Closing old MetalSharp, stopping the backend, and launching the updated app..."; @@ -153,7 +162,8 @@ onUnmounted(() => { -

Try restarting the app. If the issue persists, check the logs.

+ +

Your Steam installation and user data remain preserved at the last safe checkpoint.

diff --git a/app/src/renderer/components/SetupWizard.vue b/app/src/renderer/components/SetupWizard.vue index f14a7e73a..aece087c1 100644 --- a/app/src/renderer/components/SetupWizard.vue +++ b/app/src/renderer/components/SetupWizard.vue @@ -24,6 +24,7 @@ const installingSteam = ref(false); const brewChecking = ref(true); const brewInstalled = ref(false); const brewInstalling = ref(false); +const brewVersion = ref(""); const steps = ["Welcome", "Homebrew", "Runtime", "VC++", "Done"]; @@ -37,12 +38,14 @@ async function checkBrew() { const localStatus = await getAPI().homebrewStatus(); if (localStatus?.installed) { brewInstalled.value = true; + brewVersion.value = localStatus.version ?? "Homebrew verified"; brewChecking.value = false; return; } const deps = await api<{ dependencies: { id: string; installed: boolean }[] }>("GET", "/setup/dependencies"); const brewDep = deps?.dependencies?.find((d) => d.id === "homebrew"); brewInstalled.value = brewDep?.installed ?? false; + brewVersion.value = brewInstalled.value ? "Homebrew verified" : ""; brewChecking.value = false; } @@ -56,6 +59,7 @@ async function installHomebrew() { } if (result.installed) { brewInstalled.value = true; + brewVersion.value = result.version ?? "Homebrew verified"; brewInstalling.value = false; toast.show("Homebrew is already installed", "success"); return; @@ -132,7 +136,7 @@ async function startInstall() { async function checkSteam() { const s = await api<{ installed: boolean; running: boolean }>("GET", "/steam/status"); - if (s?.installed || s?.running) { + if (s?.installed) { steamInstalled.value = true; } installingSteam.value = true; @@ -147,8 +151,16 @@ async function installSteam() { return; } const poll = setInterval(async () => { - const s = await api<{ installed: boolean; running: boolean }>("GET", "/steam/status"); - if (s?.installed || s?.running) { + const s = await api<{ + installed: boolean; + installing: boolean; + install_handoff?: { accepted?: number; last_error?: string | null }; + }>("GET", "/steam/status"); + if (s?.install_handoff?.last_error) { + clearInterval(poll); + steamInstalling.value = false; + toast.show(s.install_handoff.last_error, "error"); + } else if (s?.installed) { clearInterval(poll); steamInstalled.value = true; steamInstalling.value = false; @@ -157,7 +169,7 @@ async function installSteam() { setTimeout(() => { clearInterval(poll); steamInstalling.value = false; - }, 300000); + }, 2700000); } async function finish() { @@ -199,7 +211,7 @@ async function goToDoneStep() { async function installVcppX64() { vcppX64Installing.value = true; try { - const result = await api<{ ok: boolean; error?: string }>("POST", "/setup/install-vcpp-x64"); + const result = await api<{ ok: boolean; error?: string }>("POST", "/setup/install-vcpp-x64", undefined, 2700000); if (result?.ok) { vcppX64Done.value = true; toast.show("VC++ 2015-2022 x64 installed", "success"); @@ -215,7 +227,7 @@ async function installVcppX64() { async function installVcppX86() { vcppX86Installing.value = true; try { - const result = await api<{ ok: boolean; error?: string }>("POST", "/setup/install-vcpp-x86"); + const result = await api<{ ok: boolean; error?: string }>("POST", "/setup/install-vcpp-x86", undefined, 2700000); if (result?.ok) { vcppX86Done.value = true; toast.show("VC++ 2015-2022 x86 installed", "success"); @@ -285,7 +297,7 @@ async function installVcppX86() {

Install Homebrew

-

MetalSharp uses Homebrew for setup tools such as zstd and Rosetta checks. GPTK/D3DMetal is optional and is only installed later when you save a game as a D3DMetal bottle.

+

Homebrew is required by MetalSharp. It supplies setup tools such as zstd and is the trusted owner of the separate GPTK/D3DMetal runtime. GPTK itself is installed through Homebrew only when you save a game as a D3DMetal bottle.

@@ -294,6 +306,7 @@ async function installVcppX86() { 2. Follow the prompts in Terminal to install Homebrew
3. When finished, click Continue

+

✓ {{ brewVersion }}