22 Commits
Author SHA1 Message Date
ClassicWasTaken 358f47a41f Integrate 1.4.2 SD and menu patch with reviewed issue fixes and build provenance 2026-10-06 20:54:02 -07:00
ClassicWasTaken 8741bcecb1 Merge verified bundled USB tools preference from PR25 2026-10-06 20:42:47 -07:00
ClassicWasTaken 5a274a3e57 Merge bounded SSH handshake and output fixes from PR24 2026-10-06 20:42:47 -07:00
ClassicWasTaken bc1812eb7d Merge interrupted upgrade recovery from PR23 2026-10-06 20:42:47 -07:00
ClassicWasTaken 1babeeaad8 Merge safe configuration validation from PR17 2026-10-06 20:42:47 -07:00
ClassicWasTaken 8a6ab23f83 Merge progress phase redaction fix from PR16 2026-10-06 20:42:47 -07:00
ClassicWasTaken d9946eba19 Merge Steam preference preservation from PR15 2026-10-06 20:42:47 -07:00
ClassicWasTaken 1a9544d5b2 Merge reviewed cancellation reporting fix from PR14 2026-10-06 20:42:40 -07:00
ClassicWasTaken 8ccb6b02c0 Add tested SD storage selection and front-facing VR menus 2026-10-06 20:41:42 -07:00
dime-online 8ba6c77c95 Preserve verified recovery evidence across interrupted retries 2026-10-06 21:55:52 -04:00
dime-online 74dc85524e Keep recovery compatible with shared configuration parsing 2026-10-06 21:47:06 -04:00
dime-online ca62be5682 Recover interrupted upgrades from verified transaction evidence 2026-10-06 21:46:15 -04:00
dime-online f2adff9e12 Scope SSH watchdogs to cancellable handshakes 2026-10-06 21:42:53 -04:00
dime-online ffac411559 Preserve cancellation and cleanup outcomes across setup workers 2026-10-06 21:42:51 -04:00
dime-online cd19772da9 Handle configuration errors consistently during preflight 2026-10-06 21:42:32 -04:00
dime-online d0122eb2c5 fix: reject unusual configuration shapes with a readable message 2026-10-06 21:22:50 -04:00
dime-online c2d1b978aa fix: look up progress phases before password redaction 2026-10-06 21:22:49 -04:00
dime-online 0d23860151 fix: preserve user library choices when re-adding the shortcut 2026-10-06 21:22:49 -04:00
dime-online 5641ea544d fix: report a cancelled setup as cancelled, not a failure 2026-10-06 21:22:49 -04:00
dime-online bb43a64d3d fix: prefer the pinned bundled adb over one found on PATH 2026-10-06 21:15:31 -04:00
dime-online 7267be68a6 fix: finish an interrupted in-place upgrade instead of refusing it 2026-10-06 19:03:01 -04:00
dime-online 8721561b2f fix: make SSH handshakes and streaming always interruptible 2026-10-06 18:59:28 -04:00
40 changed files with 4797 additions and 338 deletions

No files matched your search

+10
View File
@@ -64,6 +64,10 @@ jobs:
windows-executable:
needs: [test, build-container, native-arm64]
runs-on: windows-latest
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -84,6 +88,12 @@ jobs:
if ($process.ExitCode -ne 0) { throw 'Executable smoke test failed' }
$report = Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json
if (-not $report.ok) { throw 'Executable did not report success' }
- name: Attest the release executable
# PR builds run without publishing provenance under the release identity.
if: github.event_name != 'pull_request'
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-path: 'dist/Halo-Steam-Frame-Mod-Setup-*.exe'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: HaloFrameSetup-windows
+10 -9
View File
@@ -9,33 +9,34 @@
<h1 align="center">Halo CE Steam Frame VR Mod</h1>
<h2 align="center">
<a href="https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.1/Halo-Steam-Frame-Mod-Setup-1.4.1.exe">Download 1.4.1 — Windows installer (.exe)</a>
<a href="https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.2/Halo-Steam-Frame-Mod-Setup-1.4.2.exe">Download 1.4.2 — Windows installer (.exe)</a>
</h2>
<p align="center">
<strong>Halo-Steam-Frame-Mod-Setup-1.4.1.exe</strong><br>
<strong>Halo-Steam-Frame-Mod-Setup-1.4.2.exe</strong><br>
Windows 10/11 x64 · One file · Runtime and USB tools included<br>
<a href="https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/tag/v1.4.1">Release notes &amp; SHA256 checksum</a>
<a href="https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/tag/v1.4.2">Release notes &amp; SHA256 checksum</a> · <a href="docs/BUILD_PROVENANCE.md">Verify release provenance</a>
</p>
Play native ARM64/OpenXR Halo CE on Steam Frame with **LAN and online campaign co-op**, **head-directed walking**, and **Xbox-style buttons**. Tracked controller aiming, a render-rate reticle, half-strength stereo sun glow, and automatic Steam artwork and Notes are included.
**1.4.1 fixes first-mission VR aiming, look-training checks, tutorial buttons and the unarmed flashlight.** Install and Repair apply standing mode and snap turning. Choose Repair to update while keeping your existing 1.4.0 profiles and saves.
**1.4.2 adds SD-card installation and menus that open in front of your current headset view.** It retains the tutorial and unarmed-flashlight fixes, standing mode and snap turning, with improved cancellation, upgrade recovery and USB tool selection.
## Install or upgrade
You need a Steam Frame with internet access and **12 GiB of free storage**, plus your own authorized **original Xbox Halo CE ISO/XISO or extracted maps**. USA Rev 2 was validated with the stable installer. PC, Anniversary and MCC data are unsupported.
You need a Steam Frame with internet access and **12 GiB free on the selected storage**, plus your own authorized **original Xbox Halo CE ISO/XISO or extracted maps**. USA Rev 2 was validated with the stable installer. SD cards need a mounted, writable, executable **ext4 or f2fs** filesystem. PC, Anniversary and MCC data are unsupported.
1. **Run the EXE and choose Install or Repair.** Select your Xbox game data for a first install. Choose Repair to update an existing native installation using its verified maps without another ISO.
2. **Connect your Frame over USB-C or network SSH.** Enable Developer Mode, set the Frame's user password, and follow the installer's connection guide.
3. **Save and close games, then start installation.** Keep Steam Home and SteamVR running, and leave the Frame awake and connected. Building on the Frame can take tens of minutes.
3. **Choose Internal storage or SD card.** Click **Refresh storage**, select the detected card and check the destination. Install, Repair and Uninstall use this selection.
4. **Save and close games, then start installation.** Keep Steam Home and SteamVR running, and leave the Frame awake and connected. Building on the Frame can take tens of minutes.
**1.4.1 updates the native game in place** and keeps **Halo: Combat Evolved VR (Native)** in Steam. Existing 1.4.0 profiles and saves stay in `save-v1.4`. When upgrading from 1.3.5, old saves are retained but its checkpoints are incompatible; create a **new profile and campaign**.
Internal installs keep **Halo: Combat Evolved VR (Native)**. SD installs use **Halo: Combat Evolved VR (Native, SD card)** and keep your internal copy. A new SD install has separate profiles and saves; existing 1.4.x saves on the selected location stay in `save-v1.4`. Upgrading from 1.3.5 retains old saves but requires a **new profile and campaign**.
[Setup & co-op](docs/SETUP.md) · [Controls](docs/CONTROLS.md) · [Release notes](docs/RELEASE_NOTES.md) · [Sources](docs/SOURCES.md) · [Third-party notices](docs/THIRD_PARTY.md)
[Setup & co-op](docs/SETUP.md) · [SD-card guide](docs/SD_CARD.md) · [Controls](docs/CONTROLS.md) · [Release notes](docs/RELEASE_NOTES.md) · [Sources](docs/SOURCES.md) · [Third-party notices](docs/THIRD_PARTY.md)
<img src="docs/images/halocevr-steam-frame-example.jpeg" alt="Halo CE VR gameplay example with tracked hands and a plasma rifle beneath Halo's night sky" width="800">
*Community VR example by [u/Sea-Communication760](https://www.reddit.com/r/virtualreality/comments/1wpvi8n/halo_halocevr_working_great_on_steam_frame/): PC HaloCEVR on Steam Frame through Proton ARM. This is not a native 1.4.0 capture.*
*Community VR example by [u/Sea-Communication760](https://www.reddit.com/r/virtualreality/comments/1wpvi8n/halo_halocevr_working_great_on_steam_frame/): PC HaloCEVR on Steam Frame through Proton ARM. This is not a native 1.4.2 capture.*
No retail executable, disc image, maps, keys or soundtrack is included or downloaded. Halo artwork and third-party components retain their rights and notices.
+4 -2
View File
@@ -8,13 +8,15 @@ USB transfer uses a direct physical USB ADB device to forward a temporary local
The release bundles checksum-verified ADB components and their original notices from Google's official Platform Tools package. Setup does not download SDK tools during an installation. **Browse adb.exe** can select a user-installed replacement; obtain replacements from [Google's official Platform Tools download and terms](https://developer.android.com/tools/releases/platform-tools). Only the specific forward created for this setup operation is removed on disconnect. Setup never kills the shared ADB server, removes other applications' forwards, or changes the Frame's Wi-Fi, Steam streaming settings, or Steam/SteamVR session. A cable carries PC-to-Frame SSH/SFTP traffic; the Frame still downloads build dependencies using its own internet connection.
The bundled ADB version is 37.0.1 (protocol 1.0.41). Build-time archive and per-file SHA256 checks are recorded in [resources/usb/manifest.json](resources/usb/manifest.json). Runtime validates the bundled executable, its two DLLs, and the unmodified notices before copying them into the versioned `%LOCALAPPDATA%/HaloFrameInstaller/usb-tools/37.0.1` cache. ADB on `PATH` takes precedence; an explicit tool selection overrides automatic discovery. The cache remains available because the shared ADB server may outlive setup.
The bundled ADB version is 37.0.1 (protocol 1.0.41). Build-time archive and per-file SHA256 checks are recorded in [resources/usb/manifest.json](resources/usb/manifest.json). Runtime validates the bundled executable, its two DLLs, and the unmodified notices before copying them into the versioned `%LOCALAPPDATA%/HaloFrameInstaller/usb-tools/37.0.1` cache. An explicit tool selection takes priority; automatic discovery prefers the verified bundle, then an installed SDK or PATH only when the bundle is absent. A damaged bundle fails closed. The cache remains available because the shared ADB server may outlive setup.
Setup clears inherited ADB server-address/port overrides and selects a local server. It checks the selected client's protocol against an existing server before issuing normal ADB commands, refusing a mismatch rather than letting the client replace another application's server. It checks the actual operating-system TCP listener for the new forward and refuses non-loopback bindings before SSH authentication sends the password. Setup does not weaken those checks when a tool, device identity, or listener cannot be verified.
The Xbox image parser treats selected files as untrusted, bounds directory/file ranges, rejects unsafe paths and symlinks, and only extracts known retail maps. Remote writes are limited to owned per-user directories. The installer never disables SteamOS read-only mode or uses sudo to install packages.
The source revision is fixed, but the native build still downloads an Ubuntu container and toolchain/dependencies from their upstream distributors. This is not an offline or completely hermetic build. Updates to the fixed source and controller patch must be reviewed and tested together.
The native build pins the engine revision, Ubuntu container digest, musl archive checksum and SDL commit. LLVM's published primary signing fingerprint is checked before its repository key is trusted. Signed Ubuntu/LLVM22 package updates remain enabled and their versions are logged; this is not an offline, hermetic or reproducible build. The released Windows EXE has GitHub build-provenance attestation, separate from the release asset/checksum, and remains unsigned by Windows Authenticode. [Verification and the build/update policy](docs/BUILD_PROVENANCE.md) define the trusted workflow identity and remaining limits. Updates to pins and the controller patch must be reviewed and tested together.
Automatic USB selection prefers the verified bundle, with explicit executable selection remaining the user's choice. Client hash verification does not authenticate an already-running ADB server against a process controlled by the same local user. The retained SSH fingerprint protects Frame identity; first-use acceptance requires a trusted comparison. Setup assumes a trusted local user account and does not claim to resist a compromised computer or user-writable trust store.
Cancellation stops the installer-owned build container; an already completed installation may remain. Repairs back up program files and roll back a failed transaction, preserving saves and unrelated settings. Supplied valid Xbox maps may replace existing maps with backups; no-image repairs reuse existing verified maps. Unknown native folders are adopted only through an explicit Repair request and strict native/data validation. With Steam running, library registration uses the verified client's bounded local interface and reads back the exact native shortcut settings and artwork. Direct library-file edits require Steam to be already closed, use backups, and preserve unrelated entries. Setup never shuts down or relaunches Steam. All paths close their SSH channels; USB also removes its own temporary forward.
+34
View File
@@ -0,0 +1,34 @@
# Verify the installer and build inputs
The public Windows EXE is built by this repository's `.github/workflows/build.yml` after Windows/Linux tests, rootless-container checks and a complete native ARM64/OpenXR link. Release 1.4.2 adds GitHub's signed build-provenance attestation for that exact executable. It is separate from the downloadable asset and its checksum. Pull-request builds skip the attestation step; the verifier below requires the trusted main-branch source identity and does not accept a pull-request source ref.
Use a trusted installation of [GitHub CLI](https://cli.github.com/) to check the downloaded file:
```powershell
gh attestation verify Halo-Steam-Frame-Mod-Setup-1.4.2.exe --repo ClassicWasTaken/HaloSteamFrameMod --signer-workflow ClassicWasTaken/HaloSteamFrameMod/.github/workflows/build.yml --source-ref refs/heads/main --deny-self-hosted-runners
```
For a stricter check, add `--source-digest` followed by the release's installer commit. Read the command and repository identity from a trusted copy of this project or an independently saved reference. An attestation from a different project is not sufficient. [GitHub's verifier](https://cli.github.com/manual/gh_attestation_verify) checks the artifact digest and signed workflow/source identity; the [attestation documentation](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations) explains the trust model.
The release page also records SHA256 for accidental corruption and quick comparisons. A checksum fetched with the EXE alone does not independently authenticate a replacement of both. The EXE has no Windows Authenticode certificate; build provenance does not remove Windows' unsigned-application warning, establish the legal status of upstream sources, or prove that the software is free of vulnerabilities. It cannot protect against compromise of the authorized repository/workflow identity itself.
## Native build policy
The installer compiles the native program on the Frame using reviewed inputs:
- Engine: exact upstream commit `2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4` plus the shipped patch.
- Ubuntu 22.04 container: exact multi-architecture image digest, recorded in `BUILD_CONTAINER_IMAGE` in `resources/remote_install.py`.
- LLVM: major version 22, with the primary repository signing fingerprint `6084F3CF814B57C1CF12EFD515CF4D18AF4F7421` checked before the downloaded key is installed. The [LLVM repository](https://apt.llvm.org/) publishes that identity. The compiler must support the upstream `arm64_32` guest route.
- musl 1.2.5: repository-pinned archive SHA256 checked before extraction.
- SDL 3.4.16: exact Git commit, verified after checkout and before reuse.
- Bundled Paramiko source/license archive: repository-pinned URL and SHA256, independent of downloaded PyPI metadata. This archive is included for source/license compliance and is not installed or executed by the installer.
Ubuntu and LLVM's signed package updates remain enabled within those release/major-version channels. Installed package versions and the compiler version are recorded in the native build activity log. Maintainers review pin changes and run the full checks before each release. A rotated LLVM primary key, changed source archive or missing pinned revision stops the build until an updated installer is reviewed.
This policy favors maintained signed packages over a promise of byte-for-byte reproducible native builds. Apt package versions, dependency installation during the Windows build and operating-system libraries are not all frozen. Build attestations identify the released installer, while each Frame's native output hashes describe its own completed build; those hashes are not an independently established reproducible binary checksum. Downloaded OpenSSL/Tcl notices are bounded, non-executable licensing data with their resulting hashes recorded in the bundle manifest.
## USB and host trust
Automatic USB setup prefers the checksum-verified bundled ADB tools. An explicitly chosen ADB executable is the user's choice; SDK/PATH discovery is a fallback only when no bundle exists. A damaged bundle stops setup.
The installer requires a trusted Windows computer. A bundled client hash and a loopback-listener check do not authenticate an already-running ADB server against another process controlled by that same local user. Retained SSH host-key fingerprints are the Frame identity check for both USB and network connections. First use requires verifying the displayed fingerprint through a trusted Frame connection; do not accept an unexpected key or erase a saved fingerprint just to bypass a mismatch. The per-user `hosts.json` store is user-writable, so an attacker controlling that user account is outside this protection boundary. No blanket defense against a compromised local account is claimed.
+16 -19
View File
@@ -1,27 +1,24 @@
# Halo CE Steam Frame VR Mod 1.4.1
# Halo CE Steam Frame VR Mod 1.4.2
[**Download Halo-Steam-Frame-Mod-Setup-1.4.1.exe**](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.1/Halo-Steam-Frame-Mod-Setup-1.4.1.exe) for Windows 10/11 x64. This is the release's only installer asset. Its SHA256 is listed on the [release page](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/tag/v1.4.1); matching installer source is in the [v1.4.1 tag](https://github.com/ClassicWasTaken/HaloSteamFrameMod/tree/v1.4.1).
[**Download Halo-Steam-Frame-Mod-Setup-1.4.2.exe**](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.2/Halo-Steam-Frame-Mod-Setup-1.4.2.exe) for Windows 10/11 x64. This is the release's only installer asset. Its SHA256 is listed on the [release page](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/tag/v1.4.2); matching installer source is in the [v1.4.2 tag](https://github.com/ClassicWasTaken/HaloSteamFrameMod/tree/v1.4.2). [Verify release provenance](BUILD_PROVENANCE.md).
This patch fixes VR tutorial behavior, unarmed lighting and setup completion:
- **SD-card installation:** choose Internal storage or SD card on Connect Frame, click **Refresh storage**, and select the detected card. Install, Repair and Uninstall use the selected location. SD cards need a mounted, writable, executable **ext4 or f2fs** filesystem and **12 GiB free**. Setup does not format or mount cards. Missing or changed cards stop the operation; other installations are kept. [SD-card guide](SD_CARD.md).
- **Separate SD library entry:** **Halo: Combat Evolved VR (Native, SD card)** coexists with the internal **Halo: Combat Evolved VR (Native)**. A new SD installation starts separate profiles and campaign saves in its own `save-v1.4`. There is no automatic campaign migration between drives. Steam artwork and Notes stay with your Steam account; Podman's container storage can still use internal space.
- **Menus in front of you:** opening a menu uses your current headset position and gaze. It stays in place while open after walking or turning; closing and reopening captures your new view. Recentring deliberately places an open menu in front again.
- **Cancellation and progress:** a completed user cancellation reports Setup cancelled. Genuine failures, unconfirmed remote stopping and failed disconnect cleanup still need attention. Password redaction no longer interferes with progress-phase lookup. Fixes [#10](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/10) and [#12](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/12).
- **Steam choices preserved:** re-registering an existing shortcut keeps hidden status, desktop configuration, overlay and devkit choices. The VR-library flag remains enabled. Fixes [#11](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/11).
- **Safe repair and upgrade recovery:** malformed or unsupported configuration is rejected with readable preservation messages. An interrupted 1.3.5 upgrade can resume when the owned cache, original backups and replacement files provide consistent proof. Keep those records; damaged or ambiguous evidence requires manual recovery. Fixes [#13](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/13) and [#18](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/18).
- **Bounded SSH waits:** command and SFTP setup handshakes can be cancelled and have deadlines. Continuous or newline-free output cannot indefinitely postpone cancellation or grow its retained buffer. Fixes [#19](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/19) and [#20](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/20).
- **Verified USB tools first:** an explicit adb.exe selection takes priority, followed by the verified bundled tools. SDK/PATH discovery is a fallback when the bundle is absent; a damaged bundle fails closed. This does not authenticate an already-running ADB server. Fixes [#21](https://github.com/ClassicWasTaken/HaloSteamFrameMod/issues/21).
- **Aim before receiving a weapon:** the unarmed/default reticle follows the tracked aiming hand. Equipped weapons retain the existing nominal bullet-hit alignment and render-rate preview.
- **Look at the technician and panel:** person checks use headset gaze. The five tutorial dots also accept the unarmed hand reticle within their original five-degree target cone.
- **Cryopod look calibration:** look tests reset their tracking correctly and accept headset movement and legitimate snap or smooth stick turns. Recentring, menus and cinematics do not count as tutorial progress; AI and remote players retain their behavior.
- **Standing and snap defaults:** Install and Repair apply standing mode and snap turning. Other preferences, including snap angle and refresh rate, remain intact.
- **Inversion prompt buttons:** tutorial Back no longer leaks into melee or other mapped gameplay actions while pressed or held. The reported crash still needs confirmation in the headset test; the profile's inversion-setting and save logic remain intact.
- **Tracking loss:** a failed or partial OpenXR pose query cannot reuse an old valid head or hand pose to complete a tutorial target.
- **Unarmed flashlight:** the beam follows the current rendered controller pose with bounded aim and matching render bounds, instead of the slower simulation attachment. Equipped-weapon flashlight markers retain their behavior.
- **Native build fix:** tutorial button handling uses the engine's existing VR-session function, avoiding the missing-symbol failure at the final linking step. Long build errors retain their failure summary in the setup dialog.
- **Completion display:** an optional Steam shortcut icon that has not loaded no longer holds a successful Install or Repair at 99%. Setup shows Complete at 100%; icon details remain in the activity log.
Thanks to **Dime ([dime-online](https://github.com/dime-online))** for the installer fixes in [PR #14](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/14), [#15](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/15), [#16](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/16), [#17](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/17), [#23](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/23), [#24](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/24) and [#25](https://github.com/ClassicWasTaken/HaloSteamFrameMod/pull/25).
**Update an existing native installation with Repair.** Save and close Halo, then choose Repair to rebuild the patched game using its verified Xbox maps. The game stays at `~/Games/HaloCENativeVR` with the Steam title **Halo: Combat Evolved VR (Native)**. Existing 1.4.0 profiles and checkpoints remain in `save-v1.4`; this patch does not change the save format.
**Updating:** save and close Halo, select its storage location, then choose Repair to rebuild using verified Xbox maps without another ISO. Existing 1.4.0/1.4.1 profiles and checkpoints on that location stay in `save-v1.4`. The 1.4 engine **cannot load old checkpoints** from 1.3.5: that upgrade retains old saves and backs up the original config/program, but requires a new profile and campaign. Separate 1.4.0a2–a5 Experimental installations stay untouched.
Upgrading from 1.3.5 retains its old saves and backs up the original config/program. The 1.4 engine cannot load old checkpoints from 1.3.5, so that upgrade requires a new profile and campaign. Setup does not migrate those checkpoints or profiles. The separate 1.4.0a2–a5 Experimental installations remain untouched.
The [1.4.1 tutorial, tracking and unarmed-flashlight fixes](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/tag/v1.4.1) remain included, with standing mode and snap turning applied on Install/Repair. Source stays pinned to `2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4`, native peer protocol **17**. LAN/online campaign, head-directed walking, Xbox buttons, render-rate reticle and half-strength stereo sun glow are retained. Use matching builds/maps for co-op; retail clients cannot join. Restrictive NAT can prevent Internet connections because there is no gameplay relay.
Source remains pinned to `2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4`, native peer protocol **17**. LAN/online campaign, head-directed walking, Xbox-style buttons, stereo sun glow and automatic Steam artwork/Notes are retained. Use matching builds/maps for co-op; retail Halo clients cannot join. Restrictive NAT can prevent Internet connections because there is no gameplay relay.
The user confirmed the private build's SD installation and menu placement on their headset. Earlier testing confirmed the 1.4.0 baseline's reticle motion and LAN/online campaign. No measured FPS, frame timing or complete new multiplayer/hardware matrix was supplied. See [release provenance](BUILD_PROVENANCE.md) for verification of the exact source commit, build and downloaded artifact.
The earlier headset report verified the 1.4.0 baseline, not these new tutorial or flashlight changes. This patch needs its own headset verification; automated checks do not establish tutorial behavior or Frame performance on hardware.
Supply your own authorized original Xbox Halo CE data. USA Rev 2 was validated with the stable installer. No retail executable, ISO, maps, keys or soundtrack is bundled or downloaded. Halo artwork and licensed components retain their notices. The EXE is unsigned; use the checksum and provenance verification instructions to check its source and artifact.
Supply your own authorized original Xbox Halo CE data. No retail executable, ISO, maps, keys, or soundtrack is bundled or downloaded. Halo identification artwork and licensed components retain their notices. The EXE is unsigned; verify it against the SHA256 shown on the release page.
[Setup guide](SETUP.md) · [Controls](CONTROLS.md) · [Sources and credits](SOURCES.md) · [Third-party notices](THIRD_PARTY.md) · [Previous 1.4.0 release](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/tag/v1.4.0)
[Setup guide](SETUP.md) · [Controls](CONTROLS.md) · [Sources and credits](SOURCES.md) · [Third-party notices](THIRD_PARTY.md)
+25
View File
@@ -0,0 +1,25 @@
# Install Halo VR on an SD card
Version 1.4.2 adds **Internal storage / SD card** selection. [Download the Windows installer](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.2/Halo-Steam-Frame-Mod-Setup-1.4.2.exe) and [verify release provenance](BUILD_PROVENANCE.md).
1. Insert a card that is already prepared and mounted on your Frame with an executable ext4 or f2fs filesystem and at least 12 GiB free.
2. Choose your original Xbox Halo CE image for a new SD installation.
3. On **Connect Frame**, enter your connection details and click **Refresh storage** or **Test connection**.
4. Select **SD card** and choose the detected card. Check its free space and the destination shown before continuing.
5. Install, then launch **Halo: Combat Evolved VR (Native, SD card)** from Steam.
The game, maps, new campaign saves, installation workspace and installer backups use the selected card. Steam artwork and Notes stay with your Steam account. Podman may still need temporary space on internal storage for its compiler container.
**Install, Repair and Uninstall** use the location you select. An SD operation keeps the internal copy. A new SD installation starts its own profiles and campaign saves in `save-v1.4`; setup does not automatically migrate campaigns between drives. Repair keeps existing 1.4.x saves at that selected location. To remove an old internal copy, select **Internal storage**, then **Uninstall** with **Keep saves** checked. Retained save backups stay beside the selected game folder, on that same drive.
If the card is missing or changed, refresh storage and select it again. Setup rejects unmounted, read-only or non-executable cards instead of falling back to the internal drive. It does not format or mount cards for you. Cards using FAT/exFAT need to be prepared with a supported Linux filesystem before this installer can run native programs on them; formatting erases card contents, so keep any files you need first.
Menus use your current headset position and gaze when they open, then stay in place while open. Try opening the pause menu after walking or turning 90°/180°, close it, turn again, and reopen. Check controller navigation and clicking as well as placement; recentering should deliberately place the open menu in front again.
Detection checks Linux mount and physical SD-device metadata. A returning card can be repaired or uninstalled after a normal remount at the same path; a mount change during an operation stops it. Refresh storage after reconnecting. A renamed mount path is not automatically adopted.
The user confirmed SD installation and menu placement on their Frame using the private build. Measured performance and a complete repair/uninstall or multiplayer test matrix were not supplied.
[Setup and co-op](SETUP.md) · [Release notes](RELEASE_NOTES.md)
Implementation references: [Linux mountinfo format](https://www.man7.org/linux/man-pages/man5/proc_pid_mountinfo.5.html), [Linux SD/MMC device attributes](https://www.kernel.org/doc/html/latest/driver-api/mmc/mmc-dev-attrs.html), [Valve's Steam Frame specifications](https://store.steampowered.com/hardware/steamframe).
+26 -18
View File
@@ -1,20 +1,20 @@
# Halo CE Steam Frame VR mod — setup 1.4.1
# Halo CE Steam Frame VR mod — setup 1.4.2
[Back to the project README](../README.md)
1.4.1 updates the native installation at `~/Games/HaloCENativeVR`, keeping the Steam title **Halo: Combat Evolved VR (Native)**. Existing 1.4.0 profiles and saves remain in `save-v1.4`. When upgrading from 1.3.5, its old saves are retained but you need a new profile/campaign for the 1.4 save format.
1.4.2 lets you select **Internal storage or SD card** for Install, Repair and Uninstall. Internal installs stay at `~/Games/HaloCENativeVR` with **Halo: Combat Evolved VR (Native)**. SD installs use the selected card's `Games/HaloCENativeVR` and **Halo: Combat Evolved VR (Native, SD card)**, keeping the internal copy. Existing 1.4.x saves at the selected location remain in `save-v1.4`; a new SD installation starts separate profiles/campaign saves. A 1.3.5 upgrade retains old saves but needs a new profile/campaign.
## Before you begin
Download and run the single [**Halo-Steam-Frame-Mod-Setup-1.4.1.exe**](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.1/Halo-Steam-Frame-Mod-Setup-1.4.1.exe) for Windows 10/11 x64. It includes its runtime and USB transfer tools; no administrator rights, Python installation, installer ZIP, or separate Platform Tools download is needed. Verify its SHA256 against the checksum shown on the [release page](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/tag/v1.4.1).
Download and run the single [**Halo-Steam-Frame-Mod-Setup-1.4.2.exe**](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.2/Halo-Steam-Frame-Mod-Setup-1.4.2.exe) for Windows 10/11 x64. It includes its runtime and USB transfer tools; no administrator rights, Python installation, installer ZIP, or separate Platform Tools download is needed. Check its SHA256 on the [release page](https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/tag/v1.4.2) and follow [Verify release provenance](BUILD_PROVENANCE.md).
Use your own authorized original Xbox Halo CE ISO/XISO or extracted maps. USA Rev 2 was validated with the stable installer; the supported-build table below explains other retail revisions. Halo PC, Xbox 360 Anniversary, MCC, and Quest packages are unsupported. No retail game executable, disc image, maps, product keys, or soundtrack is bundled or downloaded. The project provides no game-download sources.
Halo artwork and trademarks remain copyrighted to their respective owners, and third-party software retains its licenses and notices. This community project is unaffiliated with Microsoft, Xbox, Bungie, or Valve. Its license does not grant rights in retail game data or certify the legal status of upstream source; see [third-party notices](THIRD_PARTY.md).
The Frame needs ARM64 SteamOS, SteamVR/OpenXR, rootless Podman, internet access, and **12 GiB of free internal storage**. Setup checks these before installing. Your Windows computer needs about 2.5 GB of temporary space for maps. The SteamOS root filesystem stays read-only. Podman runs as the ordinary `steamos` host user; dependency installation uses root only inside the rootless container, with `no-new-privileges` retained.
The Frame needs ARM64 SteamOS, SteamVR/OpenXR, rootless Podman, internet access, and **12 GiB free on the selected storage**. Setup checks these before installing. Your Windows computer needs about 2.5 GB of temporary space for maps. The SteamOS root filesystem stays read-only. Podman runs as the ordinary `steamos` host user; dependency installation uses root only inside the rootless container, with `no-new-privileges` retained.
The headset tester confirmed the 1.4.0 baseline's reticle motion and LAN/online campaign co-op. The tutorial and unarmed-flashlight changes in 1.4.1 need their own headset verification; the earlier report does not validate them. No measured FPS, frame timing, peer-distance details, or complete uninstall test was supplied. Earlier white flashes and stuttering have not been separately diagnosed.
The user confirmed SD installation and the new menu placement on their headset with the private 1.4.2 build. Earlier testing confirmed the 1.4.0 baseline's reticle motion and LAN/online campaign co-op. Measured FPS, frame timing, distant-peer details and a complete new hardware test matrix were not supplied. Earlier white flashes and stuttering have not been separately diagnosed.
The executable uses the classic Halo PC Master Chief helmet-and-shoulders icon. Its [source and hashes](../resources/ui/README.md) are documented separately from the installer's code license. The Steam library's project-drawn helmet icon is unchanged.
@@ -22,9 +22,9 @@ The executable uses the classic Halo PC Master Chief helmet-and-shoulders icon.
The source is pinned to `2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4`. It includes upstream network campaign co-op, delayed texture-stage binding, and vertex clip-position handling changes. These renderer changes are a candidate improvement; they do not establish the cause or resolution of reported white flashes or frame drops.
1.4.1 uses **`~/Games/HaloCENativeVR`** and the Steam title **Halo: Combat Evolved VR (Native)**. It keeps the 1.4.0 save format and **`~/Games/HaloCENativeVR/save-v1.4`**; existing 1.4.0 profiles and checkpoints do not require migration. Old 1.3.5 saves remain unchanged in their original `save` folder or configured location. The original config and program files are backed up in the owned installer run's `previous-program-files` folder. The 1.4 engine's larger game-state capacity makes 1.3.5 checkpoints incompatible. When upgrading from 1.3.5, create a new profile and campaign; setup does not automatically migrate those checkpoints or profiles.
1.4.2 uses **`~/Games/HaloCENativeVR`** and the Steam title **Halo: Combat Evolved VR (Native)**. It keeps the 1.4.0 save format and **`~/Games/HaloCENativeVR/save-v1.4`**; existing 1.4.0/1.4.1 profiles and checkpoints do not require migration. Old 1.3.5 saves remain unchanged in their original `save` folder or configured location. The original config and program files are backed up in the owned installer run's `previous-program-files` folder. The 1.4 engine's larger game-state capacity makes 1.3.5 checkpoints incompatible. When upgrading from 1.3.5, create a new profile and campaign; setup does not automatically migrate those checkpoints or profiles.
Existing 1.4.0a2–a5 installations at `~/Games/HaloCENativeVRExperimental`, with the Steam title **Halo: Combat Evolved VR (Experimental)**, remain untouched. You can keep that tested copy for reference. The public 1.4.1 installer targets the normal native installation.
Existing 1.4.0a2–a5 installations at `~/Games/HaloCENativeVRExperimental`, with the Steam title **Halo: Combat Evolved VR (Experimental)**, remain untouched. You can keep that tested copy for reference. The public 1.4.2 installer targets the internal or SD location you select.
With `vr.aim = "controller"`, **`vr.movement = "head"`** makes the left stick follow the headset's horizontal facing on foot. The right controller still aims the weapon. `vr.movement = "aim"` restores the previous aim-directed movement; `HALO_VR_MOVEMENT=aim` is the equivalent environment override. The movement patch excludes menus, flat mode, gamepad aiming, cinematics, remote players, and vehicle steering. Invalid tracking pauses head-directed movement rather than using an untrusted direction. The Xbox bumper layout and physical-head tutorial tests remain included.
@@ -34,7 +34,7 @@ The build stages `brokers.txt` for internet invite/server discovery. The relativ
## Online campaign co-op with a friend
Both players should use **the same 1.4.1 build** and supply their own supported original Xbox game data. Each player runs the game on their own headset or computer; campaign has one local player per machine. Use the left d-pad to navigate rows and change left/right choices, **A** to accept, and **B** to go back.
Both players should use **the same 1.4.2 build** and supply their own supported original Xbox game data. Each player runs the game on their own headset or computer; campaign has one local player per machine. Use the left d-pad to navigate rows and change left/right choices, **A** to accept, and **B** to go back.
1. **Host:** open **Multiplayer > CO-OP CAMPAIGN**. On the same network choose **HOST LOCAL (LAN)**; across different networks choose **HOST ONLINE (INTERNET)**. The Map screen starts on **SINGLEPLAYER**. Choose a campaign level and difficulty.
2. In **Server Setup**, set **MAXIMUM PLAYERS** to 2 for two players, then select **START GAME**. For online browser discovery, change **LISTING** to **PUBLIC**. **PUBLIC lets anyone see and join the game.** Online co-op starts **PRIVATE** by default; setup does not force public listing.
@@ -48,6 +48,10 @@ If a game is visible but joining says **FAILED**, check matching builds/maps fir
## Install Halo VR
On **Connect Frame**, click **Refresh storage** or **Test connection**, then choose **Internal storage** or a detected **SD card** and check the displayed destination. This selection applies to Install, Repair and Uninstall. SD cards require a writable, executable **ext4 or f2fs** filesystem mounted by SteamOS and **12 GiB free**; setup does not format or mount cards. Game files, new saves, the build workspace and backups use the selected drive. Steam artwork and Notes stay in the active account on internal storage. Podman can still need internal space for its compiler container. [SD-card guide](SD_CARD.md).
Menus capture your current headset position and gaze when opened, then stay in place while open after a turn. Closing/reopening captures the new view; recentering deliberately places an open menu in front again.
1. **Game data.** Choose **Install**. For a first installation, use **Choose ISO** or **Choose maps folder** to select an original **Xbox** Halo CE `.iso` / `.xiso`, or its extracted `maps` folder, that you are authorized to use. Setup validates the retail Xbox map format and extracts only the required maps. A recognized, verified 1.3.5 installation can reuse its Xbox maps without another ISO; see [upgrade instructions](#already-have-halo-installed). Halo PC, Xbox 360 Anniversary, MCC, and Quest packages are not inputs for this build. The repository and executable contain no commercial game data or download links for it. Click **Continue** to move to the next step.
2. **Connect Frame.** Enable Developer Mode and set the Frame's user password, then choose **Network (Wi-Fi or Ethernet)** or **USB-C cable**. For network transfer, keep both devices on a network that permits connections between them and enter the Frame's hostname/IP. For USB, connect a data-capable cable directly to the PC and approve device authorization on the headset if prompted; no Frame IP is required. The SSH login remains `steamos`, port 22; username and port are under **Advanced connection settings**. Click **Test connection** and approve the displayed host fingerprint only after identifying your device. Your password is used in memory and is never saved or logged.
3. **Install Halo VR.** Save and close games, confirm **I have saved and closed games on my Frame**, then click **Install Halo VR**. Keep Steam Home and SteamVR running, and leave the Frame awake and connected. The app uploads or verifies maps, creates a rootless build container, compiles the pinned VR source, applies the controls patch, and registers **Halo: Combat Evolved VR (Native)** through the running Steam client when its verified library interface is available. Any remaining shortcut or artwork step is shown at the end; it does not require rebuilding the game. The first build may take tens of minutes. Activity opens automatically and shows the current phase and live build output; use **Save log** for troubleshooting.
@@ -56,7 +60,7 @@ If a game is visible but joining says **FAILED**, check matching builds/maps fir
1. In the Frame's **Steam Settings > System**, enable **Developer Mode**. In **Developer**, set a user password. Leave Steam Home and SteamVR running.
2. Connect the Frame directly to your Windows PC with a USB data cable. A charging-only cable cannot transfer files. Keep the Frame awake and allow this computer's device authorization on the headset if prompted.
3. In **Connect Frame**, choose **USB-C cable**. Setup first uses ADB already available on your computer's `PATH`, then falls back to its included ADB 37.0.1 files. **Advanced connection settings > Browse adb.exe** can select a specific installation. If you need replacement tools, use [Google's official Windows Platform Tools download and terms](https://developer.android.com/tools/releases/platform-tools), extract the complete package, and choose its `platform-tools\adb.exe`, keeping the companion DLLs next to it. Setup does not download SDK tools during an installation.
3. In **Connect Frame**, choose **USB-C cable**. **Advanced connection settings > Browse adb.exe** selects an explicit installation first. Otherwise setup uses its verified included ADB 37.0.1 files before SDK/PATH fallback. A damaged bundle is rejected. If you need replacement tools, use [Google's official Windows Platform Tools download and terms](https://developer.android.com/tools/releases/platform-tools), extract the complete package, and choose its `platform-tools\adb.exe`, keeping the companion DLLs next to it. Setup does not download SDK tools during an installation.
4. Enter the same Frame user password used for SSH and click **Test connection**. Setup selects the sole physical USB device automatically. If several physical USB devices are connected, unplug the others or enter the Frame's exact ADB serial in **Advanced connection settings**. If a serial cannot be verified as a physical USB connection, unplug other devices and leave the serial field blank for automatic detection. Emulators and network-connected ADB devices are not USB targets. Review the Frame's SSH host fingerprint when asked.
5. Continue with install, repair, library registration, or uninstall. Leave the cable attached until setup reports that it has disconnected. Setup removes only the temporary ADB forward it created and leaves the shared ADB server and other forwards running.
@@ -66,7 +70,7 @@ The installation bar fills from left to right and does not move backward during
Setup closes its SFTP and command channels and the underlying SSH connection after each operation, including failure and cancellation. The activity feed shows disconnecting and disconnected stages when cleanup succeeds. A connection-cleanup failure is shown separately from the game result: an installed game remains installed, and a completed removal remains recorded. Follow the displayed connection-cleanup instructions before closing setup; a remaining cleanup step stays at 99%. USB cleanup removes only this operation's temporary ADB forward. Setup does not stop the shared ADB server or change the Frame's Wi-Fi or Steam streaming settings.
If a prepared build is cancelled, times out, or loses its connection, setup tries to stop that same installer run. If the existing stop request fails, it makes one bounded reconnect attempt using the SSH host key you already approved. It does not approve a different key. If the Frame cannot confirm stopping, setup warns that the build may still be running and provides the exact stop command for the Frame's terminal. Restore the connection and follow that command before retrying installation or uninstalling. The original cancellation or failure remains visible even if its stop request or connection cleanup also fails.
If a prepared build is cancelled, times out, or loses its connection, setup tries to stop that same installer run. If the existing stop request fails, it makes one bounded reconnect attempt using the SSH host key you already approved. It does not approve a different key. If the Frame cannot confirm stopping, setup warns that the build may still be running and provides the exact stop command for the Frame's terminal. Restore the connection and follow that command before retrying installation or uninstalling. A completed cancellation reports **Setup cancelled** without a failure dialog. Genuine failures, failed cleanup and unconfirmed remote stopping still require attention. SSH setup waits are bounded, and cancellation remains responsive during continuously streamed output.
The included ADB executable, two companion DLLs, and unmodified notices are checksum-verified before being copied to `%LOCALAPPDATA%/HaloFrameInstaller/usb-tools/37.0.1`. This versioned cache remains after setup closes so a shared ADB server can keep using it without locking the EXE's temporary extraction folder. The selected client's protocol is checked against an already-running local ADB server; a mismatch stops USB setup without restarting that server. Setup also verifies that the temporary forwarded port listens only on this PC's loopback addresses before sending the SSH password. [Bundled-tool provenance and licenses](THIRD_PARTY.md#adb-for-usb-transfer).
@@ -76,7 +80,7 @@ After you select valid local Xbox game data, the installer automatically loops t
### Supported Xbox ISO revisions
Use **Halo: Combat Evolved for the original Xbox**. **USA Rev 2 was validated with the stable installer.** 1.4.1 retains its map-header checks. Other retail revisions, including an original USA release or an image labeled **USA Rev 1**, and PAL releases are accepted when their actual map headers match one of these supported builds:
Use **Halo: Combat Evolved for the original Xbox**. **USA Rev 2 was validated with the stable installer.** 1.4.2 retains its map-header checks. Other retail revisions, including an original USA release or an image labeled **USA Rev 1**, and PAL releases are accepted when their actual map headers match one of these supported builds:
| Map-header build | Upstream region |
| --- | --- |
@@ -88,13 +92,15 @@ These builds come from the [pinned port's compatibility table](https://github.co
### Already have Halo installed?
To update an existing installation, close **Halo: Combat Evolved VR (Native)** and choose **Repair**. Setup rebuilds the native program and applies the 1.4.1 patch, reusing verified Xbox maps without another ISO. Install also recognizes a verified 1.3.5 installation and upgrades its program in place. Setup replaces the native program, SDL library, broker list, patch, and notices. If maps are missing or damaged, select valid local data and retry. An unknown folder can be adopted only through explicit Repair after its native executable and Xbox maps validate; other locations are left alone.
To update an existing installation, close **Halo: Combat Evolved VR (Native)** and choose **Repair**. Setup rebuilds the native program and applies the 1.4.2 patch, reusing verified Xbox maps without another ISO. Install also recognizes a verified 1.3.5 installation and upgrades its program in place. Setup replaces the native program, SDL library, broker list, patch, and notices. If maps are missing or damaged, select valid local data and retry. An unknown folder can be adopted only through explicit Repair after its native executable and Xbox maps validate; other locations are left alone.
The update keeps existing 1.4.0 profiles/checkpoints in `save-v1.4` and backs up the original config and program in the owned build run's `previous-program-files` folder. Upgrading from 1.3.5 retains its old saves at their original location and selects `save-v1.4` for a new profile/campaign; there is no automatic migration of 1.3.5 checkpoints or profiles. Repairs keep unrelated settings. A failed replacement rolls back the affected program/config files. The separate 1.4.0a2–a5 Experimental copy is not removed or upgraded by this installer.
The update keeps existing 1.4.0/1.4.1 profiles/checkpoints in `save-v1.4` and backs up the original config and program in the owned build run's `previous-program-files` folder. Upgrading from 1.3.5 retains its old saves at their original location and selects `save-v1.4` for a new profile/campaign; there is no automatic migration of 1.3.5 checkpoints or profiles. Repairs keep unrelated settings. A failed replacement rolls back affected program/config files while the selected mount remains valid. If an SD card changes, setup stops and keeps its original backups rather than restoring onto another mount. The separate 1.4.0a2–a5 Experimental copy is not removed or upgraded by this installer.
An interrupted 1.3.5 upgrade can be recovered when setup verifies the original owned cache, program/config backups and installed replacements. Keep the game folder and installer cache, then retry Install or Repair. Recovery preserves both save generations and recorded custom save locations. Missing, changed or ambiguous evidence is refused with a preservation message; some older repeated interruptions still need manual recovery. Malformed configuration is also rejected without resetting preferences or save paths.
The old PC installation at `~/Games/HaloCEVR` is detected and explained separately. Its PC maps cannot be used for this native Xbox build. Setup installs the native version separately and does not automatically remove the PC version. Close the native game before repair. Program-file backups are retained in the owned build workspace, and a failed repair rolls back the affected files. **Add to Steam again** repairs library registration and adds missing library artwork; it does not rebuild the game.
With Steam running, setup uses the current client's library interface and verifies the native executable, launch options, VR flag, and disabled compatibility tool before reporting registration success. This interface is undocumented and may change with Steam updates. If it is unavailable, ambiguous, or cannot verify the result within its time limit, setup keeps the headset session running and shows manual steps. Use Steam's **Add a Game > Add a Non-Steam Game** to select `~/Games/HaloCENativeVR/halo`, apply the launch option below, and enable **Include in VR Library**. Leave forced compatibility tools disabled for this native Linux executable. **Add to Steam again** retries available library setup without rebuilding. If Steam is already closed, setup can use backed-up atomic library-file updates; it never shuts down, launches, or restarts the headset's Steam session.
With Steam running, setup uses the current client's library interface and verifies the native executable, launch options, VR flag, and disabled compatibility tool before reporting registration success. This interface is undocumented and may change with Steam updates. If it is unavailable, ambiguous, or cannot verify the result within its time limit, setup keeps the headset session running and shows manual steps. Use Steam's **Add a Game > Add a Non-Steam Game** to select the executable path shown by setup (`~/Games/HaloCENativeVR/halo` for internal storage, or the selected card's `Games/HaloCENativeVR/halo`), apply the launch option below, and enable **Include in VR Library**. Leave forced compatibility tools disabled for this native Linux executable. **Add to Steam again** retries available library setup without rebuilding. If Steam is already closed, setup can use backed-up atomic library-file updates; it never shuts down, launches, or restarts the headset's Steam session.
### Automatic Steam library information
@@ -104,9 +110,9 @@ Open **Notes** on the Halo game's Steam details page or in its overlay to read t
### Uninstall Halo VR
Choose **Uninstall** on the first page to remove the recognized, owned native installation at `~/Games/HaloCENativeVR` and its native Steam shortcut. You do not need an ISO or local maps for uninstall, but you must connect over SSH with the Frame's user password. Save and close games; keep Steam Home and SteamVR running. Setup matches native shortcuts by their exact executable, so renaming the entry in Steam does not prevent removal. It verifies shortcut removal before deleting game files. If the client interface is unavailable or removal cannot be verified, setup keeps the native game files and stops at 99%, explaining Steam's **Remove Non-Steam Game** option. Invalid matching shortcut records also stop removal before library-file changes. An owned installation build still running stops removal before deletion. Setup never shuts down or relaunches Steam. If Steam is already closed, automatic removal can use its backed-up library-file path across the local accounts.
Choose **Uninstall** on the first page, then select **Internal storage** or the detected **SD card** on Connect Frame. Setup removes only that location's recognized native installation and exact Steam shortcut. You do not need an ISO or local maps for uninstall, but you must connect over SSH with the Frame's user password. Save and close games; keep Steam Home and SteamVR running. Setup matches native shortcuts by their exact executable, so renaming the entry in Steam does not prevent removal. It verifies shortcut removal before deleting game files. If the client interface is unavailable or removal cannot be verified, setup keeps the native game files and stops at 99%, explaining Steam's **Remove Non-Steam Game** option. Invalid matching shortcut records also stop removal before library-file changes. An owned installation build still running stops removal before deletion. Setup never shuts down or relaunches Steam. If Steam is already closed, automatic removal can use its backed-up library-file path across the local accounts.
**Keep my campaign saves in a backup (recommended)** is checked by default. Setup backs up recognized saves inside the native game folder, including retained `save` and active `save-v1.4` data, together with the game's config to `~/Games/HaloCENativeVR-saves-<operation ID>`. It reports the exact backup location. The recognized native folder, including its maps, program files, and config, is then removed; configured saves outside that folder remain untouched. Turning off the checkbox allows saves inside the removed folder to be deleted. Uninstall leaves unrelated games, foreign or unrecognized folders, and unrelated settings alone. The build cache remains available for troubleshooting. Setup disconnects its SSH session after the operation and reaches 100% only after completion.
**Keep my campaign saves in a backup (recommended)** is checked by default. Setup backs up recognized saves inside the selected native game folder, including retained `save` and active `save-v1.4` data, together with the game's config to `Games/HaloCENativeVR-saves-<operation ID>` beside that game on the same drive. It reports the exact backup location. The recognized native folder, including its maps, program files, and config, is then removed; configured saves outside that folder remain untouched. Turning off the checkbox allows saves inside the removed folder to be deleted. Uninstall leaves the other drive's installation, unrelated games, foreign or unrecognized folders, and unrelated settings alone. The build cache remains available for troubleshooting. Setup disconnects its SSH session after the operation and reaches 100% only after completion.
If an earlier uninstall was interrupted, run **Uninstall** again. Setup can finish deleting its verified unfinished game folders even when the main game folder is gone or Halo has since been reinstalled. It checks the earlier operation's private record and the remaining folder's identity before resuming. Verified save backups remain in place, and their paths appear in the result. Changed or unverified folders are preserved and listed for review; setup reports removal pending at 99% instead of claiming the game was already absent. Review **Show activity** and **Save log** for these paths. Do not delete a reported save backup. If setup reports that it reached its recovery limit, run Uninstall again to continue; folders that fail verification need manual review.
@@ -155,6 +161,7 @@ The renderer uses the Xbox game's data and translated Xbox shaders. Native OpenX
- [Valve's official Frame setup](https://partner.steamgames.com/doc/steamhardware/steamframe/setup) and [SSH/debugging instructions](https://partner.steamgames.com/doc/steamhardware/steamframe/debugging).
- [Google's ADB connection and forwarding reference](https://developer.android.com/tools/adb) and [official Platform Tools download and terms](https://developer.android.com/tools/releases/platform-tools).
- [Original Xbox dashboard visual references](SOURCES.md#dashboard-visual-references) and [Halo CE artwork attribution and Steam Notes references](SOURCES.md#steam-library-artwork).
- [Dime (dime-online)](https://github.com/dime-online), for the 1.4.2 installer fixes credited in the [release notes](RELEASE_NOTES.md).
Full source attribution, decompilation lineage, multiplayer references, and third-party licenses are in [SOURCES.md](SOURCES.md) and [THIRD_PARTY.md](THIRD_PARTY.md). Original installer code is MIT licensed. Upstream notices apply separately; no license here grants rights to commercial game data.
@@ -171,6 +178,7 @@ Full source attribution, decompilation lineage, multiplayer references, and thir
- **Authentication fails:** enter the password set in the Frame's Developer settings; your Steam account password is unrelated.
- **Host key changed:** setup refuses the connection. Verify the new device/key independently before removing its old public fingerprint from `%LOCALAPPDATA%/HaloFrameInstaller/hosts.json`.
- **Insufficient disk space or Podman missing:** resolve the failed preflight check and retry. Setup does not disable SteamOS protections or install system packages as root.
- **SD card missing or changed:** insert the same prepared card, click **Refresh storage**, and check the selected destination before retrying. Setup rejects unmounted, read-only, non-executable or unsupported filesystems. It does not move the game to internal storage. Keep the card inserted while launching or playing its SD installation. [SD-card guide](SD_CARD.md).
- **Build/download fails:** the error dialog shows a short failure headline; open **Show activity** or **Save log** for the full redacted details. Keep the Frame's build log and retry. Version 1.2.1 fixes the container dependency-installation permission errors (`setgroups`, `seteuid`, or APT cache permissions) seen in 1.2.0. Retry can reuse previously uploaded maps only when the complete map set matches the selected local data manifest and passes header and SHA256 verification; otherwise setup uploads fresh data. Reused data stays in its previous owned upload run and is verified again at build and finalization. A failed staging build does not replace an existing game or save.
- **Cancelled, but remote stopping is unconfirmed:** the build may still be running. Restore the connection and run the exact stop command shown by setup in the Frame's terminal before installing or uninstalling again. Setup's recovery connection accepts only your previously approved host key.
- **Game operation finished, connection cleanup pending:** follow the separate cleanup warning and save the activity log. The game outcome is retained; a USB-forward cleanup error does not undo an installation or completed uninstall. Setup leaves the shared ADB server and unrelated forwards alone.
@@ -183,7 +191,7 @@ Full source attribution, decompilation lineage, multiplayer references, and thir
- **Steam Home remains on a loading logo after an older installer run:** 1.2.3 removes the automatic shutdown request that disrupted the SteamOS-managed session in a reported case. It does not repair a session already in a failed state. Restart the Frame using its normal power controls, and contact Steam Support if Steam Home does not return. [Steam session safeguard and diagnostic sources](SOURCES.md#steam-session-safeguard).
- **Menu music unavailable:** select valid local original Xbox data containing `ui.map`; music starts automatically after validation. A repair using only remote maps cannot preview music. Playback errors do not block setup; Windows volume controls the final sound level.
The executable is unsigned. Verify its SHA256 against the checksum shown on the release page; this project does not ask you to disable antivirus or other security protections. Review logs for private information before sharing them.
The executable is unsigned. Follow [Verify release provenance](BUILD_PROVENANCE.md) to check the downloaded artifact and its build source; this project does not ask you to disable antivirus or other security protections. Review logs for private information before sharing them.
## Develop and rebuild the executable
@@ -195,7 +203,7 @@ python -m venv .venv
.\.venv\Scripts\python scripts/build_release.py
```
The build writes a matching installer source bundle and `SHA256SUMS.txt` to `dist/` alongside the EXE. These remain local build outputs. The public release uploads one EXE and lists its SHA256 in the release notes; matching installer source is available in the [v1.4.1 Git tag](https://github.com/ClassicWasTaken/HaloSteamFrameMod/tree/v1.4.1), with GitHub's automatic source downloads available for developers. Applicable third-party source/notices are included for rebuilding, including the LGPL Paramiko dependency. ADB binaries are not checked into Git; [bundle_usb_tools.py](../scripts/bundle_usb_tools.py) downloads the pinned official archive during the executable build and verifies its archive and selected-file checksums before packaging. [resources/usb/manifest.json](../resources/usb/manifest.json) records those hashes and provenance. The package contains installer code/resources and verified tools with their notices; no retail executable, ISO, maps, SSH password, or private key is included.
The build writes a matching installer source bundle and `SHA256SUMS.txt` to `dist/` alongside the EXE. These remain local build outputs. The public release uploads one EXE and lists its SHA256 in the release notes; matching installer source is available in the [v1.4.2 Git tag](https://github.com/ClassicWasTaken/HaloSteamFrameMod/tree/v1.4.2), with GitHub's automatic source downloads available for developers. Applicable third-party source/notices are included for rebuilding, including the LGPL Paramiko dependency. ADB binaries are not checked into Git; [bundle_usb_tools.py](../scripts/bundle_usb_tools.py) downloads the pinned official archive during the executable build and verifies its archive and selected-file checksums before packaging. [resources/usb/manifest.json](../resources/usb/manifest.json) records those hashes and provenance. The package contains installer code/resources and verified tools with their notices; no retail executable, ISO, maps, SSH password, or private key is included.
To check an exact local engine checkout without connecting a headset or downloading data, run `python scripts/check-experimental-engine.py PATH_TO_ENGINE_CHECKOUT`. The checker accepts a clean or fully patched checkout at the pinned revision, checks patch applicability in a private temporary projection, and verifies renderer/co-op/protocol/control integration. It generates the ARM64 Ninja graph offline with external downloads disabled; this is a source/configuration check, not an ARM64 compilation or headset test.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "halo-steam-frame-installer"
version = "1.4.1"
version = "1.4.2"
description = "Community Halo CE VR mod installer for Steam Frame; original Xbox game data must be supplied separately"
requires-python = ">=3.11"
license = "MIT"
+12 -1
View File
@@ -17,6 +17,11 @@ apt-get install -y --no-install-recommends ca-certificates curl gnupg git python
phase toolchain 'Downloading and installing the LLVM 22 ARM64 compiler...'
curl --fail --silent --show-error --location https://apt.llvm.org/llvm-snapshot.gpg.key \
-o /build/llvm-archive-key.asc
# Check the primary signing identity published by LLVM before trusting the key.
# A refreshed self-signature/subkey is allowed; a different primary key is not.
llvm_primary_fingerprints=$(gpg --batch --with-colons --show-keys --fingerprint /build/llvm-archive-key.asc | \
awk -F: '$1 == "pub" { primary = 1; next } primary && $1 == "fpr" { print $10; primary = 0 }')
test "$llvm_primary_fingerprints" = '6084F3CF814B57C1CF12EFD515CF4D18AF4F7421'
gpg --batch --yes --dearmor --output /usr/share/keyrings/llvm-archive-keyring.gpg \
/build/llvm-archive-key.asc
printf '%s\n' 'deb [arch=arm64 signed-by=/usr/share/keyrings/llvm-archive-keyring.gpg] https://apt.llvm.org/jammy/ llvm-toolchain-jammy-22 main' \
@@ -28,8 +33,11 @@ for tool in clang ld.lld llvm-ar; do
done
clang --version
cd /build/src
phase configure 'Checking Xbox controls, VR tutorial, unarmed flashlight, render-rate reticle, stereo sun glow and LAN/online campaign menus...'
phase configure 'Verifying pinned build-source download safeguards...'
python3 tools/test_build_sources.py
phase configure 'Checking Xbox controls, front-facing VR menus, tutorial, unarmed flashlight, render-rate reticle, stereo sun glow and LAN/online campaign menus...'
python3 tools/test_vr_locomotion.py --cc clang
python3 tools/test_vr_menu.py --cc clang
python3 tools/test_vr_tutorial.py --cc clang
python3 tools/test_vr_tracking.py --cc clang
python3 tools/test_vr_tutorial_buttons.py --cc clang
@@ -39,6 +47,9 @@ python3 tools/test_vr_projectile_reticle.py --cc clang
python3 tools/test_online_coop_menu.py --cc clang
phase configure 'Configuring the native ARM64 OpenXR game and downloading its build sources...'
python3 configure.py --release --vr --linux-arm64-cc clang
# Record signed-package versions. Security/maintenance updates within Ubuntu
# 22.04 and LLVM22 are intentional; this is not a reproducible-build promise.
dpkg-query -W -f='${Package} ${Version}\n'
export CMAKE_BUILD_PARALLEL_LEVEL=4
export NINJAFLAGS=-j4
phase sdl 'Configuring and compiling SDL3; the native game build follows...'
+745 -11
View File
@@ -1310,7 +1310,7 @@ index c23181bd889e417aa70dd1b549765265a254ba5c..f997e57f79e1253b8e36528e32ad92f2
"gamepad's Back, held) is the player's eye. \"standing\": the head\n"
"vr.player_height above the floor is." },
diff --git a/port/linux/src/vr.c b/port/linux/src/vr.c
index 89cfb6f0d81d1d406b6b9cff2e76a66129202b4a..110884526b023bccde19ff04b657867e1bb482b6 100644
index 89cfb6f0d81d1d406b6b9cff2e76a66129202b4a..1f995a3946f537e589e818f3203900c00acd8410 100644
--- a/port/linux/src/vr.c
+++ b/port/linux/src/vr.c
@@ -31,6 +31,7 @@ it or in steps ("level", "snap"). The controllers are a gamepad's halves.
@@ -1336,7 +1336,18 @@ index 89cfb6f0d81d1d406b6b9cff2e76a66129202b4a..110884526b023bccde19ff04b657867e
int snap_latched;
float recentre_time;
/* a gamepad's Back held (halo_vr_gamepad_back), and for how long */
@@ -153,6 +162,7 @@ static struct
@@ -109,6 +118,10 @@ static struct
int acquired[VR_SWAPCHAIN_COUNT];
int force_render;
int menus;
+ /* A menu is placed once when opened, independently of the gameplay
+ origin. It stays in the play space until closed or deliberately recentred. */
+ int menu_open;
+ struct vr_host_pose menu_pose;
/* the eyes' depth this frame, and its range */
int depth_written;
/* the melee gesture: the right hand's last position, and the frames
@@ -153,6 +166,7 @@ static struct
float menu_width;
int controller_aim;
int gamepad_aim;
@@ -1344,7 +1355,7 @@ index 89cfb6f0d81d1d406b6b9cff2e76a66129202b4a..110884526b023bccde19ff04b657867e
/* vr.gamepad_view: the view's heading in steps ("snap"), and pitched
with the facing ("camera") */
int gamepad_snap;
@@ -236,6 +246,7 @@ static void settings_read(void)
@@ -236,6 +250,7 @@ static void settings_read(void)
settings.menu_width = (float)config_real("vr.menu_width");
settings.gamepad_aim = !strcmp(config_string("vr.aim"), "gamepad");
settings.controller_aim = !settings.gamepad_aim && strcmp(config_string("vr.aim"), "head") != 0;
@@ -1352,7 +1363,7 @@ index 89cfb6f0d81d1d406b6b9cff2e76a66129202b4a..110884526b023bccde19ff04b657867e
settings.gamepad_snap = !strcmp(config_string("vr.gamepad_view"), "snap");
settings.gamepad_pitch = !settings.gamepad_snap && strcmp(config_string("vr.gamepad_view"), "level") != 0;
settings.melee_gesture = config_boolean("vr.melee_gesture");
@@ -395,6 +406,7 @@ static void recentre(void)
@@ -395,10 +410,12 @@ static void recentre(void)
else if (vr.origin_valid)
vr.body_yaw += yaw - vr.origin_yaw;
vr.origin_yaw = yaw;
@@ -1360,7 +1371,12 @@ index 89cfb6f0d81d1d406b6b9cff2e76a66129202b4a..110884526b023bccde19ff04b657867e
vr.origin_position[0] = head->position[0];
vr.origin_position[1] = settings.standing ? settings.player_height : head->position[1];
vr.origin_position[2] = head->position[2];
@@ -511,6 +523,43 @@ static void weapon_pointing(float forward[3], float up[3])
vr.origin_valid = 1;
+ vr.menu_pose.valid = 0;
platform_log("vr: recentred (%s)", settings.standing ? "standing" : "seated");
}
@@ -511,6 +528,43 @@ static void weapon_pointing(float forward[3], float up[3])
}
/* the controllers' turning and recentring, once a frame */
@@ -1404,7 +1420,7 @@ index 89cfb6f0d81d1d406b6b9cff2e76a66129202b4a..110884526b023bccde19ff04b657867e
static void input_update(void)
{
unsigned long now = GetTickCount();
@@ -564,20 +613,7 @@ static void input_update(void)
@@ -564,20 +618,7 @@ static void input_update(void)
{
vr.gamepad_recentre_time = 0.0f;
}
@@ -1426,7 +1442,117 @@ index 89cfb6f0d81d1d406b6b9cff2e76a66129202b4a..110884526b023bccde19ff04b657867e
/* (not while the left hand holds a long gun's foregrip) */
if (vr.input.left_grip > 0.8f && vr.input.right_grip > 0.8f && !vr.two_handed)
{
@@ -1044,6 +1080,126 @@ int halo_vr_view(struct halo_vr_view *view)
@@ -863,6 +904,51 @@ int halo_vr_frame_active(void)
return vr.frame && vr.views.should_render;
}
+/* Capture only a fresh, focused pose. Menus can open without a gameplay
+camera (the main menu or postgame), so they do not depend on aiming or the
+recentred gameplay origin. Use the full orientation to remain in front even
+when looking up or down, then keep the quad fixed while the menu is open. */
+static int menu_tracking_valid(float orientation[4])
+{
+ const struct vr_host_pose *head = &vr.views.head;
+ int axis;
+
+ if (!vr.initialized || !vr.frame || !vr.views.should_render || !vr.views.focused || !head->valid ||
+ !halo_vr_locomotion_orientation(head->orientation, orientation))
+ return 0;
+ for (axis = 0; axis < 3; axis++)
+ if (!isfinite(head->position[axis]))
+ return 0;
+ return 1;
+}
+
+static int menu_place(void)
+{
+ static const float forwards[3] = { 0.0f, 0.0f, -1.0f };
+ const struct vr_host_pose *head = &vr.views.head;
+ struct vr_host_pose pose;
+ float pointing[3];
+ int axis;
+
+ if (!menu_tracking_valid(pose.orientation) || !isfinite(settings.menu_distance) || settings.menu_distance <= 0.0f)
+ {
+ vr.menu_pose.valid = 0;
+ return 0;
+ }
+ if (vr.menu_pose.valid)
+ return 1;
+ quaternion_rotate(pose.orientation, forwards, pointing);
+ for (axis = 0; axis < 3; axis++)
+ {
+ pose.position[axis] = head->position[axis] + pointing[axis] * settings.menu_distance;
+ if (!isfinite(pose.position[axis]))
+ return 0;
+ }
+ pose.valid = 1;
+ vr.menu_pose = pose;
+ return 1;
+}
+
/* The frame is ended (xrEndFrame) only when the next begins, as the game
starts drawing it: SteamVR's xrEndFrame waits for the GPU to finish the
frame, and in the meantime the game updates (input, ticks, sounds). */
@@ -876,6 +962,15 @@ void halo_vr_present(int eyes_drawn, int hud_drawn)
gamepad goes by this frame's) */
vr.aimed = vr.aiming;
vr.aiming = 0;
+ if (vr.menu_open)
+ {
+ float orientation[4];
+
+ /* Even a blank or suppressed frame can lose tracking or focus.
+ The next valid frame places the menu for the current wearer. */
+ if (!menu_tracking_valid(orientation))
+ vr.menu_pose.valid = 0;
+ }
if (!vr.frame)
return;
memset(pending, 0, sizeof(*pending));
@@ -889,11 +984,13 @@ void halo_vr_present(int eyes_drawn, int hud_drawn)
vr.depth_far > vr.depth_near;
pending->near_z = vr.depth_near;
pending->far_z = vr.depth_far;
- if (pending->projection && !vr.menus)
+ if (eyes_drawn && !vr.menus)
{
/* the HUD before the eyes, where the HUD's camera looks */
float width = 2.0f * settings.hud_distance * tanf(settings.hud_size * 0.5f * (float)M_PI / 180.0f);
+ vr.menu_open = 0;
+ vr.menu_pose.valid = 0;
pending->hud_head_locked = 1;
pending->hud_pose.orientation[3] = 1.0f;
pending->hud_pose.position[2] = -settings.hud_distance;
@@ -902,14 +999,18 @@ void halo_vr_present(int eyes_drawn, int hud_drawn)
}
else
{
- /* the menus, in front of where the head was at the recentring */
- float s = sinf(vr.origin_yaw * 0.5f), c = cosf(vr.origin_yaw * 0.5f);
-
- pending->hud_pose.orientation[1] = s;
- pending->hud_pose.orientation[3] = c;
- pending->hud_pose.position[0] = vr.origin_position[0] - sinf(vr.origin_yaw) * settings.menu_distance;
- pending->hud_pose.position[1] = settings.standing ? settings.player_height : vr.origin_position[1];
- pending->hud_pose.position[2] = vr.origin_position[2] - cosf(vr.origin_yaw) * settings.menu_distance;
+ /* Capture at the first actually displayed menu, not during a
+ blank loading frame. A missing eye image is still a gameplay HUD. */
+ if (pending->hud)
+ {
+ if (!vr.menu_open)
+ vr.menu_pose.valid = 0;
+ vr.menu_open = 1;
+ if (menu_place())
+ pending->hud_pose = vr.menu_pose;
+ else
+ pending->hud = 0;
+ }
pending->hud_size[0] = settings.menu_width;
pending->hud_size[1] = settings.menu_width * (float)HUD_HEIGHT / (float)HUD_WIDTH;
}
@@ -1044,6 +1145,126 @@ int halo_vr_view(struct halo_vr_view *view)
/* ---------- aiming */
@@ -1553,7 +1679,7 @@ index 89cfb6f0d81d1d406b6b9cff2e76a66129202b4a..110884526b023bccde19ff04b657867e
int halo_vr_aim(float current_yaw, int long_gun, float *yaw, float *pitch)
{
static const float backwards[3] = { 0.0f, 0.0f, -1.0f };
@@ -1105,11 +1261,17 @@ void halo_vr_aim_release(void)
@@ -1105,11 +1326,23 @@ void halo_vr_aim_release(void)
{
vr.aim_synced = 0;
vr.aiming = 0;
@@ -1562,6 +1688,12 @@ index 89cfb6f0d81d1d406b6b9cff2e76a66129202b4a..110884526b023bccde19ff04b657867e
void halo_vr_menus(int active)
{
+ active = active != 0;
+ if (active != vr.menus)
+ {
+ vr.menu_open = 0;
+ vr.menu_pose.valid = 0;
+ }
vr.menus = active;
+ if (active)
+ {
@@ -1571,7 +1703,7 @@ index 89cfb6f0d81d1d406b6b9cff2e76a66129202b4a..110884526b023bccde19ff04b657867e
}
int halo_vr_aiming(void)
@@ -1161,10 +1323,10 @@ void halo_vr_gamepad(void *gamepad)
@@ -1161,10 +1394,10 @@ void halo_vr_gamepad(void *gamepad)
analog(pad, XINPUT_GAMEPAD_B, (buttons & VR_BUTTON_B) || vr.melee_frames > 0);
analog(pad, XINPUT_GAMEPAD_X, buttons & VR_BUTTON_X);
analog(pad, XINPUT_GAMEPAD_Y, buttons & VR_BUTTON_Y);
@@ -2830,6 +2962,85 @@ index ba4d9c2bf3a8fd98bd2076eb8c40210a49de343a..d2ebc960f4904431fb0dd291506f721b
void unit_render_debug(
long object_index);
boolean unit_clip_to_aiming_bounds(long unit_index, real_vector3d *vector, boolean use_aiming_screen);
diff --git a/tools/android_build.py b/tools/android_build.py
index 9e5f67bf04a7dfcae08ede2760d823b527d0de1f..26a26cff323c575ca1a428d80f0e438921c1e7b4 100644
--- a/tools/android_build.py
+++ b/tools/android_build.py
@@ -18,6 +18,7 @@ and stages both, with the SDL3 Java sources, for the Gradle project in
port/android/app, which ``ninja android_apk`` then assembles.
"""
+import hashlib
import os
import shutil
import subprocess
@@ -45,7 +46,10 @@ MONOCYPHER_DIR = Path("port/third_party/monocypher")
MUSL_VERSION = "1.2.5"
MUSL_DIR = THIRD_PARTY / f"musl-{MUSL_VERSION}"
MUSL_URL = f"https://musl.libc.org/releases/musl-{MUSL_VERSION}.tar.gz"
+MUSL_SHA256 = "a9a118bbe84d8764da0ea0d28b3ab3fae8477fc7e4085d90102b8596fc7c75e4"
+MUSL_STAMP = ".halo-verified-source-sha256"
SDL_TAG = "release-3.4.16"
+SDL_COMMIT = "fa2c02bb6e21974a89ea9824bc53c9932abe5f9c"
SDL_DIR = THIRD_PARTY / "SDL3"
SDL_URL = "https://github.com/libsdl-org/SDL.git"
ANDROID_API = 28
@@ -166,20 +170,47 @@ def _find_ndk() -> Optional[Path]:
def fetch_third_party(third_party: Path = THIRD_PARTY) -> None:
- """Download musl and SDL3 (configure time, once)."""
+ """Fetch pinned build sources; reject unverified or modified cached inputs."""
musl_dir = third_party / f"musl-{MUSL_VERSION}"
sdl_dir = third_party / "SDL3"
third_party.mkdir(parents=True, exist_ok=True)
- if not musl_dir.is_dir():
+ if musl_dir.is_symlink() or sdl_dir.is_symlink():
+ raise OSError("Build source caches must not be symbolic links. Use a fresh third-party directory.")
+ if musl_dir.is_dir():
+ stamp = musl_dir / MUSL_STAMP
+ if (not stamp.is_file() or stamp.is_symlink() or stamp.stat().st_size != 65
+ or stamp.read_bytes() != (MUSL_SHA256 + "\n").encode("ascii")):
+ raise OSError(f"Unverified musl source cache: {musl_dir}. Remove it and configure again to fetch the pinned archive.")
+ else:
print(f"Downloading {MUSL_URL}")
archive = third_party / f"musl-{MUSL_VERSION}.tar.gz"
- subprocess.run(["curl", "-sSfL", "-o", str(archive), MUSL_URL], check=True)
- subprocess.run(["tar", "xzf", archive.name], cwd=third_party, check=True)
- archive.unlink()
+ if archive.is_symlink():
+ raise OSError("The musl archive cache must not be a symbolic link.")
+ try:
+ subprocess.run(["curl", "-sSfL", "-o", str(archive), MUSL_URL], check=True)
+ digest = hashlib.sha256()
+ with archive.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ if digest.hexdigest() != MUSL_SHA256:
+ raise OSError("The musl archive checksum does not match the pinned source. Extraction refused.")
+ subprocess.run(["tar", "xzf", archive.name], cwd=third_party, check=True)
+ if not musl_dir.is_dir() or musl_dir.is_symlink():
+ raise OSError("The verified musl archive did not produce its expected source directory.")
+ (musl_dir / MUSL_STAMP).write_bytes((MUSL_SHA256 + "\n").encode("ascii"))
+ finally:
+ archive.unlink(missing_ok=True)
if not sdl_dir.is_dir():
- print(f"Cloning SDL3 {SDL_TAG}")
- subprocess.run(["git", "clone", "-q", "--depth", "1", "--branch", SDL_TAG, SDL_URL, str(sdl_dir)],
- check=True)
+ print(f"Fetching SDL3 {SDL_TAG} at {SDL_COMMIT}")
+ subprocess.run(["git", "init", "-q", str(sdl_dir)], check=True)
+ subprocess.run(["git", "-C", str(sdl_dir), "fetch", "-q", "--depth", "1", SDL_URL, SDL_COMMIT], check=True)
+ subprocess.run(["git", "-C", str(sdl_dir), "checkout", "-q", "--detach", SDL_COMMIT], check=True)
+ head = subprocess.check_output(["git", "-C", str(sdl_dir), "rev-parse", "HEAD"], text=True).strip()
+ if head != SDL_COMMIT:
+ raise OSError(f"SDL source cache is not the pinned commit {SDL_COMMIT}. Use a fresh third-party directory.")
+ dirty = subprocess.check_output(["git", "-C", str(sdl_dir), "status", "--porcelain", "--untracked-files=no"], text=True)
+ if dirty.strip():
+ raise OSError("SDL source cache contains tracked local changes. Use a fresh third-party directory.")
def _musl_sources(musl_dir: Path = MUSL_DIR) -> List[Path]:
diff --git a/tools/port_settings.py b/tools/port_settings.py
index 63166f2ee98e00214ed4edbadda8f57e63a75d87..381b6661615ced04a955117ea747e22b13a778bd 100644
--- a/tools/port_settings.py
@@ -2931,6 +3142,188 @@ index 63166f2ee98e00214ed4edbadda8f57e63a75d87..381b6661615ced04a955117ea747e22b
lines = _widget(MAP_KIND_CHOOSER, [("width", 256), ("height", 28), ("flags", "pass_unhandled_to_focused_child"),
("bitmap", "bitmaps/option_bkds_sm"), ("font", "ui\\large_ui"),
("color", "#FF2896FF"), ("align", "center"), ("text_y", 3)],
diff --git a/tools/test_build_sources.py b/tools/test_build_sources.py
new file mode 100644
index 0000000000000000000000000000000000000000..cbf5316de2938537be2ea1d4d52c442283279f30
--- /dev/null
+++ b/tools/test_build_sources.py
@@ -0,0 +1,176 @@
+#!/usr/bin/env python3
+"""Offline regressions for the production musl digest and SDL commit fetch.
+
+Network/download/git commands are synthetic stubs. Corrupt-download tests use
+the real SHA256 implementation; the successful extraction test supplies a
+deterministic digest stub for synthetic bytes rather than shipping upstream data.
+"""
+import hashlib
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from tools import android_build as build
+
+
+class SyntheticSources:
+ payload = b"synthetic musl archive; no upstream source bytes"
+
+ def __init__(self):
+ self.commands = []
+ self.queries = []
+ self.head = build.SDL_COMMIT
+ self.dirty = ""
+ self.fail_tar = False
+
+ def run(self, argv, *, check, cwd=None):
+ assert check is True
+ self.commands.append(tuple(str(value) for value in argv))
+ if argv[0] == "curl":
+ assert argv[-1] == build.MUSL_URL
+ Path(argv[argv.index("-o") + 1]).write_bytes(self.payload)
+ elif argv[0] == "tar":
+ assert argv[1:] == ["xzf", f"musl-{build.MUSL_VERSION}.tar.gz"]
+ source = Path(cwd) / f"musl-{build.MUSL_VERSION}"
+ source.mkdir()
+ if self.fail_tar:
+ raise subprocess.CalledProcessError(1, argv)
+ elif argv[:3] == ["git", "init", "-q"]:
+ Path(argv[3]).mkdir()
+ elif argv[0] == "git" and argv[3] == "fetch":
+ assert argv[4:] == ["-q", "--depth", "1", build.SDL_URL, build.SDL_COMMIT]
+ elif argv[0] == "git" and argv[3] == "checkout":
+ assert argv[4:] == ["-q", "--detach", build.SDL_COMMIT]
+ else:
+ raise AssertionError(f"Unexpected command {argv!r}")
+ return subprocess.CompletedProcess(argv, 0)
+
+ def check_output(self, argv, *, text):
+ assert text is True
+ self.queries.append(tuple(argv))
+ if argv[3:] == ["rev-parse", "HEAD"]:
+ return self.head + "\n"
+ if argv[3:] == ["status", "--porcelain", "--untracked-files=no"]:
+ return self.dirty
+ raise AssertionError(f"Unexpected query {argv!r}")
+
+
+class PinnedSourcesTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory(prefix="halo-pinned-sources-")
+ self.addCleanup(self.temp.cleanup)
+ self.root = Path(self.temp.name) / "third_party"
+ self.server = SyntheticSources()
+ self.run_patch = patch.object(build.subprocess, "run", self.server.run)
+ self.output_patch = patch.object(build.subprocess, "check_output", self.server.check_output)
+ self.run_patch.start(); self.output_patch.start()
+ self.addCleanup(self.run_patch.stop); self.addCleanup(self.output_patch.stop)
+
+ @property
+ def musl(self):
+ return self.root / f"musl-{build.MUSL_VERSION}"
+
+ def cached_musl(self, stamp=True):
+ self.musl.mkdir(parents=True)
+ if stamp:
+ (self.musl / build.MUSL_STAMP).write_bytes((build.MUSL_SHA256 + "\n").encode("ascii"))
+
+ def synthetic_valid_digest(self):
+ case = self
+ class Digest:
+ def __init__(self):
+ self.bytes = bytearray()
+ def update(self, data):
+ self.bytes.extend(data)
+ def hexdigest(self):
+ case.assertEqual(bytes(self.bytes), case.server.payload)
+ return build.MUSL_SHA256
+ return patch.object(build.hashlib, "sha256", Digest)
+
+ def test_known_upstream_pins_and_guest_abi_are_unchanged(self):
+ self.assertEqual(build.MUSL_VERSION, "1.2.5")
+ self.assertEqual(build.MUSL_SHA256, "a9a118bbe84d8764da0ea0d28b3ab3fae8477fc7e4085d90102b8596fc7c75e4")
+ self.assertEqual(build.SDL_TAG, "release-3.4.16")
+ self.assertEqual(build.SDL_COMMIT, "fa2c02bb6e21974a89ea9824bc53c9932abe5f9c")
+ self.assertIn("--target=arm64_32-apple-watchos", build.GUEST_ABI_FLAGS)
+
+ def test_corrupt_download_is_rejected_before_tar_or_git(self):
+ self.assertNotEqual(hashlib.sha256(self.server.payload).hexdigest(), build.MUSL_SHA256)
+ with self.assertRaisesRegex(OSError, "checksum.*Extraction refused"):
+ build.fetch_third_party(self.root)
+ self.assertEqual([command[0] for command in self.server.commands], ["curl"])
+ self.assertFalse(self.musl.exists())
+ self.assertFalse((self.root / f"musl-{build.MUSL_VERSION}.tar.gz").exists())
+
+ def test_verified_download_and_exact_checkout_then_cached_head_recheck(self):
+ with self.synthetic_valid_digest():
+ build.fetch_third_party(self.root)
+ self.assertEqual((self.musl / build.MUSL_STAMP).read_bytes(), (build.MUSL_SHA256 + "\n").encode("ascii"))
+ fetch = next(command for command in self.server.commands if command[0] == "git" and command[3] == "fetch")
+ self.assertEqual(fetch[-2:], (build.SDL_URL, build.SDL_COMMIT))
+ self.assertFalse(any("--branch" in command or "clone" in command for command in self.server.commands))
+ self.assertEqual(self.server.queries[0][3:], ("rev-parse", "HEAD"))
+ before = len(self.server.commands)
+ build.fetch_third_party(self.root)
+ self.assertEqual(len(self.server.commands), before) # No download or checkout silently replaces a cache.
+ self.assertEqual(len(self.server.queries), 4) # HEAD and tracked status checked on both calls.
+
+ def test_preexisting_unverified_musl_is_never_reused(self):
+ self.cached_musl(stamp=False)
+ with self.assertRaisesRegex(OSError, "Unverified musl source cache"):
+ build.fetch_third_party(self.root)
+ self.assertFalse(self.server.commands)
+
+ def test_wrong_or_malformed_musl_stamp_is_never_reused(self):
+ self.cached_musl()
+ stamp = self.musl / build.MUSL_STAMP
+ for content in (b"0" * 64 + b"\n", b"not a verified archive\n", (build.MUSL_SHA256 + "\r\n").encode("ascii")):
+ with self.subTest(stamp=content):
+ stamp.write_bytes(content)
+ with self.assertRaisesRegex(OSError, "Unverified musl source cache"):
+ build.fetch_third_party(self.root)
+ self.assertFalse(self.server.commands)
+
+ def test_partial_extract_cannot_acquire_a_verification_stamp(self):
+ self.server.fail_tar = True
+ with self.synthetic_valid_digest(), self.assertRaises(subprocess.CalledProcessError):
+ build.fetch_third_party(self.root)
+ self.assertTrue(self.musl.is_dir())
+ self.assertFalse((self.musl / build.MUSL_STAMP).exists())
+ with self.assertRaisesRegex(OSError, "Unverified musl source cache"):
+ build.fetch_third_party(self.root)
+ self.assertFalse(any(command[0] == "git" for command in self.server.commands))
+
+ def test_cached_sdl_wrong_head_rejected_without_reset_or_network(self):
+ self.cached_musl()
+ (self.root / "SDL3").mkdir()
+ self.server.head = "0" * 40
+ with self.assertRaisesRegex(OSError, "SDL source cache is not the pinned commit"):
+ build.fetch_third_party(self.root)
+ self.assertFalse(self.server.commands)
+ self.assertEqual(len(self.server.queries), 1)
+
+ def test_new_sdl_fetch_must_also_verify_its_actual_head(self):
+ self.cached_musl()
+ self.server.head = "0" * 40
+ with self.assertRaisesRegex(OSError, "SDL source cache is not the pinned commit"):
+ build.fetch_third_party(self.root)
+ self.assertEqual([command[3] if command[1] == "-C" else command[1]
+ for command in self.server.commands], ["init", "fetch", "checkout"])
+
+ def test_cached_sdl_with_tracked_changes_is_rejected(self):
+ self.cached_musl()
+ (self.root / "SDL3").mkdir()
+ self.server.dirty = " M CMakeLists.txt\n"
+ with self.assertRaisesRegex(OSError, "tracked local changes"):
+ build.fetch_third_party(self.root)
+ self.assertFalse(self.server.commands)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tools/test_online_coop_menu.py b/tools/test_online_coop_menu.py
new file mode 100644
index 0000000000000000000000000000000000000000..72086bda0960abfdbd70213e48f3e4f868a75ce7
@@ -3763,10 +4156,10 @@ index 0000000000000000000000000000000000000000..0b0f6b83d8e2a90962c81eac5ae46a81
+ main()
diff --git a/tools/test_vr_locomotion.py b/tools/test_vr_locomotion.py
new file mode 100644
index 0000000000000000000000000000000000000000..84926978f6b60e17d53891f4ce6df113833000bc
index 0000000000000000000000000000000000000000..8f9f3b4045152e97a7d067194455afe9b86a93ac
--- /dev/null
+++ b/tools/test_vr_locomotion.py
@@ -0,0 +1,440 @@
@@ -0,0 +1,442 @@
+#!/usr/bin/env python3
+"""Compile and run the production VR walking math and integration on a host.
+
@@ -3857,6 +4250,8 @@ index 0000000000000000000000000000000000000000..84926978f6b60e17d53891f4ce6df113
+static struct {
+ int initialized, frame, pending, origin_valid, aim_synced, menus, aiming, long_gun;
+ struct vr_host_views views;
+ int menu_open;
+ struct vr_host_pose menu_pose;
+ float origin_yaw, body_yaw, origin_position[3];
+ struct halo_vr_locomotion_heading movement_heading;
+ int tutorial_head_valid, tutorial_turn_flags, turn_control_allowed;
@@ -4207,6 +4602,345 @@ index 0000000000000000000000000000000000000000..84926978f6b60e17d53891f4ce6df113
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/test_vr_menu.py b/tools/test_vr_menu.py
new file mode 100644
index 0000000000000000000000000000000000000000..3bf8bf32d48b58d877e7b7485305fa077c02e73b
--- /dev/null
+++ b/tools/test_vr_menu.py
@@ -0,0 +1,333 @@
+#!/usr/bin/env python3
+"""Run production menu placement and OpenXR quad submission on a C11 host.
+
+No game data, headset, runtime or SDL is required. The checked-in guest/host
+ABI and Khronos headers are used; only runtime calls and unrelated logging
+are stubbed. Menu input remains covered by the existing walking/tutorial tests.
+"""
+import argparse
+import os
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import tempfile
+
+from test_vr_locomotion import function
+
+ROOT = Path(__file__).resolve().parents[1]
+
+HARNESS = r'''
+#define _USE_MATH_DEFINES
+#include <math.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <openxr/openxr.h>
+#include "vr_host.h"
+#include "vr_locomotion.h"
+#ifndef M_PI
+#define M_PI 3.14159265358979323846
+#endif
+#define HUD_WIDTH 1280
+#define HUD_HEIGHT 960
+static unsigned checks;
+#define CHECK(c) do { checks++; if (!(c)) { fprintf(stderr,"failed line %d: %s\n",__LINE__,#c); exit(1); } } while(0)
+static int close_float(float a, float b) { return fabsf(a-b) < 0.00005f; }
+static struct {
+ int initialized, frame, pending, attempted, origin_valid, aim_synced, aiming, aimed;
+ int menus, menu_open, depth_written;
+ unsigned long presents;
+ int acquired[VR_SWAPCHAIN_COUNT];
+ float origin_yaw, body_yaw, origin_position[3], depth_near, depth_far;
+ struct vr_host_views views;
+ struct vr_host_layers pending_layers;
+ struct vr_host_pose menu_pose;
+ struct halo_vr_locomotion_heading movement_heading;
+ int tutorial_turn_flags, tutorial_head_valid;
+} vr;
+static struct {
+ int gamepad_aim, standing;
+ float player_height, menu_distance, menu_width, hud_distance, hud_size;
+} settings;
+static void platform_log(const char *format, const char *mode) { (void)format; (void)mode; }
+@@GUEST@@
+
+/* Keep the host's production global separate from the guest's production
+global; this is its only identifier substitution in the extracted functions. */
+static struct {
+ int frame_begun;
+ XrFrameState frame;
+ XrSession session;
+ XrSpace play_space, view_space;
+ XrSwapchain swapchains[VR_SWAPCHAIN_COUNT];
+ int swapchain_width[VR_SWAPCHAIN_COUNT], swapchain_height[VR_SWAPCHAIN_COUNT];
+} runtime;
+static struct { int enabled; double release, end; } timing;
+static XrCompositionLayerQuad submitted_quad;
+static unsigned submitted_quads, submitted_projections, release_calls;
+static double seconds_now(void) { return 0.0; }
+static void timing_frame(void) { CHECK(timing.enabled > 0); }
+static void check(XrResult result, const char *message) { CHECK(result == XR_SUCCESS && message != NULL); }
+void host_vr_release(int swapchain) { CHECK(swapchain >= 0 && swapchain < VR_SWAPCHAIN_COUNT); release_calls++; }
+XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInfo *end) {
+ uint32_t index;
+ CHECK(session == runtime.session && end->type == XR_TYPE_FRAME_END_INFO && end->next == NULL);
+ CHECK(end->displayTime == runtime.frame.predictedDisplayTime);
+ CHECK(end->environmentBlendMode == XR_ENVIRONMENT_BLEND_MODE_OPAQUE && end->layerCount <= 2);
+ submitted_quads = submitted_projections = 0;
+ for (index = 0; index < end->layerCount; index++) {
+ const XrCompositionLayerBaseHeader *layer = end->layers[index];
+ if (layer->type == XR_TYPE_COMPOSITION_LAYER_QUAD) {
+ submitted_quad = *(const XrCompositionLayerQuad *)layer; submitted_quads++;
+ } else {
+ CHECK(layer->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION);
+ submitted_projections++;
+ }
+ }
+ return XR_SUCCESS;
+}
+/* OpenXR's conventional { TYPE } initializes the other members to zero. */
+#ifdef __clang__
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wmissing-field-initializers"
+#endif
+@@HOST@@
+#ifdef __clang__
+#pragma clang diagnostic pop
+#endif
+
+static void head_pose(float yaw, float pitch, float roll) {
+ float turn[4] = { 0.f, sinf(yaw*.5f), 0.f, cosf(yaw*.5f) };
+ float tilt[4] = { sinf(pitch*.5f), 0.f, 0.f, cosf(pitch*.5f) };
+ float lean[4] = { 0.f, 0.f, sinf(roll*.5f), cosf(roll*.5f) };
+ float oriented[4];
+ quaternion_multiply(turn, tilt, oriented);
+ quaternion_multiply(oriented, lean, vr.views.head.orientation);
+ vr.views.head.valid = 1;
+}
+static void fixture(void) {
+ int image;
+ memset(&vr, 0, sizeof(vr)); memset(&settings, 0, sizeof(settings));
+ vr.initialized = vr.frame = vr.origin_valid = vr.aim_synced = 1;
+ vr.views.should_render = vr.views.focused = 1;
+ head_pose(0.f, 0.f, 0.f);
+ vr.views.head.position[0] = 1.f; vr.views.head.position[1] = 1.6f; vr.views.head.position[2] = 2.f;
+ vr.origin_position[0] = -500.f; vr.origin_position[1] = 20.f; vr.origin_position[2] = 700.f;
+ vr.origin_yaw = -1.3f; vr.body_yaw = 2.5f;
+ settings.standing = 1; settings.player_height = 1.7f;
+ settings.menu_distance = 1.5f; settings.menu_width = 1.8f;
+ settings.hud_distance = 1.f; settings.hud_size = 60.f;
+ memset(&runtime, 0, sizeof(runtime)); memset(&timing, 0, sizeof(timing));
+ runtime.session = (XrSession)(uintptr_t)1;
+ runtime.play_space = (XrSpace)(uintptr_t)2; runtime.view_space = (XrSpace)(uintptr_t)3;
+ runtime.frame.predictedDisplayTime = 123456;
+ for (image = 0; image < VR_SWAPCHAIN_COUNT; image++) {
+ vr.acquired[image] = 0;
+ runtime.swapchains[image] = (XrSwapchain)(uintptr_t)(image+4);
+ runtime.swapchain_width[image] = HUD_WIDTH; runtime.swapchain_height[image] = HUD_HEIGHT;
+ }
+ release_calls = 0;
+}
+static void submit(int eyes, int hud) {
+ unsigned releases_before = release_calls;
+ vr.frame = 1;
+ halo_vr_present(eyes, hud);
+ runtime.frame_begun = 1;
+ host_vr_frame_end(&vr.pending_layers);
+ CHECK(!runtime.frame_begun && vr.pending && !vr.frame);
+ CHECK(release_calls == releases_before + VR_SWAPCHAIN_COUNT);
+ CHECK(submitted_quads == (unsigned)vr.pending_layers.hud);
+ CHECK(submitted_projections == (unsigned)vr.pending_layers.projection);
+ if (submitted_quads) {
+ CHECK(submitted_quad.space == (vr.pending_layers.hud_head_locked ? runtime.view_space : runtime.play_space));
+ CHECK(submitted_quad.eyeVisibility == XR_EYE_VISIBILITY_BOTH);
+ CHECK(submitted_quad.subImage.swapchain == runtime.swapchains[VR_SWAPCHAIN_HUD]);
+ CHECK(close_float(submitted_quad.pose.position.x, vr.pending_layers.hud_pose.position[0]));
+ CHECK(close_float(submitted_quad.pose.position.y, vr.pending_layers.hud_pose.position[1]));
+ CHECK(close_float(submitted_quad.pose.position.z, vr.pending_layers.hud_pose.position[2]));
+ CHECK(close_float(submitted_quad.pose.orientation.x, vr.pending_layers.hud_pose.orientation[0]));
+ CHECK(close_float(submitted_quad.pose.orientation.y, vr.pending_layers.hud_pose.orientation[1]));
+ CHECK(close_float(submitted_quad.pose.orientation.z, vr.pending_layers.hud_pose.orientation[2]));
+ CHECK(close_float(submitted_quad.pose.orientation.w, vr.pending_layers.hud_pose.orientation[3]));
+ CHECK(close_float(submitted_quad.size.width, vr.pending_layers.hud_size[0]));
+ CHECK(close_float(submitted_quad.size.height, vr.pending_layers.hud_size[1]));
+ }
+}
+static void check_front(void) {
+ static const float forwards[3] = { 0.f, 0.f, -1.f };
+ float orientation[4], pointing[3], distance_squared = 0.f;
+ int axis;
+ CHECK(halo_vr_locomotion_orientation(vr.views.head.orientation, orientation));
+ quaternion_rotate(orientation, forwards, pointing);
+ CHECK(vr.menu_open && vr.menu_pose.valid && vr.pending_layers.hud && !vr.pending_layers.hud_head_locked);
+ for (axis = 0; axis < 3; axis++) {
+ float offset = vr.pending_layers.hud_pose.position[axis] - vr.views.head.position[axis];
+ CHECK(close_float(offset, pointing[axis] * settings.menu_distance));
+ distance_squared += offset * offset;
+ }
+ CHECK(close_float(distance_squared, settings.menu_distance * settings.menu_distance));
+ for (axis = 0; axis < 4; axis++) CHECK(close_float(vr.pending_layers.hud_pose.orientation[axis], orientation[axis]));
+}
+static void check_open_angles_and_gameplay_state(void) {
+ int yaw, pitch, roll;
+ for (yaw = -180; yaw <= 180; yaw += 15) for (pitch = -90; pitch <= 90; pitch += 15)
+ for (roll = -60; roll <= 60; roll += 30) {
+ float origin[3], body;
+ fixture(); head_pose((float)yaw*(float)M_PI/180.f, (float)pitch*(float)M_PI/180.f, (float)roll*(float)M_PI/180.f);
+ memcpy(origin, vr.origin_position, sizeof(origin)); body = vr.body_yaw;
+ halo_vr_menus(1); submit(1, 1); check_front();
+ CHECK(memcmp(origin, vr.origin_position, sizeof(origin)) == 0 && vr.body_yaw == body);
+ CHECK(vr.origin_yaw == -1.3f && vr.aim_synced && vr.pending_layers.projection);
+ }
+ fixture(); settings.gamepad_aim = 1; settings.standing = 0; vr.aim_synced = vr.origin_valid = 0;
+ head_pose(2.8f, 0.4f, -0.2f); halo_vr_menus(1); submit(1, 1); check_front();
+ CHECK(!vr.origin_valid && !vr.aim_synced); /* A menu needs no gameplay camera/aim. */
+ fixture(); vr.views.head.orientation[3] = 1.01f; halo_vr_menus(1); submit(1, 1); check_front();
+}
+static void check_stable_reopen_recentre(void) {
+ struct vr_host_pose anchor;
+ int frame;
+ fixture(); head_pose(2.4f, 0.3f, 0.1f); halo_vr_menus(1); submit(1, 1); check_front();
+ anchor = vr.menu_pose;
+ for (frame = 0; frame < 20; frame++) {
+ head_pose((float)frame*0.2f, -(float)frame*0.03f, 0.f);
+ vr.views.head.position[0] += 0.1f; vr.views.head.position[1] += 0.01f;
+ vr.views.head.position[2] -= 0.1f;
+ halo_vr_menus(1); submit(1, 1);
+ CHECK(memcmp(&anchor, &vr.menu_pose, sizeof(anchor)) == 0);
+ CHECK(memcmp(&anchor, &vr.pending_layers.hud_pose, sizeof(anchor)) == 0);
+ }
+ halo_vr_menus(0); CHECK(!vr.menu_pose.valid && !vr.menu_open);
+ halo_vr_menus(1); submit(1, 1); check_front();
+ CHECK(memcmp(&anchor, &vr.menu_pose, sizeof(anchor)) != 0);
+ /* Deliberate recenter is one replacement, not continual head locking. */
+ head_pose(-2.3f, -0.2f, 0.f); recentre(); CHECK(!vr.menu_pose.valid);
+ submit(1, 1); check_front(); anchor = vr.menu_pose;
+ head_pose(0.f, 0.f, 0.f); submit(1, 1); CHECK(memcmp(&anchor, &vr.menu_pose, sizeof(anchor)) == 0);
+ halo_vr_menus(0); submit(1, 1);
+ CHECK(!vr.menu_open && !vr.menu_pose.valid && vr.pending_layers.hud_head_locked);
+ CHECK(close_float(submitted_quad.pose.position.z, -settings.hud_distance));
+ /* Failed eye acquisition must not reclassify the gameplay HUD as a menu. */
+ vr.acquired[VR_SWAPCHAIN_EYES] = -1; submit(1, 1);
+ CHECK(!vr.pending_layers.projection && vr.pending_layers.hud_head_locked && !vr.menu_open);
+}
+static void corrupt_pose(int corruption) {
+ switch (corruption) {
+ case 0: vr.initialized = 0; break;
+ case 1: vr.views.should_render = 0; break;
+ case 2: vr.views.focused = 0; break;
+ case 3: vr.views.head.valid = 0; break;
+ case 4: vr.views.head.position[0] = NAN; break;
+ case 5: vr.views.head.position[1] = INFINITY; break;
+ case 6: vr.views.head.position[2] = -INFINITY; break;
+ case 7: vr.views.head.orientation[0] = NAN; break;
+ case 8: vr.views.head.orientation[1] = INFINITY; break;
+ case 9: memset(vr.views.head.orientation, 0, sizeof(vr.views.head.orientation)); break;
+ case 10: vr.views.head.orientation[3] = 2.f; break;
+ case 11: settings.menu_distance = NAN; break;
+ case 12: settings.menu_distance = 0.f; break;
+ }
+}
+static void check_tracking_loss_and_deferral(void) {
+ int corruption, already_open;
+ for (already_open = 0; already_open < 2; already_open++) for (corruption = 0; corruption < 13; corruption++) {
+ fixture(); halo_vr_menus(1);
+ if (already_open) { submit(1, 1); CHECK(vr.menu_pose.valid); }
+ corrupt_pose(corruption); submit(1, 1);
+ CHECK(!vr.menu_pose.valid && !vr.pending_layers.hud && !submitted_quads);
+ vr.initialized = vr.views.should_render = vr.views.focused = 1;
+ vr.views.head.position[0] = 7.f; vr.views.head.position[1] = 1.25f; vr.views.head.position[2] = -4.f;
+ settings.menu_distance = 1.5f; head_pose(-2.9f, 0.8f, 0.3f);
+ submit(1, 1); check_front(); /* Never retain the previous wearer's pose. */
+ }
+ fixture(); halo_vr_menus(1); submit(1, 1);
+ vr.frame = 0; CHECK(!menu_place() && !vr.menu_pose.valid); /* Pending frame is stale. */
+ submit(1, 1); check_front();
+ vr.views.focused = 0; submit(0, 0); CHECK(!vr.menu_pose.valid);
+ vr.views.focused = 1; head_pose(2.6f, -0.4f, 0.f); submit(1, 1); check_front();
+ fixture(); halo_vr_menus(1); submit(1, 1);
+ vr.frame = 0; halo_vr_present(1, 1); CHECK(!vr.menu_pose.valid);
+ head_pose(-2.f, 0.2f, 0.f); submit(1, 1); check_front();
+}
+static void check_main_postgame_and_blank_frames(void) {
+ struct vr_host_pose anchor;
+ fixture(); vr.origin_valid = vr.aim_synced = 0;
+ head_pose(2.5f, -0.5f, 0.f); submit(0, 0);
+ CHECK(!vr.menu_open && !vr.menu_pose.valid); /* No capture before menu is visible. */
+ head_pose(-2.7f, 0.2f, 0.f); submit(0, 1); check_front();
+ CHECK(!vr.pending_layers.projection && submitted_projections == 0);
+ anchor = vr.menu_pose;
+ head_pose(0.f, 0.f, 0.f); submit(0, 1); CHECK(memcmp(&anchor, &vr.menu_pose, sizeof(anchor)) == 0);
+ submit(1, 1); CHECK(!vr.menu_open && vr.pending_layers.hud_head_locked);
+ /* The endgame path displays no 3D projection even before its menu flag. */
+ head_pose(2.9f, 0.5f, 0.2f); submit(0, 1); check_front();
+ CHECK(!vr.pending_layers.projection && submitted_quad.space == runtime.play_space);
+ /* An explicit close/reopen without an intermediate render is still new. */
+ halo_vr_menus(1); submit(0, 1); anchor = vr.menu_pose;
+ halo_vr_menus(0); halo_vr_menus(1); head_pose(-2.9f, -0.2f, 0.f);
+ submit(0, 1); check_front(); CHECK(memcmp(&anchor, &vr.menu_pose, sizeof(anchor)) != 0);
+ fixture(); vr.tutorial_head_valid = 1; vr.tutorial_turn_flags = 3; halo_vr_menus(7);
+ CHECK(vr.menus == 1 && !vr.tutorial_head_valid && !vr.tutorial_turn_flags);
+}
+int main(void) {
+ check_open_angles_and_gameplay_state(); check_stable_reopen_recentre();
+ check_tracking_loss_and_deferral(); check_main_postgame_and_blank_frames();
+ printf("PASS: %u production VR menu/quad placement assertions\n", checks); return 0;
+}
+'''
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--cc')
+ parser.add_argument('--vcvarsall', type=Path)
+ args = parser.parse_args()
+ guest = (ROOT / 'port/linux/src/vr.c').read_text()
+ host = (ROOT / 'port/linux/arm64/host_vr.c').read_text()
+ harness = HARNESS.replace('@@GUEST@@', '\n\n'.join(function(guest, name) for name in (
+ 'quaternion_rotate', 'quaternion_yaw', 'quaternion_multiply', 'recentre',
+ 'menu_tracking_valid', 'menu_place', 'halo_vr_present', 'halo_vr_menus')))
+ harness = harness.replace('@@HOST@@', re.sub(r'\bvr\b', 'runtime', '\n\n'.join(
+ function(host, name) for name in ('pose_to_xr', 'host_vr_frame_end'))))
+ compiler = args.cc or os.environ.get('CC')
+ vcvars = args.vcvarsall
+ if not compiler and os.name == 'nt':
+ installations = Path(os.environ.get('ProgramFiles', 'C:/Program Files')) / 'Microsoft Visual Studio'
+ candidates = sorted(installations.glob('*/**/VC/Tools/MSVC/*/bin/Hostx64/x64/cl.exe'))
+ if candidates:
+ compiler = str(candidates[-1])
+ vcvars = vcvars or candidates[-1].parents[6] / 'Auxiliary/Build/vcvarsall.bat'
+ compiler = compiler or shutil.which('cc') or shutil.which('clang') or shutil.which('gcc')
+ if not compiler:
+ raise SystemExit('No C11 host compiler found; select one with --cc.')
+ include_paths = [ROOT / 'port/third_party/openxr', ROOT / 'port/linux/src']
+ with tempfile.TemporaryDirectory(prefix='halo-vr-menu-', ignore_cleanup_errors=True) as directory:
+ temp = Path(directory)
+ source = temp / 'test.c'
+ executable = temp / ('test.exe' if os.name == 'nt' else 'test')
+ source.write_text(harness)
+ if Path(compiler).name.lower() in ('cl', 'cl.exe'):
+ command = [compiler, '/nologo', '/std:c11', '/W4', '/WX', '/D_CRT_SECURE_NO_WARNINGS',
+ '/Fe:' + str(executable), '/Fo:' + str(temp / 'test.obj')]
+ command.extend('/I' + str(path) for path in include_paths)
+ command.append(str(source))
+ if vcvars:
+ for path in (vcvars, Path(compiler), temp):
+ if any(character in str(path) for character in '\r\n"&|<>^%!'):
+ raise SystemExit('Unsupported compiler/temp shell metacharacter')
+ batch = temp / 'compile.cmd'
+ batch.write_text('@echo off\ncall "' + str(vcvars) + '" x64 >nul\n'
+ 'if errorlevel 1 exit /b 1\n' + subprocess.list2cmdline(command) + '\n')
+ command = ['cmd.exe', '/d', '/c', str(batch)]
+ else:
+ command = [compiler, '-std=c11', '-Wall', '-Wextra', '-Werror']
+ command.extend('-I' + str(path) for path in include_paths)
+ command.extend([str(source), *([] if os.name == 'nt' else ['-lm']), '-o', str(executable)])
+ subprocess.run(command, cwd=temp, check=True)
+ subprocess.run([str(executable)], cwd=temp, check=True)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/test_vr_projectile_reticle.py b/tools/test_vr_projectile_reticle.py
new file mode 100644
index 0000000000000000000000000000000000000000..8725f0da3a55b453f0b4718e5f29235a317b6c6a
+223
View File
@@ -0,0 +1,223 @@
"""Discover fixed native-game destinations without accepting arbitrary paths."""
from __future__ import annotations
import hashlib
import json
import os
import pathlib
import platform
import re
import shutil
import stat
import subprocess
MAX_MOUNT_BYTES = 2 * 1024 * 1024
MAX_MOUNTS = 4096
MAX_SD_CANDIDATES = 8
# Btrfs can span devices and its mountinfo identity is not the SD partition's
# block-device identity. Only filesystems this check can prove are eligible.
SUPPORTED_FILESYSTEMS = frozenset(("ext4", "f2fs"))
MARKER = ".halo-frame-installer.json"
OWNER = "halo-frame-installer"
def _safe_path(path):
path = pathlib.Path(path)
remote_fixture = os.name == "nt" and path.anchor == "\\"
if (not path.is_absolute() and not remote_fixture) or ".." in path.parts or any(ord(c) < 32 or ord(c) == 127 for c in str(path)):
raise ValueError("Storage paths must be ordinary absolute paths.")
cursor = pathlib.Path(path.anchor)
for component in path.parts[1:]:
cursor /= component
if cursor.is_symlink():
raise ValueError("Storage paths must not contain symbolic links.")
if not remote_fixture and path.resolve() != path:
raise ValueError("The storage path does not resolve to its displayed location.")
return path
def _mount_records():
with pathlib.Path("/proc/self/mountinfo").open("rb") as stream:
data = stream.read(MAX_MOUNT_BYTES + 1)
if len(data) > MAX_MOUNT_BYTES:
raise ValueError("Storage mount metadata exceeds supported bounds.")
records = []
for line in data.decode("utf-8", "surrogateescape").splitlines():
fields = line.split()
if len(records) >= MAX_MOUNTS or "-" not in fields:
raise ValueError("Storage mount metadata cannot be safely checked.")
separator = fields.index("-")
if (separator < 6 or len(fields) < separator + 4 or not fields[0].isdigit()
or not fields[1].isdigit() or not re.fullmatch(r"[0-9]+:[0-9]+", fields[2])):
raise ValueError("Storage mount metadata cannot be safely checked.")
decode = lambda value: re.sub(r"\\([0-7]{3})", lambda match: chr(int(match[1], 8)), value)
records.append({"mountId": int(fields[0]), "device": fields[2], "root": decode(fields[3]),
"path": pathlib.Path(decode(fields[4])), "options": set(fields[5].split(",")),
"filesystem": fields[separator + 1], "source": decode(fields[separator + 2]),
"superOptions": set(fields[separator + 3].split(","))})
return records
def _sd_identity(record):
"""Match the mounted partition to the kernel's physical SD card identity."""
partition = (pathlib.Path("/sys/dev/block") / record["device"]).resolve(strict=True)
if not re.fullmatch(r"mmcblk[0-9]+p[0-9]+", partition.name):
raise ValueError("The mounted device is not a native SD-card partition.")
disk = partition.parent
if (not re.fullmatch(r"mmcblk[0-9]+", disk.name)
or (partition / "dev").read_text().strip() != record["device"]
or not (partition / "partition").read_text().strip().isdigit()
or (disk / "device/type").read_text().strip() != "SD"):
raise ValueError("The mounted device does not have a matching kernel SD identity.")
cid = (disk / "device/cid").read_text().strip().lower()
if not re.fullmatch(r"[a-f0-9]{32}", cid):
raise ValueError("The SD card's physical identity is unavailable.")
device = pathlib.Path("/dev") / partition.name
details = device.stat()
if (not stat.S_ISBLK(details.st_mode)
or f"{os.major(details.st_rdev)}:{os.minor(details.st_rdev)}" != record["device"]):
raise ValueError("The mounted SD partition does not match its block device.")
uuid_result = subprocess.run(["lsblk", "--noheadings", "--nodeps", "--output", "UUID", "--", str(device)],
text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=5)
filesystem_uuid = uuid_result.stdout.strip().lower()
if uuid_result.returncode or not re.fullmatch(r"[a-z0-9-]{1,128}", filesystem_uuid):
raise ValueError("The SD filesystem identity is unavailable.")
return {"cardIdentity": hashlib.sha256(cid.encode("ascii")).hexdigest(),
"filesystemUuid": filesystem_uuid, "blockDevice": str(device)}
def _mount_path_allowed(path, home):
if path.parent not in (pathlib.Path("/run/media"), pathlib.Path("/run/media") / home.name):
return False
# Steam stores this path in its executable command. Spaces and Unicode are
# fine; interpolation/quote/control characters must never enter that string.
return bool(path.name) and not any(c in "$`\"'\\;|&<>:" or ord(c) < 32 or ord(c) == 127 for c in path.name)
def _installed(game):
if not game.is_dir() or game.is_symlink():
return False
for name, owner in ((MARKER, OWNER), (".codex-halo-native-install.json", "codex-halo-native-frame-20261005")):
path = game / name
try:
details = path.lstat()
if (not stat.S_ISREG(details.st_mode) or details.st_nlink != 1
or (hasattr(os, "getuid") and details.st_uid != os.getuid())
or not 0 < details.st_size <= 64 * 1024):
continue
value = json.loads(path.read_text())
if isinstance(value, dict) and value.get("owner") == owner:
return True
except (OSError, ValueError, UnicodeError):
continue
return False
def _descriptor(storage_id, kind, root, identity, home):
root = _safe_path(root)
game = _safe_path(root / "Games/HaloCENativeVR")
cache = _safe_path(home / ".cache/halo-frame-installer" if kind == "internal" else root / ".halo-frame-installer")
return {"id": storage_id, "kind": kind, "label": "Internal storage" if kind == "internal" else "SD card · " + root.name,
"mountPath": str(root), "gamePath": str(game), "cachePath": str(cache),
"freeBytes": shutil.disk_usage(root).free, "installed": _installed(game), "identity": identity,
"ownershipIdentity": ownership_identity(identity)}
def ownership_identity(identity):
if identity.get("kind") == "internal":
return {"kind": "internal", "root": identity["root"]}
return {key: identity[key] for key in ("kind", "mountPath", "cardIdentity", "filesystemUuid", "filesystem")}
def discover_storage(home=pathlib.Path("/home/steamos")):
home = _safe_path(home)
internal = _descriptor("internal", "internal", home, {"kind": "internal", "root": str(home)}, home)
destinations, unavailable = [internal], []
if platform.system() != "Linux":
return {"home": str(home), "destinations": destinations, "unavailable": unavailable}
records = _mount_records()
checked = 0
for record in records:
root = record["path"]
allowed_path = _mount_path_allowed(root, home)
native_card_source = re.fullmatch(r"/dev/mmcblk[0-9]+p[0-9]+", record["source"])
if not allowed_path and not native_card_source:
continue
checked += 1
if checked > MAX_SD_CANDIDATES:
break
if not allowed_path:
try:
_sd_identity(record)
except (OSError, ValueError, UnicodeError, subprocess.SubprocessError):
continue
unavailable.append({"mountPath": str(root), "reason": "The mounted SD card path is outside the supported SteamOS mount locations or contains unsupported launch-command characters."})
continue
try:
root = _safe_path(root)
flags = record["options"] | record["superOptions"]
if (record["root"] != "/" or record["filesystem"] not in SUPPORTED_FILESYSTEMS
or "ro" in flags or "noexec" in flags or "rw" not in record["options"]
or not os.access(root, os.W_OK | os.X_OK)):
raise ValueError("The SD card needs a writable, executable Linux filesystem mounted by SteamOS.")
if any(other is not record and (other["device"] == record["device"]
or other["path"] == root or other["path"].is_relative_to(root)) for other in records):
raise ValueError("The SD card has another mount or a mounted directory within it.")
details = root.stat()
if not stat.S_ISDIR(details.st_mode) or f"{os.major(details.st_dev)}:{os.minor(details.st_dev)}" != record["device"]:
raise ValueError("The SD mount no longer matches its mounted filesystem.")
identity = {"kind": "sd", "mountPath": str(root), "mountId": record["mountId"],
"device": record["device"], "filesystem": record["filesystem"],
"rootDevice": details.st_dev, "rootInode": details.st_ino, **_sd_identity(record)}
storage_id = "sd:" + hashlib.sha256(json.dumps(ownership_identity(identity), sort_keys=True).encode("utf-8")).hexdigest()[:32]
destinations.append(_descriptor(storage_id, "sd", root, identity, home))
except (OSError, ValueError, UnicodeError, subprocess.SubprocessError) as error:
unavailable.append({"mountPath": str(root), "reason": str(error)[:300]})
return {"home": str(home), "destinations": destinations, "unavailable": unavailable}
def resolve_storage(storage_id, home=pathlib.Path("/home/steamos")):
if storage_id != "internal" and not (isinstance(storage_id, str) and re.fullmatch(r"sd:[a-f0-9]{32}", storage_id)):
raise ValueError("Choose internal storage or one detected SD card.")
inventory = discover_storage(home)
for descriptor in inventory["destinations"]:
if descriptor["id"] == storage_id:
return descriptor
raise ValueError("The selected SD card is missing or its mount identity changed. Refresh storage and choose it again.")
def validate_game_path(home, game):
home, game = _safe_path(home), _safe_path(game)
for descriptor in discover_storage(home)["destinations"]:
if descriptor.get("gamePath") == str(game):
root = _safe_path(descriptor["mountPath"])
if game != root / "Games/HaloCENativeVR":
break
if descriptor.get("kind") == "internal" and root == home and descriptor.get("id") == "internal":
return descriptor
if (descriptor.get("kind") == "sd" and re.fullmatch(r"sd:[a-f0-9]{32}", str(descriptor.get("id", "")))
and root != home):
return descriptor
raise ValueError("Steam integration is restricted to a validated fixed native Halo installation path.")
def main():
home = pathlib.Path("/home/steamos")
if platform.system() != "Linux":
raise ValueError("Storage discovery supports SteamOS Steam Frame only.")
import pwd
if (os.getuid() == 0 or pwd.getpwuid(os.getuid()).pw_name != "steamos" or pathlib.Path.home() != home
or platform.machine().lower() not in ("aarch64", "arm64")):
raise ValueError("Connect to an ARM64 Steam Frame using its steamos account.")
system = pathlib.Path("/etc/os-release").read_text()
if not re.search(r'^ID=(?:"steamos"|steamos)$', system, re.M):
raise ValueError("Storage discovery supports SteamOS Steam Frame only.")
print("HFI_RESULT " + json.dumps(discover_storage(home)), flush=True)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, UnicodeError, subprocess.SubprocessError) as error:
print("HFI_ERROR " + str(error).replace("\n", " ")[:600], flush=True)
raise SystemExit(1)
+429 -60
View File
@@ -6,6 +6,7 @@ import argparse
import codecs
import ctypes
import hashlib
import importlib.util
import json
import os
import pathlib
@@ -24,6 +25,7 @@ import tomllib
OWNER = "halo-frame-installer"
SOURCE_URL = "https://github.com/startupfoundry/halo-ce-universal.git"
SOURCE_COMMIT = "2ae0ee4e3e8a4dfdadfd528a5b085ca699fc9ea4"
BUILD_CONTAINER_IMAGE = "docker.io/library/ubuntu:22.04@sha256:5ec03bb3441e8b0bf3b4f9cd4629a1ae763010dc3035bb8da3ae6cf026486401"
LEGACY_SOURCE_COMMIT = "88142798513ebd99fc7c6224023e8b44c05d0106"
MIN_FREE_BYTES = 12 * 1024 ** 3
EXPECTED_MAPS = frozenset("maps/" + name + ".map" for name in (
@@ -42,6 +44,71 @@ HOME = pathlib.Path("/home/steamos")
CACHE = HOME / ".cache/halo-frame-installer"
GAME = HOME / "Games/HaloCENativeVR"
MARKER = ".halo-frame-installer.json"
STORAGE_ID = "internal"
STORAGE_DESCRIPTOR = None
_LAST_STORAGE_CHECK = 0.0
def storage_module():
try:
import frame_storage
return frame_storage
except ModuleNotFoundError as error:
if error.name != "frame_storage":
raise
if "__file__" not in globals():
raise ValueError("The storage validation resource must be bootstrapped before remote setup.")
spec = importlib.util.spec_from_file_location("frame_storage", pathlib.Path(__file__).with_name("frame_storage.py"))
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def storage_root():
return pathlib.Path(STORAGE_DESCRIPTOR["mountPath"]) if STORAGE_DESCRIPTOR else HOME
def select_storage(identifier="internal"):
global STORAGE_ID, STORAGE_DESCRIPTOR, GAME, CACHE, _LAST_STORAGE_CHECK
descriptor = storage_module().resolve_storage(identifier, HOME)
STORAGE_ID, STORAGE_DESCRIPTOR = identifier, descriptor
GAME, CACHE = pathlib.Path(descriptor["gamePath"]), pathlib.Path(descriptor["cachePath"])
_LAST_STORAGE_CHECK = time.monotonic()
return descriptor
def assert_storage_active(force=False):
global _LAST_STORAGE_CHECK
if STORAGE_ID == "internal" or STORAGE_DESCRIPTOR is None:
return
if not force and time.monotonic() - _LAST_STORAGE_CHECK < 1:
return
current = storage_module().resolve_storage(STORAGE_ID, HOME)
if (current["identity"] != STORAGE_DESCRIPTOR["identity"] or current["gamePath"] != str(GAME)
or current["cachePath"] != str(CACHE)):
raise ValueError("The selected SD card changed. Its files were kept; refresh storage before retrying.")
_LAST_STORAGE_CHECK = time.monotonic()
def check_storage_record(metadata, active_run=False):
if STORAGE_DESCRIPTOR is None:
return
expected = STORAGE_DESCRIPTOR.get("ownershipIdentity", STORAGE_DESCRIPTOR["identity"])
recorded = metadata.get("storageOwnership")
if ((STORAGE_ID != "internal" and recorded != expected)
or (recorded is not None and recorded != expected)
or metadata.get("storageId", STORAGE_ID) != STORAGE_ID
or metadata.get("gamePath", str(GAME)) != str(GAME)):
raise ValueError("The installer ownership record belongs to another storage destination.")
if active_run and STORAGE_ID != "internal" and metadata.get("storageIdentity") != STORAGE_DESCRIPTOR["identity"]:
raise ValueError("This installation run belongs to an earlier SD mount. Start a fresh run; its uploaded maps were kept.")
def storage_fields():
return {"storageId": STORAGE_ID, "storageIdentity": STORAGE_DESCRIPTOR["identity"] if STORAGE_DESCRIPTOR else None,
"storageOwnership": STORAGE_DESCRIPTOR.get("ownershipIdentity", STORAGE_DESCRIPTOR["identity"]) if STORAGE_DESCRIPTOR else None,
"gamePath": str(GAME)}
def progress(stage, message, percent=None):
@@ -97,14 +164,17 @@ def read_marker(path, owner=OWNER):
def owned(directory):
beneath(directory, HOME)
assert_storage_active()
beneath(directory, storage_root())
directory.mkdir(parents=True, exist_ok=True)
marker = directory / MARKER
if marker.exists():
return read_marker(marker)
value = read_marker(marker)
check_storage_record(value)
return value
if any(directory.iterdir()):
raise ValueError("The destination is not empty and has no installer ownership marker: " + str(directory))
value = {"owner": OWNER, "sourceCommit": SOURCE_COMMIT}
value = {"owner": OWNER, "sourceCommit": SOURCE_COMMIT, **storage_fields()}
marker.write_text(json.dumps(value, indent=2) + "\n")
return value
@@ -116,8 +186,11 @@ def run_id(value):
def run_dir(value):
read_marker(CACHE / MARKER)
assert_storage_active()
check_storage_record(read_marker(CACHE / MARKER))
directory = beneath(CACHE / "runs" / run_id(value), CACHE)
if directory.exists():
check_storage_record(read_marker(directory / MARKER))
return directory
@@ -219,7 +292,7 @@ def validate_save_paths(settings, require_explicit=False):
if not isinstance(paths, dict):
raise ValueError("Version 1.4 game and save paths must use their owned installation folder.")
for key, expected in (("data", GAME), ("saves", save_root())):
beneath(expected, HOME)
beneath(expected, storage_root())
if key not in paths and not require_explicit:
continue
value = paths.get(key)
@@ -236,8 +309,156 @@ def validate_save_paths(settings, require_explicit=False):
raise ValueError("Version 1.4 must use its own game folder and save-v1.4 subfolder. Existing saves were kept.")
def interrupted_upgrade_record(metadata):
"""Corroborate a redirected legacy install against its unpublished transaction."""
failure = ("The interrupted upgrade could not be verified. Existing games and saves were kept. "
"Keep the game folder and installer cache; review the original configuration and "
"program files in the run's previous-program-files backup before retrying.")
try:
assert_storage_active(force=True)
check_storage_record(metadata)
if metadata.get("owner") not in (OWNER, "codex-halo-native-frame-20261005"):
raise ValueError(failure)
cache = private_json(CACHE / MARKER, CACHE)
check_storage_record(cache)
if cache.get("owner") != OWNER:
raise ValueError(failure)
runs = beneath(CACHE / "runs", CACHE)
matches = []
with os.scandir(runs) as entries:
for index, entry in enumerate(entries):
assert_storage_active()
if index >= MAX_RETRY_ENTRIES:
raise ValueError(failure)
if not re.fullmatch(r"[a-f0-9]{32}", entry.name):
continue
try:
directory = beneath(runs / entry.name, CACHE)
for folder in (directory, directory / "stage", directory / "previous-program-files"):
beneath(folder, CACHE)
if not folder.is_dir() or folder.stat().st_uid != os.getuid():
raise ValueError("Not an owned upgrade directory.")
run = private_json(directory / MARKER, directory)
backup = directory / "previous-program-files"
backup_owner = private_json(backup / ".backup-owner.json", backup)
staged = private_json(directory / "stage" / MARKER, directory)
# Old evidence may survive a reboot of this same card.
# Its persistent ownership must match the selected card;
# only the new retry's write path uses full live identity.
for record in (run, backup_owner, staged):
check_storage_record(record)
if (run.get("owner") != OWNER or run.get("sourceCommit") != SOURCE_COMMIT
or run.get("repair") is not True
or backup_owner.get("owner") != OWNER or backup_owner.get("sourceCommit") != SOURCE_COMMIT
or set(backup_owner) - ({"owner", "sourceCommit"} | set(storage_fields()))
or staged.get("owner") != OWNER or staged.get("sourceCommit") != SOURCE_COMMIT
or staged.get("sourceUrl") != SOURCE_URL or staged.get("saveRoot") != str(save_root())):
raise ValueError("Not a matching interrupted upgrade.")
if metadata["owner"] == OWNER:
if private_json(backup / MARKER, backup) != metadata:
raise ValueError("The original native marker does not match its backup.")
elif (backup / MARKER).exists():
raise ValueError("The manual native marker does not match this upgrade.")
old_files, new_files = metadata.get("files"), staged.get("files")
required = {"halo", "libSDL3.so.0", "brokers.txt", "frame-controls.patch"}
if (not isinstance(old_files, dict) or not isinstance(new_files, dict)
or set(new_files) != required):
raise ValueError("The interrupted upgrade has incomplete verification hashes.")
origin, previous_files = backup, old_files
journal = beneath(backup / ".upgrade-recovery.json", backup)
carried = None
if journal.exists():
carried = private_json(journal, backup)
check_storage_record(carried)
previous_files = carried.get("previousFiles")
if (set(carried) - ({"owner", "sourceCommit", "previousFiles", "originFiles"} | set(storage_fields()))
or carried.get("owner") != OWNER or carried.get("sourceCommit") != SOURCE_COMMIT
or not isinstance(previous_files, dict) or set(previous_files) != required):
raise ValueError("The carried upgrade recovery record is invalid.")
for name, expected in previous_files.items():
if expected is not None and (not isinstance(expected, str)
or not re.fullmatch(r"[a-f0-9]{64}", expected)):
raise ValueError("A carried previous program hash is invalid.")
saved = beneath(backup / name, backup)
actual = digest(ordinary(saved)) if saved.exists() else None
if actual != expected:
raise ValueError("The retry's program backup does not match its verified predecessor.")
original_config = ordinary(beneath(backup / "config.toml", backup))
if (original_config.stat().st_size > MAX_MANIFEST_BYTES
or configured_save_path(parse_config(original_config.read_text())) != str(save_root())):
raise ValueError("The retry's configuration does not match its interrupted predecessor.")
origin = beneath(backup / "upgrade-origin", backup)
if not origin.is_dir() or origin.stat().st_uid != os.getuid():
raise ValueError("The carried original backup is not an owned directory.")
if metadata["owner"] == OWNER:
if private_json(origin / MARKER, origin) != metadata:
raise ValueError("The carried original native marker does not match.")
elif (origin / MARKER).exists():
raise ValueError("The carried original manual marker does not match.")
for name in ("halo", "libSDL3.so.0"):
expected = old_files.get(name)
if (not isinstance(expected, str) or not re.fullmatch(r"[a-f0-9]{64}", expected)
or digest(ordinary(beneath(origin / name, origin))) != expected):
raise ValueError("The original native program backup failed verification.")
current_files = {}
for name, expected in new_files.items():
if not isinstance(expected, str) or not re.fullmatch(r"[a-f0-9]{64}", expected):
raise ValueError("The replacement program hashes are invalid.")
live = beneath(GAME / name, GAME)
fresh = beneath(directory / "stage" / name, directory)
if fresh.exists():
if digest(ordinary(fresh)) != expected:
raise ValueError("A staged replacement failed verification.")
# A file still staged has not necessarily replaced its legacy copy.
old_hash = previous_files.get(name)
if live.exists() and digest(ordinary(live)) not in (expected, old_hash):
raise ValueError("An installed program file does not match this upgrade.")
elif not live.exists() or digest(ordinary(live)) != expected:
raise ValueError("An installed replacement failed verification.")
current_files[name] = digest(ordinary(live)) if live.exists() else None
# A redirect with the legacy engine still installed is not evidence
# that checkpoints in this destination use the new save format.
if digest(ordinary(GAME / "halo")) != new_files["halo"]:
raise ValueError("The redirected save destination has no verified new engine.")
config = beneath(origin / "config.toml", origin)
if config.exists():
details = ordinary(config).stat()
if (details.st_uid != os.getuid() or details.st_nlink != 1
or details.st_size > MAX_MANIFEST_BYTES):
raise ValueError("The original configuration backup is not a private bounded file.")
original = configured_save_path(parse_config(config.read_text()))
else:
original = str(GAME / "save")
previous = previous_save_paths(metadata)
if original not in previous:
previous.append(original)
if (original == str(save_root()) or len(previous) > 32
or staged.get("previousSavePaths") != previous):
raise ValueError("The original save locations do not match the staged marker.")
origin_files = {}
for name in ("halo", "libSDL3.so.0", "config.toml", MARKER):
path = beneath(origin / name, origin)
origin_files[name] = digest(ordinary(path)) if path.exists() else None
if carried is not None and carried.get("originFiles") != origin_files:
raise ValueError("The carried original files do not match their verification record.")
matches.append({"files": new_files, "previousSavePaths": previous,
"originPath": str(origin), "originFiles": origin_files,
"currentFiles": current_files})
except (OSError, ValueError, KeyError, TypeError):
continue
if not matches or any(any(value[key] != matches[0][key] for key in
("files", "previousSavePaths", "originFiles", "currentFiles"))
for value in matches[1:]):
raise ValueError(failure)
assert_storage_active(force=True)
return matches[0]
except (OSError, ValueError, KeyError, TypeError):
raise ValueError(failure) from None
def existing_install(repair=False, adopt=False):
beneath(GAME, HOME)
assert_storage_active(force=True)
beneath(GAME, storage_root())
if not GAME.exists():
return None
if not GAME.is_dir():
@@ -246,6 +467,7 @@ def existing_install(repair=False, adopt=False):
codex = GAME / ".codex-halo-native-install.json"
if own.exists():
value = read_marker(own)
check_storage_record(value)
source = value.get("sourceCommit")
elif codex.exists():
value = read_marker(codex, "codex-halo-native-frame-20261005")
@@ -272,15 +494,30 @@ def existing_install(repair=False, adopt=False):
previous_paths = previous_save_paths(value)
migrating = source != SOURCE_COMMIT
beneath(save_root(), GAME)
recovered = None
config_path = GAME / "config.toml"
if migrating and config_path.exists():
try:
settings = tomllib.loads(ordinary(config_path).read_text())
except tomllib.TOMLDecodeError as error:
raise ValueError("The existing game configuration is not valid TOML. Its original contents and saves "
"were kept. Fix the config.toml syntax before retrying.") from error
if configured_save_path(settings) == str(save_root()):
if not needs_upgrade:
raise ValueError("The save destination cannot establish an interrupted legacy upgrade. Existing saves were kept.")
recovered = interrupted_upgrade_record(value)
previous_paths = recovered["previousSavePaths"]
if save_root().exists() and (not save_root().is_dir() or (migrating and any(save_root().iterdir()))):
raise ValueError("The version 1.4 save destination already contains files or is not a directory. Existing saves were kept.")
if not recovered or not save_root().is_dir():
raise ValueError("The version 1.4 save destination already contains files or is not a directory. Existing saves were kept.")
if (GAME / "config.toml").exists():
ordinary(GAME / "config.toml")
settings = tomllib.loads((GAME / "config.toml").read_text())
settings = parse_config((GAME / "config.toml").read_text())
if migrating:
previous = configured_save_path(settings)
if previous not in previous_paths:
previous_paths.append(previous)
if not recovered:
previous = configured_save_path(settings)
if previous not in previous_paths:
previous_paths.append(previous)
else:
validate_save_paths(settings, require_explicit=not repair)
if repair:
@@ -303,6 +540,9 @@ def existing_install(repair=False, adopt=False):
if isinstance(expected, str):
path = beneath(GAME / relative, GAME)
if not path.exists() or digest(ordinary(path)) != expected:
if (recovered and relative in recovered["currentFiles"] and path.exists()
and digest(ordinary(path)) == recovered["currentFiles"][relative]):
continue
# Runtime rewrites config comments/defaults, so its stored hash can differ.
if relative != "config.toml" and not repair:
raise ValueError("An existing native game file failed verification: " + relative)
@@ -314,9 +554,12 @@ def existing_install(repair=False, adopt=False):
if not repair:
raise
maps_verified = False
return {"gamePath": str(GAME), "reused": True, "owner": value["owner"], "sourceCommit": source,
response = {"gamePath": str(GAME), "reused": True, "owner": value["owner"], "sourceCommit": source,
"mapsVerified": maps_verified, "needsImage": not maps_verified, "programIssues": issues,
"needsUpgrade": needs_upgrade, "previousSavePaths": previous_paths}
if recovered:
response["upgradeRecovery"] = recovered
return response
def os_release():
@@ -329,6 +572,7 @@ def os_release():
def preflight(repair=False, adopt=False):
assert_storage_active(force=True)
if pwd.getpwuid(os.getuid()).pw_name != "steamos" or pathlib.Path.home() != HOME:
raise ValueError("Connect using the Frame's steamos account.")
if platform.machine().lower() not in ("aarch64", "arm64"):
@@ -360,16 +604,16 @@ def preflight(repair=False, adopt=False):
beneath(CACHE / "resources", CACHE).mkdir(exist_ok=True)
beneath(CACHE / "resources/artwork", CACHE).mkdir(exist_ok=True)
beneath(CACHE / "runs", CACHE).mkdir(exist_ok=True)
for name in ("remote_install.py", "build-native.sh", "steam_shortcut.py", "steam_live.py", "steam_notes.py", "frame-controls.patch",
for name in ("remote_install.py", "frame_storage.py", "build-native.sh", "steam_shortcut.py", "steam_live.py", "steam_notes.py", "frame-controls.patch",
"artwork/halo-ce-cover.jpg", "artwork/halo-ce-landscape.png", "artwork/halo-ce-hero.jpg",
"artwork/halo-ce-logo.png", "artwork/halo-ce-icon.png"):
candidate = beneath(CACHE / "resources" / name, CACHE)
if candidate.exists() and (not candidate.is_file() or candidate.stat().st_nlink != 1 or candidate.stat().st_uid != os.getuid()):
raise ValueError("An installer resource path is not a private ordinary file.")
existing = existing_install(repair, adopt)
free = shutil.disk_usage(HOME).free
free = shutil.disk_usage(storage_root()).free
if (existing is None or repair or existing.get("needsUpgrade")) and free < MIN_FREE_BYTES:
raise ValueError("At least 12 GiB of free space is needed for the Xbox data and isolated build. Free space and retry.")
raise ValueError("At least 12 GiB of free space is needed on the selected storage for Xbox data and the isolated build. Free space and retry.")
# Podman itself is unprivileged. Never use sudo or disable SteamOS read-only protection.
podman = json.loads(command(["podman", "info", "--format", "json"]))
if not podman.get("host", {}).get("security", {}).get("rootless", False):
@@ -378,7 +622,11 @@ def preflight(repair=False, adopt=False):
"architecture": platform.machine(), "freeBytes": free, "existing": existing,
"retainedUploadReuse": True,
"pcVersionDetected": (HOME / "Games/HaloCEVR").is_dir(),
"steamOSVersion": system.get("VERSION_ID", "unknown")}
"steamOSVersion": system.get("VERSION_ID", "unknown"),
"storage": STORAGE_DESCRIPTOR,
"alternateInstalls": [item for item in storage_module().discover_storage(HOME)["destinations"]
if item["id"] != STORAGE_ID and item["installed"]],
"containerStorageNote": "Podman's image and temporary container storage may still use internal storage; only the selected game and build workspace use the SD card." if STORAGE_ID != "internal" else None}
def preflight_uninstall():
@@ -387,22 +635,24 @@ def preflight_uninstall():
raise ValueError("Connect using the Frame's steamos account.")
if platform.machine().lower() not in ("aarch64", "arm64") or os_release().get("ID") != "steamos":
raise ValueError("Uninstall supports the native game on ARM64 SteamOS Steam Frame only.")
if GAME != HOME / "Games/HaloCENativeVR":
raise ValueError("Uninstall is restricted to the standard native Halo game folder.")
assert_storage_active(force=True)
if GAME != storage_root() / "Games/HaloCENativeVR":
raise ValueError("Uninstall is restricted to the selected fixed native Halo game folder.")
owned(CACHE)
for relative in ("resources", "resources/artwork", "runs"):
directory = beneath(CACHE / relative, CACHE)
directory.mkdir(exist_ok=True)
if not directory.is_dir() or directory.stat().st_uid != os.getuid():
raise ValueError("An installer resource directory is not owned by this account.")
for relative in ("remote_install.py", "steam_shortcut.py", "steam_live.py", "steam_notes.py", "artwork/halo-ce-cover.jpg", "artwork/halo-ce-landscape.png",
for relative in ("remote_install.py", "frame_storage.py", "steam_shortcut.py", "steam_live.py", "steam_notes.py", "artwork/halo-ce-cover.jpg", "artwork/halo-ce-landscape.png",
"artwork/halo-ce-hero.jpg", "artwork/halo-ce-logo.png", "artwork/halo-ce-icon.png"):
path = beneath(CACHE / "resources" / relative, CACHE)
if path.exists():
details = ordinary(path).stat()
if details.st_uid != os.getuid() or details.st_nlink != 1:
raise ValueError("An uninstall helper resource is not a private ordinary file.")
return {"home": str(HOME), "cachePath": str(CACHE), "gamePath": str(GAME), "uninstallSupported": True}
return {"home": str(HOME), "cachePath": str(CACHE), "gamePath": str(GAME), "uninstallSupported": True,
"storage": STORAGE_DESCRIPTOR}
def preflight_library():
@@ -421,10 +671,11 @@ def preflight_library():
def prepare(value, use_existing_maps=False, repair=False, adopt=False):
directory = run_dir(value)
owned(directory)
metadata = read_marker(directory / MARKER)
check_storage_record(metadata, active_run=True)
for relative in ("upload", "upload/maps", "stage"):
path = beneath(directory / relative, directory)
path.mkdir(exist_ok=True)
metadata = read_marker(directory / MARKER)
if use_existing_maps:
current = existing_install(repair, adopt)
if not current or not current["mapsVerified"]:
@@ -434,6 +685,7 @@ def prepare(value, use_existing_maps=False, repair=False, adopt=False):
metadata["mapsOrigin"] = "existing" if use_existing_maps else "upload"
metadata["repair"] = repair
metadata["adopt"] = adopt
metadata.update(storage_fields())
(directory / MARKER).write_text(json.dumps(metadata, indent=2) + "\n")
return {"runPath": str(directory), "uploadPath": str(directory / "upload")}
@@ -482,6 +734,7 @@ def retained_upload(value):
if not directory.is_dir() or directory.stat().st_uid != os.getuid():
raise ValueError("The retained upload run is not an owned directory.")
marker = private_json(directory / MARKER, directory)
check_storage_record(marker)
if (marker.get("owner") != OWNER or marker.get("sourceCommit") != SOURCE_COMMIT
or marker.get("mapsOrigin") != "upload"):
raise ValueError("The retained upload has no recognized original-upload marker.")
@@ -520,6 +773,7 @@ def reuse_upload(value):
"""Reuse verified local data from an older attempt, without changing it."""
directory = run_dir(value)
metadata = read_marker(directory / MARKER)
check_storage_record(metadata, active_run=True)
if metadata.get("mapsOrigin") != "upload":
raise ValueError("Only a fresh upload run can select retained Xbox data.")
upload = beneath(directory / "upload", directory)
@@ -529,6 +783,7 @@ def reuse_upload(value):
manifest = private_json(upload / "xbox-data-manifest.json", upload)
selected = map_identity(manifest)
def check_cancelled():
assert_storage_active()
if (directory / "cancelled").exists():
raise RuntimeError("Installation cancelled. Existing games and saves were kept.")
check_cancelled()
@@ -623,7 +878,7 @@ def build_container_args(value, directory, payload=None):
"--userns=keep-id:uid=0,gid=0", "--user=0:0",
"--security-opt=no-new-privileges",
"--volume", str(directory) + ":/build:rw", "--workdir", "/build",
"docker.io/library/ubuntu:22.04",
BUILD_CONTAINER_IMAGE,
*(payload or ["/bin/bash", "/build/build-native.sh"])]
@@ -827,6 +1082,7 @@ class BuildLogMonitor:
def build(value, repair=False, adopt=False):
directory = run_dir(value)
metadata = read_marker(directory / MARKER)
check_storage_record(metadata, active_run=True)
existing = existing_install(repair, adopt)
if existing and not repair:
if existing.get("needsUpgrade"):
@@ -834,6 +1090,7 @@ def build(value, repair=False, adopt=False):
return {"reused": True}
upload = directory / "upload"
def check_cancelled():
assert_storage_active()
if (directory / "cancelled").exists():
raise RuntimeError("Build cancelled. Existing games and saves were kept.")
check_cancelled()
@@ -870,22 +1127,36 @@ def build(value, repair=False, adopt=False):
monitor = BuildLogMonitor(directory)
try:
while process.poll() is None:
check_cancelled()
monitor.poll()
if (directory / "cancelled").exists():
cancel(value)
stop_build_process(process)
raise RuntimeError("Build cancelled. Existing games and saves were kept.")
monitor.heartbeat()
time.sleep(0.25)
except BaseException:
assert_storage_active(force=True)
except BaseException as error:
try:
cancel(value)
try:
cancel(value)
except (OSError, ValueError):
# The selected mount can disappear with its journal. Its
# run/owner labels still authorize stopping this container.
stop_owned_container(value)
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as stop_error:
error.add_note("Stopping the owned container could not be confirmed: " + str(stop_error)[:500])
finally:
stop_build_process(process)
try:
stop_build_process(process)
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as stop_error:
error.add_note("Reaping the supervised build process could not be confirmed: " + str(stop_error)[:500])
raise
finally:
output.flush()
monitor.drain()
try:
assert_storage_active(force=True)
except (OSError, ValueError):
# Do not open logs through a changed or absent mount.
pass
else:
output.flush()
monitor.drain()
if process.returncode:
stream, details = open_build_log(log, directory)
with stream:
@@ -926,25 +1197,48 @@ def stop_build_process(process):
process.wait(timeout=10)
def cancel(value):
directory = run_dir(value)
read_marker(directory / MARKER)
(directory / "cancelled").touch()
def stop_owned_container(value):
"""Stop this installer's labeled run without depending on its filesystem."""
run_id(value)
container = "halo-frame-installer-" + value
check = subprocess.run(["podman", "container", "inspect", container],
text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=15)
if check.returncode == 0:
info = json.loads(check.stdout)[0]
labels = info.get("Config", {}).get("Labels", {})
if labels.get("org.halo-frame-installer.owner") != OWNER or labels.get("org.halo-frame-installer.run") != value:
if len(check.stdout) > 64 * 1024:
raise ValueError("The container ownership response exceeds supported bounds.")
inspected = json.loads(check.stdout)
if not isinstance(inspected, list) or len(inspected) != 1 or not isinstance(inspected[0], dict):
raise ValueError("The container ownership response is invalid.")
config = inspected[0].get("Config")
labels = config.get("Labels") if isinstance(config, dict) else None
if (not isinstance(labels, dict) or labels.get("org.halo-frame-installer.owner") != OWNER
or labels.get("org.halo-frame-installer.run") != value):
raise ValueError("Refusing to stop an unowned container.")
command(["podman", "stop", "--time", "10", container])
command(["podman", "stop", "--time", "10", container], timeout=20)
return {"cancelled": True}
def parse_config(original):
"""Reject unsupported input consistently before preflight or rewriting."""
try:
settings = tomllib.loads(original) if original.strip() else {}
except tomllib.TOMLDecodeError:
raise ValueError("The game configuration is not valid TOML. Your original configuration was kept.") from None
if any(not isinstance(settings.get(section, {}), dict) for section in ("vr", "update", "network", "paths")):
raise ValueError("The game configuration has an unsupported setting shape. Your original configuration was kept.")
return settings
def cancel(value):
directory = run_dir(value)
read_marker(directory / MARKER)
(directory / "cancelled").touch()
return stop_owned_container(value)
def merge_config(original, migrate=False):
"""Restore required native controls while preserving unrelated TOML settings."""
settings = tomllib.loads(original) if original.strip() else {}
settings = parse_config(original)
if migrate:
configured_save_path(settings)
else:
@@ -980,7 +1274,10 @@ def merge_config(original, migrate=False):
lines[i] = key + " = " + json.dumps(remaining.pop(key))
lines[end:end] = [key + " = " + json.dumps(val) for key, val in remaining.items()]
combined = "\n".join(lines) + "\n"
updated = tomllib.loads(combined)
try:
updated = tomllib.loads(combined)
except tomllib.TOMLDecodeError:
raise ValueError("The updated configuration could not be parsed. Your original configuration was kept.") from None
if any(updated.get(section, {}).get(key) != value for section, changes in required.items() for key, value in changes.items()):
raise ValueError("This TOML formatting cannot be safely updated automatically. Your original configuration was kept.")
for section, value in settings.items():
@@ -994,12 +1291,12 @@ def merge_config(original, migrate=False):
return combined
def repair_program_files(stage, directory, names, check_cancelled):
def repair_program_files(stage, directory, names, check_cancelled, recovery=None):
"""Back up then atomically replace each selected file; roll back on any failure."""
game_closed()
backup = beneath(directory / "previous-program-files", directory)
backup.mkdir(exist_ok=False)
(backup / ".backup-owner.json").write_text(json.dumps({"owner": OWNER, "sourceCommit": SOURCE_COMMIT}) + "\n")
(backup / ".backup-owner.json").write_text(json.dumps({"owner": OWNER, "sourceCommit": SOURCE_COMMIT, **storage_fields()}) + "\n")
snapshot = []
for relative in names:
check_cancelled()
@@ -1016,6 +1313,37 @@ def repair_program_files(stage, directory, names, check_cancelled):
if digest(saved) != old_hash:
raise ValueError("The existing game changed while backing up: " + relative)
snapshot.append((relative, old_hash, digest(fresh)))
if recovery is not None:
# Carry one verified original snapshot forward, not an unbounded chain of
# references to earlier runs. Publish its record before any replacement.
old_hashes = {relative: old_hash for relative, old_hash, _ in snapshot}
if any(old_hashes.get(name) != expected for name, expected in recovery["currentFiles"].items()):
raise ValueError("The interrupted installation changed while preparing its recovery backup.")
source = beneath(pathlib.Path(recovery["originPath"]), CACHE)
origin = beneath(backup / "upgrade-origin", backup)
assert_storage_active(force=True)
origin.mkdir(mode=0o700, exist_ok=False)
for name, expected in recovery["originFiles"].items():
check_cancelled()
original = beneath(source / name, source)
if expected is None:
if original.exists():
raise ValueError("The original upgrade backup changed during recovery.")
continue
if digest(ordinary(original)) != expected:
raise ValueError("The original upgrade backup changed during recovery.")
target = beneath(origin / name, origin)
assert_storage_active(force=True)
shutil.copy2(original, target)
if digest(ordinary(target)) != expected:
raise ValueError("The original upgrade backup failed copy verification.")
journal = {"owner": OWNER, "sourceCommit": SOURCE_COMMIT, **storage_fields(),
"previousFiles": recovery["currentFiles"], "originFiles": recovery["originFiles"]}
assert_storage_active(force=True)
with (backup / ".upgrade-recovery.json").open("x", encoding="utf-8") as stream:
stream.write(json.dumps(journal) + "\n")
stream.flush()
os.fsync(stream.fileno())
applied = []
try:
for relative, old_hash, new_hash in snapshot:
@@ -1025,6 +1353,7 @@ def repair_program_files(stage, directory, names, check_cancelled):
actual = digest(ordinary(target)) if target.exists() else None
if actual != old_hash:
raise ValueError("The existing game changed during repair: " + relative)
assert_storage_active(force=True)
target.parent.mkdir(parents=True, exist_ok=True)
os.replace(stage / relative, target)
applied.append((relative, old_hash, new_hash))
@@ -1033,15 +1362,20 @@ def repair_program_files(stage, directory, names, check_cancelled):
raise ValueError("A repaired program file failed verification: " + relative)
check_cancelled()
except BaseException as error:
try:
assert_storage_active(force=True)
except (OSError, ValueError):
raise RuntimeError("Repair stopped because the selected SD mount changed. Restoration was not attempted on that mount; original program backups remain in " + str(backup) + ".") from error
failures = []
for relative, old_hash, _ in reversed(applied):
target = GAME / relative
try:
assert_storage_active(force=True)
if old_hash is None:
target.unlink()
else:
os.replace(backup / relative, target)
except OSError:
except (OSError, ValueError):
failures.append(relative)
if failures:
raise RuntimeError("Repair failed and some files could not be restored: " + ", ".join(failures) + ". Original files remain in " + str(backup)) from error
@@ -1052,7 +1386,9 @@ def repair_program_files(stage, directory, names, check_cancelled):
def finalize(value, repair=False, adopt=False):
directory = run_dir(value)
run_metadata = read_marker(directory / MARKER)
check_storage_record(run_metadata, active_run=True)
def check_cancelled():
assert_storage_active()
if (directory / "cancelled").exists():
raise ValueError("This run was cancelled. Existing games and saves were kept.")
check_cancelled()
@@ -1129,12 +1465,12 @@ def finalize(value, repair=False, adopt=False):
libs = command(["ldd", str(stage / "halo")])
if "not found" in libs:
raise ValueError("A required native runtime library is missing:\n" + libs)
metadata = {"owner": OWNER, "version": 1, "sourceCommit": SOURCE_COMMIT,
metadata = {"owner": OWNER, "version": 1, "sourceCommit": SOURCE_COMMIT, **storage_fields(),
"sourceUrl": SOURCE_URL, "architecture": "native ARM64 ELF64 / ILP32 AArch64 guest",
"files": {name: digest(stage / name) for name in ("halo", "libSDL3.so.0", "brokers.txt", "frame-controls.patch")},
"maps": {"files": 24, "bytes": manifest["totalBytes"]},
"controls": "Head-directed walking, Xbox buttons, motion aim, LB grenade change, RB flashlight",
"installerVersion": "1.4.1", "networkProtocol": 17, "saveRoot": str(save_root()),
"installerVersion": "1.4.2", "networkProtocol": 17, "saveRoot": str(save_root()),
"previousSavePaths": existing.get("previousSavePaths", []) if existing else [],
"buildLog": str(directory / "native-build.log")}
(stage / MARKER).write_text(json.dumps(metadata, indent=2) + "\n")
@@ -1150,16 +1486,17 @@ def finalize(value, repair=False, adopt=False):
names += [item["path"] for item in manifest["files"]]
# Publish the ownership/provenance marker last; saves and all other files stay put.
names.append(MARKER)
backup = repair_program_files(stage, directory, names, check_cancelled)
backup = repair_program_files(stage, directory, names, check_cancelled, existing.get("upgradeRecovery"))
return {"gamePath": str(GAME), "reused": False, "repaired": True,
"sourceCommit": SOURCE_COMMIT, "backupPath": backup, "mapsReinstalled": not reuse_maps,
"previousSavePaths": existing.get("previousSavePaths", [])}
beneath(GAME.parent, HOME).mkdir(exist_ok=True)
beneath(GAME.parent, storage_root()).mkdir(exist_ok=True)
if GAME.exists() or GAME.is_symlink():
raise ValueError("The game destination appeared during installation. It was not modified.")
if stage.stat().st_dev != GAME.parent.stat().st_dev:
raise ValueError("Game and staging must be on the same filesystem for atomic installation.")
check_cancelled()
assert_storage_active(force=True)
rename_noreplace(stage, GAME)
return {"gamePath": str(GAME), "reused": False, "sourceCommit": SOURCE_COMMIT}
@@ -1209,7 +1546,8 @@ def no_active_build():
def uninstall_identity():
beneath(GAME, HOME)
assert_storage_active(force=True)
beneath(GAME, storage_root())
if not GAME.exists():
return None
if not GAME.is_dir() or GAME.stat().st_uid != os.getuid():
@@ -1218,6 +1556,7 @@ def uninstall_identity():
path = GAME / name
if path.exists() or path.is_symlink():
metadata = private_json(path, GAME)
check_storage_record(metadata)
if metadata.get("owner") != owner:
raise ValueError("The native game folder belongs to another application. It was kept.")
return metadata
@@ -1230,7 +1569,8 @@ def uninstall_signature(details):
def uninstall_root_identity(directory):
"""The directory's identity survives its rename and partial file removal."""
details = beneath(directory, HOME).lstat()
assert_storage_active(force=True)
details = beneath(directory, storage_root()).lstat()
if not stat.S_ISDIR(details.st_mode) or details.st_uid != os.getuid():
raise ValueError("The uninstall folder is not an ordinary owned directory.")
return {"device": details.st_dev, "inode": details.st_ino, "uid": details.st_uid}
@@ -1241,6 +1581,7 @@ def write_uninstall_record(directory, metadata, owner=OWNER):
if metadata.get("owner") != owner:
raise ValueError("The uninstall record has an invalid owner.")
uninstall_root_identity(directory)
metadata.update(storage_fields())
target = beneath(directory / MARKER, directory)
if target.exists():
current = private_json(target, directory)
@@ -1282,7 +1623,8 @@ def uninstall_mounts():
def uninstall_inventory(directory):
"""Validate the whole owned tree before deletion; never follow a link or mount."""
beneath(directory, HOME)
assert_storage_active(force=True)
beneath(directory, storage_root())
root = directory.lstat()
if not stat.S_ISDIR(root.st_mode) or root.st_uid != os.getuid() or os.path.ismount(directory):
raise ValueError("Uninstall requires an ordinary owned folder, not a link or mount.")
@@ -1363,17 +1705,20 @@ def uninstall_save_files(inventory):
def copy_uninstall_save(relative, target, signature):
assert_storage_active(force=True)
source = ordinary(beneath(GAME / relative, GAME))
descriptor = os.open(source, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
with os.fdopen(descriptor, "rb") as stream:
details = os.fstat(stream.fileno())
if details.st_nlink != 1 or uninstall_signature(details) != signature:
raise ValueError("A save file changed while preparing its backup. Game files were kept.")
assert_storage_active(force=True)
target.parent.mkdir(parents=True, exist_ok=True)
checksum = hashlib.sha256()
copied = 0
with target.open("xb") as output:
while True:
assert_storage_active()
block = stream.read(min(1024 * 1024, details.st_size - copied + 1))
if not block:
break
@@ -1384,6 +1729,7 @@ def copy_uninstall_save(relative, target, signature):
checksum.update(block)
output.flush()
os.fsync(output.fileno())
assert_storage_active(force=True)
if target.stat().st_size != details.st_size or uninstall_signature(os.fstat(stream.fileno())) != signature:
raise ValueError("A save backup could not be verified. Game files were kept.")
target.chmod(details.st_mode & 0o777)
@@ -1396,12 +1742,14 @@ def copy_uninstall_save(relative, target, signature):
def remove_uninstall_tree(directory, inventory, completed, total):
"""Unlink only validated paths, counting actual removal work for the UI."""
assert_storage_active(force=True)
files = [relative for relative, (kind, _) in inventory.items() if kind == "file"]
markers = [relative for relative in files if relative in (MARKER, ".codex-halo-native-install.json")]
files = sorted(relative for relative in files if relative not in markers)
directories = sorted((relative for relative, (kind, _) in inventory.items() if kind == "directory"),
key=lambda relative: len(pathlib.Path(relative).parts), reverse=True)
for relative in files + directories + markers:
assert_storage_active()
path = beneath(directory / relative, directory)
details = path.lstat()
kind, signature = inventory[relative]
@@ -1417,14 +1765,22 @@ def remove_uninstall_tree(directory, inventory, completed, total):
if total:
progress("uninstall", f"Uninstalling native Halo: {completed[0]:,} of {total:,} file tasks.",
completed[0] * 100 // total)
assert_storage_active(force=True)
directory.rmdir()
completed[0] += 1
def same_uninstall_root(expected, directory):
def same_uninstall_root(expected, directory, metadata=None):
actual = uninstall_root_identity(directory)
comparable = dict(expected) if isinstance(expected, dict) else expected
if STORAGE_ID != "internal" and isinstance(comparable, dict):
# Require the persistent CID/filesystem journal before allowing a
# device number to change when that same card has been remounted.
check_storage_record(metadata or {})
comparable["device"] = actual["device"]
if (not isinstance(expected, dict) or set(expected) != {"device", "inode", "uid"}
or any(type(value) is not int or value < 0 for value in expected.values())
or expected != uninstall_root_identity(directory)):
or comparable != actual):
raise ValueError("The quarantine directory does not match its recorded uninstall identity.")
@@ -1434,18 +1790,21 @@ def uninstall_quarantine_record(path, identifier):
directory = beneath(CACHE / "runs" / identifier, CACHE)
uninstall_root_identity(directory)
metadata = private_json(directory / MARKER, directory)
check_storage_record(metadata)
if (metadata.get("owner") != OWNER or metadata.get("operation") != "uninstall"
or metadata.get("state") not in ("quarantining", "removing")
or metadata.get("quarantinePath") != str(path)):
raise ValueError("No matching private interrupted-uninstall record was found.")
root = metadata.get("quarantineRoot")
if root is not None:
same_uninstall_root(root, path)
same_uninstall_root(root, path, metadata)
recognized = False
for name, owner in ((MARKER, OWNER), (".codex-halo-native-install.json", "codex-halo-native-frame-20261005")):
marker = path / name
if marker.exists() or marker.is_symlink():
if private_json(marker, path).get("owner") != owner:
value = private_json(marker, path)
check_storage_record(value)
if value.get("owner") != owner:
raise ValueError("A quarantine marker belongs to another application.")
recognized = True
# Older versions wrote the exact path and operation only after the rename.
@@ -1465,9 +1824,10 @@ def verified_uninstall_backup(identifier, metadata):
if not isinstance(location, str) or location != str(saved):
raise ValueError("The uninstall save backup path does not match its operation.")
if metadata.get("savedBackupRoot") is not None:
same_uninstall_root(metadata["savedBackupRoot"], saved)
same_uninstall_root(metadata["savedBackupRoot"], saved, metadata)
inventory = uninstall_inventory(saved)
value = private_json(saved / MARKER, saved)
check_storage_record(value)
if value.get("owner") != OWNER + "-save-backup" or value.get("gamePath") != str(GAME):
raise ValueError("The recorded save backup has no valid native-game ownership record.")
actual = {relative for relative, (kind, _) in inventory.items() if kind == "file" and relative != MARKER}
@@ -1517,7 +1877,7 @@ def recover_interrupted_uninstall():
prefix = ".HaloCENativeVR-uninstall-"
candidates = []
# Avoid sorting/materializing an unbounded glob of user-controlled entries.
beneath(GAME.parent, HOME)
beneath(GAME.parent, storage_root())
if not GAME.parent.exists():
return recovery
with os.scandir(GAME.parent) as entries:
@@ -1612,8 +1972,8 @@ def uninstall(value, close_steam=False, keep_saves=False):
inventory = uninstall_inventory(GAME) if identity else {}
save_files, external = uninstall_save_files(inventory) if identity and keep_saves else ([], [])
quarantine = GAME.parent / (".HaloCENativeVR-uninstall-" + value)
saved = beneath(GAME.parent / ("HaloCENativeVR-saves-" + value), HOME)
staging = beneath(GAME.parent / (".HaloCENativeVR-saves-" + value + ".tmp"), HOME)
saved = beneath(GAME.parent / ("HaloCENativeVR-saves-" + value), storage_root())
staging = beneath(GAME.parent / (".HaloCENativeVR-saves-" + value + ".tmp"), storage_root())
if staging.exists() or staging.is_symlink():
raise ValueError("An unfinished save backup already exists and was kept: " + str(staging)
+ ". Use a fresh uninstall run.")
@@ -1635,6 +1995,7 @@ def uninstall(value, close_steam=False, keep_saves=False):
progress("uninstall", "Preparing to remove the managed native game and its Steam shortcut...", 0)
try:
if save_files:
assert_storage_active(force=True)
staging.mkdir(mode=0o700, exist_ok=False)
staged = True
backup_metadata = {"owner": OWNER + "-save-backup", "gamePath": str(GAME),
@@ -1688,9 +2049,11 @@ def uninstall(value, close_steam=False, keep_saves=False):
"keepSaves": keep_saves, "state": "quarantining"})
write_uninstall_record(directory, metadata)
if staged:
assert_storage_active(force=True)
rename_noreplace(staging, saved)
staged, saved_path = False, str(saved)
verified_uninstall_backup(value, metadata)
assert_storage_active(force=True)
rename_noreplace(GAME, quarantine)
renamed = True
metadata["state"] = "removing"
@@ -1714,6 +2077,7 @@ def uninstall(value, close_steam=False, keep_saves=False):
removals = completed[0] - len(save_files)
if removals == 0:
try:
assert_storage_active(force=True)
rename_noreplace(quarantine, GAME)
renamed = False
except (OSError, ValueError, RuntimeError):
@@ -1733,16 +2097,21 @@ def uninstall(value, close_steam=False, keep_saves=False):
def main():
parser = argparse.ArgumentParser()
parser.add_argument("step", choices=("preflight", "preflight-uninstall", "preflight-library", "prepare", "reuse-upload", "build", "finalize", "cancel", "shortcut", "uninstall"))
parser.add_argument("step", choices=("storage", "preflight", "preflight-uninstall", "preflight-library", "prepare", "reuse-upload", "build", "finalize", "cancel", "shortcut", "uninstall"))
parser.add_argument("--run-id")
parser.add_argument("--close-steam", action="store_true")
parser.add_argument("--repair", action="store_true")
parser.add_argument("--adopt-existing", action="store_true")
parser.add_argument("--use-existing-maps", action="store_true")
parser.add_argument("--keep-saves", action="store_true")
parser.add_argument("--storage-id", default="internal")
args = parser.parse_args()
if args.step == "storage":
storage_module().main()
return
if args.step in ("prepare", "reuse-upload", "build", "finalize", "cancel", "uninstall") and args.run_id is None:
parser.error("--run-id is required")
select_storage(args.storage_id)
functions = {"preflight": lambda: preflight(args.repair, args.adopt_existing),
"preflight-uninstall": preflight_uninstall,
"preflight-library": preflight_library,
+23 -11
View File
@@ -17,6 +17,8 @@ import urllib.parse
import urllib.request
import zlib
from frame_storage import validate_game_path
MAX_BYTES = 8 * 1024 * 1024
PORT = 8080
TIMEOUT = 30
@@ -24,6 +26,7 @@ LOOPBACK = {"127.0.0.1", "localhost", "::1"}
HOME = Path("/home/steamos")
GAME = HOME / "Games/HaloCENativeVR"
NAME = "Halo: Combat Evolved VR (Native)"
SD_NAME = "Halo: Combat Evolved VR (Native, SD card)"
LAUNCH_OPTIONS = "SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 %command%"
@@ -241,12 +244,20 @@ class Client:
self.ws.close()
def native_spec(home, game, name, launch_options=LAUNCH_OPTIONS):
if (Path(home) != HOME or Path(game) != GAME or name != NAME or launch_options != LAUNCH_OPTIONS):
def native_spec(home, game, name=None, launch_options=LAUNCH_OPTIONS):
if Path(home) != HOME or launch_options != LAUNCH_OPTIONS:
raise LiveSteamError("The live client path is restricted to the managed native Halo installation.")
exe = str(GAME / "halo")
return {"name": NAME, "exe": exe, "directory": str(GAME), "launchOptions": LAUNCH_OPTIONS,
"candidateAppids": [zlib.crc32((value + NAME).encode()) | 0x80000000
try:
destination = validate_game_path(home, game)
except (OSError, ValueError) as error:
raise LiveSteamError("The live client path is restricted to a currently verified native Halo installation: " + str(error)) from error
expected_name = SD_NAME if destination["kind"] == "sd" else NAME
if name is not None and name != expected_name:
raise LiveSteamError("The live client shortcut name is restricted to its verified native Halo destination.")
game = Path(game)
exe = str(game / "halo")
return {"name": expected_name, "exe": exe, "directory": str(game), "launchOptions": LAUNCH_OPTIONS,
"candidateAppids": [zlib.crc32((value + expected_name).encode()) | 0x80000000
for value in (exe, '"' + exe + '"')]}
@@ -409,6 +420,7 @@ def _library(home, game, name, operation, launch_options=LAUNCH_OPTIONS):
or verified.get("launchOptions") != LAUNCH_OPTIONS or verified.get("shortcutLaunchOptions") != LAUNCH_OPTIONS
or verified.get("vr") is not True or verified.get("compatibilityTool") != ""):
raise LiveSteamError("Steam's native launch settings did not pass readback verification.")
value["name"] = spec["name"]
return value
@@ -535,7 +547,7 @@ def _artwork_config(home, account_name):
def _live_artwork(home, game, appid, sources=None):
from steam_shortcut import (ARTWORK, ARTWORK_EXTENSIONS, ARTWORK_TYPES, ICON_NAME,
ICON_SHA256, _artwork_file, _private_steam_directory)
spec = native_spec(home, game, NAME)
spec = native_spec(home, game)
spec["appid"] = appid
sources = Path(sources) if sources is not None else Path(__file__).resolve().parent / "artwork"
_private_steam_directory(sources)
@@ -625,7 +637,7 @@ def _live_icon(home, game, appid, sources=None):
from steam_shortcut import (ICON_NAME, ICON_SHA256, _artwork_file,
_private_steam_directory, _publish_shortcut_icon,
_shortcut_icon_field)
spec = native_spec(home, game, NAME)
spec = native_spec(home, game)
spec["appid"] = appid
spec["requestIcon"] = True
source = Path(sources) if sources is not None else Path(__file__).resolve().parent / "artwork"
@@ -706,7 +718,7 @@ def _live_icon(home, game, appid, sources=None):
return {"status": "added", "path": icon["path"]}
def add_native(home, game, name=NAME, launch_options=LAUNCH_OPTIONS, artwork=None):
def add_native(home, game, name=None, launch_options=LAUNCH_OPTIONS, artwork=None):
value = _library(home, game, name, "add", launch_options)
try:
art_result = _live_artwork(home, game, value["appid"], artwork)
@@ -718,12 +730,12 @@ def add_native(home, game, name=NAME, launch_options=LAUNCH_OPTIONS, artwork=Non
except (ImportError, OSError, ValueError, RuntimeError, UnicodeError) as error:
art_result["icon"] = {"status": "manual", "reason": str(error),
"instructions": "Click Add to Steam again to retry the Halo shortcut icon."}
return {"status": "added", "libraryAdded": True, "appid": value["appid"], "name": NAME,
return {"status": "added", "libraryAdded": True, "appid": value["appid"], "name": value["name"],
"unchanged": not value.get("created", False), "transport": "live-client",
"artwork": art_result}
def remove_native(home, game, name=NAME):
def remove_native(home, game, name=None):
value = _library(home, game, name, "remove")
return {"status": value["status"], "name": NAME, "transport": "live-client", "accountScope": "active-client",
return {"status": value["status"], "name": value["name"], "transport": "live-client", "accountScope": "active-client",
**({"appid": value["appid"]} if "appid" in value else {})}
+24 -4
View File
@@ -6,7 +6,7 @@ official Steam application. No Steam files or sessions are changed here.
"""
from __future__ import annotations
from steam_live import Client, GAME, HOME, NAME, expression, native_spec
from steam_live import Client, GAME, HOME, NAME, SD_NAME, expression, native_spec
NOTE_ID = "haloframeaboutv1"
NOTE_TITLE = "Halo CE VR — About & controls"
@@ -279,18 +279,38 @@ NOTES = r"""(async () => {
})()"""
def add_notes(home, game, appid, name=NAME):
def _note_content(name):
if name == NAME:
return NOTE_CONTENT
if name != SD_NAME:
raise ValueError("The native Halo Notes name is unsupported.")
return (NOTE_CONTENT.replace(NAME, SD_NAME)
.replace("the same 1.4.0 release", "matching native protocol-17 builds")
.replace("1.4.0 uses protocol 17; 1.3.5", "This native build uses protocol 17; 1.3.5")
.replace(
"1.4.0 upgrades the existing native game. New profiles and saves use\n"
"save-v1.4 inside its game folder. Your old save folder is retained\n"
"unchanged; older checkpoints cannot load in the newer engine.",
"This SD-card installation stays separate from the internal installation.\n"
"Profiles and saves use save-v1.4 inside this game folder. Keep this SD\n"
"card inserted and mounted when playing. Internal game files are retained."))
def add_notes(home, game, appid, name=None):
"""Return independent notes status without interrupting game registration."""
result = {"ok": False, "status": "manual",
"message": "Steam Game Notes is unavailable. Add the supplied text as a note manually.",
"title": NOTE_TITLE, "content": NOTE_CONTENT}
try:
spec = native_spec(home, game, name)
content = _note_content(spec["name"])
result["content"] = content
if type(appid) is not int or not 2 ** 31 <= appid < 2 ** 32:
return result
spec.update({"appid": appid, "noteId": NOTE_ID, "title": NOTE_TITLE,
"content": NOTE_CONTENT, "maxBytes": MAX_DOCUMENT_BYTES,
"maxNotes": MAX_NOTES, "previousContents": list(PREVIOUS_NOTE_CONTENTS)})
"content": content, "maxBytes": MAX_DOCUMENT_BYTES,
"maxNotes": MAX_NOTES,
"previousContents": list(PREVIOUS_NOTE_CONTENTS) if spec["name"] == NAME else []})
with Client() as client:
value = client.evaluate(expression(NOTES, spec))
status = value.get("status")
+44 -20
View File
@@ -19,6 +19,7 @@ from dataclasses import dataclass
from pathlib import Path
NAME = "Halo: Combat Evolved VR (Native)"
SD_NAME = "Halo: Combat Evolved VR (Native, SD card)"
LAUNCH_OPTIONS = "SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 %command%"
ARTWORK = (("p", ".jpg", "halo-ce-cover.jpg", "84fee9349f8f00d8ade44c840330ac54ec4b01ef84086d72b8c791d16c8ad248"),
("", ".png", "halo-ce-landscape.png", "179ff6b9bd1a6ba610e9c0c4b9b32c76e525719b253856d547f65eaa02d1f33d"),
@@ -243,15 +244,17 @@ def _compatibility_warning(steam: Path, appid: int) -> str | None:
return None
def _added_result(steam: Path, appid: int, **extra) -> dict:
def _added_result(steam: Path, appid: int, name: str = NAME, **extra) -> dict:
warning = _compatibility_warning(steam, appid)
if warning:
return {"status": "manual", "libraryAdded": True, "appid": appid, "name": NAME,
return {"status": "manual", "libraryAdded": True, "appid": appid, "name": name,
"reason": warning, "instructions": warning, **extra}
return {"status": "added", "appid": appid, "name": NAME, **extra}
return {"status": "added", "appid": appid, "name": name, **extra}
def update_shortcut(data: bytes | None, executable: str, directory: str) -> tuple[bytes, int]:
def update_shortcut(data: bytes | None, executable: str, directory: str, name: str = NAME) -> tuple[bytes, int]:
if name not in (NAME, SD_NAME):
raise ValueError("The native Halo shortcut name is unsupported.")
root = loads(data) if data is not None else OrderedDict([("shortcuts", Value(0, OrderedDict()))])
if set(root) != {"shortcuts"} or root["shortcuts"].kind != 0:
raise ValueError("Unexpected Steam shortcuts root; no changes were made.")
@@ -265,7 +268,7 @@ def update_shortcut(data: bytes | None, executable: str, directory: str) -> tupl
fields = entry.value
title = fields.get("AppName", fields.get("appname"))
path = fields.get("Exe", fields.get("exe"))
if title and title.value == NAME:
if title and title.value == name:
if path is None or str(path.value).strip('"') != executable:
raise ValueError("A different game already uses this Steam shortcut name. Add this game manually.")
matches.append((index, fields))
@@ -278,7 +281,7 @@ def update_shortcut(data: bytes | None, executable: str, directory: str) -> tupl
raise ValueError("The existing native Halo shortcut has an invalid app ID.")
appid = app.value
else:
appid = zlib.crc32((exe + NAME).encode("utf-8")) | 0x80000000
appid = zlib.crc32((exe + name).encode("utf-8")) | 0x80000000
used = {entry.value.get("appid").value for entry in shortcuts.values() if entry.value.get("appid")}
if appid in used:
raise ValueError("Steam shortcut app ID collision. Add the game manually.")
@@ -288,7 +291,7 @@ def update_shortcut(data: bytes | None, executable: str, directory: str) -> tupl
shortcuts[key] = Value(0, fields)
fields["appid"] = Value(2, appid)
# Preserve tags, artwork, playtime and all other fields on an existing entry.
for key, value in {"AppName": NAME, "Exe": exe, "StartDir": start,
for key, value in {"AppName": name, "Exe": exe, "StartDir": start,
"LaunchOptions": LAUNCH_OPTIONS}.items():
# Remove only an alternative case spelling of fields we are writing.
for old in list(fields):
@@ -298,9 +301,19 @@ def update_shortcut(data: bytes | None, executable: str, directory: str) -> tupl
for key in ("icon", "ShortcutPath"):
if key not in fields:
fields[key] = Value(1, "")
# Library choices the user made on an existing entry, such as hiding it,
# are theirs; only new entries get the defaults, and a choice is only
# kept when it is a real integer flag. OpenVR stays managed: the live
# registration path sets and verifies the VR flag as well, and the game
# needs it to appear in the headset's library.
for key, value in {"IsHidden": 0, "AllowDesktopConfig": 1, "AllowOverlay": 1,
"OpenVR": 1, "Devkit": 0, "DevkitOverrideAppID": 0}.items():
fields[key] = Value(2, value)
existing = next((old for old in fields if old.lower() == key.lower()), None)
if existing is not None and key != "OpenVR" and fields[existing].kind == 2:
continue
# Assign through any existing slot so field order, and therefore the
# written bytes, stay stable across repeated registrations.
fields[existing if existing is not None else key] = Value(2, value)
if "LastPlayTime" not in fields:
fields["LastPlayTime"] = Value(2, 0)
if "tags" not in fields:
@@ -382,8 +395,10 @@ def add_native_shortcut(home: Path, game: Path, close_steam: bool = False) -> di
Steam Frame's headset session is managed by SteamOS. This helper never shuts
down or starts Steam, Steam Home, SteamVR, games or their services.
"""
name = _managed_name(home, game)
return _with_closed_steam(game, close_steam, lambda: _write_native_shortcut(home, game),
_manual, "Add to Steam", lambda: _live_native(home, game, "add"))
lambda target, reason=None: _manual(target, reason, name),
"Add to Steam", lambda: _live_native(home, game, "add"))
def _with_closed_steam(game: Path, close_steam: bool, action, manual, button: str, live=None) -> dict:
@@ -400,13 +415,19 @@ def _with_closed_steam(game: Path, close_steam: bool, action, manual, button: st
def _live_native(home: Path, game: Path, operation: str) -> dict:
from steam_live import add_native, remove_native
return add_native(home, game, NAME, LAUNCH_OPTIONS) if operation == "add" else remove_native(home, game, NAME)
return add_native(home, game, launch_options=LAUNCH_OPTIONS) if operation == "add" else remove_native(home, game)
def _manual(game: Path, reason: str | None = None) -> dict:
manual = {"status": "manual", "name": NAME, "executable": str(game / "halo"),
def _managed_name(home: Path, game: Path) -> str:
from frame_storage import validate_game_path
destination = validate_game_path(home, game)
return SD_NAME if destination["kind"] == "sd" else NAME
def _manual(game: Path, reason: str | None = None, name: str = NAME) -> dict:
manual = {"status": "manual", "name": name, "executable": str(game / "halo"),
"directory": str(game), "launchOptions": LAUNCH_OPTIONS,
"instructions": "Use Steam > Add a Game > Add a Non-Steam Game and browse to the executable. Set the displayed launch options and leave compatibility tools disabled. Automatic library editing can be retried when Steam is already closed."}
"instructions": "Use Steam > Add a Game > Add a Non-Steam Game and browse to the executable. Set the game name to " + name + ". Set the displayed launch options and leave compatibility tools disabled. Automatic library editing can be retried when Steam is already closed."}
if reason is not None:
manual["reason"] = reason
return manual
@@ -653,7 +674,8 @@ def _artwork_result(config: Path, appid: int) -> dict:
def _write_native_shortcut(home: Path, game: Path) -> dict:
manual = _manual(game)
name = _managed_name(home, game)
manual = _manual(game, name=name)
if steam_running():
return {**manual, "reason": "Steam is still running; its library files were left unchanged."}
steam = home / ".local/share/Steam"
@@ -669,7 +691,7 @@ def _write_native_shortcut(home: Path, game: Path) -> dict:
if path.is_symlink() or (path.exists() and (not path.is_file() or path.stat().st_size > 8 * 1024 * 1024)):
raise ValueError("Steam shortcuts file is not a supported ordinary file.")
before = path.read_bytes() if path.exists() else None
after, appid = update_shortcut(before, str(game / "halo"), str(game))
after, appid = update_shortcut(before, str(game / "halo"), str(game), name)
after, icon = _closed_shortcut_icon(after, game, appid)
# A second guard prevents overwriting Steam's live view if it opened during work.
if steam_running():
@@ -679,7 +701,7 @@ def _write_native_shortcut(home: Path, game: Path) -> dict:
if before == after:
art = _artwork_result(config, appid)
art["icon"] = icon
return _added_result(steam, appid, unchanged=True, artwork=art)
return _added_result(steam, appid, name=name, unchanged=True, artwork=art)
backup = None
if before is not None:
backup = config / ("shortcuts.vdf.halo-frame-installer-" + str(time.time_ns()) + ".bak")
@@ -699,21 +721,23 @@ def _write_native_shortcut(home: Path, game: Path) -> dict:
os.unlink(temp)
art = _artwork_result(config, appid)
art["icon"] = icon
return _added_result(steam, appid, backup=str(backup) if backup else None, artwork=art)
return _added_result(steam, appid, name=name, backup=str(backup) if backup else None, artwork=art)
except (OSError, ValueError, UnicodeError) as error:
return {**manual, "reason": str(error)}
def _remove_manual(game: Path, reason: str | None = None) -> dict:
return {"status": "manual", "name": NAME, "executable": str(game / "halo"),
def _remove_manual(game: Path, reason: str | None = None, name: str = NAME) -> dict:
return {"status": "manual", "name": name, "executable": str(game / "halo"),
"reason": reason or "Steam must be closed before uninstalling.",
"instructions": "Use Steam's Remove Non-Steam Game option for this native Halo entry. Automatic uninstall can be retried when Steam is already closed. The native game files were kept."}
def remove_native_shortcut(home: Path, game: Path, close_steam: bool = False) -> dict:
"""Use Steam's live API or closed library; ignore the legacy close flag."""
name = _managed_name(home, game)
return _with_closed_steam(game, close_steam, lambda: _write_removed_shortcuts(home, game),
_remove_manual, "Uninstall", lambda: _live_native(home, game, "remove"))
lambda target, reason=None: _remove_manual(target, reason, name),
"Uninstall", lambda: _live_native(home, game, "remove"))
def _private_steam_directory(path: Path):
+1 -1
View File
@@ -9,7 +9,7 @@ import zipfile
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist"
VERSION = "1.4.1"
VERSION = "1.4.2"
EXE_NAME = f"Halo-Steam-Frame-Mod-Setup-{VERSION}.exe"
def allowed_source_files():
+2 -1
View File
@@ -33,7 +33,8 @@ PATCH_FILES = frozenset((
"source/units/units.c", "source/units/units.h",
"source/hs/hs_library_external.c", "port/linux/arm64/host_vr.c",
"tools/test_vr_tutorial.py", "tools/test_vr_tracking.py", "tools/test_vr_tutorial_buttons.py",
"source/objects/object_lights.c", "tools/test_vr_flashlight.py",
"source/objects/object_lights.c", "tools/test_vr_flashlight.py", "tools/test_vr_menu.py",
"tools/android_build.py", "tools/test_build_sources.py",
))
+1
View File
@@ -197,6 +197,7 @@ def main(argv=None):
library = native_elf_proof(source / "build/linux_arm64/libSDL3.so.0", directory)
proof = {"schemaVersion": 1, "result": "passed", "sourceUrl": helper.SOURCE_URL,
"sourceCommit": helper.SOURCE_COMMIT,
"buildContainerImage": helper.BUILD_CONTAINER_IMAGE,
"patchSha256": hashlib.sha256(patch_bytes).hexdigest(),
"buildScriptSha256": hashlib.sha256(script_bytes).hexdigest(),
"remoteHelperSha256": helper_sha, "target": "linux_arm64", "vrEnabled": True,
+11 -13
View File
@@ -18,6 +18,10 @@ from pathlib import Path, PurePosixPath
from urllib.parse import urlparse
from urllib.request import Request, urlopen
PARAMIKO_SOURCE_VERSION = "4.0.0"
PARAMIKO_SOURCE_URL = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz"
PARAMIKO_SOURCE_SHA256 = "6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f"
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
@@ -110,20 +114,14 @@ def download_sources(output: Path, entries: list[dict]) -> list[dict]:
paramiko = next((p for p in entries if p["name"].lower() == "paramiko"), None)
if not paramiko:
raise RuntimeError("Paramiko is not installed in the build environment")
api = f"https://pypi.org/pypi/paramiko/{paramiko['version']}/json"
info = json.loads(get_https(api, 4 * 1024 * 1024))
files = [p for p in info["urls"] if p["packagetype"] == "sdist"]
if len(files) != 1:
raise RuntimeError("Expected one official Paramiko source archive")
archive = files[0]
filename = archive["filename"]
if PurePosixPath(filename).name != filename or not filename.endswith(".tar.gz"):
raise ValueError("Unsafe Paramiko archive filename")
data = get_https(archive["url"])
if digest(data) != archive["digests"]["sha256"]:
if paramiko["version"] != PARAMIKO_SOURCE_VERSION:
raise RuntimeError("Review and update the repository-pinned Paramiko source before building a new dependency version")
filename = f"paramiko-{PARAMIKO_SOURCE_VERSION}.tar.gz"
data = get_https(PARAMIKO_SOURCE_URL)
if digest(data) != PARAMIKO_SOURCE_SHA256:
raise RuntimeError("Paramiko source checksum mismatch")
notices = [write_notice(output, "sources/" + filename, data, archive["url"])]
notices[0]["metadata_source"] = api
notices = [write_notice(output, "sources/" + filename, data, PARAMIKO_SOURCE_URL)]
notices[0]["checksum_source"] = "repository-pinned scripts/gather_notices.py"
# cryptography statically links its own OpenSSL; Python ssl may use another.
import ssl
from cryptography.hazmat.backends.openssl.backend import backend
+1 -1
View File
@@ -1,2 +1,2 @@
"""Guided installer for the native ARM64 Halo CE Steam Frame VR port."""
__version__ = "1.4.1"
__version__ = "1.4.2"
+235 -49
View File
@@ -15,8 +15,8 @@ from tkinter import filedialog, messagebox, ttk
import webbrowser
from . import __version__
from .assets import inspect_image, inspect_maps, extract_image, copy_maps
from .ssh import Settings, SSHConnection
from .assets import ExtractionCancelled, inspect_image, inspect_maps, extract_image, copy_maps
from .ssh import CancelledError, Settings, SSHConnection
from .music import MenuMusic
from .progress import SetupProgress
from .ui import (BG, SIDEBAR, SURFACE, TEXT, MUTED, BORDER,
@@ -82,6 +82,15 @@ class App(tk.Tk):
self.authorized = tk.BooleanVar()
self.steam_closed = tk.BooleanVar()
self.mode = tk.StringVar(value="install")
self.storage_kind = tk.StringVar(value="internal")
self.storage_id = tk.StringVar(value="internal")
self.storage_label = tk.StringVar()
self.storage_note = tk.StringVar(value="Internal storage is selected. SD cards are checked securely on your Frame.")
self.summary_storage = tk.StringVar()
self._storage_generation = 0
self._storage_destinations = {}
self._storage_picker_ids = {}
self._storage_inventory_identity = None
self.keep_saves = tk.BooleanVar(value=True)
self.current_page = self.current_step = 0
self.max_page = 0
@@ -116,7 +125,9 @@ class App(tk.Tk):
self.source.trace_add("write", lambda *args:self._source_changed())
self.mode.trace_add("write", lambda *args:self._mode_changed())
self.transport.trace_add("write", lambda *args:self._transport_changed())
self.usb_serial.trace_add("write", lambda *args:self._update_summary())
for variable in (self.host, self.port, self.usb_serial, self.adb_path):
variable.trace_add("write", lambda *args:self._invalidate_storage())
self.storage_kind.trace_add("write", lambda *args:self._storage_changed())
self.keep_saves.trace_add("write", lambda *args:self._update_summary())
self._apply_icon()
self.after_idle(self._apply_window_theme)
@@ -140,6 +151,14 @@ class App(tk.Tk):
style.map("TEntry", bordercolor=[("focus", ACCENT)],
fieldbackground=[("disabled", "#233027")],
foreground=[("disabled", DISABLED)])
style.configure("Storage.TCombobox", fieldbackground=INSET, background=SURFACE,
foreground=TEXT, arrowcolor=ACCENT, bordercolor=BORDER,
font=("Segoe UI", 10), padding=(10, 9))
style.map("Storage.TCombobox", fieldbackground=[("readonly", INSET), ("disabled", "#233027")],
foreground=[("disabled", DISABLED)], selectbackground=[("readonly", INSET)],
selectforeground=[("readonly", TEXT)])
self.option_add("*TCombobox*Listbox.background", INSET)
self.option_add("*TCombobox*Listbox.foreground", TEXT)
style.configure("TCheckbutton", background=SURFACE, foreground=TEXT,
indicatorbackground=INSET, indicatorforeground=ACCENT,
indicatorcolor=ACCENT, bordercolor=BORDER,
@@ -381,7 +400,7 @@ class App(tk.Tk):
uninstall = self._card(body, "REMOVE NATIVE HALO VR")
self.uninstall_card = uninstall.master
self._label(uninstall,
"Remove ~/Games/HaloCENativeVR and its native Halo Steam library entry.\n"
"Remove Halo VR and its Steam entry from the location selected on the next page.\n"
"No disc image is needed. Your other games are kept.",
10, background=SURFACE, wraplength=620).pack(anchor="w", pady=(0, 9))
self._check(uninstall, "Keep my campaign saves in a backup (recommended)", self.keep_saves).pack(anchor="w")
@@ -468,6 +487,28 @@ class App(tk.Tk):
"Steam Home and SteamVR stay running. Setup will show any manual library steps.",
9, color=MUTED, background=SURFACE, wraplength=620)
self.connection_steam_note.pack(anchor="w", pady=(2, 0))
storage = self._card(body, "Install location")
choices = tk.Frame(storage, bg=SURFACE)
choices.pack(fill="x")
choices.columnconfigure((0, 1), weight=1, uniform="storage")
self.storage_choices = []
for index, title, subtitle, value in (
(0, "Internal storage", "Frame's built-in drive", "internal"),
(1, "SD card", "Mounted on your Frame", "sd")):
choice = Choice(choices, title, subtitle, self.storage_kind, value, self._storage_changed)
choice.grid(row=0, column=index, sticky="ew", padx=(0, 6) if index == 0 else (6, 0))
self.storage_choices.append(choice)
self.storage_picker = ttk.Combobox(storage, textvariable=self.storage_label, state="disabled",
style="Storage.TCombobox")
self.storage_picker.pack(fill="x", pady=(10, 0))
self.storage_picker.bind("<<ComboboxSelected>>", self._select_storage_card)
self._label(storage, textvariable=self.storage_note, size=9, color=MUTED,
background=SURFACE, wraplength=620).pack(anchor="w", pady=(9, 0))
self._button(storage, "Refresh storage", self._test_connection).pack(anchor="w", pady=(9, 0))
self._label(storage,
"Install uses your selected Xbox data. Repair and Uninstall affect only the selected location.\n"
"Existing installations on another drive are kept; setup does not move them.",
8, color=MUTED, background=SURFACE, wraplength=620).pack(anchor="w", pady=(8, 0))
def _build_install_page(self, body):
summary = self._card(body)
@@ -486,6 +527,8 @@ class App(tk.Tk):
background=SURFACE, wraplength=620).pack(anchor="w", pady=(4, 6))
self._label(summary, textvariable=self.summary_note, size=9, color=MUTED,
background=SURFACE, wraplength=620).pack(anchor="w")
self._label(summary, textvariable=self.summary_storage, size=9, color=MUTED,
background=SURFACE, wraplength=620).pack(anchor="w", pady=(9, 0))
self.progress_card = self._card(body)
progress_heading = tk.Frame(self.progress_card, bg=SURFACE)
progress_heading.pack(fill="x")
@@ -510,7 +553,7 @@ class App(tk.Tk):
self.notes_button = self._button(self.progress_card, "View game description", self._show_steam_note)
self.notes_button.pack_forget()
self.install_note = self._label(body,
"Keep this window open during setup. Allow 12 GB free on the Frame.\nThe first native build can take a while.",
"Allow 12 GiB free on the selected drive. The compiler container may also need temporary internal space.\nThe first native build can take a while.",
9, color=MUTED, wraplength=620)
self.install_note.pack(anchor="w", pady=(0, 10))
@@ -575,7 +618,7 @@ class App(tk.Tk):
self.connection_steam_note.configure(text="Steam Home and SteamVR stay running. Uninstall may need a manual library step." if uninstall
else "Steam Home and SteamVR stay running. Setup will show any manual library steps.")
self.install_note.configure(text="Keep this window open until the uninstall finishes and setup disconnects." if uninstall
else "Keep this window open during setup. Allow 12 GB free on the Frame.\nThe first native build can take a while.")
else "Allow 12 GiB free on the selected drive. The compiler container may also need temporary internal space.\nThe first native build can take a while.")
self._update_summary()
self._update_navigation()
if uninstall:
@@ -588,7 +631,7 @@ class App(tk.Tk):
uninstall = self.mode.get() == "uninstall"
self.summary_heading.set("UNINSTALL SUMMARY" if uninstall else "INSTALLATION SUMMARY")
self.summary_data_heading.set("NATIVE GAME FOLDER" if uninstall else "GAME DATA")
self.summary_source.set("~/Games/HaloCENativeVR" if uninstall else
self.summary_source.set(self._storage_game_path() if uninstall else
Path(source).name if source else "Use valid Xbox maps already installed on the Frame")
if self.transport.get() == "usb":
serial = self.usb_serial.get().strip()
@@ -601,6 +644,77 @@ class App(tk.Tk):
"A Jump · B Melee · X Reload / use · Y Change weapon")
self.summary_note.set("The native game files and its Steam entry will be removed." if uninstall else
"Motion aiming and Xbox-style controls are included. Steam artwork and game Notes are added automatically.")
self.summary_storage.set("LOCATION: " + self._storage_game_path())
def _storage_identity(self):
return (self.transport.get(), self.host.get().strip(), self.port.get().strip(),
self.usb_serial.get().strip(), self.adb_path.get().strip())
def _invalidate_storage(self):
self._storage_generation += 1
self._storage_destinations = {}
self._storage_picker_ids = {}
self._storage_inventory_identity = None
self.storage_label.set("")
if hasattr(self, "storage_picker"):
self.storage_picker.configure(values=())
self._storage_changed()
if self.busy and getattr(self, "operation", None) == "connection":
self.cancel.set()
self._set_busy(False)
self._update_summary()
def _storage_game_path(self):
if self.storage_kind.get() == "internal":
return self._storage_destinations.get("internal", {}).get("gamePath", "~/Games/HaloCENativeVR")
destination = self._storage_destinations.get(self.storage_id.get())
return destination["gamePath"] if destination else "Choose a mounted SD card on your Frame"
def _storage_changed(self):
if self.storage_kind.get() == "internal":
self.storage_id.set("internal")
self.storage_label.set("")
selected = self._storage_destinations.get("internal")
self.storage_note.set((f"Internal storage · {selected['freeBytes'] / 1024**3:.1f} GiB free\n" + selected["gamePath"]
+ ("\nAn existing installation was detected here." if selected.get("installed") else ""))
if selected else "Internal storage is selected. SD cards are checked securely on your Frame.")
else:
cards = [item for item in self._storage_destinations.values() if item.get("kind") == "sd"]
if self.storage_id.get() not in {item["id"] for item in cards}:
self.storage_id.set(cards[0]["id"] if len(cards) == 1 else "")
selected = self._storage_destinations.get(self.storage_id.get())
self.storage_label.set(next((label for label, identifier in self._storage_picker_ids.items()
if identifier == self.storage_id.get()), ""))
if selected:
self.storage_note.set(f"{selected['label']} · {selected['freeBytes'] / 1024**3:.1f} GiB free\n"
+ selected["gamePath"] + ("\nAn existing installation was detected here." if selected.get("installed") else
"\nNo recognized installation is here. Use Install with your Xbox data."))
elif cards:
self.storage_note.set("Multiple SD cards are available. Choose the card to use; other installations are kept.")
elif self._storage_inventory_identity == self._storage_identity():
self.storage_note.set("No eligible mounted SD card was found. Insert or prepare a card in Steam Settings → Storage, then Refresh storage.")
else:
self.storage_note.set("Insert a card in your Frame and prepare it in Steam Settings → Storage, then Refresh storage.")
if hasattr(self, "storage_picker"):
self.storage_picker.configure(state="readonly" if not self.busy and self.storage_kind.get() == "sd"
and self._storage_picker_ids else "disabled")
self._update_summary()
def _select_storage_card(self, event=None):
self.storage_id.set(self._storage_picker_ids.get(self.storage_label.get(), ""))
self._storage_changed()
def _apply_storage_inventory(self, destinations, identity):
self._storage_destinations = {item["id"]: dict(item) for item in destinations}
self._storage_inventory_identity = identity
self._storage_picker_ids = {}
for index, item in enumerate(item for item in destinations if item.get("kind") == "sd"):
label = f"{index + 1}. {item['label']} · {item['freeBytes'] / 1024**3:.1f} GiB free"
if item.get("installed"):
label += " · Installed"
self._storage_picker_ids[label] = item["id"]
self.storage_picker.configure(values=tuple(self._storage_picker_ids))
self._storage_changed()
def _show_page(self, index):
if not 0 <= index < len(self.pages):
@@ -719,6 +833,7 @@ class App(tk.Tk):
self.advanced_button.configure(text="Hide connection settings" if self.advanced_open else "Advanced connection settings")
def _transport_changed(self):
self._invalidate_storage()
usb = self.transport.get() == "usb"
self.network_panel.pack_forget()
self.usb_panel.pack_forget()
@@ -836,7 +951,8 @@ class App(tk.Tk):
" USB tools are included. Approve USB debugging if prompted.\n"
"• The Frame still needs internet access to download and build the game.\n"
"• Save and close games. Steam Home and SteamVR stay running.\n"
"• Install SteamVR and allow at least 12 GB free on your Frame.\n"
"• Install SteamVR and allow at least 12 GiB free on the selected drive.\n"
"• For SD, prepare a writable ext4/f2fs card, then Refresh storage.\n"
"• Repair refreshes the program and controls while preserving saves.\n"
"• Install and repair automatically add Steam artwork and a game description in Steam Notes.\n"
"• Online play uses the native port's own multiplayer protocol.\n\n" + EXPERIMENTAL_NOTICE,
@@ -922,7 +1038,7 @@ class App(tk.Tk):
self.music.load(Path(selected))
self.status.set("Game data validated. Connect your Frame next.")
def _settings(self):
def _settings(self, *, storage_id=None):
usb = self.transport.get() == "usb"
if usb:
host, port = "frame", 22
@@ -934,12 +1050,18 @@ class App(tk.Tk):
host = self.host.get().strip()
serial = self.usb_serial.get().strip() or None
identity = f"usb:{serial}" if usb and serial else f"{host}:{port}" if not usb else None
if storage_id is None:
storage_id = self.storage_id.get()
if self.storage_kind.get() == "sd" and (not storage_id or storage_id == "internal"
or self._storage_inventory_identity != self._storage_identity()
or storage_id not in self._storage_destinations):
raise ValueError("Refresh storage and select a mounted SD card on your Frame before continuing.")
return Settings(host=host, password=self.password.get(), port=port,
transport=self.transport.get(), adb_path=self.adb_path.get().strip() or None,
usb_serial=serial, known_host_fingerprints=dict(self.fingerprints),
known_host_fingerprint=self.fingerprints.get(identity) if identity else None,
accept_host_key=lambda h,a,v:self._approve_host(h,a,v,port),
close_steam_for_shortcut=False)
close_steam_for_shortcut=False, storage_id=storage_id)
def _approve_host(self, host, algorithm, value, port):
ready = threading.Event()
@@ -962,6 +1084,10 @@ class App(tk.Tk):
choice.configure(state="disabled" if value else "normal")
for choice in self.transport_choices:
choice.configure(state="disabled" if value else "normal")
for choice in self.storage_choices:
choice.configure(state="disabled" if value else "normal")
self.storage_picker.configure(state="readonly" if not value and self.storage_kind.get() == "sd"
and self._storage_picker_ids else "disabled")
steam = self.last_result.steam if self.last_result is not None else {}
pending_steam = (bool(self.last_result and self.last_result.requires_manual_steam_step)
or steam.get("artwork", {}).get("status") == "manual"
@@ -985,46 +1111,50 @@ class App(tk.Tk):
self._update_navigation()
def _test_connection(self):
if self.busy:
return
try:
settings = self._settings()
settings = self._settings(storage_id="internal")
except ValueError as exc:
messagebox.showerror("Connection details", str(exc), parent=self)
return
self.password_to_redact = settings.password
self.operation = "connection"
self._storage_generation += 1
generation = self._storage_generation
identity = self._storage_identity()
self._storage_destinations = {}
self._storage_picker_ids = {}
self._storage_inventory_identity = None
self.storage_picker.configure(values=())
self._storage_changed()
settings.accept_host_key = lambda h,a,v:self._approve_storage_host(generation, identity, h, a, v, settings.port)
self._set_busy(True)
self.status.set("Connecting to your Frame…")
self.connection_status.set("Connecting to your Frame…")
self.status.set("Connecting to your Frame and checking storage…")
self.connection_status.set("Connecting to your Frame and checking storage…")
def worker():
connection = None
confirmed = None
error = None
from .install import Installer
try:
connection = SSHConnection(settings)
connection.connect()
arch = connection.run(["uname", "-m"], cancel_event=self.cancel).strip()
if arch not in ("aarch64", "arm64"):
raise ValueError("This device is not an ARM64 Steam Frame.")
confirmed = ("connected", settings.host_identity, connection.host_fingerprint)
result = Installer().discover_storage(settings, cancel_event=self.cancel)
if result.cleanup_warning:
raise ValueError("Setup could not finish disconnecting after the connection check.\n" + result.cleanup_warning)
self.events.put(("storage_result", generation, identity, settings.host_identity, result))
except Exception as exc:
error = str(exc)
cancelled = isinstance(exc, CancelledError) and not exc.requires_attention
self.events.put(("storage_error", generation, identity, str(exc), cancelled))
finally:
try:
if connection is not None:
connection.close()
except Exception as exc:
cleanup = "Setup could not finish disconnecting after the connection check.\n" + str(exc)
error = (error + "\n\n" + cleanup) if error else cleanup
finally:
settings.password = ""
if error is not None:
self.events.put(("error", error))
self.events.put(("clear_password",))
elif confirmed is not None:
self.events.put(confirmed)
self.events.put(("idle",))
settings.password = ""
self.events.put(("storage_idle", generation, identity))
threading.Thread(target=worker, daemon=True).start()
def _approve_storage_host(self, generation, identity, host, algorithm, value, port):
ready, answer = threading.Event(), []
self.events.put(("storage_fingerprint", generation, identity, host, algorithm, value, port, ready, answer))
while not ready.wait(0.1):
if self.cancel.is_set() or generation != self._storage_generation:
return False
return bool(answer and answer[0])
def _install(self, repair=False):
if (not repair and not self.source.get()) or not self.authorized.get():
messagebox.showinfo("Choose game data", "Confirm that you are authorized to use the installed Xbox game data." if repair else "Choose your original Xbox Halo CE image or maps, and confirm you are authorized to use them.", parent=self)
@@ -1041,7 +1171,7 @@ class App(tk.Tk):
source = Path(self.source.get()) if self.source.get() else None
if repair:
data_note = "Your selected Xbox data may replace the installed maps." if source else "Valid installed Xbox maps are kept. If maps are damaged, choose an original Xbox image and retry."
if not messagebox.askyesno("Repair existing native game", "Setup will check ~/Games/HaloCENativeVR on your Frame and reinstall its native program files and Xbox controls. Saves and unrelated settings are preserved.\n\n" + data_note + "\n\nRepair this installation?", parent=self):
if not messagebox.askyesno("Repair existing native game", "Setup will check " + self._storage_game_path() + " on your Frame and reinstall its native program files and Xbox controls. Saves and unrelated settings are preserved.\n\n" + data_note + "\n\nOnly this location is repaired; other installations are kept.\n\nRepair this installation?", parent=self):
return
settings.adopt_existing_native = True
self.operation = "install"
@@ -1080,7 +1210,9 @@ class App(tk.Tk):
result = run(settings, extracted / "maps", report, self.cancel)
self.events.put(("complete", result))
except Exception as exc:
self.events.put(("error", str(exc)))
cancelled = (isinstance(exc, ExtractionCancelled)
or isinstance(exc, CancelledError) and not exc.requires_attention)
self.events.put(("error", str(exc), cancelled))
finally:
settings.password = ""
self.events.put(("clear_password",))
@@ -1094,7 +1226,13 @@ class App(tk.Tk):
messagebox.showinfo("Close games first", GAME_CLOSE_NOTE, parent=self)
return
try:
settings = self._settings()
settings = self._settings(storage_id=getattr(self.last_result, "storage_id", "internal"))
expected_frame = getattr(self.last_result, "host_fingerprint", None)
if expected_frame:
# A pending library update belongs to the Frame that installed
# this game, even after its address or current selection changes.
settings.known_host_fingerprint = expected_frame
settings.accept_host_key = None
except ValueError as exc:
messagebox.showerror("Connection details", str(exc), parent=self)
return
@@ -1113,7 +1251,7 @@ class App(tk.Tk):
lambda s,m,p=None:self.events.put(("progress",s,str(m),p)), self.cancel)
self.events.put(("complete",result))
except Exception as exc:
self.events.put(("error",str(exc)))
self.events.put(("error",str(exc), isinstance(exc, CancelledError) and not exc.requires_attention))
finally:
settings.password = ""
self.events.put(("clear_password",)); self.events.put(("idle",))
@@ -1133,7 +1271,7 @@ class App(tk.Tk):
keep_saves = self.keep_saves.get()
saves_note = ("Your campaign saves will be backed up on the Frame before removing the game."
if keep_saves else "Campaign saves contained in the native game folder will also be removed.")
confirmation = ("Remove the native Halo VR installation at ~/Games/HaloCENativeVR on your Frame?\n\n"
confirmation = ("Remove the native Halo VR installation at " + self._storage_game_path() + " on your Frame?\n\n"
"This removes the native game program, Xbox maps, configuration files, and its native Halo Steam library entry.\n\n"
+ saves_note + "\n\nOther games and saved backups are kept.\n\nOnce removal begins, let setup finish and disconnect.")
if not messagebox.askyesno("Uninstall native Halo VR", confirmation, parent=self):
@@ -1156,7 +1294,7 @@ class App(tk.Tk):
self.cancel, keep_saves=keep_saves)
self.events.put(("uninstall_complete", result))
except Exception as exc:
self.events.put(("error", str(exc)))
self.events.put(("error", str(exc), isinstance(exc, CancelledError) and not exc.requires_attention))
finally:
settings.password = ""
self.events.put(("clear_password",))
@@ -1177,6 +1315,14 @@ class App(tk.Tk):
for _ in range(100):
event = self.events.get_nowait()
kind = event[0]
if kind == "storage_fingerprint":
_, generation, identity, host, algorithm, value, port, ready, answer = event
if generation != self._storage_generation or identity != self._storage_identity():
answer.append(False)
ready.set()
continue
kind = "fingerprint"
event = (kind, host, algorithm, value, port, ready, answer)
if kind == "asset":
self._finish_asset_check(*event[1:])
elif kind == "music":
@@ -1188,6 +1334,28 @@ class App(tk.Tk):
if accepted:
self._remember(host, port, value)
ready.set()
elif kind in ("storage_result", "storage_error", "storage_idle"):
_, generation, identity, *payload = event
if generation != self._storage_generation or identity != self._storage_identity():
continue
if kind == "storage_result":
resolved, result = payload
if result.host_fingerprint:
self._remember(resolved, None, result.host_fingerprint)
self._apply_storage_inventory(result.destinations, identity)
self.status.set("Connected to your Frame. Review the install location before continuing.")
self.connection_status.set("Connected to your Frame. Storage checked; setup disconnected securely.")
self._append("SSH connection verified and storage checked. No password was saved.")
for item in result.destinations:
self._append(f"Storage: {item['label']} · {item['freeBytes'] / 1024**3:.1f} GiB free · {item['gamePath']}")
for item in getattr(result, "unavailable", ()):
self._append("Storage unavailable: " + item["mountPath"] + " — " + item["reason"])
elif kind == "storage_error":
self._show_setup_error(payload[0], len(payload) > 1 and bool(payload[1]))
self.password.set("")
self.password_to_redact = ""
else:
self._set_busy(False)
elif kind == "connected":
_, identity, value = event
if value:
@@ -1201,15 +1369,20 @@ class App(tk.Tk):
if stage == "uninstall" and getattr(self, "operation", None) == "uninstall":
self.uninstall_committing = True
self.cancel_button.configure(state="disabled")
stage, message = self._redact(stage), self._redact(message)
if detail:
self._append(message)
else:
# The raw stage drives the phase table and activity choice;
# redaction applies to displayed text only, so a password
# that appears inside a phase name cannot stall the bar.
# _append redacts once on its own.
if not self.cancel.is_set() or self.uninstall_committing:
self.progress_state.update(stage, percent)
self._display_progress()
self.phase.set(self.progress_state.caption(stage))
self.status.set(message[:500])
# Unknown phases fall back to the raw stage name, so the
# caption is redacted too, like every other display text.
self.phase.set(self._redact(self.progress_state.caption(stage)))
self.status.set(self._redact(message)[:500])
if self.installing or stage in ("build", "dependencies", "toolchain", "configure", "sdl", "compile", "build-check"):
self._show_activity()
self._append(f"{stage}: {message}")
@@ -1220,8 +1393,9 @@ class App(tk.Tk):
cleanup_pending = bool(cleanup_warning)
library_update = getattr(self, "operation", None) == "library"
action = "repaired" if getattr(result, "repaired", False) else "installed"
game_name = result.steam.get("name", "Halo: Combat Evolved VR (Native)")
done = ("Halo's Steam library info has been updated. Your native game and saves were kept." if library_update else
f"Native Halo VR is {action}. Open Steam and launch Halo: Combat Evolved VR (Native).")
f"Native Halo VR is {action}. Open Steam and launch {game_name}.")
if getattr(result, "backup_path", None):
self._append("Program-file backup: " + result.backup_path)
if result.requires_manual_steam_step:
@@ -1320,7 +1494,7 @@ class App(tk.Tk):
self.retry_button.pack_forget()
messagebox.showinfo("Uninstall needs attention" if pending else "Uninstall complete", done, parent=self)
elif kind == "error":
self._show_setup_error(event[1])
self._show_setup_error(event[1], len(event) > 2 and bool(event[2]))
elif kind == "clear_password":
self.password.set(""); self.password_to_redact = ""
elif kind == "idle":
@@ -1333,8 +1507,20 @@ class App(tk.Tk):
pass
self.after(10 if not self.events.empty() else 80, self._drain)
def _show_setup_error(self, text):
def _show_setup_error(self, text, cancelled=False):
text = self._redact(text)
if cancelled:
# The worker tagged this outcome as a cancellation; report it as
# stopped, never as a connection or data problem.
summary = text.strip().splitlines()[0] if text.strip() else "Setup cancelled."
if len(summary) > 500 or "\n" in text:
summary = summary[:500] + "\n\nUse the setup activity panel (Show activity) for full details, or Save log for troubleshooting."
self.status.set(summary)
self.phase.set("Setup cancelled")
if getattr(self, "operation", None) == "connection":
self.connection_status.set("Connection check cancelled.")
self._append(text)
return
self.status.set("Setup stopped. See the message below; you can retry.")
self.phase.set("Let's try that again")
if getattr(self, "operation", None) == "connection":
+241 -27
View File
@@ -8,9 +8,10 @@ import re
import sys
import tempfile
import threading
import time
import uuid
from dataclasses import dataclass, replace
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import Callable
from .ssh import CancelledError, RemoteTimeoutError, Settings, SSHConnection, SSHError
@@ -29,6 +30,88 @@ def resources_path() -> Path:
return Path(__file__).resolve().parents[2] / "resources"
def storage_arguments(settings: Settings) -> list[str]:
return [] if settings.storage_id == "internal" else ["--storage-id", settings.storage_id]
def validate_sd_identity(value: dict, mount: str) -> dict:
"""Require the card, filesystem and live kernel mount checked by preflight."""
if (not isinstance(value, dict) or value.get("kind") != "sd" or value.get("mountPath") != mount
or any(type(value.get(key)) is not int or value[key] <= 0
for key in ("mountId", "rootDevice", "rootInode"))
or not isinstance(value.get("device"), str) or not re.fullmatch(r"[0-9]+:[0-9]+", value["device"])
or value.get("filesystem") not in ("ext4", "f2fs")
or not isinstance(value.get("cardIdentity"), str) or not re.fullmatch(r"[a-f0-9]{64}", value["cardIdentity"])
or not isinstance(value.get("filesystemUuid"), str) or not re.fullmatch(r"[a-z0-9-]{1,128}", value["filesystemUuid"])):
raise SSHError("The Frame returned an invalid SD card identity. Refresh storage before retrying.")
# Preserve and compare all live identity fields, including the block-device
# name supplied by the bundled validator. Metadata must remain bounded.
if (len(value) > 16 or any(not isinstance(key, str) or not 0 < len(key) <= 64
or (type(item) is not int and not isinstance(item, str))
or (isinstance(item, str) and (len(item) > 512
or any(ord(c) < 32 or ord(c) == 127 for c in item)))
for key, item in value.items())
or ("blockDevice" in value and (not isinstance(value["blockDevice"], str)
or not re.fullmatch(r"/dev/mmcblk[0-9]+p[0-9]+", value["blockDevice"])))):
raise SSHError("The Frame returned an invalid SD card identity. Refresh storage before retrying.")
return dict(value)
def validate_storage_destination(value: dict, selected_id: str | None = None) -> dict:
"""Bound remote paths to the fixed leaves of one detected storage volume."""
if not isinstance(value, dict):
raise SSHError("The Frame returned an invalid storage location.")
identifier = value.get("id")
if (not isinstance(identifier, str) or not re.fullmatch(r"internal|sd:[a-f0-9]{32}", identifier)
or (selected_id is not None and identifier != selected_id)):
raise SSHError("The Frame returned an unexpected storage selection.")
mount = value.get("mountPath")
if (not isinstance(mount, str) or len(mount) > 400
or any(ord(c) < 32 or ord(c) == 127 or c in "\"'`$\\;|&<>:" for c in mount)
or str(PurePosixPath(mount)) != mount or ".." in PurePosixPath(mount).parts):
raise SSHError("The Frame returned an invalid storage mount path.")
if identifier == "internal":
if mount != "/home/steamos" or value.get("kind") != "internal":
raise SSHError("The Frame returned an unexpected internal storage location.")
cache = mount + "/.cache/halo-frame-installer"
else:
if (value.get("kind") != "sd"
or not re.fullmatch(r"/run/media/(?:steamos/)?[^/]+", mount)
or mount == "/run/media/steamos"):
raise SSHError("The Frame returned an unexpected SD card location.")
cache = mount + "/.halo-frame-installer"
if value.get("gamePath") != mount + "/Games/HaloCENativeVR" or value.get("cachePath") != cache:
raise SSHError("The Frame returned an unexpected installation location.")
if (type(value.get("freeBytes")) is not int or value["freeBytes"] < 0
or not isinstance(value.get("label"), str) or not 0 < len(value["label"]) <= 512
or any(ord(c) < 32 or ord(c) == 127 for c in value["label"])
or type(value.get("installed")) is not bool):
raise SSHError("The Frame returned invalid storage details.")
destination = dict(value)
if identifier != "internal":
destination["identity"] = validate_sd_identity(value.get("identity"), mount)
return destination
def validate_preflight_location(info: dict, settings: Settings) -> dict:
if info.get("home") != "/home/steamos":
raise SSHError("The Frame reported an unexpected installation account.")
storage = info.get("storage")
if storage is None and settings.storage_id == "internal":
# The fixed internal location is also used by existing integration fixtures.
storage = {"id": "internal", "kind": "internal", "mountPath": "/home/steamos",
"gamePath": "/home/steamos/Games/HaloCENativeVR",
"cachePath": "/home/steamos/.cache/halo-frame-installer",
"freeBytes": 0, "label": "Internal storage", "installed": False}
destination = validate_storage_destination(storage, settings.storage_id)
if any(info.get(key) != destination[key] for key in ("gamePath", "cachePath")):
raise SSHError("The Frame reported an unexpected installation location.")
existing = info.get("existing")
if existing is not None and (not isinstance(existing, dict) or existing.get("gamePath") != destination["gamePath"]):
raise SSHError("The Frame reported an unexpected existing game location.")
return destination
def map_manifest(maps_dir: Path) -> tuple[Path, dict]:
maps_dir = Path(maps_dir)
if (maps_dir / "maps").is_dir():
@@ -89,6 +172,14 @@ def parse_activity(line: str) -> tuple[str, str, float | None] | None:
return stage, message, percent
@dataclass(frozen=True)
class StorageResult:
destinations: tuple[dict, ...]
host_fingerprint: str | None
cleanup_warning: str | None = None
unavailable: tuple[dict, ...] = ()
@dataclass(frozen=True)
class InstallResult:
game_path: str
@@ -99,6 +190,7 @@ class InstallResult:
repaired: bool = False
backup_path: str | None = None
cleanup_warning: str | None = None
storage_id: str = "internal"
@property
def connection_cleanup_pending(self) -> bool:
@@ -126,6 +218,7 @@ class UninstallResult:
removal_warning: str | None = None
recovered_save_backup_paths: tuple[str, ...] = ()
retained_quarantine_paths: tuple[str, ...] = ()
storage_id: str = "internal"
@property
def connection_cleanup_pending(self) -> bool:
@@ -163,9 +256,9 @@ def close_connection(connection, progress: ProgressCallback) -> str | None:
return None
def uninstall_history(data: dict) -> tuple[tuple[str, ...], tuple[str, ...], str | None]:
def uninstall_history(data: dict, game_path="/home/steamos/Games/HaloCENativeVR") -> tuple[tuple[str, ...], tuple[str, ...], str | None]:
"""Validate direct owned-path references before showing remote recovery history."""
root = "/home/steamos/Games/"
root = str(PurePosixPath(game_path).parent) + "/"
backups, retained = [], []
for key, target in (("recoveredSaveBackupPaths", backups),
("retainedQuarantinePaths", retained)):
@@ -173,7 +266,7 @@ def uninstall_history(data: dict) -> tuple[tuple[str, ...], tuple[str, ...], str
if not isinstance(values, list) or len(values) > 64:
raise SSHError("The Frame returned an invalid uninstall recovery history.")
for value in values:
if (not isinstance(value, str) or len(value) > 512
if (not isinstance(value, str) or len(value) > max(512, len(root) + 255)
or len(value.rsplit("/", 1)[-1]) > 255 or value in target):
raise SSHError("The Frame returned an invalid uninstall recovery history.")
if key == "recoveredSaveBackupPaths":
@@ -196,6 +289,116 @@ class Installer:
self.connection_factory = connection_factory
self.resource_dir = resource_dir or resources_path()
def remote_source(self) -> str:
"""Bootstrap the same bundled storage validator before the inline helper."""
storage = (self.resource_dir / "frame_storage.py").read_text(encoding="utf-8")
helper = (self.resource_dir / "remote_install.py").read_text(encoding="utf-8")
# exec handles future-import declarations in each source independently.
bootstrap = ("import sys,types\n"
"_hfi_storage=types.ModuleType('frame_storage')\n"
"_hfi_storage.__file__='<bundled frame_storage>'\n"
"sys.modules['frame_storage']=_hfi_storage\n"
"exec(" + repr(storage) + ",_hfi_storage.__dict__)\n"
"exec(" + repr(helper) + ",globals())\n")
return bootstrap
def guarded_upload(self, connection, settings: Settings, destination: dict,
cancel_event: threading.Event):
"""Pin direct SFTP writes to the original live SD mount identity."""
if settings.storage_id == "internal":
def upload(local, remote, *, callback=None):
options = {"callback": callback} if callback is not None else {}
connection.put(local, remote, cancel_event=cancel_event, **options)
return upload
original = validate_storage_destination(destination, settings.storage_id)
source = (self.resource_dir / "frame_storage.py").read_text(encoding="utf-8")
# Run our bundled read-only validator even before frame_storage.py itself
# is uploaded. No user map data or preflight snapshot is sent in the check.
inline = ("import sys,types,json,subprocess\n"
"_hfi_storage=types.ModuleType('frame_storage')\n"
"_hfi_storage.__file__='<bundled frame_storage>'\n"
"exec(" + repr(source) + ",_hfi_storage.__dict__)\n"
"try:\n"
" print('HFI_RESULT '+json.dumps(_hfi_storage.resolve_storage(sys.argv[-1])),flush=True)\n"
"except (OSError,ValueError,UnicodeError,subprocess.SubprocessError) as error:\n"
" print('HFI_ERROR '+str(error).replace('\\n',' ')[:600],flush=True)\n"
" raise SystemExit(1)\n")
argv = ["python3", "-c", inline] + storage_arguments(settings)
def verify():
if cancel_event.is_set():
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
current = validate_storage_destination(parse_result(connection.run(
argv, cancel_event=cancel_event, timeout=90)), settings.storage_id)
if (current["identity"] != original["identity"]
or any(current[key] != original[key] for key in ("mountPath", "gamePath", "cachePath"))):
raise SSHError("The selected SD card changed during transfer. Setup stopped without updating the Steam library. Refresh storage and choose the card again.")
if cancel_event.is_set():
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
def upload(local, remote, *, callback=None):
verify()
checked_at = time.monotonic()
def update(done, total):
nonlocal checked_at
if cancel_event.is_set():
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
if time.monotonic() - checked_at >= 1.0:
verify()
checked_at = time.monotonic()
if callback is not None:
callback(done, total)
connection.put(local, remote, callback=update, cancel_event=cancel_event)
verify()
return upload
def discover_storage(self, settings: Settings,
cancel_event: threading.Event | None = None) -> StorageResult:
"""Inspect mounted storage without changing the game or mounting/formatting a card."""
connection = self.connection_factory(settings)
cancel_event = cancel_event or threading.Event()
cleanup_attempted = False
try:
if cancel_event.is_set():
raise CancelledError("Storage check cancelled.")
connection.connect()
source = (self.resource_dir / "frame_storage.py").read_text(encoding="utf-8")
inventory = parse_result(connection.run(["python3", "-c", source],
cancel_event=cancel_event, timeout=90))
values = inventory.get("destinations")
if (inventory.get("home") != "/home/steamos" or not isinstance(values, list)
or not 1 <= len(values) <= 16):
raise SSHError("The Frame returned an invalid storage inventory.")
destinations = tuple(validate_storage_destination(value) for value in values)
identifiers = [item["id"] for item in destinations]
if identifiers.count("internal") != 1 or len(set(identifiers)) != len(identifiers):
raise SSHError("The Frame returned duplicate or missing storage locations.")
unavailable = inventory.get("unavailable", [])
if not isinstance(unavailable, list) or len(unavailable) > 16:
raise SSHError("The Frame returned invalid storage diagnostics.")
for item in unavailable:
if (not isinstance(item, dict)
or not isinstance(item.get("mountPath"), str) or not 0 < len(item["mountPath"]) <= 512
or not isinstance(item.get("reason"), str) or not 0 < len(item["reason"]) <= 512
or any(ord(c) < 32 or ord(c) == 127 for c in item["mountPath"] + item["reason"])):
raise SSHError("The Frame returned invalid storage diagnostics.")
fingerprint = connection.host_fingerprint
warning = close_connection(connection, lambda *args: None)
cleanup_attempted = True
return StorageResult(destinations, fingerprint, warning, tuple(dict(item) for item in unavailable))
finally:
if not cleanup_attempted:
warning = close_connection(connection, lambda *args: None)
if warning and sys.exc_info()[1] is not None:
error = sys.exc_info()[1]
if isinstance(error, CancelledError):
error.requires_attention = True
error.args = (str(error) + "\n\n" + warning,)
def run(self, settings: Settings, maps_dir: Path | None,
progress_callback: ProgressCallback | None = None,
cancel_event: threading.Event | None = None, *, registration_only=False) -> InstallResult:
@@ -225,7 +428,7 @@ class Installer:
if remote_helper is None or not prepared or cancel_attempted:
return
cancel_attempted = True
argv = ["python3", remote_helper, "cancel", "--run-id", run_identifier]
argv = ["python3", remote_helper, "cancel", "--run-id", run_identifier] + storage_arguments(settings)
def request(target):
response = parse_result(target.run(argv, timeout=45))
@@ -282,6 +485,7 @@ class Installer:
if repair and settings.adopt_existing_native:
argv.append("--adopt-existing")
argv += list(extras)
argv += storage_arguments(settings)
return parse_result(connection.run(argv, progress=remote_progress,
cancel_event=cancel_event, timeout=timeout,
on_cancel=cancel_remote if prepared else None))
@@ -293,16 +497,18 @@ class Installer:
check_cancel()
progress("preflight", "Checking the installed native game and Steam account..." if registration_only
else "Checking ARM64 SteamOS, SteamVR, Podman and free space...", None)
source = (self.resource_dir / "remote_install.py").read_text(encoding="utf-8")
source = self.remote_source()
preflight_args = ["python3", "-c", source, "preflight-library" if registration_only else "preflight"]
if repair:
preflight_args.append("--repair")
if repair and settings.adopt_existing_native:
preflight_args.append("--adopt-existing")
preflight_args += storage_arguments(settings)
info = parse_result(connection.run(preflight_args,
progress=remote_progress, cancel_event=cancel_event, timeout=90))
if info.get("home") != "/home/steamos" or info.get("cachePath") != "/home/steamos/.cache/halo-frame-installer" or info.get("gamePath") != "/home/steamos/Games/HaloCENativeVR":
raise SSHError("The Frame reported an unexpected installation location.")
destination = validate_preflight_location(info, settings)
upload = self.guarded_upload(connection, settings, destination, cancel_event)
cleanup_activity(progress, "Selected " + destination["label"] + ": " + destination["gamePath"])
if registration_only and info.get("existing") is None:
raise SSHError("Install and verify native Halo VR before updating its Steam information.")
if (info.get("existing") or {}).get("needsUpgrade"):
@@ -313,18 +519,18 @@ class Installer:
if info.get("pcVersionDetected"):
progress("detect", "Found an older Halo PC installation. The native VR build requires original Xbox maps.", None)
remote_resources = info["cachePath"] + "/resources"
helpers = ("remote_install.py", "steam_shortcut.py", "steam_live.py", "steam_notes.py")
helpers = ("frame_storage.py", "remote_install.py", "steam_shortcut.py", "steam_live.py", "steam_notes.py")
if not registration_only:
helpers += ("build-native.sh", "frame-controls.patch")
for name in helpers:
check_cancel()
connection.put(self.resource_dir / name, remote_resources + "/" + name, cancel_event=cancel_event)
upload(self.resource_dir / name, remote_resources + "/" + name)
# Artwork ships inside the one-file installer; no metadata service or
# account API key is needed, including an Add to Steam again retry.
for name in ("halo-ce-cover.jpg", "halo-ce-landscape.png", "halo-ce-hero.jpg", "halo-ce-logo.png", "halo-ce-icon.png"):
check_cancel()
connection.put(self.resource_dir / "artwork" / name,
remote_resources + "/artwork/" + name, cancel_event=cancel_event)
upload(self.resource_dir / "artwork" / name,
remote_resources + "/artwork/" + name)
remote_helper = remote_resources + "/remote_install.py"
existing = info.get("existing")
if existing is not None and not repair:
@@ -349,7 +555,7 @@ class Installer:
with tempfile.TemporaryDirectory(prefix="halo-frame-manifest-") as scratch:
manifest_path = Path(scratch) / "xbox-data-manifest.json"
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
connection.put(manifest_path, expected_upload + "/xbox-data-manifest.json", cancel_event=cancel_event)
upload(manifest_path, expected_upload + "/xbox-data-manifest.json")
reused_upload = False
if info.get("retainedUploadReuse") is True:
progress("reuse", "Looking for verified Xbox maps from an earlier installation attempt...", None)
@@ -372,12 +578,13 @@ class Installer:
def uploaded(done, file_total):
percent = 100 * (transferred + done) / total if total else 100
progress("upload", "Uploading " + path.name, percent)
connection.put(path, expected_upload + "/" + entry["path"],
callback=uploaded, cancel_event=cancel_event)
upload(path, expected_upload + "/" + entry["path"], callback=uploaded)
transferred += entry["size"]
step("build", run_id=True, timeout=7200)
check_cancel()
installed = step("finalize", run_id=True, timeout=600)
if not isinstance(installed, dict) or installed.get("gamePath") != destination["gamePath"]:
raise SSHError("The Frame returned an unexpected installed game location.")
check_cancel()
progress("steam", "Updating Halo's Steam entry, artwork and game Notes while keeping Steam Home running...", None)
steam = step("shortcut", timeout=150)
@@ -393,10 +600,13 @@ class Installer:
progress("complete", "Native VR game is ready. Launch Halo: Combat Evolved VR (Native) from your Steam library." if steam.get("status") == "added" else "Native VR game is installed. One Steam library step remains.", 100)
return InstallResult(installed["gamePath"], bool(installed.get("reused")),
steam, host_fingerprint, repaired=bool(installed.get("repaired")),
backup_path=installed.get("backupPath"), cleanup_warning=cleanup_warning)
backup_path=installed.get("backupPath"), cleanup_warning=cleanup_warning,
storage_id=settings.storage_id)
except (CancelledError, RemoteTimeoutError, SSHError) as error:
cancel_remote()
if cancellation_warning:
if isinstance(error, CancelledError):
error.requires_attention = True
message = str(error)
if cancellation_unconfirmed:
first, separator, rest = message.partition("\n")
@@ -411,6 +621,8 @@ class Installer:
cleanup_activity(progress, "Setup has disconnected from your Frame.")
elif sys.exc_info()[1] is not None:
error = sys.exc_info()[1]
if isinstance(error, CancelledError):
error.requires_attention = True
error.args = (str(error) + "\n\n" + warning,)
def add_to_steam(self, settings: Settings, progress_callback: ProgressCallback | None = None,
@@ -442,21 +654,21 @@ class Installer:
connection.connect()
check_cancel()
progress("preflight", "Checking the native game location and safe uninstall support...", None)
helper_source = (self.resource_dir / "remote_install.py").read_text(encoding="utf-8")
info = parse_result(connection.run(["python3", "-c", helper_source, "preflight-uninstall"],
helper_source = self.remote_source()
info = parse_result(connection.run(["python3", "-c", helper_source, "preflight-uninstall"] + storage_arguments(settings),
progress=remote_progress, cancel_event=cancel_event, timeout=90))
if (info.get("home") != "/home/steamos"
or info.get("cachePath") != "/home/steamos/.cache/halo-frame-installer"
or info.get("gamePath") != "/home/steamos/Games/HaloCENativeVR"
or info.get("uninstallSupported") is not True):
destination = validate_preflight_location(info, settings)
upload = self.guarded_upload(connection, settings, destination, cancel_event)
if info.get("uninstallSupported") is not True:
raise SSHError("The Frame returned an unexpected uninstall location or capability.")
remote_resources = info["cachePath"] + "/resources"
for name in ("remote_install.py", "steam_shortcut.py", "steam_live.py"):
for name in ("frame_storage.py", "remote_install.py", "steam_shortcut.py", "steam_live.py"):
check_cancel()
connection.put(self.resource_dir / name, remote_resources + "/" + name, cancel_event=cancel_event)
upload(self.resource_dir / name, remote_resources + "/" + name)
check_cancel()
progress("uninstall", "Removing the native game and its Steam entry. Please wait for removal to finish...", None)
argv = ["python3", remote_resources + "/remote_install.py", "uninstall", "--run-id", run_identifier]
argv += storage_arguments(settings)
if keep_saves:
argv.append("--keep-saves")
# Once removal starts, finish the owned transaction rather than
@@ -466,7 +678,7 @@ class Installer:
steam = data.get("steam")
if not isinstance(steam, dict):
raise SSHError("The Frame returned an invalid uninstall result.")
recovered, retained, warning = uninstall_history(data)
recovered, retained, warning = uninstall_history(data, destination["gamePath"])
for text in ([warning] if warning else []) + ["Recovered campaign save backup: " + path for path in recovered] + ["Preserved uninstall folder: " + path for path in retained]:
progress("detail", text, None)
if steam.get("status") == "manual":
@@ -487,7 +699,7 @@ class Installer:
or (not pending and not (uninstalled or absent))
or (retained and not pending)
or steam.get("status") not in ("removed", "already-absent")
or (saved is not None and saved != "/home/steamos/Games/HaloCENativeVR-saves-" + run_identifier)):
or (saved is not None and saved != str(PurePosixPath(destination["gamePath"]).parent) + "/HaloCENativeVR-saves-" + run_identifier)):
raise SSHError("The Frame returned an invalid uninstall result.")
host_fingerprint = connection.host_fingerprint
progress("disconnect", "Closing the setup SSH connection...", None)
@@ -504,7 +716,7 @@ class Installer:
return UninstallResult(info["gamePath"], uninstalled, absent, saved, steam, host_fingerprint,
cleanup_warning=cleanup_warning, removal_pending=pending,
removal_warning=warning, recovered_save_backup_paths=recovered,
retained_quarantine_paths=retained)
retained_quarantine_paths=retained, storage_id=settings.storage_id)
finally:
if not cleanup_attempted:
cleanup_activity(progress, "Closing the setup SSH connection...")
@@ -513,6 +725,8 @@ class Installer:
cleanup_activity(progress, "Setup has disconnected from your Frame.")
elif sys.exc_info()[1] is not None:
error = sys.exc_info()[1]
if isinstance(error, CancelledError):
error.requires_attention = True
error.args = (str(error) + "\n\n" + warning,)
+23 -1
View File
@@ -11,7 +11,7 @@ def smoke_test(resources: Path) -> dict:
from .install import InstallResult, SOURCE_COMMIT
from . import gui
from .ssh import Settings
names = ('remote_install.py', 'steam_shortcut.py', 'steam_live.py', 'steam_notes.py', 'build-native.sh',
names = ('remote_install.py', 'frame_storage.py', 'steam_shortcut.py', 'steam_live.py', 'steam_notes.py', 'build-native.sh',
'frame-controls.patch', 'ui/app-icon.png', 'ui/app-icon.ico',
'artwork/halo-ce-cover.jpg', 'artwork/halo-ce-landscape.png',
'artwork/halo-ce-thumbnail.png', 'artwork/halo-ce-hero.jpg',
@@ -73,6 +73,26 @@ def smoke_test(resources: Path) -> dict:
raise RuntimeError('Packaged USB settings did not preserve device identity')
if settings.close_steam_for_shortcut:
raise RuntimeError('Packaged USB setup must keep Steam Home running')
if {choice.value for choice in app.storage_choices} != {'internal', 'sd'}:
raise RuntimeError('Packaged storage choices are incomplete')
card_id = 'sd:' + 'a' * 32
card_path = '/run/media/steamos/smoke-card/Games/HaloCENativeVR'
card = {'id': card_id, 'kind': 'sd', 'label': 'SD smoke test',
'mountPath': '/run/media/steamos/smoke-card', 'gamePath': card_path,
'cachePath': '/run/media/steamos/smoke-card/.halo-frame-installer',
'freeBytes': 24 * 1024**3, 'installed': True}
app._apply_storage_inventory((card,), app._storage_identity())
app.storage_kind.set('sd')
if app._settings().storage_id != card_id or card_path not in app.summary_storage.get():
raise RuntimeError('Packaged SD storage selection was not carried into settings and summary')
app.host.set('another-frame')
try:
app._settings()
except ValueError:
pass
else:
raise RuntimeError('Packaged SD selection survived a connection identity change')
app.storage_kind.set('internal')
settings.password = ''
app.password.set('')
for mode in ('install', 'repair'):
@@ -120,5 +140,7 @@ def smoke_test(resources: Path) -> dict:
'steamInfoAutomaticOnInstall': True,
'steamDescriptionUsesNotes': True,
'optionalSteamIconNonBlocking': True,
'sdCardInstallAvailable': True,
'storageDiscoveryRequiredForSD': True,
'usbToolsBundled': usb_bundled,
'passwordReprRedacted': True, 'networkConnections': 0}
+88 -5
View File
@@ -10,6 +10,7 @@ import socket
import threading
import time
from collections import deque
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
@@ -22,11 +23,13 @@ class SSHError(RuntimeError):
class RemoteTimeoutError(SSHError):
"""A started remote operation exceeded its bounded host wait."""
"""A remote operation exceeded its bounded wait before it could be verified."""
class CancelledError(RuntimeError):
pass
"""The user stopped setup; failed cleanup can still require attention."""
requires_attention = False
def validate_host(host: str) -> str:
@@ -67,8 +70,12 @@ class Settings:
adb_path: str | None = None
usb_serial: str | None = None
known_host_fingerprints: dict[str, str] = field(default_factory=dict, repr=False)
storage_id: str = "internal"
def __post_init__(self) -> None:
if (not isinstance(self.storage_id, str)
or not re.fullmatch(r"internal|sd:[a-f0-9]{32}", self.storage_id)):
raise ValueError("Choose internal storage or a detected SD card.")
if self.transport not in ("network", "usb"):
raise ValueError("Choose Wi-Fi / Ethernet or USB-C transfer.")
self.host = validate_host(self.host) if self.transport == "network" else "frame"
@@ -224,6 +231,49 @@ class SSHConnection:
raise SSHError(str(exc)) from None
self._usb_forward = None
handshake_grace = 5.0
"""Extra seconds a handshake may take past its step timeout before the
transport is force-closed; paramiko's internal waits are otherwise
unbounded on a live-but-silent session."""
sftp_timeout = 20.0
@contextmanager
def _handshake_watchdog(self, timeout: float, cancel_event: threading.Event | None):
"""Interrupt only this handshake, then retire before any recovery work."""
transport = self.client.get_transport()
finished = threading.Event()
expired = threading.Event()
deadline = time.monotonic() + timeout
def watch():
while not finished.is_set():
remaining = deadline - time.monotonic()
if (cancel_event is not None and cancel_event.is_set()) or remaining <= 0:
if remaining <= 0:
expired.set()
# Closing the captured session releases Paramiko's exec,
# subsystem and SFTP-version waits, which ignore the channel
# timeout. Never look up a potentially replacement session.
try:
if transport is not None:
transport.close()
except Exception:
pass
return
if finished.wait(min(0.05, remaining)):
return
watchdog = threading.Thread(target=watch, name="ssh-handshake-watchdog", daemon=True)
watchdog.start()
try:
yield expired
finally:
finished.set()
# cancel() alone cannot stop a timer callback that has already
# started. Wait for retirement before a transfer or on_cancel can
# start another operation on this connection.
watchdog.join()
def run(self, argv: list[str], *, progress: Callable[[str], None] | None = None,
cancel_event: threading.Event | None = None, timeout: float = 3600,
on_cancel: Callable[[], None] | None = None,
@@ -236,17 +286,34 @@ class SSHConnection:
except Exception:
pass # The primary cancellation/timeout must always survive.
def handshake_timed_out():
# The request may have reached the device without its acknowledgement.
# The watchdog is already retired here; cancellation recovery can
# safely open a new pinned session before the timeout is reported.
notify_cancelled()
if cancel_event is not None and cancel_event.is_set():
raise CancelledError("Installation cancelled. Existing games and saves were kept.") from None
raise RemoteTimeoutError(timeout_message or
"The remote command handshake timed out before its start could be verified. "
"The operation may have started on the Frame; reconnect and check its status before retrying.") from None
if cancel_event is not None and cancel_event.is_set():
notify_cancelled()
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
command = shlex.join([str(arg) for arg in argv])
started = time.monotonic()
expired = None
try:
_, stdout, _ = self.client.exec_command(command, timeout=min(30, timeout))
with self._handshake_watchdog(min(30, timeout) + self.handshake_grace, cancel_event) as expired:
_, stdout, _ = self.client.exec_command(command, timeout=min(30, timeout))
if expired.is_set():
handshake_timed_out()
except (paramiko.SSHException, OSError, EOFError):
if cancel_event is not None and cancel_event.is_set():
notify_cancelled()
raise CancelledError("Installation cancelled. Existing games and saves were kept.") from None
if expired is not None and expired.is_set():
handshake_timed_out()
raise SSHError("The remote command could not be started. Check the Frame's connection and retry.") from None
channel = stdout.channel
self._command_channels.add(channel)
@@ -266,6 +333,15 @@ class SSHConnection:
notify_cancelled()
raise RemoteTimeoutError(timeout_message or "The remote step timed out. You can reconnect and retry; existing games and saves were kept.")
while channel.recv_ready():
# These checks live inside the receive loop on purpose: a
# device that keeps the channel readable must not make the
# cancel button or the step timeout unreachable.
if cancel_event is not None and cancel_event.is_set():
notify_cancelled()
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
if time.monotonic() - started > timeout:
notify_cancelled()
raise RemoteTimeoutError(timeout_message or "The remote step timed out. You can reconnect and retry; existing games and saves were kept.")
data = channel.recv(65536)
if not data:
break
@@ -280,6 +356,10 @@ class SSHConnection:
else:
retained_bytes -= len(first)
pending.extend(data)
if len(pending) > 1024 * 1024:
# Endless newline-free output must not grow host memory
# without bound; keep only the most recent tail.
del pending[:len(pending) - 64 * 1024]
while b"\n" in pending:
line, _, rest = pending.partition(b"\n")
pending[:] = rest
@@ -339,9 +419,12 @@ class SSHConnection:
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
sftp = None
try:
sftp = self.client.open_sftp()
with self._handshake_watchdog(self.sftp_timeout + self.handshake_grace, cancel_event):
sftp = self.client.open_sftp()
self._sftp_clients.add(sftp)
sftp.get_channel().settimeout(20)
if cancel_event is not None and cancel_event.is_set():
raise CancelledError("Installation cancelled. Existing games and saves were kept.")
sftp.get_channel().settimeout(self.sftp_timeout)
sftp.put(str(local), remote, callback=update, confirm=True)
except (OSError, EOFError, paramiko.SSHException):
if cancel_event is not None and cancel_event.is_set():
+11 -5
View File
@@ -82,17 +82,23 @@ def resolve_adb(selected: str | None = None) -> Path:
if not path.is_file() or path.name.lower() not in ("adb", "adb.exe"):
raise USBError("Select adb.exe from the Android SDK platform-tools folder.")
return path
# Prefer an existing client to cooperate with other applications' ADB server.
existing = shutil.which("adb")
if existing:
return Path(existing).resolve()
if sys.platform == "win32":
if sys.platform == "win32" or getattr(sys, "frozen", False):
# Only the hash-verified bundle is known to be the build shipped with
# this installer. The search path (which on Windows also covers the
# working directory) can surface an unrelated or shadowing adb.exe, so
# it is never trusted while a verified tool is available. The bundle
# is consulted on every platform it ships on, not just Windows.
included = bundled_adb()
if included:
return included
sdk = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "Android" / "Sdk" / "platform-tools" / "adb.exe"
if sdk.is_file():
return sdk
# A client found on the search path may cooperate with another
# application's ADB server, but its provenance is unknown: last resort.
existing = shutil.which("adb")
if existing:
return Path(existing).resolve()
raise USBError("USB tools were not found. Install Google's Android SDK Platform Tools and select adb.exe in Advanced settings.")
+77
View File
@@ -13,6 +13,10 @@ RESOURCES = Path(__file__).resolve().parents[1] / "resources"
@pytest.fixture
def steam(monkeypatch):
storage_spec = importlib.util.spec_from_file_location("frame_storage", RESOURCES / "frame_storage.py")
storage = importlib.util.module_from_spec(storage_spec)
monkeypatch.setitem(sys.modules, storage_spec.name, storage)
storage_spec.loader.exec_module(storage)
spec = importlib.util.spec_from_file_location("steam_artwork_test", RESOURCES / "steam_shortcut.py")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
@@ -220,6 +224,79 @@ def owned_game(tmp_path):
return game
def test_sd_closed_shortcut_keeps_internal_entry_and_uses_home_grid(steam, tmp_path, monkeypatch):
home, active, _ = make_steam_home(tmp_path)
internal_game = owned_game(home)
mount = tmp_path / "media" / "Halo SD Ω"
game = owned_game(mount)
storage = sys.modules["frame_storage"]
internal = storage.discover_storage(home)["destinations"][0]
descriptor = {"id": "sd:" + "c" * 32, "kind": "sd", "mountPath": str(mount),
"gamePath": str(game)}
monkeypatch.setattr(storage, "discover_storage", lambda home=home: {
"home": str(home), "destinations": [internal, descriptor]})
raw, internal_id = steam.update_shortcut(None, str(internal_game / "halo"), str(internal_game))
path = active / "shortcuts.vdf"
path.write_bytes(raw)
result = steam.add_native_shortcut(home, game)
assert result["status"] == "added" and result["name"] == steam.SD_NAME
assert result["appid"] != internal_id
records = steam.loads(path.read_bytes())["shortcuts"].value
assert records["0"] == steam.loads(raw)["shortcuts"].value["0"]
assert records["1"].value["AppName"].value == steam.SD_NAME
assert records["1"].value["Exe"].value == '"' + str(game / "halo") + '"'
assert records["1"].value["StartDir"].value == '"' + str(game) + '"'
assert records["1"].value["icon"].value == str(game / ".installer-artwork" / steam.ICON_NAME)
assert (active / "grid" / (str(result["appid"]) + "p.jpg")).is_file()
assert not (mount / ".local").exists()
removed = steam.remove_native_shortcut(home, game)
assert removed["status"] == "removed" and path.read_bytes() == raw
assert not (active / "grid" / (str(result["appid"]) + "p.jpg")).exists()
def test_closed_shortcut_rejects_unapproved_display_title(steam, tmp_path):
game = owned_game(tmp_path)
with pytest.raises(ValueError, match="name is unsupported"):
steam.update_shortcut(None, str(game / "halo"), str(game), "Another game")
def test_sd_closed_retry_preserves_user_flags_and_internal_entry(steam, tmp_path, monkeypatch):
home, active, _ = make_steam_home(tmp_path)
internal_game = owned_game(home)
mount = tmp_path / "media" / "Halo SD Ω"
game = owned_game(mount)
storage = sys.modules["frame_storage"]
internal = storage.discover_storage(home)["destinations"][0]
card = {"id": "sd:" + "c" * 32, "kind": "sd", "mountPath": str(mount), "gamePath": str(game)}
monkeypatch.setattr(storage, "discover_storage", lambda home=home: {
"home": str(home), "destinations": [internal, card]})
raw, internal_id = steam.update_shortcut(None, str(internal_game / "halo"), str(internal_game))
path = active / "shortcuts.vdf"
path.write_bytes(raw)
first = steam.add_native_shortcut(home, game)
root = steam.loads(path.read_bytes())
fields = root["shortcuts"].value["1"].value
del fields["IsHidden"]
fields["ishidden"] = steam.Value(2, 1)
choices = {"AllowDesktopConfig": 0, "AllowOverlay": 0, "Devkit": 1, "DevkitOverrideAppID": 480}
for key, value in choices.items():
fields[key] = steam.Value(2, value)
fields["OpenVR"] = steam.Value(2, 0)
path.write_bytes(steam.dumps(root))
retry = steam.add_native_shortcut(home, game)
stable = path.read_bytes()
after = steam.loads(stable)["shortcuts"].value
assert retry["appid"] == first["appid"] != internal_id
assert after["0"] == steam.loads(raw)["shortcuts"].value["0"]
fields = after["1"].value
assert fields["ishidden"].value == 1 and "IsHidden" not in fields
assert all(fields[key].value == value for key, value in choices.items())
assert fields["OpenVR"].value == 1 and fields["AppName"].value == steam.SD_NAME
assert steam.add_native_shortcut(home, game)["unchanged"]
assert path.read_bytes() == stable
def test_closed_shortcut_icon_uses_persistent_owned_sidecar(steam, tmp_path):
game = owned_game(tmp_path)
raw, appid = steam.update_shortcut(None, str(game / "halo"), str(game))
+2 -1
View File
@@ -85,7 +85,8 @@ def test_build_launches_package_manager_as_container_root_with_host_isolation(tm
assert argv.count("--volume") == 1
assert argv[argv.index("--volume") + 1] == str(directory) + ":/build:rw"
assert argv[argv.index("--workdir") + 1] == "/build"
assert argv[-3:] == ["docker.io/library/ubuntu:22.04", "/bin/bash", "/build/build-native.sh"]
assert argv[-3:] == [remote.BUILD_CONTAINER_IMAGE, "/bin/bash", "/build/build-native.sh"]
assert remote.BUILD_CONTAINER_IMAGE.startswith("docker.io/library/ubuntu:22.04@sha256:")
assert kwargs["start_new_session"] is True
assert not kwargs.get("shell", False)
assert not any(option in argv for option in (
+39
View File
@@ -0,0 +1,39 @@
"""Build source artifacts cannot replace their repository-pinned checksums."""
import importlib.util
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
def notices():
spec = importlib.util.spec_from_file_location("pinned_notice_sources", ROOT / "scripts/gather_notices.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_substituted_source_is_refused_before_writing_any_notice(tmp_path, monkeypatch):
module = notices()
downloads = []
def fetch(url, *args):
downloads.append(url)
# Remote metadata could advertise this archive and a matching digest;
# the expected value comes only from the separately reviewed source pin.
return b"substituted source archive"
monkeypatch.setattr(module, "get_https", fetch)
with pytest.raises(RuntimeError, match="checksum mismatch"):
module.download_sources(tmp_path, [{"name": "paramiko", "version": "4.0.0"}])
assert downloads == [module.PARAMIKO_SOURCE_URL]
assert not list(tmp_path.iterdir())
def test_dependency_upgrade_requires_review_before_any_network_fetch(tmp_path, monkeypatch):
module = notices()
def no_fetch(*args):
raise AssertionError("Unreviewed dependency must not initiate a download")
monkeypatch.setattr(module, "get_https", no_fetch)
with pytest.raises(RuntimeError, match="repository-pinned Paramiko"):
module.download_sources(tmp_path, [{"name": "paramiko", "version": "future-unreviewed"}])
assert not list(tmp_path.iterdir())
+6 -2
View File
@@ -80,12 +80,16 @@ def test_protocol_11_engine_cannot_be_mislabeled_as_matching_experimental_coop(t
def test_public_release_documentation_names_the_single_installer_download():
for name in ("README.md", "docs/SETUP.md", "docs/RELEASE_NOTES.md"):
text = (ROOT / name).read_text(encoding="utf-8")
assert "1.4.1" in text and "Halo-Steam-Frame-Mod-Setup-1.4.1.exe" in text
assert "1.4.2" in text and "Halo-Steam-Frame-Mod-Setup-1.4.2.exe" in text
assert "has not been published" not in text and "local experimental preview" not in text
readme = (ROOT / "README.md").read_text(encoding="utf-8")
assert "https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.1/Halo-Steam-Frame-Mod-Setup-1.4.1.exe" in readme
assert "https://github.com/ClassicWasTaken/HaloSteamFrameMod/releases/download/v1.4.2/Halo-Steam-Frame-Mod-Setup-1.4.2.exe" in readme
notes = (ROOT / "docs/RELEASE_NOTES.md").read_text(encoding="utf-8")
assert "cannot load old checkpoints" in notes and "save-v1.4" in notes
sd_guide = (ROOT / "docs/SD_CARD.md").read_text(encoding="utf-8")
assert "Halo: Combat Evolved VR (Native, SD card)" in sd_guide
assert "Refresh storage" in readme and "ext4 or f2fs" in sd_guide
assert "BUILD_PROVENANCE.md" in readme and "BUILD_PROVENANCE.md" in notes
def test_actual_exact_pinned_engine_and_shipped_patch_when_checkout_is_supplied():
+382 -49
View File
@@ -2,11 +2,201 @@ import sys
import json
import subprocess
from pathlib import Path
from types import SimpleNamespace
import pytest
WINDOWS_GUI = pytest.mark.skipif(sys.platform != 'win32', reason='Windows Tk packaging smoke test')
def _storage_destination(card=None, *, label='Frame SD card', installed=False):
mount = '/home/steamos' if card is None else '/run/media/steamos/card-' + card
return {'id': 'internal' if card is None else 'sd:' + card * 32,
'kind': 'internal' if card is None else 'sd', 'label': 'Internal storage' if card is None else label,
'mountPath': mount, 'gamePath': mount + '/Games/HaloCENativeVR',
'cachePath': mount + ('/.cache/halo-frame-installer' if card is None else '/.halo-frame-installer'), 'freeBytes': 24 * 1024**3,
'installed': installed}
@WINDOWS_GUI
def test_internal_storage_remains_default_without_discovery(app):
app.password.set('offline-secret')
assert {choice.value for choice in app.storage_choices} == {'internal', 'sd'}
assert app._settings().storage_id == 'internal'
assert '~/Games/HaloCENativeVR' in app.summary_storage.get()
@WINDOWS_GUI
def test_no_sd_card_requires_refresh_and_never_falls_back_to_internal(app):
app.password.set('offline-secret')
app.storage_kind.set('sd')
with pytest.raises(ValueError, match='Refresh storage'):
app._settings()
assert app._settings(storage_id='internal').storage_id == 'internal'
app._apply_storage_inventory((_storage_destination(),), app._storage_identity())
assert 'No eligible mounted SD card' in app.storage_note.get()
assert 'Steam Settings → Storage' in app.storage_note.get()
assert app.storage_id.get() == '' and _disabled(app.storage_picker)
with pytest.raises(ValueError, match='select a mounted SD card'):
app._settings()
@WINDOWS_GUI
def test_multiple_sd_cards_need_explicit_selection_and_show_exact_destination(app):
app.password.set('offline-secret')
cards = (_storage_destination(), _storage_destination('a', installed=True), _storage_destination('b'))
app._apply_storage_inventory(cards, app._storage_identity())
app.storage_kind.set('sd')
assert not app.storage_id.get() and 'Multiple SD cards' in app.storage_note.get()
with pytest.raises(ValueError, match='Refresh storage'):
app._settings()
labels = tuple(app._storage_picker_ids)
assert len(labels) == 2 and labels[0] != labels[1] and 'Installed' in labels[0]
app.storage_label.set(labels[1])
app._select_storage_card()
assert app._settings().storage_id == cards[2]['id']
assert cards[2]['gamePath'] in app.summary_storage.get()
assert cards[2]['gamePath'] in app.storage_note.get()
for mode in ('repair', 'uninstall'):
app.mode.set(mode)
assert cards[2]['gamePath'] in app.summary_storage.get()
if mode == 'uninstall':
assert app.summary_source.get() == cards[2]['gamePath']
app._apply_storage_inventory((_storage_destination(),), app._storage_identity())
assert app.storage_id.get() == ''
with pytest.raises(ValueError, match='Refresh storage'):
app._settings()
@WINDOWS_GUI
@pytest.mark.parametrize('field,value', [('host', '192.0.2.44'), ('port', '2223'),
('transport', 'usb'), ('usb_serial', 'another-frame'), ('adb_path', 'C:/another/adb.exe')])
def test_connection_changes_invalidate_sd_selection_without_internal_fallback(app, field, value):
app.password.set('offline-secret')
app._apply_storage_inventory((_storage_destination(), _storage_destination('a')), app._storage_identity())
app.storage_kind.set('sd')
assert app._settings().storage_id == 'sd:' + 'a' * 32
getattr(app, field).set(value)
assert app.storage_kind.get() == 'sd' and app.storage_id.get() == ''
assert not app._storage_destinations and not app._storage_picker_ids
assert _disabled(app.storage_picker)
with pytest.raises(ValueError, match='Refresh storage'):
app._settings()
@WINDOWS_GUI
def test_stale_storage_events_and_host_approval_cannot_replace_new_state(app):
import threading
from halo_frame_installer.install import StorageResult
app.password.set('offline-secret')
old_generation, old_identity = app._storage_generation, app._storage_identity()
app.host.set('192.0.2.45')
app.operation = 'connection'
app._set_busy(True)
ready, answer = threading.Event(), []
app.events.put(('storage_result', old_generation, old_identity, 'frame:22',
StorageResult((_storage_destination(), _storage_destination('a')), 'SHA256:old')))
app.events.put(('storage_error', old_generation, old_identity, 'Old discovery cancelled.', True))
app.events.put(('storage_idle', old_generation, old_identity))
app.events.put(('storage_fingerprint', old_generation, old_identity, 'frame', 'ssh-ed25519',
'SHA256:old', 22, ready, answer))
_drain_events(app)
assert app.busy and not app._storage_destinations and not app.fingerprints
assert app.password.get() == 'offline-secret' and not app._test_dialogs
assert ready.is_set() and answer == [False]
assert 'Old discovery' not in '\n'.join(app.log_lines)
@WINDOWS_GUI
def test_sd_repair_and_uninstall_pass_selected_location_and_confirm_it(app, monkeypatch):
from halo_frame_installer import gui, install
card = _storage_destination('a', installed=True)
app._apply_storage_inventory((_storage_destination(), card), app._storage_identity())
app.storage_kind.set('sd')
app.password.set('offline-secret')
app.authorized.set(True)
app.steam_closed.set(True)
confirmations, requests = [], []
monkeypatch.setattr(gui.messagebox, 'askyesno', lambda title,message,**kwargs: confirmations.append(message) or True)
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
def repair(settings, maps, progress, cancel):
requests.append(('repair', settings.storage_id, maps))
return install.InstallResult(card['gamePath'], True, {'status': 'added'}, None,
repaired=True, storage_id=settings.storage_id)
monkeypatch.setattr(install, 'run', repair)
app.mode.set('repair')
app._install(repair=True)
_drain_events(app)
app.password.set('offline-secret')
def uninstall(self, settings, progress, cancel, *, keep_saves):
requests.append(('uninstall', settings.storage_id, keep_saves))
return install.UninstallResult(card['gamePath'], True, False, None, {'status': 'removed'},
None, storage_id=settings.storage_id)
monkeypatch.setattr(install.Installer, 'uninstall', uninstall)
app.mode.set('uninstall')
app._uninstall()
_drain_events(app)
assert requests == [('repair', card['id'], None), ('uninstall', card['id'], True)]
assert len(confirmations) == 2 and all(card['gamePath'] in text for text in confirmations)
assert all('~/Games/HaloCENativeVR' not in text for text in confirmations)
@WINDOWS_GUI
def test_library_retry_uses_last_result_storage_even_when_current_selection_changed(app, monkeypatch):
from halo_frame_installer import gui, install
card = _storage_destination('a', installed=True)
app.last_result = install.InstallResult(card['gamePath'], True, {'status': 'manual'}, 'SHA256:original-frame',
storage_id=card['id'])
app.host.set('192.0.2.55')
app.fingerprints = {'192.0.2.55:22': 'SHA256:another-frame'}
app.storage_kind.set('internal')
app.password.set('offline-secret')
app.steam_closed.set(True)
requests = []
def add(self, settings, progress, cancel):
requests.append(settings.storage_id)
assert settings.known_host_fingerprint == 'SHA256:original-frame'
assert settings.accept_host_key is None
return install.InstallResult(card['gamePath'], True,
{'status': 'added', 'name': 'Halo: Combat Evolved VR (Native, SD card)'}, None,
storage_id=settings.storage_id)
monkeypatch.setattr(install.Installer, 'add_to_steam', add)
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
app._retry_steam()
_drain_events(app)
assert requests == [card['id']] and app.last_result.storage_id == card['id']
assert app.phase.get() == 'Steam info updated'
@WINDOWS_GUI
def test_sd_storage_card_and_picker_are_visible_after_scrolling_at_minimum_size(app):
app._apply_storage_inventory((_storage_destination(), _storage_destination('a')), app._storage_identity())
app.storage_kind.set('sd')
width, height = app.minsize()
app.geometry(f'{width}x{height}')
app.deiconify()
app._show_page(1)
app.update()
app.pages[1].canvas.yview_moveto(1)
app.update()
picker = app.storage_picker
canvas = app.pages[1].canvas
assert picker.winfo_ismapped() and str(picker.cget('state')) == 'readonly'
assert picker.winfo_rooty() >= canvas.winfo_rooty()
assert picker.winfo_rooty() + picker.winfo_height() <= canvas.winfo_rooty() + canvas.winfo_height()
assert app.storage_label.get() and app.storage_id.get() == 'sd:' + 'a' * 32
assert '12 GiB free on the selected drive' in app.install_note.cget('text')
@WINDOWS_GUI
def test_successful_sd_install_uses_actual_library_name(app):
from halo_frame_installer.install import InstallResult
name = 'Halo: Combat Evolved VR (Native, SD card)'
app.events.put(('complete', InstallResult('/run/media/steamos/card/Games/HaloCENativeVR', False,
{'status': 'added', 'name': name}, None, storage_id='sd:' + 'a' * 32)))
_drain_events(app)
assert name in app.status.get() and name in app._test_dialogs[-1][1]
def _disabled(widget):
return str(widget.cget('state')) == 'disabled'
@@ -65,6 +255,8 @@ def app(app_window, monkeypatch):
instance.after_cancel(callback)
instance.events.queue.clear()
instance.last_result = None
instance.storage_kind.set('internal')
instance._invalidate_storage()
instance.source.set('')
instance.host.set('frame')
instance.port.set('22')
@@ -294,32 +486,20 @@ def test_network_settings_keep_address_port_and_existing_trust_identity(app):
@WINDOWS_GUI
def test_connection_worker_remembers_resolved_usb_identity_without_opening_network(app, monkeypatch):
from halo_frame_installer import gui
closed = []
class OfflineConnection:
host_fingerprint = 'SHA256:usb'
def __init__(self, settings):
self.settings = settings
def connect(self):
assert self.settings.transport == 'usb'
self.settings.usb_serial = 'FRAME-SERIAL'
def run(self, argv, **kwargs):
assert argv == ['uname', '-m']
return 'aarch64'
def close(self):
closed.append(True)
monkeypatch.setattr(gui, 'SSHConnection', OfflineConnection)
from halo_frame_installer.install import Installer, StorageResult
captured = []
def discover(self, settings, *, cancel_event):
assert settings.transport == 'usb' and settings.storage_id == 'internal'
settings.usb_serial = 'FRAME-SERIAL'
captured.append(settings)
return StorageResult((_storage_destination(),), 'SHA256:usb', None)
monkeypatch.setattr(Installer, 'discover_storage', discover)
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
app.transport.set('usb')
app.password.set('offline-secret')
app._test_connection()
_drain_events(app)
assert closed == [True]
assert len(captured) == 1 and captured[0].password == ''
assert app.fingerprints == {'usb:FRAME-SERIAL': 'SHA256:usb'}
assert not app.busy
assert 'Connected to your Frame' in app.connection_status.get()
@@ -333,26 +513,16 @@ def test_connection_cleanup_failure_restores_controls_clears_password_and_never_
captured = []
actions = []
class OfflineConnection:
host_fingerprint = 'SHA256:usb'
def __init__(self, settings):
self.settings = settings
captured.append(settings)
def connect(self):
self.settings.usb_serial = 'FRAME-SERIAL'
if connection_fails:
raise SSHError('Offline connection failed.')
def run(self, argv, **kwargs):
return 'aarch64'
def close(self):
actions.append('close')
raise SSHError('The USB forward could not be removed: offline-secret')
monkeypatch.setattr(gui, 'SSHConnection', OfflineConnection)
from halo_frame_installer.install import Installer, StorageResult
def discover(self, settings, *, cancel_event):
captured.append(settings)
settings.usb_serial = 'FRAME-SERIAL'
actions.append('close')
if connection_fails:
raise SSHError('Offline connection failed.\nThe USB forward could not be removed: offline-secret')
return StorageResult((_storage_destination(),), 'SHA256:usb',
'The USB forward could not be removed: offline-secret')
monkeypatch.setattr(Installer, 'discover_storage', discover)
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
app.transport.set('usb')
app.password.set('offline-secret')
@@ -361,7 +531,7 @@ def test_connection_cleanup_failure_restores_controls_clears_password_and_never_
assert captured[0].password == ''
queued = list(app.events.queue)
assert not any(event[0] == 'connected' for event in queued)
assert queued[-1] == ('idle',)
assert queued[-1][0] == 'storage_idle'
_drain_events(app)
assert not app.busy
assert not app.password.get()
@@ -373,7 +543,8 @@ def test_connection_cleanup_failure_restores_controls_clears_password_and_never_
log = '\n'.join(app.log_lines)
assert 'USB forward could not be removed' in log
assert 'offline-secret' not in log
assert 'disconnecting' in log
if not connection_fails:
assert 'disconnecting' in log
assert app._test_dialogs
if connection_fails:
assert 'Offline connection failed' in log
@@ -610,6 +781,22 @@ def test_error_and_cancel_keep_progress_and_new_attempt_resets_it(app):
assert app.progress_label.get() == '0%'
@WINDOWS_GUI
def test_password_inside_a_phase_name_still_advances_progress(app):
app._begin_setup_progress()
app._toggle_activity()
assert not app.activity_open
app.password_to_redact = 'build'
app.events.put(('progress', 'build', 'Preparing the native build container.', 45))
_drain_events(app)
# The phase is still recognized (bar advances, caption resolves) even though
# the caption's display text redacts the password it happens to contain.
assert float(app.progress['value']) == 45
assert app.phase.get() == 'Preparing the native [redacted] container'
assert app.activity_open
app.password_to_redact = ''
@WINDOWS_GUI
def test_box_art_pending_stays_at_99_with_retry_action(app):
from halo_frame_installer.install import InstallResult
@@ -1387,7 +1574,8 @@ def test_current_inspection_failure_preserves_selection_and_readable_redacted_er
@WINDOWS_GUI
@pytest.mark.parametrize('folder', [False, True])
def test_cancel_during_install_revalidation_stops_before_extraction_and_clears_credentials(app, monkeypatch, tmp_path, folder):
@pytest.mark.parametrize('phase', ['validation', 'extraction'])
def test_cancel_during_install_data_preparation_is_not_a_failure(app, monkeypatch, tmp_path, folder, phase):
from halo_frame_installer import gui, install
selected = tmp_path / ('maps' if folder else 'Halo.iso')
if folder:
@@ -1405,15 +1593,25 @@ def test_cancel_during_install_revalidation_stops_before_extraction_and_clears_c
def cancel_validation(path, *, cancel_event):
assert cancel_event is app.cancel
inspections.append(path)
# Model Cancel arriving as the worker starts its second validation.
cancel_event.set()
return inspect_source(path, cancel_event=cancel_event)
if phase == 'validation':
# Exercise the real cancellation check before reading game data.
cancel_event.set()
return inspect_source(path, cancel_event=cancel_event)
return SimpleNamespace(estimated_bytes=0)
monkeypatch.setattr(app, '_settings', remember_settings)
monkeypatch.setattr(gui, name, cancel_validation)
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
for action in ('extract_image', 'copy_maps'):
monkeypatch.setattr(gui, action, lambda *args, **kwargs: pytest.fail('Cancelled validation extracted maps.'))
if phase == 'validation':
for action in ('extract_image', 'copy_maps'):
monkeypatch.setattr(gui, action, lambda *args, **kwargs: pytest.fail('Cancelled validation extracted maps.'))
else:
action = 'copy_maps' if folder else 'extract_image'
extract_source = getattr(gui, action)
def cancel_extraction(*args, **kwargs):
kwargs['cancel_event'].set()
return extract_source(*args, **kwargs)
monkeypatch.setattr(gui, action, cancel_extraction)
monkeypatch.setattr(install, 'run', lambda *args, **kwargs: pytest.fail('Cancelled validation began installation.'))
app.source.set(str(selected))
app.authorized.set(True)
@@ -1428,6 +1626,9 @@ def test_cancel_during_install_revalidation_stops_before_extraction_and_clears_c
assert not app.password_to_redact
assert app.source.get() == str(selected)
assert 'cancelled' in '\n'.join(app.log_lines).lower()
assert app.phase.get() == 'Setup cancelled'
assert 'cancelled' in app.status.get().lower()
assert not app._test_dialogs
assert 'offline-secret' not in '\n'.join(app.log_lines)
assert float(app.progress['value']) == 0
assert not _disabled(app.primary_button)
@@ -1500,3 +1701,135 @@ def test_uninstalled_game_reports_pending_connection_cleanup(app):
assert 'Connection cleanup needs attention' in app.status.get()
assert 'Unplug the USB cable' in app.status.get()
assert app._test_dialogs[-1][0] == 'Uninstall needs attention'
@WINDOWS_GUI
def test_user_cancel_is_not_reported_as_a_failure(app):
app._begin_setup_progress()
app.operation = 'connection'
app.connection_status.set('Connecting to your Frame…')
app.events.put(('error', 'Installation cancelled. Existing games and saves were kept.', True))
app.events.put(('idle',))
_drain_events(app)
assert app.phase.get() == 'Setup cancelled'
assert 'cancelled' in app.status.get().lower()
assert 'Setup stopped' not in app.status.get()
assert app.connection_status.get() == 'Connection check cancelled.'
assert not any(title == 'Setup needs attention' for title, _ in app._test_dialogs)
@WINDOWS_GUI
def test_genuine_failure_during_a_set_cancel_still_needs_attention(app):
app._begin_setup_progress()
app.operation = 'connection'
app.connection_status.set('Connecting to your Frame…')
app._cancel_setup()
app.events.put(('error', 'Could not reach the Frame: connection refused (port 22)', False))
app.events.put(('idle',))
_drain_events(app)
assert app.phase.get() == "Let's try that again"
assert 'Setup stopped' in app.status.get()
assert app.connection_status.get() == 'Connection check needs attention. See setup activity, then retry.'
assert any(title == 'Setup needs attention' for title, _ in app._test_dialogs)
@WINDOWS_GUI
@pytest.mark.parametrize('cleanup_fails', [False, True])
@pytest.mark.parametrize('primary_failure', [False, True])
def test_connection_worker_keeps_cleanup_failures_visible_after_cancel(app, monkeypatch, cleanup_fails, primary_failure):
from halo_frame_installer import gui, install
from halo_frame_installer.ssh import CancelledError, SSHError
actions, settings_used = [], []
class OfflineConnection:
def __init__(self, settings):
settings_used.append(settings)
def connect(self):
actions.append('connect')
def run(self, argv, *, cancel_event, **kwargs):
cancel_event.set()
if primary_failure:
raise SSHError('Original storage discovery failure.')
raise CancelledError('Connection check cancelled.')
def close(self):
actions.append('close')
if cleanup_fails:
raise SSHError('The USB forward could not be removed: offline-secret')
# Use the production discovery and its cleanup path; this injected
# connection never creates an SSH client or reaches a device.
installer = install.Installer(OfflineConnection)
monkeypatch.setattr(install, 'Installer', lambda: installer)
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
app.password.set('offline-secret')
app._test_connection()
_drain_events(app)
assert actions == ['connect', 'close']
assert not app.busy and not app.password.get() and not settings_used[0].password
assert not app.password_to_redact
if primary_failure or cleanup_fails:
assert app.phase.get() == "Let's try that again"
assert 'needs attention' in app.connection_status.get()
assert app._test_dialogs[-1][0] == 'Setup needs attention'
assert app._test_dialogs[-1][1].startswith('Original storage discovery failure.' if primary_failure
else 'Connection check cancelled.')
if cleanup_fails:
assert 'USB forward could not be removed' in '\n'.join(app.log_lines)
if not primary_failure and not cleanup_fails:
assert app.phase.get() == 'Setup cancelled'
assert app.connection_status.get() == 'Connection check cancelled.'
assert not app._test_dialogs
assert 'offline-secret' not in '\n'.join(app.log_lines)
@WINDOWS_GUI
@pytest.mark.parametrize('operation', ['install', 'library', 'uninstall'])
@pytest.mark.parametrize('primary_failure', [False, True])
@pytest.mark.parametrize('cleanup_fails', [False, True])
def test_installer_workers_distinguish_cancel_from_failures(app, monkeypatch, operation, primary_failure, cleanup_fails):
from halo_frame_installer import gui, install
from halo_frame_installer.ssh import SSHError
actions, settings_used = [], []
class OfflineConnection:
def __init__(self, settings):
self.settings = settings
settings_used.append(settings)
def connect(self):
actions.append('connect')
app.cancel.set()
if primary_failure:
raise SSHError('Original connection failure.')
def close(self):
actions.append('close')
if cleanup_fails:
raise SSHError('The USB forward could not be removed: offline-secret')
# Keep the real installer cleanup and GUI worker; no SSH or device methods run.
installer = install.Installer(OfflineConnection)
monkeypatch.setattr(install, 'Installer', lambda: installer)
monkeypatch.setattr(gui.threading, 'Thread', _ImmediateThread)
app.password.set('offline-secret')
app.authorized.set(True)
app.steam_closed.set(True)
if operation == 'install':
app._install(repair=True)
elif operation == 'library':
app._retry_steam()
else:
app._uninstall()
_drain_events(app)
assert actions == ['connect', 'close']
assert not app.busy and not app.installing
assert not app.password.get() and not settings_used[0].password and not app.password_to_redact
if primary_failure or cleanup_fails:
assert app.phase.get() == "Let's try that again"
assert app._test_dialogs[-1][0] == 'Setup needs attention'
if primary_failure:
assert app._test_dialogs[-1][1].startswith('Original connection failure.')
else:
assert app.phase.get() == 'Setup cancelled'
assert not app._test_dialogs
if cleanup_fails:
assert 'USB forward could not be removed' in '\n'.join(app.log_lines)
assert 'offline-secret' not in '\n'.join(app.log_lines)
+382 -6
View File
@@ -2,6 +2,7 @@ import importlib.util
import json
import sys
import struct
import subprocess
import threading
import types
from collections import OrderedDict
@@ -17,6 +18,11 @@ RESOURCES = Path(__file__).resolve().parents[1] / "resources"
def load_resource(name):
if "frame_storage" not in sys.modules:
storage_spec = importlib.util.spec_from_file_location("frame_storage", RESOURCES / "frame_storage.py")
storage = importlib.util.module_from_spec(storage_spec)
sys.modules[storage_spec.name] = storage
storage_spec.loader.exec_module(storage)
spec = importlib.util.spec_from_file_location("resource_" + name, RESOURCES / (name + ".py"))
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
@@ -39,6 +45,44 @@ def test_shortcut_roundtrip_preserves_foreign_entry_and_is_idempotent():
assert steam.update_shortcut(first, "/home/steamos/Games/HaloCENativeVR/halo", "/home/steamos/Games/HaloCENativeVR") == (first, appid)
def test_shortcut_update_preserves_user_library_choices():
steam = load_resource("steam_shortcut")
executable = "/home/steamos/Games/HaloCENativeVR/halo"
directory = "/home/steamos/Games/HaloCENativeVR"
first, appid = steam.update_shortcut(None, executable, directory)
root = steam.loads(first)
fields = next(iter(root["shortcuts"].value.values())).value
for key, value in {"IsHidden": 1, "AllowDesktopConfig": 0, "AllowOverlay": 0,
"OpenVR": 0, "Devkit": 1, "DevkitOverrideAppID": 480}.items():
fields[key] = steam.Value(2, value)
fields["LaunchOptions"] = steam.Value(1, "user-modified --mods")
updated, same = steam.update_shortcut(steam.dumps(root), executable, directory)
after = next(iter(steam.loads(updated)["shortcuts"].value.values())).value
assert after["IsHidden"].value == 1 and after["AllowDesktopConfig"].value == 0
assert after["AllowOverlay"].value == 0
assert after["Devkit"].value == 1 and after["DevkitOverrideAppID"].value == 480
assert after["OpenVR"].value == 1 # required for the VR library; still managed
assert after["LaunchOptions"].value == steam.LAUNCH_OPTIONS # identity fields still refreshed
assert after["Exe"].value == f'"{executable}"'
assert same == appid
def test_shortcut_update_keeps_case_variant_choices_and_repairs_damaged_flags():
steam = load_resource("steam_shortcut")
executable = "/home/steamos/Games/HaloCENativeVR/halo"
directory = "/home/steamos/Games/HaloCENativeVR"
first, _ = steam.update_shortcut(None, executable, directory)
root = steam.loads(first)
fields = next(iter(root["shortcuts"].value.values())).value
del fields["IsHidden"], fields["Devkit"]
fields["ishidden"] = steam.Value(2, 1) # old-format casing keeps the choice
fields["Devkit"] = steam.Value(1, "damaged") # wrong value kind cannot be a choice
updated, _ = steam.update_shortcut(steam.dumps(root), executable, directory)
after = next(iter(steam.loads(updated)["shortcuts"].value.values())).value
assert after["ishidden"].value == 1 and "IsHidden" not in after
assert after["Devkit"].kind == 2 and after["Devkit"].value == 0
def test_shortcut_rejects_unknown_types_or_duplicate_fields():
steam = load_resource("steam_shortcut")
with pytest.raises(ValueError, match="Unsupported"):
@@ -50,7 +94,7 @@ def test_shortcut_rejects_unknown_types_or_duplicate_fields():
def test_shortcut_wont_change_live_steam_files(tmp_path, monkeypatch):
steam = load_resource("steam_shortcut")
monkeypatch.setattr(steam, "steam_running", lambda: True)
result = steam.add_native_shortcut(tmp_path, tmp_path / "game")
result = steam.add_native_shortcut(tmp_path, tmp_path / "Games/HaloCENativeVR")
assert result["status"] == "manual"
assert list(tmp_path.iterdir()) == []
@@ -74,7 +118,7 @@ def test_legacy_shutdown_request_leaves_running_steam_and_games_untouched(tmp_pa
shutdown, restart = Mock(), Mock()
monkeypatch.setattr(subprocess, "run", shutdown)
monkeypatch.setattr(subprocess, "Popen", restart)
result = steam.add_native_shortcut(tmp_path, tmp_path / "game", close_steam=True)
result = steam.add_native_shortcut(tmp_path, tmp_path / "Games/HaloCENativeVR", close_steam=True)
assert result["status"] == "manual"
shutdown.assert_not_called()
restart.assert_not_called()
@@ -90,7 +134,7 @@ def test_closed_steam_registration_does_not_start_a_new_client(tmp_path, monkeyp
restart = Mock()
monkeypatch.setattr(subprocess, "run", normal_shutdown)
monkeypatch.setattr(subprocess, "Popen", restart)
assert steam.add_native_shortcut(tmp_path, tmp_path / "game", close_steam=True)["status"] == "added"
assert steam.add_native_shortcut(tmp_path, tmp_path / "Games/HaloCENativeVR", close_steam=True)["status"] == "added"
normal_shutdown.assert_not_called()
restart.assert_not_called()
@@ -266,8 +310,11 @@ def test_cancelled_install_is_not_replaced_by_a_failed_disconnect():
event = threading.Event()
event.set()
fake = ForwardCleanupFailure(Settings("frame", "private"))
with pytest.raises(CancelledError):
with pytest.raises(CancelledError) as error:
Installer(lambda settings: fake, RESOURCES).run(fake.settings, None, cancel_event=event)
assert error.value.requires_attention
assert str(error.value).startswith('Installation cancelled.')
assert 'USB forward cleanup failed' in str(error.value)
def test_close_connection_reports_a_swallowed_cleanup_failure():
@@ -302,7 +349,8 @@ def test_successful_install_with_failed_cleanup_retains_result_and_needs_attenti
@pytest.mark.parametrize('primary_error', [CancelledError, RemoteTimeoutError, SSHError])
@pytest.mark.parametrize('original_active', [False, True])
def test_failed_remote_stop_uses_one_pinned_fresh_connection(primary_error, original_active):
@pytest.mark.parametrize('recovery_cleanup_fails', [False, True])
def test_failed_remote_stop_uses_one_pinned_fresh_connection(primary_error, original_active, recovery_cleanup_fails):
class BrokenConnection(FakeConnection):
def is_active(self):
return original_active
@@ -322,6 +370,10 @@ def test_failed_remote_stop_uses_one_pinned_fresh_connection(primary_error, orig
class RecoveryConnection(FakeConnection):
def connect(self, *, timeout):
self.connect_timeout = timeout
def close(self):
super().close()
if recovery_cleanup_fails:
raise SSHError('Recovery connection cleanup failed.')
def run(self, argv, **kwargs):
self.commands.append(argv)
assert kwargs['timeout'] == 45 and 'cancel_event' not in kwargs
@@ -352,6 +404,10 @@ def test_failed_remote_stop_uses_one_pinned_fresh_connection(primary_error, orig
assert 'could not be confirmed' not in str(error.value)
assert not any(command[2] in ('finalize', 'shortcut') for command in original.commands if command[1] != '-c')
assert any(event[1] == 'Remote build cancellation confirmed.' for event in events)
if primary_error is CancelledError:
assert error.value.requires_attention is recovery_cleanup_fails
if recovery_cleanup_fails:
assert 'Recovery connection cleanup failed.' in str(error.value)
@pytest.mark.parametrize('failure', ['unapproved', 'changed-key', 'unconfirmed-response'])
@@ -381,6 +437,7 @@ def test_unconfirmed_remote_stop_is_visible_without_masking_cancel(failure):
with pytest.raises(CancelledError) as error:
Installer(factory, RESOURCES).run(settings, None)
first_line = str(error.value).splitlines()[0]
assert error.value.requires_attention
assert 'cancelled' in first_line and 'Remote stopping could not be confirmed' in first_line
assert 'In the Frame\'s terminal, run: python3 /home/steamos/.cache/' in str(error.value)
assert 'private' not in str(error.value) and original.closed
@@ -596,6 +653,45 @@ def test_repair_preserves_custom_sun_glow_strength(remote):
assert updated['vr']['sun_glow_strength'] == 0.25
def legacy_install(remote, source_commit):
game = remote.GAME
game.mkdir(parents=True)
header = bytearray(64)
header[:6] = b'ELF'
struct.pack_into('<H', header, 18, 183)
for name in ('halo', 'libSDL3.so.0'):
(game / name).write_bytes(header)
maps = xbox_maps(game, remote)
(game / 'xbox-data-manifest.json').write_text(json.dumps(maps))
(game / remote.MARKER).write_text(json.dumps({'owner': remote.OWNER,
'sourceCommit': source_commit,
'files': {name: remote.digest(game / name) for name in ('halo', 'libSDL3.so.0')}}))
return game
@pytest.mark.parametrize('repair', [False, True])
def test_redirected_config_without_upgrade_evidence_is_not_proof_of_save_ownership(remote, repair):
game = legacy_install(remote, remote.LEGACY_SOURCE_COMMIT)
(game / 'config.toml').write_text('[paths]\ndata = %s\nsaves = %s\n'
% (json.dumps(str(game)), json.dumps(str(remote.save_root()))))
remote.save_root().mkdir(parents=True)
(remote.save_root() / 'checkpoint.sav').write_bytes(b'played after the interrupted upgrade')
with pytest.raises(ValueError, match='interrupted upgrade could not be verified'):
remote.existing_install(repair=repair)
assert (remote.save_root() / 'checkpoint.sav').read_bytes() == b'played after the interrupted upgrade'
@pytest.mark.parametrize('repair', [False, True])
def test_foreign_save_destination_still_refused_during_migration(remote, repair):
game = legacy_install(remote, remote.LEGACY_SOURCE_COMMIT)
(game / 'config.toml').write_text('[paths]\ndata = %s\nsaves = %s\n'
% (json.dumps(str(game)), json.dumps(str(game / 'save'))))
remote.save_root().mkdir(parents=True)
(remote.save_root() / 'foreign.sav').write_bytes(b'not ours')
with pytest.raises(ValueError, match='save destination already contains files'):
remote.existing_install(repair=repair)
@pytest.mark.parametrize('repair', [False, True])
@pytest.mark.parametrize('unsafe', ['legacy-save-config', 'copied-experimental-config', 'symlinked-save-directory', 'missing-paths'])
def test_new_reuse_and_repair_guard_save_format_and_keep_experimental_instance(remote, repair, unsafe):
@@ -640,6 +736,46 @@ def test_new_reuse_and_repair_guard_save_format_and_keep_experimental_instance(r
assert (experimental / 'save/checkpoint').read_bytes() == b'experimental checkpoint'
def test_config_repair_rejects_unusual_value_shapes_without_a_traceback(remote):
with pytest.raises(ValueError, match="unsupported setting shape"):
remote.merge_config('vr = 5\n[update]\nauto = false\n')
with pytest.raises(ValueError, match="unsupported setting shape"):
remote.merge_config('update = 5\n[vr]\nenabled = true\n')
with pytest.raises(ValueError, match="unsupported setting shape"):
remote.merge_config('vr = true\n')
with pytest.raises(ValueError, match="unsupported setting shape"):
remote.merge_config('[[update]]\nauto = false\n')
with pytest.raises(ValueError, match="not valid TOML"):
remote.merge_config('update.auto = false\n[update]\nauto = true\n')
with pytest.raises(ValueError, match="not valid TOML"):
remote.merge_config('vr = [unclosed\n')
with pytest.raises(ValueError, match="updated configuration could not be parsed"):
remote.merge_config('update.auto = false\n[vr]\nenabled = true\n')
@pytest.mark.parametrize('repair', [False, True])
@pytest.mark.parametrize('original, diagnostic', [
('vr = [unclosed\n', 'not valid TOML'),
('update.auto = false\n[update]\nauto = true\n', 'not valid TOML'),
('vr = 5\n', 'unsupported setting shape'),
('update = 5\n', 'unsupported setting shape'),
('network = []\n', 'unsupported setting shape'),
('paths = true\n', 'unsupported setting shape'),
])
def test_existing_install_reports_config_errors_before_merge_without_writes(remote, repair, original, diagnostic):
native_install(remote, source=remote.SOURCE_COMMIT)
config = remote.GAME / 'config.toml'
config.write_text(original)
before = {path.relative_to(remote.GAME): path.read_bytes()
for path in remote.GAME.rglob('*') if path.is_file()}
with pytest.raises(ValueError, match=diagnostic) as failure:
remote.existing_install(repair=repair)
assert 'Your original configuration was kept.' in str(failure.value)
assert not isinstance(failure.value, remote.tomllib.TOMLDecodeError)
assert {path.relative_to(remote.GAME): path.read_bytes()
for path in remote.GAME.rglob('*') if path.is_file()} == before
def test_repair_backup_and_replacements_preserve_saves_and_unrelated_files(remote):
game = remote.GAME
game.mkdir(parents=True)
@@ -778,6 +914,8 @@ def native_install(remote, *, source=None, save_path=None):
+ '\nsaves = ' + json.dumps(str(save_path)) + '\n[audio]\nvolume = 0.3\n[vr]\nturn = "snap"\n')
marker = {"owner": remote.OWNER, "sourceCommit": source or remote.LEGACY_SOURCE_COMMIT,
"files": {name: remote.digest(game / name) for name in ("halo", "libSDL3.so.0")}}
if remote.STORAGE_DESCRIPTOR is not None:
marker.update(remote.storage_fields())
(game / remote.MARKER).write_text(json.dumps(marker))
return manifest, save_path
@@ -809,6 +947,244 @@ def built_run(remote, manifest, identifier, *, existing=True):
return directory
@pytest.mark.parametrize("repair", [False, True])
@pytest.mark.parametrize("config", ['[paths\nsaves = "save"\n', 'paths = 7\n'])
def test_upgrade_config_validation_keeps_malformed_legacy_installs(remote, repair, config):
native_install(remote)
(remote.GAME / "config.toml").write_text(config)
before = {path: path.read_bytes() for path in remote.GAME.rglob("*") if path.is_file()}
match = "original contents and saves were kept" if config.startswith("[paths") else "paths"
with pytest.raises(ValueError, match=match):
remote.existing_install(repair=repair)
assert {path: path.read_bytes() for path in before} == before
def hard_exit_finalizer(remote, identifier):
"""Kill the real scratch finalizer before marker publication, bypassing rollback."""
directory = remote.CACHE / "runs" / identifier
metadata = remote.read_marker(directory / remote.MARKER)
metadata["repair"] = True
(directory / remote.MARKER).write_text(json.dumps(metadata))
script = '''
import importlib.util, json, os, pathlib, sys, types
if sys.platform == "win32":
sys.modules["pwd"] = types.SimpleNamespace()
spec = importlib.util.spec_from_file_location("scratch_remote", sys.argv[1])
remote = importlib.util.module_from_spec(spec)
spec.loader.exec_module(remote)
remote.HOME = pathlib.Path(sys.argv[2])
remote.GAME = pathlib.Path(sys.argv[4])
remote.CACHE = pathlib.Path(sys.argv[5])
remote.os.getuid = lambda: remote.HOME.stat().st_uid
descriptor = json.loads(sys.argv[6])
if descriptor is not None:
remote.STORAGE_ID = descriptor["id"]
remote.STORAGE_DESCRIPTOR = descriptor
remote.storage_module().resolve_storage = lambda identifier, home: descriptor
remote.game_closed = lambda: None
remote.command = lambda *args, **kwargs: "native libraries resolved"
marker = remote.CACHE / "runs" / sys.argv[3] / "stage" / remote.MARKER
replace = remote.os.replace
def interrupt(source, target):
if pathlib.Path(source) == marker:
os._exit(72)
replace(source, target)
remote.os.replace = interrupt
remote.finalize(sys.argv[3], repair=True)
'''
result = subprocess.run([sys.executable, "-c", script, remote.__file__, str(remote.HOME), identifier,
str(remote.GAME), str(remote.CACHE), json.dumps(remote.STORAGE_DESCRIPTOR)],
capture_output=True, text=True, timeout=30)
assert result.returncode == 72, result.stdout + result.stderr
return directory
def interrupted_native_upgrade(remote, *, save_path=None, guided=False):
manifest, previous = native_install(remote, save_path=save_path)
old = remote.read_marker(remote.GAME / remote.MARKER)
if guided:
old["owner"] = "codex-halo-native-frame-20261005"
old["build"] = {"sourceCommit": old.pop("sourceCommit")}
(remote.GAME / remote.MARKER).unlink()
(remote.GAME / ".codex-halo-native-install.json").write_text(json.dumps(old))
directory = built_run(remote, manifest, "1" * 32)
hard_exit_finalizer(remote, "1" * 32)
marker = remote.GAME / (".codex-halo-native-install.json" if guided else remote.MARKER)
assert remote.read_marker(marker, old["owner"]) == old
assert remote.digest(remote.GAME / "halo") != old["files"]["halo"]
assert remote.configured_save_path(remote.tomllib.loads((remote.GAME / "config.toml").read_text())) == str(remote.save_root())
(remote.save_root() / "checkpoint").write_bytes(b"played after the interrupted upgrade")
return manifest, previous, directory
def interrupt_upgrade_retry(remote, manifest, identifier="2" * 32):
directory = built_run(remote, manifest, identifier)
for name in ("halo", "libSDL3.so.0"):
with (directory / "src/build/linux_arm64" / name).open("ab") as stream:
stream.write(b"different rebuilt bytes on retry " + identifier.encode())
return hard_exit_finalizer(remote, identifier)
def test_unrelated_unsafe_run_does_not_block_verified_upgrade_recovery(remote, monkeypatch):
_, previous, _ = interrupted_native_upgrade(remote, save_path=remote.GAME / "profiles/custom")
unrelated = remote.CACHE / "runs" / ("0" * 32)
unrelated.mkdir()
original = remote.beneath
def refuse_link(path, parent):
if path == unrelated:
raise ValueError("Symbolic links are not accepted in installation paths.")
return original(path, parent)
monkeypatch.setattr(remote, "beneath", refuse_link)
assert remote.existing_install()["previousSavePaths"] == [str(previous)]
@pytest.mark.parametrize("repair", [False, True])
@pytest.mark.parametrize("guided", [False, True])
@pytest.mark.parametrize("partial_library", [False, True])
def test_repeated_interruption_carries_original_save_ancestry_without_a_chain(remote, monkeypatch, repair, guided, partial_library):
manifest, previous, first = interrupted_native_upgrade(remote, save_path=remote.GAME / "profiles/custom", guided=guided)
initial_hash = remote.digest(remote.GAME / "halo")
second = interrupt_upgrade_retry(remote, manifest)
if partial_library:
# Also represent stopping after the new engine, before the retry's library:
# its prior verified 1.4 library has a third hash, distinct from legacy/B.
live = remote.GAME / "libSDL3.so.0"
(second / "stage/libSDL3.so.0").write_bytes(live.read_bytes())
live.write_bytes((second / "previous-program-files/libSDL3.so.0").read_bytes())
assert remote.digest(remote.GAME / "halo") != initial_hash
assert (second / "previous-program-files/upgrade-origin/config.toml").read_bytes() == (first / "previous-program-files/config.toml").read_bytes()
assert remote.existing_install(repair=repair)["previousSavePaths"] == [str(previous)]
monkeypatch.setattr(remote, "command", lambda *args, **kwargs: "native libraries resolved")
for identifier in ("3" * 32, "4" * 32):
built_run(remote, manifest, identifier)
response = remote.finalize(identifier, repair=True)
assert response["previousSavePaths"] == [str(previous)]
assert (previous / "checkpoint").read_bytes() == b"incompatible previous checkpoint"
assert (remote.save_root() / "checkpoint").read_bytes() == b"played after the interrupted upgrade"
selected, _ = remote.uninstall_save_files(remote.uninstall_inventory(remote.GAME))
assert str(Path("profiles/custom/checkpoint")) in selected
assert str(Path("save-v1.4/checkpoint")) in selected
origin = remote.CACHE / "runs" / ("3" * 32) / "previous-program-files/upgrade-origin"
assert all(path.is_file() for path in origin.iterdir())
@pytest.mark.parametrize("damage", ["journal", "oversize-journal", "hardlinked-journal", "original-config",
"original-program", "previous-program", "missing-origin"])
def test_repeated_interruption_refuses_damaged_carried_evidence(remote, damage):
manifest, previous, _ = interrupted_native_upgrade(remote, save_path=remote.GAME / "profiles/custom")
directory = interrupt_upgrade_retry(remote, manifest)
backup = directory / "previous-program-files"
if damage in ("journal", "oversize-journal", "hardlinked-journal"):
journal = backup / ".upgrade-recovery.json"
if damage == "oversize-journal":
journal.write_text(" " * (remote.MAX_MANIFEST_BYTES + 1))
elif damage == "hardlinked-journal":
(backup / "journal-link").hardlink_to(journal)
else:
value = json.loads(journal.read_text())
value["previousFiles"]["halo"] = "0" * 64
journal.write_text(json.dumps(value))
elif damage == "missing-origin":
(backup / "upgrade-origin/config.toml").unlink()
else:
target = {"original-config": backup / "upgrade-origin/config.toml",
"original-program": backup / "upgrade-origin/halo", "previous-program": backup / "halo"}[damage]
with target.open("ab") as stream:
stream.write(b"tampered evidence")
for repair in (False, True):
with pytest.raises(ValueError, match="interrupted upgrade could not be verified"):
remote.existing_install(repair=repair)
assert (previous / "checkpoint").read_bytes() == b"incompatible previous checkpoint"
assert (remote.save_root() / "checkpoint").read_bytes() == b"played after the interrupted upgrade"
@pytest.mark.parametrize("failure", ["copy", "changed-source"])
def test_recovery_snapshot_failure_leaves_the_installed_game_untouched(remote, monkeypatch, failure):
manifest, _, first = interrupted_native_upgrade(remote, save_path=remote.GAME / "profiles/custom")
built_run(remote, manifest, "2" * 32)
before = {path: path.read_bytes() for path in remote.GAME.rglob("*") if path.is_file()}
original = remote.shutil.copy2
def fail_origin(source, target, *args, **kwargs):
if failure == "copy" and Path(target).parent.name == "upgrade-origin":
raise OSError("injected origin snapshot copy failure")
return original(source, target, *args, **kwargs)
def before_backup(*args, **kwargs):
if failure == "changed-source":
with (first / "previous-program-files/halo").open("ab") as stream:
stream.write(b"source changed after verification")
return "native libraries resolved"
monkeypatch.setattr(remote.shutil, "copy2", fail_origin)
monkeypatch.setattr(remote, "command", before_backup)
with pytest.raises((OSError, ValueError), match="origin snapshot copy failure|original upgrade backup changed"):
remote.finalize("2" * 32, repair=True)
assert {path: path.read_bytes() for path in before} == before
@pytest.mark.parametrize("repair", [False, True])
@pytest.mark.parametrize("kind", ["default", "custom-internal", "external"])
def test_real_interrupted_upgrade_recovers_hashes_and_original_save_locations(remote, monkeypatch, repair, kind):
path = {"default": remote.GAME / "save", "custom-internal": remote.GAME / "profiles/custom",
"external": remote.HOME / "external-checkpoints"}[kind]
manifest, previous, directory = interrupted_native_upgrade(remote, save_path=path)
saved = {previous / "checkpoint": b"incompatible previous checkpoint",
remote.save_root() / "checkpoint": b"played after the interrupted upgrade"}
signatures = {path: (path.read_bytes(), path.stat().st_mtime_ns) for path in saved}
verified = remote.existing_install(repair=repair)
assert verified["needsUpgrade"] and verified["mapsVerified"]
assert verified["previousSavePaths"] == [str(previous)]
# Install promotes the verified needsUpgrade response to repair, as the host does.
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: "native libraries resolved")
for identifier in ("2" * 32, "3" * 32):
built_run(remote, manifest, identifier)
response = remote.finalize(identifier, repair=True)
assert response["previousSavePaths"] == [str(previous)]
assert remote.read_marker(remote.GAME / remote.MARKER)["previousSavePaths"] == [str(previous)]
assert not remote.existing_install()["needsUpgrade"]
assert {path: (path.read_bytes(), path.stat().st_mtime_ns) for path in saved} == signatures
selected, external = remote.uninstall_save_files(remote.uninstall_inventory(remote.GAME))
assert str(Path("save-v1.4/checkpoint")) in selected
if kind == "external":
assert external == [str(previous)]
else:
assert str((previous / "checkpoint").relative_to(remote.GAME)) in selected
assert (directory / "previous-program-files/config.toml").exists()
@pytest.mark.parametrize("repair", [False, True])
@pytest.mark.parametrize("damage", ["missing-stage-marker", "unrelated-run", "old-marker", "old-program",
"live-program", "legacy-engine", "new-hash", "previous-paths", "original-config"])
def test_interrupted_upgrade_refuses_unrelated_or_tampered_evidence(remote, repair, damage):
_, previous, directory = interrupted_native_upgrade(remote, save_path=remote.GAME / "profiles/custom")
backup, stage = directory / "previous-program-files", directory / "stage"
if damage == "missing-stage-marker":
(stage / remote.MARKER).unlink()
elif damage in ("unrelated-run", "old-marker", "new-hash", "previous-paths"):
target = {"unrelated-run": directory / remote.MARKER, "old-marker": backup / remote.MARKER,
"new-hash": stage / remote.MARKER, "previous-paths": stage / remote.MARKER}[damage]
metadata = json.loads(target.read_text())
if damage in ("unrelated-run", "old-marker"):
metadata["sourceCommit"] = "unrelated-revision"
elif damage == "new-hash":
metadata["files"]["halo"] = "0" * 64
else:
metadata["previousSavePaths"] = [str(remote.GAME / "save")]
target.write_text(json.dumps(metadata))
elif damage == "original-config":
(backup / "config.toml").write_text('[paths]\nsaves = "save"\n')
elif damage == "legacy-engine":
(stage / "halo").write_bytes((remote.GAME / "halo").read_bytes())
(remote.GAME / "halo").write_bytes((backup / "halo").read_bytes())
else:
target = backup / "halo" if damage == "old-program" else remote.GAME / "halo"
with target.open("ab") as stream:
stream.write(b"tampered program")
before = {path: path.read_bytes() for path in (previous / "checkpoint", remote.save_root() / "checkpoint",
remote.GAME / "halo", remote.GAME / "config.toml", remote.GAME / remote.MARKER)}
with pytest.raises(ValueError, match="interrupted upgrade could not be verified.*previous-program-files"):
remote.existing_install(repair=repair)
assert {path: path.read_bytes() for path in before} == before
@pytest.mark.parametrize("kind", ["default", "custom-internal", "external"])
def test_verified_stable_upgrade_keeps_old_checkpoints_and_backs_up_config(remote, monkeypatch, kind):
path = {"default": remote.GAME / "save", "custom-internal": remote.GAME / "profiles/custom",
@@ -838,7 +1214,7 @@ def test_verified_stable_upgrade_keeps_old_checkpoints_and_backs_up_config(remot
assert settings["audio"]["volume"] == 0.3 and settings["vr"]["turn"] == "snap"
assert remote.save_root().is_dir() and not any(remote.save_root().iterdir())
marker = remote.read_marker(remote.GAME / remote.MARKER)
assert marker["installerVersion"] == "1.4.1" and marker["networkProtocol"] == 17
assert marker["installerVersion"] == "1.4.2" and marker["networkProtocol"] == 17
assert marker["saveRoot"] == str(remote.save_root()) and "experimental" not in marker
assert not remote.existing_install()["needsUpgrade"]
# A later Repair preserves both generations and the former custom location.
+379 -4
View File
@@ -4,7 +4,7 @@ from unittest.mock import Mock
import paramiko
import pytest
from halo_frame_installer.ssh import CancelledError, Settings, SSHConnection, SSHError, _VerifyHost, fingerprint, validate_host
from halo_frame_installer.ssh import CancelledError, RemoteTimeoutError, Settings, SSHConnection, SSHError, _VerifyHost, fingerprint, validate_host
@pytest.mark.parametrize("host", ["192.168.1.12", "frame.local", "frame", "::1"])
@@ -220,7 +220,9 @@ def test_uninstall_timeout_reports_uncertain_removal_and_closes_channel(monkeypa
client = Mock()
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
monkeypatch.setattr(ssh.time, 'monotonic', Mock(side_effect=[0, 601]))
clock = Mock(return_value=0)
monkeypatch.setattr(ssh.time, 'monotonic', clock)
channel.set_combine_stderr.side_effect = lambda value: setattr(clock, 'return_value', 601)
connection = SSHConnection(Settings('frame', 'private'))
with pytest.raises(SSHError, match='Removal may be incomplete') as error:
connection.run(['python3', 'helper.py', 'uninstall'], timeout=600,
@@ -277,7 +279,9 @@ def test_failed_cancel_notice_does_not_mask_the_timeout_error(monkeypatch):
client = Mock()
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
monkeypatch.setattr(ssh.time, 'monotonic', Mock(side_effect=[0, 601]))
clock = Mock(return_value=0)
monkeypatch.setattr(ssh.time, 'monotonic', clock)
channel.set_combine_stderr.side_effect = lambda value: setattr(clock, 'return_value', 601)
on_cancel = Mock(side_effect=paramiko.SSHException('SSH session not active'))
with pytest.raises(SSHError, match='timed out'):
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'], timeout=600, on_cancel=on_cancel)
@@ -332,7 +336,9 @@ def test_channel_close_failure_cannot_replace_the_timeout(monkeypatch):
client = Mock()
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
monkeypatch.setattr(ssh.time, 'monotonic', Mock(side_effect=[0, 601]))
clock = Mock(return_value=0)
monkeypatch.setattr(ssh.time, 'monotonic', clock)
channel.set_combine_stderr.side_effect = lambda value: setattr(clock, 'return_value', 601)
connection = SSHConnection(Settings('frame', 'private'))
with pytest.raises(ssh.RemoteTimeoutError, match='timed out'):
connection.run(['helper'], timeout=600)
@@ -384,3 +390,372 @@ def test_eof_connect_or_keepalive_failure_is_redacted_and_tears_down(monkeypatch
SSHConnection(Settings('frame', 'private')).connect()
assert 'private' not in str(error.value)
client.close.assert_called_once()
def test_silent_exec_handshake_is_bounded_by_a_watchdog(monkeypatch):
import time as time_module
release = threading.Event()
transport = Mock()
transport.close.side_effect = release.set
client = Mock()
client.get_transport.return_value = transport
def blocked_exec(command, timeout=None):
release.wait(60)
raise paramiko.SSHException('SSH session not active')
client.exec_command.side_effect = blocked_exec
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
connection = SSHConnection(Settings('frame', 'private'))
connection.handshake_grace = 0.05
started = time_module.monotonic()
with pytest.raises(RemoteTimeoutError, match='handshake timed out'):
connection.run(['python3', 'helper.py'], timeout=0.5)
assert time_module.monotonic() - started < 5 # unbounded waits defeat this without the watchdog
def test_newline_free_flood_keeps_only_the_recent_tail(monkeypatch):
from collections import deque
payload = b'A' * (2 * 1024 * 1024) + b'B\n'
packets = deque(payload[index:index + 65536] for index in range(0, len(payload), 65536))
channel = Mock()
channel.recv_ready.side_effect = lambda: bool(packets)
channel.recv.side_effect = lambda count: packets.popleft()
channel.exit_status_ready.side_effect = lambda: not packets
channel.recv_exit_status.return_value = 0
client = Mock()
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
lines = []
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'], progress=lines.append)
assert lines and len(lines[-1]) < 200 * 1024
def test_cancel_is_honored_inside_a_readable_channel(monkeypatch):
cancel = threading.Event()
calls = [0]
channel = Mock()
channel.recv_ready.return_value = True
channel.exit_status_ready.return_value = False
def recv(count):
calls[0] += 1
if calls[0] == 3:
cancel.set()
return b'y' * 65536
channel.recv.side_effect = recv
client = Mock()
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
with pytest.raises(CancelledError):
SSHConnection(Settings('frame', 'private')).run(['python3', 'helper.py'], cancel_event=cancel)
def assert_no_handshake_watchdog():
assert not any(thread.name == 'ssh-handshake-watchdog' for thread in threading.enumerate())
def test_healthy_upload_can_outlast_the_handshake_budget(monkeypatch):
import time
client, sftp, transport = Mock(), Mock(), Mock()
client.get_transport.return_value = transport
client.open_sftp.return_value = sftp
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
connection = SSHConnection(Settings('frame', 'private'))
connection.sftp_timeout = 0.02
connection.handshake_grace = 0.03
progress = Mock()
def transfer(local, remote, *, callback, confirm):
# Scale the handshake budget down while the healthy upload lasts four
# times longer. The upload must never inherit the handshake deadline.
assert_no_handshake_watchdog()
for count in range(4):
time.sleep(0.05)
callback(count + 1, 4)
transport.close.assert_not_called()
sftp.put.side_effect = transfer
connection.put('local.map', 'remote.map', callback=progress)
assert progress.call_count == 4
sftp.close.assert_called_once()
assert not connection._sftp_clients
assert_no_handshake_watchdog()
@pytest.mark.parametrize('operation', ['exec', 'sftp'])
@pytest.mark.parametrize('fail', [False, True])
def test_handshake_watchdog_retires_after_success_or_error(monkeypatch, operation, fail):
import time
client, channel, sftp = Mock(), Mock(), Mock()
channel.recv_ready.return_value = False
channel.exit_status_ready.return_value = True
channel.recv_exit_status.return_value = 0
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
client.open_sftp.return_value = sftp
target = client.exec_command if operation == 'exec' else client.open_sftp
if fail:
target.side_effect = EOFError('private')
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
connection = SSHConnection(Settings('frame', 'private'))
connection.sftp_timeout = 0.02
connection.handshake_grace = 0.03
cancel = threading.Event()
invoke = (lambda: connection.run(['helper'], timeout=0.2, cancel_event=cancel)) if operation == 'exec' else (
lambda: connection.put('local', 'remote', cancel_event=cancel))
if fail:
with pytest.raises(SSHError):
invoke()
else:
invoke()
assert_no_handshake_watchdog()
# A late cancel/deadline must not affect a following operation's transport.
cancel.set()
time.sleep(0.3)
client.get_transport.return_value.close.assert_not_called()
def test_cancel_recovery_waits_for_watchdog_and_uses_captured_transport(monkeypatch):
import time
client, original, replacement = Mock(), Mock(), Mock()
closing = threading.Event()
closed = threading.Event()
cancel = threading.Event()
client.get_transport.return_value = original
def close():
closing.set()
time.sleep(0.05)
closed.set()
def blocked_exec(*args, **kwargs):
cancel.set()
assert closing.wait(2)
client.get_transport.return_value = replacement
raise EOFError('private')
def recover():
assert closed.is_set()
assert_no_handshake_watchdog()
replacement.close.assert_not_called()
original.close.side_effect = close
client.exec_command.side_effect = blocked_exec
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
recovery = Mock(side_effect=recover)
with pytest.raises(CancelledError):
SSHConnection(Settings('frame', 'private')).run(['helper'], cancel_event=cancel, on_cancel=recovery)
recovery.assert_called_once()
original.close.assert_called_once()
replacement.close.assert_not_called()
def test_cancel_at_sftp_handshake_completion_never_starts_upload(monkeypatch):
client, sftp = Mock(), Mock()
cancel = threading.Event()
def open_sftp():
cancel.set()
return sftp
client.open_sftp.side_effect = open_sftp
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
connection = SSHConnection(Settings('frame', 'private'))
with pytest.raises(CancelledError):
connection.put('never-opened', 'never-written', cancel_event=cancel)
sftp.put.assert_not_called()
sftp.close.assert_called_once()
assert not connection._sftp_clients
assert_no_handshake_watchdog()
def test_continuous_output_still_honors_the_step_deadline(monkeypatch):
from halo_frame_installer import ssh
client, channel = Mock(), Mock()
clock = Mock(return_value=0)
channel.recv_ready.return_value = True
channel.exit_status_ready.return_value = False
def recv(count):
clock.return_value = 2
return b'still streaming without newlines'
channel.recv.side_effect = recv
client.exec_command.return_value = (Mock(), Mock(channel=channel), Mock())
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
monkeypatch.setattr(ssh.time, 'monotonic', clock)
recovery = Mock()
with pytest.raises(ssh.RemoteTimeoutError, match='custom step deadline'):
SSHConnection(Settings('frame', 'private')).run(
['helper'], timeout=1, timeout_message='custom step deadline', on_cancel=recovery)
recovery.assert_called_once()
channel.recv.assert_called_once()
channel.close.assert_called_once()
assert_no_handshake_watchdog()
@pytest.fixture
def silent_paramiko_session():
"""A local socketpair server that never runs commands or accesses files."""
import socket
sessions = []
def create(stage):
requested, release = threading.Event(), threading.Event()
class Server(paramiko.ServerInterface):
def check_auth_password(self, username, password):
return paramiko.AUTH_SUCCESSFUL
def check_channel_request(self, kind, chanid):
return paramiko.OPEN_SUCCEEDED
def check_channel_exec_request(self, channel, command):
requested.set()
release.wait(3)
return False
def check_channel_subsystem_request(self, channel, name):
requested.set()
if stage == 'subsystem':
release.wait(3)
return False
# Accept SFTP but send no version packet. This reproduces the
# additional read inside SFTPClient construction, after the
# subsystem request itself has already succeeded.
return True
left, right = socket.socketpair()
server_transport = paramiko.Transport(left)
client_transport = paramiko.Transport(right)
sessions.append((client_transport, server_transport, release))
server_transport.add_server_key(paramiko.RSAKey.generate(1024))
server_transport.start_server(event=threading.Event(), server=Server())
client_transport.start_client(timeout=2)
client_transport.auth_password('steamos', 'test-only')
connection = SSHConnection(Settings('frame', 'private'))
connection.client._transport = client_transport
return connection, requested
yield create
for client, server, release in sessions:
release.set()
client.close()
server.close()
client.join(timeout=2)
server.join(timeout=2)
@pytest.mark.parametrize('stage', ['exec', 'subsystem', 'sftp-version'])
@pytest.mark.parametrize('cancelled', [False, True])
def test_real_silent_handshake_stops_on_deadline_or_cancel(silent_paramiko_session, stage, cancelled):
import time
connection, requested = silent_paramiko_session(stage)
cancel = threading.Event()
connection.handshake_grace = 0
connection.sftp_timeout = 20 if cancelled else 0.1
notifier = None
if cancelled:
def press_cancel():
if requested.wait(2):
cancel.set()
notifier = threading.Thread(target=press_cancel, daemon=True)
notifier.start()
recovery = Mock(side_effect=assert_no_handshake_watchdog)
started = time.monotonic()
try:
expected = CancelledError if cancelled else RemoteTimeoutError if stage == 'exec' else SSHError
with pytest.raises(expected) as error:
if stage == 'exec':
connection.run(['never-executed'], cancel_event=cancel,
timeout=30 if cancelled else 0.1, on_cancel=recovery)
else:
connection.put('never-opened', 'never-written', cancel_event=cancel)
assert time.monotonic() - started < 1.5
assert requested.is_set()
assert not connection.is_active()
assert 'private' not in str(error.value)
if stage == 'exec':
recovery.assert_called_once()
else:
recovery.assert_not_called()
assert_no_handshake_watchdog()
finally:
if notifier is not None:
notifier.join(timeout=2)
connection.close()
@pytest.mark.parametrize('acknowledged', [False, True])
@pytest.mark.parametrize('recovery_fails', [False, True])
def test_exec_watchdog_timeout_preserves_uncertain_removal_after_retirement(monkeypatch, acknowledged, recovery_fails):
import time
original, replacement, client, channel = Mock(), Mock(), Mock(), Mock()
closing, closed = threading.Event(), threading.Event()
client.get_transport.return_value = original
def close():
closing.set()
time.sleep(0.025)
closed.set()
def stalled_exec(*args, **kwargs):
assert closing.wait(2)
client.get_transport.return_value = replacement
if acknowledged:
# An acknowledgement racing the deadline must not begin streaming
# from the closed transport or lose its timeout classification.
return Mock(), Mock(channel=channel), Mock()
raise EOFError('private')
def recover():
assert closed.is_set()
assert_no_handshake_watchdog()
replacement.close.assert_not_called()
if recovery_fails:
raise paramiko.SSHException('private recovery failure')
original.close.side_effect = close
client.exec_command.side_effect = stalled_exec
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
connection = SSHConnection(Settings('frame', 'private'))
connection.handshake_grace = 0
recovery = Mock(side_effect=recover)
message = 'Removal may be incomplete; check the saved backup and activity log before retrying.'
with pytest.raises(RemoteTimeoutError) as error:
connection.run(['python3', 'helper.py', 'uninstall'], timeout=0.05,
timeout_message=message, on_cancel=recovery)
assert str(error.value) == message and 'private' not in str(error.value)
assert error.value.__suppress_context__ and error.value.__cause__ is None
recovery.assert_called_once()
original.close.assert_called_once()
replacement.close.assert_not_called()
channel.recv.assert_not_called()
assert not connection._command_channels
assert_no_handshake_watchdog()
def test_cancel_at_watchdog_deadline_remains_cancellation_after_retirement(monkeypatch):
client, transport = Mock(), Mock()
release = threading.Event()
cancel = threading.Event()
client.get_transport.return_value = transport
def close():
cancel.set()
release.set()
def stalled_exec(*args, **kwargs):
assert release.wait(2)
raise EOFError('private')
transport.close.side_effect = close
client.exec_command.side_effect = stalled_exec
monkeypatch.setattr(paramiko, 'SSHClient', lambda: client)
connection = SSHConnection(Settings('frame', 'private'))
connection.handshake_grace = 0
recovery = Mock(side_effect=assert_no_handshake_watchdog)
with pytest.raises(CancelledError) as error:
connection.run(['helper'], timeout=0.05, cancel_event=cancel, on_cancel=recovery,
timeout_message='An expired deadline must not replace this cancellation.')
assert error.value.requires_attention is False
recovery.assert_called_once()
assert_no_handshake_watchdog()
+130 -8
View File
@@ -18,11 +18,20 @@ RESOURCE = Path(__file__).resolve().parents[1] / "resources/steam_live.py"
@pytest.fixture
def live(monkeypatch):
def live(monkeypatch, tmp_path):
storage_spec = importlib.util.spec_from_file_location("frame_storage", RESOURCE.parent / "frame_storage.py")
storage = importlib.util.module_from_spec(storage_spec)
monkeypatch.setitem(sys.modules, storage_spec.name, storage)
storage_spec.loader.exec_module(storage)
spec = importlib.util.spec_from_file_location("steam_live_test", RESOURCE)
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, spec.name, module)
spec.loader.exec_module(module)
home = tmp_path / "home"
game = home / "Games/HaloCENativeVR"
game.mkdir(parents=True)
monkeypatch.setattr(module, "HOME", home)
monkeypatch.setattr(module, "GAME", game)
return module
@@ -96,17 +105,17 @@ for (const key of input.missing || []) delete SteamClient.Apps[key];
"""
def run_client(live, operation="add", source=None, spec_values=None, **options):
def run_client(live, operation="add", source=None, spec_values=None, game=None, **options):
node = shutil.which("node")
if not node:
pytest.skip("Node is required to exercise the actual Steam API expression")
spec = live.native_spec(live.HOME, live.GAME, live.NAME)
spec = live.native_spec(live.HOME, live.GAME if game is None else game)
spec["operation"] = operation
spec.update(spec_values or {})
body = {"spec": spec, "probe": live.expression(live.PROBE, spec),
"expression": live.expression(source or live.LIBRARY, spec), **options}
result = subprocess.run([node, "-e", NODE_CLIENT], input=json.dumps(body),
text=True, capture_output=True, timeout=12, check=True)
text=True, encoding="utf-8", capture_output=True, timeout=12, check=True)
return json.loads(result.stdout)
@@ -303,6 +312,95 @@ def test_native_spec_rejects_unmanaged_paths(live):
live.native_spec(live.HOME, live.GAME / "../foreign", live.NAME)
@pytest.fixture
def sd_game(live, tmp_path, monkeypatch):
storage = sys.modules["frame_storage"]
internal = storage.discover_storage(live.HOME)["destinations"][0]
mount = tmp_path / "media" / "Halo SD Ω"
game = mount / "Games/HaloCENativeVR"
game.mkdir(parents=True)
descriptor = {"id": "sd:" + "a" * 32, "kind": "sd", "label": "Halo SD Ω",
"mountPath": str(mount), "gamePath": str(game),
"cachePath": str(mount / ".cache/halo-frame-installer"),
"freeBytes": 32 * 1024 ** 3, "installed": False}
monkeypatch.setattr(storage, "discover_storage", lambda home=live.HOME: {
"home": str(home), "destinations": [internal, descriptor]})
return game
def test_native_spec_accepts_only_discovered_sd_fixed_game_path(live, sd_game):
spec = live.native_spec(live.HOME, sd_game)
assert spec["exe"] == str(sd_game / "halo") and spec["directory"] == str(sd_game)
assert spec["name"] == live.SD_NAME
assert spec["candidateAppids"] != live.native_spec(live.HOME, live.GAME, live.NAME)["candidateAppids"]
for foreign in (sd_game.parent / "Foreign", sd_game.parent.parent / "halo", sd_game / "../foreign"):
with pytest.raises(live.LiveSteamError, match="restricted"):
live.native_spec(live.HOME, foreign)
@pytest.mark.parametrize("field", ["home", "name", "launch_options"])
def test_sd_spec_retains_fixed_home_title_and_launch_options(live, sd_game, field):
arguments = {"home": live.HOME, "game": sd_game, "name": live.SD_NAME,
"launch_options": live.LAUNCH_OPTIONS}
arguments[field] = live.HOME.parent if field == "home" else "foreign"
with pytest.raises(live.LiveSteamError, match="restricted"):
live.native_spec(**arguments)
with pytest.raises(live.LiveSteamError, match="restricted"):
live.native_spec(live.HOME, sd_game, live.NAME)
def test_sd_registration_uses_exact_quoted_executable_and_working_directory(live, sd_game):
result = run_client(live, game=sd_game)
assert result["result"]["verified"]["exe"] == str(sd_game / "halo")
assert result["result"]["verified"]["directory"] == str(sd_game)
add = next(call for call in result["calls"] if call[0] == "AddShortcut")
assert add == ["AddShortcut", live.SD_NAME, str(sd_game / "halo"), "", '"' + str(sd_game / "halo") + '"']
assert [call for call in result["calls"] if call[0] == "SetShortcutStartDir"] == [
["SetShortcutStartDir", result["result"]["appid"], str(sd_game)]]
assert result["result"]["verified"]["vr"] is True
assert result["result"]["verified"]["compatibilityTool"] == ""
def test_sd_registration_coexists_with_existing_internal_entry(live, sd_game):
result = run_client(live, game=sd_game, entries=[entry(live)], returnId=3000000002)
assert result["result"]["created"] is True
assert result["entries"][0]["display_name"] == live.NAME
assert result["entries"][1]["display_name"] == live.SD_NAME
assert result["data"][0]["strShortcutExe"] == str(live.GAME / "halo")
assert not any(call[0].startswith("Set") and call[1] == 3000000001 for call in result["calls"])
def test_sd_registration_refuses_foreign_entry_with_sd_title(live, sd_game):
result = run_client(live, game=sd_game, entries=[entry(live, name=live.SD_NAME, exe="/foreign/game")])
assert "different game" in result["error"]
assert all(call[0] in ("RegisterForAppDetails", "unregister") for call in result["calls"])
def test_sd_repair_keeps_existing_sd_appid_and_internal_shortcut(live, sd_game):
result = run_client(live, game=sd_game, entries=[entry(live),
entry(live, appid=3000000002, name="My SD Halo", exe=str(sd_game / "halo"))])
assert result["result"]["appid"] == 3000000002 and not result["result"]["created"]
assert not any(call[0] == "AddShortcut" for call in result["calls"])
assert result["entries"][0]["display_name"] == live.NAME
assert result["entries"][1]["display_name"] == live.SD_NAME
def test_sd_remove_keeps_internal_copy_and_only_removes_exact_selected_executable(live, sd_game):
result = run_client(live, "remove", game=sd_game, entries=[
entry(live), entry(live, appid=3000000002, name="My SD Halo", exe=str(sd_game / "halo"))])
assert result["result"] == {"ok": True, "status": "removed", "appid": 3000000002}
assert [item["appid"] for item in result["entries"]] == [3000000001]
assert [call for call in result["calls"] if call[0] == "RemoveShortcut"] == [["RemoveShortcut", 3000000002]]
def test_missing_sd_is_rejected_before_any_live_client_access(live, sd_game, monkeypatch):
monkeypatch.setattr(sys.modules["frame_storage"], "discover_storage", lambda home=live.HOME: {
"home": str(home), "destinations": []})
monkeypatch.setattr(live, "Client", lambda: pytest.fail("Missing SD must not open Steam's client"))
with pytest.raises(live.LiveSteamError, match="restricted"):
live.add_native(live.HOME, sd_game)
@pytest.fixture
def artwork_client(live, tmp_path, monkeypatch):
spec = importlib.util.spec_from_file_location("steam_shortcut", RESOURCE.parent / "steam_shortcut.py")
@@ -324,7 +422,7 @@ def artwork_client(live, tmp_path, monkeypatch):
login.write_text('"users" { "76561197960265738" { "AccountName" "active-user" "MostRecent" "0" } '
'"76561197960265748" { "AccountName" "another-user" "MostRecent" "1" } }')
game = live.GAME
game.mkdir(parents=True)
game.mkdir(parents=True, exist_ok=True)
program = bytearray(64)
program[:6] = b"\x7fELF\x02\x01"
program[18:20] = (183).to_bytes(2, "little")
@@ -366,7 +464,10 @@ def artwork_client(live, tmp_path, monkeypatch):
if self.fail_write:
raise live.LiveSteamError("Steam shortcut icon API did not finish")
record = shortcut.loads(persisted.read_bytes())
record["shortcuts"].value["0"].value["icon"].value = "/custom/concurrent.png" if self.corrupt_write else request["iconPath"]
matching = [item.value for item in record["shortcuts"].value.values()
if item.value["appid"].value == request["appid"]]
assert len(matching) == 1
matching[0]["icon"].value = "/custom/concurrent.png" if self.corrupt_write else request["iconPath"]
persisted.write_bytes(shortcut.dumps(record)) # Simulate Steam's own path setter.
if self.switch_after_write:
self.account = "another-user"
@@ -409,6 +510,27 @@ def test_live_artwork_uses_api_types_and_active_account_not_most_recent(live, ar
assert login.read_text().count('"MostRecent" "1"') == 1
def test_sd_live_artwork_uses_home_account_grid_and_sd_icon_sidecar(live, artwork_client, sd_game):
home, internal_game, grid, foreign, login, client, shortcut = artwork_client
assert shortcut.SD_NAME == live.SD_NAME
for filename in ("halo", ".halo-frame-installer.json"):
(sd_game / filename).write_bytes((internal_game / filename).read_bytes())
persisted = grid.parent / "shortcuts.vdf"
before = persisted.read_bytes()
updated, appid = shortcut.update_shortcut(before, str(sd_game / "halo"), str(sd_game), live.SD_NAME)
persisted.write_bytes(updated)
result = live._live_artwork(home, sd_game, appid)
icon = live._live_icon(home, sd_game, appid)
assert result["status"] == icon["status"] == "added"
assert icon["path"] == str(sd_game / ".installer-artwork" / shortcut.ICON_NAME)
assert all(item["name"] == live.SD_NAME and item["exe"] == str(sd_game / "halo") for item in client.writes)
assert all((grid / (str(appid) + suffix + extension)).is_file()
for suffix, extension, _, _ in shortcut.ARTWORK)
assert shortcut.loads(persisted.read_bytes())["shortcuts"].value["0"] == shortcut.loads(before)["shortcuts"].value["0"]
assert list(foreign.iterdir()) == [foreign / "unrelated.png"]
assert not (sd_game / ".local").exists() and not (sd_game.parent.parent / ".local").exists()
def test_live_artwork_preserves_custom_extensions_and_reuses_managed_hash(live, artwork_client):
home, game, grid, foreign, login, client, shortcut = artwork_client
(grid / "3000000001p.webp").write_bytes(b"custom portrait")
@@ -461,7 +583,7 @@ def test_live_artwork_source_integrity_is_checked_before_any_client_request(live
def test_artwork_failure_keeps_verified_library_result_and_reports_manual(live, artwork_client, monkeypatch):
home, game, grid, foreign, login, client, shortcut = artwork_client
monkeypatch.setattr(live, "_library", lambda *args: {"appid": 3000000001, "created": False})
monkeypatch.setattr(live, "_library", lambda *args: {"appid": 3000000001, "created": False, "name": live.NAME})
client.fail_write = True
result = live.add_native(home, game)
assert result["status"] == "added" and result["libraryAdded"] is True
@@ -580,7 +702,7 @@ def test_live_icon_unverified_client_selection_is_kept_and_reported_manual(live,
def test_unpersisted_icon_record_does_not_block_library_or_banner_completion(live, artwork_client, monkeypatch):
home, game, grid, foreign, login, client, shortcut = artwork_client
(grid.parent / "shortcuts.vdf").unlink()
monkeypatch.setattr(live, "_library", lambda *args: {"appid": 3000000001, "created": False})
monkeypatch.setattr(live, "_library", lambda *args: {"appid": 3000000001, "created": False, "name": live.NAME})
result = live.add_native(home, game)
assert result["status"] == "added" and result["libraryAdded"] is True
assert result["artwork"]["status"] == "added"
+82 -7
View File
@@ -13,12 +13,18 @@ RESOURCES = Path(__file__).resolve().parents[1] / "resources"
@pytest.fixture
def notes(monkeypatch):
for name in ("steam_live", "steam_notes"):
def notes(monkeypatch, tmp_path):
for name in ("frame_storage", "steam_live", "steam_notes"):
spec = importlib.util.spec_from_file_location(name, RESOURCES / (name + ".py"))
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, name, module)
spec.loader.exec_module(module)
if name == "steam_live":
home = tmp_path / "home"
game = home / "Games/HaloCENativeVR"
game.mkdir(parents=True)
monkeypatch.setattr(module, "HOME", home)
monkeypatch.setattr(module, "GAME", game)
return sys.modules["steam_notes"]
@@ -37,7 +43,7 @@ function call(method,...args) {
}
globalThis.SteamClient={Apps:{RegisterForAppDetails:(id,callback)=>{
call('RegisterForAppDetails',id);
callback({unAppID:input.wrongId?id+1:id,strShortcutExe:input.foreignExe?'/foreign/game':spec.exe});
callback({unAppID:input.wrongId?id+1:id,strShortcutExe:input.foreignExe?'/foreign/game':(input.actualExe ?? spec.exe)});
return {unregister:()=>call('unregister')};
}},GameNotes:{
SyncToClient:async()=>{call('SyncToClient');return input.syncClientResult ?? 1;},
@@ -65,15 +71,15 @@ if (input.missingMethod) delete SteamClient.GameNotes[input.missingMethod];
"""
def run_notes(notes, **options):
def run_notes(notes, game=None, **options):
node = shutil.which("node")
if not node:
pytest.skip("Node is required to exercise the real Steam Notes expression")
spec = notes.native_spec(notes.HOME, notes.GAME, notes.NAME)
spec = notes.native_spec(notes.HOME, notes.GAME if game is None else game)
spec.update({"appid": 3000000001, "noteId": notes.NOTE_ID,
"title": notes.NOTE_TITLE, "content": notes.NOTE_CONTENT,
"title": notes.NOTE_TITLE, "content": notes._note_content(spec["name"]),
"maxBytes": notes.MAX_DOCUMENT_BYTES, "maxNotes": notes.MAX_NOTES,
"previousContents": list(notes.PREVIOUS_NOTE_CONTENTS)})
"previousContents": list(notes.PREVIOUS_NOTE_CONTENTS) if spec["name"] == notes.NAME else []})
spec.update(options.pop("spec_overrides", {}))
body = {"spec": spec, "expression": notes.expression(notes.NOTES, spec), **options}
output = subprocess.run([node, "-e", NODE_CLIENT], input=json.dumps(body),
@@ -95,6 +101,75 @@ def methods(result):
return [call[0] for call in result["calls"]]
@pytest.fixture
def sd_game(notes, tmp_path, monkeypatch):
storage = sys.modules["frame_storage"]
internal = storage.discover_storage(notes.HOME)["destinations"][0]
mount = tmp_path / "media" / "Halo SD Ω"
game = mount / "Games/HaloCENativeVR"
game.mkdir(parents=True)
descriptor = {"id": "sd:" + "b" * 32, "kind": "sd", "label": "Halo SD Ω",
"mountPath": str(mount), "gamePath": str(game),
"cachePath": str(mount / ".cache/halo-frame-installer"),
"freeBytes": 32 * 1024 ** 3, "installed": False}
monkeypatch.setattr(storage, "discover_storage", lambda home=notes.HOME: {
"home": str(home), "destinations": [internal, descriptor]})
return game
def test_sd_notes_use_distinct_document_name_and_destination_content(notes, sd_game):
result = run_notes(notes, game=sd_game)
assert result["result"]["status"] == "added"
expected = "notes_shortcut_Halo__Combat_Evolved_VR__Native__SD_card_"
reads = [call for call in result["calls"] if call[0] == "GetNotes"]
assert reads and all(call[1:] == [expected, expected + "_images/"] for call in reads)
assert result["document"]["shortcut_name"] == notes.SD_NAME
content = result["document"]["notes"][0]["content"]
assert notes.SD_NAME in content and "SD-card installation stays separate" in content
assert "Keep this SD" in content and "Internal game files are retained" in content
def test_sd_notes_reject_internal_executable_readback_without_writes(notes, sd_game):
result = run_notes(notes, game=sd_game, actualExe=str(notes.GAME / "halo"))
assert result["result"]["status"] == "manual" and "SaveNotes" not in methods(result)
def test_sd_notes_reject_internal_document_instead_of_upgrading_it(notes, sd_game):
original = document(notes, [managed(notes)])
result = run_notes(notes, game=sd_game, document=original)
assert result["result"]["status"] == "manual" and "SaveNotes" not in methods(result)
assert result["document"] == original
def test_sd_notes_wrapper_delegates_exact_sd_identity(notes, sd_game, monkeypatch):
requests = []
class Client:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def evaluate(self, expression):
requests.append(json.loads(expression.removeprefix("(() => {const S = ").split(";\nreturn ", 1)[0]))
return {"ok": True, "status": "added"}
monkeypatch.setattr(notes, "Client", Client)
result = notes.add_notes(notes.HOME, sd_game, 3000000002)
assert result["ok"] and requests[0]["name"] == notes.SD_NAME
assert requests[0]["exe"] == str(sd_game / "halo")
assert requests[0]["previousContents"] == []
assert result["content"] == requests[0]["content"] == notes._note_content(notes.SD_NAME)
def test_removed_sd_does_not_open_notes_client(notes, sd_game, monkeypatch):
monkeypatch.setattr(sys.modules["frame_storage"], "discover_storage", lambda home=notes.HOME: {
"home": str(home), "destinations": []})
monkeypatch.setattr(notes, "Client", lambda: pytest.fail("Missing SD must not open Steam Notes"))
assert notes.add_notes(notes.HOME, sd_game, 3000000002)["status"] == "manual"
def test_missing_file_adds_verified_shortcut_note_with_native_filename(notes):
result = run_notes(notes)
assert result["result"]["status"] == "added"
+4
View File
@@ -16,6 +16,10 @@ RESOURCES = Path(__file__).resolve().parents[1] / "resources"
@pytest.fixture
def steam(monkeypatch):
storage_spec = importlib.util.spec_from_file_location("frame_storage", RESOURCES / "frame_storage.py")
storage = importlib.util.module_from_spec(storage_spec)
monkeypatch.setitem(sys.modules, storage_spec.name, storage)
storage_spec.loader.exec_module(storage)
spec = importlib.util.spec_from_file_location("steam_session_safety", RESOURCES / "steam_shortcut.py")
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, spec.name, module)
+568
View File
@@ -0,0 +1,568 @@
"""Synthetic mounted-card identities; no device, game disc or SSH is used."""
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import struct
import subprocess
import sys
import types
import pytest
RESOURCES = Path(__file__).resolve().parents[1] / "resources"
def resource(name):
spec = importlib.util.spec_from_file_location(name, RESOURCES / (name + ".py"))
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
@pytest.fixture
def storage(tmp_path, monkeypatch):
module = resource("frame_storage")
monkeypatch.setitem(sys.modules, "frame_storage", module)
home, card = tmp_path / "home", tmp_path / "card"
home.mkdir()
card.mkdir()
module.TEST_HOME, module.TEST_CARD = home, card
if not hasattr(os, "getuid"):
monkeypatch.setattr(os, "getuid", lambda: home.stat().st_uid, raising=False)
return module
def card_descriptor(storage, *, mount_id=10, device="179:1", cid="a" * 64):
root = storage.TEST_CARD
identity = {"kind": "sd", "mountPath": str(root), "mountId": mount_id, "device": device,
"filesystem": "ext4", "rootDevice": root.stat().st_dev, "rootInode": root.stat().st_ino,
"cardIdentity": cid, "filesystemUuid": "filesystem-123", "blockDevice": "/dev/mmcblk0p1"}
ownership = storage.ownership_identity(identity)
identifier = "sd:" + hashlib.sha256(json.dumps(ownership, sort_keys=True).encode()).hexdigest()[:32]
return storage._descriptor(identifier, "sd", root, identity, storage.TEST_HOME)
@pytest.fixture
def sd_remote(storage, monkeypatch):
if "pwd" not in sys.modules:
monkeypatch.setitem(sys.modules, "pwd", types.SimpleNamespace(getpwuid=lambda uid: types.SimpleNamespace(pw_name="steamos")))
spec = importlib.util.spec_from_file_location("storage_remote_test", RESOURCES / "remote_install.py")
remote = importlib.util.module_from_spec(spec)
spec.loader.exec_module(remote)
remote.HOME = storage.TEST_HOME
state = {"card": card_descriptor(storage)}
internal = storage._descriptor("internal", "internal", remote.HOME, {"kind": "internal", "root": str(remote.HOME)}, remote.HOME)
monkeypatch.setattr(storage, "discover_storage", lambda home=remote.HOME: {"home": str(home), "destinations": [internal, state["card"]]})
remote.select_storage(state["card"]["id"])
remote.owned(remote.CACHE)
(remote.CACHE / "runs").mkdir()
monkeypatch.setattr(remote, "game_closed", lambda *args: None)
monkeypatch.setattr(remote, "uninstall_mounts", lambda: [])
return remote, state
def test_internal_destination_and_exact_game_path(storage):
inventory = storage.discover_storage(storage.TEST_HOME)
internal = inventory["destinations"][0]
assert internal["id"] == "internal"
assert internal["gamePath"] == str(storage.TEST_HOME / "Games/HaloCENativeVR")
validated = storage.validate_game_path(storage.TEST_HOME, Path(internal["gamePath"]))
assert validated["id"] == internal["id"]
assert validated["gamePath"] == internal["gamePath"]
assert validated["ownershipIdentity"] == internal["ownershipIdentity"]
for invalid in (storage.TEST_HOME / "Games/AnotherGame", storage.TEST_HOME / "Games/HaloCENativeVR/../Other", storage.TEST_HOME):
with pytest.raises(ValueError):
storage.validate_game_path(storage.TEST_HOME, invalid)
def test_sd_validation_uses_only_current_discovered_fixed_leaf(storage, monkeypatch):
descriptor = card_descriptor(storage)
monkeypatch.setattr(storage, "discover_storage", lambda home: {"destinations": [descriptor]})
assert storage.validate_game_path(storage.TEST_HOME, Path(descriptor["gamePath"])) == descriptor
with pytest.raises(ValueError):
storage.validate_game_path(storage.TEST_HOME, storage.TEST_CARD / "Games/Other")
with pytest.raises(ValueError):
storage.resolve_storage("sd:" + "f" * 32, storage.TEST_HOME)
with pytest.raises(ValueError):
storage.resolve_storage(str(storage.TEST_CARD), storage.TEST_HOME)
@pytest.mark.parametrize("name", ["bad$label", "bad`label", 'bad"label', "bad'label", "bad\\label", "bad:label", "bad\nlabel", "bad;label"])
def test_unsafe_mount_labels_are_rejected(storage, name):
assert not storage._mount_path_allowed(Path("/run/media/steamos") / name, Path("/home/steamos"))
def test_spaces_unicode_and_legacy_mount_layout_are_supported(storage):
assert storage._mount_path_allowed(Path("/run/media/steamos/My SD_日本"), Path("/home/steamos"))
assert storage._mount_path_allowed(Path("/run/media/mmcblk0p1"), Path("/home/steamos"))
assert not storage._mount_path_allowed(Path("/tmp/card"), Path("/home/steamos"))
def mounted_record(storage):
return {"mountId": 20, "device": "0:0", "root": "/", "path": storage.TEST_CARD,
"options": {"rw"}, "superOptions": {"rw"}, "filesystem": "ext4", "source": "/dev/mmcblk0p1"}
def discovery_fixture(storage, monkeypatch, records):
monkeypatch.setattr(storage.platform, "system", lambda: "Linux")
monkeypatch.setattr(storage, "_mount_records", lambda: records)
monkeypatch.setattr(storage, "_mount_path_allowed", lambda path, home: path == storage.TEST_CARD)
monkeypatch.setattr(storage.os, "major", lambda dev: 0, raising=False)
monkeypatch.setattr(storage.os, "minor", lambda dev: 0, raising=False)
monkeypatch.setattr(storage, "_sd_identity", lambda record: {"cardIdentity": "a" * 64, "filesystemUuid": "uuid-123", "blockDevice": "/dev/mmcblk0p1"})
def test_discovery_card_id_survives_remount_but_live_identity_changes(storage, monkeypatch):
record = mounted_record(storage)
discovery_fixture(storage, monkeypatch, [record])
first = storage.discover_storage(storage.TEST_HOME)["destinations"][1]
record["mountId"] = 99
second = storage.discover_storage(storage.TEST_HOME)["destinations"][1]
assert first["id"] == second["id"]
assert first["ownershipIdentity"] == second["ownershipIdentity"]
assert first["identity"] != second["identity"]
@pytest.mark.parametrize("problem", ["read-only", "noexec", "unsupported-fs", "bind-root", "descendant-bind", "duplicate-device", "wrong-card"])
def test_ineligible_mounts_are_not_destinations(storage, monkeypatch, problem):
record = mounted_record(storage)
records = [record]
if problem == "read-only":
record["options"] = {"ro"}
elif problem == "noexec":
record["options"].add("noexec")
elif problem == "unsupported-fs":
record["filesystem"] = "exfat"
elif problem == "bind-root":
record["root"] = "/subdirectory"
elif problem == "descendant-bind":
records.append({**record, "path": storage.TEST_CARD / "Games", "device": "1:1"})
elif problem == "duplicate-device":
records.append({**record, "path": storage.TEST_HOME / "other-mount"})
discovery_fixture(storage, monkeypatch, records)
if problem == "wrong-card":
def not_sd(record):
raise ValueError("Kernel card type is MMC, not SD")
monkeypatch.setattr(storage, "_sd_identity", not_sd)
inventory = storage.discover_storage(storage.TEST_HOME)
assert len(inventory["destinations"]) == 1
assert inventory["unavailable"]
def test_unrecognized_sd_mount_layout_is_reported_without_authorizing_it(storage, monkeypatch):
record = mounted_record(storage)
discovery_fixture(storage, monkeypatch, [record])
monkeypatch.setattr(storage, "_mount_path_allowed", lambda path, home: False)
inventory = storage.discover_storage(storage.TEST_HOME)
assert len(inventory["destinations"]) == 1
assert inventory["unavailable"][0]["mountPath"] == str(storage.TEST_CARD)
assert "mount locations" in inventory["unavailable"][0]["reason"]
def test_discovery_cli_reports_wrong_system_without_traceback():
result = subprocess.run([sys.executable, str(RESOURCES / "frame_storage.py")],
text=True, capture_output=True, timeout=10)
# Unit/CI runners are never the Frame's privileged hardware fixture.
assert result.returncode == 1
assert result.stdout.startswith("HFI_ERROR ")
assert "Traceback" not in result.stdout + result.stderr
def test_sd_cache_and_game_use_selected_filesystem_without_internal_migration(sd_remote):
remote, _ = sd_remote
internal = remote.HOME / "Games/HaloCENativeVR"
internal.mkdir(parents=True)
(internal / "save.txt").write_bytes(b"internal progress")
response = remote.prepare("a" * 32)
assert remote.GAME == remote.storage_root() / "Games/HaloCENativeVR"
assert remote.CACHE == remote.storage_root() / ".halo-frame-installer"
assert Path(response["uploadPath"]).is_relative_to(remote.CACHE)
assert (internal / "save.txt").read_bytes() == b"internal progress"
assert not remote.GAME.exists()
def test_same_card_after_reboot_reuses_cache_but_old_active_run_is_refused(sd_remote):
remote, state = sd_remote
identifier = "b" * 32
remote.prepare(identifier)
previous = remote.read_marker(remote.run_dir(identifier) / remote.MARKER)
state["card"] = card_descriptor(remote.storage_module(), mount_id=90, device="179:33")
remote.select_storage(state["card"]["id"])
remote.owned(remote.CACHE)
remote.check_storage_record(previous)
with pytest.raises(ValueError, match="earlier SD mount"):
remote.build(identifier)
with pytest.raises(ValueError, match="earlier SD mount"):
remote.prepare(identifier)
fresh = remote.prepare("c" * 32)
assert Path(fresh["uploadPath"]).is_relative_to(remote.CACHE)
def test_free_space_and_installed_status_are_not_ownership_identity(sd_remote):
remote, state = sd_remote
before = remote.read_marker(remote.CACHE / remote.MARKER)
state["card"]["freeBytes"] = 1
state["card"]["installed"] = True
remote.select_storage(state["card"]["id"])
remote.check_storage_record(before)
def test_different_card_cannot_adopt_copied_cache_or_operation_journal(sd_remote):
remote, state = sd_remote
remote.prepare("d" * 32)
old = remote.read_marker(remote.run_dir("d" * 32) / remote.MARKER)
state["card"] = card_descriptor(remote.storage_module(), cid="f" * 64)
remote.select_storage(state["card"]["id"])
with pytest.raises(ValueError, match="another storage"):
remote.owned(remote.CACHE)
with pytest.raises(ValueError, match="another storage"):
remote.check_storage_record(old)
def test_active_card_remount_is_detected_before_more_file_operations(sd_remote):
remote, state = sd_remote
state["card"] = card_descriptor(remote.storage_module(), mount_id=100)
with pytest.raises(ValueError, match="SD card changed"):
remote.assert_storage_active(force=True)
def test_uninstall_sd_preserves_saves_and_other_internal_and_card_games(sd_remote, monkeypatch):
remote, _ = sd_remote
remote.owned(remote.GAME)
(remote.GAME / "halo").write_bytes(b"native program")
(remote.GAME / "save-v1.4").mkdir()
(remote.GAME / "save-v1.4/checkpoint").write_bytes(b"my campaign")
other = remote.GAME.parent / "OtherGame"
other.mkdir()
(other / "keep").write_bytes(b"another game")
internal = remote.HOME / "Games/HaloCENativeVR"
internal.mkdir(parents=True)
(internal / "keep").write_bytes(b"internal game")
monkeypatch.setattr(remote, "preflight_uninstall", lambda: {})
monkeypatch.setattr(remote, "no_active_build", lambda: None)
monkeypatch.setitem(sys.modules, "steam_shortcut", types.SimpleNamespace(remove_native_shortcut=lambda *args, **kwargs: {"status": "removed"}))
def rename(source, target):
assert not target.exists()
source.rename(target)
monkeypatch.setattr(remote, "rename_noreplace", rename)
result = remote.uninstall("e" * 32, keep_saves=True)
assert result["uninstalled"] and not remote.GAME.exists()
assert (Path(result["savedBackupPath"]) / "save-v1.4/checkpoint").read_bytes() == b"my campaign"
assert (other / "keep").read_bytes() == b"another game"
assert (internal / "keep").read_bytes() == b"internal game"
def test_same_card_recovery_keeps_inode_guard_across_new_device_number(sd_remote):
remote, _ = sd_remote
remote.GAME.mkdir(parents=True)
expected = remote.uninstall_root_identity(remote.GAME)
expected["device"] += 1
with pytest.raises(ValueError, match="another storage"):
remote.same_uninstall_root(expected, remote.GAME)
remote.same_uninstall_root(expected, remote.GAME, remote.storage_fields())
expected["inode"] += 1
with pytest.raises(ValueError, match="identity"):
remote.same_uninstall_root(expected, remote.GAME, remote.storage_fields())
def native_build_fixture(remote, identifier, payload=b"fixture executable"):
directory = remote.run_dir(identifier)
build = directory / "src/build/linux_arm64"
build.mkdir(parents=True)
header = bytearray(64)
header[:6] = b"\x7fELF\x02\x01"
struct.pack_into("<H", header, 18, 183)
(build / "halo").write_bytes(header + payload)
(build / "libSDL3.so.0").write_bytes(header)
(build / "brokers.txt").write_text("broker.example.org:1883\n")
resources = remote.CACHE / "resources"
resources.mkdir(exist_ok=True)
(resources / "frame-controls.patch").write_bytes(b"verified controls")
return directory
def maps_fixture(remote, directory):
header = bytearray(2048)
header[:4], header[-4:] = b"daeh", b"toof"
struct.pack_into("<II", header, 4, 5, 2048)
header[64:77] = b"01.10.12.2276"
files = []
for relative in sorted(remote.EXPECTED_MAPS):
path = directory / relative
path.write_bytes(header)
files.append({"path": relative, "size": len(header), "sha256": remote.digest(path)})
manifest = {"files": files, "totalBytes": len(header) * len(files)}
(directory / "xbox-data-manifest.json").write_text(json.dumps(manifest))
return manifest
def test_sd_install_then_repair_after_reboot_preserves_saves_and_internal_copy(sd_remote, monkeypatch):
remote, state = sd_remote
internal = remote.HOME / "Games/HaloCENativeVR"
internal.mkdir(parents=True)
(internal / "keep").write_bytes(b"internal game")
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: "libraries resolved")
def publish(source, destination):
assert source.is_relative_to(remote.CACHE) and destination == remote.GAME
assert source.stat().st_dev == destination.parent.stat().st_dev
assert not destination.exists()
source.rename(destination)
monkeypatch.setattr(remote, "rename_noreplace", publish)
first = "1" * 32
remote.prepare(first)
directory = native_build_fixture(remote, first)
manifest = maps_fixture(remote, directory / "upload")
assert remote.finalize(first)["gamePath"] == str(remote.GAME)
assert remote.verify_maps(remote.GAME, manifest) == manifest["totalBytes"]
(remote.save_root() / "checkpoint").write_bytes(b"campaign progress")
config = remote.GAME / "config.toml"
config.write_text(config.read_text().replace("resolution_scale = 1.0", "resolution_scale = 0.65"))
old_program = (remote.GAME / "halo").read_bytes()
state["card"] = card_descriptor(remote.storage_module(), mount_id=88, device="179:33")
remote.select_storage(state["card"]["id"])
second = "2" * 32
remote.prepare(second, use_existing_maps=True, repair=True)
native_build_fixture(remote, second, b"repaired executable")
response = remote.finalize(second, repair=True)
assert response["repaired"] and not response["mapsReinstalled"]
assert (remote.GAME / "halo").read_bytes() != old_program
assert remote.verify_maps(remote.GAME, manifest) == manifest["totalBytes"]
assert (remote.save_root() / "checkpoint").read_bytes() == b"campaign progress"
assert "resolution_scale = 0.65" in config.read_text()
assert (internal / "keep").read_bytes() == b"internal game"
metadata = remote.read_marker(remote.GAME / remote.MARKER)
assert metadata["storageIdentity"] == state["card"]["identity"]
def test_interrupted_sd_uninstall_recovers_after_same_card_remount(sd_remote, monkeypatch):
remote, state = sd_remote
remote.owned(remote.GAME)
(remote.GAME / "halo").write_bytes(b"native program")
monkeypatch.setattr(remote, "preflight_uninstall", lambda: {})
monkeypatch.setattr(remote, "no_active_build", lambda: None)
monkeypatch.setitem(sys.modules, "steam_shortcut", types.SimpleNamespace(remove_native_shortcut=lambda *args, **kwargs: {"status": "removed"}))
monkeypatch.setattr(remote, "rename_noreplace", lambda source, target: source.rename(target))
remove = remote.remove_uninstall_tree
def interrupted(directory, inventory, completed, total):
(directory / "halo").unlink()
completed[0] += 1
raise RuntimeError("simulated device shutdown")
monkeypatch.setattr(remote, "remove_uninstall_tree", interrupted)
identifier = "3" * 32
with pytest.raises(RuntimeError, match="Remaining native files"):
remote.uninstall(identifier)
directory = remote.run_dir(identifier)
journal = remote.read_marker(directory / remote.MARKER)
quarantine = Path(journal["quarantinePath"])
assert quarantine.exists() and not remote.GAME.exists()
# The real filesystem is unchanged in this fixture; record the old kernel
# device number to exercise reboot recovery without mounting any device.
journal["quarantineRoot"]["device"] += 100
(directory / remote.MARKER).write_text(json.dumps(journal))
state["card"] = card_descriptor(remote.storage_module(), mount_id=101, device="179:33")
remote.select_storage(state["card"]["id"])
monkeypatch.setattr(remote, "remove_uninstall_tree", remove)
recovery = remote.recover_interrupted_uninstall()
assert recovery["removed"] == 1 and not recovery["retained"]
assert not quarantine.exists()
assert remote.read_marker(directory / remote.MARKER)["state"] == "complete"
def test_live_sd_build_detects_remount_and_stops_labeled_container_without_card_writes(sd_remote, monkeypatch):
remote, state = sd_remote
identifier = "4" * 32
remote.prepare(identifier)
directory = remote.run_dir(identifier)
maps_fixture(remote, directory / "upload")
resources = remote.CACHE / "resources"
resources.mkdir()
(resources / "frame-controls.patch").write_bytes(b"controls")
(resources / "build-native.sh").write_bytes(b"synthetic script")
commands, stopped = [], []
def command(argv, **kwargs):
commands.append(argv)
if argv[:2] == ["git", "init"]:
Path(argv[2]).mkdir()
return remote.SOURCE_COMMIT if "rev-parse" in argv else ""
monkeypatch.setattr(remote, "command", command)
def launch(argv, **kwargs):
def poll():
state["card"] = card_descriptor(remote.storage_module(), mount_id=202)
remote._LAST_STORAGE_CHECK = 0
return None
return types.SimpleNamespace(returncode=None, poll=poll)
monkeypatch.setattr(remote.subprocess, "Popen", launch)
labels = {"org.halo-frame-installer.owner": remote.OWNER, "org.halo-frame-installer.run": identifier}
monkeypatch.setattr(remote.subprocess, "run", lambda *args, **kwargs: types.SimpleNamespace(returncode=0, stdout=json.dumps([{"Config": {"Labels": labels}}])))
monkeypatch.setattr(remote, "stop_build_process", lambda process: stopped.append(process))
with pytest.raises(ValueError, match="SD card changed"):
remote.build(identifier)
assert ["podman", "stop", "--time", "10", "halo-frame-installer-" + identifier] in commands
assert len(stopped) == 1
assert not (directory / "cancelled").exists()
assert not remote.GAME.exists()
@pytest.mark.parametrize("wrong_label", ["owner", "run"])
def test_filesystem_independent_container_stop_rejects_foreign_labels(sd_remote, monkeypatch, wrong_label):
remote, _ = sd_remote
identifier = "5" * 32
labels = {"org.halo-frame-installer.owner": remote.OWNER, "org.halo-frame-installer.run": identifier}
labels["org.halo-frame-installer." + wrong_label] = "another application"
monkeypatch.setattr(remote.subprocess, "run", lambda *args, **kwargs: types.SimpleNamespace(returncode=0, stdout=json.dumps([{"Config": {"Labels": labels}}])))
commands = []
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: commands.append(argv))
with pytest.raises(ValueError, match="unowned container"):
remote.stop_owned_container(identifier)
assert not commands
@pytest.mark.parametrize("response", ["[]", "{}", '[{"Config": []}]', "[null]", "x" * (64 * 1024 + 1)],
ids=["empty", "object", "config", "null", "oversize"])
def test_container_cleanup_rejects_malformed_ownership_response(sd_remote, monkeypatch, response):
remote, _ = sd_remote
monkeypatch.setattr(remote.subprocess, "run", lambda *args, **kwargs: types.SimpleNamespace(returncode=0, stdout=response))
commands = []
monkeypatch.setattr(remote, "command", lambda argv, **kwargs: commands.append(argv))
with pytest.raises(ValueError):
remote.stop_owned_container("7" * 32)
assert not commands
def test_uninstall_removal_checks_live_mount_between_file_operations(sd_remote, monkeypatch):
remote, state = sd_remote
remote.owned(remote.GAME)
(remote.GAME / "halo").write_bytes(b"program")
inventory = remote.uninstall_inventory(remote.GAME)
original = remote.assert_storage_active
checks = 0
def card_change(force=False):
nonlocal checks
checks += 1
if checks == 3:
state["card"] = card_descriptor(remote.storage_module(), mount_id=203)
remote._LAST_STORAGE_CHECK = 0
original(force=force)
monkeypatch.setattr(remote, "assert_storage_active", card_change)
completed = [0]
with pytest.raises(ValueError, match="SD card changed"):
remote.remove_uninstall_tree(remote.GAME, inventory, completed, 0)
assert completed == [1]
assert remote.GAME.exists() and (remote.GAME / remote.MARKER).exists()
assert not (remote.GAME / "halo").exists()
def test_repair_does_not_restore_into_changed_sd_mount(sd_remote, monkeypatch):
remote, state = sd_remote
remote.owned(remote.GAME)
(remote.GAME / "halo").write_bytes(b"old program")
identifier = "6" * 32
remote.prepare(identifier)
directory = remote.run_dir(identifier)
stage = directory / "stage"
(stage / "halo").write_bytes(b"new program")
checks = 0
def check_cancelled():
nonlocal checks
checks += 1
if checks == 3:
state["card"] = card_descriptor(remote.storage_module(), mount_id=204)
remote.assert_storage_active(force=True)
replacements = []
replace = remote.os.replace
def record_replace(source, target):
replacements.append((source, target))
replace(source, target)
monkeypatch.setattr(remote.os, "replace", record_replace)
with pytest.raises(RuntimeError, match="Restoration was not attempted"):
remote.repair_program_files(stage, directory, ["halo"], check_cancelled)
assert len(replacements) == 1
assert (directory / "previous-program-files/halo").read_bytes() == b"old program"
def test_uninstall_save_copy_checks_card_after_source_open_before_target_creation(sd_remote, monkeypatch):
remote, state = sd_remote
remote.owned(remote.GAME)
source = remote.GAME / "save-v1.4/checkpoint"
source.parent.mkdir()
source.write_bytes(b"original campaign")
signature = remote.uninstall_signature(source.stat())
target = remote.GAME.parent / "backup/save-v1.4/checkpoint"
original = remote.assert_storage_active
checks = 0
def card_change(force=False):
nonlocal checks
checks += 1
if checks == 2:
state["card"] = card_descriptor(remote.storage_module(), mount_id=205)
original(force=force)
monkeypatch.setattr(remote, "assert_storage_active", card_change)
with pytest.raises(ValueError, match="SD card changed"):
remote.copy_uninstall_save("save-v1.4/checkpoint", target, signature)
assert source.read_bytes() == b"original campaign"
assert not target.parent.exists()
@pytest.mark.parametrize("repair", [False, True])
def test_interrupted_sd_upgrade_recovery_survives_remount_and_repeated_retry(sd_remote, monkeypatch, repair):
from test_install import built_run, interrupted_native_upgrade, interrupt_upgrade_retry
remote, state = sd_remote
internal = remote.HOME / "Games/HaloCENativeVR"
internal.mkdir(parents=True)
(internal / "keep").write_bytes(b"internal game and saves")
manifest, previous, first = interrupted_native_upgrade(remote, save_path=remote.GAME / "profiles/custom")
state["card"] = card_descriptor(remote.storage_module(), mount_id=301, device="179:33")
remote.select_storage(state["card"]["id"])
verified = remote.existing_install(repair=repair)
assert verified["needsUpgrade"] and verified["previousSavePaths"] == [str(previous)]
assert verified["upgradeRecovery"]["originPath"] == str(first / "previous-program-files")
second = interrupt_upgrade_retry(remote, manifest)
journal = remote.private_json(second / "previous-program-files/.upgrade-recovery.json", second)
assert journal["storageOwnership"] == state["card"]["ownershipIdentity"]
assert journal["storageIdentity"] == state["card"]["identity"]
state["card"] = card_descriptor(remote.storage_module(), mount_id=302, device="179:65")
remote.select_storage(state["card"]["id"])
assert remote.existing_install(repair=repair)["previousSavePaths"] == [str(previous)]
monkeypatch.setattr(remote, "command", lambda *args, **kwargs: "native libraries resolved")
built_run(remote, manifest, "3" * 32)
result = remote.finalize("3" * 32, repair=True)
assert result["repaired"] and result["previousSavePaths"] == [str(previous)]
assert (previous / "checkpoint").read_bytes() == b"incompatible previous checkpoint"
assert (remote.save_root() / "checkpoint").read_bytes() == b"played after the interrupted upgrade"
assert (internal / "keep").read_bytes() == b"internal game and saves"
@pytest.mark.parametrize("record", ["cache", "run", "backup", "stage", "carried"])
def test_sd_upgrade_recovery_refuses_other_card_provenance_without_writes(sd_remote, record):
from test_install import interrupted_native_upgrade, interrupt_upgrade_retry
remote, _ = sd_remote
manifest, previous, first = interrupted_native_upgrade(remote, save_path=remote.GAME / "profiles/custom")
directory = interrupt_upgrade_retry(remote, manifest) if record == "carried" else first
path = {"cache": remote.CACHE / remote.MARKER, "run": directory / remote.MARKER,
"backup": directory / "previous-program-files/.backup-owner.json",
"stage": directory / "stage" / remote.MARKER,
"carried": directory / "previous-program-files/.upgrade-recovery.json"}[record]
metadata = remote.private_json(path, remote.CACHE)
metadata["storageOwnership"]["cardIdentity"] = "f" * 64
path.write_text(json.dumps(metadata))
before = {item.relative_to(remote.GAME): item.read_bytes() for item in remote.GAME.rglob("*") if item.is_file()}
for repair in (False, True):
with pytest.raises(ValueError, match="interrupted upgrade could not be verified"):
remote.existing_install(repair=repair)
assert before == {item.relative_to(remote.GAME): item.read_bytes() for item in remote.GAME.rglob("*") if item.is_file()}
assert (previous / "checkpoint").read_bytes() == b"incompatible previous checkpoint"
def test_interrupted_upgrade_accepts_internal_backup_with_new_storage_fields(sd_remote):
from test_install import interrupted_native_upgrade
remote, _ = sd_remote
remote.select_storage("internal")
remote.owned(remote.CACHE)
(remote.CACHE / "runs").mkdir()
_, previous, directory = interrupted_native_upgrade(remote)
backup_owner = remote.private_json(directory / "previous-program-files/.backup-owner.json", directory)
assert backup_owner["storageId"] == "internal"
assert remote.existing_install()["previousSavePaths"] == [str(previous)]
+451
View File
@@ -0,0 +1,451 @@
"""Storage selection must bind every remote step to one verified destination."""
import json
import threading
import contextlib
import io
from pathlib import Path
import pytest
from halo_frame_installer.install import (
EXPECTED_MAPS, Installer, validate_storage_destination,
)
from halo_frame_installer.ssh import CancelledError, Settings, SSHError
RESOURCES = Path(__file__).resolve().parents[1] / "resources"
SD_ID = "sd:" + "a" * 32
MOUNT = "/run/media/steamos/Halo SD"
def destination(identifier=SD_ID):
internal = identifier == "internal"
root = "/home/steamos" if internal else MOUNT
return {"id": identifier, "kind": "internal" if internal else "sd",
"label": "Internal storage" if internal else "SD card — Halo SD",
"mountPath": root, "gamePath": root + "/Games/HaloCENativeVR",
"cachePath": root + ("/.cache/halo-frame-installer" if internal else "/.halo-frame-installer"),
"freeBytes": 32 * 1024 ** 3, "installed": False,
"identity": {"kind": "internal", "root": root} if internal else {
"kind": "sd", "mountPath": root, "mountId": 42, "device": "179:1",
"filesystem": "ext4", "rootDevice": 45825, "rootInode": 2,
"cardIdentity": "b" * 64, "filesystemUuid": "filesystem-123",
"blockDevice": "/dev/mmcblk0p1"}}
class StorageConnection:
def __init__(self, settings, *, existing=False):
self.settings, self.existing = settings, existing
self.host_fingerprint = "SHA256:public"
self.commands, self.uploads = [], []
self.closed = False
self.failure = None
self.bad_history = False
self.live_changes = {}
def connect(self):
pass
def close(self):
self.closed = True
def put(self, local, remote, **kwargs):
self.uploads.append((Path(local).name, remote))
def run(self, argv, **kwargs):
self.commands.append(argv)
selected = destination(self.settings.storage_id)
if argv[1] == "-c" and len(argv) == 3:
response = {"home": "/home/steamos", "destinations": [destination("internal"), destination()]}
elif argv[1] == "-c":
if argv[3] == "--storage-id":
selected.update(self.live_changes)
response = selected
else:
response = {"home": "/home/steamos", "storage": selected,
"cachePath": selected["cachePath"], "gamePath": selected["gamePath"],
"existing": {"gamePath": selected["gamePath"], "reused": True,
"mapsVerified": True} if self.existing else None,
"uninstallSupported": True}
elif argv[2] == "prepare":
run = argv[argv.index("--run-id") + 1]
response = {"uploadPath": selected["cachePath"] + "/runs/" + run + "/upload"}
elif argv[2] == "build":
if self.failure:
raise self.failure
response = {"built": True}
elif argv[2] == "finalize":
response = {"gamePath": selected["gamePath"], "repaired": self.existing}
elif argv[2] == "shortcut":
response = {"status": "added", "appid": 3000000042}
elif argv[2] == "cancel":
response = {"cancelled": True}
elif argv[2] == "uninstall":
run = argv[argv.index("--run-id") + 1]
parent = selected["gamePath"].rsplit("/", 1)[0]
response = {"gamePath": selected["gamePath"], "uninstalled": True,
"steam": {"status": "removed"},
"savedBackupPath": parent + "/HaloCENativeVR-saves-" + run,
"recoveredSaveBackupPaths": [
("/home/steamos/Games" if self.bad_history else parent)
+ "/HaloCENativeVR-saves-" + "b" * 32]}
else:
raise AssertionError(argv)
return "HFI_RESULT " + json.dumps(response)
def maps_fixture(tmp_path):
maps = tmp_path / "maps"
maps.mkdir()
for name in EXPECTED_MAPS:
(maps / name).write_bytes(b"synthetic Xbox map")
return maps
@pytest.mark.parametrize("extra", [
{"id": "sd:../../other"}, {"kind": "internal"},
{"mountPath": "/home/steamos"}, {"mountPath": "/run/media/steamos"},
{"mountPath": "/run/media/steamos/../other"}, {"mountPath": '/run/media/steamos/evil"'},
{"mountPath": "/run/media/steamos/evil$(command)"},
{"gamePath": MOUNT + "/Games/other"}, {"cachePath": "/home/steamos/.cache/halo-frame-installer"},
{"freeBytes": True}, {"installed": "yes"}, {"label": "bad\nlabel"},
])
def test_remote_storage_paths_and_details_fail_closed(extra):
with pytest.raises(SSHError):
validate_storage_destination(dict(destination(), **extra), SD_ID)
def test_sd_card_path_accepts_spaces_and_unicode_without_shell_syntax():
item = destination()
root = "/run/media/steamos/Halo 卡"
item.update(mountPath=root, gamePath=root + "/Games/HaloCENativeVR", cachePath=root + "/.halo-frame-installer")
item["identity"]["mountPath"] = root
assert validate_storage_destination(item, SD_ID)["mountPath"] == root
def test_discovery_closes_connection_before_returning_verified_choices():
settings = Settings("frame", "private")
connection = StorageConnection(settings)
result = Installer(lambda _: connection, RESOURCES).discover_storage(settings)
assert connection.closed and not connection.uploads
assert [item["id"] for item in result.destinations] == ["internal", SD_ID]
assert result.host_fingerprint == "SHA256:public" and result.cleanup_warning is None
def test_discovery_cleanup_warning_is_redacted_and_not_ignored():
settings = Settings("frame", "private", transport="usb")
connection = StorageConnection(settings)
def close():
raise SSHError("private USB forward remains")
connection.close = close
result = Installer(lambda _: connection, RESOURCES).discover_storage(settings)
assert result.cleanup_warning and "private" not in result.cleanup_warning
assert "Unplug the USB cable" in result.cleanup_warning
def test_discovery_preserves_bounded_unavailable_card_diagnostics():
settings = Settings("frame", "private")
connection = StorageConnection(settings)
original = connection.run
detail = {"mountPath": MOUNT, "reason": "Card is mounted read-only."}
def run(argv, **kwargs):
response = json.loads(original(argv, **kwargs).split(" ", 1)[1])
response["unavailable"] = [detail]
return "HFI_RESULT " + json.dumps(response)
connection.run = run
result = Installer(lambda _: connection, RESOURCES).discover_storage(settings)
assert result.unavailable == (detail,) and connection.closed
@pytest.mark.parametrize("existing", [False, True])
def test_sd_install_and_repair_bind_all_steps_uploads_and_library_to_card(tmp_path, existing):
settings = Settings("frame", "private", storage_id=SD_ID, reinstall_existing=existing)
connection = StorageConnection(settings, existing=existing)
result = Installer(lambda _: connection, RESOURCES).run(settings, None if existing else maps_fixture(tmp_path))
assert result.game_path == destination()["gamePath"] and result.storage_id == SD_ID
assert connection.closed and result.repaired == existing
assert all(command[-2:] == ["--storage-id", SD_ID] for command in connection.commands)
assert all(path.startswith(destination()["cachePath"] + "/resources/") or
path.startswith(destination()["cachePath"] + "/runs/") for _, path in connection.uploads)
assert sum(path.endswith(".map") for _, path in connection.uploads) == (0 if existing else 24)
assert any(name == "frame_storage.py" for name, _ in connection.uploads)
assert any(command[2] == "shortcut" for command in connection.commands if command[1] != "-c")
def test_sd_library_retry_does_not_rebuild_or_upload_maps():
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings, existing=True)
result = Installer(lambda _: connection, RESOURCES).add_to_steam(settings)
assert result.storage_id == SD_ID and result.reused and connection.closed
assert all(command[-2:] == ["--storage-id", SD_ID] for command in connection.commands)
assert not any(path.endswith(".map") for _, path in connection.uploads)
assert not any(command[2] in ("build", "prepare") for command in connection.commands if command[1] != "-c")
def test_sd_build_failure_cancels_selected_run_and_does_not_register(tmp_path):
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings)
connection.failure = SSHError("Build failed")
with pytest.raises(SSHError, match="Build failed"):
Installer(lambda _: connection, RESOURCES).run(settings, maps_fixture(tmp_path))
steps = [command[2] for command in connection.commands if command[1] != "-c"]
assert steps == ["prepare", "build", "cancel"] and connection.closed
assert connection.commands[-1][-2:] == ["--storage-id", SD_ID]
def test_sd_uninstall_uses_selected_parent_for_backup_and_recovery_history():
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings)
result = Installer(lambda _: connection, RESOURCES).uninstall(settings)
assert result.storage_id == SD_ID and result.uninstalled and connection.closed
assert result.saved_backup_path.startswith(MOUNT + "/Games/HaloCENativeVR-saves-")
assert result.recovered_save_backup_paths == (MOUNT + "/Games/HaloCENativeVR-saves-" + "b" * 32,)
assert all("--storage-id" in command for command in connection.commands)
def test_sd_uninstall_rejects_history_from_internal_storage():
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings)
connection.bad_history = True
with pytest.raises(SSHError, match="invalid uninstall recovery"):
Installer(lambda _: connection, RESOURCES).uninstall(settings)
assert connection.closed
def test_storage_settings_reject_arbitrary_target_paths():
with pytest.raises(ValueError, match="detected SD"):
Settings("frame", "private", storage_id="/run/media/steamos/card")
def test_discovery_cancel_before_connect_is_noop():
settings = Settings("frame", "private")
connection = StorageConnection(settings)
cancel = threading.Event()
cancel.set()
with pytest.raises(CancelledError):
Installer(lambda _: connection, RESOURCES).discover_storage(settings, cancel)
assert connection.closed and not connection.commands
@pytest.mark.parametrize("field,value", [
("kind", "internal"), ("mountPath", "/run/media/steamos/other"),
("mountId", True), ("device", "bad"), ("filesystem", "vfat"),
("rootDevice", 0), ("rootInode", -1), ("cardIdentity", "missing"),
("filesystemUuid", "bad/uuid"), ("blockDevice", "/dev/sda1"),
])
def test_sd_identity_fields_fail_closed(field, value):
item = destination()
item["identity"][field] = value
with pytest.raises(SSHError, match="invalid SD card identity"):
validate_storage_destination(item, SD_ID)
@pytest.mark.parametrize("field", [
"kind", "mountPath", "mountId", "device", "filesystem", "rootDevice",
"rootInode", "cardIdentity", "filesystemUuid",
])
def test_sd_identity_missing_fields_fail_closed(field):
item = destination()
del item["identity"][field]
with pytest.raises(SSHError, match="invalid SD card identity"):
validate_storage_destination(item, SD_ID)
def storage_checks(connection):
return [command for command in connection.commands
if command[1] == "-c" and len(command) > 3 and command[3] == "--storage-id"]
def remote_steps(connection):
return [command[2] for command in connection.commands if command[1] != "-c"]
def test_sd_each_put_has_independent_bundled_before_and_after_checks(tmp_path):
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings)
Installer(lambda _: connection, RESOURCES).run(settings, maps_fixture(tmp_path))
checks = storage_checks(connection)
assert len(checks) == len(connection.uploads) * 2
assert all(command[-2:] == ["--storage-id", SD_ID] for command in checks)
assert all("types.ModuleType" in command[2] and "resolve_storage(sys.argv[-1])" in command[2]
and "def _sd_identity(" in command[2] for command in checks)
assert all("synthetic Xbox map" not in command[2] and "xbox-data-manifest" not in command[2]
for command in checks)
@pytest.mark.parametrize("field,value", [
("mountId", 43), ("device", "179:2"), ("rootDevice", 45826),
("rootInode", 3), ("cardIdentity", "c" * 64),
("filesystemUuid", "new-filesystem"), ("filesystem", "f2fs"),
("blockDevice", "/dev/mmcblk0p2"),
])
def test_card_identity_changed_between_puts_stops_before_next_write(tmp_path, field, value):
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings)
original = connection.put
def put(local, remote, **kwargs):
original(local, remote, **kwargs)
if Path(local).name == "frame_storage.py":
# The first upload's post-check still sees the original card.
# Its next call models the card being swapped between files.
original_run = connection.run
def run(argv, **options):
response = original_run(argv, **options)
if argv[1] == "-c" and argv[3] == "--storage-id":
identity = dict(destination()["identity"], **{field: value})
connection.live_changes = {"identity": identity}
connection.run = original_run
return response
connection.run = run
connection.put = put
with pytest.raises(SSHError, match="SD card changed during transfer"):
Installer(lambda _: connection, RESOURCES).run(settings, maps_fixture(tmp_path))
assert connection.closed and len(connection.uploads) == 1
assert not remote_steps(connection)
def test_card_swapped_during_large_map_is_checked_before_upload_continues(tmp_path, monkeypatch):
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings)
original = connection.put
clock = [0.0]
monkeypatch.setattr("halo_frame_installer.install.time.monotonic", lambda: clock[0])
callbacks = []
def put(local, remote, **kwargs):
original(local, remote, **kwargs)
if Path(local).suffix == ".map":
callback = kwargs["callback"]
clock[0] = 0.5; callback(1, 8); callbacks.append(1)
connection.live_changes = {"identity": dict(destination()["identity"], mountId=99)}
clock[0] = 1.1; callback(2, 8)
callbacks.append(2) # Must never reach the next SFTP write callback.
connection.put = put
activity = []
with pytest.raises(SSHError, match="SD card changed during transfer"):
Installer(lambda _: connection, RESOURCES).run(settings, maps_fixture(tmp_path),
lambda *update: activity.append(update))
assert callbacks == [1] and connection.closed
assert sum(name.endswith(".map") for name, _ in connection.uploads) == 1
assert remote_steps(connection) == ["prepare", "cancel"]
assert any("cancellation confirmed" in message for _, message, _ in activity)
def test_sd_post_upload_guard_stops_swapped_card_before_build(tmp_path):
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings)
original = connection.put
def put(local, remote, **kwargs):
original(local, remote, **kwargs)
if Path(local).suffix == ".map":
connection.live_changes = {"identity": dict(destination()["identity"], rootInode=99)}
connection.put = put
with pytest.raises(SSHError, match="SD card changed during transfer"):
Installer(lambda _: connection, RESOURCES).run(settings, maps_fixture(tmp_path))
assert remote_steps(connection) == ["prepare", "cancel"] and connection.closed
def test_sd_guard_ignores_changing_free_space_and_install_metadata(tmp_path):
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings)
connection.live_changes = {"freeBytes": 1, "installed": True, "label": "Halo SD renamed"}
result = Installer(lambda _: connection, RESOURCES).run(settings, maps_fixture(tmp_path))
assert result.storage_id == SD_ID and connection.closed and "shortcut" in remote_steps(connection)
def test_sd_uninstall_upload_guard_prevents_removal_on_changed_card():
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings)
connection.live_changes = {"identity": dict(destination()["identity"], mountId=99)}
with pytest.raises(SSHError, match="SD card changed during transfer"):
Installer(lambda _: connection, RESOURCES).uninstall(settings)
assert not connection.uploads and not remote_steps(connection) and connection.closed
def test_sd_callback_cancel_stops_run_and_closes_connection(tmp_path):
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings)
original = connection.put
cancel = threading.Event()
def put(local, remote, **kwargs):
original(local, remote, **kwargs)
if Path(local).suffix == ".map":
cancel.set()
kwargs["callback"](1, 8)
connection.put = put
with pytest.raises(CancelledError):
Installer(lambda _: connection, RESOURCES).run(settings, maps_fixture(tmp_path), cancel_event=cancel)
assert remote_steps(connection) == ["prepare", "cancel"] and connection.closed
def test_internal_uploads_keep_original_transfer_behavior_without_sd_checks(tmp_path):
settings = Settings("frame", "private")
connection = StorageConnection(settings)
result = Installer(lambda _: connection, RESOURCES).run(settings, maps_fixture(tmp_path))
assert result.storage_id == "internal" and not storage_checks(connection) and connection.closed
def test_sd_preflight_requires_complete_identity_before_any_write(tmp_path):
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings)
original = connection.run
def run(argv, **kwargs):
value = json.loads(original(argv, **kwargs).split(" ", 1)[1])
if argv[1] == "-c" and argv[3] == "preflight":
del value["storage"]["identity"]
return "HFI_RESULT " + json.dumps(value)
connection.run = run
with pytest.raises(SSHError, match="invalid SD card identity"):
Installer(lambda _: connection, RESOURCES).run(settings, maps_fixture(tmp_path))
assert not connection.uploads and not remote_steps(connection) and connection.closed
def test_sd_guard_requires_original_fixed_paths_even_with_same_selected_id():
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings, existing=True)
mount = "/run/media/steamos/Card moved"
connection.live_changes = {"mountPath": mount, "gamePath": mount + "/Games/HaloCENativeVR",
"cachePath": mount + "/.halo-frame-installer",
"identity": dict(destination()["identity"], mountPath=mount)}
with pytest.raises(SSHError, match="SD card changed during transfer"):
Installer(lambda _: connection, RESOURCES).add_to_steam(settings)
assert not connection.uploads and not remote_steps(connection) and connection.closed
def test_sd_periodic_guard_throttles_checks_and_preserves_progress(tmp_path, monkeypatch):
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings)
clock = [0.0]
monkeypatch.setattr("halo_frame_installer.install.time.monotonic", lambda: clock[0])
progress = []
def put(local, remote, **kwargs):
for done, moment in enumerate((0.2, 0.4, 1.1, 1.3, 2.2), 1):
clock[0] = moment
kwargs["callback"](done, 5)
connection.put = put
upload = Installer(resource_dir=RESOURCES).guarded_upload(connection, settings, destination(), threading.Event())
upload(tmp_path / "example.map", MOUNT + "/.halo-frame-installer/example.map",
callback=lambda done, total: progress.append((done, total)))
assert progress == [(done, 5) for done in range(1, 6)]
assert len(storage_checks(connection)) == 4 # before, two elapsed seconds, after
def test_missing_card_inline_check_emits_bounded_error_without_traceback(tmp_path, monkeypatch):
settings = Settings("frame", "private", storage_id=SD_ID)
connection = StorageConnection(settings)
(tmp_path / "frame_storage.py").write_text(
"def resolve_storage(selected):\n raise ValueError('card missing\\n' + 'x' * 1000)\n")
messages = []
def run(argv, **kwargs):
output = io.StringIO()
monkeypatch.setattr("sys.argv", ["-c", *argv[3:]])
with contextlib.redirect_stdout(output), pytest.raises(SystemExit) as stopped:
exec(compile(argv[2], "<inline SD check>", "exec"), {})
assert stopped.value.code == 1
messages.append(output.getvalue())
raise SSHError(output.getvalue())
connection.run = run
upload = Installer(resource_dir=tmp_path).guarded_upload(connection, settings, destination(), threading.Event())
with pytest.raises(SSHError, match="HFI_ERROR card missing"):
upload(tmp_path / "file", MOUNT + "/.halo-frame-installer/file")
assert not connection.uploads and len(messages) == 1
assert len(messages[0]) <= len("HFI_ERROR ") + 600 + 1
assert messages[0].count("\n") == 1 and "Traceback" not in messages[0]
+1 -1
View File
@@ -796,7 +796,7 @@ def test_uninstall_preflight_needs_no_game_maps_space_vr_or_compiler(installatio
monkeypatch.setattr(remote, "command", forbidden)
result = remote.preflight_uninstall()
assert result == {"home": str(remote.HOME), "cachePath": str(remote.CACHE),
"gamePath": str(remote.GAME), "uninstallSupported": True}
"gamePath": str(remote.GAME), "uninstallSupported": True, "storage": None}
@pytest.mark.parametrize("failure", ["space", "config"])
+1 -1
View File
@@ -66,7 +66,7 @@ def test_uninstall_needs_no_iso_maps_or_build_and_disconnects_before_success():
result = Installer(lambda settings: fake, RESOURCES).uninstall(fake.settings, progress)
assert result.uninstalled and not result.requires_manual_steam_step
assert result.saved_backup_path.startswith('/home/steamos/Games/HaloCENativeVR-saves-')
assert [name for name, remote in fake.uploads] == ['remote_install.py', 'steam_shortcut.py', 'steam_live.py']
assert [name for name, remote in fake.uploads] == ['frame_storage.py', 'remote_install.py', 'steam_shortcut.py', 'steam_live.py']
assert len(fake.commands) == 2
assert '--keep-saves' in fake.commands[-1][0] and '--close-steam' not in fake.commands[-1][0]
assert stages[-3:] == ['disconnect', 'disconnected', 'complete']
+53
View File
@@ -2,6 +2,7 @@ import hashlib
import json
import socket
import subprocess
import sys
from unittest.mock import Mock
import paramiko
@@ -291,3 +292,55 @@ def test_bundled_helper_and_notices_are_verified_then_cached(tmp_path, monkeypat
def test_unknown_transport_rejected():
with pytest.raises(ValueError, match='Choose'):
Settings('frame', 'secret', transport='other')
def _install_fake_bundle(tmp_path, monkeypatch):
root = tmp_path / 'bundle' / 'resources' / 'usb'
root.mkdir(parents=True)
files = {name: hashlib.sha256(name.encode()).hexdigest() for name in usb.ADB_FILES}
for name in (*usb.ADB_FILES, 'NOTICE.txt'):
(root / name).write_bytes(name.encode())
manifest = {'version': '37.0.1', 'files': files, 'noticeSha256': hashlib.sha256(b'NOTICE.txt').hexdigest()}
(root / 'manifest.json').write_text(json.dumps(manifest))
monkeypatch.setattr(usb.sys, 'frozen', True, raising=False)
monkeypatch.setattr(usb.sys, '_MEIPASS', str(root.parent.parent), raising=False)
monkeypatch.setenv('LOCALAPPDATA', str(tmp_path / 'local'))
return tmp_path / 'local' / 'HaloFrameInstaller' / 'usb-tools' / '37.0.1' / 'adb.exe'
def _expose_adb_on_search_path(tmp_path, monkeypatch):
folder = tmp_path / 'search-path'
folder.mkdir()
# shutil.which("adb") needs an executable file named exactly "adb" on
# POSIX and finds "adb.exe" via the Windows suffix rules, so provide both.
for name in ('adb', 'adb.exe'):
shadow = folder / name
shadow.write_bytes(b'shadowing adb')
shadow.chmod(0o755)
# shutil.which on Windows searches the working directory before PATH.
monkeypatch.chdir(tmp_path)
monkeypatch.setenv('PATH', str(folder))
return (folder / ('adb.exe' if sys.platform == 'win32' else 'adb')).resolve()
def test_hash_verified_bundle_is_preferred_over_search_path_adb(tmp_path, monkeypatch):
bundled = _install_fake_bundle(tmp_path, monkeypatch)
_expose_adb_on_search_path(tmp_path, monkeypatch)
assert usb.resolve_adb() == bundled
def test_search_path_adb_is_used_when_no_bundle_exists(tmp_path, monkeypatch):
monkeypatch.setattr(usb.sys, 'frozen', True, raising=False)
monkeypatch.setattr(usb.sys, '_MEIPASS', str(tmp_path / 'no-bundle'), raising=False)
monkeypatch.setenv('LOCALAPPDATA', str(tmp_path / 'local'))
shadow = _expose_adb_on_search_path(tmp_path, monkeypatch)
assert usb.resolve_adb() == shadow
def test_explicitly_selected_adb_wins_over_bundle_and_search_path(tmp_path, monkeypatch):
_install_fake_bundle(tmp_path, monkeypatch)
_expose_adb_on_search_path(tmp_path, monkeypatch)
chosen = tmp_path / 'chosen' / 'adb.exe'
chosen.parent.mkdir()
chosen.write_bytes(b'user selected adb')
assert usb.resolve_adb(str(chosen)) == chosen.resolve()